Skip to main content

xlog_decoder/
lib.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use clap::{Args, Parser, Subcommand};
5use k256::elliptic_curve::sec1::ToEncodedPoint;
6use k256::SecretKey;
7use mars_xlog_core::decoder::{decode_xlog, DecodeOptions};
8use rand_core::OsRng;
9use thiserror::Error;
10
11#[derive(Debug, Parser)]
12#[command(
13    name = "xlog-decoder",
14    version,
15    about = "Decode Tencent Mars xlog files, log directories, and Feedback diagnostic ZIP packages."
16)]
17struct Cli {
18    #[command(subcommand)]
19    command: Option<Command>,
20
21    /// Input .xlog file, directory, or feedback .zip archive (flag).
22    #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG", global = true)]
23    flag_input: Option<PathBuf>,
24
25    /// Output file or target directory (flag).
26    #[arg(
27        short = 'o',
28        long = "output",
29        value_name = "OUTPUT_FLAG",
30        global = true
31    )]
32    flag_output: Option<PathBuf>,
33
34    /// Input .xlog file, directory, or feedback .zip archive.
35    #[arg(value_name = "INPUT", global = true)]
36    input: Option<PathBuf>,
37
38    /// Output file or target directory (optional).
39    #[arg(value_name = "OUTPUT", global = true)]
40    output: Option<PathBuf>,
41
42    /// 64-hex-character private key for encrypted xlog blocks.
43    #[arg(
44        short = 'k',
45        short_alias = 'p',
46        long,
47        alias = "private-key",
48        env = "MARS_XLOG_KEY",
49        global = true
50    )]
51    key: Option<String>,
52
53    /// Clean (delete) original .xlog files after successful decoding.
54    #[arg(long, global = true)]
55    clean: bool,
56}
57
58#[derive(Debug, Subcommand)]
59enum Command {
60    /// Decode one xlog file, directory, or feedback zip package.
61    Decode(DecodeArgs),
62    /// Generate a secp256k1 key pair compatible with Mars xlog encryption.
63    GenKey,
64}
65
66#[derive(Debug, Args)]
67struct DecodeArgs {
68    /// Input .xlog file, directory, or feedback .zip archive (flag).
69    #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG")]
70    input: Option<PathBuf>,
71
72    /// Output file or target directory (flag).
73    #[arg(short = 'o', long = "output", value_name = "OUTPUT_FLAG")]
74    output: Option<PathBuf>,
75
76    /// Positional input argument.
77    #[arg(value_name = "INPUT")]
78    pos_input: Option<PathBuf>,
79
80    /// Positional output argument.
81    #[arg(value_name = "OUTPUT")]
82    pos_output: Option<PathBuf>,
83
84    /// 64-hex-character private key for encrypted xlog blocks.
85    #[arg(short = 'k', short_alias = 'p', long, alias = "private-key")]
86    key: Option<String>,
87
88    /// Clean (delete) original .xlog files after successful decoding.
89    #[arg(long)]
90    clean: bool,
91}
92
93#[derive(Debug, Error)]
94pub enum CliError {
95    #[error("input path does not exist: {0}")]
96    InputNotFound(String),
97    #[error("input path is required. Run with --help for usage.")]
98    MissingInput,
99    #[error("I/O error at {path}: {source}")]
100    Io {
101        path: String,
102        source: std::io::Error,
103    },
104    #[error("ZIP error: {0}")]
105    Zip(String),
106    #[error(transparent)]
107    Decode(#[from] mars_xlog_core::decoder::DecodeError),
108}
109
110pub fn run() -> Result<(), CliError> {
111    let cli = Cli::parse();
112    match cli.command {
113        Some(Command::Decode(args)) => {
114            let input = args
115                .input
116                .or(args.pos_input)
117                .or(cli.flag_input)
118                .or(cli.input)
119                .ok_or(CliError::MissingInput)?;
120            let output = args
121                .output
122                .or(args.pos_output)
123                .or(cli.flag_output)
124                .or(cli.output);
125            let key = args.key.or(cli.key);
126            let clean = args.clean || cli.clean;
127            process_input(input, output, key, clean)
128        }
129        Some(Command::GenKey) => {
130            print_key_pair();
131            Ok(())
132        }
133        None => {
134            let input = cli.flag_input.or(cli.input).ok_or(CliError::MissingInput)?;
135            let output = cli.flag_output.or(cli.output);
136            process_input(input, output, cli.key, cli.clean)
137        }
138    }
139}
140
141fn process_input(
142    input: PathBuf,
143    output: Option<PathBuf>,
144    key: Option<String>,
145    clean: bool,
146) -> Result<(), CliError> {
147    if !input.exists() {
148        return Err(CliError::InputNotFound(input.display().to_string()));
149    }
150
151    if is_zip_file(&input) {
152        process_zip(&input, output.as_deref(), key.as_deref(), clean)
153    } else if input.is_dir() {
154        process_directory(&input, output.as_deref(), key.as_deref(), clean)
155    } else {
156        process_single_file(&input, output.as_deref(), key.as_deref(), clean)
157    }
158}
159
160fn is_zip_file(path: &Path) -> bool {
161    if path
162        .extension()
163        .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
164    {
165        return true;
166    }
167    // Also check magic PK\x03\x04
168    if let Ok(mut file) = fs::File::open(path) {
169        use std::io::Read;
170        let mut magic = [0u8; 4];
171        if file.read_exact(&mut magic).is_ok() && &magic == b"PK\x03\x04" {
172            return true;
173        }
174    }
175    false
176}
177
178fn process_zip(
179    zip_path: &Path,
180    output_dir: Option<&Path>,
181    key: Option<&str>,
182    clean: bool,
183) -> Result<(), CliError> {
184    let target_dir = match output_dir {
185        Some(dir) => dir.to_path_buf(),
186        None => {
187            let stem = zip_path
188                .file_stem()
189                .and_then(|s| s.to_str())
190                .unwrap_or("feedback");
191            let parent = zip_path.parent().unwrap_or_else(|| Path::new("."));
192            parent.join(format!("{}_decoded", stem))
193        }
194    };
195
196    println!("\nšŸ“¦ Extracting ZIP: {}", zip_path.display());
197    println!("šŸ“‚ Output directory: {}\n", target_dir.display());
198
199    fs::create_dir_all(&target_dir).map_err(|e| CliError::Io {
200        path: target_dir.display().to_string(),
201        source: e,
202    })?;
203
204    let file = fs::File::open(zip_path).map_err(|e| CliError::Io {
205        path: zip_path.display().to_string(),
206        source: e,
207    })?;
208    let mut archive = zip::ZipArchive::new(file).map_err(|e| CliError::Zip(e.to_string()))?;
209
210    for i in 0..archive.len() {
211        let mut file = archive
212            .by_index(i)
213            .map_err(|e| CliError::Zip(e.to_string()))?;
214        let outpath = match file.enclosed_name() {
215            Some(path) => target_dir.join(path),
216            None => continue,
217        };
218
219        if file.is_dir() {
220            fs::create_dir_all(&outpath).map_err(|e| CliError::Io {
221                path: outpath.display().to_string(),
222                source: e,
223            })?;
224        } else {
225            if let Some(p) = outpath.parent() {
226                if !p.exists() {
227                    fs::create_dir_all(p).map_err(|e| CliError::Io {
228                        path: p.display().to_string(),
229                        source: e,
230                    })?;
231                }
232            }
233            let mut outfile = fs::File::create(&outpath).map_err(|e| CliError::Io {
234                path: outpath.display().to_string(),
235                source: e,
236            })?;
237            std::io::copy(&mut file, &mut outfile).map_err(|e| CliError::Io {
238                path: outpath.display().to_string(),
239                source: e,
240            })?;
241        }
242    }
243
244    let mut xlog_files = Vec::new();
245    for entry in walkdir::WalkDir::new(&target_dir)
246        .into_iter()
247        .filter_map(|e| e.ok())
248    {
249        if entry.file_type().is_file()
250            && entry
251                .path()
252                .extension()
253                .is_some_and(|ext| ext.eq_ignore_ascii_case("xlog"))
254        {
255            xlog_files.push(entry.into_path());
256        }
257    }
258    xlog_files.sort();
259
260    println!("Found {} .xlog file(s) to decode:", xlog_files.len());
261    let mut success_count = 0;
262    for xlog_file in &xlog_files {
263        let log_file = xlog_file.with_extension("log");
264        let rel_path = xlog_file.strip_prefix(&target_dir).unwrap_or(xlog_file);
265        print!("• {}: ", rel_path.display());
266        match decode_single_xlog(xlog_file, &log_file, key) {
267            Ok(bytes) => {
268                println!(
269                    "[OK] -> {} ({} bytes)",
270                    log_file.file_name().unwrap_or_default().to_string_lossy(),
271                    bytes
272                );
273                success_count += 1;
274                if clean {
275                    let _ = fs::remove_file(xlog_file);
276                }
277            }
278            Err(e) => {
279                println!("[FAIL] {}", e);
280            }
281        }
282    }
283
284    let summary_file = target_dir.join("export_summary.txt");
285    if summary_file.is_file() {
286        if let Ok(content) = fs::read_to_string(&summary_file) {
287            println!("\n{}", "=".repeat(70));
288            println!("šŸ“ Diagnostic Package Summary (export_summary.txt):");
289            println!("{}", "=".repeat(70));
290            println!("{}", content.trim());
291            println!("{}", "=".repeat(70));
292        }
293    }
294
295    println!(
296        "\n✨ Completed: {}/{} logs decoded successfully.",
297        success_count,
298        xlog_files.len()
299    );
300    println!("šŸ“ Decoded logs available at: {}\n", target_dir.display());
301    Ok(())
302}
303
304fn process_directory(
305    dir_path: &Path,
306    output_dir: Option<&Path>,
307    key: Option<&str>,
308    clean: bool,
309) -> Result<(), CliError> {
310    let mut xlog_files = Vec::new();
311    for entry in walkdir::WalkDir::new(dir_path)
312        .into_iter()
313        .filter_map(|e| e.ok())
314    {
315        if entry.file_type().is_file()
316            && entry
317                .path()
318                .extension()
319                .is_some_and(|ext| ext.eq_ignore_ascii_case("xlog"))
320        {
321            xlog_files.push(entry.into_path());
322        }
323    }
324    xlog_files.sort();
325
326    if xlog_files.is_empty() {
327        println!("No .xlog files found in directory: {}", dir_path.display());
328        return Ok(());
329    }
330
331    println!("\nšŸ“‚ Processing directory: {}", dir_path.display());
332    println!("Found {} .xlog file(s) to decode:\n", xlog_files.len());
333
334    let mut success_count = 0;
335    for xlog_file in &xlog_files {
336        let rel_path = xlog_file.strip_prefix(dir_path).unwrap_or(xlog_file);
337        let log_file = match output_dir {
338            Some(out_dir) if out_dir != dir_path => {
339                let target = out_dir.join(rel_path).with_extension("log");
340                if let Some(p) = target.parent() {
341                    let _ = fs::create_dir_all(p);
342                }
343                target
344            }
345            _ => xlog_file.with_extension("log"),
346        };
347
348        print!("• {}: ", rel_path.display());
349        match decode_single_xlog(xlog_file, &log_file, key) {
350            Ok(bytes) => {
351                println!(
352                    "[OK] -> {} ({} bytes)",
353                    log_file.file_name().unwrap_or_default().to_string_lossy(),
354                    bytes
355                );
356                success_count += 1;
357                if clean {
358                    let _ = fs::remove_file(xlog_file);
359                }
360            }
361            Err(e) => {
362                println!("[FAIL] {}", e);
363            }
364        }
365    }
366
367    println!(
368        "\n✨ Completed: {}/{} logs decoded successfully.\n",
369        success_count,
370        xlog_files.len()
371    );
372    Ok(())
373}
374
375fn process_single_file(
376    input: &Path,
377    output: Option<&Path>,
378    key: Option<&str>,
379    clean: bool,
380) -> Result<(), CliError> {
381    let output_path = match output {
382        Some(out) => {
383            if out.is_dir() {
384                out.join(input.file_name().unwrap_or_default())
385                    .with_extension("log")
386            } else {
387                out.to_path_buf()
388            }
389        }
390        None => input.with_extension("log"),
391    };
392
393    print!("• {}: ", input.display());
394    let bytes = decode_single_xlog(input, &output_path, key)?;
395    println!("[OK] -> {} ({} bytes)", output_path.display(), bytes);
396    if clean {
397        let _ = fs::remove_file(input);
398    }
399    Ok(())
400}
401
402fn decode_single_xlog(
403    input_file: &Path,
404    output_file: &Path,
405    key: Option<&str>,
406) -> Result<usize, CliError> {
407    if let Some(p) = output_file.parent() {
408        if !p.exists() {
409            let _ = fs::create_dir_all(p);
410        }
411    }
412
413    let metadata = fs::metadata(input_file).map_err(|e| CliError::Io {
414        path: input_file.display().to_string(),
415        source: e,
416    })?;
417
418    if metadata.len() == 0 {
419        fs::write(output_file, b"").map_err(|e| CliError::Io {
420            path: output_file.display().to_string(),
421            source: e,
422        })?;
423        return Ok(0);
424    }
425
426    let bytes = fs::read(input_file).map_err(|e| CliError::Io {
427        path: input_file.display().to_string(),
428        source: e,
429    })?;
430
431    let mut opts = DecodeOptions::new();
432    if let Some(k) = key {
433        opts = opts.private_key_hex(k).map_err(CliError::Decode)?;
434    }
435
436    let mut decoded = Vec::with_capacity(bytes.len());
437    let summary = decode_xlog(&bytes, &mut decoded, &opts).map_err(CliError::Decode)?;
438    fs::write(output_file, &decoded).map_err(|e| CliError::Io {
439        path: output_file.display().to_string(),
440        source: e,
441    })?;
442
443    Ok(summary.output_bytes)
444}
445
446fn print_key_pair() {
447    let secret = SecretKey::random(&mut OsRng);
448    let private_key = secret.to_bytes();
449    let point = secret.public_key().to_encoded_point(false);
450    let bytes = point.as_bytes();
451
452    println!("============================================================");
453    println!("šŸ”‘ secp256k1 Key Pair for Mars Xlog Encryption");
454    println!("============================================================");
455    println!("Private Key (64 hex chars - KEEP SECRET FOR DECODER):");
456    println!("{}", hex::encode(private_key));
457    println!("\nPublic Key (128 hex chars - CONFIGURE IN ANDROID APP):");
458    println!("{}", hex::encode(&bytes[1..]));
459    println!("============================================================");
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use mars_xlog_core::compress::{StreamCompressor, ZlibStreamCompressor};
466    use mars_xlog_core::protocol::{select_magic, AppendMode, CompressionKind, LogHeader};
467    use std::io::Write;
468    use tempfile::tempdir;
469
470    fn make_test_xlog_bytes(content: &[u8]) -> Vec<u8> {
471        let mut zlib = ZlibStreamCompressor::new(6);
472        let mut zlib_payload = Vec::new();
473        zlib.compress_chunk(content, &mut zlib_payload).unwrap();
474        zlib.flush(&mut zlib_payload).unwrap();
475
476        let header = LogHeader {
477            magic: select_magic(CompressionKind::Zlib, AppendMode::Async, false),
478            seq: 1,
479            begin_hour: 1,
480            end_hour: 1,
481            len: zlib_payload.len() as u32,
482            client_pubkey: [0; 64],
483        };
484
485        let mut bytes = Vec::new();
486        bytes.extend_from_slice(&header.encode());
487        bytes.extend_from_slice(&zlib_payload);
488        bytes.push(0);
489        bytes
490    }
491
492    #[test]
493    fn test_single_file_decode() {
494        let dir = tempdir().unwrap();
495        let xlog_path = dir.path().join("test.xlog");
496        let log_path = dir.path().join("test.log");
497
498        let log_text = b"Hello Mars Xlog CLI test line\n";
499        fs::write(&xlog_path, make_test_xlog_bytes(log_text)).unwrap();
500
501        process_single_file(&xlog_path, Some(&log_path), None, false).unwrap();
502
503        let decoded = fs::read(&log_path).unwrap();
504        assert_eq!(decoded, log_text);
505    }
506
507    #[test]
508    fn test_directory_decode() {
509        let dir = tempdir().unwrap();
510        let sub = dir.path().join("sub");
511        fs::create_dir_all(&sub).unwrap();
512
513        let xlog1 = dir.path().join("root.xlog");
514        let xlog2 = sub.join("child.xlog");
515
516        fs::write(&xlog1, make_test_xlog_bytes(b"root log\n")).unwrap();
517        fs::write(&xlog2, make_test_xlog_bytes(b"child log\n")).unwrap();
518
519        process_directory(dir.path(), None, None, false).unwrap();
520
521        assert_eq!(
522            fs::read(dir.path().join("root.log")).unwrap(),
523            b"root log\n"
524        );
525        assert_eq!(fs::read(sub.join("child.log")).unwrap(), b"child log\n");
526    }
527
528    #[test]
529    fn test_zip_decode() {
530        let dir = tempdir().unwrap();
531        let zip_path = dir.path().join("feedback.zip");
532
533        // Create zip with export_summary.txt and a .xlog file
534        let file = fs::File::create(&zip_path).unwrap();
535        let mut zip_writer = zip::ZipWriter::new(file);
536
537        let options = zip::write::SimpleFileOptions::default()
538            .compression_method(zip::CompressionMethod::Stored);
539
540        zip_writer
541            .start_file("export_summary.txt", options)
542            .unwrap();
543        zip_writer
544            .write_all(b"User ID: 3920\nApp Version: 3.31.0\n")
545            .unwrap();
546
547        zip_writer.start_file("main.xlog", options).unwrap();
548        zip_writer
549            .write_all(&make_test_xlog_bytes(b"main process crash log\n"))
550            .unwrap();
551
552        zip_writer.finish().unwrap();
553
554        // Process zip
555        let out_dir = dir.path().join("feedback_decoded");
556        process_zip(&zip_path, Some(&out_dir), None, false).unwrap();
557
558        assert!(out_dir.join("export_summary.txt").is_file());
559        assert_eq!(
560            fs::read(out_dir.join("main.log")).unwrap(),
561            b"main process crash log\n"
562        );
563    }
564}