Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 20 additions & 52 deletions src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,10 +1144,12 @@ fn iterate_without_lines<'a>(
/// 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.
/// over line-mapped entries, since they are not keyed to a line either. 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.
///
/// This mirrors [`crate::mapper`]'s `resolve_no_line_frames`; keep the two in sync.
fn resolve_no_line_frames<'a>(
cache: &ProguardCache<'a>,
frame: &StackFrame<'a>,
Expand All @@ -1163,57 +1165,23 @@ fn resolve_no_line_frames<'a>(
return resolve_base_entries(cache, frame, &base_entries, outer_source_file);
}

// No base entries — check if the first range group forms an inline group
// (multiple entries sharing the same startline/endline). If so, resolve
// that group with proper output lines. Otherwise, fall back to emitting
// a single frame with line 0 (ambiguous non-inline case).
//
// This matches retrace's `allRangesForLine(0, true)` which picks the first
// range containing line 0 and returns all entries in that range group.
// Whether this is intentional retrace behavior or accidental is debatable,
// but we match it because users compare our output against retrace-based tools.
// Every entry is keyed by a minified line range the frame cannot match. Entries
// sharing a range are an inlined call stack R8 flattened into one place; entries
// with differing ranges are unrelated bodies merged under one name. Neither can
// be resolved without a line number, so a single frame is reported: the outermost
// entry of the first group, i.e. the method that physically exists on this class.
// See `resolve_no_line_frames` in `crate::mapper` for the reasoning.
let mut frames = Vec::new();
if let Some(first) = members.first() {
let first_start = first.startline();
let first_end = first.endline();
let first_group: Vec<_> = members
let outermost = members
.iter()
.take_while(|m| m.startline() == first_start && m.endline() == first_end)
.collect();

if first_group.len() > 1 {
// Inline group: multiple entries share the same range.
// Resolve each with its proper original line.
for member in &first_group {
let line = 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 {
// Ambiguous: each entry has a different range. Collapse to one
// frame with line 0, matching retrace behavior.
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);
}
}
}
.take_while(|m| m.startline() == first.startline() && m.endline() == first.endline())
.last()
.unwrap_or(first);
if let Some(f) =
map_member_without_lines(cache, frame, outermost, outer_source_file, Some(0))
{
frames.push(f);
}
}
frames
Expand Down
79 changes: 33 additions & 46 deletions src/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,13 @@ 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.
/// over line-mapped entries, since they are not keyed to a line either. 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.
///
/// If there are no base entries, every entry is keyed by a minified range that the
/// frame cannot match, and the entries are resolved by shape instead: see below.
fn resolve_no_line_frames<'s>(
frame: &StackFrame<'s>,
mapping_entries: &'s [MemberMapping<'s>],
Expand All @@ -318,54 +321,38 @@ fn resolve_no_line_frames<'s>(
return;
}

// No base entries — check if the first range group forms an inline group
// (multiple entries sharing the same startline/endline). If so, resolve
// that group with proper output lines. Otherwise, fall back to emitting
// a single frame with line 0 (ambiguous non-inline case).
// Every entry is keyed by a minified line range, and the frame carries no line
// to select one with. R8 reuses a single obfuscated name for many unrelated
// method bodies, and it also flattens inlined call stacks into consecutive
// entries sharing one range. Neither can be resolved without a line number.
//
// This matches retrace's `allRangesForLine(0, true)` which picks the first
// range containing line 0 and returns all entries in that range group.
// Whether this is intentional retrace behavior or accidental is debatable,
// but we match it because users compare our output against retrace-based tools.
// Retrace emits a single frame here: the method that physically exists on this
// class. It reaches that by taking the first range group and reporting only its
// outermost entry -- the caller the inlined bodies were spliced into. Inlinees
// are dropped, because which one ran is exactly what the missing line would have
// told us. We do the same, so that a frame without line information deobfuscates
// identically in both tools.
let Some(first) = mapping_entries.first() else {
return;
};

let first_start = first.startline;
let first_end = first.endline;
let first_group: Vec<_> = mapping_entries
// The outermost entry of a group is its last member; R8 lists inlinees
// innermost-first, with the enclosing method last. A group of one is its own
// outermost entry.
let outermost = mapping_entries
.iter()
.take_while(|m| m.startline == first_start && m.endline == first_end)
.collect();

if first_group.len() > 1 {
// Inline group: multiple entries share the same range.
// Resolve each with its proper original line.
for member in &first_group {
let line = member.original_startline.filter(|&v| v > 0).or(Some(0));
collected
.frames
.push(map_member_without_lines(frame, member, line));
collected.rewrite_rules.extend(member.rewrite_rules.iter());
}
} else {
// Ambiguous: each entry has a different range. Collapse to one
// frame with line 0, matching retrace behavior.
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());
}
}
}
.take_while(|m| m.startline == first.startline && m.endline == first.endline)
.last()
.unwrap_or(first);

// The entry's rewrite rules are deliberately not collected. `removeInnerFrames`
// trims an inline chain, and we just established there is no chain to trim: the
// one frame we report is the outermost one, which the rule would never remove.
// Forwarding the rules would instead delete the sole frame and lose the
// stacktrace entirely. Retrace keeps the frame here too.
collected
.frames
.push(map_member_without_lines(frame, outermost, Some(0)));
}

/// Resolves output lines for base (endline==0) entries when the frame has no line number.
Expand Down
37 changes: 37 additions & 0 deletions tests/r8-ambiguous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

use proguard::{ProguardCache, ProguardMapper, ProguardMapping};

/// Excerpt of a real R8 mapping: the `a1.y` class block, trimmed to a representative
/// spread of the method bodies R8 merged into the obfuscated method `j`.
static MAPPING_AMBIGUOUS: &str = include_str!("res/mapping-ambiguous.txt");

// =============================================================================
// AmbiguousStackTrace
// =============================================================================
Expand Down Expand Up @@ -342,3 +346,36 @@ Exception in thread \"main\" java.lang.NullPointerException
let actual = cache.remap_stacktrace(input).unwrap();
assert_eq!(actual.trim(), expected.trim());
}

// =============================================================================
// DistinctRangesNoLineNumberStackTrace
// =============================================================================

/// Tombstone stacktraces carry no line numbers, so a frame is just `a1.y.j`.
///
/// R8 can merge many unrelated method bodies into the single obfuscated method `j`,
/// keeping them apart only by minified line range. Every entry has its own distinct
/// range, so a frame without a line matches none of them and nothing is left to
/// disambiguate them.
///
/// Instead of emitting every entry, which fabricates a garbage stack, we only
/// report a single frame asserted below.
#[test]
fn test_distinct_ranges_no_line_number_stacktrace() {
let input = " at a1.y.j(Unknown Source)";

let expected = "at androidx.compose.foundation.text.TextFieldDelegate$Companion$updateTextLayoutResult$1$1$1.invoke(TextFieldDelegate.kt:0)";

let mapper = ProguardMapper::from(MAPPING_AMBIGUOUS);
let actual = mapper.remap_stacktrace(input).unwrap();
assert_eq!(actual.trim(), expected.trim());

let mapping = ProguardMapping::new(MAPPING_AMBIGUOUS.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(), expected.trim());
}
Loading
Loading