#!/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()