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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
This commit is contained in:
Rongsong Shen 2026-09-09 11:41:05 +00:00
parent 7d0ac6f6eb
commit a2686516f7
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
7 changed files with 580 additions and 2 deletions

View file

@ -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_<module>(void);` + `TEST(test_<module>);` 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)

View file

@ -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',

View file

@ -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 <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#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;
}

View file

@ -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 <action_description>. 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);

View file

@ -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);

View file

@ -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;

View file

@ -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 <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#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);
}