#!/usr/bin/env python
# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import mock
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
import unittest
import zipfile
sys.path.insert(
1,
os.path.join(
os.path.dirname(__file__), '..', '..', '..', '..', 'build', 'android'))
import devil_chromium
from pylib import constants
sys.path.insert(1,
constants.host_paths.ANDROID_PLATFORM_DEVELOPMENT_SCRIPTS_PATH)
import stack
# Use Python-based zipalign so that these tests can run on the Presubmit bot.
sys.path.insert(
1, os.path.join(constants.DIR_SOURCE_ROOT, 'build'))
import zip_helpers
# These tests exercise stack.py by generating fake APKs (zip-aligned archives),
# full of fake .so files (with ELF headers), and using a fake symbolizer.
#
# The symbolizer returns deterministic function descriptions given an address
# and library name, so that test cases can be easily contrived. Eg:
#
# libchrome.so at 0x174 --> chrome::Func_174 at chrome.cc:1:1
#
# All libraries generated are slightly under 4K in size (0x1000). This means
# that when a fake APK is generated, libraries within it will reside at
# consecutive 4K boundaries. Eg., an APK with libfoo.so and libbar.so:
#
# libfoo.so will reside at APK offset 0x1000
# libbar.so will reside at APK offset 0x2000
#
# Each test invokes stack.py with a given test input (fudged trace lines), runs
# them through stack.py with the fake symbolizer, grabs output, and matches it
# line-for-line with the expected output. Whitespace is ignored at that step, so
# that test expectations don't have to be column-accurate.
class FakeSymbolizer:
def __init__(self, directory):
self._lib_directory = directory
def GetSymbolInformation(self, library, address):
basename = os.path.basename(library)
local_file = os.path.join(self._lib_directory, basename)
# If the library doesn't exist, the LLVM symbolizer wrapper script
# intercepts the call and returns <UNKNOWN>.
if not os.path.exists(local_file):
return [('<UNKNOWN>', library)]
# If the address isn't in the library, LLVM symbolizer yields ??.
lib_size = os.stat(local_file).st_size
if address >= lib_size:
return [('??', '??:0:0')]
namespace = basename.split('.')[0].replace('lib', '', 1)
# Determine if the lib is a secondary ABI library, in which case, preface
# its namespace with '32'.
if 'android_clang_' in library:
namespace += '32'
method_name = '{}::Func_{:X}'.format(namespace, address)
return [(method_name, '{}.cc:1:1'.format(namespace))]
@staticmethod
def IsValidTarget(path):
# pylint: disable=unused-argument
return True
class StackDecodeTest(unittest.TestCase):
def setUp(self):
self._num_libraries = 0
self._temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self._temp_dir)
def _MakeElf(self, library):
# Make the unstripped lib directory in case stack.py looks for it.
lib_dir = os.path.dirname(library)
if not os.path.exists(lib_dir):
os.makedirs(lib_dir)
# Create a library slightly less than 4K in size, so that when added to an
# APK archive, all libraries end up on 4K boundaries. Also, make each
# library a slightly different size, since stack.py may utilize size when
# matching up libraries.
data = '\x7fELF' + ' ' * (0xE00 - self._num_libraries)
self._num_libraries += 1
with open(library, 'wb') as f:
f.write(data.encode('utf-8'))
# Build a dummy APK with native libraries in it.
def _MakeApk(self, apk, libs, apk_dir, out_dir, crazy):
apk_file = os.path.join(apk_dir, apk)
with zipfile.ZipFile(apk_file, 'w') as archive:
for lib in libs:
# Make an ELF-format .so file. The fake symbolizer will fudge functions
# for libraries that exist.
path, name = os.path.split(lib)
library_file = os.path.join(out_dir, path, 'lib.unstripped', name)
self._MakeElf(library_file)
# Add the library to the APK.
name_in_apk = 'crazy.' + lib if crazy else lib
zip_helpers.add_to_zip_hermetic(
archive,
name_in_apk,
src_path=library_file,
alignment=0x1000)
# Accept either a multi-line string or a list of strings, strip leading and
# trailing whitespace, and return the strings as a list.
def _StripLines(self, text):
if isinstance(text, str):
lines = text.splitlines()
else:
assert isinstance(text, list)
lines = text
lines = [line.strip() for line in lines]
return [line for line in lines if line]
def _RunCase(self, logcat, expected, apks, crazy=False):
# Set up staging directories.
temp = self._temp_dir
out_dir = os.path.join(temp, 'out', 'Debug')
os.makedirs(out_dir)
apk_dir = os.path.join(out_dir, 'apks')
os.makedirs(apk_dir)
input_file = os.path.join(temp, 'input.txt')
output_file = os.path.join(temp, 'output.txt')
# Create test APKs, with .so libraries in them, that are real enough to
# trick the stack decoder.
for name, libs in apks.items():
self._MakeApk(name, libs, apk_dir, out_dir, crazy)
symbolizer = FakeSymbolizer(os.path.join(out_dir, 'lib.unstripped'))
# Put the input into a temp file.
with open(input_file, 'w') as f:
input_lines = self._StripLines(logcat)
f.write('\n'.join(input_lines))
# Run the stack script and capture its stdout in a file.
# TODO(cjgrant): Figure out how to output to a stream buffer instead.
stack_script_args = [
'--output-directory',
out_dir,
'--apks-directory',
apk_dir,
input_file,
]
with open(output_file, 'w') as f:
old_stdout = sys.stdout
sys.stdout = f
try:
stack.main(stack_script_args, test_symbolizer=symbolizer)
except Exception:
pass
sys.stdout.flush()
sys.stdout = old_stdout
# Filter out all output lines before actual decoding starts.
with open(output_file, 'r') as f:
lines = f.readlines()
delimiter = [l for l in lines if 'RELADDR' in l]
if delimiter:
index = lines.index(delimiter[-1])
output_lines = lines[index + 1:]
output_lines = self._StripLines(output_lines)
else:
output_lines = []
# Tokenize the input and output so that we can ignore whitespace in the
# validation. This way a test doesn't fail if a column shifts slightly.
expected_lines = self._StripLines(expected)
expected_tokens = [line.split() for line in expected_lines]
actual_tokens = [line.split() for line in output_lines]
self.assertEqual(len(expected_tokens), len(actual_tokens))
for i in range(len(expected_tokens)):
self.assertEqual(expected_tokens[i], actual_tokens[i])
@mock.patch('stack_core._BuildIdFromElf')
def test_BasicDecoding(self, patch_build_id):
patch_build_id.side_effect = ['1', '1', '2', '2']
apks = {
'chrome.apk': ['libchrome.so', 'libfoo.so'],
}
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00000174 /path==/base.apk (offset 0x00001000)
DEBUG : #02 pc 00000274 /path==/base.apk (offset 0x00002000)
DEBUG : #03 pc 00000374 /path==/lib/arm/libchrome.so
''')
expected_decode = textwrap.dedent('''
00000174 chrome::Func_174 chrome.cc:1:1
00000274 foo::Func_274 foo.cc:1:1
00000374 chrome::Func_374 chrome.cc:1:1
''')
self._RunCase(input_trace, expected_decode, apks)
@mock.patch('stack_core._BuildIdFromElf')
def test_OutOfRangeAddresses(self, patch_build_id):
patch_build_id.side_effect = ['1', '1']
apks = {
'chrome.apk': ['libchrome.so'],
}
# Test offsets where the address is outside the range of a valid library,
# and when the offset does not correspond to a valid library.
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00777777 /path==/base.apk (offset 0x00001000)
DEBUG : #02 pc 00000374 /path==/base.apk (offset 0x00003000)
''')
expected_decode = textwrap.dedent('''
00777777 ?? ??:0:0
00000374 offset 0x00003000 /path==/base.apk
''')
self._RunCase(input_trace, expected_decode, apks)
def test_SystemLibraries(self):
apks = {
'chrome.apk': [],
}
# Here, the frames are in an on-device system library. If the trace is able
# to supply a symbol name, ensure it's preserved in the output.
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00000474 /system/lib/libart.so (art_function+40)
DEBUG : #02 pc 00000474 /system/lib/libart.so
''')
expected_decode = textwrap.dedent('''
00000474 art_function+40 /system/lib/libart.so
00000474 <UNKNOWN> /system/lib/libart.so
''')
self._RunCase(input_trace, expected_decode, apks)
@mock.patch('stack_core._BuildIdFromElf')
def test_MultiArchPrimaryAbi(self, patch_build_id):
# '_BuildIdFromElf' is invoked twice to find:
# 1. Build ID of lib in apk at the offset
# 2. Build ID of out/lib.unstripped/libmonochrome.so
patch_build_id.side_effect = ['1', '1']
apks = {
'monochrome.apk': [
'libmonochrome.so', 'android_clang_arm/libmonochrome.so'
],
}
# With both architectures present, verify that the correct ABI output and
# directory is chosen, even if identically-named libraries exist in both.
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00000174 /path==/base.apk (offset 0x00001000)
''')
expected_decode = textwrap.dedent('''
00000174 monochrome::Func_174 monochrome.cc:1:1
''')
self._RunCase(input_trace, expected_decode, apks)
@mock.patch('stack_core._BuildIdFromElf')
def test_MultiArchSecondary(self, patch_build_id):
# '_BuildIdFromElf' is invoked 3 times to find:
# 1. Build ID of lib in apk at the offset
# 2. Build ID of out/lib.unstripped/libmonochrome.so
# 3. Build ID of out/android_clang_arm/lib.unstripped/libmonochrome.so
patch_build_id.side_effect = ['1', '2', '1']
apks = {
'monochrome.apk': [
'libmonochrome.so', 'android_clang_arm/libmonochrome.so'
],
}
# With both architectures present, verify that the secondary ABI output
# directory is chosen when appropriate, even if identically-named libraries
# exist in both. This must be a different test from the primary ABI case,
# since in practice, traces are from a single ABI (and the script relies on
# this).
input_trace = textwrap.dedent('''
DEBUG : #02 pc 00000274 /path==/base.apk (offset 0x00002000)
''')
expected_decode = textwrap.dedent('''
00000274 monochrome32::Func_274 monochrome32.cc:1:1
''')
self._RunCase(input_trace, expected_decode, apks)
@mock.patch('stack_core._BuildIdFromElf')
def test_CrazyUncompressedLibraries(self, patch_build_id):
patch_build_id.side_effect = ['1', '1']
# Here, the library in the APK is prefixed with "crazy.", as in
# ChromeModern.
apks = {
'chrome.apk': ['libchrome.so'],
}
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00000174 /path==/base.apk (offset 0x00001000)
''')
expected_decode = textwrap.dedent('''
00000174 chrome::Func_174 chrome.cc:1:1
''')
self._RunCase(input_trace, expected_decode, apks, crazy=True)
def test_AndroidQ(self):
apks = {
'chrome.apk': ['libchrome.so'],
}
# Android Q helpfully prints both APK and library, so we don't need to do
# any matching, as long as we can parse this.
input_trace = textwrap.dedent('''
DEBUG : #01 pc 00000174 /path==/base.apk!libchrome.so (offset 0x00001000)
''')
expected_decode = textwrap.dedent('''
00000174 chrome::Func_174 chrome.cc:1:1
''')
self._RunCase(input_trace, expected_decode, apks)
if __name__ == '__main__':
unittest.main()