Files
spine/tests/unit/test_linked.c
Thomas Vincent 9be5f8ad2e fix(results): [develop] normalize output and unify SNMP batch persistence (#641)
Classify numeric, undefined, multipart and Cacti octet results through
one parser, normalize undefined agent values before persistence and
flush every SNMP batch consistently. Validate whole non-negative
uptimes, retain unknown reindex baselines and free script results
through their owning pointer. The mocked poll_host suite checks
session-profile boundaries, spike handling, output_regex input and
completion writes.

Originally step 4/14 of the C17/native-correctness series; its preceding
steps have merged. The current base is develop.

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

- Clean GCC build and make check: 15 passed, 1 existing prerequisite
skips, 0 failures.
- ASan/UBSan: 14 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.



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.

## Current merge dependencies

Current base: `develop`. Immediate dependency: `develop` (earlier steps
already merged). Next open PR:
[#644](https://github.com/Cacti/spine/pull/644).

Merge order: #641 → #644 → #654 → #646 → #648 → #649 → #650 → #651 →
#655 → #658 → #659.

This repository uses squash-only merges and deletes merged branches.
Record the old parent SHA, squash one PR at a time, then restack and
retarget dependents before merging the next PR. Re-run merge validation
and checks on the resulting heads; an ordinary clean merge does not
replace squash/restack validation.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-12 18:39:39 -04:00

1132 lines
33 KiB
C

/* Unit tests that exercise the shipped objects.
*
* The other test binaries in this directory inline the function under test, or
* replicate its predicate, so they compile standalone. That keeps them cheap
* but means a fix can land in util.c while the test still passes against the
* old copy. This binary links the real translation units instead, with
* tests/fuzz/stubs.c supplying the globals that spine.c would otherwise define,
* so what runs here is what ships.
*/
#include <stdarg.h>
#include <stddef.h>
#include <setjmp.h>
#include <cmocka.h>
#include <string.h>
#include <stdlib.h>
#include <limits.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include "common.h"
#include "spine.h"
#include "util.h"
#include "ping.h"
#include "poller.h"
#include "nft_popen.h"
#include <fcntl.h>
#if !defined(ICMP_DEST_UNREACH) && defined(ICMP_UNREACH)
#define ICMP_DEST_UNREACH ICMP_UNREACH
#endif
/* provided by tests/fuzz/stubs.c, as spine.c would */
extern int *debug_devices;
#ifdef SPINE_TEST_WRAP_WAITPID
/* Arms __wrap_waitpid() to fail every call for one pid; -1/unset disables it
* so every other test in this binary keeps hitting the real waitpid(). */
static pid_t forced_waitpid_error_pid = -1;
static int forced_waitpid_error_errno = 0;
extern pid_t __real_waitpid(pid_t pid, int *status, int options);
pid_t __wrap_waitpid(pid_t pid, int *status, int options) {
if (pid == forced_waitpid_error_pid) {
errno = forced_waitpid_error_errno;
return -1;
}
return __real_waitpid(pid, status, options);
}
#endif
/* --- strncopy(): issue#447, the off-by-one when src fills the destination -- */
static void test_strncopy_truncates_within_the_buffer(void **state) {
struct { char dst[8]; unsigned char canary; } b;
(void) state;
memset(&b, 0xAA, sizeof b);
strncopy(b.dst, "ABCDEFGHIJK", sizeof b.dst);
assert_string_equal(b.dst, "ABCDEFG");
assert_int_equal(strlen(b.dst), sizeof b.dst - 1);
assert_int_equal(b.canary, 0xAA);
}
static void test_strncopy_copies_a_short_source_whole(void **state) {
char dst[16];
(void) state;
memset(dst, 0xAA, sizeof dst);
strncopy(dst, "abc", sizeof dst);
assert_string_equal(dst, "abc");
}
static void test_strncopy_handles_a_zero_size(void **state) {
char dst[4] = {'z', 'z', 'z', '\0'};
(void) state;
strncopy(dst, "abc", 0);
assert_string_equal(dst, "zzz");
}
static void test_strncopy_terminates_an_exact_fit(void **state) {
struct { char dst[4]; unsigned char canary; } b;
(void) state;
memset(&b, 0xAA, sizeof b);
strncopy(b.dst, "abc", sizeof b.dst);
assert_string_equal(b.dst, "abc");
assert_int_equal(b.canary, 0xAA);
}
/* --- regex_replace(): returns the match, or the input when it cannot ------- */
static void test_regex_replace_returns_the_match(void **state) {
(void) state;
assert_string_equal(regex_replace("[0-9][0-9]*", "load 42 avg"), "42");
assert_string_equal(regex_replace("\\([0-9][0-9]*\\)", "value 42"), "42");
}
static void test_regex_replace_passes_through_on_no_match(void **state) {
(void) state;
assert_string_equal(regex_replace("[0-9][0-9]*", "no digits"), "no digits");
}
static void test_regex_replace_passes_through_on_bad_pattern(void **state) {
(void) state;
assert_string_equal(regex_replace("[unclosed", "value"), "value");
assert_string_equal(regex_replace("*", "value"), "value");
}
static void test_spine_appendf_reports_truncation_and_guards(void **state) {
char buffer[8] = "";
char success[8] = "";
char *cursor = buffer;
char *success_cursor = success;
char *null_cursor = NULL;
size_t remaining = sizeof(buffer);
size_t success_remaining = sizeof(success);
size_t zero = 0;
(void) state;
assert_true(spine_appendf(&success_cursor, &success_remaining, "%s", "abc"));
assert_int_equal(success_cursor - success, 3);
assert_int_equal(success_remaining, sizeof(success) - 3);
assert_string_equal(success, "abc");
assert_false(spine_appendf(&cursor, &remaining, "%s", "0123456789"));
assert_int_equal(cursor - buffer, 7);
assert_int_equal(remaining, 1);
assert_int_equal(buffer[7], '\0');
assert_false(spine_appendf(&cursor, &remaining, "%s", "x"));
assert_string_equal(buffer, "0123456");
assert_false(spine_appendf(NULL, &remaining, "%s", "x"));
assert_false(spine_appendf(&null_cursor, &remaining, "%s", "x"));
assert_false(spine_appendf(&cursor, NULL, "%s", "x"));
assert_false(spine_appendf(&cursor, &zero, "%s", "x"));
}
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 ----------------------------------------------------------- */
static void test_all_digits(void **state) {
(void) state;
assert_int_equal(all_digits("12345"), TRUE);
assert_int_equal(all_digits("0"), TRUE);
assert_int_equal(all_digits(""), FALSE); /* empty is not all digits */
assert_int_equal(all_digits("12a45"), FALSE);
assert_int_equal(all_digits("-12"), FALSE); /* sign is not a digit */
assert_int_equal(all_digits(" 12"), FALSE);
}
static void test_is_ipaddress(void **state) {
(void) state;
assert_int_equal(is_ipaddress("192.168.0.1"), TRUE);
assert_int_equal(is_ipaddress("::1"), TRUE);
assert_int_equal(is_ipaddress("2001:db8::1"), FALSE); /* letters rejected */
assert_int_equal(is_ipaddress("host.example"), FALSE);
assert_int_equal(is_ipaddress(""), TRUE); /* vacuously true */
}
/* --- string surgery ------------------------------------------------------- */
static void test_trim_family(void **state) {
char a[32], b[32], c[32];
(void) state;
strcpy(a, " padded "); assert_string_equal(trim(a), "padded");
strcpy(b, " left"); assert_string_equal(ltrim(b), "left");
strcpy(c, "right "); assert_string_equal(rtrim(c), "right");
}
static void test_reverse(void **state) {
char s[16], one[2], empty[1];
(void) state;
strcpy(s, "abcdef"); assert_string_equal(reverse(s), "fedcba");
strcpy(one, "x"); assert_string_equal(reverse(one), "x");
empty[0] = '\0'; assert_string_equal(reverse(empty), "");
}
static void test_strpos(void **state) {
(void) state;
assert_int_equal(strpos("hello world", "world"), 6);
assert_int_equal(strpos("hello", "hello"), 0);
assert_int_equal(strpos("hello", "zzz"), -1);
assert_int_equal(strpos("hello", ""), 0);
}
static void test_char_count(void **state) {
(void) state;
assert_int_equal(char_count("a,b,c", ','), 2);
assert_int_equal(char_count("none", ','), 0);
assert_int_equal(char_count("", 'x'), 0);
assert_int_equal(char_count("anything", '\0'), 1); /* documented shortcut */
}
static void test_strip_alpha(void **state) {
char a[32], b[32];
(void) state;
strcpy(a, "load42"); assert_string_equal(strip_alpha(a), "42");
strcpy(b, "abc123def"); assert_string_equal(strip_alpha(b), "123");
}
static void test_add_slashes_doubles_a_backslash(void **state) {
char in[32];
char *out;
(void) state;
strcpy(in, "a\\b");
out = add_slashes(in);
assert_non_null(out);
assert_string_equal(out, "a\\\\b");
free(out); /* add_slashes() returns owned memory */
}
static void test_add_slashes_passes_plain_text_through(void **state) {
char in[32];
char *out;
(void) state;
strcpy(in, "plain");
out = add_slashes(in);
assert_non_null(out);
assert_string_equal(out, "plain");
free(out);
}
static void test_row_alias_upsert_version_gate(void **state) {
(void) state;
assert_false(db_row_alias_upsert_supported(NULL, 80020));
assert_false(db_row_alias_upsert_supported("8.0.19", 80019));
assert_true(db_row_alias_upsert_supported("8.0.20", 80020));
assert_true(db_row_alias_upsert_supported("8.4.0", 80400));
assert_true(db_row_alias_upsert_supported("9.1.0", 90100));
assert_false(db_row_alias_upsert_supported("10.11.6-MariaDB", 101106));
assert_false(db_row_alias_upsert_supported("5.5.5-10.11.6-MariaDB-log", 50505));
}
/* --- misc ----------------------------------------------------------------- */
static void test_file_exists(void **state) {
(void) state;
assert_int_equal(file_exists("/etc/hostname") || file_exists("/etc/passwd"), TRUE);
assert_int_equal(file_exists("/no/such/path/at/all"), FALSE);
}
static void test_get_time_as_double_advances(void **state) {
double t1, t2;
(void) state;
t1 = get_time_as_double();
assert_true(t1 > 0.0);
t2 = get_time_as_double();
assert_true(t2 >= t1);
}
static void test_get_checksum_is_stable(void **state) {
unsigned char buf[16];
unsigned short a, b;
(void) state;
memset(buf, 0x5A, sizeof buf);
a = get_checksum(buf, sizeof buf);
b = get_checksum(buf, sizeof buf);
assert_int_equal(a, b);
buf[0] = 0x00;
assert_int_not_equal(get_checksum(buf, sizeof buf), a);
}
/* --- spine_icmp_classify_reply(): the real classifier --------------------- */
#define ICMP_TEST_BUFSIZE 64
static void build_ip_icmp(unsigned char *buf, size_t len, uint16_t id, uint16_t seq, int type) {
struct ip *iph;
struct icmp *pkt;
memset(buf, 0, len);
iph = (struct ip *) buf;
iph->ip_hl = sizeof(struct ip) / 4;
iph->ip_v = 4;
/* a raw ICMP socket only delivers ICMP datagrams; the classifier checks it */
iph->ip_p = IPPROTO_ICMP;
pkt = (struct icmp *) (buf + sizeof(struct ip));
pkt->icmp_type = type;
pkt->icmp_id = id;
pkt->icmp_seq = seq;
}
static void test_icmp_classify_accepts_our_reply(void **state) {
unsigned char buf[ICMP_TEST_BUFSIZE];
const struct icmp *out = NULL;
(void) state;
build_ip_icmp(buf, sizeof buf, 0x1234, 7, ICMP_ECHOREPLY);
assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 0x1234, 7, &out), SPINE_ICMP_REPLY_OK);
assert_non_null(out);
}
static void test_icmp_classify_rejects_a_runt(void **state) {
unsigned char buf[ICMP_TEST_BUFSIZE];
const struct icmp *out = (const struct icmp *) 1;
(void) state;
build_ip_icmp(buf, sizeof buf, 1, 1, ICMP_ECHOREPLY);
assert_int_equal(spine_icmp_classify_reply(buf, 4, 1, 1, &out), SPINE_ICMP_REPLY_TOO_SHORT);
assert_null(out);
}
static void test_icmp_classify_rejects_a_bad_ihl(void **state) {
unsigned char buf[ICMP_TEST_BUFSIZE];
const struct icmp *out = NULL;
(void) state;
build_ip_icmp(buf, sizeof buf, 1, 1, ICMP_ECHOREPLY);
((struct ip *) buf)->ip_hl = 2; /* below sizeof(struct ip) */
assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 1, 1, &out), SPINE_ICMP_REPLY_BAD_HEADER);
}
static void test_icmp_classify_rejects_a_non_echo(void **state) {
unsigned char buf[ICMP_TEST_BUFSIZE];
const struct icmp *out = NULL;
(void) state;
/* 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);
}
static void test_icmp_classify_rejects_another_hosts_reply(void **state) {
unsigned char buf[ICMP_TEST_BUFSIZE];
const struct icmp *out = NULL;
(void) state;
build_ip_icmp(buf, sizeof buf, 0xBEEF, 9, ICMP_ECHOREPLY);
assert_int_equal(spine_icmp_classify_reply(buf, sizeof buf, 0x1234, 9, &out), SPINE_ICMP_REPLY_NOT_OURS);
assert_null(out);
}
static void test_icmp_classify_rejects_a_null_buffer(void **state) {
const struct icmp *out = NULL;
(void) state;
assert_int_equal(spine_icmp_classify_reply(NULL, ICMP_TEST_BUFSIZE, 1, 1, &out), SPINE_ICMP_REPLY_TOO_SHORT);
}
/* --- get_namebyhost(): the real parser ------------------------------------ */
static void test_namebyhost_plain_hostname(void **state) {
char host[64];
name_t *n;
(void) state;
strcpy(host, "device.example.net");
n = get_namebyhost(host, NULL);
assert_non_null(n);
assert_string_equal(n->hostname, "device.example.net");
free(n);
}
static void test_namebyhost_is_reentrant_across_calls(void **state) {
char a[64], b[64];
name_t *na, *nb;
(void) state;
strcpy(a, "first.example.net");
strcpy(b, "second.example.net");
na = get_namebyhost(a, NULL);
nb = get_namebyhost(b, NULL);
assert_string_equal(na->hostname, "first.example.net");
assert_string_equal(nb->hostname, "second.example.net");
free(na);
free(nb);
}
static void test_namebyhost_tcpv6_method_keeps_the_full_hostname(void **state) {
char host[64];
name_t *n;
(void) state;
strcpy(host, "[fe80::1]:161");
n = get_namebyhost(host, NULL);
assert_non_null(n);
assert_string_equal(n->hostname, "[fe80::1]:161");
free(n);
}
/* --- configuration: defaults, the file parser, and set_option() ----------- */
static void test_config_defaults_populates_the_set(void **state) {
(void) state;
memset(&set, 0, sizeof set);
config_defaults();
assert_int_equal(set.poller.threads, DEFAULT_THREADS);
assert_int_equal(set.database.port, DEFAULT_DB_PORT);
assert_string_equal(set.database.host, DEFAULT_DB_HOST);
assert_string_equal(set.database.database, DEFAULT_DB_DB);
}
static void test_read_spine_config_rejects_a_missing_file(void **state) {
(void) state;
assert_int_equal(read_spine_config("/no/such/spine.conf"), -1);
}
static void test_read_spine_config_reads_settings(void **state) {
const char *path = "/tmp/spine_test.conf";
FILE *fp;
(void) state;
fp = fopen(path, "wb");
assert_non_null(fp);
fputs("DB_Host testhost\n", fp);
fputs("DB_Database testdb\n", fp);
fputs("DB_User testuser\n", fp);
fputs("DB_Port 3399\n", fp);
fputs("Poller_Threads 7\n", fp);
fputs("# a comment line\n", fp);
fputs("\n", fp);
fclose(fp);
config_defaults();
assert_int_equal(read_spine_config(path), 0);
assert_string_equal(set.database.host, "testhost");
assert_string_equal(set.database.database, "testdb");
assert_string_equal(set.database.user, "testuser");
assert_int_equal(set.database.port, 3399);
remove(path);
}
/* --- get_date_format(): cached storage, rebuilt by set_date_format() ------ */
static void test_get_date_format_returns_cached_storage(void **state) {
char *fmt;
(void) state;
config_defaults();
set_date_format();
fmt = get_date_format();
assert_non_null(fmt);
assert_true(strlen(fmt) > 0);
/* the buffer belongs to util.c and is handed out, not owned by us */
assert_ptr_equal(fmt, get_date_format());
}
static void test_set_date_format_clamps_an_out_of_range_format(void **state) {
char *fmt;
(void) state;
config_defaults();
set.logging.log_datetime_format = GD_MAX + 10;
set.logging.log_datetime_separator = GDC_MAX + 10;
set_date_format();
fmt = get_date_format();
assert_non_null(fmt);
assert_int_equal(set.logging.log_datetime_format, GD_DEFAULT);
assert_int_equal(set.logging.log_datetime_separator, GDC_DEFAULT);
}
static void test_get_date_format_covers_each_supported_format(void **state) {
int fmt_value;
int sep_value;
char *fmt;
(void) state;
config_defaults();
for (fmt_value = GD_MIN; fmt_value <= GD_MAX; fmt_value++) {
for (sep_value = GDC_MIN; sep_value <= GDC_MAX; sep_value++) {
set.logging.log_datetime_format = fmt_value;
set.logging.log_datetime_separator = sep_value;
set_date_format();
fmt = get_date_format();
assert_non_null(fmt);
assert_true(strlen(fmt) > 0);
}
}
}
/* --- is_debug_device(): reads the global table stubs.c provides ----------- */
static void test_is_debug_device_matches_only_listed_ids(void **state) {
int table[100];
int *saved = debug_devices;
(void) state;
memset(table, 0, sizeof table);
table[0] = 42;
table[1] = 77;
debug_devices = table;
assert_int_equal(is_debug_device(42), TRUE);
assert_int_equal(is_debug_device(77), TRUE);
assert_int_equal(is_debug_device(1), FALSE);
debug_devices = saved;
}
/* --- nft_popen(): registry entries have exactly one closing owner -------- */
struct close_result {
int fd;
int result;
int error;
};
static void *close_from_thread(void *arg) {
struct close_result *result = arg;
result->result = nft_pclose(result->fd);
result->error = errno;
return NULL;
}
static void test_nft_pclose_has_one_owner_per_registry_entry(void **state) {
static struct close_result results[2];
pthread_t threads[2];
int create_results[2] = {-1, -1};
int fd;
int successes = 0;
int bad_fds = 0;
int owner_result = -1;
int join_results[2] = {-1, -1};
int reap_error;
int reap_result;
pid_t child;
int i;
(void) state;
fd = nft_popen("exit 7", "r");
assert_true(fd >= 0);
child = nft_pchild(fd);
assert_true(child > 0);
for (i = 0; i < 2; i++) {
results[i].fd = fd;
results[i].result = -1;
results[i].error = 0;
create_results[i] = pthread_create(&threads[i], NULL, close_from_thread, &results[i]);
}
for (i = 0; i < 2; i++) {
if (create_results[i] == 0) {
join_results[i] = pthread_join(threads[i], NULL);
}
if (results[i].result >= 0) {
successes++;
owner_result = results[i].result;
} else if (results[i].error == EBADF) {
bad_fds++;
}
}
/* Avoid leaking the child if thread creation failed before either closer ran. */
if (successes == 0) {
(void)nft_pclose(fd);
}
assert_int_equal(create_results[0], 0);
assert_int_equal(create_results[1], 0);
assert_int_equal(join_results[0], 0);
assert_int_equal(join_results[1], 0);
assert_int_equal(successes, 1);
assert_int_equal(bad_fds, 1);
assert_true(WIFEXITED(owner_result));
assert_int_equal(WEXITSTATUS(owner_result), 7);
errno = 0;
reap_result = waitpid(child, NULL, WNOHANG);
reap_error = errno;
assert_int_equal(fcntl(fd, F_GETFD), -1);
assert_int_equal(errno, EBADF);
assert_int_equal(reap_result, -1);
assert_int_equal(reap_error, ECHILD);
}
static void test_nft_pclose_cancellation_releases_registry_entry(void **state) {
struct close_result result;
pthread_t thread;
void *thread_result = NULL;
pid_t child;
int cancel_result = -1;
int create_result;
int detached = 0;
int fd;
int i;
int join_result = -1;
int lookup_error;
int lookup_result;
char ready;
int status;
(void) state;
fd = nft_popen("printf x; kill -STOP $$", "r");
assert_true(fd >= 0);
child = nft_pchild(fd);
assert_true(child > 0);
assert_int_equal(read(fd, &ready, 1), 1);
assert_int_equal(ready, 'x');
result.fd = fd;
result.result = -1;
result.error = 0;
create_result = pthread_create(&thread, NULL, close_from_thread, &result);
if (create_result == 0) {
/* The registry transition, rather than elapsed time, proves the closer has
* taken exclusive ownership and reached waitpid().
*/
for (i = 0; i < 5000; i++) {
errno = 0;
if (nft_pchild(fd) == -1 && errno == EBADF) {
detached = 1;
break;
}
usleep(1000);
}
if (detached) {
cancel_result = pthread_cancel(thread);
} else {
kill(child, SIGKILL);
}
join_result = pthread_join(thread, &thread_result);
}
errno = 0;
lookup_result = nft_pchild(fd);
lookup_error = errno;
/* Cancellation stops nft_pclose() before it can reap. Clean up the child
* before asserting because cmocka assertions longjmp.
*/
if (lookup_result > 0) {
kill(lookup_result, SIGKILL);
(void)nft_pclose(fd);
} else if (detached) {
kill(child, SIGKILL);
do {
status = waitpid(child, NULL, 0);
} while (status < 0 && errno == EINTR);
}
assert_int_equal(create_result, 0);
assert_true(detached);
assert_int_equal(cancel_result, 0);
assert_int_equal(join_result, 0);
assert_ptr_equal(thread_result, PTHREAD_CANCELED);
assert_int_equal(lookup_result, -1);
assert_int_equal(lookup_error, EBADF);
}
static void test_nft_pclose_early_error_preserves_cancellation_mode(void **state) {
int cancel_state_after;
int cancel_state_before;
(void) state;
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancel_state_before);
pthread_setcancelstate(cancel_state_before, NULL);
errno = 0;
assert_int_equal(nft_pclose(-1), -1);
assert_int_equal(errno, EBADF);
errno = 0;
assert_int_equal(nft_pchild(-1), -1);
assert_int_equal(errno, EBADF);
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancel_state_after);
pthread_setcancelstate(cancel_state_after, NULL);
assert_int_equal(cancel_state_after, cancel_state_before);
}
/* ---------------------------------------------------------------------------
* Child process hardening (nft_popen.c)
*
* PR #542 removed the close-on-exec and bounded-reap code PR #557 had just
* added, and nothing failed, because the only guard was a shell script that
* grepped the source and was deleted in the same commit. These exercise the
* behaviour against the shipped object instead.
* ------------------------------------------------------------------------- */
static void test_cloexec_is_set_on_both_pipe_ends(void **state) {
int pdes[2];
int i;
(void) state;
assert_true(spine_open_pipe_cloexec(pdes));
for (i = 0; i < 2; i++) {
int flags = fcntl(pdes[i], F_GETFD);
assert_true(flags >= 0);
assert_true((flags & FD_CLOEXEC) != 0);
}
close(pdes[0]);
close(pdes[1]);
}
static void test_cloexec_pipe_is_a_working_pipe(void **state) {
int pdes[2];
char buf[8];
(void) state;
assert_true(spine_open_pipe_cloexec(pdes));
assert_int_equal(write(pdes[1], "ok", 2), 2);
assert_int_equal(read(pdes[0], buf, sizeof(buf)), 2);
assert_memory_equal(buf, "ok", 2);
close(pdes[0]);
close(pdes[1]);
}
static void test_duplicated_descriptor_is_close_on_exec(void **state) {
int original;
int duplicate;
int flags;
(void) state;
original = open("/dev/null", O_RDONLY);
assert_true(original >= 0);
duplicate = spine_dup_cloexec(original);
assert_true(duplicate >= 0);
flags = fcntl(duplicate, F_GETFD);
assert_true(flags >= 0);
assert_true((flags & FD_CLOEXEC) != 0);
close(duplicate);
close(original);
}
static void test_existing_pipe_on_stdout_does_not_close_a_new_child_redirect(void **state) {
int saved_stdin;
int saved_stdout;
int writer;
int reader;
int writer_status;
int wait_status = -1;
pid_t writer_pid;
siginfo_t writer_info = {0};
int reader_status;
ssize_t bytes;
char output[32] = {0};
(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);
/* The write-mode parent retains fd 1 in PidList. The following read-mode
* child also redirects its new pipe onto fd 1. Its later PidList close walk
* must not close that newly installed stdout. */
writer = nft_popen("exit 0", "w");
writer_pid = writer >= 0 ? nft_pchild(writer) : -1;
reader = nft_popen("printf second-child-visible", "r");
bytes = reader >= 0 ? read(reader, output, sizeof(output) - 1) : -1;
reader_status = reader >= 0 ? nft_pclose(reader) : -1;
/* Keep the writer entry and fd 1 registered until after the reader has
* spawned. Observe its exit without reaping, so close tests ownership
* rather than the runner scheduling the child within the reap budget. */
if (writer_pid > 0) {
do {
wait_status = waitid(P_PID, (id_t)writer_pid, &writer_info, WEXITED | WNOWAIT);
} while (wait_status < 0 && errno == EINTR);
}
writer_status = writer >= 0 ? nft_pclose(writer) : -1;
dup2(saved_stdin, STDIN_FILENO);
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdin);
close(saved_stdout);
assert_true(writer >= 0);
assert_true(reader >= 0);
assert_true(bytes > 0);
assert_string_equal(output, "second-child-visible");
assert_true(WIFEXITED(reader_status));
assert_int_equal(WEXITSTATUS(reader_status), 0);
assert_int_equal(wait_status, 0);
assert_int_equal(writer_info.si_pid, writer_pid);
assert_int_equal(writer_info.si_code, CLD_EXITED);
assert_int_equal(writer_info.si_status, 0);
assert_true(WIFEXITED(writer_status));
assert_int_equal(WEXITSTATUS(writer_status), 0);
}
static void test_abandoned_children_are_swept_and_capacity_is_bounded(void **state) {
pid_t pids[NFT_ABANDONED_MAX + 1];
int i;
int created = 0;
int status;
(void) state;
for (i = 0; i < NFT_ABANDONED_MAX + 1; i++) {
pids[i] = fork();
if (pids[i] < 0)
break;
if (pids[i] == 0) {
pause();
_exit(0);
}
created++;
nft_abandon_child(pids[i], "unit test");
}
if (created != NFT_ABANDONED_MAX + 1) {
for (i = 0; i < created; i++) {
(void)kill(pids[i], SIGKILL);
(void)waitpid(pids[i], &status, 0);
}
(void)nft_abandoned_pending();
skip();
}
assert_int_equal(nft_abandoned_pending(), NFT_ABANDONED_MAX);
for (i = 0; i < created; i++) {
assert_int_equal(kill(pids[i], SIGKILL), 0);
assert_int_equal(waitpid(pids[i], &status, 0), pids[i]);
}
assert_int_equal(nft_abandoned_pending(), 0);
}
#ifdef SPINE_TEST_WRAP_WAITPID
/* waitpid() failing with anything other than ECHILD/EINTR (never true for a
* real call with WNOHANG) must still kill the child before parking it,
* rather than leaving a still-running process outside the sweep's reach. */
static void test_nft_pclose_kills_child_when_waitpid_errors(void **state) {
int fd;
int status;
pid_t child;
pid_t reaped;
int real_status;
int attempts;
(void) state;
fd = nft_popen("printf x; while :; do sleep 1; done", "r");
assert_true(fd >= 0);
child = nft_pchild(fd);
assert_true(child > 0);
forced_waitpid_error_pid = child;
forced_waitpid_error_errno = EINVAL;
errno = 0;
status = nft_pclose(fd);
forced_waitpid_error_pid = -1;
assert_int_equal(status, -1);
assert_int_equal(errno, EINVAL);
/* Reap for real (bypassing the wrap) to prove the child was killed
* rather than merely parked. */
reaped = -1;
for (attempts = 0; attempts < 100 && reaped != child; attempts++) {
reaped = __real_waitpid(child, &real_status, WNOHANG);
if (reaped == 0) {
usleep(10000);
reaped = -1;
}
}
assert_int_equal(reaped, child);
assert_true(WIFSIGNALED(real_status));
assert_int_equal(WTERMSIG(real_status), SIGKILL);
/* nft_abandon_child() still parked it; sweep it back out so it does not
* leak into a later test's nft_abandoned_pending() count. */
assert_int_equal(nft_abandoned_pending(), 0);
}
#endif
/* The descriptor must not survive an exec. A child that inherits the write end
keeps the pipe open, so the polling thread never sees EOF and blocks to
script_timeout for a data source that already answered. */
static void test_pipe_is_not_inherited_across_exec(void **state) {
int pdes[2];
int status;
pid_t pid;
char fdarg[32];
(void) state;
assert_true(spine_open_pipe_cloexec(pdes));
snprintf(fdarg, sizeof(fdarg), "/proc/self/fd/%d", pdes[1]);
pid = fork();
assert_true(pid >= 0);
if (pid == 0) {
/* exits 0 when the descriptor survived exec, 1 when it did not */
execl("/bin/sh", "sh", "-c", "test -e \"$0\"", fdarg, (char *) NULL);
_exit(127);
}
assert_int_equal(waitpid(pid, &status, 0), pid);
assert_true(WIFEXITED(status));
assert_int_equal(WEXITSTATUS(status), 1);
close(pdes[0]);
close(pdes[1]);
}
static void test_reap_returns_still_running_rather_than_blocking(void **state) {
int pstat = 0;
int status;
pid_t pid;
(void) state;
pid = fork();
assert_true(pid >= 0);
if (pid == 0) {
pause();
_exit(0);
}
/* the shipped code blocked here forever; two attempts must come back */
assert_int_equal(spine_reap_child_bounded(pid, &pstat, 2), 1);
assert_int_equal(kill(pid, SIGKILL), 0);
assert_int_equal(waitpid(pid, &status, 0), pid);
}
static void test_reap_collects_an_exited_child(void **state) {
int pstat = 0;
pid_t pid;
(void) state;
pid = fork();
assert_true(pid >= 0);
if (pid == 0) {
_exit(3);
}
assert_int_equal(spine_reap_child_bounded(pid, &pstat, 20), 0);
assert_true(WIFEXITED(pstat));
assert_int_equal(WEXITSTATUS(pstat), 3);
}
static void test_reap_reports_an_already_reaped_child(void **state) {
int pstat = 99;
int status;
pid_t pid;
(void) state;
pid = fork();
assert_true(pid >= 0);
if (pid == 0) {
_exit(0);
}
assert_int_equal(waitpid(pid, &status, 0), pid);
/* ECHILD: someone else took the status, which is success with none */
assert_int_equal(spine_reap_child_bounded(pid, &pstat, 2), 0);
assert_int_equal(pstat, 0);
}
static void test_nft_pclose_does_not_block_on_a_lingering_child(void **state) {
char ready[6] = {0};
int fd;
double start;
int attempts;
(void) state;
fd = nft_popen("printf ready; while :; do sleep 1; done", "r");
assert_true(fd >= 0);
assert_int_equal(read(fd, ready, 5), 5);
assert_string_equal(ready, "ready");
start = get_time_as_double();
assert_int_equal(nft_pclose(fd), -1);
/* The removed escalating spin/sleep/SIGTERM/SIGKILL sequence took up to
* ~5 seconds here. nft_pclose() must kill the child and hand it to the
* abandoned-pid sweep instead of blocking waiting for it to exit. */
assert_true(get_time_as_double() - start < 0.5);
for (attempts = 0; attempts < 50; attempts++) {
if (nft_abandoned_pending() == 0) {
break;
}
usleep(20000);
}
assert_int_equal(nft_abandoned_pending(), 0);
}
int main(void) {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_strncopy_truncates_within_the_buffer),
cmocka_unit_test(test_strncopy_copies_a_short_source_whole),
cmocka_unit_test(test_strncopy_handles_a_zero_size),
cmocka_unit_test(test_strncopy_terminates_an_exact_fit),
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_spine_appendf_reports_truncation_and_guards),
cmocka_unit_test(test_all_digits),
cmocka_unit_test(test_is_ipaddress),
cmocka_unit_test(test_trim_family),
cmocka_unit_test(test_reverse),
cmocka_unit_test(test_strpos),
cmocka_unit_test(test_char_count),
cmocka_unit_test(test_strip_alpha),
cmocka_unit_test(test_add_slashes_doubles_a_backslash),
cmocka_unit_test(test_add_slashes_passes_plain_text_through),
cmocka_unit_test(test_row_alias_upsert_version_gate),
cmocka_unit_test(test_file_exists),
cmocka_unit_test(test_get_time_as_double_advances),
cmocka_unit_test(test_get_checksum_is_stable),
cmocka_unit_test(test_icmp_classify_accepts_our_reply),
cmocka_unit_test(test_icmp_classify_rejects_a_runt),
cmocka_unit_test(test_icmp_classify_rejects_a_bad_ihl),
cmocka_unit_test(test_icmp_classify_rejects_a_non_echo),
cmocka_unit_test(test_icmp_classify_rejects_another_hosts_reply),
cmocka_unit_test(test_icmp_classify_rejects_a_null_buffer),
cmocka_unit_test(test_namebyhost_plain_hostname),
cmocka_unit_test(test_namebyhost_is_reentrant_across_calls),
cmocka_unit_test(test_namebyhost_tcpv6_method_keeps_the_full_hostname),
cmocka_unit_test(test_config_defaults_populates_the_set),
cmocka_unit_test(test_read_spine_config_rejects_a_missing_file),
cmocka_unit_test(test_read_spine_config_reads_settings),
cmocka_unit_test(test_get_date_format_returns_cached_storage),
cmocka_unit_test(test_set_date_format_clamps_an_out_of_range_format),
cmocka_unit_test(test_get_date_format_covers_each_supported_format),
cmocka_unit_test(test_is_debug_device_matches_only_listed_ids),
cmocka_unit_test(test_nft_pclose_has_one_owner_per_registry_entry),
cmocka_unit_test(test_nft_pclose_cancellation_releases_registry_entry),
cmocka_unit_test(test_nft_pclose_early_error_preserves_cancellation_mode),
cmocka_unit_test(test_cloexec_is_set_on_both_pipe_ends),
cmocka_unit_test(test_cloexec_pipe_is_a_working_pipe),
cmocka_unit_test(test_duplicated_descriptor_is_close_on_exec),
cmocka_unit_test(test_existing_pipe_on_stdout_does_not_close_a_new_child_redirect),
cmocka_unit_test(test_pipe_is_not_inherited_across_exec),
cmocka_unit_test(test_reap_returns_still_running_rather_than_blocking),
cmocka_unit_test(test_reap_collects_an_exited_child),
cmocka_unit_test(test_reap_reports_an_already_reaped_child),
cmocka_unit_test(test_nft_pclose_does_not_block_on_a_lingering_child),
cmocka_unit_test(test_abandoned_children_are_swept_and_capacity_is_bounded),
#ifdef SPINE_TEST_WRAP_WAITPID
cmocka_unit_test(test_nft_pclose_kills_child_when_waitpid_errors),
#endif
};
return cmocka_run_group_tests(tests, NULL, NULL);
}