Skip to main content

idb/cli/
undelete.rs

1//! CLI implementation for the `inno undelete` subcommand.
2//!
3//! Recovers deleted records from InnoDB tablespaces using three strategies:
4//! delete-marked records, free-list records, and (optionally) undo log records.
5//! Supports CSV, JSON, SQL, and metadata JSON output formats.
6
7use std::io::Write;
8
9use crate::cli::wprintln;
10use crate::innodb::export::csv_escape;
11use crate::innodb::undelete::{
12    field_value_to_json, field_value_to_sql, scan_undeleted, RecoverySource, UndeleteScanResult,
13};
14use crate::IdbError;
15
16/// Output format for undeleted records.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum UndeleteFormat {
19    Csv,
20    Json,
21    Sql,
22    Hex,
23}
24
25impl UndeleteFormat {
26    fn from_str(s: &str) -> Result<Self, IdbError> {
27        match s.to_lowercase().as_str() {
28            "csv" => Ok(UndeleteFormat::Csv),
29            "json" => Ok(UndeleteFormat::Json),
30            "sql" => Ok(UndeleteFormat::Sql),
31            "hex" => Ok(UndeleteFormat::Hex),
32            _ => Err(IdbError::Argument(format!(
33                "Unknown format '{}'. Use csv, json, sql, or hex.",
34                s
35            ))),
36        }
37    }
38}
39
40/// Options for the `inno undelete` subcommand.
41pub struct UndeleteOptions {
42    /// Path to the InnoDB tablespace file (.ibd).
43    pub file: String,
44    /// Path to an undo tablespace (ibdata1 or .ibu) for undo log scanning.
45    pub undo_file: Option<String>,
46    /// Filter by table name.
47    pub table: Option<String>,
48    /// Minimum transaction ID to include.
49    pub min_trx_id: Option<u64>,
50    /// Minimum confidence threshold (0.0–1.0).
51    pub confidence: f64,
52    /// Record output format: csv, json, sql, hex.
53    pub format: String,
54    /// Output full metadata JSON envelope (overrides format).
55    pub json: bool,
56    /// Show additional detail.
57    pub verbose: bool,
58    /// Recover from a specific page only.
59    pub page: Option<u64>,
60    /// Override page size.
61    pub page_size: Option<u32>,
62    /// Path to MySQL keyring file.
63    pub keyring: Option<String>,
64    /// Use memory-mapped I/O.
65    pub mmap: bool,
66}
67
68/// Execute the undelete subcommand.
69pub fn execute(opts: &UndeleteOptions, writer: &mut dyn Write) -> Result<(), IdbError> {
70    let format = UndeleteFormat::from_str(&opts.format)?;
71
72    let mut ts = crate::cli::open_tablespace(&opts.file, opts.page_size, opts.mmap)?;
73    if let Some(ref keyring_path) = opts.keyring {
74        crate::cli::setup_decryption(&mut ts, keyring_path)?;
75    }
76
77    // Open undo tablespace if provided
78    let mut undo_ts_opt = match &opts.undo_file {
79        Some(path) => Some(crate::cli::open_tablespace(
80            path,
81            opts.page_size,
82            opts.mmap,
83        )?),
84        None => None,
85    };
86
87    let result = scan_undeleted(
88        &mut ts,
89        undo_ts_opt.as_mut(),
90        opts.confidence,
91        opts.min_trx_id,
92        opts.page,
93    )?;
94
95    // Filter by table name if requested
96    if let Some(ref filter_table) = opts.table {
97        match result.table_name {
98            Some(ref table_name) => {
99                if !table_name.eq_ignore_ascii_case(filter_table) {
100                    return Err(IdbError::Argument(format!(
101                        "Table name '{}' does not match filter '{}'",
102                        table_name, filter_table
103                    )));
104                }
105            }
106            None => {
107                return Err(IdbError::Argument(
108                    "Cannot filter by table name: SDI metadata not available (pre-8.0 tablespace)"
109                        .to_string(),
110                ));
111            }
112        }
113    }
114
115    if opts.verbose && !opts.json {
116        eprintln!(
117            "Recovered {} records ({} delete-marked, {} free-list, {} undo-log)",
118            result.summary.total,
119            result.summary.delete_marked,
120            result.summary.free_list,
121            result.summary.undo_log,
122        );
123    }
124
125    if opts.json {
126        // Full metadata JSON envelope
127        let json =
128            serde_json::to_string_pretty(&result).map_err(|e| IdbError::Parse(e.to_string()))?;
129        wprintln!(writer, "{}", json)?;
130    } else {
131        match format {
132            UndeleteFormat::Csv => output_csv(writer, &result)?,
133            UndeleteFormat::Json => output_json(writer, &result)?,
134            UndeleteFormat::Sql => output_sql(writer, &result)?,
135            UndeleteFormat::Hex => output_hex(writer, &result)?,
136        }
137    }
138
139    Ok(())
140}
141
142/// Output records as CSV.
143fn output_csv(writer: &mut dyn Write, result: &UndeleteScanResult) -> Result<(), IdbError> {
144    // Header: _source,_confidence,_trx_id,_page,<column names>
145    let mut headers = vec![
146        "_source".to_string(),
147        "_confidence".to_string(),
148        "_trx_id".to_string(),
149        "_page".to_string(),
150    ];
151    headers.extend(result.column_names.clone());
152    wprintln!(writer, "{}", headers.join(","))?;
153
154    for rec in &result.records {
155        let source_str = match rec.source {
156            RecoverySource::DeleteMarked => "delete_marked",
157            RecoverySource::FreeList => "free_list",
158            RecoverySource::UndoLog => "undo_log",
159        };
160        let mut values = vec![
161            source_str.to_string(),
162            format!("{:.2}", rec.confidence),
163            rec.trx_id.map_or(String::new(), |t| t.to_string()),
164            rec.page_number.to_string(),
165        ];
166
167        for (_, val) in &rec.columns {
168            values.push(csv_escape(val));
169        }
170
171        wprintln!(writer, "{}", values.join(","))?;
172    }
173
174    Ok(())
175}
176
177/// Output records as JSON array.
178fn output_json(writer: &mut dyn Write, result: &UndeleteScanResult) -> Result<(), IdbError> {
179    let mut json_records: Vec<serde_json::Value> = Vec::new();
180
181    for rec in &result.records {
182        let mut obj = serde_json::Map::new();
183        obj.insert(
184            "source".to_string(),
185            serde_json::json!(match rec.source {
186                RecoverySource::DeleteMarked => "delete_marked",
187                RecoverySource::FreeList => "free_list",
188                RecoverySource::UndoLog => "undo_log",
189            }),
190        );
191        obj.insert("confidence".to_string(), serde_json::json!(rec.confidence));
192        if let Some(trx) = rec.trx_id {
193            obj.insert("trx_id".to_string(), serde_json::json!(trx));
194        }
195        obj.insert("page".to_string(), serde_json::json!(rec.page_number));
196
197        let mut cols = serde_json::Map::new();
198        for (name, val) in &rec.columns {
199            cols.insert(name.clone(), field_value_to_json(val));
200        }
201        obj.insert("columns".to_string(), serde_json::Value::Object(cols));
202
203        json_records.push(serde_json::Value::Object(obj));
204    }
205
206    let output =
207        serde_json::to_string_pretty(&json_records).map_err(|e| IdbError::Parse(e.to_string()))?;
208    wprintln!(writer, "{}", output)?;
209
210    Ok(())
211}
212
213/// Output records as SQL INSERT statements.
214fn output_sql(writer: &mut dyn Write, result: &UndeleteScanResult) -> Result<(), IdbError> {
215    let table_name = result.table_name.as_deref().unwrap_or("unknown_table");
216
217    let col_names = if !result.column_names.is_empty() {
218        result.column_names.join(", ")
219    } else if let Some(first_rec) = result.records.first() {
220        first_rec
221            .columns
222            .iter()
223            .map(|(n, _)| n.as_str())
224            .collect::<Vec<_>>()
225            .join(", ")
226    } else {
227        return Ok(());
228    };
229
230    for rec in &result.records {
231        let source_str = match rec.source {
232            RecoverySource::DeleteMarked => "delete_marked",
233            RecoverySource::FreeList => "free_list",
234            RecoverySource::UndoLog => "undo_log",
235        };
236        wprintln!(
237            writer,
238            "-- source: {}, confidence: {:.2}, page: {}",
239            source_str,
240            rec.confidence,
241            rec.page_number
242        )?;
243
244        let values: Vec<String> = rec
245            .columns
246            .iter()
247            .map(|(_, val)| field_value_to_sql(val))
248            .collect();
249
250        wprintln!(
251            writer,
252            "INSERT INTO {} ({}) VALUES ({});",
253            table_name,
254            col_names,
255            values.join(", ")
256        )?;
257    }
258
259    Ok(())
260}
261
262/// Output records as hex dump.
263fn output_hex(writer: &mut dyn Write, result: &UndeleteScanResult) -> Result<(), IdbError> {
264    wprintln!(
265        writer,
266        "{:<12} {:<8} {:<8} {:<6} {}",
267        "SOURCE",
268        "CONF",
269        "PAGE",
270        "OFFSET",
271        "DATA (hex)"
272    )?;
273
274    for rec in &result.records {
275        let source_str = match rec.source {
276            RecoverySource::DeleteMarked => "delete_marked",
277            RecoverySource::FreeList => "free_list",
278            RecoverySource::UndoLog => "undo_log",
279        };
280
281        let hex = rec.raw_hex.as_deref().unwrap_or("");
282
283        wprintln!(
284            writer,
285            "{:<12} {:<8.2} {:<8} {:<6} {}",
286            source_str,
287            rec.confidence,
288            rec.page_number,
289            rec.offset,
290            hex
291        )?;
292    }
293
294    Ok(())
295}