From 5f70f285d030df15d3425b9734b62400281eb640 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 10:54:11 -0600 Subject: [PATCH 01/13] feat: add symbolication library module Add stack trace symbolication support for 7 platforms: - Android: ProGuard/R8 mapping.txt parsing - JavaScript/Electron: source map support - Flutter/Dart: flutter symbolize integration - Rust: backtrace parsing - Go: goroutine stack parsing - Python: traceback parsing - React Native: Hermes bytecode + JS source maps Includes MappingStore for organizing mapping files by platform/app_id/version directory structure. Closes #10 Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/Cargo.toml | 5 + rust/src/lib.rs | 5 + rust/src/symbolication/android.rs | 326 +++++++++++++++++++++++++ rust/src/symbolication/flutter.rs | 182 ++++++++++++++ rust/src/symbolication/go.rs | 169 +++++++++++++ rust/src/symbolication/javascript.rs | 155 ++++++++++++ rust/src/symbolication/mod.rs | 283 +++++++++++++++++++++ rust/src/symbolication/python.rs | 189 ++++++++++++++ rust/src/symbolication/react_native.rs | 204 ++++++++++++++++ rust/src/symbolication/rust_sym.rs | 134 ++++++++++ rust/src/symbolication/store.rs | 308 +++++++++++++++++++++++ 11 files changed, 1960 insertions(+) create mode 100644 rust/src/symbolication/android.rs create mode 100644 rust/src/symbolication/flutter.rs create mode 100644 rust/src/symbolication/go.rs create mode 100644 rust/src/symbolication/javascript.rs create mode 100644 rust/src/symbolication/mod.rs create mode 100644 rust/src/symbolication/python.rs create mode 100644 rust/src/symbolication/react_native.rs create mode 100644 rust/src/symbolication/rust_sym.rs create mode 100644 rust/src/symbolication/store.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e9dee4f..63a6921 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -41,4 +41,9 @@ tower-http = { version = "0.6", features = ["cors", "fs"] } rust-embed = { version = "8.5", features = ["axum"] } mime_guess = "2.0" +# Symbolication +regex = "1.10" +sourcemap = "9.0" + [dev-dependencies] +tempfile = "3.14" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index faa5758..c5c0e84 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -26,11 +26,16 @@ pub mod compression; pub mod event; pub mod storage; +pub mod symbolication; pub mod web; pub use compression::{compress_payload, decompress_payload, maybe_compress_payload, DEFAULT_THRESHOLD}; pub use event::UnsignedNostrEvent; pub use storage::{CrashReport, CrashGroup, CrashStorage, parse_crash_content}; +pub use symbolication::{ + MappingStore, Platform, Symbolicator, SymbolicatedFrame, SymbolicatedStack, + SymbolicationContext, SymbolicationError, +}; pub use web::{create_router, AppState}; /// Configuration for the crash report handler. diff --git a/rust/src/symbolication/android.rs b/rust/src/symbolication/android.rs new file mode 100644 index 0000000..ba5c901 --- /dev/null +++ b/rust/src/symbolication/android.rs @@ -0,0 +1,326 @@ +//! Android symbolication using ProGuard/R8 mapping files. +//! +//! Parses ProGuard mapping.txt files and uses them to deobfuscate +//! Android stack traces. + +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; + +use regex::Regex; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// Parsed ProGuard mapping entry for a class. +#[derive(Debug, Clone)] +struct ClassMapping { + /// Original class name + original: String, + /// Obfuscated class name + obfuscated: String, + /// Method mappings (obfuscated -> original) + methods: HashMap, + /// Field mappings (obfuscated -> original) + fields: HashMap, +} + +/// Parsed ProGuard mapping entry for a method. +#[derive(Debug, Clone)] +struct MethodMapping { + /// Original method name + original: String, + /// Original return type + return_type: String, + /// Original parameter types + parameters: Vec, + /// Line number mapping (obfuscated -> original) + line_mapping: Vec<(u32, u32, u32, u32)>, // (start_obf, end_obf, start_orig, end_orig) +} + +/// Parsed ProGuard mapping file. +#[derive(Debug)] +struct ProguardMapping { + /// Class mappings (obfuscated name -> mapping) + classes: HashMap, +} + +impl ProguardMapping { + /// Parse a ProGuard mapping file. + fn parse(reader: R) -> Result { + let mut classes = HashMap::new(); + let mut current_class: Option = None; + + // Regex patterns + let class_re = Regex::new(r"^(\S+)\s+->\s+(\S+):$").unwrap(); + let method_re = Regex::new( + r"^\s+(\d+):(\d+):(\S+)\s+(\S+)\((.*)\)\s+->\s+(\S+)$" + ).unwrap(); + let method_no_line_re = Regex::new( + r"^\s+(\S+)\s+([^\s(]+)\(([^)]*)\)\s+->\s+(\S+)$" + ).unwrap(); + let field_re = Regex::new(r"^\s+(\S+)\s+(\S+)\s+->\s+(\S+)$").unwrap(); + + for line in reader.lines() { + let line = line.map_err(|e| SymbolicationError::ParseError(e.to_string()))?; + + // Skip comments and empty lines + if line.trim().is_empty() || line.trim().starts_with('#') { + continue; + } + + // Class mapping + if let Some(caps) = class_re.captures(&line) { + // Save previous class + if let Some(class) = current_class.take() { + classes.insert(class.obfuscated.clone(), class); + } + + current_class = Some(ClassMapping { + original: caps[1].to_string(), + obfuscated: caps[2].to_string(), + methods: HashMap::new(), + fields: HashMap::new(), + }); + continue; + } + + // Method or field mapping (only if we have a current class) + if let Some(ref mut class) = current_class { + // Method with line numbers + if let Some(caps) = method_re.captures(&line) { + let start_line: u32 = caps[1].parse().unwrap_or(0); + let end_line: u32 = caps[2].parse().unwrap_or(0); + let return_type = caps[3].to_string(); + let method_name = caps[4].to_string(); + let params = caps[5].to_string(); + let obfuscated_name = caps[6].to_string(); + + let parameters: Vec = if params.is_empty() { + vec![] + } else { + params.split(',').map(|s| s.trim().to_string()).collect() + }; + + let mapping = class.methods.entry(obfuscated_name).or_insert_with(|| { + MethodMapping { + original: method_name.clone(), + return_type: return_type.clone(), + parameters: parameters.clone(), + line_mapping: vec![], + } + }); + + mapping.line_mapping.push((start_line, end_line, start_line, end_line)); + continue; + } + + // Method without line numbers + if let Some(caps) = method_no_line_re.captures(&line) { + let return_type = caps[1].to_string(); + let method_name = caps[2].to_string(); + let params = caps[3].to_string(); + let obfuscated_name = caps[4].to_string(); + + let parameters: Vec = if params.is_empty() { + vec![] + } else { + params.split(',').map(|s| s.trim().to_string()).collect() + }; + + class.methods.entry(obfuscated_name).or_insert_with(|| { + MethodMapping { + original: method_name, + return_type, + parameters, + line_mapping: vec![], + } + }); + continue; + } + + // Field mapping + if let Some(caps) = field_re.captures(&line) { + let _field_type = &caps[1]; + let original_name = caps[2].to_string(); + let obfuscated_name = caps[3].to_string(); + + class.fields.insert(obfuscated_name, original_name); + } + } + } + + // Save last class + if let Some(class) = current_class { + classes.insert(class.obfuscated.clone(), class); + } + + Ok(Self { classes }) + } + + /// Deobfuscate a class name. + fn deobfuscate_class(&self, obfuscated: &str) -> Option<&str> { + self.classes.get(obfuscated).map(|c| c.original.as_str()) + } + + /// Deobfuscate a method name. + fn deobfuscate_method(&self, class: &str, method: &str) -> Option<&str> { + self.classes + .get(class) + .and_then(|c| c.methods.get(method)) + .map(|m| m.original.as_str()) + } + + /// Deobfuscate a full stack frame. + fn deobfuscate_frame(&self, class: &str, method: &str, line: Option) -> Option<(String, String, Option)> { + let class_mapping = self.classes.get(class)?; + let original_class = &class_mapping.original; + + let method_mapping = class_mapping.methods.get(method); + let original_method = method_mapping + .map(|m| m.original.as_str()) + .unwrap_or(method); + + // Try to map line number + let original_line = line.and_then(|l| { + method_mapping.and_then(|m| { + for (start_obf, end_obf, start_orig, _end_orig) in &m.line_mapping { + if l >= *start_obf && l <= *end_obf { + return Some(start_orig + (l - start_obf)); + } + } + Some(l) // Return original if no mapping found + }) + }); + + Some((original_class.clone(), original_method.to_string(), original_line)) + } +} + +/// Android stack trace symbolicator. +pub struct AndroidSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> AndroidSymbolicator<'a> { + /// Create a new Android symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate an Android stack trace. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Load mapping file + let mapping_info = self + .store + .get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ) + .ok_or_else(|| SymbolicationError::MappingNotFound { + platform: "android".to_string(), + app_id: context.app_id.clone().unwrap_or_default(), + version: context.version.clone().unwrap_or_default(), + })?; + + let file = fs::File::open(&mapping_info.path)?; + let reader = BufReader::new(file); + let mapping = ProguardMapping::parse(reader)?; + + // Parse and symbolicate each frame + let mut frames = Vec::new(); + let mut symbolicated_count = 0; + + // Regex for Android stack frames + // Examples: + // at com.example.a.b(Unknown Source:12) + // at a.b.c.d(SourceFile:34) + let frame_re = Regex::new( + r"^\s*at\s+([a-zA-Z0-9_.]+)\.([a-zA-Z0-9_<>]+)\(([^:)]+)?:?(\d+)?\)" + ).unwrap(); + + for line in stack_trace.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some(caps) = frame_re.captures(line) { + let class = &caps[1]; + let method = &caps[2]; + let _source = caps.get(3).map(|m| m.as_str()); + let line_num: Option = caps.get(4).and_then(|m| m.as_str().parse().ok()); + + if let Some((orig_class, orig_method, orig_line)) = + mapping.deobfuscate_frame(class, method, line_num) + { + // Extract source file from original class name + let source_file = orig_class + .rsplit('.') + .next() + .map(|s| format!("{}.java", s)); + + frames.push(SymbolicatedFrame::symbolicated( + line.to_string(), + format!("{}.{}", orig_class, orig_method), + source_file, + orig_line, + None, + )); + symbolicated_count += 1; + } else { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } else { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count: stack_trace.lines().filter(|l| !l.trim().is_empty()).count(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_parse_proguard_mapping() { + let mapping_content = r#" +# This is a comment +com.example.MyClass -> a.a: + void myMethod() -> a + int myField -> b +com.example.OtherClass -> a.b: + 1:10:void doSomething(java.lang.String) -> c +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + assert_eq!( + mapping.deobfuscate_class("a.a"), + Some("com.example.MyClass") + ); + assert_eq!( + mapping.deobfuscate_class("a.b"), + Some("com.example.OtherClass") + ); + assert_eq!( + mapping.deobfuscate_method("a.a", "a"), + Some("myMethod") + ); + } +} diff --git a/rust/src/symbolication/flutter.rs b/rust/src/symbolication/flutter.rs new file mode 100644 index 0000000..d86c591 --- /dev/null +++ b/rust/src/symbolication/flutter.rs @@ -0,0 +1,182 @@ +//! Flutter/Dart symbolication. +//! +//! Uses Flutter symbol files or the external `flutter symbolize` command +//! to symbolicate Dart stack traces from release builds. + +use std::fs; +use std::process::Command; + +use regex::Regex; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// Flutter stack trace symbolicator. +pub struct FlutterSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> FlutterSymbolicator<'a> { + /// Create a new Flutter symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a Flutter stack trace. + /// + /// Attempts to use the `flutter symbolize` command if available, + /// otherwise falls back to basic symbol file parsing. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Try to find symbols file + let mapping_info = self.store.get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ); + + // Try flutter symbolize command first + if let Some(info) = &mapping_info { + if let Ok(result) = self.symbolicate_with_flutter_command(stack_trace, &info.path) { + return Ok(result); + } + } + + // Fall back to basic parsing + self.symbolicate_basic(stack_trace, mapping_info.map(|i| i.path.as_path())) + } + + /// Use `flutter symbolize` command for symbolication. + fn symbolicate_with_flutter_command( + &self, + stack_trace: &str, + symbols_path: &std::path::Path, + ) -> Result { + // Write stack trace to temp file + let temp_dir = std::env::temp_dir(); + let input_path = temp_dir.join("bugstr_flutter_input.txt"); + fs::write(&input_path, stack_trace)?; + + // Run flutter symbolize + let output = Command::new("flutter") + .args([ + "symbolize", + "-i", + input_path.to_str().unwrap(), + "-d", + symbols_path.to_str().unwrap(), + ]) + .output() + .map_err(|e| SymbolicationError::ToolError(format!("flutter symbolize failed: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SymbolicationError::ToolError(format!( + "flutter symbolize failed: {}", + stderr + ))); + } + + let symbolicated = String::from_utf8_lossy(&output.stdout); + + // Parse the symbolicated output + let frames: Vec = symbolicated + .lines() + .map(|line| { + // Check if line was symbolicated (contains source location) + if line.contains("(") && line.contains(".dart:") { + SymbolicatedFrame { + raw: line.to_string(), + function: self.extract_function(line), + file: self.extract_file(line), + line: self.extract_line(line), + column: None, + symbolicated: true, + } + } else { + SymbolicatedFrame::raw(line.to_string()) + } + }) + .collect(); + + let symbolicated_count = frames.iter().filter(|f| f.symbolicated).count(); + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count: stack_trace.lines().filter(|l| !l.trim().is_empty()).count(), + }) + } + + /// Basic symbolication without flutter command. + fn symbolicate_basic( + &self, + stack_trace: &str, + _symbols_path: Option<&std::path::Path>, + ) -> Result { + // Regex for Dart stack frames + // Example: #0 MyClass.myMethod (package:myapp/src/my_class.dart:42:15) + let frame_re = Regex::new( + r"#(\d+)\s+(.+?)\s+\((.+?):(\d+)(?::(\d+))?\)" + ).unwrap(); + + let mut frames = Vec::new(); + + for line in stack_trace.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some(caps) = frame_re.captures(line) { + let function = caps.get(2).map(|m| m.as_str().to_string()); + let file = caps.get(3).map(|m| m.as_str().to_string()); + let line_num: Option = caps.get(4).and_then(|m| m.as_str().parse().ok()); + let col: Option = caps.get(5).and_then(|m| m.as_str().parse().ok()); + + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function, + file, + line: line_num, + column: col, + symbolicated: true, // Already readable in debug builds + }); + } else { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + let symbolicated_count = frames.iter().filter(|f| f.symbolicated).count(); + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count: stack_trace.lines().filter(|l| !l.trim().is_empty()).count(), + }) + } + + fn extract_function(&self, line: &str) -> Option { + // Extract function name from symbolicated line + let re = Regex::new(r"#\d+\s+(.+?)\s+\(").ok()?; + re.captures(line)?.get(1).map(|m| m.as_str().to_string()) + } + + fn extract_file(&self, line: &str) -> Option { + // Extract file path from symbolicated line + let re = Regex::new(r"\((.+\.dart):\d+").ok()?; + re.captures(line)?.get(1).map(|m| m.as_str().to_string()) + } + + fn extract_line(&self, line: &str) -> Option { + // Extract line number from symbolicated line + let re = Regex::new(r"\.dart:(\d+)").ok()?; + re.captures(line)?.get(1)?.as_str().parse().ok() + } +} diff --git a/rust/src/symbolication/go.rs b/rust/src/symbolication/go.rs new file mode 100644 index 0000000..cafe37f --- /dev/null +++ b/rust/src/symbolication/go.rs @@ -0,0 +1,169 @@ +//! Go symbolication. +//! +//! Go binaries typically include symbol information by default. +//! For stripped binaries, this module attempts to use external symbol files. + +use regex::Regex; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// Go stack trace symbolicator. +pub struct GoSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> GoSymbolicator<'a> { + /// Create a new Go symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a Go stack trace. + /// + /// Go stack traces typically already include source locations. + /// This method parses and formats them for display. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Try to find symbol file (for stripped binaries) + let _mapping_info = self.store.get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ); + + self.parse_go_stack(stack_trace) + } + + /// Parse a Go stack trace. + fn parse_go_stack( + &self, + stack_trace: &str, + ) -> Result { + // Go stack trace format: + // goroutine 1 [running]: + // main.myFunction(0x123, 0x456) + // /path/to/file.go:42 +0x1a + // main.main() + // /path/to/main.go:10 +0x2b + + let func_re = Regex::new(r"^([a-zA-Z0-9_./*]+)\(([^)]*)\)$").unwrap(); + let location_re = Regex::new(r"^\s+(.+\.go):(\d+)\s+\+0x[0-9a-f]+$").unwrap(); + let goroutine_re = Regex::new(r"^goroutine\s+\d+\s+\[.+\]:$").unwrap(); + + let mut frames = Vec::new(); + let mut current_function: Option = None; + let mut current_args: Option = None; + let mut current_raw = String::new(); + + for line in stack_trace.lines() { + let line_trimmed = line.trim(); + + // Skip goroutine header + if goroutine_re.is_match(line_trimmed) { + frames.push(SymbolicatedFrame::raw(line.to_string())); + continue; + } + + // Function line + if let Some(caps) = func_re.captures(line_trimmed) { + // Save previous frame if exists + if let Some(func) = current_function.take() { + frames.push(SymbolicatedFrame { + raw: current_raw.clone(), + function: Some(func), + file: None, + line: None, + column: None, + symbolicated: true, + }); + } + + current_function = Some(caps[1].to_string()); + current_args = Some(caps[2].to_string()); + current_raw = line.to_string(); + continue; + } + + // Location line + if let Some(caps) = location_re.captures(line) { + if let Some(func) = current_function.take() { + let file = caps.get(1).map(|m| m.as_str().to_string()); + let line_num: Option = caps.get(2).and_then(|m| m.as_str().parse().ok()); + + let display_func = if let Some(args) = current_args.take() { + if args.is_empty() { + func + } else { + format!("{}(...)", func) + } + } else { + func + }; + + frames.push(SymbolicatedFrame { + raw: format!("{}\n{}", current_raw, line), + function: Some(display_func), + file, + line: line_num, + column: None, + symbolicated: true, + }); + current_raw.clear(); + } + continue; + } + + // Other lines + if !line_trimmed.is_empty() { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + // Handle last frame without location + if let Some(func) = current_function { + frames.push(SymbolicatedFrame { + raw: current_raw, + function: Some(func), + file: None, + line: None, + column: None, + symbolicated: true, + }); + } + + let symbolicated_count = frames.iter().filter(|f| f.symbolicated).count(); + let total_count = frames.len(); + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_go_stack() { + let stack = r#"goroutine 1 [running]: +main.myFunction(0x123, 0x456) + /home/user/project/main.go:42 +0x1a +main.main() + /home/user/project/main.go:10 +0x2b"#; + + let store = MappingStore::new("/tmp"); + let sym = GoSymbolicator::new(&store); + let result = sym.parse_go_stack(stack).unwrap(); + + assert!(result.symbolicated_count >= 2); + } +} diff --git a/rust/src/symbolication/javascript.rs b/rust/src/symbolication/javascript.rs new file mode 100644 index 0000000..721d4cb --- /dev/null +++ b/rust/src/symbolication/javascript.rs @@ -0,0 +1,155 @@ +//! JavaScript/Electron symbolication using source maps. +//! +//! Parses source map files (.map) and uses them to map minified +//! JavaScript stack traces back to original source locations. + +use std::fs; + +use regex::Regex; +use sourcemap::SourceMap; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// JavaScript stack trace symbolicator. +pub struct JavaScriptSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> JavaScriptSymbolicator<'a> { + /// Create a new JavaScript symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a JavaScript stack trace. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Load source map file + let mapping_info = self + .store + .get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ) + .ok_or_else(|| SymbolicationError::MappingNotFound { + platform: "javascript".to_string(), + app_id: context.app_id.clone().unwrap_or_default(), + version: context.version.clone().unwrap_or_default(), + })?; + + let content = fs::read_to_string(&mapping_info.path)?; + let sourcemap = SourceMap::from_reader(content.as_bytes()) + .map_err(|e| SymbolicationError::ParseError(e.to_string()))?; + + // Parse and symbolicate each frame + let mut frames = Vec::new(); + let mut symbolicated_count = 0; + + // Regex patterns for JavaScript stack frames + // Chrome/V8 style: " at functionName (file.js:line:col)" + // Firefox style: "functionName@file.js:line:col" + // Node.js style: " at functionName (file.js:line:col)" + let chrome_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?([^:]+):(\d+):(\d+)\)?" + ).unwrap(); + let firefox_re = Regex::new( + r"^(.+?)@([^:]+):(\d+):(\d+)" + ).unwrap(); + + for line in stack_trace.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parsed = chrome_re.captures(line).or_else(|| firefox_re.captures(line)); + + if let Some(caps) = parsed { + let _function = caps.get(1).map(|m| m.as_str()); + let _file = caps.get(2).map(|m| m.as_str()); + let line_num: u32 = caps + .get(3) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + let col_num: u32 = caps + .get(4) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + + // Source maps use 0-based line/column numbers + let line_0 = if line_num > 0 { line_num - 1 } else { 0 }; + let col_0 = if col_num > 0 { col_num - 1 } else { 0 }; + + if let Some(token) = sourcemap.lookup_token(line_0, col_0) { + let orig_function = token.get_name().map(|s| s.to_string()); + let orig_file = token.get_source().map(|s| s.to_string()); + let orig_line = token.get_src_line(); + let orig_col = token.get_src_col(); + + let function_name = orig_function + .or_else(|| _function.map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()); + + frames.push(SymbolicatedFrame::symbolicated( + line.to_string(), + function_name, + orig_file, + Some(orig_line + 1), // Convert back to 1-based + Some(orig_col + 1), + )); + symbolicated_count += 1; + } else { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } else { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count: stack_trace.lines().filter(|l| !l.trim().is_empty()).count(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_chrome_stack_frame() { + let chrome_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?([^:]+):(\d+):(\d+)\)?" + ).unwrap(); + + let frame = " at myFunction (bundle.js:1:2345)"; + let caps = chrome_re.captures(frame).unwrap(); + + assert_eq!(caps.get(1).map(|m| m.as_str()), Some("myFunction")); + assert_eq!(caps.get(2).map(|m| m.as_str()), Some("bundle.js")); + assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); + assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); + } + + #[test] + fn test_parse_firefox_stack_frame() { + let firefox_re = Regex::new(r"^(.+?)@([^:]+):(\d+):(\d+)").unwrap(); + + let frame = "myFunction@bundle.js:1:2345"; + let caps = firefox_re.captures(frame).unwrap(); + + assert_eq!(caps.get(1).map(|m| m.as_str()), Some("myFunction")); + assert_eq!(caps.get(2).map(|m| m.as_str()), Some("bundle.js")); + assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); + assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); + } +} diff --git a/rust/src/symbolication/mod.rs b/rust/src/symbolication/mod.rs new file mode 100644 index 0000000..126b742 --- /dev/null +++ b/rust/src/symbolication/mod.rs @@ -0,0 +1,283 @@ +//! Symbolication support for crash reports. +//! +//! This module provides functionality to symbolicate stack traces from various platforms +//! using their respective mapping files (ProGuard, source maps, DWARF, etc.). +//! +//! # Supported Platforms +//! +//! - **Android**: ProGuard/R8 mapping.txt files +//! - **JavaScript/Electron**: Source map (.map) files +//! - **Flutter/Dart**: Flutter symbol files or external `flutter symbolize` +//! - **Rust**: DWARF debug info via addr2line +//! - **Go**: Go symbol tables (usually embedded) +//! - **Python**: Source file mapping for bundled apps +//! - **React Native**: Hermes bytecode maps + JS source maps +//! +//! # Example +//! +//! ```rust,ignore +//! use bugstr::symbolication::{Symbolicator, MappingStore, Platform, SymbolicationContext}; +//! +//! let store = MappingStore::new("/path/to/mappings"); +//! let symbolicator = Symbolicator::new(store); +//! +//! let context = SymbolicationContext { +//! platform: Platform::Android, +//! app_id: Some("com.myapp".to_string()), +//! version: Some("1.0.0".to_string()), +//! build_id: None, +//! }; +//! +//! let stack_trace = "..."; +//! let result = symbolicator.symbolicate(stack_trace, &context); +//! ``` + +mod android; +mod javascript; +mod flutter; +mod rust_sym; +mod go; +mod python; +mod react_native; +mod store; + +pub use android::AndroidSymbolicator; +pub use javascript::JavaScriptSymbolicator; +pub use flutter::FlutterSymbolicator; +pub use rust_sym::RustSymbolicator; +pub use go::GoSymbolicator; +pub use python::PythonSymbolicator; +pub use react_native::ReactNativeSymbolicator; +pub use store::MappingStore; + +use thiserror::Error; + +/// Errors that can occur during symbolication. +#[derive(Error, Debug)] +pub enum SymbolicationError { + #[error("No mapping file found for {platform} {app_id} {version}")] + MappingNotFound { + platform: String, + app_id: String, + version: String, + }, + + #[error("Failed to parse mapping file: {0}")] + ParseError(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Unsupported platform: {0}")] + UnsupportedPlatform(String), + + #[error("External tool error: {0}")] + ToolError(String), +} + +/// Platform identifier for crash reports. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Platform { + Android, + Electron, + Flutter, + Rust, + Go, + Python, + ReactNative, + Unknown(String), +} + +impl Platform { + /// Parse platform from string. + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "android" => Platform::Android, + "electron" | "javascript" | "js" => Platform::Electron, + "flutter" | "dart" => Platform::Flutter, + "rust" => Platform::Rust, + "go" | "golang" => Platform::Go, + "python" => Platform::Python, + "react-native" | "reactnative" | "rn" => Platform::ReactNative, + other => Platform::Unknown(other.to_string()), + } + } + + /// Get platform name as string. + pub fn as_str(&self) -> &str { + match self { + Platform::Android => "android", + Platform::Electron => "electron", + Platform::Flutter => "flutter", + Platform::Rust => "rust", + Platform::Go => "go", + Platform::Python => "python", + Platform::ReactNative => "react-native", + Platform::Unknown(s) => s, + } + } +} + +/// Information needed to symbolicate a stack trace. +#[derive(Debug, Clone)] +pub struct SymbolicationContext { + /// Platform the crash came from + pub platform: Platform, + /// Application identifier (package name, bundle id, etc.) + pub app_id: Option, + /// Application version + pub version: Option, + /// Build ID or commit hash + pub build_id: Option, +} + +/// A symbolicated stack frame. +#[derive(Debug, Clone)] +pub struct SymbolicatedFrame { + /// Original raw frame text + pub raw: String, + /// Symbolicated function/method name + pub function: Option, + /// Source file path + pub file: Option, + /// Line number + pub line: Option, + /// Column number + pub column: Option, + /// Whether this frame was successfully symbolicated + pub symbolicated: bool, +} + +impl SymbolicatedFrame { + /// Create a new frame that wasn't symbolicated. + pub fn raw(text: String) -> Self { + Self { + raw: text, + function: None, + file: None, + line: None, + column: None, + symbolicated: false, + } + } + + /// Create a symbolicated frame. + pub fn symbolicated( + raw: String, + function: String, + file: Option, + line: Option, + column: Option, + ) -> Self { + Self { + raw, + function: Some(function), + file, + line, + column, + symbolicated: true, + } + } + + /// Format the frame for display. + pub fn display(&self) -> String { + if self.symbolicated { + let location = match (&self.file, self.line) { + (Some(f), Some(l)) => format!(" ({}:{})", f, l), + (Some(f), None) => format!(" ({})", f), + _ => String::new(), + }; + format!( + "{}{}", + self.function.as_deref().unwrap_or(""), + location + ) + } else { + self.raw.clone() + } + } +} + +/// Result of symbolicating a stack trace. +#[derive(Debug)] +pub struct SymbolicatedStack { + /// Original raw stack trace + pub raw: String, + /// Symbolicated frames + pub frames: Vec, + /// Number of frames that were successfully symbolicated + pub symbolicated_count: usize, + /// Total number of frames + pub total_count: usize, +} + +impl SymbolicatedStack { + /// Format the symbolicated stack for display. + pub fn display(&self) -> String { + self.frames + .iter() + .map(|f| f.display()) + .collect::>() + .join("\n") + } + + /// Get symbolication percentage. + pub fn percentage(&self) -> f64 { + if self.total_count == 0 { + 0.0 + } else { + (self.symbolicated_count as f64 / self.total_count as f64) * 100.0 + } + } +} + +/// Main symbolicator that dispatches to platform-specific implementations. +pub struct Symbolicator { + store: MappingStore, +} + +impl Symbolicator { + /// Create a new symbolicator with the given mapping store. + pub fn new(store: MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a stack trace. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + match &context.platform { + Platform::Android => { + let sym = AndroidSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Electron => { + let sym = JavaScriptSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Flutter => { + let sym = FlutterSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Rust => { + let sym = RustSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Go => { + let sym = GoSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Python => { + let sym = PythonSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::ReactNative => { + let sym = ReactNativeSymbolicator::new(&self.store); + sym.symbolicate(stack_trace, context) + } + Platform::Unknown(p) => Err(SymbolicationError::UnsupportedPlatform(p.clone())), + } + } +} diff --git a/rust/src/symbolication/python.rs b/rust/src/symbolication/python.rs new file mode 100644 index 0000000..8344e4f --- /dev/null +++ b/rust/src/symbolication/python.rs @@ -0,0 +1,189 @@ +//! Python symbolication. +//! +//! Python stack traces typically include source information. +//! For bundled apps (PyInstaller, Nuitka), this module attempts to +//! map back to original source files. + +use regex::Regex; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// Python stack trace symbolicator. +pub struct PythonSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> PythonSymbolicator<'a> { + /// Create a new Python symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a Python stack trace. + /// + /// Python tracebacks already include source locations in most cases. + /// This method parses and formats them, and attempts to resolve + /// bundled app paths to original sources if a mapping is available. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Try to find mapping file (for bundled apps) + let _mapping_info = self.store.get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ); + + self.parse_python_traceback(stack_trace) + } + + /// Parse a Python traceback. + fn parse_python_traceback( + &self, + stack_trace: &str, + ) -> Result { + // Python traceback format: + // Traceback (most recent call last): + // File "/path/to/file.py", line 42, in my_function + // some_code_here() + // File "/path/to/other.py", line 10, in other_function + // other_code() + // ExceptionType: error message + + let file_re = Regex::new( + r#"^\s*File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)$"# + ).unwrap(); + let exception_re = Regex::new(r"^([A-Z][a-zA-Z0-9]*(?:Error|Exception|Warning)?):?\s*(.*)$").unwrap(); + + let mut frames = Vec::new(); + let mut in_frame = false; + let mut current_file: Option = None; + let mut current_line: Option = None; + let mut current_function: Option = None; + let mut current_raw = String::new(); + + for line in stack_trace.lines() { + // File line + if let Some(caps) = file_re.captures(line) { + // Save previous frame + if in_frame { + frames.push(SymbolicatedFrame { + raw: current_raw.clone(), + function: current_function.take(), + file: current_file.take(), + line: current_line.take(), + column: None, + symbolicated: true, + }); + } + + current_file = Some(caps[1].to_string()); + current_line = caps[2].parse().ok(); + current_function = Some(caps[3].to_string()); + current_raw = line.to_string(); + in_frame = true; + continue; + } + + // Code line (belongs to current frame) + if in_frame && line.starts_with(" ") && !line.trim().is_empty() { + current_raw.push('\n'); + current_raw.push_str(line); + continue; + } + + // Exception line + if let Some(caps) = exception_re.captures(line) { + // Save any pending frame + if in_frame { + frames.push(SymbolicatedFrame { + raw: current_raw.clone(), + function: current_function.take(), + file: current_file.take(), + line: current_line.take(), + column: None, + symbolicated: true, + }); + in_frame = false; + } + + let exception_type = caps[1].to_string(); + let message = caps.get(2).map(|m| m.as_str()).unwrap_or(""); + + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function: Some(format!("{}: {}", exception_type, message)), + file: None, + line: None, + column: None, + symbolicated: true, + }); + continue; + } + + // Header or other lines + if !line.trim().is_empty() { + if in_frame { + frames.push(SymbolicatedFrame { + raw: current_raw.clone(), + function: current_function.take(), + file: current_file.take(), + line: current_line.take(), + column: None, + symbolicated: true, + }); + in_frame = false; + current_raw.clear(); + } + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + // Handle last frame + if in_frame { + frames.push(SymbolicatedFrame { + raw: current_raw, + function: current_function, + file: current_file, + line: current_line, + column: None, + symbolicated: true, + }); + } + + let symbolicated_count = frames.iter().filter(|f| f.symbolicated).count(); + let total_count = frames.len(); + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_python_traceback() { + let traceback = r#"Traceback (most recent call last): + File "/home/user/app/main.py", line 42, in my_function + result = do_something() + File "/home/user/app/utils.py", line 10, in do_something + raise ValueError("test error") +ValueError: test error"#; + + let store = MappingStore::new("/tmp"); + let sym = PythonSymbolicator::new(&store); + let result = sym.parse_python_traceback(traceback).unwrap(); + + assert!(result.symbolicated_count >= 2); + } +} diff --git a/rust/src/symbolication/react_native.rs b/rust/src/symbolication/react_native.rs new file mode 100644 index 0000000..5695ab4 --- /dev/null +++ b/rust/src/symbolication/react_native.rs @@ -0,0 +1,204 @@ +//! React Native symbolication. +//! +//! Handles both Hermes bytecode symbolication and JavaScript source maps +//! for React Native applications. + +use std::fs; + +use regex::Regex; +use sourcemap::SourceMap; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// React Native stack trace symbolicator. +pub struct ReactNativeSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> ReactNativeSymbolicator<'a> { + /// Create a new React Native symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a React Native stack trace. + /// + /// React Native stacks can contain: + /// - JavaScript frames (bundled, need source maps) + /// - Native frames (Java/ObjC, may need ProGuard/dSYM) + /// - Hermes bytecode frames (need Hermes source maps) + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Try to find source map + let mapping_info = self.store.get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ); + + let sourcemap = if let Some(info) = mapping_info { + let content = fs::read_to_string(&info.path)?; + SourceMap::from_reader(content.as_bytes()).ok() + } else { + None + }; + + self.parse_react_native_stack(stack_trace, sourcemap.as_ref()) + } + + /// Parse a React Native stack trace. + fn parse_react_native_stack( + &self, + stack_trace: &str, + sourcemap: Option<&SourceMap>, + ) -> Result { + // React Native stack frame formats: + // JS: " at myFunction (index.bundle:1:2345)" + // Hermes: " at myFunction (address at index.android.bundle:1:2345)" + // Native Android: " at com.example.MyClass.method(MyClass.java:42)" + // Native iOS: "0 MyApp 0x00000001 myFunction + 123" + + let js_frame_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?([^:]+):(\d+):(\d+)\)?" + ).unwrap(); + let native_android_re = Regex::new( + r"^\s*at\s+([a-zA-Z0-9_.]+)\.([a-zA-Z0-9_<>]+)\(([^:]+):(\d+)\)" + ).unwrap(); + let native_ios_re = Regex::new( + r"^\d+\s+(\S+)\s+0x[0-9a-f]+\s+(.+)\s+\+\s+\d+" + ).unwrap(); + + let mut frames = Vec::new(); + let mut symbolicated_count = 0; + + for line in stack_trace.lines() { + let line_trimmed = line.trim(); + if line_trimmed.is_empty() { + continue; + } + + // Try JS/Hermes frame + if let Some(caps) = js_frame_re.captures(line_trimmed) { + let function = caps.get(1).map(|m| m.as_str()); + let _file = caps.get(2).map(|m| m.as_str()); + let line_num: u32 = caps + .get(3) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + let col_num: u32 = caps + .get(4) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(0); + + // Try to symbolicate with source map + if let Some(sm) = sourcemap { + let line_0 = if line_num > 0 { line_num - 1 } else { 0 }; + let col_0 = if col_num > 0 { col_num - 1 } else { 0 }; + + if let Some(token) = sm.lookup_token(line_0, col_0) { + let orig_function = token + .get_name() + .map(|s| s.to_string()) + .or_else(|| function.map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()); + let orig_file = token.get_source().map(|s| s.to_string()); + let orig_line = token.get_src_line(); + let orig_col = token.get_src_col(); + + frames.push(SymbolicatedFrame::symbolicated( + line.to_string(), + orig_function, + orig_file, + Some(orig_line + 1), + Some(orig_col + 1), + )); + symbolicated_count += 1; + continue; + } + } + + // No source map or token not found + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function: function.map(|s| s.to_string()), + file: None, + line: Some(line_num), + column: Some(col_num), + symbolicated: false, + }); + continue; + } + + // Try native Android frame + if let Some(caps) = native_android_re.captures(line_trimmed) { + let class = &caps[1]; + let method = &caps[2]; + let file = caps.get(3).map(|m| m.as_str().to_string()); + let line_num: Option = caps.get(4).and_then(|m| m.as_str().parse().ok()); + + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function: Some(format!("{}.{}", class, method)), + file, + line: line_num, + column: None, + symbolicated: true, // Native frames are usually not obfuscated + }); + symbolicated_count += 1; + continue; + } + + // Try native iOS frame + if let Some(caps) = native_ios_re.captures(line_trimmed) { + let _binary = &caps[1]; + let symbol = &caps[2]; + + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function: Some(symbol.to_string()), + file: None, + line: None, + column: None, + symbolicated: true, + }); + symbolicated_count += 1; + continue; + } + + // Other lines + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count: stack_trace.lines().filter(|l| !l.trim().is_empty()).count(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_js_frame() { + let js_frame_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?([^:]+):(\d+):(\d+)\)?" + ).unwrap(); + + let frame = " at myFunction (index.bundle:1:2345)"; + let caps = js_frame_re.captures(frame).unwrap(); + + assert_eq!(caps.get(1).map(|m| m.as_str()), Some("myFunction")); + assert_eq!(caps.get(2).map(|m| m.as_str()), Some("index.bundle")); + assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); + assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); + } +} diff --git a/rust/src/symbolication/rust_sym.rs b/rust/src/symbolication/rust_sym.rs new file mode 100644 index 0000000..a467003 --- /dev/null +++ b/rust/src/symbolication/rust_sym.rs @@ -0,0 +1,134 @@ +//! Rust symbolication using addr2line/DWARF debug info. +//! +//! Parses Rust stack traces and resolves addresses to source locations +//! using debug symbols. + +use regex::Regex; + +use super::{ + MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, +}; + +/// Rust stack trace symbolicator. +pub struct RustSymbolicator<'a> { + store: &'a MappingStore, +} + +impl<'a> RustSymbolicator<'a> { + /// Create a new Rust symbolicator. + pub fn new(store: &'a MappingStore) -> Self { + Self { store } + } + + /// Symbolicate a Rust stack trace. + /// + /// Rust stack traces from panics typically include source locations + /// in debug builds. For release builds with symbols stripped, this + /// attempts to use addr2line with debug symbols if available. + pub fn symbolicate( + &self, + stack_trace: &str, + context: &SymbolicationContext, + ) -> Result { + // Try to find debug symbols + let mapping_info = self.store.get_with_fallback( + &context.platform, + context.app_id.as_deref().unwrap_or("unknown"), + context.version.as_deref().unwrap_or("unknown"), + ); + + // Rust backtraces already include source info in debug builds + // We just need to parse and format them nicely + self.parse_rust_backtrace(stack_trace, mapping_info.map(|i| i.path.as_path())) + } + + /// Parse a Rust backtrace. + fn parse_rust_backtrace( + &self, + stack_trace: &str, + _symbols_path: Option<&std::path::Path>, + ) -> Result { + // Regex patterns for Rust stack frames + // Format 1: " 0: std::panicking::begin_panic" + // Format 2: " 0: 0x7f1234567890 - std::panicking::begin_panic" + // Format 3 (with location): " at /path/to/file.rs:42:5" + let frame_num_re = Regex::new(r"^\s*(\d+):\s+(?:0x[0-9a-f]+\s+-\s+)?(.+)$").unwrap(); + let location_re = Regex::new(r"^\s+at\s+(.+):(\d+)(?::(\d+))?$").unwrap(); + + let mut frames = Vec::new(); + let mut current_function: Option = None; + let mut current_raw: String = String::new(); + + for line in stack_trace.lines() { + // Check for frame number line + if let Some(caps) = frame_num_re.captures(line) { + // Save previous frame if exists + if let Some(func) = current_function.take() { + frames.push(SymbolicatedFrame { + raw: current_raw.clone(), + function: Some(func), + file: None, + line: None, + column: None, + symbolicated: true, + }); + } + + current_function = Some(caps[2].trim().to_string()); + current_raw = line.to_string(); + continue; + } + + // Check for location line (belongs to current frame) + if let Some(caps) = location_re.captures(line) { + if let Some(func) = current_function.take() { + let file = caps.get(1).map(|m| m.as_str().to_string()); + let line_num: Option = caps.get(2).and_then(|m| m.as_str().parse().ok()); + let col: Option = caps.get(3).and_then(|m| m.as_str().parse().ok()); + + frames.push(SymbolicatedFrame { + raw: format!("{}\n{}", current_raw, line), + function: Some(func), + file, + line: line_num, + column: col, + symbolicated: true, + }); + current_raw.clear(); + } + continue; + } + + // Other lines (thread info, etc.) + if !line.trim().is_empty() { + frames.push(SymbolicatedFrame::raw(line.to_string())); + } + } + + // Don't forget last frame + if let Some(func) = current_function { + frames.push(SymbolicatedFrame { + raw: current_raw, + function: Some(func), + file: None, + line: None, + column: None, + symbolicated: true, + }); + } + + let symbolicated_count = frames.iter().filter(|f| f.symbolicated).count(); + let total_count = frames.len(); + + Ok(SymbolicatedStack { + raw: stack_trace.to_string(), + frames, + symbolicated_count, + total_count, + }) + } + + // Note: addr2line integration for stripped binaries is available but requires + // additional setup. For most Rust applications, debug builds include full + // symbol information in the stack trace itself. +} diff --git a/rust/src/symbolication/store.rs b/rust/src/symbolication/store.rs new file mode 100644 index 0000000..6f0514c --- /dev/null +++ b/rust/src/symbolication/store.rs @@ -0,0 +1,308 @@ +//! Mapping file storage and management. +//! +//! Manages mapping files for different platforms and versions. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{Platform, SymbolicationError}; + +/// Key for looking up mapping files. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MappingKey { + pub platform: Platform, + pub app_id: String, + pub version: String, +} + +/// Information about a loaded mapping file. +#[derive(Debug, Clone)] +pub struct MappingInfo { + pub path: PathBuf, + pub platform: Platform, + pub app_id: String, + pub version: String, + pub loaded_at: std::time::SystemTime, +} + +/// Storage for mapping files. +/// +/// Organizes mapping files by platform/app/version and provides +/// lookup functionality for symbolicators. +pub struct MappingStore { + /// Root directory for mapping files + root: PathBuf, + /// Cached mapping file paths + mappings: HashMap, +} + +impl MappingStore { + /// Create a new mapping store at the given root directory. + /// + /// Directory structure: + /// ```text + /// root/ + /// android/ + /// com.example.app/ + /// 1.0.0/ + /// mapping.txt + /// electron/ + /// my-app/ + /// 1.0.0/ + /// main.js.map + /// renderer.js.map + /// flutter/ + /// com.example.app/ + /// 1.0.0/ + /// app.android-arm64.symbols + /// ... + /// ``` + pub fn new>(root: P) -> Self { + Self { + root: root.as_ref().to_path_buf(), + mappings: HashMap::new(), + } + } + + /// Get the root directory. + pub fn root(&self) -> &Path { + &self.root + } + + /// Scan the root directory and load all mapping file metadata. + pub fn scan(&mut self) -> Result { + self.mappings.clear(); + let mut count = 0; + + if !self.root.exists() { + fs::create_dir_all(&self.root)?; + return Ok(0); + } + + // Scan platform directories + for platform_entry in fs::read_dir(&self.root)? { + let platform_entry = platform_entry?; + if !platform_entry.file_type()?.is_dir() { + continue; + } + + let platform_name = platform_entry.file_name().to_string_lossy().to_string(); + let platform = Platform::from_str(&platform_name); + + // Scan app directories + for app_entry in fs::read_dir(platform_entry.path())? { + let app_entry = app_entry?; + if !app_entry.file_type()?.is_dir() { + continue; + } + + let app_id = app_entry.file_name().to_string_lossy().to_string(); + + // Scan version directories + for version_entry in fs::read_dir(app_entry.path())? { + let version_entry = version_entry?; + if !version_entry.file_type()?.is_dir() { + continue; + } + + let version = version_entry.file_name().to_string_lossy().to_string(); + let version_path = version_entry.path(); + + // Look for mapping files based on platform + if let Some(mapping_path) = self.find_mapping_file(&platform, &version_path) { + let key = MappingKey { + platform: platform.clone(), + app_id: app_id.clone(), + version: version.clone(), + }; + + let info = MappingInfo { + path: mapping_path, + platform: platform.clone(), + app_id: app_id.clone(), + version: version.clone(), + loaded_at: std::time::SystemTime::now(), + }; + + self.mappings.insert(key, info); + count += 1; + } + } + } + } + + Ok(count) + } + + /// Find the primary mapping file for a platform in a directory. + fn find_mapping_file(&self, platform: &Platform, dir: &Path) -> Option { + let candidates: &[&str] = match platform { + Platform::Android => &["mapping.txt", "proguard-mapping.txt", "r8-mapping.txt"], + Platform::Electron => &["main.js.map", "index.js.map", "bundle.js.map"], + Platform::Flutter => &["app.android-arm64.symbols", "app.ios-arm64.symbols", "app.symbols"], + Platform::Rust => &["symbols.txt", "debug.dwarf"], + Platform::Go => &["symbols.txt", "go.sym"], + Platform::Python => &["source-map.json", "mapping.json"], + Platform::ReactNative => &["index.android.bundle.map", "index.ios.bundle.map", "main.jsbundle.map"], + Platform::Unknown(_) => &[], + }; + + for candidate in candidates { + let path = dir.join(candidate); + if path.exists() { + return Some(path); + } + } + + // Also check for any .map or .txt files + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension() { + let ext = ext.to_string_lossy().to_lowercase(); + if ext == "map" || ext == "txt" || ext == "symbols" { + return Some(path); + } + } + } + } + + None + } + + /// Get mapping info for a specific app version. + pub fn get(&self, platform: &Platform, app_id: &str, version: &str) -> Option<&MappingInfo> { + let key = MappingKey { + platform: platform.clone(), + app_id: app_id.to_string(), + version: version.to_string(), + }; + self.mappings.get(&key) + } + + /// Get mapping info, trying version fallbacks. + /// + /// Tries exact version first, then looks for closest match. + pub fn get_with_fallback( + &self, + platform: &Platform, + app_id: &str, + version: &str, + ) -> Option<&MappingInfo> { + // Try exact match first + if let Some(info) = self.get(platform, app_id, version) { + return Some(info); + } + + // Try to find any version for this app + let prefix = MappingKey { + platform: platform.clone(), + app_id: app_id.to_string(), + version: String::new(), + }; + + self.mappings + .iter() + .filter(|(k, _)| k.platform == prefix.platform && k.app_id == prefix.app_id) + .max_by(|(a, _), (b, _)| a.version.cmp(&b.version)) + .map(|(_, v)| v) + } + + /// Add a mapping file manually. + pub fn add_mapping( + &mut self, + platform: Platform, + app_id: String, + version: String, + path: PathBuf, + ) { + let key = MappingKey { + platform: platform.clone(), + app_id: app_id.clone(), + version: version.clone(), + }; + + let info = MappingInfo { + path, + platform, + app_id, + version, + loaded_at: std::time::SystemTime::now(), + }; + + self.mappings.insert(key, info); + } + + /// List all loaded mappings. + pub fn list(&self) -> impl Iterator { + self.mappings.values() + } + + /// Get the expected path for a new mapping file. + pub fn mapping_path( + &self, + platform: &Platform, + app_id: &str, + version: &str, + filename: &str, + ) -> PathBuf { + self.root + .join(platform.as_str()) + .join(app_id) + .join(version) + .join(filename) + } + + /// Save a mapping file to the store. + pub fn save_mapping( + &mut self, + platform: Platform, + app_id: &str, + version: &str, + filename: &str, + content: &[u8], + ) -> Result { + let path = self.mapping_path(&platform, app_id, version, filename); + + // Create parent directories + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(&path, content)?; + + // Add to cache + self.add_mapping( + platform, + app_id.to_string(), + version.to_string(), + path.clone(), + ); + + Ok(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_mapping_store_scan() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + // Create test structure + let android_path = root.join("android/com.test.app/1.0.0"); + fs::create_dir_all(&android_path).unwrap(); + fs::write(android_path.join("mapping.txt"), "# test mapping").unwrap(); + + let mut store = MappingStore::new(root); + let count = store.scan().unwrap(); + + assert_eq!(count, 1); + assert!(store.get(&Platform::Android, "com.test.app", "1.0.0").is_some()); + } +} From 16a3c0d09854bb2318a431742c617be7e8ee9805 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 10:56:27 -0600 Subject: [PATCH 02/13] feat: add symbolicate CLI command Add `bugstr symbolicate` command for symbolicating stack traces: - Reads from file or stdin - Supports all 7 platforms (android, electron, flutter, rust, go, python, react-native) - Pretty and JSON output formats - Uses mapping files from configurable directory Usage: bugstr symbolicate -P python -i stack.txt echo "traceback..." | bugstr symbolicate -P go --format json Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/src/bin/main.rs | 147 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index a1378af..ed03270 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -3,7 +3,10 @@ //! Subscribes to Nostr relays and decrypts NIP-17 gift-wrapped crash reports. //! Optionally serves a web dashboard for viewing and analyzing crashes. -use bugstr::{decompress_payload, parse_crash_content, AppState, CrashReport, CrashStorage, create_router}; +use bugstr::{ + decompress_payload, parse_crash_content, AppState, CrashReport, CrashStorage, create_router, + MappingStore, Platform, Symbolicator, SymbolicationContext, +}; use tokio::sync::Mutex; use chrono::{DateTime, Utc}; use clap::{Parser, Subcommand}; @@ -71,6 +74,33 @@ enum Commands { #[arg(short, long, env = "BUGSTR_PRIVKEY")] privkey: String, }, + + /// Symbolicate a stack trace using mapping files + Symbolicate { + /// Platform: android, electron, flutter, rust, go, python, react-native + #[arg(short = 'P', long)] + platform: String, + + /// Input file containing stack trace (or - for stdin) + #[arg(short, long, default_value = "-")] + input: String, + + /// Directory containing mapping files + #[arg(short, long, default_value = "mappings")] + mappings: PathBuf, + + /// Application ID (package name, bundle id, etc.) + #[arg(short, long)] + app_id: Option, + + /// Application version + #[arg(short, long)] + version: Option, + + /// Output format: pretty or json + #[arg(short, long, default_value = "pretty")] + format: SymbolicateFormat, + }, } #[derive(Clone, Debug, clap::ValueEnum)] @@ -80,6 +110,12 @@ enum OutputFormat { Raw, } +#[derive(Clone, Debug, clap::ValueEnum)] +enum SymbolicateFormat { + Pretty, + Json, +} + /// A received crash report ready for storage. struct ReceivedCrash { event_id: String, @@ -111,6 +147,16 @@ async fn main() -> Result<(), Box> { Commands::Pubkey { privkey } => { show_pubkey(&privkey)?; } + Commands::Symbolicate { + platform, + input, + mappings, + app_id, + version, + format, + } => { + symbolicate_stack(&platform, &input, &mappings, app_id, version, format)?; + } } Ok(()) @@ -140,6 +186,105 @@ fn show_pubkey(privkey: &str) -> Result<(), Box> { Ok(()) } +/// Symbolicate a stack trace using mapping files. +fn symbolicate_stack( + platform_str: &str, + input: &str, + mappings_dir: &PathBuf, + app_id: Option, + version: Option, + format: SymbolicateFormat, +) -> Result<(), Box> { + // Read stack trace + let stack_trace = if input == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else { + std::fs::read_to_string(input)? + }; + + // Parse platform + let platform = Platform::from_str(platform_str); + if matches!(platform, Platform::Unknown(_)) { + eprintln!( + "{} Unknown platform '{}'. Supported: android, electron, flutter, rust, go, python, react-native", + "warning".yellow(), + platform_str + ); + } + + // Create symbolicator + let store = MappingStore::new(mappings_dir); + let symbolicator = Symbolicator::new(store); + + // Create context + let context = SymbolicationContext { + platform, + app_id, + version, + build_id: None, + }; + + // Symbolicate + let result = symbolicator.symbolicate(&stack_trace, &context)?; + + // Output + match format { + SymbolicateFormat::Pretty => { + println!("{}", "━".repeat(60).dimmed()); + println!( + "{} {} frames symbolicated ({:.1}%)", + "Symbolication Results".green().bold(), + result.symbolicated_count, + result.percentage() + ); + println!("{}", "━".repeat(60).dimmed()); + println!(); + + for (i, frame) in result.frames.iter().enumerate() { + if frame.symbolicated { + let location = match (&frame.file, frame.line) { + (Some(f), Some(l)) => format!(" ({}:{})", f.dimmed(), l), + (Some(f), None) => format!(" ({})", f.dimmed()), + _ => String::new(), + }; + println!( + " {} {}{}", + format!("#{}", i).cyan(), + frame.function.as_deref().unwrap_or("").green(), + location + ); + } else { + println!(" {} {}", format!("#{}", i).cyan(), frame.raw.dimmed()); + } + } + println!(); + } + SymbolicateFormat::Json => { + let output = serde_json::json!({ + "symbolicated_count": result.symbolicated_count, + "total_count": result.total_count, + "percentage": result.percentage(), + "frames": result.frames.iter().map(|f| { + serde_json::json!({ + "raw": f.raw, + "function": f.function, + "file": f.file, + "line": f.line, + "column": f.column, + "symbolicated": f.symbolicated, + }) + }).collect::>() + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } + } + + Ok(()) +} + /// Run web dashboard with crash collection. async fn serve( privkey: &str, From 93dd946f41bc90bc9836fe1d119b811c66d91bf4 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 10:56:56 -0600 Subject: [PATCH 03/13] feat: add symbolication web API endpoint Add POST /api/symbolicate endpoint for dashboard integration: - Accepts stack trace with platform, app_id, version, build_id - Returns symbolicated frames with function, file, line info - Returns 503 if symbolication not configured Add --mappings option to `bugstr serve`: - Configures mapping files directory for symbolication - Creates Symbolicator and passes to AppState - Displays mappings path in startup banner Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/src/bin/main.rs | 23 ++++++++++-- rust/src/web.rs | 84 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index ed03270..137eefc 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -66,6 +66,10 @@ enum Commands { /// Database file path #[arg(long, default_value = DEFAULT_DB_PATH)] db: PathBuf, + + /// Directory containing mapping files for symbolication + #[arg(long)] + mappings: Option, }, /// Show your receiver pubkey (npub) @@ -141,8 +145,9 @@ async fn main() -> Result<(), Box> { relays, port, db, + mappings, } => { - serve(&privkey, &relays, port, db).await?; + serve(&privkey, &relays, port, db, mappings).await?; } Commands::Pubkey { privkey } => { show_pubkey(&privkey)?; @@ -291,6 +296,7 @@ async fn serve( relays: &[String], port: u16, db_path: PathBuf, + mappings_dir: Option, ) -> Result<(), Box> { let secret = parse_privkey(privkey)?; let keys = Keys::new(secret); @@ -298,7 +304,17 @@ async fn serve( // Open/create database let storage = CrashStorage::open(&db_path)?; - let state = Arc::new(AppState { storage: Mutex::new(storage) }); + + // Create symbolicator if mappings directory is provided + let symbolicator = mappings_dir.as_ref().map(|dir| { + let store = MappingStore::new(dir); + Symbolicator::new(store) + }); + + let state = Arc::new(AppState { + storage: Mutex::new(storage), + symbolicator, + }); println!("{}", "━".repeat(60).dimmed()); println!( @@ -310,6 +326,9 @@ async fn serve( println!(" {} {}", "Database:".cyan(), db_path.display()); println!(" {} http://localhost:{}", "Dashboard:".cyan(), port); println!(" {} {}", "Relays:".cyan(), relays.join(", ")); + if let Some(ref dir) = mappings_dir { + println!(" {} {}", "Mappings:".cyan(), dir.display()); + } println!("{}", "━".repeat(60).dimmed()); println!(); diff --git a/rust/src/web.rs b/rust/src/web.rs index c8b7a9d..29539ca 100644 --- a/rust/src/web.rs +++ b/rust/src/web.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{Html, IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; use rust_embed::Embed; @@ -15,6 +15,7 @@ use tokio::sync::Mutex; use tower_http::cors::CorsLayer; use crate::storage::{CrashGroup, CrashReport, CrashStorage}; +use crate::symbolication::{Platform, Symbolicator, SymbolicationContext}; /// Embedded static files for the dashboard. #[derive(Embed)] @@ -24,6 +25,7 @@ struct Assets; /// Shared application state. pub struct AppState { pub storage: Mutex, + pub symbolicator: Option, } /// Creates the web server router. @@ -39,6 +41,7 @@ pub fn create_router(state: Arc) -> Router { .route("/api/crashes/{id}", get(get_crash)) .route("/api/groups", get(get_groups)) .route("/api/stats", get(get_stats)) + .route("/api/symbolicate", post(symbolicate_stack)) // Static files and SPA fallback .route("/", get(index_handler)) .route("/{*path}", get(static_handler)) @@ -86,6 +89,50 @@ async fn get_stats(State(state): State>) -> impl IntoResponse { } } +/// POST /api/symbolicate - Symbolicate a stack trace +async fn symbolicate_stack( + State(state): State>, + Json(request): Json, +) -> impl IntoResponse { + let Some(ref symbolicator) = state.symbolicator else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "Symbolication not configured. Start server with --mappings option." + })) + ).into_response(); + }; + + let platform = Platform::from_str(&request.platform); + let context = SymbolicationContext { + platform, + app_id: request.app_id, + version: request.version, + build_id: request.build_id, + }; + + match symbolicator.symbolicate(&request.stack_trace, &context) { + Ok(result) => Json(SymbolicateResponse { + symbolicated_count: result.symbolicated_count, + total_count: result.total_count, + percentage: result.percentage(), + display: result.display(), + frames: result.frames.iter().map(|f| FrameJson { + raw: f.raw.clone(), + function: f.function.clone(), + file: f.file.clone(), + line: f.line, + column: f.column, + symbolicated: f.symbolicated, + }).collect(), + }).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e.to_string() })) + ).into_response(), + } +} + /// Serve index.html async fn index_handler() -> impl IntoResponse { match Assets::get("index.html") { @@ -178,3 +225,38 @@ impl From for GroupJson { struct StatsJson { total_crashes: i64, } + +// Symbolication request/response types + +#[derive(serde::Deserialize)] +struct SymbolicateRequest { + /// Stack trace to symbolicate + stack_trace: String, + /// Platform: android, electron, flutter, rust, go, python, react-native + platform: String, + /// Optional application ID + app_id: Option, + /// Optional version + version: Option, + /// Optional build ID + build_id: Option, +} + +#[derive(serde::Serialize)] +struct SymbolicateResponse { + symbolicated_count: usize, + total_count: usize, + percentage: f64, + display: String, + frames: Vec, +} + +#[derive(serde::Serialize)] +struct FrameJson { + raw: String, + function: Option, + file: Option, + line: Option, + column: Option, + symbolicated: bool, +} From 4090f256d4524d902c8c2e9d676346a8e1ed8d92 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 10:57:01 -0600 Subject: [PATCH 04/13] docs: add rust CHANGELOG Document symbolication feature and initial release notes. Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 rust/CHANGELOG.md diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md new file mode 100644 index 0000000..8510405 --- /dev/null +++ b/rust/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Symbolication module with support for 7 platforms: + - Android (ProGuard/R8 mapping.txt parsing) + - JavaScript/Electron (source map support) + - Flutter/Dart (flutter symbolize integration) + - Rust (backtrace parsing) + - Go (goroutine stack parsing) + - Python (traceback parsing) + - React Native (Hermes bytecode + JS source maps) +- `bugstr symbolicate` CLI command for symbolicating stack traces +- `POST /api/symbolicate` web API endpoint for dashboard integration +- `--mappings` option for `bugstr serve` to enable symbolication +- `MappingStore` for organizing mapping files by platform/app/version + +## [0.1.0] - 2025-01-15 + +### Added +- Initial release +- Crash report receiver with NIP-17 gift wrap decryption +- Web dashboard for viewing and grouping crash reports +- SQLite storage with deduplication +- Gzip compression support for large payloads +- `bugstr listen` command for terminal-only crash monitoring +- `bugstr serve` command for web dashboard with crash collection +- `bugstr pubkey` command to display receiver public key From 2c16c643726f5f48b22c74b307f3303516543310 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 11:28:45 -0600 Subject: [PATCH 05/13] fix: call MappingStore.scan() to load mapping files The MappingStore.scan() method was never being called, which meant mapping files were never actually loaded. This fix ensures mappings are scanned in both the CLI symbolicate command and the serve command. Also adds helpful warnings when no mapping files are found in the specified directory. Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/src/bin/main.rs | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 137eefc..d3160d5 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -220,8 +220,16 @@ fn symbolicate_stack( ); } - // Create symbolicator - let store = MappingStore::new(mappings_dir); + // Create symbolicator with scanned mapping store + let mut store = MappingStore::new(mappings_dir); + let count = store.scan()?; + if count == 0 { + eprintln!( + "{} No mapping files found in {}", + "warning".yellow(), + mappings_dir.display() + ); + } let symbolicator = Symbolicator::new(store); // Create context @@ -306,10 +314,29 @@ async fn serve( let storage = CrashStorage::open(&db_path)?; // Create symbolicator if mappings directory is provided - let symbolicator = mappings_dir.as_ref().map(|dir| { - let store = MappingStore::new(dir); - Symbolicator::new(store) - }); + let symbolicator = if let Some(ref dir) = mappings_dir { + let mut store = MappingStore::new(dir); + match store.scan() { + Ok(count) => { + if count == 0 { + eprintln!( + "{} No mapping files found in {}", + "warning".yellow(), + dir.display() + ); + } else { + println!(" {} {} mapping files loaded", "Loaded:".cyan(), count); + } + Some(Symbolicator::new(store)) + } + Err(e) => { + eprintln!("{} Failed to scan mappings: {}", "error".red(), e); + None + } + } + } else { + None + }; let state = Arc::new(AppState { storage: Mutex::new(storage), From b1593900243e10494585a3f2c5c7b9232f07169b Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 11:30:48 -0600 Subject: [PATCH 06/13] fix: improve regex patterns for stack trace parsing - Go: Match pointer receivers like main.(*Type).Method - JavaScript: Handle colons in URLs and Windows paths - Python: Require Error/Exception/Warning suffix to avoid matching "Traceback" - React Native: Handle URLs in file paths, preserve file path in fallback Adds tests for URL handling in JavaScript and React Native. Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/src/symbolication/go.rs | 22 +++++++++++++++++++++- rust/src/symbolication/javascript.rs | 25 +++++++++++++++++++++---- rust/src/symbolication/python.rs | 3 ++- rust/src/symbolication/react_native.rs | 26 +++++++++++++++++++++----- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/rust/src/symbolication/go.rs b/rust/src/symbolication/go.rs index cafe37f..b18de48 100644 --- a/rust/src/symbolication/go.rs +++ b/rust/src/symbolication/go.rs @@ -51,7 +51,8 @@ impl<'a> GoSymbolicator<'a> { // main.main() // /path/to/main.go:10 +0x2b - let func_re = Regex::new(r"^([a-zA-Z0-9_./*]+)\(([^)]*)\)$").unwrap(); + // Matches function names including pointer receivers like main.(*Type).Method + let func_re = Regex::new(r"^([a-zA-Z0-9_./]+(?:\(\*[^)]+\))?[a-zA-Z0-9_.]*)\(([^)]*)\)$").unwrap(); let location_re = Regex::new(r"^\s+(.+\.go):(\d+)\s+\+0x[0-9a-f]+$").unwrap(); let goroutine_re = Regex::new(r"^goroutine\s+\d+\s+\[.+\]:$").unwrap(); @@ -151,6 +152,7 @@ impl<'a> GoSymbolicator<'a> { #[cfg(test)] mod tests { use super::*; + use regex::Regex; #[test] fn test_parse_go_stack() { @@ -166,4 +168,22 @@ main.main() assert!(result.symbolicated_count >= 2); } + + #[test] + fn test_func_re_matches_pointer_receiver() { + // Matches function names including pointer receivers like main.(*Type).Method + let func_re = Regex::new(r"^([a-zA-Z0-9_./]+(?:\(\*[^)]+\))?[a-zA-Z0-9_.]*)\(([^)]*)\)$").unwrap(); + + // Regular function + let caps = func_re.captures("main.myFunction(0x123)").unwrap(); + assert_eq!(caps.get(1).unwrap().as_str(), "main.myFunction"); + + // Pointer receiver method + let caps = func_re.captures("main.(*Server).handleRequest(0x123, 0x456)").unwrap(); + assert_eq!(caps.get(1).unwrap().as_str(), "main.(*Server).handleRequest"); + + // Value receiver method + let caps = func_re.captures("net/http.(*Server).Serve(0x123)").unwrap(); + assert_eq!(caps.get(1).unwrap().as_str(), "net/http.(*Server).Serve"); + } } diff --git a/rust/src/symbolication/javascript.rs b/rust/src/symbolication/javascript.rs index 721d4cb..df8da7a 100644 --- a/rust/src/symbolication/javascript.rs +++ b/rust/src/symbolication/javascript.rs @@ -55,11 +55,13 @@ impl<'a> JavaScriptSymbolicator<'a> { // Chrome/V8 style: " at functionName (file.js:line:col)" // Firefox style: "functionName@file.js:line:col" // Node.js style: " at functionName (file.js:line:col)" + // Note: File paths can contain colons (URLs, Windows paths), so we match + // greedily up to the last :line:col pattern let chrome_re = Regex::new( - r"^\s*at\s+(?:(.+?)\s+)?\(?([^:]+):(\d+):(\d+)\)?" + r"^\s*at\s+(?:(.+?)\s+)?\(?(.+):(\d+):(\d+)\)?" ).unwrap(); let firefox_re = Regex::new( - r"^(.+?)@([^:]+):(\d+):(\d+)" + r"^(.+?)@(.+):(\d+):(\d+)$" ).unwrap(); for line in stack_trace.lines() { @@ -128,7 +130,7 @@ mod tests { #[test] fn test_parse_chrome_stack_frame() { let chrome_re = Regex::new( - r"^\s*at\s+(?:(.+?)\s+)?\(?([^:]+):(\d+):(\d+)\)?" + r"^\s*at\s+(?:(.+?)\s+)?\(?(.+):(\d+):(\d+)\)?" ).unwrap(); let frame = " at myFunction (bundle.js:1:2345)"; @@ -140,9 +142,24 @@ mod tests { assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); } + #[test] + fn test_parse_chrome_stack_frame_with_url() { + let chrome_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?(.+):(\d+):(\d+)\)?" + ).unwrap(); + + let frame = " at myFunction (http://localhost:8080/bundle.js:1:2345)"; + let caps = chrome_re.captures(frame).unwrap(); + + assert_eq!(caps.get(1).map(|m| m.as_str()), Some("myFunction")); + assert_eq!(caps.get(2).map(|m| m.as_str()), Some("http://localhost:8080/bundle.js")); + assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); + assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); + } + #[test] fn test_parse_firefox_stack_frame() { - let firefox_re = Regex::new(r"^(.+?)@([^:]+):(\d+):(\d+)").unwrap(); + let firefox_re = Regex::new(r"^(.+?)@(.+):(\d+):(\d+)$").unwrap(); let frame = "myFunction@bundle.js:1:2345"; let caps = firefox_re.captures(frame).unwrap(); diff --git a/rust/src/symbolication/python.rs b/rust/src/symbolication/python.rs index 8344e4f..dbb958d 100644 --- a/rust/src/symbolication/python.rs +++ b/rust/src/symbolication/python.rs @@ -57,7 +57,8 @@ impl<'a> PythonSymbolicator<'a> { let file_re = Regex::new( r#"^\s*File\s+"([^"]+)",\s+line\s+(\d+),\s+in\s+(.+)$"# ).unwrap(); - let exception_re = Regex::new(r"^([A-Z][a-zA-Z0-9]*(?:Error|Exception|Warning)?):?\s*(.*)$").unwrap(); + // Exception line must end with Error, Exception, or Warning to avoid matching "Traceback" + let exception_re = Regex::new(r"^([A-Z][a-zA-Z0-9]*(?:Error|Exception|Warning)):?\s*(.*)$").unwrap(); let mut frames = Vec::new(); let mut in_frame = false; diff --git a/rust/src/symbolication/react_native.rs b/rust/src/symbolication/react_native.rs index 5695ab4..68d4088 100644 --- a/rust/src/symbolication/react_native.rs +++ b/rust/src/symbolication/react_native.rs @@ -63,8 +63,9 @@ impl<'a> ReactNativeSymbolicator<'a> { // Native Android: " at com.example.MyClass.method(MyClass.java:42)" // Native iOS: "0 MyApp 0x00000001 myFunction + 123" + // Note: File paths can contain colons (URLs), so we match greedily let js_frame_re = Regex::new( - r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?([^:]+):(\d+):(\d+)\)?" + r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?(.+):(\d+):(\d+)\)?" ).unwrap(); let native_android_re = Regex::new( r"^\s*at\s+([a-zA-Z0-9_.]+)\.([a-zA-Z0-9_<>]+)\(([^:]+):(\d+)\)" @@ -85,7 +86,7 @@ impl<'a> ReactNativeSymbolicator<'a> { // Try JS/Hermes frame if let Some(caps) = js_frame_re.captures(line_trimmed) { let function = caps.get(1).map(|m| m.as_str()); - let _file = caps.get(2).map(|m| m.as_str()); + let file = caps.get(2).map(|m| m.as_str()); let line_num: u32 = caps .get(3) .and_then(|m| m.as_str().parse().ok()) @@ -122,11 +123,11 @@ impl<'a> ReactNativeSymbolicator<'a> { } } - // No source map or token not found + // No source map or token not found - preserve original file path frames.push(SymbolicatedFrame { raw: line.to_string(), function: function.map(|s| s.to_string()), - file: None, + file: file.map(|s| s.to_string()), line: Some(line_num), column: Some(col_num), symbolicated: false, @@ -190,7 +191,7 @@ mod tests { #[test] fn test_parse_js_frame() { let js_frame_re = Regex::new( - r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?([^:]+):(\d+):(\d+)\)?" + r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?(.+):(\d+):(\d+)\)?" ).unwrap(); let frame = " at myFunction (index.bundle:1:2345)"; @@ -201,4 +202,19 @@ mod tests { assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); } + + #[test] + fn test_parse_js_frame_with_url() { + let js_frame_re = Regex::new( + r"^\s*at\s+(?:(.+?)\s+)?\(?(?:address at\s+)?(.+):(\d+):(\d+)\)?" + ).unwrap(); + + let frame = " at myFunction (http://localhost:8081/index.bundle:1:2345)"; + let caps = js_frame_re.captures(frame).unwrap(); + + assert_eq!(caps.get(1).map(|m| m.as_str()), Some("myFunction")); + assert_eq!(caps.get(2).map(|m| m.as_str()), Some("http://localhost:8081/index.bundle")); + assert_eq!(caps.get(3).map(|m| m.as_str()), Some("1")); + assert_eq!(caps.get(4).map(|m| m.as_str()), Some("2345")); + } } From 19561048eb7bb943b52ab5e460c2a39138588e72 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 11:31:35 -0600 Subject: [PATCH 07/13] fix: thread-safe symbolication with spawn_blocking Change symbolicator to Arc for safe sharing across threads. Use spawn_blocking to run CPU-intensive symbolication work without blocking the async runtime's executor threads. This prevents symbolication requests from blocking other API endpoints. Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/src/bin/main.rs | 2 +- rust/src/web.rs | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index d3160d5..04e2172 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -327,7 +327,7 @@ async fn serve( } else { println!(" {} {} mapping files loaded", "Loaded:".cyan(), count); } - Some(Symbolicator::new(store)) + Some(Arc::new(Symbolicator::new(store))) } Err(e) => { eprintln!("{} Failed to scan mappings: {}", "error".red(), e); diff --git a/rust/src/web.rs b/rust/src/web.rs index 29539ca..3a23064 100644 --- a/rust/src/web.rs +++ b/rust/src/web.rs @@ -25,7 +25,7 @@ struct Assets; /// Shared application state. pub struct AppState { pub storage: Mutex, - pub symbolicator: Option, + pub symbolicator: Option>, } /// Creates the web server router. @@ -111,8 +111,17 @@ async fn symbolicate_stack( build_id: request.build_id, }; - match symbolicator.symbolicate(&request.stack_trace, &context) { - Ok(result) => Json(SymbolicateResponse { + // Clone Arc for move into spawn_blocking + let symbolicator = Arc::clone(symbolicator); + let stack_trace = request.stack_trace; + + // Run symbolication in blocking task pool to avoid blocking async runtime + let result = tokio::task::spawn_blocking(move || { + symbolicator.symbolicate(&stack_trace, &context) + }).await; + + match result { + Ok(Ok(result)) => Json(SymbolicateResponse { symbolicated_count: result.symbolicated_count, total_count: result.total_count, percentage: result.percentage(), @@ -126,10 +135,14 @@ async fn symbolicate_stack( symbolicated: f.symbolicated, }).collect(), }).into_response(), - Err(e) => ( + Ok(Err(e)) => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": e.to_string() })) ).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Task failed: {}", e) })) + ).into_response(), } } From 293401317133032b426207816bc9047023307491 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 11:32:53 -0600 Subject: [PATCH 08/13] refactor: use tempfile and semver crates - flutter.rs: Use NamedTempFile for secure temp file handling instead of predictable fixed path that could be exploited - store.rs: Use semver for proper version comparison instead of lexicographic ordering (so 1.10.0 > 1.9.0) Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/Cargo.toml | 3 ++- rust/src/symbolication/flutter.rs | 12 ++++++------ rust/src/symbolication/store.rs | 24 ++++++++++++++---------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 63a6921..5d6a559 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -44,6 +44,7 @@ mime_guess = "2.0" # Symbolication regex = "1.10" sourcemap = "9.0" +tempfile = "3.14" +semver = "1.0" [dev-dependencies] -tempfile = "3.14" diff --git a/rust/src/symbolication/flutter.rs b/rust/src/symbolication/flutter.rs index d86c591..0340167 100644 --- a/rust/src/symbolication/flutter.rs +++ b/rust/src/symbolication/flutter.rs @@ -3,10 +3,11 @@ //! Uses Flutter symbol files or the external `flutter symbolize` command //! to symbolicate Dart stack traces from release builds. -use std::fs; +use std::io::Write; use std::process::Command; use regex::Regex; +use tempfile::NamedTempFile; use super::{ MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, @@ -56,17 +57,16 @@ impl<'a> FlutterSymbolicator<'a> { stack_trace: &str, symbols_path: &std::path::Path, ) -> Result { - // Write stack trace to temp file - let temp_dir = std::env::temp_dir(); - let input_path = temp_dir.join("bugstr_flutter_input.txt"); - fs::write(&input_path, stack_trace)?; + // Write stack trace to a secure temp file + let mut temp_file = NamedTempFile::new()?; + temp_file.write_all(stack_trace.as_bytes())?; // Run flutter symbolize let output = Command::new("flutter") .args([ "symbolize", "-i", - input_path.to_str().unwrap(), + temp_file.path().to_str().unwrap(), "-d", symbols_path.to_str().unwrap(), ]) diff --git a/rust/src/symbolication/store.rs b/rust/src/symbolication/store.rs index 6f0514c..20d8425 100644 --- a/rust/src/symbolication/store.rs +++ b/rust/src/symbolication/store.rs @@ -6,6 +6,8 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use semver::Version; + use super::{Platform, SymbolicationError}; /// Key for looking up mapping files. @@ -183,7 +185,7 @@ impl MappingStore { /// Get mapping info, trying version fallbacks. /// - /// Tries exact version first, then looks for closest match. + /// Tries exact version first, then looks for closest match using semantic versioning. pub fn get_with_fallback( &self, platform: &Platform, @@ -195,17 +197,19 @@ impl MappingStore { return Some(info); } - // Try to find any version for this app - let prefix = MappingKey { - platform: platform.clone(), - app_id: app_id.to_string(), - version: String::new(), - }; - + // Try to find the newest version for this app using semantic versioning self.mappings .iter() - .filter(|(k, _)| k.platform == prefix.platform && k.app_id == prefix.app_id) - .max_by(|(a, _), (b, _)| a.version.cmp(&b.version)) + .filter(|(k, _)| k.platform == *platform && k.app_id == app_id) + .max_by(|(a, _), (b, _)| { + // Parse as semver, fallback to lexicographic if parsing fails + match (Version::parse(&a.version), Version::parse(&b.version)) { + (Ok(va), Ok(vb)) => va.cmp(&vb), + (Ok(_), Err(_)) => std::cmp::Ordering::Greater, // Valid semver > invalid + (Err(_), Ok(_)) => std::cmp::Ordering::Less, + (Err(_), Err(_)) => a.version.cmp(&b.version), // Fallback to lexicographic + } + }) .map(|(_, v)| v) } From 2e29325c8bc90dcf11170d52dd3b2452ee4cf8cc Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 11:33:15 -0600 Subject: [PATCH 09/13] docs: update CHANGELOG and symbolication docs - Add Changed/Fixed subsections to CHANGELOG (Keep a Changelog format) - Fix incorrect claim about addr2line in Rust symbolication docs (we parse backtraces, not DWARF debug info) Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- rust/CHANGELOG.md | 6 ++++++ rust/src/symbolication/mod.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 8510405..65cd593 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--mappings` option for `bugstr serve` to enable symbolication - `MappingStore` for organizing mapping files by platform/app/version +### Changed +- None + +### Fixed +- None + ## [0.1.0] - 2025-01-15 ### Added diff --git a/rust/src/symbolication/mod.rs b/rust/src/symbolication/mod.rs index 126b742..a057c5a 100644 --- a/rust/src/symbolication/mod.rs +++ b/rust/src/symbolication/mod.rs @@ -8,7 +8,7 @@ //! - **Android**: ProGuard/R8 mapping.txt files //! - **JavaScript/Electron**: Source map (.map) files //! - **Flutter/Dart**: Flutter symbol files or external `flutter symbolize` -//! - **Rust**: DWARF debug info via addr2line +//! - **Rust**: Backtrace parsing (debug builds include source locations) //! - **Go**: Go symbol tables (usually embedded) //! - **Python**: Source file mapping for bundled apps //! - **React Native**: Hermes bytecode maps + JS source maps From 3a66c9f72576799cfbc2f6607797fdca6305f94b Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 12:00:58 -0600 Subject: [PATCH 10/13] docs: add comprehensive rustdoc and path validation security Add detailed rustdoc documentation for the symbolication module: - symbolicate_stack function with parameters, errors, output formats - SymbolicationContext, SymbolicatedFrame, SymbolicatedStack structs - Symbolicator::symbolicate with error cases and examples - MappingStore and all public methods with usage examples Add path validation to MappingStore.save_mapping to prevent directory traversal attacks: - New InvalidPath error variant in SymbolicationError - validate_path_component() rejects empty, "..", "/", "\" in paths - 6 new tests for path validation Co-Authored-By: Claude Opus 4.5 --- rust/src/bin/main.rs | 112 ++++++++ rust/src/symbolication/mod.rs | 314 ++++++++++++++++++-- rust/src/symbolication/store.rs | 490 ++++++++++++++++++++++++++++++-- 3 files changed, 867 insertions(+), 49 deletions(-) diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 04e2172..581d7f1 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -192,6 +192,118 @@ fn show_pubkey(privkey: &str) -> Result<(), Box> { } /// Symbolicate a stack trace using mapping files. +/// +/// Reads a stack trace from a file or stdin, symbolicates it using the appropriate +/// platform-specific symbolicator, and outputs the result in the specified format. +/// +/// # Parameters +/// +/// * `platform_str` - Platform identifier string. Supported values: +/// - `"android"` - Android (ProGuard/R8 mapping files) +/// - `"electron"` or `"javascript"` or `"js"` - JavaScript/Electron (source maps) +/// - `"flutter"` or `"dart"` - Flutter/Dart (symbol files) +/// - `"rust"` - Rust (backtrace parsing) +/// - `"go"` or `"golang"` - Go (goroutine stacks) +/// - `"python"` - Python (traceback parsing) +/// - `"react-native"` or `"reactnative"` or `"rn"` - React Native (Hermes + source maps) +/// Unknown platforms trigger a warning but still attempt symbolication. +/// +/// * `input` - Path to file containing the stack trace, or `"-"` to read from stdin. +/// The file is read entirely into memory as UTF-8 text. +/// +/// * `mappings_dir` - Borrowed reference to the directory containing mapping files. +/// Expected structure: `////`. +/// See [`MappingStore`] for detailed directory layout. +/// +/// * `app_id` - Optional application identifier (e.g., package name, bundle ID). +/// Used to locate the correct mapping file. If `None`, defaults to `"unknown"`. +/// +/// * `version` - Optional application version string (e.g., `"1.0.0"`). +/// Used to locate the correct mapping file. If `None`, defaults to `"unknown"`. +/// Falls back to newest available version if exact match not found. +/// +/// * `format` - Output format selection. See [`SymbolicateFormat`]: +/// - `Pretty` - Human-readable colored output with frame numbers and source locations +/// - `Json` - Machine-readable JSON with full frame details +/// +/// # Returns +/// +/// * `Ok(())` - Symbolication completed (results printed to stdout) +/// * `Err(_)` - One of the following errors occurred: +/// - IO error reading input file or stdin +/// - [`SymbolicationError::MappingNotFound`] - No mapping file for platform/app/version +/// - [`SymbolicationError::ParseError`] - Failed to parse mapping file +/// - [`SymbolicationError::IoError`] - Failed to read mapping file +/// - [`SymbolicationError::UnsupportedPlatform`] - Platform::Unknown was provided +/// - JSON serialization error (for JSON format output) +/// +/// # Output Format +/// +/// **Pretty format** (default): +/// ```text +/// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// Symbolication Results 5 frames symbolicated (83.3%) +/// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// +/// #0 com.example.MyClass.myMethod (MyClass.java:42) +/// #1 com.example.OtherClass.call (OtherClass.java:15) +/// ``` +/// +/// **JSON format**: +/// ```json +/// { +/// "symbolicated_count": 5, +/// "total_count": 6, +/// "percentage": 83.33, +/// "frames": [ +/// { +/// "raw": "at a.b.c(Unknown:1)", +/// "function": "com.example.MyClass.myMethod", +/// "file": "MyClass.java", +/// "line": 42, +/// "column": null, +/// "symbolicated": true +/// } +/// ] +/// } +/// ``` +/// +/// # Side Effects +/// +/// - Reads from filesystem (input file and mapping files) +/// - Reads from stdin if `input` is `"-"` +/// - Writes to stdout (symbolication results) +/// - Writes to stderr (warnings for unknown platform, missing mappings) +/// - Creates mapping directory if it doesn't exist (via [`MappingStore::scan`]) +/// +/// # Panics +/// +/// This function does not panic under normal operation. All errors are returned +/// as `Result::Err`. +/// +/// # Example +/// +/// ```ignore +/// // Symbolicate an Android stack trace from a file +/// symbolicate_stack( +/// "android", +/// "crash.txt", +/// &PathBuf::from("./mappings"), +/// Some("com.myapp".to_string()), +/// Some("1.0.0".to_string()), +/// SymbolicateFormat::Pretty, +/// )?; +/// +/// // Symbolicate from stdin with JSON output +/// symbolicate_stack( +/// "python", +/// "-", +/// &PathBuf::from("./mappings"), +/// None, +/// None, +/// SymbolicateFormat::Json, +/// )?; +/// ``` fn symbolicate_stack( platform_str: &str, input: &str, diff --git a/rust/src/symbolication/mod.rs b/rust/src/symbolication/mod.rs index a057c5a..8a23cac 100644 --- a/rust/src/symbolication/mod.rs +++ b/rust/src/symbolication/mod.rs @@ -73,6 +73,9 @@ pub enum SymbolicationError { #[error("External tool error: {0}")] ToolError(String), + + #[error("Invalid path component: {0}")] + InvalidPath(String), } /// Platform identifier for crash reports. @@ -118,33 +121,125 @@ impl Platform { } } -/// Information needed to symbolicate a stack trace. +/// Context information needed to symbolicate a stack trace. +/// +/// Provides metadata about the crash report that helps locate the correct +/// mapping files and apply platform-specific symbolication logic. +/// +/// # Fields +/// +/// * `platform` - The platform/runtime the crash originated from. Determines +/// which symbolicator implementation to use. See [`Platform`] for supported values. +/// +/// * `app_id` - Optional application identifier such as: +/// - Android: package name (e.g., `"com.example.myapp"`) +/// - iOS/Flutter: bundle ID (e.g., `"com.example.myapp"`) +/// - Electron: app name (e.g., `"my-desktop-app"`) +/// - Other: any unique identifier +/// When `None`, defaults to `"unknown"` for mapping file lookup. +/// +/// * `version` - Optional semantic version string (e.g., `"1.2.3"`). +/// Used to locate version-specific mapping files. When `None`, defaults to +/// `"unknown"`. If exact version not found, [`MappingStore::get_with_fallback`] +/// returns the newest available version using semver comparison. +/// +/// * `build_id` - Optional build identifier or commit hash. +/// Currently unused but reserved for future build-specific mapping lookup. +/// +/// # Example +/// +/// ``` +/// use bugstr::symbolication::{SymbolicationContext, Platform}; +/// +/// let context = SymbolicationContext { +/// platform: Platform::Android, +/// app_id: Some("com.myapp".to_string()), +/// version: Some("2.1.0".to_string()), +/// build_id: None, +/// }; +/// ``` #[derive(Debug, Clone)] pub struct SymbolicationContext { - /// Platform the crash came from + /// Platform the crash came from. Determines which symbolicator to use. pub platform: Platform, - /// Application identifier (package name, bundle id, etc.) + /// Application identifier (package name, bundle ID, app name). + /// Defaults to `"unknown"` if `None`. pub app_id: Option, - /// Application version + /// Application version (e.g., `"1.0.0"`). + /// Falls back to newest available if exact match not found. pub version: Option, - /// Build ID or commit hash + /// Build ID or commit hash. Reserved for future use. pub build_id: Option, } -/// A symbolicated stack frame. +/// A single stack frame with optional symbolication information. +/// +/// Represents one frame in a stack trace. If symbolication succeeded, +/// the `function`, `file`, and `line` fields contain human-readable +/// source information. If not, only the `raw` field contains the +/// original obfuscated/minified frame text. +/// +/// # Fields +/// +/// * `raw` - Original frame text as it appeared in the stack trace. +/// Always populated, useful for debugging and fallback display. +/// +/// * `function` - Symbolicated function or method name (e.g., `"MyClass.myMethod"`). +/// `None` if symbolication failed or function name unavailable. +/// +/// * `file` - Source file path (e.g., `"src/main.rs"`, `"MyClass.java"`). +/// `None` if symbolication failed or file info unavailable. +/// +/// * `line` - 1-based line number in the source file. +/// `None` if symbolication failed or line info unavailable. +/// +/// * `column` - 1-based column number in the source file. +/// `None` if symbolication failed or column info unavailable. +/// Primarily available for JavaScript/source map symbolication. +/// +/// * `symbolicated` - `true` if this frame was successfully symbolicated, +/// `false` if it contains only raw/unparsed data. +/// +/// # Display Format +/// +/// The [`display()`](Self::display) method formats frames as: +/// - Symbolicated: `"functionName (file.rs:42)"` or `"functionName (file.rs)"` or `"functionName"` +/// - Unsymbolicated: returns the raw text unchanged +/// +/// # Example +/// +/// ``` +/// use bugstr::symbolication::SymbolicatedFrame; +/// +/// // Create a symbolicated frame +/// let frame = SymbolicatedFrame::symbolicated( +/// "at a.b.c(Unknown:1)".to_string(), +/// "com.example.MyClass.method".to_string(), +/// Some("MyClass.java".to_string()), +/// Some(42), +/// None, +/// ); +/// assert!(frame.symbolicated); +/// assert_eq!(frame.display(), "com.example.MyClass.method (MyClass.java:42)"); +/// +/// // Create an unsymbolicated frame +/// let raw_frame = SymbolicatedFrame::raw("at a.b.c(Unknown:1)".to_string()); +/// assert!(!raw_frame.symbolicated); +/// assert_eq!(raw_frame.display(), "at a.b.c(Unknown:1)"); +/// ``` #[derive(Debug, Clone)] pub struct SymbolicatedFrame { - /// Original raw frame text + /// Original raw frame text as it appeared in the stack trace. pub raw: String, - /// Symbolicated function/method name + /// Symbolicated function/method name, or `None` if unavailable. pub function: Option, - /// Source file path + /// Source file path, or `None` if unavailable. pub file: Option, - /// Line number + /// 1-based line number, or `None` if unavailable. pub line: Option, - /// Column number + /// 1-based column number, or `None` if unavailable. pub column: Option, - /// Whether this frame was successfully symbolicated + /// Whether this frame was successfully symbolicated. pub symbolicated: bool, } @@ -199,20 +294,74 @@ impl SymbolicatedFrame { } /// Result of symbolicating a stack trace. +/// +/// Contains both the original raw stack trace and the processed frames, +/// along with statistics about symbolication success. This struct is returned +/// by [`Symbolicator::symbolicate`] on successful symbolication. +/// +/// # Fields +/// +/// * `raw` - The original stack trace text exactly as provided to the symbolicator. +/// Preserved for logging, debugging, and fallback display. +/// +/// * `frames` - Vector of [`SymbolicatedFrame`] objects, one per line/frame in the +/// stack trace. Frames maintain the same order as the original stack trace. +/// Each frame indicates whether it was successfully symbolicated via its +/// `symbolicated` field. +/// +/// * `symbolicated_count` - Number of frames where symbolication succeeded +/// (i.e., frames where `symbolicated == true`). Use this with `total_count` +/// to calculate success rate. +/// +/// * `total_count` - Total number of non-empty lines/frames in the stack trace. +/// Note: This counts all non-empty lines, which may differ from `frames.len()` +/// depending on the platform-specific parser implementation. +/// +/// # Example +/// +/// ``` +/// use bugstr::symbolication::SymbolicatedStack; +/// +/// fn process_result(result: SymbolicatedStack) { +/// println!("Symbolicated {}/{} frames ({:.1}%)", +/// result.symbolicated_count, +/// result.total_count, +/// result.percentage()); +/// +/// // Display the symbolicated stack +/// println!("{}", result.display()); +/// } +/// ``` #[derive(Debug)] pub struct SymbolicatedStack { - /// Original raw stack trace + /// Original raw stack trace text as provided to the symbolicator. pub raw: String, - /// Symbolicated frames + /// Processed frames in stack trace order. pub frames: Vec, - /// Number of frames that were successfully symbolicated + /// Count of frames where symbolication succeeded. pub symbolicated_count: usize, - /// Total number of frames + /// Total count of non-empty lines in the original stack trace. pub total_count: usize, } impl SymbolicatedStack { - /// Format the symbolicated stack for display. + /// Format the symbolicated stack trace for human-readable display. + /// + /// Iterates through all frames and calls [`SymbolicatedFrame::display()`] on each, + /// joining them with newlines. Symbolicated frames show function names and source + /// locations; unsymbolicated frames show the original raw text. + /// + /// # Returns + /// + /// A newline-separated string of all frames suitable for terminal output or logging. + /// + /// # Example + /// + /// ```text + /// com.example.MyClass.method (MyClass.java:42) + /// com.example.OtherClass.call (OtherClass.java:15) + /// at a.b.c(Unknown:1) + /// ``` pub fn display(&self) -> String { self.frames .iter() @@ -221,7 +370,28 @@ impl SymbolicatedStack { .join("\n") } - /// Get symbolication percentage. + /// Calculate the percentage of frames that were successfully symbolicated. + /// + /// Returns `(symbolicated_count / total_count) * 100.0`. If `total_count` is zero, + /// returns `0.0` to avoid division by zero. + /// + /// # Returns + /// + /// A floating-point percentage from `0.0` to `100.0`. + /// + /// # Example + /// + /// ``` + /// # use bugstr::symbolication::{SymbolicatedStack, SymbolicatedFrame}; + /// # let stack = SymbolicatedStack { + /// # raw: String::new(), + /// # frames: vec![], + /// # symbolicated_count: 8, + /// # total_count: 10, + /// # }; + /// let pct = stack.percentage(); + /// assert!((pct - 80.0).abs() < 0.001); + /// ``` pub fn percentage(&self) -> f64 { if self.total_count == 0 { 0.0 @@ -232,17 +402,123 @@ impl SymbolicatedStack { } /// Main symbolicator that dispatches to platform-specific implementations. +/// +/// `Symbolicator` is the primary entry point for stack trace symbolication. +/// It holds a [`MappingStore`] containing mapping files and dispatches +/// symbolication requests to the appropriate platform-specific implementation +/// based on the [`SymbolicationContext::platform`] field. +/// +/// # Supported Platforms +/// +/// - [`Platform::Android`] - Uses [`AndroidSymbolicator`] with ProGuard/R8 mapping.txt files +/// - [`Platform::Electron`] - Uses [`JavaScriptSymbolicator`] with source map files +/// - [`Platform::Flutter`] - Uses [`FlutterSymbolicator`] with Flutter symbol files +/// - [`Platform::Rust`] - Uses [`RustSymbolicator`] for backtrace parsing +/// - [`Platform::Go`] - Uses [`GoSymbolicator`] for goroutine stack parsing +/// - [`Platform::Python`] - Uses [`PythonSymbolicator`] for Python traceback parsing +/// - [`Platform::ReactNative`] - Uses [`ReactNativeSymbolicator`] with Hermes + JS source maps +/// +/// # Thread Safety +/// +/// `Symbolicator` is `Send` but not `Sync`. For use in async contexts with multiple +/// concurrent requests, wrap in `Arc` and use `spawn_blocking` for +/// the CPU-bound symbolication work. +/// +/// # Example +/// +/// ```rust,ignore +/// use bugstr::symbolication::{Symbolicator, MappingStore, Platform, SymbolicationContext}; +/// +/// // Create and scan mapping store +/// let mut store = MappingStore::new("/path/to/mappings"); +/// store.scan()?; +/// +/// let symbolicator = Symbolicator::new(store); +/// +/// let context = SymbolicationContext { +/// platform: Platform::Android, +/// app_id: Some("com.example.app".to_string()), +/// version: Some("1.0.0".to_string()), +/// build_id: None, +/// }; +/// +/// let stack = "java.lang.NullPointerException\n\tat a.b.c(Unknown:1)"; +/// match symbolicator.symbolicate(stack, &context) { +/// Ok(result) => println!("{}", result.display()), +/// Err(e) => eprintln!("Symbolication failed: {}", e), +/// } +/// ``` pub struct Symbolicator { store: MappingStore, } impl Symbolicator { /// Create a new symbolicator with the given mapping store. + /// + /// # Arguments + /// + /// * `store` - A [`MappingStore`] that has been populated with mapping files. + /// Call [`MappingStore::scan()`] before creating the symbolicator to load + /// available mapping files from disk. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut store = MappingStore::new("/path/to/mappings"); + /// store.scan()?; + /// let symbolicator = Symbolicator::new(store); + /// ``` pub fn new(store: MappingStore) -> Self { Self { store } } - /// Symbolicate a stack trace. + /// Symbolicate a stack trace using platform-specific logic. + /// + /// Dispatches to the appropriate platform symbolicator based on `context.platform`, + /// loads the corresponding mapping file from the store, and processes the stack trace. + /// + /// # Arguments + /// + /// * `stack_trace` - Raw stack trace text. Format varies by platform: + /// - Android: Java stack trace with `at` frames + /// - JavaScript/Electron: Error stack with `at` or `@` frames + /// - Flutter: Dart stack trace with `#N` numbered frames + /// - Rust: Backtrace with `N:` numbered frames + /// - Go: Goroutine stack with `goroutine N [status]:` header + /// - Python: Traceback with `File "...", line N` frames + /// - React Native: Mixed Hermes/JavaScript stack traces + /// + /// * `context` - [`SymbolicationContext`] providing platform, app ID, and version + /// for locating the correct mapping file. + /// + /// # Returns + /// + /// * `Ok(SymbolicatedStack)` - Successfully processed stack trace. Note that + /// individual frames may still be unsymbolicated if they couldn't be mapped; + /// check `symbolicated_count` vs `total_count` for success rate. + /// + /// * `Err(SymbolicationError)` - Symbolication failed. Possible errors: + /// - [`SymbolicationError::MappingNotFound`] - No mapping file for the given + /// platform/app/version combination (only for platforms that require mappings) + /// - [`SymbolicationError::ParseError`] - Mapping file exists but couldn't be parsed + /// - [`SymbolicationError::IoError`] - Failed to read mapping file from disk + /// - [`SymbolicationError::UnsupportedPlatform`] - Platform is `Unknown(...)`, + /// returned for unrecognized platform strings + /// - [`SymbolicationError::ToolError`] - External tool (e.g., `flutter symbolize`) + /// failed or is not available + /// + /// # Example + /// + /// ```rust,ignore + /// let result = symbolicator.symbolicate(stack_trace, &context)?; + /// + /// if result.symbolicated_count == result.total_count { + /// println!("Fully symbolicated:"); + /// } else { + /// println!("Partially symbolicated ({:.0}%):", result.percentage()); + /// } + /// println!("{}", result.display()); + /// ``` pub fn symbolicate( &self, stack_trace: &str, diff --git a/rust/src/symbolication/store.rs b/rust/src/symbolication/store.rs index 20d8425..088ff8f 100644 --- a/rust/src/symbolication/store.rs +++ b/rust/src/symbolication/store.rs @@ -1,6 +1,43 @@ //! Mapping file storage and management. //! -//! Manages mapping files for different platforms and versions. +//! This module provides [`MappingStore`], a file-system-based storage system for +//! mapping files used during stack trace symbolication. It organizes files in a +//! hierarchical directory structure by platform, application ID, and version. +//! +//! # Directory Structure +//! +//! ```text +//! / +//! / # e.g., "android", "electron", "flutter" +//! / # e.g., "com.example.app", "my-desktop-app" +//! / # e.g., "1.0.0", "2.1.3" +//! # e.g., "mapping.txt", "main.js.map" +//! ``` +//! +//! # Example +//! +//! ```rust,ignore +//! use bugstr::symbolication::{MappingStore, Platform}; +//! +//! // Create and scan store +//! let mut store = MappingStore::new("./mappings"); +//! let count = store.scan()?; +//! println!("Loaded {} mapping files", count); +//! +//! // Look up a mapping (with version fallback) +//! if let Some(info) = store.get_with_fallback(&Platform::Android, "com.myapp", "1.2.0") { +//! println!("Found mapping at: {:?}", info.path); +//! } +//! +//! // Save a new mapping file +//! store.save_mapping( +//! Platform::Android, +//! "com.myapp", +//! "1.3.0", +//! "mapping.txt", +//! mapping_content.as_bytes(), +//! )?; +//! ``` use std::collections::HashMap; use std::fs; @@ -28,37 +65,104 @@ pub struct MappingInfo { pub loaded_at: std::time::SystemTime, } -/// Storage for mapping files. +/// Storage and management for symbolication mapping files. +/// +/// `MappingStore` provides a file-system-based storage system for mapping files +/// organized by platform, application ID, and version. It supports scanning +/// existing files, looking up mappings with version fallback, and saving new +/// mapping files with path validation. +/// +/// # Directory Structure +/// +/// Mapping files are organized in a three-level hierarchy: +/// +/// ```text +/// / +/// android/ +/// com.example.app/ +/// 1.0.0/ +/// mapping.txt # ProGuard/R8 mapping +/// 1.1.0/ +/// mapping.txt +/// electron/ +/// my-desktop-app/ +/// 1.0.0/ +/// main.js.map # Source map +/// renderer.js.map +/// flutter/ +/// com.example.app/ +/// 1.0.0/ +/// app.android-arm64.symbols +/// ``` +/// +/// # Thread Safety +/// +/// `MappingStore` is **not thread-safe**. For concurrent access, wrap in +/// `Arc>` or use separate instances per thread. +/// The [`scan()`](Self::scan) method clears and rebuilds the internal cache, +/// so concurrent reads during a scan will produce inconsistent results. +/// +/// # Security +/// +/// The [`save_mapping()`](Self::save_mapping) method validates all path components +/// to prevent directory traversal attacks. It rejects: +/// - Absolute paths +/// - Path components containing `..` +/// - Path components containing path separators (`/` or `\`) +/// - Empty path components +/// +/// # Example /// -/// Organizes mapping files by platform/app/version and provides -/// lookup functionality for symbolicators. +/// ```rust,ignore +/// use bugstr::symbolication::{MappingStore, Platform}; +/// +/// // Create store pointing to mappings directory +/// let mut store = MappingStore::new("./mappings"); +/// +/// // Scan to discover existing mapping files +/// let count = store.scan()?; +/// println!("Found {} mapping files", count); +/// +/// // Look up a mapping with version fallback +/// if let Some(info) = store.get_with_fallback(&Platform::Android, "com.myapp", "1.0.0") { +/// println!("Using mapping: {:?}", info.path); +/// } +/// +/// // Save a new mapping file +/// store.save_mapping( +/// Platform::Android, +/// "com.myapp", +/// "2.0.0", +/// "mapping.txt", +/// content.as_bytes(), +/// )?; +/// ``` pub struct MappingStore { - /// Root directory for mapping files + /// Root directory for mapping files. root: PathBuf, - /// Cached mapping file paths + /// In-memory cache of discovered mapping files, keyed by platform/app/version. mappings: HashMap, } impl MappingStore { /// Create a new mapping store at the given root directory. /// - /// Directory structure: - /// ```text - /// root/ - /// android/ - /// com.example.app/ - /// 1.0.0/ - /// mapping.txt - /// electron/ - /// my-app/ - /// 1.0.0/ - /// main.js.map - /// renderer.js.map - /// flutter/ - /// com.example.app/ - /// 1.0.0/ - /// app.android-arm64.symbols - /// ... + /// Creates an empty `MappingStore` instance. The directory does not need + /// to exist yet; it will be created by [`scan()`](Self::scan) if missing. + /// Call `scan()` after construction to discover existing mapping files. + /// + /// # Arguments + /// + /// * `root` - Path to the root directory for mapping files. Can be any type + /// implementing `AsRef` (e.g., `&str`, `String`, `PathBuf`). + /// + /// # Example + /// + /// ```rust,ignore + /// use bugstr::symbolication::MappingStore; + /// + /// let store = MappingStore::new("./mappings"); + /// let store = MappingStore::new(PathBuf::from("/var/lib/bugstr/mappings")); /// ``` pub fn new>(root: P) -> Self { Self { @@ -67,12 +171,54 @@ impl MappingStore { } } - /// Get the root directory. + /// Get a reference to the root directory path. + /// + /// # Returns + /// + /// Borrowed reference to the root directory `Path`. pub fn root(&self) -> &Path { &self.root } /// Scan the root directory and load all mapping file metadata. + /// + /// Recursively walks the directory structure looking for mapping files. + /// Each discovered mapping is indexed by its platform/app_id/version tuple. + /// + /// # Side Effects + /// + /// - **Clears** the internal mapping cache before scanning + /// - **Creates** the root directory if it doesn't exist + /// - Does **not** load file contents into memory (only paths are cached) + /// + /// # Returns + /// + /// * `Ok(count)` - Number of mapping files discovered + /// * `Err(SymbolicationError::IoError)` - Failed to read directory or create root + /// + /// # Platform-Specific Files + /// + /// The scanner looks for these files by platform: + /// - **Android**: `mapping.txt`, `proguard-mapping.txt`, `r8-mapping.txt` + /// - **Electron**: `main.js.map`, `index.js.map`, `bundle.js.map` + /// - **Flutter**: `app.android-arm64.symbols`, `app.ios-arm64.symbols`, `app.symbols` + /// - **Rust**: `symbols.txt`, `debug.dwarf` + /// - **Go**: `symbols.txt`, `go.sym` + /// - **Python**: `source-map.json`, `mapping.json` + /// - **React Native**: `index.android.bundle.map`, `index.ios.bundle.map`, `main.jsbundle.map` + /// + /// Falls back to any `.map`, `.txt`, or `.symbols` file if primary names not found. + /// + /// # Example + /// + /// ```rust,ignore + /// let mut store = MappingStore::new("./mappings"); + /// match store.scan() { + /// Ok(0) => println!("No mapping files found"), + /// Ok(n) => println!("Loaded {} mapping files", n), + /// Err(e) => eprintln!("Scan failed: {}", e), + /// } + /// ``` pub fn scan(&mut self) -> Result { self.mappings.clear(); let mut count = 0; @@ -173,7 +319,30 @@ impl MappingStore { None } - /// Get mapping info for a specific app version. + /// Get mapping info for a specific platform/app/version combination. + /// + /// Performs an exact match lookup in the cache. Returns `None` if no mapping + /// was found for the exact combination. For fallback behavior, use + /// [`get_with_fallback()`](Self::get_with_fallback). + /// + /// # Arguments + /// + /// * `platform` - Platform to look up (e.g., `Platform::Android`) + /// * `app_id` - Application identifier (e.g., `"com.example.app"`) + /// * `version` - Exact version string (e.g., `"1.0.0"`) + /// + /// # Returns + /// + /// * `Some(&MappingInfo)` - Mapping found for exact match + /// * `None` - No mapping for this exact combination + /// + /// # Example + /// + /// ```rust,ignore + /// if let Some(info) = store.get(&Platform::Android, "com.myapp", "1.0.0") { + /// let content = std::fs::read_to_string(&info.path)?; + /// } + /// ``` pub fn get(&self, platform: &Platform, app_id: &str, version: &str) -> Option<&MappingInfo> { let key = MappingKey { platform: platform.clone(), @@ -183,9 +352,39 @@ impl MappingStore { self.mappings.get(&key) } - /// Get mapping info, trying version fallbacks. + /// Get mapping info with version fallback. /// - /// Tries exact version first, then looks for closest match using semantic versioning. + /// First attempts an exact version match. If not found, returns the mapping + /// for the **newest available version** of the same app/platform, using + /// semantic versioning comparison. + /// + /// This is useful when crash reports may reference versions that don't have + /// their own mapping files, but an older or newer mapping may still be useful. + /// + /// # Arguments + /// + /// * `platform` - Platform to look up + /// * `app_id` - Application identifier + /// * `version` - Preferred version (exact match attempted first) + /// + /// # Returns + /// + /// * `Some(&MappingInfo)` - Mapping found (exact or fallback) + /// * `None` - No mappings exist for this app/platform at any version + /// + /// # Version Comparison + /// + /// Uses the [`semver`] crate for version comparison. Non-semver version strings + /// fall back to lexicographic comparison. Valid semver versions sort higher than + /// invalid version strings. + /// + /// # Example + /// + /// ```rust,ignore + /// // If 1.0.0 exists but 1.0.1 doesn't, returns 1.0.0 mapping + /// // If only 2.0.0 exists, returns 2.0.0 (newest available) + /// let info = store.get_with_fallback(&Platform::Android, "com.myapp", "1.0.1"); + /// ``` pub fn get_with_fallback( &self, platform: &Platform, @@ -213,7 +412,23 @@ impl MappingStore { .map(|(_, v)| v) } - /// Add a mapping file manually. + /// Add a mapping file to the cache manually. + /// + /// Registers a mapping file in the internal cache without scanning the filesystem. + /// Useful for adding mappings after [`save_mapping()`](Self::save_mapping) or for + /// testing. Does not validate that the file exists. + /// + /// # Arguments + /// + /// * `platform` - Platform for this mapping + /// * `app_id` - Application identifier + /// * `version` - Version string + /// * `path` - Path to the mapping file + /// + /// # Note + /// + /// This method takes ownership of the `app_id` and `version` strings. + /// If a mapping already exists for the same platform/app/version, it is replaced. pub fn add_mapping( &mut self, platform: Platform, @@ -239,11 +454,47 @@ impl MappingStore { } /// List all loaded mappings. + /// + /// Returns an iterator over all [`MappingInfo`] entries in the cache. + /// Order is not guaranteed (depends on internal `HashMap` iteration). + /// + /// # Example + /// + /// ```rust,ignore + /// for info in store.list() { + /// println!("{}/{}/{}: {:?}", + /// info.platform.as_str(), + /// info.app_id, + /// info.version, + /// info.path + /// ); + /// } + /// ``` pub fn list(&self) -> impl Iterator { self.mappings.values() } - /// Get the expected path for a new mapping file. + /// Get the expected filesystem path for a mapping file. + /// + /// Constructs the path where a mapping file would be stored based on + /// the directory layout convention: `////`. + /// + /// # Arguments + /// + /// * `platform` - Platform (determines first directory component) + /// * `app_id` - Application identifier + /// * `version` - Version string + /// * `filename` - Mapping file name (e.g., `"mapping.txt"`) + /// + /// # Returns + /// + /// Constructed `PathBuf`. Does not validate the path components or check + /// if the file exists. + /// + /// # Warning + /// + /// This method does **not** validate path components. For safe file writing, + /// use [`save_mapping()`](Self::save_mapping) which validates all inputs. pub fn mapping_path( &self, platform: &Platform, @@ -258,7 +509,102 @@ impl MappingStore { .join(filename) } - /// Save a mapping file to the store. + /// Validate a path component for safe filesystem usage. + /// + /// Ensures the component cannot be used for directory traversal attacks. + /// + /// # Errors + /// + /// Returns `Err(SymbolicationError::InvalidPath)` if: + /// - Component is empty + /// - Component is exactly `"."` or `".."` + /// - Component contains `..` (parent directory reference) + /// - Component contains `/` or `\` (path separators) + /// - Component starts with `/` or `\` (absolute path attempt) + fn validate_path_component(component: &str, name: &str) -> Result<(), SymbolicationError> { + if component.is_empty() { + return Err(SymbolicationError::InvalidPath(format!( + "{} cannot be empty", + name + ))); + } + + if component == "." || component == ".." { + return Err(SymbolicationError::InvalidPath(format!( + "{} cannot be '.' or '..'", + name + ))); + } + + if component.contains("..") { + return Err(SymbolicationError::InvalidPath(format!( + "{} cannot contain '..'", + name + ))); + } + + if component.contains('/') || component.contains('\\') { + return Err(SymbolicationError::InvalidPath(format!( + "{} cannot contain path separators", + name + ))); + } + + Ok(()) + } + + /// Save a mapping file to the store with path validation. + /// + /// Writes the mapping file content to the appropriate location in the + /// directory hierarchy and adds it to the internal cache. + /// + /// # Arguments + /// + /// * `platform` - Platform for this mapping. `Platform::Unknown` is allowed + /// and uses the contained string as the directory name. + /// * `app_id` - Application identifier (e.g., `"com.example.app"`). + /// Used as a directory name; must not contain path separators or `..`. + /// * `version` - Version string (e.g., `"1.0.0"`). + /// Used as a directory name; must not contain path separators or `..`. + /// * `filename` - Name of the mapping file (e.g., `"mapping.txt"`). + /// Must not contain path separators or `..`. + /// * `content` - Raw bytes to write to the file. + /// + /// # Returns + /// + /// * `Ok(PathBuf)` - Path where the file was written + /// * `Err(SymbolicationError::InvalidPath)` - A path component failed validation + /// * `Err(SymbolicationError::IoError)` - Failed to create directories or write file + /// + /// # Security + /// + /// All path components are validated to prevent directory traversal attacks: + /// - Rejects empty components + /// - Rejects components containing `..` + /// - Rejects components containing `/` or `\` + /// + /// # Side Effects + /// + /// - Creates parent directories if they don't exist + /// - Overwrites existing file at the target path + /// - Adds mapping to internal cache + /// + /// # Example + /// + /// ```rust,ignore + /// let path = store.save_mapping( + /// Platform::Android, + /// "com.myapp", + /// "1.0.0", + /// "mapping.txt", + /// mapping_content.as_bytes(), + /// )?; + /// println!("Saved mapping to: {:?}", path); + /// + /// // These will fail with InvalidPath error: + /// store.save_mapping(Platform::Android, "../etc", "1.0", "passwd", b"")?; // Error + /// store.save_mapping(Platform::Android, "app", "1.0", "/etc/passwd", b"")?; // Error + /// ``` pub fn save_mapping( &mut self, platform: Platform, @@ -267,6 +613,12 @@ impl MappingStore { filename: &str, content: &[u8], ) -> Result { + // Validate all path components to prevent directory traversal + Self::validate_path_component(platform.as_str(), "platform")?; + Self::validate_path_component(app_id, "app_id")?; + Self::validate_path_component(version, "version")?; + Self::validate_path_component(filename, "filename")?; + let path = self.mapping_path(&platform, app_id, version, filename); // Create parent directories @@ -309,4 +661,82 @@ mod tests { assert_eq!(count, 1); assert!(store.get(&Platform::Android, "com.test.app", "1.0.0").is_some()); } + + #[test] + fn test_validate_path_component_rejects_parent_traversal() { + assert!(MappingStore::validate_path_component("..", "test").is_err()); + assert!(MappingStore::validate_path_component("../etc", "test").is_err()); + assert!(MappingStore::validate_path_component("foo/../bar", "test").is_err()); + } + + #[test] + fn test_validate_path_component_rejects_path_separators() { + assert!(MappingStore::validate_path_component("foo/bar", "test").is_err()); + assert!(MappingStore::validate_path_component("foo\\bar", "test").is_err()); + assert!(MappingStore::validate_path_component("/etc/passwd", "test").is_err()); + } + + #[test] + fn test_validate_path_component_rejects_empty() { + assert!(MappingStore::validate_path_component("", "test").is_err()); + } + + #[test] + fn test_validate_path_component_rejects_dot() { + assert!(MappingStore::validate_path_component(".", "test").is_err()); + } + + #[test] + fn test_validate_path_component_allows_valid() { + assert!(MappingStore::validate_path_component("com.example.app", "test").is_ok()); + assert!(MappingStore::validate_path_component("1.0.0", "test").is_ok()); + assert!(MappingStore::validate_path_component("mapping.txt", "test").is_ok()); + assert!(MappingStore::validate_path_component("my-app_v2", "test").is_ok()); + } + + #[test] + fn test_save_mapping_validates_paths() { + let dir = tempdir().unwrap(); + let mut store = MappingStore::new(dir.path()); + + // Valid save should succeed + let result = store.save_mapping( + Platform::Android, + "com.test.app", + "1.0.0", + "mapping.txt", + b"# test", + ); + assert!(result.is_ok()); + + // Directory traversal in app_id should fail + let result = store.save_mapping( + Platform::Android, + "../etc", + "1.0.0", + "passwd", + b"malicious", + ); + assert!(matches!(result, Err(SymbolicationError::InvalidPath(_)))); + + // Path separator in filename should fail + let result = store.save_mapping( + Platform::Android, + "com.test.app", + "1.0.0", + "/etc/passwd", + b"malicious", + ); + assert!(matches!(result, Err(SymbolicationError::InvalidPath(_)))); + + // Empty version should fail + let result = store.save_mapping( + Platform::Android, + "com.test.app", + "", + "mapping.txt", + b"test", + ); + assert!(matches!(result, Err(SymbolicationError::InvalidPath(_)))); + } } From 220b422a713f213e6ad386ada7f6785d51af1eec Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 12:14:46 -0600 Subject: [PATCH 11/13] fix: ProGuard/R8 parsing with line range and overload support Fix three issues in Android symbolication: 1. HIGH: Support R8/ProGuard :origStart:origEnd format - Parse method lines like "1:5:void m():100:104 -> a" - Correctly map obfuscated line numbers to original source lines 2. MEDIUM: Handle overloaded/inlined methods correctly - Store each line range with its associated method name - Different line ranges can map to different original methods 3. MEDIUM: Preserve original line number when mapping absent - Return original line number instead of None when no range matches - Useful location info no longer lost Add 5 new tests covering R8 format, overloads, and line preservation. Co-Authored-By: Claude Opus 4.5 --- rust/src/symbolication/android.rs | 336 +++++++++++++++++++++++------- 1 file changed, 262 insertions(+), 74 deletions(-) diff --git a/rust/src/symbolication/android.rs b/rust/src/symbolication/android.rs index ba5c901..1c7e208 100644 --- a/rust/src/symbolication/android.rs +++ b/rust/src/symbolication/android.rs @@ -1,7 +1,21 @@ //! Android symbolication using ProGuard/R8 mapping files. //! //! Parses ProGuard mapping.txt files and uses them to deobfuscate -//! Android stack traces. +//! Android stack traces. Supports the full R8/ProGuard line range format +//! including original line number mappings for inlined methods. +//! +//! # ProGuard Mapping Format +//! +//! ```text +//! original.ClassName -> obfuscated.name: +//! returnType methodName(params) -> obfuscatedMethod +//! startLine:endLine:returnType methodName(params) -> obfuscatedMethod +//! startLine:endLine:returnType methodName(params):origStart:origEnd -> obfuscatedMethod +//! startLine:endLine:returnType methodName(params):origStart -> obfuscatedMethod +//! ``` +//! +//! The `:origStart:origEnd` suffix indicates the original source line range, +//! which differs from the obfuscated line range when methods are inlined. use std::collections::HashMap; use std::fs; @@ -13,32 +27,43 @@ use super::{ MappingStore, SymbolicatedFrame, SymbolicatedStack, SymbolicationContext, SymbolicationError, }; +/// A single line range mapping entry. +/// +/// Each entry maps an obfuscated line range to an original method and line range. +/// Multiple entries can exist for the same obfuscated method name when methods +/// are inlined or overloaded. +#[derive(Debug, Clone)] +struct LineRangeEntry { + /// Obfuscated line range start + obf_start: u32, + /// Obfuscated line range end + obf_end: u32, + /// Original line range start + orig_start: u32, + /// Original line range end + orig_end: u32, + /// Original method name for this line range + method_name: String, +} + /// Parsed ProGuard mapping entry for a class. #[derive(Debug, Clone)] struct ClassMapping { /// Original class name original: String, /// Obfuscated class name + #[allow(dead_code)] obfuscated: String, - /// Method mappings (obfuscated -> original) - methods: HashMap, + /// Line range mappings indexed by obfuscated method name. + /// Each method can have multiple line ranges (for inlined/overloaded methods). + method_line_ranges: HashMap>, + /// Methods without line info (obfuscated -> original name) + methods_no_lines: HashMap, /// Field mappings (obfuscated -> original) + #[allow(dead_code)] fields: HashMap, } -/// Parsed ProGuard mapping entry for a method. -#[derive(Debug, Clone)] -struct MethodMapping { - /// Original method name - original: String, - /// Original return type - return_type: String, - /// Original parameter types - parameters: Vec, - /// Line number mapping (obfuscated -> original) - line_mapping: Vec<(u32, u32, u32, u32)>, // (start_obf, end_obf, start_orig, end_orig) -} - /// Parsed ProGuard mapping file. #[derive(Debug)] struct ProguardMapping { @@ -48,18 +73,31 @@ struct ProguardMapping { impl ProguardMapping { /// Parse a ProGuard mapping file. + /// + /// Handles the full R8/ProGuard format including: + /// - `startLine:endLine:returnType method(params) -> obfuscated` + /// - `startLine:endLine:returnType method(params):origStart -> obfuscated` + /// - `startLine:endLine:returnType method(params):origStart:origEnd -> obfuscated` fn parse(reader: R) -> Result { let mut classes = HashMap::new(); let mut current_class: Option = None; // Regex patterns let class_re = Regex::new(r"^(\S+)\s+->\s+(\S+):$").unwrap(); + + // Method with line numbers and optional original line range + // Format: startLine:endLine:returnType methodName(params):origStart:origEnd -> obfuscated + // or: startLine:endLine:returnType methodName(params):origStart -> obfuscated + // or: startLine:endLine:returnType methodName(params) -> obfuscated let method_re = Regex::new( - r"^\s+(\d+):(\d+):(\S+)\s+(\S+)\((.*)\)\s+->\s+(\S+)$" + r"^\s+(\d+):(\d+):(\S+)\s+(\S+)\(([^)]*)\)(?::(\d+)(?::(\d+))?)?\s+->\s+(\S+)$" ).unwrap(); + + // Method without line numbers let method_no_line_re = Regex::new( r"^\s+(\S+)\s+([^\s(]+)\(([^)]*)\)\s+->\s+(\S+)$" ).unwrap(); + let field_re = Regex::new(r"^\s+(\S+)\s+(\S+)\s+->\s+(\S+)$").unwrap(); for line in reader.lines() { @@ -80,7 +118,8 @@ impl ProguardMapping { current_class = Some(ClassMapping { original: caps[1].to_string(), obfuscated: caps[2].to_string(), - methods: HashMap::new(), + method_line_ranges: HashMap::new(), + methods_no_lines: HashMap::new(), fields: HashMap::new(), }); continue; @@ -90,53 +129,48 @@ impl ProguardMapping { if let Some(ref mut class) = current_class { // Method with line numbers if let Some(caps) = method_re.captures(&line) { - let start_line: u32 = caps[1].parse().unwrap_or(0); - let end_line: u32 = caps[2].parse().unwrap_or(0); - let return_type = caps[3].to_string(); + let obf_start: u32 = caps[1].parse().unwrap_or(0); + let obf_end: u32 = caps[2].parse().unwrap_or(0); + let _return_type = &caps[3]; let method_name = caps[4].to_string(); - let params = caps[5].to_string(); - let obfuscated_name = caps[6].to_string(); - - let parameters: Vec = if params.is_empty() { - vec![] - } else { - params.split(',').map(|s| s.trim().to_string()).collect() + let _params = &caps[5]; + // Original line start (group 6) - if present + let orig_start: u32 = caps.get(6) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(obf_start); + // Original line end (group 7) - if present + let orig_end: u32 = caps.get(7) + .and_then(|m| m.as_str().parse().ok()) + .unwrap_or(orig_start + (obf_end - obf_start)); + let obfuscated_name = caps[8].to_string(); + + let entry = LineRangeEntry { + obf_start, + obf_end, + orig_start, + orig_end, + method_name, }; - let mapping = class.methods.entry(obfuscated_name).or_insert_with(|| { - MethodMapping { - original: method_name.clone(), - return_type: return_type.clone(), - parameters: parameters.clone(), - line_mapping: vec![], - } - }); - - mapping.line_mapping.push((start_line, end_line, start_line, end_line)); + class.method_line_ranges + .entry(obfuscated_name) + .or_insert_with(Vec::new) + .push(entry); continue; } // Method without line numbers if let Some(caps) = method_no_line_re.captures(&line) { - let return_type = caps[1].to_string(); + let _return_type = &caps[1]; let method_name = caps[2].to_string(); - let params = caps[3].to_string(); + let _params = &caps[3]; let obfuscated_name = caps[4].to_string(); - let parameters: Vec = if params.is_empty() { - vec![] - } else { - params.split(',').map(|s| s.trim().to_string()).collect() - }; - - class.methods.entry(obfuscated_name).or_insert_with(|| { - MethodMapping { - original: method_name, - return_type, - parameters, - line_mapping: vec![], - } - }); + // Only store if we don't already have line range info for this method + if !class.method_line_ranges.contains_key(&obfuscated_name) { + class.methods_no_lines.entry(obfuscated_name) + .or_insert(method_name); + } continue; } @@ -160,41 +194,77 @@ impl ProguardMapping { } /// Deobfuscate a class name. + #[allow(dead_code)] fn deobfuscate_class(&self, obfuscated: &str) -> Option<&str> { self.classes.get(obfuscated).map(|c| c.original.as_str()) } - /// Deobfuscate a method name. + /// Deobfuscate a method name (without line number context). + #[allow(dead_code)] fn deobfuscate_method(&self, class: &str, method: &str) -> Option<&str> { - self.classes - .get(class) - .and_then(|c| c.methods.get(method)) - .map(|m| m.original.as_str()) + let class_mapping = self.classes.get(class)?; + + // First check methods without line info + if let Some(name) = class_mapping.methods_no_lines.get(method) { + return Some(name.as_str()); + } + + // Then check line range entries (return first match) + if let Some(entries) = class_mapping.method_line_ranges.get(method) { + if let Some(entry) = entries.first() { + return Some(entry.method_name.as_str()); + } + } + + None } /// Deobfuscate a full stack frame. - fn deobfuscate_frame(&self, class: &str, method: &str, line: Option) -> Option<(String, String, Option)> { + /// + /// Returns (original_class, original_method, original_line). + /// Preserves the original line number if no mapping is found. + fn deobfuscate_frame( + &self, + class: &str, + method: &str, + line: Option, + ) -> Option<(String, String, Option)> { let class_mapping = self.classes.get(class)?; let original_class = &class_mapping.original; - let method_mapping = class_mapping.methods.get(method); - let original_method = method_mapping - .map(|m| m.original.as_str()) - .unwrap_or(method); - - // Try to map line number - let original_line = line.and_then(|l| { - method_mapping.and_then(|m| { - for (start_obf, end_obf, start_orig, _end_orig) in &m.line_mapping { - if l >= *start_obf && l <= *end_obf { - return Some(start_orig + (l - start_obf)); + // Try to find method and line mapping + if let Some(line_num) = line { + // Check line range entries for this obfuscated method + if let Some(entries) = class_mapping.method_line_ranges.get(method) { + for entry in entries { + if line_num >= entry.obf_start && line_num <= entry.obf_end { + // Found matching line range - calculate original line + let offset = line_num - entry.obf_start; + let orig_line = entry.orig_start + offset; + return Some(( + original_class.clone(), + entry.method_name.clone(), + Some(orig_line), + )); } } - Some(l) // Return original if no mapping found + } + } + + // No line range match - try to get method name without line info + let original_method = class_mapping.methods_no_lines.get(method) + .map(|s| s.as_str()) + .or_else(|| { + // Fallback: use first line range entry's method name if available + class_mapping.method_line_ranges.get(method) + .and_then(|entries| entries.first()) + .map(|e| e.method_name.as_str()) }) - }); + .unwrap_or(method); - Some((original_class.clone(), original_method.to_string(), original_line)) + // IMPORTANT: Preserve original line number when method mapping exists + // but line range doesn't match + Some((original_class.clone(), original_method.to_string(), line)) } } @@ -323,4 +393,122 @@ com.example.OtherClass -> a.b: Some("myMethod") ); } + + #[test] + fn test_parse_r8_format_with_original_line_ranges() { + // R8 format with :origStart:origEnd suffix + let mapping_content = r#" +com.example.Inlined -> a.a: + 1:5:void inlinedMethod():100:104 -> a + 6:10:void anotherMethod():200:204 -> a +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + // Line 3 in obfuscated maps to line 102 in original (100 + offset 2) + let result = mapping.deobfuscate_frame("a.a", "a", Some(3)); + assert!(result.is_some()); + let (class, method, line) = result.unwrap(); + assert_eq!(class, "com.example.Inlined"); + assert_eq!(method, "inlinedMethod"); + assert_eq!(line, Some(102)); + + // Line 8 in obfuscated maps to line 202 in original (200 + offset 2) + let result = mapping.deobfuscate_frame("a.a", "a", Some(8)); + assert!(result.is_some()); + let (class, method, line) = result.unwrap(); + assert_eq!(class, "com.example.Inlined"); + assert_eq!(method, "anotherMethod"); + assert_eq!(line, Some(202)); + } + + #[test] + fn test_parse_r8_format_with_single_original_line() { + // R8 format with just :origStart (no origEnd) + let mapping_content = r#" +com.example.MyClass -> a.a: + 1:3:void singleLine():50 -> b +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + // Line 2 maps to 51 (50 + offset 1) + let result = mapping.deobfuscate_frame("a.a", "b", Some(2)); + assert!(result.is_some()); + let (_, method, line) = result.unwrap(); + assert_eq!(method, "singleLine"); + assert_eq!(line, Some(51)); + } + + #[test] + fn test_overloaded_methods_different_line_ranges() { + // Multiple methods with same obfuscated name but different line ranges + let mapping_content = r#" +com.example.Overloads -> a.a: + 1:5:void process(int):10:14 -> a + 6:10:void process(java.lang.String):20:24 -> a + 11:15:void helper():30:34 -> a +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + // Line 3 -> process(int) at line 12 + let result = mapping.deobfuscate_frame("a.a", "a", Some(3)); + let (_, method, line) = result.unwrap(); + assert_eq!(method, "process"); + assert_eq!(line, Some(12)); + + // Line 8 -> process(String) at line 22 + let result = mapping.deobfuscate_frame("a.a", "a", Some(8)); + let (_, method, line) = result.unwrap(); + assert_eq!(method, "process"); + assert_eq!(line, Some(22)); + + // Line 13 -> helper at line 32 + let result = mapping.deobfuscate_frame("a.a", "a", Some(13)); + let (_, method, line) = result.unwrap(); + assert_eq!(method, "helper"); + assert_eq!(line, Some(32)); + } + + #[test] + fn test_preserve_line_number_when_method_mapping_missing() { + let mapping_content = r#" +com.example.MyClass -> a.a: + void knownMethod() -> a +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + // Unknown method 'b' with line 42 - should preserve the line number + let result = mapping.deobfuscate_frame("a.a", "b", Some(42)); + assert!(result.is_some()); + let (class, method, line) = result.unwrap(); + assert_eq!(class, "com.example.MyClass"); + assert_eq!(method, "b"); // Unknown method name preserved + assert_eq!(line, Some(42)); // Line number preserved! + } + + #[test] + fn test_preserve_line_number_when_line_range_not_matched() { + let mapping_content = r#" +com.example.MyClass -> a.a: + 1:10:void myMethod():100:109 -> a +"#; + + let reader = Cursor::new(mapping_content); + let mapping = ProguardMapping::parse(reader).unwrap(); + + // Line 50 is outside the mapped range 1-10, should preserve original line + let result = mapping.deobfuscate_frame("a.a", "a", Some(50)); + assert!(result.is_some()); + let (class, method, line) = result.unwrap(); + assert_eq!(class, "com.example.MyClass"); + assert_eq!(method, "myMethod"); // Method name still resolved + assert_eq!(line, Some(50)); // Line number preserved since no range matched + } } From 9b89796b904bd9c7583c76ff5cc9fb66a610bea9 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 12:17:28 -0600 Subject: [PATCH 12/13] fix(electron): guard clearPendingReports against uninitialized store Add initialization check to clearPendingReports() to prevent runtime error when called before init(). Now silently returns (no-op) like processPendingReports() and captureException(). Co-Authored-By: Claude Opus 4.5 --- electron/src/sdk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/electron/src/sdk.ts b/electron/src/sdk.ts index a9d7cfd..0370160 100644 --- a/electron/src/sdk.ts +++ b/electron/src/sdk.ts @@ -135,8 +135,9 @@ function removeReport(id: string): void { store.set("pendingReports", reports.filter((r) => r.id !== id)); } -/** Clear all pending reports */ +/** Clear all pending reports. No-op if not initialized. */ export function clearPendingReports(): void { + if (!initialized) return; store.set("pendingReports", []); } From 94c5027ff5393d8ae5d32146e59d84933b764c72 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Fri, 16 Jan 2026 12:20:18 -0600 Subject: [PATCH 13/13] docs: update CHANGELOGs with recent fixes Rust: - ProGuard/R8 :origStart:origEnd line range support - Overloaded method differentiation by line range - Line number preservation when mapping missing - Path validation security in MappingStore Electron: - clearPendingReports() guard against uninitialized store Co-Authored-By: Claude Opus 4.5 --- electron/CHANGELOG.md | 3 +++ rust/CHANGELOG.md | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/electron/CHANGELOG.md b/electron/CHANGELOG.md index 4a9a1a5..5600ca1 100644 --- a/electron/CHANGELOG.md +++ b/electron/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Moved to bugstr monorepo from standalone bugstr-ts repository +### Fixed +- `clearPendingReports()` no longer throws when called before `init()` + ## [0.1.0] - 2025-01-15 ### Added diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 65cd593..f77f0d6 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -25,7 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - None ### Fixed -- None +- ProGuard/R8 parsing now supports `:origStart:origEnd` line range format +- Overloaded/inlined methods with same obfuscated name now correctly differentiated by line range +- Original line numbers preserved when method mapping is missing or line range doesn't match +- Path validation in `MappingStore.save_mapping` prevents directory traversal attacks ## [0.1.0] - 2025-01-15