Files
spine/tests/integration/test_alloc_failure.sh
86a062dd09 ci: enforce cross-platform quality gates and connector coverage (#618)
## Summary

Consolidates the connector/build work from #613 and the broader
test-infrastructure work extracted from #597 into one review:

- discover MariaDB Connector/C first while retaining MySQL and
Percona-compatible support
- test MariaDB and MySQL Connector/C with strict GCC and Clang builds on
Ubuntu 24.04
- add a real macOS 15 build and unit-test lane with portable Autotools
platform detection
- run unit tests under ASan/UBSan and ThreadSanitizer
- enforce the measured 7% global coverage baseline and 70% changed-line
coverage
- add libFuzzer coverage for output parsing alongside the existing
hostname parser
- exercise the SNMPv3 integration suite against MariaDB and MySQL 8.4
- add cppcheck, shellcheck, DCO verification, CODEOWNERS, distcheck, and
Linux-capability checks while retaining the repository-managed default
CodeQL setup
- expose a stable `CI / required` aggregate check for branch protection
- pin runners and third-party actions and keep workflow permissions
read-only by default

## Correctness fixes found by the new gates

- reject truncated capability strings and poller-output SQL tuples
instead of publishing partial data
- make `bootstrap` fail closed and select `libtoolize`/`glibtoolize`
portably
- use Autoconf's canonical host for Darwin detection instead of an empty
native-build alias
- remove stale initialization/name-shadow findings and make read-only
interfaces const-correct
- make the ICMP parser test portable across Linux and BSD header names

## Verification

- strict GCC and Clang builds: 4/4 unit targets passed
- ASan/UBSan: 4/4 passed
- ThreadSanitizer: 4/4 passed
- macOS Clang/Homebrew build: 4/4 passed
- MariaDB integration: passed
- MySQL 8.4 integration: passed
- both libFuzzer targets: passed sustained local runs
- coverage: 7.2% global and 84% on changed lines
- cppcheck, shellcheck, actionlint, DCO verification, and `git diff
--check`: passed locally

## Post-merge repository settings

After these workflows exist on `develop`, an administrator should
require `CI / required`, Fuzz, Integration, Rocky Linux, and the
repository-managed CodeQL/code-scanning result in the active ruleset;
enable code-owner approval; and require the branch to be current (or use
a merge queue). Enabling those settings before this PR merges would
create required checks that the base branch cannot yet emit.

Consolidates and supersedes #613. Closes #612.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: TheWitness <thewitness@cacti.net>
Signed-off-by: TheWitness <copilot@users.noreply.github.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
Co-authored-by: TheWitness <copilot@users.noreply.github.com>
2026-09-24 05:52:56 -04:00

154 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Integration test for the startup allocation guards (issue#564).
#
# php_processes and debug_devices are dereferenced on the line after their
# calloc(), so an allocation failure there used to be a NULL deref rather than
# a diagnosable exit. A unit test cannot reach them: they live in main().
# This injects the failure into the real binary with an LD_PRELOAD calloc that
# returns NULL on the Nth call, and asserts spine dies with its own message.
#
# The db_pool_local and db_pool_remote guards are not covered here. They run
# after the database connection, so reaching them needs the SNMPv3 harness's
# MySQL container rather than a bare binary.
#
# Skips with exit 77 (automake and prove read that as "skipped") when the
# binary, a compiler, or LD_PRELOAD interposition is unavailable, so the
# suite stays green on platforms where this technique does not apply.
#
# Usage: ./tests/integration/test_alloc_failure.sh
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "LD_PRELOAD allocation interposition is only supported by this test on Linux; skipping"
exit 77
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PASS=0
FAIL=0
pass() { echo " PASS: $*"; PASS=$((PASS+1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); }
SPINE=""
if [[ -n "${top_builddir:-}" && -x "$top_builddir/spine" ]]; then
SPINE="$top_builddir/spine"
elif [[ -x "$REPO_ROOT/spine" ]]; then
SPINE="$REPO_ROOT/spine"
elif command -v spine >/dev/null 2>&1; then
SPINE="$(command -v spine)"
fi
if [[ -z "$SPINE" ]]; then
echo "no spine binary found; skipping"
exit 77
fi
if [[ -u "$SPINE" ]]; then
echo "the spine binary is setuid and the loader will ignore LD_PRELOAD; skipping"
exit 77
fi
# ASan/UBSan/TSan install their own allocator interceptors ahead of anything
# LD_PRELOAD can add, so a preloaded calloc() never sees (and cannot fail)
# the process's real allocations under those runtimes. Detect a sanitized
# binary by its exported runtime-init symbols and skip rather than silently
# testing nothing (or, worse, forcing the sanitizer runtime's own bootstrap
# allocation to fail and hang instead of exercising the guard at all).
#
# The symbol table is captured into a variable before grep runs against it
# (rather than piping straight into `grep -q`) because grep -q exits as soon
# as it finds a match, which under `set -o pipefail` can SIGPIPE the still-
# writing nm/strings process and make the pipeline look like it failed even
# though the match was found.
if command -v nm >/dev/null 2>&1; then
spine_symbols="$(nm -D "$SPINE" 2>/dev/null || true)"
elif command -v strings >/dev/null 2>&1; then
spine_symbols="$(strings "$SPINE" 2>/dev/null || true)"
else
spine_symbols=""
fi
if grep -qE '__asan_init|__tsan_init|__msan_init|__ubsan_(handle|default_options)' <<<"$spine_symbols"; then
echo "the spine binary is built with a sanitizer runtime, which takes over calloc() ahead of LD_PRELOAD; skipping"
exit 77
fi
if ! command -v cc >/dev/null 2>&1; then
echo "no compiler available for the interposer; skipping"
exit 77
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cat > "$WORK/failcalloc.c" <<'INTERPOSER'
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdlib.h>
/* Return NULL from the Nth calloc() of the process, pass the rest through. */
static long seen;
static long fail_at = -1;
void *calloc(size_t nmemb, size_t size) {
static void *(*real_calloc)(size_t, size_t);
if (real_calloc == NULL) {
const char *at;
real_calloc = dlsym(RTLD_NEXT, "calloc");
at = getenv("SPINE_FAIL_CALLOC_AT");
if (at != NULL) {
fail_at = atol(at);
}
}
if (++seen == fail_at) {
return NULL;
}
return real_calloc(nmemb, size);
}
INTERPOSER
if ! cc -shared -fPIC -o "$WORK/failcalloc.so" "$WORK/failcalloc.c" -ldl 2>/dev/null; then
echo "could not build the LD_PRELOAD interposer; skipping"
exit 77
fi
# Sanity check: without injection --help must still work, otherwise the
# interposer itself is broken and every assertion below would be meaningless.
if ! LD_PRELOAD="$WORK/failcalloc.so" "$SPINE" --help >/dev/null 2>&1; then
echo "LD_PRELOAD interposition not supported here; skipping"
exit 77
fi
echo "Allocation failure guards:"
# Shared-library constructors can call calloc before main(), so the process-
# wide ordinal is deliberately discovered rather than assumed. A matching
# diagnostic proves the guarded allocation itself was the one that failed.
check_guard() {
local want="$1" nth out
for nth in $(seq 1 256); do
out="$(SPINE_FAIL_CALLOC_AT="$nth" LD_PRELOAD="$WORK/failcalloc.so" "$SPINE" --help 2>&1 || true)"
if grep -q "Fatal calloc error: spine.c $want" <<<"$out"; then
pass "failing $want allocation exits through its guard (calloc #$nth)"
return
fi
done
fail "could not reach the $want allocation guard in the first 256 calloc calls"
}
check_guard php_processes
check_guard debug_devices
echo
echo "passed: $PASS, failed: $FAIL"
[[ "$FAIL" -eq 0 ]]