Skip to main content

idb/cli/
comply.rs

1//! CLI implementation for the `inno comply` subcommand.
2//!
3//! Forensic deletion-verification and data-residue scanning - the inverse of
4//! `inno undelete`. Three modes, exactly one per invocation:
5//!
6//! - `--verify-deleted --where <col>=<value>` - confirm a value has been purged
7//!   from every InnoDB-retained location (live/delete-marked/free-list/undo records).
8//! - `--scan-residue --pattern <needle>` - raw literal byte sweep across all pages.
9//! - `--encryption-audit` - report encrypted vs plaintext pages and key availability.
10//!
11//! This verifies residue within the file passed in. It cannot see the OS page cache,
12//! replicas, other backups, or binlog archives, and does not certify legal compliance.
13
14use std::io::Write;
15
16use crate::cli::wprintln;
17use crate::innodb::compliance::{encryption_audit, scan_residue, verify_deleted, Pattern};
18use crate::IdbError;
19
20/// Options for the `inno comply` subcommand.
21pub struct ComplyOptions {
22    /// Path to the InnoDB tablespace file (.ibd).
23    pub file: String,
24    /// Mode: verify a value has been deleted everywhere.
25    pub verify_deleted: bool,
26    /// Mode: raw byte-pattern residue scan.
27    pub scan_residue: bool,
28    /// Mode: encryption audit.
29    pub encryption_audit: bool,
30    /// Optional table-name filter (verify mode); errors if it mismatches SDI.
31    pub table: Option<String>,
32    /// `--where col=value` (verify mode).
33    pub where_clause: Option<String>,
34    /// `--pattern` needle (scan mode): UTF-8 text or `hex:...`.
35    pub pattern: Option<String>,
36    /// Also run the raw byte pass inside `--verify-deleted`.
37    pub thorough: bool,
38    /// Cap on residue matches reported (scan mode).
39    pub max_hits: usize,
40    /// Override page size.
41    pub page_size: Option<u32>,
42    /// Path to MySQL keyring file.
43    pub keyring: Option<String>,
44    /// Use memory-mapped I/O.
45    pub mmap: bool,
46    /// Emit JSON.
47    pub json: bool,
48    /// Emit CSV.
49    pub csv: bool,
50    /// Show additional detail.
51    pub verbose: bool,
52}
53
54/// Execute the comply subcommand.
55pub fn execute(opts: &ComplyOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
56    // Exactly one mode.
57    let mode_count =
58        opts.verify_deleted as u8 + opts.scan_residue as u8 + opts.encryption_audit as u8;
59    if mode_count == 0 {
60        return Err(IdbError::Argument(
61            "select a mode: --verify-deleted, --scan-residue, or --encryption-audit".to_string(),
62        ));
63    }
64    if mode_count > 1 {
65        return Err(IdbError::Argument(
66            "modes are mutually exclusive: pick one of --verify-deleted, --scan-residue, --encryption-audit"
67                .to_string(),
68        ));
69    }
70
71    let mut ts = crate::cli::open_tablespace(&opts.file, opts.page_size, opts.mmap)?;
72    if let Some(ref keyring_path) = opts.keyring {
73        crate::cli::setup_decryption(&mut ts, keyring_path)?;
74    }
75
76    if opts.verify_deleted {
77        run_verify(opts, &mut ts, writer)
78    } else if opts.scan_residue {
79        run_scan(opts, &mut ts, writer)
80    } else {
81        run_encryption_audit(opts, &mut ts, writer)
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Mode: verify-deleted
87// ---------------------------------------------------------------------------
88
89fn run_verify(
90    opts: &ComplyOptions,
91    ts: &mut crate::innodb::tablespace::Tablespace,
92    writer: &mut dyn Write,
93) -> Result<(), IdbError> {
94    let where_clause = opts.where_clause.as_deref().ok_or_else(|| {
95        IdbError::Argument("--verify-deleted requires --where <column>=<value>".to_string())
96    })?;
97    let (col, val) = where_clause.split_once('=').ok_or_else(|| {
98        IdbError::Argument("--where must be of the form <column>=<value>".to_string())
99    })?;
100    let col = col.trim();
101    if col.is_empty() {
102        return Err(IdbError::Argument(
103            "--where column name must not be empty".to_string(),
104        ));
105    }
106    if val.is_empty() {
107        return Err(IdbError::Argument(
108            "--where value must not be empty (an empty value would match every NULL row)"
109                .to_string(),
110        ));
111    }
112
113    // Optional table-name filter.
114    if let Some(ref filter) = opts.table {
115        match crate::innodb::export::extract_table_name(ts) {
116            Some(name) if name.eq_ignore_ascii_case(filter) => {}
117            Some(name) => {
118                return Err(IdbError::Argument(format!(
119                    "Table name '{}' does not match filter '{}'",
120                    name, filter
121                )))
122            }
123            None => {
124                return Err(IdbError::Argument(
125                    "Cannot filter by table name: SDI metadata not available".to_string(),
126                ))
127            }
128        }
129    }
130
131    let report = verify_deleted(ts, col, val, opts.thorough)?;
132
133    if opts.json {
134        let json =
135            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?;
136        wprintln!(writer, "{}", json)?;
137        return Ok(());
138    }
139
140    if opts.csv {
141        wprintln!(writer, "region,page,offset,delete_marked,trx_id")?;
142        for s in &report.residue_sites {
143            wprintln!(
144                writer,
145                "{},{},{},{},{}",
146                s.region,
147                s.page_number,
148                s.offset,
149                if s.delete_marked { "Y" } else { "N" },
150                s.trx_id.map_or(String::new(), |t| t.to_string()),
151            )?;
152        }
153        return Ok(());
154    }
155
156    // Human-readable.
157    let table = report.table_name.as_deref().unwrap_or("(unknown table)");
158    wprintln!(
159        writer,
160        "Deletion verification: {}.{} = '{}'",
161        table,
162        report.column,
163        report.target_value
164    )?;
165    wprintln!(
166        writer,
167        "Scanned: {} ({} records examined){}",
168        report.regions_scanned.join(", "),
169        report.records_examined,
170        if report.thorough {
171            ""
172        } else {
173            " - logical structures only; pass --thorough to also sweep slack space"
174        }
175    )?;
176
177    if report.fully_purged {
178        wprintln!(
179            writer,
180            "\nRESULT: fully purged - no residue found in the scanned regions of this file."
181        )?;
182    } else {
183        wprintln!(
184            writer,
185            "\nRESULT: NOT purged - {} residue site(s) found:",
186            report.residue_sites.len()
187        )?;
188        for s in &report.residue_sites {
189            let trx = s
190                .trx_id
191                .map_or(String::new(), |t| format!(", trx_id={}", t));
192            wprintln!(
193                writer,
194                "  [{}] page {} offset {}{}{}",
195                s.region,
196                s.page_number,
197                s.offset,
198                if s.delete_marked {
199                    ", delete-marked"
200                } else {
201                    ""
202                },
203                trx
204            )?;
205        }
206    }
207
208    Ok(())
209}
210
211// ---------------------------------------------------------------------------
212// Mode: scan-residue
213// ---------------------------------------------------------------------------
214
215fn run_scan(
216    opts: &ComplyOptions,
217    ts: &mut crate::innodb::tablespace::Tablespace,
218    writer: &mut dyn Write,
219) -> Result<(), IdbError> {
220    let pattern_str = opts.pattern.as_deref().ok_or_else(|| {
221        IdbError::Argument("--scan-residue requires --pattern <text|hex:...>".to_string())
222    })?;
223    let pattern = Pattern::parse(pattern_str)?;
224
225    let matches = scan_residue(ts, &pattern, opts.max_hits)?;
226
227    if opts.json {
228        let json =
229            serde_json::to_string_pretty(&matches).map_err(|e| IdbError::Parse(e.to_string()))?;
230        wprintln!(writer, "{}", json)?;
231        return Ok(());
232    }
233
234    if opts.csv {
235        wprintln!(writer, "page,page_type,offset,region,context_hex")?;
236        for m in &matches {
237            wprintln!(
238                writer,
239                "{},{},{},{},{}",
240                m.page_number,
241                m.page_type,
242                m.offset,
243                m.region.name(),
244                m.context_hex,
245            )?;
246        }
247        return Ok(());
248    }
249
250    if matches.is_empty() {
251        wprintln!(
252            writer,
253            "No residue found for pattern in the scanned pages of this file."
254        )?;
255        return Ok(());
256    }
257
258    wprintln!(
259        writer,
260        "{} match(es){}:",
261        matches.len(),
262        if matches.len() >= opts.max_hits {
263            format!(" (capped at {})", opts.max_hits)
264        } else {
265            String::new()
266        }
267    )?;
268    wprintln!(
269        writer,
270        "{:<8} {:<16} {:<8} {:<12} {}",
271        "PAGE",
272        "PAGE_TYPE",
273        "OFFSET",
274        "REGION",
275        "CONTEXT (hex)"
276    )?;
277    for m in &matches {
278        wprintln!(
279            writer,
280            "{:<8} {:<16} {:<8} {:<12} {}",
281            m.page_number,
282            m.page_type,
283            m.offset,
284            m.region.name(),
285            m.context_hex,
286        )?;
287    }
288
289    Ok(())
290}
291
292// ---------------------------------------------------------------------------
293// Mode: encryption-audit
294// ---------------------------------------------------------------------------
295
296fn run_encryption_audit(
297    opts: &ComplyOptions,
298    ts: &mut crate::innodb::tablespace::Tablespace,
299    writer: &mut dyn Write,
300) -> Result<(), IdbError> {
301    let report = encryption_audit(ts)?;
302
303    if opts.json {
304        let json =
305            serde_json::to_string_pretty(&report).map_err(|e| IdbError::Parse(e.to_string()))?;
306        wprintln!(writer, "{}", json)?;
307        return Ok(());
308    }
309
310    if opts.csv {
311        wprintln!(
312            writer,
313            "tablespace_encrypted,algorithm,key_available,encrypted_pages,total_pages"
314        )?;
315        wprintln!(
316            writer,
317            "{},{},{},{},{}",
318            if report.tablespace_encrypted {
319                "Y"
320            } else {
321                "N"
322            },
323            report.algorithm.as_deref().unwrap_or(""),
324            if report.key_available { "Y" } else { "N" },
325            report.encrypted_page_count,
326            report.total_pages,
327        )?;
328        return Ok(());
329    }
330
331    wprintln!(
332        writer,
333        "Encryption audit: {}",
334        if report.tablespace_encrypted {
335            "ENCRYPTED tablespace"
336        } else {
337            "plaintext tablespace"
338        }
339    )?;
340    if let Some(ref algo) = report.algorithm {
341        wprintln!(writer, "  Algorithm:       {}", algo)?;
342    }
343    wprintln!(
344        writer,
345        "  Key available:   {}",
346        if report.key_available { "yes" } else { "no" }
347    )?;
348    wprintln!(
349        writer,
350        "  Encrypted pages: {} / {}",
351        report.encrypted_page_count,
352        report.total_pages
353    )?;
354
355    Ok(())
356}