diff --git a/src/builder.rs b/src/builder.rs index 0244952..37a8448 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -291,9 +291,11 @@ 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| { + // If the mapping has no line records, use a sentinel to distinguish from + // an explicit 0:0 minified range. + let (startline, endline) = line_mapping + .as_ref() + .map_or((usize::MAX, usize::MAX), |line_mapping| { (line_mapping.startline, line_mapping.endline) }); let (original_startline, original_endline) = diff --git a/src/cache/mod.rs b/src/cache/mod.rs index c2c1ac9..830de97 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -88,6 +88,19 @@ use crate::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Thr pub use raw::{ProguardCache, PRGCACHE_VERSION}; +const NO_MINIFIED_RANGE: u32 = u32::MAX; + +fn is_implicit_no_range(member: &raw::Member) -> bool { + member.startline == NO_MINIFIED_RANGE && member.endline == NO_MINIFIED_RANGE +} + +fn is_explicit_base(member: &raw::Member) -> bool { + member.startline == 0 && member.endline == 0 +} + +fn has_line_range(member: &raw::Member) -> bool { + !is_implicit_no_range(member) && member.endline > 0 +} /// Result of looking up member mappings for a frame. /// Contains: (members, prepared_frame, rewrite_rules, had_mappings, has_line_info, outer_source_file) type MemberLookupResult<'data> = ( @@ -389,8 +402,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 + if is_explicit_base(member) + || is_implicit_no_range(member) + || (has_line_range(member) + && prepared_frame.line >= member.startline as usize && prepared_frame.line <= member.endline as usize) { had_mappings = true; @@ -405,7 +420,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(has_line_range); Some(( mapping_entries, @@ -428,7 +443,7 @@ impl<'data> ProguardCache<'data> { ) -> RemappedFrameIter<'r, 'data> { let Some(( members, - prepared_frame, + mut prepared_frame, _rewrite_rules, had_mappings, has_line_info, @@ -438,6 +453,27 @@ impl<'data> ProguardCache<'data> { return RemappedFrameIter::empty(); }; + if prepared_frame.line > 0 + && prepared_frame.file().is_some_and(|file| file.is_empty()) + && members.len() == 1 + && is_implicit_no_range(&members[0]) + { + prepared_frame.line = 0; + } + + if prepared_frame.line > 0 && !had_mappings { + let file = + synthesize_source_file(prepared_frame.class, outer_source_file).map(Cow::Owned); + return RemappedFrameIter::single(StackFrame { + class: prepared_frame.class, + method: prepared_frame.method, + file, + line: prepared_frame.line, + parameters: prepared_frame.parameters, + method_synthesized: false, + }); + } + RemappedFrameIter::members( self, prepared_frame, @@ -485,7 +521,7 @@ impl<'data> ProguardCache<'data> { let Some(( members, - prepared_frame, + mut prepared_frame, rewrite_rules, had_mappings, has_line_info, @@ -509,6 +545,27 @@ impl<'data> ProguardCache<'data> { return Some(RemappedFrameIter::empty()); }; + if prepared_frame.line > 0 + && prepared_frame.file().is_some_and(|file| file.is_empty()) + && members.len() == 1 + && is_implicit_no_range(&members[0]) + { + prepared_frame.line = 0; + } + + if prepared_frame.line > 0 && !had_mappings { + let file = + synthesize_source_file(prepared_frame.class, outer_source_file).map(Cow::Owned); + return Some(RemappedFrameIter::single(StackFrame { + class: prepared_frame.class, + method: prepared_frame.method, + file, + line: prepared_frame.line, + parameters: prepared_frame.parameters, + method_synthesized: false, + })); + } + // Compute skip_count from rewrite rules let skip_count = if apply_rewrite { compute_skip_count(&rewrite_rules, exception_descriptor) @@ -831,8 +888,8 @@ 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>, + /// Precomputed frames (fallbacks, no-line, span expansions). + queued_frames: Vec>, /// Number of frames to skip from rewrite rules. skip_count: usize, /// Whether there were mapping entries (for should_skip determination). @@ -844,10 +901,15 @@ pub struct RemappedFrameIter<'r, 'data> { } impl<'r, 'data> RemappedFrameIter<'r, 'data> { + fn enqueue_frames(&mut self, frames: Vec>) -> Option> { + self.queued_frames = frames.into_iter().rev().collect(); + self.queued_frames.pop() + } + fn empty() -> Self { Self { inner: None, - fallback: None, + queued_frames: Vec::new(), skip_count: 0, had_mappings: false, has_line_info: false, @@ -866,7 +928,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { ) -> Self { Self { inner: Some((cache, frame, members)), - fallback: None, + queued_frames: Vec::new(), skip_count, had_mappings, has_line_info, @@ -877,7 +939,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { fn single(frame: StackFrame<'data>) -> Self { Self { inner: None, - fallback: Some(frame), + queued_frames: vec![frame], skip_count: 0, had_mappings: false, has_line_info: false, @@ -894,7 +956,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { } fn next_inner(&mut self) -> Option> { - if let Some(frame) = self.fallback.take() { + if let Some(frame) = self.queued_frames.pop() { return Some(frame); } @@ -904,40 +966,23 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { // 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; + let frames = collect_no_line_frames( + cache, + &frame, + members.as_slice(), + self.outer_source_file, + )?; + return self.enqueue_frames(frames); + } + + if !self.has_line_info { + let frames = collect_base_frames_with_line( + cache, + &frame, + members.as_slice(), + self.outer_source_file, + )?; + return self.enqueue_frames(frames); } // With a concrete line number, skip base entries if there are line mappings. @@ -976,6 +1021,115 @@ impl<'data> Iterator for RemappedFrameIter<'_, 'data> { } } +fn collect_no_line_frames<'a>( + cache: &ProguardCache<'a>, + frame: &StackFrame<'a>, + members: &[raw::Member], + outer_source_file: Option<&str>, +) -> Option>> { + let selection = select_no_line_selection(members); + let (candidates, suppress_line) = match selection { + NoLineSelection::ExplicitBase(candidates, suppress_line) => (candidates, suppress_line), + NoLineSelection::ImplicitNoRange(candidates, suppress_line) => (candidates, suppress_line), + NoLineSelection::All(candidates, suppress_line) => (candidates, suppress_line), + }; + + let first = candidates.first().copied()?; + let all_same = candidates.iter().all(|m| { + m.original_class_offset == first.original_class_offset + && m.original_name_offset == first.original_name_offset + }); + let iter_candidates: Vec<_> = if all_same { vec![first] } else { candidates }; + + let mut frames = Vec::new(); + for member in iter_candidates { + let mut mapped = map_member_without_lines(cache, frame, member, outer_source_file)?; + if suppress_line { + mapped.line = 0; + } else if is_implicit_no_range(member) && member.original_startline > 0 { + mapped.line = member.original_startline as usize; + } + frames.push(mapped); + } + + if frames.is_empty() { + None + } else { + Some(frames) + } +} + +enum NoLineSelection<'a> { + ExplicitBase(Vec<&'a raw::Member>, bool), + ImplicitNoRange(Vec<&'a raw::Member>, bool), + All(Vec<&'a raw::Member>, bool), +} + +fn select_no_line_selection<'a>(members: &'a [raw::Member]) -> NoLineSelection<'a> { + let explicit_base: Vec<_> = members.iter().filter(|m| is_explicit_base(m)).collect(); + if !explicit_base.is_empty() { + let has_span = explicit_base + .iter() + .any(|m| m.original_endline != u32::MAX && m.original_endline > m.original_startline); + return NoLineSelection::ExplicitBase(explicit_base, has_span); + } + + let implicit_no_range: Vec<_> = members.iter().filter(|m| is_implicit_no_range(m)).collect(); + if !implicit_no_range.is_empty() { + let suppress_line = implicit_no_range.len() > 1; + let mut implicit_no_range = implicit_no_range; + implicit_no_range.sort_by_key(|m| { + if m.original_startline > 0 { + m.original_startline + } else { + u32::MAX + } + }); + return NoLineSelection::ImplicitNoRange(implicit_no_range, suppress_line); + } + + NoLineSelection::All(members.iter().collect(), true) +} + +fn collect_base_frames_with_line<'a>( + cache: &ProguardCache<'a>, + frame: &StackFrame<'a>, + members: &[raw::Member], + outer_source_file: Option<&str>, +) -> Option>> { + let mut frames = Vec::new(); + for member in members { + let is_base = is_explicit_base(member); + let is_implicit = is_implicit_no_range(member); + if !(is_base || is_implicit) { + continue; + } + if member.original_endline != u32::MAX + && member.original_endline > member.original_startline + && member.original_startline > 0 + { + for line in member.original_startline..=member.original_endline { + let mut mapped = map_member_without_lines(cache, frame, member, outer_source_file)?; + mapped.line = line as usize; + frames.push(mapped); + } + continue; + } + + let mut mapped = map_member_without_lines(cache, frame, member, outer_source_file)?; + if is_implicit && member.original_startline > 0 { + mapped.line = member.original_startline as usize; + } + frames.push(mapped); + } + + if frames.is_empty() { + None + } else { + Some(frames) + } +} + fn iterate_with_lines<'a>( cache: &ProguardCache<'a>, frame: &mut StackFrame<'a>, @@ -984,25 +1138,34 @@ fn iterate_with_lines<'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 { + let is_base = is_explicit_base(member); + let is_implicit = is_implicit_no_range(member); + // If this method has line mappings, skip base and implicit entries when we have a line. + if has_line_info && frame.line > 0 && (is_base || is_implicit) { 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, remap without line filters. + if is_base || is_implicit { + let mut mapped = map_member_without_lines(cache, frame, member, outer_source_file)?; + if is_implicit && frame.line > 0 && member.original_startline > 0 { + mapped.line = member.original_startline as usize; + } + return Some(mapped); } // skip any members which do not match our frames line - if member.endline > 0 + if has_line_range(member) + && member.startline <= member.endline && (frame.line < member.startline as usize || frame.line > member.endline 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 - { + let line = if member.original_endline == u32::MAX { + member.original_startline as usize + } else if member.original_endline < member.original_startline { + frame.line + } else if member.original_endline == member.original_startline { member.original_startline as usize } else { member.original_startline as usize + frame.line - member.startline as usize @@ -1043,47 +1206,6 @@ 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>, diff --git a/src/cache/raw.rs b/src/cache/raw.rs index 66a18a1..c431d26 100644 --- a/src/cache/raw.rs +++ b/src/cache/raw.rs @@ -19,7 +19,7 @@ pub(crate) const PRGCACHE_MAGIC: u32 = u32::from_le_bytes(PRGCACHE_MAGIC_BYTES); pub(crate) const PRGCACHE_MAGIC_FLIPPED: u32 = PRGCACHE_MAGIC.swap_bytes(); /// The current version of the ProguardCache format. -pub const PRGCACHE_VERSION: u32 = 4; +pub const PRGCACHE_VERSION: u32 = 5; /// The header of a proguard cache file. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/mapper.rs b/src/mapper.rs index 4e419ed..ee84d5a 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -102,6 +102,20 @@ struct CollectedFrames<'s> { type MemberIter<'m> = std::slice::Iter<'m, MemberMapping<'m>>; +const NO_MINIFIED_RANGE: usize = usize::MAX; + +fn is_implicit_no_range(member: &MemberMapping<'_>) -> bool { + member.startline == NO_MINIFIED_RANGE && member.endline == NO_MINIFIED_RANGE +} + +fn is_explicit_base(member: &MemberMapping<'_>) -> bool { + member.startline == 0 && member.endline == 0 +} + +fn has_line_range(member: &MemberMapping<'_>) -> bool { + !is_implicit_no_range(member) && member.endline > 0 +} + /// An Iterator over remapped StackFrames. #[derive(Clone, Debug, Default)] pub struct RemappedFrameIter<'m> { @@ -140,18 +154,20 @@ 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) { + if has_line_range(member) + && member.startline <= member.endline + && (frame.line < member.startline || frame.line > member.endline) + { 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_startline - } else { - member.original_startline + frame.line - member.startline + let line = match member.original_endline { + None => member.original_startline, + Some(end) if end < member.original_startline => frame.line, + Some(end) if end == member.original_startline => member.original_startline, + Some(_) => member.original_startline + frame.line - member.startline, }; let class = member.original_class.unwrap_or(frame.class); @@ -200,8 +216,8 @@ fn map_member_without_lines<'a>( 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. + // Preserve input line when available, unless this is an explicit 0:0 mapping with + // an original line (which should take precedence in R8 retrace behavior). line, parameters: frame.parameters, method_synthesized: member.is_synthesized, @@ -220,42 +236,105 @@ fn remap_class_only<'a>(frame: &StackFrame<'a>, reference_file: Option<&str>) -> } } -/// 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, + ExplicitBase(Vec<&'a MemberMapping<'a>>, bool), + ImplicitNoRange(Vec<&'a MemberMapping<'a>>, bool), + All(Vec<&'a MemberMapping<'a>>, bool), } -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) +fn select_no_line_selection<'a>(mapping_entries: &'a [MemberMapping<'a>]) -> NoLineSelection<'a> { + let explicit_base: Vec<_> = mapping_entries + .iter() + .filter(|m| is_explicit_base(m)) + .collect(); + if !explicit_base.is_empty() { + let has_span = explicit_base.iter().any(|m| { + m.original_endline + .is_some_and(|end| end > m.original_startline) + }); + return NoLineSelection::ExplicitBase(explicit_base, has_span); + } + + let implicit_no_range: Vec<_> = mapping_entries + .iter() + .filter(|m| is_implicit_no_range(m)) + .collect(); + if !implicit_no_range.is_empty() { + let suppress_line = implicit_no_range.len() > 1; + let mut implicit_no_range = implicit_no_range; + implicit_no_range.sort_by_key(|m| { + if m.original_startline > 0 { + m.original_startline } else { - NoLineSelection::IterateBase - }); + usize::MAX + } + }); + return NoLineSelection::ImplicitNoRange(implicit_no_range, suppress_line); + } + + NoLineSelection::All(mapping_entries.iter().collect(), true) +} + +fn collect_no_line_frames<'a>( + frame: &StackFrame<'a>, + mapping_entries: &'a [MemberMapping<'a>], + collected: &mut CollectedFrames<'a>, +) { + let selection = select_no_line_selection(mapping_entries); + let (candidates, suppress_line) = match selection { + NoLineSelection::ExplicitBase(candidates, suppress_line) => (candidates, suppress_line), + NoLineSelection::ImplicitNoRange(candidates, suppress_line) => (candidates, suppress_line), + NoLineSelection::All(candidates, suppress_line) => (candidates, suppress_line), + }; + + let Some(first) = candidates.first().copied() else { + return; + }; + let all_same = candidates.iter().all(|m| m.original == first.original); + let iter_candidates: Vec<_> = if all_same { vec![first] } else { candidates }; + + for member in iter_candidates { + let mut mapped = map_member_without_lines(frame, member); + if suppress_line { + mapped.line = 0; + } else if is_implicit_no_range(member) && member.original_startline > 0 { + mapped.line = member.original_startline; } + collected.frames.push(mapped); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); } +} - let first = mapping_entries.first()?; - let unambiguous = mapping_entries.iter().all(|m| m.original == first.original); +fn collect_base_frames_with_line<'a>( + frame: &StackFrame<'a>, + mapping_entries: &'a [MemberMapping<'a>], + collected: &mut CollectedFrames<'a>, +) { + for member in mapping_entries { + let is_base = is_explicit_base(member); + let is_implicit = is_implicit_no_range(member); + if !(is_base || is_implicit) { + continue; + } + if let Some(original_endline) = member.original_endline { + if original_endline > member.original_startline && member.original_startline > 0 { + for line in member.original_startline..=original_endline { + let mut mapped = map_member_without_lines(frame, member); + mapped.line = line; + collected.frames.push(mapped); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + continue; + } + } - Some(if unambiguous { - NoLineSelection::Single(first) - } else { - NoLineSelection::IterateAll - }) + let mut mapped = map_member_without_lines(frame, member); + if is_implicit && member.original_startline > 0 { + mapped.line = member.original_startline; + } + collected.frames.push(mapped); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } } fn apply_rewrite_rules<'s>(collected: &mut CollectedFrames<'s>, thrown_descriptor: Option<&str>) { @@ -296,13 +375,19 @@ fn iterate_with_lines<'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 { + let is_base = is_explicit_base(member); + let is_implicit = is_implicit_no_range(member); + // If this method has line mappings, skip base and implicit entries when we have a line. + if has_line_info && frame.line > 0 && (is_base || is_implicit) { 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, remap without line filters. + if is_base || is_implicit { + let mut mapped = map_member_without_lines(frame, member); + if is_implicit && frame.line > 0 && member.original_startline > 0 { + mapped.line = member.original_startline; + } + return Some(mapped); } if let Some(mapped) = map_member_with_lines(frame, member) { return Some(mapped); @@ -580,54 +665,48 @@ 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| has_line_range(m)); + if frame.line > 0 + && frame.file().is_some_and(|file| file.is_empty()) + && mapping_entries.len() == 1 + && is_implicit_no_range(&mapping_entries[0]) + { + // Treat empty file names like "(:2)" as no-line info when there's only a single + // implicit no-range mapping (R8 falls back to the original line in that case). + frame.line = 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. + // applying line filters. If there are explicit 0:0 mappings, prefer those. if frame.line == 0 { - let selection = select_no_line_members(mapping_entries, has_line_info); - match selection { - Some(NoLineSelection::Single(member)) => { - collected - .frames - .push(map_member_without_lines(&frame, member)); - collected.rewrite_rules.extend(member.rewrite_rules.iter()); - } - Some(NoLineSelection::IterateAll) => { - for member in mapping_entries { - collected - .frames - .push(map_member_without_lines(&frame, member)); - collected.rewrite_rules.extend(member.rewrite_rules.iter()); - } - } - 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, - } + collect_no_line_frames(&frame, mapping_entries, &mut collected); return collected; } for member in mapping_entries { - if has_line_info && member.endline == 0 { + let is_base = is_explicit_base(member); + let is_implicit = is_implicit_no_range(member); + if has_line_info && (is_base || is_implicit) { continue; } - if member.endline == 0 { - collected - .frames - .push(map_member_without_lines(&frame, member)); - collected.rewrite_rules.extend(member.rewrite_rules.iter()); + + if is_base || is_implicit { + collect_base_frames_with_line( + &frame, + std::slice::from_ref(member), + &mut collected, + ); } else if let Some(mapped) = map_member_with_lines(&frame, member) { collected.frames.push(mapped); collected.rewrite_rules.extend(member.rewrite_rules.iter()); } } + + if collected.frames.is_empty() { + collected + .frames + .push(remap_class_only(&frame, frame.file())); + } } else { for member in mapping_entries { let mapped = map_member_without_lines(&frame, member); @@ -692,7 +771,7 @@ 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| has_line_range(m)); RemappedFrameIter::members(frame, mappings, has_line_info) } diff --git a/src/mapping.rs b/src/mapping.rs index f9668d6..3fdc28e 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -643,10 +643,19 @@ fn parse_proguard_field_or_method( 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. + // Use a sentinel to distinguish from an explicit 0:0 minified range, but keep + // an explicit 0:0 when the original line is also 0. (None, None, Some(original_startline)) => Some(LineMapping { - startline: 0, - endline: 0, + startline: if original_startline == 0 { + 0 + } else { + usize::MAX + }, + endline: if original_startline == 0 { + 0 + } else { + usize::MAX + }, original_startline: Some(original_startline), original_endline, }), diff --git a/src/utils.rs b/src/utils.rs index fa0089c..c0cc8ea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,16 +1,19 @@ //! Internal helpers shared across modules. -/// For no-line mappings, prefer the original line if the mapping has no minified range. +/// For explicit 0:0 mappings, prefer the original line when available. +/// Otherwise, preserve the input line when present. pub(crate) fn resolve_no_line_output_line( frame_line: usize, original_startline: Option, startline: usize, endline: usize, ) -> usize { - if frame_line > 0 { + if startline == 0 && endline == 0 { + original_startline + .filter(|value| *value > 0 && *value != usize::MAX) + .unwrap_or(0) + } else if frame_line > 0 { frame_line - } else if startline == 0 && endline == 0 { - original_startline.unwrap_or(0) } else { 0 } diff --git a/tests/r8-line-number-handling.NOTES.md b/tests/r8-line-number-handling.NOTES.md deleted file mode 100644 index a475f7e..0000000 --- a/tests/r8-line-number-handling.NOTES.md +++ /dev/null @@ -1,122 +0,0 @@ -# R8 Retrace: Line Number Handling — Current Failures & Needed Fixes - -This note accompanies `tests/r8-line-number-handling.rs`. - -Status when this note was written: - -- **10 tests total** -- **2 passing**: `test_obfuscated_range_to_single_line_stacktrace`, `test_preamble_line_number_stacktrace` -- **8 failing**: listed below - -Like other ported suites, these tests: - -- **Omit upstream `` markers** and list alternatives as duplicate frames. -- **Normalize expected indentation** to this crate’s output (`" at ..."`). -- **Use `:0`** for “no line info” since this crate represents missing line numbers as `0`. - -Below is a **one-by-one** explanation of the remaining failures and what behavior in the crate likely needs fixing. - -## 1) `test_no_obfuscation_range_mapping_with_stacktrace` - -- **Expected**: - - `foo.a(…:0)` retraces to `foo(long):1:1` → `Main.foo(Main.java:1)` - - `foo.b(…:2)` retraces to `bar(int):3` → `Main.bar(Main.java:3)` - - For `0:0` and `0` mappings, upstream expects “use original line info semantics” (see upstream fixture comment). -- **Actual**: - - `Main.foo(Main.java:0)` (lost the `:1`) - - `Main.bar(Main.java:2)` (seems to preserve the **minified** line `2` rather than mapping to `3`) - - `baz` and `main` keep minified lines `8`/`7` rather than dropping/normalizing. -- **What needs fixing**: - - The crate’s “base mapping” (`0`, `0:0`) line-number semantics don’t match R8: - - Some cases should map to the **original** line (e.g. `:1` for `foo`) - - Some cases should prefer the **method’s declared original line** even when minified line is present (e.g. `bar(int):3`) - - Some `0:0` entries should use the **stacktrace line** (R8’s special-case behavior). - - The logic likely lives in member selection / line translation in `src/mapper.rs` / cache iteration paths. - -## 2) `test_multiple_lines_no_line_number_stacktrace` - -- **Expected** (no line in stacktrace): - - Choose the `0:0` entries (base mappings) and emit their original lines: - - `method1(Main.java:42)` and `main(Main.java:28)` -- **Actual**: - - Emits both with `:0`. -- **What needs fixing**: - - When the stacktrace has “no line” (`Unknown Source`), and the mapping provides `0:0:…:origLine:origLine` (or explicit original line metadata), we should be able to emit those original lines instead of forcing `0`. - - Today we are collapsing “unknown line” to numeric `0` too early and then losing the mapping’s original line information. - -## 3) `test_single_line_no_line_number_stacktrace` - -- **Expected**: - - Base mappings (`0:0`) for `a` and `b` should expand into multiple original methods (`method1` + `main`, etc.) with specific original lines where available. - - For `c`, upstream emits two alternatives (`main3` and `method3`) and preserves their source context. - - `main4` should preserve its declared original line `153`. -- **Actual**: - - Everything ends up as `:0` (e.g. `method1(Main.java:0)`, `main4(Main.java:0)`). -- **What needs fixing**: - - Same core issue as (3), but more visible: - - Preserve/emit mapping-derived original lines for `0:0` entries. - - Don’t convert “unknown” into `0` in a way that prevents later line reconstruction. - -## 4) `test_no_obfuscated_line_number_with_override` - -- **Expected**: - - `main(Unknown Source)` still maps to `Main.main(Main.java:3)` because the mapping has a single `main(...):3`. - - `overload(Unknown Source)` yields both overloads but without line suffixes in the non-verbose output. - - `mainPC(:3)` should map to `Main.java:42` (mapping line is `42`). -- **Actual**: - - Most frames show `:0`, and `mainPC` shows `:3` (minified line preserved) instead of `:42`. -- **What needs fixing**: - - When obfuscated line numbers are missing (`Unknown Source`) but mapping provides a concrete original line, we should emit it (e.g. `main:3`). - - For `mainPC(:3)`, we’re not translating minified `3` to original `42` even though the mapping is unambiguous. - - This points to incorrect or missing “no obfuscated line number override” behavior in remapping. - -## 5) `test_different_line_number_span_stacktrace` - -- **Expected**: - - The mapping says `method1(...):42:44 -> a` and the stacktrace is `a.a(…:1)`. - - Upstream expands this to **three** possible original lines `42`, `43`, `44` (span). -- **Actual**: - - Only one frame, and it uses the minified line `1` as the output line. -- **What needs fixing**: - - For mappings that define a span of original lines for a single minified line (or ambiguous mapping within a span), we need to expand into the full set of candidate original lines rather than carrying through the minified line. - - This is core “line span expansion” logic (member lookup + line translation). - -## 6) `test_outside_line_range_stacktrace` - -- **Expected**: - - `a.a(:2)` and `a.a(Unknown Source)` both map to `some.other.Class.method1(Class.java:42)` - - `b.a(:27)` maps to `some.Class.a(Class.java:27)` (outside any range → fall back to the “unmapped member name” for that class, per fixture) - - `b.a(Unknown Source)` maps to `some.Class.method2(Class.java)` (no line) -- **Actual**: - - `some.other.Class.method1(Class.java:2)` and `...:0` (line propagation wrong) - - One line remains unparsed and unchanged: `at b.a(:27)` (it is emitted verbatim when not remapped) - - Last frame becomes `method2(Class.java:0)` instead of `method2(Class.java)` -- **What needs fixing**: - - **Parsing / fallback**: the `(:27)` location should be parsed and then remapped (or at least “best-effort” remapped), but currently it falls back to printing the original frame line. - - **Outside-range semantics**: when the minified line is outside any mapped range, decide how to choose: - - either fall back to a “best effort” member name remap, - - or keep obfuscated, but the expected behavior is a best-effort remap. - - **No-line formatting**: `Class.java` vs `Class.java:0` (same as (1)). - -## 7) `test_invalid_minified_range_stacktrace` - -- **Expected**: - - Even though the mapping has an invalid minified range (`5:3`), upstream still retraces the method and produces `Main.java:3`. -- **Actual**: - - The input line is emitted unchanged (not retraced). -- **What needs fixing**: - - The mapping parser / remapper currently rejects or ignores invalid minified ranges entirely. - - Upstream seems to treat this as recoverable and still uses the information to retrace. - - Implement more tolerant handling of invalid minified ranges (or normalize them) so retrace still occurs. - -## 8) `test_invalid_original_range_stacktrace` - -- **Expected**: - - For an invalid original range (`:5:2`), upstream still retraces and emits `Main.java:3`. -- **Actual**: - - Emits `Main.java:6` (wrong translation). -- **What needs fixing**: - - The translation logic from minified line → original line is not handling inverted original ranges correctly. - - Needs clamping / normalization rules consistent with R8 (e.g. treat as single line, or swap, or ignore original span and use minified). - - diff --git a/tests/r8-line-number-handling.rs b/tests/r8-line-number-handling.rs index 190da5a..bc2c0dd 100644 --- a/tests/r8-line-number-handling.rs +++ b/tests/r8-line-number-handling.rs @@ -51,8 +51,8 @@ fn test_no_obfuscation_range_mapping_with_stacktrace() { 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) + at com.android.tools.r8.naming.retrace.Main.baz(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) "#; assert_remap_stacktrace(