1use serde::Serialize;
28
29use crate::innodb::export::{decode_page_records, extract_column_layout, extract_table_name};
30use crate::innodb::field_decode::{ColumnStorageInfo, FieldValue};
31use crate::innodb::index::IndexHeader;
32use crate::innodb::page::FilHeader;
33use crate::innodb::page_types::PageType;
34use crate::innodb::tablespace::Tablespace;
35use crate::innodb::undelete::scan_free_list_records;
36use crate::IdbError;
37
38#[derive(Debug, Clone)]
47pub enum Pattern {
48 Bytes(Vec<u8>),
50}
51
52impl Pattern {
53 pub fn parse(s: &str) -> Result<Self, IdbError> {
58 if let Some(hex) = s.strip_prefix("hex:") {
59 let hex: String = hex.chars().filter(|c| !c.is_whitespace()).collect();
60 if hex.is_empty() || (hex.len() & 1) != 0 || !hex.bytes().all(|b| b.is_ascii_hexdigit())
61 {
62 return Err(IdbError::Argument(
63 "hex: pattern must be an even number of hex digits".to_string(),
64 ));
65 }
66 let mut bytes = Vec::with_capacity(hex.len() / 2);
67 let hb = hex.as_bytes();
68 let mut i = 0;
69 while i < hb.len() {
70 let pair = std::str::from_utf8(&hb[i..i + 2])
71 .ok()
72 .and_then(|p| u8::from_str_radix(p, 16).ok());
73 match pair {
74 Some(b) => bytes.push(b),
75 None => {
76 return Err(IdbError::Argument(format!(
77 "invalid hex byte '{}' in pattern",
78 &hex[i..i + 2]
79 )))
80 }
81 }
82 i += 2;
83 }
84 Ok(Pattern::Bytes(bytes))
85 } else if s.is_empty() {
86 Err(IdbError::Argument("pattern must not be empty".to_string()))
87 } else {
88 Ok(Pattern::Bytes(s.as_bytes().to_vec()))
89 }
90 }
91
92 fn needle(&self) -> &[u8] {
93 match self {
94 Pattern::Bytes(b) => b,
95 }
96 }
97}
98
99fn find_all(haystack: &[u8], needle: &[u8], out: &mut Vec<usize>, cap: usize) {
101 if needle.is_empty() || needle.len() > haystack.len() {
102 return;
103 }
104 let mut i = 0;
105 let last = haystack.len() - needle.len();
106 while i <= last {
107 if &haystack[i..i + needle.len()] == needle {
108 out.push(i);
109 if out.len() >= cap {
110 return;
111 }
112 i += 1;
113 } else {
114 i += 1;
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "snake_case")]
126pub enum Region {
127 FilHeader,
129 FilTrailer,
131 RecordHeap,
133 FreeSpace,
135 Body,
137}
138
139impl Region {
140 pub fn name(self) -> &'static str {
142 match self {
143 Region::FilHeader => "fil_header",
144 Region::FilTrailer => "fil_trailer",
145 Region::RecordHeap => "record_heap",
146 Region::FreeSpace => "free_space",
147 Region::Body => "body",
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct ResidueMatch {
155 pub page_number: u64,
157 pub page_type: String,
159 pub offset: usize,
161 pub region: Region,
163 pub context_hex: String,
165}
166
167fn classify_region(
169 page_data: &[u8],
170 offset: usize,
171 page_type: PageType,
172 page_size: usize,
173) -> Region {
174 if offset < crate::innodb::constants::FIL_PAGE_DATA {
175 return Region::FilHeader;
176 }
177 if page_size >= crate::innodb::constants::SIZE_FIL_TRAILER
178 && offset >= page_size - crate::innodb::constants::SIZE_FIL_TRAILER
179 {
180 return Region::FilTrailer;
181 }
182 if page_type == PageType::Index {
183 if let Some(idx) = IndexHeader::parse(page_data) {
184 if idx.heap_top != 0 && offset >= idx.heap_top as usize {
185 return Region::FreeSpace;
186 }
187 return Region::RecordHeap;
188 }
189 }
190 Region::Body
191}
192
193pub fn scan_residue(
198 ts: &mut Tablespace,
199 pattern: &Pattern,
200 max_hits: usize,
201) -> Result<Vec<ResidueMatch>, IdbError> {
202 let needle = pattern.needle().to_vec();
203 let page_size = ts.page_size() as usize;
204 let mut matches = Vec::new();
205
206 ts.for_each_page(|page_num, page_data| {
207 if matches.len() >= max_hits {
208 return Ok(());
209 }
210 let page_type = FilHeader::parse(page_data)
211 .map(|h| h.page_type)
212 .unwrap_or(PageType::Unknown(0));
213
214 let mut offsets = Vec::new();
215 let remaining = max_hits - matches.len();
216 find_all(page_data, &needle, &mut offsets, remaining);
217
218 for off in offsets {
219 let region = classify_region(page_data, off, page_type, page_size);
220 let ctx_end = (off + 32).min(page_data.len());
221 let context_hex = page_data[off..ctx_end]
222 .iter()
223 .map(|b| format!("{:02x}", b))
224 .collect::<String>();
225 matches.push(ResidueMatch {
226 page_number: page_num,
227 page_type: page_type.name().to_string(),
228 offset: off,
229 region,
230 context_hex,
231 });
232 }
233 Ok(())
234 })?;
235
236 Ok(matches)
237}
238
239#[derive(Debug, Clone, Serialize)]
245pub struct ResidueSite {
246 pub region: String,
250 pub page_number: u64,
252 pub offset: usize,
254 pub delete_marked: bool,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub trx_id: Option<u64>,
259}
260
261#[derive(Debug, Clone, Serialize)]
263pub struct DeletionReport {
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub table_name: Option<String>,
267 pub column: String,
269 pub target_value: String,
271 pub fully_purged: bool,
273 pub residue_sites: Vec<ResidueSite>,
275 pub regions_scanned: Vec<String>,
277 pub thorough: bool,
279 pub records_examined: usize,
281}
282
283fn field_display(val: &FieldValue) -> String {
285 match val {
286 FieldValue::Null => String::new(),
287 FieldValue::Int(n) => n.to_string(),
288 FieldValue::Uint(n) => n.to_string(),
289 FieldValue::Float(f) => f.to_string(),
290 FieldValue::Double(d) => d.to_string(),
291 FieldValue::Str(s) => s.clone(),
292 FieldValue::Hex(h) => h.clone(),
293 }
294}
295
296fn value_matches(val: &FieldValue, target: &str) -> bool {
299 if matches!(val, FieldValue::Null) {
300 return target.is_empty();
301 }
302 field_display(val).trim_end() == target.trim_end()
303}
304
305fn pk_columns(columns: &[ColumnStorageInfo]) -> Vec<ColumnStorageInfo> {
308 let mut pk = Vec::new();
309 for col in columns {
310 if col.is_system_column {
311 break;
312 }
313 pk.push(col.clone());
314 }
315 pk
316}
317
318fn encode_value_needles(col: &ColumnStorageInfo, target: &str) -> Vec<Vec<u8>> {
324 let mut needles: Vec<Vec<u8>> = Vec::new();
325 if !target.is_empty() {
326 needles.push(target.as_bytes().to_vec());
327 }
328
329 let width = col.fixed_len;
330 let is_int = matches!(col.dd_type, 2 | 3 | 4 | 5 | 9); if is_int && (1..=8).contains(&width) {
332 if col.is_unsigned {
333 if let Ok(v) = target.parse::<u64>() {
334 let be = v.to_be_bytes();
335 needles.push(be[8 - width..].to_vec());
336 }
337 } else if let Ok(v) = target.parse::<i64>() {
338 let be = v.to_be_bytes();
340 let mut enc = be[8 - width..].to_vec();
341 if let Some(first) = enc.first_mut() {
342 *first ^= 0x80;
343 }
344 needles.push(enc);
345 }
346 }
347
348 needles.sort();
350 needles.dedup();
351 needles
352}
353
354pub fn verify_deleted(
361 ts: &mut Tablespace,
362 column: &str,
363 target_value: &str,
364 thorough: bool,
365) -> Result<DeletionReport, IdbError> {
366 let table_name = extract_table_name(ts);
367
368 let (columns, clustered_index_id) = extract_column_layout(ts).ok_or_else(|| {
369 IdbError::Parse(
370 "Cannot extract column layout from SDI (pre-8.0 tablespace or missing SDI)".to_string(),
371 )
372 })?;
373
374 let target_col = columns
376 .iter()
377 .find(|c| !c.is_system_column && c.name.eq_ignore_ascii_case(column))
378 .cloned()
379 .ok_or_else(|| {
380 IdbError::Argument(format!(
381 "Column '{}' not found in table (available: {})",
382 column,
383 columns
384 .iter()
385 .filter(|c| !c.is_system_column)
386 .map(|c| c.name.as_str())
387 .collect::<Vec<_>>()
388 .join(", ")
389 ))
390 })?;
391
392 let page_size = ts.page_size();
393 let pk_cols = pk_columns(&columns);
394 let target_is_pk = pk_cols.iter().any(|c| c.name.eq_ignore_ascii_case(column));
395
396 let mut sites: Vec<ResidueSite> = Vec::new();
397 let mut records_examined = 0usize;
398
399 let mut leaf_pages: Vec<(u64, Vec<u8>)> = Vec::new();
401 ts.for_each_page(|pn, pdata| {
402 let hdr = match FilHeader::parse(pdata) {
403 Some(h) => h,
404 None => return Ok(()),
405 };
406 if hdr.page_type != PageType::Index {
407 return Ok(());
408 }
409 let idx = match IndexHeader::parse(pdata) {
410 Some(h) => h,
411 None => return Ok(()),
412 };
413 if idx.index_id != clustered_index_id || !idx.is_leaf() {
414 return Ok(());
415 }
416 leaf_pages.push((pn, pdata.to_vec()));
417 Ok(())
418 })?;
419
420 for (pn, pdata) in &leaf_pages {
421 for row in decode_page_records(pdata, &columns, false, false, page_size) {
423 records_examined += 1;
424 if let Some((_, val)) = row.iter().find(|(n, _)| n.eq_ignore_ascii_case(column)) {
425 if value_matches(val, target_value) {
426 sites.push(ResidueSite {
427 region: "live_record".to_string(),
428 page_number: *pn,
429 offset: 0,
430 delete_marked: false,
431 trx_id: None,
432 });
433 }
434 }
435 }
436
437 for row in decode_page_records(pdata, &columns, true, true, page_size) {
439 records_examined += 1;
440 let matched = row
441 .iter()
442 .find(|(n, _)| n.eq_ignore_ascii_case(column))
443 .map(|(_, v)| value_matches(v, target_value))
444 .unwrap_or(false);
445 if matched {
446 let trx_id = row.iter().find_map(|(n, v)| {
447 if n == "DB_TRX_ID" {
448 match v {
449 FieldValue::Uint(x) => Some(*x),
450 FieldValue::Int(x) => Some(*x as u64),
451 _ => None,
452 }
453 } else {
454 None
455 }
456 });
457 sites.push(ResidueSite {
458 region: "delete_marked".to_string(),
459 page_number: *pn,
460 offset: 0,
461 delete_marked: true,
462 trx_id,
463 });
464 }
465 }
466
467 for rec in scan_free_list_records(pdata, *pn, &columns, page_size) {
469 records_examined += 1;
470 if rec
471 .columns
472 .iter()
473 .find(|(n, _)| n.eq_ignore_ascii_case(column))
474 .map(|(_, v)| value_matches(v, target_value))
475 .unwrap_or(false)
476 {
477 sites.push(ResidueSite {
478 region: "free_list".to_string(),
479 page_number: *pn,
480 offset: rec.offset,
481 delete_marked: false,
482 trx_id: rec.trx_id,
483 });
484 }
485 }
486 }
487
488 let mut regions_scanned = vec![
489 "clustered_leaf_live".to_string(),
490 "clustered_leaf_delete_marked".to_string(),
491 "free_list".to_string(),
492 ];
493
494 if target_is_pk {
498 regions_scanned.push("undo_del_mark".to_string());
499 if let Some(table_id) = crate::innodb::undelete::extract_table_id(ts) {
500 let undo_recs = crate::innodb::undelete::scan_undo_for_deletes(ts, table_id, &pk_cols)?;
501 for rec in undo_recs {
502 if rec
503 .columns
504 .iter()
505 .find(|(n, _)| n.eq_ignore_ascii_case(column))
506 .map(|(_, v)| value_matches(v, target_value))
507 .unwrap_or(false)
508 {
509 sites.push(ResidueSite {
510 region: "undo_del_mark".to_string(),
511 page_number: rec.page_number,
512 offset: rec.offset,
513 delete_marked: true,
514 trx_id: rec.trx_id,
515 });
516 }
517 }
518 }
519 }
520
521 if thorough {
523 regions_scanned.push("raw_page_bytes".to_string());
524 for needle in encode_value_needles(&target_col, target_value) {
525 let pat = Pattern::Bytes(needle);
526 for m in scan_residue(ts, &pat, 10_000)? {
527 sites.push(ResidueSite {
528 region: format!("raw_{}", m.region.name()),
529 page_number: m.page_number,
530 offset: m.offset,
531 delete_marked: false,
532 trx_id: None,
533 });
534 }
535 }
536 }
537
538 Ok(DeletionReport {
539 table_name,
540 column: target_col.name,
541 target_value: target_value.to_string(),
542 fully_purged: sites.is_empty(),
543 residue_sites: sites,
544 regions_scanned,
545 thorough,
546 records_examined,
547 })
548}
549
550#[derive(Debug, Clone, Serialize)]
556pub struct EncryptionAuditReport {
557 pub tablespace_encrypted: bool,
559 #[serde(skip_serializing_if = "Option::is_none")]
561 pub algorithm: Option<String>,
562 pub key_available: bool,
564 pub encrypted_page_count: u64,
566 pub total_pages: u64,
568}
569
570pub fn encryption_audit(ts: &mut Tablespace) -> Result<EncryptionAuditReport, IdbError> {
572 let tablespace_encrypted = ts.is_encrypted();
573 let key_available = ts.has_decryption_key();
574 let algorithm = if tablespace_encrypted {
575 let flags = ts.fsp_header().map(|h| h.flags).unwrap_or(0);
576 match crate::innodb::encryption::detect_encryption(flags, Some(ts.vendor_info())) {
577 crate::innodb::encryption::EncryptionAlgorithm::Aes => Some("AES".to_string()),
578 crate::innodb::encryption::EncryptionAlgorithm::None => Some("unknown".to_string()),
579 }
580 } else {
581 None
582 };
583
584 let mut encrypted_page_count = 0u64;
585 let mut total_pages = 0u64;
586
587 ts.for_each_page(|_pn, page_data| {
588 total_pages += 1;
589 if let Some(hdr) = FilHeader::parse(page_data) {
590 if matches!(
591 hdr.page_type,
592 PageType::Encrypted | PageType::CompressedEncrypted | PageType::EncryptedRtree
593 ) {
594 encrypted_page_count += 1;
595 }
596 }
597 Ok(())
598 })?;
599
600 Ok(EncryptionAuditReport {
601 tablespace_encrypted,
602 algorithm,
603 key_available,
604 encrypted_page_count,
605 total_pages,
606 })
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::innodb::field_decode::FieldValue;
613
614 #[test]
615 fn pattern_parse_utf8() {
616 let p = Pattern::parse("alice@example.com").unwrap();
617 assert_eq!(p.needle(), b"alice@example.com");
618 }
619
620 #[test]
621 fn pattern_parse_hex() {
622 let p = Pattern::parse("hex:00ffca").unwrap();
623 assert_eq!(p.needle(), &[0x00, 0xff, 0xca]);
624 }
625
626 #[test]
627 fn pattern_parse_hex_odd_rejected() {
628 assert!(Pattern::parse("hex:0ff").is_err());
629 }
630
631 #[test]
632 fn pattern_parse_empty_rejected() {
633 assert!(Pattern::parse("").is_err());
634 }
635
636 #[test]
637 fn find_all_overlapping() {
638 let mut out = Vec::new();
639 find_all(b"aaaa", b"aa", &mut out, 100);
640 assert_eq!(out, vec![0, 1, 2]);
641 }
642
643 #[test]
644 fn find_all_respects_cap() {
645 let mut out = Vec::new();
646 find_all(b"aaaa", b"a", &mut out, 2);
647 assert_eq!(out.len(), 2);
648 }
649
650 #[test]
651 fn find_all_no_match() {
652 let mut out = Vec::new();
653 find_all(b"abcdef", b"xyz", &mut out, 100);
654 assert!(out.is_empty());
655 }
656
657 #[test]
658 fn value_matches_trailing_ws() {
659 assert!(value_matches(&FieldValue::Str("bob ".into()), "bob"));
660 assert!(value_matches(&FieldValue::Int(42), "42"));
661 assert!(!value_matches(&FieldValue::Int(42), "43"));
662 }
663
664 #[test]
665 fn value_matches_null() {
666 assert!(value_matches(&FieldValue::Null, ""));
667 assert!(!value_matches(&FieldValue::Null, "x"));
668 }
669
670 #[test]
671 fn region_names_stable() {
672 assert_eq!(Region::FreeSpace.name(), "free_space");
673 assert_eq!(Region::RecordHeap.name(), "record_heap");
674 }
675
676 #[test]
677 fn encode_needles_signed_int_xor() {
678 let col = ColumnStorageInfo {
680 name: "id".into(),
681 dd_type: 4,
682 column_type: "int".into(),
683 is_nullable: false,
684 is_unsigned: false,
685 fixed_len: 4,
686 is_variable: false,
687 charset_max_bytes: 0,
688 datetime_precision: 0,
689 is_system_column: false,
690 elements: vec![],
691 numeric_precision: 0,
692 numeric_scale: 0,
693 };
694 let needles = encode_value_needles(&col, "1");
695 assert!(needles.contains(&vec![0x80, 0x00, 0x00, 0x01]));
696 assert!(needles.contains(&b"1".to_vec())); }
698
699 #[test]
700 fn encode_needles_unsigned_int() {
701 let col = ColumnStorageInfo {
702 name: "n".into(),
703 dd_type: 9,
704 column_type: "bigint unsigned".into(),
705 is_nullable: false,
706 is_unsigned: true,
707 fixed_len: 8,
708 is_variable: false,
709 charset_max_bytes: 0,
710 datetime_precision: 0,
711 is_system_column: false,
712 elements: vec![],
713 numeric_precision: 0,
714 numeric_scale: 0,
715 };
716 let needles = encode_value_needles(&col, "255");
717 assert!(needles.contains(&vec![0, 0, 0, 0, 0, 0, 0, 0xff]));
718 }
719
720 #[test]
721 fn pk_columns_stop_at_system() {
722 let mk = |name: &str, sys: bool| ColumnStorageInfo {
723 name: name.into(),
724 dd_type: 4,
725 column_type: "int".into(),
726 is_nullable: false,
727 is_unsigned: false,
728 fixed_len: 4,
729 is_variable: false,
730 charset_max_bytes: 0,
731 datetime_precision: 0,
732 is_system_column: sys,
733 elements: vec![],
734 numeric_precision: 0,
735 numeric_scale: 0,
736 };
737 let cols = vec![
738 mk("id", false),
739 mk("DB_TRX_ID", true),
740 mk("DB_ROLL_PTR", true),
741 mk("email", false),
742 ];
743 let pk = pk_columns(&cols);
744 assert_eq!(pk.len(), 1);
745 assert_eq!(pk[0].name, "id");
746 }
747}