From bb0ef8de287334150f3d58e035f643721e4df650 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:15:25 -0700 Subject: [PATCH 1/8] add precommit and gitignore Signed-off-by: Skyler Medeiros --- .github/workflows/pre-commit.yml | 15 +++++++++++++++ .gitignore | 3 +++ .pre-commit-config.yaml | 17 +++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..6a474d2 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,15 @@ +--- +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ac5da2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..82b6255 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +--- +repos: + - repo: https://github.com/polymathrobotics/polymath_code_standard + rev: v2.2.0 + hooks: + # Basic checks and fixes that apply to any text file and the git repository itself + - id: polymath-general + - id: polymath-copyright + args: [--license, Apache-2.0, --copyright-org, 'Polymath Robotics, Inc.', --reuse-style] + # Specific languages + - id: polymath-python + - id: polymath-cpp + - id: polymath-shell + - id: polymath-cmake + - id: polymath-markdown + - id: polymath-yaml + - id: polymath-json From 41d9b6471ce4b7434c83315448fec3d9145c46c7 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:16:18 -0700 Subject: [PATCH 2/8] add license Signed-off-by: Skyler Medeiros --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9..01f9f9f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Polymath Robotics, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 2bc19a43001ce2cd9d4e37582d5fe5aaea08dae9 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:19:20 -0700 Subject: [PATCH 3/8] add sanitizer cmake hook Signed-off-by: Skyler Medeiros --- cmake/sanitizers.cmake | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 cmake/sanitizers.cmake diff --git a/cmake/sanitizers.cmake b/cmake/sanitizers.cmake new file mode 100644 index 0000000..8c67aca --- /dev/null +++ b/cmake/sanitizers.cmake @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# Injected into every package via -DCMAKE_PROJECT_INCLUDE, which CMake evaluates +# immediately after each project() call. Instruments a whole workspace without +# editing any package's CMakeLists.txt. +# +# Flags are applied as directory properties rather than CMAKE_CXX_FLAGS so they +# land after the per-config flags. A -DCMAKE_BUILD_TYPE=RelWithDebInfo would +# otherwise append -O2 and undo -O1 -fno-omit-frame-pointer. + +if(NOT ROS2_SANITIZER) + return() +endif() + +# Excluded packages still build, just uninstrumented. Defaults to message packages +if(ROS2_SANITIZER_EXCLUDE AND PROJECT_NAME MATCHES "${ROS2_SANITIZER_EXCLUDE}") + message(STATUS "ros2-sanitizers: skipping ${PROJECT_NAME}") + return() +endif() + +# Nothing here runs on the host, and no sanitizer runtime exists for most embedded +# toolchains. A repo mixing firmware with host code would otherwise fail to configure. +if(CMAKE_CROSSCOMPILING) + message(STATUS "ros2-sanitizers: skipping ${PROJECT_NAME}, cross-compiling") + return() +endif() + +# Checked per enabled language rather than against CMAKE_CXX_COMPILER_ID alone, which is +# empty in a `project(foo C)` package and would reject every pure C library. +get_property(_ros2_sanitizer_languages GLOBAL PROPERTY ENABLED_LANGUAGES) +set(_ros2_sanitizer_instrumentable OFF) + +foreach(_ros2_sanitizer_lang IN ITEMS C CXX) + if(_ros2_sanitizer_lang IN_LIST _ros2_sanitizer_languages) + if(NOT CMAKE_${_ros2_sanitizer_lang}_COMPILER_ID MATCHES "GNU|Clang") + message(FATAL_ERROR + "ros2-sanitizers: unsupported ${_ros2_sanitizer_lang} compiler " + "${CMAKE_${_ros2_sanitizer_lang}_COMPILER_ID}") + endif() + set(_ros2_sanitizer_instrumentable ON) + endif() +endforeach() + +# project(foo NONE), or a language-less interface package. Nothing to instrument. +if(NOT _ros2_sanitizer_instrumentable) + return() +endif() + +set(_ros2_sanitizer_common -O1 -g -fno-omit-frame-pointer) + +if(ROS2_SANITIZER STREQUAL "asan-ubsan") + # vptr is clang-only and false-positives on classes deriving from an uninstrumented + # base, which is every rclcpp::Node subclass in an apt-installed ROS. Without + # -fno-sanitize-recover UBSan reports a finding and the process still exits 0. + set(_ros2_sanitizer_flags + -fsanitize=address,undefined + -fno-sanitize=vptr + -fsanitize-address-use-after-scope + -fno-sanitize-recover=all) +elseif(ROS2_SANITIZER STREQUAL "lsan") + set(_ros2_sanitizer_flags -fsanitize=leak) +elseif(ROS2_SANITIZER STREQUAL "tsan") + set(_ros2_sanitizer_flags -fsanitize=thread) +else() + message(FATAL_ERROR "ros2-sanitizers: unknown preset '${ROS2_SANITIZER}'") +endif() + +message(STATUS "ros2-sanitizers: instrumenting ${PROJECT_NAME} with ${ROS2_SANITIZER}") + +add_compile_options(${_ros2_sanitizer_common} ${_ros2_sanitizer_flags}) +add_link_options(${_ros2_sanitizer_flags}) From 34067c56ed495c4ca75a3a3f3e24a502e6e974b8 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:25:46 -0700 Subject: [PATCH 4/8] add sanitizer tool and suppression files for lsan / tsan Signed-off-by: Skyler Medeiros --- sanitizer_tool/__main__.py | 168 ++++++++++++++++++++++++++ sanitizer_tool/suppressions/lsan.supp | 9 ++ sanitizer_tool/suppressions/tsan.supp | 9 ++ scripts/sanitize.sh | 53 ++++++++ 4 files changed, 239 insertions(+) create mode 100644 sanitizer_tool/__main__.py create mode 100644 sanitizer_tool/suppressions/lsan.supp create mode 100644 sanitizer_tool/suppressions/tsan.supp create mode 100755 scripts/sanitize.sh diff --git a/sanitizer_tool/__main__.py b/sanitizer_tool/__main__.py new file mode 100644 index 0000000..b7cecdf --- /dev/null +++ b/sanitizer_tool/__main__.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Run-stage half of the sanitizer presets: runtime options, suppressions, launcher. + +Compile and link flags live in cmake/sanitizers.cmake, which CMake injects into every +package. This tool covers what CMake cannot: the environment the tests run under. + +Stdlib only and ROS-agnostic, so it runs unmodified on a runner, inside a +`jobs..container:`, or inside a hand-rolled `docker run`. +""" + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SUPPRESSIONS_DIR = Path(__file__).resolve().parent / 'suppressions' +CMAKE_INCLUDE = REPO_ROOT / 'cmake' / 'sanitizers.cmake' + +# rclpy dlopens generated message typesupport into an uninstrumented python3. +DEFAULT_EXCLUDE = '_msgs$' + +PRESETS = { + 'asan-ubsan': { + 'summary': 'Address + undefined behavior. GCC and clang. Cheap enough to gate every PR.', + 'preload': 'libasan.so', + 'options': { + # Leaks are the standalone `lsan` preset; ROS and CPython leak far too much at + # exit to gate a PR on. ODR checks trip over pluginlib and component libraries. + 'ASAN_OPTIONS': ['detect_leaks=0', 'detect_odr_violation=0', 'print_stacktrace=1', 'halt_on_error=1'], + 'UBSAN_OPTIONS': ['print_stacktrace=1', 'halt_on_error=1'], + }, + }, + 'lsan': { + 'summary': 'Standalone leak detection. GCC and clang. Nightly.', + 'preload': 'liblsan.so', + 'options': { + 'LSAN_OPTIONS': ['print_suppressions=0', 'report_objects=1'], + }, + 'suppressions': {'LSAN_OPTIONS': 'lsan.supp'}, + }, + 'tsan': { + 'summary': 'Data races. GCC and clang. Nightly only, 5-15x slowdown.', + # No preload. TSan must own the address space from process start; preloading it + # into an already-instrumented binary aborts with "unexpected memory mapping". + # The cost is that TSan cannot cover tests driven by an uninstrumented python3. + 'preload': None, + 'options': { + # Uninstrumented dependencies produce unavoidable reports; collect them all + # rather than stopping at the first. + 'TSAN_OPTIONS': ['halt_on_error=0', 'history_size=7', 'second_deadlock_stack=1'], + }, + 'suppressions': {'TSAN_OPTIONS': 'tsan.supp'}, + }, +} + +# MemorySanitizer is deliberately absent: it is clang-only and requires every dependency +# down to libstdc++ to be instrumented, which no ROS apt stack provides. + + +def resolve_preload(lib: str, compiler: str) -> str | None: + """Resolve a sanitizer runtime library to an absolute path. + + Covers the case of an uninstrumented python3 dlopening an instrumented library, which + aborts unless the runtime is loaded first. The path is compiler-version specific, so + it only resolves on the machine that runs the tests. + + @param lib Runtime library name, e.g. `libasan.so`. + @param compiler Compiler driver to interrogate. + @return Absolute path, or None when the compiler does not ship the runtime. + """ + try: + result = subprocess.run([compiler, f'-print-file-name={lib}'], capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError): + return None + + path = result.stdout.strip() + # The driver echoes the bare name back when it cannot find the library. + return path if path != lib and Path(path).exists() else None + + +def cmake_args(preset_name: str, exclude: str) -> list[str]: + """Return the colcon --cmake-args that instrument every package in a workspace. + + @param preset_name Key into PRESETS. + @param exclude Regex matched against PROJECT_NAME; matches build uninstrumented. + @return Argument list, ready to append to `colcon build --cmake-args`. + """ + return [ + f'-DCMAKE_PROJECT_INCLUDE={CMAKE_INCLUDE}', + f'-DROS2_SANITIZER={preset_name}', + f'-DROS2_SANITIZER_EXCLUDE={exclude}', + ] + + +def test_env(preset: dict, compiler: str) -> dict[str, str]: + """Return the runtime option variables for running a sanitized test suite. + + @param preset Entry from PRESETS. + @param compiler Compiler driver used to locate the runtime library. + @return Mapping of variable name to value. + """ + env = {} + suppressions = preset.get('suppressions', {}) + for var, options in preset['options'].items(): + values = list(options) + supp = suppressions.get(var) + if supp is not None: + values.append(f'suppressions={SUPPRESSIONS_DIR / supp}') + env[var] = ':'.join(values) + + preload_lib = preset.get('preload') + preload = resolve_preload(preload_lib, compiler) if preload_lib is not None else None + if preload is not None: + inherited = os.environ.get('LD_PRELOAD', '').strip() + env['LD_PRELOAD'] = f'{preload}:{inherited}' if inherited else preload + + return env + + +def main() -> int: + parser = argparse.ArgumentParser(prog='sanitizer_tool', description=__doc__) + subparsers = parser.add_subparsers(dest='command', required=True) + + subparsers.add_parser('list', help='list preset names and what they cover') + + cmake_parser = subparsers.add_parser('cmake-args', help='emit colcon --cmake-args for a preset') + cmake_parser.add_argument('preset', choices=sorted(PRESETS)) + cmake_parser.add_argument( + '--exclude', default=DEFAULT_EXCLUDE, help='regex of package names to leave uninstrumented' + ) + + env_parser = subparsers.add_parser('test-env', help='emit runtime option variables for a preset') + env_parser.add_argument('preset', choices=sorted(PRESETS)) + env_parser.add_argument('--compiler', default=os.environ.get('CXX') or 'g++') + env_parser.add_argument( + '--format', + choices=('shell', 'github-env'), + default='shell', + help='shell emits `export K=V` for eval; github-env emits bare K=V for $GITHUB_ENV', + ) + + args = parser.parse_args() + + if 'list' == args.command: + for name, preset in sorted(PRESETS.items()): + print(f'{name:<12} {preset["summary"]}') + return 0 + + if 'cmake-args' == args.command: + # One argument per line. Shell-quoting them onto a single line breaks the caller: + # a regex like `_msgs$` comes back quoted, and command substitution word-splits + # without removing quotes, so CMake receives a literal quote character. + # Read with `mapfile -t args < <(... cmake-args ...)`. + print('\n'.join(cmake_args(args.preset, args.exclude))) + return 0 + + for var, value in test_env(PRESETS[args.preset], args.compiler).items(): + print(f'{var}={value}' if 'github-env' == args.format else f'export {var}={shlex.quote(value)}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/sanitizer_tool/suppressions/lsan.supp b/sanitizer_tool/suppressions/lsan.supp new file mode 100644 index 0000000..51457bd --- /dev/null +++ b/sanitizer_tool/suppressions/lsan.supp @@ -0,0 +1,9 @@ +# Leaks in uninstrumented dependencies. Keep entries narrow; anything in your own code must be fixed. +leak:libpython3 +leak:libdl +leak:libddsc +leak:libcyclonedds +leak:libfastrtps +leak:librmw +leak:libclass_loader +leak:libtinyxml2 diff --git a/sanitizer_tool/suppressions/tsan.supp b/sanitizer_tool/suppressions/tsan.supp new file mode 100644 index 0000000..6dbbfdf --- /dev/null +++ b/sanitizer_tool/suppressions/tsan.supp @@ -0,0 +1,9 @@ +# Races reported inside uninstrumented dependencies. TSan cannot see their atomics, so these +# are noise, not verdicts. Keep entries narrow; anything in your own code must be fixed. +called_from_lib:libddsc.so +called_from_lib:libcyclonedds.so +called_from_lib:libfastrtps.so +called_from_lib:libpython3.so +race:libddsc.so +race:libcyclonedds.so +race:libfastrtps.so diff --git a/scripts/sanitize.sh b/scripts/sanitize.sh new file mode 100755 index 0000000..b13246b --- /dev/null +++ b/scripts/sanitize.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# Build and test a colcon workspace under one sanitizer preset. Shared by the per-sanitizer +# actions so the build and test logic exists once. + +set -euo pipefail + +PRESET="${PRESET:?PRESET is required}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOL="$REPO_ROOT/sanitizer_tool" + +WORKSPACE="${WORKSPACE:-.}" +ROS_SETUP="${ROS_SETUP:-/opt/ros/${ROS_DISTRO:-}/setup.bash}" +EXCLUDE_PACKAGES="${EXCLUDE_PACKAGES:-_msgs$}" +PACKAGES="${PACKAGES:-}" +BUILD_ARGS="${BUILD_ARGS:-}" +TEST_ARGS="${TEST_ARGS:-}" +COMPILER="${COMPILER:-g++}" +SKIP_BUILD="${SKIP_BUILD:-false}" + +cd "$WORKSPACE" + +if [[ -f "$ROS_SETUP" ]]; then + # shellcheck disable=SC1090 + source "$ROS_SETUP" +fi + +if [[ 'true' != "$SKIP_BUILD" ]]; then + mapfile -t CMAKE_ARGS < <(python3 "$TOOL" cmake-args "$PRESET" --exclude "$EXCLUDE_PACKAGES") + echo "::group::colcon build ($PRESET)" + # shellcheck disable=SC2086 + colcon build \ + --event-handlers console_cohesion+ summary+ \ + $PACKAGES \ + $BUILD_ARGS \ + --cmake-args "${CMAKE_ARGS[@]}" + echo '::endgroup::' +fi + +eval "$(python3 "$TOOL" test-env "$PRESET" --compiler "$COMPILER")" + +echo "::group::colcon test ($PRESET)" +# shellcheck disable=SC2086 +colcon test \ + --executor sequential \ + --event-handlers console_cohesion+ \ + $PACKAGES \ + $TEST_ARGS +echo '::endgroup::' + +colcon test-result --verbose From 59448b78ab2c218127b60317565c642741013c3f Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:26:25 -0700 Subject: [PATCH 5/8] add lsan, tsan and asan github actions Signed-off-by: Skyler Medeiros --- asan/action.yml | 48 ++++++++++++++++++++++++++++++++++++++++ lsan/action.yml | 45 +++++++++++++++++++++++++++++++++++++ tsan/action.yml | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 asan/action.yml create mode 100644 lsan/action.yml create mode 100644 tsan/action.yml diff --git a/asan/action.yml b/asan/action.yml new file mode 100644 index 0000000..fd33111 --- /dev/null +++ b/asan/action.yml @@ -0,0 +1,48 @@ +--- +name: ROS 2 AddressSanitizer +description: Build and test a ROS 2 workspace under AddressSanitizer and UndefinedBehaviorSanitizer. + +inputs: + workspace: + description: Colcon workspace root, containing src/. + default: . + ros-setup: + description: setup.bash to source before colcon. Skipped when the file does not exist. + default: /opt/ros/${ROS_DISTRO}/setup.bash + exclude-packages: + description: > + Regex matched against each package's CMake PROJECT_NAME. Matches build uninstrumented. + Defaults to message packages, whose generated typesupport rclpy dlopens into an + uninstrumented python3, which aborts an instrumented process. + default: _msgs$ + packages: + description: Package selection arguments passed to both colcon build and colcon test. + default: '' + build-args: + description: Extra arguments for colcon build. + default: '' + test-args: + description: Extra arguments for colcon test. + default: '' + compiler: + description: Compiler driver used to locate the sanitizer runtime library. + default: g++ + skip-build: + description: Test an already-instrumented workspace instead of building it. + default: 'false' + +runs: + using: composite + steps: + - shell: bash + run: ${{ github.action_path }}/../scripts/sanitize.sh + env: + PRESET: asan-ubsan + WORKSPACE: ${{ inputs.workspace }} + ROS_SETUP: ${{ inputs.ros-setup }} + EXCLUDE_PACKAGES: ${{ inputs.exclude-packages }} + PACKAGES: ${{ inputs.packages }} + BUILD_ARGS: ${{ inputs.build-args }} + TEST_ARGS: ${{ inputs.test-args }} + COMPILER: ${{ inputs.compiler }} + SKIP_BUILD: ${{ inputs.skip-build }} diff --git a/lsan/action.yml b/lsan/action.yml new file mode 100644 index 0000000..891fac1 --- /dev/null +++ b/lsan/action.yml @@ -0,0 +1,45 @@ +--- +name: ROS 2 LeakSanitizer +description: Build and test a ROS 2 workspace under standalone LeakSanitizer. + +inputs: + workspace: + description: Colcon workspace root, containing src/. + default: . + ros-setup: + description: setup.bash to source before colcon. Skipped when the file does not exist. + default: /opt/ros/${ROS_DISTRO}/setup.bash + exclude-packages: + description: Regex matched against each package's CMake PROJECT_NAME. Matches build uninstrumented. + default: _msgs$ + packages: + description: Package selection arguments passed to both colcon build and colcon test. + default: '' + build-args: + description: Extra arguments for colcon build. + default: '' + test-args: + description: Extra arguments for colcon test. + default: '' + compiler: + description: Compiler driver used to locate the sanitizer runtime library. + default: g++ + skip-build: + description: Test an already-instrumented workspace instead of building it. + default: 'false' + +runs: + using: composite + steps: + - shell: bash + run: ${{ github.action_path }}/../scripts/sanitize.sh + env: + PRESET: lsan + WORKSPACE: ${{ inputs.workspace }} + ROS_SETUP: ${{ inputs.ros-setup }} + EXCLUDE_PACKAGES: ${{ inputs.exclude-packages }} + PACKAGES: ${{ inputs.packages }} + BUILD_ARGS: ${{ inputs.build-args }} + TEST_ARGS: ${{ inputs.test-args }} + COMPILER: ${{ inputs.compiler }} + SKIP_BUILD: ${{ inputs.skip-build }} diff --git a/tsan/action.yml b/tsan/action.yml new file mode 100644 index 0000000..c4e131f --- /dev/null +++ b/tsan/action.yml @@ -0,0 +1,59 @@ +--- +name: ROS 2 ThreadSanitizer +description: Build and test a ROS 2 workspace under ThreadSanitizer. + +inputs: + workspace: + description: Colcon workspace root, containing src/. + default: . + ros-setup: + description: setup.bash to source before colcon. Skipped when the file does not exist. + default: /opt/ros/${ROS_DISTRO}/setup.bash + exclude-packages: + description: Regex matched against each package's CMake PROJECT_NAME. Matches build uninstrumented. + default: _msgs$ + packages: + description: Package selection arguments passed to both colcon build and colcon test. + default: '' + build-args: + description: Extra arguments for colcon build. + default: '' + test-args: + description: Extra arguments for colcon test. + default: '' + compiler: + description: Compiler driver used to locate the sanitizer runtime library. + default: g++ + skip-build: + description: Test an already-instrumented workspace instead of building it. + default: 'false' + mmap-rnd-bits: + description: > + vm.mmap_rnd_bits to set before running. Kernels since 6.x default this to 32, more + ASLR entropy than TSan's shadow mapping tolerates, and every run dies before main + with "unexpected memory mapping". Set empty to skip, on a host already configured + or where the runner has no sudo. + default: '28' + +runs: + using: composite + steps: + # Host-wide and not namespaced, so this also fixes TSan inside any container the + # workflow later starts. Harmless to re-apply; the runner VM is discarded after the job. + - name: Lower ASLR entropy for ThreadSanitizer + if: inputs.mmap-rnd-bits != '' + shell: bash + run: sudo sysctl -w vm.mmap_rnd_bits=${{ inputs.mmap-rnd-bits }} + + - shell: bash + run: ${{ github.action_path }}/../scripts/sanitize.sh + env: + PRESET: tsan + WORKSPACE: ${{ inputs.workspace }} + ROS_SETUP: ${{ inputs.ros-setup }} + EXCLUDE_PACKAGES: ${{ inputs.exclude-packages }} + PACKAGES: ${{ inputs.packages }} + BUILD_ARGS: ${{ inputs.build-args }} + TEST_ARGS: ${{ inputs.test-args }} + COMPILER: ${{ inputs.compiler }} + SKIP_BUILD: ${{ inputs.skip-build }} From b9639b7e06e07dd68f087cc6fe0c75e410fc7936 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 11:27:22 -0700 Subject: [PATCH 6/8] add test job, and example problematic C / C++ programs Signed-off-by: Skyler Medeiros --- .github/workflows/test.yml | 59 ++++++++++++++++++++++++++ test/CMakeLists.txt | 15 +++++++ test/excluded/CMakeLists.txt | 13 ++++++ test/fixtures/c_heap_overflow.c | 15 +++++++ test/fixtures/data_race.cpp | 24 +++++++++++ test/fixtures/heap_overflow.cpp | 14 +++++++ test/fixtures/leak.cpp | 17 ++++++++ test/fixtures/no_defect.cpp | 20 +++++++++ test/fixtures/signed_overflow.cpp | 15 +++++++ test/pkg_CMakeLists.txt | 10 +++++ test/pure_c/CMakeLists.txt | 9 ++++ test/run_preset.sh | 69 +++++++++++++++++++++++++++++++ 12 files changed, 280 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 test/CMakeLists.txt create mode 100644 test/excluded/CMakeLists.txt create mode 100644 test/fixtures/c_heap_overflow.c create mode 100644 test/fixtures/data_race.cpp create mode 100644 test/fixtures/heap_overflow.cpp create mode 100644 test/fixtures/leak.cpp create mode 100644 test/fixtures/no_defect.cpp create mode 100644 test/fixtures/signed_overflow.cpp create mode 100644 test/pkg_CMakeLists.txt create mode 100644 test/pure_c/CMakeLists.txt create mode 100755 test/run_preset.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ac6c56d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,59 @@ +--- +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + presets: + name: ${{ matrix.preset }} on ${{ matrix.runner }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: [ubuntu-22.04, ubuntu-24.04, ubuntu-26.04] + preset: [asan-ubsan, lsan, tsan] + steps: + - uses: actions/checkout@v6 + + - name: Report ASLR entropy + run: sudo sysctl -n vm.mmap_rnd_bits + + # Only the tsan preset needs this. Left conditional rather than applied to all so a + # regression in the other two surfaces as a failure instead of being papered over. + - name: Lower ASLR entropy for ThreadSanitizer + if: matrix.preset == 'tsan' + run: sudo sysctl -w vm.mmap_rnd_bits=28 + + - run: test/run_preset.sh ${{ matrix.preset }} + + action: + name: action end to end + runs-on: ubuntu-26.04 + container: ros:rolling-ros-base + steps: + - uses: actions/checkout@v6 + + - name: Build a workspace from the fixtures + run: | + mkdir -p ws/src/fixture_pkg + cp -r test/fixtures ws/src/fixture_pkg/ + cp test/pkg_CMakeLists.txt ws/src/fixture_pkg/CMakeLists.txt + + # The workspace's only test is a heap overflow, so the action must fail. That failure + # is the assertion: it can only happen if the CMake injection, the instrumented + # build, the test environment and result reporting all worked. + - uses: ./asan + id: sanitized + continue-on-error: true + with: + workspace: ws + + - name: Assert the defect was caught + run: |- + if [[ 'failure' != '${{ steps.sanitized.outcome }}' ]]; then + echo "::error::asan action reported ${{ steps.sanitized.outcome }} on a workspace with a known heap overflow" + exit 1 + fi diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..a1260ea --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.16) +project(sanitizer_fixtures CXX) + +find_package(Threads REQUIRED) + +foreach(fixture heap_overflow signed_overflow leak data_race no_defect) + add_executable(${fixture} fixtures/${fixture}.cpp) + target_link_libraries(${fixture} PRIVATE Threads::Threads) +endforeach() + +# test/excluded is configured as its own top-level project by run_preset.sh, not added +# here. Colcon configures each package independently, and a nested add_subdirectory would +# inherit this project's compile options and defeat the exclusion under test. diff --git a/test/excluded/CMakeLists.txt b/test/excluded/CMakeLists.txt new file mode 100644 index 0000000..b11162b --- /dev/null +++ b/test/excluded/CMakeLists.txt @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.16) + +# A separate project() whose name the default exclude regex matches, configured on its own +# the way colcon configures each package. CMAKE_PROJECT_INCLUDE must decline this one. +# +# An overflow rather than a leak: detecting it requires instrumented loads, so it stays +# undetected exactly when exclusion worked. A leak would be caught regardless, because +# LeakSanitizer intercepts the allocator at runtime and never needed the compile flag. +project(fixtures_msgs CXX) + +add_executable(excluded_overflow ../fixtures/heap_overflow.cpp) diff --git a/test/fixtures/c_heap_overflow.c b/test/fixtures/c_heap_overflow.c new file mode 100644 index 0000000..3482a95 --- /dev/null +++ b/test/fixtures/c_heap_overflow.c @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +int main(void) +{ + int * buffer = malloc(4 * sizeof(int)); + buffer[0] = 1; + const int out_of_bounds = buffer[5]; + printf("%d\n", out_of_bounds); + free(buffer); + return 0; +} diff --git a/test/fixtures/data_race.cpp b/test/fixtures/data_race.cpp new file mode 100644 index 0000000..03c5000 --- /dev/null +++ b/test/fixtures/data_race.cpp @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +static int shared_counter = 0; + +static void increment() +{ + for (int i = 0; i < 10000; ++i) { + ++shared_counter; + } +} + +int main() +{ + std::thread first(increment); + std::thread second(increment); + first.join(); + second.join(); + printf("%d\n", shared_counter); + return 0; +} diff --git a/test/fixtures/heap_overflow.cpp b/test/fixtures/heap_overflow.cpp new file mode 100644 index 0000000..2031d9f --- /dev/null +++ b/test/fixtures/heap_overflow.cpp @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include + +int main() +{ + int * buffer = new int[4]; + buffer[0] = 1; + const int out_of_bounds = buffer[5]; + printf("%d\n", out_of_bounds); + delete[] buffer; + return 0; +} diff --git a/test/fixtures/leak.cpp b/test/fixtures/leak.cpp new file mode 100644 index 0000000..1d4c556 --- /dev/null +++ b/test/fixtures/leak.cpp @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include + +// The sink is cleared so the allocation is genuinely unreachable at exit. Leaving the +// pointer live makes LSan classify it as still-reachable and report nothing. +static int * volatile sink; + +int main() +{ + sink = new int[256]; + sink[0] = 1; + printf("%d\n", sink[0]); + sink = nullptr; + return 0; +} diff --git a/test/fixtures/no_defect.cpp b/test/fixtures/no_defect.cpp new file mode 100644 index 0000000..3f645bb --- /dev/null +++ b/test/fixtures/no_defect.cpp @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +int main() +{ + std::vector values(256, 1); + int total = 0; + std::thread worker([&values, &total]() { + for (const int value : values) { + total += value; + } + }); + worker.join(); + printf("%d\n", total); + return 0; +} diff --git a/test/fixtures/signed_overflow.cpp b/test/fixtures/signed_overflow.cpp new file mode 100644 index 0000000..3630e51 --- /dev/null +++ b/test/fixtures/signed_overflow.cpp @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include + +__attribute__((noinline)) int add(int a, int b) +{ + return a + b; +} + +int main() +{ + printf("%d\n", add(2147483647, 1)); + return 0; +} diff --git a/test/pkg_CMakeLists.txt b/test/pkg_CMakeLists.txt new file mode 100644 index 0000000..fec6360 --- /dev/null +++ b/test/pkg_CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.16) + +# A plain CMake package, which colcon discovers from CMakeLists.txt alone. Used by the +# end-to-end workflow to drive the action through a real colcon build and test. +project(fixture_pkg CXX) + +enable_testing() + +add_executable(heap_overflow_test fixtures/heap_overflow.cpp) +add_test(NAME heap_overflow COMMAND heap_overflow_test) diff --git a/test/pure_c/CMakeLists.txt b/test/pure_c/CMakeLists.txt new file mode 100644 index 0000000..3937002 --- /dev/null +++ b/test/pure_c/CMakeLists.txt @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.16) + +# A C-only project, the shape a firmware or transport library uses. CMAKE_CXX_COMPILER_ID +# is empty here, so this catches a guard that only inspects the C++ compiler. +project(fixtures_c C) + +add_executable(c_heap_overflow ../fixtures/c_heap_overflow.c) diff --git a/test/run_preset.sh b/test/run_preset.sh new file mode 100755 index 0000000..735b2bb --- /dev/null +++ b/test/run_preset.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# Build deliberately-buggy fixtures through the real CMAKE_PROJECT_INCLUDE path and assert +# each is caught by exactly the preset that claims to find it. Catches a preset that has +# silently stopped detecting, which a build-only smoke test cannot. + +set -euo pipefail + +PRESET="${1:?usage: run_preset.sh }" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD="$(mktemp -d)" +trap 'rm -rf "$BUILD"' EXIT + +# Fixture -> the one preset that must report it. `none` must stay clean under every preset. +# excluded_overflow is heap_overflow.cpp built in a package the exclude regex matches, so +# the pair proves opting a package out actually leaves it uninstrumented. +declare -A DETECTED_BY=( + [heap_overflow]=asan-ubsan + [signed_overflow]=asan-ubsan + [leak]=lsan + [data_race]=tsan + [no_defect]=none + [excluded_overflow]=none + [c_heap_overflow]=asan-ubsan +) + +mapfile -t CMAKE_ARGS < <(python3 "$ROOT/sanitizer_tool" cmake-args "$PRESET") + +# Each source dir is configured on its own, the way colcon configures each package. +# RelWithDebInfo on purpose: its -O2 must not defeat the preset's -O1 -fno-omit-frame-pointer. +for source_dir in "$ROOT/test" "$ROOT/test/excluded" "$ROOT/test/pure_c"; do + cmake -S "$source_dir" -B "$BUILD/$(basename "$source_dir")" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_RUNTIME_OUTPUT_DIRECTORY="$BUILD/bin" \ + "${CMAKE_ARGS[@]}" >> "$BUILD/configure.log" + cmake --build "$BUILD/$(basename "$source_dir")" --parallel > /dev/null +done +grep 'ros2-sanitizers:' "$BUILD/configure.log" + +eval "$(python3 "$ROOT/sanitizer_tool" test-env "$PRESET")" + +failures=0 +for fixture in "${!DETECTED_BY[@]}"; do + expected=no + if [[ "${DETECTED_BY[$fixture]}" == "$PRESET" ]]; then + expected=yes + fi + + status=0 + output="$("$BUILD/bin/$fixture" 2>&1)" || status=$? + + detected=no + if [[ 0 -ne $status ]]; then + detected=yes + fi + + if [[ "$detected" == "$expected" ]]; then + echo "ok $fixture (detected=$detected)" + else + echo "NOT OK $fixture (detected=$detected, expected=$expected, exit=$status)" + echo "$output" | head -20 + failures=$((failures + 1)) + fi +done + +echo "$failures failure(s) across ${#DETECTED_BY[@]} fixtures under $PRESET" +[[ 0 -eq $failures ]] From 4c70c210f2be5d49dbc0afc3839da4b8670f627a Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 12:32:44 -0700 Subject: [PATCH 7/8] fix partially instrumented package leading to linker errors Signed-off-by: Skyler Medeiros --- cmake/sanitizers.cmake | 4 ++++ test/fixtures/nested_no_defect.cpp | 12 ++++++++++++ test/fixtures/vendored.cpp | 7 +++++++ test/nested/CMakeLists.txt | 14 ++++++++++++++ test/nested/vendor/CMakeLists.txt | 6 ++++++ test/run_preset.sh | 3 ++- 6 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/nested_no_defect.cpp create mode 100644 test/fixtures/vendored.cpp create mode 100644 test/nested/CMakeLists.txt create mode 100644 test/nested/vendor/CMakeLists.txt diff --git a/cmake/sanitizers.cmake b/cmake/sanitizers.cmake index 8c67aca..ddc48bc 100644 --- a/cmake/sanitizers.cmake +++ b/cmake/sanitizers.cmake @@ -13,6 +13,10 @@ if(NOT ROS2_SANITIZER) return() endif() +if(NOT PROJECT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + return() +endif() + # Excluded packages still build, just uninstrumented. Defaults to message packages if(ROS2_SANITIZER_EXCLUDE AND PROJECT_NAME MATCHES "${ROS2_SANITIZER_EXCLUDE}") message(STATUS "ros2-sanitizers: skipping ${PROJECT_NAME}") diff --git a/test/fixtures/nested_no_defect.cpp b/test/fixtures/nested_no_defect.cpp new file mode 100644 index 0000000..706d1e6 --- /dev/null +++ b/test/fixtures/nested_no_defect.cpp @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#include + +int vendored_value(); + +int main() +{ + printf("%d\n", vendored_value()); + return 0; +} diff --git a/test/fixtures/vendored.cpp b/test/fixtures/vendored.cpp new file mode 100644 index 0000000..472e782 --- /dev/null +++ b/test/fixtures/vendored.cpp @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 + +int vendored_value() +{ + return 42; +} diff --git a/test/nested/CMakeLists.txt b/test/nested/CMakeLists.txt new file mode 100644 index 0000000..611ff04 --- /dev/null +++ b/test/nested/CMakeLists.txt @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.16) + +# Name matches the default exclude regex, so this project must stay uninstrumented. +project(fixtures_nested_msgs CXX) + +# A nested project() whose own name does NOT match the regex, mirroring the project(gtest) +# that ament_cmake_gtest pulls in from gtest_vendor. If it were instrumented independently +# of its excluded parent, linking it in below would fail with undefined __asan_* symbols. +add_subdirectory(vendor) + +add_executable(nested_no_defect ../fixtures/nested_no_defect.cpp) +target_link_libraries(nested_no_defect PRIVATE vendored) diff --git a/test/nested/vendor/CMakeLists.txt b/test/nested/vendor/CMakeLists.txt new file mode 100644 index 0000000..66b1626 --- /dev/null +++ b/test/nested/vendor/CMakeLists.txt @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.16) +project(vendored_lib CXX) + +add_library(vendored STATIC ../../fixtures/vendored.cpp) diff --git a/test/run_preset.sh b/test/run_preset.sh index 735b2bb..2e6522b 100755 --- a/test/run_preset.sh +++ b/test/run_preset.sh @@ -24,13 +24,14 @@ declare -A DETECTED_BY=( [no_defect]=none [excluded_overflow]=none [c_heap_overflow]=asan-ubsan + [nested_no_defect]=none ) mapfile -t CMAKE_ARGS < <(python3 "$ROOT/sanitizer_tool" cmake-args "$PRESET") # Each source dir is configured on its own, the way colcon configures each package. # RelWithDebInfo on purpose: its -O2 must not defeat the preset's -O1 -fno-omit-frame-pointer. -for source_dir in "$ROOT/test" "$ROOT/test/excluded" "$ROOT/test/pure_c"; do +for source_dir in "$ROOT/test" "$ROOT/test/excluded" "$ROOT/test/pure_c" "$ROOT/test/nested"; do cmake -S "$source_dir" -B "$BUILD/$(basename "$source_dir")" \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_RUNTIME_OUTPUT_DIRECTORY="$BUILD/bin" \ From 91c0857d114a4b3c1ea0744dcafa525780a58d53 Mon Sep 17 00:00:00 2001 From: Skyler Medeiros Date: Thu, 13 Aug 2026 13:02:33 -0700 Subject: [PATCH 8/8] downgrade maybe-uninitialized from an error so -Werror packages still build Signed-off-by: Skyler Medeiros --- cmake/sanitizers.cmake | 7 +++++++ test/CMakeLists.txt | 5 +++++ test/fixtures/werror_regex.cpp | 16 ++++++++++++++++ test/run_preset.sh | 1 + 4 files changed, 29 insertions(+) create mode 100644 test/fixtures/werror_regex.cpp diff --git a/cmake/sanitizers.cmake b/cmake/sanitizers.cmake index ddc48bc..8923058 100644 --- a/cmake/sanitizers.cmake +++ b/cmake/sanitizers.cmake @@ -70,6 +70,13 @@ else() message(FATAL_ERROR "ros2-sanitizers: unknown preset '${ROS2_SANITIZER}'") endif() +# GCC's uninitialized analysis does not survive the ASan instrumentation pass and +# false-positives inside libstdc++ . Downgraded rather than silenced, and only for +# GNU, since the option name does not exist in Clang. +list(APPEND _ros2_sanitizer_common + "$<$:-Wno-error=maybe-uninitialized>" + "$<$:-Wno-error=maybe-uninitialized>") + message(STATUS "ros2-sanitizers: instrumenting ${PROJECT_NAME} with ${ROS2_SANITIZER}") add_compile_options(${_ros2_sanitizer_common} ${_ros2_sanitizer_flags}) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a1260ea..6a204db 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,6 +10,11 @@ foreach(fixture heap_overflow signed_overflow leak data_race no_defect) target_link_libraries(${fixture} PRIVATE Threads::Threads) endforeach() +# target_compile_options lands after the directory options this project injects, so a +# package's own -Werror outranks them. +add_executable(werror_regex fixtures/werror_regex.cpp) +target_compile_options(werror_regex PRIVATE -Wall -Wextra -Werror) + # test/excluded is configured as its own top-level project by run_preset.sh, not added # here. Colcon configures each package independently, and a nested add_subdirectory would # inherit this project's compile options and defeat the exclusion under test. diff --git a/test/fixtures/werror_regex.cpp b/test/fixtures/werror_regex.cpp new file mode 100644 index 0000000..1e6fb37 --- /dev/null +++ b/test/fixtures/werror_regex.cpp @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Polymath Robotics, Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// Built with -Werror. GCC's uninitialized analysis does not survive the ASan +// instrumentation pass and false-positives inside libstdc++ . + +#include +#include + +int main() +{ + const std::regex pattern(R"(^\s*(MSG:|=+)\s*(\S+)?\s*$)"); + const std::string line = "MSG: pkg/Type"; + std::smatch what; + return std::regex_search(line, what, pattern) ? 0 : 1; +} diff --git a/test/run_preset.sh b/test/run_preset.sh index 2e6522b..eefbc85 100755 --- a/test/run_preset.sh +++ b/test/run_preset.sh @@ -25,6 +25,7 @@ declare -A DETECTED_BY=( [excluded_overflow]=none [c_heap_overflow]=asan-ubsan [nested_no_defect]=none + [werror_regex]=none ) mapfile -t CMAKE_ARGS < <(python3 "$ROOT/sanitizer_tool" cmake-args "$PRESET")