diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..7b4dd606 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,208 @@ +name: Sanitizer Tests + +# Run on main branch pushes, pull requests, and manual trigger +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +env: + CMAKE_VERSION: 3.21.7 + NINJA_VERSION: 1.11.0 + +jobs: + sanitizers: + name: ${{ matrix.config.name }} + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + # GCC 14 with AddressSanitizer + UndefinedBehaviorSanitizer + - { + name: "Linux GCC 14 + ASan + UBSan", + os: ubuntu-24.04, + build_type: RelWithDebInfo, + cc: "gcc-14", cxx: "g++-14", + cxx_standard: 23 + } + + # Clang 18 with AddressSanitizer + UndefinedBehaviorSanitizer + - { + name: "Linux Clang 18 + ASan + UBSan (libc++)", + os: ubuntu-24.04, + build_type: RelWithDebInfo, + cc: "clang-18", cxx: "clang++-18", + cxx_standard: 23, + use_libcxx: true + } + + # Clang 21 with AddressSanitizer + UndefinedBehaviorSanitizer + - { + name: "Linux Clang 21 + ASan + UBSan (libc++)", + os: ubuntu-24.04, + build_type: RelWithDebInfo, + cc: "clang-21", cxx: "clang++-21", + cxx_standard: 23, + use_libcxx: true + } + + steps: + - uses: actions/checkout@master + + - name: Download Ninja and CMake + id: cmake_and_ninja + shell: cmake -P {0} + run: | + set(cmake_version $ENV{CMAKE_VERSION}) + set(ninja_version $ENV{NINJA_VERSION}) + + message(STATUS "Using host CMake version: ${CMAKE_VERSION}") + + set(ninja_suffix "linux.zip") + set(cmake_suffix "linux-x86_64.tar.gz") + set(cmake_dir "cmake-${cmake_version}-linux-x86_64/bin") + + set(ninja_url "https://github.com/ninja-build/ninja/releases/download/v${ninja_version}/ninja-${ninja_suffix}") + file(DOWNLOAD "${ninja_url}" ./ninja.zip SHOW_PROGRESS) + execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./ninja.zip) + + set(cmake_url "https://github.com/Kitware/CMake/releases/download/v${cmake_version}/cmake-${cmake_version}-${cmake_suffix}") + file(DOWNLOAD "${cmake_url}" ./cmake.zip SHOW_PROGRESS) + execute_process(COMMAND ${CMAKE_COMMAND} -E tar xvf ./cmake.zip) + + # Save the path for other steps + file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/${cmake_dir}" cmake_dir) + message("::set-output name=cmake_dir::${cmake_dir}") + + execute_process( + COMMAND chmod +x ninja + COMMAND chmod +x ${cmake_dir}/cmake + ) + + - name: Install Clang and libc++ (C++23 support) + id: install_clang + if: contains(matrix.config.cxx, 'clang++') + shell: bash + run: | + # Extract version number from compiler name (e.g., clang++-21 -> 21) + CLANG_VERSION=$(echo "${{ matrix.config.cxx }}" | grep -oP '\d+') + + # Add LLVM repository for newer versions (18 is pre-installed) + if [[ "$CLANG_VERSION" != "18" ]]; then + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + sudo add-apt-repository -y "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-${CLANG_VERSION} main" + fi + + sudo apt-get update + + # Install Clang and libc++ for the specific version + sudo apt-get -y install \ + clang-${CLANG_VERSION} \ + libc++-${CLANG_VERSION}-dev \ + libc++abi-${CLANG_VERSION}-dev + + - name: Install vcpkg + id: vcpkg + shell: bash + run: | + mkdir -p ${GITHUB_WORKSPACE}/vcpkg + cd ${GITHUB_WORKSPACE}/vcpkg + git init + git remote add origin https://github.com/microsoft/vcpkg.git + git fetch origin master + git checkout -b master origin/master + ./bootstrap-vcpkg.sh + + # For Clang builds, use custom triplet with libc++ and set compiler + if [[ "${{ matrix.config.use_libcxx }}" == "true" ]]; then + export CC=${{ matrix.config.cc }} + export CXX=${{ matrix.config.cxx }} + ./vcpkg install uni-algo \ + --triplet x64-linux-libcxx \ + --overlay-triplets=${GITHUB_WORKSPACE}/cmake/vcpkg-triplets + else + ./vcpkg install uni-algo + fi + + - name: Configure + shell: cmake -P {0} + run: | + set(ENV{CC} ${{ matrix.config.cc }}) + set(ENV{CXX} ${{ matrix.config.cxx }}) + + file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}/ninja" ninja_program) + + # Determine triplet for vcpkg + if ("${{ matrix.config.use_libcxx }}" STREQUAL "true") + set(vcpkg_triplet "x64-linux-libcxx") + set(use_libcxx ON) + else() + set(vcpkg_triplet "x64-linux") + set(use_libcxx OFF) + endif() + + execute_process( + COMMAND ${{ steps.cmake_and_ninja.outputs.cmake_dir }}/cmake + -S . + -B build + -D CMAKE_BUILD_TYPE=${{ matrix.config.build_type }} + -G Ninja + -D CMAKE_MAKE_PROGRAM=${ninja_program} + -D CMAKE_TOOLCHAIN_FILE=${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/scripts/buildsystems/vcpkg.cmake + -D VCPKG_TARGET_TRIPLET=${vcpkg_triplet} + -D skyr_BUILD_TESTS=OFF + -D skyr_BUILD_EXAMPLES=OFF + -D skyr_ENABLE_SANITIZERS=ON + -D skyr_BUILD_WITH_LLVM_LIBCXX=${use_libcxx} + -D skyr_WARNINGS_AS_ERRORS=OFF + RESULT_VARIABLE result + ) + if (NOT result EQUAL 0) + message(FATAL_ERROR "Bad exit status") + endif() + + - name: Build + shell: cmake -P {0} + run: | + set(ENV{NINJA_STATUS} "[%f/%t %o/sec] ") + + file(TO_CMAKE_PATH "$ENV{GITHUB_WORKSPACE}" ccache_basedir) + set(ENV{CCACHE_BASEDIR} "${ccache_basedir}") + set(ENV{CCACHE_DIR} "${ccache_basedir}/.ccache") + set(ENV{CCACHE_COMPRESS} "true") + set(ENV{CCACHE_COMPRESSLEVEL} "6") + set(ENV{CCACHE_MAXSIZE} "400M") + + execute_process( + COMMAND ${{ steps.cmake_and_ninja.outputs.cmake_dir }}/cmake --build build --target url_sanitizer_tests + RESULT_VARIABLE result + ) + if (NOT result EQUAL 0) + message(FATAL_ERROR "Build failed") + endif() + + - name: Run Sanitizer Tests + shell: bash + run: | + echo "========================================" + echo "Running AddressSanitizer + UBSan Tests" + echo "========================================" + + # Set sanitizer options for comprehensive checking + # alloc_dealloc_mismatch=0: Suppress false positive from libc++ exception handling + export ASAN_OPTIONS=detect_leaks=1:check_initialization_order=1:strict_init_order=1:detect_stack_use_after_return=1:alloc_dealloc_mismatch=0:verbosity=0 + export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 + + # Run the sanitizer test + ./build/tests/sanitizers/url_sanitizer_tests + + TEST_RESULT=$? + + if [ $TEST_RESULT -eq 0 ]; then + echo "✓ All sanitizer tests passed - no memory safety issues detected!" + else + echo "✗ Sanitizer tests failed or detected issues" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index a2c40b67..31e42f92 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -1,13 +1,10 @@ name: Web Platform Tests -# Run on push to main, weekly schedule, and manual trigger +# Run on push to main and manual trigger on: push: branches: - main - schedule: - # Run every Monday at 00:00 UTC - - cron: '0 0 * * 1' workflow_dispatch: # Allow manual triggering diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d0de820..9588baf7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,7 @@ option(skyr_USE_STATIC_CRT "Use static C Runtime library (/MT or MTd)." ON) option(skyr_BUILD_WITH_LLVM_LIBCXX "Instruct Clang to use LLVM's implementation of C++ standard library" OFF) option(skyr_ENABLE_FILESYSTEM_FUNCTIONS "Enable functions to convert URL to std::filesystem::path" ON) option(skyr_ENABLE_JSON_FUNCTIONS "Enable functions to convert URL components to JSON" ON) +option(skyr_ENABLE_SANITIZERS "Enable sanitizers (address, undefined, etc.) for tests and examples" OFF) option(skyr_CXX_STANDARD_LIBRARY "Path to non-system C++ standard library" "") if (skyr_IS_TOP_LEVEL_PROJECT) @@ -74,6 +75,7 @@ set(full_warnings $) set(warnings_as_errors $) set(no_exceptions $) set(no_rtti $) +set(enable_sanitizers $) set(gnu $) set(clang $,$>) @@ -92,6 +94,15 @@ if (skyr_BUILD_TESTS) add_subdirectory(tests) endif() +# Sanitizer tests (independent, no Catch2 needed) +if (skyr_ENABLE_SANITIZERS) + message(STATUS "[skyr-url] Configuring sanitizer tests") + if (NOT skyr_BUILD_TESTS) + enable_testing() # Only call this if not already enabled + endif() + add_subdirectory(tests/sanitizers) +endif() + # Documentation if (skyr_BUILD_DOCS) message(STATUS "[skyr-url] Configuring documentation") diff --git a/tests/sanitizers/CMakeLists.txt b/tests/sanitizers/CMakeLists.txt new file mode 100644 index 00000000..64af9375 --- /dev/null +++ b/tests/sanitizers/CMakeLists.txt @@ -0,0 +1,86 @@ +# Copyright (c) Glyn Matthews 2025. +# Distributed under the Boost Software License, Version 1.0. +# (See accompanying file LICENSE_1_0.txt or copy at +# http://www.boost.org/LICENSE_1_0.txt) + +include(${PROJECT_SOURCE_DIR}/cmake/skyr-url-functions.cmake) + +# Sanitizer tests - only build when sanitizers are enabled +if (NOT skyr_ENABLE_SANITIZERS) + message(STATUS "Sanitizer tests skipped (enable with -Dskyr_ENABLE_SANITIZERS=ON)") + return() +endif() + +message(STATUS "Building sanitizer tests with AddressSanitizer + UndefinedBehaviorSanitizer") + +foreach( + file_name + url_sanitizer_tests.cpp +) + skyr_remove_extension(${file_name} basename) + set(test ${basename}) + add_executable(${test} ${file_name}) + add_dependencies(${test} skyr-url) + + target_compile_options( + ${test} + PRIVATE + # Standard warnings (but no -Werror for sanitizer tests) + $<$,${full_warnings}>:-Wall> + $<$,${no_exceptions}>:-fno-exceptions> + $<$,${no_rtti}>:-fno-rtti> + $<${libcxx}:-stdlib=libc++> + + # AddressSanitizer flags (GCC/Clang) + $<$:-fsanitize=address> + $<$:-fsanitize=undefined> + $<$:-fno-omit-frame-pointer> + $<$:-fno-optimize-sibling-calls> + $<$:-g> + $<$:-O1> + + # MSVC sanitizer flags + $<$:/W4> + $<$>:/EHsc> + $<$:/GR-> + $<${msvc}:/fsanitize=address> + $<${msvc}:/Zi> + ) + + target_link_options( + ${test} + PRIVATE + # Sanitizer linker flags (GCC/Clang) + $<$:-fsanitize=address> + $<$:-fsanitize=undefined> + + # MSVC sanitizer linker flags + $<${msvc}:/fsanitize=address> + ) + + target_link_libraries( + ${test} + PRIVATE + skyr-url + ) + + set_target_properties( + ${test} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/tests/sanitizers/ + ) + + # Add as a test so it can be run with ctest + add_test( + NAME ${test} + COMMAND ${test} + WORKING_DIRECTORY ${PROJECT_BINARY_DIR}/tests/sanitizers/ + ) + + # Set environment variables for sanitizers + set_tests_properties( + ${test} + PROPERTIES + ENVIRONMENT "ASAN_OPTIONS=detect_leaks=1:check_initialization_order=1:strict_init_order=1:detect_stack_use_after_return=1" + ) +endforeach() \ No newline at end of file diff --git a/tests/sanitizers/url_sanitizer_tests.cpp b/tests/sanitizers/url_sanitizer_tests.cpp new file mode 100644 index 00000000..8b292e50 --- /dev/null +++ b/tests/sanitizers/url_sanitizer_tests.cpp @@ -0,0 +1,184 @@ +// Copyright 2025 Glyn Matthews. +// Distributed under the Boost Software License, Version 1.0. +// (See accompanying file LICENSE_1_0.txt or copy at +// http://www.boost.org/LICENSE_1_0.txt) + +#include +#include +#include +#include + +#include + +// Comprehensive URL sanitizer tests covering edge cases and diverse inputs +// This test is designed to stress-test the URL parser with AddressSanitizer +// to detect any memory safety issues (buffer overflows, use-after-free, etc.) + +struct test_case { + std::string input; + std::string description; +}; + +int main() { + // Test cases covering a wide range of URL patterns to stress-test with ASan + const std::vector test_cases = { + // Basic URLs + {"http://example.com", "Simple HTTP URL"}, + {"https://example.com", "Simple HTTPS URL"}, + {"ftp://ftp.example.com", "FTP URL"}, + {"file:///path/to/file", "File URL"}, + + // URLs with ports + {"http://example.com:8080", "HTTP with port"}, + {"https://example.com:443", "HTTPS with default port"}, + {"http://example.com:0", "Port 0 (edge case)"}, + {"http://example.com:65535", "Maximum valid port"}, + {"http://example.com:65536", "Port overflow"}, + {"http://example.com:99999", "Port out of range"}, + + // URLs with authentication + {"http://user:pass@example.com", "URL with credentials"}, + {"http://user@example.com", "URL with username only"}, + {"http://:pass@example.com", "URL with password only"}, + {"http://user%20name:pass%20word@example.com", "Encoded credentials"}, + + // IPv4 addresses + {"http://192.168.1.1", "IPv4 address"}, + {"http://127.0.0.1:8080", "Localhost with port"}, + {"http://255.255.255.255", "Max IPv4 address"}, + {"http://256.1.1.1", "Invalid IPv4 (overflow)"}, + {"http://0.0.0.0", "Zero IPv4 address"}, + + // IPv6 addresses + {"http://[::1]", "IPv6 loopback"}, + {"http://[2001:db8::1]", "IPv6 address"}, + {"http://[::ffff:192.0.2.1]", "IPv4-mapped IPv6"}, + {"http://[2001:db8::1]:8080", "IPv6 with port"}, + {"http://[::1]:65536", "IPv6 with invalid port"}, + + // Path components + {"http://example.com/path/to/resource", "URL with path"}, + {"http://example.com/path/../other", "URL with dot segments"}, + {"http://example.com/./path", "URL with single dot"}, + {"http://example.com/../path", "URL starting with .."}, + {"http://example.com//double//slash", "Double slashes in path"}, + + // Query strings + {"http://example.com?key=value", "URL with query"}, + {"http://example.com?key1=value1&key2=value2", "Multiple query params"}, + {"http://example.com?key=", "Empty query value"}, + {"http://example.com?=value", "Empty query key"}, + {"http://example.com?", "Empty query string"}, + {"http://example.com?key=value%20with%20spaces", "Encoded query"}, + + // Fragments + {"http://example.com#fragment", "URL with fragment"}, + {"http://example.com#", "Empty fragment"}, + {"http://example.com#fragment%20with%20spaces", "Encoded fragment"}, + {"http://example.com?query=1#fragment", "Query and fragment"}, + + // Percent encoding edge cases (potential for buffer issues) + {"http://example.com/%20", "Encoded space"}, + {"http://example.com/%00", "Null byte encoded"}, + {"http://example.com/%", "Incomplete encoding"}, + {"http://example.com/%2", "Incomplete encoding 2"}, + {"http://example.com/%GG", "Invalid hex encoding"}, + {"http://example.com/%C3%A9", "UTF-8 encoded character"}, + + // Unicode and internationalized domains (potential encoding issues) + {"http://\xE2\x98\x83.example.com", "Snowman in domain"}, + {"http://\xF0\x9F\x92\xA9.example.com", "Emoji in domain"}, + {"http://münchen.de", "German umlaut domain"}, + {"http://\xE4\xB8\xAD\xE5\x9B\xBD.cn", "Chinese domain"}, + {"http://example.com/\xF0\x9F\x92\xA9", "Emoji in path"}, + + // Special schemes + {"data:text/plain,Hello", "Data URL"}, + {"mailto:user@example.com", "Mailto URL"}, + {"tel:+1-234-567-8900", "Tel URL"}, + {"javascript:alert('xss')", "JavaScript URL"}, + {"about:blank", "About URL"}, + + // Edge cases and malformed URLs (boundary conditions) + {"http://", "No host"}, + {"http:///path", "Empty host"}, + {"//example.com", "Protocol-relative URL"}, + {"/path/to/resource", "Path-only URL"}, + {"http://example.com:abc", "Non-numeric port"}, + {"http://exam ple.com", "Space in host"}, + {"http://example..com", "Double dot in domain"}, + {"http://.example.com", "Leading dot in domain"}, + {"http://example.com.", "Trailing dot in domain"}, + + // Very long URLs (stress test buffers) + {"http://example.com/" + std::string(1000, 'a'), "Very long path (1KB)"}, + {"http://example.com/" + std::string(10000, 'x'), "Very long path (10KB)"}, + {"http://" + std::string(253, 'a') + ".com", "Very long domain (253 chars)"}, + {"http://example.com?" + std::string(1000, 'q'), "Very long query (1KB)"}, + {"http://example.com#" + std::string(1000, 'f'), "Very long fragment (1KB)"}, + + // Special characters (potential for injection or buffer issues) + {"http://example.com/path?key=