1#!/usr/bin/env python
2# Copyright 2018 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""A helper script to list class verification errors.
7
8This is a wrapper around the device's oatdump executable, parsing desired output
9and accommodating API-level-specific details, such as file paths.
10"""
11
12from __future__ import print_function
13
14import argparse
15import exceptions
16import logging
17import os
18import re
19
20import devil_chromium
21from devil.android import device_errors
22from devil.android import device_temp_file
23from devil.android import device_utils
24from devil.android.ndk import abis
25from devil.android.sdk import version_codes
26from devil.android.tools import script_common
27from devil.utils import logging_common
28from py_utils import tempfile_ext
29
30STATUSES = [
31    'NotReady',
32    'RetryVerificationAtRuntime',
33    'Verified',
34    'Initialized',
35    'SuperclassValidated',
36]
37
38
39def DetermineDeviceToUse(devices):
40  """Like DeviceUtils.HealthyDevices(), but only allow a single device.
41
42  Args:
43    devices: A (possibly empty) list of serial numbers, such as from the
44        --device flag.
45  Returns:
46    A single device_utils.DeviceUtils instance.
47  Raises:
48    device_errors.NoDevicesError: Raised when no non-denylisted devices exist.
49    device_errors.MultipleDevicesError: Raise when multiple devices exist, but
50        |devices| does not distinguish which to use.
51  """
52  if not devices:
53    # If the user did not specify which device, we let HealthyDevices raise
54    # MultipleDevicesError.
55    devices = None
56  usable_devices = device_utils.DeviceUtils.HealthyDevices(device_arg=devices)
57  # If the user specified more than one device, we still only want to support a
58  # single device, so we explicitly raise MultipleDevicesError.
59  if len(usable_devices) > 1:
60    raise device_errors.MultipleDevicesError(usable_devices)
61  return usable_devices[0]
62
63
64class DeviceOSError(Exception):
65  """Raised when a file is missing from the device, or something similar."""
66  pass
67
68
69class UnsupportedDeviceError(Exception):
70  """Raised when the device is not supported by this script."""
71  pass
72
73
74def _GetFormattedArch(device):
75  abi = device.product_cpu_abi
76  # Some architectures don't map 1:1 with the folder names.
77  return {abis.ARM_64: 'arm64', abis.ARM: 'arm'}.get(abi, abi)
78
79
80def PathToDexForPlatformVersion(device, package_name):
81  """Gets the full path to the dex file on the device."""
82  sdk_level = device.build_version_sdk
83  paths_to_apk = device.GetApplicationPaths(package_name)
84  if not paths_to_apk:
85    raise DeviceOSError(
86        'Could not find data directory for {}. Is it installed?'.format(
87            package_name))
88  if len(paths_to_apk) != 1:
89    raise DeviceOSError(
90        'Expected exactly one path for {} but found {}'.format(
91            package_name,
92            paths_to_apk))
93  path_to_apk = paths_to_apk[0]
94
95  if version_codes.LOLLIPOP <= sdk_level <= version_codes.LOLLIPOP_MR1:
96    # Of the form "com.example.foo-\d", where \d is some digit (usually 1 or 2)
97    package_with_suffix = os.path.basename(os.path.dirname(path_to_apk))
98    arch = _GetFormattedArch(device)
99    dalvik_prefix = '/data/dalvik-cache/{arch}'.format(arch=arch)
100    odex_file = '{prefix}/data@app@{package}@base.apk@classes.dex'.format(
101        prefix=dalvik_prefix,
102        package=package_with_suffix)
103  elif sdk_level >= version_codes.MARSHMALLOW:
104    arch = _GetFormattedArch(device)
105    odex_file = '{data_dir}/oat/{arch}/base.odex'.format(
106        data_dir=os.path.dirname(path_to_apk), arch=arch)
107  else:
108    raise UnsupportedDeviceError('Unsupported API level: {}'.format(sdk_level))
109
110  odex_file_exists = device.FileExists(odex_file)
111  if odex_file_exists:
112    return odex_file
113  elif sdk_level >= version_codes.PIE:
114    raise DeviceOSError(
115        'Unable to find odex file: you must run dex2oat on debuggable apps '
116        'on >= P after installation.')
117  raise DeviceOSError('Unable to find odex file ' + odex_file)
118
119
120def _AdbOatDumpForPackage(device, package_name, out_file):
121  """Runs oatdump on the device."""
122  # Get the path to the odex file.
123  odex_file = PathToDexForPlatformVersion(device, package_name)
124  device.RunShellCommand(
125      ['oatdump', '--oat-file=' + odex_file, '--output=' + out_file],
126      timeout=420,
127      shell=True,
128      check_return=True)
129
130
131class JavaClass(object):
132  """This represents a Java Class and its ART Class Verification status."""
133
134  def __init__(self, name, verification_status):
135    self.name = name
136    self.verification_status = verification_status
137
138
139def _ParseMappingFile(proguard_map_file):
140  """Creates a map of obfuscated names to deobfuscated names."""
141  mappings = {}
142  with open(proguard_map_file, 'r') as f:
143    pattern = re.compile(r'^(\S+) -> (\S+):')
144    for line in f:
145      m = pattern.match(line)
146      if m is not None:
147        deobfuscated_name = m.group(1)
148        obfuscated_name = m.group(2)
149        mappings[obfuscated_name] = deobfuscated_name
150  return mappings
151
152
153def _DeobfuscateJavaClassName(dex_code_name, proguard_mappings):
154  return proguard_mappings.get(dex_code_name, dex_code_name)
155
156
157def FormatJavaClassName(dex_code_name, proguard_mappings):
158  obfuscated_name = dex_code_name.replace('/', '.')
159  if proguard_mappings is not None:
160    return _DeobfuscateJavaClassName(obfuscated_name, proguard_mappings)
161  else:
162    return obfuscated_name
163
164
165def ListClassesAndVerificationStatus(oatdump_output, proguard_mappings):
166  """Lists all Java classes in the dex along with verification status."""
167  java_classes = []
168  pattern = re.compile(r'\d+: L([^;]+).*\(type_idx=[^(]+\((\w+)\).*')
169  for line in oatdump_output:
170    m = pattern.match(line)
171    if m is not None:
172      name = FormatJavaClassName(m.group(1), proguard_mappings)
173      # Some platform levels prefix this with "Status" while other levels do
174      # not. Strip this for consistency.
175      verification_status = m.group(2).replace('Status', '')
176      java_classes.append(JavaClass(name, verification_status))
177  return java_classes
178
179
180def _PrintVerificationResults(target_status, java_classes, show_summary):
181  """Prints results for user output."""
182  # Sort to keep output consistent between runs.
183  java_classes.sort(key=lambda c: c.name)
184  d = {}
185  for status in STATUSES:
186    d[status] = 0
187
188  for java_class in java_classes:
189    if java_class.verification_status == target_status:
190      print(java_class.name)
191    if java_class.verification_status not in d:
192      raise exceptions.RuntimeError('Unexpected status: {0}'.format(
193          java_class.verification_status))
194    else:
195      d[java_class.verification_status] += 1
196
197  if show_summary:
198    for status in d:
199      count = d[status]
200      print('Total {status} classes: {num}'.format(
201          status=status, num=count))
202    print('Total number of classes: {num}'.format(
203        num=len(java_classes)))
204
205
206def RealMain(mapping, device_arg, package, status, hide_summary, workdir):
207  if mapping is None:
208    logging.warn('Skipping deobfuscation because no map file was provided.')
209  device = DetermineDeviceToUse(device_arg)
210  device.EnableRoot()
211  with device_temp_file.DeviceTempFile(
212      device.adb) as file_on_device:
213    _AdbOatDumpForPackage(device, package, file_on_device.name)
214    file_on_host = os.path.join(workdir, 'out.dump')
215    device.PullFile(file_on_device.name, file_on_host, timeout=220)
216  proguard_mappings = (_ParseMappingFile(mapping) if mapping else None)
217  with open(file_on_host, 'r') as f:
218    java_classes = ListClassesAndVerificationStatus(f, proguard_mappings)
219    _PrintVerificationResults(status, java_classes, not hide_summary)
220
221
222def main():
223  parser = argparse.ArgumentParser(description="""
224List Java classes in an APK which fail ART class verification.
225""")
226  parser.add_argument(
227      '--package',
228      '-P',
229      type=str,
230      default=None,
231      required=True,
232      help='Specify the full application package name')
233  parser.add_argument(
234      '--mapping',
235      '-m',
236      type=os.path.realpath,
237      default=None,
238      help='Mapping file for the desired APK to deobfuscate class names')
239  parser.add_argument(
240      '--hide-summary',
241      default=False,
242      action='store_true',
243      help='Do not output the total number of classes in each Status.')
244  parser.add_argument(
245      '--status',
246      type=str,
247      default='RetryVerificationAtRuntime',
248      choices=STATUSES,
249      help='Which category of classes to list at the end of the script')
250  parser.add_argument(
251      '--workdir',
252      '-w',
253      type=os.path.realpath,
254      default=None,
255      help=('Work directory for oatdump output (default = temporary '
256            'directory). If specified, this will not be cleaned up at the end '
257            'of the script (useful if you want to inspect oatdump output '
258            'manually)'))
259
260  script_common.AddEnvironmentArguments(parser)
261  script_common.AddDeviceArguments(parser)
262  logging_common.AddLoggingArguments(parser)
263
264  args = parser.parse_args()
265  devil_chromium.Initialize(adb_path=args.adb_path)
266  logging_common.InitializeLogging(args)
267
268  if args.workdir:
269    if not os.path.isdir(args.workdir):
270      raise RuntimeError('Specified working directory does not exist')
271    RealMain(args.mapping, args.devices, args.package, args.status,
272             args.hide_summary, args.workdir)
273    # Assume the user wants the workdir to persist (useful for debugging).
274    logging.warn('Not cleaning up explicitly-specified workdir: %s',
275                 args.workdir)
276  else:
277    with tempfile_ext.NamedTemporaryDirectory() as workdir:
278      RealMain(args.mapping, args.devices, args.package, args.status,
279               args.hide_summary, workdir)
280
281
282if __name__ == '__main__':
283  main()
284