diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..7c19294f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Default review routing. +* @Cacti/cacti-developers + +# CI, release and dependency policy changes need both implementation and +# security review. These requirements take effect when the repository ruleset +# enables code-owner approval. +/.github/ @Cacti/cacti-developers @Cacti/cacti-security +/configure.ac @Cacti/cacti-developers @Cacti/cacti-security +/Makefile.am @Cacti/cacti-developers @Cacti/cacti-security +/Dockerfile @Cacti/cacti-developers @Cacti/cacti-security + +# High-risk runtime boundaries. +/sql.c @Cacti/cacti-developers @Cacti/cacti-security +/php.c @Cacti/cacti-developers @Cacti/cacti-security +/nft_popen.c @Cacti/cacti-developers @Cacti/cacti-security +/snmp.c @Cacti/cacti-developers @Cacti/cacti-security +/ping.c @Cacti/cacti-developers @Cacti/cacti-security +/poller.c @Cacti/cacti-developers @Cacti/cacti-security diff --git a/.github/actions/build-spine/action.yml b/.github/actions/build-spine/action.yml new file mode 100644 index 00000000..9c16ea81 --- /dev/null +++ b/.github/actions/build-spine/action.yml @@ -0,0 +1,64 @@ +name: Build Spine +description: Install the build dependencies, then bootstrap and configure Spine. + +inputs: + mysql-client-package: + description: MySQL-compatible client development package to install. + required: false + default: libmysqlclient-dev + extra-packages: + description: Additional apt packages, beyond the set every Spine build needs. + required: false + default: '' + configure-args: + description: Arguments passed through to ./configure. + required: false + default: '' + cflags: + description: CFLAGS for ./configure. + required: false + default: '' + ldflags: + description: LDFLAGS for ./configure. + required: false + default: '' + +runs: + using: composite + steps: + # mysql-server is deliberately absent: configure only needs the selected + # client headers and library, and no job starts or connects to a server. + - name: Install build dependencies + shell: bash + env: + MYSQL_CLIENT_PACKAGE: ${{ inputs.mysql-client-package }} + EXTRA_PACKAGES: ${{ inputs.extra-packages }} + run: | + set -euo pipefail + sudo apt-get update + # shellcheck disable=SC2086 + sudo apt-get install -y \ + autoconf automake libtool build-essential help2man dos2unix \ + "$MYSQL_CLIENT_PACKAGE" libsnmp-dev libssl-dev libcmocka-dev \ + $EXTRA_PACKAGES + + - name: Bootstrap and configure + shell: bash + env: + CONFIGURE_ARGS: ${{ inputs.configure-args }} + IN_CFLAGS: ${{ inputs.cflags }} + IN_LDFLAGS: ${{ inputs.ldflags }} + run: | + set -euo pipefail + ./bootstrap + args=() + if [ -n "$CONFIGURE_ARGS" ]; then + read -ra args <<< "$CONFIGURE_ARGS" + fi + if [ -n "$IN_CFLAGS" ]; then + args+=("CFLAGS=$IN_CFLAGS") + fi + if [ -n "$IN_LDFLAGS" ]; then + args+=("LDFLAGS=$IN_LDFLAGS") + fi + ./configure ${args[@]+"${args[@]}"} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c317e160..81df3d06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,33 +14,85 @@ concurrency: cancel-in-progress: true jobs: + dco: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # pull_request normally checks out GitHub's synthetic merge commit, + # which cannot carry the contributor's DCO trailer. + ref: ${{ github.event.pull_request.head.sha }} + + - name: Verify Developer Certificate of Origin trailers + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: scripts/check-dco.sh "origin/${BASE_REF}..HEAD" + build: - runs-on: ubuntu-latest + name: Build (${{ matrix.compiler }}, ${{ matrix.client.name }}) + runs-on: ubuntu-24.04 timeout-minutes: 20 strategy: fail-fast: false matrix: compiler: [gcc, clang] + client: + - name: MariaDB Connector/C + package: libmariadb-dev + config: mariadb_config + - name: MySQL Connector/C + package: libmysqlclient-dev + config: mysql_config env: CC: ${{ matrix.compiler }} + MYSQL_CONFIG: ${{ matrix.client.config }} + # get_date_format() deliberately constructs the bounded strftime + # format; keep every other warning fatal. + STRICT_CFLAGS: -Wall -Wextra -Wformat=2 -Wno-format-nonliteral steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install build dependencies + - uses: ./.github/actions/build-spine + with: + mysql-client-package: ${{ matrix.client.package }} + cflags: ${{ env.STRICT_CFLAGS }} + + - name: Build Spine run: | - sudo apt-get update - sudo apt-get install -y \ - mysql-server libmysqlclient-dev \ - libsnmp-dev libssl-dev build-essential \ - help2man autoconf automake libtool dos2unix libcmocka-dev + set -euo pipefail + configured_cflags=$(sed -n 's/^CFLAGS = //p' Makefile) + make -j"$(nproc)" CFLAGS="$configured_cflags -Werror" - - name: Prepare for Spine Build + - name: Run the unit tests run: | - ./bootstrap - ./configure --enable-warnings + set -euo pipefail + configured_cflags=$(sed -n 's/^CFLAGS = //p' Makefile) + # Test doubles and cmocka callbacks intentionally leave parameters + # unused; every other test-source warning remains fatal. + make check CFLAGS="$configured_cflags -Werror -Wno-unused-parameter" - - name: Build Spine + # Linux capabilities are opt-in at configure time. Keep one build on that + # path so hasCaps() and the CAP_NET_RAW handling cannot silently rot behind + # an otherwise-green default build. + linux-capabilities: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/build-spine + with: + extra-packages: libcap-dev + # Exercise the supported low-buffer configuration as well as the + # Linux-capability path; formatter tests must not assume defaults. + configure-args: --enable-lcap --enable-warnings --with-results-buffer=512 + + - name: Build Spine with Linux capabilities run: | + set -euo pipefail make -j"$(nproc)" - name: Run the unit tests @@ -48,58 +100,108 @@ jobs: set -euo pipefail make check -# cppcheck: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 -# -# - name: Install cppcheck -# run: | -# sudo apt-get update -# sudo apt-get install -y cppcheck build-essential -# -# - name: Run cppcheck -# run: | -# cppcheck \ -# --enable=all \ -# --std=c11 \ -# --error-exitcode=1 \ -# --suppress=missingIncludeSystem \ -# --suppress=unusedFunction \ -# --suppress=checkersReport \ -# --suppress=variableScope \ -# --suppress=unreadVariable \ -# --suppress=shadowVariable \ -# --suppress=constVariablePointer \ -# --suppress=redundantAssignment \ -# --suppress=toomanyconfigs \ -# *.c *.h - - # Coverage is reported, not gated. The number is only meaningful once - # something measures it; the threshold conversation comes after that. - coverage: - runs-on: ubuntu-latest - timeout-minutes: 20 + macos: + runs-on: macos-15 + timeout-minutes: 25 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install build dependencies + run: | + set -euo pipefail + brew install autoconf automake libtool help2man \ + mariadb-connector-c net-snmp openssl@3 cmocka + + - name: Build and test + env: + CC: clang + run: | + set -euo pipefail + mariadb_prefix=$(brew --prefix mariadb-connector-c) + openssl_prefix=$(brew --prefix openssl@3) + net_snmp_prefix=$(brew --prefix net-snmp) + cmocka_prefix=$(brew --prefix cmocka) + export MYSQL_CONFIG="$mariadb_prefix/bin/mariadb_config" + export CPPFLAGS="-I$openssl_prefix/include -I$net_snmp_prefix/include -I$cmocka_prefix/include" + export LDFLAGS="-L$mariadb_prefix/lib -L$openssl_prefix/lib -L$net_snmp_prefix/lib -L$cmocka_prefix/lib" + ./bootstrap + ./configure --enable-warnings --with-snmp="$net_snmp_prefix" + make -j"$(sysctl -n hw.ncpu)" + make check + + cppcheck: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run cppcheck run: | set -euo pipefail sudo apt-get update - sudo apt-get install -y mysql-server libmysqlclient-dev libsnmp-dev \ - libssl-dev build-essential help2man autoconf automake libtool \ - dos2unix libcmocka-dev gcovr + sudo apt-get install -y cppcheck + # Project data structures are consumed across translation units; + # checking headers in isolation otherwise reports every member. A + # few externally visible helpers are deliberate unit-test seams. + cppcheck \ + --enable=all \ + --std=c11 \ + --error-exitcode=1 \ + --suppress=missingInclude \ + --suppress=missingIncludeSystem \ + --suppress=normalCheckLevelMaxBranches \ + --suppress=checkLevelNormal \ + --suppress=unmatchedSuppression \ + --suppress=unusedFunction \ + --suppress=checkersReport \ + --suppress=variableScope \ + --suppress=unreadVariable \ + --suppress=unusedStructMember \ + --suppress=staticFunction \ + --suppress=constVariablePointer \ + --suppress=redundantAssignment \ + --suppress=toomanyconfigs \ + ./*.c ./*.h + + shellcheck: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run shellcheck + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y shellcheck + find scripts tests -type f -name '*.sh' -print0 \ + | xargs -0 shellcheck + + # Preserve the measured baseline and require stronger coverage on changed + # lines. Raise both thresholds as integration coverage grows. + coverage: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - uses: ./.github/actions/build-spine + with: + extra-packages: gcovr python3-pip + cflags: -g -O0 --coverage + ldflags: --coverage - name: Build instrumented run: | set -euo pipefail - ./bootstrap - ./configure CFLAGS="-g -O0 --coverage" LDFLAGS="--coverage" make -j"$(nproc)" - name: Run the unit tests - run: make check + run: | + set -euo pipefail + make check - name: Report coverage run: | @@ -107,15 +209,42 @@ jobs: # configure leaves instrumented conftest objects behind and gcovr # cannot resolve their sources rm -f ./*conftest*.gcno ./*conftest*.gcda - gcovr -r . --exclude 'tests/.*' --txt --print-summary + gcovr -r . \ + --exclude 'tests/.*' \ + --txt --print-summary \ + --xml coverage.xml \ + --html-details coverage.html \ + --fail-under-line 7 + + - name: Enforce changed-line coverage + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + python3 -m pip install --break-system-packages diff-cover==10.5.1 + diff-cover coverage.xml \ + --compare-branch="origin/${BASE_REF}" \ + --fail-under=70 + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3 + with: + name: coverage-report + path: | + coverage.xml + coverage.html + coverage.*.html + if-no-files-found: error flawfinder: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" @@ -127,6 +256,7 @@ jobs: # informational so we have a baseline to chip away at. - name: Run flawfinder run: | + set -euo pipefail flawfinder \ --minlevel=3 \ --error-level=5 \ @@ -134,64 +264,117 @@ jobs: --context \ . | tee flawfinder-report.txt - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: flawfinder-report path: flawfinder-report.txt # Spine is a threaded network daemon; ASan and UBSan are the checks most - # likely to catch a real defect here. + # likely to catch a real defect here. The unit tests are what actually + # exercises the code, so they are what runs instrumented. sanitizers: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 + env: + CC: clang + ASAN_OPTIONS: detect_leaks=1:exitcode=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install build dependencies - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y clang mysql-server libmysqlclient-dev libsnmp-dev \ - libssl-dev build-essential help2man autoconf automake libtool dos2unix libcmocka-dev + - uses: ./.github/actions/build-spine + with: + extra-packages: clang + cflags: -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 + ldflags: -fsanitize=address,undefined - name: Build with ASan and UBSan - env: - CC: clang run: | set -euo pipefail - ./bootstrap - ./configure \ - CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" \ - LDFLAGS="-fsanitize=address,undefined" make -j"$(nproc)" - - name: Run the binary under the sanitizers - env: - ASAN_OPTIONS: detect_leaks=1:exitcode=1 - UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + - name: Run the unit tests under the sanitizers + run: | + set -euo pipefail + make check + + - name: Smoke-test the binary under the sanitizers run: | set -euo pipefail ./spine --version ./spine --help > /dev/null + + # Spine is highly concurrent. Keep race detection separate from ASan/UBSan + # because the sanitizer runtimes cannot be combined. + thread-sanitizer: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CC: clang + TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/build-spine + with: + extra-packages: clang + cflags: -fsanitize=thread -fno-omit-frame-pointer -g -O1 + ldflags: -fsanitize=thread + + - name: Build with ThreadSanitizer + run: | + set -euo pipefail + make -j"$(nproc)" + + - name: Run the unit tests under ThreadSanitizer + run: | + set -euo pipefail + make check + # A release tarball that cannot be compiled is a release blocker, and the # only way to catch it is to build from the tarball the way a packager does. distcheck: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install build dependencies - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y mysql-server libmysqlclient-dev libsnmp-dev \ - libssl-dev build-essential help2man autoconf automake libtool dos2unix libcmocka-dev + - uses: ./.github/actions/build-spine - name: make distcheck run: | set -euo pipefail - ./bootstrap - ./configure make distcheck + + # Branch protection should require this stable name rather than every matrix + # child. Any failed, cancelled or unexpectedly skipped dependency fails it. + required: + name: CI / required + if: always() + needs: + - dco + - build + - linux-capabilities + - macos + - cppcheck + - shellcheck + - coverage + - flawfinder + - sanitizers + - thread-sanitizer + - distcheck + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Verify every required job passed + env: + RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + failures=$(jq -r 'to_entries[] | select(.value.result != "success") | "\(.key)=\(.value.result)"' <<<"$RESULTS") + if [ -n "$failures" ]; then + echo "Required CI jobs did not pass:" + echo "$failures" + exit 1 + fi diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index f35834ce..cee96ed9 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -3,7 +3,12 @@ name: Fuzz on: pull_request: branches: [develop] - paths: ['**.c', '**.h', 'tests/fuzz/**', '.github/workflows/fuzz.yml'] + paths: + - '**.c' + - '**.h' + - 'tests/fuzz/**' + - '.github/actions/build-spine/**' + - '.github/workflows/fuzz.yml' schedule: - cron: '0 3 * * 0' workflow_dispatch: @@ -17,23 +22,14 @@ concurrency: jobs: fuzz: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install build dependencies - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y clang libmysqlclient-dev libsnmp-dev libssl-dev \ - autoconf automake libtool help2man pkg-config - - - name: Generate config.h - run: | - set -euo pipefail - ./bootstrap - ./configure + - uses: ./.github/actions/build-spine + with: + extra-packages: clang pkg-config # A pull request gets a short regression run against the committed corpus. # The weekly schedule runs long enough to explore. @@ -47,7 +43,7 @@ jobs: fi make -C tests/fuzz FUZZ_SECONDS="$seconds" run - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: fuzz-crashes diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 1ad5bd82..ace93609 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -3,7 +3,7 @@ name: Integration on: pull_request: branches: [develop] - paths: ['**.c', '**.h', 'Dockerfile', '.dockerignore', 'tests/snmpv3/**', '.github/workflows/integration.yml'] + paths: ['**.c', '**.h', 'Dockerfile', '.dockerignore', 'tests/integration/**', 'tests/test-harness.sh', 'tests/snmpv3/**', '.github/workflows/integration.yml'] workflow_dispatch: permissions: @@ -15,18 +15,65 @@ concurrency: jobs: snmpv3: - runs-on: ubuntu-latest + name: SNMPv3 integration (${{ matrix.database }}) + runs-on: ubuntu-24.04 timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + database: [mariadb, mysql] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Brings up MariaDB and an snmpd, then polls a real device through # poll_host() and checks the value lands in poller_output. - name: Poll an SNMPv3 device end to end - run: tests/snmpv3/scripts/run-integration.sh + env: + SPINE_TEST_DATABASE: ${{ matrix.database }} + run: | + set -euo pipefail + tests/snmpv3/scripts/run-integration.sh + + - name: Exercise concurrent host polling + if: matrix.database == 'mariadb' + env: + SPINE_TEST_DATABASE: mariadb + SPINE_TEST_HOSTS: 24 + SPINE_TEST_THREAD_COUNTS: "1 4 12" + SPINE_TEST_REPEATS: 1 + SPINE_TEST_TIMEOUT_SECONDS: 120 + SPINE_TEST_ARTIFACT_DIR: test-artifacts/concurrency + run: | + set -euo pipefail + tests/integration/test_concurrency.sh + + - name: Exercise SNMP packet loss and recovery + if: matrix.database == 'mariadb' + env: + SPINE_TEST_TIMEOUT_SECONDS: 120 + SPINE_TEST_ARTIFACT_DIR: test-artifacts/network-faults + run: | + set -euo pipefail + tests/integration/test_network_faults.sh - name: Show container logs on failure if: failure() + env: + SPINE_TEST_DATABASE: ${{ matrix.database }} run: | + set -euo pipefail cd tests/snmpv3 - docker compose logs --no-color || true + compose=(docker compose) + if [[ "$SPINE_TEST_DATABASE" == mysql ]]; then + compose+=(-f docker-compose.yml -f docker-compose.mysql.yml) + fi + "${compose[@]}" logs --no-color || true + + - name: Upload integration diagnostics + if: always() && matrix.database == 'mariadb' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3 + with: + name: integration-diagnostics-${{ matrix.database }} + path: test-artifacts + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/soak.yml b/.github/workflows/soak.yml new file mode 100644 index 00000000..0a1d049e --- /dev/null +++ b/.github/workflows/soak.yml @@ -0,0 +1,41 @@ +name: Concurrency soak + +on: + schedule: + - cron: '23 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: spine-concurrency-soak + cancel-in-progress: false + +jobs: + concurrency-soak: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run repeated many-host polling + env: + SPINE_TEST_DATABASE: mariadb + SPINE_TEST_HOSTS: 100 + SPINE_TEST_THREAD_COUNTS: "1 8 32" + SPINE_TEST_REPEATS: 5 + SPINE_TEST_TIMEOUT_SECONDS: 180 + SPINE_TEST_ARTIFACT_DIR: test-artifacts/concurrency-soak + run: | + set -euo pipefail + tests/integration/test_concurrency.sh + + - name: Upload soak diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4.4.3 + with: + name: concurrency-soak + path: test-artifacts/concurrency-soak + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index f2a12c8b..bb6224e1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ libtool *.o *.m4 tests/unit/bin/ +test-artifacts/ diff --git a/CHANGELOG b/CHANGELOG index 4833ff70..59be48c2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ The Cacti Group | spine 1.3.0 +-feature#612: Prefer MariaDB Connector/C discovery while retaining MySQL Connector/C compatibility -issue#60: Warn at startup when the spine and Cacti versions come from different release lines -issue#263: Unify the debug message prefix so a single poll no longer logs two orders -issue#360: If a Remote Data Collector has not devices, spine does not properly register itself diff --git a/INSTALL b/INSTALL index ed5879de..d2361a9f 100644 --- a/INSTALL +++ b/INSTALL @@ -72,8 +72,7 @@ CYGWIN Prerequisite * gcc-debuginfo * gzip * help2man - * libmysqlclient - * libmariadb-devel + * libmariadb-devel (preferred) or libmysqlclient * libtool * m4 * make diff --git a/Makefile.am b/Makefile.am index 2ecbf61b..b3affa21 100644 --- a/Makefile.am +++ b/Makefile.am @@ -36,7 +36,11 @@ man_MANS = spine.1 noinst_HEADERS = common.h error.h keywords.h locks.h nft_popen.h php.h \ ping.h poller.h snmp.h spine.h spine_sem.h sql.h uthash.h util.h -EXTRA_DIST = spine.1 spine.conf.dist +EXTRA_DIST = spine.1 spine.conf.dist \ + tests/README.md \ + tests/test-harness.sh \ + tests/integration/test_concurrency.sh \ + tests/integration/test_network_faults.sh # Docker targets — require Dockerfile and Dockerfile.dev (from PR #401) .PHONY: docker docker-dev verify cppcheck diff --git a/README.md b/README.md index f1c8d573..b90dde31 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,7 @@ chmod +s /usr/local/spine/bin/spine * gzip * help2man * inetutils-src - * libmysqlclient - * libmariadb-devel + * libmariadb-devel (preferred) or libmysqlclient * libssl-devel * libtool * m4 diff --git a/bootstrap b/bootstrap index 342c28bd..b1c102e4 100755 --- a/bootstrap +++ b/bootstrap @@ -22,6 +22,8 @@ # # ---------------------------------------------------------- + +set -eu # Name: bootstrap # # Function: build spine from scratch @@ -49,7 +51,7 @@ display_help () { } # Check for parameters -if [ "${1}" = "--help" -o "${1}" = "-h" ]; then +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then display_help exit 0 fi @@ -61,22 +63,28 @@ echo "INFO: Removing cache directories" rm -rf autom4te.cache .deps # Make sure all files are unix formatted files -which dos2unix > /dev/null 2>&1 -if [ $? -eq 0 ]; then - for e in $(echo "ac am c h in md mdlrc rb sh yml"); do +if command -v dos2unix > /dev/null 2>&1; then + for e in ac am c h in md mdlrc rb sh yml; do echo "INFO: Ensuring UNIX format for *.$e" - find . -type f -name \*.$e -exec dos2unix --d2u \{\} \; > /dev/null 2>&1 + find . -type f -name "*.${e}" -exec dos2unix --d2u {} \; > /dev/null 2>&1 done fi # Prepare a build state echo "INFO: Running auto-tools to verify buildability" -aclocal --install -libtoolize -autoheader -automake --add-missing +mkdir -p config m4 + +if command -v libtoolize > /dev/null 2>&1; then + LIBTOOLIZE=libtoolize +elif command -v glibtoolize > /dev/null 2>&1; then + LIBTOOLIZE=glibtoolize +else + echo "ERROR: GNU libtoolize (or glibtoolize on macOS) is required" >&2 + exit 1 +fi +export LIBTOOLIZE + autoreconf --force --install -[ $? -ne 0 ] && echo "ERROR: 'autoreconf' exited with errors" && exit -1 # Provide some meaningful notes diff --git a/configure.ac b/configure.ac index df11158b..b910be52 100644 --- a/configure.ac +++ b/configure.ac @@ -57,15 +57,10 @@ AC_ARG_WITH(snmp, [SNMP_DIR=$withval] ) -# if host_alias is empty, ac_cv_host_alias may still have the info -if test -z "$host_alias"; then - host_alias=$ac_cv_host_alias -fi - # Platform-specific tweaks ShLib="so" -case $host_alias in +case $host in *sparc-sun-solaris2.8) CPPFLAGS="$CPPFLAGS -D_POSIX_PTHREAD_SEMANTICS" AC_DEFINE(SOLAR_THREAD, 1, [Correct issue around Solaris threading model]);; @@ -216,98 +211,73 @@ else AC_MSG_RESULT([no]) fi -# ****************** MySQL Checks *********************** -AC_DEFUN([MYSQL_LIB_CHK], - [ str="$1/libmysqlclient.*" - for j in `echo $str`; do - if test -r $j; then - MYSQL_LIB_DIR=$1 - break 2 - fi - done - ] -) +# ****************** MySQL-compatible client checks *********************** +AC_ARG_VAR([MYSQL_CONFIG], [path to mariadb_config or mysql_config]) -# Determine MySQL installation paths -MYSQL_SUB_DIR="include include/mysql include/mariadb mysql"; -for i in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do - for d in $MYSQL_SUB_DIR; do - if [[ -f $i/$d/mysql.h ]]; then - MYSQL_INC_DIR=$i/$d - break; +if test -n "$MYSQL_DIR" && test -z "$MYSQL_CONFIG"; then + for mysql_config_candidate in mariadb_config mysql_config; do + if test -x "$MYSQL_DIR/bin/$mysql_config_candidate"; then + MYSQL_CONFIG="$MYSQL_DIR/bin/$mysql_config_candidate" + break fi done - - if [[ ! -z $MYSQL_INC_DIR ]]; then - break; - fi -# test -f $i/include/mysql.h && MYSQL_INC_DIR=$i/include && break -# test -f $i/include/mysql/mysql.h && MYSQL_INC_DIR=$i/include/mysql && break -# test -f $i/include/mariadb/mysql.h && MYSQL_INC_DIR=$i/include/mariadb && break -# test -f $i/mysql/include/mysql.h && MYSQL_INC_DIR=$i/mysql/include && break -done - -if test -z "$MYSQL_INC_DIR"; then - if test "x$MYSQL_DIR" != "x"; then - AC_MSG_ERROR(Cannot find MySQL header files under $MYSQL_DIR) - else - AC_MSG_ERROR(Cannot find MySQL headers. Use --with-mysql= to specify non-default path.) - fi fi -for i in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do - MYSQL_LIB_CHK($i/lib64) - MYSQL_LIB_CHK($i/lib64/mysql) - MYSQL_LIB_CHK($i/lib/x86_64-linux-gnu) - MYSQL_LIB_CHK($i/lib/x86_64-linux-gnu/mysql) - MYSQL_LIB_CHK($i/lib) - MYSQL_LIB_CHK($i/lib/mysql) -done - -if test -n "$MYSQL_LIB_DIR" ; then - LDFLAGS="-L$MYSQL_LIB_DIR $LDFLAGS" -fi - CFLAGS="-I$MYSQL_INC_DIR $CFLAGS" - -unamestr=$(uname) -if test $unamestr = 'OpenBSD'; then - AC_CHECK_LIB(mysqlclient, mysql_init, - [ LIBS="-lmysqlclient -lexecinfo -lm $LIBS" - AC_DEFINE(HAVE_MYSQL, 1, MySQL Client API) - HAVE_MYSQL=yes ], - [ HAVE_MYSQL=no ] - ) -else - AC_CHECK_LIB(mysqlclient, mysql_init, - [ LIBS="-lmysqlclient -lm -ldl $LIBS" - AC_DEFINE(HAVE_MYSQL, 1, MySQL Client API) - HAVE_MYSQL=yes ], - [ HAVE_MYSQL=no ] - ) +if test -z "$MYSQL_CONFIG"; then + AC_PATH_PROGS([MYSQL_CONFIG], [mariadb_config mysql_config]) fi -if test -f $MYSQL_LIB_DIR/libmysqlclient_r.a -o -f $MYSQL_LIB_DIR/libmysqlclient_r.$ShLib; then - LIBS="-lmysqlclient_r -lm -ldl $LIBS" +if test -n "$MYSQL_CONFIG"; then + AC_MSG_NOTICE([using $MYSQL_CONFIG for database client flags]) + if ! MYSQL_CFLAGS=`"$MYSQL_CONFIG" --cflags`; then + AC_MSG_ERROR([$MYSQL_CONFIG --cflags failed]) + fi + if ! MYSQL_LIBS=`"$MYSQL_CONFIG" --libs`; then + AC_MSG_ERROR([$MYSQL_CONFIG --libs failed]) + fi + CFLAGS="$MYSQL_CFLAGS $CFLAGS" + LIBS="$MYSQL_LIBS $LIBS" else - if test -f $MYSQL_LIB_DIR/libmysqlclient_r.a -o -f $MYSQL_LIB_DIR/libmysqlclient_r.$ShLib ; then - LIBS="-lmysqlclient_r -lm -ldl $LIBS" - else - if test "$HAVE_MYSQL" = "yes"; then - if test $unamestr = 'OpenBSD'; then - LIBS="-lmysqlclient -lm $LIBS" - else - LIBS="-lmysqlclient -lm -ldl $LIBS" - fi - else - if test -f $MYSQL_LIB_DIR/libperconaserverclient.a -o -f $MYSQL_LIB_DIR/libperconaserverclient.$ShLib; then - LIBS="-lperconaserverclient -lm -ldl $LIBS" - else - LIBS="-lmariadbclient -lm -ldl $LIBS" + AC_MSG_NOTICE([no client config tool found; using header and library discovery]) + + mysql_header_prefix= + + for mysql_prefix in $MYSQL_DIR /usr /usr/local /opt /opt/mysql /usr/pkg /usr/local/mysql; do + for mysql_include_dir in include include/mysql include/mariadb mysql; do + if test -f "$mysql_prefix/$mysql_include_dir/mysql.h"; then + CFLAGS="-I$mysql_prefix/$mysql_include_dir $CFLAGS" + mysql_header_prefix=$mysql_prefix + break 2 fi - fi + done + done + + dnl The header can turn up under a prefix the caller never named, so search + dnl for the library under that same prefix. Adding only the MYSQL_DIR paths + dnl leaves mysql_init unlinkable when the client lives anywhere else. + mysql_library_prefixes="$MYSQL_DIR" + + if test -n "$mysql_header_prefix" && test "x$mysql_header_prefix" != "x$MYSQL_DIR"; then + mysql_library_prefixes="$mysql_library_prefixes $mysql_header_prefix" fi + + for mysql_prefix in $mysql_library_prefixes; do + for mysql_library_dir in lib64 lib64/mysql lib lib/mysql; do + if test -d "$mysql_prefix/$mysql_library_dir"; then + LDFLAGS="-L$mysql_prefix/$mysql_library_dir $LDFLAGS" + fi + done + done fi +AC_CHECK_HEADER([mysql.h], [], + [AC_MSG_ERROR([Cannot find mysql.h. Install MariaDB Connector/C or MySQL Connector/C development files.])]) + +AC_SEARCH_LIBS([mysql_init], [mariadb mysqlclient perconaserverclient], + [AC_DEFINE([HAVE_MYSQL], [1], [MySQL-compatible Client API]) + HAVE_MYSQL=yes], + [AC_MSG_ERROR([Cannot link a MySQL-compatible client library.])]) + # ****************** Net-SNMP Checks *********************** if test "x$SNMP_DIR" != "x"; then for i in / /net-snmp /include/net-snmp; do @@ -462,7 +432,7 @@ fi AC_MSG_CHECKING([if we can support mysql/mariadb retry count]) AC_LINK_IFELSE([AC_LANG_PROGRAM([[ #include - #include "$MYSQL_INC_DIR/mysql.h" + #include ]], [[ if (MYSQL_OPT_RETRY_COUNT) { exit(0); @@ -477,7 +447,7 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[ AC_MSG_CHECKING([if we mysql/mariadb supports verify certificates]) AC_LINK_IFELSE([AC_LANG_PROGRAM([[ #include - #include "$MYSQL_INC_DIR/mysql.h" + #include ]], [[ if (MYSQL_OPT_SSL_VERIFY_SERVER_CERT) { exit(0); @@ -493,7 +463,7 @@ AC_LINK_IFELSE([AC_LANG_PROGRAM([[ AC_MSG_CHECKING([if we can support mysql/mariadb ssl keys]) AC_LINK_IFELSE([AC_LANG_PROGRAM([[ #include - #include "$MYSQL_INC_DIR/mysql.h" + #include ]], [[ if (MYSQL_OPT_SSL_KEY) { exit(0); diff --git a/poller.c b/poller.c index b03cd6e2..3b68016a 100644 --- a/poller.c +++ b/poller.c @@ -34,6 +34,44 @@ #include "common.h" #include "spine.h" +int format_poller_output_row(char *output, size_t output_size, + int local_data_id, const char *escaped_rrd_name, + const char *host_time, const char *escaped_result) { + const char *timep; + int decimal_points = 0; + int digits = 0; + int written; + + if (output == NULL || output_size == 0 || escaped_rrd_name == NULL || + host_time == NULL || host_time[0] == '\0' || escaped_result == NULL) { + return FALSE; + } + + /* host_time is emitted outside SQL quotes. Accept the integer timestamps + * Spine generates and a single fractional part, but no SQL syntax. */ + for (timep = host_time; *timep != '\0'; timep++) { + if (*timep == '.') { + decimal_points++; + if (decimal_points > 1) { + return FALSE; + } + } else if (!isdigit((unsigned char)*timep)) { + return FALSE; + } else { + digits++; + } + } + if (digits == 0) { + return FALSE; + } + + written = snprintf(output, output_size, + " (%i, '%s', FROM_UNIXTIME(%s), '%s')", + local_data_id, escaped_rrd_name, host_time, escaped_result); + + return written >= 0 && (size_t)written < output_size; +} + void child_cleanup(void *arg) { poller_thread_t poller_details = *(poller_thread_t*) arg; @@ -117,7 +155,7 @@ void *child(void *arg) { exit(0); } -/*! \fn void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double) +/*! \fn void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double) * \brief core Spine function that polls a host * \param host_id integer value for the host_id from the hosts table in Cacti * @@ -139,7 +177,7 @@ void *child(void *arg) { * as the host poller_items table dictates. * */ -void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double) { +void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double) { char query1[BUFSIZE]; char query2[BIG_BUFSIZE]; char *query3 = NULL; @@ -1888,11 +1926,17 @@ void poll_host(int device_counter, int host_id, int host_thread, int host_thread db_escape(&mysqlt, escaped_result, sizeof(escaped_result), poller_items[i].result); db_escape(&mysqlt, escaped_rrd_name, sizeof(escaped_rrd_name), poller_items[i].rrd_name); - snprintf(result_string, RESULTS_BUFFER+SMALL_BUFSIZE, " (%i, '%s', FROM_UNIXTIME(%s), '%s')", + if (!format_poller_output_row(result_string, sizeof(result_string), poller_items[i].local_data_id, escaped_rrd_name, host_time, - escaped_result); + escaped_result)) { + SPINE_LOG(("Device[%i] HT[%i] ERROR: Poller output for DS[%i] " + "exceeds the configured result buffer and was skipped", + host_id, host_thread, poller_items[i].local_data_id)); + i++; + continue; + } result_length = strlen(result_string); diff --git a/poller.h b/poller.h index 78763745..433d4f2f 100644 --- a/poller.h +++ b/poller.h @@ -37,11 +37,14 @@ extern void *child(void *arg); extern void child_cleanup(void *arg); extern void child_cleanup_thread(void *arg); extern void child_cleanup_script(void *arg); -extern void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, char *host_time, int *host_errors, double host_time_double); +extern void poll_host(int device_counter, int host_id, int host_thread, int host_threads, int host_data_ids, const char *host_time, int *host_errors, double host_time_double); extern char *exec_poll(host_t *current_host, char *command, int id, const char *type); extern void get_system_information(host_t *host, MYSQL *mysql, int system); extern int is_multipart_output(char *result); extern int validate_result(char *result); +extern int format_poller_output_row(char *output, size_t output_size, + int local_data_id, const char *escaped_rrd_name, + const char *host_time, const char *escaped_result); extern void buffer_output_errors(char * error_string, int * buf_size, int * buf_errors, int device_id, int thread_id, int local_data_id, bool flush); #endif /* SPINE_POLLER_H */ diff --git a/scripts/check-dco.sh b/scripts/check-dco.sh new file mode 100755 index 00000000..5d7feef6 --- /dev/null +++ b/scripts/check-dco.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Verify that every commit in a revision range has a Signed-off-by trailer +# matching that commit's author. This enforces the Developer Certificate of +# Origin without granting a third-party action access to the repository. +set -euo pipefail + +if [[ $# -ne 1 || -z $1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +range=$1 +failures=0 + +while IFS= read -r commit; do + author=$(git show -s --format='%an <%ae>' "$commit") + if ! git show -s --format='%B' "$commit" \ + | git interpret-trailers --parse \ + | grep -Fqi "Signed-off-by: $author"; then + echo "${commit}: missing Signed-off-by: ${author}" >&2 + failures=$((failures + 1)) + fi +done < <(git rev-list --reverse "$range") + +if ((failures > 0)); then + echo "$failures commit(s) failed the DCO check." >&2 + exit 1 +fi + +echo "All commits in $range have matching Signed-off-by trailers." diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..c0669de7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,49 @@ +# Spine test harness + +`make check` runs the cmocka unit tests. The Docker-backed integration fixture +uses a real Net-SNMP agent and MariaDB or MySQL to exercise the complete poll +and persistence path. + +Run the standard end-to-end test with: + +```sh +SPINE_TEST_DATABASE=mariadb tests/snmpv3/scripts/run-integration.sh +``` + +Run the concurrency contract with: + +```sh +SPINE_TEST_HOSTS=24 \ +SPINE_TEST_THREAD_COUNTS="1 4 12" \ +SPINE_TEST_REPEATS=1 \ +tests/integration/test_concurrency.sh +``` + +Exercise timeout and recovery behavior with real UDP packet loss: + +```sh +tests/integration/test_network_faults.sh +``` + +The concurrency test creates an isolated Compose project, clones independent +SNMPv3 host records, sweeps worker counts, and verifies exact output and host +accounting after every run. It is intentionally transport-neutral so it can +also test a future libuv engine and reactor-count matrix without changing its +assertions. Fixture services communicate only on their project-local Compose +network, so independent test projects can run without competing for host ports. +The fault test uses `tc netem` inside the test-only SNMP agent container to +drop every response, verifies bounded completion without stale output, removes +the fault, and verifies the next poll recovers. + +Useful controls: + +- `SPINE_TEST_TIMEOUT_SECONDS`: per-Spine-run timeout (default `120`). +- `SPINE_TEST_ARTIFACT_DIR`: destination for logs and TAP results. +- `SPINE_TEST_KEEP_ENV_ON_FAILURE=1`: preserve containers after a failure. +- `SPINE_TEST_PROJECT`: explicit Compose project name for reproducibility. +- `SPINE_TEST_EXTRA_ARGS`: additional whitespace-separated Spine arguments. + This provides the hook for future `--snmp-engine` and reactor-count sweeps; + arguments are split but never evaluated as shell code. + +Keep CI workloads bounded. Larger host counts and repeat counts belong in a +scheduled soak workflow rather than the pull-request critical path. diff --git a/tests/fuzz/Makefile b/tests/fuzz/Makefile index 49460224..c1922acb 100644 --- a/tests/fuzz/Makefile +++ b/tests/fuzz/Makefile @@ -13,6 +13,8 @@ # fuzzer is always built with a clang that supports -fsanitize=fuzzer. FUZZ_CC ?= clang FUZZ_SECONDS ?= 60 +FUZZ_TIMEOUT ?= 5 +FUZZ_RSS_MB ?= 2048 SPINE_ROOT := ../.. BINDIR := bin @@ -22,6 +24,7 @@ SANITIZERS := -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer -fno-l # hardening options that clash with the sanitizer build. Keep only the includes. NETSNMP_CFLAGS := $(filter -I% -D%, $(shell net-snmp-config --base-cflags 2>/dev/null)) NETSNMP_LIBS := $(filter -l% -L%, $(shell net-snmp-config --libs 2>/dev/null)) +DB_CONFIG ?= $(shell command -v mariadb_config 2>/dev/null || command -v mysql_config 2>/dev/null) SPINE_SRC := $(addprefix $(SPINE_ROOT)/, \ ping.c util.c sql.c snmp.c locks.c poller.c nft_popen.c php.c keywords.c error.c) @@ -29,11 +32,11 @@ SPINE_SRC := $(addprefix $(SPINE_ROOT)/, \ FUZZ_CFLAGS := $(SANITIZERS) -g -O1 -DHAVE_CONFIG_H \ -I$(SPINE_ROOT) -I$(SPINE_ROOT)/config \ $(NETSNMP_CFLAGS) \ - $(shell mysql_config --include 2>/dev/null) + $(shell $(DB_CONFIG) --include 2>/dev/null) -FUZZ_LDLIBS := $(NETSNMP_LIBS) $(filter -l% -L%, $(shell mysql_config --libs 2>/dev/null)) +FUZZ_LDLIBS := $(NETSNMP_LIBS) $(filter -l% -L%, $(shell $(DB_CONFIG) --libs 2>/dev/null)) -TARGETS := $(BINDIR)/fuzz_namebyhost +TARGETS := $(BINDIR)/fuzz_namebyhost $(BINDIR)/fuzz_output_parser .PHONY: all run clean @@ -50,7 +53,9 @@ run: $(TARGETS) name=$$(basename $$t); \ echo "== $$name ($(FUZZ_SECONDS)s)"; \ mkdir -p corpus/$$name; \ - $$t corpus/$$name -max_total_time=$(FUZZ_SECONDS) -print_final_stats=1 || rc=1; \ + $$t corpus/$$name -max_total_time=$(FUZZ_SECONDS) \ + -timeout=$(FUZZ_TIMEOUT) -rss_limit_mb=$(FUZZ_RSS_MB) \ + -print_final_stats=1 || rc=1; \ done; exit $$rc clean: diff --git a/tests/fuzz/corpus/fuzz_output_parser/invalid_regex b/tests/fuzz/corpus/fuzz_output_parser/invalid_regex new file mode 100644 index 00000000..bd844341 --- /dev/null +++ b/tests/fuzz/corpus/fuzz_output_parser/invalid_regex @@ -0,0 +1,2 @@ +([unterminated +value diff --git a/tests/fuzz/corpus/fuzz_output_parser/multipart b/tests/fuzz/corpus/fuzz_output_parser/multipart new file mode 100644 index 00000000..a928d28a --- /dev/null +++ b/tests/fuzz/corpus/fuzz_output_parser/multipart @@ -0,0 +1,2 @@ +^[^ ]+$ +in:1 out:2 diff --git a/tests/fuzz/corpus/fuzz_output_parser/numeric b/tests/fuzz/corpus/fuzz_output_parser/numeric new file mode 100644 index 00000000..2516367f --- /dev/null +++ b/tests/fuzz/corpus/fuzz_output_parser/numeric @@ -0,0 +1,2 @@ +[0-9]+ +42 diff --git a/tests/fuzz/fuzz_output_parser.c b/tests/fuzz/fuzz_output_parser.c new file mode 100644 index 00000000..e59fd729 --- /dev/null +++ b/tests/fuzz/fuzz_output_parser.c @@ -0,0 +1,54 @@ +/* + +-------------------------------------------------------------------------+ + | Copyright (C) 2004-2026 The Cacti Group | + | | + | This program is free software; you can redistribute it and/or | + | modify it under the terms of the GNU Lesser General Public License | + | as published by the Free Software Foundation; either version 2.1 | + | of the License, or (at your option) any later version. | + +-------------------------------------------------------------------------+ + */ + +#include +#include +#include +#include + +#include "common.h" +#include "spine.h" +#include "poller.h" +#include "util.h" + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + char value[1025]; + char row[RESULTS_BUFFER + SMALL_BUFSIZE]; + const char *result; + static const char *expressions[] = { + REGEX_NUMBER, + "[0-9][0-9]*", + "[a-zA-Z][a-zA-Z0-9_.:-]*", + ".*" + }; + size_t value_len; + + if (data == NULL || size == 0 || size > sizeof(value)) { + return 0; + } + + value_len = size - 1; + memcpy(value, data + 1, value_len); + value[value_len] = '\0'; + + result = regex_replace(expressions[data[0] % + (sizeof(expressions) / sizeof(expressions[0]))], value); + if (result == NULL) { + abort(); + } + + (void)format_poller_output_row(row, sizeof(row), 1, + "fuzz", "1700000000", result); + (void)is_multipart_output(value); + (void)validate_result(value); + + return 0; +} diff --git a/tests/integration/test_concurrency.sh b/tests/integration/test_concurrency.sh new file mode 100755 index 00000000..8ddf43db --- /dev/null +++ b/tests/integration/test_concurrency.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Exercise Spine against many independent hosts while sweeping worker counts. +# This is deliberately backend-neutral so the same contract can exercise the +# legacy pthread poller and the future sharded libuv reactor. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +HOSTS=${SPINE_TEST_HOSTS:-24} +THREADS=${SPINE_TEST_THREAD_COUNTS:-"1 4 12"} +REPEATS=${SPINE_TEST_REPEATS:-1} +TIMEOUT_SECONDS=${SPINE_TEST_TIMEOUT_SECONDS:-120} +DATABASE=${SPINE_TEST_DATABASE:-mariadb} +PROJECT=${SPINE_TEST_PROJECT:-spine-concurrency-${DATABASE}-$$} +ARTIFACT_ROOT=${SPINE_TEST_ARTIFACT_DIR:-"$REPO_ROOT/test-artifacts/concurrency"} +EXTRA_ARGS=() +if [[ -n "${SPINE_TEST_EXTRA_ARGS:-}" ]]; then + # This is argument splitting, not evaluation: shell metacharacters remain data. + read -r -a EXTRA_ARGS <<< "$SPINE_TEST_EXTRA_ARGS" +fi + +for numeric_value in "$HOSTS" "$REPEATS" "$TIMEOUT_SECONDS"; do + [[ "$numeric_value" =~ ^[0-9]+$ ]] || { + echo "host, repeat, and timeout values must be positive integers" >&2 + exit 2 + } +done +(( HOSTS >= 2 && HOSTS <= 500 )) || { echo "SPINE_TEST_HOSTS must be between 2 and 500" >&2; exit 2; } +(( REPEATS >= 1 && REPEATS <= 100 )) || { echo "SPINE_TEST_REPEATS must be between 1 and 100" >&2; exit 2; } +(( TIMEOUT_SECONDS >= 10 )) || { echo "SPINE_TEST_TIMEOUT_SECONDS must be at least 10" >&2; exit 2; } + +export COMPOSE_PROJECT_NAME=$PROJECT +COMPOSE=(docker compose -f "$REPO_ROOT/tests/snmpv3/docker-compose.yml") +case "$DATABASE" in + mariadb) ;; + mysql) COMPOSE+=(-f "$REPO_ROOT/tests/snmpv3/docker-compose.mysql.yml") ;; + *) echo "unsupported SPINE_TEST_DATABASE: $DATABASE" >&2; exit 2 ;; +esac + +mkdir -p "$ARTIFACT_ROOT" +export SPINE_TEST_ARTIFACT_DIR=$ARTIFACT_ROOT +# shellcheck source=tests/test-harness.sh +source "$REPO_ROOT/tests/test-harness.sh" +harness_init "Spine concurrency integration" + +cleanup() { + local rc=$? + "${COMPOSE[@]}" ps --all > "$ARTIFACT_ROOT/compose-ps.txt" 2>&1 || true + "${COMPOSE[@]}" logs --no-color > "$ARTIFACT_ROOT/compose.log" 2>&1 || true + if [[ $rc -eq 0 || "${SPINE_TEST_KEEP_ENV_ON_FAILURE:-0}" != 1 ]]; then + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true + else + printf 'Preserving failed Compose project %s\n' "$COMPOSE_PROJECT_NAME" >&2 + fi +} +trap cleanup EXIT + +db_query() { + "${COMPOSE[@]}" exec -T -e MYSQL_PWD=spine db \ + "$DATABASE" -uspine cacti -N -B -e "$1" +} + +wait_for_fixture() { + local elapsed=0 + while (( elapsed < 120 )); do + if [[ "$(db_query 'SELECT COUNT(*) FROM host;' 2>/dev/null || true)" == 1 ]] && \ + "${COMPOSE[@]}" exec -T snmpd snmpget -v3 -u testuser -l authPriv \ + -a SHA-256 -A authpass1234 -x AES -X privpass1234 \ + localhost:1161 .1.3.6.1.2.1.1.3.0 >/dev/null 2>&1; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + return 1 +} + +run_with_timeout() { + if command -v timeout >/dev/null 2>&1; then + timeout --signal=TERM --kill-after=10 "$TIMEOUT_SECONDS" "$@" + else + "$@" + fi +} + +echo "=== Build and start the isolated fixture ===" +"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true +"${COMPOSE[@]}" build spine +"${COMPOSE[@]}" up -d db snmpd +if wait_for_fixture; then + harness_pass "database and SNMP agent became ready" +else + harness_fail "fixture did not become ready within 120 seconds" + harness_finish || true + exit 1 +fi + +# Clone the real v3 host and its SNMP item. All rows address the same agent but +# maintain independent host/session state, which exposes lost completions and +# host ownership errors without requiring hundreds of containers. +db_query " +DELIMITER // +CREATE PROCEDURE seed_stress_hosts(IN wanted INT) +BEGIN + DECLARE current_id INT DEFAULT 2; + WHILE current_id <= wanted DO + INSERT INTO host ( + id, hostname, snmp_community, snmp_version, snmp_username, snmp_password, + snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol, + snmp_context, snmp_engine_id, snmp_port, snmp_timeout, max_oids, + availability_method, ping_method, ping_port, ping_timeout, ping_retries, + status, poller_id, device_threads, deleted + ) SELECT + current_id, hostname, snmp_community, snmp_version, snmp_username, snmp_password, + snmp_auth_protocol, snmp_priv_passphrase, snmp_priv_protocol, + snmp_context, '', snmp_port, snmp_timeout, max_oids, + availability_method, ping_method, ping_port, ping_timeout, ping_retries, + status, poller_id, device_threads, deleted + FROM host WHERE id = 1; + + INSERT INTO poller_item ( + local_data_id, host_id, action, hostname, snmp_community, + snmp_version, snmp_username, snmp_password, snmp_auth_protocol, + snmp_priv_passphrase, snmp_priv_protocol, snmp_context, snmp_engine_id, + snmp_port, snmp_timeout, rrd_name, rrd_path, rrd_num, rrd_step, + arg1, deleted, poller_id + ) SELECT + 1000 + current_id, current_id, action, hostname, snmp_community, + snmp_version, snmp_username, snmp_password, snmp_auth_protocol, + snmp_priv_passphrase, snmp_priv_protocol, snmp_context, '', + snmp_port, snmp_timeout, rrd_name, rrd_path, rrd_num, rrd_step, + arg1, deleted, poller_id + FROM poller_item WHERE local_data_id = 1; + + SET current_id = current_id + 1; + END WHILE; +END// +DELIMITER ; +CALL seed_stress_hosts($HOSTS); +DROP PROCEDURE seed_stress_hosts;" + +harness_assert_eq "$HOSTS" "$(db_query 'SELECT COUNT(*) FROM host;')" \ + "stress fixture has the requested host count" + +for thread_count in $THREADS; do + if [[ ! "$thread_count" =~ ^[0-9]+$ ]] || \ + (( thread_count < 1 || thread_count > 256 )); then + echo "invalid thread count: $thread_count" >&2 + exit 2 + fi + + for (( iteration=1; iteration<=REPEATS; iteration++ )); do + case_id="threads-${thread_count}-iteration-${iteration}" + log_file="$ARTIFACT_ROOT/$case_id.log" + db_query "TRUNCATE poller_output; TRUNCATE host_errors; + UPDATE host SET total_polls=0, failed_polls=0, status=3;" + + set +e + run_with_timeout "${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \ + --conf=/etc/spine/spine.conf -f 1 -l "$HOSTS" -S -t "$thread_count" \ + "${EXTRA_ARGS[@]}" \ + > "$log_file" 2>&1 + rc=$? + set -e + + output=$(<"$log_file") + harness_assert_eq 0 "$rc" "$case_id exits successfully" + harness_assert_not_contains "$output" \ + 'segmentation fault|SIGSEGV|Aborted|core dump|FATAL|Polling timed out while waiting' \ + "$case_id has no fatal, crash, or shutdown-timeout diagnostics" + harness_assert_contains "$output" "Threads: ${thread_count}, Devices: ${HOSTS}" \ + "$case_id reports every device completed" + harness_assert_eq "$HOSTS" \ + "$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \ + "$case_id writes exactly one SNMP result per host" + harness_assert_eq "$((HOSTS + 1))" \ + "$(db_query 'SELECT COUNT(*) FROM poller_output;')" \ + "$case_id neither loses nor duplicates SNMP and script results" + harness_assert_eq "$HOSTS" \ + "$(db_query 'SELECT COUNT(*) FROM host WHERE total_polls > 0;')" \ + "$case_id accounts for every host poll" + harness_assert_eq 0 \ + "$(db_query 'SELECT COUNT(*) FROM host WHERE failed_polls > 0 OR status <> 3;')" \ + "$case_id leaves every host healthy" + harness_assert_eq 0 "$(db_query 'SELECT COUNT(*) FROM host_errors;')" \ + "$case_id records no host-level errors" + done +done + +harness_finish diff --git a/tests/integration/test_network_faults.sh b/tests/integration/test_network_faults.sh new file mode 100755 index 00000000..13dfe6b9 --- /dev/null +++ b/tests/integration/test_network_faults.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Verify that complete SNMP packet loss is bounded and polling recovers after +# connectivity returns. This is a lifecycle contract for both the current +# synchronous implementation and the future libuv reactor. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TIMEOUT_SECONDS=${SPINE_TEST_TIMEOUT_SECONDS:-120} +PROJECT=${SPINE_TEST_PROJECT:-spine-network-faults-$$} +ARTIFACT_ROOT=${SPINE_TEST_ARTIFACT_DIR:-"$REPO_ROOT/test-artifacts/network-faults"} +EXTRA_ARGS=() +if [[ -n "${SPINE_TEST_EXTRA_ARGS:-}" ]]; then + read -r -a EXTRA_ARGS <<< "$SPINE_TEST_EXTRA_ARGS" +fi + +if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || (( TIMEOUT_SECONDS < 10 )); then + echo "SPINE_TEST_TIMEOUT_SECONDS must be an integer of at least 10" >&2 + exit 2 +fi + +export COMPOSE_PROJECT_NAME=$PROJECT +COMPOSE=(docker compose -f "$REPO_ROOT/tests/snmpv3/docker-compose.yml") +mkdir -p "$ARTIFACT_ROOT" +export SPINE_TEST_ARTIFACT_DIR=$ARTIFACT_ROOT +# shellcheck source=tests/test-harness.sh +source "$REPO_ROOT/tests/test-harness.sh" +harness_init "Spine SNMP network-fault integration" + +cleanup() { + local rc=$? + "${COMPOSE[@]}" exec -T snmpd tc qdisc del dev eth0 root >/dev/null 2>&1 || true + "${COMPOSE[@]}" ps --all > "$ARTIFACT_ROOT/compose-ps.txt" 2>&1 || true + "${COMPOSE[@]}" logs --no-color > "$ARTIFACT_ROOT/compose.log" 2>&1 || true + if [[ $rc -eq 0 || "${SPINE_TEST_KEEP_ENV_ON_FAILURE:-0}" != 1 ]]; then + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true + else + printf 'Preserving failed Compose project %s\n' "$COMPOSE_PROJECT_NAME" >&2 + fi +} +trap cleanup EXIT + +db_query() { + "${COMPOSE[@]}" exec -T -e MYSQL_PWD=spine db \ + mariadb -uspine cacti -N -B -e "$1" +} + +wait_for_fixture() { + local elapsed=0 + while (( elapsed < 120 )); do + if [[ "$(db_query 'SELECT COUNT(*) FROM host;' 2>/dev/null || true)" == 1 ]] && \ + "${COMPOSE[@]}" exec -T snmpd snmpget -v3 -u testuser -l authPriv \ + -a SHA-256 -A authpass1234 -x AES -X privpass1234 \ + localhost:1161 .1.3.6.1.2.1.1.3.0 >/dev/null 2>&1; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + return 1 +} + +run_spine() { + local log_file=$1 + local rc + set +e + if command -v timeout >/dev/null 2>&1; then + timeout --signal=TERM --kill-after=10 "$TIMEOUT_SECONDS" \ + "${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \ + --conf=/etc/spine/spine.conf -f 1 -l 1 -S "${EXTRA_ARGS[@]}" \ + > "$log_file" 2>&1 + else + "${COMPOSE[@]}" run --rm --no-deps --entrypoint spine spine \ + --conf=/etc/spine/spine.conf -f 1 -l 1 -S "${EXTRA_ARGS[@]}" \ + > "$log_file" 2>&1 + fi + rc=$? + set -e + return "$rc" +} + +reset_poll_state() { + db_query "TRUNCATE poller_output; TRUNCATE host_errors; + UPDATE host SET total_polls=0, failed_polls=0, status=3;" +} + +echo "=== Build and start the isolated fixture ===" +"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true +"${COMPOSE[@]}" build spine snmpd +"${COMPOSE[@]}" up -d db snmpd +if wait_for_fixture; then + harness_pass "database and SNMP agent became ready" +else + harness_fail "fixture did not become ready within 120 seconds" + harness_finish || true + exit 1 +fi + +baseline_log="$ARTIFACT_ROOT/baseline.log" +reset_poll_state +if run_spine "$baseline_log"; then + harness_pass "baseline poll exits successfully" +else + harness_fail "baseline poll exits successfully" +fi +harness_assert_eq 1 \ + "$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \ + "baseline poll writes its SNMP result" + +echo "=== Drop every response from the SNMP agent ===" +"${COMPOSE[@]}" exec -T snmpd tc qdisc replace dev eth0 root netem loss 100% +loss_log="$ARTIFACT_ROOT/packet-loss.log" +reset_poll_state +if run_spine "$loss_log"; then + harness_pass "poll under complete packet loss exits successfully" +else + harness_fail "poll under complete packet loss exits successfully" +fi +loss_output=$(<"$loss_log") +harness_assert_not_contains "$loss_output" \ + 'segmentation fault|SIGSEGV|Aborted|core dump|Polling timed out while waiting' \ + "packet loss does not crash or deadlock the poller" +harness_assert_eq 0 \ + "$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \ + "complete packet loss cannot produce a stale SNMP result" + +echo "=== Restore connectivity and verify recovery ===" +"${COMPOSE[@]}" exec -T snmpd tc qdisc del dev eth0 root +recovery_log="$ARTIFACT_ROOT/recovery.log" +reset_poll_state +if run_spine "$recovery_log"; then + harness_pass "recovery poll exits successfully" +else + harness_fail "recovery poll exits successfully" +fi +recovery_output=$(<"$recovery_log") +harness_assert_not_contains "$recovery_output" \ + 'segmentation fault|SIGSEGV|Aborted|core dump|FATAL' \ + "recovery poll has no fatal diagnostics" +harness_assert_eq 1 \ + "$(db_query "SELECT COUNT(*) FROM poller_output WHERE rrd_name = 'uptime';")" \ + "SNMP output resumes after packet loss" +harness_assert_eq 3 "$(db_query 'SELECT status FROM host WHERE id = 1;')" \ + "host is healthy after connectivity recovers" + +harness_finish diff --git a/tests/snmpv3/docker-compose.mysql.yml b/tests/snmpv3/docker-compose.mysql.yml new file mode 100644 index 00000000..1c34e1a1 --- /dev/null +++ b/tests/snmpv3/docker-compose.mysql.yml @@ -0,0 +1,20 @@ +services: + db: + image: mysql:8.4 + # The Cacti 1.x schema intentionally uses zero-date sentinel values. + command: ["--sql-mode=NO_ENGINE_SUBSTITUTION"] + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: cacti + MYSQL_USER: spine + MYSQL_PASSWORD: spine + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"] + interval: 5s + timeout: 5s + retries: 20 + + verify: + image: mysql:8.4 + environment: + DB_CLIENT: mysql diff --git a/tests/snmpv3/docker-compose.yml b/tests/snmpv3/docker-compose.yml index 11c9ee18..5ea3d256 100644 --- a/tests/snmpv3/docker-compose.yml +++ b/tests/snmpv3/docker-compose.yml @@ -19,8 +19,7 @@ services: build: ./snmpd cap_add: - SYS_TIME # allows date -s inside container for clock-skew test - ports: - - "127.0.0.1:10161:1161/udp" + - NET_ADMIN # allows tc netem in the network-fault integration test healthcheck: test: ["CMD", "snmpget", "-v3", "-u", "testuser", "-l", "authPriv", "-a", "SHA-256", "-A", "authpass1234", diff --git a/tests/snmpv3/scripts/run-integration.sh b/tests/snmpv3/scripts/run-integration.sh index a06da230..195ff352 100755 --- a/tests/snmpv3/scripts/run-integration.sh +++ b/tests/snmpv3/scripts/run-integration.sh @@ -7,20 +7,27 @@ set -euo pipefail cd "$(dirname "$0")/.." -cleanup() { docker compose down -v --remove-orphans >/dev/null 2>&1 || true; } +compose=(docker compose) +case "${SPINE_TEST_DATABASE:-mariadb}" in + mariadb) ;; + mysql) compose+=(-f docker-compose.yml -f docker-compose.mysql.yml) ;; + *) echo "unsupported SPINE_TEST_DATABASE: ${SPINE_TEST_DATABASE}" >&2; exit 2 ;; +esac + +cleanup() { "${compose[@]}" down -v --remove-orphans >/dev/null 2>&1 || true; } trap cleanup EXIT # Start from an empty database. Without this the verifier can pass on rows the # previous run wrote, so a poll that produced nothing still looks green. cleanup -docker compose up --build -d +"${compose[@]}" up --build -d -verify_id=$(docker compose ps -qa verify) -[ -n "$verify_id" ] || { echo "verify container was never created"; docker compose logs --no-color; exit 1; } +verify_id=$("${compose[@]}" ps -qa verify) +[ -n "$verify_id" ] || { echo "verify container was never created"; "${compose[@]}" logs --no-color; exit 1; } code=$(docker wait "$verify_id") -docker compose logs --no-color spine verify +"${compose[@]}" logs --no-color spine verify exit "$code" diff --git a/tests/snmpv3/scripts/run_test.sh b/tests/snmpv3/scripts/run_test.sh index 172a472e..5d1e0c81 100755 --- a/tests/snmpv3/scripts/run_test.sh +++ b/tests/snmpv3/scripts/run_test.sh @@ -42,6 +42,8 @@ echo "=== Phase 2: skew snmpd clock +200s to trigger notInTimeWindow ===" "${COMPOSE[@]}" ps snmpd 2>/dev/null | grep -qw "healthy" \ || { echo " SKIP: snmpd not healthy, skipping clock-skew phase"; exit 0; } +# Expansion must happen inside the container. +# shellcheck disable=SC2016 "${COMPOSE[@]}" exec -T snmpd /bin/sh -c \ 'date -s "$(date -d "+200 seconds" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -v+200S +%Y-%m-%dT%H:%M:%S)" 2>/dev/null' \ || { echo " SKIP: SYS_TIME capability not available, skipping clock-skew phase"; exit 0; } diff --git a/tests/snmpv3/scripts/verify.sh b/tests/snmpv3/scripts/verify.sh index 4faee2f3..e040cb70 100755 --- a/tests/snmpv3/scripts/verify.sh +++ b/tests/snmpv3/scripts/verify.sh @@ -5,7 +5,8 @@ # to. Every check here is something a regression in poll_host() would break. set -eu -Q="mariadb -h db -u spine -pspine cacti -N -B -e" +DB_CLIENT=${DB_CLIENT:-mariadb} +Q="$DB_CLIENT -h db -u spine -pspine cacti -N -B -e" fail() { echo "FAIL: $1"; exit 1; } ok() { echo "ok: $1"; } @@ -32,7 +33,6 @@ status=$($Q "SELECT status FROM host WHERE id = 1;") ok "host 1 recorded as up" # 5. availability accounting ran -fails=$($Q "SELECT status_fail_date FROM host WHERE id = 1;") errs=$($Q "SELECT COUNT(*) FROM host_errors WHERE host_id = 1;") [ "$errs" = "0" ] || fail "host_errors has $errs row(s) for host 1" ok "no host errors recorded" diff --git a/tests/snmpv3/snmpd/Dockerfile b/tests/snmpv3/snmpd/Dockerfile index 4b56ead5..1975725a 100644 --- a/tests/snmpv3/snmpd/Dockerfile +++ b/tests/snmpv3/snmpd/Dockerfile @@ -1,6 +1,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ + iproute2 \ snmpd \ snmp \ && rm -rf /var/lib/apt/lists/* diff --git a/tests/test-harness.sh b/tests/test-harness.sh new file mode 100755 index 00000000..6f4e8a6b --- /dev/null +++ b/tests/test-harness.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Shared assertions and TAP output for Spine's shell integration tests. +# shellcheck shell=bash + +if [[ -n "${SPINE_TEST_HARNESS_LOADED:-}" ]]; then + return 0 +fi +readonly SPINE_TEST_HARNESS_LOADED=1 + +HARNESS_PASS=0 +HARNESS_FAIL=0 +HARNESS_ASSERTIONS=0 +HARNESS_SUITE=${HARNESS_SUITE:-spine} +HARNESS_ARTIFACT_DIR=${SPINE_TEST_ARTIFACT_DIR:-} +HARNESS_TAP_FILE= + +harness_init() { + HARNESS_SUITE=$1 + HARNESS_PASS=0 + HARNESS_FAIL=0 + HARNESS_ASSERTIONS=0 + + if [[ -n "$HARNESS_ARTIFACT_DIR" ]]; then + mkdir -p "$HARNESS_ARTIFACT_DIR" + HARNESS_TAP_FILE="$HARNESS_ARTIFACT_DIR/results.tap" + printf 'TAP version 13\n' > "$HARNESS_TAP_FILE" + fi +} + +harness_tap_escape() { + local message=$1 + message=${message//$'\n'/ } + message=${message//$'\r'/ } + printf '%s' "$message" +} + +harness_record() { + local status=$1 + shift + local message=$* + + HARNESS_ASSERTIONS=$((HARNESS_ASSERTIONS + 1)) + if [[ "$status" == pass ]]; then + HARNESS_PASS=$((HARNESS_PASS + 1)) + printf ' PASS: %s\n' "$message" + if [[ -n "$HARNESS_TAP_FILE" ]]; then + printf 'ok %d - %s\n' "$HARNESS_ASSERTIONS" \ + "$(harness_tap_escape "$message")" >> "$HARNESS_TAP_FILE" + fi + else + HARNESS_FAIL=$((HARNESS_FAIL + 1)) + printf ' FAIL: %s\n' "$message" >&2 + if [[ -n "$HARNESS_TAP_FILE" ]]; then + printf 'not ok %d - %s\n' "$HARNESS_ASSERTIONS" \ + "$(harness_tap_escape "$message")" >> "$HARNESS_TAP_FILE" + fi + fi +} + +harness_pass() { harness_record pass "$@"; } +harness_fail() { harness_record fail "$@"; } + +harness_assert_eq() { + local expected=$1 + local actual=$2 + shift 2 + if [[ "$actual" == "$expected" ]]; then + harness_pass "$* (got $actual)" + else + harness_fail "$* (expected $expected, got $actual)" + fi +} + +harness_assert_contains() { + local value=$1 + local pattern=$2 + shift 2 + if grep -Eq "$pattern" <<< "$value"; then + harness_pass "$*" + else + harness_fail "$* (pattern not found: $pattern)" + fi +} + +harness_assert_not_contains() { + local value=$1 + local pattern=$2 + shift 2 + if grep -Eiq "$pattern" <<< "$value"; then + harness_fail "$* (unexpected pattern: $pattern)" + else + harness_pass "$*" + fi +} + +harness_finish() { + printf '\n=== %s: %d passed, %d failed ===\n' \ + "$HARNESS_SUITE" "$HARNESS_PASS" "$HARNESS_FAIL" + + if [[ -n "$HARNESS_TAP_FILE" ]]; then + printf '1..%d\n' "$HARNESS_ASSERTIONS" >> "$HARNESS_TAP_FILE" + printf 'TAP results: %s\n' "$HARNESS_TAP_FILE" + fi + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + printf '### %s\n\n' "$HARNESS_SUITE" + printf -- '- Passed: %d\n- Failed: %d\n' "$HARNESS_PASS" "$HARNESS_FAIL" + } >> "$GITHUB_STEP_SUMMARY" + fi + + [[ $HARNESS_FAIL -eq 0 ]] +} diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index 71371452..49dfda8c 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -19,6 +19,7 @@ #include "spine.h" #include "util.h" #include "ping.h" +#include "poller.h" /* provided by tests/fuzz/stubs.c, as spine.c would */ extern int *debug_devices; @@ -82,6 +83,52 @@ static void test_regex_replace_passes_through_on_bad_pattern(void **state) { assert_string_equal(regex_replace("[unclosed", "value"), "value"); } +static void test_bounded_formatters(void **state) { + char capabilities[BUFSIZE]; + char row[DBL_BUFSIZE + SMALL_BUFSIZE]; + char small[32]; + char long_value[DBL_BUFSIZE]; + const char *auth_start; + const char *auth_end; + const char *priv_start; + const char *priv_end; + (void) state; + + memset(long_value, '7', sizeof(long_value) - 1); + long_value[sizeof(long_value) - 1] = '\0'; + + assert_true(format_spine_capabilities(capabilities, + sizeof(capabilities), long_value, long_value)); + assert_true(strlen(capabilities) < sizeof(capabilities)); + auth_start = strstr(capabilities, "authProtocols: \""); + assert_non_null(auth_start); + auth_start += strlen("authProtocols: \""); + auth_end = strchr(auth_start, '"'); + assert_non_null(auth_end); + priv_start = strstr(capabilities, "privProtocols: \""); + assert_non_null(priv_start); + priv_start += strlen("privProtocols: \""); + priv_end = strchr(priv_start, '"'); + assert_non_null(priv_end); + assert_int_equal(auth_end - auth_start, CAPABILITY_PROTOCOL_LIST_MAX); + assert_int_equal(priv_end - priv_start, CAPABILITY_PROTOCOL_LIST_MAX); + assert_string_equal(priv_end, "\" }"); + assert_false(format_spine_capabilities(small, + sizeof(small), long_value, long_value)); + assert_false(format_spine_capabilities(NULL, 0, long_value, long_value)); + + assert_true(format_poller_output_row(row, sizeof(row), 17, + "traffic_in", "1700000000.25", long_value)); + assert_non_null(strstr(row, + "(17, 'traffic_in', FROM_UNIXTIME(1700000000.25)")); + assert_false(format_poller_output_row(small, sizeof(small), 17, + "traffic_in", "1700000000.25", long_value)); + assert_false(format_poller_output_row(row, sizeof(row), 17, + "traffic_in", "1700000000); DROP TABLE host", long_value)); + assert_false(format_poller_output_row(NULL, 0, 17, + "traffic_in", "1700000000.25", long_value)); +} + /* --- predicates ----------------------------------------------------------- */ @@ -119,6 +166,9 @@ static void test_is_numeric(void **state) { static void test_is_hexadecimal(void **state) { (void) state; assert_int_equal(is_hexadecimal("AA BB CC", 0), TRUE); + assert_int_equal(is_hexadecimal("AA\tBB", 0), FALSE); + assert_int_equal(is_hexadecimal("AA\tBB", 1), FALSE); + assert_int_equal(is_hexadecimal("AA\tBB:CC", 1), TRUE); assert_int_equal(is_hexadecimal("zz", 0), FALSE); assert_int_equal(is_hexadecimal("", 0), FALSE); } @@ -288,7 +338,10 @@ static void test_icmp_classify_rejects_a_non_echo(void **state) { const struct icmp *out = NULL; (void) state; - build_ip_icmp(buf, sizeof buf, 1, 1, ICMP_DEST_UNREACH); + /* Type 3 is Destination Unreachable in RFC 792. BSD names the symbol + * ICMP_UNREACH while Linux names it ICMP_DEST_UNREACH, so keep the wire + * value explicit in this platform-independent parser test. */ + build_ip_icmp(buf, sizeof buf, 1, 1, 3); assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 1, 1, &out), SPINE_ICMP_REPLY_NOT_ECHO); assert_null(out); } @@ -466,6 +519,7 @@ int main(void) { cmocka_unit_test(test_regex_replace_returns_the_match), cmocka_unit_test(test_regex_replace_passes_through_on_no_match), cmocka_unit_test(test_regex_replace_passes_through_on_bad_pattern), + cmocka_unit_test(test_bounded_formatters), cmocka_unit_test(test_all_digits), cmocka_unit_test(test_is_ipaddress), cmocka_unit_test(test_is_numeric), diff --git a/util.c b/util.c index fa1dd93f..09b25e66 100644 --- a/util.c +++ b/util.c @@ -35,6 +35,9 @@ #include "spine.h" #include "regex.h" +#define SPINE_STRINGIFY_INNER(value) #value +#define SPINE_STRINGIFY(value) SPINE_STRINGIFY_INNER(value) + static int nopts = 0; /*! Override Options Structure @@ -516,7 +519,7 @@ void read_config_options(void) { int mode; char web_root[BUFSIZE]; char sqlbuf[HUGE_BUFSIZE]; - char *sqlp = sqlbuf; + char *sqlp; char *res; char spine_priv[BUFSIZE]; char spine_auth[BUFSIZE]; @@ -914,9 +917,15 @@ void read_config_options(void) { strcat(spine_priv, (strlen(spine_priv) > 0 ? ",AES256":"AES256")); #endif - snprintf(spine_capabilities, BUFSIZE, "{ authProtocols: \"%s\", privProtocols: \"%s\" }", spine_auth, spine_priv); + /* Each source buffer can be BUFSIZE bytes. Bound both fields so the + * combined capability document always fits in its destination. */ + if (!format_spine_capabilities(spine_capabilities, + sizeof(spine_capabilities), spine_auth, spine_priv)) { + SPINE_LOG(("ERROR: Unable to format Spine SNMP capabilities")); + spine_capabilities[0] = '\0'; + } - if (set.poller_id == 1) { + if (set.poller_id == 1 && spine_capabilities[0] != '\0') { putsetting(&mysql, LOCAL, "spine_capabilities", spine_capabilities); } @@ -1744,6 +1753,7 @@ int is_hexadecimal(const char * str, const short ignore_special) { if (ignore_special) { break; } + /* fall through */ default: return FALSE; } @@ -1801,14 +1811,14 @@ char *strip_alpha(char *string) { return string; } -/*! \fn char *add_slashes(char *string) +/*! \fn char *add_slashes(const char *string) * \brief add escaping to back slashes on for Windows type commands. * \param string the string to replace slashes * * \return a pointer to the modified string. Variable must be freed by parent. * */ -char *add_slashes(char *string) { +char *add_slashes(const char *string) { int length; int position; int new_position; @@ -1915,14 +1925,14 @@ char *trim(char *str) { */ char *rtrim(char *str) { char *end; - const char *trim = " \"\'\\\t\n\r"; + const char *trim_chars = " \"\'\\\t\n\r"; if (!str) return NULL; end = str + strlen(str); while (end-- > str) { - if (!strchr(trim, *end)) return str; + if (!strchr(trim_chars, *end)) return str; *end = 0; } @@ -1937,12 +1947,12 @@ char *rtrim(char *str) { * \return the trimmed string. */ char *ltrim(char *str) { - const char *trim = " \"\'\\\t\n\r"; + const char *trim_chars = " \"\'\\\t\n\r"; if (!str) return NULL; while (*str) { - if (!strchr(trim, *str)) return str; + if (!strchr(trim_chars, *str)) return str; ++str; } @@ -2285,3 +2295,20 @@ const char *regex_replace(const char *exp, const char *value) { return (reti) ? value : msgbuf; } + +int format_spine_capabilities(char *output, size_t output_size, + const char *auth_protocols, const char *priv_protocols) { + int written; + + if (output == NULL || output_size == 0 || + auth_protocols == NULL || priv_protocols == NULL) { + return FALSE; + } + + written = snprintf(output, output_size, + "{ authProtocols: \"%." SPINE_STRINGIFY(CAPABILITY_PROTOCOL_LIST_MAX) + "s\", privProtocols: \"%." SPINE_STRINGIFY(CAPABILITY_PROTOCOL_LIST_MAX) "s\" }", + auth_protocols, priv_protocols); + + return written >= 0 && (size_t)written < output_size; +} diff --git a/util.h b/util.h index 557d1ed9..0a1e4b94 100644 --- a/util.h +++ b/util.h @@ -59,7 +59,7 @@ extern int is_hexadecimal(const char * str, const short ignore_special); extern int is_debug_device(int device_id); /* string and file functions */ -extern char *add_slashes(char *string); +extern char *add_slashes(const char *string); extern int file_exists(const char *filename); extern char *strip_alpha(char *string); extern char *strncopy(char *dst, const char *src, size_t n); @@ -76,7 +76,10 @@ unsigned long long hex2dec(char *str); /* custom regex replace to return a value if matches */ #define MAX_MATCHES 5 #define REGEX_NUMBER "([-+]*)([0-9]*)([.][0-9]+)" +#define CAPABILITY_PROTOCOL_LIST_MAX 480 const char *regex_replace(const char *exp, const char *value); +int format_spine_capabilities(char *output, size_t output_size, + const char *auth_protocols, const char *priv_protocols); /* macro to copy string to string with an ending null */ #define STRNCOPY(dst, src) strncopy((dst), (src), sizeof(dst))