From 90c03b1c469ad2fba47b7562aa20b671fc1151c4 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 10:46:33 +0100 Subject: [PATCH 1/7] wip --- apps/backend/src/main.rs | 1 + apps/backend/src/mappings/javascript.rs | 54 ++++ apps/backend/src/mappings/mod.rs | 34 +++ apps/backend/src/mappings/proguard.rs | 353 ++++++++++++++++++++++++ apps/backend/src/routes.rs | 228 +++++++-------- packages/bundler-plugin/src/index.ts | 2 + 6 files changed, 563 insertions(+), 109 deletions(-) create mode 100644 apps/backend/src/mappings/javascript.rs create mode 100644 apps/backend/src/mappings/mod.rs create mode 100644 apps/backend/src/mappings/proguard.rs diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 41828b4..544f1b8 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod config; mod crypto; mod error; +mod mappings; mod routes; mod storage; diff --git a/apps/backend/src/mappings/javascript.rs b/apps/backend/src/mappings/javascript.rs new file mode 100644 index 0000000..44db714 --- /dev/null +++ b/apps/backend/src/mappings/javascript.rs @@ -0,0 +1,54 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::storage::Storage; + +use super::{OriginalPosition, s3_key}; + +pub async fn ingest( + storage: &Storage, + project_id: Uuid, + build_id: &str, + sourcemaps: &[(String, String)], // (file_name, sourcemap_content) +) -> Result<(), AppError> { + for (file_name, content) in sourcemaps { + let key = s3_key(project_id, build_id, file_name); + storage.put(&key, content.as_bytes()).await?; + } + Ok(()) +} + +pub fn apply( + data: &[u8], + _file_name: &str, + line: u32, + column: u32, +) -> Result { + let source_map = sourcemap::SourceMap::from_slice(data) + .map_err(|e| AppError::BadRequest(format!("invalid sourcemap: {e}")))?; + let token = source_map + .lookup_token(line.saturating_sub(1), column.saturating_sub(1)) + .ok_or(AppError::NotFound)?; + let source = token.get_source().ok_or(AppError::NotFound)?; + let src_line = token.get_src_line(); + let src_col = token.get_src_col(); + + if src_line == u32::MAX || src_col == u32::MAX { + return Err(AppError::NotFound); + } + + Ok(OriginalPosition { + source: source.to_string(), + line: src_line.saturating_add(1), + column: src_col.saturating_add(1), + name: token.get_name().map(ToString::to_string), + }) +} + +pub fn map_file_name(file_name: &str) -> String { + if file_name.ends_with(".map") { + file_name.to_string() + } else { + format!("{file_name}.map") + } +} diff --git a/apps/backend/src/mappings/mod.rs b/apps/backend/src/mappings/mod.rs new file mode 100644 index 0000000..2770130 --- /dev/null +++ b/apps/backend/src/mappings/mod.rs @@ -0,0 +1,34 @@ +pub mod javascript; +pub mod proguard; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MappingType { + JavaScript, + Proguard, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OriginalPosition { + pub source: String, + pub line: u32, + pub column: u32, + pub name: Option, +} + +pub(crate) fn require_non_empty(field: &str, value: &str) -> Result<(), AppError> { + if value.trim().is_empty() { + return Err(AppError::BadRequest(format!("{field} is required"))); + } + Ok(()) +} + +pub(crate) fn s3_key(project_id: Uuid, build_id: &str, file_name: &str) -> String { + format!("{project_id}/{build_id}/{file_name}") +} diff --git a/apps/backend/src/mappings/proguard.rs b/apps/backend/src/mappings/proguard.rs new file mode 100644 index 0000000..8646fa9 --- /dev/null +++ b/apps/backend/src/mappings/proguard.rs @@ -0,0 +1,353 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::error::AppError; +use crate::storage::Storage; + +use super::{OriginalPosition, s3_key}; + +const PROGUARD_FILE_NAME: &str = "proguard/mapping.txt"; + +/// A parsed proguard mapping file. +struct ProguardMapping { + /// Maps obfuscated class name -> ClassMapping + classes: HashMap, +} + +struct ClassMapping { + original_name: String, + /// Source file name from comment metadata + file_name: Option, + /// Maps obfuscated method name -> Vec of method entries (overloaded methods can have multiple) + methods: Vec, + /// Maps obfuscated field name -> original field name + fields: HashMap, +} + +struct MethodMapping { + original_name: String, + obfuscated_name: String, + start_line: u32, + end_line: u32, +} + +impl ProguardMapping { + fn parse(input: &str) -> Result { + let mut classes = HashMap::new(); + let mut current_class: Option<(String, ClassMapping)> = None; + + for line in input.lines() { + let line = line.trim_end(); + + // Skip empty lines + if line.is_empty() { + continue; + } + + // Handle comments - check for source file metadata + if line.starts_with('#') { + if let Some((_, ref mut class)) = current_class { + // Try to extract fileName from JSON comment + if let Some(json_start) = line.find('{') { + if let Ok(meta) = + serde_json::from_str::(&line[json_start..]) + { + if let Some(file_name) = + meta.get("fileName").and_then(|v| v.as_str()) + { + class.file_name = Some(file_name.to_string()); + } + } + } + } + continue; + } + + // Class mapping: no leading whitespace, ends with ':' + if !line.starts_with(' ') && !line.starts_with('\t') { + // Save previous class + if let Some((obfuscated, class)) = current_class.take() { + classes.insert(obfuscated, class); + } + + // Parse: "original.Class -> obfuscated.Class:" + if let Some((original, obfuscated)) = parse_class_line(line) { + current_class = Some(( + obfuscated, + ClassMapping { + original_name: original, + file_name: None, + methods: Vec::new(), + fields: HashMap::new(), + }, + )); + } + continue; + } + + // Member mapping (indented) + if let Some((_, ref mut class)) = current_class { + let trimmed = line.trim(); + parse_member_line(trimmed, class); + } + } + + // Save last class + if let Some((obfuscated, class)) = current_class { + classes.insert(obfuscated, class); + } + + Ok(ProguardMapping { classes }) + } + + fn resolve( + &self, + class_name: &str, + method_name: Option<&str>, + line: Option, + ) -> Result { + let class = self.classes.get(class_name).ok_or(AppError::NotFound)?; + + let resolved_method = method_name.and_then(|method| { + // Find best matching method by line number + if let Some(line_num) = line { + class + .methods + .iter() + .find(|m| { + m.obfuscated_name == method + && line_num >= m.start_line + && line_num <= m.end_line + }) + .or_else(|| { + class + .methods + .iter() + .find(|m| m.obfuscated_name == method) + }) + } else { + class + .methods + .iter() + .find(|m| m.obfuscated_name == method) + } + }); + + let _source = class + .file_name + .as_deref() + .unwrap_or(&class.original_name); + + Ok(OriginalPosition { + source: class.original_name.clone(), + line: line.unwrap_or(0), + column: 0, + name: resolved_method.map(|m| m.original_name.clone()), + }) + } +} + +/// Parse "original.Class -> obfuscated.Class:" into (original, obfuscated) +fn parse_class_line(line: &str) -> Option<(String, String)> { + let line = line.strip_suffix(':')?; + let (original, obfuscated) = line.split_once(" -> ")?; + Some((original.trim().to_string(), obfuscated.trim().to_string())) +} + +/// Parse member lines (methods and fields) and add to class mapping +fn parse_member_line(line: &str, class: &mut ClassMapping) { + // Try to parse as method with line numbers: "startLine:endLine:returnType method(params) -> obfuscated" + // Or field: "type fieldName -> obfuscated" + let Some((original_part, obfuscated)) = line.rsplit_once(" -> ") else { + return; + }; + let obfuscated = obfuscated.trim().to_string(); + + // Check if it has line number prefixes (method mapping) + // Format: "29:33:void (java.nio.file.Path,java.nio.charset.Charset,java.lang.Object)" + // or: "java.nio.file.Path file" (field) + if let Some(method) = parse_method_with_lines(original_part) { + class.methods.push(MethodMapping { + original_name: method.name, + obfuscated_name: obfuscated, + start_line: method.start_line, + end_line: method.end_line, + }); + } else if original_part.contains('(') { + // Method without line numbers — no range info to store + } else { + // Field mapping: "type fieldName -> obfuscated" + // We just need the field name (last token before ->) + let parts: Vec<&str> = original_part.trim().rsplitn(2, ' ').collect(); + if let Some(field_name) = parts.first() { + class.fields.insert(obfuscated, field_name.to_string()); + } + } +} + +struct ParsedMethod { + name: String, + start_line: u32, + end_line: u32, +} + +fn parse_method_with_lines(s: &str) -> Option { + let s = s.trim(); + // Format: "startLine:endLine:returnType methodName(params)" + let (start_str, rest) = s.split_once(':')?; + let start_line: u32 = start_str.parse().ok()?; + let (end_str, rest) = rest.split_once(':')?; + let end_line: u32 = end_str.parse().ok()?; + + // rest is "returnType methodName(params)" or "returnType methodName(params):startLine2:endLine2" + // Handle inlined methods with additional line info + let rest = if let Some(colon_pos) = rest.find(':') { + // Check if what follows the colon is digits (inline mapping) + let after = &rest[colon_pos + 1..]; + if after + .chars() + .next() + .map_or(false, |c| c.is_ascii_digit()) + { + &rest[..colon_pos] + } else { + rest + } + } else { + rest + }; + + // Split "returnType methodName(params)" - find the method name + let paren_pos = rest.find('(')?; + let before_paren = &rest[..paren_pos]; + // The method name is the last space-separated token + let method_name = before_paren + .rsplit_once(' ') + .map(|(_, name)| name) + .unwrap_or(before_paren); + + Some(ParsedMethod { + name: method_name.to_string(), + start_line, + end_line, + }) +} + +pub async fn ingest( + storage: &Storage, + project_id: Uuid, + build_id: &str, + mapping: &str, +) -> Result<(), AppError> { + let key = s3_key(project_id, build_id, PROGUARD_FILE_NAME); + storage.put(&key, mapping.as_bytes()).await +} + +pub fn apply( + data: &[u8], + class_name: &str, + method_name: Option<&str>, + line: Option, +) -> Result { + let content = std::str::from_utf8(data) + .map_err(|e| AppError::BadRequest(format!("invalid proguard mapping: {e}")))?; + let mapping = ProguardMapping::parse(content)?; + mapping.resolve(class_name, method_name, line) +} + +pub fn proguard_s3_key(project_id: Uuid, build_id: &str) -> String { + s3_key(project_id, build_id, PROGUARD_FILE_NAME) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_MAPPING: &str = r#"core.file.FileIO -> a.a.a: +# {"fileName":"FileIO.java","id":"sourceFile"} + java.nio.file.Path file -> a + java.nio.charset.Charset charset -> b + java.lang.Object root -> c + boolean loaded -> d + 29:33:void (java.nio.file.Path,java.nio.charset.Charset,java.lang.Object) -> + 42:43:void (java.nio.file.Path,java.lang.Object) -> + 52:54:core.file.FileIO setRoot(java.lang.Object) -> a + 65:67:java.lang.Object getRoot() -> a + java.lang.Object load() -> b + core.file.FileIO save(java.nio.file.attribute.FileAttribute[]) -> a + 92:92:core.file.FileIO reload() -> c +core.file.Validatable -> a.a.b: +# {"fileName":"Validatable.java","id":"sourceFile"} + core.file.FileIO validate(core.file.Validatable$Scope) -> a + 26:26:core.file.FileIO validate() -> a_ +"#; + + #[test] + fn parse_class_mappings() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + assert!(mapping.classes.contains_key("a.a.a")); + assert!(mapping.classes.contains_key("a.a.b")); + + let file_io = &mapping.classes["a.a.a"]; + assert_eq!(file_io.original_name, "core.file.FileIO"); + assert_eq!(file_io.file_name.as_deref(), Some("FileIO.java")); + } + + #[test] + fn parse_field_mappings() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let file_io = &mapping.classes["a.a.a"]; + assert_eq!(file_io.fields.get("a"), Some(&"file".to_string())); + assert_eq!(file_io.fields.get("b"), Some(&"charset".to_string())); + assert_eq!(file_io.fields.get("d"), Some(&"loaded".to_string())); + } + + #[test] + fn parse_method_mappings() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let file_io = &mapping.classes["a.a.a"]; + + let init_methods: Vec<_> = file_io + .methods + .iter() + .filter(|m| m.obfuscated_name == "") + .collect(); + assert_eq!(init_methods.len(), 2); + assert_eq!(init_methods[0].start_line, 29); + assert_eq!(init_methods[0].end_line, 33); + assert_eq!(init_methods[1].start_line, 42); + assert_eq!(init_methods[1].end_line, 43); + } + + #[test] + fn resolve_class() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let result = mapping.resolve("a.a.a", None, None).unwrap(); + assert_eq!(result.source, "core.file.FileIO"); + } + + #[test] + fn resolve_method_with_line() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let result = mapping.resolve("a.a.a", Some("c"), Some(92)).unwrap(); + assert_eq!(result.source, "core.file.FileIO"); + assert_eq!(result.name.as_deref(), Some("reload")); + } + + #[test] + fn resolve_method_without_line() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let result = mapping.resolve("a.a.a", Some("a"), None).unwrap(); + assert_eq!(result.source, "core.file.FileIO"); + // Should find one of the methods named "a" (setRoot or getRoot) + assert!(result.name.is_some()); + } + + #[test] + fn resolve_unknown_class() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let result = mapping.resolve("z.z.z", None, None); + assert!(result.is_err()); + } +} diff --git a/apps/backend/src/routes.rs b/apps/backend/src/routes.rs index 1cceb4a..89e3488 100644 --- a/apps/backend/src/routes.rs +++ b/apps/backend/src/routes.rs @@ -13,6 +13,7 @@ use uuid::Uuid; use crate::SharedState; use crate::auth::{AdminAuthenticatedProject, AuthenticatedProject}; use crate::error::AppError; +use crate::mappings::{require_non_empty, s3_key}; use crate::storage::StoredObjectMeta; const INGEST_MAX_BODY_BYTES: usize = 50 * 1024 * 1024; @@ -25,12 +26,21 @@ pub struct SourcemapEntry { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IngestPayload { - pub build_id: String, - pub bundler: String, - pub uploaded_at: String, - pub sourcemaps: Vec, +#[serde(tag = "mappingType", rename_all = "camelCase")] +pub enum IngestPayload { + #[serde(rename = "javascript")] + JavaScript { + build_id: String, + bundler: String, + uploaded_at: String, + sourcemaps: Vec, + }, + #[serde(rename = "proguard")] + Proguard { + build_id: String, + uploaded_at: String, + mapping: String, + }, } #[derive(Serialize)] @@ -61,12 +71,22 @@ pub struct SourcemapListResponse { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApplyPayload { - pub build_id: String, - pub file_name: String, - pub line: u32, - pub column: u32, +#[serde(tag = "mappingType", rename_all = "camelCase")] +pub enum ApplyPayload { + #[serde(rename = "javascript")] + JavaScript { + build_id: String, + file_name: String, + line: u32, + column: u32, + }, + #[serde(rename = "proguard")] + Proguard { + build_id: String, + class_name: String, + method_name: Option, + line: Option, + }, } #[derive(Debug, Deserialize)] @@ -76,20 +96,11 @@ pub struct CleanupPayload { pub excluded_build_ids: Vec, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OriginalPosition { - pub source: String, - pub line: u32, - pub column: u32, - pub name: Option, -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ApplyResponse { pub ok: bool, - pub original: OriginalPosition, + pub original: crate::mappings::OriginalPosition, } #[derive(Serialize)] @@ -124,10 +135,6 @@ pub fn internal_router(state: SharedState) -> Router { .with_state(state) } -fn s3_key(project_id: Uuid, build_id: &str, file_name: &str) -> String { - format!("{project_id}/{build_id}/{file_name}") -} - pub async fn health() -> &'static str { "ok" } @@ -137,41 +144,61 @@ pub async fn ingest( State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), AppError> { - validate_ingest_payload(&payload)?; - let project_id = auth.project_id; - for entry in &payload.sourcemaps { - let key = s3_key(project_id, &payload.build_id, &entry.file_name); - - state.storage.put(&key, entry.sourcemap.as_bytes()).await?; - } - - record_build_id( - &state.db, - project_id, - &payload.build_id, - &payload.uploaded_at, - ) - .await?; + let (build_id, uploaded_at, ingested, total_bytes, mapping_type) = match &payload { + IngestPayload::JavaScript { + build_id, + bundler: _, + uploaded_at, + sourcemaps, + } => { + validate_js_ingest(build_id, uploaded_at, sourcemaps)?; + let entries: Vec<(String, String)> = sourcemaps + .iter() + .map(|e| (e.file_name.clone(), e.sourcemap.clone())) + .collect(); + crate::mappings::javascript::ingest(&state.storage, project_id, build_id, &entries) + .await?; + let total_bytes: usize = sourcemaps.iter().map(|e| e.sourcemap.len()).sum(); + ( + build_id.as_str(), + uploaded_at.as_str(), + sourcemaps.len(), + total_bytes, + "javascript", + ) + } + IngestPayload::Proguard { + build_id, + uploaded_at, + mapping, + } => { + require_non_empty("build_id", build_id)?; + require_non_empty("uploaded_at", uploaded_at)?; + require_non_empty("mapping", mapping)?; + crate::mappings::proguard::ingest(&state.storage, project_id, build_id, mapping) + .await?; + let total_bytes = mapping.len(); + ( + build_id.as_str(), + uploaded_at.as_str(), + 1, + total_bytes, + "proguard", + ) + } + }; - let ingested = payload.sourcemaps.len(); - let total_bytes: usize = payload.sourcemaps.iter().map(|e| e.sourcemap.len()).sum(); - let file_names: Vec<&str> = payload - .sourcemaps - .iter() - .map(|e| e.file_name.as_str()) - .collect(); + record_build_id(&state.db, project_id, build_id, uploaded_at).await?; info!( %project_id, - build_id = %payload.build_id, - bundler = %payload.bundler, - uploaded_at = %payload.uploaded_at, + build_id, + mapping_type, count = ingested, total_bytes, - files = ?file_names, - "ingested sourcemaps" + "ingested mappings" ); Ok(( @@ -258,37 +285,35 @@ pub async fn apply_sourcemap( State(state): State, Json(payload): Json, ) -> Result, AppError> { - require_non_empty("build_id", &payload.build_id)?; - require_non_empty("file_name", &payload.file_name)?; - - let map_file = map_file_name(&payload.file_name); - let key = s3_key(auth.project_id, &payload.build_id, &map_file); - let data = state.storage.get(&key).await?; - let source_map = sourcemap::SourceMap::from_slice(&data) - .map_err(|e| AppError::BadRequest(format!("invalid sourcemap: {e}")))?; - let token = source_map - .lookup_token( - payload.line.saturating_sub(1), - payload.column.saturating_sub(1), - ) - .ok_or(AppError::NotFound)?; - let source = token.get_source().ok_or(AppError::NotFound)?; - let src_line = token.get_src_line(); - let src_col = token.get_src_col(); - - if src_line == u32::MAX || src_col == u32::MAX { - return Err(AppError::NotFound); - } + let original = match &payload { + ApplyPayload::JavaScript { + build_id, + file_name, + line, + column, + } => { + require_non_empty("build_id", build_id)?; + require_non_empty("file_name", file_name)?; + let map_file = crate::mappings::javascript::map_file_name(file_name); + let key = s3_key(auth.project_id, build_id, &map_file); + let data = state.storage.get(&key).await?; + crate::mappings::javascript::apply(&data, file_name, *line, *column)? + } + ApplyPayload::Proguard { + build_id, + class_name, + method_name, + line, + } => { + require_non_empty("build_id", build_id)?; + require_non_empty("class_name", class_name)?; + let key = crate::mappings::proguard::proguard_s3_key(auth.project_id, build_id); + let data = state.storage.get(&key).await?; + crate::mappings::proguard::apply(&data, class_name, method_name.as_deref(), *line)? + } + }; - Ok(Json(ApplyResponse { - ok: true, - original: OriginalPosition { - source: source.to_string(), - line: src_line.saturating_add(1), - column: src_col.saturating_add(1), - name: token.get_name().map(ToString::to_string), - }, - })) + Ok(Json(ApplyResponse { ok: true, original })) } pub async fn cleanup_old_builds( @@ -336,33 +361,20 @@ pub async fn cleanup_old_builds( })) } -fn map_file_name(file_name: &str) -> String { - if file_name.ends_with(".map") { - file_name.to_string() - } else { - format!("{file_name}.map") - } -} - -fn require_non_empty(field: &str, value: &str) -> Result<(), AppError> { - if value.trim().is_empty() { - return Err(AppError::BadRequest(format!("{field} is required"))); - } - Ok(()) -} - -fn validate_ingest_payload(payload: &IngestPayload) -> Result<(), AppError> { - require_non_empty("build_id", &payload.build_id)?; - require_non_empty("uploaded_at", &payload.uploaded_at)?; - if payload.sourcemaps.is_empty() { +fn validate_js_ingest( + build_id: &str, + uploaded_at: &str, + sourcemaps: &[SourcemapEntry], +) -> Result<(), AppError> { + require_non_empty("build_id", build_id)?; + require_non_empty("uploaded_at", uploaded_at)?; + if sourcemaps.is_empty() { return Err(AppError::BadRequest("no sourcemaps provided".into())); } - - for entry in &payload.sourcemaps { + for entry in sourcemaps { require_non_empty("file_name", &entry.file_name)?; require_non_empty("sourcemap", &entry.sourcemap)?; } - Ok(()) } @@ -426,10 +438,8 @@ fn select_builds_for_cleanup( #[cfg(test)] mod tests { - use super::{ - map_file_name, normalized_build_ids, parse_sourcemap_key, require_non_empty, - select_builds_for_cleanup, - }; + use super::{normalized_build_ids, parse_sourcemap_key, select_builds_for_cleanup}; + use crate::mappings::{javascript::map_file_name, require_non_empty}; use crate::storage::StoredObjectMeta; #[test] diff --git a/packages/bundler-plugin/src/index.ts b/packages/bundler-plugin/src/index.ts index 596c4e2..28a6647 100644 --- a/packages/bundler-plugin/src/index.ts +++ b/packages/bundler-plugin/src/index.ts @@ -75,6 +75,7 @@ export type SourcemapUpload = { }; export type SourcemapUploadPayload = { + mappingType: "javascript"; buildId: string; bundler: BundlerName; uploadedAt: string; @@ -185,6 +186,7 @@ const createUploadBatches = ( let currentBatch: SourcemapUpload[] = []; const toPayload = (batch: SourcemapUpload[]): SourcemapUploadPayload => ({ + mappingType: "javascript", buildId, bundler, uploadedAt, From fff0d2a803771020833b522b1106c8edbba37102 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 10:47:00 +0100 Subject: [PATCH 2/7] format --- apps/backend/src/mappings/proguard.rs | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/mappings/proguard.rs b/apps/backend/src/mappings/proguard.rs index 8646fa9..acbe91f 100644 --- a/apps/backend/src/mappings/proguard.rs +++ b/apps/backend/src/mappings/proguard.rs @@ -53,9 +53,7 @@ impl ProguardMapping { if let Ok(meta) = serde_json::from_str::(&line[json_start..]) { - if let Some(file_name) = - meta.get("fileName").and_then(|v| v.as_str()) - { + if let Some(file_name) = meta.get("fileName").and_then(|v| v.as_str()) { class.file_name = Some(file_name.to_string()); } } @@ -120,24 +118,13 @@ impl ProguardMapping { && line_num >= m.start_line && line_num <= m.end_line }) - .or_else(|| { - class - .methods - .iter() - .find(|m| m.obfuscated_name == method) - }) + .or_else(|| class.methods.iter().find(|m| m.obfuscated_name == method)) } else { - class - .methods - .iter() - .find(|m| m.obfuscated_name == method) + class.methods.iter().find(|m| m.obfuscated_name == method) } }); - let _source = class - .file_name - .as_deref() - .unwrap_or(&class.original_name); + let _source = class.file_name.as_deref().unwrap_or(&class.original_name); Ok(OriginalPosition { source: class.original_name.clone(), @@ -205,11 +192,7 @@ fn parse_method_with_lines(s: &str) -> Option { let rest = if let Some(colon_pos) = rest.find(':') { // Check if what follows the colon is digits (inline mapping) let after = &rest[colon_pos + 1..]; - if after - .chars() - .next() - .map_or(false, |c| c.is_ascii_digit()) - { + if after.chars().next().map_or(false, |c| c.is_ascii_digit()) { &rest[..colon_pos] } else { rest From 4f08c4c8a1b9fa31b391569bc7a5ee4cc58bfd2b Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 10:48:11 +0100 Subject: [PATCH 3/7] clean --- apps/backend/src/mappings/mod.rs | 9 +-------- apps/backend/src/mappings/proguard.rs | 14 ++++++-------- apps/backend/src/routes.rs | 10 +++++++++- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/mappings/mod.rs b/apps/backend/src/mappings/mod.rs index 2770130..f3c2878 100644 --- a/apps/backend/src/mappings/mod.rs +++ b/apps/backend/src/mappings/mod.rs @@ -1,18 +1,11 @@ pub mod javascript; pub mod proguard; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use uuid::Uuid; use crate::error::AppError; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum MappingType { - JavaScript, - Proguard, -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct OriginalPosition { diff --git a/apps/backend/src/mappings/proguard.rs b/apps/backend/src/mappings/proguard.rs index acbe91f..5d6d110 100644 --- a/apps/backend/src/mappings/proguard.rs +++ b/apps/backend/src/mappings/proguard.rs @@ -49,14 +49,12 @@ impl ProguardMapping { if line.starts_with('#') { if let Some((_, ref mut class)) = current_class { // Try to extract fileName from JSON comment - if let Some(json_start) = line.find('{') { - if let Ok(meta) = + if let Some(json_start) = line.find('{') + && let Ok(meta) = serde_json::from_str::(&line[json_start..]) - { - if let Some(file_name) = meta.get("fileName").and_then(|v| v.as_str()) { - class.file_name = Some(file_name.to_string()); - } - } + && let Some(file_name) = meta.get("fileName").and_then(|v| v.as_str()) + { + class.file_name = Some(file_name.to_string()); } } continue; @@ -192,7 +190,7 @@ fn parse_method_with_lines(s: &str) -> Option { let rest = if let Some(colon_pos) = rest.find(':') { // Check if what follows the colon is digits (inline mapping) let after = &rest[colon_pos + 1..]; - if after.chars().next().map_or(false, |c| c.is_ascii_digit()) { + if after.chars().next().is_some_and(|c| c.is_ascii_digit()) { &rest[..colon_pos] } else { rest diff --git a/apps/backend/src/routes.rs b/apps/backend/src/routes.rs index 89e3488..d37dbe0 100644 --- a/apps/backend/src/routes.rs +++ b/apps/backend/src/routes.rs @@ -149,7 +149,7 @@ pub async fn ingest( let (build_id, uploaded_at, ingested, total_bytes, mapping_type) = match &payload { IngestPayload::JavaScript { build_id, - bundler: _, + bundler, uploaded_at, sourcemaps, } => { @@ -161,6 +161,14 @@ pub async fn ingest( crate::mappings::javascript::ingest(&state.storage, project_id, build_id, &entries) .await?; let total_bytes: usize = sourcemaps.iter().map(|e| e.sourcemap.len()).sum(); + let file_names: Vec<&str> = + sourcemaps.iter().map(|e| e.file_name.as_str()).collect(); + info!( + %project_id, + build_id, + %bundler, + files = ?file_names, + ); ( build_id.as_str(), uploaded_at.as_str(), From 1cfdae19b117700efe73ee208983648f78d1347b Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 10:50:29 +0100 Subject: [PATCH 4/7] format --- apps/backend/src/routes.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/backend/src/routes.rs b/apps/backend/src/routes.rs index d37dbe0..118e76b 100644 --- a/apps/backend/src/routes.rs +++ b/apps/backend/src/routes.rs @@ -161,8 +161,7 @@ pub async fn ingest( crate::mappings::javascript::ingest(&state.storage, project_id, build_id, &entries) .await?; let total_bytes: usize = sourcemaps.iter().map(|e| e.sourcemap.len()).sum(); - let file_names: Vec<&str> = - sourcemaps.iter().map(|e| e.file_name.as_str()).collect(); + let file_names: Vec<&str> = sourcemaps.iter().map(|e| e.file_name.as_str()).collect(); info!( %project_id, build_id, From d8a9588ff631eb2c894ddd5c36214bade4ced4e6 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 10:57:19 +0100 Subject: [PATCH 5/7] whole stack --- apps/backend/src/mappings/proguard.rs | 212 ++++++++++++++++++++++---- apps/backend/src/routes.rs | 37 +++-- 2 files changed, 207 insertions(+), 42 deletions(-) diff --git a/apps/backend/src/mappings/proguard.rs b/apps/backend/src/mappings/proguard.rs index 5d6d110..0773bb1 100644 --- a/apps/backend/src/mappings/proguard.rs +++ b/apps/backend/src/mappings/proguard.rs @@ -5,7 +5,9 @@ use uuid::Uuid; use crate::error::AppError; use crate::storage::Storage; -use super::{OriginalPosition, s3_key}; +#[cfg(test)] +use super::OriginalPosition; +use super::s3_key; const PROGUARD_FILE_NAME: &str = "proguard/mapping.txt"; @@ -97,6 +99,128 @@ impl ProguardMapping { Ok(ProguardMapping { classes }) } + fn retrace(&self, stacktrace: &str) -> String { + let mut output = String::with_capacity(stacktrace.len()); + for (i, line) in stacktrace.lines().enumerate() { + if i > 0 { + output.push('\n'); + } + output.push_str(&self.retrace_line(line)); + } + // Preserve trailing newline if present + if stacktrace.ends_with('\n') { + output.push('\n'); + } + output + } + + fn retrace_line(&self, line: &str) -> String { + // Match "at .()" with optional leading whitespace + let trimmed = line.trim_start(); + let prefix = &line[..line.len() - trimmed.len()]; + + let Some(rest) = trimmed.strip_prefix("at ") else { + // Not a stack frame line — try to retrace exception class name + // e.g. "java.lang.NullPointerException: message" or "Caused by: a.b.c: msg" + return self.retrace_exception_line(line); + }; + + // Parse "package.Class.method(Source:line)" or "package.Class.method(Unknown Source)" + let Some(paren_start) = rest.find('(') else { + return line.to_string(); + }; + let qualified = &rest[..paren_start]; + let location = &rest[paren_start..]; + + // Split into class + method on last '.' + let Some(dot_pos) = qualified.rfind('.') else { + return line.to_string(); + }; + let obf_class = &qualified[..dot_pos]; + let obf_method = &qualified[dot_pos + 1..]; + + // Extract line number from location like "(SourceFile:92)" or "(Unknown Source)" + let line_num = location + .trim_start_matches('(') + .trim_end_matches(')') + .rsplit_once(':') + .and_then(|(_, num)| num.parse::().ok()); + + let Some(class) = self.classes.get(obf_class) else { + return line.to_string(); + }; + + let original_class = &class.original_name; + let resolved_method = self.resolve_method(class, obf_method, line_num); + let method_name = resolved_method + .map(|m| m.original_name.as_str()) + .unwrap_or(obf_method); + + let source_file = class.file_name.as_deref().unwrap_or("Unknown Source"); + + let location_str = match line_num { + Some(n) => format!("({source_file}:{n})"), + None => format!("({source_file})"), + }; + + format!("{prefix}at {original_class}.{method_name}{location_str}") + } + + fn retrace_exception_line(&self, line: &str) -> String { + // Handle "Caused by: a.b.c: message" or "a.b.c: message" or "a.b.c" + let trimmed = line.trim_start(); + let prefix = &line[..line.len() - trimmed.len()]; + + let (before_class, class_and_rest) = if let Some(rest) = trimmed.strip_prefix("Caused by: ") + { + ("Caused by: ", rest) + } else { + ("", trimmed) + }; + + // Extract class name (everything before first ": " or end of string) + let (obf_class, suffix) = class_and_rest + .split_once(": ") + .map(|(c, m)| (c, format!(": {m}"))) + .unwrap_or((class_and_rest, String::new())); + + if let Some(class) = self.classes.get(obf_class) { + format!("{prefix}{before_class}{}{suffix}", class.original_name) + } else { + line.to_string() + } + } + + fn resolve_method<'a>( + &'a self, + class: &'a ClassMapping, + obf_method: &str, + line: Option, + ) -> Option<&'a MethodMapping> { + if let Some(line_num) = line { + class + .methods + .iter() + .find(|m| { + m.obfuscated_name == obf_method + && line_num >= m.start_line + && line_num <= m.end_line + }) + .or_else(|| { + class + .methods + .iter() + .find(|m| m.obfuscated_name == obf_method) + }) + } else { + class + .methods + .iter() + .find(|m| m.obfuscated_name == obf_method) + } + } + + #[cfg(test)] fn resolve( &self, class_name: &str, @@ -104,25 +228,8 @@ impl ProguardMapping { line: Option, ) -> Result { let class = self.classes.get(class_name).ok_or(AppError::NotFound)?; - - let resolved_method = method_name.and_then(|method| { - // Find best matching method by line number - if let Some(line_num) = line { - class - .methods - .iter() - .find(|m| { - m.obfuscated_name == method - && line_num >= m.start_line - && line_num <= m.end_line - }) - .or_else(|| class.methods.iter().find(|m| m.obfuscated_name == method)) - } else { - class.methods.iter().find(|m| m.obfuscated_name == method) - } - }); - - let _source = class.file_name.as_deref().unwrap_or(&class.original_name); + let resolved_method = + method_name.and_then(|method| self.resolve_method(class, method, line)); Ok(OriginalPosition { source: class.original_name.clone(), @@ -225,16 +332,11 @@ pub async fn ingest( storage.put(&key, mapping.as_bytes()).await } -pub fn apply( - data: &[u8], - class_name: &str, - method_name: Option<&str>, - line: Option, -) -> Result { +pub fn retrace_stacktrace(data: &[u8], stacktrace: &str) -> Result { let content = std::str::from_utf8(data) .map_err(|e| AppError::BadRequest(format!("invalid proguard mapping: {e}")))?; let mapping = ProguardMapping::parse(content)?; - mapping.resolve(class_name, method_name, line) + Ok(mapping.retrace(stacktrace)) } pub fn proguard_s3_key(project_id: Uuid, build_id: &str) -> String { @@ -331,4 +433,60 @@ core.file.Validatable -> a.a.b: let result = mapping.resolve("z.z.z", None, None); assert!(result.is_err()); } + + #[test] + fn retrace_stacktrace_full() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let input = "\ +java.lang.NullPointerException: something broke +\tat a.a.a.c(SourceFile:92) +\tat a.a.a.(SourceFile:30) +\tat a.a.b.a_(SourceFile:26)"; + + let output = mapping.retrace(input); + assert_eq!( + output, + "\ +java.lang.NullPointerException: something broke +\tat core.file.FileIO.reload(FileIO.java:92) +\tat core.file.FileIO.(FileIO.java:30) +\tat core.file.Validatable.validate(Validatable.java:26)" + ); + } + + #[test] + fn retrace_preserves_unknown_lines() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let input = "\ +java.lang.RuntimeException: oops +\tat a.a.a.c(SourceFile:92) +\tat com.unknown.Foo.bar(Foo.java:10) +\t... 3 more"; + + let output = mapping.retrace(input); + assert_eq!( + output, + "\ +java.lang.RuntimeException: oops +\tat core.file.FileIO.reload(FileIO.java:92) +\tat com.unknown.Foo.bar(Foo.java:10) +\t... 3 more" + ); + } + + #[test] + fn retrace_caused_by() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let input = "Caused by: a.a.a: some message"; + let output = mapping.retrace(input); + assert_eq!(output, "Caused by: core.file.FileIO: some message"); + } + + #[test] + fn retrace_unknown_source() { + let mapping = ProguardMapping::parse(SAMPLE_MAPPING).unwrap(); + let input = "\tat a.a.a.c(Unknown Source)"; + let output = mapping.retrace(input); + assert_eq!(output, "\tat core.file.FileIO.reload(FileIO.java)"); + } } diff --git a/apps/backend/src/routes.rs b/apps/backend/src/routes.rs index 118e76b..3b168e2 100644 --- a/apps/backend/src/routes.rs +++ b/apps/backend/src/routes.rs @@ -83,9 +83,7 @@ pub enum ApplyPayload { #[serde(rename = "proguard")] Proguard { build_id: String, - class_name: String, - method_name: Option, - line: Option, + stacktrace: String, }, } @@ -97,10 +95,16 @@ pub struct CleanupPayload { } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ApplyResponse { - pub ok: bool, - pub original: crate::mappings::OriginalPosition, +#[serde(untagged)] +pub enum ApplyResponse { + JavaScript { + ok: bool, + original: crate::mappings::OriginalPosition, + }, + Proguard { + ok: bool, + stacktrace: String, + }, } #[derive(Serialize)] @@ -292,7 +296,7 @@ pub async fn apply_sourcemap( State(state): State, Json(payload): Json, ) -> Result, AppError> { - let original = match &payload { + let response = match &payload { ApplyPayload::JavaScript { build_id, file_name, @@ -304,23 +308,26 @@ pub async fn apply_sourcemap( let map_file = crate::mappings::javascript::map_file_name(file_name); let key = s3_key(auth.project_id, build_id, &map_file); let data = state.storage.get(&key).await?; - crate::mappings::javascript::apply(&data, file_name, *line, *column)? + let original = crate::mappings::javascript::apply(&data, file_name, *line, *column)?; + ApplyResponse::JavaScript { ok: true, original } } ApplyPayload::Proguard { build_id, - class_name, - method_name, - line, + stacktrace, } => { require_non_empty("build_id", build_id)?; - require_non_empty("class_name", class_name)?; + require_non_empty("stacktrace", stacktrace)?; let key = crate::mappings::proguard::proguard_s3_key(auth.project_id, build_id); let data = state.storage.get(&key).await?; - crate::mappings::proguard::apply(&data, class_name, method_name.as_deref(), *line)? + let retraced = crate::mappings::proguard::retrace_stacktrace(&data, stacktrace)?; + ApplyResponse::Proguard { + ok: true, + stacktrace: retraced, + } } }; - Ok(Json(ApplyResponse { ok: true, original })) + Ok(Json(response)) } pub async fn cleanup_old_builds( From 8857d9987e62f91f058aec751c125bf4675263e9 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 11:00:16 +0100 Subject: [PATCH 6/7] remove useless backend thing --- .github/workflows/backend.yml | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/backend.yml diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml deleted file mode 100644 index be2b907..0000000 --- a/.github/workflows/backend.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Backend CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - backend: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - - - name: Install dependencies - run: bun install - - - name: Run backend tasks with Turborepo - run: bunx turbo run lint check-types test build --filter=@sourcemaps/backend From 827763be9094d1a556d82e21b3a965cd44aac84d Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 22 Mar 2026 11:01:51 +0100 Subject: [PATCH 7/7] add changeset --- .changeset/brown-suits-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brown-suits-lie.md diff --git a/.changeset/brown-suits-lie.md b/.changeset/brown-suits-lie.md new file mode 100644 index 0000000..ce94912 --- /dev/null +++ b/.changeset/brown-suits-lie.md @@ -0,0 +1,5 @@ +--- +"@faststats/sourcemap-uploader-plugin": patch +--- + +fix: add javascript as mapping type