diff --git a/Cargo.lock b/Cargo.lock index 4980d0e..aabbf08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ dependencies = [ [[package]] name = "proguard" -version = "5.8.0" +version = "5.8.1" dependencies = [ "criterion", "serde", diff --git a/src/builder.rs b/src/builder.rs index b906f5b..0244952 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -121,6 +121,29 @@ pub(crate) struct MethodInfo { pub(crate) is_outline: bool, } +/// Supported rewrite frame actions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RewriteAction<'s> { + RemoveInnerFrames(usize), + /// Placeholder to retain unsupported action strings for future handling. + Unknown(&'s str), +} + +/// Supported rewrite frame conditions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum RewriteCondition<'s> { + Throws(&'s str), + /// Placeholder to retain unsupported condition strings for future handling. + Unknown(&'s str), +} + +/// A rewrite frame rule attached to a method mapping. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RewriteRule<'s> { + pub(crate) conditions: Vec>, + pub(crate) actions: Vec>, +} + /// A member record in a Proguard file. #[derive(Clone, Debug)] pub(crate) struct Member<'s> { @@ -136,6 +159,51 @@ pub(crate) struct Member<'s> { pub(crate) original_endline: Option, /// Optional outline callsite positions map attached to this member. pub(crate) outline_callsite_positions: Option>, + /// Optional rewrite rules attached to this member. + pub(crate) rewrite_rules: Vec>, +} + +fn parse_rewrite_rule<'s>(conditions: &[&'s str], actions: &[&'s str]) -> Option> { + if conditions.is_empty() || actions.is_empty() { + return None; + } + + let mut parsed_conditions = Vec::with_capacity(conditions.len()); + for condition in conditions { + let condition = condition.trim(); + if condition.is_empty() { + return None; + } + if let Some(rest) = condition.strip_prefix("throws(") { + let descriptor = rest.strip_suffix(')')?; + if descriptor.is_empty() { + return None; + } + parsed_conditions.push(RewriteCondition::Throws(descriptor)); + } else { + parsed_conditions.push(RewriteCondition::Unknown(condition)); + } + } + + let mut parsed_actions = Vec::with_capacity(actions.len()); + for action in actions { + let action = action.trim(); + if action.is_empty() { + return None; + } + if let Some(rest) = action.strip_prefix("removeInnerFrames(") { + let count_str = rest.strip_suffix(')')?; + let count = count_str.parse().ok()?; + parsed_actions.push(RewriteAction::RemoveInnerFrames(count)); + } else { + parsed_actions.push(RewriteAction::Unknown(action)); + } + } + + Some(RewriteRule { + conditions: parsed_conditions, + actions: parsed_actions, + }) } /// A collection of member records for a particular class @@ -196,6 +264,7 @@ impl<'s> ParsedProguardMapping<'s> { // Consume R8 headers attached to this class. while let Some(ProguardRecord::R8Header(r8_header)) = records.peek() { match r8_header { + R8Header::RewriteFrame { .. } => {} R8Header::SourceFile { file_name } => { current_class.source_file = Some(file_name) } @@ -251,6 +320,7 @@ impl<'s> ParsedProguardMapping<'s> { .entry((current_class_obfuscated, ObfuscatedName(obfuscated))) .or_default(); + let mut rewrite_rules: Vec> = Vec::new(); let method = MethodKey { // Save the receiver name, keeping track of whether it's the current class // (i.e. the one to which this member record belongs) or another class. @@ -276,6 +346,14 @@ impl<'s> ParsedProguardMapping<'s> { R8Header::Outline => { method_info.is_outline = true; } + R8Header::RewriteFrame { + conditions, + actions, + } => { + if let Some(rule) = parse_rewrite_rule(conditions, actions) { + rewrite_rules.push(rule); + } + } R8Header::OutlineCallsite { positions, outline: _, @@ -302,6 +380,7 @@ impl<'s> ParsedProguardMapping<'s> { original_startline, original_endline, outline_callsite_positions, + rewrite_rules, }; members.all.push(member.clone()); diff --git a/src/mapper.rs b/src/mapper.rs index 5631347..3a3cc66 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -3,7 +3,9 @@ use std::fmt; use std::fmt::{Error as FmtError, Write}; use std::iter::FusedIterator; -use crate::builder::{Member, MethodReceiver, ParsedProguardMapping}; +use crate::builder::{ + Member, MethodReceiver, ParsedProguardMapping, RewriteAction, RewriteCondition, RewriteRule, +}; use crate::java; use crate::mapping::ProguardMapping; use crate::stacktrace::{self, StackFrame, StackTrace, Throwable}; @@ -63,6 +65,7 @@ struct MemberMapping<'s> { is_synthesized: bool, is_outline: bool, outline_callsite_positions: Option>, + rewrite_rules: Vec>, } #[derive(Clone, Debug, Default)] @@ -83,6 +86,12 @@ struct ClassMapping<'s> { is_synthesized: bool, } +#[derive(Default)] +struct CollectedFrames<'s> { + frames: Vec>, + rewrite_rules: Vec<&'s RewriteRule<'s>>, +} + type MemberIter<'m> = std::slice::Iter<'m, MemberMapping<'m>>; /// An Iterator over remapped StackFrames. @@ -120,53 +129,113 @@ fn extract_class_name(full_path: &str) -> Option<&str> { after_last_period.split('$').next() } -fn iterate_with_lines<'a>( - frame: &mut StackFrame<'a>, - members: &mut core::slice::Iter<'_, MemberMapping<'a>>, +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 +} + +fn map_member_with_lines<'a>( + frame: &StackFrame<'a>, + member: &MemberMapping<'a>, ) -> Option> { - for member in members { - // skip any members which do not match our frames line - if member.endline > 0 && (frame.line < member.startline || frame.line > member.endline) { - continue; - } + if member.endline > 0 && (frame.line < member.startline || frame.line > member.endline) { + return None; + } - // parents of inlined frames don’t have an `endline`, and - // the top inlined frame need to be correctly offset. - let line = if member.original_endline.is_none() - || member.original_endline == Some(member.original_startline) - { - member.original_startline - } else { - member.original_startline + frame.line - member.startline - }; + // parents of inlined frames don’t have an `endline`, and + // the top inlined frame need to be correctly offset. + let line = if member.original_endline.is_none() + || member.original_endline == Some(member.original_startline) + { + member.original_startline + } else { + member.original_startline + frame.line - member.startline + }; - let file = if let Some(file_name) = member.original_file { - if file_name == "R8$$SyntheticClass" { - extract_class_name(member.original_class.unwrap_or(frame.class)) - } else { - member.original_file - } - } else if member.original_class.is_some() { - // when an inlined function is from a foreign class, we - // don’t know the file it is defined in. - None + let file = if let Some(file_name) = member.original_file { + if file_name == "R8$$SyntheticClass" { + extract_class_name(member.original_class.unwrap_or(frame.class)) } else { - frame.file - }; + member.original_file + } + } else if member.original_class.is_some() { + // when an inlined function is from a foreign class, we + // don’t know the file it is defined in. + None + } else { + frame.file + }; - let class = match member.original_class { - Some(class) => class, - _ => frame.class, - }; + let class = member.original_class.unwrap_or(frame.class); - return Some(StackFrame { - class, - method: member.original, - file, - line, - parameters: frame.parameters, - method_synthesized: member.is_synthesized, + Some(StackFrame { + class, + method: member.original, + file, + line, + parameters: frame.parameters, + method_synthesized: member.is_synthesized, + }) +} + +fn map_member_without_lines<'a>( + frame: &StackFrame<'a>, + member: &MemberMapping<'a>, +) -> StackFrame<'a> { + let class = member.original_class.unwrap_or(frame.class); + StackFrame { + class, + method: member.original, + file: None, + line: 0, + parameters: frame.parameters, + method_synthesized: member.is_synthesized, + } +} + +fn apply_rewrite_rules<'s>(collected: &mut CollectedFrames<'s>, thrown_descriptor: Option<&str>) { + if collected.frames.is_empty() { + return; + } + for rule in &collected.rewrite_rules { + let matches = rule.conditions.iter().all(|condition| match condition { + RewriteCondition::Throws(descriptor) => Some(*descriptor) == thrown_descriptor, + RewriteCondition::Unknown(_) => false, }); + + if !matches { + continue; + } + + for action in &rule.actions { + match action { + RewriteAction::RemoveInnerFrames(count) => { + if *count >= collected.frames.len() { + collected.frames.clear(); + } else { + collected.frames.drain(0..*count); + } + } + RewriteAction::Unknown(_) => {} + } + } + if collected.frames.is_empty() { + break; + } + } +} + +fn iterate_with_lines<'a>( + frame: &mut StackFrame<'a>, + members: &mut core::slice::Iter<'_, MemberMapping<'a>>, +) -> Option> { + for member in members { + if let Some(mapped) = map_member_with_lines(frame, member) { + return Some(mapped); + } } None } @@ -175,20 +244,9 @@ fn iterate_without_lines<'a>( frame: &mut StackFrame<'a>, members: &mut core::slice::Iter<'_, MemberMapping<'a>>, ) -> Option> { - let member = members.next()?; - - let class = match member.original_class { - Some(class) => class, - _ => frame.class, - }; - Some(StackFrame { - class, - method: member.original, - file: None, - line: 0, - parameters: frame.parameters, - method_synthesized: member.is_synthesized, - }) + members + .next() + .map(|member| map_member_without_lines(frame, member)) } impl FusedIterator for RemappedFrameIter<'_> {} @@ -328,6 +386,7 @@ impl<'s> ProguardMapper<'s> { is_synthesized, is_outline, outline_callsite_positions, + rewrite_rules: member.rewrite_rules.clone(), } } @@ -415,6 +474,45 @@ impl<'s> ProguardMapper<'s> { self.classes.get(class).map(|class| class.original) } + fn collect_remapped_frames(&'s self, frame: &StackFrame<'s>) -> CollectedFrames<'s> { + let mut collected = CollectedFrames::default(); + 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; + + let mapping_entries: &[MemberMapping<'s>] = if let Some(parameters) = frame.parameters { + let Some(typed_members) = members.mappings_by_params.get(parameters) else { + return collected; + }; + typed_members.as_slice() + } else { + members.all_mappings.as_slice() + }; + + if frame.parameters.is_none() { + for member in mapping_entries { + if let Some(mapped) = map_member_with_lines(&frame, member) { + collected.frames.push(mapped); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } + } else { + for member in mapping_entries { + let mapped = map_member_without_lines(&frame, member); + collected.frames.push(mapped); + collected.rewrite_rules.extend(member.rewrite_rules.iter()); + } + } + + collected + } + /// returns a tuple where the first element is the list of the function /// parameters and the second one is the return type pub fn deobfuscate_signature(&'s self, signature: &str) -> Option { @@ -500,57 +598,68 @@ impl<'s> ProguardMapper<'s> { /// strings as input and output. pub fn remap_stacktrace(&self, input: &str) -> Result { let mut stacktrace = String::new(); - let mut lines = input.lines(); let mut carried_outline_pos: Option = None; + let mut current_exception_descriptor: Option = None; + let mut next_frame_can_rewrite = false; - if let Some(line) = lines.next() { - match stacktrace::parse_throwable(line) { - None => match stacktrace::parse_frame(line) { - None => writeln!(&mut stacktrace, "{line}")?, - Some(frame) => { - if self.is_outline_frame(frame.class, frame.method) { - carried_outline_pos = Some(frame.line); - } else { - let effective_frame = - self.prepare_frame_for_mapping(&frame, &mut carried_outline_pos); - - format_frames( - &mut stacktrace, - line, - self.remap_frame(&effective_frame), - )?; - } - } - }, - Some(throwable) => { - format_throwable(&mut stacktrace, line, self.remap_throwable(&throwable))? - } + for line in input.lines() { + if let Some(throwable) = stacktrace::parse_throwable(line) { + let remapped_throwable = self.remap_throwable(&throwable); + let descriptor_class = remapped_throwable + .as_ref() + .map(|t| t.class) + .unwrap_or(throwable.class); + current_exception_descriptor = Some(class_name_to_descriptor(descriptor_class)); + next_frame_can_rewrite = true; + format_throwable(&mut stacktrace, line, remapped_throwable)?; + continue; } - } - for line in lines { - match stacktrace::parse_frame(line) { - None => match line - .strip_prefix("Caused by: ") - .and_then(stacktrace::parse_throwable) - { - None => writeln!(&mut stacktrace, "{line}")?, - Some(cause) => { - format_cause(&mut stacktrace, line, self.remap_throwable(&cause))? - } - }, - Some(frame) => { - if self.is_outline_frame(frame.class, frame.method) { - carried_outline_pos = Some(frame.line); - continue; - } + if let Some(frame) = stacktrace::parse_frame(line) { + if self.is_outline_frame(frame.class, frame.method) { + carried_outline_pos = Some(frame.line); + continue; + } + + let effective_frame = + self.prepare_frame_for_mapping(&frame, &mut carried_outline_pos); + + let mut collected = self.collect_remapped_frames(&effective_frame); + let had_frames = !collected.frames.is_empty(); + if next_frame_can_rewrite { + apply_rewrite_rules(&mut collected, current_exception_descriptor.as_deref()); + } - let effective_frame = - self.prepare_frame_for_mapping(&frame, &mut carried_outline_pos); + next_frame_can_rewrite = false; + current_exception_descriptor = None; - format_frames(&mut stacktrace, line, self.remap_frame(&effective_frame))?; + // If rewrite rules cleared all frames, skip entirely + if had_frames && collected.frames.is_empty() { + continue; } + + format_frames(&mut stacktrace, line, collected.frames.into_iter())?; + continue; + } + + if let Some(cause) = line + .strip_prefix("Caused by: ") + .and_then(stacktrace::parse_throwable) + { + let remapped_cause = self.remap_throwable(&cause); + let descriptor_class = remapped_cause + .as_ref() + .map(|t| t.class) + .unwrap_or(cause.class); + current_exception_descriptor = Some(class_name_to_descriptor(descriptor_class)); + next_frame_can_rewrite = true; + format_cause(&mut stacktrace, line, remapped_cause)?; + continue; } + + current_exception_descriptor = None; + next_frame_can_rewrite = false; + writeln!(&mut stacktrace, "{line}")?; } Ok(stacktrace) } @@ -561,9 +670,17 @@ impl<'s> ProguardMapper<'s> { .exception .as_ref() .and_then(|t| self.remap_throwable(t)); + let exception_descriptor = trace.exception.as_ref().map(|original| { + let class = exception + .as_ref() + .map(|t| t.class) + .unwrap_or(original.class); + class_name_to_descriptor(class) + }); let mut carried_outline_pos: Option = None; let mut frames_out = Vec::with_capacity(trace.frames.len()); + 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); @@ -571,11 +688,22 @@ impl<'s> ProguardMapper<'s> { } let effective = self.prepare_frame_for_mapping(f, &mut carried_outline_pos); - let mut iter = self.remap_frame(&effective).peekable(); - if iter.peek().is_some() { - frames_out.extend(iter); - } else { + let mut collected = self.collect_remapped_frames(&effective); + let had_frames = !collected.frames.is_empty(); + if next_frame_can_rewrite { + apply_rewrite_rules(&mut collected, exception_descriptor.as_deref()); + } + next_frame_can_rewrite = false; + + // If rewrite rules cleared all frames, skip entirely + if had_frames && collected.frames.is_empty() { + continue; + } + + if collected.frames.is_empty() { frames_out.push(f.clone()); + } else { + frames_out.append(&mut collected.frames); } } @@ -740,4 +868,211 @@ Caused by: com.example.MainFragment$EngineFailureException: Engines overheating assert_eq!(mapper.remap_stacktrace(stacktrace).unwrap(), expect); } + + #[test] + fn rewrite_frame_remove_inner_frame() { + let mapping = "\ +some.Class -> a: + 4:4:void other.Class.inlinee():23:23 -> a + 4:4:void caller(other.Class):7 -> a + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(1)\"]} +"; + let stacktrace = "\ +java.lang.NullPointerException: Boom + at a.a(SourceFile:4)"; + let expect = "\ +java.lang.NullPointerException: Boom + at some.Class.caller(SourceFile:7) +"; + + let mapper = ProguardMapper::from(mapping); + + assert_eq!(mapper.remap_stacktrace(stacktrace).unwrap(), expect); + } + + #[test] + fn rewrite_frame_condition_mismatch() { + let mapping = "\ +some.Class -> a: + 4:4:void other.Class.inlinee():23:23 -> a + 4:4:void caller(other.Class):7 -> a + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(1)\"]} +"; + let stacktrace = "\ +java.lang.IllegalStateException: Boom + at a.a(SourceFile:4)"; + let expect = "\ +java.lang.IllegalStateException: Boom + at other.Class.inlinee(:23) + at some.Class.caller(SourceFile:7) +"; + + let mapper = ProguardMapper::from(mapping); + + assert_eq!(mapper.remap_stacktrace(stacktrace).unwrap(), expect); + } + + #[test] + fn rewrite_frame_typed_stacktrace() { + let mapping = "\ +some.Class -> a: + 4:4:void other.Class.inlinee():23:23 -> a + 4:4:void caller(other.Class):7 -> a + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(1)\"]} +"; + let trace = StackTrace { + exception: Some(Throwable { + class: "java.lang.NullPointerException", + message: Some("Boom"), + }), + frames: vec![StackFrame { + class: "a", + method: "a", + line: 4, + file: Some("SourceFile"), + parameters: None, + method_synthesized: false, + }], + cause: None, + }; + + let mapper = ProguardMapper::from(mapping); + let remapped = mapper.remap_stacktrace_typed(&trace); + + 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); + } + + #[test] + fn rewrite_frame_multiple_rules_or_semantics() { + let mapping = "\ +some.Class -> a: + 4:4:void other.Class.inlinee():23:23 -> call + 4:4:void outer():7 -> call + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(1)\"]} + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/IllegalStateException;)\"],\"actions\":[\"removeInnerFrames(1)\"]} +"; + let mapper = ProguardMapper::from(mapping); + + let input_npe = "\ +java.lang.NullPointerException: Boom + at a.call(SourceFile:4)"; + let expected_npe = "\ +java.lang.NullPointerException: Boom + at some.Class.outer(SourceFile:7) +"; + assert_eq!(mapper.remap_stacktrace(input_npe).unwrap(), expected_npe); + + let input_ise = "\ +java.lang.IllegalStateException: Boom + at a.call(SourceFile:4)"; + let expected_ise = "\ +java.lang.IllegalStateException: Boom + at some.Class.outer(SourceFile:7) +"; + assert_eq!(mapper.remap_stacktrace(input_ise).unwrap(), expected_ise); + } + + #[test] + fn remap_frame_without_mapping_keeps_original_line() { + let mapping = "\ +some.Class -> a: + 1:1:void some.Class.existing():10:10 -> a +"; + let mapper = ProguardMapper::from(mapping); + + let input = "\ +java.lang.RuntimeException: boom + at a.missing(SourceFile:42) +"; + let expected = "\ +java.lang.RuntimeException: boom + at a.missing(SourceFile:42) +"; + + assert_eq!(mapper.remap_stacktrace(input).unwrap(), expected); + } + + #[test] + fn rewrite_frame_removes_all_frames_skips_line() { + // When rewrite rules remove ALL frames, the line should be skipped entirely + // (not fall back to original obfuscated frame) + let mapping = "\ +some.Class -> a: + 4:4:void inlined():10:10 -> call + 4:4:void outer():20 -> call + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(2)\"]} +some.Other -> b: + 5:5:void method():30 -> run +"; + let mapper = ProguardMapper::from(mapping); + + let input = "\ +java.lang.NullPointerException: Boom + at a.call(SourceFile:4) + at b.run(SourceFile:5) +"; + + // The first frame (a.call) should be completely removed by rewrite rules, + // not replaced with the original "at a.call(SourceFile:4)" + let expected = "\ +java.lang.NullPointerException: Boom + at some.Other.method(SourceFile:30) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn rewrite_frame_removes_all_frames_skips_line_typed() { + // When rewrite rules remove ALL frames, the frame should be skipped entirely + // (not fall back to original obfuscated frame) + let mapping = "\ +some.Class -> a: + 4:4:void inlined():10:10 -> call + 4:4:void outer():20 -> call + # {\"id\":\"com.android.tools.r8.rewriteFrame\",\"conditions\":[\"throws(Ljava/lang/NullPointerException;)\"],\"actions\":[\"removeInnerFrames(2)\"]} +some.Other -> b: + 5:5:void method():30 -> run +"; + let mapper = ProguardMapper::from(mapping); + + let trace = StackTrace { + exception: Some(Throwable { + class: "java.lang.NullPointerException", + message: Some("Boom"), + }), + frames: vec![ + StackFrame { + class: "a", + method: "call", + line: 4, + file: Some("SourceFile"), + parameters: None, + method_synthesized: false, + }, + StackFrame { + class: "b", + method: "run", + line: 5, + file: Some("SourceFile"), + parameters: None, + method_synthesized: false, + }, + ], + cause: None, + }; + + let remapped = mapper.remap_stacktrace_typed(&trace); + + // The first frame should be completely removed by rewrite rules, + // leaving only the second frame + 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); + } } diff --git a/src/mapping.rs b/src/mapping.rs index cc689a4..e433117 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -337,6 +337,16 @@ pub enum R8Header<'s> { outline: Option<&'s str>, }, + /// A rewrite frame header, describing how retracing should rewrite frames. + /// + /// See . + #[serde(rename_all = "camelCase")] + #[serde(rename = "com.android.tools.r8.rewriteFrame")] + RewriteFrame { + conditions: Vec<&'s str>, + actions: Vec<&'s str>, + }, + /// Catchall variant for headers we don't support. #[serde(other)] Other, @@ -1175,6 +1185,33 @@ androidx.activity.OnBackPressedCallback ); } + #[test] + fn try_parse_header_rewrite_frame() { + let bytes = br#"# {"id":"com.android.tools.r8.rewriteFrame","conditions":["throws(Ljava/lang/NullPointerException;)"],"actions":["removeInnerFrames(1)"]}"#; + assert_eq!( + ProguardRecord::try_parse(bytes).unwrap(), + ProguardRecord::R8Header(R8Header::RewriteFrame { + conditions: vec!["throws(Ljava/lang/NullPointerException;)"], + actions: vec!["removeInnerFrames(1)"], + }) + ); + } + + #[test] + fn try_parse_header_rewrite_frame_multiple_entries() { + let bytes = br#"# {"id":"com.android.tools.r8.rewriteFrame","conditions":["throws(Ljava/lang/NullPointerException;)","throws(Ljava/lang/IllegalStateException;)"],"actions":["removeInnerFrames(2)"]}"#; + assert_eq!( + ProguardRecord::try_parse(bytes).unwrap(), + ProguardRecord::R8Header(R8Header::RewriteFrame { + conditions: vec![ + "throws(Ljava/lang/NullPointerException;)", + "throws(Ljava/lang/IllegalStateException;)" + ], + actions: vec!["removeInnerFrames(2)"], + }) + ); + } + #[test] fn try_parse_header_outline_callsite_without_outline_key() { let bytes = diff --git a/tests/r8.rs b/tests/r8.rs index 4b1f1ad..45963f5 100644 --- a/tests/r8.rs +++ b/tests/r8.rs @@ -1,6 +1,6 @@ use std::sync::LazyLock; -use proguard::{ProguardCache, ProguardMapper, ProguardMapping, StackFrame}; +use proguard::{ProguardCache, ProguardMapper, ProguardMapping, StackFrame, StackTrace, Throwable}; #[cfg(feature = "uuid")] use uuid::uuid; @@ -10,6 +10,7 @@ static MAPPING_R8_SYMBOLICATED_FILE_NAMES: &[u8] = include_bytes!("res/mapping-r8-symbolicated_file_names.txt"); static MAPPING_OUTLINE: &[u8] = include_bytes!("res/mapping-outline.txt"); static MAPPING_OUTLINE_COMPLEX: &[u8] = include_bytes!("res/mapping-outline-complex.txt"); +static MAPPING_REWRITE_COMPLEX: &str = include_str!("res/mapping-rewrite-complex.txt"); static MAPPING_ZERO_LINE_INFO: &[u8] = include_bytes!("res/mapping-zero-line-info.txt"); static MAPPING_WIN_R8: LazyLock> = LazyLock::new(|| { @@ -332,6 +333,94 @@ fn test_outline_frame_retracing() { assert_eq!(mapped.next(), None); } +#[test] +fn rewrite_frame_complex_stacktrace() { + let mapper = ProguardMapper::from(MAPPING_REWRITE_COMPLEX); + + let input = "\ +java.lang.NullPointerException: Primary issue + at a.start(SourceFile:10) + at b.dispatch(SourceFile:5) + at c.draw(SourceFile:20) +Caused by: java.lang.IllegalStateException: Secondary issue + at b.dispatch(SourceFile:5) + at c.draw(SourceFile:20) +"; + + let expected = "\ +java.lang.NullPointerException: Primary issue + at com.example.flow.Initializer.start(SourceFile:42) + at com.example.flow.StreamRouter$Inline.internalDispatch(:30) + at com.example.flow.StreamRouter.dispatch(SourceFile:12) + at com.example.flow.UiBridge.render(SourceFile:200) +Caused by: java.lang.IllegalStateException: Secondary issue + at com.example.flow.StreamRouter.dispatch(SourceFile:12) + at com.example.flow.UiBridge.render(SourceFile:200) +"; + + let actual = mapper.remap_stacktrace(input).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn rewrite_frame_complex_stacktrace_typed() { + let mapper = ProguardMapper::from(MAPPING_REWRITE_COMPLEX); + + let trace = StackTrace::with_cause( + Some(Throwable::with_message( + "java.lang.NullPointerException", + "Primary issue", + )), + vec![ + StackFrame::with_file("a", "start", 10, "SourceFile"), + StackFrame::with_file("b", "dispatch", 5, "SourceFile"), + StackFrame::with_file("c", "draw", 20, "SourceFile"), + ], + StackTrace::new( + Some(Throwable::with_message( + "java.lang.IllegalStateException", + "Secondary issue", + )), + vec![ + StackFrame::with_file("b", "dispatch", 5, "SourceFile"), + StackFrame::with_file("c", "draw", 20, "SourceFile"), + ], + ), + ); + + let remapped = mapper.remap_stacktrace_typed(&trace); + + // After rewrite rule removes 2 inner frames for NullPointerException + let frames = remapped.frames(); + 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[1].class(), "com.example.flow.StreamRouter$Inline"); + assert_eq!(frames[1].method(), "internalDispatch"); + assert_eq!(frames[1].line(), 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[3].class(), "com.example.flow.UiBridge"); + assert_eq!(frames[3].method(), "render"); + assert_eq!(frames[3].line(), 200); + + // Caused by exception (also not in mapping) + let cause = remapped.cause().unwrap(); + assert!(cause.exception().is_none()); + + // After rewrite rule removes 1 inner frame for IllegalStateException + let cause_frames = cause.frames(); + 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[1].class(), "com.example.flow.UiBridge"); + assert_eq!(cause_frames[1].method(), "render"); + assert_eq!(cause_frames[1].line(), 200); +} + #[test] fn test_remap_zero_line_info() { let mapping = ProguardMapping::new(MAPPING_ZERO_LINE_INFO); diff --git a/tests/res/mapping-rewrite-complex.txt b/tests/res/mapping-rewrite-complex.txt new file mode 100644 index 0000000..d4a449c --- /dev/null +++ b/tests/res/mapping-rewrite-complex.txt @@ -0,0 +1,13 @@ +com.example.flow.Initializer -> a: + 10:10:void com.example.flow.Inliner.firstStep(com.example.flow.Step):60:60 -> start + 10:10:void com.example.flow.Inliner.secondStep(com.example.flow.Step):61:61 -> start + 10:10:void start(com.example.flow.Step):42 -> start + # {"id":"com.android.tools.r8.rewriteFrame","conditions":["throws(Ljava/lang/NullPointerException;)"],"actions":["removeInnerFrames(2)"]} + 15:15:void resume():55 -> start +com.example.flow.StreamRouter -> b: + 5:5:void com.example.flow.StreamRouter$Inline.internalDispatch(java.lang.String):30:30 -> dispatch + 5:5:void dispatch(java.lang.String):12 -> dispatch + # {"id":"com.android.tools.r8.rewriteFrame","conditions":["throws(Ljava/lang/IllegalStateException;)"],"actions":["removeInnerFrames(1)"]} +com.example.flow.UiBridge -> c: + 20:20:void render():200 -> draw +