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 #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG", global = true)]
23 flag_input: Option<PathBuf>,
24
25 #[arg(
27 short = 'o',
28 long = "output",
29 value_name = "OUTPUT_FLAG",
30 global = true
31 )]
32 flag_output: Option<PathBuf>,
33
34 #[arg(value_name = "INPUT", global = true)]
36 input: Option<PathBuf>,
37
38 #[arg(value_name = "OUTPUT", global = true)]
40 output: Option<PathBuf>,
41
42 #[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 #[arg(long, global = true)]
55 clean: bool,
56}
57
58#[derive(Debug, Subcommand)]
59enum Command {
60 Decode(DecodeArgs),
62 GenKey,
64}
65
66#[derive(Debug, Args)]
67struct DecodeArgs {
68 #[arg(short = 'i', long = "input", value_name = "INPUT_FLAG")]
70 input: Option<PathBuf>,
71
72 #[arg(short = 'o', long = "output", value_name = "OUTPUT_FLAG")]
74 output: Option<PathBuf>,
75
76 #[arg(value_name = "INPUT")]
78 pos_input: Option<PathBuf>,
79
80 #[arg(value_name = "OUTPUT")]
82 pos_output: Option<PathBuf>,
83
84 #[arg(short = 'k', short_alias = 'p', long, alias = "private-key")]
86 key: Option<String>,
87
88 #[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 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 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 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}