Skip to main content

idb/innodb/
compliance.rs

1//! GDPR / compliance verification: deletion verification and data-residue scanning.
2//!
3//! This is the inverse of [`crate::innodb::undelete`]. Instead of recovering data
4//! that still lingers, `compliance` answers "has this value been purged from every
5//! InnoDB-retained location in this file?" and reports every place it still appears.
6//!
7//! Two strategies, deliberately kept separate:
8//!
9//! 1. **Deletion verification** ([`verify_deleted`]) - decode-and-compare over real
10//!    record structures (live clustered records, delete-marked records, free-list
11//!    records, and undo `DEL_MARK` entries). Type-aware and authoritative for "is
12//!    this value still reachable as a record field". Blind to torn/overwritten bytes
13//!    in slack space.
14//! 2. **Residue scanning** ([`scan_residue`]) - a raw literal byte-pattern sweep over
15//!    every page region including free/slack space. Catches residue a record-structure
16//!    scan cannot see, but cannot attribute a match to a column/row.
17//!
18//! `verify_deleted` runs the logical pass by default; passing `thorough=true` adds the
19//! raw byte pass over the encoded form of the target value.
20//!
21//! # Scope honesty
22//!
23//! This verifies residue within the tablespace file(s) passed in. It cannot see the OS
24//! page cache, replicas, other backups, or binary-log archives. It reports byte- and
25//! record-level residue; it does not certify legal compliance.
26
27use 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// ---------------------------------------------------------------------------
39// Pattern
40// ---------------------------------------------------------------------------
41
42/// A literal byte needle to search for across page bytes.
43///
44/// No regex: the core library stays dependency-free and WASM-lean. `--pattern`
45/// accepts UTF-8 text (matched as its UTF-8 bytes) or a `hex:` prefix for raw bytes.
46#[derive(Debug, Clone)]
47pub enum Pattern {
48    /// Literal byte sequence to match.
49    Bytes(Vec<u8>),
50}
51
52impl Pattern {
53    /// Parse a user-supplied `--pattern` string.
54    ///
55    /// A `hex:` prefix decodes the remainder as hex (even number of hex digits);
56    /// anything else is taken as UTF-8 text and matched as its raw bytes.
57    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
99/// Find every start offset of `needle` within `haystack` (overlapping matches).
100fn 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// ---------------------------------------------------------------------------
120// Residue scanning (#175)
121// ---------------------------------------------------------------------------
122
123/// Where within a page a residue match landed.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "snake_case")]
126pub enum Region {
127    /// FIL header (first 38 bytes).
128    FilHeader,
129    /// FIL trailer (last 8 bytes).
130    FilTrailer,
131    /// Live record heap of an INDEX page (below `heap_top`).
132    RecordHeap,
133    /// Free / slack space of an INDEX page (at or above `heap_top`).
134    FreeSpace,
135    /// Page body of a non-INDEX page (unclassified).
136    Body,
137}
138
139impl Region {
140    /// Stable lowercase name for CSV / text output.
141    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/// A single raw byte-pattern match.
153#[derive(Debug, Clone, Serialize)]
154pub struct ResidueMatch {
155    /// Page number the match was found in.
156    pub page_number: u64,
157    /// Page type name (e.g. "INDEX", "UNDO_LOG").
158    pub page_type: String,
159    /// Byte offset of the match within the page.
160    pub offset: usize,
161    /// Region classification within the page.
162    pub region: Region,
163    /// Up to 32 bytes of surrounding context, hex-encoded.
164    pub context_hex: String,
165}
166
167/// Classify which region of a page an offset falls in.
168fn 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
193/// Scan every page of a tablespace for a literal byte pattern.
194///
195/// Returns up to `max_hits` matches across all page regions. This is the raw pass:
196/// it sees slack space and non-record bytes a decode-based scan cannot.
197pub 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// ---------------------------------------------------------------------------
240// Deletion verification (#176)
241// ---------------------------------------------------------------------------
242
243/// A single place a target value still appears.
244#[derive(Debug, Clone, Serialize)]
245pub struct ResidueSite {
246    /// How the value was found: `live_record`, `delete_marked`, `free_list`,
247    /// `undo_del_mark`, or `raw_<region>` (a raw byte-pass hit, tagged with the
248    /// page region it landed in, e.g. `raw_free_space` or `raw_record_heap`).
249    pub region: String,
250    /// Page number.
251    pub page_number: u64,
252    /// Byte offset within the page (0 when the source does not expose one).
253    pub offset: usize,
254    /// Whether the containing record is delete-marked.
255    pub delete_marked: bool,
256    /// Approximate transaction id, when available.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub trx_id: Option<u64>,
259}
260
261/// Result of a deletion-verification scan.
262#[derive(Debug, Clone, Serialize)]
263pub struct DeletionReport {
264    /// Table name from SDI, if available.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub table_name: Option<String>,
267    /// Column that was checked.
268    pub column: String,
269    /// Target value (as supplied).
270    pub target_value: String,
271    /// True when no residue site was found anywhere scanned.
272    pub fully_purged: bool,
273    /// Every place the value still appears.
274    pub residue_sites: Vec<ResidueSite>,
275    /// Logical regions that were scanned (for honesty about coverage).
276    pub regions_scanned: Vec<String>,
277    /// Whether the raw byte pass ran.
278    pub thorough: bool,
279    /// Total records examined during the logical pass.
280    pub records_examined: usize,
281}
282
283/// Render a decoded field value as a canonical comparison string.
284fn 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
296/// Compare a decoded value against a target string (trailing-whitespace-insensitive,
297/// to absorb CHAR space padding). NULL never matches a non-empty target.
298fn 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
305/// Return the PK columns (leading user columns that precede the first system
306/// column in the physical layout produced by `build_column_layout`).
307fn 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
318/// Produce literal byte needles for the raw pass, given a column and target value.
319///
320/// Always includes the UTF-8 bytes of the target (covers string/text columns). For
321/// integer columns, additionally encodes the value the way InnoDB stores it
322/// (big-endian, high bit XOR'd for signed) so numeric residue is found in slack space.
323fn 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); // TINY/SHORT/LONG/INT24/LONGLONG
331    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            // InnoDB flips the sign bit for memcmp ordering.
339            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    // Deduplicate identical needles.
349    needles.sort();
350    needles.dedup();
351    needles
352}
353
354/// Verify that `target_value` in column `column` has been purged from every
355/// InnoDB-retained location in `ts`.
356///
357/// Runs the logical decode-and-compare pass over clustered-index leaf pages
358/// (live + delete-marked + free-list records) and undo `DEL_MARK` entries. When
359/// `thorough` is set, also runs a raw byte pass over the encoded value.
360pub 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    // Resolve and validate the target column.
375    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    // Collect clustered leaf pages first (the callback borrows ts immutably).
400    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        // Live records.
422        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        // Delete-marked records (include system cols to recover the trx id).
438        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        // Free-list records (purged from the active chain but not overwritten).
468        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    // Undo DEL_MARK scan - only meaningful when the target is a PK column (undo
495    // stores PK fields for deletes). Undo pages live in ibdata1/undo tablespaces;
496    // on a bare .ibd this simply finds nothing.
497    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    // Thorough: raw byte pass over the encoded value across all page regions.
522    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// ---------------------------------------------------------------------------
551// Encryption audit (#177 --encryption-audit)
552// ---------------------------------------------------------------------------
553
554/// Result of an encryption audit.
555#[derive(Debug, Clone, Serialize)]
556pub struct EncryptionAuditReport {
557    /// Whether the tablespace declares encryption in its FSP flags.
558    pub tablespace_encrypted: bool,
559    /// Encryption algorithm string (from the FSP encryption info), if any.
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub algorithm: Option<String>,
562    /// Whether a decryption key/context is available for this tablespace.
563    pub key_available: bool,
564    /// Count of pages whose FIL page type indicates encrypted content.
565    pub encrypted_page_count: u64,
566    /// Total pages inspected.
567    pub total_pages: u64,
568}
569
570/// Audit which pages of a tablespace are encrypted and whether a key is available.
571pub 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        // A 4-byte signed INT column, value 1: BE 0x00000001, sign bit flipped -> 0x80000001.
679        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())); // utf8 form always present
697    }
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}