diff --git a/compiler-rt/test/CMakeLists.txt b/compiler-rt/test/CMakeLists.txt index a2e4c8cbf5685..b387a28d86a83 100644 --- a/compiler-rt/test/CMakeLists.txt +++ b/compiler-rt/test/CMakeLists.txt @@ -106,6 +106,17 @@ if(COMPILER_RT_CAN_EXECUTE_TESTS) # ShadowCallStack does not yet provide a runtime with compiler-rt, the tests # include their own minimal runtime add_subdirectory(shadowcallstack) + + # TO_UPSTREAM(BoundsSafety) + # -fbounds-safety doesn't have a runtime in compiler-rt so guarding with + # `COMPILER_RT_HAS_BOUNDS_SAFETY` is unnecessary so that is why + # `compiler_rt_test_runtime` is not used here. + if(COMPILER_RT_INCLUDE_TESTS) + option(COMPILER_RT_TEST_BOUNDS_SAFETY "Include -fbounds-safety runtime tests" ON) + if(COMPILER_RT_TEST_BOUNDS_SAFETY) + add_subdirectory(bounds_safety) + endif() + endif() endif() # Now that we've traversed all the directories and know all the lit testsuites, diff --git a/compiler-rt/test/bounds_safety/CMakeLists.txt b/compiler-rt/test/bounds_safety/CMakeLists.txt new file mode 100644 index 0000000000000..a1670946d4d2d --- /dev/null +++ b/compiler-rt/test/bounds_safety/CMakeLists.txt @@ -0,0 +1,174 @@ +message(STATUS "Trying to enable -fbounds-safety runtime tests") +if (NOT APPLE) + # FIXME: We should support Linux too. + message(STATUS "Skipping -fbounds-safety runtime tests on non-Apple platforms") + return() +endif() +set(BOUNDS_SAFETY_LIT_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +set(BOUNDS_SAFETY_HOST_TEST_SUITES) +# Currently no dependencies +set(BOUNDS_SAFETY_TEST_DEPS "") + +# Detect LLDB availability and Python compatibility +find_program(LLDB_EXECUTABLE lldb HINTS ${LLVM_TOOLS_DIR}) +set(_LLDB_DETECTED FALSE) +set(_LLDB_PYTHON_COMPATIBLE FALSE) +if(LLDB_EXECUTABLE) + execute_process( + COMMAND ${LLDB_EXECUTABLE} -P + OUTPUT_VARIABLE BOUNDS_SAFETY_LLDB_PYTHON_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE LLDB_P_RESULT + ) + if(LLDB_P_RESULT EQUAL 0) + set(_LLDB_DETECTED TRUE) + # Check that the Python interpreter used by lit can import the lldb module. + # A mismatch (e.g. Homebrew Python 3.13 vs Xcode LLDB built for Python 3.11) + # causes a confusing ImportError at test time. + execute_process( + COMMAND ${Python3_EXECUTABLE} -c + "import sys; sys.path.insert(0, '${BOUNDS_SAFETY_LLDB_PYTHON_PATH}'); import lldb" + RESULT_VARIABLE _LLDB_IMPORT_RESULT + ERROR_VARIABLE _LLDB_IMPORT_ERROR + OUTPUT_QUIET + ) + if(_LLDB_IMPORT_RESULT EQUAL 0) + set(_LLDB_PYTHON_COMPATIBLE TRUE) + endif() + endif() +endif() + +# CMake option: defaults to ON if LLDB detected AND the Python interpreter +# can import the lldb module. If explicitly set to ON but either condition +# fails, error the build. +option(COMPILER_RT_BOUNDS_SAFETY_USE_LLDB + "Use LLDB for bounds-safety debugger test suites" ${_LLDB_PYTHON_COMPATIBLE}) + +if(COMPILER_RT_BOUNDS_SAFETY_USE_LLDB AND NOT _LLDB_DETECTED) + message(FATAL_ERROR + "COMPILER_RT_BOUNDS_SAFETY_USE_LLDB=ON but LLDB was not found or " + "lldb -P failed. Set to OFF or ensure LLDB is available.") +endif() + +if(COMPILER_RT_BOUNDS_SAFETY_USE_LLDB AND NOT _LLDB_PYTHON_COMPATIBLE) + message(FATAL_ERROR + "COMPILER_RT_BOUNDS_SAFETY_USE_LLDB=ON but the Python interpreter " + "used by lit (${Python3_EXECUTABLE}) cannot import the lldb module " + "from ${BOUNDS_SAFETY_LLDB_PYTHON_PATH}.\n" + "This typically means Python and LLDB were built for different Python " + "versions. Either:\n" + " - Set Python3_EXECUTABLE to the Python that matches LLDB " + "(e.g. the one bundled with Xcode), or\n" + " - Set COMPILER_RT_BOUNDS_SAFETY_USE_LLDB=OFF to disable debugger " + "test suites.\n" + "Import error: ${_LLDB_IMPORT_ERROR}") +endif() + +if (COMPILER_RT_BOUNDS_SAFETY_USE_LLDB) + message(STATUS "Enabled -fbounds-safety tests using LLDB: ${BOUNDS_SAFETY_LLDB_PYTHON_PATH}") +else() + message(STATUS "Disabled -fbounds-safety tests using LLDB") +endif() + +set(BOUNDS_SAFETY_OPT_LEVELS unopt opt) +set(BOUNDS_SAFETY_TRAP_KINDS unique-traps merged-traps soft-traps) +set(BOUNDS_SAFETY_RUN_MODES direct) +if(COMPILER_RT_BOUNDS_SAFETY_USE_LLDB) + list(APPEND BOUNDS_SAFETY_RUN_MODES debugger) +endif() + + +list(FIND SANITIZER_COMMON_SUPPORTED_OS "osx" OSX_INDEX) +if (${OSX_INDEX} EQUAL -1) + message(FATAL_ERROR "Support for osx is missing") +endif() + +# TODO: Add support for other platforms +set(BOUNDS_SAFETY_APPLE_PLATFORMS osx) + +foreach(platform ${BOUNDS_SAFETY_APPLE_PLATFORMS}) + # Determine archs for this platform + if("${platform}" STREQUAL "osx") + # For osx, filter to host-compatible archs + set(PLATFORM_ARCHS ${DARWIN_osx_ARCHS}) + darwin_filter_host_archs(PLATFORM_ARCHS PLATFORM_ARCHS) + set(BOUNDS_SAFETY_TEST_APPLE_TARGET_IS_HOST ON) + else() + # For other platforms, use all available archs + set(PLATFORM_ARCHS ${DARWIN_${platform}_ARCHS}) + set(BOUNDS_SAFETY_TEST_APPLE_TARGET_IS_HOST OFF) + endif() + pythonize_bool(BOUNDS_SAFETY_TEST_APPLE_TARGET_IS_HOST) + + set(BOUNDS_SAFETY_TEST_APPLE_PLATFORM "${platform}") + set(BOUNDS_SAFETY_TEST_MIN_DEPLOYMENT_TARGET_FLAG + "${DARWIN_${platform}_MIN_VER_FLAG}") + + foreach(arch ${PLATFORM_ARCHS}) + get_test_cflags_for_apple_platform( + "${platform}" "${arch}" BOUNDS_SAFETY_TEST_TARGET_CFLAGS) + + foreach(opt_level ${BOUNDS_SAFETY_OPT_LEVELS}) + # Determine which trap kinds apply for this opt level. + # At -O0 merged traps are identical to unique traps, so skip. + if("${opt_level}" STREQUAL "unopt") + set(ACTIVE_TRAP_KINDS unique-traps soft-traps) + else() + set(ACTIVE_TRAP_KINDS ${BOUNDS_SAFETY_TRAP_KINDS}) + endif() + + foreach(trap_kind ${ACTIVE_TRAP_KINDS}) + set(BOUNDS_SAFETY_TEST_TRAP_KIND "${trap_kind}") + + foreach(mode ${BOUNDS_SAFETY_RUN_MODES}) + set(BOUNDS_SAFETY_TEST_TARGET_ARCH ${arch}) + + if("${platform}" STREQUAL "osx") + set(CONFIG_NAME "host") + else() + set(CONFIG_NAME "remote") + endif() + string(APPEND CONFIG_NAME "-${arch}-apple-${platform}--${opt_level}--${trap_kind}--${mode}") + + if("${opt_level}" STREQUAL "unopt") + set(BOUNDS_SAFETY_TEST_OPTIMIZED FALSE) + else() + set(BOUNDS_SAFETY_TEST_OPTIMIZED TRUE) + endif() + pythonize_bool(BOUNDS_SAFETY_TEST_OPTIMIZED) + + if("${mode}" STREQUAL "debugger") + set(BOUNDS_SAFETY_TEST_USE_DEBUGGER TRUE) + else() + set(BOUNDS_SAFETY_TEST_USE_DEBUGGER FALSE) + endif() + pythonize_bool(BOUNDS_SAFETY_TEST_USE_DEBUGGER) + + configure_compiler_rt_lit_site_cfg( + ${CMAKE_CURRENT_SOURCE_DIR}/lit.site.cfg.py.in + ${CMAKE_CURRENT_BINARY_DIR}/${CONFIG_NAME}/lit.site.cfg.py + ) + + if("${platform}" STREQUAL "osx") + # Host tests: accumulate into main check-bounds-safety target + list(APPEND BOUNDS_SAFETY_HOST_TEST_SUITES + ${CMAKE_CURRENT_BINARY_DIR}/${CONFIG_NAME}) + else() + # Non-host: individual targets, excluded from check-all + add_lit_testsuite( + check-bounds-safety-remote-${platform}-${arch}-${opt_level}-${trap_kind}-${mode} + "bounds-safety remote ${platform} ${arch} ${opt_level} ${trap_kind} ${mode} tests" + ${CMAKE_CURRENT_BINARY_DIR}/${CONFIG_NAME} + EXCLUDE_FROM_CHECK_ALL + DEPENDS ${BOUNDS_SAFETY_TEST_DEPS}) + endif() + endforeach() + endforeach() + endforeach() + endforeach() +endforeach() + +add_lit_testsuite(check-bounds-safety + "Running the bounds-safety runtime tests" + ${BOUNDS_SAFETY_HOST_TEST_SUITES} + DEPENDS ${BOUNDS_SAFETY_TEST_DEPS}) diff --git a/compiler-rt/test/bounds_safety/bidi_indexable/lower_upper_check.c b/compiler-rt/test/bounds_safety/bidi_indexable/lower_upper_check.c new file mode 100644 index 0000000000000..ffcdb3efb4803 --- /dev/null +++ b/compiler-rt/test/bounds_safety/bidi_indexable/lower_upper_check.c @@ -0,0 +1,31 @@ +// RUN: %clang_bsafe %s -o %t +// RUN: %expect-no-trap %t +// RUN: %expect-trap --verify-prefix=lower-trap %s %t arg1 +// RUN: %expect-trap --verify-prefix=upper-trap %s %t arg1 arg2 +#include +#include +#include "soft_trap_runtime_impl.h" + +// lower-trap-merged{bad_read} +// upper-trap-merged{bad_read} +int bad_read(int *__bidi_indexable ptr, int idx) { + // lower-trap@+2{indexing below lower bound in 'ptr[idx]'} + // upper-trap@+1{indexing above upper bound in 'ptr[idx]'} + return ptr[idx]; +} + +int main(int argc, const char **__counted_by(argc) argv) { + int pad; + int local[] = {0, 1}; + int pad2; + int result = 0; + if (argc == 1) { + result = bad_read(local, 1); + } else if (argc == 2) { + result = bad_read(local, -1); + } else { + result = bad_read(local, 2); + } + printf("result: %d\n", result); + return 0; +} diff --git a/compiler-rt/test/bounds_safety/lit.cfg.py b/compiler-rt/test/bounds_safety/lit.cfg.py new file mode 100644 index 0000000000000..8805522a0db69 --- /dev/null +++ b/compiler-rt/test/bounds_safety/lit.cfg.py @@ -0,0 +1,88 @@ +# -*- Python -*- + +import os +import shlex + +import lit.formats + + +config.name = "BoundsSafety :: " + config.name_suffix + +config.suffixes = [".c"] + +config.test_source_root = os.path.dirname(__file__) +config.test_exec_root = os.path.join( + config.compiler_rt_obj_root, "test", "bounds-safety", + config.name_suffix +) + +# Build the %clang_bsafe substitution. +clang_bsafe_flags = [ + config.clang, + "-fbounds-safety", + "-g", + "-O2" if config.optimized else "-O0", + config.target_cflags, +] +if config.trap_kind == "unique-traps": + clang_bsafe_flags.append("-fbounds-safety-unique-traps") +elif config.trap_kind == "merged-traps": + clang_bsafe_flags.append("-fno-bounds-safety-unique-traps") + assert config.optimized +elif config.trap_kind == "soft-traps": + clang_bsafe_flags.append("-fbounds-safety-soft-traps=call-minimal") + +# Add utils/ to include path for soft_trap_runtime_impl.h. +utils_dir = os.path.join(os.path.dirname(__file__), "utils") +clang_bsafe_flags.append("-I" + utils_dir) + +# Expose configuration properties as preprocessor macros for test cases. +clang_bsafe_flags.append("-DTEST_OPTIMIZED={}".format(int(config.optimized))) +clang_bsafe_flags.append("-DTEST_UNIQUE_TRAPS={}".format( + int(config.trap_kind != "merged-traps"))) +clang_bsafe_flags.append("-DTEST_USE_DEBUGGER={}".format(int(config.use_debugger))) +clang_bsafe_flags.append("-DTEST_SOFT_TRAP={}".format( + int(config.trap_kind == "soft-traps"))) + +config.substitutions.append( + ("%clang_bsafe", " " + " ".join(clang_bsafe_flags) + " ") +) + +# Build %expect-trap and %expect-no-trap substitutions. +python_exec = shlex.quote(config.python_executable) +scripts_dir = os.path.join(os.path.dirname(__file__), "scripts") +expect_trap_script = os.path.join(scripts_dir, "expect_trap.py") +expect_no_trap_script = os.path.join(scripts_dir, "expect_no_trap.py") + +debugger_flags = "" +if config.use_debugger: + debugger_flags = " --use-debugger --lldb-python-path {}".format( + shlex.quote(config.lldb_python_path) + ) + +merged_trap_flag = " --merged-traps" if config.trap_kind == "merged-traps" else "" +soft_trap_flag = " --soft-traps" if config.trap_kind == "soft-traps" else "" + +config.substitutions.append( + ("%expect-trap", "{} {}{}{}{}".format( + python_exec, expect_trap_script, debugger_flags, merged_trap_flag, + soft_trap_flag)) +) +config.substitutions.append( + ( + "%expect-no-trap", + "{} {}{}{}".format(python_exec, expect_no_trap_script, debugger_flags, + soft_trap_flag), + ) +) + +# Add features for REQUIRES/UNSUPPORTED lines. +if config.use_debugger: + config.available_features.add("run-under-debugger") +else: + config.available_features.add("run-directly") +if config.optimized: + config.available_features.add("optimized") +else: + config.available_features.add("unoptimized") +config.available_features.add(config.trap_kind) diff --git a/compiler-rt/test/bounds_safety/lit.site.cfg.py.in b/compiler-rt/test/bounds_safety/lit.site.cfg.py.in new file mode 100644 index 0000000000000..e1127e74ba2ff --- /dev/null +++ b/compiler-rt/test/bounds_safety/lit.site.cfg.py.in @@ -0,0 +1,34 @@ +@LIT_SITE_CFG_IN_HEADER@ + +config.name_suffix = "@CONFIG_NAME@" +config.target_cflags = "@BOUNDS_SAFETY_TEST_TARGET_CFLAGS@" +config.target_arch = "@BOUNDS_SAFETY_TEST_TARGET_ARCH@" +config.optimized = @BOUNDS_SAFETY_TEST_OPTIMIZED_PYBOOL@ +config.use_debugger = @BOUNDS_SAFETY_TEST_USE_DEBUGGER_PYBOOL@ +config.trap_kind = "@BOUNDS_SAFETY_TEST_TRAP_KIND@" +config.lldb_python_path = "@BOUNDS_SAFETY_LLDB_PYTHON_PATH@" +config.apple_platform = "@BOUNDS_SAFETY_TEST_APPLE_PLATFORM@" +config.apple_platform_min_deployment_target_flag = "@BOUNDS_SAFETY_TEST_MIN_DEPLOYMENT_TARGET_FLAG@" +config.apple_target_is_host = @BOUNDS_SAFETY_TEST_APPLE_TARGET_IS_HOST_PYBOOL@ + +# FIXME: Commented out because the performance is terrible due to the lit files +# doing lots of work that should not be done at test time (spawning +# processes for things that won't change). There are lots of variants of this +# test suite and there's no caching so this work gets done for each test suite. +# We should fix this so we can take advantage of the infrastructure +# for running outside of the host machine. +#lit_config.load_config(config, "@COMPILER_RT_BINARY_DIR@/test/lit.common.configured") + +# HACK: These should come from loading +# "@COMPILER_RT_BINARY_DIR@/test/lit.common.configured" +# but we've commented out loading it so we have to manually set these to get +# things to work. +config.compiler_rt_obj_root = "@COMPILER_RT_BINARY_DIR@" +config.clang = "@COMPILER_RT_RESOLVED_TEST_COMPILER@" +config.python_executable = "@Python3_EXECUTABLE@" +# Setup test format. +import lit +config.test_format = lit.formats.ShTest(execute_external=False) + +# Consume this configuration and setup subscriptions, features, etc. +lit_config.load_config(config, "@BOUNDS_SAFETY_LIT_SOURCE_DIR@/lit.cfg.py") diff --git a/compiler-rt/test/bounds_safety/scripts/expect_no_trap.py b/compiler-rt/test/bounds_safety/scripts/expect_no_trap.py new file mode 100755 index 0000000000000..cd0b7bdc41c13 --- /dev/null +++ b/compiler-rt/test/bounds_safety/scripts/expect_no_trap.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Verify a binary runs without trapping. + +It supports running in two modes. In both modes this succeeds if the following +are true: + +* The binary exits with a zero exit code. +* In soft trap mode a soft trap is not hit. + +The binary can be run in one of two modes. The distinction between running in +these two modes only matters when the binary fails. + +Direct mode: Run binary directly. If the binary fails the only available + information is the exit code. +Debugger mode: Run binary under debugger. A backtrace is printed where the + binary unexpectedly stopped. +""" + +import argparse +import logging +import os +import subprocess +import sys + +log = logging.getLogger("expect_no_trap") + + +SOFT_TRAP_MARKER = "***HIT BOUNDS SAFETY SOFT TRAP***:" + + +def run_direct(binary, args, soft_traps=False): + if soft_traps: + result = subprocess.run([binary] + args, capture_output=True, text=True) + if result.returncode != 0: + if result.returncode < 0: + log.error("process was killed by signal %d", -result.returncode) + else: + log.error("process exited with status %d", result.returncode) + return 1 + if SOFT_TRAP_MARKER in result.stderr: + log.error("unexpected soft trap detected in stderr") + log.error("stderr: %s", result.stderr) + return 1 + return 0 + + result = subprocess.run([binary] + args) + if result.returncode == 0: + return 0 + if result.returncode < 0: + log.error("process was killed by signal %d", -result.returncode) + else: + log.error("process exited with status %d", result.returncode) + return 1 + + +def check_no_trap(lldb, process): + """Verify the process exited cleanly without trapping. Returns 0 on success.""" + state = process.GetState() + + if state == lldb.eStateExited: + exit_status = process.GetExitStatus() + if exit_status == 0: + return 0 + log.error("process exited with status %d", exit_status) + return 1 + + if state == lldb.eStateStopped: + thread = process.GetSelectedThread() + stop_reason = thread.GetStopDescription(256) + log.error("process stopped unexpectedly: %s", stop_reason) + log.info("Backtrace:") + for frame in thread: + log.info(" %s", frame) + return 1 + + log.error("unexpected process state: %s", state) + return 1 + + +def run_debugger(binary, args, lldb_python_path): + from lldb_utils import LLDBLaunchError, LLDBProcessContextManager, import_lldb + + lldb = import_lldb(lldb_python_path) + + with LLDBProcessContextManager(lldb, binary, args) as ctx: + try: + ctx.launch() + except LLDBLaunchError as e: + log.error("%s", e) + return 1 + return check_no_trap(lldb, ctx.process) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--log-level", default="info", + choices=["debug", "info", "warning", "error"], + help="Logging level (default: warning)") + parser.add_argument("--use-debugger", action="store_true") + parser.add_argument("--lldb-python-path", default=None) + parser.add_argument("--soft-traps", action="store_true", + help="Expect soft traps (non-fatal)") + parser.add_argument("binary") + parser.add_argument("args", nargs="*") + + parsed = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, parsed.log_level.upper()), + format="%(name)s: %(levelname)s: %(message)s", + ) + + if not os.path.isfile(parsed.binary): + log.error("binary not found: '%s'", parsed.binary) + return 1 + + if parsed.use_debugger: + return run_debugger(parsed.binary, parsed.args, parsed.lldb_python_path) + return run_direct(parsed.binary, parsed.args, parsed.soft_traps) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/compiler-rt/test/bounds_safety/scripts/expect_trap.py b/compiler-rt/test/bounds_safety/scripts/expect_trap.py new file mode 100755 index 0000000000000..74ea1411f7e1f --- /dev/null +++ b/compiler-rt/test/bounds_safety/scripts/expect_trap.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +"""Runs a binary and verify a trap occurs in it as expected. + +It supports running in two modes: + +Direct mode: Run binary directly. Succeeds if process is killed by a signal + (exit code < 0). +Debugger mode: Run binary under debugger. Succeeds if process stops with a + bounds-safety trap and the trap location and message match the + verify comments in the provided source file. + +Verify comment format for unique or soft traps: + // prefix@+N{reason} - trap expected N lines below + // prefix@-N{reason} - trap expected N lines above + // prefix{reason} - trap expected on the same line + +Verify comment format for merged traps: + // prefix-merged{function_name} - trap occurs in function `function_name` +""" + +import argparse +import logging +import os +import re +import subprocess +import sys + +log = logging.getLogger("expect_trap") + +SOFT_TRAP_MARKER = "***HIT BOUNDS SAFETY SOFT TRAP***:" + + +def run_direct(binary, args): + result = subprocess.run([binary] + args) + if result.returncode < 0: + return 0 + log.error( + "expected trap but process exited with status %d", result.returncode + ) + return 1 + + +def run_direct_soft_trap(binary, args): + """Verify a soft trap fires in direct mode.""" + result = subprocess.run([binary] + args, capture_output=True, text=True) + if result.returncode != 0: + log.error( + "expected soft trap but process exited with status %d", + result.returncode, + ) + return 1 + if SOFT_TRAP_MARKER not in result.stderr: + log.error("soft trap marker not found in stderr") + log.error("stderr: %s", result.stderr) + return 1 + return 0 + + +# --------------------------------------------------------------------------- +# Verify-comment parsing +# --------------------------------------------------------------------------- + +def parse_verify_comments(source_path, prefix): + """Parse verify comments from a source file. + + Returns a list of (expected_line, expected_message) tuples. + """ + pattern = re.compile( + r"//\s*" + re.escape(prefix) + r"(?:@([+-]?\d+))?\{(.+)\}" + ) + results = [] + with open(source_path, "r") as f: + for line_no, line in enumerate(f, start=1): + m = pattern.search(line) + if m: + offset = int(m.group(1)) if m.group(1) is not None else 0 + expected_line = line_no + offset + expected_message = m.group(2) + results.append((expected_line, expected_message)) + return results + + +def parse_merged_verify_comments(source_path, prefix): + """Parse merged-trap verify comments from a source file. + + Format: // prefix-merged{function_name} + Returns a list of function_name strings. + """ + pattern = re.compile( + r"//\s*" + re.escape(prefix) + r"-merged\{(.+)\}" + ) + results = [] + with open(source_path, "r") as f: + for line_no, line in enumerate(f, start=1): + m = pattern.search(line) + if m: + results.append(m.group(1)) + return results + + +# --------------------------------------------------------------------------- +# LLDB helpers +# --------------------------------------------------------------------------- + +BOUNDS_CHECK_PREFIX = "Bounds check failed: " +SOFT_BOUNDS_CHECK_PREFIX = "Soft Bounds check failed: " + + +def strip_trap_prefix(description): + """Strip the bounds-safety trap prefix from a stop description. + + Returns (stripped_message, True) on success, (description, False) on failure. + """ + for pfx in (BOUNDS_CHECK_PREFIX, SOFT_BOUNDS_CHECK_PREFIX): + if description.startswith(pfx): + return description[len(pfx):], True + return description, False + + +# Expected trap instructions per architecture (mnemonic, operands). +EXPECTED_TRAP_INSTRUCTIONS = { + "arm64": ("brk", "#0x5519"), + "arm64e": ("brk", "#0x5519"), + "x86_64": ("ud1l", "0x19(%eax), %eax"), + "x86_64h": ("ud1l", "0x19(%eax), %eax"), +} + + +def check_trap_instruction(lldb, thread, process, arch): + """Verify frame 0 stopped on the expected trap instruction. + + Returns (True, "") on success, (False, detail_string) on failure. + """ + expected = EXPECTED_TRAP_INSTRUCTIONS.get(arch) + if expected is None: + return False, "unsupported architecture '{}' for trap instruction check".format(arch) + + expected_mnemonic, expected_operands = expected + target = process.GetTarget() + frame0 = thread.GetFrameAtIndex(0) + addr = frame0.GetPCAddress() + + inst_list = target.ReadInstructions(addr, 1) + if inst_list.GetSize() == 0: + return False, "could not disassemble instruction at PC" + + inst = inst_list.GetInstructionAtIndex(0) + mnemonic = inst.GetMnemonic(target) + operands = inst.GetOperands(target) + + if mnemonic != expected_mnemonic or operands != expected_operands: + return False, ( + "unexpected trap instruction\n" + " expected: {} {}\n" + " actual: {} {}".format( + expected_mnemonic, expected_operands, mnemonic, operands) + ) + + return True, f"{mnemonic} {operands}" + + +def find_test_frame(thread, source_basename): + """Walk stack frames to find first frame matching the test source. + + Skips frames with line number 0 (e.g. the trap function itself which + has the source filename but no meaningful line info). + """ + for frame in thread: + line_entry = frame.GetLineEntry() + if not line_entry.IsValid(): + continue + if line_entry.GetLine() == 0: + continue + file_spec = line_entry.GetFileSpec() + if file_spec.GetFilename() == source_basename: + return frame, line_entry + return None, None + + +def _stop_reason_name(lldb, reason): + """Return a human-readable name for an LLDB stop reason enum value.""" + names = {getattr(lldb, a): a for a in dir(lldb) if a.startswith("eStopReason")} + name = names.get(reason, "unknown") + return "{} ({})".format(name, reason) + + +def log_stop_info(thread): + """Log the stop reason and backtrace from a thread, if available.""" + if thread is None: + return + import lldb + stop_reason = thread.GetStopReason() + stop_desc = thread.GetStopDescription(256) + log.info("Stop reason: %s", _stop_reason_name(lldb, stop_reason)) + log.info("Stop description: %s", stop_desc) + log.info("Backtrace:") + for f in thread: + log.info(" %s", f) + + +def has_bs_instrumentation_plugin(lldb, debugger): + """Check if the BoundsSafety instrumentation runtime plugin is available.""" + result = lldb.SBCommandReturnObject() + debugger.GetCommandInterpreter().HandleCommand( + "plugin list instrumentation-runtime.BoundsSafety", result) + return result.Succeeded() and "[+] BoundsSafety" in result.GetOutput() + + +def extract_trap_msg_from_stack(thread, start_frame=0): + """Extract trap message from __clang_trap_msg frame in the call stack. + + When the BoundsSafety instrumentation plugin or VerboseTrapFrameRecognizer + is not available, the trap message can be recovered from a frame's + function name, which has the format: + __clang_trap_msg$$ + + Searches from start_frame onwards. + Returns (category, message) on success, (None, None) on failure. + """ + num_frames = thread.GetNumFrames() + # For hard traps the __clang_trap_msg should be on frame 0, for soft traps + # it's frame 1. + for i in range(start_frame, min(num_frames, start_frame + 2)): + frame = thread.GetFrameAtIndex(i) + if frame is None: + continue + func_name = frame.GetFunctionName() + if func_name is None or not func_name.startswith("__clang_trap_msg$"): + continue + parts = func_name.split("$", 2) + if len(parts) != 3: + continue + return parts[1], parts[2] + return None, None + + +def check_soft_trap_no_plugin(lldb, process, expectation, verify_source): + """Verify a soft trap when the instrumentation plugin is absent. + + The process is stopped at a manual breakpoint on __bounds_safety_soft_trap. + Extract the trap message from the __clang_trap_msg frame in the stack. + + This can be removed once we no longer need to test using older LLDB's + that are missing the instrumentation-runtime.BoundsSafety plugin. + """ + state = process.GetState() + + if state == lldb.eStateExited: + exit_status = process.GetExitStatus() + log.error( + "expected soft trap but process exited with status %d", exit_status + ) + return 1 + + if state != lldb.eStateStopped: + log.error("unexpected process state: %s", state) + return 1 + + thread = process.GetSelectedThread() + stop_reason = thread.GetStopReason() + + if stop_reason != lldb.eStopReasonBreakpoint: + log.error("unexpected stop reason %s: %s", + stop_reason, thread.GetStopDescription(256)) + log_stop_info(thread) + return 1 + + category, message = extract_trap_msg_from_stack(thread) + if category is None: + log.error("could not extract trap message from call stack") + log_stop_info(thread) + return 1 + + # Build a stop description matching the verify-comment format + stop_desc = "Soft {}: {}".format(category, message) + stripped_msg, ok = strip_trap_prefix(stop_desc) + if not ok: + log.error("could not strip trap prefix from: '%s'", stop_desc) + log_stop_info(thread) + return 1 + + expected_line, expected_message = expectation + + source_basename = os.path.basename(verify_source) + frame, line_entry = find_test_frame(thread, source_basename) + if frame is None: + log.error("no stack frame found in '%s'", source_basename) + log_stop_info(thread) + return 1 + + actual_line = line_entry.GetLine() + if actual_line != expected_line: + log.error( + "trap at line %d but expected line %d", + actual_line, expected_line, + ) + log_stop_info(thread) + return 1 + + if stripped_msg != expected_message: + log.error( + "trap message mismatch\n" + " expected: '%s'\n" + " actual: '%s'", + expected_message, stripped_msg, + ) + log_stop_info(thread) + return 1 + log.info("Found expected -fbounds-safety soft trap") + log_stop_info(thread) + return 0 + + +def check_trap(lldb, process, expectation, verify_source, merged_traps=False, + soft_traps=False): + """Verify the process stopped with a bounds-safety trap. Returns 0 on success.""" + state = process.GetState() + + if state == lldb.eStateExited: + exit_status = process.GetExitStatus() + log.error( + "expected trap but process exited with status %d", exit_status + ) + return 1 + + if state != lldb.eStateStopped: + log.error("unexpected process state: %s", state) + return 1 + + # Process is stopped - verify it's a bounds-safety trap + thread = process.GetSelectedThread() + stop_reason = thread.GetStopReason() + stop_desc = thread.GetStopDescription(256) + arch = process.GetTarget().GetTriple().split("-")[0] + + # Determine the expected stop reason based on trap type + if soft_traps: + expected_stop_reason = lldb.eStopReasonInstrumentation + else: + expected_stop_reason = lldb.eStopReasonException + + if stop_reason != expected_stop_reason: + log.error("unexpected stop reason %s: %s", stop_reason, stop_desc) + log_stop_info(thread) + return 1 + + # For hard traps, verify the trap instruction encoding and handle merged + # traps if necessary + if not soft_traps: + ok, detail = check_trap_instruction(lldb, thread, process, arch) + if not ok: + log.error("%s", detail) + log_stop_info(thread) + return 1 + else: + log.info('Stopped at expected -fbounds-safety trap instruction: %s', detail) + + if merged_traps: + # Check function name + expected_func = expectation # just a string + source_basename = os.path.basename(verify_source) + frame, line_entry = find_test_frame(thread, source_basename) + if frame is None: + log.error("no stack frame found in '%s'", source_basename) + log_stop_info(thread) + return 1 + actual_func = frame.GetFunctionName() + if actual_func != expected_func: + log.error( + "function name mismatch\n" + " expected: '%s'\n" + " actual: '%s'", + expected_func, actual_func, + ) + log_stop_info(thread) + return 1 + log.info("Found expected -fbounds-safety trap in '%s'", actual_func) + log_stop_info(thread) + return 0 + + assert not merged_traps + + # Verify the stop description indicates a bounds-safety hard or soft trap. + # When using older LLDB versions the VerboseFrapFrameRecognizer might not + # fire. When that happens the stop description will be a raw exception + # description (e.g. "EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, + # subcode=0x0)"). Fall back to extracting the trap message from the + # __clang_trap_msg function name in the call stack. + if not ( + stop_desc.startswith(BOUNDS_CHECK_PREFIX) + or stop_desc.startswith(SOFT_BOUNDS_CHECK_PREFIX) + ): + # TODO: Remove this once we no longer need to support older LLDB + # versions. + category, message = extract_trap_msg_from_stack(thread) + if category is None: + log.error( + "stop description does not indicate a bounds-safety trap: '%s'", + stop_desc, + ) + log_stop_info(thread) + return 1 + log.warning( + "stop description ('%s') does not indicate a bounds-safety trap. " + "Extracting trap message from call stack instead. This likely " + "means an older LLDB is being used with a VerboseTrapFrame " + "recognizer that is out of sync with the compiler.", + stop_desc, + ) + if soft_traps: + stop_desc = "Soft {}: {}".format(category, message) + else: + stop_desc = "{}: {}".format(category, message) + + # Check location and message + stripped_msg, ok = strip_trap_prefix(stop_desc) + if not ok: + log.error("could not strip trap prefix from: '%s'", stop_desc) + log_stop_info(thread) + return 1 + + expected_line, expected_message = expectation + + # Find the frame in the test source + source_basename = os.path.basename(verify_source) + frame, line_entry = find_test_frame(thread, source_basename) + if frame is None: + log.error("no stack frame found in '%s'", source_basename) + log_stop_info(thread) + return 1 + + actual_line = line_entry.GetLine() + if actual_line != expected_line: + log.error( + "trap at line %d but expected line %d", + actual_line, expected_line, + ) + log_stop_info(thread) + return 1 + + if stripped_msg != expected_message: + log.error( + "trap message mismatch\n" + " expected: '%s'\n" + " actual: '%s'", + expected_message, stripped_msg, + ) + log_stop_info(thread) + return 1 + + log.info("Found expected -fbounds-safety %s trap", 'soft' if soft_traps else 'hard') + log_stop_info(thread) + return 0 + + +def run_debugger(binary, args, lldb_python_path, verify_source, verify_prefix, + merged_traps=False, soft_traps=False): + # Parse and validate verify comments before launching the debugger so we + # fail fast on malformed or missing expectations. + if merged_traps: + expectations = parse_merged_verify_comments( + verify_source, verify_prefix) + if not expectations: + log.error( + "no merged verify comments with prefix '%s' found in '%s'", + verify_prefix, verify_source, + ) + return 1 + if len(expectations) > 1: + log.error( + "multiple merged verify comments with prefix '%s' found " + "in '%s' (expected exactly one)", + verify_prefix, verify_source, + ) + return 1 + expectation = expectations[0] # function name string + else: + expectations = parse_verify_comments(verify_source, verify_prefix) + if not expectations: + log.error( + "no verify comments with prefix '%s' found in '%s'", + verify_prefix, verify_source, + ) + return 1 + if len(expectations) > 1: + log.error( + "multiple verify comments with prefix '%s' found in '%s' " + "(expected exactly one)", + verify_prefix, verify_source, + ) + return 1 + expectation = expectations[0] + + from lldb_utils import LLDBLaunchError, LLDBProcessContextManager, import_lldb + + lldb = import_lldb(lldb_python_path) + + with LLDBProcessContextManager(lldb, binary, args) as ctx: + # For soft traps without the instrumentation plugin, set a manual + # breakpoint before launching so we stop at the trap function. + has_plugin = False + if soft_traps: + has_plugin = has_bs_instrumentation_plugin(lldb, ctx.debugger) + if not has_plugin: + log.info("BoundsSafety instrumentation plugin not available, " + "setting manual breakpoint") + ctx.target.BreakpointCreateByName("__bounds_safety_soft_trap") + + try: + ctx.launch() + except LLDBLaunchError as e: + log.error("%s", e) + return 1 + + if soft_traps and not has_plugin: + return check_soft_trap_no_plugin(lldb, ctx.process, expectation, + verify_source) + return check_trap(lldb, ctx.process, expectation, verify_source, + merged_traps, soft_traps) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--log-level", default="info", + choices=["debug", "info", "warning", "error"], + help="Logging level (default: warning)") + parser.add_argument("--use-debugger", action="store_true") + parser.add_argument("--lldb-python-path", default=None) + parser.add_argument("--verify-prefix", default="expect-trap", + help="Prefix for verify comments (default: expect-trap)") + parser.add_argument("--merged-traps", action="store_true", + help="Merged-traps mode: verify function name only") + parser.add_argument("--soft-traps", action="store_true", + help="Soft-traps mode: expect non-fatal traps") + parser.add_argument("source_file_path", + help="Source file with verify comments") + parser.add_argument("binary") + parser.add_argument("args", nargs="*") + + parsed = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, parsed.log_level.upper()), + format="%(name)s: %(levelname)s: %(message)s", + ) + + if not os.path.isfile(parsed.binary): + log.error("binary not found: '%s'", parsed.binary) + return 1 + if not os.path.isfile(parsed.source_file_path): + log.error("source file not found: '%s'", parsed.source_file_path) + return 1 + + if parsed.use_debugger: + return run_debugger( + parsed.binary, + parsed.args, + parsed.lldb_python_path, + parsed.source_file_path, + parsed.verify_prefix, + parsed.merged_traps, + parsed.soft_traps, + ) + if parsed.soft_traps: + return run_direct_soft_trap(parsed.binary, parsed.args) + return run_direct(parsed.binary, parsed.args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/compiler-rt/test/bounds_safety/scripts/lldb_utils.py b/compiler-rt/test/bounds_safety/scripts/lldb_utils.py new file mode 100644 index 0000000000000..a8645946eaca1 --- /dev/null +++ b/compiler-rt/test/bounds_safety/scripts/lldb_utils.py @@ -0,0 +1,74 @@ +"""Shared utilities for bounds-safety test scripts.""" + +import logging +import sys + +log = logging.getLogger(__name__) + + +class LLDBLaunchError(Exception): + """Raised when LLDB target creation or process launch fails.""" + pass + + +def import_lldb(lldb_python_path): + """Import and return the lldb module, adjusting sys.path if needed.""" + if lldb_python_path: + sys.path.insert(0, lldb_python_path) + import lldb + return lldb + + +class LLDBProcessContextManager: + """Context manager that creates an LLDB target and kills the process on exit. + + Usage: + lldb = import_lldb(lldb_python_path) + with LLDBProcessContextManager(lldb, binary, args) as ctx: + # ctx.debugger and ctx.target are available here + # Set breakpoints, check plugins, etc. before launching + ctx.launch() # raises LLDBLaunchError on failure + # ctx.process is the SBProcess + """ + + def __init__(self, lldb, binary, args): + self._lldb = lldb + self._binary = binary + self._args = args + self.debugger = None + self.target = None + self.process = None + + def __enter__(self): + self.debugger = self._lldb.SBDebugger.Create() + self.debugger.SetAsync(False) + + self.target = self.debugger.CreateTarget(self._binary) + if not self.target: + raise LLDBLaunchError( + "could not create target for '{}'".format(self._binary) + ) + + return self + + def launch(self): + """Launch the process. Raises LLDBLaunchError on failure.""" + launch_info = self._lldb.SBLaunchInfo(self._args) + launch_info.SetLaunchFlags(0) + + error = self._lldb.SBError() + self.process = self.target.Launch(launch_info, error) + if not self.process or not error.Success(): + self.process = None + raise LLDBLaunchError( + "could not launch process: {}".format(error) + ) + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.process is not None: + try: + if self.process.is_alive: + self.process.Kill() + except Exception: + pass + return False diff --git a/compiler-rt/test/bounds_safety/utils/soft_trap_runtime_impl.h b/compiler-rt/test/bounds_safety/utils/soft_trap_runtime_impl.h new file mode 100644 index 0000000000000..1156239351310 --- /dev/null +++ b/compiler-rt/test/bounds_safety/utils/soft_trap_runtime_impl.h @@ -0,0 +1,23 @@ +#if TEST_SOFT_TRAP +#include +#include + +#if __CLANG_BOUNDS_SAFETY_SOFT_TRAP_API_VERSION > 0 +#error API changed +#endif + +static unsigned trap_counter = 0; + +// If this is inlined LLDB won't be able to set a breakpoint (or use the bounds +// safety instrumentation plugin which internally relies on setting a +// breakpoint) on the soft trap. The `expect_trap.py` script relies on being +// able to observe the soft trap, so we have to use the `noinline` attribute to +// avoid this. +__attribute__((noinline)) +void __bounds_safety_soft_trap(void) { + // print a simple message that `expect_trap.py` can look for + fprintf(stderr, "***HIT BOUNDS SAFETY SOFT TRAP***: %d\n", trap_counter); + ++trap_counter; +} + +#endif