diff --git a/src/cache/mod.rs b/src/cache/mod.rs index e700fde..9c9e26a 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1163,26 +1163,56 @@ fn resolve_no_line_frames<'a>( return resolve_base_entries(cache, frame, &base_entries, outer_source_file); } - // No base entries — fall back to all entries with output line 0. + // 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. 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); + let first_start = first.startline(); + let first_end = first.endline(); + let first_group: Vec<_> = 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 { - for member in members { + // 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, member, outer_source_file, Some(0)) + 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); + } + } } } } diff --git a/src/mapper.rs b/src/mapper.rs index 493a225..79f05ea 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -318,23 +318,53 @@ fn resolve_no_line_frames<'s>( return; } - // No base entries — fall back to all entries with output line 0. + // 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. 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 { + + let first_start = first.startline; + let first_end = first.endline; + let first_group: Vec<_> = 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, Some(0))); + .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()); + } + } } } diff --git a/tests/r8.rs b/tests/r8.rs index c534587..8d3ba37 100644 --- a/tests/r8.rs +++ b/tests/r8.rs @@ -12,6 +12,7 @@ static MAPPING_OUTLINE: &[u8] = include_bytes!("res/mapping-outline.txt"); static MAPPING_OUTLINE_COMPLEX: &[u8] = include_bytes!("res/mapping-outline-complex.txt"); static MAPPING_REWRITE_COMPLEX: &str = include_str!("res/mapping-rewrite-complex.txt"); static MAPPING_ZERO_LINE_INFO: &[u8] = include_bytes!("res/mapping-zero-line-info.txt"); +static MAPPING_INLINE_NO_BASE: &str = include_str!("res/mapping-inline-no-base.txt"); static MAPPING_WIN_R8: LazyLock> = LazyLock::new(|| { MAPPING_R8 @@ -577,3 +578,57 @@ fn test_method_with_zero_zero_and_line_specific_mappings_cache() { assert_eq!(remapped_frame.line(), Some(70)); assert_eq!(mapped.next(), None); } + +#[test] +fn test_inline_group_no_base_entries() { + // Regression test: when a frame has no line number and all mapping entries + // have non-zero endline (no base entries), the first range group should be + // detected as an inline chain and each entry should get its proper original + // line number instead of all being collapsed to line 0. + let mapper = ProguardMapper::new(ProguardMapping::new(MAPPING_INLINE_NO_BASE.as_bytes())); + + let test = mapper.remap_stacktrace( + r#" + java.lang.RuntimeException: Crash + at a.b.onClick(SourceFile:0)"#, + ); + + assert_eq!( + test.unwrap().trim(), + r#"java.lang.RuntimeException: Crash + at com.example.app.MainActivity.innerCall(MainActivity.kt:54) + at com.example.app.MainActivity.middleCall(MainActivity.kt:44) + at com.example.app.MainActivity.onClick(MainActivity.kt:30)"# + .trim() + ); +} + +#[test] +fn test_inline_group_no_base_entries_cache() { + // Same regression test as above, but using ProguardCache. + let mapping = ProguardMapping::new(MAPPING_INLINE_NO_BASE.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + cache.test(); + + let frame = StackFrame::new("a.b", "onClick", 0); + let mut mapped = cache.remap_frame(&frame); + + let frame1 = mapped.next().unwrap(); + assert_eq!(frame1.class(), "com.example.app.MainActivity"); + assert_eq!(frame1.method(), "innerCall"); + assert_eq!(frame1.line(), Some(54)); + + let frame2 = mapped.next().unwrap(); + assert_eq!(frame2.class(), "com.example.app.MainActivity"); + assert_eq!(frame2.method(), "middleCall"); + assert_eq!(frame2.line(), Some(44)); + + let frame3 = mapped.next().unwrap(); + assert_eq!(frame3.class(), "com.example.app.MainActivity"); + assert_eq!(frame3.method(), "onClick"); + assert_eq!(frame3.line(), Some(30)); + + assert_eq!(mapped.next(), None); +} diff --git a/tests/res/mapping-inline-no-base.txt b/tests/res/mapping-inline-no-base.txt new file mode 100644 index 0000000..6bb0b8a --- /dev/null +++ b/tests/res/mapping-inline-no-base.txt @@ -0,0 +1,11 @@ +# compiler: R8 +# compiler_version: 8.6.17 +# min_api: 21 +# pg_map_id: abc123 +# common_typos_disable +com.example.app.MainActivity -> a.b: +# {"id":"sourceFile","fileName":"MainActivity.kt"} + 1:1:void innerCall():54:54 -> onClick + 1:1:void middleCall():44 -> onClick + 1:1:void onClick(android.view.View):30 -> onClick + 2:5:void otherMethod():100:100 -> otherMethod