myxce/src/controller/testsuite/test_rcfile.c
Rongsong Shen 77aba21fb0
feat(xce-test-rc-file): task 1.2 rc-file line-loading via pluggable runner
Adds rcfile_load(path, runner) to rcfile.c/rcfile.h: reads lines with
getline() (no fixed-size buffer, so a long line like a multi-peer
`set masters ...` can never be silently truncated or split), strips the
trailing newline, invokes the caller-supplied runner once per line, and
stops at the first nonzero runner return, propagating that value. A missing
rc file is a silent no-op; any other open failure prints one stderr warning
and returns 0.

Found during GREEN: fopen() succeeds on a directory path on this platform,
so the EISDIR failure only surfaces on the first getline() call (ferror(f)
set) -- added a post-loop ferror() check to catch it (bug-1716).

5 new unit tests (11 total with task 1.1): ENOENT-silent, per-line-in-order
with newline stripped, stop-on-nonzero with exact propagation, non-ENOENT
open-failure warning, and a >4KB single line proving no truncation/split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
2026-08-26 15:02:03 +00:00

419 lines
14 KiB
C

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "rcfile.h"
/* Saved prior state of one environment variable, captured before a test
* case mutates it. Distinguishes "was unset" (getenv() returned NULL) from
* "was set to X" (including X == "") so restoration is exact -- required
* because controller_unit_test links 80+ test_*.c files into ONE process
* running sequentially, and a leaked HOME/XDG_CONFIG_HOME value would
* silently affect every test that runs after this file. */
typedef struct {
int was_set; /* 1 if getenv() returned non-NULL before mutation */
char* value; /* heap copy of the prior value; valid only if was_set */
} rcfile_envsave_t;
static rcfile_envsave_t env_save(const char* name)
{
rcfile_envsave_t s;
const char* cur = getenv(name);
if (cur) {
s.was_set = 1;
s.value = strdup(cur);
TEST_ASSERT_NOT_NULL(s.value);
} else {
s.was_set = 0;
s.value = NULL;
}
return s;
}
/* Restores `name` to exactly the state `s` captured, then frees `s`'s heap
* copy. Safe to call even if the test already unset/reset the variable
* itself -- always drives it back to the captured prior state. */
static void env_restore(const char* name, rcfile_envsave_t* s)
{
if (s->was_set) {
TEST_ASSERT_EQUAL_INT(0, setenv(name, s->value, 1));
free(s->value);
} else {
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
}
s->value = NULL;
}
static void test_resolve_uses_xdg_config_home_when_set_and_nonempty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(
0, setenv("XDG_CONFIG_HOME", "/tmp/xce_rcfile_test_xdg", 1));
/* HOME is deliberately set to something recognizably different, so a
* pass here proves HOME was NOT consulted, not just that the result
* happens to match by coincidence. */
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home_unused", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_xdg/xce_test/rc", path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "/tmp/xce_rcfile_test_home", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home2", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home2/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, unsetenv("HOME"));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
/* Proves the save/restore mechanism itself round-trips exactly, for all
* three prior-state shapes (unset / empty / a real value), regardless of
* whatever ambient state this test happened to start with -- this is what
* guarantees a test run AFTER this file never observes state leaked by it,
* since there is no later test in this same file to leak into. */
static void test_env_save_restore_roundtrips_all_three_prior_states(void)
{
const char* name = "XDG_CONFIG_HOME";
rcfile_envsave_t real_prior = env_save(name);
/* Case 1: prior state was unset. */
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
rcfile_envsave_t s1 = env_save(name);
TEST_ASSERT_EQUAL_INT(0, s1.was_set);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case1", 1));
env_restore(name, &s1);
TEST_ASSERT_NULL(getenv(name));
/* Case 2: prior state was set to the empty string. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "", 1));
rcfile_envsave_t s2 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s2.was_set);
TEST_ASSERT_EQUAL_STRING("", s2.value);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case2", 1));
env_restore(name, &s2);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("", getenv(name));
/* Case 3: prior state was set to a real value. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/xce_rcfile_prior_value", 1));
rcfile_envsave_t s3 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s3.was_set);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", s3.value);
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
env_restore(name, &s3);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", getenv(name));
env_restore(name, &real_prior);
}
/* ---- rcfile_load() tests (task 1.2) ---------------------------------- */
/* Test-local temp dir helper, mirroring test_configcompare.c's
* fresh_dir()/cleanup_dir() pattern -- keeps rc-file fixtures out of the
* shared /tmp leak this sandbox already tracks (bug-924) by cleaning up
* after itself instead of leaving an mkdtemp() dir behind forever. */
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_rcfile_test_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
static void write_file(const char* path, const char* content, size_t len)
{
FILE* f = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(f);
if (len > 0)
TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(content, 1, len, f));
fclose(f);
}
/* Fake runner: records every line it is called with (heap copies, in call
* order) and, once scripted via fail_at_call, returns fail_rc on that call
* (1-based) -- lets a test assert both the exact propagated return value
* and the exact point iteration stopped. */
#define RCFILE_TEST_MAX_LINES 8
typedef struct {
char* lines[RCFILE_TEST_MAX_LINES];
int count;
int fail_at_call; /* 1-based; 0 = never fail */
int fail_rc;
} rcfile_fake_runner_state_t;
static rcfile_fake_runner_state_t g_fake;
static void fake_runner_reset(void)
{
for (int i = 0; i < g_fake.count; i++) {
free(g_fake.lines[i]);
g_fake.lines[i] = NULL;
}
g_fake.count = 0;
g_fake.fail_at_call = 0;
g_fake.fail_rc = 0;
}
static int fake_runner(char* line)
{
TEST_ASSERT_LESS_THAN_INT(RCFILE_TEST_MAX_LINES, g_fake.count);
g_fake.lines[g_fake.count] = strdup(line);
TEST_ASSERT_NOT_NULL(g_fake.lines[g_fake.count]);
g_fake.count++;
if (g_fake.fail_at_call != 0 && g_fake.count == g_fake.fail_at_call)
return g_fake.fail_rc;
return 0;
}
static void test_rcfile_load_returns_0_and_never_calls_runner_when_path_missing(void)
{
fresh_dir();
fake_runner_reset();
char missing_path[300];
snprintf(missing_path, sizeof(missing_path), "%s/does_not_exist", g_dir);
int rc = rcfile_load(missing_path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_fake.count);
cleanup_dir();
}
static void test_rcfile_load_calls_runner_once_per_line_in_order_newline_stripped(void)
{
fresh_dir();
fake_runner_reset();
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
/* Last line deliberately has no trailing newline -- getline() must
* still hand it to the runner intact, not require a final \n. */
const char* content =
"set masters host1:1,host2:2\nworker\njoin\nquit";
write_file(path, content, strlen(content));
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(4, g_fake.count);
TEST_ASSERT_EQUAL_STRING("set masters host1:1,host2:2", g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("worker", g_fake.lines[1]);
TEST_ASSERT_EQUAL_STRING("join", g_fake.lines[2]);
TEST_ASSERT_EQUAL_STRING("quit", g_fake.lines[3]);
cleanup_dir();
}
static void test_rcfile_load_stops_after_nonzero_runner_and_propagates_value(void)
{
fresh_dir();
fake_runner_reset();
g_fake.fail_at_call = 2;
g_fake.fail_rc = 42;
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
const char* content = "line1\nline2\nline3\nline4\n";
write_file(path, content, strlen(content));
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(42, rc);
TEST_ASSERT_EQUAL_INT(2, g_fake.count);
TEST_ASSERT_EQUAL_STRING("line1", g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("line2", g_fake.lines[1]);
cleanup_dir();
}
/* A present-but-unreadable-as-a-regular-file path (a directory) must warn
* to stderr (non-fatal) and return 0 without ever calling the runner --
* proven here by actually capturing stderr via dup/dup2 (not just
* asserting the return value), then restoring the original fd exactly so
* later test files in this same combined binary see stderr unaffected. */
static void test_rcfile_load_directory_path_warns_and_returns_0(void)
{
fresh_dir();
fake_runner_reset();
fflush(stderr);
int saved_stderr_fd = dup(fileno(stderr));
TEST_ASSERT_TRUE(saved_stderr_fd >= 0);
char capture_path[300];
snprintf(capture_path, sizeof(capture_path), "%s/stderr_capture", g_dir);
FILE* redirected = freopen(capture_path, "w", stderr);
TEST_ASSERT_NOT_NULL(redirected);
int rc = rcfile_load(g_dir, fake_runner); /* g_dir is a directory */
fflush(stderr);
TEST_ASSERT_TRUE(dup2(saved_stderr_fd, fileno(stderr)) >= 0);
close(saved_stderr_fd);
clearerr(stderr);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_fake.count);
FILE* f = fopen(capture_path, "r");
TEST_ASSERT_NOT_NULL(f);
char buf[512];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
buf[n] = '\0';
fclose(f);
TEST_ASSERT_TRUE(n > 0);
TEST_ASSERT_NOT_NULL(strstr(buf, g_dir));
cleanup_dir();
}
/* Proves the getline()-not-fgets() design decision actually holds: a
* single line well beyond any typical small fixed-size buffer (e.g. the
* historic 256/1024-byte stack buffers this design note calls out) must
* reach the runner completely intact and unsplit -- a real content/length
* equality check, not just "didn't crash". */
static void test_rcfile_load_long_line_reaches_runner_intact_and_unsplit(void)
{
fresh_dir();
fake_runner_reset();
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
/* Build a >4KB single line shaped like a real long `set masters` rc
* command: many comma-separated host:port pairs on one line. */
size_t cap = 8192;
char* long_line = (char*)malloc(cap);
TEST_ASSERT_NOT_NULL(long_line);
size_t off = (size_t)snprintf(long_line, cap, "set masters ");
int pair_no = 0;
while (off < 4096 + 200) {
int written = snprintf(long_line + off, cap - off,
"%shost%d:1000%d", pair_no ? "," : "",
pair_no, pair_no % 10);
TEST_ASSERT_TRUE(written > 0);
off += (size_t)written;
pair_no++;
}
TEST_ASSERT_TRUE(off > 4096);
/* File content: the long line, then a short trailer line, so a
* truncation/splitting bug would also show up as an extra/garbled
* runner call rather than only a length mismatch. */
size_t file_len = off + 1 /* \n */ + strlen("quit") + 1 /* \n */;
char* file_content = (char*)malloc(file_len + 1);
TEST_ASSERT_NOT_NULL(file_content);
int fc_len = snprintf(file_content, file_len + 1, "%s\nquit\n", long_line);
TEST_ASSERT_EQUAL_INT((int)file_len, fc_len);
write_file(path, file_content, file_len);
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, g_fake.count);
TEST_ASSERT_TRUE(strlen(g_fake.lines[0]) > 4096);
TEST_ASSERT_EQUAL_INT((int)off, (int)strlen(g_fake.lines[0]));
TEST_ASSERT_EQUAL_STRING(long_line, g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("quit", g_fake.lines[1]);
free(long_line);
free(file_content);
cleanup_dir();
}
void test_rcfile(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_resolve_uses_xdg_config_home_when_set_and_nonempty);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_unset);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_empty);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_unset);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_empty);
RUN_TEST(test_env_save_restore_roundtrips_all_three_prior_states);
RUN_TEST(test_rcfile_load_returns_0_and_never_calls_runner_when_path_missing);
RUN_TEST(test_rcfile_load_calls_runner_once_per_line_in_order_newline_stripped);
RUN_TEST(test_rcfile_load_stops_after_nonzero_runner_and_propagates_value);
RUN_TEST(test_rcfile_load_directory_path_warns_and_returns_0);
RUN_TEST(test_rcfile_load_long_line_reaches_runner_intact_and_unsplit);
}