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