#!/usr/bin/env python3

# Copyright 2015 The Crashpad Authors
#
# 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.

import os
import platform
import pywintypes
import random
import re
import struct
import subprocess
import sys
import tempfile
import time
import win32con
import win32pipe
import winerror

g_temp_dirs = []
g_had_failures = False


def MakeTempDir():
    global g_temp_dirs
    new_dir = tempfile.mkdtemp()
    g_temp_dirs.append(new_dir)
    return new_dir


def CleanUpTempDirs():
    global g_temp_dirs
    for d in g_temp_dirs:
        subprocess.call(['rmdir', '/s', '/q', d], shell=True)


def FindInstalledWindowsApplication(app_path):
    search_paths = [
        os.getenv('PROGRAMFILES(X86)'),
        os.getenv('PROGRAMFILES'),
        os.getenv('PROGRAMW6432'),
        os.getenv('LOCALAPPDATA')
    ]
    search_paths += os.getenv('PATH', '').split(os.pathsep)

    for search_path in search_paths:
        if not search_path:
            continue
        path = os.path.join(search_path, app_path)
        if os.path.isfile(path):
            return path

    return None


def GetCdbPath():
    """Search in some reasonable places to find cdb.exe. Searches x64 before x86
    and newer versions before older versions.
    """
    possible_paths = (
        os.path.join('Windows Kits', '10', 'Debuggers', 'x64'),
        os.path.join('Windows Kits', '10', 'Debuggers', 'x86'),
        os.path.join('Windows Kits', '8.1', 'Debuggers', 'x64'),
        os.path.join('Windows Kits', '8.1', 'Debuggers', 'x86'),
        os.path.join('Windows Kits', '8.0', 'Debuggers', 'x64'),
        os.path.join('Windows Kits', '8.0', 'Debuggers', 'x86'),
        'Debugging Tools For Windows (x64)',
        'Debugging Tools For Windows (x86)',
        'Debugging Tools For Windows',
    )
    for possible_path in possible_paths:
        app_path = os.path.join(possible_path, 'cdb.exe')
        app_path = FindInstalledWindowsApplication(app_path)
        if app_path:
            return app_path
    return None


def Win32_20H1():
    (major, _, build) = platform.win32_ver()[1].split('.')
    if int(major) < 10:
        return False
    if int(build) >= 19041:
        return True
    return False


def NamedPipeExistsAndReady(pipe_name):
    """Returns False if pipe_name does not exist. If pipe_name does exist,
    blocks until the pipe is ready to service clients, and then returns True.

    This is used as a drop-in replacement for os.path.exists() and os.access()
    to test for the pipe's existence. Both of those calls tickle the pipe in a
    way that appears to the server to be a client connecting, triggering error
    messages when no data is received.

    Although this function only needs to test pipe existence (waiting for
    CreateNamedPipe()), it actually winds up testing pipe readiness (waiting for
    ConnectNamedPipe()). This is unnecessary but harmless.
    """
    try:
        win32pipe.WaitNamedPipe(pipe_name, win32pipe.NMPWAIT_WAIT_FOREVER)
    except pywintypes.error as e:
        if e.winerror == winerror.ERROR_FILE_NOT_FOUND:
            return False
        raise
    return True


def GetDumpFromProgram(out_dir, pipe_name, executable_name, expect_exit_code,
                       *args):
    """Initialize a crash database, and run |executable_name| connecting to a
    crash handler. If pipe_name is set, crashpad_handler will be started first.
    If pipe_name is empty, the executable is responsible for starting
    crashpad_handler. *args will be passed after other arguments to
    executable_name. If the child process does not exit with |expect_exit_code|,
    an exception will be raised. Returns the path to the minidump generated by
    crashpad_handler for further testing.
    """
    test_database = MakeTempDir()
    handler = None

    try:
        subprocess.check_call([
            os.path.join(out_dir, 'crashpad_database_util.exe'), '--create',
            '--database=' + test_database
        ])

        if pipe_name is not None:
            handler = subprocess.Popen([
                os.path.join(out_dir, 'crashpad_handler.com'),
                '--pipe-name=' + pipe_name, '--database=' + test_database
            ])

            # Wait until the server is ready.
            printed = False
            while not NamedPipeExistsAndReady(pipe_name):
                if not printed:
                    print('Waiting for crashpad_handler to be ready...')
                    printed = True
                time.sleep(0.001)

            command = [os.path.join(out_dir, executable_name), pipe_name
                      ] + list(args)
        else:
            command = ([
                os.path.join(out_dir, executable_name),
                os.path.join(out_dir, 'crashpad_handler.com'), test_database
            ] + list(args))
        print('Running %s' % os.path.basename(command[0]))
        exit_code = subprocess.call(command)

        # Some win32con codes are negative signed integers, whereas all exit
        # codes are unsigned integers. Convert from signed to unsigned.
        if expect_exit_code < 0:
            expect_exit_code = struct.unpack('I',
                                             struct.pack('i',
                                                         expect_exit_code))[0]

        if exit_code != expect_exit_code:
            raise subprocess.CalledProcessError(exit_code, executable_name)

        out = subprocess.check_output([
            os.path.join(out_dir, 'crashpad_database_util.exe'),
            '--database=' + test_database,
            '--show-pending-reports',
            '--show-all-report-info',
        ],
                                      text=True)
        for line in out.splitlines():
            if line.strip().startswith('Path:'):
                return line.partition(':')[2].strip()
    finally:
        if handler:
            handler.kill()


def GetDumpFromCrashyProgram(out_dir, pipe_name):
    return GetDumpFromProgram(out_dir, pipe_name, 'crashy_program.exe',
                              win32con.EXCEPTION_ACCESS_VIOLATION)


def GetDumpFromOtherProgram(out_dir, pipe_name, *args):
    return GetDumpFromProgram(out_dir, pipe_name, 'crash_other_program.exe', 0,
                              *args)


def GetDumpFromSignal(out_dir, pipe_name, *args):
    STATUS_FATAL_APP_EXIT = 0x40000015  # Not known by win32con.
    return GetDumpFromProgram(out_dir, pipe_name, 'crashy_signal.exe',
                              STATUS_FATAL_APP_EXIT, *args)


def GetDumpFromSelfDestroyingProgram(out_dir, pipe_name):
    return GetDumpFromProgram(out_dir, pipe_name, 'self_destroying_program.exe',
                              win32con.EXCEPTION_BREAKPOINT)


def GetDumpFromZ7Program(out_dir, pipe_name):
    return GetDumpFromProgram(out_dir, pipe_name, 'crashy_z7_loader.exe',
                              win32con.EXCEPTION_ACCESS_VIOLATION)


def GetDumpFromHeapCorruptingProgram(out_dir, pipe_name):
    STATUS_HEAP_CORRUPTION = 0xC0000374
    return GetDumpFromProgram(out_dir, pipe_name, 'heap_corrupting_program.exe',
                              STATUS_HEAP_CORRUPTION)


def GetDumpFromFastFailProgram(out_dir, pipe_name, *args):
    STATUS_STACK_BUFFER_OVERRUN = 0xc0000409
    return GetDumpFromProgram(out_dir, pipe_name, 'fastfail_program.exe',
                              STATUS_STACK_BUFFER_OVERRUN, *args)


class CdbRun(object):
    """Run cdb.exe passing it a cdb command and capturing the output.
    `Check()` searches for regex patterns in sequence allowing verification of
    expected output.
    """

    def __init__(self, cdb_path, dump_path, command):
        # Run a command line that loads the dump, runs the specified cdb
        # command, and then quits, and capturing stdout.
        self.out = subprocess.check_output(
            [cdb_path, '-z', dump_path, '-c', command + ';q'], text=True)

    def Check(self, pattern, message, re_flags=0, must_not_match=False):
        match_obj = re.search(pattern, self.out, flags=re_flags)
        if match_obj and not must_not_match:
            # Matched. Consume up to end of match.
            self.out = self.out[match_obj.end(0):]
            print('ok - %s' % message)
            sys.stdout.flush()
        elif must_not_match and not match_obj:
            # Did not match and did not want to match.
            print('ok - %s' % message)
            sys.stdout.flush()
        else:
            print('-' * 80, file=sys.stderr)
            print('FAILED - %s' % message, file=sys.stderr)
            print('-' * 80, file=sys.stderr)
            if must_not_match:
                print('unexpected match:\n  %s' % pattern, file=sys.stderr)
            else:
                print('did not match:\n  %s' % pattern, file=sys.stderr)
            print('-' * 80, file=sys.stderr)
            print('remaining output was:\n  %s' % self.out, file=sys.stderr)
            print('-' * 80, file=sys.stderr)
            sys.stderr.flush()
            global g_had_failures
            g_had_failures = True

    def Find(self, pattern, re_flags=0):
        match_obj = re.search(pattern, self.out, flags=re_flags)
        if match_obj:
            # Matched. Consume up to end of match.
            self.out = self.out[match_obj.end(0):]
            return match_obj
        return None


def RunTests(cdb_path, dump_path, start_handler_dump_path, destroyed_dump_path,
             pipe_name):
    """Runs various tests in sequence. Runs a new cdb instance on the dump for
    each block of tests to reduce the chances that output from one command is
    confused for output from another.
    """
    out = CdbRun(cdb_path, dump_path, '.ecxr')
    out.Check('This dump file has an exception of interest stored in it',
              'captured exception')

    # When SomeCrashyFunction is inlined, cdb doesn't demangle its namespace as
    # "`anonymous namespace'" and instead gives the decorated form.
    out.Check(
        'crashy_program!crashpad::(`anonymous namespace\'|\?A0x[0-9a-f]+)::'
        'SomeCrashyFunction', 'exception at correct location')

    out = CdbRun(cdb_path, start_handler_dump_path, '.ecxr')
    out.Check('This dump file has an exception of interest stored in it',
              'captured exception (using StartHandler())')
    out.Check(
        'crashy_program!crashpad::(`anonymous namespace\'|\?A0x[0-9a-f]+)::'
        'SomeCrashyFunction',
        'exception at correct location (using StartHandler())')

    out = CdbRun(cdb_path, dump_path, '!peb')
    out.Check(r'PEB at', 'found the PEB')
    out.Check(r'Ldr\.InMemoryOrderModuleList:.*\d+ \. \d+',
              'PEB_LDR_DATA saved')
    out.Check(r'Base TimeStamp                     Module',
              'module list present')
    pipe_name_escaped = pipe_name.replace('\\', '\\\\')
    out.Check(r'CommandLine: *\'.*crashy_program\.exe *' + pipe_name_escaped,
              'some PEB data is correct')
    out.Check(r'SystemRoot=C:\\Windows', 'some of environment captured',
              re.IGNORECASE)

    out = CdbRun(cdb_path, dump_path, '?? @$peb->ProcessParameters')
    out.Check(r' ImagePathName *: _UNICODE_STRING ".*\\crashy_program\.exe"',
              'PEB->ProcessParameters.ImagePathName string captured')
    out.Check(
        ' DesktopInfo *: '
        '_UNICODE_STRING "(?!--- memory read error at address ).*"',
        'PEB->ProcessParameters.DesktopInfo string captured')

    out = CdbRun(cdb_path, dump_path, '!teb')
    out.Check(r'TEB at', 'found the TEB')
    out.Check(r'ExceptionList:\s+[0-9a-fA-F]+', 'some valid teb data')
    out.Check(r'LastErrorValue:\s+2', 'correct LastErrorValue')

    out = CdbRun(cdb_path, dump_path, '!gle')
    out.Check(
        'LastErrorValue: \(Win32\) 0x2 \(2\) - The system cannot find the '
        'file specified.', '!gle gets last error')
    out.Check(
        'LastStatusValue: \(NTSTATUS\) 0xc000000f - {File Not Found}  The '
        'file %hs does not exist.', '!gle gets last ntstatus')

    if False:
        # TODO(scottmg): Re-enable when we grab ntdll!RtlCriticalSectionList.
        out = CdbRun(cdb_path, dump_path, '!locks')
        out.Check(
            r'CritSec crashy_program!crashpad::`anonymous namespace\'::'
            r'g_test_critical_section', 'lock was captured')
        if platform.win32_ver()[0] != '7':
            # We can't allocate CRITICAL_SECTIONs with .DebugInfo on Win 7.
            out.Check(r'\*\*\* Locked',
                      'lock debug info was captured, and is locked')

    out = CdbRun(cdb_path, dump_path, '!handle')
    out.Check(r'\d+ Handles', 'captured handles')
    out.Check(r'Event\s+\d+', 'capture some event handles')
    out.Check(r'File\s+\d+', 'capture some file handles')

    out = CdbRun(cdb_path, dump_path, 'lm')
    out.Check(r'Unloaded modules:', 'captured some unloaded modules')
    out.Check(r'lz32\.dll', 'found expected unloaded module lz32')
    out.Check(r'wmerror\.dll', 'found expected unloaded module wmerror')

    out = CdbRun(cdb_path, destroyed_dump_path, '.ecxr;!peb;k 2')
    out.Check(r'Ldr\.InMemoryOrderModuleList:.*\d+ \. \d+',
              'PEB_LDR_DATA saved')
    out.Check(r'ntdll\.dll', 'ntdll present', re.IGNORECASE)

    # Check that there is no stack trace in the self-destroyed process. Confirm
    # that the top is where we expect it (that's based only on IP), but
    # subsequent stack entries will not be available. This confirms that we have
    # a mostly valid dump, but that the stack was omitted.
    out.Check(
        r'self_destroying_program!crashpad::`anonymous namespace\'::'
        r'FreeOwnStackAndBreak.*\nquit:',
        'at correct location, no additional stack entries')

    # Dump memory pointed to be EDI on the background suspended thread. We don't
    # know the index of the thread because the system may have started other
    # threads, so first do a run to extract the thread index that's suspended,
    # and then another run to dump the data pointed to by EDI for that thread.
    out = CdbRun(cdb_path, dump_path, '.ecxr;~')
    match_obj = out.Find(r'(\d+)\s+Id: [0-9a-f.]+ Suspend: 1 Teb:')
    if match_obj:
        thread = match_obj.group(1)
        out = CdbRun(cdb_path, dump_path, '.ecxr;~' + thread + 's;db /c14 edi')
    out.Check(r'63 62 61 60 5f 5e 5d 5c-5b 5a 59 58 57 56 55 54 53 52 51 50',
              'data pointed to by registers captured')

    # Move up one stack frame after jumping to the exception, and examine
    # memory.
    out = CdbRun(cdb_path, dump_path,
                 '.ecxr; .f+; dd /c100 poi(offset_pointer)-20')
    out.Check(
        r'80000078 00000079 8000007a 0000007b 8000007c 0000007d 8000007e '
        r'0000007f 80000080 00000081 80000082 00000083 80000084 00000085 '
        r'80000086 00000087 80000088 00000089 8000008a 0000008b 8000008c '
        r'0000008d 8000008e 0000008f 80000090 00000091 80000092 00000093 '
        r'80000094 00000095 80000096 00000097',
        'data pointed to by stack captured')

    # Attempt to retrieve the value of g_extra_memory_pointer (by name), and
    # then examine the memory at which it points. Both should have been saved.
    out = CdbRun(
        cdb_path, dump_path,
        'dd poi(crashy_program!crashpad::g_extra_memory_pointer)+0x1f30 '
        'L8')
    out.Check(r'0000655e 0000656b 00006578 00006585',
              'extra memory range captured')

    out = CdbRun(cdb_path, dump_path, '.dumpdebug')
    out.Check(r'type \?\?\? \(333333\), size 00001000', 'first user stream')
    out.Check(r'type \?\?\? \(222222\), size 00000080', 'second user stream')


def Run7zDumpTest(cdb_path, z7_dump_path):
    """Validate output when non-pdb symbols are in a module."""
    out = CdbRun(cdb_path, z7_dump_path, '.ecxr;lm')
    out.Check('This dump file has an exception of interest stored in it',
              'captured exception in z7 module')
    # Older versions of cdb display relative to exports for /Z7 modules,
    # newer ones just display the offset.
    out.Check(r'z7_test(!CrashMe\+0xe|\+0x100e):',
              'exception in z7 at correct location')
    out.Check(r'z7_test  C \(codeview symbols\)     z7_test\.dll',
              'expected non-pdb symbol format')


def RunOtherProgramTests(cdb_path, other_program_path,
                         other_program_no_exception_path):
    out = CdbRun(cdb_path, other_program_path, '.ecxr;k;~')
    out.Check('Unknown exception - code deadbea7',
              'other program dump exception code')
    out.Check('!Sleep', 'other program reasonable location')
    out.Check("hanging_program!`anonymous namespace'::Thread1",
              'other program dump right thread')
    count = 0
    while True:
        match_obj = out.Find(r'Id.*Suspend: (\d+) ')
        if match_obj:
            if match_obj.group(1) != '0':
                out.Check(r'FAILED', 'all suspend counts should be 0')
            else:
                count += 1
        else:
            break
    assert count > 2

    out = CdbRun(cdb_path, other_program_no_exception_path, '.ecxr;k')
    out.Check('Unknown exception - code 0cca11ed',
              'other program with no exception given')
    out.Check('!RaiseException', 'other program in RaiseException()')


def RunSigAbrtTest(cdb_path, sigabrt_main_path, sigabrt_background_path):
    """Validate that abort signals are collected."""
    out = CdbRun(cdb_path, sigabrt_main_path, '.ecxr')
    out.Check('code 40000015', 'got sigabrt signal')
    out.Check('::HandleAbortSignal', '  stack in expected location')

    out = CdbRun(cdb_path, sigabrt_background_path, '.ecxr')
    out.Check('code 40000015', 'got sigabrt signal from background thread')


def RunHeapCorruptionTest(cdb_path, heap_path):
    """Runs tests on heap corruption caught using the vectored handler."""
    out = CdbRun(cdb_path, heap_path, '.ecxr;k')
    out.Check('code c0000374', 'captured exception from heap corruption crash')
    out.Check('::HeapCorruptionCrash', 'See expected throwing function')
    out = CdbRun(cdb_path, heap_path, '.ecxr;k')


def RunFastFailDumpTest(cdb_path, fastfail_path):
    """Runs tests on __fastfail() caught using the runtime exception helper."""
    out = CdbRun(cdb_path, fastfail_path, '.ecxr;k')
    out.Check('This dump file has an exception of interest stored in it',
              'captured exception from __fastfail() crash()')
    out.Check(r'Subcode: 0x4d \(unknown subcode\)', 'See expected subcode.')
    out.Check('FastFailCrash', 'See expected throwing function.')
    out = CdbRun(cdb_path, fastfail_path, '.ecxr;k')


def RunCfgDumpTest(cdb_path, cfg_path):
    """Runs tests on a cfg crash caught using the runtime exception helper."""
    out = CdbRun(cdb_path, cfg_path, '.ecxr;k')
    out.Check('This dump file has an exception of interest stored in it',
              'captured exception from cfg crash()')
    out.Check('Subcode: 0xa FAST_FAIL_GUARD_ICALL_CHECK_FAILURE',
              'See expected cfg error code.')
    out.Check('RtlFailFast',
              'See expected Windows exception throwing function.')
    out.Check('::CfgCrash', 'expected crashy function is on the stack.')
    out = CdbRun(cdb_path, cfg_path, '.ecxr;k')
    out.Check(r'CallRffeManyTimes',
              'Do not see the function we fiddled the pointer for.',
              must_not_match=True)


def main(args):
    try:
        if len(args) != 1:
            print('must supply binary dir', file=sys.stderr)
            return 1

        cdb_path = GetCdbPath()
        if not cdb_path:
            print('could not find cdb', file=sys.stderr)
            return 1

        # Make sure we can download Windows symbols.
        if not os.environ.get('_NT_SYMBOL_PATH'):
            symbol_dir = MakeTempDir()
            protocol = 'https' if platform.win32_ver()[0] != 'XP' else 'http'
            os.environ['_NT_SYMBOL_PATH'] = (
                'SRV*' + symbol_dir + '*' + protocol +
                '://msdl.microsoft.com/download/symbols')

        pipe_name = r'\\.\pipe\end-to-end_%s_%s' % (os.getpid(),
                                                    str(random.getrandbits(64)))

        # Basic tests.
        crashy_dump_path = GetDumpFromCrashyProgram(args[0], pipe_name)
        if not crashy_dump_path:
            return 1

        start_handler_dump_path = GetDumpFromCrashyProgram(args[0], None)
        if not start_handler_dump_path:
            return 1

        destroyed_dump_path = GetDumpFromSelfDestroyingProgram(
            args[0], pipe_name)
        if not destroyed_dump_path:
            return 1

        RunTests(cdb_path, crashy_dump_path, start_handler_dump_path,
                 destroyed_dump_path, pipe_name)

        # Other program dumps.
        other_program_path = GetDumpFromOtherProgram(args[0], pipe_name)
        if not other_program_path:
            return 1

        other_program_no_exception_path = GetDumpFromOtherProgram(
            args[0], pipe_name, 'noexception')
        if not other_program_no_exception_path:
            return 1

        RunOtherProgramTests(cdb_path, other_program_path,
                             other_program_no_exception_path)

        # SIGABRT.
        sigabrt_main_path = GetDumpFromSignal(args[0], pipe_name, 'main')
        if not sigabrt_main_path:
            return 1

        sigabrt_background_path = GetDumpFromSignal(args[0], pipe_name,
                                                    'background')
        if not sigabrt_background_path:
            return 1

        RunSigAbrtTest(cdb_path, sigabrt_main_path, sigabrt_background_path)

        # Can only build the z7 program on x86.
        if not args[0].endswith('_x64'):
            z7_dump_path = GetDumpFromZ7Program(args[0], pipe_name)
            if not z7_dump_path:
                return 1
            Run7zDumpTest(cdb_path, z7_dump_path)

        heap_path = GetDumpFromHeapCorruptingProgram(args[0], pipe_name)
        if not heap_path:
            return 1
        RunHeapCorruptionTest(cdb_path, heap_path)

        # __fastfail() & CFG crash caught by WerRuntimeExceptionHelperModule.
        # TODO(crashpad:458) These are not working when launched from python.
        if (False and Win32_20H1()):
            cfg_path = GetDumpFromFastFailProgram(args[0], pipe_name, "cf")
            if not cfg_path:
                return 1
            RunCfgDumpTest(cdb_path, cfg_path)
            fastfail_path = GetDumpFromFastFailProgram(args[0], pipe_name, "ff")
            if not fastfail_path:
                return 1
            RunFastFailDumpTest(cdb_path, fastfail_path)

        return 1 if g_had_failures else 0
    finally:
        CleanUpTempDirs()


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
