diff --git a/src/tools/cron.c b/src/tools/cron.c index a6a753b..ca7418e 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -26,14 +26,17 @@ static int parse_field(const char *s, int *out, int min_val, int max_val) { + char *end = NULL; + long n; if (!s || !out) return -1; if (strcmp(s, "*") == 0) { *out = -1; return 0; } - char *end = NULL; - long n = strtol(s, &end, 10); - if (end == s || n < min_val || n > max_val) return -1; + /* strtol stops at the first junk character. "1,15" would otherwise + * match only day 1 and still schedule successfully. */ + n = strtol(s, &end, 10); + if (!end || end == s || *end != '\0' || n < min_val || n > max_val) return -1; *out = (int)n; return 0; } @@ -129,13 +132,21 @@ int cron_parse_next_run(const char *schedule, long long now, long long *next_out { if (!schedule || !next_out) return -1; if (strncmp(schedule, CRON_PREFIX_INTERVAL, strlen(CRON_PREFIX_INTERVAL)) == 0) { - long sec = strtol(schedule + strlen(CRON_PREFIX_INTERVAL), NULL, 10); - if (sec <= 0) return -1; + const char *num = schedule + strlen(CRON_PREFIX_INTERVAL); + char *end = NULL; + long sec = strtol(num, &end, 10); + if (!end || end == num || *end != '\0' || sec <= 0) return -1; *next_out = now + sec; return 0; } if (strncmp(schedule, CRON_PREFIX_AT, strlen(CRON_PREFIX_AT)) == 0) { - long long ts = (long long)strtoll(schedule + strlen(CRON_PREFIX_AT), NULL, 10); + const char *num = schedule + strlen(CRON_PREFIX_AT); + char *end = NULL; + long long ts; + /* A partial convert (at:2026-10-08 -> 2026) is already due, so the + * one-shot fires on the next poll and ack deletes the future reminder. */ + ts = strtoll(num, &end, 10); + if (!end || end == num || *end != '\0') return -1; *next_out = ts; return 0; } diff --git a/tests/test_cron.c b/tests/test_cron.c index 2e8369c..97e9b7c 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -124,6 +124,30 @@ static int test_invalid_schedule(void) return 0; } +/* strto* used to stop at the first non-digit and still return success. + * at:2026-10-08 became next_run 2026 (already due), so the one-shot fired + * on the next poll and ack deleted it. 1,3,5 matched only the first day. */ +static int test_schedule_rejects_partial_numbers(void) +{ + long long now = 1700000000; + long long next = 99; + + ASSERT(cron_parse_next_run("at:2026-10-08", now, &next) == -1); + ASSERT(next == 99); + ASSERT(cron_parse_next_run("at:tomorrow", now, &next) == -1); + ASSERT(cron_parse_next_run("at:", now, &next) == -1); + ASSERT(cron_parse_next_run("interval:60abc", now, &next) == -1); + ASSERT(cron_parse_next_run("cron:0 9 * * 1,3,5", now, &next) == -1); + ASSERT(cron_parse_next_run("cron:0 9 1,15 * *", now, &next) == -1); + ASSERT(cron_parse_next_run("0 9 * * 1-5", now, &next) == 0); + ASSERT(next > now); + ASSERT(cron_parse_next_run("at:1700000100", now, &next) == 0); + ASSERT(next == 1700000100); + ASSERT(cron_parse_next_run("interval:60", now, &next) == 0); + ASSERT(next == now + 60); + return 0; +} + static int test_cron_job_crud_and_due(void) { const char *path = "/tmp/shellclaw_test_cron.db"; @@ -659,6 +683,7 @@ int main(void) RUN(test_cron_expr_skips_current_minute()); RUN(test_cron_expr_monthly_beyond_eight_days()); RUN(test_invalid_schedule()); + RUN(test_schedule_rejects_partial_numbers()); RUN(test_cron_job_crud_and_due()); RUN(test_cron_tool_execute()); RUN(test_one_shot_detection());