/* * test_record_lifecycle.c - contract test for the record start/stop * lifecycle, the driver-loop recording-write hooks, periodic snapshot * checkpointing, and recording error handling (scheduler-core spec, tasks * 9.2/9.3, Req 5 AC 10/AC 11). * * Observable under test (task 9.2's own text): * - start -> the file begins with a decodable ReplayHeader then a * decodable SnapshotEntry. * - start -> submit an event batch -> complete one session -> stop * produces a file with events and exactly one ReplaySession in * between, ending in a ReplayEvent{endSession} marker. * - when `sched->recording` is false throughout, no file I/O occurs. * - double-start fails cleanly; stop-when-not-recording fails cleanly. * - a TOCTOU-style symlink/hardlink swap between path validation and the * eventual open() is rejected, not silently followed. * * Observable under test (task 9.3's own text, added this revision): * - recording through a configured `record_snapshot_interval` produces * the expected NUMBER of extra periodic SnapshotEntry messages in the * file (interval=3, 7 events across 7 calls -> 2 extra checkpoints; * interval=0 -> zero extra checkpoints regardless of event count; one * large single-call batch crossing two interval multiples -> exactly * ONE checkpoint, proving/documenting this implementation's "once per * call" boundary choice -- see recording_events.c's own comment on * sched_record_write_events() for the full rationale). * - simulating a write failure leaves the scheduler still processing * sessions afterward with recording cleanly disabled: a genuine fd-level * write failure (not a simulated return code -- see the fflush()+ * setvbuf(_IONBF)+close(fd) sequence in * test_write_failure_disables_recording_and_driver_keeps_running(), and * .wolf/cerebrum.md's matching Key Learning for why plain fd-closing * alone is not sufficient against a buffered stdio stream) triggers * sched_record_abort_on_error()'s cleanup (recording=false, * record_file/record_file_path both NULL), then a full real * normal-mode round still completes with no crash/hang and no further * recording I/O attempted. * * White-box #include of ../src/core.c, ../src/core_clock.c, * ../src/core_events.c, ../src/core_session.c, ../src/driver.c, * ../src/plugin.c (same file set/rationale as * test_driver_session_ordering.c -- a real picker pthread + stand-in * allocator thread is needed to drive xce_sched_driver_run_normal_mode_round() * through one genuine session, jbuf's hardcoded capacity-1 rendezvous * requires it) PLUS ../src/recording.c, ../src/recording_events.c, * ../src/recording_session.c (the code under test) PLUS ../src/snapshot.c, * ../src/snapshot_wire.c (task 8.1's REAL xce_sched_core_save_snapshot(), * needed by sched_record_start() -- NOT snapshot_load.c, which nothing * here ever calls). No jobs are ever pushed (same "picker produces zero * decisions" fixture shape as test_driver_session_ordering.c), so * decision.c is not needed either. * * Decoding: this file reads its own produced `.replay` files back with the * SAME generated read_X()/decode_X() functions (controller.capnp.h) group * 7's own round-trip tests (test_e2e_capnp.c) use, via capn_init_fp() * (sequential per-message stream reads, since a `.replay` file is * consecutive self-framed capnp messages with no outer framing, Req 5 * AC 20) -- never by hand-parsing bytes. * * Stubs below are the exact same set test_driver_session_ordering.c * already establishes for this core.c/core_clock.c/core_events.c/ * core_session.c/driver.c/plugin.c combination (kill_sched_*, * sched_worker_release_host_ctx, sched_publish_dispatch/ * sched_publish_pend_reason, sched_fill_limit_requests, * sched_resolve_job_picker's real reimplementation) -- see that file's own * header comment for the full rationale. plugin_unregister is NOT stubbed * (real, from the genuinely-linked plugin.c). No stub is needed for * xce_core_now()/xce_sched_core_save_snapshot() here (unlike * test_recording_path_validation.c's narrower TU): core_clock.c and * snapshot.c/snapshot_wire.c are genuinely linked into this file. */ #include #include #include #include #include #include #include #include #include #include #include #include "controller.capnp.h" #include "kvmgr.h" #include "preason.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_session_ordering.c). */ #include "../src/core.c" #include "../src/core_clock.c" #include "../src/core_events.c" #include "../src/core_session.c" #include "../src/driver.c" #include "../src/plugin.c" #include "../src/recording.c" #include "../src/recording_events.c" #include "../src/recording_session.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; } /* core_reset_workers()'s (core_session.c) linker dependency -- never * invoked (no worker is ever added here). */ void sched_worker_release_host_ctx(xce_sched_worker_t* w) { (void)w; } /* Link-satisfying stubs for scheduler.c's sched_publish_dispatch()/ * sched_publish_pend_reason() -- never actually invoked (this fixture * pushes no jobs, so the driver's decision-drain loop never observes a * non-NULL decision). */ 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 stub for resource.c's sched_fill_limit_requests() -- * plugin.c's plugin_prepare_allocation() references it; never actually * invoked here. */ int sched_fill_limit_requests(xce_scheduler_t* sched, xvec_idval_t* vec, xlist_t* limits) { (void)sched; (void)vec; (void)limits; return 0; } int sched_account_jobs(xce_scheduler_t* sched) { (void)sched; return 0; } /* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same * rationale/algorithm as test_driver_session_ordering.c's own copy * (schedobj.c is not linked). */ int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out) { xce_sched_plugin_t* plugin = NULL; int rc = 0; if ((core == NULL) || (out == NULL)) { return -EINVAL; } *out = NULL; if (core->job_pick_plugin == NULL) { return -EINVAL; } lock(core->plugins, RTTI_LOCK_READ); rc = key_find(core->plugins, core->job_pick_plugin, (void**)&plugin); unlock(core->plugins); if (rc <= 0) { return (rc < 0) ? rc : -ENOENT; } if ((plugin == NULL) || (plugin->picker_iface == NULL) || (plugin->picker_iface->next_job == NULL) || (plugin->picker_iface->begin_session == NULL) || (plugin->picker_iface->end_session == NULL) || (plugin->picker_iface->on_dispatch == NULL) || (plugin->picker_iface->on_pend == NULL)) { return -EINVAL; } *out = plugin; return 0; } void setUp(void) { return; } void tearDown(void) { return; } /* ---------------------------------------------------------------------- */ /* Stub picker plugin -- identical shape to */ /* test_driver_session_ordering.c's own: no jobs are ever pushed, so */ /* next_job() always returns NULL immediately. Its SOLE purpose is proving */ /* the wbuf/jbuf rendezvous timing lets a real session complete. */ /* ---------------------------------------------------------------------- */ static int stub_picker_begin_session(void* picker) { (void)picker; return 0; } static xce_sched_job_t* stub_picker_next_job(void* picker) { (void)picker; return NULL; } static int stub_picker_end_session(void* picker) { (void)picker; return 0; } static int stub_picker_jobid_noop(void* picker, uint64_t jobid) { (void)picker; (void)jobid; return 0; } static int stub_picker_queue_update_noop(void* picker, xce_sched_queue_t* q) { (void)picker; (void)q; return 0; } static int stub_picker_job_noop(void* picker, xce_sched_job_t* job) { (void)picker; (void)job; return 0; } static int stub_picker_job_state_change_noop(void* picker, xce_sched_job_t* job, uint32_t state) { (void)picker; (void)job; (void)state; return 0; } static xce_job_picker_iface_t g_stub_picker_iface = { .init = NULL, .fini = NULL, .begin_session = stub_picker_begin_session, .next_job = stub_picker_next_job, .end_session = stub_picker_end_session, .on_dispatch = stub_picker_jobid_noop, .on_pend = stub_picker_jobid_noop, .on_queue_update = stub_picker_queue_update_noop, .on_job_push = stub_picker_job_noop, .on_job_state_change = stub_picker_job_state_change_noop, .on_job_update = stub_picker_job_noop, .on_config = NULL, .on_job_rusage = NULL }; static xce_sched_plugin_t g_stub_picker_plugin = { .name = "test_record_lifecycle_picker", .picker_iface = &g_stub_picker_iface }; static void* picker_thread_fn(void* arg) { xce_sched_core_t* core = (xce_sched_core_t*)arg; xce_sched_plugin_t* job_picker = NULL; int rc; int go = 0; uint64_t jobid; rc = sched_resolve_job_picker(core, &job_picker); if (rc < 0) { return NULL; } cbuf_get(&(core->wbuf), &go); job_picker->picker_iface->begin_session(job_picker); for (;;) { xce_sched_job_t* job = job_picker->picker_iface->next_job(job_picker); if (job == NULL) { break; } jobid = job->jobid; cbuf_put(&(core->jbuf), &jobid); } jobid = 0; cbuf_put(&(core->jbuf), &jobid); job_picker->picker_iface->end_session(job_picker); return NULL; } static void* stand_in_allocator_thread_fn(void* arg) { xce_sched_core_t* core = (xce_sched_core_t*)arg; uint64_t jobid; xce_decision_t* decision = NULL; cbuf_get(&(core->jbuf), &jobid); (void)jobid; cbuf_put(&(core->decisions), &decision); return NULL; } /* ---------------------------------------------------------------------- */ /* Fixture helpers */ /* ---------------------------------------------------------------------- */ static void make_tmp_dir(char* out, size_t out_len, const char* suffix) { char tmpl[PATH_MAX]; snprintf(tmpl, sizeof(tmpl), "/tmp/sched_reclife%s_XXXXXX", suffix); TEST_ASSERT_NOT_NULL(mkdtemp(tmpl)); TEST_ASSERT_TRUE(strlen(tmpl) < out_len); strcpy(out, tmpl); } /* Real xce_sched_core_init() + manually dataq_init'd pend_updates + the * stub picker plugin registered -- same pattern as * test_driver_session_ordering.c's fixture. `record_dir` must outlive * `sched` (caller-owned storage). Non-const to match * xce_scheduler_t.record_dir's own `char*` type (scheduler.h) -- every * caller passes a mutable local buffer anyway. */ static void fixture_init(xce_scheduler_t* sched, char* record_dir) { int rc; memset(sched, 0, sizeof(*sched)); /* record_lock (scheduler.h field comments, task 9.4 fix round) is * normally initialized by sched_init(), which this white-box fixture * deliberately bypasses (same rationale as this function's own header * comment) -- initialize it here too, same pairing as the * xce_sched_core_init() call just below initializing core.lock. Every * sched_record_start()/sched_record_stop() call in this file now takes * 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); lock(sched->core.plugins, RTTI_LOCK_WRITE); rc = key_add(sched->core.plugins, (void*)g_stub_picker_plugin.name, (void*)&g_stub_picker_plugin); TEST_ASSERT_GREATER_OR_EQUAL(0, rc); unlock(sched->core.plugins); sched->core.job_pick_plugin = g_stub_picker_plugin.name; sched->record_dir = record_dir; } /* Runs one complete normal-mode round (real picker thread + stand-in * allocator thread, same design as * test_driver_session_ordering.c's own single test) and blocks until it * completes. */ static void run_one_real_session(xce_scheduler_t* sched) { pthread_t picker_tid; pthread_t allocator_tid; int rc; rc = pthread_create(&picker_tid, NULL, picker_thread_fn, &(sched->core)); TEST_ASSERT_EQUAL_INT(0, rc); rc = pthread_create(&allocator_tid, NULL, stand_in_allocator_thread_fn, &(sched->core)); TEST_ASSERT_EQUAL_INT(0, rc); xce_sched_driver_run_normal_mode_round(sched); rc = pthread_join(picker_tid, NULL); TEST_ASSERT_EQUAL_INT(0, rc); rc = pthread_join(allocator_tid, NULL); TEST_ASSERT_EQUAL_INT(0, rc); } /* ---------------------------------------------------------------------- */ /* Test 1: start() writes a decodable ReplayHeader then a decodable */ /* SnapshotEntry -- this task's own Observable, "byte-decodable by the */ /* round-trip decoder from group 7". */ /* ---------------------------------------------------------------------- */ static void test_start_writes_decodable_header_then_snapshot_entry(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_copy[PATH_MAX]; int rc; FILE* f; struct capn c1; struct capn c2; ReplayHeader_ptr hdr; SnapshotEntry_ptr snap; capn_text rv; make_tmp_dir(record_dir, sizeof(record_dir), "_hdr"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "session.replay"); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file_path); TEST_ASSERT_NOT_NULL(sched.record_file); strcpy(path_copy, sched.record_file_path); rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_FALSE(sched.recording); f = fopen(path_copy, "r"); TEST_ASSERT_NOT_NULL(f); /* msg 1: ReplayHeader */ TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c1, f, 0)); hdr.p = capn_getp(capn_root(&c1), 0, 1); rv = ReplayHeader_get_recorderVersion(hdr); TEST_ASSERT_EQUAL_STRING("1.0", rv.str); capn_free(&c1); /* msg 2: SnapshotEntry -- MUST be the first data message after * ReplayHeader (Req 5 AC 20). */ TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c2, f, 0)); snap.p = capn_getp(capn_root(&c2), 0, 1); { SnapshotData_ptr sdata = SnapshotEntry_get_data(snap); TEST_ASSERT_FALSE(sdata.p.type == CAPN_NULL); } capn_free(&c2); fclose(f); printf("test_start_writes_decodable_header_then_snapshot_entry: PASS\n"); } /* ---------------------------------------------------------------------- */ /* Test 2: start -> submit an event batch -> complete one session -> stop */ /* produces events + exactly one ReplaySession + a trailing endSession */ /* marker -- this task's own literal Observable. */ /* ---------------------------------------------------------------------- */ static void test_full_cycle_start_event_session_stop_produces_expected_stream(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_sched_update_request_t* req; char path_copy[PATH_MAX]; int rc; FILE* f; struct capn c; make_tmp_dir(record_dir, sizeof(record_dir), "_cycle"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "cycle.replay"); TEST_ASSERT_EQUAL_INT(0, rc); strcpy(path_copy, sched.record_file_path); /* "submit an event batch": one JSTATE_CHANGE update, drained by * xce_sched_driver_translate_pending_events() (called from * xce_sched_driver_run_normal_mode_round() below) -- the SAME * pend_updates dataq mechanism handler.c populates in production. */ req = (xce_sched_update_request_t*)calloc(1, sizeof(*req)); req->op = XCE_SCHED_OP_JSTATE_CHANGE; req->u.job_state_change.jobid = 42ULL; req->u.job_state_change.state = 7u; dataq_enq(sched.core.pend_updates[0], &(req->link)); /* "complete a session": one full normal-mode round through a real * picker thread + stand-in allocator thread (no jobs pushed, so the * session produces zero decisions -- still exactly one ReplaySession * message, matching this task's Observable which requires "one * session in between", not any particular decision count). */ run_one_real_session(&sched); TEST_ASSERT_EQUAL_UINT64(1ULL, sched.record_event_count); rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(0, rc); f = fopen(path_copy, "r"); TEST_ASSERT_NOT_NULL(f); /* msg 1: ReplayHeader */ TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); capn_free(&c); /* msg 2: SnapshotEntry (initial) */ TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); capn_free(&c); /* msg 3: ReplayEvent{jobStateChange, jobid=42} -- the submitted batch. */ { ReplayEvent_ptr p; struct ReplayEvent wire; replay_event_t dst; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); p.p = capn_getp(capn_root(&c), 0, 1); read_ReplayEvent(&wire, p); memset(&dst, 0, sizeof(dst)); decode_ReplayEvent(&dst, &wire); TEST_ASSERT_EQUAL_INT((int)ReplayEvent_body_jobStateChange, (int)dst.body_which); TEST_ASSERT_NOT_NULL(dst.body.job_state_change); TEST_ASSERT_EQUAL_UINT64(42ULL, dst.body.job_state_change->jobid); free_ReplayEvent(&dst); capn_free(&c); } /* msg 4: exactly one ReplaySession. */ { ReplaySession_ptr p; struct ReplaySession wire; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); p.p = capn_getp(capn_root(&c), 0, 1); read_ReplaySession(&wire, p); (void)wire; /* begin_round/decisions checked structurally only -- * this fixture pushes no jobs, so 0 decisions is * correct and expected. */ capn_free(&c); } /* msg 5: the end-of-recording marker, ReplayEvent{endSession}. */ { ReplayEvent_ptr p; struct ReplayEvent wire; replay_event_t dst; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); p.p = capn_getp(capn_root(&c), 0, 1); read_ReplayEvent(&wire, p); memset(&dst, 0, sizeof(dst)); decode_ReplayEvent(&dst, &wire); TEST_ASSERT_EQUAL_INT((int)ReplayEvent_body_endSession, (int)dst.body_which); free_ReplayEvent(&dst); capn_free(&c); } /* Exactly 5 messages -- nothing more to read. */ TEST_ASSERT_NOT_EQUAL(0, capn_init_fp(&c, f, 0)); fclose(f); printf("test_full_cycle_start_event_session_stop_produces_expected_stream:" " PASS\n"); } /* ---------------------------------------------------------------------- */ /* Test 3: when recording is inactive, the driver-loop hooks touch no */ /* file/record state -- zero file I/O (Req 5 AC 10, "Performance"). */ /* ---------------------------------------------------------------------- */ static void test_recording_inactive_hooks_are_zero_cost_noops(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_core_event_t events[1]; sched_record_session_ctx_t* ctx; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_inactive"); fixture_init(&sched, record_dir); /* sched.recording deliberately never set true. */ memset(events, 0, sizeof(events)); events[0].type = XCE_CORE_EVENT_JOB_STATE_CHANGE; events[0].u.job_state_change.jobid = 99ULL; rc = sched_record_write_events(&sched, events, 1); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); ctx = sched_record_session_begin(&sched, 123ULL); TEST_ASSERT_NULL(ctx); TEST_ASSERT_NULL(sched.record_file); /* Driving a full real round with recording inactive must not create * any file either. */ run_one_real_session(&sched); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_EQUAL_UINT64(0ULL, sched.record_event_count); } /* ---------------------------------------------------------------------- */ /* Test 4: double-start / stop-when-not-recording fail cleanly. */ /* ---------------------------------------------------------------------- */ static void test_double_start_fails_cleanly(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_dblstart"); fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "a.replay"); TEST_ASSERT_EQUAL_INT(0, rc); rc = sched_record_start(&sched, "b.replay"); TEST_ASSERT_EQUAL_INT(-EALREADY, rc); /* Original recording untouched by the rejected second start. */ TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_TRUE(strstr(sched.record_file_path, "a.replay") != NULL); rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(0, rc); } static void test_stop_when_not_recording_fails_cleanly(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; int rc; make_tmp_dir(record_dir, sizeof(record_dir), "_nostart"); fixture_init(&sched, record_dir); rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(-EALREADY, rc); rc = sched_record_start(&sched, "c.replay"); TEST_ASSERT_EQUAL_INT(0, rc); rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(0, rc); /* Stopping again after a clean stop also fails cleanly. */ rc = sched_record_stop(&sched); TEST_ASSERT_EQUAL_INT(-EALREADY, rc); } static void test_null_sched_returns_einval(void) { TEST_ASSERT_EQUAL_INT(-EINVAL, sched_record_start(NULL, "x.replay")); TEST_ASSERT_EQUAL_INT(-EINVAL, sched_record_stop(NULL)); } /* ---------------------------------------------------------------------- */ /* Test 5: TOCTOU -- a symlink/hard link swapped in at the resolved path */ /* between path validation and the eventual open() is rejected, not */ /* silently followed (recording.h's own documented mandate for this task). */ /* */ /* Genuinely racing a background thread against the exact few-instruction */ /* window inside sched_record_start() between its internal */ /* sched_validate_record_filename() call and its open() call would be */ /* inherently flaky (no test hook exists to pause mid-function, nor should */ /* one be added to production code just for this). Instead, both tests */ /* below reproduce the STATE a genuine race would produce -- a malicious */ /* symlink/hard link occupying the target path at the moment */ /* sched_record_start() is invoked, planted via real symlink(2)/link(2) */ /* syscalls (no mocking) -- and prove the full end-to-end call rejects it, */ /* AND (the part that specifically proves the open()-time hardening this */ /* task added, not just sched_validate_record_filename()'s pre-existing */ /* containment check) that the exact open()+fstat()+st_nlink==1 sequence */ /* sched_record_start() runs internally -- reproduced here verbatim, same */ /* flags -- independently rejects the swap. Either layer alone would be */ /* enough in production; proving the second layer here is what closes the */ /* TOCTOU gap this task's own docstring (recording.h) flags: a real race */ /* would land AFTER sched_validate_record_filename() already accepted the */ /* path, i.e. exactly the scenario where ONLY the open()-time check saves */ /* it. */ /* ---------------------------------------------------------------------- */ static int toctou_hardened_open(const char* path) { int fd; struct stat st; /* Verbatim copy of sched_record_start()'s own hardening sequence * (recording.c) -- see this test's own header comment for why this is * reproduced rather than racing a real thread. Deliberately NO * O_TRUNC: it truncates atomically as part of open() itself, before * the fstat()/st_nlink check below ever runs -- against a hard-linked * victim (same inode, no symlink involved) that would corrupt the * victim regardless of what the check then decides. ftruncate() only * runs after the check passes. */ fd = open(path, O_WRONLY | O_CREAT | O_NOFOLLOW, 0640); if (fd < 0) { return (errno > 0) ? -errno : -EIO; } if (fstat(fd, &st) != 0) { int rc = (errno > 0) ? -errno : -EIO; close(fd); return rc; } if (!S_ISREG(st.st_mode) || (st.st_nlink != 1)) { close(fd); return -EACCES; } if (ftruncate(fd, 0) != 0) { int rc = (errno > 0) ? -errno : -EIO; close(fd); return rc; } close(fd); return 0; } static void test_toctou_symlink_planted_after_validation_rejected(void) { char record_dir[PATH_MAX]; char outside_dir[PATH_MAX]; char outside_secret[PATH_MAX]; char resolved[PATH_MAX]; char target[PATH_MAX]; const char* secret_content = "do-not-touch"; int rc; FILE* f; char readback[64]; make_tmp_dir(record_dir, sizeof(record_dir), "_toctou_sym_src"); make_tmp_dir(outside_dir, sizeof(outside_dir), "_toctou_sym_dst"); snprintf(outside_secret, sizeof(outside_secret), "%s/secret", outside_dir); f = fopen(outside_secret, "w"); TEST_ASSERT_NOT_NULL(f); fputs(secret_content, f); fclose(f); /* Simulates "validation just resolved this path and found nothing * there yet" (a fresh filename -- sched_validate_record_filename() * itself accepts this, since lstat() on a nonexistent entry short- * circuits its containment checks entirely). */ rc = sched_validate_record_filename(record_dir, "race.replay", resolved, sizeof(resolved)); TEST_ASSERT_EQUAL_INT(0, rc); /* The race: a symlink appears at the just-validated path, pointing * OUTSIDE record_dir, in the window before open() runs. */ snprintf(target, sizeof(target), "%s/race.replay", record_dir); TEST_ASSERT_EQUAL_INT(0, symlink(outside_secret, target)); /* Layer 1: the open()-time hardening sequence itself rejects it. */ rc = toctou_hardened_open(resolved); TEST_ASSERT_TRUE(rc < 0); /* Layer 2 (defense in depth): a real end-to-end sched_record_start() * call against the same record_dir/filename is ALSO rejected -- * sched_validate_record_filename() re-runs and now sees the symlink, * closing the loop even though this particular ordering happens to be * caught one layer earlier than the pure race would be. */ { xce_scheduler_t sched; fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "race.replay"); TEST_ASSERT_TRUE(rc < 0); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); } /* The outside secret file was never opened for writing/truncated. */ f = fopen(outside_secret, "r"); TEST_ASSERT_NOT_NULL(f); memset(readback, 0, sizeof(readback)); TEST_ASSERT_NOT_NULL(fgets(readback, sizeof(readback), f)); fclose(f); TEST_ASSERT_EQUAL_STRING(secret_content, readback); } static void test_toctou_hardlink_planted_after_validation_rejected(void) { char record_dir[PATH_MAX]; char outside_dir[PATH_MAX]; char victim[PATH_MAX]; char resolved[PATH_MAX]; char aliased[PATH_MAX]; const char* victim_content = "do-not-alias"; int rc; FILE* f; char readback[64]; make_tmp_dir(record_dir, sizeof(record_dir), "_toctou_hl_src"); make_tmp_dir(outside_dir, sizeof(outside_dir), "_toctou_hl_dst"); snprintf(victim, sizeof(victim), "%s/victim", outside_dir); f = fopen(victim, "w"); TEST_ASSERT_NOT_NULL(f); fputs(victim_content, f); fclose(f); rc = sched_validate_record_filename(record_dir, "alias.replay", resolved, sizeof(resolved)); TEST_ASSERT_EQUAL_INT(0, rc); /* The race: a hard link to the OUTSIDE victim file appears at the * just-validated path -- same inode, no symlink involved, so * O_NOFOLLOW alone would NOT catch this (recording.h's own doc * comment: "no symlink is involved in that race, so O_NOFOLLOW never * triggers") -- only the fstat()+st_nlink==1 check does. */ snprintf(aliased, sizeof(aliased), "%s/alias.replay", record_dir); TEST_ASSERT_EQUAL_INT(0, link(victim, aliased)); rc = toctou_hardened_open(resolved); TEST_ASSERT_EQUAL_INT(-EACCES, rc); { xce_scheduler_t sched; fixture_init(&sched, record_dir); rc = sched_record_start(&sched, "alias.replay"); TEST_ASSERT_TRUE(rc < 0); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); } f = fopen(victim, "r"); TEST_ASSERT_NOT_NULL(f); memset(readback, 0, sizeof(readback)); TEST_ASSERT_NOT_NULL(fgets(readback, sizeof(readback), f)); fclose(f); TEST_ASSERT_EQUAL_STRING(victim_content, readback); } /* ---------------------------------------------------------------------- */ /* Test 6: periodic snapshot checkpointing (task 9.3, Req 5 AC 11) -- */ /* interval N produces the expected NUMBER of extra SnapshotEntry */ /* messages, decoded and counted (not just file-size-checked) from the */ /* real generated capnp decoders, same convention as the tests above. */ /* ---------------------------------------------------------------------- */ /* Builds and submits one synthetic JOB_STATE_CHANGE event via * sched_record_write_events() -- the direct entry point recording_events.c * exposes, bypassing the need to drive a real session (xce_sched_core_t's * `session_active` stays 0 throughout, which is exactly what * write_snapshot_entry()'s xce_sched_core_save_snapshot() call requires -- * core.h's own documented precondition -- so calling this hook directly, * without ever running a session, is a faithful, simpler stand-in for the * real driver.c call site). `jobid` varies per call purely so decoded * events are individually distinguishable in assertions below. */ static void submit_one_jobstate_event(xce_scheduler_t* sched, uint64_t jobid) { xce_core_event_t ev; memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = jobid; TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(sched, &ev, 1)); } /* Reads exactly one capnp message off `f` and decodes it as a ReplayEvent, * asserting it is a jobStateChange event for `expected_jobid`. */ static void expect_next_msg_is_jobstate_event(FILE* f, uint64_t expected_jobid) { struct capn c; ReplayEvent_ptr p; struct ReplayEvent wire; replay_event_t dst; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); p.p = capn_getp(capn_root(&c), 0, 1); read_ReplayEvent(&wire, p); memset(&dst, 0, sizeof(dst)); decode_ReplayEvent(&dst, &wire); TEST_ASSERT_EQUAL_INT((int)ReplayEvent_body_jobStateChange, (int)dst.body_which); TEST_ASSERT_NOT_NULL(dst.body.job_state_change); TEST_ASSERT_EQUAL_UINT64(expected_jobid, dst.body.job_state_change->jobid); free_ReplayEvent(&dst); capn_free(&c); } /* Reads exactly one capnp message off `f` and decodes it as a * SnapshotEntry, asserting its `data` field is present (same structural * check test 1 above already establishes for the initial SnapshotEntry). */ static void expect_next_msg_is_snapshot_entry(FILE* f) { struct capn c; SnapshotEntry_ptr snap; SnapshotData_ptr sdata; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); snap.p = capn_getp(capn_root(&c), 0, 1); sdata = SnapshotEntry_get_data(snap); TEST_ASSERT_FALSE(sdata.p.type == CAPN_NULL); capn_free(&c); } /* Reads exactly one capnp message off `f` and decodes it as the * end-of-recording marker, ReplayEvent{endSession}. */ static void expect_next_msg_is_end_session_marker(FILE* f) { struct capn c; ReplayEvent_ptr p; struct ReplayEvent wire; replay_event_t dst; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); p.p = capn_getp(capn_root(&c), 0, 1); read_ReplayEvent(&wire, p); memset(&dst, 0, sizeof(dst)); decode_ReplayEvent(&dst, &wire); TEST_ASSERT_EQUAL_INT((int)ReplayEvent_body_endSession, (int)dst.body_which); free_ReplayEvent(&dst); capn_free(&c); } /* Skips the ReplayHeader + initial SnapshotEntry that sched_record_start() * always writes first (Req 5 AC 20) -- every test below only cares about * what comes AFTER those two, so this consumes them structurally (same * "structural check only" pattern test 2 above already uses for its own * ReplaySession message). */ static void skip_header_and_initial_snapshot(FILE* f) { struct capn c; TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); /* ReplayHeader */ capn_free(&c); TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0)); /* initial SnapshotEntry */ capn_free(&c); } /* interval=3, 7 events submitted ONE PER CALL (7 separate * sched_record_write_events() calls) -- record_event_count runs * 1,2,3,4,5,6,7, landing exactly on the interval's multiples at 3 and 6. * Expect exactly 2 extra periodic SnapshotEntry messages, positioned * immediately after the 3rd and 6th events respectively (this task's own * Observable, using this task's own example numbers). */ static void test_periodic_checkpoint_interval_produces_expected_snapshot_count(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_copy[PATH_MAX]; FILE* f; struct capn c; int i; make_tmp_dir(record_dir, sizeof(record_dir), "_periodic"); fixture_init(&sched, record_dir); sched.record_snapshot_interval = 3ULL; TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "periodic.replay")); strcpy(path_copy, sched.record_file_path); for (i = 0; i < 7; i++) { submit_one_jobstate_event(&sched, (uint64_t)(100 + i)); } TEST_ASSERT_EQUAL_UINT64(7ULL, sched.record_event_count); TEST_ASSERT_TRUE(sched.recording); /* no abort fired along the way */ TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched)); f = fopen(path_copy, "r"); TEST_ASSERT_NOT_NULL(f); skip_header_and_initial_snapshot(f); expect_next_msg_is_jobstate_event(f, 100ULL); expect_next_msg_is_jobstate_event(f, 101ULL); expect_next_msg_is_jobstate_event(f, 102ULL); expect_next_msg_is_snapshot_entry(f); /* count==3, extra checkpoint #1 */ expect_next_msg_is_jobstate_event(f, 103ULL); expect_next_msg_is_jobstate_event(f, 104ULL); expect_next_msg_is_jobstate_event(f, 105ULL); expect_next_msg_is_snapshot_entry(f); /* count==6, extra checkpoint #2 */ expect_next_msg_is_jobstate_event(f, 106ULL); expect_next_msg_is_end_session_marker(f); /* Nothing more to read -- exactly 2 extra SnapshotEntry messages, no * more, no fewer. */ TEST_ASSERT_NOT_EQUAL(0, capn_init_fp(&c, f, 0)); fclose(f); printf("test_periodic_checkpoint_interval_produces_expected_snapshot_count:" " PASS\n"); } /* record_snapshot_interval == 0 (the default/disabled case) -- many events * recorded, zero extra SnapshotEntry messages produced regardless. */ static void test_periodic_checkpoint_interval_zero_disables_checkpointing(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_copy[PATH_MAX]; FILE* f; struct capn c; int i; make_tmp_dir(record_dir, sizeof(record_dir), "_nointerval"); fixture_init(&sched, record_dir); TEST_ASSERT_EQUAL_UINT64(0ULL, sched.record_snapshot_interval); /* default */ TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "nointerval.replay")); strcpy(path_copy, sched.record_file_path); for (i = 0; i < 10; i++) { submit_one_jobstate_event(&sched, (uint64_t)(200 + i)); } TEST_ASSERT_EQUAL_UINT64(10ULL, sched.record_event_count); TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched)); f = fopen(path_copy, "r"); TEST_ASSERT_NOT_NULL(f); skip_header_and_initial_snapshot(f); for (i = 0; i < 10; i++) { expect_next_msg_is_jobstate_event(f, (uint64_t)(200 + i)); } expect_next_msg_is_end_session_marker(f); /* No SnapshotEntry anywhere in that stream -- exactly the 10 events * plus the end marker, nothing more. */ TEST_ASSERT_NOT_EQUAL(0, capn_init_fp(&c, f, 0)); fclose(f); printf("test_periodic_checkpoint_interval_zero_disables_checkpointing:" " PASS\n"); } /* interval=3, but all 6 events are submitted in ONE single * sched_record_write_events() call (one batch) -- record_event_count jumps * straight from 0 to 6 in that single call, crossing BOTH the count==3 and * count==6 multiples within it. Documents/proves this task's chosen * "once per call" semantics: only ONE checkpoint is written for the call, * not one per crossed boundary (see recording_events.c's own comment on * sched_record_write_events() for the full rationale) -- this is the exact * scenario where "once per crossed boundary" and "once per call" would * observably differ, and this test proves which one this implementation * chose. */ static void test_periodic_checkpoint_single_batch_crossing_two_boundaries_checkpoints_once( void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; char path_copy[PATH_MAX]; xce_core_event_t events[6]; FILE* f; struct capn c; int i; make_tmp_dir(record_dir, sizeof(record_dir), "_batchboundary"); fixture_init(&sched, record_dir); sched.record_snapshot_interval = 3ULL; TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "batchboundary.replay")); strcpy(path_copy, sched.record_file_path); memset(events, 0, sizeof(events)); for (i = 0; i < 6; i++) { events[i].type = XCE_CORE_EVENT_JOB_STATE_CHANGE; events[i].u.job_state_change.jobid = (uint64_t)(300 + i); } TEST_ASSERT_EQUAL_INT(0, sched_record_write_events(&sched, events, 6)); TEST_ASSERT_EQUAL_UINT64(6ULL, sched.record_event_count); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_EQUAL_INT(0, sched_record_stop(&sched)); f = fopen(path_copy, "r"); TEST_ASSERT_NOT_NULL(f); skip_header_and_initial_snapshot(f); for (i = 0; i < 6; i++) { expect_next_msg_is_jobstate_event(f, (uint64_t)(300 + i)); } expect_next_msg_is_snapshot_entry(f); /* exactly ONE, not two */ expect_next_msg_is_end_session_marker(f); TEST_ASSERT_NOT_EQUAL(0, capn_init_fp(&c, f, 0)); fclose(f); printf("test_periodic_checkpoint_single_batch_crossing_two_boundaries_" "checkpoints_once: PASS\n"); } /* A periodic checkpoint's OWN write can fail independently of the * per-event writes that precede it in the same call -- proven here by * forcing xce_sched_core_save_snapshot() (which write_snapshot_entry() * calls first) to fail via core.h's own documented `session_active != 0` * precondition, entirely independent of file-descriptor health. Proves * task 9.3's "route through the SAME sched_record_abort_on_error() path, * do not special-case it" requirement for the periodic-checkpoint branch * specifically (the general write-failure test below already covers the * per-event-write branch). */ static void test_periodic_checkpoint_write_failure_disables_recording_cleanly(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_core_event_t ev; make_tmp_dir(record_dir, sizeof(record_dir), "_ckptfail"); fixture_init(&sched, record_dir); sched.record_snapshot_interval = 1ULL; /* every single event checkpoints */ TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "ckptfail.replay")); TEST_ASSERT_TRUE(sched.recording); /* Force xce_sched_core_save_snapshot()'s precondition check to fail * (core.h: "-EBUSY ... checked BEFORE any mutation") -- no real session * is ever driven; this only fakes the flag write_snapshot_entry()'s * save_snapshot() call reads. */ sched.core.session_active = 1; memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = 400ULL; /* The event itself writes fine; the periodic checkpoint that fires * immediately after it (interval=1) is what fails and aborts. */ TEST_ASSERT_TRUE(sched_record_write_events(&sched, &ev, 1) < 0); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); sched.core.session_active = 0; /* restore -- avoid tripping other state */ } /* ---------------------------------------------------------------------- */ /* Test 7: write-failure end-to-end (task 9.3's own Observable) -- */ /* a genuine write failure (the underlying fd closed out from under the */ /* buffered FILE*, then forced unbuffered so the very next fwrite() hits */ /* the syscall directly instead of silently buffering past the break) */ /* cleanly disables recording via sched_record_abort_on_error(), AND the */ /* driver loop keeps working afterward -- a full real session still */ /* completes with no crash/hang, recording simply staying inactive. */ /* ---------------------------------------------------------------------- */ static void test_write_failure_disables_recording_and_driver_keeps_running(void) { char record_dir[PATH_MAX]; xce_scheduler_t sched; xce_core_event_t ev; int fd; uint64_t event_count_before_failure; make_tmp_dir(record_dir, sizeof(record_dir), "_writefail"); fixture_init(&sched, record_dir); TEST_ASSERT_EQUAL_INT(0, sched_record_start(&sched, "writefail.replay")); TEST_ASSERT_TRUE(sched.recording); TEST_ASSERT_NOT_NULL(sched.record_file); /* Commit the header+initial-snapshot bytes sched_record_start() already * buffered to the still-healthy fd, then force the stream unbuffered so * every subsequent fwrite() hits the underlying fd directly (no stdio * buffering to mask a failure), THEN close that fd out from under the * FILE* -- "close the underlying fd out from under the FILE* before a * write", per this task's own suggested technique. */ 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)); memset(&ev, 0, sizeof(ev)); ev.type = XCE_CORE_EVENT_JOB_STATE_CHANGE; ev.u.job_state_change.jobid = 500ULL; /* The write genuinely fails now (EBADF on the closed fd, surfaced * immediately since the stream is unbuffered) -- proving a REAL I/O * failure, not a simulated return code. */ TEST_ASSERT_TRUE(sched_record_write_events(&sched, &ev, 1) < 0); /* sched_record_abort_on_error()'s cleanup fired: recording cleanly * disabled, file handle closed+NULLed, path freed+NULLed -- this * task's own Observable, "recording cleanly disabled". */ TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); event_count_before_failure = sched.record_event_count; /* "the scheduler still processing sessions afterward" -- drive one full * real normal-mode round (real picker thread + stand-in allocator * thread, same helper the earlier full-cycle test uses) through the * now-recording-inactive scheduler. No crash, no hang, and no further * recording I/O is attempted (recording_inactive_hooks test above * already proves the zero-I/O contract for `recording == false`; this * proves it holds true specifically AFTER a real abort, not just from a * fixture that never started recording). */ run_one_real_session(&sched); TEST_ASSERT_FALSE(sched.recording); TEST_ASSERT_NULL(sched.record_file); TEST_ASSERT_NULL(sched.record_file_path); TEST_ASSERT_EQUAL_UINT64(event_count_before_failure, sched.record_event_count); printf("test_write_failure_disables_recording_and_driver_keeps_running:" " PASS\n"); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_start_writes_decodable_header_then_snapshot_entry); RUN_TEST(test_full_cycle_start_event_session_stop_produces_expected_stream); RUN_TEST(test_recording_inactive_hooks_are_zero_cost_noops); RUN_TEST(test_double_start_fails_cleanly); RUN_TEST(test_stop_when_not_recording_fails_cleanly); RUN_TEST(test_null_sched_returns_einval); RUN_TEST(test_toctou_symlink_planted_after_validation_rejected); RUN_TEST(test_toctou_hardlink_planted_after_validation_rejected); RUN_TEST( test_periodic_checkpoint_interval_produces_expected_snapshot_count); RUN_TEST(test_periodic_checkpoint_interval_zero_disables_checkpointing); RUN_TEST( test_periodic_checkpoint_single_batch_crossing_two_boundaries_checkpoints_once); RUN_TEST(test_periodic_checkpoint_write_failure_disables_recording_cleanly); RUN_TEST(test_write_failure_disables_recording_and_driver_keeps_running); return UNITY_END(); }