diff --git a/.kiro/specs/xce-test-rc-file/tasks.md b/.kiro/specs/xce-test-rc-file/tasks.md index 102208c7..d13aeeaf 100644 --- a/.kiro/specs/xce-test-rc-file/tasks.md +++ b/.kiro/specs/xce-test-rc-file/tasks.md @@ -9,7 +9,7 @@ - Observable: a unit test run demonstrates the correct location is chosen for each of the three environment combinations above, and a subsequent unrelated test's own `$HOME`/`$XDG_CONFIG_HOME` reads are unaffected - _Requirements: 1.1_ -- [ ] 1.2 Implement rc-file line-loading with a pluggable command runner, with unit tests +- [x] 1.2 Implement rc-file line-loading with a pluggable command runner, with unit tests - Read the rc file line by line using `getline()` (no fixed-size buffer), invoking a caller-supplied runner once per line with the trailing newline removed - Treat a missing rc file as a no-op success (no error), and a present-but-unreadable rc file as a non-fatal warning that still allows startup to proceed - Stop reading immediately the first time the runner signals a stop, and propagate that signal to the caller without processing any remaining lines @@ -53,3 +53,10 @@ - Observable: the existing scripts' outcomes are unchanged, demonstrating this feature introduces no regression for callers that know nothing about it - _Requirements: 1.3_ - _Depends: 2.2_ + +## Implementation Notes + +- Task 1.1: `controller_unit_test` can silently die with exit code 1/2 before reaching later-registered test groups if `/tmp` fills up — a pre-existing, already-tracked leak (bug-924) where test fixtures under `/tmp/xce_*` aren't cleaned up across many prior sessions' runs can exhaust disk and make `mkdtemp()` in unrelated tests (e.g. `test_repl_apply.c`) `exit(1)` on `ENOSPC`. If a later task's `controller_unit_test` run dies early or shows an unexplained failure in an unrelated file, check `df -h /tmp` before assuming a regression in this task's own code. +- Task 1.1: the 2 pre-existing `test_config_repl.c` failures (`test_conf_trim_keeps_only_n_versions`, `test_conf_get_saved_tag_no_config`) are the tracked `bug-1561` overlayfs-artifact regression, unrelated to this spec — expect them to keep appearing in every subsequent task's `controller_unit_test` run; do not treat them as a new regression. +- Task 1.2: `fopen()` succeeds on a directory path on this platform's glibc — the EISDIR failure only surfaces on the first `getline()` call (`errno=EISDIR`, `ferror(f)` set). A naive `if (!f)` open-failure check misses this; a post-loop `ferror(f)` check is required to catch it (see `rcfile_load()`, bug-1716). +- Task 1.2: an ASAN heap-use-after-free was observed ONCE in an ad-hoc standalone `controller_unit_test` run (`commit_resolve_pending`/`fsync_worker`/`tap_teardown` in `test_apply_pump.c`) — did not reproduce on a second run or in the canonical `xce-build.sh` run. Unrelated to this spec's files; logged as bug-1717 (unreproduced flake) for tracking. If it recurs on a later task's run, capture the full ASAN report before assuming it's this spec's fault. diff --git a/src/controller/testsuite/rcfile.c b/src/controller/testsuite/rcfile.c index 27ac996f..93a385a5 100644 --- a/src/controller/testsuite/rcfile.c +++ b/src/controller/testsuite/rcfile.c @@ -1,6 +1,7 @@ #define _GNU_SOURCE #include "rcfile.h" +#include #include #include #include @@ -36,3 +37,44 @@ char* rcfile_resolve_path(void) return NULL; } + +int rcfile_load(const char* path, int (*runner)(char* line)) +{ + if (!path) + return 0; + + FILE* f = fopen(path, "r"); + if (!f) { + if (errno == ENOENT) + return 0; /* missing rc file: silent no-op success */ + fprintf(stderr, "xce_test: warning: could not open rc file %s: %s\n", + path, strerror(errno)); + return 0; /* present but unreadable: non-fatal warning */ + } + + int rc = 0; + char* line = NULL; + size_t cap = 0; + ssize_t n; + while ((n = getline(&line, &cap, f)) != -1) { + if (n > 0 && line[n - 1] == '\n') + line[n - 1] = '\0'; + rc = runner(line); + if (rc != 0) + break; + } + + /* fopen() succeeds on a directory on this platform -- the failure only + * surfaces on the first read (getline() returns -1 with errno==EISDIR + * and ferror(f) set, feof(f) NOT set). Treat that the same as an open + * failure: one non-fatal stderr warning, no runner calls attributed to + * it (rc is still 0 here since no line was ever successfully read). */ + if (rc == 0 && ferror(f)) { + fprintf(stderr, "xce_test: warning: could not read rc file %s: %s\n", + path, strerror(errno)); + } + + free(line); + fclose(f); + return rc; +} diff --git a/src/controller/testsuite/rcfile.h b/src/controller/testsuite/rcfile.h index 4daeaa01..cf544fc2 100644 --- a/src/controller/testsuite/rcfile.h +++ b/src/controller/testsuite/rcfile.h @@ -1,15 +1,17 @@ #pragma once /* - * rcfile.h -- xce_test startup rc-file path resolution (xce-test-rc-file - * spec, task 1.1). + * rcfile.h -- xce_test startup rc-file path resolution and line-loading + * (xce-test-rc-file spec, tasks 1.1/1.2). * * A standalone, dependency-light module following the same pattern as * configcompare.c/h -- no dependency on test.c internals, linked into both * the xce_test executable and controller_unit_test. * - * This task implements path resolution only (rcfile_resolve_path()). - * Line-loading (rcfile_load()) is a later task in the same spec and will - * extend this file, not replace it. + * Task 1.1 implemented path resolution (rcfile_resolve_path()). Task 1.2 + * adds line-loading (rcfile_load()) with a pluggable runner callback. + * Wiring rcfile_load() into xce_test's own startup/main() is a later task + * in the same spec (2.2) and is out of scope here -- this module has no + * knowledge of xce_test commands, contexts, or client state. */ /* Resolves the rc-file path per the XDG Base Directory Specification: @@ -18,3 +20,20 @@ * a heap string the caller must free(), or NULL if neither variable yields * a usable base directory. */ char* rcfile_resolve_path(void); + +/* Opens `path` and calls `runner(line)` once per line (trailing newline + * stripped), stopping early if `runner` returns nonzero. Lines are read via + * getline() with no length cap -- a line is never truncated or split + * regardless of length. + * + * Returns: + * 0 - path is NULL, the file does not exist (ENOENT), + * the file could not be opened for another reason + * (a warning is printed to stderr; non-fatal), or + * every line ran and every runner call returned 0. + * nonzero (== runner's) - the runner requested an abort; no further lines + * are read. + * + * Preconditions: `runner` is non-NULL when `path` is non-NULL. + * Postconditions: the file handle is always closed before returning. */ +int rcfile_load(const char* path, int (*runner)(char* line)); diff --git a/src/controller/testsuite/test_rcfile.c b/src/controller/testsuite/test_rcfile.c index e316ecb7..c07223de 100644 --- a/src/controller/testsuite/test_rcfile.c +++ b/src/controller/testsuite/test_rcfile.c @@ -1,6 +1,9 @@ #define _GNU_SOURCE +#include #include #include +#include +#include #include @@ -176,6 +179,229 @@ static void test_env_save_restore_roundtrips_all_three_prior_states(void) 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__); @@ -185,4 +411,9 @@ void test_rcfile(void) 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); }