Skip to main content

asc_engine/names/
proguard.rs

1//! R8 / ProGuard `mapping.txt` → [`NameMappings`], the only way names other
2//! than the DEX's own reach decompiled source.
3//!
4//! A mapping file lists each class as `original -> obfuscated:` followed by
5//! indented member lines (`type name -> obf`, `[a:b:]ret name(args)[:c[:d]] -> obf`).
6//! Member lines spell types with their original names, while
7//! [`NameMappings`] keys use the DEX's, so every type is translated through
8//! the whole class map, or taken from R8's `residualsignature` when given.
9//! The class map is therefore read first: the text is scanned twice, once
10//! for class lines and once for members, borrowing from it throughout.
11
12use super::mappings::{MappingError, NameMappings, NameResolver, is_class_descriptor};
13use std::collections::HashMap;
14use std::collections::hash_map::Entry;
15use std::fmt;
16use std::io::{self, Read};
17use std::path::{Path, PathBuf};
18
19/// Why a mapping file could not be read at all.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum ProguardError {
23    /// Reading the mapping from a reader failed (or it is not UTF-8).
24    #[error("reading mapping file: {0}")]
25    Io(#[from] io::Error),
26    /// The mapping file could not be read (or is not UTF-8).
27    #[error("reading mapping file {}: {source}", path.display())]
28    File {
29        /// The file.
30        path: PathBuf,
31        /// The read error.
32        source: io::Error,
33    },
34    /// A line that is not mapping syntax at all.
35    #[error("mapping file line {line}: {reason}")]
36    Syntax {
37        /// 1-based line in the mapping file.
38        line: usize,
39        /// What was expected.
40        reason: &'static str,
41    },
42}
43
44/// An entry of a mapping file that was read but not used.
45#[derive(Debug, Clone, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct ProguardWarning {
48    /// 1-based line in the mapping file.
49    pub line: usize,
50    /// What was wrong with it.
51    pub kind: WarningKind,
52}
53
54/// Why a mapping file entry was skipped.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[non_exhaustive]
57pub enum WarningKind {
58    /// The same DEX name was given a second, different name; the first wins.
59    #[error("{key} mapped again to {ignored}; keeping {kept}")]
60    Conflict {
61        /// The DEX name.
62        key: String,
63        /// The name it keeps.
64        kept: String,
65        /// The name dropped.
66        ignored: String,
67    },
68    /// Two DEX classes claim one original name; member types written with it
69    /// translate to the first.
70    #[error("original class {original} mapped again from {ignored}; keeping {kept}")]
71    DuplicateOriginal {
72        /// The original class name.
73        original: String,
74        /// The DEX class it keeps translating to.
75        kept: String,
76        /// The DEX class dropped.
77        ignored: String,
78    },
79    /// A name [`NameMappings`] refuses.
80    #[error(transparent)]
81    Rejected(#[from] MappingError),
82}
83
84impl fmt::Display for ProguardWarning {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "mapping file line {}: {}", self.line, self.kind)
87    }
88}
89
90/// A parsed mapping file: the names, and what was skipped on the way.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92#[non_exhaustive]
93pub struct ProguardMapping {
94    /// The renames read.
95    pub mappings: NameMappings,
96    /// Entries skipped, in file order.
97    pub warnings: Vec<ProguardWarning>,
98}
99
100/// R8 marks classes it removed with these names; nothing in the DEX has them.
101const REMOVED_CLASS: &str = "R8$$REMOVED$$CLASS$$";
102const RESIDUAL_SIGNATURE: &str = "com.android.tools.r8.residualsignature";
103
104impl NameMappings {
105    /// Parse an R8 / ProGuard mapping file. Only the outermost frame of an R8
106    /// inline stack names the method; members that R8 moved in from another
107    /// class, identity entries and removed classes are skipped silently.
108    /// Duplicates and unusable names become warnings; only lines that are
109    /// not mapping syntax at all fail the parse.
110    ///
111    /// # Errors
112    ///
113    /// [`ProguardError::Syntax`] for the first line that is not mapping syntax.
114    pub fn from_proguard(text: &str) -> Result<ProguardMapping, ProguardError> {
115        let text = text.strip_prefix('\u{feff}').unwrap_or(text);
116        let mut parser = Parser::default();
117        let classes = parser.read_classes(text)?;
118        parser.read_members(text, &classes)?;
119        Ok(ProguardMapping { mappings: parser.mappings, warnings: parser.warnings })
120    }
121
122    /// [`NameMappings::from_proguard`] on everything `reader` yields (UTF-8).
123    ///
124    /// # Errors
125    ///
126    /// [`ProguardError::Io`] when reading fails or the text is not UTF-8,
127    /// plus the errors of [`NameMappings::from_proguard`].
128    pub fn from_proguard_reader(mut reader: impl Read) -> Result<ProguardMapping, ProguardError> {
129        let mut text = String::new();
130        reader.read_to_string(&mut text)?;
131        NameMappings::from_proguard(&text)
132    }
133
134    /// [`NameMappings::from_proguard`] on the file at `path`.
135    ///
136    /// # Errors
137    ///
138    /// [`ProguardError::File`] when the file cannot be read or is not UTF-8,
139    /// plus the errors of [`NameMappings::from_proguard`].
140    pub fn from_proguard_file(path: impl AsRef<Path>) -> Result<ProguardMapping, ProguardError> {
141        let path = path.as_ref();
142        let text = std::fs::read_to_string(path).map_err(|source| ProguardError::File { path: path.to_path_buf(), source })?;
143        NameMappings::from_proguard(&text)
144    }
145}
146
147/// Numbered lines with CR and trailing whitespace dropped.
148fn lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
149    text.lines().enumerate().map(|(i, l)| (i + 1, l.trim_end()))
150}
151
152/// A class line: not indented and not a comment.
153fn is_class_line(line: &str) -> bool {
154    !line.is_empty() && !line.starts_with(|c: char| c.is_whitespace() || c == '#')
155}
156
157/// `orig.Name -> obf.Name:` → `(orig.Name, obf.Name)`.
158fn parse_class_line(line: &str) -> Option<(&str, &str)> {
159    let (original, obfuscated) = line.strip_suffix(':')?.split_once("->")?;
160    let (original, obfuscated) = (original.trim(), obfuscated.trim());
161    let word = |s: &str| !s.is_empty() && !s.contains(char::is_whitespace);
162    (word(original) && word(obfuscated)).then_some((original, obfuscated))
163}
164
165/// `com.a.B$C` → `Lcom/a/B$C;`.
166fn descriptor(binary_name: &str) -> String {
167    let mut out = String::with_capacity(binary_name.len() + 2);
168    push_descriptor(&mut out, binary_name);
169    out
170}
171
172fn push_descriptor(out: &mut String, binary_name: &str) {
173    out.push('L');
174    out.extend(binary_name.chars().map(|c| if c == '.' { '/' } else { c }));
175    out.push(';');
176}
177
178/// A class line as the member pass needs it.
179struct Class<'a> {
180    original: &'a str,
181    /// The class's descriptor in the DEX, the prefix of its members' keys.
182    descriptor: String,
183}
184
185#[derive(Default)]
186struct Parser<'a> {
187    mappings: NameMappings,
188    warnings: Vec<ProguardWarning>,
189    /// Original binary name → binary name in the DEX.
190    obfuscated: HashMap<&'a str, &'a str>,
191}
192
193#[derive(Clone, Copy)]
194enum Section {
195    Class,
196    Field,
197    Method,
198}
199
200impl<'a> Parser<'a> {
201    /// The first pass: every class line, in order (`None` for classes whose
202    /// members are of no use), and the class map.
203    fn read_classes(&mut self, text: &'a str) -> Result<Vec<Option<Class<'a>>>, ProguardError> {
204        let mut classes = Vec::new();
205        for (line, l) in lines(text).filter(|(_, l)| is_class_line(l)) {
206            let (original, obfuscated) =
207                parse_class_line(l).ok_or(ProguardError::Syntax { line, reason: "expected `original -> obfuscated:`" })?;
208            classes.push(self.class(line, original, obfuscated));
209        }
210        Ok(classes)
211    }
212
213    fn class(&mut self, line: usize, original: &'a str, obfuscated: &'a str) -> Option<Class<'a>> {
214        if obfuscated.starts_with(REMOVED_CLASS) {
215            return None;
216        }
217        let dex_descriptor = descriptor(obfuscated);
218        if !is_class_descriptor(&dex_descriptor) {
219            self.warn(line, MappingError::Class(dex_descriptor).into());
220            return None;
221        }
222        match self.obfuscated.entry(original) {
223            Entry::Vacant(e) => {
224                e.insert(obfuscated);
225            }
226            Entry::Occupied(e) if *e.get() != obfuscated => {
227                let kind = WarningKind::DuplicateOriginal {
228                    original: original.to_string(),
229                    kept: e.get().to_string(),
230                    ignored: obfuscated.to_string(),
231                };
232                self.warn(line, kind);
233            }
234            Entry::Occupied(_) => {}
235        }
236        if original != obfuscated {
237            self.insert(line, Section::Class, &dex_descriptor, &descriptor(original));
238        }
239        Some(Class { original, descriptor: dex_descriptor })
240    }
241
242    /// The second pass: member lines, each under the class line before it.
243    fn read_members(&mut self, text: &'a str, classes: &[Option<Class<'a>>]) -> Result<(), ProguardError> {
244        let mut classes = classes.iter();
245        let mut class: Option<&Class<'a>> = None;
246        let mut seen_class = false;
247        let mut pending: Option<Pending<'a>> = None;
248        let mut previous: Option<Previous<'a>> = None;
249        for (line, l) in lines(text) {
250            let body = l.trim_start();
251            if body.is_empty() {
252                continue;
253            }
254            if let Some(comment) = body.strip_prefix('#') {
255                if let (Some(p), Some(residual)) = (pending.as_mut(), residual_signature(comment)) {
256                    p.residual = Some(residual);
257                }
258                continue;
259            }
260            if is_class_line(l) {
261                self.flush(class, pending.take(), &mut previous);
262                previous = None;
263                class = classes.next().and_then(Option::as_ref);
264                seen_class = true;
265                continue;
266            }
267            if !seen_class {
268                return Err(ProguardError::Syntax { line, reason: "member line before any class line" });
269            }
270            let member = Member::parse(body).ok_or(ProguardError::Syntax { line, reason: "expected a field or method mapping" })?;
271            match (pending.as_mut(), member) {
272                (Some(p), Member::Method(m)) if p.member.is_inlined_into(&m) => {
273                    p.member = Member::Method(m);
274                    p.line = line;
275                }
276                (_, member) => {
277                    self.flush(class, pending.take(), &mut previous);
278                    pending = Some(Pending { member, line, residual: None });
279                }
280            }
281        }
282        self.flush(class, pending, &mut previous);
283        Ok(())
284    }
285
286    /// Record the member `pending` (for a method, its outermost frame).
287    fn flush(&mut self, class: Option<&Class<'a>>, pending: Option<Pending<'a>>, previous: &mut Option<Previous<'a>>) {
288        let (Some(class), Some(Pending { member, line, residual })) = (class, pending) else { return };
289        match member {
290            Member::Field(f) => {
291                let Some(name) = own_name(class, f.name) else { return };
292                if name == f.obfuscated {
293                    return;
294                }
295                let mut key = format!("{}->{}:", class.descriptor, f.obfuscated);
296                match &residual {
297                    Some(ty) => key.push_str(ty),
298                    None => self.push_type(&mut key, f.ty),
299                }
300                self.insert(line, Section::Field, &key, name);
301            }
302            Member::Method(m) => {
303                // R8 writes the residual signature once, under the first
304                // line range of a method; later ranges of it inherit it.
305                let residual = residual.or_else(|| previous.take().filter(|p| p.method.same_method(&m)).and_then(|p| p.residual));
306                *previous = Some(Previous { method: m, residual });
307                let residual = previous.as_ref().and_then(|p| p.residual.as_deref());
308                let Some(name) = own_name(class, m.name) else { return };
309                if name == m.obfuscated || m.obfuscated.starts_with('<') {
310                    return;
311                }
312                let mut key = format!("{}->{}", class.descriptor, m.obfuscated);
313                match residual {
314                    Some(proto) => key.push_str(proto),
315                    None => {
316                        key.push('(');
317                        m.args.split(',').filter(|a| !a.trim().is_empty()).for_each(|a| self.push_type(&mut key, a));
318                        key.push(')');
319                        self.push_type(&mut key, m.ret);
320                    }
321                }
322                self.insert(line, Section::Method, &key, name);
323            }
324        }
325    }
326
327    /// Append the DEX descriptor of the Java type `java` (`int`,
328    /// `com.a.B[][]`), whose class part is an original name.
329    fn push_type(&self, out: &mut String, java: &str) {
330        let mut base = java.trim();
331        while let Some(element) = base.strip_suffix("[]") {
332            out.push('[');
333            base = element.trim_end();
334        }
335        let primitive = match base {
336            "void" => 'V',
337            "boolean" => 'Z',
338            "byte" => 'B',
339            "char" => 'C',
340            "short" => 'S',
341            "int" => 'I',
342            "long" => 'J',
343            "float" => 'F',
344            "double" => 'D',
345            _ => return push_descriptor(out, self.obfuscated.get(base).copied().unwrap_or(base)),
346        };
347        out.push(primitive);
348    }
349
350    /// Insert unless `key` already has a name: the first entry wins, and a
351    /// different second one is a warning.
352    fn insert(&mut self, line: usize, section: Section, key: &str, name: &str) {
353        let existing = match section {
354            Section::Class => self.mappings.class(key),
355            Section::Field => self.mappings.field(key),
356            Section::Method => self.mappings.method(key),
357        };
358        if let Some(kept) = existing {
359            if kept != name {
360                let kind = WarningKind::Conflict { key: key.to_string(), kept: kept.to_string(), ignored: name.to_string() };
361                self.warn(line, kind);
362            }
363            return;
364        }
365        let inserted = match section {
366            Section::Class => self.mappings.insert_class(key, name),
367            Section::Field => self.mappings.insert_field(key, name),
368            Section::Method => self.mappings.insert_method(key, name),
369        };
370        if let Err(e) = inserted {
371            self.warn(line, e.into());
372        }
373    }
374
375    fn warn(&mut self, line: usize, kind: WarningKind) {
376        self.warnings.push(ProguardWarning { line, kind });
377    }
378}
379
380/// The simple name of a member of `class`: `name` as written, or with the
381/// class's own original name as qualifier. `None` for a member qualified
382/// with another class (an inlined or moved method), which this class's
383/// member was not called.
384fn own_name<'a>(class: &Class<'_>, name: &'a str) -> Option<&'a str> {
385    match name.rsplit_once('.') {
386        None => Some(name),
387        Some((owner, simple)) => (owner == class.original).then_some(simple),
388    }
389}
390
391/// The signature in `# {"id":"com.android.tools.r8.residualsignature","signature":"(I)V"}`.
392/// Other comments, R8 metadata of other kinds included, are `None`.
393fn residual_signature(comment: &str) -> Option<String> {
394    let json = comment.trim_start();
395    if !json.starts_with('{') || !json.contains(RESIDUAL_SIGNATURE) {
396        return None;
397    }
398    let value: serde_json::Value = serde_json::from_str(json).ok()?;
399    if value.get("id")?.as_str()? != RESIDUAL_SIGNATURE {
400        return None;
401    }
402    value.get("signature")?.as_str().map(str::to_string)
403}
404
405/// The member line the next lines may still turn out to be inlined into.
406struct Pending<'a> {
407    member: Member<'a>,
408    /// The line of the outermost frame read so far.
409    line: usize,
410    residual: Option<String>,
411}
412
413/// The last method recorded in the current class, whose residual signature
414/// later line ranges of the same method inherit.
415struct Previous<'a> {
416    method: MethodLine<'a>,
417    residual: Option<String>,
418}
419
420enum Member<'a> {
421    Field(FieldLine<'a>),
422    Method(MethodLine<'a>),
423}
424
425/// `type name -> obfuscated`.
426struct FieldLine<'a> {
427    ty: &'a str,
428    name: &'a str,
429    obfuscated: &'a str,
430}
431
432/// `[a:b:]ret name(args)[:c[:d]] -> obfuscated`.
433#[derive(Clone, Copy)]
434struct MethodLine<'a> {
435    /// `a:b`, the line range in the DEX; empty when absent.
436    range: &'a str,
437    ret: &'a str,
438    /// Possibly qualified (`orig.Class.method`) for inlined frames.
439    name: &'a str,
440    args: &'a str,
441    /// The original position is one line (`:c`), as R8 writes it for the
442    /// call sites of an inline stack.
443    call_site: bool,
444    obfuscated: &'a str,
445}
446
447impl<'a> Member<'a> {
448    fn parse(body: &'a str) -> Option<Member<'a>> {
449        let (lhs, obfuscated) = body.rsplit_once("->")?;
450        let (lhs, obfuscated) = (lhs.trim_end(), obfuscated.trim());
451        if obfuscated.is_empty() || obfuscated.contains(char::is_whitespace) {
452            return None;
453        }
454        let digits = lhs.find(|c: char| !(c.is_ascii_digit() || c == ':')).unwrap_or(lhs.len());
455        let (range, rest) = match lhs.split_at(digits) {
456            ("", rest) => ("", rest),
457            (prefix, rest) => (prefix.strip_suffix(':')?, rest),
458        };
459        let (ty, rest) = rest.split_once(' ')?;
460        let rest = rest.trim_start();
461        if ty.is_empty() || rest.is_empty() {
462            return None;
463        }
464        let Some((name, rest)) = rest.split_once('(') else {
465            return (range.is_empty() && !rest.contains(char::is_whitespace)).then_some(Member::Field(FieldLine {
466                ty,
467                name: rest,
468                obfuscated,
469            }));
470        };
471        let (args, position) = rest.split_once(')')?;
472        let numbers = match position {
473            "" => 0,
474            p => {
475                let p = p.strip_prefix(':')?;
476                if !p.split(':').all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
477                    return None;
478                }
479                p.split(':').count()
480            }
481        };
482        if name.is_empty() || numbers > 2 {
483            return None;
484        }
485        Some(Member::Method(MethodLine { range, ret: ty, name, args, call_site: numbers == 1, obfuscated }))
486    }
487
488    /// `next` is a caller frame of this method's inline stack: it shares the
489    /// DEX method and line range and gives a single call-site line.
490    fn is_inlined_into(&self, next: &MethodLine<'_>) -> bool {
491        match self {
492            Member::Method(m) => !m.range.is_empty() && m.range == next.range && m.obfuscated == next.obfuscated && next.call_site,
493            Member::Field(_) => false,
494        }
495    }
496}
497
498impl MethodLine<'_> {
499    /// Another line range of the same DEX method.
500    fn same_method(&self, other: &MethodLine<'_>) -> bool {
501        (self.obfuscated, self.ret, self.name, self.args) == (other.obfuscated, other.ret, other.name, other.args)
502    }
503}
504
505#[cfg(test)]
506mod tests;