diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 1826829..1884103 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -85,12 +85,13 @@ use crate::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Thr pub use raw::{ProguardCache, PRGCACHE_VERSION}; /// Result of looking up member mappings for a frame. -/// Contains: (members, prepared_frame, rewrite_rules, had_mappings, outer_source_file) +/// Contains: (members, prepared_frame, rewrite_rules, had_mappings, has_line_info, outer_source_file) type MemberLookupResult<'data> = ( &'data [raw::Member], StackFrame<'data>, Vec>, bool, + bool, Option<&'data str>, ); @@ -384,11 +385,14 @@ impl<'data> ProguardCache<'data> { } } + let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + Some(( mapping_entries, prepared_frame, rewrite_rules, had_mappings, + has_line_info, outer_source_file, )) } @@ -402,8 +406,14 @@ impl<'data> ProguardCache<'data> { &'r self, frame: &StackFrame<'data>, ) -> RemappedFrameIter<'r, 'data> { - let Some((members, prepared_frame, _rewrite_rules, _had_mappings, outer_source_file)) = - self.find_members_and_rules(frame) + let Some(( + members, + prepared_frame, + _rewrite_rules, + had_mappings, + has_line_info, + outer_source_file, + )) = self.find_members_and_rules(frame) else { return RemappedFrameIter::empty(); }; @@ -413,7 +423,8 @@ impl<'data> ProguardCache<'data> { prepared_frame, members.iter(), 0, - false, + had_mappings, + has_line_info, outer_source_file, ) } @@ -452,9 +463,32 @@ impl<'data> ProguardCache<'data> { let effective = self.prepare_frame_for_mapping(frame, carried_outline_pos); - let Some((members, prepared_frame, rewrite_rules, had_mappings, outer_source_file)) = - self.find_members_and_rules(&effective) + let Some(( + members, + prepared_frame, + rewrite_rules, + had_mappings, + has_line_info, + outer_source_file, + )) = self.find_members_and_rules(&effective) else { + // Even if we cannot resolve a member mapping, we may still be able to remap the class. + if let Some(class) = self.get_class(effective.class) { + let original_class = self + .read_string(class.original_name_offset) + .unwrap_or(effective.class); + let outer_source_file = self.read_string(class.file_name_offset).ok(); + let file = + synthesize_source_file(original_class, outer_source_file).map(Cow::Owned); + return Some(RemappedFrameIter::single(StackFrame { + class: original_class, + method: effective.method, + file, + line: effective.line, + parameters: effective.parameters, + method_synthesized: false, + })); + } return Some(RemappedFrameIter::empty()); }; @@ -471,6 +505,7 @@ impl<'data> ProguardCache<'data> { members.iter(), skip_count, had_mappings, + has_line_info, outer_source_file, )) } @@ -779,10 +814,14 @@ pub struct RemappedFrameIter<'r, 'data> { StackFrame<'data>, std::slice::Iter<'data, raw::Member>, )>, + /// A single remapped frame fallback (e.g. class-only remapping). + fallback: Option>, /// Number of frames to skip from rewrite rules. skip_count: usize, /// Whether there were mapping entries (for should_skip determination). had_mappings: bool, + /// Whether this method has any line-based mappings. + has_line_info: bool, /// The source file of the outer class for synthesis. outer_source_file: Option<&'data str>, } @@ -791,8 +830,10 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { fn empty() -> Self { Self { inner: None, + fallback: None, skip_count: 0, had_mappings: false, + has_line_info: false, outer_source_file: None, } } @@ -803,16 +844,30 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { members: std::slice::Iter<'data, raw::Member>, skip_count: usize, had_mappings: bool, + has_line_info: bool, outer_source_file: Option<&'data str>, ) -> Self { Self { inner: Some((cache, frame, members)), + fallback: None, skip_count, had_mappings, + has_line_info, outer_source_file, } } + fn single(frame: StackFrame<'data>) -> Self { + Self { + inner: None, + fallback: Some(frame), + skip_count: 0, + had_mappings: false, + has_line_info: false, + outer_source_file: None, + } + } + /// Returns whether there were mapping entries before rewrite rules were applied. /// /// After collecting frames, if `had_mappings()` is true but the result is empty, @@ -822,12 +877,108 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { } fn next_inner(&mut self) -> Option> { - let (cache, frame, members) = self.inner.as_mut()?; - if frame.parameters.is_none() { - iterate_with_lines(cache, frame, members, self.outer_source_file) - } else { - iterate_without_lines(cache, frame, members, self.outer_source_file) + if let Some(frame) = self.fallback.take() { + return Some(frame); } + + let (cache, mut frame, mut members) = self.inner.take()?; + + let out = if frame.parameters.is_none() { + // If we have no line number, treat it as unknown. If there are base (no-line) mappings + // present, prefer those over line-mapped entries. + if frame.line == 0 { + let remaining = members.as_slice(); + // Prefer base entries (endline == 0) if present. + let mut base_members = remaining.iter().filter(|m| m.endline == 0); + if let Some(first_base) = base_members.next() { + // If all base entries resolve to the same original method name, deduplicate. + let all_same = base_members.all(|m| { + m.original_class_offset == first_base.original_class_offset + && m.original_name_offset == first_base.original_name_offset + }); + + if all_same { + let class = cache + .read_string(first_base.original_class_offset) + .unwrap_or(frame.class); + let method = cache.read_string(first_base.original_name_offset).ok()?; + let file = + synthesize_source_file(class, self.outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + // Preserve input line if present when the mapping has no line info. + line: frame.line, + parameters: frame.parameters, + method_synthesized: first_base.is_synthesized(), + }); + } + + // Multiple distinct base entries: iterate them (skip line-mapped entries). + let mapped = iterate_without_lines_preferring_base( + cache, + &mut frame, + &mut members, + self.outer_source_file, + ); + self.inner = Some((cache, frame, members)); + return mapped; + } + + // No base entries: fall back to existing behavior (may yield multiple candidates). + let first = remaining.first()?; + let unambiguous = remaining.iter().all(|m| { + m.original_class_offset == first.original_class_offset + && m.original_name_offset == first.original_name_offset + }); + + if unambiguous { + let class = cache + .read_string(first.original_class_offset) + .unwrap_or(frame.class); + let method = cache.read_string(first.original_name_offset).ok()?; + let file = + synthesize_source_file(class, self.outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + // Preserve input line if present when the mapping has no line info. + line: frame.line, + parameters: frame.parameters, + method_synthesized: first.is_synthesized(), + }); + } + + let mapped = + iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file); + self.inner = Some((cache, frame, members)); + return mapped; + } + + // With a concrete line number, skip base entries if there are line mappings. + let mapped = iterate_with_lines( + cache, + &mut frame, + &mut members, + self.outer_source_file, + self.has_line_info, + ); + self.inner = Some((cache, frame, members)); + mapped + } else { + let mapped = + iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file); + self.inner = Some((cache, frame, members)); + mapped + }; + + // If we returned early for the unambiguous line==0 case above, `self.inner` remains `None` + // which ensures the iterator terminates. + out } } @@ -849,8 +1000,33 @@ fn iterate_with_lines<'a>( frame: &mut StackFrame<'a>, members: &mut std::slice::Iter<'_, raw::Member>, outer_source_file: Option<&str>, + has_line_info: bool, ) -> Option> { for member in members { + // If this method has line mappings, skip base (no-line) entries when we have a concrete line. + if has_line_info && frame.line > 0 && member.endline == 0 { + continue; + } + // If the mapping entry has no line range, preserve the input line number (if any). + if member.endline == 0 { + let class = cache + .read_string(member.original_class_offset) + .unwrap_or(frame.class); + + let method = cache.read_string(member.original_name_offset).ok()?; + + // Synthesize from class name (input filename is not reliable) + let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + line: frame.line, + parameters: frame.parameters, + method_synthesized: member.is_synthesized(), + }); + } // skip any members which do not match our frames line if member.endline > 0 && (frame.line < member.startline as usize || frame.line > member.endline as usize) @@ -902,6 +1078,39 @@ fn iterate_with_lines<'a>( None } +fn iterate_without_lines_preferring_base<'a>( + cache: &ProguardCache<'a>, + frame: &mut StackFrame<'a>, + members: &mut std::slice::Iter<'_, raw::Member>, + outer_source_file: Option<&str>, +) -> Option> { + for member in members { + if member.endline != 0 { + continue; + } + + let class = cache + .read_string(member.original_class_offset) + .unwrap_or(frame.class); + + let method = cache.read_string(member.original_name_offset).ok()?; + + // Synthesize from class name (input filename is not reliable) + let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + // Preserve input line if present when the mapping has no line info. + line: frame.line, + parameters: frame.parameters, + method_synthesized: member.is_synthesized(), + }); + } + None +} + fn iterate_without_lines<'a>( cache: &ProguardCache<'a>, frame: &mut StackFrame<'a>, @@ -923,7 +1132,8 @@ fn iterate_without_lines<'a>( class, method, file, - line: 0, + // Preserve input line if present when the mapping has no line info. + line: frame.line, parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) diff --git a/src/cache/raw.rs b/src/cache/raw.rs index bd7f850..66a18a1 100644 --- a/src/cache/raw.rs +++ b/src/cache/raw.rs @@ -343,12 +343,7 @@ impl<'data> ProguardCache<'data> { .entry(obfuscated_method.as_str()) .or_default(); - let has_line_info = members.all.iter().any(|m| m.endline > 0); for member in members.all.iter() { - // Skip members without line information if there are members with line information - if has_line_info && member.startline == 0 && member.endline == 0 { - continue; - } method_mappings.push(Self::resolve_mapping( &mut string_table, &parsed, diff --git a/src/mapper.rs b/src/mapper.rs index ef3cb74..300102a 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -102,15 +102,20 @@ type MemberIter<'m> = std::slice::Iter<'m, MemberMapping<'m>>; #[derive(Clone, Debug, Default)] pub struct RemappedFrameIter<'m> { inner: Option<(StackFrame<'m>, MemberIter<'m>)>, + has_line_info: bool, } impl<'m> RemappedFrameIter<'m> { fn empty() -> Self { - Self { inner: None } + Self { + inner: None, + has_line_info: false, + } } - fn members(frame: StackFrame<'m>, members: MemberIter<'m>) -> Self { + fn members(frame: StackFrame<'m>, members: MemberIter<'m>, has_line_info: bool) -> Self { Self { inner: Some((frame, members)), + has_line_info, } } } @@ -120,7 +125,7 @@ impl<'m> Iterator for RemappedFrameIter<'m> { fn next(&mut self) -> Option { let (frame, ref mut members) = self.inner.as_mut()?; if frame.parameters.is_none() { - iterate_with_lines(frame, members) + iterate_with_lines(frame, members, self.has_line_info) } else { iterate_without_lines(frame, members) } @@ -219,7 +224,9 @@ fn map_member_without_lines<'a>( class, method: member.original, file, - line: 0, + // Preserve input line if present (e.g. "Unknown Source:7") when the mapping itself + // has no line information. This matches R8 retrace behavior. + line: frame.line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } @@ -260,8 +267,17 @@ fn apply_rewrite_rules<'s>(collected: &mut CollectedFrames<'s>, thrown_descripto fn iterate_with_lines<'a>( frame: &mut StackFrame<'a>, members: &mut core::slice::Iter<'_, MemberMapping<'a>>, + has_line_info: bool, ) -> Option> { for member in members { + // If this method has line mappings, skip base (no-line) entries when we have a concrete line. + if has_line_info && frame.line > 0 && member.endline == 0 { + continue; + } + // If the mapping entry has no line range, preserve the input line number (if any). + if member.endline == 0 { + return Some(map_member_without_lines(frame, member)); + } if let Some(mapped) = map_member_with_lines(frame, member) { return Some(mapped); } @@ -361,12 +377,7 @@ impl<'s> ProguardMapper<'s> { .entry(obfuscated_method.as_str()) .or_default(); - let has_line_info = members.all.iter().any(|m| m.endline > 0); for member in members.all.iter() { - // Skip members without line information if there are members with line information - if has_line_info && member.startline == 0 && member.endline == 0 { - continue; - } method_mappings.all_mappings.push(Self::resolve_mapping( &parsed, member, @@ -519,13 +530,26 @@ impl<'s> ProguardMapper<'s> { let Some(class) = self.classes.get(frame.class) else { return collected; }; - let Some(members) = class.members.get(frame.method) else { - return collected; - }; let mut frame = frame.clone(); frame.class = class.original; + // If we don't have any member mappings, we can still remap the class name. + // This is especially important for stack frames where the method is not mapped or the + // stacktrace does not contain sufficient information to resolve the method. + let Some(members) = class.members.get(frame.method) else { + let file = synthesize_source_file(frame.class, frame.file()).map(Cow::Owned); + collected.frames.push(StackFrame { + class: frame.class, + method: frame.method, + file, + line: frame.line, + parameters: frame.parameters, + method_synthesized: false, + }); + return collected; + }; + let mapping_entries: &[MemberMapping<'s>] = if let Some(parameters) = frame.parameters { let Some(typed_members) = members.mappings_by_params.get(parameters) else { return collected; @@ -536,8 +560,54 @@ impl<'s> ProguardMapper<'s> { }; if frame.parameters.is_none() { + let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + + // If the stacktrace has no line number, treat it as unknown and remap without + // applying line filters. If there are base (no-line) mappings present, prefer those. + if frame.line == 0 { + let preferred: Vec<_> = if has_line_info { + let base: Vec<_> = mapping_entries.iter().filter(|m| m.endline == 0).collect(); + if base.is_empty() { + mapping_entries.iter().collect() + } else { + base + } + } else { + mapping_entries.iter().collect() + }; + + let mut members_iter = preferred.iter().copied(); + let Some(first) = members_iter.next() else { + return collected; + }; + + let unambiguous = members_iter.all(|m| m.original == first.original); + if unambiguous { + collected + .frames + .push(map_member_without_lines(&frame, first)); + collected.rewrite_rules.extend(first.rewrite_rules.iter()); + } else { + for member in preferred { + collected + .frames + .push(map_member_without_lines(&frame, member)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } + return collected; + } + for member in mapping_entries { - if let Some(mapped) = map_member_with_lines(&frame, member) { + if has_line_info && member.endline == 0 { + continue; + } + if member.endline == 0 { + collected + .frames + .push(map_member_without_lines(&frame, member)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } else if let Some(mapped) = map_member_with_lines(&frame, member) { collected.frames.push(mapped); collected.rewrite_rules.extend(member.rewrite_rules.iter()); } @@ -606,7 +676,8 @@ impl<'s> ProguardMapper<'s> { members.all_mappings.iter() }; - RemappedFrameIter::members(frame, mappings) + let has_line_info = members.all_mappings.iter().any(|m| m.endline > 0); + RemappedFrameIter::members(frame, mappings, has_line_info) } /// Remaps a throwable which is the first line of a full stacktrace. @@ -1016,7 +1087,7 @@ java.lang.IllegalStateException: Boom } #[test] - fn remap_frame_without_mapping_keeps_original_line() { + fn remap_frame_without_mapping_remaps_class_best_effort() { let mapping = "\ some.Class -> a: 1:1:void some.Class.existing():10:10 -> a @@ -1029,7 +1100,7 @@ java.lang.RuntimeException: boom "; let expected = "\ java.lang.RuntimeException: boom - at a.missing(SourceFile:42) + at some.Class.missing(Class.java:42) "; assert_eq!(mapper.remap_stacktrace(input).unwrap(), expected); diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 09d0544..875f4ca 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -286,8 +286,10 @@ pub(crate) fn parse_frame(line: &str) -> Option> { let (method_split, file_split) = line[3..line.len() - 1].split_once('(')?; let (class, method) = method_split.rsplit_once('.')?; - let (file, line) = file_split.split_once(':')?; - let line = line.parse().ok()?; + let (file, line) = match file_split.split_once(':') { + Some((file, line)) => (file, line.parse().ok()?), + None => (file_split, 0), + }; Some(StackFrame { class, diff --git a/tests/retrace.rs b/tests/retrace.rs index ca9fc5e..ca54fae 100644 --- a/tests/retrace.rs +++ b/tests/retrace.rs @@ -71,7 +71,8 @@ fn test_remap_no_lines() { let mut mapped = mapper.remap_frame(&StackFrame::new("a", "b", 10)); assert_eq!( mapped.next().unwrap(), - StackFrame::with_file("original.class.name", "originalMethodName", 0, "name.java") + // Preserve input line number when the mapping has no line information. + StackFrame::with_file("original.class.name", "originalMethodName", 10, "name.java") ); assert_eq!(mapped.next(), None); }