feat(scheduler-core): implement recording directory provisioning and path validation (task 9.1)

Adds --record-dir CLI flag and sched_validate_record_filename() with
canonical-path containment (realpath()-based, not textual-prefix) plus
separator/".." rejection before any filesystem access.

Adversarial review found and this task fixed two CWE-59 inode-aliasing
bypasses: a direct hard link inside record_dir aliasing an outside file,
and a compound variant where a symlink pointed at an in-directory file
itself hard-linked outside record_dir. Both closed via st_nlink checks
on the direct entry and the fully-resolved symlink target respectively,
each verified with a live RED->GREEN reproduction. TOCTOU residual risk
documented and deferred to task 9.2 (O_NOFOLLOW + fstat() re-check).

18/18 recording subtests, 24/24 scheduler suite, clang-format clean.
This commit is contained in:
Rongsong Shen 2026-08-05 05:39:22 +00:00
parent 31ef5d7093
commit 4e5547ee55
Signed by: rshen
SSH key fingerprint: SHA256:8Vf2pZ3h6rJa7EhGK+WaAQ9Mta/apZvteW6g9U/WKKk
8 changed files with 889 additions and 30 deletions

View file

@ -393,6 +393,25 @@ sched_snapshot_save_load_test = executable(
dependencies: [scheduler_test_deps],
install: false)
# White-box test for the recording-directory path-validation function
# (scheduler-core spec, task 9.1, Req 5 AC 10 "Record file path
# validation"): rejects a recordFile containing a path separator or a ".."
# segment BEFORE any filesystem access, and confirms containment beneath
# record_dir via a genuine canonical-path (realpath()) comparison rather
# than a textual prefix match -- including a symlink-escape case and the
# "record_dir vs record_dir-evil sibling" prefix-match trap named in this
# task's own security brief. Same white-box #include pattern as
# test_job_update.c/test_handlers.c (pulls the real
# ../src/recording.c into this TU) -- the function under test has no
# scheduler-struct fixture dependency at all, so this is the simplest
# white-box test in this directory.
sched_recording_path_validation_test = executable(
'sched_recording_path_validation_test',
['test_recording_path_validation.c'],
include_directories:[incdir],
dependencies: [scheduler_test_deps],
install: false)
# Register as meson tests so `meson test --suite scheduler` runs them all.
# detect_leaks=0 matches the suite convention (fixtures are not torn down).
test('scheduler test', scheduler_test, suite: 'scheduler',
@ -453,3 +472,6 @@ test('scheduler driver session ordering test',
test('scheduler snapshot save load test',
sched_snapshot_save_load_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])
test('scheduler recording path validation test',
sched_recording_path_validation_test, suite: 'scheduler',
env: ['ASAN_OPTIONS=detect_leaks=0'])

View file

@ -0,0 +1,487 @@
/*
* test_recording_path_validation.c - contract test for
* sched_validate_record_filename() (scheduler-core spec, task 9.1, Req 5
* AC 10 "Record file path validation").
*
* Observable under test:
* - A filename containing a path separator ('/') is rejected (-EINVAL)
* before ANY filesystem call is made -- proven by pairing a malicious
* filename with a record_dir that does not exist on disk: if the
* function had proceeded past the string check, resolving that
* nonexistent record_dir would have failed with -ENOENT instead. Only
* -EINVAL coming back proves the string check short-circuited first.
* - A filename containing a ".." substring -- leading, trailing,
* embedded, or doubled -- is rejected the same way, same proof.
* - A record_dir containing a pre-existing symlink whose target escapes
* record_dir is REJECTED even when the filename component looks
* perfectly safe (no '/', no ".."), via a genuine canonical-path
* (realpath()) comparison -- not a textual prefix match. A dedicated
* test also proves the comparison isn't a naive strncmp() prefix
* check by using a sibling directory whose name textually starts with
* record_dir's own canonical path (the "/var/log/xce-evil" vs
* "/var/log/xce" trap called out in this task's own security brief).
* - A legitimate bare filename resolves to record_dir/filename and is
* accepted.
*
* White-box #include of ../src/recording.c (same convention as every
* other white-box test in this directory) -- the function under test has
* no internal state or fixture dependencies, so this is purely for the
* standalone-executable convention this testsuite already establishes
* (e.g. test_job_update.c, test_picker_iface.c), not because anything
* static/internal needs exercising beyond the one exported function.
*/
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
/* Pull in the real sched_validate_record_filename() under test. */
#include "../src/recording.c"
void setUp(void) { return; }
void tearDown(void) { return; }
/* ---------------------------------------------------------------------- */
/* Helpers */
/* ---------------------------------------------------------------------- */
static void make_tmp_dir(char* out, size_t out_len, const char* suffix)
{
char tmpl[PATH_MAX];
/* mkdtemp() requires the template's LAST SIX characters to be
* "XXXXXX" -- suffix must precede it, not follow it. */
snprintf(tmpl, sizeof(tmpl), "/tmp/sched_recval%s_XXXXXX", suffix);
TEST_ASSERT_NOT_NULL(mkdtemp(tmpl));
TEST_ASSERT_TRUE(strlen(tmpl) < out_len);
strcpy(out, tmpl);
}
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);
}
/* ---------------------------------------------------------------------- */
/* NULL / basic argument validation */
/* ---------------------------------------------------------------------- */
static void test_null_record_dir_returns_einval(void)
{
char out[PATH_MAX];
int rc
= sched_validate_record_filename(NULL, "foo.replay", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_null_filename_returns_einval(void)
{
char out[PATH_MAX];
int rc = sched_validate_record_filename("/tmp", NULL, out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_null_out_path_returns_einval(void)
{
int rc = sched_validate_record_filename("/tmp", "foo.replay", NULL, 64);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_zero_out_path_len_returns_einval(void)
{
char out[PATH_MAX];
int rc = sched_validate_record_filename("/tmp", "foo.replay", out, 0);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_empty_filename_returns_einval(void)
{
char out[PATH_MAX];
int rc = sched_validate_record_filename("/tmp", "", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
/* ---------------------------------------------------------------------- */
/* Path-separator rejection -- BEFORE any filesystem access (Req 5 AC 10) */
/* ---------------------------------------------------------------------- */
static void test_filename_with_slash_rejected_before_filesystem_access(void)
{
/* record_dir deliberately does NOT exist -- if the implementation had
* proceeded past the string check, realpath(record_dir) would fail
* with -ENOENT. Getting -EINVAL back instead proves the '/' check
* short-circuited first, with zero filesystem calls on the malicious
* string (or even on record_dir). */
static const char* bad_names[]
= { "/etc/passwd", "evil/", "a/b", "../../etc/passwd", "/" };
char out[PATH_MAX];
size_t i;
for (i = 0; i < sizeof(bad_names) / sizeof(bad_names[0]); i++) {
int rc = sched_validate_record_filename(
"/definitely/does/not/exist/xyz123", bad_names[i], out,
sizeof(out));
TEST_ASSERT_EQUAL_INT_MESSAGE(-EINVAL, rc, bad_names[i]);
}
}
/* ---------------------------------------------------------------------- */
/* ".." rejection, all positions -- BEFORE any filesystem access */
/* ---------------------------------------------------------------------- */
static void test_dotdot_all_positions_rejected_before_filesystem_access(void)
{
static const char* bad_names[] = {
"..", /* bare */
"...", /* tripled dot, still contains ".." */
"....", /* quadrupled */
"..evil", /* leading */
"evil..", /* trailing */
"ev..il", /* embedded */
"a..b..c" /* doubled */
};
char out[PATH_MAX];
size_t i;
for (i = 0; i < sizeof(bad_names) / sizeof(bad_names[0]); i++) {
int rc = sched_validate_record_filename(
"/definitely/does/not/exist/xyz123", bad_names[i], out,
sizeof(out));
TEST_ASSERT_EQUAL_INT_MESSAGE(-EINVAL, rc, bad_names[i]);
}
}
/* ---------------------------------------------------------------------- */
/* record_dir configuration errors (distinct from malicious-filename path) */
/* ---------------------------------------------------------------------- */
static void test_nonexistent_record_dir_with_valid_filename_returns_error(void)
{
/* A perfectly well-formed filename still fails once the (nonexistent)
* record_dir itself is resolved -- proves this is a DIFFERENT error
* path than the string-rejection tests above (which never reach
* realpath() at all). */
char out[PATH_MAX];
int rc = sched_validate_record_filename("/definitely/does/not/exist/xyz123",
"session.replay", out, sizeof(out));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_NOT_EQUAL(-EINVAL, rc);
}
static void test_record_dir_that_is_a_file_returns_enotdir(void)
{
char record_dir[PATH_MAX];
char file_path[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_parent");
snprintf(file_path, sizeof(file_path), "%s/not_a_dir", record_dir);
touch_file(file_path);
rc = sched_validate_record_filename(file_path, "session.replay", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(-ENOTDIR, rc);
}
/* ---------------------------------------------------------------------- */
/* Legitimate filename acceptance */
/* ---------------------------------------------------------------------- */
static void test_legitimate_bare_filename_resolves_inside_record_dir(void)
{
char record_dir[PATH_MAX];
char resolved_dir[PATH_MAX];
char out[PATH_MAX];
char expected[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_ok");
TEST_ASSERT_NOT_NULL(realpath(record_dir, resolved_dir));
rc = sched_validate_record_filename(record_dir, "session.replay", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(0, rc);
snprintf(expected, sizeof(expected), "%s/session.replay", resolved_dir);
TEST_ASSERT_EQUAL_STRING(expected, out);
}
static void test_legitimate_filename_overwriting_existing_regular_file_ok(void)
{
/* An existing regular file with the same name is a legitimate
* overwrite target (task 9.2's fopen() will truncate it), not an
* error. */
char record_dir[PATH_MAX];
char resolved_dir[PATH_MAX];
char existing[PATH_MAX];
char out[PATH_MAX];
char expected[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_overwrite");
TEST_ASSERT_NOT_NULL(realpath(record_dir, resolved_dir));
snprintf(existing, sizeof(existing), "%s/session.replay", resolved_dir);
touch_file(existing);
rc = sched_validate_record_filename(record_dir, "session.replay", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(0, rc);
snprintf(expected, sizeof(expected), "%s/session.replay", resolved_dir);
TEST_ASSERT_EQUAL_STRING(expected, out);
}
static void test_out_path_buffer_too_small_returns_enametoolong(void)
{
char record_dir[PATH_MAX];
char out[4]; /* far too small for any real resolved path */
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_small");
rc = sched_validate_record_filename(record_dir, "session.replay", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(-ENAMETOOLONG, rc);
}
/* ---------------------------------------------------------------------- */
/* Symlink-escape containment -- canonical-path comparison, not textual */
/* prefix matching (Req 5 AC 10's core security property) */
/* ---------------------------------------------------------------------- */
static void test_symlink_inside_record_dir_escaping_outside_is_rejected(void)
{
/* record_dir contains a symlink (named exactly like a legitimate,
* innocent-looking filename) whose target lives OUTSIDE record_dir.
* The filename component itself violates neither the '/' nor the
* ".." rule -- only the canonical-path containment check on the
* pre-existing symlink's resolved target can catch this. */
char record_dir[PATH_MAX];
char outside_dir[PATH_MAX];
char outside_target[PATH_MAX];
char link_path[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_escsrc");
make_tmp_dir(outside_dir, sizeof(outside_dir), "_escdst");
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 = sched_validate_record_filename(record_dir, "looks_safe", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(-EACCES, rc);
}
static void test_symlink_escape_not_caught_by_naive_textual_prefix_match(void)
{
/* The exact trap this task's security brief calls out by name:
* record_dir="<tmp>/xce" vs a SIBLING directory
* "<tmp>/xce-evil" -- the sibling's path textually STARTS WITH
* record_dir's canonical path, so a naive strncmp()-only containment
* check would wrongly accept it. A pre-existing symlink inside
* record_dir points at a file inside that sibling; a correct
* canonical-path comparison (requiring the byte after the prefix to
* be '/') must still reject it. */
char base[PATH_MAX];
char record_dir[PATH_MAX];
char sibling_dir[PATH_MAX];
char sibling_target[PATH_MAX];
char link_path[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(base, sizeof(base), "_prefixtrap");
snprintf(record_dir, sizeof(record_dir), "%s/xce", base);
TEST_ASSERT_EQUAL_INT(0, mkdir(record_dir, 0755));
snprintf(sibling_dir, sizeof(sibling_dir), "%s/xce-evil", base);
TEST_ASSERT_EQUAL_INT(0, mkdir(sibling_dir, 0755));
snprintf(sibling_target, sizeof(sibling_target), "%s/payload", sibling_dir);
touch_file(sibling_target);
snprintf(link_path, sizeof(link_path), "%s/trap", record_dir);
TEST_ASSERT_EQUAL_INT(0, symlink(sibling_target, link_path));
rc = sched_validate_record_filename(record_dir, "trap", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EACCES, rc);
}
static void test_symlink_inside_record_dir_staying_contained_is_accepted(void)
{
/* Sanity companion to the two rejection tests above: a symlink whose
* target genuinely stays within record_dir must be ACCEPTED -- proves
* the containment check isn't simply "reject every symlink". */
char record_dir[PATH_MAX];
char resolved_dir[PATH_MAX];
char real_target[PATH_MAX];
char link_path[PATH_MAX];
char out[PATH_MAX];
char expected[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_inbound");
TEST_ASSERT_NOT_NULL(realpath(record_dir, resolved_dir));
snprintf(real_target, sizeof(real_target), "%s/real.replay", resolved_dir);
touch_file(real_target);
snprintf(link_path, sizeof(link_path), "%s/alias", resolved_dir);
TEST_ASSERT_EQUAL_INT(0, symlink(real_target, link_path));
rc = sched_validate_record_filename(record_dir, "alias", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(0, rc);
snprintf(expected, sizeof(expected), "%s/alias", resolved_dir);
TEST_ASSERT_EQUAL_STRING(expected, out);
}
/* ---------------------------------------------------------------------- */
/* Hard-link inode-aliasing escape -- CWE-59 link-following via a hard */
/* link rather than a symlink (Req 5 AC 10's core security property) */
/* ---------------------------------------------------------------------- */
static void test_hardlink_inside_record_dir_aliasing_outside_is_rejected(void)
{
/* record_dir contains a REGULAR file (named exactly like a legitimate,
* innocent-looking filename) that is actually a hard link to a victim
* file OUTSIDE record_dir -- same inode, no separate "link target"
* path for realpath()/lstat() to resolve and contain-check the way
* there is for a symlink (mirrors
* test_symlink_inside_record_dir_escaping_outside_is_rejected above,
* substituting link() for symlink()). The filename component itself
* violates neither the '/' nor the ".." rule, and the entry IS
* S_ISREG and canonically inside record_dir -- only the st_nlink > 1
* check can catch this. This exercises real link(2)/lstat(2) kernel
* behavior (no mocking): a genuine hard link is created on disk and
* genuinely shares the victim's inode, so if this test passes only
* because the fix is in place, the hard link IS a real bypass on this
* platform absent that fix (proven by the RED phase below). */
char record_dir[PATH_MAX];
char outside_dir[PATH_MAX];
char victim[PATH_MAX];
char aliased_path[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_hlsrc");
make_tmp_dir(outside_dir, sizeof(outside_dir), "_hldst");
snprintf(victim, sizeof(victim), "%s/victim", outside_dir);
touch_file(victim);
snprintf(aliased_path, sizeof(aliased_path), "%s/looks_safe", record_dir);
TEST_ASSERT_EQUAL_INT(0, link(victim, aliased_path));
rc = sched_validate_record_filename(record_dir, "looks_safe", out,
sizeof(out));
TEST_ASSERT_EQUAL_INT(-EACCES, rc);
}
static void test_symlink_to_hardlinked_alias_inside_record_dir_is_rejected(void)
{
/* One extra hop beyond both escape tests above: record_dir contains a
* REGULAR file ("internal") that is a hard link to a victim file
* OUTSIDE record_dir (mirrors
* test_hardlink_inside_record_dir_aliasing_outside_is_rejected), PLUS a
* SYMLINK ("alias") inside record_dir pointing at that hard-linked
* "internal" entry (mirrors
* test_symlink_inside_record_dir_escaping_outside_is_rejected's shape).
* Requesting "internal" directly is already caught by the direct-entry
* st_nlink > 1 check. Requesting "alias" must ALSO be rejected: the
* S_ISLNK branch's realpath()/path_is_contained() call only confirms
* the resolved target is textually/canonically inside record_dir --
* "internal" IS inside record_dir, so containment alone is not enough.
* The resolved target must ALSO be checked for st_nlink > 1, exactly
* like the direct-entry branch does, or fopen()-ing the returned
* out_path follows the symlink into the aliased outside inode and
* corrupts the victim file -- same CWE-59 impact, reached via one
* extra symlink hop. */
char record_dir[PATH_MAX];
char outside_dir[PATH_MAX];
char victim[PATH_MAX];
char internal_path[PATH_MAX];
char alias_path[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_hlaliassrc");
make_tmp_dir(outside_dir, sizeof(outside_dir), "_hlaliasdst");
snprintf(victim, sizeof(victim), "%s/victim", outside_dir);
touch_file(victim);
snprintf(internal_path, sizeof(internal_path), "%s/internal", record_dir);
TEST_ASSERT_EQUAL_INT(0, link(victim, internal_path));
snprintf(alias_path, sizeof(alias_path), "%s/alias", record_dir);
TEST_ASSERT_EQUAL_INT(0, symlink(internal_path, alias_path));
rc = sched_validate_record_filename(record_dir, "alias", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EACCES, rc);
}
/* ---------------------------------------------------------------------- */
/* Defensive: existing non-regular, non-symlink entry is rejected */
/* ---------------------------------------------------------------------- */
static void test_existing_directory_with_same_name_rejected(void)
{
char record_dir[PATH_MAX];
char subdir[PATH_MAX];
char out[PATH_MAX];
int rc;
make_tmp_dir(record_dir, sizeof(record_dir), "_subdir");
snprintf(subdir, sizeof(subdir), "%s/oops", record_dir);
TEST_ASSERT_EQUAL_INT(0, mkdir(subdir, 0755));
rc = sched_validate_record_filename(record_dir, "oops", out, sizeof(out));
TEST_ASSERT_EQUAL_INT(-EEXIST, rc);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_null_record_dir_returns_einval);
RUN_TEST(test_null_filename_returns_einval);
RUN_TEST(test_null_out_path_returns_einval);
RUN_TEST(test_zero_out_path_len_returns_einval);
RUN_TEST(test_empty_filename_returns_einval);
RUN_TEST(test_filename_with_slash_rejected_before_filesystem_access);
RUN_TEST(test_dotdot_all_positions_rejected_before_filesystem_access);
RUN_TEST(test_nonexistent_record_dir_with_valid_filename_returns_error);
RUN_TEST(test_record_dir_that_is_a_file_returns_enotdir);
RUN_TEST(test_legitimate_bare_filename_resolves_inside_record_dir);
RUN_TEST(test_legitimate_filename_overwriting_existing_regular_file_ok);
RUN_TEST(test_out_path_buffer_too_small_returns_enametoolong);
RUN_TEST(test_symlink_inside_record_dir_escaping_outside_is_rejected);
RUN_TEST(test_symlink_escape_not_caught_by_naive_textual_prefix_match);
RUN_TEST(test_symlink_inside_record_dir_staying_contained_is_accepted);
RUN_TEST(test_hardlink_inside_record_dir_aliasing_outside_is_rejected);
RUN_TEST(test_symlink_to_hardlinked_alias_inside_record_dir_is_rejected);
RUN_TEST(test_existing_directory_with_same_name_rejected);
return UNITY_END();
}