From 5dd8432b1c86817ec8f940e087ac90ef46fe91fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 11:38:07 +0000 Subject: [PATCH] fix(cron): deliver full SQLite TEXT for job schedule and message Fixed-size cron_job_row_t buffers silently truncated TEXT on read. A due interval job whose truncated schedule no longer parsed stayed due and re-fired every poll. Messages over 511 bytes were clipped before the agent ran. Co-authored-by: esadrianno --- src/core/memory.c | 58 ++++++++++++++++++++++++++++--------- src/core/memory.h | 28 ++++++++++++++---- src/gateway/routes.c | 25 ++++++++++++++-- src/tools/cron.c | 21 +++++++++++--- tests/test_cron.c | 69 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 174 insertions(+), 27 deletions(-) diff --git a/src/core/memory.c b/src/core/memory.c index cb763ab..c87d557 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -333,6 +333,40 @@ static void copy_str_bounded(char *dst, size_t dst_size, const char *src) dst[n] = '\0'; } +static char *dup_sqlite_text(sqlite3_stmt *stmt, int col) +{ + const char *src = (const char *)sqlite3_column_text(stmt, col); + return strdup(src ? src : ""); +} + +void cron_job_row_free(cron_job_row_t *row) +{ + if (!row) return; + free(row->schedule); + free(row->message); + row->schedule = NULL; + row->message = NULL; +} + +static int fill_cron_job_row(sqlite3_stmt *stmt, cron_job_row_t *out) +{ + char *schedule = dup_sqlite_text(stmt, 1); + char *message = dup_sqlite_text(stmt, 2); + if (!schedule || !message) { + free(schedule); + free(message); + return -1; + } + copy_str_bounded(out->id, sizeof(out->id), (const char *)sqlite3_column_text(stmt, 0)); + out->schedule = schedule; + out->message = message; + copy_str_bounded(out->channel, sizeof(out->channel), (const char *)sqlite3_column_text(stmt, 3)); + copy_str_bounded(out->recipient, sizeof(out->recipient), (const char *)sqlite3_column_text(stmt, 4)); + out->next_run = sqlite3_column_int64(stmt, 5); + out->enabled = sqlite3_column_int(stmt, 6); + return 0; +} + int cron_job_create(const char *id, const char *schedule, const char *message, const char *channel, const char *recipient, long long next_run, int enabled) { @@ -400,13 +434,12 @@ int cron_job_list(cron_job_row_t *out, int max_count) if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; int count = 0; while (count < max_count && sqlite3_step(stmt) == SQLITE_ROW) { - copy_str_bounded(out[count].id, sizeof(out[count].id), (const char *)sqlite3_column_text(stmt, 0)); - copy_str_bounded(out[count].schedule, sizeof(out[count].schedule), (const char *)sqlite3_column_text(stmt, 1)); - copy_str_bounded(out[count].message, sizeof(out[count].message), (const char *)sqlite3_column_text(stmt, 2)); - copy_str_bounded(out[count].channel, sizeof(out[count].channel), (const char *)sqlite3_column_text(stmt, 3)); - copy_str_bounded(out[count].recipient, sizeof(out[count].recipient), (const char *)sqlite3_column_text(stmt, 4)); - out[count].next_run = sqlite3_column_int64(stmt, 5); - out[count].enabled = sqlite3_column_int(stmt, 6); + if (fill_cron_job_row(stmt, &out[count]) != 0) { + for (int i = 0; i < count; i++) + cron_job_row_free(&out[i]); + sqlite3_finalize(stmt); + return -1; + } count++; } sqlite3_finalize(stmt); @@ -423,13 +456,10 @@ int cron_job_get_next_due(long long now, cron_job_row_t *out) sqlite3_bind_int64(stmt, 1, now); int ret = 0; if (sqlite3_step(stmt) == SQLITE_ROW) { - copy_str_bounded(out->id, sizeof(out->id), (const char *)sqlite3_column_text(stmt, 0)); - copy_str_bounded(out->schedule, sizeof(out->schedule), (const char *)sqlite3_column_text(stmt, 1)); - copy_str_bounded(out->message, sizeof(out->message), (const char *)sqlite3_column_text(stmt, 2)); - copy_str_bounded(out->channel, sizeof(out->channel), (const char *)sqlite3_column_text(stmt, 3)); - copy_str_bounded(out->recipient, sizeof(out->recipient), (const char *)sqlite3_column_text(stmt, 4)); - out->next_run = sqlite3_column_int64(stmt, 5); - out->enabled = sqlite3_column_int(stmt, 6); + if (fill_cron_job_row(stmt, out) != 0) { + sqlite3_finalize(stmt); + return -1; + } ret = 1; } sqlite3_finalize(stmt); diff --git a/src/core/memory.h b/src/core/memory.h index 44aac17..af392dc 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -97,17 +97,34 @@ int config_kv_get(const char *key, char *value_out, size_t max_len); */ int config_kv_set(const char *key, const char *value); -/** Row from cron_jobs table for list/get operations. */ +/** + * Row from cron_jobs table for list/get operations. + * + * schedule and message are heap copies of the SQLite TEXT columns. The + * caller must cron_job_row_free() each filled row. Example: + * cron_job_row_t row; + * memset(&row, 0, sizeof(row)); + * if (cron_job_get_next_due(now, &row) == 1) { + * cron_job_row_free(&row); + * } + */ typedef struct cron_job_row { char id[128]; - char schedule[128]; - char message[512]; + char *schedule; + char *message; char channel[64]; char recipient[64]; long long next_run; int enabled; } cron_job_row_t; +/** + * Free heap fields on a cron job row. Safe on NULL, zeroed, or already-freed rows. + * + * @param row Row to release (may be NULL). + */ +void cron_job_row_free(cron_job_row_t *row); + /** * Create a cron job. * @@ -140,7 +157,8 @@ int cron_job_update_next_run(const char *id, long long next_run); /** * List cron jobs into output array. * - * @param out Array to fill (caller-allocated). + * @param out Array to fill (caller-allocated). Heap fields are owned + * by the caller on success; on -1, no row is owned. * @param max_count Maximum jobs to return. * @return Number of jobs written, or -1 on error. */ @@ -150,7 +168,7 @@ int cron_job_list(cron_job_row_t *out, int max_count); * Get the next due job (next_run <= now, enabled). * * @param now Current Unix timestamp. - * @param out Filled with job data if found. + * @param out Filled with job data if found. Caller must cron_job_row_free(). * @return 1 if found, 0 if none, -1 on error. */ int cron_job_get_next_due(long long now, cron_job_row_t *out); diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 514b60f..0ea3489 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -399,30 +399,49 @@ static void handle_session_delete(const char *id, char *buf, size_t size, int *s json_response(buf, size, status, "{\"ok\":true}"); } +static void free_cron_job_rows(cron_job_row_t *rows, int n) +{ + if (!rows || n <= 0) return; + for (int i = 0; i < n; i++) + cron_job_row_free(&rows[i]); +} + static void handle_cron_list(char *buf, size_t size, int *status) { cron_job_row_t *rows = calloc(64, sizeof(cron_job_row_t)); if (!rows) { json_error(buf, size, status, 500, "Out of memory"); return; } int n = cron_job_list(rows, 64); + if (n < 0) { + free(rows); + json_error(buf, size, status, 500, "Internal error"); + return; + } cJSON *arr = cJSON_CreateArray(); - if (!arr) { free(rows); json_error(buf, size, status, 500, "Internal error"); return; } + if (!arr) { + free_cron_job_rows(rows, n); + free(rows); + json_error(buf, size, status, 500, "Internal error"); + return; + } for (int i = 0; i < n; i++) { cJSON *obj = cJSON_CreateObject(); if (!obj) { + free_cron_job_rows(rows, n); free(rows); cJSON_Delete(arr); json_error(buf, size, status, 500, "Internal error"); return; } cJSON_AddItemToObject(obj, "id", cJSON_CreateString(rows[i].id)); - cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule)); - cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message)); + cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule ? rows[i].schedule : "")); + cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message ? rows[i].message : "")); cJSON_AddItemToObject(obj, "channel", cJSON_CreateString(rows[i].channel)); cJSON_AddItemToObject(obj, "recipient", cJSON_CreateString(rows[i].recipient)); cJSON_AddItemToObject(obj, "next_run", cJSON_CreateNumber((double)rows[i].next_run)); cJSON_AddItemToObject(obj, "enabled", cJSON_CreateBool(rows[i].enabled)); cJSON_AddItemToArray(arr, obj); } + free_cron_job_rows(rows, n); free(rows); json_print_to_buf(arr, buf, size, status); cJSON_Delete(arr); diff --git a/src/tools/cron.c b/src/tools/cron.c index eb46e18..cfa866a 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -194,9 +194,10 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) row.recipient[0] ? row.recipient : "default"); out->session_id = strdup(session_id); out->user_id = strdup(row.id); - out->text = strdup(row.message); + out->text = strdup(row.message ? row.message : ""); out->attachments = NULL; out->attachments_count = 0; + cron_job_row_free(&row); return 1; } @@ -251,21 +252,33 @@ static int cron_tool_execute(const char *args_json, char *result_buf, size_t max int ret = 0; if (strcmp(operation, "list") == 0) { cron_job_row_t rows[64]; + memset(rows, 0, sizeof(rows)); int n = cron_job_list(rows, 64); + if (n < 0) { + cJSON_Delete(root); + snprintf(result_buf, max_len, "{\"error\":\"failed to list jobs\"}"); + return -1; + } cJSON *arr = cJSON_CreateArray(); - if (!arr) { cJSON_Delete(root); snprintf(result_buf, max_len, "{\"error\":\"out of memory\"}"); return -1; } + if (!arr) { + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); + cJSON_Delete(root); + snprintf(result_buf, max_len, "{\"error\":\"out of memory\"}"); + return -1; + } for (int i = 0; i < n; i++) { cJSON *obj = cJSON_CreateObject(); if (!obj) break; cJSON_AddItemToObject(obj, "id", cJSON_CreateString(rows[i].id)); - cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule)); - cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message)); + cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule ? rows[i].schedule : "")); + cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message ? rows[i].message : "")); cJSON_AddItemToObject(obj, "channel", cJSON_CreateString(rows[i].channel)); cJSON_AddItemToObject(obj, "recipient", cJSON_CreateString(rows[i].recipient)); cJSON_AddItemToObject(obj, "next_run", cJSON_CreateNumber((double)rows[i].next_run)); cJSON_AddItemToObject(obj, "enabled", cJSON_CreateBool(rows[i].enabled)); cJSON_AddItemToArray(arr, obj); } + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); char *s = cJSON_PrintUnformatted(arr); cJSON_Delete(arr); if (s) { diff --git a/tests/test_cron.c b/tests/test_cron.c index ea21c0c..6ab6ff3 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -5,6 +5,7 @@ #include "tools/cron.h" #include "core/memory.h" +#include "channels/channel.h" #include #include #include @@ -74,16 +75,22 @@ static int test_cron_job_crud_and_due(void) ASSERT(cron_parse_next_run("at:9999999999", now, &next) == 0); ASSERT(cron_job_create("job1", "at:9999999999", "Remind me", "cli", "default", next, 1) == 0); cron_job_row_t rows[16]; + memset(rows, 0, sizeof(rows)); int n = cron_job_list(rows, 16); ASSERT(n == 1); ASSERT(strcmp(rows[0].id, "job1") == 0); ASSERT(strcmp(rows[0].message, "Remind me") == 0); + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); cron_job_row_t due; + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(now, &due) == 0); ASSERT(cron_job_get_next_due(9999999999, &due) == 1); ASSERT(strcmp(due.id, "job1") == 0); + cron_job_row_free(&due); ASSERT(cron_job_toggle("job1") == 0); + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(9999999999, &due) == 0); + cron_job_row_free(&due); ASSERT(cron_job_toggle("job1") == 0); ASSERT(cron_job_delete("job1") == 0); ASSERT(cron_job_list(rows, 16) == 0); @@ -108,9 +115,12 @@ static int test_cron_tool_execute(void) ASSERT(cron_tool->execute("{\"operation\":\"list\"}", buf, sizeof(buf)) == 0); ASSERT(strstr(buf, "test") != NULL); cron_job_row_t rows[16]; + memset(rows, 0, sizeof(rows)); int n = cron_job_list(rows, 16); ASSERT(n >= 1); - const char *id = rows[0].id; + char id[128]; + snprintf(id, sizeof(id), "%s", rows[0].id); + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); char del_json[256]; snprintf(del_json, sizeof(del_json), "{\"operation\":\"delete\",\"id\":\"%s\"}", id); ASSERT(cron_tool->execute(del_json, buf, sizeof(buf)) == 0); @@ -131,14 +141,69 @@ static int test_one_shot_detection(void) ASSERT(memory_init(path) == 0); ASSERT(cron_job_create("oneshot1", "at:9999999999", "One-shot", "cli", "default", 9999999999, 1) == 0); cron_job_row_t due; + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(9999999999, &due) == 1); ASSERT(cron_is_one_shot(due.schedule) == 1); + cron_job_row_free(&due); cron_job_delete("oneshot1"); memory_cleanup(); remove(path); return 0; } +static int test_due_job_delivers_full_message(void) +{ + const char *path = "/tmp/shellclaw_test_cron_long_message.db"; + remove(path); + ASSERT(memory_init(path) == 0); + char message[600]; + memset(message, 'A', 599); + message[599] = '\0'; + long long now = (long long)time(NULL); + ASSERT(cron_job_create("longmsg", "interval:60", message, "cli", "default", now - 1, 1) == 0); + const channel_t *ch = channel_cron_get(); + channel_incoming_msg_t msg; + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 1); + ASSERT(msg.text != NULL); + ASSERT(strlen(msg.text) == 599); + ASSERT(strcmp(msg.text, message) == 0); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_long_interval_schedule_advances_next_run(void) +{ + const char *path = "/tmp/shellclaw_test_cron_long_schedule.db"; + remove(path); + ASSERT(memory_init(path) == 0); + char zeros[121]; + memset(zeros, '0', 120); + zeros[120] = '\0'; + char schedule[160]; + snprintf(schedule, sizeof(schedule), "interval:%s60", zeros); + ASSERT(strlen(schedule) > 127); + long long parsed = 0; + long long now = (long long)time(NULL); + ASSERT(cron_parse_next_run(schedule, now, &parsed) == 0); + ASSERT(parsed == now + 60); + ASSERT(cron_job_create("longsched", schedule, "tick", "cli", "default", now - 1, 1) == 0); + const channel_t *ch = channel_cron_get(); + channel_incoming_msg_t msg; + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 1); + ASSERT(msg.text != NULL && strcmp(msg.text, "tick") == 0); + channel_incoming_msg_clear(&msg); + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 0); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -149,6 +214,8 @@ int main(void) RUN(test_cron_job_crud_and_due()); RUN(test_cron_tool_execute()); RUN(test_one_shot_detection()); + RUN(test_due_job_delivers_full_message()); + RUN(test_long_interval_schedule_advances_next_run()); printf("test_cron: all tests passed\n"); return 0; }