Files
spine/tests/unit/test_php_runtime.c
T
Thomas Vincent 898d8c692c fix(privileges): drop root before input and retain owned ICMP sockets (#639)
Open and own the required ICMP sockets before permanently dropping
setuid root, then parse input without elevated identity. Close-on-exec
socket ownership, shared IPv4/IPv6 dispatch and fault-injected
identity/capability calls are covered. The owned fixture tests check
deny/allow behavior without contacting external devices.

Step 2/14 of the complete C17/native-correctness series. Base: the
preceding series branch; merge/review in order so this PR shows only its
own step.

Earlier validation, before this review follow-up, in isolated copied
snapshots on Linux arm64:

- Clean GCC build and make check: 13 passed, 1 existing prerequisite
skips, 0 failures.
- ASan/UBSan: 12 passed, 2 existing privilege/allocator-interposition
prerequisite skips, 0 failures; no sanitizer findings.
- Production source/profile integrity verification against disposable
MariaDB and loopback SNMP fixtures passed.
- Release archive extracted into a separate source tree and built/tested
out of tree; fuzz checks passed.

Earlier local profiles retain source hashes for the copied validation
tree; GitHub checks validate the published commit. No production
database/device, host installation or live privilege change is part of
validation. Existing prerequisite skips were retained; no test or
finding was suppressed to obtain a pass.


Series navigation: [Series
index](https://github.com/Cacti/spine/pull/638); [previous
step](https://github.com/Cacti/spine/pull/638). [Next
step](https://github.com/Cacti/spine/pull/640).

Review follow-up (2026-10-09): Restacked on the corrected preceding
branches. The SNMP status fix is introduced in #638, the
reindex/completion fixes in #640, and focused regression coverage in
#650.

Restacked tip validation: isolated Linux arm64 build and make check
passed (18 passed, 1 setuid prerequisite skip, no failures).
GitHub checks validate the updated published commits; earlier
profile/archive/sanitizer evidence above belongs to the previous
revisions.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-10 21:08:52 -04:00

1155 lines
37 KiB
C

/* Production-linked PHP Script Server runtime tests.
*
* These use a real child, real close-on-exec pipes and the shipped php.c.
* They deliberately avoid a Cacti/PHP installation while covering the hot
* init -> command -> response -> close lifecycle and deterministic failures.
*/
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include <errno.h>
#include <limits.h>
#include <fcntl.h>
#include <signal.h>
#include <spawn.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <sys/resource.h>
#include <unistd.h>
#include <poll.h>
#include "common.h"
#include "spine.h"
#include "php.h"
static pthread_mutex_t spawn_barrier_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t spawn_barrier_cond = PTHREAD_COND_INITIALIZER;
static int block_shell_spawn;
static int block_php_spawn;
static int shell_spawn_reached;
static int release_shell_spawn;
static int fail_next_dup;
static int fail_duplicated_cloexec;
static int fail_next_cloexec;
static __thread int duplicated_fd = -1;
static int fail_next_setsigdefault;
static int track_spawnattr_destroy;
static int spawnattr_destroyed;
static int php_spawn_calls;
static int fail_php_spawn_call;
static int fail_next_sigmask;
static int track_write;
static int write_calls;
static struct rlimit saved_nofile;
static int restore_nofile;
static int duplicate_at_fdsetsize(int fd) {
struct rlimit limit;
struct rlimit raised;
int duplicate;
if (getrlimit(RLIMIT_NOFILE, &limit) == 0 && limit.rlim_cur <= FD_SETSIZE &&
limit.rlim_max > FD_SETSIZE) {
raised = limit;
raised.rlim_cur = FD_SETSIZE + 1;
if (raised.rlim_cur > raised.rlim_max)
raised.rlim_cur = raised.rlim_max;
if (setrlimit(RLIMIT_NOFILE, &raised) == 0) {
saved_nofile = limit;
restore_nofile = TRUE;
}
}
duplicate = fcntl(fd, F_DUPFD, FD_SETSIZE);
if (duplicate < FD_SETSIZE)
skip();
return duplicate;
}
int __real_pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);
int __wrap_pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset) {
if (fail_next_sigmask && how == SIG_BLOCK) {
fail_next_sigmask = FALSE;
return EAGAIN;
}
return __real_pthread_sigmask(how, set, oldset);
}
ssize_t __real_write(int fd, const void *buffer, size_t length);
ssize_t __wrap_write(int fd, const void *buffer, size_t length) {
if (track_write) write_calls++;
return __real_write(fd, buffer, length);
}
int __real_dup(int fd);
int __wrap_dup(int fd) {
int result;
if (fail_next_dup) {
fail_next_dup = FALSE;
errno = EMFILE;
return -1;
}
result = __real_dup(fd);
duplicated_fd = result;
return result;
}
int __real_fcntl(int fd, int command, ...);
int __wrap_fcntl(int fd, int command, ...) {
va_list args;
int argument;
if ((fail_next_cloexec || (fail_duplicated_cloexec && fd == duplicated_fd)) &&
command == F_SETFD) {
fail_next_cloexec = FALSE;
fail_duplicated_cloexec = FALSE;
errno = EIO;
return -1;
}
if (command == F_GETFD) return __real_fcntl(fd, command);
va_start(args, command);
argument = va_arg(args, int);
va_end(args);
return __real_fcntl(fd, command, argument);
}
int __real_posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *actions,
const posix_spawnattr_t *attributes,
char *const argv[], char *const envp[]);
int __wrap_posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *actions,
const posix_spawnattr_t *attributes,
char *const argv[], char *const envp[]) {
if (strcmp(path, PHP_TEST_SERVER_PATH) == 0) {
php_spawn_calls++;
if (php_spawn_calls == fail_php_spawn_call) return EIO;
}
if ((block_shell_spawn && strcmp(path, "/bin/sh") == 0) ||
(block_php_spawn && strcmp(path, PHP_TEST_SERVER_PATH) == 0)) {
pthread_mutex_lock(&spawn_barrier_mutex);
shell_spawn_reached = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
while (!release_shell_spawn) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
}
return __real_posix_spawn(pid, path, actions, attributes, argv, envp);
}
int __real_posix_spawnattr_setsigdefault(posix_spawnattr_t *attr, const sigset_t *defaults);
int __wrap_posix_spawnattr_setsigdefault(posix_spawnattr_t *attr, const sigset_t *defaults) {
if (fail_next_setsigdefault) {
fail_next_setsigdefault = FALSE;
return EIO;
}
return __real_posix_spawnattr_setsigdefault(attr, defaults);
}
int __real_posix_spawnattr_destroy(posix_spawnattr_t *attr);
int __wrap_posix_spawnattr_destroy(posix_spawnattr_t *attr) {
if (track_spawnattr_destroy) spawnattr_destroyed = TRUE;
return __real_posix_spawnattr_destroy(attr);
}
static int php_setup(void **state) {
(void) state;
memset(&set, 0, sizeof(set));
init_mutexes();
signal(SIGPIPE, SIG_IGN);
php_processes = calloc(MAX_PHP_SERVERS, sizeof(*php_processes));
assert_non_null(php_processes);
php_processes_initialize(php_processes, MAX_PHP_SERVERS);
set.php.php_servers = 2;
set.php.script_timeout = 1;
set.cacti_version = 1300;
set.poller.poller_id = 1;
set.logging.log_destination = LOGDEST_STDOUT;
block_shell_spawn = FALSE;
block_php_spawn = FALSE;
shell_spawn_reached = FALSE;
release_shell_spawn = FALSE;
fail_next_dup = FALSE;
fail_duplicated_cloexec = FALSE;
fail_next_cloexec = FALSE;
duplicated_fd = -1;
fail_next_setsigdefault = FALSE;
track_spawnattr_destroy = FALSE;
spawnattr_destroyed = FALSE;
php_spawn_calls = 0;
fail_php_spawn_call = 0;
fail_next_sigmask = FALSE;
track_write = FALSE;
write_calls = 0;
restore_nofile = FALSE;
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", PHP_TEST_SERVER_PATH);
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "normal");
return 0;
}
static int php_teardown(void **state) {
int i;
(void) state;
if (php_processes != NULL) {
for (i = 0; i < MAX_PHP_SERVERS; i++) {
if (php_processes[i].php_pid > 1) {
php_close(i);
} else {
if (php_processes[i].php_read_fd > STDERR_FILENO)
close(php_processes[i].php_read_fd);
if (php_processes[i].php_write_fd > STDERR_FILENO &&
php_processes[i].php_write_fd != php_processes[i].php_read_fd)
close(php_processes[i].php_write_fd);
}
}
free(php_processes);
php_processes = NULL;
}
if (restore_nofile) {
(void)setrlimit(RLIMIT_NOFILE, &saved_nofile);
restore_nofile = FALSE;
}
return 0;
}
static void test_round_robin_wraps_at_server_count(void **state) {
(void) state;
php_processes[0].php_state = PHP_READY;
php_processes[0].php_pid = 10;
php_processes[0].php_read_fd = 10;
php_processes[0].php_write_fd = 11;
php_processes[1].php_state = PHP_READY;
php_processes[1].php_pid = 12;
php_processes[1].php_read_fd = 12;
php_processes[1].php_write_fd = 13;
set.php.php_current_server = set.php.php_servers;
assert_int_equal(php_get_process(), 0);
assert_int_equal(php_get_process(), 1);
assert_int_equal(php_get_process(), 0);
php_processes[0].php_pid = php_processes[0].php_read_fd = php_processes[0].php_write_fd = -1;
php_processes[1].php_pid = php_processes[1].php_read_fd = php_processes[1].php_write_fd = -1;
}
static void test_process_slot_initialization_invalidates_zero_descriptors(void **state) {
php_t slots[3] = {{0}};
int i;
(void) state;
php_processes_initialize(slots, 3);
for (i = 0; i < 3; i++) {
assert_int_equal(slots[i].php_state, PHP_BUSY);
assert_int_equal(slots[i].php_pid, -1);
assert_int_equal(slots[i].php_read_fd, -1);
assert_int_equal(slots[i].php_write_fd, -1);
}
}
static void test_round_robin_rejects_failed_slots(void **state) {
(void) state;
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
assert_int_equal(php_get_process(), -1);
}
static void test_round_robin_returns_a_healthy_slot_when_all_are_contended(void **state) {
int i;
(void) state;
for (i = 0; i < set.php.php_servers; i++) {
php_processes[i].php_state = PHP_READY;
php_processes[i].php_pid = 100 + i;
php_processes[i].php_read_fd = 20 + (i * 2);
php_processes[i].php_write_fd = 21 + (i * 2);
thread_mutex_lock(LOCK_PHP_PROC_0 + i);
}
set.php.php_current_server = 0;
assert_int_equal(php_get_process(), 0);
for (i = 0; i < set.php.php_servers; i++) {
thread_mutex_unlock(LOCK_PHP_PROC_0 + i);
php_processes[i].php_pid = -1;
php_processes[i].php_read_fd = -1;
php_processes[i].php_write_fd = -1;
}
}
static void *hold_php_slot(void *arg) {
int slot = *(int *)arg;
thread_mutex_lock(LOCK_PHP_PROC_0 + slot);
pthread_mutex_lock(&spawn_barrier_mutex);
shell_spawn_reached = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
while (!release_shell_spawn) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
thread_mutex_unlock(LOCK_PHP_PROC_0 + slot);
return NULL;
}
static void test_dead_slot_is_recovered_while_another_slot_is_contended(void **state) {
pthread_t holder;
int held_slot = 0;
int selected;
(void) state;
php_processes[0].php_state = PHP_READY;
php_processes[0].php_pid = 100;
php_processes[0].php_read_fd = 20;
php_processes[0].php_write_fd = 21;
set.php.php_current_server = 0;
assert_int_equal(pthread_create(&holder, NULL, hold_php_slot, &held_slot), 0);
pthread_mutex_lock(&spawn_barrier_mutex);
while (!shell_spawn_reached) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
selected = php_get_process();
assert_int_equal(selected, 1);
assert_int_equal(php_processes[1].php_state, PHP_READY);
pthread_mutex_lock(&spawn_barrier_mutex);
release_shell_spawn = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
pthread_mutex_unlock(&spawn_barrier_mutex);
assert_int_equal(pthread_join(holder, NULL), 0);
php_processes[0].php_pid = -1;
php_processes[0].php_read_fd = -1;
php_processes[0].php_write_fd = -1;
}
static void test_failed_recovery_falls_back_to_the_contended_slot(void **state) {
pthread_t holder;
int held_slot = 0;
int selected;
(void) state;
php_processes[0].php_state = PHP_READY;
php_processes[0].php_pid = 100;
php_processes[0].php_read_fd = 20;
php_processes[0].php_write_fd = 21;
set.php.php_current_server = 0;
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
assert_int_equal(pthread_create(&holder, NULL, hold_php_slot, &held_slot), 0);
pthread_mutex_lock(&spawn_barrier_mutex);
while (!shell_spawn_reached) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
selected = php_get_process();
assert_int_equal(selected, 0);
pthread_mutex_lock(&spawn_barrier_mutex);
release_shell_spawn = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
pthread_mutex_unlock(&spawn_barrier_mutex);
assert_int_equal(pthread_join(holder, NULL), 0);
php_processes[0].php_pid = -1;
php_processes[0].php_read_fd = -1;
php_processes[0].php_write_fd = -1;
}
static void noop_sigpipe_handler(int signal_number) {
(void) signal_number;
}
static void test_broken_pipe_with_runtime_sigpipe_handler_does_not_block(void **state) {
struct sigaction action;
int pdes[2];
pid_t child;
int status;
int attempts;
(void) state;
assert_int_equal(pipe(pdes), 0);
close(pdes[0]);
child = fork();
assert_true(child >= 0);
if (child == 0) {
memset(&action, 0, sizeof(action));
action.sa_handler = noop_sigpipe_handler;
sigemptyset(&action.sa_mask);
if (sigaction(SIGPIPE, &action, NULL) != 0) _exit(2);
errno = 0;
if (php_write_no_sigpipe_for_test(pdes[1], "x", 1) != -1 || errno != EPIPE)
_exit(3);
_exit(0);
}
close(pdes[1]);
for (attempts = 0; attempts < 100; attempts++) {
pid_t waited = waitpid(child, &status, WNOHANG);
if (waited == child) break;
assert_true(waited == 0 || (waited < 0 && errno == EINTR));
usleep(10000);
}
if (attempts == 100) {
kill(child, SIGKILL);
waitpid(child, &status, 0);
fail_msg("SIGPIPE-protected write blocked for more than one second");
}
assert_true(WIFEXITED(status));
assert_int_equal(WEXITSTATUS(status), 0);
}
static void test_sigmask_failure_prevents_the_pipe_write(void **state) {
(void) state;
fail_next_sigmask = TRUE;
track_write = TRUE;
errno = 0;
assert_int_equal(php_write_no_sigpipe_for_test(-1, "x", 1), -1);
assert_int_equal(errno, EAGAIN);
assert_int_equal(write_calls, 0);
track_write = FALSE;
}
static void test_preexisting_pending_sigpipe_is_preserved(void **state) {
struct sigaction saved_action;
struct sigaction default_action;
sigset_t blocked;
sigset_t old_mask;
sigset_t pending;
int pdes[2];
int received_signal = 0;
int result;
int saved_errno;
int pending_before;
int pending_after;
(void) state;
assert_int_equal(sigaction(SIGPIPE, NULL, &saved_action), 0);
sigemptyset(&blocked);
sigaddset(&blocked, SIGPIPE);
assert_int_equal(pthread_sigmask(SIG_BLOCK, &blocked, &old_mask), 0);
memset(&default_action, 0, sizeof(default_action));
default_action.sa_handler = SIG_DFL;
sigemptyset(&default_action.sa_mask);
assert_int_equal(sigaction(SIGPIPE, &default_action, NULL), 0);
assert_int_equal(raise(SIGPIPE), 0);
assert_int_equal(sigpending(&pending), 0);
pending_before = sigismember(&pending, SIGPIPE);
assert_int_equal(pipe(pdes), 0);
close(pdes[0]);
errno = 0;
result = (int)php_write_no_sigpipe_for_test(pdes[1], "x", 1);
saved_errno = errno;
assert_int_equal(sigpending(&pending), 0);
pending_after = sigismember(&pending, SIGPIPE);
close(pdes[1]);
assert_int_equal(sigwait(&blocked, &received_signal), 0);
assert_int_equal(sigaction(SIGPIPE, &saved_action, NULL), 0);
assert_int_equal(pthread_sigmask(SIG_SETMASK, &old_mask, NULL), 0);
assert_true(pending_before);
assert_int_equal(result, -1);
assert_int_equal(saved_errno, EPIPE);
assert_true(pending_after);
assert_int_equal(received_signal, SIGPIPE);
}
static void test_spawnattr_sigpipe_failure_destroys_initialized_attr(void **state) {
posix_spawnattr_t attr;
(void) state;
fail_next_setsigdefault = TRUE;
track_spawnattr_destroy = TRUE;
errno = 0;
assert_int_equal(spine_spawnattr_sigpipe_default(&attr), -1);
assert_int_equal(errno, EIO);
assert_true(spawnattr_destroyed);
track_spawnattr_destroy = FALSE;
}
static void test_full_script_server_lifecycle(void **state) {
char *result;
(void) state;
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
assert_true(php_processes[0].php_pid > 1);
assert_true(php_processes[0].php_read_fd >= 0);
assert_true(php_processes[0].php_write_fd >= 0);
result = php_cmd("poll 7", 0);
assert_non_null(result);
assert_string_equal(result, "42\n");
free(result);
php_close(0);
assert_int_equal(php_processes[0].php_pid, -1);
assert_int_equal(php_processes[0].php_read_fd, -1);
assert_int_equal(php_processes[0].php_write_fd, -1);
}
static void test_legacy_cacti_version_uses_positional_poller_id(void **state) {
(void) state;
set.cacti_version = 1222;
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
php_close(0);
}
static void test_remote_poller_id_uses_environ_argument(void **state) {
(void) state;
set.poller.poller_id = 2;
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
php_close(0);
}
static void test_php_child_restores_sigpipe_default(void **state) {
(void) state;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "check-sigpipe");
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_READY);
}
static void test_script_server_lifecycle_with_stdio_closed(void **state) {
int saved_stdin;
int saved_stdout;
int init_result;
char *result = NULL;
(void) state;
saved_stdin = dup(STDIN_FILENO);
saved_stdout = dup(STDOUT_FILENO);
assert_true(saved_stdin >= 0);
assert_true(saved_stdout >= 0);
close(STDIN_FILENO);
close(STDOUT_FILENO);
init_result = php_init(0);
if (init_result == TRUE) {
result = php_cmd("poll 7", 0);
php_close(0);
}
dup2(saved_stdin, STDIN_FILENO);
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdin);
close(saved_stdout);
assert_int_equal(init_result, TRUE);
assert_non_null(result);
assert_string_equal(result, "42\n");
free(result);
}
struct popen_thread_result {
int fd;
};
static void *open_script_while_blocked(void *arg) {
struct popen_thread_result *result = arg;
result->fd = nft_popen("printf concurrent-visible", "r");
return NULL;
}
static void test_php_spawn_does_not_inherit_an_nft_collision_descriptor(void **state) {
struct popen_thread_result opened = { .fd = -1 };
pthread_t thread;
struct pollfd pfd;
char output[128] = {0};
ssize_t n;
int total = 0;
int reached_eof = FALSE;
int saved_stdin;
int saved_stdout;
int init_result;
(void) state;
saved_stdin = dup(STDIN_FILENO);
saved_stdout = dup(STDOUT_FILENO);
assert_true(saved_stdin >= 0);
assert_true(saved_stdout >= 0);
close(STDIN_FILENO);
close(STDOUT_FILENO);
block_shell_spawn = TRUE;
assert_int_equal(pthread_create(&thread, NULL, open_script_while_blocked, &opened), 0);
pthread_mutex_lock(&spawn_barrier_mutex);
while (!shell_spawn_reached) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
/* The nft_popen collision duplicate exists but has not spawned yet. A PHP
* child started in this window must not inherit that pipe write end. */
init_result = php_init(0);
pthread_mutex_lock(&spawn_barrier_mutex);
release_shell_spawn = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
pthread_mutex_unlock(&spawn_barrier_mutex);
assert_int_equal(pthread_join(thread, NULL), 0);
if (opened.fd >= 0) {
pfd.fd = opened.fd;
pfd.events = POLLIN;
while (poll(&pfd, 1, 2000) > 0) {
n = read(opened.fd, output + total, sizeof(output) - 1 - (size_t)total);
if (n == 0) {
reached_eof = TRUE;
break;
}
if (n < 0) break;
total += (int)n;
if ((size_t)total >= sizeof(output) - 1) break;
}
output[total] = '\0';
}
if (init_result == TRUE) php_close(0);
if (opened.fd >= 0) nft_pclose(opened.fd);
dup2(saved_stdin, STDIN_FILENO);
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdin);
close(saved_stdout);
assert_int_equal(init_result, TRUE);
assert_true(opened.fd >= 0);
assert_non_null(strstr(output, "concurrent-visible"));
assert_true(reached_eof);
}
static void test_init_marks_an_unexpected_handshake_busy(void **state) {
(void) state;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "bad-start");
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
}
static void test_busy_handshake_is_recovered_on_the_next_poll(void **state) {
char *result;
int process;
pid_t failed_pid;
(void) state;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "bad-start");
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
failed_pid = php_processes[0].php_pid;
assert_true(failed_pid > 1);
assert_true(php_processes[0].php_read_fd >= 0);
assert_true(php_processes[0].php_write_fd >= 0);
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "normal");
process = php_get_process();
assert_int_equal(process, 0);
assert_true(php_processes[0].php_pid > 1);
assert_int_not_equal(php_processes[0].php_pid, failed_pid);
errno = 0;
assert_int_equal(waitpid(failed_pid, NULL, WNOHANG), -1);
assert_int_equal(errno, ECHILD);
result = php_cmd("poll 7", process);
assert_non_null(result);
assert_string_equal(result, "42\n");
free(result);
}
struct php_thread_result {
int process;
char *command_result;
};
static int command_finished;
static void *get_php_process_thread(void *arg) {
struct php_thread_result *result = arg;
result->process = php_get_process();
return NULL;
}
static void *run_php_command_thread(void *arg) {
struct php_thread_result *result = arg;
result->command_result = php_cmd("poll 7", 0);
pthread_mutex_lock(&spawn_barrier_mutex);
command_finished = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
pthread_mutex_unlock(&spawn_barrier_mutex);
return NULL;
}
static void test_recovery_and_command_share_the_slot_lock(void **state) {
struct php_thread_result recovery = { .process = -1, .command_result = NULL };
struct php_thread_result command = { .process = -1, .command_result = NULL };
pthread_t recovery_thread;
pthread_t command_thread;
(void) state;
set.php.php_servers = 1;
block_php_spawn = TRUE;
command_finished = FALSE;
assert_int_equal(pthread_create(&recovery_thread, NULL, get_php_process_thread, &recovery), 0);
pthread_mutex_lock(&spawn_barrier_mutex);
while (!shell_spawn_reached) {
pthread_cond_wait(&spawn_barrier_cond, &spawn_barrier_mutex);
}
pthread_mutex_unlock(&spawn_barrier_mutex);
/* A blocked handshake must not retain the process-global round-robin lock. */
assert_int_equal(thread_mutex_trylock(LOCK_PHP), 0);
thread_mutex_unlock(LOCK_PHP);
assert_int_equal(pthread_create(&command_thread, NULL, run_php_command_thread, &command), 0);
usleep(20000);
pthread_mutex_lock(&spawn_barrier_mutex);
assert_false(command_finished);
release_shell_spawn = TRUE;
pthread_cond_broadcast(&spawn_barrier_cond);
pthread_mutex_unlock(&spawn_barrier_mutex);
assert_int_equal(pthread_join(recovery_thread, NULL), 0);
assert_int_equal(pthread_join(command_thread, NULL), 0);
assert_int_equal(recovery.process, 0);
assert_non_null(command.command_result);
assert_string_equal(command.command_result, "42\n");
free(command.command_result);
}
static void test_recovery_attempts_only_one_failed_slot_per_call(void **state) {
double started;
double elapsed;
(void) state;
set.php.php_servers = 3;
set.php.script_timeout = 1;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "silent");
started = get_time_as_double();
assert_int_equal(php_get_process(), -1);
elapsed = get_time_as_double() - started;
assert_true(elapsed < 2.5);
}
static void test_child_exit_during_startup_fails_closed_without_restart(void **state) {
(void) state;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "exit-before-start");
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
}
static void test_php_init_later_failure_preserves_earlier_server(void **state) {
pid_t first_pid;
int first_read_fd;
int first_write_fd;
(void) state;
set.php.php_servers = 2;
fail_php_spawn_call = 2;
assert_int_equal(php_init(PHP_INIT), FALSE);
first_pid = php_processes[0].php_pid;
first_read_fd = php_processes[0].php_read_fd;
first_write_fd = php_processes[0].php_write_fd;
assert_true(first_pid > 1);
assert_true(first_read_fd >= 0);
assert_true(first_write_fd >= 0);
assert_int_equal(php_processes[0].php_state, PHP_READY);
assert_int_equal(php_processes[0].php_pid, first_pid);
assert_int_equal(php_processes[0].php_read_fd, first_read_fd);
assert_int_equal(php_processes[0].php_write_fd, first_write_fd);
assert_int_equal(php_processes[1].php_pid, -1);
assert_int_equal(php_processes[1].php_read_fd, -1);
assert_int_equal(php_processes[1].php_write_fd, -1);
assert_int_equal(php_processes[1].php_state, PHP_BUSY);
}
static void assert_stdio_collision_failure_is_clean(int fail_dup, int fail_cloexec) {
int saved_stdin = dup(STDIN_FILENO);
int result;
assert_true(saved_stdin >= 0);
close(STDIN_FILENO);
fail_next_dup = fail_dup;
fail_duplicated_cloexec = fail_cloexec;
result = php_init(0);
dup2(saved_stdin, STDIN_FILENO);
close(saved_stdin);
assert_int_equal(result, FALSE);
assert_int_equal(php_processes[0].php_pid, -1);
assert_int_equal(php_processes[0].php_read_fd, -1);
assert_int_equal(php_processes[0].php_write_fd, -1);
}
static void test_stdio_collision_dup_failure_is_clean(void **state) {
(void) state;
assert_stdio_collision_failure_is_clean(TRUE, FALSE);
}
static void test_stdio_collision_cloexec_failure_is_clean(void **state) {
(void) state;
assert_stdio_collision_failure_is_clean(FALSE, TRUE);
}
static void assert_nft_collision_failure_is_clean(const char *type, int fail_dup, int fail_cloexec) {
int saved_stdin = dup(STDIN_FILENO);
int saved_stdout = dup(STDOUT_FILENO);
int result;
assert_true(saved_stdin >= 0);
assert_true(saved_stdout >= 0);
close(STDIN_FILENO);
close(STDOUT_FILENO);
fail_next_dup = fail_dup;
fail_duplicated_cloexec = fail_cloexec;
result = nft_popen("exit 0", type);
dup2(saved_stdin, STDIN_FILENO);
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdin);
close(saved_stdout);
assert_int_equal(result, -1);
}
static void test_nft_read_collision_cloexec_failure_is_clean(void **state) {
(void) state;
assert_nft_collision_failure_is_clean("r", FALSE, TRUE);
}
static void test_nft_write_collision_cloexec_failure_is_clean(void **state) {
(void) state;
assert_nft_collision_failure_is_clean("w", FALSE, TRUE);
}
static void test_cloexec_pipe_failure_releases_both_descriptors(void **state) {
int pdes[2] = {-2, -2};
int first_reused;
int second_reused;
(void) state;
fail_next_cloexec = TRUE;
assert_false(spine_open_pipe_cloexec(pdes));
assert_int_equal(pdes[0], -1);
assert_int_equal(pdes[1], -1);
first_reused = open("/dev/null", O_RDONLY);
second_reused = open("/dev/null", O_RDONLY);
assert_true(first_reused >= 0);
assert_true(second_reused >= 0);
close(first_reused);
close(second_reused);
}
static void test_init_timeout_does_not_recurse(void **state) {
(void) state;
set.php.script_timeout = 0;
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "silent");
assert_int_equal(php_init(0), TRUE);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
}
static void test_spawn_failure_releases_every_resource(void **state) {
(void) state;
/* calloc-like zeroes reproduce the process-wide initialization that used to
* let a failed slot masquerade as stdin. php_init() must replace them. */
memset(&php_processes[0], 0, sizeof(php_processes[0]));
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
assert_int_equal(php_init(0), FALSE);
assert_int_equal(php_processes[0].php_pid, -1);
assert_int_equal(php_processes[0].php_read_fd, -1);
assert_int_equal(php_processes[0].php_write_fd, -1);
}
static void test_command_rejects_a_writable_poisoned_slot(void **state) {
char *result;
int fd;
(void) state;
fd = open("/dev/null", O_RDWR);
assert_true(fd >= 0);
memset(&php_processes[0], 0, sizeof(php_processes[0]));
php_processes[0].php_write_fd = fd;
result = php_cmd("poll 9", 0);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
close(fd);
php_processes[0].php_read_fd = php_processes[0].php_write_fd = -1;
}
static void assert_invalid_reader_result(int slot) {
char command[] = "test";
char *result;
result = php_readpipe(slot, command);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
result = php_read_result_for_test(slot, command, FALSE);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
}
static void test_readpipe_rejects_invalid_slots(void **state) {
php_t *owned_processes = php_processes;
int invalid_slots[] = {-1, 2, MAX_PHP_SERVERS, INT_MAX};
size_t index;
(void) state;
track_write = TRUE;
php_processes = NULL;
assert_invalid_reader_result(0);
php_processes = owned_processes;
for (index = 0; index < sizeof(invalid_slots) / sizeof(invalid_slots[0]); index++) {
assert_invalid_reader_result(invalid_slots[index]);
}
set.php.php_servers = 0;
assert_invalid_reader_result(0);
set.php.php_servers = -1;
assert_invalid_reader_result(0);
/* An invalid configured count cannot authorize an out-of-array slot. */
set.php.php_servers = MAX_PHP_SERVERS + 1;
assert_invalid_reader_result(MAX_PHP_SERVERS);
assert_int_equal(php_spawn_calls, 0);
assert_int_equal(write_calls, 0);
}
static void test_readpipe_rejects_fd_at_fd_setsize(void **state) {
char command[] = "test";
char *result;
int oversized_fd;
int pdes[2];
(void) state;
assert_int_equal(pipe(pdes), 0);
oversized_fd = duplicate_at_fdsetsize(pdes[0]);
close(pdes[0]);
close(pdes[1]);
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
php_processes[0].php_state = PHP_READY;
php_processes[0].php_read_fd = oversized_fd;
result = php_readpipe(0, command);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_not_equal(php_processes[0].php_state, PHP_READY);
assert_int_equal(php_processes[0].php_read_fd, -1);
assert_int_equal(php_get_process(), -1);
}
static void test_startup_read_rejects_fd_at_fd_setsize_without_restart(void **state) {
char command[] = "INIT";
char *result;
int oversized_fd;
int pdes[2];
(void) state;
assert_int_equal(pipe(pdes), 0);
oversized_fd = duplicate_at_fdsetsize(pdes[0]);
close(pdes[0]);
close(pdes[1]);
php_processes[0].php_state = PHP_READY;
php_processes[0].php_read_fd = oversized_fd;
php_processes[0].php_write_fd = -1;
result = php_read_result_for_test(0, command, FALSE);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
assert_int_equal(php_processes[0].php_read_fd, oversized_fd);
assert_int_equal(php_spawn_calls, 0);
close(oversized_fd);
php_processes[0].php_read_fd = -1;
}
static void test_command_retires_fd_at_fd_setsize(void **state) {
char *result;
int oversized_fd;
int pdes[2];
(void) state;
assert_int_equal(pipe(pdes), 0);
oversized_fd = duplicate_at_fdsetsize(pdes[0]);
close(pdes[0]);
php_processes[0].php_pid = fork();
assert_true(php_processes[0].php_pid >= 0);
if (php_processes[0].php_pid == 0) {
pause();
_exit(0);
}
set.php.php_servers = 1;
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
php_processes[0].php_state = PHP_READY;
php_processes[0].php_read_fd = oversized_fd;
php_processes[0].php_write_fd = pdes[1];
result = php_cmd("poll 9", 0);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_not_equal(php_processes[0].php_state, PHP_READY);
assert_int_equal(php_processes[0].php_pid, -1);
assert_int_equal(php_processes[0].php_read_fd, -1);
assert_int_equal(php_processes[0].php_write_fd, -1);
assert_int_equal(php_get_process(), -1);
}
static void test_readpipe_rejects_an_oversized_response(void **state) {
char payload[RESULTS_BUFFER];
char command[] = "test";
char *result;
int pdes[2];
(void) state;
memset(payload, 'x', sizeof(payload));
assert_int_equal(pipe(pdes), 0);
assert_int_equal(write(pdes[1], payload, sizeof(payload)), (ssize_t)sizeof(payload));
close(pdes[1]);
php_processes[0].php_read_fd = pdes[0];
result = php_readpipe(0, command);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_equal(php_processes[0].php_state, PHP_READY);
assert_true(php_processes[0].php_pid > 1);
result = php_cmd("poll 7", 0);
assert_non_null(result);
assert_string_equal(result, "42\n");
free(result);
}
static void prepare_broken_ready_slot(void) {
int pdes[2];
assert_int_equal(pipe(pdes), 0);
close(pdes[0]);
php_processes[0].php_pid = fork();
assert_true(php_processes[0].php_pid >= 0);
if (php_processes[0].php_pid == 0) {
pause();
_exit(0);
}
php_processes[0].php_state = PHP_READY;
php_processes[0].php_read_fd = open("/dev/null", O_RDONLY);
assert_true(php_processes[0].php_read_fd >= 0);
php_processes[0].php_write_fd = pdes[1];
track_write = TRUE;
}
static void test_failed_write_stops_when_restart_handshake_is_not_ready(void **state) {
char *result;
(void) state;
prepare_broken_ready_slot();
snprintf(set.php.path_php_server, sizeof(set.php.path_php_server), "%s", "bad-start");
result = php_cmd("poll 9", 0);
track_write = FALSE;
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_equal(php_spawn_calls, 1);
assert_int_equal(write_calls, 2);
assert_int_equal(php_processes[0].php_state, PHP_BUSY);
}
static void test_failed_write_stops_when_restart_spawn_fails(void **state) {
char *result;
(void) state;
prepare_broken_ready_slot();
fail_php_spawn_call = 1;
result = php_cmd("poll 9", 0);
track_write = FALSE;
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
assert_int_equal(php_spawn_calls, 1);
assert_int_equal(write_calls, 2);
assert_int_equal(php_processes[0].php_pid, -1);
}
static void test_command_gives_up_after_three_failed_writes(void **state) {
struct sigaction saved_sigpipe;
struct sigaction default_sigpipe;
char *result;
int pdes[2];
(void) state;
assert_int_equal(pipe(pdes), 0);
close(pdes[0]);
php_processes[0].php_state = PHP_READY;
php_processes[0].php_pid = fork();
assert_true(php_processes[0].php_pid >= 0);
if (php_processes[0].php_pid == 0) {
pause();
_exit(0);
}
php_processes[0].php_read_fd = pdes[0];
php_processes[0].php_write_fd = pdes[1];
snprintf(set.php.path_php, sizeof(set.php.path_php), "%s", "/does/not/exist/spine-php-test");
assert_int_equal(sigaction(SIGPIPE, NULL, &saved_sigpipe), 0);
memset(&default_sigpipe, 0, sizeof(default_sigpipe));
default_sigpipe.sa_handler = SIG_DFL;
assert_int_equal(sigaction(SIGPIPE, &default_sigpipe, NULL), 0);
result = php_cmd("poll 9", 0);
sigaction(SIGPIPE, &saved_sigpipe, NULL);
assert_non_null(result);
assert_string_equal(result, "U");
free(result);
}
int main(void) {
const struct CMUnitTest tests[] = {
cmocka_unit_test_setup_teardown(test_round_robin_wraps_at_server_count, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_process_slot_initialization_invalidates_zero_descriptors, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_round_robin_rejects_failed_slots, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_round_robin_returns_a_healthy_slot_when_all_are_contended, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_dead_slot_is_recovered_while_another_slot_is_contended, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_failed_recovery_falls_back_to_the_contended_slot, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_broken_pipe_with_runtime_sigpipe_handler_does_not_block, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_sigmask_failure_prevents_the_pipe_write, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_preexisting_pending_sigpipe_is_preserved, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_spawnattr_sigpipe_failure_destroys_initialized_attr, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_full_script_server_lifecycle, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_legacy_cacti_version_uses_positional_poller_id, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_remote_poller_id_uses_environ_argument, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_php_child_restores_sigpipe_default, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_script_server_lifecycle_with_stdio_closed, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_php_spawn_does_not_inherit_an_nft_collision_descriptor, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_init_marks_an_unexpected_handshake_busy, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_busy_handshake_is_recovered_on_the_next_poll, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_recovery_and_command_share_the_slot_lock, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_recovery_attempts_only_one_failed_slot_per_call, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_child_exit_during_startup_fails_closed_without_restart, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_php_init_later_failure_preserves_earlier_server, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_stdio_collision_dup_failure_is_clean, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_stdio_collision_cloexec_failure_is_clean, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_nft_read_collision_cloexec_failure_is_clean, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_nft_write_collision_cloexec_failure_is_clean, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_cloexec_pipe_failure_releases_both_descriptors, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_init_timeout_does_not_recurse, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_spawn_failure_releases_every_resource, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_command_rejects_a_writable_poisoned_slot, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_readpipe_rejects_invalid_slots, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_readpipe_rejects_fd_at_fd_setsize, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_startup_read_rejects_fd_at_fd_setsize_without_restart, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_command_retires_fd_at_fd_setsize, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_readpipe_rejects_an_oversized_response, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_failed_write_stops_when_restart_handshake_is_not_ready, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_failed_write_stops_when_restart_spawn_fails, php_setup, php_teardown),
cmocka_unit_test_setup_teardown(test_command_gives_up_after_three_failed_writes, php_setup, php_teardown),
};
return cmocka_run_group_tests(tests, NULL, NULL);
}