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/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", []); } diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md new file mode 100644 index 0000000..f77f0d6 --- /dev/null +++ b/rust/CHANGELOG.md @@ -0,0 +1,43 @@ +# 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 + +### Changed +- None + +### Fixed +- 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 + +### 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 diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e9dee4f..5d6a559 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -41,4 +41,10 @@ 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" +tempfile = "3.14" +semver = "1.0" + [dev-dependencies] diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index a1378af..581d7f1 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}; @@ -63,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) @@ -71,6 +78,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 +114,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, @@ -105,12 +145,23 @@ 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)?; } + Commands::Symbolicate { + platform, + input, + mappings, + app_id, + version, + format, + } => { + symbolicate_stack(&platform, &input, &mappings, app_id, version, format)?; + } } Ok(()) @@ -140,12 +191,232 @@ fn show_pubkey(privkey: &str) -> Result<(), Box> { Ok(()) } +/// 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, + 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 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 + 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, relays: &[String], port: u16, db_path: PathBuf, + mappings_dir: Option, ) -> Result<(), Box> { let secret = parse_privkey(privkey)?; let keys = Keys::new(secret); @@ -153,7 +424,36 @@ 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 = 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(Arc::new(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), + symbolicator, + }); println!("{}", "━".repeat(60).dimmed()); println!( @@ -165,6 +465,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/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..1c7e208 --- /dev/null +++ b/rust/src/symbolication/android.rs @@ -0,0 +1,514 @@ +//! Android symbolication using ProGuard/R8 mapping files. +//! +//! Parses ProGuard mapping.txt files and uses them to deobfuscate +//! 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; +use std::io::{BufRead, BufReader}; + +use regex::Regex; + +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, + /// 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 file. +#[derive(Debug)] +struct ProguardMapping { + /// Class mappings (obfuscated name -> mapping) + classes: HashMap, +} + +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+)\(([^)]*)\)(?::(\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() { + 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(), + method_line_ranges: HashMap::new(), + methods_no_lines: 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 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]; + // 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, + }; + + 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]; + let method_name = caps[2].to_string(); + let _params = &caps[3]; + let obfuscated_name = caps[4].to_string(); + + // 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; + } + + // 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. + #[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 (without line number context). + #[allow(dead_code)] + fn deobfuscate_method(&self, class: &str, method: &str) -> Option<&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. + /// + /// 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; + + // 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), + )); + } + } + } + } + + // 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); + + // IMPORTANT: Preserve original line number when method mapping exists + // but line range doesn't match + Some((original_class.clone(), original_method.to_string(), 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") + ); + } + + #[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 + } +} diff --git a/rust/src/symbolication/flutter.rs b/rust/src/symbolication/flutter.rs new file mode 100644 index 0000000..0340167 --- /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::io::Write; +use std::process::Command; + +use regex::Regex; +use tempfile::NamedTempFile; + +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 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", + temp_file.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..b18de48 --- /dev/null +++ b/rust/src/symbolication/go.rs @@ -0,0 +1,189 @@ +//! 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 + + // 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(); + + 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::*; + use regex::Regex; + + #[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); + } + + #[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 new file mode 100644 index 0000000..df8da7a --- /dev/null +++ b/rust/src/symbolication/javascript.rs @@ -0,0 +1,172 @@ +//! 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)" + // 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+)\)?" + ).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_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 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..8a23cac --- /dev/null +++ b/rust/src/symbolication/mod.rs @@ -0,0 +1,559 @@ +//! 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**: 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 +//! +//! # 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), + + #[error("Invalid path component: {0}")] + InvalidPath(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, + } + } +} + +/// 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. Determines which symbolicator to use. + pub platform: Platform, + /// Application identifier (package name, bundle ID, app name). + /// Defaults to `"unknown"` if `None`. + pub app_id: Option, + /// 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. Reserved for future use. + pub build_id: Option, +} + +/// 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 as it appeared in the stack trace. + pub raw: String, + /// Symbolicated function/method name, or `None` if unavailable. + pub function: Option, + /// Source file path, or `None` if unavailable. + pub file: Option, + /// 1-based line number, or `None` if unavailable. + pub line: Option, + /// 1-based column number, or `None` if unavailable. + 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. +/// +/// 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 text as provided to the symbolicator. + pub raw: String, + /// Processed frames in stack trace order. + pub frames: Vec, + /// Count of frames where symbolication succeeded. + pub symbolicated_count: usize, + /// Total count of non-empty lines in the original stack trace. + pub total_count: usize, +} + +impl SymbolicatedStack { + /// 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() + .map(|f| f.display()) + .collect::>() + .join("\n") + } + + /// 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 + } else { + (self.symbolicated_count as f64 / self.total_count as f64) * 100.0 + } + } +} + +/// 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 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, + 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..dbb958d --- /dev/null +++ b/rust/src/symbolication/python.rs @@ -0,0 +1,190 @@ +//! 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(); + // 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; + 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..68d4088 --- /dev/null +++ b/rust/src/symbolication/react_native.rs @@ -0,0 +1,220 @@ +//! 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" + + // 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+)\)?" + ).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 - preserve original file path + frames.push(SymbolicatedFrame { + raw: line.to_string(), + function: function.map(|s| s.to_string()), + file: file.map(|s| s.to_string()), + 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")); + } + + #[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")); + } +} 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..088ff8f --- /dev/null +++ b/rust/src/symbolication/store.rs @@ -0,0 +1,742 @@ +//! Mapping file storage and management. +//! +//! 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; +use std::path::{Path, PathBuf}; + +use semver::Version; + +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 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 +/// +/// ```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: PathBuf, + /// 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. + /// + /// 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 { + root: root.as_ref().to_path_buf(), + mappings: HashMap::new(), + } + } + + /// 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; + + 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 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(), + app_id: app_id.to_string(), + version: version.to_string(), + }; + self.mappings.get(&key) + } + + /// Get mapping info with version fallback. + /// + /// 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, + 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 the newest version for this app using semantic versioning + self.mappings + .iter() + .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) + } + + /// 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, + 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. + /// + /// 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 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, + app_id: &str, + version: &str, + filename: &str, + ) -> PathBuf { + self.root + .join(platform.as_str()) + .join(app_id) + .join(version) + .join(filename) + } + + /// 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, + app_id: &str, + version: &str, + 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 + 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()); + } + + #[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(_)))); + } +} diff --git a/rust/src/web.rs b/rust/src/web.rs index c8b7a9d..3a23064 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,63 @@ 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, + }; + + // 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(), + 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(), + 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(), + } +} + /// Serve index.html async fn index_handler() -> impl IntoResponse { match Assets::get("index.html") { @@ -178,3 +238,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, +}