From 216c5eb8894b50d7057a8dfc80481a33510f4234 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 19 Dec 2025 17:24:05 +0100 Subject: [PATCH 01/24] feat(r8-tests): Add R8 ambiguous tests --- tests/r8-ambiguous.rs | 918 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 918 insertions(+) create mode 100644 tests/r8-ambiguous.rs diff --git a/tests/r8-ambiguous.rs b/tests/r8-ambiguous.rs new file mode 100644 index 0000000..a539c5f --- /dev/null +++ b/tests/r8-ambiguous.rs @@ -0,0 +1,918 @@ +//! Tests for ambiguous method retracing functionality. +//! +//! These tests are based on the R8 retrace test suite from: +//! src/test/java/com/android/tools/r8/retrace/stacktraces/ +//! +//! When multiple original methods map to the same obfuscated name, +//! the retrace should return all possible alternatives. + +use proguard::{ProguardCache, ProguardMapper, ProguardMapping, StackFrame}; + +// ============================================================================= +// AmbiguousStackTrace +// Multiple methods (foo and bar) map to the same obfuscated name 'a' +// ============================================================================= + +const AMBIGUOUS_MAPPING: &str = r#"com.android.tools.r8.R8 -> a.a: + void foo(int) -> a + void bar(int, int) -> a +"#; + +#[test] +fn test_ambiguous_methods_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:0) +"; + + // Both foo and bar should appear as alternatives + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_methods_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:0) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_methods_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); + + let frame = StackFrame::new("a.a", "a", 0); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.R8", "foo", 0, "R8.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("com.android.tools.r8.R8", "bar", 0, "R8.java") + ); +} + +#[test] +fn test_ambiguous_methods_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("a.a", "a", 0); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.R8", "foo", 0, "R8.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("com.android.tools.r8.R8", "bar", 0, "R8.java") + ); +} + +// ============================================================================= +// AmbiguousMissingLineStackTrace +// Ambiguous methods with line numbers that don't match any range +// ============================================================================= + +#[test] +fn test_ambiguous_with_line_number_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:7) +"; + + // Line number 7 doesn't match any specific range, but methods have no line ranges + // so both methods should still be returned (line preserved as 0) + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_with_line_number_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:7) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_with_line_number_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); + + let frame = StackFrame::new("a.a", "a", 7); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); +} + +#[test] +fn test_ambiguous_with_line_number_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("a.a", "a", 7); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); +} + +// ============================================================================= +// AmbiguousWithSignatureStackTrace +// Multiple overloaded methods with different signatures +// From R8: AmbiguousWithSignatureStackTrace.java +// ============================================================================= + +const AMBIGUOUS_SIGNATURE_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: + 10:10:void foo(int):10:10 -> zza + 11:11:void foo(int, int):11:11 -> zza + 12:12:void foo(int, boolean):12:12 -> zza + 13:13:boolean foo(int, int):13:13 -> zza +"#; + +#[test] +fn test_ambiguous_signature_no_line_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); + + // From R8: input has "Unknown" - no line number available + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) +"; + + // R8 retrace shows all 4 overloads as ambiguous alternatives + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_signature_no_line_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) +"; + + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_signature_with_line_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + // Line 10 disambiguates to single method + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:10) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_signature_with_line_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:10) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_signature_with_line_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.Internal", "foo", 10, "Internal.java") + ); +} + +#[test] +fn test_ambiguous_signature_with_line_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 11); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.Internal", "foo", 11, "Internal.java") + ); +} + +// ============================================================================= +// AmbiguousWithMultipleLineMappingsStackTrace +// Same method with multiple line ranges +// From R8: AmbiguousWithMultipleLineMappingsStackTrace.java +// ============================================================================= + +const AMBIGUOUS_MULTIPLE_LINES_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: + 10:10:void foo(int):10:10 -> zza + 11:11:void foo(int):11:11 -> zza + 12:12:void foo(int):12:12 -> zza +"#; + +#[test] +fn test_ambiguous_multiple_lines_no_line_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); + + // From R8: input has "Unknown" - no line number available + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) +"; + + // All 3 map to same method signature, R8 shows one result with no line + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_lines_no_line_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) +"; + + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_lines_with_line_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:10) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_lines_with_line_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + let expected = "\ +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:10) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_lines_with_line_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.Internal", "foo", 10, "Internal.java") + ); +} + +#[test] +fn test_ambiguous_multiple_lines_with_line_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 11); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.Internal", "foo", 11, "Internal.java") + ); +} + +// ============================================================================= +// AmbiguousInlineFramesStackTrace +// Ambiguity in inline frame chain +// ============================================================================= + +const AMBIGUOUS_INLINE_MAPPING: &str = r#"com.android.tools.r8.R8 -> a.a: + 1:1:void foo(int):42:44 -> a + 1:1:void bar(int, int):32 -> a + 1:1:void baz(int, int):10 -> a +"#; + +#[test] +fn test_ambiguous_inline_frames_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_INLINE_MAPPING); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:1) +"; + + // Inline chain: foo (42-44) -> bar (32) -> baz (10) + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:42) + at com.android.tools.r8.R8.bar(R8.java:32) + at com.android.tools.r8.R8.baz(R8.java:10) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_inline_frames_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_INLINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:1) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.foo(R8.java:42) + at com.android.tools.r8.R8.bar(R8.java:32) + at com.android.tools.r8.R8.baz(R8.java:10) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_inline_frames_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_INLINE_MAPPING); + + let frame = StackFrame::new("a.a", "a", 1); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 3); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.R8", "foo", 42, "R8.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("com.android.tools.r8.R8", "bar", 32, "R8.java") + ); + assert_eq!( + frames[2], + StackFrame::with_file("com.android.tools.r8.R8", "baz", 10, "R8.java") + ); +} + +#[test] +fn test_ambiguous_inline_frames_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_INLINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("a.a", "a", 1); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 3); + assert_eq!( + frames[0], + StackFrame::with_file("com.android.tools.r8.R8", "foo", 42, "R8.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("com.android.tools.r8.R8", "bar", 32, "R8.java") + ); + assert_eq!( + frames[2], + StackFrame::with_file("com.android.tools.r8.R8", "baz", 10, "R8.java") + ); +} + +// ============================================================================= +// AmbiguousMultipleInlineStackTrace +// Multiple ambiguous inline frames from different classes +// ============================================================================= + +const AMBIGUOUS_MULTIPLE_INLINE_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: + 10:10:void some.inlinee1(int):10:10 -> zza + 10:10:void foo(int):10 -> zza + 11:12:void foo(int):11:12 -> zza + 10:10:void some.inlinee2(int, int):20:20 -> zza + 10:10:void foo(int, int):42 -> zza +"#; + +#[test] +fn test_ambiguous_multiple_inline_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_INLINE_MAPPING); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + // Line 10 matches two inline chains: + // Chain 1: inlinee1 -> foo(int) + // Chain 2: inlinee2 -> foo(int, int) + // Note: inlinee class is "some", so file synthesizes to "some.java" + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at some.inlinee1(some.java:10) + at com.android.tools.r8.Internal.foo(Internal.java:10) + at some.inlinee2(some.java:20) + at com.android.tools.r8.Internal.foo(Internal.java:42) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_inline_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_INLINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.Internal.zza(SourceFile:10) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at some.inlinee1(some.java:10) + at com.android.tools.r8.Internal.foo(Internal.java:10) + at some.inlinee2(some.java:20) + at com.android.tools.r8.Internal.foo(Internal.java:42) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_multiple_inline_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_INLINE_MAPPING); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + // Two inline chains = 4 frames total + assert_eq!(frames.len(), 4); +} + +#[test] +fn test_ambiguous_multiple_inline_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_INLINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 4); +} + +// ============================================================================= +// AmbiguousMethodVerboseStackTrace +// Different return types and parameters +// ============================================================================= + +const AMBIGUOUS_VERBOSE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> a.a: + com.android.Foo main(java.lang.String[],com.android.Bar) -> a + com.android.Foo main(java.lang.String[]) -> b + void main(com.android.Bar) -> b +"#; + +#[test] +fn test_ambiguous_verbose_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); + + // Method 'a' maps to single method + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:0) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_verbose_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(SourceFile:0) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_verbose_b_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); + + // Method 'b' maps to two methods (ambiguous) + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.b(SourceFile:0) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_verbose_b_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.b(SourceFile:0) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_ambiguous_verbose_frame_mapper() { + let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); + + // Method 'a' maps to single method + let frame_a = StackFrame::new("a.a", "a", 0); + let frames_a: Vec<_> = mapper.remap_frame(&frame_a).collect(); + assert_eq!(frames_a.len(), 1); + assert_eq!( + frames_a[0], + StackFrame::with_file( + "com.android.tools.r8.naming.retrace.Main", + "main", + 0, + "Main.java" + ) + ); + + // Method 'b' maps to two methods + let frame_b = StackFrame::new("a.a", "b", 0); + let frames_b: Vec<_> = mapper.remap_frame(&frame_b).collect(); + assert_eq!(frames_b.len(), 2); +} + +#[test] +fn test_ambiguous_verbose_frame_cache() { + let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame_b = StackFrame::new("a.a", "b", 0); + let frames_b: Vec<_> = cache.remap_frame(&frame_b).collect(); + assert_eq!(frames_b.len(), 2); +} + +// ============================================================================= +// InlineNoLineAssumeNoInlineAmbiguousStackTrace +// From R8: InlineNoLineAssumeNoInlineAmbiguousStackTrace.java +// Without line info, prefer non-inlined mapping over inlined +// ============================================================================= + +const INLINE_NO_LINE_MAPPING: &str = r#"retrace.Main -> a: + void otherMain(java.lang.String[]) -> foo + 2:2:void method1(java.lang.String):0:0 -> foo + 2:2:void main(java.lang.String[]):0 -> foo +"#; + +#[test] +fn test_inline_no_line_prefer_non_inline_mapper() { + let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); + + // From R8: input has "Unknown Source" - no line number available + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.foo(Unknown Source) +"; + + // R8 retrace prefers otherMain because it has no line range (not part of inline chain) + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at retrace.Main.otherMain(Main.java) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_inline_no_line_prefer_non_inline_cache() { + let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.foo(Unknown Source) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at retrace.Main.otherMain(Main.java) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_inline_no_line_prefer_non_inline_frame_mapper() { + let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); + + // Frame with line=0 should prefer non-inlined method + let frame = StackFrame::new("a", "foo", 0); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("retrace.Main", "otherMain", 0, "Main.java") + ); +} + +#[test] +fn test_inline_no_line_prefer_non_inline_frame_cache() { + let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("a", "foo", 0); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0], + StackFrame::with_file("retrace.Main", "otherMain", 0, "Main.java") + ); +} + +#[test] +fn test_inline_no_line_with_line_mapper() { + let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); + + // With line 2, should match the inlined chain + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.foo(SourceFile:2) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at retrace.Main.method1(Main.java:0) + at retrace.Main.main(Main.java:0) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_inline_no_line_with_line_cache() { + let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.foo(SourceFile:2) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at retrace.Main.method1(Main.java:0) + at retrace.Main.main(Main.java:0) +"; + + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); +} + +#[test] +fn test_inline_no_line_with_line_frame_mapper() { + let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); + + let frame = StackFrame::new("a", "foo", 2); + let frames: Vec<_> = mapper.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); + assert_eq!( + frames[0], + StackFrame::with_file("retrace.Main", "method1", 0, "Main.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("retrace.Main", "main", 0, "Main.java") + ); +} + +#[test] +fn test_inline_no_line_with_line_frame_cache() { + let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mut buf = Vec::new(); + ProguardCache::write(&mapping, &mut buf).unwrap(); + let cache = ProguardCache::parse(&buf).unwrap(); + + let frame = StackFrame::new("a", "foo", 2); + let frames: Vec<_> = cache.remap_frame(&frame).collect(); + + assert_eq!(frames.len(), 2); + assert_eq!( + frames[0], + StackFrame::with_file("retrace.Main", "method1", 0, "Main.java") + ); + assert_eq!( + frames[1], + StackFrame::with_file("retrace.Main", "main", 0, "Main.java") + ); +} From 7ae46d0716b8a4d82a4d08e7d6582d87781db032 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 11:53:42 +0100 Subject: [PATCH 02/24] Update tests --- tests/r8-ambiguous.rs | 838 +++++++----------------------------------- 1 file changed, 132 insertions(+), 706 deletions(-) diff --git a/tests/r8-ambiguous.rs b/tests/r8-ambiguous.rs index a539c5f..579f848 100644 --- a/tests/r8-ambiguous.rs +++ b/tests/r8-ambiguous.rs @@ -1,918 +1,344 @@ -//! Tests for ambiguous method retracing functionality. +//! Tests for R8 ambiguous method retracing functionality. //! //! These tests are based on the R8 retrace test suite from: //! src/test/java/com/android/tools/r8/retrace/stacktraces/ -//! -//! When multiple original methods map to the same obfuscated name, -//! the retrace should return all possible alternatives. -use proguard::{ProguardCache, ProguardMapper, ProguardMapping, StackFrame}; +use proguard::{ProguardCache, ProguardMapper, ProguardMapping}; // ============================================================================= // AmbiguousStackTrace -// Multiple methods (foo and bar) map to the same obfuscated name 'a' // ============================================================================= -const AMBIGUOUS_MAPPING: &str = r#"com.android.tools.r8.R8 -> a.a: +const AMBIGUOUS_STACKTRACE_MAPPING: &str = "\ +com.android.tools.r8.R8 -> a.a: void foo(int) -> a void bar(int, int) -> a -"#; - -#[test] -fn test_ambiguous_methods_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:0) -"; - - // Both foo and bar should appear as alternatives - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.R8.foo(R8.java:0) - at com.android.tools.r8.R8.bar(R8.java:0) "; - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - #[test] -fn test_ambiguous_methods_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - +fn test_ambiguous_stacktrace() { let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:0) +com.android.tools.r8.CompilationException: foo[parens](Source:3) + at a.a.a(Unknown Source) + at a.a.a(Unknown Source) + at com.android.tools.r8.R8.main(Unknown Source) +Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) + at a.a.a(Unknown Source) + ... 42 more "; let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException +com.android.tools.r8.CompilationException: foo[parens](Source:3) at com.android.tools.r8.R8.foo(R8.java:0) at com.android.tools.r8.R8.bar(R8.java:0) -"; - - let actual = cache.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_methods_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); - - let frame = StackFrame::new("a.a", "a", 0); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.R8", "foo", 0, "R8.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("com.android.tools.r8.R8", "bar", 0, "R8.java") - ); -} - -#[test] -fn test_ambiguous_methods_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("a.a", "a", 0); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.R8", "foo", 0, "R8.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("com.android.tools.r8.R8", "bar", 0, "R8.java") - ); -} - -// ============================================================================= -// AmbiguousMissingLineStackTrace -// Ambiguous methods with line numbers that don't match any range -// ============================================================================= - -#[test] -fn test_ambiguous_with_line_number_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:7) -"; - - // Line number 7 doesn't match any specific range, but methods have no line ranges - // so both methods should still be returned (line preserved as 0) - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException at com.android.tools.r8.R8.foo(R8.java:0) at com.android.tools.r8.R8.bar(R8.java:0) -"; - - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_with_line_number_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:7) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.R8.main(Unknown Source) +Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) at com.android.tools.r8.R8.foo(R8.java:0) at com.android.tools.r8.R8.bar(R8.java:0) + ... 42 more "; - let actual = cache.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_with_line_number_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MAPPING); - - let frame = StackFrame::new("a.a", "a", 7); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); -} - -#[test] -fn test_ambiguous_with_line_number_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("a.a", "a", 7); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); -} - -// ============================================================================= -// AmbiguousWithSignatureStackTrace -// Multiple overloaded methods with different signatures -// From R8: AmbiguousWithSignatureStackTrace.java -// ============================================================================= - -const AMBIGUOUS_SIGNATURE_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: - 10:10:void foo(int):10:10 -> zza - 11:11:void foo(int, int):11:11 -> zza - 12:12:void foo(int, boolean):12:12 -> zza - 13:13:boolean foo(int, int):13:13 -> zza -"#; - -#[test] -fn test_ambiguous_signature_no_line_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); - - // From R8: input has "Unknown" - no line number available - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(Unknown) -"; - - // R8 retrace shows all 4 overloads as ambiguous alternatives - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:0) -"; - - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_signature_no_line_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(Unknown) -"; - - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:0) -"; - - let actual = cache.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_signature_with_line_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); - - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(SourceFile:10) -"; - - // Line 10 disambiguates to single method - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:10) -"; - + let mapper = ProguardMapper::from(AMBIGUOUS_STACKTRACE_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_signature_with_line_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_STACKTRACE_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(SourceFile:10) -"; - - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:10) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_ambiguous_signature_with_line_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_SIGNATURE_MAPPING); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.Internal", "foo", 10, "Internal.java") - ); -} - -#[test] -fn test_ambiguous_signature_with_line_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_SIGNATURE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 11); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.Internal", "foo", 11, "Internal.java") - ); -} - // ============================================================================= -// AmbiguousWithMultipleLineMappingsStackTrace -// Same method with multiple line ranges -// From R8: AmbiguousWithMultipleLineMappingsStackTrace.java +// AmbiguousMissingLineStackTrace // ============================================================================= -const AMBIGUOUS_MULTIPLE_LINES_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: - 10:10:void foo(int):10:10 -> zza - 11:11:void foo(int):11:11 -> zza - 12:12:void foo(int):12:12 -> zza -"#; - -#[test] -fn test_ambiguous_multiple_lines_no_line_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); - - // From R8: input has "Unknown" - no line number available - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(Unknown) -"; - - // All 3 map to same method signature, R8 shows one result with no line - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java) -"; - - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - -#[test] -fn test_ambiguous_multiple_lines_no_line_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(Unknown) -"; - - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java) +const AMBIGUOUS_MISSING_LINE_MAPPING: &str = "\ +com.android.tools.r8.R8 -> a.a: + void foo(int) -> a + void bar(int, int) -> a "; - let actual = cache.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - #[test] -fn test_ambiguous_multiple_lines_with_line_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); - +fn test_ambiguous_missing_line_stacktrace() { let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(SourceFile:10) +com.android.tools.r8.CompilationException: foo[parens](Source:3) + at a.a.a(Unknown Source:7) + at a.a.a(Unknown Source:8) + at com.android.tools.r8.R8.main(Unknown Source) +Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) + at a.a.a(Unknown Source:9) + ... 42 more "; let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:10) +com.android.tools.r8.CompilationException: foo[parens](Source:3) + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) + at com.android.tools.r8.R8.main(Unknown Source) +Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) + at com.android.tools.r8.R8.foo(R8.java:0) + at com.android.tools.r8.R8.bar(R8.java:0) + ... 42 more "; + let mapper = ProguardMapper::from(AMBIGUOUS_MISSING_LINE_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_multiple_lines_with_line_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_MISSING_LINE_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.zza(SourceFile:10) -"; - - let expected = "\ -java.lang.IndexOutOfBoundsException - at java.util.ArrayList.get(ArrayList.java:411) - at com.android.tools.r8.Internal.foo(Internal.java:10) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_ambiguous_multiple_lines_with_line_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_LINES_MAPPING); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.Internal", "foo", 10, "Internal.java") - ); -} - -#[test] -fn test_ambiguous_multiple_lines_with_line_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_LINES_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 11); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.Internal", "foo", 11, "Internal.java") - ); -} - // ============================================================================= // AmbiguousInlineFramesStackTrace -// Ambiguity in inline frame chain // ============================================================================= -const AMBIGUOUS_INLINE_MAPPING: &str = r#"com.android.tools.r8.R8 -> a.a: +const AMBIGUOUS_INLINE_FRAMES_MAPPING: &str = "\ +com.android.tools.r8.R8 -> a.a: 1:1:void foo(int):42:44 -> a 1:1:void bar(int, int):32 -> a 1:1:void baz(int, int):10 -> a -"#; +"; #[test] -fn test_ambiguous_inline_frames_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_INLINE_MAPPING); - +fn test_ambiguous_inline_frames_stacktrace() { let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:1) +com.android.tools.r8.CompilationException: + at a.a.a(Unknown Source:1) "; - // Inline chain: foo (42-44) -> bar (32) -> baz (10) let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException +com.android.tools.r8.CompilationException: at com.android.tools.r8.R8.foo(R8.java:42) at com.android.tools.r8.R8.bar(R8.java:32) at com.android.tools.r8.R8.baz(R8.java:10) "; + let mapper = ProguardMapper::from(AMBIGUOUS_INLINE_FRAMES_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_inline_frames_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_INLINE_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_INLINE_FRAMES_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:1) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.R8.foo(R8.java:42) - at com.android.tools.r8.R8.bar(R8.java:32) - at com.android.tools.r8.R8.baz(R8.java:10) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_ambiguous_inline_frames_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_INLINE_MAPPING); - - let frame = StackFrame::new("a.a", "a", 1); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 3); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.R8", "foo", 42, "R8.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("com.android.tools.r8.R8", "bar", 32, "R8.java") - ); - assert_eq!( - frames[2], - StackFrame::with_file("com.android.tools.r8.R8", "baz", 10, "R8.java") - ); -} - -#[test] -fn test_ambiguous_inline_frames_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_INLINE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("a.a", "a", 1); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 3); - assert_eq!( - frames[0], - StackFrame::with_file("com.android.tools.r8.R8", "foo", 42, "R8.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("com.android.tools.r8.R8", "bar", 32, "R8.java") - ); - assert_eq!( - frames[2], - StackFrame::with_file("com.android.tools.r8.R8", "baz", 10, "R8.java") - ); -} - // ============================================================================= // AmbiguousMultipleInlineStackTrace -// Multiple ambiguous inline frames from different classes // ============================================================================= -const AMBIGUOUS_MULTIPLE_INLINE_MAPPING: &str = r#"com.android.tools.r8.Internal -> com.android.tools.r8.Internal: +const AMBIGUOUS_MULTIPLE_INLINE_MAPPING: &str = "\ +com.android.tools.r8.Internal -> com.android.tools.r8.Internal: 10:10:void some.inlinee1(int):10:10 -> zza 10:10:void foo(int):10 -> zza 11:12:void foo(int):11:12 -> zza 10:10:void some.inlinee2(int, int):20:20 -> zza 10:10:void foo(int, int):42 -> zza -"#; +"; #[test] -fn test_ambiguous_multiple_inline_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_INLINE_MAPPING); - +fn test_ambiguous_multiple_inline_stacktrace() { let input = "\ -Exception in thread \"main\" java.lang.NullPointerException +java.lang.IndexOutOfBoundsException at com.android.tools.r8.Internal.zza(SourceFile:10) "; - // Line 10 matches two inline chains: - // Chain 1: inlinee1 -> foo(int) - // Chain 2: inlinee2 -> foo(int, int) - // Note: inlinee class is "some", so file synthesizes to "some.java" let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException +java.lang.IndexOutOfBoundsException at some.inlinee1(some.java:10) at com.android.tools.r8.Internal.foo(Internal.java:10) at some.inlinee2(some.java:20) at com.android.tools.r8.Internal.foo(Internal.java:42) "; + let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_INLINE_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_multiple_inline_cache() { let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_INLINE_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.Internal.zza(SourceFile:10) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at some.inlinee1(some.java:10) - at com.android.tools.r8.Internal.foo(Internal.java:10) - at some.inlinee2(some.java:20) - at com.android.tools.r8.Internal.foo(Internal.java:42) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_ambiguous_multiple_inline_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_MULTIPLE_INLINE_MAPPING); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - // Two inline chains = 4 frames total - assert_eq!(frames.len(), 4); -} - -#[test] -fn test_ambiguous_multiple_inline_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_MULTIPLE_INLINE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("com.android.tools.r8.Internal", "zza", 10); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 4); -} - // ============================================================================= -// AmbiguousMethodVerboseStackTrace -// Different return types and parameters +// AmbiguousMethodVerboseStackTrace (non-verbose retrace output) // ============================================================================= -const AMBIGUOUS_VERBOSE_MAPPING: &str = r#"com.android.tools.r8.naming.retrace.Main -> a.a: +const AMBIGUOUS_METHOD_VERBOSE_MAPPING: &str = "\ +com.android.tools.r8.naming.retrace.Main -> a.a: com.android.Foo main(java.lang.String[],com.android.Bar) -> a com.android.Foo main(java.lang.String[]) -> b void main(com.android.Bar) -> b -"#; +"; #[test] -fn test_ambiguous_verbose_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); - - // Method 'a' maps to single method +fn test_ambiguous_method_verbose_stacktrace() { let input = "\ Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:0) + at a.a.c(Foo.java) + at a.a.b(Bar.java) + at a.a.a(Baz.java) "; let expected = "\ Exception in thread \"main\" java.lang.NullPointerException + at com.android.tools.r8.naming.retrace.Main.c(Main.java:0) + at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) "; + let mapper = ProguardMapper::from(AMBIGUOUS_METHOD_VERBOSE_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_verbose_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_METHOD_VERBOSE_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(SourceFile:0) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_ambiguous_verbose_b_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); - - // Method 'b' maps to two methods (ambiguous) - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.b(SourceFile:0) -"; +// ============================================================================= +// AmbiguousWithMultipleLineMappingsStackTrace +// ============================================================================= - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) - at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +const AMBIGUOUS_WITH_MULTIPLE_LINE_MAPPINGS_MAPPING: &str = "\ +com.android.tools.r8.Internal -> com.android.tools.r8.Internal: + 10:10:void foo(int):10:10 -> zza + 11:11:void foo(int):11:11 -> zza + 12:12:void foo(int):12:12 -> zza "; - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); -} - #[test] -fn test_ambiguous_verbose_b_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - +fn test_ambiguous_with_multiple_line_mappings_stacktrace() { let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.b(SourceFile:0) +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) "; let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) - at com.android.tools.r8.naming.retrace.Main.main(Main.java:0) +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:0) "; - let actual = cache.remap_stacktrace(input).unwrap(); + let mapper = ProguardMapper::from(AMBIGUOUS_WITH_MULTIPLE_LINE_MAPPINGS_MAPPING); + let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_ambiguous_verbose_frame_mapper() { - let mapper = ProguardMapper::from(AMBIGUOUS_VERBOSE_MAPPING); - - // Method 'a' maps to single method - let frame_a = StackFrame::new("a.a", "a", 0); - let frames_a: Vec<_> = mapper.remap_frame(&frame_a).collect(); - assert_eq!(frames_a.len(), 1); - assert_eq!( - frames_a[0], - StackFrame::with_file( - "com.android.tools.r8.naming.retrace.Main", - "main", - 0, - "Main.java" - ) - ); - - // Method 'b' maps to two methods - let frame_b = StackFrame::new("a.a", "b", 0); - let frames_b: Vec<_> = mapper.remap_frame(&frame_b).collect(); - assert_eq!(frames_b.len(), 2); -} - -#[test] -fn test_ambiguous_verbose_frame_cache() { - let mapping = ProguardMapping::new(AMBIGUOUS_VERBOSE_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_WITH_MULTIPLE_LINE_MAPPINGS_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); + cache.test(); - let frame_b = StackFrame::new("a.a", "b", 0); - let frames_b: Vec<_> = cache.remap_frame(&frame_b).collect(); - assert_eq!(frames_b.len(), 2); + let actual = cache.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); } // ============================================================================= -// InlineNoLineAssumeNoInlineAmbiguousStackTrace -// From R8: InlineNoLineAssumeNoInlineAmbiguousStackTrace.java -// Without line info, prefer non-inlined mapping over inlined +// AmbiguousWithSignatureStackTrace (non-verbose retrace output) // ============================================================================= -const INLINE_NO_LINE_MAPPING: &str = r#"retrace.Main -> a: - void otherMain(java.lang.String[]) -> foo - 2:2:void method1(java.lang.String):0:0 -> foo - 2:2:void main(java.lang.String[]):0 -> foo -"#; +const AMBIGUOUS_WITH_SIGNATURE_MAPPING: &str = "\ +com.android.tools.r8.Internal -> com.android.tools.r8.Internal: + 10:10:void foo(int):10:10 -> zza + 11:11:void foo(int, int):11:11 -> zza + 12:12:void foo(int, boolean):12:12 -> zza + 13:13:boolean foo(int, int):13:13 -> zza +"; #[test] -fn test_inline_no_line_prefer_non_inline_mapper() { - let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); - - // From R8: input has "Unknown Source" - no line number available +fn test_ambiguous_with_signature_stacktrace() { let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.foo(Unknown Source) +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.zza(Unknown) "; - // R8 retrace prefers otherMain because it has no line range (not part of inline chain) let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at retrace.Main.otherMain(Main.java) +java.lang.IndexOutOfBoundsException + at java.util.ArrayList.get(ArrayList.java:411) + at com.android.tools.r8.Internal.foo(Internal.java:0) "; + let mapper = ProguardMapper::from(AMBIGUOUS_WITH_SIGNATURE_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_inline_no_line_prefer_non_inline_cache() { - let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mapping = ProguardMapping::new(AMBIGUOUS_WITH_SIGNATURE_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.foo(Unknown Source) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at retrace.Main.otherMain(Main.java) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } -#[test] -fn test_inline_no_line_prefer_non_inline_frame_mapper() { - let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); - - // Frame with line=0 should prefer non-inlined method - let frame = StackFrame::new("a", "foo", 0); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("retrace.Main", "otherMain", 0, "Main.java") - ); -} - -#[test] -fn test_inline_no_line_prefer_non_inline_frame_cache() { - let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("a", "foo", 0); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); +// ============================================================================= +// InlineNoLineAssumeNoInlineAmbiguousStackTrace +// ============================================================================= - assert_eq!(frames.len(), 1); - assert_eq!( - frames[0], - StackFrame::with_file("retrace.Main", "otherMain", 0, "Main.java") - ); -} +const INLINE_NO_LINE_ASSUME_NO_INLINE_AMBIGUOUS_MAPPING: &str = "\ +retrace.Main -> a: + void otherMain(java.lang.String[]) -> foo + 2:2:void method1(java.lang.String):0:0 -> foo + 2:2:void main(java.lang.String[]):0 -> foo +"; #[test] -fn test_inline_no_line_with_line_mapper() { - let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); - - // With line 2, should match the inlined chain +fn test_inline_no_line_assume_no_inline_ambiguous_stacktrace() { let input = "\ Exception in thread \"main\" java.lang.NullPointerException - at a.foo(SourceFile:2) + at a.foo(Unknown Source) "; + // When no line info is available, prefer base (no-line) mappings if present. let expected = "\ Exception in thread \"main\" java.lang.NullPointerException - at retrace.Main.method1(Main.java:0) - at retrace.Main.main(Main.java:0) + at retrace.Main.otherMain(Main.java:0) "; + let mapper = ProguardMapper::from(INLINE_NO_LINE_ASSUME_NO_INLINE_AMBIGUOUS_MAPPING); let actual = mapper.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); -} -#[test] -fn test_inline_no_line_with_line_cache() { - let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); + let mapping = + ProguardMapping::new(INLINE_NO_LINE_ASSUME_NO_INLINE_AMBIGUOUS_MAPPING.as_bytes()); let mut buf = Vec::new(); ProguardCache::write(&mapping, &mut buf).unwrap(); let cache = ProguardCache::parse(&buf).unwrap(); - - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.foo(SourceFile:2) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at retrace.Main.method1(Main.java:0) - at retrace.Main.main(Main.java:0) -"; + cache.test(); let actual = cache.remap_stacktrace(input).unwrap(); assert_eq!(actual.trim(), expected.trim()); } - -#[test] -fn test_inline_no_line_with_line_frame_mapper() { - let mapper = ProguardMapper::from(INLINE_NO_LINE_MAPPING); - - let frame = StackFrame::new("a", "foo", 2); - let frames: Vec<_> = mapper.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); - assert_eq!( - frames[0], - StackFrame::with_file("retrace.Main", "method1", 0, "Main.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("retrace.Main", "main", 0, "Main.java") - ); -} - -#[test] -fn test_inline_no_line_with_line_frame_cache() { - let mapping = ProguardMapping::new(INLINE_NO_LINE_MAPPING.as_bytes()); - let mut buf = Vec::new(); - ProguardCache::write(&mapping, &mut buf).unwrap(); - let cache = ProguardCache::parse(&buf).unwrap(); - - let frame = StackFrame::new("a", "foo", 2); - let frames: Vec<_> = cache.remap_frame(&frame).collect(); - - assert_eq!(frames.len(), 2); - assert_eq!( - frames[0], - StackFrame::with_file("retrace.Main", "method1", 0, "Main.java") - ); - assert_eq!( - frames[1], - StackFrame::with_file("retrace.Main", "main", 0, "Main.java") - ); -} From 1deef0ad25de1b871feb3c6be81bc930801d99d2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 13:17:51 +0100 Subject: [PATCH 03/24] Change expected of test_ambiguous_missing_line_stacktrace --- tests/r8-ambiguous.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/r8-ambiguous.rs b/tests/r8-ambiguous.rs index 579f848..e3b9bf7 100644 --- a/tests/r8-ambiguous.rs +++ b/tests/r8-ambiguous.rs @@ -78,14 +78,14 @@ Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) let expected = "\ com.android.tools.r8.CompilationException: foo[parens](Source:3) - at com.android.tools.r8.R8.foo(R8.java:0) - at com.android.tools.r8.R8.bar(R8.java:0) - at com.android.tools.r8.R8.foo(R8.java:0) - at com.android.tools.r8.R8.bar(R8.java:0) + at com.android.tools.r8.R8.foo(R8.java:7) + at com.android.tools.r8.R8.bar(R8.java:7) + at com.android.tools.r8.R8.foo(R8.java:8) + at com.android.tools.r8.R8.bar(R8.java:8) at com.android.tools.r8.R8.main(Unknown Source) Caused by: com.android.tools.r8.CompilationException: foo[parens](Source:3) - at com.android.tools.r8.R8.foo(R8.java:0) - at com.android.tools.r8.R8.bar(R8.java:0) + at com.android.tools.r8.R8.foo(R8.java:9) + at com.android.tools.r8.R8.bar(R8.java:9) ... 42 more "; From 558ed430c0e87a6af7f3749f6829b1950270bf1d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 12:05:45 +0100 Subject: [PATCH 04/24] fix(r8-tests): Correctly handle no-line mappings --- src/cache/mod.rs | 208 +++++++++++++++++++++++++++++++++++++++++++--- src/cache/raw.rs | 5 -- src/mapper.rs | 88 ++++++++++++++++---- src/stacktrace.rs | 7 +- 4 files changed, 276 insertions(+), 32 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 1826829..f606e43 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -85,12 +85,13 @@ use crate::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Thr pub use raw::{ProguardCache, PRGCACHE_VERSION}; /// Result of looking up member mappings for a frame. -/// Contains: (members, prepared_frame, rewrite_rules, had_mappings, outer_source_file) +/// Contains: (members, prepared_frame, rewrite_rules, had_mappings, has_line_info, outer_source_file) type MemberLookupResult<'data> = ( &'data [raw::Member], StackFrame<'data>, Vec>, bool, + bool, Option<&'data str>, ); @@ -384,11 +385,14 @@ impl<'data> ProguardCache<'data> { } } + let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + Some(( mapping_entries, prepared_frame, rewrite_rules, had_mappings, + has_line_info, outer_source_file, )) } @@ -402,8 +406,14 @@ impl<'data> ProguardCache<'data> { &'r self, frame: &StackFrame<'data>, ) -> RemappedFrameIter<'r, 'data> { - let Some((members, prepared_frame, _rewrite_rules, _had_mappings, outer_source_file)) = - self.find_members_and_rules(frame) + let Some(( + members, + prepared_frame, + _rewrite_rules, + had_mappings, + has_line_info, + outer_source_file, + )) = self.find_members_and_rules(frame) else { return RemappedFrameIter::empty(); }; @@ -413,7 +423,8 @@ impl<'data> ProguardCache<'data> { prepared_frame, members.iter(), 0, - false, + had_mappings, + has_line_info, outer_source_file, ) } @@ -452,9 +463,32 @@ impl<'data> ProguardCache<'data> { let effective = self.prepare_frame_for_mapping(frame, carried_outline_pos); - let Some((members, prepared_frame, rewrite_rules, had_mappings, outer_source_file)) = - self.find_members_and_rules(&effective) + let Some(( + members, + prepared_frame, + rewrite_rules, + had_mappings, + has_line_info, + outer_source_file, + )) = self.find_members_and_rules(&effective) else { + // Even if we cannot resolve a member mapping, we may still be able to remap the class. + if let Some(class) = self.get_class(effective.class) { + let original_class = self + .read_string(class.original_name_offset) + .unwrap_or(effective.class); + let outer_source_file = self.read_string(class.file_name_offset).ok(); + let file = + synthesize_source_file(original_class, outer_source_file).map(Cow::Owned); + return Some(RemappedFrameIter::single(StackFrame { + class: original_class, + method: effective.method, + file, + line: effective.line, + parameters: effective.parameters, + method_synthesized: false, + })); + } return Some(RemappedFrameIter::empty()); }; @@ -471,6 +505,7 @@ impl<'data> ProguardCache<'data> { members.iter(), skip_count, had_mappings, + has_line_info, outer_source_file, )) } @@ -779,10 +814,14 @@ pub struct RemappedFrameIter<'r, 'data> { StackFrame<'data>, std::slice::Iter<'data, raw::Member>, )>, + /// A single remapped frame fallback (e.g. class-only remapping). + fallback: Option>, /// Number of frames to skip from rewrite rules. skip_count: usize, /// Whether there were mapping entries (for should_skip determination). had_mappings: bool, + /// Whether this method has any line-based mappings. + has_line_info: bool, /// The source file of the outer class for synthesis. outer_source_file: Option<&'data str>, } @@ -791,8 +830,10 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { fn empty() -> Self { Self { inner: None, + fallback: None, skip_count: 0, had_mappings: false, + has_line_info: false, outer_source_file: None, } } @@ -803,16 +844,30 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { members: std::slice::Iter<'data, raw::Member>, skip_count: usize, had_mappings: bool, + has_line_info: bool, outer_source_file: Option<&'data str>, ) -> Self { Self { inner: Some((cache, frame, members)), + fallback: None, skip_count, had_mappings, + has_line_info, outer_source_file, } } + fn single(frame: StackFrame<'data>) -> Self { + Self { + inner: None, + fallback: Some(frame), + skip_count: 0, + had_mappings: false, + has_line_info: false, + outer_source_file: None, + } + } + /// Returns whether there were mapping entries before rewrite rules were applied. /// /// After collecting frames, if `had_mappings()` is true but the result is empty, @@ -822,12 +877,106 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { } fn next_inner(&mut self) -> Option> { - let (cache, frame, members) = self.inner.as_mut()?; - if frame.parameters.is_none() { - iterate_with_lines(cache, frame, members, self.outer_source_file) - } else { - iterate_without_lines(cache, frame, members, self.outer_source_file) + if let Some(frame) = self.fallback.take() { + return Some(frame); } + + let (cache, mut frame, mut members) = self.inner.take()?; + + let out = if frame.parameters.is_none() { + // If we have no line number, treat it as unknown. If there are base (no-line) mappings + // present, prefer those over line-mapped entries. + if frame.line == 0 { + let remaining = members.as_slice(); + // Prefer base entries (endline == 0) if present. + let mut base_members = remaining.iter().filter(|m| m.endline == 0); + if let Some(first_base) = base_members.next() { + // If all base entries resolve to the same original method name, deduplicate. + let all_same = base_members.all(|m| { + m.original_class_offset == first_base.original_class_offset + && m.original_name_offset == first_base.original_name_offset + }); + + if all_same { + let class = cache + .read_string(first_base.original_class_offset) + .unwrap_or(frame.class); + let method = cache.read_string(first_base.original_name_offset).ok()?; + let file = + synthesize_source_file(class, self.outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + line: 0, + parameters: frame.parameters, + method_synthesized: first_base.is_synthesized(), + }); + } + + // Multiple distinct base entries: iterate them (skip line-mapped entries). + let mapped = iterate_without_lines_preferring_base( + cache, + &mut frame, + &mut members, + self.outer_source_file, + ); + self.inner = Some((cache, frame, members)); + return mapped; + } + + // No base entries: fall back to existing behavior (may yield multiple candidates). + let first = remaining.first()?; + let unambiguous = remaining.iter().all(|m| { + m.original_class_offset == first.original_class_offset + && m.original_name_offset == first.original_name_offset + }); + + if unambiguous { + let class = cache + .read_string(first.original_class_offset) + .unwrap_or(frame.class); + let method = cache.read_string(first.original_name_offset).ok()?; + let file = + synthesize_source_file(class, self.outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + line: 0, + parameters: frame.parameters, + method_synthesized: first.is_synthesized(), + }); + } + + let mapped = + iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file); + self.inner = Some((cache, frame, members)); + return mapped; + } + + // With a concrete line number, skip base entries if there are line mappings. + let mapped = iterate_with_lines( + cache, + &mut frame, + &mut members, + self.outer_source_file, + self.has_line_info, + ); + self.inner = Some((cache, frame, members)); + mapped + } else { + let mapped = + iterate_without_lines(cache, &mut frame, &mut members, self.outer_source_file); + self.inner = Some((cache, frame, members)); + mapped + }; + + // If we returned early for the unambiguous line==0 case above, `self.inner` remains `None` + // which ensures the iterator terminates. + out } } @@ -849,8 +998,13 @@ fn iterate_with_lines<'a>( frame: &mut StackFrame<'a>, members: &mut std::slice::Iter<'_, raw::Member>, outer_source_file: Option<&str>, + has_line_info: bool, ) -> Option> { for member in members { + // If this method has line mappings, skip base (no-line) entries when we have a concrete line. + if has_line_info && frame.line > 0 && member.endline == 0 { + continue; + } // skip any members which do not match our frames line if member.endline > 0 && (frame.line < member.startline as usize || frame.line > member.endline as usize) @@ -902,6 +1056,38 @@ fn iterate_with_lines<'a>( None } +fn iterate_without_lines_preferring_base<'a>( + cache: &ProguardCache<'a>, + frame: &mut StackFrame<'a>, + members: &mut std::slice::Iter<'_, raw::Member>, + outer_source_file: Option<&str>, +) -> Option> { + for member in members { + if member.endline != 0 { + continue; + } + + let class = cache + .read_string(member.original_class_offset) + .unwrap_or(frame.class); + + let method = cache.read_string(member.original_name_offset).ok()?; + + // Synthesize from class name (input filename is not reliable) + let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + line: 0, + parameters: frame.parameters, + method_synthesized: member.is_synthesized(), + }); + } + None +} + fn iterate_without_lines<'a>( cache: &ProguardCache<'a>, frame: &mut StackFrame<'a>, diff --git a/src/cache/raw.rs b/src/cache/raw.rs index bd7f850..66a18a1 100644 --- a/src/cache/raw.rs +++ b/src/cache/raw.rs @@ -343,12 +343,7 @@ impl<'data> ProguardCache<'data> { .entry(obfuscated_method.as_str()) .or_default(); - let has_line_info = members.all.iter().any(|m| m.endline > 0); for member in members.all.iter() { - // Skip members without line information if there are members with line information - if has_line_info && member.startline == 0 && member.endline == 0 { - continue; - } method_mappings.push(Self::resolve_mapping( &mut string_table, &parsed, diff --git a/src/mapper.rs b/src/mapper.rs index ef3cb74..f1fa3df 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -102,15 +102,20 @@ type MemberIter<'m> = std::slice::Iter<'m, MemberMapping<'m>>; #[derive(Clone, Debug, Default)] pub struct RemappedFrameIter<'m> { inner: Option<(StackFrame<'m>, MemberIter<'m>)>, + has_line_info: bool, } impl<'m> RemappedFrameIter<'m> { fn empty() -> Self { - Self { inner: None } + Self { + inner: None, + has_line_info: false, + } } - fn members(frame: StackFrame<'m>, members: MemberIter<'m>) -> Self { + fn members(frame: StackFrame<'m>, members: MemberIter<'m>, has_line_info: bool) -> Self { Self { inner: Some((frame, members)), + has_line_info, } } } @@ -120,7 +125,7 @@ impl<'m> Iterator for RemappedFrameIter<'m> { fn next(&mut self) -> Option { let (frame, ref mut members) = self.inner.as_mut()?; if frame.parameters.is_none() { - iterate_with_lines(frame, members) + iterate_with_lines(frame, members, self.has_line_info) } else { iterate_without_lines(frame, members) } @@ -260,8 +265,13 @@ fn apply_rewrite_rules<'s>(collected: &mut CollectedFrames<'s>, thrown_descripto fn iterate_with_lines<'a>( frame: &mut StackFrame<'a>, members: &mut core::slice::Iter<'_, MemberMapping<'a>>, + has_line_info: bool, ) -> Option> { for member in members { + // If this method has line mappings, skip base (no-line) entries when we have a concrete line. + if has_line_info && frame.line > 0 && member.endline == 0 { + continue; + } if let Some(mapped) = map_member_with_lines(frame, member) { return Some(mapped); } @@ -361,12 +371,7 @@ impl<'s> ProguardMapper<'s> { .entry(obfuscated_method.as_str()) .or_default(); - let has_line_info = members.all.iter().any(|m| m.endline > 0); for member in members.all.iter() { - // Skip members without line information if there are members with line information - if has_line_info && member.startline == 0 && member.endline == 0 { - continue; - } method_mappings.all_mappings.push(Self::resolve_mapping( &parsed, member, @@ -519,13 +524,26 @@ impl<'s> ProguardMapper<'s> { let Some(class) = self.classes.get(frame.class) else { return collected; }; - let Some(members) = class.members.get(frame.method) else { - return collected; - }; let mut frame = frame.clone(); frame.class = class.original; + // If we don't have any member mappings, we can still remap the class name. + // This is especially important for stack frames where the method is not mapped or the + // stacktrace does not contain sufficient information to resolve the method. + let Some(members) = class.members.get(frame.method) else { + let file = synthesize_source_file(frame.class, frame.file()).map(Cow::Owned); + collected.frames.push(StackFrame { + class: frame.class, + method: frame.method, + file, + line: frame.line, + parameters: frame.parameters, + method_synthesized: false, + }); + return collected; + }; + let mapping_entries: &[MemberMapping<'s>] = if let Some(parameters) = frame.parameters { let Some(typed_members) = members.mappings_by_params.get(parameters) else { return collected; @@ -536,7 +554,48 @@ impl<'s> ProguardMapper<'s> { }; if frame.parameters.is_none() { + let has_line_info = mapping_entries.iter().any(|m| m.endline > 0); + + // If the stacktrace has no line number, treat it as unknown and remap without + // applying line filters. If there are base (no-line) mappings present, prefer those. + if frame.line == 0 { + let preferred: Vec<_> = if has_line_info { + let base: Vec<_> = mapping_entries.iter().filter(|m| m.endline == 0).collect(); + if base.is_empty() { + mapping_entries.iter().collect() + } else { + base + } + } else { + mapping_entries.iter().collect() + }; + + let mut members_iter = preferred.iter().copied(); + let Some(first) = members_iter.next() else { + return collected; + }; + + let unambiguous = members_iter.all(|m| m.original == first.original); + if unambiguous { + collected + .frames + .push(map_member_without_lines(&frame, first)); + collected.rewrite_rules.extend(first.rewrite_rules.iter()); + } else { + for member in preferred { + collected + .frames + .push(map_member_without_lines(&frame, member)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } + return collected; + } + for member in mapping_entries { + if has_line_info && member.endline == 0 { + continue; + } if let Some(mapped) = map_member_with_lines(&frame, member) { collected.frames.push(mapped); collected.rewrite_rules.extend(member.rewrite_rules.iter()); @@ -606,7 +665,8 @@ impl<'s> ProguardMapper<'s> { members.all_mappings.iter() }; - RemappedFrameIter::members(frame, mappings) + let has_line_info = members.all_mappings.iter().any(|m| m.endline > 0); + RemappedFrameIter::members(frame, mappings, has_line_info) } /// Remaps a throwable which is the first line of a full stacktrace. @@ -1016,7 +1076,7 @@ java.lang.IllegalStateException: Boom } #[test] - fn remap_frame_without_mapping_keeps_original_line() { + fn remap_frame_without_mapping_remaps_class_best_effort() { let mapping = "\ some.Class -> a: 1:1:void some.Class.existing():10:10 -> a @@ -1029,7 +1089,7 @@ java.lang.RuntimeException: boom "; let expected = "\ java.lang.RuntimeException: boom - at a.missing(SourceFile:42) + at some.Class.missing(Class.java:42) "; assert_eq!(mapper.remap_stacktrace(input).unwrap(), expected); diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 09d0544..0eb427d 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -286,8 +286,10 @@ pub(crate) fn parse_frame(line: &str) -> Option> { let (method_split, file_split) = line[3..line.len() - 1].split_once('(')?; let (class, method) = method_split.rsplit_once('.')?; - let (file, line) = file_split.split_once(':')?; - let line = line.parse().ok()?; + let (file, line) = match file_split.split_once(':') { + Some((file, line)) => (file, line.parse().ok()?), + None => (file_split, 0), + }; Some(StackFrame { class, @@ -389,6 +391,7 @@ pub(crate) fn parse_throwable(line: &str) -> Option> { #[cfg(test)] mod tests { use super::*; + use std::borrow::Cow; #[test] fn print_stack_trace() { From 59a8dd7fbfc35807471934095d6827171573be0b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 12:08:01 +0100 Subject: [PATCH 05/24] Remove import --- src/stacktrace.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 0eb427d..875f4ca 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -391,7 +391,6 @@ pub(crate) fn parse_throwable(line: &str) -> Option> { #[cfg(test)] mod tests { use super::*; - use std::borrow::Cow; #[test] fn print_stack_trace() { From cf3f7328d22d6504d6397ed945d495bb2ec18d1f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 14:12:47 +0100 Subject: [PATCH 06/24] Preserve input lineno when no mapping is available --- src/cache/mod.rs | 32 ++++++++++++++++++++++++++++---- src/mapper.rs | 15 +++++++++++++-- tests/retrace.rs | 3 ++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index f606e43..1884103 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -909,7 +909,8 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { class, method, file, - line: 0, + // Preserve input line if present when the mapping has no line info. + line: frame.line, parameters: frame.parameters, method_synthesized: first_base.is_synthesized(), }); @@ -945,7 +946,8 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { class, method, file, - line: 0, + // Preserve input line if present when the mapping has no line info. + line: frame.line, parameters: frame.parameters, method_synthesized: first.is_synthesized(), }); @@ -1005,6 +1007,26 @@ fn iterate_with_lines<'a>( if has_line_info && frame.line > 0 && member.endline == 0 { continue; } + // If the mapping entry has no line range, preserve the input line number (if any). + if member.endline == 0 { + let class = cache + .read_string(member.original_class_offset) + .unwrap_or(frame.class); + + let method = cache.read_string(member.original_name_offset).ok()?; + + // Synthesize from class name (input filename is not reliable) + let file = synthesize_source_file(class, outer_source_file).map(Cow::Owned); + + return Some(StackFrame { + class, + method, + file, + line: frame.line, + parameters: frame.parameters, + method_synthesized: member.is_synthesized(), + }); + } // skip any members which do not match our frames line if member.endline > 0 && (frame.line < member.startline as usize || frame.line > member.endline as usize) @@ -1080,7 +1102,8 @@ fn iterate_without_lines_preferring_base<'a>( class, method, file, - line: 0, + // Preserve input line if present when the mapping has no line info. + line: frame.line, parameters: frame.parameters, method_synthesized: member.is_synthesized(), }); @@ -1109,7 +1132,8 @@ fn iterate_without_lines<'a>( class, method, file, - line: 0, + // Preserve input line if present when the mapping has no line info. + line: frame.line, parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) diff --git a/src/mapper.rs b/src/mapper.rs index f1fa3df..300102a 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -224,7 +224,9 @@ fn map_member_without_lines<'a>( class, method: member.original, file, - line: 0, + // Preserve input line if present (e.g. "Unknown Source:7") when the mapping itself + // has no line information. This matches R8 retrace behavior. + line: frame.line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } @@ -272,6 +274,10 @@ fn iterate_with_lines<'a>( if has_line_info && frame.line > 0 && member.endline == 0 { continue; } + // If the mapping entry has no line range, preserve the input line number (if any). + if member.endline == 0 { + return Some(map_member_without_lines(frame, member)); + } if let Some(mapped) = map_member_with_lines(frame, member) { return Some(mapped); } @@ -596,7 +602,12 @@ impl<'s> ProguardMapper<'s> { if has_line_info && member.endline == 0 { continue; } - if let Some(mapped) = map_member_with_lines(&frame, member) { + if member.endline == 0 { + collected + .frames + .push(map_member_without_lines(&frame, member)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } else if let Some(mapped) = map_member_with_lines(&frame, member) { collected.frames.push(mapped); collected.rewrite_rules.extend(member.rewrite_rules.iter()); } diff --git a/tests/retrace.rs b/tests/retrace.rs index ca9fc5e..ca54fae 100644 --- a/tests/retrace.rs +++ b/tests/retrace.rs @@ -71,7 +71,8 @@ fn test_remap_no_lines() { let mut mapped = mapper.remap_frame(&StackFrame::new("a", "b", 10)); assert_eq!( mapped.next().unwrap(), - StackFrame::with_file("original.class.name", "originalMethodName", 0, "name.java") + // Preserve input line number when the mapping has no line information. + StackFrame::with_file("original.class.name", "originalMethodName", 10, "name.java") ); assert_eq!(mapped.next(), None); } From 147a43eb546c47e18ed0b8dc13cc19b6fd8fc419 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 13:03:24 +0100 Subject: [PATCH 07/24] feat(r8-tests): Add R8 synthetic tests --- tests/r8-synthetic.rs | 152 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/r8-synthetic.rs diff --git a/tests/r8-synthetic.rs b/tests/r8-synthetic.rs new file mode 100644 index 0000000..97d0b68 --- /dev/null +++ b/tests/r8-synthetic.rs @@ -0,0 +1,152 @@ +//! Tests for R8 synthetic / lambda method retracing fixtures. +//! +//! These tests are based on the R8 retrace test suite from: +//! src/test/java/com/android/tools/r8/retrace/stacktraces/ + +use proguard::{ProguardCache, ProguardMapper, ProguardMapping}; + +// ============================================================================= +// SyntheticLambdaMethodStackTrace +// ============================================================================= + +const SYNTHETIC_LAMBDA_METHOD_MAPPING: &str = "\ +# {'id':'com.android.tools.r8.mapping','version':'1.0'} +example.Main -> example.Main: + 1:1:void main(java.lang.String[]):123 -> main +example.Foo -> a.a: + 5:5:void lambda$main$0():225 -> a + 3:3:void runIt():218 -> b + 2:2:void main():223 -> c +example.Foo$$ExternalSyntheticLambda0 -> a.b: + void run(example.Foo) -> a + # {'id':'com.android.tools.r8.synthesized'} +"; + +#[test] +fn test_synthetic_lambda_method_stacktrace() { + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.a.a(a.java:5) + at a.b.a(Unknown Source) + at a.a.b(a.java:3) + at a.a.c(a.java:2) + at example.Main.main(Main.java:1) +"; + + // Note: this crate prints 4 spaces before each remapped frame. + // Also, when no line info is available, it will output :0. + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at example.Foo.lambda$main$0(Foo.java:225) + at example.Foo.runIt(Foo.java:218) + at example.Foo.main(Foo.java:223) + at example.Main.main(Main.java:123) +"; + + let mapper = ProguardMapper::from(SYNTHETIC_LAMBDA_METHOD_MAPPING); + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); + + let mapping = ProguardMapping::new(SYNTHETIC_LAMBDA_METHOD_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(), expected.trim()); +} + +// ============================================================================= +// SyntheticLambdaMethodWithInliningStackTrace +// ============================================================================= + +const SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_MAPPING: &str = "\ +# {'id':'com.android.tools.r8.mapping','version':'1.0'} +example.Main -> example.Main: + 1:1:void main(java.lang.String[]):123 -> main +example.Foo -> a.a: + 3:3:void runIt():218 -> b + 2:2:void main():223 -> c +example.Foo$$ExternalSyntheticLambda0 -> a.b: + 4:4:void example.Foo.lambda$main$0():225 -> a + 4:4:void run(example.Foo):0 -> a + # {'id':'com.android.tools.r8.synthesized'} +"; + +#[test] +fn test_synthetic_lambda_method_with_inlining_stacktrace() { + let input = "\ +Exception in thread \"main\" java.lang.NullPointerException + at a.b.a(Unknown Source:4) + at a.a.b(a.java:3) + at a.a.c(a.java:2) + at example.Main.main(Main.java:1) +"; + + let expected = "\ +Exception in thread \"main\" java.lang.NullPointerException + at example.Foo.lambda$main$0(Foo.java:225) + at example.Foo.runIt(Foo.java:218) + at example.Foo.main(Foo.java:223) + at example.Main.main(Main.java:123) +"; + + let mapper = ProguardMapper::from(SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_MAPPING); + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); + + let mapping = ProguardMapping::new(SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_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(), expected.trim()); +} + +// ============================================================================= +// MovedSynthetizedInfoStackTraceTest +// ============================================================================= + +const MOVED_SYNTHETIZED_INFO_MAPPING: &str = "\ +# { id: 'com.android.tools.r8.mapping', version: '2.2' } +com.android.tools.r8.BaseCommand$Builder -> foo.bar: + 1:1:void inlinee(java.util.Collection):0:0 -> inlinee$synthetic + 1:1:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic + 2:2:void inlinee(java.util.Collection):206:206 -> inlinee$synthetic + 2:2:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic + # {\"id\":\"com.android.tools.r8.synthesized\"} + 4:4:void inlinee(java.util.Collection):208:208 -> inlinee$synthetic + 4:4:void inlinee$synthetic(java.util.Collection):0 -> inlinee$synthetic + 7:7:void error(origin.Origin,java.lang.Throwable):363:363 -> inlinee$synthetic + 7:7:void inlinee(java.util.Collection):210 -> inlinee$synthetic + 7:7:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic +"; + +#[test] +fn test_moved_synthetized_info_stacktrace() { + let input = "\ +java.lang.RuntimeException: foobar + at foo.bar.inlinee$synthetic(BaseCommand.java:2) +"; + + let expected = "\ +java.lang.RuntimeException: foobar + at com.android.tools.r8.BaseCommand$Builder.inlinee(BaseCommand.java:206) +"; + + let mapper = ProguardMapper::from(MOVED_SYNTHETIZED_INFO_MAPPING); + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual.trim(), expected.trim()); + + let mapping = ProguardMapping::new(MOVED_SYNTHETIZED_INFO_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(), expected.trim()); +} From 7800a1f48b36bed75d5ad82901bd3dcdbbefba02 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Dec 2025 16:58:54 +0100 Subject: [PATCH 08/24] feat(r8-tests): Add R8 source file tests --- tests/r8-source-file-edge-cases.rs | 224 +++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/r8-source-file-edge-cases.rs diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs new file mode 100644 index 0000000..e428cbb --- /dev/null +++ b/tests/r8-source-file-edge-cases.rs @@ -0,0 +1,224 @@ +//! Tests for R8 retrace "Source File Edge Cases" fixtures. +//! +//! These tests are ported from the upstream R8 retrace fixtures in: +//! `src/test/java/com/android/tools/r8/retrace/stacktraces/`. +#![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()); +} + +// ============================================================================= +// ColonInFileNameStackTrace +// ============================================================================= + +const COLON_IN_FILE_NAME_MAPPING: &str = "\ +some.Class -> a: +# {\"id\":\"sourceFile\",\"fileName\":\"Class.kt\"} + 1:3:int strawberry(int):99:101 -> s + 4:5:int mango(float):121:122 -> s + int passionFruit(float):121:121 -> t +"; + +#[test] +fn test_colon_in_file_name_stacktrace() { + // Preserve leading whitespace exactly (no accidental leading newline). + let input = r#" at a.s(:foo::bar:1) + at a.t(:foo::bar:) +"#; + + let expected = r#" at some.Class.strawberry(Class.kt:99) + at some.Class.passionFruit(Class.kt:121) +"#; + + assert_remap_stacktrace(COLON_IN_FILE_NAME_MAPPING, input, expected); +} + +// ============================================================================= +// UnicodeInFileNameStackTrace +// ============================================================================= + +const UNICODE_IN_FILE_NAME_MAPPING: &str = "\ +some.Class -> a: +# {\"id\":\"sourceFile\",\"fileName\":\"Class.kt\"} + 1:3:int strawberry(int):99:101 -> s + 4:5:int mango(float):121:122 -> s +"; + +#[test] +fn test_unicode_in_file_name_stacktrace() { + let input = r#" at a.s(Blåbærgrød.jàvà:1) +"#; + + // Normalize indentation to this crate's output (`" at ..."`) + let expected = r#" at some.Class.strawberry(Class.kt:99) +"#; + + assert_remap_stacktrace(UNICODE_IN_FILE_NAME_MAPPING, input, expected); +} + +// ============================================================================= +// MultipleDotsInFileNameStackTrace +// ============================================================================= + +const MULTIPLE_DOTS_IN_FILE_NAME_MAPPING: &str = "\ +some.Class -> a: +# {\"id\":\"sourceFile\",\"fileName\":\"Class.kt\"} + 1:3:int strawberry(int):99:101 -> s + 4:5:int mango(float):121:122 -> s +"; + +#[test] +fn test_multiple_dots_in_file_name_stacktrace() { + let input = r#" at a.s(foo.bar.baz:1) +"#; + + // Normalize indentation to this crate's output (`" at ..."`) + let expected = r#" at some.Class.strawberry(Class.kt:99) +"#; + + assert_remap_stacktrace(MULTIPLE_DOTS_IN_FILE_NAME_MAPPING, input, expected); +} + +// ============================================================================= +// FileNameExtensionStackTrace +// ============================================================================= + +const FILE_NAME_EXTENSION_MAPPING: &str = "\ +foo.bar.baz -> a.b.c: +R8 -> R8: +"; + +#[test] +fn test_file_name_extension_stacktrace() { + // Preserve upstream whitespace exactly (no accidental leading newline). + let input = r#"a.b.c: Problem when compiling program + at R8.main(App:800) + at R8.main(Native Method) + at R8.main(Main.java:) + at R8.main(Main.kt:1) + at R8.main(Main.foo) + at R8.main() + at R8.main(Unknown) + at R8.main(SourceFile) + at R8.main(SourceFile:1) +Suppressed: a.b.c: You have to write the program first + at R8.retrace(App:184) + ... 7 more +"#; + + let expected = r#"foo.bar.baz: Problem when compiling program + at R8.main(R8.java:800) + at R8.main(Native Method) + at R8.main(R8.java) + at R8.main(R8.kt:1) + at R8.main(R8.java) + at R8.main(R8.java) + at R8.main(R8.java) + at R8.main(R8.java) + at R8.main(R8.java:1) +Suppressed: foo.bar.baz: You have to write the program first + at R8.retrace(R8.java:184) + ... 7 more +"#; + + assert_remap_stacktrace(FILE_NAME_EXTENSION_MAPPING, input, expected); +} + +// ============================================================================= +// SourceFileNameSynthesizeStackTrace +// ============================================================================= + +const SOURCE_FILE_NAME_SYNTHESIZE_MAPPING: &str = "\ +android.support.v7.widget.ActionMenuView -> mapping: + 21:21:void invokeItem():624 -> a +android.support.v7.widget.ActionMenuViewKt -> mappingKotlin: + 21:21:void invokeItem():624 -> b +"; + +#[test] +fn test_source_file_name_synthesize_stacktrace() { + // Preserve upstream whitespace exactly (no accidental leading newline). + let input = r#" at mapping.a(AW779999992:21) + at noMappingKt.noMapping(AW779999992:21) + at mappingKotlin.b(AW779999992:21) +"#; + + // Normalize indentation for remapped frames to match this crate (`" at ..."`). The middle + // line is intentionally unmapped and keeps its original leading tab. + let expected = r#" at android.support.v7.widget.ActionMenuView.invokeItem(ActionMenuView.java:624) + at noMappingKt.noMapping(AW779999992:21) + at android.support.v7.widget.ActionMenuViewKt.invokeItem(ActionMenuView.kt:624) +"#; + + assert_remap_stacktrace(SOURCE_FILE_NAME_SYNTHESIZE_MAPPING, input, expected); +} + +// ============================================================================= +// SourceFileWithNumberAndEmptyStackTrace +// ============================================================================= + +const SOURCE_FILE_WITH_NUMBER_AND_EMPTY_MAPPING: &str = "\ +com.android.tools.r8.R8 -> com.android.tools.r8.R8: + 34:34:void com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(com.android.tools.r8.utils.Reporter,com.android.tools.r8.utils.ExceptionUtils$CompileAction):59:59 -> a + 34:34:void runForTesting(com.android.tools.r8.utils.AndroidApp,com.android.tools.r8.utils.InternalOptions):261 -> a +"; + +#[test] +fn test_source_file_with_number_and_empty_stacktrace() { + // Preserve upstream whitespace exactly (no accidental leading newline). + let input = r#" at com.android.tools.r8.R8.a(R.java:34) + at com.android.tools.r8.R8.a(:34) +"#; + + // Normalize indentation to this crate's output (`" at ..."`) + let expected = r#" at com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(ExceptionUtils.java:59) + at com.android.tools.r8.R8.runForTesting(R8.java:261) + at com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(ExceptionUtils.java:59) + at com.android.tools.r8.R8.runForTesting(R8.java:261) +"#; + + assert_remap_stacktrace(SOURCE_FILE_WITH_NUMBER_AND_EMPTY_MAPPING, input, expected); +} + +// ============================================================================= +// ClassWithDashStackTrace +// ============================================================================= + +const CLASS_WITH_DASH_MAPPING: &str = "\ +# {\"id\":\"com.android.tools.r8.mapping\",\"version\":\"1.0\"} +Unused -> I$-CC: +# {\"id\":\"com.android.tools.r8.synthesized\"} + 66:66:void I.staticMethod() -> staticMethod + 66:66:void staticMethod():0 -> staticMethod + # {\"id\":\"com.android.tools.r8.synthesized\"} +"; + +#[test] +fn test_class_with_dash_stacktrace() { + // Preserve upstream whitespace exactly (no accidental leading newline). + let input = r#"java.lang.NullPointerException + at I$-CC.staticMethod(I.java:66) + at Main.main(Main.java:73) +"#; + + let expected = r#"java.lang.NullPointerException + at I.staticMethod(I.java:66) + at Main.main(Main.java:73) +"#; + + assert_remap_stacktrace(CLASS_WITH_DASH_MAPPING, input, expected); +} From 6989e38f55b49626432278e43e6e00e59f21ccc1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 8 Jan 2026 14:13:02 +0100 Subject: [PATCH 09/24] Fix fixtures --- tests/r8-source-file-edge-cases.rs | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index e428cbb..7b94c0a 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -35,7 +35,6 @@ some.Class -> a: #[test] fn test_colon_in_file_name_stacktrace() { - // Preserve leading whitespace exactly (no accidental leading newline). let input = r#" at a.s(:foo::bar:1) at a.t(:foo::bar:) "#; @@ -86,7 +85,6 @@ fn test_multiple_dots_in_file_name_stacktrace() { let input = r#" at a.s(foo.bar.baz:1) "#; - // Normalize indentation to this crate's output (`" at ..."`) let expected = r#" at some.Class.strawberry(Class.kt:99) "#; @@ -104,7 +102,6 @@ R8 -> R8: #[test] fn test_file_name_extension_stacktrace() { - // Preserve upstream whitespace exactly (no accidental leading newline). let input = r#"a.b.c: Problem when compiling program at R8.main(App:800) at R8.main(Native Method) @@ -122,13 +119,13 @@ Suppressed: a.b.c: You have to write the program first let expected = r#"foo.bar.baz: Problem when compiling program at R8.main(R8.java:800) - at R8.main(Native Method) - at R8.main(R8.java) + at R8.main(R8.java:0) + at R8.main(R8.java:0) at R8.main(R8.kt:1) - at R8.main(R8.java) - at R8.main(R8.java) - at R8.main(R8.java) - at R8.main(R8.java) + at R8.main(R8.java:0) + at R8.main(R8.java:0) + at R8.main(R8.java:0) + at R8.main(R8.java:0) at R8.main(R8.java:1) Suppressed: foo.bar.baz: You have to write the program first at R8.retrace(R8.java:184) @@ -144,21 +141,18 @@ Suppressed: foo.bar.baz: You have to write the program first const SOURCE_FILE_NAME_SYNTHESIZE_MAPPING: &str = "\ android.support.v7.widget.ActionMenuView -> mapping: - 21:21:void invokeItem():624 -> a + 21:21:void invokeItem():624 -> a android.support.v7.widget.ActionMenuViewKt -> mappingKotlin: - 21:21:void invokeItem():624 -> b + 21:21:void invokeItem():624 -> b "; #[test] fn test_source_file_name_synthesize_stacktrace() { - // Preserve upstream whitespace exactly (no accidental leading newline). let input = r#" at mapping.a(AW779999992:21) at noMappingKt.noMapping(AW779999992:21) at mappingKotlin.b(AW779999992:21) "#; - // Normalize indentation for remapped frames to match this crate (`" at ..."`). The middle - // line is intentionally unmapped and keeps its original leading tab. let expected = r#" at android.support.v7.widget.ActionMenuView.invokeItem(ActionMenuView.java:624) at noMappingKt.noMapping(AW779999992:21) at android.support.v7.widget.ActionMenuViewKt.invokeItem(ActionMenuView.kt:624) @@ -173,18 +167,16 @@ fn test_source_file_name_synthesize_stacktrace() { const SOURCE_FILE_WITH_NUMBER_AND_EMPTY_MAPPING: &str = "\ com.android.tools.r8.R8 -> com.android.tools.r8.R8: - 34:34:void com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(com.android.tools.r8.utils.Reporter,com.android.tools.r8.utils.ExceptionUtils$CompileAction):59:59 -> a - 34:34:void runForTesting(com.android.tools.r8.utils.AndroidApp,com.android.tools.r8.utils.InternalOptions):261 -> a + 34:34:void com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(com.android.tools.r8.utils.Reporter,com.android.tools.r8.utils.ExceptionUtils$CompileAction):59:59 -> a + 34:34:void runForTesting(com.android.tools.r8.utils.AndroidApp,com.android.tools.r8.utils.InternalOptions):261 -> a "; #[test] fn test_source_file_with_number_and_empty_stacktrace() { - // Preserve upstream whitespace exactly (no accidental leading newline). let input = r#" at com.android.tools.r8.R8.a(R.java:34) at com.android.tools.r8.R8.a(:34) "#; - // Normalize indentation to this crate's output (`" at ..."`) let expected = r#" at com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(ExceptionUtils.java:59) at com.android.tools.r8.R8.runForTesting(R8.java:261) at com.android.tools.r8.utils.ExceptionUtils.withR8CompilationHandler(ExceptionUtils.java:59) @@ -209,7 +201,6 @@ Unused -> I$-CC: #[test] fn test_class_with_dash_stacktrace() { - // Preserve upstream whitespace exactly (no accidental leading newline). let input = r#"java.lang.NullPointerException at I$-CC.staticMethod(I.java:66) at Main.main(Main.java:73) From e40221fbceba6a0ad35f8dbfb746f0e2939a7a30 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 8 Jan 2026 20:51:22 +0100 Subject: [PATCH 10/24] Add MD file explaining failures --- tests/r8-source-file-edge-cases.NOTES.md | 60 ++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/r8-source-file-edge-cases.NOTES.md diff --git a/tests/r8-source-file-edge-cases.NOTES.md b/tests/r8-source-file-edge-cases.NOTES.md new file mode 100644 index 0000000..73a73f6 --- /dev/null +++ b/tests/r8-source-file-edge-cases.NOTES.md @@ -0,0 +1,60 @@ +# R8 Retrace: Source File Edge Cases — Remaining Fixes + +After normalizing fixture indentation (member mappings use 4 spaces), `tests/r8-source-file-edge-cases.rs` has **4 passing** and **3 failing** tests. + +This note documents, **one-by-one**, what still needs fixing in the crate (not in the fixtures) to make the remaining tests pass. + +## 1) `test_colon_in_file_name_stacktrace` + +- **Symptom**: Frames are emitted unchanged and do not get retraced: + - Input stays as `at a.s(:foo::bar:1)` / `at a.t(:foo::bar:)`. +- **Root cause**: `src/stacktrace.rs::parse_frame` splits `(:)` using the **first** `:`. + - For `(:foo::bar:1)`, the first split produces `file=""` and `line="foo::bar:1"`, so parsing the line number fails and the whole frame is rejected. +- **Fix needed**: + - In `parse_frame`, split `file:line` using the **last** colon (`rsplit_once(':')`) so file names can contain `:` (Windows paths and this fixture). + - Treat an empty or non-numeric suffix after the last colon as “no line info” (line `0`) instead of rejecting the frame. + +## 2) `test_file_name_extension_stacktrace` + +This failure is due to two independent gaps. + +### 2a) Weird location forms aren’t parsed/normalized consistently + +- **Symptom**: Output contains things like `Main.java:` and `R8.foo:0` instead of normalized `R8.java:0` for “no line” cases. +- **Root cause**: `parse_frame` only supports: + - `(:)`, or + - `()` (treated as line `0`), + and it currently rejects or mis-interprets inputs like: + - `(Native Method)`, `(Unknown Source)`, `(Unknown)`, `()` + - `(Main.java:)` (empty “line” part) + - `(Main.foo)` (no `:line`, but also not a normal source file extension) +- **Fix needed**: + - Make `parse_frame` permissive for these Java stacktrace forms and interpret them as a parsed frame with **line `0`** so remapping can then replace the file with the mapping’s source file (here: `R8.java`). + - Also apply the “split on last colon” rule from (1) so `file:line` parsing is robust. + +### 2b) `Suppressed:` throwables are not remapped + +- **Symptom**: The throwable in the `Suppressed:` line remains obfuscated: + - Actual: `Suppressed: a.b.c: You have to write the program first` + - Expected: `Suppressed: foo.bar.baz: You have to write the program first` +- **Root cause**: `src/mapper.rs::remap_stacktrace` remaps: + - the first-line throwable, and + - `Caused by: ...`, + but it does **not** detect/handle `Suppressed: ...`. +- **Fix needed**: + - Add handling for the `Suppressed: ` prefix analogous to `Caused by: `: + - parse the throwable after the prefix, + - remap it, + - emit with the same prefix. + +## 3) `test_class_with_dash_stacktrace` + +- **Symptom**: An extra frame appears: + - Actual includes `Unused.staticMethod(Unused.java:0)` in addition to `I.staticMethod(I.java:66)`. +- **Root cause**: The mapping includes synthesized metadata (`com.android.tools.r8.synthesized`) and multiple plausible remapped frames, including synthesized “holder/bridge” frames. + - Today we emit all candidates rather than preferring non-synthesized frames. +- **Fix needed**: + - Propagate the synthesized marker into `StackFrame.method_synthesized` during mapping. + - When multiple candidate remapped frames exist for one obfuscated frame, **filter synthesized frames** if any non-synthesized frames exist (or apply an equivalent preference rule). + + From 20a5bee03b5f63a092a3770a5b340994b6e2782c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Jan 2026 14:10:29 +0100 Subject: [PATCH 11/24] feat(r8-tests): Add R8 line number handling tests --- tests/r8-line-number-handling.NOTES.md | 122 ++++++++++ tests/r8-line-number-handling.rs | 313 +++++++++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 tests/r8-line-number-handling.NOTES.md create mode 100644 tests/r8-line-number-handling.rs diff --git a/tests/r8-line-number-handling.NOTES.md b/tests/r8-line-number-handling.NOTES.md new file mode 100644 index 0000000..a475f7e --- /dev/null +++ b/tests/r8-line-number-handling.NOTES.md @@ -0,0 +1,122 @@ +# 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 new file mode 100644 index 0000000..3b0de55 --- /dev/null +++ b/tests/r8-line-number-handling.rs @@ -0,0 +1,313 @@ +//! 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); +} + + From 3ab9f4670e3f7b8a8b0076df03038395dfd6e06f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Jan 2026 16:46:30 +0100 Subject: [PATCH 12/24] Fix formatting --- tests/r8-line-number-handling.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/r8-line-number-handling.rs b/tests/r8-line-number-handling.rs index 3b0de55..190da5a 100644 --- a/tests/r8-line-number-handling.rs +++ b/tests/r8-line-number-handling.rs @@ -204,7 +204,11 @@ fn test_no_obfuscated_line_number_with_override() { at com.android.tools.r8.naming.retrace.Main.mainPC(Main.java:42) "#; - assert_remap_stacktrace(NO_OBFUSCATED_LINE_NUMBER_WITH_OVERRIDE_MAPPING, input, expected); + assert_remap_stacktrace( + NO_OBFUSCATED_LINE_NUMBER_WITH_OVERRIDE_MAPPING, + input, + expected, + ); } // ============================================================================= @@ -309,5 +313,3 @@ fn test_invalid_original_range_stacktrace() { assert_remap_stacktrace(INVALID_ORIGINAL_RANGE_MAPPING, input, expected); } - - From 20d7b62152d479fcea71e75dd612eb1f09fb7569 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 20 Jan 2026 13:19:42 +0100 Subject: [PATCH 13/24] Add MD file explaining failures --- tests/r8-synthetic.NOTES.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/r8-synthetic.NOTES.md diff --git a/tests/r8-synthetic.NOTES.md b/tests/r8-synthetic.NOTES.md new file mode 100644 index 0000000..0ca9ca7 --- /dev/null +++ b/tests/r8-synthetic.NOTES.md @@ -0,0 +1,31 @@ +# r8-synthetic.rs failures + +This doc summarizes the current failures from running `cargo test --test r8-synthetic`. + +## `test_synthetic_lambda_method_stacktrace` + +- **Failure**: An extra synthetic frame is emitted: + - `example.Foo$$ExternalSyntheticLambda0.run(Foo.java:0)` +- **Expected**: Only the “real” deobfuscated frames (`lambda$main$0`, `runIt`, `main`, `Main.main`). +- **Why**: + - The mapper currently includes synthesized lambda bridge frames (marked via `com.android.tools.r8.synthesized`) instead of filtering them out when a better “real” frame exists. + - Also, `Unknown Source` maps to `:0` for line numbers, so the synthetic frame shows `Foo.java:0`. + +## `test_synthetic_lambda_method_with_inlining_stacktrace` + +- **Failure**: Same as above — extra synthetic frame: + - `example.Foo$$ExternalSyntheticLambda0.run(Foo.java:0)` +- **Expected**: No synthetic lambda `run(...)` frame in the output. +- **Why**: + - Same root cause: missing synthesized-frame suppression when a non-synthesized alternative exists. + +## `test_moved_synthetized_info_stacktrace` + +- **Failure**: An extra synthesized frame is emitted: + - `com.android.tools.r8.BaseCommand$Builder.inlinee$synthetic(BaseCommand.java:0)` +- **Expected**: Only: + - `com.android.tools.r8.BaseCommand$Builder.inlinee(BaseCommand.java:206)` +- **Why**: + - The mapping has both “real” and `$synthetic` variants, with synthesized metadata attached to the `$synthetic` variant. + - The mapper currently emits both candidates rather than filtering out the synthesized one when the non-synthesized target exists. + From a9125eb251c9b929b536f82f1068b737b1c37643 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Jan 2026 18:15:45 +0100 Subject: [PATCH 14/24] Remove r8-ambiguous.NOTES --- tests/r8-ambiguous.NOTES.md | 46 ------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 tests/r8-ambiguous.NOTES.md diff --git a/tests/r8-ambiguous.NOTES.md b/tests/r8-ambiguous.NOTES.md deleted file mode 100644 index 8ebaac7..0000000 --- a/tests/r8-ambiguous.NOTES.md +++ /dev/null @@ -1,46 +0,0 @@ -# r8-ambiguous.rs failures - -This doc summarizes the current failures from running `cargo test --test r8-ambiguous`. - -## `test_ambiguous_method_verbose_stacktrace` - -- **Failure**: The frame lines are emitted verbatim (no remapping at all). -- **Why**: - - The input frames have **no line numbers** (e.g. `(Foo.java)`), which means `frame.line == 0`. - - The current remapper does not produce any remapped candidates for these frames (so `format_frames` falls back to printing the original line). This indicates a gap in the “no-line” / best-effort ambiguous member mapping behavior for `line == 0` frames. - -## `test_ambiguous_stacktrace` - -- **Failure**: No remapping occurs; frames like `at a.a.a(Unknown Source)` are preserved. -- **Why**: - - These frames have `Unknown Source` with **no line number**, so `frame.line == 0`. - - For `line == 0`, the current implementation ends up with **no remapped candidates** and prints the original frame line unchanged (instead of emitting ambiguous alternatives `foo`/`bar`). - -## `test_ambiguous_missing_line_stacktrace` - -- **Failure**: Output uses `R8.java:0` rather than preserving the concrete input line numbers (`7/8/9`) in the remapped alternatives. -- **Why**: - - The mapping entries have **no original line information** (base/no-line mappings). - - The current mapping logic uses the mapping’s “original start line” (which defaults to `0`) rather than propagating the **caller-provided minified line** when available. - -## `test_ambiguous_with_multiple_line_mappings_stacktrace` - -- **Failure**: Last frame stays obfuscated (`com.android.tools.r8.Internal.zza(Unknown)`), expected a deobfuscated `Internal.foo(Internal.java:0)`-style frame. -- **Why**: - - `(...(Unknown))` parses as a frame with `file = "Unknown"` and `line = 0`. - - All available member mappings are **line-ranged** (e.g. `10:10`, `11:11`, `12:12`), so with `line == 0` they do not match and the remapper produces **no candidates**, falling back to the original frame line. - -## `test_ambiguous_with_signature_stacktrace` - -- **Failure**: Same symptom as above (`Internal.zza(Unknown)` remains), expected deobfuscated member. -- **Why**: - - Same `line == 0` issue: line-ranged overload mappings cannot be selected without a minified line. - - The remapper currently has no fallback that returns “best effort” candidates for `line == 0` frames (e.g., returning all overloads, or preferring a base mapping if present). - -## `test_inline_no_line_assume_no_inline_ambiguous_stacktrace` - -- **Failure**: Expected retraced output, but actual output is unchanged (`at a.foo(Unknown Source)`). -- **Why**: - - This fixture expects a special “no-line” ambiguity strategy: when `line == 0`, prefer the **base/no-line** mapping (`otherMain`) over line-specific inline entries. - - The current implementation returns no remapped candidates for this `line == 0` frame, so it prints the original frame line unchanged. - From a045327c8a7121cb204530d51629f9f1e37c2265 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Jan 2026 20:48:19 +0100 Subject: [PATCH 15/24] fix(r8-tests): Fix no-line original line preservation for source file edge cases --- src/cache/mod.rs | 36 +++++++++-- src/mapper.rs | 10 +++- src/mapping.rs | 12 +++- src/stacktrace.rs | 4 +- tests/r8-source-file-edge-cases.NOTES.md | 60 ------------------- tests/r8-source-file-edge-cases.rs | 76 +----------------------- 6 files changed, 55 insertions(+), 143 deletions(-) delete mode 100644 tests/r8-source-file-edge-cases.NOTES.md diff --git a/src/cache/mod.rs b/src/cache/mod.rs index e6ec744..3b57cec 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1096,8 +1096,12 @@ fn map_member_without_lines<'a>( class, method, file, - // Preserve input line if present when the mapping has no line info. - line: frame.line, + line: resolve_no_line_output_line( + frame.line, + member.original_startline, + member.startline, + member.endline, + ), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) @@ -1124,13 +1128,37 @@ fn iterate_without_lines<'a>( class, method, file, - // Preserve input line if present when the mapping has no line info. - line: frame.line, + line: resolve_no_line_output_line( + frame.line, + member.original_startline, + member.startline, + member.endline, + ), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) } +// For no-line mappings, prefer the original line if the mapping has no minified range. +fn resolve_no_line_output_line( + frame_line: usize, + original_startline: u32, + startline: u32, + endline: u32, +) -> usize { + if frame_line > 0 { + frame_line + } else if startline == 0 + && endline == 0 + && original_startline > 0 + && original_startline != u32::MAX + { + original_startline as usize + } else { + 0 + } +} + 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/src/mapper.rs b/src/mapper.rs index 827a0d5..775e616 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -220,13 +220,21 @@ fn map_member_without_lines<'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 line = if frame.line > 0 { + frame.line + } else if member.startline == 0 && member.endline == 0 && member.original_startline > 0 { + // For base (no-line) mappings, use the original line when available. + member.original_startline + } else { + 0 + }; 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: frame.line, + line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } diff --git a/src/mapping.rs b/src/mapping.rs index e433117..f9668d6 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -635,13 +635,21 @@ fn parse_proguard_field_or_method( })?; let original_class = split_class.next(); - let line_mapping = match (startline, endline) { - (Some(startline), Some(endline)) => Some(LineMapping { + let line_mapping = match (startline, endline, original_startline) { + (Some(startline), Some(endline), _) => Some(LineMapping { startline, 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, + original_startline: Some(original_startline), + original_endline, + }), _ => None, }; diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 875f4ca..9e56806 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -286,8 +286,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.split_once(':') { - Some((file, line)) => (file, line.parse().ok()?), + let (file, line) = match file_split.rsplit_once(':') { + Some((file, line)) => (file, line.parse().unwrap_or(0)), None => (file_split, 0), }; diff --git a/tests/r8-source-file-edge-cases.NOTES.md b/tests/r8-source-file-edge-cases.NOTES.md deleted file mode 100644 index 73a73f6..0000000 --- a/tests/r8-source-file-edge-cases.NOTES.md +++ /dev/null @@ -1,60 +0,0 @@ -# R8 Retrace: Source File Edge Cases — Remaining Fixes - -After normalizing fixture indentation (member mappings use 4 spaces), `tests/r8-source-file-edge-cases.rs` has **4 passing** and **3 failing** tests. - -This note documents, **one-by-one**, what still needs fixing in the crate (not in the fixtures) to make the remaining tests pass. - -## 1) `test_colon_in_file_name_stacktrace` - -- **Symptom**: Frames are emitted unchanged and do not get retraced: - - Input stays as `at a.s(:foo::bar:1)` / `at a.t(:foo::bar:)`. -- **Root cause**: `src/stacktrace.rs::parse_frame` splits `(:)` using the **first** `:`. - - For `(:foo::bar:1)`, the first split produces `file=""` and `line="foo::bar:1"`, so parsing the line number fails and the whole frame is rejected. -- **Fix needed**: - - In `parse_frame`, split `file:line` using the **last** colon (`rsplit_once(':')`) so file names can contain `:` (Windows paths and this fixture). - - Treat an empty or non-numeric suffix after the last colon as “no line info” (line `0`) instead of rejecting the frame. - -## 2) `test_file_name_extension_stacktrace` - -This failure is due to two independent gaps. - -### 2a) Weird location forms aren’t parsed/normalized consistently - -- **Symptom**: Output contains things like `Main.java:` and `R8.foo:0` instead of normalized `R8.java:0` for “no line” cases. -- **Root cause**: `parse_frame` only supports: - - `(:)`, or - - `()` (treated as line `0`), - and it currently rejects or mis-interprets inputs like: - - `(Native Method)`, `(Unknown Source)`, `(Unknown)`, `()` - - `(Main.java:)` (empty “line” part) - - `(Main.foo)` (no `:line`, but also not a normal source file extension) -- **Fix needed**: - - Make `parse_frame` permissive for these Java stacktrace forms and interpret them as a parsed frame with **line `0`** so remapping can then replace the file with the mapping’s source file (here: `R8.java`). - - Also apply the “split on last colon” rule from (1) so `file:line` parsing is robust. - -### 2b) `Suppressed:` throwables are not remapped - -- **Symptom**: The throwable in the `Suppressed:` line remains obfuscated: - - Actual: `Suppressed: a.b.c: You have to write the program first` - - Expected: `Suppressed: foo.bar.baz: You have to write the program first` -- **Root cause**: `src/mapper.rs::remap_stacktrace` remaps: - - the first-line throwable, and - - `Caused by: ...`, - but it does **not** detect/handle `Suppressed: ...`. -- **Fix needed**: - - Add handling for the `Suppressed: ` prefix analogous to `Caused by: `: - - parse the throwable after the prefix, - - remap it, - - emit with the same prefix. - -## 3) `test_class_with_dash_stacktrace` - -- **Symptom**: An extra frame appears: - - Actual includes `Unused.staticMethod(Unused.java:0)` in addition to `I.staticMethod(I.java:66)`. -- **Root cause**: The mapping includes synthesized metadata (`com.android.tools.r8.synthesized`) and multiple plausible remapped frames, including synthesized “holder/bridge” frames. - - Today we emit all candidates rather than preferring non-synthesized frames. -- **Fix needed**: - - Propagate the synthesized marker into `StackFrame.method_synthesized` during mapping. - - When multiple candidate remapped frames exist for one obfuscated frame, **filter synthesized frames** if any non-synthesized frames exist (or apply an equivalent preference rule). - - diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index 7b94c0a..bfeee9f 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -39,8 +39,8 @@ fn test_colon_in_file_name_stacktrace() { at a.t(:foo::bar:) "#; - let expected = r#" at some.Class.strawberry(Class.kt:99) - at some.Class.passionFruit(Class.kt:121) + let expected = r#" at some.Class.strawberry(Class.kt:99) + at some.Class.passionFruit(Class.kt:121) "#; assert_remap_stacktrace(COLON_IN_FILE_NAME_MAPPING, input, expected); @@ -91,50 +91,6 @@ fn test_multiple_dots_in_file_name_stacktrace() { assert_remap_stacktrace(MULTIPLE_DOTS_IN_FILE_NAME_MAPPING, input, expected); } -// ============================================================================= -// FileNameExtensionStackTrace -// ============================================================================= - -const FILE_NAME_EXTENSION_MAPPING: &str = "\ -foo.bar.baz -> a.b.c: -R8 -> R8: -"; - -#[test] -fn test_file_name_extension_stacktrace() { - let input = r#"a.b.c: Problem when compiling program - at R8.main(App:800) - at R8.main(Native Method) - at R8.main(Main.java:) - at R8.main(Main.kt:1) - at R8.main(Main.foo) - at R8.main() - at R8.main(Unknown) - at R8.main(SourceFile) - at R8.main(SourceFile:1) -Suppressed: a.b.c: You have to write the program first - at R8.retrace(App:184) - ... 7 more -"#; - - let expected = r#"foo.bar.baz: Problem when compiling program - at R8.main(R8.java:800) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.kt:1) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:1) -Suppressed: foo.bar.baz: You have to write the program first - at R8.retrace(R8.java:184) - ... 7 more -"#; - - assert_remap_stacktrace(FILE_NAME_EXTENSION_MAPPING, input, expected); -} - // ============================================================================= // SourceFileNameSynthesizeStackTrace // ============================================================================= @@ -185,31 +141,3 @@ fn test_source_file_with_number_and_empty_stacktrace() { assert_remap_stacktrace(SOURCE_FILE_WITH_NUMBER_AND_EMPTY_MAPPING, input, expected); } - -// ============================================================================= -// ClassWithDashStackTrace -// ============================================================================= - -const CLASS_WITH_DASH_MAPPING: &str = "\ -# {\"id\":\"com.android.tools.r8.mapping\",\"version\":\"1.0\"} -Unused -> I$-CC: -# {\"id\":\"com.android.tools.r8.synthesized\"} - 66:66:void I.staticMethod() -> staticMethod - 66:66:void staticMethod():0 -> staticMethod - # {\"id\":\"com.android.tools.r8.synthesized\"} -"; - -#[test] -fn test_class_with_dash_stacktrace() { - let input = r#"java.lang.NullPointerException - at I$-CC.staticMethod(I.java:66) - at Main.main(Main.java:73) -"#; - - let expected = r#"java.lang.NullPointerException - at I.staticMethod(I.java:66) - at Main.main(Main.java:73) -"#; - - assert_remap_stacktrace(CLASS_WITH_DASH_MAPPING, input, expected); -} From 764b7f426cb842c751a2407d9b1a7241676aa040 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 29 Jan 2026 23:08:57 +0100 Subject: [PATCH 16/24] fix(r8-tests): Fix no-line original line preservation for source file edge cases (#79) - Split source file names by the last `:` occurrence - Additionally fixes no-line original line preservation - Also removes `tests/r8-source-file-edge-cases.NOTES.md` - Removes two tests that we agreed do not need fixing --- src/cache/mod.rs | 72 ++++++++-------------- src/lib.rs | 6 +- src/mapper.rs | 56 +++++------------ src/mapping.rs | 12 +++- src/stacktrace.rs | 4 +- src/utils.rs | 62 +++++++++++++++++++ tests/r8-source-file-edge-cases.NOTES.md | 60 ------------------- tests/r8-source-file-edge-cases.rs | 76 +----------------------- 8 files changed, 121 insertions(+), 227 deletions(-) create mode 100644 src/utils.rs delete mode 100644 tests/r8-source-file-edge-cases.NOTES.md diff --git a/src/cache/mod.rs b/src/cache/mod.rs index e6ec744..c2c1ac9 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -80,6 +80,10 @@ 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::{java, stacktrace, DeobfuscatedSignature, StackFrame, StackTrace, Throwable}; pub use raw::{ProguardCache, PRGCACHE_VERSION}; @@ -1092,12 +1096,21 @@ 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, - // Preserve input line if present when the mapping has no line info. - line: frame.line, + 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(), }) @@ -1120,59 +1133,26 @@ fn iterate_without_lines<'a>( // Synthesize from class name (input filename is not reliable) 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, - // Preserve input line if present when the mapping has no line info. - line: frame.line, + 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(), }) } -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 - after_last_period.split('$').next() -} - -/// Synthesizes a source file name from a class name. -/// For Kotlin top-level classes ending in "Kt", the suffix is stripped and ".kt" is used. -/// Otherwise, the extension is derived from the reference file, defaulting to ".java". -/// For example: ("com.example.MainKt", Some("Other.java")) -> "Main.kt" (Kt suffix takes precedence) -/// For example: ("com.example.Main", Some("Other.kt")) -> "Main.kt" -/// For example: ("com.example.MainKt", None) -> "Main.kt" -/// For inner classes: ("com.example.Main$Inner", None) -> "Main.java" -fn synthesize_source_file(class_name: &str, reference_file: Option<&str>) -> Option { - let base = extract_class_name(class_name)?; - - // For Kotlin top-level classes (ending in "Kt"), always use .kt extension and strip suffix - // This takes precedence over reference_file since Kt suffix is a strong Kotlin indicator - if base.ends_with("Kt") && base.len() > 2 { - let kotlin_base = &base[..base.len() - 2]; - return Some(format!("{}.kt", kotlin_base)); - } - - // If we have a reference file, derive extension from it - if let Some(ext) = reference_file.and_then(|f| f.rfind('.').map(|pos| &f[pos..])) { - return Some(format!("{}{}", base, ext)); - } - - Some(format!("{}.java", base)) -} - -/// Converts a Java class name to its JVM descriptor format. -/// -/// For example, `java.lang.NullPointerException` becomes `Ljava/lang/NullPointerException;`. -pub fn class_name_to_descriptor(class: &str) -> String { - let mut descriptor = String::with_capacity(class.len() + 2); - descriptor.push('L'); - descriptor.push_str(&class.replace('.', "/")); - descriptor.push(';'); - descriptor -} - /// Computes the number of frames to skip based on rewrite rules. /// Returns the total skip count from all matching RemoveInnerFrames actions. fn compute_skip_count(rewrite_rules: &[RewriteRule<'_>], thrown_descriptor: Option<&str>) -> usize { diff --git a/src/lib.rs b/src/lib.rs index a9a270e..18c04e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,13 +42,13 @@ mod java; mod mapper; mod mapping; mod stacktrace; +mod utils; -pub use cache::{ - class_name_to_descriptor, CacheError, CacheErrorKind, ProguardCache, PRGCACHE_VERSION, -}; +pub use cache::{CacheError, CacheErrorKind, ProguardCache, PRGCACHE_VERSION}; pub use mapper::{DeobfuscatedSignature, ProguardMapper, RemappedFrameIter}; pub use mapping::{ LineMapping, MappingSummary, ParseError, ParseErrorKind, ProguardMapping, ProguardRecord, ProguardRecordIter, }; pub use stacktrace::{StackFrame, StackTrace, Throwable}; +pub use utils::class_name_to_descriptor; diff --git a/src/mapper.rs b/src/mapper.rs index 827a0d5..4e419ed 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -10,6 +10,10 @@ use crate::builder::{ 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, +}; /// A deobfuscated method signature. pub struct DeobfuscatedSignature { @@ -132,45 +136,6 @@ impl<'m> Iterator for RemappedFrameIter<'m> { } } -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 - after_last_period.split('$').next() -} - -fn class_name_to_descriptor(class: &str) -> String { - let mut descriptor = String::with_capacity(class.len() + 2); - descriptor.push('L'); - descriptor.push_str(&class.replace('.', "/")); - descriptor.push(';'); - descriptor -} - -/// Synthesizes a full source file name from a class name and a reference source file. -/// For Kotlin top-level classes ending in "Kt", the suffix is stripped and ".kt" is used. -/// Otherwise, the extension is derived from the reference file, defaulting to ".java". -/// For example: ("com.example.MainKt", Some("Other.java")) -> "Main.kt" (Kt suffix takes precedence) -/// For example: ("com.example.Main", Some("Other.kt")) -> "Main.kt" -/// For example: ("com.example.MainKt", None) -> "Main.kt" -/// For inner classes: ("com.example.Main$Inner", None) -> "Main.java" -fn synthesize_source_file(class_name: &str, reference_file: Option<&str>) -> Option { - let base = extract_class_name(class_name)?; - - // For Kotlin top-level classes (ending in "Kt"), always use .kt extension and strip suffix - // This takes precedence over reference_file since Kt suffix is a strong Kotlin indicator - if base.ends_with("Kt") && base.len() > 2 { - let kotlin_base = &base[..base.len() - 2]; - return Some(format!("{}.kt", kotlin_base)); - } - - // If we have a reference file, derive extension from it - if let Some(ext) = reference_file.and_then(|f| f.rfind('.').map(|pos| &f[pos..])) { - return Some(format!("{}{}", base, ext)); - } - - Some(format!("{}.java", base)) -} - fn map_member_with_lines<'a>( frame: &StackFrame<'a>, member: &MemberMapping<'a>, @@ -220,13 +185,24 @@ fn map_member_without_lines<'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: frame.line, + line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } diff --git a/src/mapping.rs b/src/mapping.rs index e433117..f9668d6 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -635,13 +635,21 @@ fn parse_proguard_field_or_method( })?; let original_class = split_class.next(); - let line_mapping = match (startline, endline) { - (Some(startline), Some(endline)) => Some(LineMapping { + let line_mapping = match (startline, endline, original_startline) { + (Some(startline), Some(endline), _) => Some(LineMapping { startline, 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, + original_startline: Some(original_startline), + original_endline, + }), _ => None, }; diff --git a/src/stacktrace.rs b/src/stacktrace.rs index 875f4ca..9e56806 100644 --- a/src/stacktrace.rs +++ b/src/stacktrace.rs @@ -286,8 +286,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.split_once(':') { - Some((file, line)) => (file, line.parse().ok()?), + let (file, line) = match file_split.rsplit_once(':') { + Some((file, line)) => (file, line.parse().unwrap_or(0)), None => (file_split, 0), }; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fa0089c --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,62 @@ +//! 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 + after_last_period.split('$').next() +} + +/// Synthesizes a source file name from a class name. +/// For Kotlin top-level classes ending in "Kt", the suffix is stripped and ".kt" is used. +/// Otherwise, the extension is derived from the reference file, defaulting to ".java". +/// For example: ("com.example.MainKt", Some("Other.java")) -> "Main.kt" (Kt suffix takes precedence) +/// For example: ("com.example.Main", Some("Other.kt")) -> "Main.kt" +/// For example: ("com.example.MainKt", None) -> "Main.kt" +/// For inner classes: ("com.example.Main$Inner", None) -> "Main.java" +pub(crate) fn synthesize_source_file( + class_name: &str, + reference_file: Option<&str>, +) -> Option { + let base = extract_class_name(class_name)?; + + // For Kotlin top-level classes (ending in "Kt"), always use .kt extension and strip suffix + // This takes precedence over reference_file since Kt suffix is a strong Kotlin indicator + if base.ends_with("Kt") && base.len() > 2 { + let kotlin_base = &base[..base.len() - 2]; + return Some(format!("{}.kt", kotlin_base)); + } + + // If we have a reference file, derive extension from it + if let Some(ext) = reference_file.and_then(|f| f.rfind('.').map(|pos| &f[pos..])) { + return Some(format!("{}{}", base, ext)); + } + + Some(format!("{}.java", base)) +} + +/// Converts a Java class name to its JVM descriptor format. +/// +/// For example, `java.lang.NullPointerException` becomes `Ljava/lang/NullPointerException;`. +pub fn class_name_to_descriptor(class: &str) -> String { + let mut descriptor = String::with_capacity(class.len() + 2); + descriptor.push('L'); + descriptor.push_str(&class.replace('.', "/")); + descriptor.push(';'); + descriptor +} diff --git a/tests/r8-source-file-edge-cases.NOTES.md b/tests/r8-source-file-edge-cases.NOTES.md deleted file mode 100644 index 73a73f6..0000000 --- a/tests/r8-source-file-edge-cases.NOTES.md +++ /dev/null @@ -1,60 +0,0 @@ -# R8 Retrace: Source File Edge Cases — Remaining Fixes - -After normalizing fixture indentation (member mappings use 4 spaces), `tests/r8-source-file-edge-cases.rs` has **4 passing** and **3 failing** tests. - -This note documents, **one-by-one**, what still needs fixing in the crate (not in the fixtures) to make the remaining tests pass. - -## 1) `test_colon_in_file_name_stacktrace` - -- **Symptom**: Frames are emitted unchanged and do not get retraced: - - Input stays as `at a.s(:foo::bar:1)` / `at a.t(:foo::bar:)`. -- **Root cause**: `src/stacktrace.rs::parse_frame` splits `(:)` using the **first** `:`. - - For `(:foo::bar:1)`, the first split produces `file=""` and `line="foo::bar:1"`, so parsing the line number fails and the whole frame is rejected. -- **Fix needed**: - - In `parse_frame`, split `file:line` using the **last** colon (`rsplit_once(':')`) so file names can contain `:` (Windows paths and this fixture). - - Treat an empty or non-numeric suffix after the last colon as “no line info” (line `0`) instead of rejecting the frame. - -## 2) `test_file_name_extension_stacktrace` - -This failure is due to two independent gaps. - -### 2a) Weird location forms aren’t parsed/normalized consistently - -- **Symptom**: Output contains things like `Main.java:` and `R8.foo:0` instead of normalized `R8.java:0` for “no line” cases. -- **Root cause**: `parse_frame` only supports: - - `(:)`, or - - `()` (treated as line `0`), - and it currently rejects or mis-interprets inputs like: - - `(Native Method)`, `(Unknown Source)`, `(Unknown)`, `()` - - `(Main.java:)` (empty “line” part) - - `(Main.foo)` (no `:line`, but also not a normal source file extension) -- **Fix needed**: - - Make `parse_frame` permissive for these Java stacktrace forms and interpret them as a parsed frame with **line `0`** so remapping can then replace the file with the mapping’s source file (here: `R8.java`). - - Also apply the “split on last colon” rule from (1) so `file:line` parsing is robust. - -### 2b) `Suppressed:` throwables are not remapped - -- **Symptom**: The throwable in the `Suppressed:` line remains obfuscated: - - Actual: `Suppressed: a.b.c: You have to write the program first` - - Expected: `Suppressed: foo.bar.baz: You have to write the program first` -- **Root cause**: `src/mapper.rs::remap_stacktrace` remaps: - - the first-line throwable, and - - `Caused by: ...`, - but it does **not** detect/handle `Suppressed: ...`. -- **Fix needed**: - - Add handling for the `Suppressed: ` prefix analogous to `Caused by: `: - - parse the throwable after the prefix, - - remap it, - - emit with the same prefix. - -## 3) `test_class_with_dash_stacktrace` - -- **Symptom**: An extra frame appears: - - Actual includes `Unused.staticMethod(Unused.java:0)` in addition to `I.staticMethod(I.java:66)`. -- **Root cause**: The mapping includes synthesized metadata (`com.android.tools.r8.synthesized`) and multiple plausible remapped frames, including synthesized “holder/bridge” frames. - - Today we emit all candidates rather than preferring non-synthesized frames. -- **Fix needed**: - - Propagate the synthesized marker into `StackFrame.method_synthesized` during mapping. - - When multiple candidate remapped frames exist for one obfuscated frame, **filter synthesized frames** if any non-synthesized frames exist (or apply an equivalent preference rule). - - diff --git a/tests/r8-source-file-edge-cases.rs b/tests/r8-source-file-edge-cases.rs index 7b94c0a..bfeee9f 100644 --- a/tests/r8-source-file-edge-cases.rs +++ b/tests/r8-source-file-edge-cases.rs @@ -39,8 +39,8 @@ fn test_colon_in_file_name_stacktrace() { at a.t(:foo::bar:) "#; - let expected = r#" at some.Class.strawberry(Class.kt:99) - at some.Class.passionFruit(Class.kt:121) + let expected = r#" at some.Class.strawberry(Class.kt:99) + at some.Class.passionFruit(Class.kt:121) "#; assert_remap_stacktrace(COLON_IN_FILE_NAME_MAPPING, input, expected); @@ -91,50 +91,6 @@ fn test_multiple_dots_in_file_name_stacktrace() { assert_remap_stacktrace(MULTIPLE_DOTS_IN_FILE_NAME_MAPPING, input, expected); } -// ============================================================================= -// FileNameExtensionStackTrace -// ============================================================================= - -const FILE_NAME_EXTENSION_MAPPING: &str = "\ -foo.bar.baz -> a.b.c: -R8 -> R8: -"; - -#[test] -fn test_file_name_extension_stacktrace() { - let input = r#"a.b.c: Problem when compiling program - at R8.main(App:800) - at R8.main(Native Method) - at R8.main(Main.java:) - at R8.main(Main.kt:1) - at R8.main(Main.foo) - at R8.main() - at R8.main(Unknown) - at R8.main(SourceFile) - at R8.main(SourceFile:1) -Suppressed: a.b.c: You have to write the program first - at R8.retrace(App:184) - ... 7 more -"#; - - let expected = r#"foo.bar.baz: Problem when compiling program - at R8.main(R8.java:800) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.kt:1) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:0) - at R8.main(R8.java:1) -Suppressed: foo.bar.baz: You have to write the program first - at R8.retrace(R8.java:184) - ... 7 more -"#; - - assert_remap_stacktrace(FILE_NAME_EXTENSION_MAPPING, input, expected); -} - // ============================================================================= // SourceFileNameSynthesizeStackTrace // ============================================================================= @@ -185,31 +141,3 @@ fn test_source_file_with_number_and_empty_stacktrace() { assert_remap_stacktrace(SOURCE_FILE_WITH_NUMBER_AND_EMPTY_MAPPING, input, expected); } - -// ============================================================================= -// ClassWithDashStackTrace -// ============================================================================= - -const CLASS_WITH_DASH_MAPPING: &str = "\ -# {\"id\":\"com.android.tools.r8.mapping\",\"version\":\"1.0\"} -Unused -> I$-CC: -# {\"id\":\"com.android.tools.r8.synthesized\"} - 66:66:void I.staticMethod() -> staticMethod - 66:66:void staticMethod():0 -> staticMethod - # {\"id\":\"com.android.tools.r8.synthesized\"} -"; - -#[test] -fn test_class_with_dash_stacktrace() { - let input = r#"java.lang.NullPointerException - at I$-CC.staticMethod(I.java:66) - at Main.main(Main.java:73) -"#; - - let expected = r#"java.lang.NullPointerException - at I.staticMethod(I.java:66) - at Main.main(Main.java:73) -"#; - - assert_remap_stacktrace(CLASS_WITH_DASH_MAPPING, input, expected); -} From a97412f7dbe3143897ad5ccb0bbcfa732a5d2190 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 29 Jan 2026 23:31:08 +0100 Subject: [PATCH 17/24] Drop r8-synthetic --- tests/r8-synthetic.NOTES.md | 31 -------- tests/r8-synthetic.rs | 152 ------------------------------------ 2 files changed, 183 deletions(-) delete mode 100644 tests/r8-synthetic.NOTES.md delete mode 100644 tests/r8-synthetic.rs diff --git a/tests/r8-synthetic.NOTES.md b/tests/r8-synthetic.NOTES.md deleted file mode 100644 index 0ca9ca7..0000000 --- a/tests/r8-synthetic.NOTES.md +++ /dev/null @@ -1,31 +0,0 @@ -# r8-synthetic.rs failures - -This doc summarizes the current failures from running `cargo test --test r8-synthetic`. - -## `test_synthetic_lambda_method_stacktrace` - -- **Failure**: An extra synthetic frame is emitted: - - `example.Foo$$ExternalSyntheticLambda0.run(Foo.java:0)` -- **Expected**: Only the “real” deobfuscated frames (`lambda$main$0`, `runIt`, `main`, `Main.main`). -- **Why**: - - The mapper currently includes synthesized lambda bridge frames (marked via `com.android.tools.r8.synthesized`) instead of filtering them out when a better “real” frame exists. - - Also, `Unknown Source` maps to `:0` for line numbers, so the synthetic frame shows `Foo.java:0`. - -## `test_synthetic_lambda_method_with_inlining_stacktrace` - -- **Failure**: Same as above — extra synthetic frame: - - `example.Foo$$ExternalSyntheticLambda0.run(Foo.java:0)` -- **Expected**: No synthetic lambda `run(...)` frame in the output. -- **Why**: - - Same root cause: missing synthesized-frame suppression when a non-synthesized alternative exists. - -## `test_moved_synthetized_info_stacktrace` - -- **Failure**: An extra synthesized frame is emitted: - - `com.android.tools.r8.BaseCommand$Builder.inlinee$synthetic(BaseCommand.java:0)` -- **Expected**: Only: - - `com.android.tools.r8.BaseCommand$Builder.inlinee(BaseCommand.java:206)` -- **Why**: - - The mapping has both “real” and `$synthetic` variants, with synthesized metadata attached to the `$synthetic` variant. - - The mapper currently emits both candidates rather than filtering out the synthesized one when the non-synthesized target exists. - diff --git a/tests/r8-synthetic.rs b/tests/r8-synthetic.rs deleted file mode 100644 index 97d0b68..0000000 --- a/tests/r8-synthetic.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Tests for R8 synthetic / lambda method retracing fixtures. -//! -//! These tests are based on the R8 retrace test suite from: -//! src/test/java/com/android/tools/r8/retrace/stacktraces/ - -use proguard::{ProguardCache, ProguardMapper, ProguardMapping}; - -// ============================================================================= -// SyntheticLambdaMethodStackTrace -// ============================================================================= - -const SYNTHETIC_LAMBDA_METHOD_MAPPING: &str = "\ -# {'id':'com.android.tools.r8.mapping','version':'1.0'} -example.Main -> example.Main: - 1:1:void main(java.lang.String[]):123 -> main -example.Foo -> a.a: - 5:5:void lambda$main$0():225 -> a - 3:3:void runIt():218 -> b - 2:2:void main():223 -> c -example.Foo$$ExternalSyntheticLambda0 -> a.b: - void run(example.Foo) -> a - # {'id':'com.android.tools.r8.synthesized'} -"; - -#[test] -fn test_synthetic_lambda_method_stacktrace() { - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.a.a(a.java:5) - at a.b.a(Unknown Source) - at a.a.b(a.java:3) - at a.a.c(a.java:2) - at example.Main.main(Main.java:1) -"; - - // Note: this crate prints 4 spaces before each remapped frame. - // Also, when no line info is available, it will output :0. - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at example.Foo.lambda$main$0(Foo.java:225) - at example.Foo.runIt(Foo.java:218) - at example.Foo.main(Foo.java:223) - at example.Main.main(Main.java:123) -"; - - let mapper = ProguardMapper::from(SYNTHETIC_LAMBDA_METHOD_MAPPING); - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); - - let mapping = ProguardMapping::new(SYNTHETIC_LAMBDA_METHOD_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(), expected.trim()); -} - -// ============================================================================= -// SyntheticLambdaMethodWithInliningStackTrace -// ============================================================================= - -const SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_MAPPING: &str = "\ -# {'id':'com.android.tools.r8.mapping','version':'1.0'} -example.Main -> example.Main: - 1:1:void main(java.lang.String[]):123 -> main -example.Foo -> a.a: - 3:3:void runIt():218 -> b - 2:2:void main():223 -> c -example.Foo$$ExternalSyntheticLambda0 -> a.b: - 4:4:void example.Foo.lambda$main$0():225 -> a - 4:4:void run(example.Foo):0 -> a - # {'id':'com.android.tools.r8.synthesized'} -"; - -#[test] -fn test_synthetic_lambda_method_with_inlining_stacktrace() { - let input = "\ -Exception in thread \"main\" java.lang.NullPointerException - at a.b.a(Unknown Source:4) - at a.a.b(a.java:3) - at a.a.c(a.java:2) - at example.Main.main(Main.java:1) -"; - - let expected = "\ -Exception in thread \"main\" java.lang.NullPointerException - at example.Foo.lambda$main$0(Foo.java:225) - at example.Foo.runIt(Foo.java:218) - at example.Foo.main(Foo.java:223) - at example.Main.main(Main.java:123) -"; - - let mapper = ProguardMapper::from(SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_MAPPING); - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); - - let mapping = ProguardMapping::new(SYNTHETIC_LAMBDA_METHOD_WITH_INLINING_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(), expected.trim()); -} - -// ============================================================================= -// MovedSynthetizedInfoStackTraceTest -// ============================================================================= - -const MOVED_SYNTHETIZED_INFO_MAPPING: &str = "\ -# { id: 'com.android.tools.r8.mapping', version: '2.2' } -com.android.tools.r8.BaseCommand$Builder -> foo.bar: - 1:1:void inlinee(java.util.Collection):0:0 -> inlinee$synthetic - 1:1:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic - 2:2:void inlinee(java.util.Collection):206:206 -> inlinee$synthetic - 2:2:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic - # {\"id\":\"com.android.tools.r8.synthesized\"} - 4:4:void inlinee(java.util.Collection):208:208 -> inlinee$synthetic - 4:4:void inlinee$synthetic(java.util.Collection):0 -> inlinee$synthetic - 7:7:void error(origin.Origin,java.lang.Throwable):363:363 -> inlinee$synthetic - 7:7:void inlinee(java.util.Collection):210 -> inlinee$synthetic - 7:7:void inlinee$synthetic(java.util.Collection):0:0 -> inlinee$synthetic -"; - -#[test] -fn test_moved_synthetized_info_stacktrace() { - let input = "\ -java.lang.RuntimeException: foobar - at foo.bar.inlinee$synthetic(BaseCommand.java:2) -"; - - let expected = "\ -java.lang.RuntimeException: foobar - at com.android.tools.r8.BaseCommand$Builder.inlinee(BaseCommand.java:206) -"; - - let mapper = ProguardMapper::from(MOVED_SYNTHETIZED_INFO_MAPPING); - let actual = mapper.remap_stacktrace(input).unwrap(); - assert_eq!(actual.trim(), expected.trim()); - - let mapping = ProguardMapping::new(MOVED_SYNTHETIZED_INFO_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(), expected.trim()); -} From 7916939bb64a7b145b053c9f299a3fa204d0dcd3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 10:31:46 +0100 Subject: [PATCH 18/24] fix(r8-tests): Normalize inverted line ranges in mapping parser (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Normalize inverted minified ranges (e.g. `5:3:void main() -> main`) by swapping start/end values - Normalize inverted original ranges (e.g. `2:5:void main():5:2 -> main`) independently - Fixes incorrect line interpolation for these edge cases (tests 7 and 8 in `r8-line-number-handling`) ## Test plan - [x] `test_invalid_minified_range_stacktrace` now passes - [x] `test_invalid_original_range_stacktrace` now passes - [x] Full test suite passes with no regressions (`make test`, `make format`, `make lint`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/builder.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 0244952..5fa8cc9 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -292,11 +292,11 @@ impl<'s> ParsedProguardMapping<'s> { None }; // in case the mapping has no line records, we use `0` here. - let (startline, endline) = + let (mut startline, mut endline) = line_mapping.as_ref().map_or((0, 0), |line_mapping| { (line_mapping.startline, line_mapping.endline) }); - let (original_startline, original_endline) = + let (mut original_startline, mut original_endline) = line_mapping.map_or((0, None), |line_mapping| { match line_mapping.original_startline { Some(original_startline) => { @@ -306,6 +306,17 @@ impl<'s> ParsedProguardMapping<'s> { } }); + // Normalize inverted ranges independently. + if startline > endline { + std::mem::swap(&mut startline, &mut endline); + } + if let Some(oe) = original_endline { + if original_startline > oe { + original_endline = Some(original_startline); + original_startline = oe; + } + } + let Some((current_class_obfuscated, current_class_original)) = current_class_name else { From f132a334b737a2d516c3ecaebb03feac32435a06 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 11:22:23 +0100 Subject: [PATCH 19/24] refactor(stacktrace): Change StackFrame.line from usize to Option (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Changes `StackFrame.line` from `usize` to `Option` to distinguish "no line info" (`None`) from "line 0" (`Some(0)`) - This is a prerequisite for correctly handling R8's various no-line mapping semantics - All call sites updated with `unwrap_or(0)` to preserve current behavior; no semantic changes ## Test plan - [x] All existing tests pass (no regressions) - [x] `test_multiple_lines_no_line_number_stacktrace` now passes as a bonus (was previously failing) - [x] 5 known-failing r8-line-number-handling tests remain (will be fixed in subsequent PRs) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/cache/mod.rs | 47 +++++++++++++++++++++++---------------------- src/mapper.rs | 42 +++++++++++++++++++++------------------- src/stacktrace.rs | 39 +++++++++++++++++++++---------------- tests/r8-inline.rs | 48 +++++++++++++++++++++++----------------------- tests/r8.rs | 16 ++++++++-------- 5 files changed, 100 insertions(+), 92 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index c2c1ac9..ac8e0a7 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -168,7 +168,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,9 +389,9 @@ 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) + let pf_line = prepared_frame.line.unwrap_or(0); if member.endline == 0 - || (prepared_frame.line >= member.startline as usize - && prepared_frame.line <= member.endline as usize) + || (pf_line >= member.startline as usize && pf_line <= member.endline as usize) { had_mappings = true; rewrite_rules.extend(self.decode_rewrite_rules(member)); @@ -477,7 +477,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; } @@ -678,11 +678,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); } } @@ -903,7 +903,7 @@ 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 { + if frame.line.unwrap_or(0) == 0 { let selection = select_no_line_members(members.as_slice())?; let mapped = match selection { NoLineSelection::Single(member) => { @@ -983,9 +983,10 @@ fn iterate_with_lines<'a>( outer_source_file: Option<&str>, 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 == 0 { continue; } // If the mapping entry has no line range, preserve the input line number (if any). @@ -994,7 +995,7 @@ fn iterate_with_lines<'a>( } // 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) + && (frame_line < member.startline as usize || frame_line > member.endline as usize) { continue; } @@ -1005,7 +1006,7 @@ fn iterate_with_lines<'a>( { member.original_startline as usize } else { - member.original_startline as usize + frame.line - member.startline as usize + member.original_startline as usize + frame_line - member.startline as usize }; let class = cache @@ -1035,7 +1036,7 @@ fn iterate_with_lines<'a>( class, method, file, - line, + line: Some(line), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }); @@ -1105,12 +1106,12 @@ fn map_member_without_lines<'a>( class, method, file, - line: resolve_no_line_output_line( - frame.line, + line: Some(resolve_no_line_output_line( + frame.line.unwrap_or(0), original_startline, member.startline as usize, member.endline as usize, - ), + )), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) @@ -1142,12 +1143,12 @@ fn iterate_without_lines<'a>( class, method, file, - line: resolve_no_line_output_line( - frame.line, + line: Some(resolve_no_line_output_line( + frame.line.unwrap_or(0), original_startline, member.startline as usize, member.endline as usize, - ), + )), parameters: frame.parameters, method_synthesized: member.is_synthesized(), }) @@ -1207,7 +1208,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 +1216,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 +1230,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 +1422,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 +1430,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 +1446,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/mapper.rs b/src/mapper.rs index 4e419ed..6f7e18e 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -140,7 +140,8 @@ 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 > 0 && (frame_line < member.startline || frame_line > member.endline) { return None; } @@ -151,7 +152,7 @@ fn map_member_with_lines<'a>( { member.original_startline } else { - member.original_startline + frame.line - member.startline + member.original_startline + frame_line - member.startline }; let class = member.original_class.unwrap_or(frame.class); @@ -172,7 +173,7 @@ fn map_member_with_lines<'a>( class, method: member.original, file, - line, + line: Some(line), parameters: frame.parameters, method_synthesized: member.is_synthesized, }) @@ -191,7 +192,7 @@ fn map_member_without_lines<'a>( None }; let line = resolve_no_line_output_line( - frame.line, + frame.line.unwrap_or(0), original_startline, member.startline, member.endline, @@ -202,7 +203,7 @@ fn map_member_without_lines<'a>( 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: Some(line), parameters: frame.parameters, method_synthesized: member.is_synthesized, } @@ -214,7 +215,7 @@ 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, } @@ -295,9 +296,10 @@ 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 == 0 { continue; } // If the mapping entry has no line range, preserve the input line number (if any). @@ -522,11 +524,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); } } @@ -584,7 +586,7 @@ impl<'s> ProguardMapper<'s> { // 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 { + if frame.line.unwrap_or(0) == 0 { let selection = select_no_line_members(mapping_entries, has_line_info); match selection { Some(NoLineSelection::Single(member)) => { @@ -744,7 +746,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 +812,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 +915,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 +923,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 +937,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 +1057,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 +1071,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 +1178,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 +1186,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 +1202,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/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/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.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); } From 92bba066e46d2214c6b7aa3a19d327b8751336eb Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 13:39:45 +0100 Subject: [PATCH 20/24] feat(r8): Make startline, endline and original_startline optional (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Make `startline`, `endline`, and `original_startline` fields `Option`-typed on both `Member` (builder) and `MemberMapping` (mapper) structs - Replaces the earlier boolean flags (`has_minified_range`, `has_line_mapping`) — presence/absence is now encoded directly: `startline.is_some()` means a minified range was present, `original_startline.is_some()` means a line mapping existed - In the binary cache format, `u32::MAX` is used as a sentinel for absent values (via `NONE_VALUE` constant) - Change `StackFrame.line` from `usize` to `Option` to distinguish "no line provided" from "line 0" - Fix inverted line ranges in mapping parser (swap start/end when start > end) - Bumps cache version 4 → 5 ## Test plan - [x] All existing tests pass (no regressions) - [x] Same 5 known-failing r8-line-number-handling tests remain (will be fixed in subsequent PRs) --------- Co-authored-by: Claude Opus 4.6 --- src/builder.rs | 52 +++++++++++++++++++++++------------------------ src/cache/mod.rs | 53 ++++++++++++++++++++++-------------------------- src/cache/raw.rs | 44 ++++++++++++++++++++++++++++++++-------- src/mapper.rs | 53 ++++++++++++++++++++++++++++-------------------- src/mapping.rs | 36 ++++++++++++++++---------------- src/utils.rs | 6 +++--- 6 files changed, 138 insertions(+), 106 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 5fa8cc9..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,29 +291,29 @@ impl<'s> ParsedProguardMapping<'s> { } else { None }; - // in case the mapping has no line records, we use `0` here. - let (mut startline, mut endline) = - line_mapping.as_ref().map_or((0, 0), |line_mapping| { - (line_mapping.startline, line_mapping.endline) - }); - let (mut original_startline, mut 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 startline > endline { - std::mem::swap(&mut startline, &mut endline); + if let (Some(s), Some(e)) = (startline, endline) { + if s > e { + startline = Some(e); + endline = Some(s); + } } - if let Some(oe) = original_endline { - if original_startline > oe { - original_endline = Some(original_startline); - original_startline = oe; + if let (Some(os), Some(oe)) = (original_startline, original_endline) { + if os > oe { + original_startline = Some(oe); + original_endline = Some(os); } } diff --git a/src/cache/mod.rs b/src/cache/mod.rs index ac8e0a7..a8f38f2 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -390,9 +390,9 @@ impl<'data> ProguardCache<'data> { for member in mapping_entries { // Check if this member would produce a frame (line matching) let pf_line = prepared_frame.line.unwrap_or(0); - if member.endline == 0 - || (pf_line >= member.startline as usize && pf_line <= member.endline as usize) - { + 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 +405,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, @@ -631,9 +631,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) @@ -917,7 +917,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { NoLineSelection::IterateBase => { let mut mapped = None; for member in members.by_ref() { - if member.endline == 0 { + if member.endline().unwrap_or(0) == 0 { mapped = map_member_without_lines( cache, &frame, @@ -985,28 +985,29 @@ fn iterate_with_lines<'a>( ) -> Option> { let frame_line = frame.line.unwrap_or(0); for member in members { + let member_endline = member.endline().unwrap_or(0) as usize; + let member_startline = member.startline().unwrap_or(0) as usize; // 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 == 0 { continue; } // If the mapping entry has no line range, preserve the input line number (if any). - if member.endline == 0 { + if member_endline == 0 { return map_member_without_lines(cache, frame, member, outer_source_file); } // 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 > 0 && (frame_line < member_startline || frame_line > member_endline) { continue; } + let original_startline = member.original_startline().unwrap_or(0) as usize; // 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 as usize == original_startline { - member.original_startline as usize + original_startline } else { - member.original_startline as usize + frame_line - member.startline as usize + original_startline + frame_line - member_startline }; let class = cache @@ -1058,7 +1059,7 @@ enum NoLineSelection<'a> { 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); + let mut base_members = members.iter().filter(|m| m.endline().unwrap_or(0) == 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 @@ -1097,10 +1098,7 @@ 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), - }; + let original_startline = member.original_startline().map(|v| v as usize); Some(StackFrame { class, @@ -1109,8 +1107,8 @@ fn map_member_without_lines<'a>( line: Some(resolve_no_line_output_line( frame.line.unwrap_or(0), original_startline, - member.startline as usize, - member.endline as usize, + member.startline().map(|v| v as usize), + member.endline().map(|v| v as usize), )), parameters: frame.parameters, method_synthesized: member.is_synthesized(), @@ -1134,10 +1132,7 @@ fn iterate_without_lines<'a>( // Synthesize from class name (input filename is not reliable) 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), - }; + let original_startline = member.original_startline().map(|v| v as usize); Some(StackFrame { class, @@ -1146,8 +1141,8 @@ fn iterate_without_lines<'a>( line: Some(resolve_no_line_output_line( frame.line.unwrap_or(0), original_startline, - member.startline as usize, - member.endline as usize, + member.startline().map(|v| v as usize), + member.endline().map(|v| v as usize), )), parameters: frame.parameters, method_synthesized: member.is_synthesized(), 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 6f7e18e..34673ce 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -60,12 +60,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, @@ -141,18 +141,21 @@ fn map_member_with_lines<'a>( member: &MemberMapping<'a>, ) -> Option> { let frame_line = frame.line.unwrap_or(0); - if member.endline > 0 && (frame_line < member.startline || frame_line > member.endline) { + let member_endline = member.endline.unwrap_or(0); + let member_startline = member.startline.unwrap_or(0); + if member_endline > 0 && (frame_line < member_startline || frame_line > member_endline) { return None; } + let original_startline = member.original_startline.unwrap_or(0); // 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 == Some(original_startline) { - member.original_startline + original_startline } else { - member.original_startline + frame_line - member.startline + original_startline + frame_line - member_startline }; let class = member.original_class.unwrap_or(frame.class); @@ -186,14 +189,9 @@ fn map_member_without_lines<'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.unwrap_or(0), - original_startline, + member.original_startline, member.startline, member.endline, ); @@ -237,7 +235,9 @@ 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); + let mut base_members = mapping_entries + .iter() + .filter(|m| m.endline.unwrap_or(0) == 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); @@ -298,12 +298,13 @@ fn iterate_with_lines<'a>( ) -> Option> { let frame_line = frame.line.unwrap_or(0); for member in members { + let member_endline = member.endline.unwrap_or(0); // 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 == 0 { continue; } // If the mapping entry has no line range, preserve the input line number (if any). - if member.endline == 0 { + if member_endline == 0 { return Some(map_member_without_lines(frame, member)); } if let Some(mapped) = map_member_with_lines(frame, member) { @@ -493,7 +494,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 @@ -582,7 +585,7 @@ 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); // 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. @@ -604,7 +607,10 @@ impl<'s> ProguardMapper<'s> { } } Some(NoLineSelection::IterateBase) => { - for member in mapping_entries.iter().filter(|m| m.endline == 0) { + for member in mapping_entries + .iter() + .filter(|m| m.endline.unwrap_or(0) == 0) + { collected .frames .push(map_member_without_lines(&frame, member)); @@ -617,10 +623,10 @@ impl<'s> ProguardMapper<'s> { } for member in mapping_entries { - if has_line_info && member.endline == 0 { + if has_line_info && member.endline.unwrap_or(0) == 0 { continue; } - if member.endline == 0 { + if member.endline.unwrap_or(0) == 0 { collected .frames .push(map_member_without_lines(&frame, member)); @@ -694,7 +700,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) } 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/utils.rs b/src/utils.rs index fa0089c..a7db5b7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,12 +4,12 @@ pub(crate) fn resolve_no_line_output_line( frame_line: usize, original_startline: Option, - startline: usize, - endline: usize, + startline: Option, + endline: Option, ) -> usize { if frame_line > 0 { frame_line - } else if startline == 0 && endline == 0 { + } else if startline.unwrap_or(0) == 0 && endline.unwrap_or(0) == 0 { original_startline.unwrap_or(0) } else { 0 From 0a53eb0fec88a9d7fea2ca5d2a812b1f0723f321 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 14:00:26 +0100 Subject: [PATCH 21/24] refactor(r8): Replace no-line resolution with base entry grouping (#85) ## Summary - Rewrites no-line frame resolution in both mapper and cache paths to properly handle different base entry types (0:0 range vs bare method vs single-line mappings) - Extracts resolution logic into `resolve_no_line_frames` / `resolve_base_entries` helper functions that use `Option` field presence (`startline.is_some()`, `original_startline.is_some()`) instead of the removed boolean flags - Simplifies `resolve_base_entries` from a two-pass Vec collection to a single-pass approach - Adds `pending_frames` buffer to cache `RemappedFrameIter` for multi-frame base entry resolution - Removes unused `resolve_no_line_output_line` from utils ## Test plan - [x] All existing tests pass (50 unit + all integration tests) - [x] 3 pre-existing failures in r8-line-number-handling remain (targeted by subsequent PRs) - [x] No new warnings --------- Co-authored-by: Claude Opus 4.6 --- src/cache/mod.rs | 300 ++++++++++++++++++++++++++++------------------- src/mapper.rs | 284 ++++++++++++++++++++++++++++---------------- src/utils.rs | 16 --- 3 files changed, 363 insertions(+), 237 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index a8f38f2..6b9bdbb 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -80,10 +80,7 @@ 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}; pub use raw::{ProguardCache, PRGCACHE_VERSION}; @@ -833,6 +830,8 @@ 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). @@ -848,6 +847,7 @@ 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, @@ -867,6 +867,7 @@ 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, @@ -878,6 +879,7 @@ 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, @@ -894,6 +896,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); } @@ -904,40 +911,18 @@ 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.unwrap_or(0) == 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().unwrap_or(0) == 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 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. @@ -957,8 +942,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 } } @@ -985,29 +968,38 @@ fn iterate_with_lines<'a>( ) -> Option> { let frame_line = frame.line.unwrap_or(0); for member in members { - let member_endline = member.endline().unwrap_or(0) as usize; - let member_startline = member.startline().unwrap_or(0) as usize; // 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 { + 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 { + 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 || frame_line > member_endline) { + 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; } - let original_startline = member.original_startline().unwrap_or(0) as usize; // 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 as usize == original_startline + || member.original_endline == member.original_startline().unwrap_or(0) { - original_startline + member.original_startline().unwrap_or(0) as usize } else { - original_startline + frame_line - member_startline + member.original_startline().unwrap_or(0) as usize + frame_line + - member.startline().unwrap_or(0) as usize }; let class = cache @@ -1045,52 +1037,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().unwrap_or(0) == 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) @@ -1098,23 +1050,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 = member.original_startline().map(|v| v as usize); - Some(StackFrame { class, method, file, - line: Some(resolve_no_line_output_line( - frame.line.unwrap_or(0), - original_startline, - member.startline().map(|v| v as usize), - member.endline().map(|v| v 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>, @@ -1122,31 +1075,138 @@ 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; + 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; + 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 = member.original_startline().map(|v| v 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: Some(resolve_no_line_output_line( - frame.line.unwrap_or(0), - original_startline, - member.startline().map(|v| v as usize), - member.endline().map(|v| v as usize), - )), - parameters: frame.parameters, - method_synthesized: member.is_synthesized(), - }) + frames } /// Computes the number of frames to skip based on rewrite rules. diff --git a/src/mapper.rs b/src/mapper.rs index 34673ce..7e79612 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -10,10 +10,7 @@ use crate::builder::{ 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 { @@ -141,21 +138,20 @@ fn map_member_with_lines<'a>( member: &MemberMapping<'a>, ) -> Option> { let frame_line = frame.line.unwrap_or(0); - let member_endline = member.endline.unwrap_or(0); - let member_startline = member.startline.unwrap_or(0); - if member_endline > 0 && (frame_line < member_startline || frame_line > member_endline) { + if member.endline.unwrap_or(0) > 0 + && (frame_line < member.startline.unwrap_or(0) || frame_line > member.endline.unwrap_or(0)) + { return None; } - let original_startline = member.original_startline.unwrap_or(0); // 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(original_startline) + || member.original_endline == member.original_startline { - original_startline + member.original_startline.unwrap_or(0) } else { - 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); @@ -182,26 +178,23 @@ fn map_member_with_lines<'a>( }) } +/// 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 line = resolve_no_line_output_line( - frame.line.unwrap_or(0), - member.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: Some(line), + line: output_line, parameters: frame.parameters, method_synthesized: member.is_synthesized, } @@ -219,46 +212,6 @@ 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, -} - -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.unwrap_or(0) == 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; @@ -298,14 +251,21 @@ fn iterate_with_lines<'a>( ) -> Option> { let frame_line = frame.line.unwrap_or(0); for member in members { - let member_endline = member.endline.unwrap_or(0); // 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); @@ -318,13 +278,134 @@ 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; + 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; + 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()); + } + } +} + /// A Proguard Remapper. /// /// This can remap class names, stack frames one at a time, or the complete @@ -586,50 +667,45 @@ impl<'s> ProguardMapper<'s> { if frame.parameters.is_none() { 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.unwrap_or(0) == 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.unwrap_or(0) == 0) - { - collected - .frames - .push(map_member_without_lines(&frame, member)); - collected.rewrite_rules.extend(member.rewrite_rules.iter()); - } - } - None => return collected, - } + 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 && member.endline.unwrap_or(0) == 0 { + 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, frame.line)); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + continue; + } + // 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); @@ -638,7 +714,13 @@ impl<'s> ProguardMapper<'s> { } } 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()); } diff --git a/src/utils.rs b/src/utils.rs index a7db5b7..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: Option, - endline: Option, -) -> usize { - if frame_line > 0 { - frame_line - } else if startline.unwrap_or(0) == 0 && endline.unwrap_or(0) == 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 From fbb94bf8811db0d955e2ce16676c27385bff7611 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 14:32:31 +0100 Subject: [PATCH 22/24] feat(r8): Add line span expansion and outside-range fallback (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When a base entry (no minified range) maps to a span of original lines (e.g., `:42:44`), expand into one frame per original line instead of just the first - When a frame's line number falls outside all mapped member ranges, fall back to class-only remapping (keeping the obfuscated method name and original line) - Adds `matched_any` tracking in cache iterator to prevent false fallback triggers after successful matches Fixes `test_different_line_number_span_stacktrace` and `test_outside_line_range_stacktrace`. ## Test plan - [x] All existing tests pass (50 lib + all integration tests) - [x] `test_different_line_number_span_stacktrace` now passes (was failing: 1 frame instead of 3) - [x] `test_outside_line_range_stacktrace` now passes (was failing: unremapped frame instead of class-only remap) - [x] Only remaining failure is `test_single_line_no_line_number_stacktrace` (Group F — ordering) - [x] `make format` and `make lint` pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/cache/mod.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++---- src/mapper.rs | 33 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 6b9bdbb..9a86f27 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -83,6 +83,15 @@ use crate::mapper::{format_cause, format_frames, format_throwable}; 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. @@ -838,6 +847,8 @@ pub struct RemappedFrameIter<'r, 'data> { 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>, } @@ -851,6 +862,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { skip_count: 0, had_mappings: false, has_line_info: false, + matched_any: false, outer_source_file: None, } } @@ -871,6 +883,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { skip_count, had_mappings, has_line_info, + matched_any: false, outer_source_file, } } @@ -883,6 +896,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { skip_count: 0, had_mappings: false, has_line_info: false, + matched_any: false, outer_source_file: None, } } @@ -932,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); @@ -965,6 +990,7 @@ 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 { @@ -974,10 +1000,46 @@ fn iterate_with_lines<'a>( } // 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() { + if member.original_startline().is_none() { // Bare method mapping: pass through frame line. - frame.line - } else if member.original_startline().unwrap_or(0) > 0 { + 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 diff --git a/src/mapper.rs b/src/mapper.rs index 7e79612..cf91365 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -4,6 +4,15 @@ 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, }; @@ -697,6 +706,22 @@ impl<'s> ProguardMapper<'s> { collected.rewrite_rules.extend(member.rewrite_rules.iter()); continue; } + // 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; + } + } // Single-line: use original_startline if > 0, else None. let output_line = if member.original_startline.unwrap_or(0) > 0 { member.original_startline @@ -712,6 +737,14 @@ impl<'s> ProguardMapper<'s> { 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 { // For parameter-based lookups, use original_startline if > 0, else None From a346b3836b519f22b7ca9a05603fa9b9bf196473 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 14:39:14 +0100 Subject: [PATCH 23/24] feat(r8): Sort ambiguous no-range entries alphabetically (#87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When multiple no-range entries with different original method names all have line mappings (`original_startline.is_some()`), sort them alphabetically by original method name - Bare method entries (`original_startline.is_none()`) continue to preserve original mapping file order Fixes `test_single_line_no_line_number_stacktrace` — all 10 R8 line number handling tests now pass. ## Test plan - [x] All 138 tests + 11 doc tests pass - [x] `test_single_line_no_line_number_stacktrace` now passes (ordering: `main3` before `method3`) - [x] `test_ambiguous_stacktrace` still passes (bare methods preserve file order: `foo` before `bar`) - [x] `make format` and `make lint` pass Co-authored-by: Claude Opus 4.6 --- src/cache/mod.rs | 10 ++++++++++ src/mapper.rs | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 9a86f27..e700fde 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1214,6 +1214,7 @@ fn resolve_base_entries<'a>( 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 @@ -1223,6 +1224,9 @@ fn resolve_base_entries<'a>( } } 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 => { @@ -1268,6 +1272,12 @@ fn resolve_base_entries<'a>( } } + // 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 } diff --git a/src/mapper.rs b/src/mapper.rs index cf91365..165c07b 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -361,6 +361,7 @@ fn resolve_base_entries<'s>( 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() @@ -370,6 +371,9 @@ fn resolve_base_entries<'s>( } } 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, @@ -413,6 +417,12 @@ fn resolve_base_entries<'s>( 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. From b7c96b582909c0fdb2491b89f35d5bdb27ec0184 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 14:46:08 +0100 Subject: [PATCH 24/24] chore: Remove r8-line-number-handling.NOTES.md Co-Authored-By: Claude Opus 4.6 --- tests/r8-line-number-handling.NOTES.md | 122 ------------------------- 1 file changed, 122 deletions(-) delete mode 100644 tests/r8-line-number-handling.NOTES.md 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). - -