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

"""Checks the number of static initializers in an APK's library."""

from __future__ import print_function

import argparse
import os
import re
import subprocess
import sys
import tempfile
import zipfile

from util import build_utils

_DUMP_STATIC_INITIALIZERS_PATH = os.path.join(build_utils.DIR_SOURCE_ROOT,
                                              'tools', 'linux',
                                              'dump-static-initializers.py')


def _RunReadelf(so_path, options, tool_prefix=''):
  return subprocess.check_output([tool_prefix + 'readelf'] + options +
                                 [so_path]).decode('utf8')


def _ParseLibBuildId(so_path, tool_prefix):
  """Returns the Build ID of the given native library."""
  stdout = _RunReadelf(so_path, ['-n'], tool_prefix)
  match = re.search(r'Build ID: (\w+)', stdout)
  return match.group(1) if match else None


def _VerifyLibBuildIdsMatch(tool_prefix, *so_files):
  if len(set(_ParseLibBuildId(f, tool_prefix) for f in so_files)) > 1:
    raise Exception('Found differing build ids in output directory and apk. '
                    'Your output directory is likely stale.')


def _GetStaticInitializers(so_path, tool_prefix):
  output = subprocess.check_output(
      [_DUMP_STATIC_INITIALIZERS_PATH, '-d', so_path, '-t', tool_prefix],
      encoding='utf-8')
  summary = re.search(r'Found \d+ static initializers in (\d+) files.', output)
  return output.splitlines()[:-1], int(summary.group(1))


def _PrintDumpSIsCount(apk_so_name, unzipped_so, out_dir, tool_prefix):
  lib_name = os.path.basename(apk_so_name).replace('crazy.', '')
  so_with_symbols_path = os.path.join(out_dir, 'lib.unstripped', lib_name)
  if not os.path.exists(so_with_symbols_path):
    raise Exception('Unstripped .so not found. Looked here: %s',
                    so_with_symbols_path)
  _VerifyLibBuildIdsMatch(tool_prefix, unzipped_so, so_with_symbols_path)
  sis, _ = _GetStaticInitializers(so_with_symbols_path, tool_prefix)
  for si in sis:
    print(si)


# Mostly copied from //infra/scripts/legacy/scripts/slave/chromium/sizes.py.
def _ReadInitArray(so_path, tool_prefix, expect_no_initializers):
  stdout = _RunReadelf(so_path, ['-SW'], tool_prefix)
  # Matches: .init_array INIT_ARRAY 000000000516add0 5169dd0 000010 00 WA 0 0 8
  match = re.search(r'\.init_array.*$', stdout, re.MULTILINE)
  if expect_no_initializers:
    if match:
      raise Exception(
          'Expected no initializers for %s, yet some were found' % so_path)
    else:
      return 0
  elif not match:
    raise Exception('Did not find section: .init_array in {}:\n{}'.format(
        so_path, stdout))
  size_str = re.split(r'\W+', match.group(0))[5]
  return int(size_str, 16)


def _CountStaticInitializers(so_path, tool_prefix, expect_no_initializers):
  # Find the number of files with at least one static initializer.
  # First determine if we're 32 or 64 bit
  stdout = _RunReadelf(so_path, ['-h'], tool_prefix)
  elf_class_line = re.search('Class:.*$', stdout, re.MULTILINE).group(0)
  elf_class = re.split(r'\W+', elf_class_line)[1]
  if elf_class == 'ELF32':
    word_size = 4
  else:
    word_size = 8

  # Then find the number of files with global static initializers.
  # NOTE: this is very implementation-specific and makes assumptions
  # about how compiler and linker implement global static initializers.
  init_array_size = _ReadInitArray(so_path, tool_prefix, expect_no_initializers)
  return init_array_size / word_size


def _AnalyzeStaticInitializers(apk_or_aab, tool_prefix, dump_sis, out_dir,
                               ignored_libs, no_initializers_libs):
  # Static initializer counting mostly copies logic in
  # infra/scripts/legacy/scripts/slave/chromium/sizes.py.
  with zipfile.ZipFile(apk_or_aab) as z:
    so_files = [
        f for f in z.infolist() if f.filename.endswith('.so')
        and f.file_size > 0 and os.path.basename(f.filename) not in ignored_libs
    ]
    # Skip checking static initializers for secondary abi libs. They will be
    # checked by 32-bit bots. This avoids the complexity of finding 32 bit .so
    # files in the output directory in 64 bit builds.
    has_64 = any('64' in f.filename for f in so_files)
    files_to_check = [f for f in so_files if not has_64 or '64' in f.filename]

    # Do not check partitioned libs. They have no ".init_array" section since
    # all SIs are considered "roots" by the linker, and so end up in the base
    # module.
    files_to_check = [
        f for f in files_to_check if not f.filename.endswith('_partition.so')
    ]

    si_count = 0
    for f in files_to_check:
      lib_basename = os.path.basename(f.filename)
      expect_no_initializers = lib_basename in no_initializers_libs
      with tempfile.NamedTemporaryFile(prefix=lib_basename) as temp:
        temp.write(z.read(f))
        temp.flush()
        si_count += _CountStaticInitializers(temp.name, tool_prefix,
                                             expect_no_initializers)
        if dump_sis:
          # Print count and list of SIs reported by dump-static-initializers.py.
          # Doesn't work well on all archs (particularly arm), which is why
          # the readelf method is used for tracking SI counts.
          _PrintDumpSIsCount(f.filename, temp.name, out_dir, tool_prefix)
  return si_count


def main():
  parser = argparse.ArgumentParser()
  parser.add_argument('--touch', help='File to touch upon success')
  parser.add_argument('--tool-prefix', required=True,
                      help='Prefix for nm and friends')
  parser.add_argument('--expected-count', required=True, type=int,
                      help='Fail if number of static initializers is not '
                           'equal to this value.')
  parser.add_argument('apk_or_aab', help='Path to .apk or .aab file.')
  args = parser.parse_args()

  # TODO(crbug.com/838414): add support for files included via loadable_modules.
  ignored_libs = {
      'libarcore_sdk_c.so', 'libcrashpad_handler_trampoline.so',
      'libsketchology_native.so'
  }
  # The chromium linker doesn't have static initializers, which makes the
  # regular check throw. It should not have any.
  no_initializers_libs = ['libchromium_android_linker.so']

  si_count = _AnalyzeStaticInitializers(args.apk_or_aab, args.tool_prefix,
                                        False, '.', ignored_libs,
                                        no_initializers_libs)
  if si_count != args.expected_count:
    print('Expected {} static initializers, but found {}.'.format(
        args.expected_count, si_count))
    if args.expected_count > si_count:
      print('You have removed one or more static initializers. Thanks!')
      print('To fix the build, update the expectation in:')
      print('    //chrome/android/static_initializers.gni')
    else:
      print('Dumping static initializers via dump-static-initializers.py:')
      sys.stdout.flush()
      _AnalyzeStaticInitializers(args.apk_or_aab, args.tool_prefix, True, '.',
                                 ignored_libs, no_initializers_libs)
      print()
      print('If the above list is not useful, consider listing them with:')
      print('    //tools/binary_size/diagnose_bloat.py')
      print()
      print('For more information:')
      print('    https://chromium.googlesource.com/chromium/src/+/main/docs/'
            'static_initializers.md')
    sys.exit(1)

  if args.touch:
    open(args.touch, 'w')


if __name__ == '__main__':
  main()
