/* * test_admin_request.c - contract test for the scheduler-side * OPCode_schedAdmin request handler (scheduler-core spec, task 9.4, Req 5 * AC 10). This is the scheduler-side counterpart to task 10.1's * controller-side forwarding dispatch (out of THIS spec's boundary): the * controller relays a SchedAdminRequest verbatim to this endpoint, and this * file proves the scheduler decodes it, dispatches by action to * sched_record_start()/sched_record_stop()/sched_record_status() (tasks * 9.2/9.3/9.4), and sends back a SchedAdminReply whose fields match * requirements.md's "Reply field semantics by action" table. * * Observable under test: * - recordStatus is answered SYNCHRONOUSLY, on the calling (handler) * thread, directly from handler.c's sched_handle_admin_request() -- * never enqueued -- both when not recording (all-zero/empty reply) and * while genuinely recording (live event count/file/started-at). * - recordStart/recordStop are DEFERRED: enqueued onto the same * pend_updates dataq every other controller-sourced update flows * through (XCE_SCHED_OP_ADMIN_RECORD), NOT executed synchronously -- * `sched->recording` is provably unchanged immediately after * scheduler_req_handler() returns -- and only actually performed (real * sched_record_start()/sched_record_stop() call, real reply send) by a * subsequent xce_sched_driver_translate_pending_events() drain * (driver.c's handle_deferred_admin_record()). * - recordStart while already recording / recordStop while not recording * reply {ok=false, error="already recording"/"not recording"} with the * table's documented (unchanged) field values. * - An invalid recordFile (path separator) surfaces all the way through * the deferred path as {ok=false, error="invalid record file name"}, * never just an internal log line, and never actually opens a file. * - The same rejection holds for a ".."-traversal recordFile and for a * pre-planted symlink inside record_dir that escapes outside it -- * both submitted through this same real admin request path (task * 9.1's sched_validate_record_filename() contract), not just proven at * the internal validation-function level * (test_recording_path_validation.c) or the single path-separator case * above (task 9.5's genuine-gap audit). * - A genuine write failure (the underlying fd closed out from under the * open sched->record_file, task 9.3's own real-I/O technique) that * occurs while recording -- itself started through this same real * admin request path -- cleanly disables recording via * sched_record_abort_on_error(); a subsequent recordStatus through * this SAME real handler entry point correctly reports * recording=false; and a further admin request afterward still * succeeds cleanly (task 9.5's disk-full Observable, proven through * the request-handler path specifically, not just the internal * driver-loop path task 9.3 already proved). * - An unrecognized SchedAdminAction returns -EINVAL, logs, and neither * enqueues anything nor sends any reply -- no crash. * * White-box #include of ../src/core.c, ../src/core_clock.c, * ../src/core_events.c, ../src/driver.c (same set test_driver_translate.c * uses to exercise xce_sched_driver_translate_pending_events() for real) * PLUS ../src/recording.c/recording_events.c (task 9.1/9.2/9.3's REAL * sched_record_start()/sched_record_stop()/sched_record_status()/ * sched_record_write_events() -- this file's whole point is proving the * deferred path performs REAL file I/O, so these must be genuine, not * stubbed) PLUS ../src/snapshot.c/snapshot_wire.c (task 8.1's real * xce_sched_core_save_snapshot(), needed by sched_record_start()) PLUS * ../src/handler.c (task 9.4's sched_handle_admin_request()/ * scheduler_req_handler(), the code under test). * * recording_session.c is NOT linked: its sched_record_session_begin()/ * sched_record_session_add_decision()/sched_record_session_end() trio is * only reachable from xce_sched_driver_run_normal_mode_round(), which this * file never calls (it drives xce_sched_driver_translate_pending_events() * directly, same as test_driver_translate.c) -- stubbed below purely to * satisfy the linker, same rationale as that file's identical stubs for the * same reason. Likewise core_session.c/plugin.c are not linked (their * exported functions are stubbed below); no real scheduling session is ever * run by any test in this file. * * xcecli_reply_schedadmin() is a SPY test double (client.c's real * implementation needs a live capnp connection this file never sets up) -- * same pattern test_driver_decision_cb.c already establishes for * sched_publish_dispatch()/sched_publish_pend_reason(). It deep-copies * every field of the passed sched_admin_reply_t (including string fields) * into fixed-size static storage rather than retaining the passed pointers: * both handle_deferred_admin_record() (recordStop's `file`) and * sched_record_status() (`file`, a BORROWED alias of * sched->record_file_path) document that their reply pointers are only * valid for the duration of the synchronous call -- recordStop's in * particular aliases a stack-local buffer that is gone the instant * handle_deferred_admin_record() returns, so retaining the raw pointer * would be a stack-use-after-return read the very next line. */ #include #include #include #include #include #include #include #include #include #include #include "controller.capnp.h" #include "dataq.h" #include "kvmgr.h" #include "mylock.h" #include "scheduler.h" #include "xstring.h" /* Pull in the real functions under test into this TU (white-box #include, * same convention as test_driver_translate.c/test_record_lifecycle.c). */ #include "../src/core.c" #include "../src/core_clock.c" #include "../src/core_events.c" #include "../src/driver.c" /* Hooked sources compile SCHED_TRACE bodies (scheduler-sim-decision-trace). */ #include "../src/sched_trace.c" #include "../src/sched_trace_scan.c" #include "../src/sched_trace_capture.c" #include "../src/sched_trace_outcome.c" #include "../src/recording.c" #include "../src/recording_events.c" #include "../src/snapshot.c" #include "../src/snapshot_wire.c" void kill_sched_job(void* arg) { (void)arg; } void kill_sched_queue(void* arg) { (void)arg; } void kill_sched_worker(void* arg) { (void)arg; } void kill_sched_user(void* arg) { (void)arg; } void kill_sched_project(void* arg) { (void)arg; } void kill_sched_resval(xce_val_t* val) { (void)val; } /* resource-accounting.md's reconciliation (resource.c, not linked here -- * this file only #includes core_events.c). core_events.c's * XCE_CORE_EVENT_JOB_RUSAGE handler calls this whenever job->accounts != * NULL; link-satisfying stub only, same rationale as every other stub in * this block (see test_job_update.c's identical stub). */ int sched_reconcile_job_resource_usage(xce_sched_core_t* c, xce_job_account_t* a, xce_job_rusages_t* r) { (void)c; (void)a; (void)r; return 0; } void plugin_unregister(void* arg) { (void)arg; } /* Link-satisfying stubs for scheduler.c's sched_publish_dispatch()/ * sched_publish_pend_reason() -- driver.c's (real, #included above) * xce_sched_driver_on_decision() references both; never actually invoked by * any test in this file (no session is ever run, so no decision is ever * drained). Same rationale as test_driver_translate.c's identical stubs. */ int sched_publish_dispatch(xce_scheduler_t* sched, xce_job_dispatch_t* d) { (void)sched; (void)d; return 0; } int sched_publish_pend_reason(xce_scheduler_t* sched, xce_preason_set_t* r) { (void)sched; (void)r; return 0; } /* Link-satisfying stubs: xce_sched_driver_run_normal_mode_round() (driver.c, * real, #included above, but never CALLED by any test here -- this file * only drives xce_sched_driver_translate_pending_events() directly, same as * test_driver_translate.c) references xce_core_begin_session()/ * xce_core_signal_go()/xce_core_end_session() (core_session.c) and * plugin_begin_session()/plugin_end_session() (plugin.c) / * sched_account_jobs() (scheduler.c). Never actually invoked at runtime. */ int xce_core_begin_session(xce_sched_core_t* core) { (void)core; return 0; } int xce_core_signal_go(xce_sched_core_t* core) { (void)core; return 0; } int xce_core_end_session(xce_sched_core_t* core) { (void)core; return 0; } int plugin_begin_session(xce_sched_core_t* core) { (void)core; return 0; } int plugin_end_session(xce_sched_core_t* core) { (void)core; return 0; } int sched_account_jobs(xce_sched_core_t* core) { (void)core; return 0; } /* bug-1836 (scheduler-driver-equivalence fix, Recorded-Decision * Reconciliation capacity gate): link-satisfying stubs, mirroring this * file's existing sched_account_jobs() no-op precedent immediately above -- * sim_loop.c's sim_reconcile_unaccounted_jobs() now also calls * sched_check_host_satisfy()/sched_check_shared_satisfy() (resource.c) to * re-validate a recorded decision against live capacity, and * sched_charge_account() (resource.c) to keep that check's own view of * capacity up to date across multiple candidates in the same round. This * file never exercises real resource contention (same rationale as the * existing sched_account_jobs()/sched_ctx_get_uncomit_host() no-ops), so * the satisfy checks trivially always succeed and the charge call is a * no-op. */ int sched_check_host_satisfy(xce_sched_core_t* core, xce_sched_worker_t* w, xvec_t* uncomitted, xvec_idval_t* wanted, xvec_idval_t* unmeet) { (void)core; (void)w; (void)uncomitted; (void)wanted; (void)unmeet; return 1; } int sched_check_shared_satisfy(xce_sched_core_t* core, xvec_t* uncomitted, xvec_idval_t* wanted, xvec_idval_t* unmeet) { (void)core; (void)uncomitted; (void)wanted; (void)unmeet; return 1; } int sched_charge_account(xce_sched_core_t* core, xce_job_account_t* acct) { (void)core; (void)acct; return 0; } int sched_uncharge_account(xce_sched_core_t* core, xce_job_account_t* acct) { (void)core; (void)acct; return 0; } /* Link-satisfying stubs for recording_session.c's real functions -- see * this file's header comment for why recording_session.c itself is not * linked. Never actually invoked (no session is ever run). */ sched_record_session_ctx_t* sched_record_session_begin(xce_scheduler_t* sched, uint64_t begin_round_ts) { (void)sched; (void)begin_round_ts; return NULL; } void sched_record_session_add_decision(sched_record_session_ctx_t* ctx, uint64_t ts, xce_decision_t* decision) { (void)ctx; (void)ts; (void)decision; } int sched_record_session_end(xce_scheduler_t* sched, sched_record_session_ctx_t* ctx, uint64_t end_ts) { (void)sched; (void)ctx; (void)end_ts; return 0; } /* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same * rationale as test_driver_translate.c's own copy (schedobj.c is not * linked). Never actually exercised (no test here pushes core events, so * xce_core_push_events() -- the only real caller -- is never reached: every * enqueued request in this file is XCE_SCHED_OP_ADMIN_RECORD, intercepted * by driver.c BEFORE the translated batch/push path). Present only to * satisfy the linker for core_events.c. */ int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out) { (void)core; (void)out; return -EINVAL; } /* Link-satisfying stub for scheduler.c's sched_init_config() -- handler.c's * (real, #included below) OPCode_push/configpushmsg case references it; * this file never exercises that path (out of scope -- this file's whole * surface is OPCode_schedAdmin). Never actually invoked. */ int sched_init_config(xce_scheduler_t* s, xce_conf4sched_t* c) { (void)s; (void)c; return 0; } /* bug-1846: handler.c's config-push path now stashes via sched_stash_config() * (scheduler.c, not linked here) instead of calling sched_init_config() -- * link-satisfying stub, same rationale as the one above. */ int sched_stash_config(xce_scheduler_t* s, xce_conf4sched_t* c) { (void)s; (void)c; return 0; } /* Pull in the real sched_handle_admin_request()/scheduler_req_handler() * under test into this TU. */ #include "../src/handler.c" /* ---------------------------------------------------------------------- */ /* Spy test double for client.c's xcecli_reply_schedadmin() -- see file */ /* header comment for why every field is deep-copied rather than retaining */ /* the passed sched_admin_reply_t's reply->file/reply->error pointers. */ /* ---------------------------------------------------------------------- */ static int g_reply_calls; static uint64_t g_last_seq; static xcecli_ctx_t g_last_ctx; static int g_last_ok; static int g_last_error_null; static char g_last_error[256]; static int g_last_recording; static int g_last_file_null; static char g_last_file[PATH_MAX]; static uint64_t g_last_event_count; static uint64_t g_last_started_at; static void reset_reply_spy(void) { g_reply_calls = 0; g_last_seq = 0; g_last_ctx = NULL; g_last_ok = 0; g_last_error_null = 1; g_last_error[0] = '\0'; g_last_recording = 0; g_last_file_null = 1; g_last_file[0] = '\0'; g_last_event_count = 0; g_last_started_at = 0; } int xcecli_reply_schedadmin(xcecli_ctx_t c, uint64_t seq, sched_admin_reply_t* areply) { g_reply_calls++; g_last_ctx = c; g_last_seq = seq; if (areply == NULL) { return 0; } g_last_ok = areply->ok; g_last_recording = areply->recording; g_last_event_count = areply->eventCount; g_last_started_at = areply->startedAt; g_last_error_null = (areply->error == NULL); g_last_error[0] = '\0'; if (areply->error != NULL) { strncpy(g_last_error, areply->error, sizeof(g_last_error) - 1); } g_last_file_null = (areply->file == NULL); g_last_file[0] = '\0'; if (areply->file != NULL) { strncpy(g_last_file, areply->file, sizeof(g_last_file) - 1); } return 0; } void setUp(void) { reset_reply_spy(); } void tearDown(void) { return; } /* ---------------------------------------------------------------------- */ /* Fixture helpers -- same pattern as test_record_lifecycle.c's own. */ /* ---------------------------------------------------------------------- */ static void make_tmp_dir(char* out, size_t out_len, const char* suffix) { char tmpl[PATH_MAX]; snprintf(tmpl, sizeof(tmpl), "/tmp/sched_adminreq%s_XXXXXX", suffix); TEST_ASSERT_NOT_NULL(mkdtemp(tmpl)); TEST_ASSERT_TRUE(strlen(tmpl) < out_len); strcpy(out, tmpl); } /* Same helper as test_recording_path_validation.c's own touch_file() -- * creates an empty regular file, used below to build a genuine symlink * target outside record_dir for the symlink-escape-through-the-admin-path * test. */ static void touch_file(const char* path) { int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); TEST_ASSERT_TRUE(fd >= 0); close(fd); } /* `record_dir` must outlive `sched` (caller-owned storage), same convention * as test_record_lifecycle.c's fixture_init(). No picker plugin is * registered -- unlike test_record_lifecycle.c's fixture, no test in this * file ever runs a scheduling session, so core->job_pick_plugin staying * NULL is fine (sched_resolve_job_picker() above is never actually * reached). */ static void fixture_init(xce_scheduler_t* sched, char* record_dir) { int rc; memset(sched, 0, sizeof(*sched)); /* This fixture hand-builds xce_scheduler_t rather than calling the full * sched_init() (which starts real threads/network I/O this white-box * suite never wants) -- record_lock (scheduler.h field comments, task * 9.4 fix round) is normally initialized by sched_init(), so it must be * initialized here too, the same way core.lock is initialized by the * xce_sched_core_init() call just below. Every test in this file * exercises sched_record_status()/sched_record_start()/ * sched_record_stop()/sched_record_write_events() for real, all of * which now take this lock. */ mylock_init(&(sched->record_lock)); rc = xce_sched_core_init(&(sched->core)); TEST_ASSERT_EQUAL_INT(0, rc); sched->core.pend_updates[0] = (dataq_t*)calloc(1, sizeof(dataq_t)); dataq_init(sched->core.pend_updates[0], NULL); sched->core.pend_updates[1] = (dataq_t*)calloc(1, sizeof(dataq_t)); dataq_init(sched->core.pend_updates[1], NULL); sched->record_dir = record_dir; /* Dummy non-NULL xcecli_ctx_t (an opaque void*) -- xcecli_reply_schedadmin * is spied above, so nothing ever dereferences this; a real production * ctx would come from xcecli_ctx_init(). */ sched->apictx = (xcecli_ctx_t)sched; } /* Builds a request/reply pair on the stack and drives it through the REAL * production entry point, scheduler_req_handler() (handler.c) -- exactly * the function main.c registers as the scheduler's request handler and the * function that dispatches OPCode_schedAdmin to sched_handle_admin_request() * (handler.c's own new `case` this task added). */ static int send_admin_request(xce_scheduler_t* sched, int action, const char* record_file, uint64_t seq) { sched_admin_req_t areq; xce_request_t request; memset(&areq, 0, sizeof(areq)); areq.action = action; areq.recordFile = (char*)record_file; memset(&request, 0, sizeof(request)); request.opcode = OPCode_schedAdmin; request.seq = seq; request.body_which = Request_body_schedadminmsg; request.body.schedadminmsg = &areq; return scheduler_req_handler(&request, sched); } /* ---------------------------------------------------------------------- */ /* recordStatus -- synchronous, answered directly from the handler thread. */ /* ---------------------------------------------------------------------- */ static void test_recordstatus_not_recording_returns_empty_state(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_status_idle"); fixture_init(&sched, record_dir); rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 111); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(111, g_last_seq); TEST_ASSERT_EQUAL_PTR(sched.apictx, g_last_ctx); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at); /* recordStatus never touches the deferred queue. */ TEST_ASSERT_NULL(dataq_deq(sched.core.pend_updates[0], NULL)); } static void test_recordstatus_while_recording_reflects_live_state(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_core_event_t ev; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_status_live"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "live-status.replay"); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_TRUE(sched.recording); /* Real event writes via recording_events.c's genuine * sched_record_write_events() -- record_event_count genuinely advances, * not a fabricated field. */ memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = 7ULL; TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1)); ev.u.job_state_change.jobid = 8ULL; TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1)); TEST_ASSERT_EQUAL_UINT64(2ULL, sched.record_event_count); rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 222); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_TRUE(g_last_recording); TEST_ASSERT_FALSE(g_last_file_null); TEST_ASSERT_TRUE(strstr(g_last_file, "live-status.replay") != NULL); TEST_ASSERT_EQUAL_UINT64(2ULL, g_last_event_count); TEST_ASSERT_TRUE(g_last_started_at > 0); TEST_ASSERT_EQUAL_UINT64(sched.record_start_ts, g_last_started_at); } /* ---------------------------------------------------------------------- */ /* recordStatus concurrency -- record_lock (scheduler.h field comments, */ /* task 9.4 fix round) genuinely protects the recording-state fields */ /* against the vtask-thread sched_record_status() reader / driver-thread */ /* writer race the code review flagged. Two DETERMINISTIC proofs rather */ /* than a single flaky, purely timing-dependent race reproduction -- */ /* task 9.2's TOCTOU test already established this codebase's precedent */ /* of preferring a deterministic proof over a genuinely racy test: */ /* */ /* 1. record_lock is a REAL mutex a second, genuine pthread actually */ /* blocks on -- not just "the code calls mylock_lock()" verified by */ /* inspection. Proven by holding the lock on the main thread */ /* (simulating "driver thread is mid-mutation"), launching a real */ /* thread that calls sched_record_status(), and asserting it has NOT */ /* completed after a generous wait -- then asserting it DOES */ /* complete once the lock is released. The only timing dependency is */ /* in the safe direction: a slow CI runner can only make the "still */ /* blocked" assertion harder to observe, never produce a false */ /* positive (record_lock is genuinely held for the whole wait). */ /* 2. reply->file is a genuinely independent allocation, not an alias of */ /* the live sched->record_file_path -- proven by taking a status */ /* snapshot, then calling the real sched_record_stop() (which frees */ /* sched->record_file_path for real) and confirming the snapshot's */ /* reply->file is untouched. This reproduces the exact */ /* use-after-free the finding identified, deterministically, with no */ /* reliance on OS thread-scheduling timing at all. */ /* ---------------------------------------------------------------------- */ typedef struct { xce_scheduler_t* sched; sched_admin_reply_t reply; int rc; atomic_int done; } status_thread_arg_t; static void* status_thread_fn(void* arg) { status_thread_arg_t* a = (status_thread_arg_t*)arg; a->rc = sched_record_status(a->sched, &(a->reply)); atomic_store(&(a->done), 1); return NULL; } static void test_recordstatus_genuinely_blocks_on_record_lock(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; status_thread_arg_t arg; pthread_t th; make_tmp_dir(record_dir, sizeof(record_dir), "_status_lockblock"); fixture_init(&sched, record_dir); TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "lockblock.replay")); memset(&arg, 0, sizeof(arg)); arg.sched = &sched; atomic_store(&(arg.done), 0); /* Simulate "driver thread is mid-mutation": hold record_lock on THIS * (main test) thread, exactly the window sched_record_start()/ * sched_record_stop()/sched_record_abort_on_error() hold it for in * production. */ mylock_lock(&(sched.record_lock)); TEST_ASSERT_EQUAL_INT(0, pthread_create(&th, NULL, status_thread_fn, &arg)); /* Generous wait -- if record_lock were NOT actually taken by * sched_record_status() (i.e. the fix regressed to a no-op), the * spawned thread would complete almost instantly and `done` would * already be 1 here. */ usleep(150000); /* 150ms */ TEST_ASSERT_EQUAL_INT(0, atomic_load(&(arg.done))); mylock_unlock(&(sched.record_lock)); TEST_ASSERT_EQUAL_INT(0, pthread_join(th, NULL)); TEST_ASSERT_EQUAL_INT(1, atomic_load(&(arg.done))); TEST_ASSERT_EQUAL_INT(0, arg.rc); TEST_ASSERT_TRUE(arg.reply.recording); TEST_ASSERT_NOT_NULL(arg.reply.file); TEST_ASSERT_TRUE(strstr(arg.reply.file, "lockblock.replay") != NULL); free(arg.reply.file); TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched)); } static void test_recordstatus_file_survives_concurrent_record_stop_free(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; sched_admin_reply_t snapshot; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_status_uaf"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "uaftest.replay"); TEST_ASSERT_EQUAL_INT(0, rc); rc = sched_record_status(&sched, &snapshot); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_NOT_NULL(snapshot.file); /* Genuinely a DIFFERENT allocation from the live pointer -- not an * alias (the exact bug this fix closes: the pre-fix version set * reply->file = sched->record_file_path directly). */ TEST_ASSERT_TRUE(sched.record_file_path != snapshot.file); TEST_ASSERT_EQUAL_STRING(sched.record_file_path, snapshot.file); /* sched_record_stop() frees sched->record_file_path for real -- if * snapshot.file aliased it, this would be a genuine use-after-free * (ASan would abort) the moment the next assertion reads it. */ rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_TRUE(strstr(snapshot.file, "uaftest.replay") != NULL); free(snapshot.file); } /* ---------------------------------------------------------------------- */ /* recordStart -- deferred, not synchronous. */ /* ---------------------------------------------------------------------- */ static void test_recordstart_is_deferred_then_performed_by_driver_round(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char expect_path[PATH_MAX]; int rc; struct stat st; make_tmp_dir(record_dir, sizeof(record_dir), "_start_deferred"); fixture_init(&sched, record_dir); rc = send_admin_request(&sched, SchedAdminAction_recordStart, "admin-start.replay", 333); TEST_ASSERT_EQUAL_INT(0, rc); /* Deferred, not synchronous: no start yet, no reply yet. */ TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); /* A subsequent driver round actually performs the deferred start. */ rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); /* n==0: the admin op never enters the * translated event batch. */ TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file_path); TEST_ASSERT_TRUE(strstr(sched.record_file_path, "admin-start.replay") != NULL); /* The file is genuinely created on disk. */ snprintf(expect_path, sizeof(expect_path), "%s/admin-start.replay", record_dir); TEST_ASSERT_EQUAL_INT(0, stat(expect_path, &st)); TEST_ASSERT_TRUE(S_ISREG(st.st_mode)); /* And the deferred reply was genuinely sent, matching the "recordStart * (ok)" column of requirements.md's field-semantics table. */ TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(333, g_last_seq); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_TRUE(g_last_error_null); TEST_ASSERT_TRUE(g_last_recording); TEST_ASSERT_FALSE(g_last_file_null); TEST_ASSERT_TRUE(strstr(g_last_file, "admin-start.replay") != NULL); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_TRUE(g_last_started_at > 0); } static void test_recordstart_empty_file_auto_generates_name(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_start_auto"); fixture_init(&sched, record_dir); /* NULL recordFile -- "empty = auto-generate" (requirements.md's * SchedAdminRequest.recordFile field comment). */ rc = send_admin_request(&sched, SchedAdminAction_recordStart, NULL, 444); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file_path); TEST_ASSERT_TRUE(strstr(sched.record_file_path, record_dir) != NULL); TEST_ASSERT_TRUE(strstr(sched.record_file_path, ".replay") != NULL); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_FALSE(g_last_file_null); } static void test_recordstart_while_already_recording_replies_error(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_before[PATH_MAX]; uint64_t started_before; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_start_already"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "first.replay"); TEST_ASSERT_EQUAL_INT(0, rc); strcpy(path_before, sched.record_file_path); started_before = sched.record_start_ts; rc = send_admin_request(&sched, SchedAdminAction_recordStart, "second.replay", 555); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); /* Original recording untouched by the rejected second start. */ TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_EQUAL_STRING(path_before, sched.record_file_path); TEST_ASSERT_EQUAL_UINT64(0ULL, sched.record_event_count); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(555, g_last_seq); TEST_ASSERT_FALSE(g_last_ok); TEST_ASSERT_FALSE(g_last_error_null); TEST_ASSERT_EQUAL_STRING("already recording", g_last_error); /* "(unchanged)" -- requirements.md's recordStart(err) column. */ TEST_ASSERT_TRUE(g_last_recording); TEST_ASSERT_FALSE(g_last_file_null); TEST_ASSERT_EQUAL_STRING(path_before, g_last_file); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(started_before, g_last_started_at); TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched)); } static void test_recordstart_invalid_filename_replies_error(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char escape_path[PATH_MAX]; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_start_invalid"); fixture_init(&sched, record_dir); /* Contains a path separator -- rejected before any filesystem access * (recording.c's sched_validate_record_filename(), task 9.1). */ rc = send_admin_request(&sched, SchedAdminAction_recordStart, "sub/dir.replay", 666); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); /* No file opened, no directory created, recording never started. */ TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_NULL(sched.record_file); snprintf(escape_path, sizeof(escape_path), "%s/sub", record_dir); TEST_ASSERT_FALSE( (access(escape_path, F_OK) == 0)); /* "sub" was never created */ TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_FALSE(g_last_ok); TEST_ASSERT_FALSE(g_last_error_null); TEST_ASSERT_EQUAL_STRING("invalid record file name", g_last_error); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at); } /* ---------------------------------------------------------------------- */ /* More recordStart path-validation rejection cases through the SAME real */ /* admin request path (task 9.5's genuine-gap audit): the test above only */ /* covers the path-separator case. sched_validate_record_filename() */ /* (recording.c, task 9.1) also rejects a ".."-traversal filename and a */ /* pre-planted symlink-escape -- test_recording_path_validation.c already */ /* proves both at the internal function level; these two prove the SAME */ /* rejections surface correctly once the deferred recordStart is actually */ /* attempted by xce_sched_driver_translate_pending_events(), through the */ /* real scheduler_req_handler() entry point, exactly like the */ /* path-separator test above. */ /* ---------------------------------------------------------------------- */ static void test_recordstart_dotdot_filename_replies_error(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_start_dotdot"); fixture_init(&sched, record_dir); /* Contains ".." -- rejected before any filesystem access, same * short-circuiting string check as the path-separator case above but a * genuinely DIFFERENT branch inside sched_validate_record_filename() * (strstr(filename, "..") rather than strchr(filename, '/')). */ rc = send_admin_request(&sched, SchedAdminAction_recordStart, "../evil.replay", 1111); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_FALSE(g_last_ok); TEST_ASSERT_FALSE(g_last_error_null); TEST_ASSERT_EQUAL_STRING("invalid record file name", g_last_error); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at); /* The driver loop keeps working after the rejection: a subsequent * recordStatus request through the SAME real handler entry point * (synchronous, not deferred) succeeds cleanly, proving the rejected * recordStart left no dangling state that would wedge later requests. */ rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 1112); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(2, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_FALSE(g_last_recording); } static void test_recordstart_symlink_escape_replies_error(void) { char record_dir[PATH_MAX]; char outside_dir[PATH_MAX]; char outside_target[PATH_MAX]; char link_path[PATH_MAX]; xce_scheduler_t sched; struct stat st; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_start_symlink"); make_tmp_dir(outside_dir, sizeof(outside_dir), "_start_symlink_dst"); fixture_init(&sched, record_dir); /* Same symlink-escape shape as test_recording_path_validation.c's * test_symlink_inside_record_dir_escaping_outside_is_rejected: a * pre-existing symlink INSIDE record_dir, named exactly like an * innocent-looking filename, whose target lives OUTSIDE record_dir. * The filename component alone ("looks_safe") violates neither the * '/' nor the ".." rule -- only the canonical-path containment check * on the pre-existing symlink's resolved target catches this. Proven * here through the real admin request path, not just at the internal * sched_validate_record_filename() level. * * bug-1560: a canonical-path containment failure is deliberately * -EACCES, not -EINVAL (sched_validate_record_filename(), * recording.c's path_is_contained() branch) -- -EINVAL is reserved * for a malformed filename rejected before any filesystem access * (Step 1: '/' or ".." in the bare filename, see * test_recordstart_dotdot_filename_replies_error above). The sibling * unit test at the sched_validate_record_filename() level already * asserts -EACCES for this exact scenario * (test_symlink_inside_record_dir_escaping_outside_is_rejected, * test_recording_path_validation.c) -- this test's own expected * error string below was simply wrong, asserting the -EINVAL string * against what is, and always was, an -EACCES rejection. Not an * environment-dependent permission-check ordering issue as * originally suspected: this fails identically as uid 0 or uid * 1000, since escape-containment and filesystem-permission errors * were never actually conflated in production code -- only in this * test's assertion. */ snprintf(outside_target, sizeof(outside_target), "%s/secret", outside_dir); touch_file(outside_target); snprintf(link_path, sizeof(link_path), "%s/looks_safe", record_dir); TEST_ASSERT_EQUAL_INT(0, symlink(outside_target, link_path)); rc = send_admin_request(&sched, SchedAdminAction_recordStart, "looks_safe", 2222); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_FALSE(g_last_ok); TEST_ASSERT_FALSE(g_last_error_null); TEST_ASSERT_EQUAL_STRING("permission denied accessing record file", g_last_error); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at); /* The escaped-to outside file was never opened, let alone truncated -- * proves the rejection happened before any fopen()/ftruncate() on the * resolved-outside path. */ TEST_ASSERT_EQUAL_INT(0, stat(outside_target, &st)); TEST_ASSERT_EQUAL_INT(0, (int)st.st_size); /* Driver loop keeps working after the rejection, same proof as the * ".."-traversal case above. */ rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 2223); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(2, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_FALSE(g_last_recording); } /* ---------------------------------------------------------------------- */ /* recordStop -- deferred, not synchronous. */ /* ---------------------------------------------------------------------- */ static void test_recordstop_is_deferred_then_performed_by_driver_round(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_before[PATH_MAX]; uint64_t started_before; xce_core_event_t ev; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_stop_deferred"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "stopme.replay"); TEST_ASSERT_EQUAL_INT(0, rc); strcpy(path_before, sched.record_file_path); started_before = sched.record_start_ts; memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = 9ULL; TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, &ev, 1)); TEST_ASSERT_EQUAL_UINT64(1ULL, sched.record_event_count); rc = send_admin_request(&sched, SchedAdminAction_recordStop, NULL, 777); TEST_ASSERT_EQUAL_INT(0, rc); /* Deferred, not synchronous: still recording, no reply yet. */ TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(777, g_last_seq); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_TRUE(g_last_error_null); TEST_ASSERT_FALSE(g_last_recording); /* "last path (informational)". */ TEST_ASSERT_FALSE(g_last_file_null); TEST_ASSERT_EQUAL_STRING(path_before, g_last_file); /* "final count". */ TEST_ASSERT_EQUAL_UINT64(1ULL, g_last_event_count); /* "original start (historical)". */ TEST_ASSERT_EQUAL_UINT64(started_before, g_last_started_at); } static void test_recordstop_while_not_recording_replies_error(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_stop_notrecording"); fixture_init(&sched, record_dir); rc = send_admin_request(&sched, SchedAdminAction_recordStop, NULL, 888); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); /* deferred */ rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(888, g_last_seq); TEST_ASSERT_FALSE(g_last_ok); TEST_ASSERT_FALSE(g_last_error_null); TEST_ASSERT_EQUAL_STRING("not recording", g_last_error); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(0, g_last_started_at); } /* ---------------------------------------------------------------------- */ /* Disk-full simulation through the admin/request-handler path (task 9.5). */ /* Mirrors test_record_lifecycle.c's */ /* test_write_failure_disables_recording_and_driver_keeps_running() -- */ /* same real fflush()+setvbuf(_IONBF)+close(fd) write-failure technique, */ /* task 9.3's own Observable -- but with BOTH the initiating recordStart */ /* AND the verifying recordStatus driven through scheduler_req_handler(), */ /* the real production request-handler entry point, rather than task */ /* 9.3's direct internal sched_record_start()/sched_record_status() calls. */ /* ---------------------------------------------------------------------- */ static void test_write_failure_through_admin_path_disables_recording_cleanly(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_core_event_t ev; int rc; int fd; int calls_before; uint64_t original_started_at; make_tmp_dir(record_dir, sizeof(record_dir), "_diskfull"); fixture_init(&sched, record_dir); /* 1. recordStart, genuinely through the admin request path: deferred, * then performed by a real driver round -- same pattern as * test_recordstart_is_deferred_then_performed_by_driver_round above. */ rc = send_admin_request(&sched, SchedAdminAction_recordStart, "diskfull.replay", 3001); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file); TEST_ASSERT_EQUAL_INT(1, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); /* sched_record_abort_on_error() (recording.c) deliberately does NOT * reset record_start_ts -- only sched_record_start() ever (re)stamps * it, on the NEXT successful start (recording.c's own field-write * list). So a post-abort recordStatus must still report THIS original * started-at, not 0 -- captured here for that later assertion. */ original_started_at = g_last_started_at; /* 2. Force a genuine write failure on the now-open sched->record_file -- * the same real technique test_record_lifecycle.c uses: flush what * recordStart already buffered, force the stream unbuffered, then close * the underlying fd out from under the FILE* so the very next fwrite() * hits a genuine EBADF, not a simulated return code. */ TEST_ASSERT_EQUAL_INT(0, fflush(sched.record_file)); TEST_ASSERT_EQUAL_INT(0, setvbuf(sched.record_file, NULL, _IONBF, 0)); fd = fileno(sched.record_file); TEST_ASSERT_TRUE(fd >= 0); TEST_ASSERT_EQUAL_INT(0, close(fd)); /* 3. Drive the failing write via one real event batch -- the same * sched_record_write_events() call * xce_sched_driver_translate_pending_events() itself invokes for a * translated batch (this file's own * test_recordstatus_while_recording_reflects_live_state above already * establishes this direct call as this suite's real event-batch * trigger). */ memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = 42ULL; TEST_ASSERT_TRUE(sched_record_write_events(&sched, &ev, 1) < 0); /* (a) sched_record_abort_on_error()'s cleanup genuinely fired. */ TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); /* (b) A subsequent recordStatus request through the SAME real handler * entry point correctly reports the abort. */ calls_before = g_reply_calls; rc = send_admin_request(&sched, SchedAdminAction_recordStatus, NULL, 3002); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(calls_before + 1, g_reply_calls); TEST_ASSERT_EQUAL_UINT64(3002, g_last_seq); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_FALSE(g_last_recording); TEST_ASSERT_TRUE(g_last_file_null); TEST_ASSERT_EQUAL_UINT64(0, g_last_event_count); TEST_ASSERT_EQUAL_UINT64(original_started_at, g_last_started_at); /* (c) The scheduler keeps processing normally afterward: a further * admin request -- a brand new recordStart -- succeeds cleanly through * the SAME real handler + driver-round path, genuinely creating a new * file on disk. */ calls_before = g_reply_calls; rc = send_admin_request(&sched, SchedAdminAction_recordStart, "recovery.replay", 3003); TEST_ASSERT_EQUAL_INT(0, rc); rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(calls_before + 1, g_reply_calls); TEST_ASSERT_TRUE(g_last_ok); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file_path); TEST_ASSERT_TRUE(strstr(sched.record_file_path, "recovery.replay") != NULL); } /* ---------------------------------------------------------------------- */ /* Unrecognized action / NULL argument guards. */ /* ---------------------------------------------------------------------- */ static void test_unrecognized_action_returns_einval_no_crash_no_reply(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_bad_action"); fixture_init(&sched, record_dir); rc = send_admin_request(&sched, 999 /* not a valid SchedAdminAction */, NULL, 1010); TEST_ASSERT_EQUAL_INT(-EINVAL, rc); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); /* no synchronous reply */ TEST_ASSERT_NULL( dataq_deq(sched.core.pend_updates[0], NULL)); /* nothing enqueued */ /* A subsequent driver round is a genuine no-op -- nothing to drain. */ rc = xce_sched_driver_translate_pending_events(&(sched.core), &sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); } static void test_null_sched_returns_einval(void) { sched_admin_req_t areq; xce_request_t request; memset(&areq, 0, sizeof(areq)); areq.action = SchedAdminAction_recordStatus; memset(&request, 0, sizeof(request)); request.opcode = OPCode_schedAdmin; request.body_which = Request_body_schedadminmsg; request.body.schedadminmsg = &areq; TEST_ASSERT_EQUAL_INT(-EINVAL, sched_handle_admin_request(NULL, &request)); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); } static void test_null_request_returns_einval(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; make_tmp_dir(record_dir, sizeof(record_dir), "_null_req"); fixture_init(&sched, record_dir); TEST_ASSERT_EQUAL_INT(-EINVAL, sched_handle_admin_request(&sched, NULL)); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); } static void test_null_schedadminmsg_body_returns_einval(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_request_t request; make_tmp_dir(record_dir, sizeof(record_dir), "_null_body"); fixture_init(&sched, record_dir); memset(&request, 0, sizeof(request)); request.opcode = OPCode_schedAdmin; request.body_which = Request_body_schedadminmsg; request.body.schedadminmsg = NULL; TEST_ASSERT_EQUAL_INT(-EINVAL, sched_handle_admin_request(&sched, &request)); TEST_ASSERT_EQUAL_INT(0, g_reply_calls); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_recordstatus_not_recording_returns_empty_state); RUN_TEST(test_recordstatus_while_recording_reflects_live_state); RUN_TEST(test_recordstatus_genuinely_blocks_on_record_lock); RUN_TEST(test_recordstatus_file_survives_concurrent_record_stop_free); RUN_TEST(test_recordstart_is_deferred_then_performed_by_driver_round); RUN_TEST(test_recordstart_empty_file_auto_generates_name); RUN_TEST(test_recordstart_while_already_recording_replies_error); RUN_TEST(test_recordstart_invalid_filename_replies_error); RUN_TEST(test_recordstart_dotdot_filename_replies_error); RUN_TEST(test_recordstart_symlink_escape_replies_error); RUN_TEST(test_recordstop_is_deferred_then_performed_by_driver_round); RUN_TEST(test_recordstop_while_not_recording_replies_error); RUN_TEST(test_write_failure_through_admin_path_disables_recording_cleanly); RUN_TEST(test_unrecognized_action_returns_einval_no_crash_no_reply); RUN_TEST(test_null_sched_returns_einval); RUN_TEST(test_null_request_returns_einval); RUN_TEST(test_null_schedadminmsg_body_returns_einval); return UNITY_END(); }