Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.ruff.toml
17 changes: 17 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions asan/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
83 changes: 83 additions & 0 deletions cmake/sanitizers.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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()

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}")
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()

# GCC's uninitialized analysis does not survive the ASan instrumentation pass and
# false-positives inside libstdc++ <regex>. Downgraded rather than silenced, and only for
# GNU, since the option name does not exist in Clang.
list(APPEND _ros2_sanitizer_common
"$<$<COMPILE_LANG_AND_ID:C,GNU>:-Wno-error=maybe-uninitialized>"
"$<$<COMPILE_LANG_AND_ID:CXX,GNU>:-Wno-error=maybe-uninitialized>")

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})
45 changes: 45 additions & 0 deletions lsan/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Loading
Loading