#!/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 #include /* 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 ]]