| #!/usr/bin/python3 |
| # |
| # Copyright (C) 2021 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| # |
| # Generates profiles from the set of all methods in a given set of dex/jars and |
| # bisects to find minimal repro sets. |
| # |
| |
| import shlex |
| import argparse |
| import pylibdexfile |
| import math |
| import subprocess |
| from collections import namedtuple |
| import sys |
| import random |
| import os |
| |
| ApkEntry = namedtuple("ApkEntry", ["file", "location"]) |
| |
| |
| def get_parser(): |
| parser = argparse.ArgumentParser( |
| description="Bisect profile contents. We will wait while the user runs test" |
| ) |
| |
| class ApkAction(argparse.Action): |
| |
| def __init__(self, option_strings, dest, **kwargs): |
| super(ApkAction, self).__init__(option_strings, dest, **kwargs) |
| |
| def __call__(self, parser, namespace, values, option_string=None): |
| lst = getattr(namespace, self.dest) |
| if lst is None: |
| setattr(namespace, self.dest, []) |
| lst = getattr(namespace, self.dest) |
| if len(values) == 1: |
| values = (values[0], values[0]) |
| assert len(values) == 2, values |
| lst.append(ApkEntry(*values)) |
| |
| apks = parser.add_argument_group(title="APK selection") |
| apks.add_argument( |
| "--apk", |
| action=ApkAction, |
| dest="apks", |
| nargs=1, |
| default=[], |
| help="an apk/dex/jar to get methods from. Uses same path as location. " + |
| "Use --apk-and-location if this isn't desired." |
| ) |
| apks.add_argument( |
| "--apk-and-location", |
| action=ApkAction, |
| nargs=2, |
| dest="apks", |
| help="an apk/dex/jar + location to get methods from." |
| ) |
| profiles = parser.add_argument_group( |
| title="Profile selection").add_mutually_exclusive_group() |
| profiles.add_argument( |
| "--input-text-profile", help="a text profile to use for bisect") |
| profiles.add_argument("--input-profile", help="a profile to use for bisect") |
| parser.add_argument( |
| "--output-source", help="human readable file create the profile from") |
| parser.add_argument("--test-exec", help="file to exec (without arguments) to test a" + |
| " candidate. Test should exit 0 if the issue" + |
| " is not present and non-zero if the issue is" + |
| " present.") |
| parser.add_argument("output_file", help="file we will write the profiles to") |
| return parser |
| |
| |
| def dump_files(meths, args, output): |
| for m in meths: |
| print("HS{}".format(m), file=output) |
| output.flush() |
| profman_args = [ |
| "profmand", "--reference-profile-file={}".format(args.output_file), |
| "--create-profile-from={}".format(args.output_source) |
| ] |
| print(" ".join(map(shlex.quote, profman_args))) |
| for apk in args.apks: |
| profman_args += [ |
| "--apk={}".format(apk.file), "--dex-location={}".format(apk.location) |
| ] |
| profman = subprocess.run(profman_args) |
| profman.check_returncode() |
| |
| |
| def get_answer(args): |
| if args.test_exec is None: |
| while True: |
| answer = input("Does the file at {} cause the issue (y/n):".format( |
| args.output_file)) |
| if len(answer) >= 1 and answer[0].lower() == "y": |
| return "y" |
| elif len(answer) >= 1 and answer[0].lower() == "n": |
| return "n" |
| else: |
| print("Please enter 'y' or 'n' only!") |
| else: |
| test_args = shlex.split(args.test_exec) |
| print(" ".join(map(shlex.quote, test_args))) |
| answer = subprocess.run(test_args) |
| if answer.returncode == 0: |
| return "n" |
| else: |
| return "y" |
| |
| def run_test(meths, args): |
| with open(args.output_source, "wt") as output: |
| dump_files(meths, args, output) |
| print("Currently testing {} methods. ~{} rounds to go.".format( |
| len(meths), 1 + math.floor(math.log2(len(meths))))) |
| return get_answer(args) |
| |
| def main(): |
| parser = get_parser() |
| args = parser.parse_args() |
| if args.output_source is None: |
| fdnum = os.memfd_create("tempfile_profile") |
| args.output_source = "/proc/{}/fd/{}".format(os.getpid(), fdnum) |
| all_dexs = list() |
| for f in args.apks: |
| try: |
| all_dexs.append(pylibdexfile.FileDexFile(f.file, f.location)) |
| except Exception as e1: |
| try: |
| all_dexs += pylibdexfile.OpenJar(f.file) |
| except Exception as e2: |
| parser.error("Failed to open file: {}. errors were {} and {}".format( |
| f.file, e1, e2)) |
| if args.input_profile is not None: |
| profman_args = [ |
| "profmand", "--dump-classes-and-methods", |
| "--profile-file={}".format(args.input_profile) |
| ] |
| for apk in args.apks: |
| profman_args.append("--apk={}".format(apk.file)) |
| print(" ".join(map(shlex.quote, profman_args))) |
| res = subprocess.run( |
| profman_args, capture_output=True, universal_newlines=True) |
| res.check_returncode() |
| meth_list = list(filter(lambda a: a != "", res.stdout.split())) |
| elif args.input_text_profile is not None: |
| with open(args.input_text_profile, "rt") as inp: |
| meth_list = list(filter(lambda a: a != "", inp.readlines())) |
| else: |
| all_methods = set() |
| for d in all_dexs: |
| for m in d.methods: |
| all_methods.add(m.descriptor) |
| meth_list = list(all_methods) |
| print("Found {} methods. Will take ~{} iterations".format( |
| len(meth_list), 1 + math.floor(math.log2(len(meth_list))))) |
| print( |
| "type 'yes' if the behavior you are looking for is present (i.e. the compiled code crashes " + |
| "or something)" |
| ) |
| print("Performing single check with all methods") |
| result = run_test(meth_list, args) |
| if result[0].lower() != "y": |
| cont = input( |
| "The behavior you were looking for did not occur when run against all methods. Continue " + |
| "(yes/no)? " |
| ) |
| if cont[0].lower() != "y": |
| print("Aborting!") |
| sys.exit(1) |
| needs_dump = False |
| while len(meth_list) > 1: |
| test_methods = list(meth_list[0:len(meth_list) // 2]) |
| result = run_test(test_methods, args) |
| if result[0].lower() == "y": |
| meth_list = test_methods |
| needs_dump = False |
| else: |
| meth_list = meth_list[len(meth_list) // 2:] |
| needs_dump = True |
| if needs_dump: |
| with open(args.output_source, "wt") as output: |
| dump_files(meth_list, args, output) |
| print("Found result!") |
| print("{}".format(meth_list[0])) |
| print("Leaving profile at {} and text profile at {}".format( |
| args.output_file, args.output_source)) |
| |
| |
| if __name__ == "__main__": |
| main() |