#!/usr/bin/env python3

import subprocess
import tempfile
import os
import re
import sys


def get_public_symbols_from_shared_object(prefix):
    """
    Find public (exported) symbols in a shared library (*.so) by calling
    the 'nm' command.
    :param prefix: CMAKE_INSTALL_PREFIX
    :return: a set of public symbols
    """
    so_path = os.path.join(prefix, "lib64", "libopenscap.so")
    nm_command = ["nm", "--demangle", "--dynamic", "--defined-only", so_path]
    nm_output_bytes = subprocess.check_output(nm_command)
    nm_output = nm_output_bytes.decode(encoding="utf-8")
    symbols = {line.split()[2] for line in nm_output.splitlines()}
    symbols = symbols.difference({"__bss_start", "_edata", "_end", "_fini",
                                  "_init"})
    return symbols


def get_public_headers(prefix):
    """
    It is useful to check if all the public headers are actually installed
    under $PREFIX/include/openscap.
    :param prefix: CMAKE_INSTALL_PREFIX
    :return: a set of public headers filepaths
    """
    public_headers = set()
    include_path = os.path.join(prefix, "include", "openscap")
    for root, dirs, files in os.walk(include_path):
        for f in files:
            full_path = os.path.join(root, f)
            public_headers.add(full_path)
    return public_headers


def get_public_symbols_from_header(header_path):
    """
    Parses a header file to find symbols marked by OSCAP_API macro.
    :param header_path: path to the header file
    :return: a list of public symbols in the header file
    """
    symbols = set()
    prototype_regex = re.compile(r"^[ \t]*OSCAP_API[^;]*;", re.MULTILINE)
    with open(header_path, "r") as header_file:
        content = header_file.read()
    prototypes = prototype_regex.findall(content)
    function_name_regex = re.compile(r"([a-zA-Z0-9_]+)\s*\(.*\)")
    data_name_regex = re.compile(r"\s+([a-zA-Z0-9_]+)\s*;")
    for p in prototypes:
        p = p.replace("\n", " ")
        match = function_name_regex.search(p)
        if not match:
            match = data_name_regex.search(p)
        if not match:
            print("Invalid prototype '%s'" % p, file=sys.stderr)
            continue
        symbol_name = match.group(1)
        symbols.add(symbol_name)
    return symbols


def get_public_symbols_from_headers(headers):
    """
    Find public (exported) symbols in header files by finding symbols
    marked as OSCAP_API.
    :param headers: a list of public headers file paths
    :return: a set of public symbols in public headers
    """
    symbols = set()
    for h in headers:
        symbols.update(get_public_symbols_from_header(h))
    return symbols


def _run_command(command):
    try:
        subprocess.run(command, check=True,
                       stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                       encoding="utf-8")
    except subprocess.CalledProcessError as e:
        print(e.output)
        print(e.stderr, file=sys.stderr)
        raise RuntimeError


def analyze_project_artifacts(src_dir):
    """
    Analyze the project and find all the symbols declared as public in
    public header files and all the symbols actually exported in built
    library. This is done on a real build and a real installation.
    The artifacts are installed in a temporary directory which is
    automatically removed when this function returns.
    :param src_dir: Path to project sources
    :return: tuple (header_symbols, so_symbols)
    """
    cwd = os.getcwd()
    build_dir = os.path.normpath(os.path.join(src_dir, "build"))
    build_dir_contents = os.listdir(build_dir)
    if ".gitkeep" in build_dir_contents:
        build_dir_contents.remove(".gitkeep")
    if build_dir_contents:
        print("Directory '%s' is not empty" % build_dir, file=sys.stderr)
        raise RuntimeError
    os.chdir(build_dir)
    with tempfile.TemporaryDirectory() as prefix:
        cmake_command = ["cmake",
                         "-DENABLE_PYTHON3=FALSE",
                         "-DENABLE_PERL=FALSE",
                         "-DCMAKE_INSTALL_PREFIX=" + prefix,
                         "-DENABLE_TESTS=FALSE",
                         ".."]

        _run_command(cmake_command)
        make_command = ["make"]
        _run_command(make_command)
        make_docs_command = ["make", "docs"]
        _run_command(make_docs_command)
        make_install_command = ["make", "install"]
        _run_command(make_install_command)
        public_headers = get_public_headers(prefix)
        so_symbols = get_public_symbols_from_shared_object(prefix)
        header_symbols = get_public_symbols_from_headers(public_headers)
    os.chdir(cwd)
    return header_symbols, so_symbols


def main():
    src_dir = os.path.join(os.getcwd(), "..")

    try:
        header_symbols, so_symbols = analyze_project_artifacts(src_dir)
    except RuntimeError as e:
        print("Could not analyze OpenSCAP public API", file=sys.stderr)
        sys.exit(1)

    print("Shared object symbols: %d" % len(so_symbols))
    print("Public header symbols: %d\n" % len(header_symbols))
    print()

    so_only = so_symbols.difference(header_symbols)
    if so_only:
        print("The following %d symbols are exported in binary, "
              "but are not present in public header files:" % len(so_only))
        for s in sorted(so_only):
            print(s)
        print()

    header_only = header_symbols.difference(so_symbols)
    if header_only:
        print("The following %d symbols are present in public header files, "
              "but are not exported in binary:" % len(header_only))
        for s in sorted(header_only):
            print(s)


if __name__ == "__main__":
    main()
