myxce/src/controller/testsuite/confirm.c
Rongsong Shen a2686516f7
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
2026-09-09 11:41:05 +00:00

105 lines
4.3 KiB
C

/*
* 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;
}