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 #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG", global = true)]
25 flag_input: Option<PathBuf>,
26
27 #[arg(
29 short = 'o',
30 long = "output",
31 value_name = "OUTPUT_FLAG",
32 global = true
33 )]
34 flag_output: Option<PathBuf>,
35
36 #[arg(value_name = "INPUT", global = true)]
38 input: Option<PathBuf>,
39
40 #[arg(value_name = "OUTPUT", global = true)]
42 output: Option<PathBuf>,
43
44 #[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 #[arg(long, global = true)]
57 clean: bool,
58}
59
60#[derive(Debug, Subcommand)]
61enum Command {
62 Decode(DecodeArgs),
64 GenKey,
66}
67
68#[derive(Debug, Args)]
69struct DecodeArgs {
70 #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG")]
72 input: Option<PathBuf>,
73
74 #[arg(short = 'o', long = "output", value_name = "OUTPUT_FLAG")]
76 output: Option<PathBuf>,
77
78 #[arg(value_name = "INPUT")]
80 pos_input: Option<PathBuf>,
81
82 #[arg(value_name = "OUTPUT")]
84 pos_output: Option<PathBuf>,
85
86 #[arg(short = 'k', short_alias = 'p', long, alias = "private-key")]
88 key: Option<String>,
89
90 #[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 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 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 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}