#!/usr/bin/env python3
"""
dtrace_externalMethod.py

A script to trace kernel functions related to IOConnectCallMethod using DTrace.
Prints the PID, full executable path, demangled kernel function name, and user
call stack for each event.

Requirements:
    - Must be run as root (sudo).
    - System Integrity Protection (SIP) may need to be disabled for full
      DTrace functionality on macOS.

Usage:
    1. Make the script executable:
        chmod +x dtrace_externalMethod.py
    2. Run with sudo:
        sudo ./dtrace_externalMethod.py
    3. Press CTRL+C to stop tracing gracefully.

Output:
    For each relevant kernel call, prints:
        - PID
        - Executable path
        - Demangled kernel function name
        - User call stack

Graceful termination is handled via signal (SIGINT/SIGTERM).
"""

import subprocess
import sys
import signal
import os
import threading

def main():
    """
    Runs dtrace to find IOConnectCallMethod related calls, captures the user stack,
    and formats the output to include PID, full path, the demangled
    kernel function name, and the call stack. Includes a robust signal
    handler for graceful termination.
    """
    if os.getuid() != 0:
        print("This script requires root privileges. Please run with sudo.", file=sys.stderr)
        sys.exit(1)

    # DTrace script to trace kernel functions that are part of the
    # IOConnectCallMethod call chain.
    dtrace_script = (
        "fbt::*getTargetAndMethodForIndex*:entry, "
        "fbt::*externalMethod*:entry, "
        "fbt::*ExternalMethod*:entry"
        "{"
        "    printf(\"DTRACE_EVENT|%d|%s|%s\\n\", pid, execname, probefunc);"
        "    ustack();"
        "    printf(\"DTRACE_END_EVENT\\n\");"
        "}"
    )

    dtrace_command = ["sudo", "dtrace", "-x", "switchrate=10hz", "-qn", dtrace_script]
    
    # Use preexec_fn=os.setsid to run dtrace in its own process group.
    # This prevents signals sent to the script from being forwarded to dtrace,
    # and vice-versa, providing better isolation.
    proc = subprocess.Popen(
        dtrace_command,
        stdout=subprocess.PIPE,
        text=True,
        bufsize=1,
        preexec_fn=os.setsid,
        errors="replace"  # Handle decode errors gracefully
    )

    # Use a thread-safe event to ensure the signal handler logic runs only once.
    stop_event = threading.Event()

    def signal_handler(sig, frame):
        if stop_event.is_set():
            return # Already handling signal
        stop_event.set()

        print("\nStopping dtrace...", file=sys.stderr)
        try:
            # Terminate the process group started by setsid
            os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
            proc.wait(timeout=5)
        except (ProcessLookupError, subprocess.TimeoutExpired):
            try:
                proc.kill() # Force kill if terminate fails
            except ProcessLookupError:
                pass # Process already gone
        sys.exit(0)

    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, signal_handler)

    try:
        output_iterator = iter(proc.stdout.readline, '')
        for line in output_iterator:
            # Exit loop if the signal handler has been triggered
            if stop_event.is_set():
                break

            if not line.startswith("DTRACE_EVENT|"):
                continue
            
            parts = line.split('|', 3)
            if len(parts) != 4:
                continue
            _, pid_str, execname, func_raw = parts

            stack_trace = []
            for stack_line in output_iterator:
                if stop_event.is_set(): break
                stack_line = stack_line.strip()
                if stack_line == "DTRACE_END_EVENT": break
                stack_trace.append(stack_line)
            
            if stop_event.is_set(): break

            path = f"{execname} (process terminated before path lookup)"
            if pid_str.isdigit():
                ps_proc = subprocess.run(
                    ["/bin/ps", "-p", pid_str, "-o", "command="],
                    capture_output=True, text=True, check=False
                )
                if ps_proc.stdout.strip():
                    path = ps_proc.stdout.strip()
            
            func_clean = func_raw.split(':')[0]
            symbol_to_demangle = "_" + func_clean
            demangled_proc = subprocess.run(
                ["/usr/bin/c++filt"],
                input=symbol_to_demangle, capture_output=True, text=True, check=False
            )
            demangled_func = demangled_proc.stdout.strip() if demangled_proc.returncode == 0 else func_clean

            print(f"\nPID: {pid_str}")
            print(f"Path: {path}")
            print(f"Function: {demangled_func}")
            print("--- Call Stack---")
            for stack_line in stack_trace:
                print(stack_line)
            print("-" * 40)
            sys.stdout.flush()

    finally:
        if proc.poll() is None and not stop_event.is_set():
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)

if __name__ == "__main__":
    main()
