#!/usr/bin/env python3
#
# Copyright 2023 The Cobalt Authors. All Rights Reserved.
#
# 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.
"""Detects incorrect standard library uses in the Cobalt layer.

It uses the 'nm' tool to look at the weak/undefined symbols
in the resulting cobalt shared library. Since everything should go through
Starboard, most weak or undefined symbols are "Starboard leaks" where we are
accidentally calling the system libraries when we shouldn't.

This script will load a file that contains a list of Allowed C99 Symbols that
are not considered leaks. Additionally, it will also try to read in a list of
libraries to ignore when searching for leaks. These symbols and libraries cannot
be overridden and the only way to include them in the output is to remove them
from these files.

This script can also make use of a manifest file: the set of currently known
leaking symbols for the build. This script can also be used to update the
manifest. --manifest can be used to output an updated manifest and the output
can be redirected to the build's manifest file.

Exits with 1 if run in --submit-check mode and any leaks are either introduced
or removed compared with the manifest, otherwise exits with 0.
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import argparse
import collections
import os
import re
import subprocess
import sys

from starboard.tools import paths

# pylint: disable=line-too-long
_API_LEAK_DETECTOR_TITLE = """    ___    ____  ____   __               __      ____       __            __
   /   |  / __ \\/  _/  / /   ___  ____ _/ /__   / __ \\___  / /____  _____/ /_____  _____
  / /| | / /_/ // /   / /   / _ \\/ __ `/ //_/  / / / / _ \\/ __/ _ \\/ ___/ __/ __ \\/ ___/
 / ___ |/ ____// /   / /___/  __/ /_/ / ,<    / /_/ /  __/ /_/  __/ /__/ /_/ /_/ / /
/_/  |_/_/   /___/  /_____/\\___/\\__,_/_/|_|  /_____/\\___/\\__/\\___/\\___/\\__/\\____/_/
"""
# pylint: enable=line-too-long

# Leaks will be checked against this build.

_DEFAULT_PLATFORM = 'evergreen-x64'
_DEFAULT_CONFIG = 'gold'
_DEFAULT_TARGET = 'cobalt'

_RE_LIB = re.compile(r'lib.*\.a$')
_RE_FILE = re.compile(r'\/\/.*\.[hcp]+$')
_RE_SYMBOL_AND_ANY_VERSION_INFO = re.compile(r'(([^@]+)@@?(.+))|([^@]+)')
_RE_ALLOWED_C99_SYMBOL = re.compile(r'^\*\s(.*)$')

# Manifests can be automatically generated by the tool but files for allowlisted
# libraries and C99 symbols should be edited by hand.
_LIBRARIES_TO_IGNORE_PATH = os.path.join(
    os.path.dirname(__file__), 'libraries_to_ignore')
_ALLOWED_C99_SYMBOLS_PATH = os.path.join(paths.STARBOARD_ROOT, 'doc', 'c99.md')
_DEFAULT_RELATIVE_MANIFEST_PATH = os.path.join('evergreen', 'manifest')

# Messages placed at the header of auto-generated files.
_MANIFEST_HEADER = ('# Manifest of Leaking Files\n\n' +
                    '# This file was auto-generated using ' +
                    'api_leak_detector.py.\n')

# Values to stand in for unknown libraries and source files.
_UNKNOWN_LIBRARIES = 'unknown_library(ies)'
_UNKNOWN_SOURCE_FILES = 'unknown_source_file(s)'


def DiffWithManifest(leaked_symbols, manifest_path):
  manifest_symbols = LoadManifest(manifest_path)
  introduced = leaked_symbols.difference(manifest_symbols)
  removed = manifest_symbols.difference(leaked_symbols)
  return introduced, removed


def FindLibraries(config_path):
  """Returns all non-ignored static libraries in the build config directory."""
  print('Loading libraries to ignore...', file=sys.stderr)
  libs_to_ignore = LoadLibrariesToIgnore()

  libs = []
  for root, dirs, filenames in os.walk(config_path):
    # Only look in toplevel obj directory.
    if root == config_path:
      dirs[:] = [d for d in dirs if d == 'obj']
    for filename in filenames:
      if _RE_LIB.match(filename) and filename not in libs_to_ignore:
        libs.append(os.path.join(root, filename))
  return libs


def FindLeakingSourceFiles(leaking_symbols, nm_output, config_dir):
  r"""Collects and formats all filenames that have leaks in the nm output.

  This function works by iterating through the provided output, keeping track
  of the current file it is in by maintaining a filename variable. When a
  valid leaking symbol is found, it will add the current filename to the set
  of files that contain the specified leak. Once the entire output has been
  iterated across, the dictionary of leaks to sets of files is returned.

  The absolute path of every file, such as:

    /usr/local/google/home/chadduffin/cobalt/src/out/ \
        evergreen-x64-sbversion-12_gold/obj/third_party/boringssl/src/crypto/ \
            crypto.thread.c.o

  will be stripped so that it becomes:

    //third_party/boringssl/src/crypto/thread.c

  Args:
    leaking_symbols: A list of all of the symbols that are valid leaks.
    nm_output: The output from 'nm -u' being ran against a static library.
    config_dir: Config build directory

  Returns:
    A dictionary that maps symbols to a set of filenames that are leaking the
    symbol.
  """
  files = {}
  filename = None
  for line in ProcessNmOutput(nm_output, True):
    if config_dir in line:

      # See the description above to understand what the string manipulation
      # below is accomplishing.
      try:
        filename = '//' + line.split(f'/{config_dir}/obj/')[1]
      except IndexError as e:
        print(line)
        raise e
      if filename.endswith('.asm.o:'):
        filename = re.sub(r':$', '', filename)
        continue
      filename = re.sub(r'\.o:$', '', filename)
      filename = re.sub(r'/\w+\.', '/', filename)
      continue
    elif filename and line and line in leaking_symbols:
      files_set = files.get(line, set())
      files_set.add(filename)
      files[line] = files_set
  return files


def InversedKeys(nested_dict):
  """Returns a copy of a 2D dict with its inner and outer keys swapped."""
  inverse = collections.defaultdict(dict)
  for outer_key, outer_value in nested_dict.items():
    for inner_key, inner_value in outer_value.items():
      inverse[inner_key][outer_key] = inner_value

  return inverse


def FindLeakLocations(leaked_symbols, config_path):
  """Returns the static libraries and source files leaked APIs are used in.

  Args:
    leaked_symbols: The set of leaked symbols found in the Cobalt layer.
    config_path: The path to the build config directory.

  Returns:
    A dict from leaked symbol to dict from static library to the set of source
    files using the leak.
  """
  config_dir = os.path.basename(config_path)
  leaking_files = {}
  libs_to_symbols = {}

  print('Collecting static libraries...', file=sys.stderr)
  libs = FindLibraries(config_path)

  print('Searching the static libraries for leaks...', file=sys.stderr)
  for lib in libs:
    print(lib)
    libname = os.path.basename(lib)
    nm_output = RunCommand(['nm', '-u', lib])
    libs_to_symbols[libname] = set()
    for symbol in ProcessNmOutput(nm_output):
      if symbol not in leaked_symbols:
        continue
      libs_to_symbols[libname].add(symbol)
    if not libs_to_symbols[libname]:
      del libs_to_symbols[libname]
      continue
    leaking_files[libname] = FindLeakingSourceFiles(libs_to_symbols[libname],
                                                    nm_output, config_dir)
    if not leaking_files[libname]:
      del leaking_files[libname]

  # It's possible for something that is not a Cobalt static library to
  # contribute leaked symbols. For example, some static libraries come
  # preinstalled on Linux.
  leaked_with_libraries = {s for v in libs_to_symbols.values() for s in v}
  leaked_without_libraries = leaked_symbols.difference(leaked_with_libraries)
  if leaked_without_libraries:
    leaking_files[_UNKNOWN_LIBRARIES] = {
        symbol: {_UNKNOWN_SOURCE_FILES} for symbol in leaked_without_libraries
    }

  return InversedKeys(leaking_files)


def LoadAllowedC99Symbols():
  allowed_c99_symbols = set()

  with open(_ALLOWED_C99_SYMBOLS_PATH, encoding='utf-8') as f:
    for line in f:
      line = line.strip()
      m = _RE_ALLOWED_C99_SYMBOL.match(line)
      if m:
        allowed_c99_symbols.add(m.group(1))

  return allowed_c99_symbols


def LoadManifest(manifest_path):
  symbols = set()

  with open(manifest_path, encoding='utf-8') as f:
    for line in f:
      line = line.strip()
      if line and line[0] != '#':
        symbols.add(line)

  return symbols


def LoadLibrariesToIgnore():
  # We should *always* ignore libstarboard_platform.a.
  libs = set(['libstarboard_platform.a'])

  # Read in the ignored libraries.
  try:
    with open(_LIBRARIES_TO_IGNORE_PATH, 'r', encoding='utf-8') as f:
      for line in f:
        line = line.strip()
        if not line or line[0] == '#':
          continue
        else:
          libs.add(line)
  except IOError as e:
    print(f"Error opening {_LIBRARIES_TO_IGNORE_PATH}: '{e}'", file=sys.stderr)
    print('Continuing...', file=sys.stderr)
  return libs


def ParseArgs():
  """Parse all of the arguments provided on the command line."""
  parser = argparse.ArgumentParser()
  mode = parser.add_mutually_exclusive_group(required=True)
  mode.add_argument(
      '--submit-check',
      help='Checks if any leaked symbols are entirely introduced or removed.',
      action='store_true')
  mode.add_argument(
      '--inspect',
      help='Outputs all currently leaking symbols and their uses.',
      action='store_true')
  mode.add_argument(
      '--manifest',
      help='Outputs all currently leaking symbols.',
      action='store_true')
  parser.add_argument(
      '-p',
      '--platform',
      default=_DEFAULT_PLATFORM,
      help="Device platform, eg 'evergreen-x64-sbversion-12'.")
  parser.add_argument(
      '-c',
      '--config',
      default=_DEFAULT_CONFIG,
      help="Build config (eg, 'gold','qa' or 'devel')")
  parser.add_argument(
      '-t',
      '--target',
      help="Build target to check (eg, 'cobalt' or 'd8')",
      default=_DEFAULT_TARGET)
  parser.add_argument(
      '--relative-manifest-path',
      help='Path to the manifest to use, relative to api_leak_detector dir.',
      default=_DEFAULT_RELATIVE_MANIFEST_PATH)
  return parser.parse_args()


def PrettyPrint(output, indent=0, inc=2, file=sys.stderr):
  """Alternative to pprint that produces better output for our use case."""
  if isinstance(output, dict):
    if indent == 0:
      newline = 1
    else:
      newline = 0
    for key, value in sorted(output.items()):
      print('{}{}{}'.format('\n' * newline, ' ' * indent, key), file=file)
      PrettyPrint(value, indent + inc)
  elif isinstance(output, set):
    for value in sorted(output):
      print(f"{' ' * indent}{value}", file=file)
  else:
    print(f"{' ' * indent}{output}", file=file)


def ProcessNmOutput(nm_output, collect_files=False):
  """Parses the 'nm' output and collects the matched and unresolved symbols.

  This function retains all of the allowlisted libraries that were loaded on
  startup.

  Args:
    nm_output: The output from a previously ran 'nm -u' command.
    collect_files: Identifies whether or not to include files in the results.
      This increases the reusability of the function (see
      FindLeakingSourceFiles).

  Yields:
    Unresolved symbols that match _RE_SYMBOL_AND_ANY_VERSION_INFO.
  """
  for line in nm_output.decode('utf-8').splitlines():
    line = line.lstrip()
    contents = line.split(' ')
    if len(contents) != 2:
      if len(contents) == 1 and collect_files:
        yield contents[0]
      continue
    results = _RE_SYMBOL_AND_ANY_VERSION_INFO.match(contents[1]).groups()
    if results[3]:  # True if the symbol has no associated version information.
      yield results[3]
    elif results[1]:  # True if the symbol does have version information.
      yield results[1]
    else:
      print(f'Invalid line in nm output: {line}', file=sys.stderr)


def RunCommand(args):
  """Executes a command with the given arguments and returns the output."""
  return subprocess.check_output(args)


def main():
  args = ParseArgs()
  config_dir = f'{args.platform}_{args.config}'
  config_path = os.path.join(paths.BUILD_OUTPUT_ROOT, config_dir)
  manifest_path = os.path.join(
      os.path.dirname(__file__), args.relative_manifest_path)

  print(_API_LEAK_DETECTOR_TITLE, file=sys.stderr)

  print('Loading allowed C99 symbols...', file=sys.stderr)
  allowed_c99_symbols = LoadAllowedC99Symbols()

  print(f'Building {config_dir} if necessary...', file=sys.stderr)
  RunCommand(['ninja', '-j256', '-C', config_path, args.target])

  # First try using the GYP shared library path 'lib/libcobalt.so'.
  # TODO(b/211885836): stop considering the GYP shared library path once all
  # relevant GYP builders are removed from CI.
  binary_path = os.path.join(config_path, 'lib', f'lib{args.target}.so')
  # Then fall back on the GN shared library path 'libcobalt.so'.
  if not os.path.exists(binary_path):
    binary_path = os.path.join(config_path, f'lib{args.target}.so')
  # Then fall back on the executable path 'cobalt', used by both GYP and GN.
  if not os.path.exists(binary_path):
    binary_path = os.path.join(config_path, format(args.target))
  print(f'Analyzing: {binary_path}', file=sys.stderr)

  # Get all of the unresolved symbols of the binary.
  nm_output = RunCommand([
      'nm',
      '-u',
      '-C',  # Demangle C++ symbols
      binary_path
  ])

  # Build a set from all of the unresolved symbols that have the allowlisted and
  # Starboard functions and extern configuration variables filtered out.
  def IsSbSymbol(symbol):
    return symbol.startswith('Sb') or symbol.startswith('kSb')

  leaked_symbols = set(
      symbol for symbol in ProcessNmOutput(nm_output) \
          if symbol not in allowed_c99_symbols and not IsSbSymbol(symbol)
  )

  if args.manifest:
    print('Done!', file=sys.stderr)
    print(_MANIFEST_HEADER)
    PrettyPrint(leaked_symbols, file=sys.stdout)
    return 0

  if args.submit_check:
    introduced, removed = DiffWithManifest(leaked_symbols, manifest_path)
    if introduced:
      PrettyPrint(
          {'Leaks introduced:': FindLeakLocations(introduced, config_path)})
      print(
          '\nPlease see advice for addressing new leaks at go/cobalt-api-leaks.'
      )
    else:
      print('\nNo leaks were introduced.', file=sys.stderr)

    if removed:
      PrettyPrint({'Leaks removed:': removed})
      print('\nPlease delete removed leaks from the manifest file.')
    else:
      print('No leaks were removed.', file=sys.stderr)

    # We counterintuitively still want to fail even if leaks are only removed
    # so that a CL author knows to update the manifest.
    return 1 if introduced or removed else 0

  if args.inspect:
    if leaked_symbols:
      PrettyPrint(FindLeakLocations(leaked_symbols, config_path))
    else:
      print('No leaks found!', file=sys.stderr)
    return 0


if __name__ == '__main__':
  sys.exit(main())
