From a2686516f7dc6ad3ec31c376aecbba8e87a1560b Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Wed, 9 Sep 2026 11:41:05 +0000 Subject: [PATCH] feat(xce-test-cli-ux): build destructive-command confirmation gate confirm_destructive() prompts on stderr and blocking-reads a y/n answer via a single, non-looping fgets() call (not libedit) -- the mechanism that makes a real Ctrl-C during the prompt return EINTR and be treated as a decline (Requirement 9.9), same as an explicit "n" or EOF. confirm_set_skip()/confirm_is_skip_set() add a session-scoped toggle mirroring cmd_set's existing -e/+e style. Auto-skips when input is not interactive, via a new xcetest_is_interactive() accessor over the existing g_interactive global. Destructive-command enumeration and per-command wiring (Requirement 9.5) is task 3.3, not this task. Proven with a real fork+SIGINT test. 1297/1297 controller suite passing (+7). Task 2.1 of .kiro/specs/xce-test-cli-ux/tasks.md. Requirements: 9.1, 9.2, 9.3, 9.4, 9.6, 9.7, 9.8, 9.9 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f --- .kiro/specs/xce-test-cli-ux/tasks.md | 3 +- src/controller/meson.build | 4 +- src/controller/testsuite/confirm.c | 105 ++++++ src/controller/testsuite/confirm.h | 101 ++++++ src/controller/testsuite/controller_test.c | 2 + src/controller/testsuite/test.c | 14 + src/controller/testsuite/test_confirm.c | 353 +++++++++++++++++++++ 7 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 src/controller/testsuite/confirm.c create mode 100644 src/controller/testsuite/confirm.h create mode 100644 src/controller/testsuite/test_confirm.c diff --git a/.kiro/specs/xce-test-cli-ux/tasks.md b/.kiro/specs/xce-test-cli-ux/tasks.md index a14d6f91..a7b9b432 100644 --- a/.kiro/specs/xce-test-cli-ux/tasks.md +++ b/.kiro/specs/xce-test-cli-ux/tasks.md @@ -52,7 +52,7 @@ - _Boundary: Testing Infrastructure_ - [ ] 2. Core: independent new capabilities and standalone fixes -- [ ] 2.1 (P) Build the destructive-command confirmation gate and its session-scoped toggle +- [x] 2.1 (P) Build the destructive-command confirmation gate and its session-scoped toggle - Implement a confirmation check that prompts the operator on stderr before proceeding, returns a distinct "not run" outcome on decline, and treats an interrupt during the prompt the same as a decline - Add a bypass flag usable per invocation and a session-scoped toggle (mirroring the existing continue-on-error toggle's style) that both default to requiring confirmation and both reset at process start - Ensure the confirmation check is skipped automatically when input is not from an interactive session @@ -178,6 +178,7 @@ ## Implementation Notes - `src/tools/xce-build.sh controller asan xce.build --incremental` writes its build dir at `src/xce.build/` (relative to the script's own location), not a repo-root `xce.build/` — meson logs are at `src/xce.build/meson-logs/testlog.txt`. (task 1.1) - New Unity test files need two wiring points, not just meson.build's source list: also register an `extern void test_(void);` + `TEST(test_);` in `src/controller/testsuite/controller_test.c`, mirroring the existing `test_rcfile` registration, or the suite silently never runs. (task 1.1) +- `confirm_destructive()`'s EINTR-as-decline (9.9) works because it's a single, non-looping `fgets()` call — a naive "robust I/O" retry-on-EINTR wrapper would silently defeat this requirement. `g_interactive` is exposed via a new `xcetest_is_interactive()` accessor (declared in `confirm.h`, mirroring `xcetest_interrupted()`'s pattern) for task 3.3 to reuse. Task 3.3 must short-circuit around `confirm_destructive()` entirely (not pass a parameter into it) when a command's own `--yes` flag was given — the function has no bypass-flag parameter by design. (task 2.1) - The `test_test_cli.c` black-box harness (`clitest_*` functions) is file-static and single-file by design (matching design.md's File Structure Plan and this directory's own no-split norm) — tasks 2.3, 3.5, 4.1-4.4 should add their black-box test functions directly into this same file rather than a new one. `clitest_wait_for_text()` is generic (arbitrary needle/stream) and safe to call repeatedly mid-session; `clitest_send_signal()` targets the child's real pid via `kill()`. (task 1.7) - `argp` has no built-in `-h` alias (its default short help flag is `-?`) — Requirement 4.1 literally names `-h`, so it needed an explicit `OPTION_HIDDEN` option added, not just verification of existing defaults. Task 1.6's new `test_xcetest_process_entry.c` already covers "`-h`/`--help`/`--version` exit without entering the REPL" end-to-end against the real binary; task 1.7's `test_test_cli.c` should either skip re-adding this exact scenario or consolidate the two rather than duplicating coverage. (task 1.6) - The per-connection lock is a POSIX semaphore (`sem_t`), not a `pthread_mutex_t` — a mutex was tried first but rejected on review (POSIX classifies a non-owning-thread `pthread_mutex_unlock()` as UB for every mutex type, not just error-checking ones, and this design needs the worker thread to release a lock the caller thread acquired). `sem_t` has no ownership concept and is fully POSIX-defined for this pattern. See bug-1791. (task 1.5) diff --git a/src/controller/meson.build b/src/controller/meson.build index 310bbd2f..71279c31 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -55,7 +55,7 @@ controller = executable('xce_controller', link_args: wrap_link_args, install: true) -xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c', 'testsuite/rcfile.c', 'testsuite/flagspec.c', 'testsuite/interruptcall.c'] +xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c', 'testsuite/rcfile.c', 'testsuite/flagspec.c', 'testsuite/interruptcall.c', 'testsuite/confirm.c'] # BSD-licensed readline replacement (scheduler-core spec, task 12.1 review # finding): GNU readline is GPL; xce_test's REPL only needs readline()/ @@ -174,6 +174,8 @@ controller_unit_test_src = [ 'testsuite/test_xcetest_interrupt.c', 'testsuite/test_interruptcall.c', 'testsuite/interruptcall.c', + 'testsuite/test_confirm.c', + 'testsuite/confirm.c', 'testsuite/test_xcetest_process_entry.c', 'testsuite/test_test_cli.c', 'testsuite/test_persist_snapshot_entities.c', diff --git a/src/controller/testsuite/confirm.c b/src/controller/testsuite/confirm.c new file mode 100644 index 00000000..3210eb31 --- /dev/null +++ b/src/controller/testsuite/confirm.c @@ -0,0 +1,105 @@ +/* + * confirm.c -- destructive-command confirmation gate. See confirm.h for + * the full contract, return-value semantics, and design rationale + * (xce-test-cli-ux spec, task 2.1; Requirements 9.1-9.4, 9.6-9.9). + * + * IMPLEMENTATION NOTES + * + * - confirm_destructive() checks confirm_is_skip_set() and + * xcetest_is_interactive() BEFORE touching stdin or stderr at all -- + * both are pure, side-effect-free reads, so ordering between them + * doesn't matter, but checking them first is what makes the "proceed + * without printing or reading anything" postcondition (9.4, 9.7) hold. + * + * - The prompt read is exactly one fgets() call on stdin, not a loop. + * This is deliberate and is the crux of Requirement 9.9: glibc's + * buffered stdio does NOT internally retry a read() that returns + * -1/EINTR (it propagates the failure up as fgets() returning NULL + * with errno left at EINTR) -- so as long as this function itself + * never loops back to retry after seeing EINTR, a real Ctrl-C during + * the prompt reliably falls straight through to the decline path. A + * naive wrapper that did `while (fgets(...) == NULL) { if (errno == + * EINTR) continue; ... }` would defeat this entirely; no such loop + * exists here. (This assumes test.c's main() installs SIGINT via + * xcetest_install_sigint() -- SA_RESTART cleared -- per task 1.3; see + * confirm.h's precondition note.) + * + * - errno is reset to 0 immediately before the fgets() call. fgets() + * does not promise to clear a stale errno on success, and NULL is the + * only signal we get to even look at errno, so a stale nonzero errno + * from some earlier, unrelated libc call could otherwise be + * misread as this call's own EINTR. + */ +#include +#include +#include +#include + +#include "confirm.h" + +static int g_confirm_skip = 0; /* 0 == confirmation required (default at + * process start); mirrors + * g_continue_on_error's exact style + * (test.c). */ + +void confirm_set_skip(int skip) { g_confirm_skip = skip ? 1 : 0; } +int confirm_is_skip_set(void) { return g_confirm_skip; } + +/* Accepted spellings: "y" or "yes", case-insensitive, tolerant of + * incidental leading/trailing whitespace (including the trailing + * newline fgets() leaves in place). Anything else -- "n", "no", empty, + * garbage -- is NOT affirmative; confirm_destructive() maps every + * non-affirmative case to the same "not run" outcome (see confirm.h's + * two-valued return contract). */ +static int is_affirmative(const char* raw) +{ + char norm[8]; + size_t i = 0, n = 0; + + while (raw[i] != '\0' && isspace((unsigned char)raw[i])) + i++; + + while (raw[i] != '\0' && !isspace((unsigned char)raw[i]) && + n < sizeof(norm) - 1) { + norm[n++] = (char)tolower((unsigned char)raw[i]); + i++; + } + norm[n] = '\0'; + + return (strcmp(norm, "y") == 0) || (strcmp(norm, "yes") == 0); +} + +int confirm_destructive(const char* action_description) +{ + char buf[64]; + char* line; + + /* Requirement 9.6/9.7: the session-scoped toggle bypasses the prompt + * entirely, regardless of TTY state. Requirement 9.4: a non-interactive + * session bypasses it too. Neither path touches stdin/stderr. */ + if (confirm_is_skip_set() || !xcetest_is_interactive()) + return 1; + + /* Requirement 9.8: the prompt goes to stderr, never stdout -- a + * destructive command's successful result data (if any) must stay + * uncontaminated by this diagnostic text. Deliberately NOT routed + * through libedit's readline() (see confirm.h). */ + fprintf(stderr, "About to %s. Continue? [y/N] ", action_description); + fflush(stderr); + + errno = 0; + line = fgets(buf, sizeof(buf), stdin); + + if (line == NULL) { + /* Requirement 9.9: a real Ctrl-C during the prompt's blocking + * read surfaces as EINTR here (never retried -- see the file + * header) and is treated exactly like a decline, not a distinct + * "error" outcome. EOF (errno left at 0, or set to something + * other than EINTR by the underlying read) is likewise mapped + * to decline -- see confirm.h's return-contract note on why no + * third outcome exists. */ + return 0; + } + + return is_affirmative(buf) ? 1 : 0; +} diff --git a/src/controller/testsuite/confirm.h b/src/controller/testsuite/confirm.h new file mode 100644 index 00000000..ef0623ab --- /dev/null +++ b/src/controller/testsuite/confirm.h @@ -0,0 +1,101 @@ +#pragma once +/* + * confirm.h -- destructive-command confirmation gate: prompts the operator + * on stderr before a destructive command runs, offers a session-scoped + * toggle to skip that prompt, and treats a Ctrl-C during the prompt the + * same as declining it (xce-test-cli-ux spec, task 2.1; Requirements + * 9.1-9.4, 9.6-9.9. Requirement 9.5 -- the five-command enumeration and + * each cmd_*'s own `--yes` bypass flag -- is task 3.3, NOT this module; + * this module is the generic, command-agnostic gate mechanism only). + * + * THE MECHANISM (see design.md's "Confirmation Gate" component and its + * "Destructive Command Confirmation Gate" flowchart): + * - confirm_destructive() is the gate itself. It internally checks, in + * order: the session-scoped skip toggle (confirm_is_skip_set()), then + * whether the top-level session is even interactive + * (xcetest_is_interactive(), see below). If either says "don't + * prompt", it returns 1 (proceed) immediately WITHOUT touching stdin + * or printing anything. Otherwise it prints a yes/no prompt to + * stderr and performs one blocking read of the answer from stdin. + * - confirm_destructive() deliberately has NO parameter for a + * per-command `--yes` bypass flag -- that flag is per-INVOCATION, not + * session state, so it cannot be folded into confirm_is_skip_set()'s + * persistent toggle without leaking into the next command. Task 3.3's + * five call sites (cmd_jobkill, cmd_shutdown, cmd_qctrl, cmd_hostctrl, + * cmd_upgradeconfig) are responsible for short-circuiting around + * confirm_destructive() entirely when their OWN --yes flag was given, + * e.g.: + * if (!opts.yes && !confirm_destructive("kill job 1234")) + * return NOT_RUN; + * (design.md's Implementation Notes describes this same three-way + * bypass -- toggle, TTY, --yes -- as one boolean expression each call + * site evaluates; confirm_destructive() itself owns the first two, + * the call site owns the third because only it knows its own flag.) + * + * RETURN CONTRACT (confirm_destructive): exactly two outcomes, matching + * design.md's Service Interface postcondition -- no third "error" case is + * defined for this function. + * 1 -- proceed: skip-toggle set, OR non-interactive session, OR the + * operator answered affirmatively ("y"/"yes", case-insensitive, + * surrounding whitespace ignored). + * 0 -- not run: the operator declined (anything else, including a bare + * Enter or unrecognized text), OR stdin hit EOF, OR the prompt's + * blocking read was interrupted by a real Ctrl-C/SIGINT (EINTR; + * Requirement 9.9 -- treated identically to a decline, never + * retried). The caller must report this as "not run", distinct + * from "failed" (Requirement 9.2/9.9). + * + * WHY THE PROMPT READ IS A PLAIN fgets(), NOT libedit's readline(): a + * y/n answer routed through readline() would pollute the session's + * command history and behave inconsistently when stdin isn't a TTY + * (design.md's Implementation Notes / research.md). It also would not + * reliably surface EINTR the way a bare blocking libc read does. + * + * WHY EINTR IS RELIABLE HERE: task 1.3 (xcetest_interrupt.h, + * xcetest_install_sigint()) replaced test.c's old `signal(SIGINT, + * sigbreak)` installation with an explicit sigaction() call with + * SA_RESTART cleared, once, at process startup. That is a PRECONDITION + * of this module, not something confirm_destructive() re-checks on every + * call -- see design.md's Service Interface preconditions. + */ + +/* Prompts the operator (on stderr) to confirm a destructive action + * described by `action_description` (a short phrase completing a + * sentence like "About to . Continue? [y/N] " -- + * e.g. "kill job 1234"), unless the skip toggle is set or the session + * isn't interactive, in which case it returns 1 without printing + * anything or reading stdin. See the return contract above. */ +int confirm_destructive(const char* action_description); + +/* Sets/gets the session-scoped confirmation-skip toggle (Requirement + * 9.6/9.7), in the exact same style as test.c's existing g_continue_on_error + * `-e`/`+e` toggle: a plain `static int`, defaulting to 0 (confirmation + * required -- NOT skipped) at process start, flipped by `cmd_set` (task + * 3.3 wires the `set` command's new branch; this module only owns the + * flag itself). While set, confirm_destructive() proceeds without + * prompting regardless of TTY state, so a `source`d script (Requirement + * 13) can opt in to unattended destructive commands explicitly. */ +void confirm_set_skip(int skip); +int confirm_is_skip_set(void); + +/* Read-only accessor over test.c's private `g_interactive` flag (set once + * in main() from isatty(STDIN_FILENO), never changed after that) -- + * mirrors xcetest_interrupted()'s accessor pattern over test.c's private + * `break_out` flag (xcetest_interrupt.h, task 1.3). Declared here, not in + * xcetest_interrupt.h, because confirm.c is its only consumer; g_interactive + * itself is unrelated to that header's SIGINT/EINTR concern. + * + * Per design.md's Confirmation Gate invariant, this is deliberately the + * TOP-LEVEL session's own stdin, not "is this line currently being read + * from a sourced file" -- Requirement 13.7 (a later task) depends on that + * distinction: a destructive command inside a `source`d file in an + * otherwise-interactive session must still prompt unless the skip toggle + * is set. + * + * Implemented in test.c. test.c itself is never linked into + * controller_unit_test (it pulls in libedit/cJSON/libxce/Cap'n Proto/ + * OpenSSL and defines its own main() -- see xcetest_interrupt.h's header + * comment for the same reasoning applied to xcetest_interrupted()), so + * test_confirm.c supplies its own local fake definition, exactly as + * test_interruptcall.c already does for xcetest_interrupted(). */ +int xcetest_is_interactive(void); diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index abeb3ffa..2b6b7293 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -74,6 +74,7 @@ extern void test_rcfile(void); extern void test_flagspec(void); extern void test_xcetest_interrupt(void); extern void test_interruptcall(void); +extern void test_confirm(void); extern void test_xcetest_process_entry(void); extern void test_test_cli(void); extern void test_drive_async_config(void); @@ -259,6 +260,7 @@ int main(int argc, char* argv[]) TEST(test_flagspec); TEST(test_xcetest_interrupt); TEST(test_interruptcall); + TEST(test_confirm); TEST(test_xcetest_process_entry); TEST(test_test_cli); TEST(test_drive_async_config); diff --git a/src/controller/testsuite/test.c b/src/controller/testsuite/test.c index 79ff8b57..397e1927 100644 --- a/src/controller/testsuite/test.c +++ b/src/controller/testsuite/test.c @@ -30,6 +30,7 @@ #include "snapdiff.h" /* byte-level checkpoint-file comparator (cmd_snapdiff) */ #include "snapverify.h" /* offline checkpoint-file structural validator (cmd_snapverify) */ #include "configcompare.h" /* cross-member active-config comparator (cmd_configcompare) */ +#include "confirm.h" /* destructive-command confirmation gate (xce-test-cli-ux task 2.1) */ #include "rcfile.h" /* startup rc-file path resolution + loading (xce-test-rc-file task 2.2) */ #include "resval_typeresolve.h" /* resval_resolve_type (resource-value-verification task 2.1) */ #include "xce/cluster.h" /* xce_parse_peers, xce_cluster_config_t */ @@ -734,6 +735,19 @@ void sigbreak(int signo) { break_out = 1; } * test.c-private). */ int xcetest_interrupted(void) { return break_out; } +/* xce-test-cli-ux task 2.1, Requirement 9.4/9.6/13.7: read-only accessor + * over `g_interactive` (this file's top-level-session TTY flag, set once + * in main() from isatty(STDIN_FILENO) and never changed after that) for + * code outside this file (confirm.c) to consult -- mirrors + * xcetest_interrupted()'s accessor pattern immediately above. Pure read; + * declared in confirm.h rather than xcetest_interrupt.h since confirm.c + * is its only consumer and g_interactive is unrelated to that header's + * SIGINT/EINTR concern. Deliberately the TOP-LEVEL session's stdin, not + * "is this line from a sourced file" -- that distinction is Requirement + * 13.7, a later task; this accessor's contract must already be correct + * for it, per design.md's Confirmation Gate invariant. */ +int xcetest_is_interactive(void) { return g_interactive; } + static char* get_setting(char* key) { int i; diff --git a/src/controller/testsuite/test_confirm.c b/src/controller/testsuite/test_confirm.c new file mode 100644 index 00000000..051f849f --- /dev/null +++ b/src/controller/testsuite/test_confirm.c @@ -0,0 +1,353 @@ +/* + * test_confirm.c -- proves confirm.c's destructive-command confirmation + * gate (xce-test-cli-ux spec, task 2.1; Requirements 9.1-9.4, 9.6-9.9): + * + * 1. A non-interactive session (xcetest_is_interactive() false) makes + * confirm_destructive() proceed immediately WITHOUT ever reading + * stdin (9.4). + * 2. A declining answer ("n", or anything not affirmative, or EOF) + * makes it return "not run" (0), not "failed" (9.2). + * 3. A REAL SIGINT delivered while confirm_destructive() is blocked in + * its prompt's read makes it return "not run" (0), identically to a + * decline -- not a crash, not a hang, not a distinct outcome (9.9). + * 4. The confirm_set_skip()/confirm_is_skip_set() toggle genuinely + * switches whether the read ever happens: with the toggle ON, + * confirm_destructive() returns "proceed" (1) even when stdin holds + * a decline answer that would otherwise cause "not run" (9.6/9.7). + * + * xcetest_is_interactive() (declared in confirm.h) is normally implemented + * in test.c, over test.c's own private g_interactive flag -- but test.c + * itself is never linked into controller_unit_test (it pulls in libedit/ + * cJSON/libxce/Cap'n Proto/OpenSSL and defines its own main(); see + * xcetest_interrupt.h's header comment for the identical reasoning + * already applied to xcetest_interrupted()). So this file provides its + * OWN local fake definition of xcetest_is_interactive(), over its own + * local flag -- the same "local fake standing in for a real dependency + * this binary never links" pattern test_interruptcall.c already uses for + * xcetest_interrupted(). + * + * STDIN REDIRECTION TECHNIQUE (scenarios 1, 2, 4 -- all in-process, not + * forked): rather than dup2()-ing a new fd onto the persistent + * STDIN_FILENO while reusing the single process-wide `stdin` FILE* + * object (which risks stale buffered bytes surviving from a previous + * redirection), each test opens its fixture as a fresh fd and swaps the + * global `stdin` pointer itself to a brand-new fdopen()'d FILE* -- glibc + * declares `stdin` as a plain `extern FILE *stdin;`, so this reassignment + * is well-defined. A fresh fdopen() guarantees a clean buffer with no + * carry-over from any earlier test in this same combined Unity binary. + * Each test restores the original `stdin` pointer before returning. + * + * Scenario 3 (the real-SIGINT case) is instead a fork()-based test, NOT + * an in-process one -- mirroring test_xcetest_interrupt.c's and + * test_interruptcall.c's existing convention in this exact directory. + * Forking (rather than juggling stdin/alarms in-process) means a + * regression that makes the prompt's read silently retry on EINTR (the + * exact bug Requirement 9.9 exists to prevent) only hangs a short-lived + * CHILD process, which a 2-second alarm() safety net self-terminates -- + * it can never hang this whole Unity binary or the rest of the suite. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "confirm.h" +#include "xcetest_interrupt.h" + +/* --- local fake for xcetest_is_interactive() (see file header) --- */ +static volatile int g_test_interactive = 1; + +int xcetest_is_interactive(void) { return g_test_interactive; } + +/* ---- test-local temp dir + fixture-file helpers (mirrors + * test_rcfile.c's/test_configcompare.c's fresh_dir()/cleanup_dir() + * convention) ---- */ +static char g_dir[256]; + +static void fresh_dir(void) +{ + strcpy(g_dir, "/tmp/xce_confirm_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) +{ + FILE* f = fopen(path, "wb"); + size_t len = strlen(content); + TEST_ASSERT_NOT_NULL(f); + if (len > 0) + TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(content, 1, len, f)); + fclose(f); +} + +/* ---- in-process stdin redirection (scenarios 1, 2, 4) ---- */ +static FILE* g_saved_stdin = NULL; + +static void redirect_stdin_from_fd(int fd) +{ + TEST_ASSERT_NULL(g_saved_stdin); /* catch a leaked prior redirection */ + g_saved_stdin = stdin; + stdin = fdopen(fd, "r"); /* fresh FILE*, fresh buffer, owns fd */ + TEST_ASSERT_NOT_NULL(stdin); + setvbuf(stdin, NULL, _IONBF, 0); /* belt-and-suspenders: no buffering + * to ever carry residue at all */ +} + +static void redirect_stdin_from_file(const char* path) +{ + int fd = open(path, O_RDONLY); + TEST_ASSERT_TRUE(fd >= 0); + redirect_stdin_from_fd(fd); +} + +static void restore_stdin(void) +{ + TEST_ASSERT_NOT_NULL(g_saved_stdin); + fclose(stdin); /* closes the fixture fd */ + stdin = g_saved_stdin; + g_saved_stdin = NULL; +} + +/* ---- Scenario 1: non-interactive session (Requirement 9.4) ---- */ + +/* The fixture answer is "n\n" -- a DECLINE -- specifically so that if + * confirm_destructive() incorrectly attempted to read it despite being + * told the session isn't interactive, the test would observe a 0 + * (not-run) instead of the expected 1 (proceed), catching the bug via + * the WRONG return value rather than merely "didn't crash". */ +static void test_non_interactive_session_proceeds_without_reading_stdin(void) +{ + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, "n\n"); + + g_test_interactive = 0; + confirm_set_skip(0); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + + TEST_ASSERT_EQUAL_INT(1, rc); + + g_test_interactive = 1; /* restore default for later tests */ + cleanup_dir(); +} + +/* ---- Scenario 2: decline / EOF (Requirement 9.2) ---- */ + +static void test_decline_answer_returns_not_run(void) +{ + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, "n\n"); + + g_test_interactive = 1; + confirm_set_skip(0); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + + TEST_ASSERT_EQUAL_INT(0, rc); + cleanup_dir(); +} + +static void test_garbage_answer_returns_not_run(void) +{ + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, "maybe later\n"); + + g_test_interactive = 1; + confirm_set_skip(0); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + + TEST_ASSERT_EQUAL_INT(0, rc); + cleanup_dir(); +} + +/* EOF (an empty fixture, no bytes at all) is distinguished internally by + * confirm.c from a genuine decline (fgets() returns NULL with errno NOT + * EINTR, vs. returning a real "n\n" string) but converges on the exact + * same two-valued contract confirm_destructive() documents: 0/not-run. + * design.md's Service Interface defines no third "error" outcome for + * this function, so EOF is treated as the safer of the two available + * answers rather than inventing a new return value. */ +static void test_eof_on_prompt_returns_not_run(void) +{ + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, ""); + + g_test_interactive = 1; + confirm_set_skip(0); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + + TEST_ASSERT_EQUAL_INT(0, rc); + cleanup_dir(); +} + +/* Affirmative spelling coverage: "y"/"yes", case-insensitive, tolerant of + * incidental surrounding whitespace -- the exact accepted spellings + * confirm.h documents. */ +static void test_affirmative_answers_return_proceed(void) +{ + static const char* answers[] = { "y\n", "Y\n", "yes\n", "YES\n", + " yes \n", "Yes" }; + + for (size_t i = 0; i < sizeof(answers) / sizeof(answers[0]); i++) { + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, answers[i]); + + g_test_interactive = 1; + confirm_set_skip(0); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc, answers[i]); + cleanup_dir(); + } +} + +/* ---- Scenario 4: the session-scoped skip toggle (Requirements 9.6/9.7) ---- */ + +/* Proves the toggle is a genuine short-circuit, not just a return-value + * override: with the SAME declining fixture content, toggling skip ON + * proceeds (never consults the decline) and toggling it back OFF makes + * the identical content decline again. */ +static void test_skip_toggle_bypasses_and_restores_prompting(void) +{ + fresh_dir(); + char path[300]; + snprintf(path, sizeof(path), "%s/answer", g_dir); + write_file(path, "n\n"); /* would decline if actually read */ + + g_test_interactive = 1; + + TEST_ASSERT_EQUAL_INT(0, confirm_is_skip_set()); + confirm_set_skip(1); + TEST_ASSERT_EQUAL_INT(1, confirm_is_skip_set()); + + redirect_stdin_from_file(path); + int rc = confirm_destructive("do something destructive"); + restore_stdin(); + TEST_ASSERT_EQUAL_INT(1, rc); /* proceeded despite the decline fixture */ + + confirm_set_skip(0); + TEST_ASSERT_EQUAL_INT(0, confirm_is_skip_set()); + + redirect_stdin_from_file(path); /* same fixture content, fresh fd */ + rc = confirm_destructive("do something destructive"); + restore_stdin(); + TEST_ASSERT_EQUAL_INT(0, rc); /* toggle off: prompting is back */ + + cleanup_dir(); +} + +/* ---- Scenario 3: a real SIGINT during the blocking prompt read + * (Requirement 9.9) -- fork()-based, see file header. ---- */ + +#define CHILD_EXIT_NOT_RUN 4 /* confirm_destructive() returned 0 -- expected */ +#define CHILD_EXIT_PROCEED 5 /* returned 1 -- wrong: SIGINT was ignored */ +#define CHILD_EXIT_TIMED_OUT 6 /* alarm fired first -- EINTR was silently + * retried instead of propagating, exactly + * the bug Requirement 9.9 exists to catch */ + +static void child_alarm_handler(int signo) +{ + (void)signo; + _exit(CHILD_EXIT_TIMED_OUT); +} + +/* Stands in for test.c's sigbreak(): any real, non-default SIGINT handler + * -- only its existence (not SIG_DFL/SIG_IGN) matters for whether the + * blocked read() restarts or returns EINTR once xcetest_install_sigint() + * (task 1.3) has cleared SA_RESTART. */ +static void child_sigint_noop_handler(int signo) { (void)signo; } + +static void test_sigint_during_prompt_returns_not_run_like_decline(void) +{ + int pipefd[2]; + TEST_ASSERT_EQUAL_INT(0, pipe(pipefd)); + + pid_t pid = fork(); + TEST_ASSERT_TRUE(pid >= 0); + + if (pid == 0) { + /* child: never writes to the pipe, so confirm_destructive()'s + * fgets() can only unblock via the SIGINT the parent sends below, + * or this alarm safety net if that fix regresses. */ + close(pipefd[1]); + + signal(SIGALRM, child_alarm_handler); + alarm(2); + + xcetest_install_sigint(child_sigint_noop_handler); /* task 1.3's + * installer -- + * the exact + * one test.c's + * real main() + * uses */ + g_test_interactive = 1; + confirm_set_skip(0); + redirect_stdin_from_fd(pipefd[0]); + + int rc = confirm_destructive("do something destructive"); + _exit(rc == 0 ? CHILD_EXIT_NOT_RUN : CHILD_EXIT_PROCEED); + } + + /* parent */ + close(pipefd[0]); + + usleep(200000); /* give the child time to install its handler and + * reach the blocking fgets() before we signal it */ + TEST_ASSERT_EQUAL_INT(0, kill(pid, SIGINT)); + + int status = 0; + TEST_ASSERT_EQUAL_INT(pid, waitpid(pid, &status, 0)); + close(pipefd[1]); + + TEST_ASSERT_TRUE(WIFEXITED(status)); + TEST_ASSERT_EQUAL_INT(CHILD_EXIT_NOT_RUN, WEXITSTATUS(status)); +} + +void test_confirm(void) +{ + UnitySetTestFile(__FILE__); + RUN_TEST(test_non_interactive_session_proceeds_without_reading_stdin); + RUN_TEST(test_decline_answer_returns_not_run); + RUN_TEST(test_garbage_answer_returns_not_run); + RUN_TEST(test_eof_on_prompt_returns_not_run); + RUN_TEST(test_affirmative_answers_return_proceed); + RUN_TEST(test_skip_toggle_bypasses_and_restores_prompting); + RUN_TEST(test_sigint_during_prompt_returns_not_run_like_decline); +}