#!/usr/bin/env vpython3
# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""A helper script to list class verification errors.

This is a wrapper around the device's oatdump executable, parsing desired output
and accommodating API-level-specific details, such as file paths.
"""

import argparse
import dataclasses  # pylint: disable=wrong-import-order
import logging
import os
import re

import devil_chromium
from devil.android import device_errors
from devil.android import device_temp_file
from devil.android import device_utils
from devil.android.ndk import abis
from devil.android.sdk import version_codes
from devil.android.tools import script_common
from devil.utils import logging_common
from py_utils import tempfile_ext

STATUSES = [
    'NotReady',
    'RetryVerificationAtRuntime',
    'Verified',
    'Initialized',
    'SuperclassValidated',
]


def DetermineDeviceToUse(devices):
    """Like DeviceUtils.HealthyDevices(), but only allow a single device.

    Args:
      devices: A (possibly empty) list of serial numbers, such as from the
          --device flag.
    Returns:
      A single device_utils.DeviceUtils instance.
    Raises:
      device_errors.NoDevicesError: Raised when no non-denylisted devices exist.
      device_errors.MultipleDevicesError: Raise when multiple devices exist, but
          |devices| does not distinguish which to use.
    """
    if not devices:
        # If the user did not specify which device, we let HealthyDevices raise
        # MultipleDevicesError.
        devices = None
    usable_devices = device_utils.DeviceUtils.HealthyDevices(device_arg=devices)
    # If the user specified more than one device, we still only want to support a
    # single device, so we explicitly raise MultipleDevicesError.
    if len(usable_devices) > 1:
        raise device_errors.MultipleDevicesError(usable_devices)
    return usable_devices[0]


class DeviceOSError(Exception):
    """Raised when a file is missing from the device, or something similar."""


class UnsupportedDeviceError(Exception):
    """Raised when the device is not supported by this script."""


def _GetFormattedArch(device):
    abi = device.product_cpu_abi
    # Some architectures don't map 1:1 with the folder names.
    return {abis.ARM_64: 'arm64', abis.ARM: 'arm'}.get(abi, abi)


def FindOdexFiles(device, package_name):
    """Gets the full paths to the dex files on the device."""
    sdk_level = device.build_version_sdk
    paths_to_apk = device.GetApplicationPaths(package_name)
    if not paths_to_apk:
        raise DeviceOSError(
            'Could not find data directory for {}. Is it installed?'.format(
                package_name
            )
        )

    ret = []
    for path_to_apk in paths_to_apk:
        if version_codes.LOLLIPOP <= sdk_level <= version_codes.LOLLIPOP_MR1:
            # Of the form "com.example.foo-\d", where \d is a digit (usually 1 or 2).
            package_with_suffix = os.path.basename(os.path.dirname(path_to_apk))
            arch = _GetFormattedArch(device)
            dalvik_prefix = '/data/dalvik-cache/{arch}'.format(arch=arch)
            odex_file = (
                '{prefix}/data@app@{package}@base.apk@classes.dex'.format(
                    prefix=dalvik_prefix, package=package_with_suffix
                )
            )
        elif sdk_level >= version_codes.MARSHMALLOW:
            arch = _GetFormattedArch(device)
            odex_file = '{data_dir}/oat/{arch}/base.odex'.format(
                data_dir=os.path.dirname(path_to_apk), arch=arch
            )
        else:
            raise UnsupportedDeviceError(
                'Unsupported API level: {}'.format(sdk_level)
            )

        odex_file_exists = device.FileExists(odex_file)
        if odex_file_exists:
            ret.append(odex_file)
        elif sdk_level >= version_codes.PIE:
            raise DeviceOSError(
                'Unable to find odex file: you must run dex2oat on debuggable apps '
                'on >= P after installation.'
            )
        else:
            raise DeviceOSError('Unable to find odex file ' + odex_file)
    return ret


def _AdbOatDump(device, odex_file, out_file):
    """Runs oatdump on the device."""
    # Get the path to the odex file.
    with device_temp_file.DeviceTempFile(device.adb) as device_file:
        device.RunShellCommand(
            [
                'oatdump',
                '--oat-file=' + odex_file,
                '--output=' + device_file.name,
            ],
            timeout=420,
            shell=True,
            check_return=True,
        )
        device.PullFile(device_file.name, out_file, timeout=220)


@dataclasses.dataclass(order=True, frozen=True)
class JavaClass:
    """This represents a Java Class and its ART Class Verification status."""

    name: str
    verification_status: str


def _ParseMappingFile(proguard_map_file):
    """Creates a map of obfuscated names to deobfuscated names."""
    mappings = {}
    with open(proguard_map_file, 'r') as f:
        pattern = re.compile(r'^(\S+) -> (\S+):')
        for line in f:
            m = pattern.match(line)
            if m is not None:
                deobfuscated_name = m.group(1)
                obfuscated_name = m.group(2)
                mappings[obfuscated_name] = deobfuscated_name
    return mappings


def _DeobfuscateJavaClassName(dex_code_name, proguard_mappings):
    return proguard_mappings.get(dex_code_name, dex_code_name)


def FormatJavaClassName(dex_code_name, proguard_mappings):
    obfuscated_name = dex_code_name.replace('/', '.')
    if proguard_mappings is not None:
        return _DeobfuscateJavaClassName(obfuscated_name, proguard_mappings)
    return obfuscated_name


def ParseOatdump(oatdump_output, proguard_mappings):
    """Lists all Java classes in the dex along with verification status."""
    java_classes = []
    pattern = re.compile(r'\d+: L([^;]+).*\(type_idx=[^(]+\((\w+)\).*')
    for line in oatdump_output:
        m = pattern.match(line)
        if m is not None:
            name = FormatJavaClassName(m.group(1), proguard_mappings)
            # Some platform levels prefix this with "Status" while other levels do
            # not. Strip this for consistency.
            verification_status = m.group(2).replace('Status', '')
            java_classes.append(JavaClass(name, verification_status))
    return java_classes


def _PrintVerificationResults(target_status, java_classes, show_summary):
    """Prints results for user output."""
    # Sort to keep output consistent between runs.
    java_classes.sort(key=lambda c: c.name)
    d = {}
    for status in STATUSES:
        d[status] = 0

    for java_class in java_classes:
        if java_class.verification_status == target_status:
            print(java_class.name)
        if java_class.verification_status not in d:
            raise RuntimeError(
                'Unexpected status: {0}'.format(java_class.verification_status)
            )
        d[java_class.verification_status] += 1

    if show_summary:
        for status in d:
            count = d[status]
            print(
                'Total {status} classes: {num}'.format(status=status, num=count)
            )
        print('Total number of classes: {num}'.format(num=len(java_classes)))


def RealMain(mapping, device_arg, package, status, hide_summary, workdir):
    if mapping is None:
        logging.warning(
            'Skipping deobfuscation because no map file was provided.'
        )
        proguard_mappings = None
    else:
        proguard_mappings = _ParseMappingFile(mapping)
    device = DetermineDeviceToUse(device_arg)
    host_tempfile = os.path.join(workdir, 'out.dump')
    device.EnableRoot()
    odex_files = FindOdexFiles(device, package)
    java_classes = set()
    for odex_file in odex_files:
        _AdbOatDump(device, odex_file, host_tempfile)
        with open(host_tempfile, 'r') as f:
            java_classes.update(ParseOatdump(f, proguard_mappings))
    _PrintVerificationResults(status, sorted(java_classes), not hide_summary)


def main():
    parser = argparse.ArgumentParser(
        description="""
List Java classes in an APK which fail ART class verification.
"""
    )
    parser.add_argument(
        '--package',
        '-P',
        type=str,
        default=None,
        required=True,
        help='Specify the full application package name',
    )
    parser.add_argument(
        '--mapping',
        '-m',
        type=os.path.realpath,
        default=None,
        help='Mapping file for the desired APK to deobfuscate class names',
    )
    parser.add_argument(
        '--hide-summary',
        default=False,
        action='store_true',
        help='Do not output the total number of classes in each Status.',
    )
    parser.add_argument(
        '--status',
        type=str,
        default='RetryVerificationAtRuntime',
        choices=STATUSES,
        help='Which category of classes to list at the end of the script',
    )
    parser.add_argument(
        '--workdir',
        '-w',
        type=os.path.realpath,
        default=None,
        help=(
            'Work directory for oatdump output (default = temporary '
            'directory). If specified, this will not be cleaned up at the end '
            'of the script (useful if you want to inspect oatdump output '
            'manually)'
        ),
    )

    script_common.AddEnvironmentArguments(parser)
    script_common.AddDeviceArguments(parser)
    logging_common.AddLoggingArguments(parser)

    args = parser.parse_args()
    devil_chromium.Initialize(adb_path=args.adb_path)
    logging_common.InitializeLogging(args)

    if args.workdir:
        if not os.path.isdir(args.workdir):
            raise RuntimeError('Specified working directory does not exist')
        RealMain(
            args.mapping,
            args.devices,
            args.package,
            args.status,
            args.hide_summary,
            args.workdir,
        )
        # Assume the user wants the workdir to persist (useful for debugging).
        logging.warning(
            'Not cleaning up explicitly-specified workdir: %s', args.workdir
        )
    else:
        with tempfile_ext.NamedTemporaryDirectory() as workdir:
            RealMain(
                args.mapping,
                args.devices,
                args.package,
                args.status,
                args.hide_summary,
                workdir,
            )


if __name__ == '__main__':
    main()
