From b4acde2b3371de4206f4d19a9b54cba52697fb63 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 9 Feb 2026 11:44:59 +0100 Subject: [PATCH 1/2] feat(r8): Add line span expansion and outside-range fallback When a base entry maps to a span of original lines (e.g., `:42:44`), expand into one frame per original line. When a frame's line falls outside all mapped ranges, fall back to class-only remapping. Fixes test_different_line_number_span_stacktrace and test_outside_line_range_stacktrace. Co-Authored-By: Claude Opus 4.6 --- src/cache/mod.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++---- src/mapper.rs | 24 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 6b9bdbb..a5f6f0c 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -838,6 +838,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 +853,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 +874,7 @@ impl<'r, 'data> RemappedFrameIter<'r, 'data> { skip_count, had_mappings, has_line_info, + matched_any: false, outer_source_file, } } @@ -883,6 +887,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 +937,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 +981,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 +991,44 @@ 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) + { + 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..cbef7ea 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -697,6 +697,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 { + 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 +728,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 ea0879bc9fcf46ae2f594da50191291d05cf8675 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Feb 2026 14:15:56 +0100 Subject: [PATCH 2/2] fix: Cap span expansion at u16::MAX (65535) line range Prevents excessive iteration from malformed mapping files where the original line range could be extremely large (up to u32::MAX). The JVM bytecode maximum line number is 65535, so any span beyond that is invalid and falls through to single-line handling. Co-Authored-By: Claude Opus 4.6 --- src/cache/mod.rs | 11 +++++++++++ src/mapper.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cache/mod.rs b/src/cache/mod.rs index a5f6f0c..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. @@ -1005,6 +1014,8 @@ fn iterate_with_lines<'a>( // 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; diff --git a/src/mapper.rs b/src/mapper.rs index cbef7ea..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, }; @@ -701,7 +710,7 @@ impl<'s> ProguardMapper<'s> { // emit one frame per original line. if let Some(oe) = member.original_endline { let os = member.original_startline.unwrap_or(0); - if oe > os { + if oe > os && (oe - os) <= MAX_SPAN_EXPANSION { for line in os..=oe { collected.frames.push(map_member_without_lines( &frame,