diff --git a/src/builder.rs b/src/builder.rs index 0244952..2f70138 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -149,12 +149,12 @@ pub(crate) struct RewriteRule<'s> { pub(crate) struct Member<'s> { /// The method the member refers to. pub(crate) method: MethodKey<'s>, - /// The obfuscated/minified start line. - pub(crate) startline: usize, - /// The obfuscated/minified end line. - pub(crate) endline: usize, - /// The original start line. - pub(crate) original_startline: usize, + /// The obfuscated/minified start line, `None` when no minified range prefix was present. + pub(crate) startline: Option, + /// The obfuscated/minified end line, `None` when no minified range prefix was present. + pub(crate) endline: Option, + /// The original start line, `None` when no line mapping was present. + pub(crate) original_startline: Option, /// The original end line. pub(crate) original_endline: Option, /// Optional outline callsite positions map attached to this member. @@ -291,20 +291,31 @@ impl<'s> ParsedProguardMapping<'s> { } else { None }; - // in case the mapping has no line records, we use `0` here. - let (startline, endline) = - line_mapping.as_ref().map_or((0, 0), |line_mapping| { - (line_mapping.startline, line_mapping.endline) - }); - let (original_startline, original_endline) = - line_mapping.map_or((0, None), |line_mapping| { - match line_mapping.original_startline { - Some(original_startline) => { - (original_startline, line_mapping.original_endline) - } - None => (line_mapping.startline, Some(line_mapping.endline)), - } - }); + let (mut startline, mut endline) = match line_mapping.as_ref() { + Some(lm) => (lm.startline, lm.endline), + None => (None, None), + }; + let (mut original_startline, mut original_endline) = match line_mapping { + None => (None, None), + Some(lm) => match lm.original_startline { + Some(os) => (Some(os), lm.original_endline), + None => (startline, endline), + }, + }; + + // Normalize inverted ranges independently. + if let (Some(s), Some(e)) = (startline, endline) { + if s > e { + startline = Some(e); + endline = Some(s); + } + } + if let (Some(os), Some(oe)) = (original_startline, original_endline) { + if os > oe { + original_startline = Some(oe); + original_endline = Some(os); + } + } let Some((current_class_obfuscated, current_class_original)) = current_class_name diff --git a/src/cache/mod.rs b/src/cache/mod.rs index c2c1ac9..e700fde 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -80,12 +80,18 @@ use thiserror::Error; use crate::builder::{RewriteAction, RewriteCondition, RewriteRule}; use crate::mapper::{format_cause, format_frames, format_throwable}; -use crate::utils::{ - class_name_to_descriptor, extract_class_name, resolve_no_line_output_line, - synthesize_source_file, -}; +use crate::utils::{class_name_to_descriptor, extract_class_name, synthesize_source_file}; use crate::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Throwable}; +/// Maximum number of frames emitted by span expansion for a single mapping entry. +/// +/// R8 uses `0:65535` as the catch-all range for methods with a single unique position: +/// +/// +/// No real method would span more lines than this, so ranges exceeding this cap +/// are treated as malformed and fall through to single-line handling. +const MAX_SPAN_EXPANSION: u32 = 65_535; + pub use raw::{ProguardCache, PRGCACHE_VERSION}; /// Result of looking up member mappings for a frame. @@ -168,7 +174,7 @@ impl<'data> ProguardCache<'data> { class: frame.class, method: frame.method, file, - line: frame.line, + line: Some(frame.line.unwrap_or(0)), parameters: frame.parameters, method_synthesized: false, } @@ -389,10 +395,10 @@ impl<'data> ProguardCache<'data> { if prepared_frame.parameters.is_none() { for member in mapping_entries { // Check if this member would produce a frame (line matching) - if member.endline == 0 - || (prepared_frame.line >= member.startline as usize - && prepared_frame.line <= member.endline as usize) - { + let pf_line = prepared_frame.line.unwrap_or(0); + let startline = member.startline().unwrap_or(0) as usize; + let endline = member.endline().unwrap_or(0) as usize; + if endline == 0 || (pf_line >= startline && pf_line <= endline) { had_mappings = true; rewrite_rules.extend(self.decode_rewrite_rules(member)); } @@ -405,7 +411,7 @@ impl<'data> ProguardCache<'data> { } } - let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + let has_line_info = mapping_entries.iter().any(|m| m.endline().unwrap_or(0) > 0); Some(( mapping_entries, @@ -477,7 +483,7 @@ impl<'data> ProguardCache<'data> { 'r: 'data, { if self.is_outline_frame(frame.class, frame.method) { - *carried_outline_pos = Some(frame.line); + *carried_outline_pos = Some(frame.line.unwrap_or(0)); return None; } @@ -631,9 +637,9 @@ impl<'data> ProguardCache<'data> { candidates .iter() .filter(|m| { - m.endline == 0 - || (callsite_line >= m.startline as usize - && callsite_line <= m.endline as usize) + m.endline().unwrap_or(0) == 0 + || (callsite_line >= m.startline().unwrap_or(0) as usize + && callsite_line <= m.endline().unwrap_or(0) as usize) }) .find_map(|m| { self.member_outline_pairs(m) @@ -678,11 +684,11 @@ impl<'data> ProguardCache<'data> { if let Some(mapped) = self.map_outline_position( effective.class, effective.method, - effective.line, + effective.line.unwrap_or(0), pos, effective.parameters, ) { - effective.line = mapped; + effective.line = Some(mapped); } } @@ -833,12 +839,16 @@ pub struct RemappedFrameIter<'r, 'data> { )>, /// A single remapped frame fallback (e.g. class-only remapping). fallback: Option>, + /// Buffered frames for multi-frame expansion (e.g. no-line groups). + pending_frames: Vec>, /// 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, + /// Whether any frame was successfully matched by iterate_with_lines. + matched_any: bool, /// The source file of the outer class for synthesis. outer_source_file: Option<&'data str>, } @@ -848,9 +858,11 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { Self { inner: None, fallback: None, + pending_frames: Vec::new(), skip_count: 0, had_mappings: false, has_line_info: false, + matched_any: false, outer_source_file: None, } } @@ -867,9 +879,11 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { Self { inner: Some((cache, frame, members)), fallback: None, + pending_frames: Vec::new(), skip_count, had_mappings, has_line_info, + matched_any: false, outer_source_file, } } @@ -878,9 +892,11 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { Self { inner: None, fallback: Some(frame), + pending_frames: Vec::new(), skip_count: 0, had_mappings: false, has_line_info: false, + matched_any: false, outer_source_file: None, } } @@ -894,6 +910,11 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { } fn next_inner(&mut self) -> Option> { + // Drain any buffered frames from multi-frame expansion first. + if !self.pending_frames.is_empty() { + return self.pending_frames.pop(); + } + if let Some(frame) = self.fallback.take() { return Some(frame); } @@ -903,41 +924,19 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { 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 selection = select_no_line_members(members.as_slice())?; - let mapped = match selection { - NoLineSelection::Single(member) => { - return map_member_without_lines( - cache, - &frame, - member, - self.outer_source_file, - ); - } - NoLineSelection::IterateBase => { - let mut mapped = None; - for member in members.by_ref() { - if member.endline == 0 { - mapped = map_member_without_lines( - cache, - &frame, - member, - self.outer_source_file, - ); - break; - } - } - mapped - } - NoLineSelection::IterateAll => iterate_without_lines( - cache, - &mut frame, - &mut members, - self.outer_source_file, - ), - }; - self.inner = Some((cache, frame, members)); - return mapped; + if frame.line.unwrap_or(0) == 0 { + let mut frames = resolve_no_line_frames( + cache, + &frame, + members.as_slice(), + self.outer_source_file, + ); + frames.reverse(); + if let Some(first) = frames.pop() { + self.pending_frames = frames; + return Some(first); + } + return None; } // With a concrete line number, skip base entries if there are line mappings. @@ -947,9 +946,20 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { &mut members, self.outer_source_file, self.has_line_info, + &mut self.pending_frames, ); - self.inner = Some((cache, frame, members)); - mapped + if mapped.is_some() { + self.matched_any = true; + self.inner = Some((cache, frame, members)); + mapped + } else if !self.matched_any && self.has_line_info { + // Outside-range fallback: no member matched the frame line. + // Remap only the class name, keeping the obfuscated method name + // and the original line. + Some(cache.remap_class_only(&frame, self.outer_source_file)) + } else { + None + } } else { let mapped = iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file); @@ -957,8 +967,6 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { mapped }; - // If we returned early for the unambiguous line==0 case above, `self.inner` remains `None` - // which ensures the iterator terminates. out } } @@ -982,30 +990,78 @@ fn iterate_with_lines<'a>( members: &mut std::slice::Iter<'_, raw::Member>, outer_source_file: Option<&str>, has_line_info: bool, + pending_frames: &mut Vec>, ) -> Option> { + let frame_line = frame.line.unwrap_or(0); 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 { + if has_line_info && frame_line > 0 && member.endline().unwrap_or(0) == 0 { continue; } - // If the mapping entry has no line range, preserve the input line number (if any). - if member.endline == 0 { - return map_member_without_lines(cache, frame, member, outer_source_file); + // If the mapping entry has no line range, determine output line. + if member.endline().unwrap_or(0) == 0 { + if member.original_startline().is_none() { + // Bare method mapping: pass through frame line. + return map_member_without_lines( + cache, + frame, + member, + outer_source_file, + frame.line, + ); + } + // Span expansion: if the original range spans multiple lines, + // emit one frame per original line. + if member.original_endline != u32::MAX + && member.original_endline > member.original_startline().unwrap_or(0) + && (member.original_endline - member.original_startline().unwrap_or(0)) + <= MAX_SPAN_EXPANSION + { + let first_line = member.original_startline().unwrap_or(0) as usize; + let last_line = member.original_endline as usize; + let mut first_frame = None; + for line in first_line..=last_line { + if let Some(f) = map_member_without_lines( + cache, + frame, + member, + outer_source_file, + Some(line), + ) { + if first_frame.is_none() { + first_frame = Some(f); + } else { + pending_frames.push(f); + } + } + } + // Reverse so pop() drains in ascending line order. + pending_frames.reverse(); + return first_frame; + } + let output_line = if member.original_startline().unwrap_or(0) > 0 { + Some(member.original_startline().unwrap_or(0) as usize) + } else { + None + }; + return map_member_without_lines(cache, frame, member, outer_source_file, output_line); } // 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) + if member.endline().unwrap_or(0) > 0 + && (frame_line < member.startline().unwrap_or(0) as usize + || frame_line > member.endline().unwrap_or(0) as usize) { continue; } // parents of inlined frames don't have an `endline`, and // the top inlined frame need to be correctly offset. let line = if member.original_endline == u32::MAX - || member.original_endline == member.original_startline + || member.original_endline == member.original_startline().unwrap_or(0) { - member.original_startline as usize + member.original_startline().unwrap_or(0) as usize } else { - member.original_startline as usize + frame.line - member.startline as usize + member.original_startline().unwrap_or(0) as usize + frame_line + - member.startline().unwrap_or(0) as usize }; let class = cache @@ -1035,7 +1091,7 @@ fn iterate_with_lines<'a>( class, method, file, - line, + line: Some(line), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }); @@ -1043,52 +1099,12 @@ fn iterate_with_lines<'a>( None } -/// Selection strategy for line==0 frames. -/// -/// When line info is missing, we prefer base (no-line) mappings if they exist. -/// If all candidates resolve to the same original method, we treat it as -/// unambiguous and return a single mapping. Otherwise we iterate either over -/// base mappings (when present) or all mappings (when only line-mapped entries exist). -enum NoLineSelection<'a> { - Single(&'a raw::Member), - IterateBase, - IterateAll, -} - -fn select_no_line_members<'a>(members: &'a [raw::Member]) -> Option> { - // Prefer base entries (endline == 0) if present. - let mut base_members = members.iter().filter(|m| m.endline == 0); - if let Some(first_base) = base_members.next() { - 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 - }); - - return Some(if all_same { - NoLineSelection::Single(first_base) - } else { - NoLineSelection::IterateBase - }); - } - - let first = members.first()?; - let unambiguous = members.iter().all(|m| { - m.original_class_offset == first.original_class_offset - && m.original_name_offset == first.original_name_offset - }); - - Some(if unambiguous { - NoLineSelection::Single(first) - } else { - NoLineSelection::IterateAll - }) -} - fn map_member_without_lines<'a>( cache: &ProguardCache<'a>, frame: &StackFrame<'a>, member: &raw::Member, outer_source_file: Option<&str>, + output_line: Option, ) -> Option> { let class = cache .read_string(member.original_class_offset) @@ -1096,26 +1112,24 @@ fn map_member_without_lines<'a>( let method = cache.read_string(member.original_name_offset).ok()?; let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); - let original_startline = match member.original_startline { - 0 | u32::MAX => None, - value => Some(value as usize), - }; - Some(StackFrame { class, method, file, - line: resolve_no_line_output_line( - frame.line, - original_startline, - member.startline as usize, - member.endline as usize, - ), + line: output_line, parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) } +/// Computes the default output line for a cache member's original_startline. +fn compute_member_output_line(member: &raw::Member) -> Option { + member + .original_startline() + .filter(|&v| v > 0) + .map(|v| v as usize) +} + fn iterate_without_lines<'a>( cache: &ProguardCache<'a>, frame: &mut StackFrame<'a>, @@ -1123,34 +1137,148 @@ fn iterate_without_lines<'a>( outer_source_file: Option<&str>, ) -> Option> { let member = members.next()?; + let output_line = compute_member_output_line(member); + map_member_without_lines(cache, frame, member, outer_source_file, output_line) +} - let class = cache - .read_string(member.original_class_offset) - .unwrap_or(frame.class); +/// Resolves frames for the no-line (frame_line==0) case. +/// +/// When the input frame has no line number, base entries (endline==0) are preferred +/// over line-mapped entries. Base entries are split into two groups by whether +/// `startline` is present (0:0 entries) or absent (no-range entries), and each +/// group's output lines are computed based on whether the group contains range +/// mappings, single-line mappings, or bare methods. +fn resolve_no_line_frames<'a>( + cache: &ProguardCache<'a>, + frame: &StackFrame<'a>, + members: &[raw::Member], + outer_source_file: Option<&str>, +) -> Vec> { + let base_entries: Vec<&raw::Member> = members + .iter() + .filter(|m| m.endline().unwrap_or(0) == 0) + .collect(); + + if !base_entries.is_empty() { + return resolve_base_entries(cache, frame, &base_entries, outer_source_file); + } - let method = cache.read_string(member.original_name_offset).ok()?; + // No base entries — fall back to all entries with output line 0. + let mut frames = Vec::new(); + if let Some(first) = members.first() { + let all_same = members.iter().all(|m| { + m.original_class_offset == first.original_class_offset + && m.original_name_offset == first.original_name_offset + }); + if all_same { + if let Some(f) = + map_member_without_lines(cache, frame, first, outer_source_file, Some(0)) + { + frames.push(f); + } + } else { + for member in members { + if let Some(f) = + map_member_without_lines(cache, frame, member, outer_source_file, Some(0)) + { + frames.push(f); + } + } + } + } + frames +} - // Synthesize from class name (input filename is not reliable) - let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); +/// Resolves output lines for base (endline==0) entries when the frame has no line number. +/// +/// Entries are split by whether `startline` is present: +/// - **0:0 entries** (`startline().is_some()`): if any entry has a range +/// (`original_endline != original_startline`), all emit `Some(0)`; +/// otherwise each emits its `original_startline`. +/// - **No-range entries** (`startline().is_none()`): a single entry emits +/// its `original_startline` if > 0, otherwise `Some(0)`; multiple entries +/// with the same name collapse to one frame with `Some(0)`; different names +/// each emit `Some(0)` in original order. +fn resolve_base_entries<'a>( + cache: &ProguardCache<'a>, + frame: &StackFrame<'a>, + base_entries: &[&raw::Member], + outer_source_file: Option<&str>, +) -> Vec> { + // Pre-compute aggregates in a single pass. + // Whether any 0:0 entry has a multi-line original range (original_endline != original_startline). + let mut any_zero_zero_has_range = false; + // Number of no-range (startline().is_none()) entries. + let mut no_range_count = 0usize; + // original_name_offset of the first no-range entry, used to detect ambiguity. + let mut first_no_range_offset: Option = None; + // Whether all no-range entries map to the same original method name. + let mut all_no_range_same_name = true; + let mut all_no_range_have_line_mapping = true; + for member in base_entries { + if member.startline().is_some() { + if member.original_endline != u32::MAX + && member.original_endline != member.original_startline().unwrap_or(0) + { + any_zero_zero_has_range = true; + } + } else { + no_range_count += 1; + if member.original_startline().is_none() { + all_no_range_have_line_mapping = false; + } + match first_no_range_offset { + None => first_no_range_offset = Some(member.original_name_offset), + Some(first) if member.original_name_offset != first => { + all_no_range_same_name = false + } + _ => {} + } + } + } - let original_startline = match member.original_startline { - 0 | u32::MAX => None, - value => Some(value as usize), - }; + let mut frames = Vec::new(); + // Whether a no-range entry has already been emitted (used to collapse duplicates). + let mut no_range_emitted = false; + for member in base_entries { + if member.startline().is_some() { + let line = if any_zero_zero_has_range { + Some(0) + } else { + compute_member_output_line(member) + }; + if let Some(f) = map_member_without_lines(cache, frame, member, outer_source_file, line) + { + frames.push(f); + } + } else if all_no_range_same_name { + if !no_range_emitted { + no_range_emitted = true; + let line = if no_range_count > 1 { + Some(0) + } else { + compute_member_output_line(member).or(Some(0)) + }; + if let Some(f) = + map_member_without_lines(cache, frame, member, outer_source_file, line) + { + frames.push(f); + } + } + } else if let Some(f) = + map_member_without_lines(cache, frame, member, outer_source_file, Some(0)) + { + frames.push(f); + } + } - Some(StackFrame { - class, - method, - file, - line: resolve_no_line_output_line( - frame.line, - original_startline, - member.startline as usize, - member.endline as usize, - ), - parameters: frame.parameters, - method_synthesized: member.is_synthesized(), - }) + // Sort no-range frames by original method name when all have line mappings; + // bare method entries preserve original mapping file order. + if !all_no_range_same_name && all_no_range_have_line_mapping { + frames.sort_by(|a, b| a.method.cmp(b.method)); + } + + frames } /// Computes the number of frames to skip based on rewrite rules. @@ -1207,7 +1335,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: StackFrame { class: "com.example.MainFragment$g", method: "onClick", - line: 2, + line: Some(2), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1215,7 +1343,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: StackFrame { class: "android.view.View", method: "performClick", - line: 7393, + line: Some(7393), file: Some(Cow::Borrowed("View.java")), parameters: None, method_synthesized: false, @@ -1229,7 +1357,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: frames: vec![StackFrame { class: "com.example.MainFragment$g", method: "onClick", - line: 1, + line: Some(1), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1421,7 +1549,7 @@ some.Other -> b: StackFrame { class: "a", method: "call", - line: 4, + line: Some(4), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1429,7 +1557,7 @@ some.Other -> b: StackFrame { class: "b", method: "run", - line: 5, + line: Some(5), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1445,6 +1573,6 @@ some.Other -> b: assert_eq!(remapped.frames.len(), 1); assert_eq!(remapped.frames[0].class, "some.Other"); assert_eq!(remapped.frames[0].method, "method"); - assert_eq!(remapped.frames[0].line, 30); + assert_eq!(remapped.frames[0].line, Some(30)); } } diff --git a/src/cache/raw.rs b/src/cache/raw.rs index 66a18a1..c1fd467 100644 --- a/src/cache/raw.rs +++ b/src/cache/raw.rs @@ -98,15 +98,18 @@ impl Default for Class { } } +/// Sentinel value representing absent/`None` for u32 fields in the binary format. +const NONE_VALUE: u32 = u32::MAX; + /// An entry corresponding to a method line in a proguard cache file. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[repr(C)] pub(crate) struct Member { /// The obfuscated method name (offset into the string section). pub(crate) obfuscated_name_offset: u32, - /// The start of the range covered by this entry (1-based). + /// The start of the range covered by this entry (1-based), `u32::MAX` if absent. pub(crate) startline: u32, - /// The end of the range covered by this entry (inclusive). + /// The end of the range covered by this entry (inclusive), `u32::MAX` if absent. pub(crate) endline: u32, /// The original class name (offset into the string section). pub(crate) original_class_offset: u32, @@ -114,7 +117,7 @@ pub(crate) struct Member { pub(crate) original_file_offset: u32, /// The original method name (offset into the string section). pub(crate) original_name_offset: u32, - /// The original start line (1-based). + /// The original start line (0-based), `u32::MAX` if absent. pub(crate) original_startline: u32, /// The original end line (inclusive). pub(crate) original_endline: u32, @@ -136,6 +139,7 @@ pub(crate) struct Member { /// /// `0` means `false`, all other values mean `true`. pub(crate) is_outline: u8, + /// Reserved space. pub(crate) _reserved: [u8; 2], } @@ -149,6 +153,30 @@ impl Member { pub(crate) fn is_outline(&self) -> bool { self.is_outline != 0 } + /// Returns the startline as `Option`, where `NONE_VALUE` maps to `None`. + pub(crate) fn startline(&self) -> Option { + if self.startline == NONE_VALUE { + None + } else { + Some(self.startline) + } + } + /// Returns the endline as `Option`, where `NONE_VALUE` maps to `None`. + pub(crate) fn endline(&self) -> Option { + if self.endline == NONE_VALUE { + None + } else { + Some(self.endline) + } + } + /// Returns the original_startline as `Option`, where `NONE_VALUE` maps to `None`. + pub(crate) fn original_startline(&self) -> Option { + if self.original_startline == NONE_VALUE { + None + } else { + Some(self.original_startline) + } + } } unsafe impl Pod for Header {} @@ -616,22 +644,22 @@ impl<'data> ProguardCache<'data> { .collect(); let member: Member = Member { - startline: member.startline as u32, - endline: member.endline as u32, + startline: member.startline.map_or(NONE_VALUE, |v| v as u32), + endline: member.endline.map_or(NONE_VALUE, |v| v as u32), original_class_offset, original_file_offset, original_name_offset, - original_startline: member.original_startline as u32, - original_endline: member.original_endline.map_or(u32::MAX, |l| l as u32), + original_startline: member.original_startline.map_or(NONE_VALUE, |v| v as u32), + original_endline: member.original_endline.map_or(NONE_VALUE, |l| l as u32), obfuscated_name_offset, params_offset, is_synthesized, is_outline, + _reserved: [0; 2], outline_pairs_offset: 0, outline_pairs_len: 0, rewrite_rules_offset: 0, rewrite_rules_len: 0, - _reserved: [0; 2], }; MemberInProgress { diff --git a/src/mapper.rs b/src/mapper.rs index 4e419ed..165c07b 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -4,16 +4,22 @@ use std::fmt; use std::fmt::{Error as FmtError, Write}; use std::iter::FusedIterator; +/// Maximum number of frames emitted by span expansion for a single mapping entry. +/// +/// R8 uses `0:65535` as the catch-all range for methods with a single unique position: +/// +/// +/// No real method would span more lines than this, so ranges exceeding this cap +/// are treated as malformed and fall through to single-line handling. +const MAX_SPAN_EXPANSION: usize = 65_535; + use crate::builder::{ Member, MethodReceiver, ParsedProguardMapping, RewriteAction, RewriteCondition, RewriteRule, }; use crate::java; use crate::mapping::ProguardMapping; use crate::stacktrace::{self, StackFrame, StackTrace, Throwable}; -use crate::utils::{ - class_name_to_descriptor, extract_class_name, resolve_no_line_output_line, - synthesize_source_file, -}; +use crate::utils::{class_name_to_descriptor, extract_class_name, synthesize_source_file}; /// A deobfuscated method signature. pub struct DeobfuscatedSignature { @@ -60,12 +66,12 @@ impl fmt::Display for DeobfuscatedSignature { #[derive(Clone, Debug, PartialEq, Eq)] struct MemberMapping<'s> { - startline: usize, - endline: usize, + startline: Option, + endline: Option, original_class: Option<&'s str>, original_file: Option<&'s str>, original: &'s str, - original_startline: usize, + original_startline: Option, original_endline: Option, is_synthesized: bool, is_outline: bool, @@ -140,18 +146,21 @@ fn map_member_with_lines<'a>( frame: &StackFrame<'a>, member: &MemberMapping<'a>, ) -> Option> { - if member.endline > 0 && (frame.line < member.startline || frame.line > member.endline) { + let frame_line = frame.line.unwrap_or(0); + if member.endline.unwrap_or(0) > 0 + && (frame_line < member.startline.unwrap_or(0) || frame_line > member.endline.unwrap_or(0)) + { return None; } // parents of inlined frames don't have an `endline`, and // the top inlined frame need to be correctly offset. let line = if member.original_endline.is_none() - || member.original_endline == Some(member.original_startline) + || member.original_endline == member.original_startline { - member.original_startline + member.original_startline.unwrap_or(0) } else { - member.original_startline + frame.line - member.startline + member.original_startline.unwrap_or(0) + frame_line - member.startline.unwrap_or(0) }; let class = member.original_class.unwrap_or(frame.class); @@ -172,37 +181,29 @@ fn map_member_with_lines<'a>( class, method: member.original, file, - line, + line: Some(line), parameters: frame.parameters, method_synthesized: member.is_synthesized, }) } +/// Builds a remapped frame from a no-line (base) mapping entry. +/// +/// `output_line` is the line number to use in the output frame, already computed +/// by the caller based on group context (presence of `startline`, entry count, etc.). fn map_member_without_lines<'a>( frame: &StackFrame<'a>, member: &MemberMapping<'a>, + output_line: Option, ) -> StackFrame<'a> { let class = member.original_class.unwrap_or(frame.class); // Synthesize from class name (input filename is not reliable) let file = synthesize_source_file(class, member.outer_source_file).map(Cow::Owned); - let original_startline = if member.original_startline > 0 { - Some(member.original_startline) - } else { - None - }; - let line = resolve_no_line_output_line( - frame.line, - original_startline, - member.startline, - member.endline, - ); StackFrame { class, method: member.original, file, - // Preserve input line if present (e.g. "Unknown Source:7") when the mapping itself - // has no line information. This matches R8 retrace behavior. - line, + line: output_line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } @@ -214,50 +215,12 @@ fn remap_class_only<'a>(frame: &StackFrame<'a>, reference_file: Option<&str>) -> class: frame.class, method: frame.method, file, - line: frame.line, + line: Some(frame.line.unwrap_or(0)), parameters: frame.parameters, method_synthesized: false, } } -/// Selection strategy for line==0 frames. -/// -/// When line info is missing, we prefer base (no-line) mappings if they exist. -/// If all candidates resolve to the same original method, we treat it as -/// unambiguous and return a single mapping. Otherwise we iterate either over -/// base mappings (when present) or all mappings (when only line-mapped entries exist). -enum NoLineSelection<'a> { - Single(&'a MemberMapping<'a>), - IterateAll, - IterateBase, -} - -fn select_no_line_members<'a>( - mapping_entries: &'a [MemberMapping<'a>], - has_line_info: bool, -) -> Option> { - let mut base_members = mapping_entries.iter().filter(|m| m.endline == 0); - if has_line_info { - if let Some(first_base) = base_members.next() { - let all_same = base_members.all(|m| m.original == first_base.original); - return Some(if all_same { - NoLineSelection::Single(first_base) - } else { - NoLineSelection::IterateBase - }); - } - } - - let first = mapping_entries.first()?; - let unambiguous = mapping_entries.iter().all(|m| m.original == first.original); - - Some(if unambiguous { - NoLineSelection::Single(first) - } else { - NoLineSelection::IterateAll - }) -} - fn apply_rewrite_rules<'s>(collected: &mut CollectedFrames<'s>, thrown_descriptor: Option<&str>) { if collected.frames.is_empty() { return; @@ -295,14 +258,23 @@ fn iterate_with_lines<'a>( members: &mut core::slice::Iter<'_, MemberMapping<'a>>, has_line_info: bool, ) -> Option> { + let frame_line = frame.line.unwrap_or(0); 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 { + if has_line_info && frame_line > 0 && member.endline.unwrap_or(0) == 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 the mapping entry has no line range, determine output line. + if member.endline.unwrap_or(0) == 0 { + let output_line = if member.original_startline.is_none() { + // Bare method mapping: pass through frame line. + frame.line + } else if member.original_startline.unwrap_or(0) > 0 { + member.original_startline + } else { + None + }; + return Some(map_member_without_lines(frame, member, output_line)); } if let Some(mapped) = map_member_with_lines(frame, member) { return Some(mapped); @@ -315,13 +287,144 @@ fn iterate_without_lines<'a>( frame: &mut StackFrame<'a>, members: &mut core::slice::Iter<'_, MemberMapping<'a>>, ) -> Option> { - members - .next() - .map(|member| map_member_without_lines(frame, member)) + members.next().map(|member| { + let output_line = if member.original_startline.unwrap_or(0) > 0 { + member.original_startline + } else { + None + }; + map_member_without_lines(frame, member, output_line) + }) } impl FusedIterator for RemappedFrameIter<'_> {} +/// Resolves frames for the no-line (frame_line==0) case. +/// +/// When the input frame has no line number, base entries (endline==0) are preferred +/// over line-mapped entries. Base entries are split into two groups by whether +/// `startline` is present (0:0 entries) or absent (no-range entries), and each +/// group's output lines are computed based on whether the group contains range +/// mappings, single-line mappings, or bare methods. +fn resolve_no_line_frames<'s>( + frame: &StackFrame<'s>, + mapping_entries: &'s [MemberMapping<'s>], + base_entries: &[&'s MemberMapping<'s>], + collected: &mut CollectedFrames<'s>, +) { + if !base_entries.is_empty() { + resolve_base_entries(frame, base_entries, collected); + return; + } + + // No base entries — fall back to all entries with output line 0. + let Some(first) = mapping_entries.first() else { + return; + }; + let unambiguous = mapping_entries.iter().all(|m| m.original == first.original); + if unambiguous { + collected + .frames + .push(map_member_without_lines(frame, first, Some(0))); + collected.rewrite_rules.extend(first.rewrite_rules.iter()); + } else { + for member in mapping_entries { + collected + .frames + .push(map_member_without_lines(frame, member, Some(0))); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } +} + +/// Resolves output lines for base (endline==0) entries when the frame has no line number. +/// +/// Entries are split by whether `startline` is present: +/// - **0:0 entries** (`startline.is_some()`): if any entry has a range +/// (`original_endline != original_startline`), all emit `Some(0)`; +/// otherwise each emits its `original_startline`. +/// - **No-range entries** (`startline.is_none()`): a single entry emits +/// its `original_startline` if > 0, otherwise `Some(0)`; multiple entries +/// with the same name collapse to one frame with `Some(0)`; different names +/// each emit `Some(0)` in original order. +fn resolve_base_entries<'s>( + frame: &StackFrame<'s>, + base_entries: &[&'s MemberMapping<'s>], + collected: &mut CollectedFrames<'s>, +) { + // Pre-compute aggregates in a single pass. + // Whether any 0:0 entry has a multi-line original range (original_endline != original_startline). + let mut any_zero_zero_has_range = false; + // Number of no-range (startline.is_none()) entries. + let mut no_range_count = 0usize; + // Original name of the first no-range entry, used to detect ambiguity. + let mut first_no_range_name: Option<&str> = None; + // Whether all no-range entries map to the same original method name. + let mut all_no_range_same_name = true; + let mut all_no_range_have_line_mapping = true; + for member in base_entries { + if member.startline.is_some() { + if member.original_endline.is_some() + && member.original_endline != member.original_startline + { + any_zero_zero_has_range = true; + } + } else { + no_range_count += 1; + if member.original_startline.is_none() { + all_no_range_have_line_mapping = false; + } + match first_no_range_name { + None => first_no_range_name = Some(member.original), + Some(first) if member.original != first => all_no_range_same_name = false, + _ => {} + } + } + } + + // Whether a no-range entry has already been emitted (used to collapse duplicates). + let mut no_range_emitted = false; + for member in base_entries { + if member.startline.is_some() { + let line = if any_zero_zero_has_range { + Some(0) + } else if member.original_startline.unwrap_or(0) > 0 { + member.original_startline + } else { + None + }; + collected + .frames + .push(map_member_without_lines(frame, member, line)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } else if all_no_range_same_name { + if !no_range_emitted { + no_range_emitted = true; + let line = if no_range_count == 1 { + member.original_startline.or(Some(0)) + } else { + Some(0) + }; + collected + .frames + .push(map_member_without_lines(frame, member, line)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } else { + collected + .frames + .push(map_member_without_lines(frame, member, Some(0))); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } + + // Sort no-range frames by original method name when all have line mappings; + // bare method entries preserve original mapping file order. + if !all_no_range_same_name && all_no_range_have_line_mapping { + collected.frames.sort_by_key(|f| f.method); + } +} + /// A Proguard Remapper. /// /// This can remap class names, stack frames one at a time, or the complete @@ -491,7 +594,9 @@ impl<'s> ProguardMapper<'s> { candidates .iter() .filter(|m| { - m.endline == 0 || (callsite_line >= m.startline && callsite_line <= m.endline) + m.endline.unwrap_or(0) == 0 + || (callsite_line >= m.startline.unwrap_or(0) + && callsite_line <= m.endline.unwrap_or(0)) }) .find_map(|m| { m.outline_callsite_positions @@ -522,11 +627,11 @@ impl<'s> ProguardMapper<'s> { if let Some(mapped) = self.map_outline_position( effective.class, effective.method, - effective.line, + effective.line.unwrap_or(0), pos, effective.parameters, ) { - effective.line = mapped; + effective.line = Some(mapped); } } @@ -580,57 +685,85 @@ impl<'s> ProguardMapper<'s> { }; if frame.parameters.is_none() { - let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + let has_line_info = mapping_entries.iter().any(|m| m.endline.unwrap_or(0) > 0); + let frame_line = frame.line.unwrap_or(0); + + // Base entries are those with endline == 0 (no minified range or 0:0 range). + let base_entries: Vec<&MemberMapping<'s>> = mapping_entries + .iter() + .filter(|m| m.endline.unwrap_or(0) == 0) + .collect(); // 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 selection = select_no_line_members(mapping_entries, has_line_info); - match selection { - Some(NoLineSelection::Single(member)) => { + if frame_line == 0 { + resolve_no_line_frames(&frame, mapping_entries, &base_entries, &mut collected); + return collected; + } + + // Frame has a line number > 0. + for member in mapping_entries { + if has_line_info && frame_line > 0 && member.endline.unwrap_or(0) == 0 { + continue; + } + if member.endline.unwrap_or(0) == 0 { + // No-range entry with frame_line > 0. + if member.original_startline.is_none() { + // Bare method mapping (no line info) — pass through frame line. collected .frames - .push(map_member_without_lines(&frame, member)); + .push(map_member_without_lines(&frame, member, frame.line)); collected.rewrite_rules.extend(member.rewrite_rules.iter()); + continue; } - Some(NoLineSelection::IterateAll) => { - for member in mapping_entries { - collected - .frames - .push(map_member_without_lines(&frame, member)); + // Span expansion: if the original range spans multiple lines, + // emit one frame per original line. + if let Some(oe) = member.original_endline { + let os = member.original_startline.unwrap_or(0); + if oe > os && (oe - os) <= MAX_SPAN_EXPANSION { + for line in os..=oe { + collected.frames.push(map_member_without_lines( + &frame, + member, + Some(line), + )); + } collected.rewrite_rules.extend(member.rewrite_rules.iter()); + continue; } } - Some(NoLineSelection::IterateBase) => { - for member in mapping_entries.iter().filter(|m| m.endline == 0) { - collected - .frames - .push(map_member_without_lines(&frame, member)); - collected.rewrite_rules.extend(member.rewrite_rules.iter()); - } - } - None => return collected, - } - return collected; - } - - for member in mapping_entries { - if has_line_info && member.endline == 0 { - continue; - } - if member.endline == 0 { + // Single-line: use original_startline if > 0, else None. + let output_line = if member.original_startline.unwrap_or(0) > 0 { + member.original_startline + } else { + None + }; collected .frames - .push(map_member_without_lines(&frame, member)); + .push(map_member_without_lines(&frame, member, output_line)); 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()); } } + + // Outside-range fallback: if we had line mappings but nothing matched, + // remap only the class name, keeping the obfuscated method name and original line. + if collected.frames.is_empty() && has_line_info { + collected + .frames + .push(remap_class_only(&frame, frame.file())); + } } else { for member in mapping_entries { - let mapped = map_member_without_lines(&frame, member); + // For parameter-based lookups, use original_startline if > 0, else None + let output_line = if member.original_startline.unwrap_or(0) > 0 { + member.original_startline + } else { + None + }; + let mapped = map_member_without_lines(&frame, member, output_line); collected.frames.push(mapped); collected.rewrite_rules.extend(member.rewrite_rules.iter()); } @@ -692,7 +825,10 @@ impl<'s> ProguardMapper<'s> { members.all_mappings.iter() }; - let has_line_info = members.all_mappings.iter().any(|m| m.endline > 0); + let has_line_info = members + .all_mappings + .iter() + .any(|m| m.endline.unwrap_or(0) > 0); RemappedFrameIter::members(frame, mappings, has_line_info) } @@ -744,7 +880,7 @@ impl<'s> ProguardMapper<'s> { if let Some(frame) = stacktrace::parse_frame(line) { if self.is_outline_frame(frame.class, frame.method) { - carried_outline_pos = Some(frame.line); + carried_outline_pos = Some(frame.line.unwrap_or(0)); continue; } @@ -810,7 +946,7 @@ impl<'s> ProguardMapper<'s> { let mut next_frame_can_rewrite = exception_descriptor.is_some(); for f in trace.frames.iter() { if self.is_outline_frame(f.class, f.method) { - carried_outline_pos = Some(f.line); + carried_outline_pos = Some(f.line.unwrap_or(0)); continue; } @@ -913,7 +1049,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: StackFrame { class: "com.example.MainFragment$g", method: "onClick", - line: 2, + line: Some(2), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -921,7 +1057,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: StackFrame { class: "android.view.View", method: "performClick", - line: 7393, + line: Some(7393), file: Some(Cow::Borrowed("View.java")), parameters: None, method_synthesized: false, @@ -935,7 +1071,7 @@ com.example.MainFragment$onActivityCreated$4 -> com.example.MainFragment$g: frames: vec![StackFrame { class: "com.example.MainFragment$g", method: "onClick", - line: 1, + line: Some(1), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1055,7 +1191,7 @@ some.Class -> a: frames: vec![StackFrame { class: "a", method: "a", - line: 4, + line: Some(4), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1069,7 +1205,7 @@ some.Class -> a: assert_eq!(remapped.frames.len(), 1); assert_eq!(remapped.frames[0].class, "some.Class"); assert_eq!(remapped.frames[0].method, "caller"); - assert_eq!(remapped.frames[0].line, 7); + assert_eq!(remapped.frames[0].line, Some(7)); } #[test] @@ -1176,7 +1312,7 @@ some.Other -> b: StackFrame { class: "a", method: "call", - line: 4, + line: Some(4), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1184,7 +1320,7 @@ some.Other -> b: StackFrame { class: "b", method: "run", - line: 5, + line: Some(5), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -1200,6 +1336,6 @@ some.Other -> b: assert_eq!(remapped.frames.len(), 1); assert_eq!(remapped.frames[0].class, "some.Other"); assert_eq!(remapped.frames[0].method, "method"); - assert_eq!(remapped.frames[0].line, 30); + assert_eq!(remapped.frames[0].line, Some(30)); } } diff --git a/src/mapping.rs b/src/mapping.rs index f9668d6..ebafa83 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -287,10 +287,10 @@ impl<'s> Iterator for ProguardRecordIter<'s> { /// All line mappings are 1-based and inclusive. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct LineMapping { - /// Start Line, 1-based. - pub startline: usize, - /// End Line, inclusive. - pub endline: usize, + /// Start Line, 1-based. `None` when no minified range prefix was present. + pub startline: Option, + /// End Line, inclusive. `None` when no minified range prefix was present. + pub endline: Option, /// The original Start Line. pub original_startline: Option, /// The original End Line. @@ -468,8 +468,8 @@ impl<'s> ProguardRecord<'s> { /// arguments: "", /// original_class: Some("com.example1.domain.MyBean"), /// line_mapping: Some(proguard::LineMapping { - /// startline: 1016, - /// endline: 1016, + /// startline: Some(1016), + /// endline: Some(1016), /// original_startline: Some(16), /// original_endline: Some(16), /// }), @@ -637,16 +637,16 @@ fn parse_proguard_field_or_method( let line_mapping = match (startline, endline, original_startline) { (Some(startline), Some(endline), _) => Some(LineMapping { - startline, - endline, + startline: Some(startline), + endline: Some(endline), original_startline, original_endline, }), // Preserve original line info even when no minified range is present. // This enables this crate to use the original line for no-line mappings. (None, None, Some(original_startline)) => Some(LineMapping { - startline: 0, - endline: 0, + startline: None, + endline: None, original_startline: Some(original_startline), original_endline, }), @@ -981,8 +981,8 @@ mod tests { arguments: "androidx.appcompat.widget.Toolbar", original_class: Some("androidx.appcompat.app.AppCompatDelegateImpl"), line_mapping: Some(LineMapping { - startline: 14, - endline: 15, + startline: Some(14), + endline: Some(15), original_startline: None, original_endline: None, }), @@ -1003,8 +1003,8 @@ mod tests { arguments: "androidx.appcompat.widget.Toolbar", original_class: Some("androidx.appcompat.app.AppCompatDelegateImpl"), line_mapping: Some(LineMapping { - startline: 14, - endline: 15, + startline: Some(14), + endline: Some(15), original_startline: Some(436), original_endline: None, }), @@ -1025,8 +1025,8 @@ mod tests { arguments: "androidx.appcompat.widget.Toolbar", original_class: Some("androidx.appcompat.app.AppCompatDelegateImpl"), line_mapping: Some(LineMapping { - startline: 14, - endline: 15, + startline: Some(14), + endline: Some(15), original_startline: Some(436), original_endline: Some(437), }), @@ -1161,8 +1161,8 @@ androidx.activity.OnBackPressedCallback arguments: "", original_class: None, line_mapping: Some(LineMapping { - startline: 1, - endline: 4, + startline: Some(1), + endline: Some(4), original_startline: Some(184), original_endline: Some(187), }), diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 9e56806..053ade1 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -154,7 +154,7 @@ fn parse_stacktrace(content: &str) -> Option> { pub struct StackFrame<'s> { pub(crate) class: &'s str, pub(crate) method: &'s str, - pub(crate) line: usize, + pub(crate) line: Option, pub(crate) file: Option>, pub(crate) parameters: Option<&'s str>, pub(crate) method_synthesized: bool, @@ -166,7 +166,7 @@ impl<'s> StackFrame<'s> { Self { class, method, - line, + line: Some(line), file: None, parameters: None, method_synthesized: false, @@ -178,7 +178,7 @@ impl<'s> StackFrame<'s> { Self { class, method, - line, + line: Some(line), file: Some(Cow::Borrowed(file)), parameters: None, method_synthesized: false, @@ -191,7 +191,7 @@ impl<'s> StackFrame<'s> { Self { class, method, - line: 0, + line: None, file: None, parameters: Some(arguments), method_synthesized: false, @@ -248,7 +248,9 @@ impl<'s> StackFrame<'s> { } /// The line of the StackFrame, 1-based. - pub fn line(&self) -> usize { + /// + /// Returns `None` if the frame has no line information. + pub fn line(&self) -> Option { self.line } @@ -266,11 +268,14 @@ impl<'s> StackFrame<'s> { impl Display for StackFrame<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { let file_name = self.file.as_deref().unwrap_or(""); - write!( - f, - "at {}.{}({}:{})", - self.class, self.method, file_name, self.line - ) + match self.line { + Some(line) => write!( + f, + "at {}.{}({}:{})", + self.class, self.method, file_name, line + ), + None => write!(f, "at {}.{}({})", self.class, self.method, file_name), + } } } @@ -287,8 +292,8 @@ 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) = match file_split.rsplit_once(':') { - Some((file, line)) => (file, line.parse().unwrap_or(0)), - None => (file_split, 0), + Some((file, line)) => (file, Some(line.parse().unwrap_or(0))), + None => (file_split, None), }; Some(StackFrame { @@ -402,7 +407,7 @@ mod tests { frames: vec![StackFrame { class: "com.example.Util", method: "show", - line: 5, + line: Some(5), file: Some(Cow::Borrowed("Util.java")), parameters: None, method_synthesized: false, @@ -415,7 +420,7 @@ mod tests { frames: vec![StackFrame { class: "com.example.Parser", method: "parse", - line: 115, + line: Some(115), file: None, parameters: None, method_synthesized: false, @@ -439,7 +444,7 @@ Caused by: com.example.Other: Invalid data let expect = Some(StackFrame { class: "com.example.MainFragment", method: "onClick", - line: 1, + line: Some(1), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, @@ -463,7 +468,7 @@ Caused by: com.example.Other: Invalid data let frame = StackFrame { class: "com.example.MainFragment", method: "onClick", - line: 1, + line: Some(1), file: None, parameters: None, method_synthesized: false, @@ -477,7 +482,7 @@ Caused by: com.example.Other: Invalid data let frame = StackFrame { class: "com.example.MainFragment", method: "onClick", - line: 1, + line: Some(1), file: Some(Cow::Borrowed("SourceFile")), parameters: None, method_synthesized: false, diff --git a/src/utils.rs b/src/utils.rs index fa0089c..94a5a1a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,21 +1,5 @@ //! Internal helpers shared across modules. -/// For no-line mappings, prefer the original line if the mapping has no minified range. -pub(crate) fn resolve_no_line_output_line( - frame_line: usize, - original_startline: Option, - startline: usize, - endline: usize, -) -> usize { - if frame_line > 0 { - frame_line - } else if startline == 0 && endline == 0 { - original_startline.unwrap_or(0) - } else { - 0 - } -} - pub(crate) fn extract_class_name(full_path: &str) -> Option<&str> { let after_last_period = full_path.split('.').next_back()?; // If the class is an inner class, we need to extract the outer class name diff --git a/tests/r8-inline.rs b/tests/r8-inline.rs index 0f70b69..6dc7ac9 100644 --- a/tests/r8-inline.rs +++ b/tests/r8-inline.rs @@ -83,13 +83,13 @@ fn test_inline_with_line_numbers_frame() { assert_eq!(frames.len(), 4); assert_eq!(frames[0].method(), "method3"); - assert_eq!(frames[0].line(), 81); + assert_eq!(frames[0].line(), Some(81)); assert_eq!(frames[1].method(), "method2"); - assert_eq!(frames[1].line(), 88); + assert_eq!(frames[1].line(), Some(88)); assert_eq!(frames[2].method(), "method1"); - assert_eq!(frames[2].line(), 96); + assert_eq!(frames[2].line(), Some(96)); assert_eq!(frames[3].method(), "main"); - assert_eq!(frames[3].line(), 102); + assert_eq!(frames[3].line(), Some(102)); } // ============================================================================= @@ -493,15 +493,15 @@ fn test_inline_preamble_no_original() { let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 2)).collect(); assert_eq!(frames.len(), 1); assert_eq!(frames[0].method(), "caller"); - assert_eq!(frames[0].line(), 10); + assert_eq!(frames[0].line(), Some(10)); // Test line 5 - should be in inline range (4:5) let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 5)).collect(); assert_eq!(frames.len(), 2); assert_eq!(frames[0].method(), "inlined"); - assert_eq!(frames[0].line(), 21); + assert_eq!(frames[0].line(), Some(21)); assert_eq!(frames[1].method(), "caller"); - assert_eq!(frames[1].line(), 11); + assert_eq!(frames[1].line(), Some(11)); } // ============================================================================= @@ -580,15 +580,15 @@ fn test_inline_frame_depth_one() { let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 1)).collect(); assert_eq!(frames.len(), 1); assert_eq!(frames[0].method(), "foo"); - assert_eq!(frames[0].line(), 10); + assert_eq!(frames[0].line(), Some(10)); // Line 2 - one level of inlining let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 2)).collect(); assert_eq!(frames.len(), 2); assert_eq!(frames[0].method(), "bar"); - assert_eq!(frames[0].line(), 20); + assert_eq!(frames[0].line(), Some(20)); assert_eq!(frames[1].method(), "foo"); - assert_eq!(frames[1].line(), 11); + assert_eq!(frames[1].line(), Some(11)); } #[test] @@ -605,11 +605,11 @@ fn test_inline_frame_depth_two() { let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 2)).collect(); assert_eq!(frames.len(), 3); assert_eq!(frames[0].method(), "baz"); - assert_eq!(frames[0].line(), 30); + assert_eq!(frames[0].line(), Some(30)); assert_eq!(frames[1].method(), "bar"); - assert_eq!(frames[1].line(), 21); + assert_eq!(frames[1].line(), Some(21)); assert_eq!(frames[2].method(), "foo"); - assert_eq!(frames[2].line(), 11); + assert_eq!(frames[2].line(), Some(11)); } #[test] @@ -631,11 +631,11 @@ fn test_inline_frame_depth_two_cache() { let frames: Vec<_> = cache.remap_frame(&frame).collect(); assert_eq!(frames.len(), 3); assert_eq!(frames[0].method(), "baz"); - assert_eq!(frames[0].line(), 30); + assert_eq!(frames[0].line(), Some(30)); assert_eq!(frames[1].method(), "bar"); - assert_eq!(frames[1].line(), 21); + assert_eq!(frames[1].line(), Some(21)); assert_eq!(frames[2].method(), "foo"); - assert_eq!(frames[2].line(), 11); + assert_eq!(frames[2].line(), Some(11)); } // ============================================================================= @@ -655,15 +655,15 @@ fn test_inline_with_line_range() { let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 3)).collect(); assert_eq!(frames.len(), 1); assert_eq!(frames[0].method(), "outer"); - assert_eq!(frames[0].line(), 12); // 10 + (3-1) = 12 + assert_eq!(frames[0].line(), Some(12)); // 10 + (3-1) = 12 // Line 8 - in inline range let frames: Vec<_> = mapper.remap_frame(&StackFrame::new("a", "a", 8)).collect(); assert_eq!(frames.len(), 2); assert_eq!(frames[0].method(), "inner"); - assert_eq!(frames[0].line(), 22); // 20 + (8-6) = 22 + assert_eq!(frames[0].line(), Some(22)); // 20 + (8-6) = 22 assert_eq!(frames[1].method(), "outer"); - assert_eq!(frames[1].line(), 15); + assert_eq!(frames[1].line(), Some(15)); } // ============================================================================= @@ -689,15 +689,15 @@ com.example.Main -> a: assert_eq!(frames[0].class(), "com.example.lib.Library"); assert_eq!(frames[0].method(), "work"); - assert_eq!(frames[0].line(), 100); + assert_eq!(frames[0].line(), Some(100)); assert_eq!(frames[1].class(), "com.example.util.Utils"); assert_eq!(frames[1].method(), "helper"); - assert_eq!(frames[1].line(), 51); + assert_eq!(frames[1].line(), Some(51)); assert_eq!(frames[2].class(), "com.example.Main"); assert_eq!(frames[2].method(), "main"); - assert_eq!(frames[2].line(), 11); + assert_eq!(frames[2].line(), Some(11)); } #[test] @@ -783,7 +783,7 @@ fn test_inline_with_zero_original_line() { // Should have 2 frames - the inline chain assert_eq!(frames.len(), 2); assert_eq!(frames[0].method(), "main"); - assert_eq!(frames[0].line(), 0); + assert_eq!(frames[0].line(), Some(0)); assert_eq!(frames[1].method(), "caller"); - assert_eq!(frames[1].line(), 10); + assert_eq!(frames[1].line(), Some(10)); } diff --git a/tests/r8-line-number-handling.rs b/tests/r8-line-number-handling.rs new file mode 100644 index 0000000..190da5a --- /dev/null +++ b/tests/r8-line-number-handling.rs @@ -0,0 +1,315 @@ +//! Tests for R8 retrace "Line Number Handling" fixtures. +//! +//! Ported from the upstream R8 retrace fixtures in: +//! `src/test/java/com/android/tools/r8/retrace/stacktraces/`. +//! +//! Notes: +//! - These tests intentionally **omit** upstream `` markers and instead list alternative frames +//! as duplicates, since this crate does not currently format `` groups. +//! - Fixture mapping indentation is normalized to 4-space member indentation so it is parsed by this +//! crate's Proguard mapping parser. +//! - Expected stacktrace indentation is normalized to this crate's output (`" at ..."`). +#![allow(clippy::unwrap_used)] + +use proguard::{ProguardCache, ProguardMapper, ProguardMapping}; + +fn assert_remap_stacktrace(mapping: &str, input: &str, expected: &str) { + let mapper = ProguardMapper::from(mapping); + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim_end(), expected.trim_end()); + + let mapping = ProguardMapping::new(mapping.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + cache.test(); + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim_end(), expected.trim_end()); +} + +// ============================================================================= +// NoObfuscationRangeMappingWithStackTrace +// ============================================================================= + +const NO_OBFUSCATION_RANGE_MAPPING_WITH_STACKTRACE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> foo: + void foo(long):1:1 -> a + void bar(int):3 -> b + void baz():0:0 -> c + void main(java.lang.String[]):0 -> d +"#; + +#[test] +fn test_no_obfuscation_range_mapping_with_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at foo.a(Bar.dummy:0) + at foo.b(Foo.dummy:2) + at foo.c(Baz.dummy:8) + at foo.d(Qux.dummy:7) +"#; + + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.foo(Main.java:1) + at com.android.tools.r8.naming.retrace.Main.bar(Main.java:3) + at com.android.tools.r8.naming.retrace.Main.baz(Main.java) + at com.android.tools.r8.naming.retrace.Main.main(Main.java) +"#; + + assert_remap_stacktrace( + NO_OBFUSCATION_RANGE_MAPPING_WITH_STACKTRACE_MAPPING, + input, + expected, + ); +} + +// ============================================================================= +// OutsideLineRangeStackTraceTest +// ============================================================================= + +const OUTSIDE_LINE_RANGE_STACKTRACE_MAPPING: &str = r#"some.other.Class -> a: + void method1():42:42 -> a +some.Class -> b: + 1:3:void method2():11:13 -> a + 4:4:void method2():10:10 -> a +"#; + +#[test] +fn test_outside_line_range_stacktrace() { + let input = r#"java.io.IOException: INVALID_SENDER + at a.a(:2) + at a.a(Unknown Source) + at b.a(:27) + at b.a(Unknown Source) +"#; + + let expected = r#"java.io.IOException: INVALID_SENDER + at some.other.Class.method1(Class.java:42) + at some.other.Class.method1(Class.java:42) + at some.Class.a(Class.java:27) + at some.Class.method2(Class.java:0) +"#; + + assert_remap_stacktrace(OUTSIDE_LINE_RANGE_STACKTRACE_MAPPING, input, expected); +} + +// ============================================================================= +// PreambleLineNumberStackTrace +// ============================================================================= + +const PREAMBLE_LINE_NUMBER_MAPPING: &str = r#"kotlin.ResultKt -> kotlin.t: + 1:1:void createFailure(java.lang.Throwable):122:122 -> a + 2:2:void createFailure(java.lang.Throwable):124:124 -> a +"#; + +#[test] +fn test_preamble_line_number_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at kotlin.t.a(SourceFile) + at kotlin.t.a(SourceFile:0) + at kotlin.t.a(SourceFile:1) + at kotlin.t.a(SourceFile:2) +"#; + + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at kotlin.ResultKt.createFailure(Result.kt:0) + at kotlin.ResultKt.createFailure(Result.kt:0) + at kotlin.ResultKt.createFailure(Result.kt:122) + at kotlin.ResultKt.createFailure(Result.kt:124) +"#; + + assert_remap_stacktrace(PREAMBLE_LINE_NUMBER_MAPPING, input, expected); +} + +// ============================================================================= +// DifferentLineNumberSpanStackTrace +// ============================================================================= + +const DIFFERENT_LINE_NUMBER_SPAN_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> a: + void method1(java.lang.String):42:44 -> a +"#; + +#[test] +fn test_different_line_number_span_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at a.a(Unknown Source:1) +"#; + + // Upstream emits 3 alternatives with ``. We list them as duplicates instead. + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.method1(Main.java:42) + at com.android.tools.r8.naming.retrace.Main.method1(Main.java:43) + at com.android.tools.r8.naming.retrace.Main.method1(Main.java:44) +"#; + + assert_remap_stacktrace(DIFFERENT_LINE_NUMBER_SPAN_MAPPING, input, expected); +} + +// ============================================================================= +// ObfuscatedRangeToSingleLineStackTrace +// ============================================================================= + +const OBFUSCATED_RANGE_TO_SINGLE_LINE_MAPPING: &str = r#"foo.bar.Baz -> a: + 1:10:void qux():27:27 -> a + 11:15:void qux():42 -> a + 1337:1400:void foo.bar.Baz.quux():113:113 -> b + 1337:1400:void quuz():72 -> b +"#; + +#[test] +fn test_obfuscated_range_to_single_line_stacktrace() { + let input = r#"UnknownException: This is just a fake exception + at a.a(:8) + at a.a(:13) + at a.b(:1399) +"#; + + let expected = r#"UnknownException: This is just a fake exception + at foo.bar.Baz.qux(Baz.java:27) + at foo.bar.Baz.qux(Baz.java:42) + at foo.bar.Baz.quux(Baz.java:113) + at foo.bar.Baz.quuz(Baz.java:72) +"#; + + assert_remap_stacktrace(OBFUSCATED_RANGE_TO_SINGLE_LINE_MAPPING, input, expected); +} + +// ============================================================================= +// NoObfuscatedLineNumberWithOverrideTest +// ============================================================================= + +const NO_OBFUSCATED_LINE_NUMBER_WITH_OVERRIDE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> com.android.tools.r8.naming.retrace.Main: + void main(java.lang.String):3 -> main + void definedOverload():7 -> definedOverload + void definedOverload(java.lang.String):11 -> definedOverload + void overload1():7 -> overload + void overload2(java.lang.String):11 -> overload + void mainPC(java.lang.String[]):42 -> mainPC +"#; + +#[test] +fn test_no_obfuscated_line_number_with_override() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Unknown Source) + at com.android.tools.r8.naming.retrace.Main.overload(Unknown Source) + at com.android.tools.r8.naming.retrace.Main.definedOverload(Unknown Source) + at com.android.tools.r8.naming.retrace.Main.mainPC(:3) +"#; + + // Upstream emits an `` alternative for `overload`. We list both as duplicates instead. + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:3) + at com.android.tools.r8.naming.retrace.Main.overload1(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.overload2(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.definedOverload(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.mainPC(Main.java:42) +"#; + + assert_remap_stacktrace( + NO_OBFUSCATED_LINE_NUMBER_WITH_OVERRIDE_MAPPING, + input, + expected, + ); +} + +// ============================================================================= +// SingleLineNoLineNumberStackTrace +// ============================================================================= + +const SINGLE_LINE_NO_LINE_NUMBER_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> foo.a: + 0:0:void method1(java.lang.String):42:42 -> a + 0:0:void main(java.lang.String[]):28 -> a + 0:0:void method2(java.lang.String):42:44 -> b + 0:0:void main2(java.lang.String[]):29 -> b + void method3(java.lang.String):72:72 -> c + void main3(java.lang.String[]):30 -> c + void main4(java.lang.String[]):153 -> d +"#; + +#[test] +fn test_single_line_no_line_number_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at foo.a.a(Unknown Source) + at foo.a.b(Unknown Source) + at foo.a.c(Unknown Source) + at foo.a.d(Unknown Source) +"#; + + // Upstream emits an `` alternative for frame `c`. We list both as duplicates instead. + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.method1(Main.java:42) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:28) + at com.android.tools.r8.naming.retrace.Main.method2(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main2(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main3(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.method3(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main4(Main.java:153) +"#; + + assert_remap_stacktrace(SINGLE_LINE_NO_LINE_NUMBER_MAPPING, input, expected); +} + +// ============================================================================= +// MultipleLinesNoLineNumberStackTrace +// ============================================================================= + +const MULTIPLE_LINES_NO_LINE_NUMBER_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> foo.a: + 0:0:void method1(java.lang.String):42:42 -> a + 0:0:void main(java.lang.String[]):28 -> a + 1:1:void main(java.lang.String[]):153 -> a +"#; + +#[test] +fn test_multiple_lines_no_line_number_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at foo.a.a(Unknown Source) +"#; + + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.method1(Main.java:42) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:28) +"#; + + assert_remap_stacktrace(MULTIPLE_LINES_NO_LINE_NUMBER_MAPPING, input, expected); +} + +// ============================================================================= +// InvalidMinifiedRangeStackTrace +// ============================================================================= + +const INVALID_MINIFIED_RANGE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> com.android.tools.r8.naming.retrace.Main: + 5:3:void main(java.lang.String[]) -> main +"#; + +#[test] +fn test_invalid_minified_range_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.dummy:3) +"#; + + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:3) +"#; + + assert_remap_stacktrace(INVALID_MINIFIED_RANGE_MAPPING, input, expected); +} + +// ============================================================================= +// InvalidOriginalRangeStackTrace +// ============================================================================= + +const INVALID_ORIGINAL_RANGE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> com.android.tools.r8.naming.retrace.Main: + 2:5:void main(java.lang.String[]):5:2 -> main +"#; + +#[test] +fn test_invalid_original_range_stacktrace() { + let input = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.dummy:3) +"#; + + let expected = r#"Exception in thread "main" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:3) +"#; + + assert_remap_stacktrace(INVALID_ORIGINAL_RANGE_MAPPING, input, expected); +} diff --git a/tests/r8.rs b/tests/r8.rs index cfe58b1..c534587 100644 --- a/tests/r8.rs +++ b/tests/r8.rs @@ -410,16 +410,16 @@ fn rewrite_frame_complex_stacktrace_typed() { assert_eq!(frames.len(), 4); assert_eq!(frames[0].class(), "com.example.flow.Initializer"); assert_eq!(frames[0].method(), "start"); - assert_eq!(frames[0].line(), 42); + assert_eq!(frames[0].line(), Some(42)); assert_eq!(frames[1].class(), "com.example.flow.StreamRouter$Inline"); assert_eq!(frames[1].method(), "internalDispatch"); - assert_eq!(frames[1].line(), 30); + assert_eq!(frames[1].line(), Some(30)); assert_eq!(frames[2].class(), "com.example.flow.StreamRouter"); assert_eq!(frames[2].method(), "dispatch"); - assert_eq!(frames[2].line(), 12); + assert_eq!(frames[2].line(), Some(12)); assert_eq!(frames[3].class(), "com.example.flow.UiBridge"); assert_eq!(frames[3].method(), "render"); - assert_eq!(frames[3].line(), 200); + assert_eq!(frames[3].line(), Some(200)); // Caused by exception (also not in mapping) let cause = remapped.cause().unwrap(); @@ -430,10 +430,10 @@ fn rewrite_frame_complex_stacktrace_typed() { assert_eq!(cause_frames.len(), 2); assert_eq!(cause_frames[0].class(), "com.example.flow.StreamRouter"); assert_eq!(cause_frames[0].method(), "dispatch"); - assert_eq!(cause_frames[0].line(), 12); + assert_eq!(cause_frames[0].line(), Some(12)); assert_eq!(cause_frames[1].class(), "com.example.flow.UiBridge"); assert_eq!(cause_frames[1].method(), "render"); - assert_eq!(cause_frames[1].line(), 200); + assert_eq!(cause_frames[1].line(), Some(200)); } #[test] @@ -541,7 +541,7 @@ fn test_method_with_zero_zero_and_line_specific_mappings() { ); assert_eq!(frame.method(), "obtainDropShadowRenderer-eZhPAX0"); // Should map to line 70 (from the 1:4: mapping), not line 68 (from the 0:0: mapping) - assert_eq!(frame.line(), 70); + assert_eq!(frame.line(), Some(70)); assert_eq!(mapped.next(), None); } @@ -574,6 +574,6 @@ fn test_method_with_zero_zero_and_line_specific_mappings_cache() { ); assert_eq!(remapped_frame.method(), "obtainDropShadowRenderer-eZhPAX0"); // Should map to line 70 (from the 1:4: mapping), not line 68 (from the 0:0: mapping) - assert_eq!(remapped_frame.line(), 70); + assert_eq!(remapped_frame.line(), Some(70)); assert_eq!(mapped.next(), None); }