# vim: set ts=8 sts=4 et sw=4 tw=79:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

# ----------------------------------------------------------------------------
# All heap allocations in SpiderMonkey must go through js_malloc, js_calloc,
# js_realloc, and js_free.  This is so that any embedder who uses a custom
# allocator (by defining JS_USE_CUSTOM_ALLOCATOR) will see all heap allocation
# go through that custom allocator.
#
# Therefore, the presence of any calls to "vanilla" allocation/free functions
# from within SpiderMonkey itself (e.g. malloc(), free()) is a bug.  Calls from
# within mozglue and non-SpiderMonkey locations are fine; there is a list of
# exceptions that can be added to as the need arises.
#
# This script checks for the presence of such disallowed vanilla
# allocation/free function in SpiderMonkey when it's built as a library.  It
# relies on |nm| from the GNU binutils, and so only works on Linux, but one
# platform is good enough to catch almost all violations.
#
# This checking is only 100% reliable in a JS_USE_CUSTOM_ALLOCATOR build in
# which the default definitions of js_malloc et al (in Utility.h) -- which call
# malloc et al -- are replaced with empty definitions.  This is because the
# presence and possible inlining of the default js_malloc et al can cause
# malloc/calloc/realloc/free calls show up in unpredictable places.
#
# Unfortunately, that configuration cannot be tested on Mozilla's standard
# testing infrastructure.  Instead, by default this script only tests that none
# of the other vanilla allocation/free functions (operator new, memalign, etc)
# are present.  If given the --aggressive flag, it will also check for
# malloc/calloc/realloc/free.
#
# Note:  We don't check for |operator delete| and |operator delete[]|.  These
# can be present somehow due to virtual destructors, but this is not too
# because vanilla delete/delete[] calls don't make sense without corresponding
# vanilla new/new[] calls, and any explicit calls will be caught by Valgrind's
# mismatched alloc/free checking.
# ----------------------------------------------------------------------------

import argparse
import re
import subprocess
import sys
from collections import defaultdict

import buildconfig

# The obvious way to implement this script is to search for occurrences of
# malloc et al, succeed if none are found, and fail is some are found.
# However, "none are found" does not necessarily mean "none are present" --
# this script could be buggy.  (Or the output format of |nm| might change in
# the future.)
#
# So util/Utility.cpp deliberately contains a (never-called) function that
# contains a single use of all the vanilla allocation/free functions.  And this
# script fails if it (a) finds uses of those functions in files other than
# util/Utility.cpp, *or* (b) fails to find them in util/Utility.cpp.

# Tracks overall success of the test.
has_failed = False


def fail(msg):
    print("TEST-UNEXPECTED-FAIL | check_vanilla_allocations.py |", msg)
    global has_failed
    has_failed = True


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--aggressive",
        action="store_true",
        help="also check for malloc, calloc, realloc and free",
    )
    parser.add_argument("file", type=str, help="name of the file to check")
    args = parser.parse_args()

    # Run |nm|.  Options:
    # -C: demangle symbol names
    # -A: show an object filename for each undefined symbol
    nm = buildconfig.substs.get("NM") or "nm"
    cmd = [nm, "-C", "-A", args.file]
    lines = subprocess.check_output(
        cmd, universal_newlines=True, stderr=subprocess.PIPE
    ).split("\n")

    # alloc_fns contains all the vanilla allocation/free functions that we look
    # for. Regexp chars are escaped appropriately.

    operator_news = [
        # Matches |operator new(unsigned T)|, where |T| is |int| or |long|.
        r"operator new(unsigned",
        # Matches |operator new[](unsigned T)|, where |T| is |int| or |long|.
        r"operator new[](unsigned",
    ]

    # operator new may end up inlined and replaced with moz_xmalloc.
    inlined_operator_news = [
        r"moz_xmalloc",
    ]

    alloc_fns = (
        operator_news
        + inlined_operator_news
        + [
            r"memalign",
            # These three aren't available on all Linux configurations.
            # r'posix_memalign',
            # r'aligned_alloc',
            # r'valloc',
        ]
    )

    if args.aggressive:
        alloc_fns += [r"malloc", r"calloc", r"realloc", r"free", r"strdup"]

    # This is like alloc_fns, but regexp chars are not escaped.
    alloc_fns_escaped = [re.escape(fn) for fn in alloc_fns]

    # This regexp matches the relevant lines in the output of |nm|, which look
    # like the following.
    #
    #   js/src/libjs_static.a:Utility.o:                  U malloc
    #   js/src/libjs_static.a:Utility.o: 00000000000007e0 T js::SetSourceOptions(...)
    #
    # It may also, in LTO builds, look like
    #   js/src/libjs_static.a:Utility.o: ---------------- T js::SetSourceOptions(...)
    #
    nm_line_re = re.compile(r"([^:/ ]+):\s*(?:[0-9a-fA-F]*|-*)\s+([TUw]) (.*)")
    alloc_fns_re = re.compile(r"|".join(alloc_fns_escaped))

    # This tracks which allocation/free functions have been seen.
    functions = defaultdict(set)
    files = defaultdict(int)

    # Files to ignore allocation/free functions from.
    ignored_files = [
        # Ignore implicit call to operator new in std::condition_variable_any.
        #
        # From intl/icu/source/common/umutex.h:
        # On Linux, the default constructor of std::condition_variable_any
        # produces an in-line reference to global operator new(), [...].
        "umutex.o",
        # Ignore allocations from decimal conversion functions inside mozglue.
        "Decimal.o",
        # Ignore use of std::string in regexp AST debug output.
        "regexp-ast.o",
    ]
    all_ignored_files = set((f, 1) for f in ignored_files)

    # Would it be helpful to emit detailed line number information after a failure?
    emit_line_info = False

    prev_filename = None
    for line in lines:
        m = nm_line_re.search(line)
        if m is None:
            continue

        filename, symtype, fn = m.groups()
        if prev_filename != filename:
            # When the same filename appears multiple times, separated by other
            # file names, this denotes a different file. Thankfully, we can more
            # or less safely assume that dir1/Foo.o and dir2/Foo.o are not going
            # to be next to each other.
            files[filename] += 1
            prev_filename = filename

        # The stdc++compat library has an implicit call to operator new in
        # thread::_M_start_thread.
        if "stdc++compat" in filename:
            continue

        # The memory allocator code contains calls to memalign. These are ok, so
        # we whitelist them.
        if "_memory_" in filename:
            continue

        # Ignore the fuzzing code imported from m-c
        if "Fuzzer" in filename:
            continue

        # Ignore the profiling pseudo-stack, since it needs to run even when
        # SpiderMonkey's allocator isn't initialized.
        if "ProfilingStack" in filename:
            continue

        if symtype == "T":
            # We can't match intl/components files by file name because in
            # non-unified builds they overlap with files in js/src.
            # So we check symbols they define, and consider files with symbols
            # in the mozilla::intl namespace to be those.
            if fn.startswith("mozilla::intl::"):
                all_ignored_files.add((filename, files[filename]))
        else:
            m = alloc_fns_re.match(fn)
            if m:
                functions[(filename, files[filename])].add(m.group(0))

    util_Utility_cpp = functions.pop(("Utility.o", 1))
    if ("Utility.o", 2) in functions:
        fail("There should be only one Utility.o file")

    for f, n in all_ignored_files:
        functions.pop((f, n), None)
        if f in ignored_files and (f, 2) in functions:
            fail(f"There should be only one {f} file")

    for (filename, n) in sorted(functions):
        for fn in functions[(filename, n)]:
            # An allocation is present in a non-special file.  Fail!
            fail("'" + fn + "' present in " + filename)
            # Try to give more precise information about the offending code.
            emit_line_info = True

    # Check that all functions we expect are used in util/Utility.cpp.  (This
    # will fail if the function-detection code breaks at any point.)
    # operator new and its inlined equivalent are mutually exclusive.
    has_operator_news = any(fn in operator_news for fn in util_Utility_cpp)
    has_inlined_operator_news = any(
        fn in inlined_operator_news for fn in util_Utility_cpp
    )
    if has_operator_news and has_inlined_operator_news:
        fail(
            "Both operator new and moz_xmalloc aren't expected in util/Utility.cpp at the same time"
        )

    for fn in alloc_fns:
        if fn not in util_Utility_cpp:
            if (
                (fn in operator_news and not has_inlined_operator_news)
                or (fn in inlined_operator_news and not has_operator_news)
                or (fn not in operator_news and fn not in inlined_operator_news)
            ):
                fail("'" + fn + "' isn't used as expected in util/Utility.cpp")
        else:
            util_Utility_cpp.remove(fn)

    # This should never happen, but check just in case.
    if util_Utility_cpp:
        fail(
            "unexpected allocation fns used in util/Utility.cpp: "
            + ", ".join(util_Utility_cpp)
        )

    # If we found any improper references to allocation functions, try to use
    # DWARF debug info to get more accurate line number information about the
    # bad calls. This is a lot slower than 'nm -A', and it is not always
    # precise when building with --enable-optimized.
    if emit_line_info:
        print("check_vanilla_allocations.py: Source lines with allocation calls:")
        print(
            "check_vanilla_allocations.py: Accurate in unoptimized builds; "
            "util/Utility.cpp expected."
        )

        # Run |nm|.  Options:
        # -u: show only undefined symbols
        # -C: demangle symbol names
        # -l: show line number information for each undefined symbol
        cmd = ["nm", "-u", "-C", "-l", args.file]
        lines = subprocess.check_output(
            cmd, universal_newlines=True, stderr=subprocess.PIPE
        ).split("\n")

        # This regexp matches the relevant lines in the output of |nm -l|,
        # which look like the following.
        #
        #       U malloc util/Utility.cpp:117
        #
        alloc_lines_re = (
            r"[Uw] ((" + r"|".join(alloc_fns_escaped) + r").*)\s+(\S+:\d+)$"
        )

        for line in lines:
            m = re.search(alloc_lines_re, line)
            if m:
                print(
                    "check_vanilla_allocations.py:", m.group(1), "called at", m.group(3)
                )

    if has_failed:
        sys.exit(1)

    print("TEST-PASS | check_vanilla_allocations.py | ok")
    sys.exit(0)


if __name__ == "__main__":
    main()
