205 lines
6.7 KiB
Python
205 lines
6.7 KiB
Python
|
#!/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()
|