diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 440892e..ab89d60 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -41,8 +41,10 @@ For a quick build & deploy cycle, use the `deploy.sh` script. It will automatica The HTTP response suite compiles the actual `src/http_server.c` with stubbed PS5 services and real libmicrohttpd response objects. It injects response-buffer, -response-creation, CORS-header, and queue failures, then sends 10,000 requests -through a real loopback HTTP server. ASan (including leak detection) and UBSan +response-creation, CORS-header, queue, reallocation, and file I/O failures, then +sends 10,000 requests through a real loopback HTTP server. It also checks +disconnected/chunked requests, concurrent uploads, and server-stop cleanup in +thread-per-connection mode. ASan (including leak detection) and UBSan are enabled by default. Test files, storage paths, and binaries are temporary; the suite does not connect to a console or launch payloads. @@ -68,8 +70,18 @@ the default sanitizer run for verification. `.github/workflows/verify.yml` runs the HTTP suite, existing frontend tests, and frontend build without publishing or deploying. Frontend lint still reports existing errors. The host suite tests response -ownership/status handling; it does not yet cover aborted request cleanup, -storage transactions, autoload concurrency, or PS5 firmware behavior. +ownership/status handling, request cleanup, and autoload-list bounds. Storage +transactions, autoload worker concurrency, and PS5 firmware behavior remain +separate validation scopes. Backend storage/service calls in the HTTP tests are +stubbed; successful mock imports move actual files inside temporary fixtures. + +The HTTP server accepts config/source bodies up to **1 MiB** and streamed uploads +up to **512 MiB**. Larger declared or accumulated bodies return HTTP 413; empty +request bodies return HTTP 400. Upload staging files use private, unique +`.upload-*` names and are removed by request teardown if uncommitted. This does +not purge older orphan files or guarantee cleanup after power loss/SIGKILL. +Autoload CSV responses currently allow 4,095 bytes; larger/unreadable lists return +HTTP 500 with JSON rather than a truncated list. Native compilation remains a separate gate. To preserve the previous ELF, build a candidate under `scratch/` rather than run `make clean`: diff --git a/include/http_server.h b/include/http_server.h index e18c3e3..12eb8e6 100644 --- a/include/http_server.h +++ b/include/http_server.h @@ -3,10 +3,18 @@ #include +/* Bound accumulated JSON and streamed payloads independently. */ +#define HTTP_POST_BODY_LIMIT ((size_t)1024 * 1024) +#define HTTP_UPLOAD_BODY_LIMIT ((size_t)512 * 1024 * 1024) + /* MHD request handler callback — dispatches all routes. */ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn, const char *url, const char *method, const char *version, const char *upload_data, size_t *upload_data_size, void **con_cls); +/* Registered with MHD_OPTION_NOTIFY_COMPLETED for every daemon instance. */ +void http_request_completed(void *cls, struct MHD_Connection *conn, + void **con_cls, enum MHD_RequestTerminationCode reason); + #endif diff --git a/src/http_server.c b/src/http_server.c index b00239c..5fe10f1 100644 --- a/src/http_server.c +++ b/src/http_server.c @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include "http_server.h" @@ -75,6 +77,17 @@ static enum MHD_Result send_out_of_memory(struct MHD_Connection *conn) { return send_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, resp); } +/* Messages passed here are fixed application strings, not user input. */ +static enum MHD_Result send_request_error(struct MHD_Connection *conn, + unsigned int status, const char *message) { + char json[256]; + snprintf(json, sizeof(json), "{\"error\":\"%s\"}", message); + struct MHD_Response *resp = create_buffer_response( + strlen(json), json, MHD_RESPMEM_MUST_COPY); + add_response_header(resp, "Content-Type", "application/json"); + return send_response(conn, status, resp); +} + /* Success is separate from response creation: a failed OOM response must not * be mistaken for a successful buffer allocation. */ static int alloc_response_buffer_sized(char **out, size_t size) { @@ -152,19 +165,202 @@ static void url_decode_in_place(char *str) { struct PostStatus { char *data; size_t size; - int error; + size_t capacity; + unsigned int error; }; struct UploadStatus { FILE *fp; size_t total_size; - int error; + unsigned int error; char filename[256]; char temp_path[512]; char mode[32]; - char repo_url[512]; }; +enum RequestKind { REQUEST_CONFIG, REQUEST_SOURCES, REQUEST_UPLOAD }; + +struct RequestContext { + enum RequestKind kind; + int finished; + union { + struct PostStatus post; + struct UploadStatus upload; + } state; +}; + +/* Read-only requests need no allocation. This marker is never dereferenced. */ +#define REQUEST_MARKER ((void *)1) + +void http_request_completed(void *cls, struct MHD_Connection *conn, + void **con_cls, enum MHD_RequestTerminationCode reason) { + (void)cls; + (void)conn; + (void)reason; + if (!con_cls || !*con_cls) + return; + void *context = *con_cls; + *con_cls = NULL; + if (context == REQUEST_MARKER) + return; + + struct RequestContext *request = context; + if (request->kind == REQUEST_UPLOAD) { + struct UploadStatus *upload = &request->state.upload; + if (upload->fp && fclose(upload->fp) != 0) + pldmgr_log("[PLDMGR] Warning: Failed to close upload during cleanup\n"); + /* Each request owns a unique path. A committed file was renamed out + * of this path; remove any uncommitted remainder on every exit. */ + if (upload->temp_path[0] && remove(upload->temp_path) != 0 && errno != ENOENT) + pldmgr_log("[PLDMGR] Warning: Failed to remove upload temp file: %s\n", + upload->temp_path); + } else { + free(request->state.post.data); + } + free(request); +} + +static unsigned int check_body_length(struct MHD_Connection *conn, size_t limit) { + const char *value = MHD_lookup_connection_value(conn, MHD_HEADER_KIND, "Content-Length"); + if (!value) + return 0; /* Chunked/unknown length is checked while receiving data. */ + if (!isdigit((unsigned char)*value)) + return MHD_HTTP_BAD_REQUEST; + errno = 0; + char *end; + unsigned long long length = strtoull(value, &end, 10); + if (errno == ERANGE || *end) + return MHD_HTTP_BAD_REQUEST; + return length > limit ? MHD_HTTP_CONTENT_TOO_LARGE : 0; +} + +static void append_post_data(struct PostStatus *post, const char *data, size_t size) { + if (post->error) + return; + if (size > HTTP_POST_BODY_LIMIT - post->size) { + post->error = MHD_HTTP_CONTENT_TOO_LARGE; + return; + } + size_t needed = post->size + size + 1; + if (needed > post->capacity) { + size_t capacity = post->capacity ? post->capacity : 4096; + while (capacity < needed) { + capacity = capacity > (HTTP_POST_BODY_LIMIT + 1) / 2 + ? HTTP_POST_BODY_LIMIT + 1 : capacity * 2; + } + char *new_data = realloc(post->data, capacity); + if (!new_data) { + post->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + post->data = new_data; + post->capacity = capacity; + } + memcpy(post->data + post->size, data, size); + post->size += size; + post->data[post->size] = '\0'; +} + +static void prepare_upload(struct MHD_Connection *conn, struct UploadStatus *upload) { + const char *filename = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename"); + const char *mode = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "mode"); + if (!mode) + mode = "new"; + if (!is_safe_filename(filename) || strlen(filename) >= sizeof(upload->filename) || + strlen(mode) >= sizeof(upload->mode)) { + upload->error = MHD_HTTP_BAD_REQUEST; + return; + } + strcpy(upload->filename, filename); + strcpy(upload->mode, mode); + upload->error = check_body_length(conn, HTTP_UPLOAD_BODY_LIMIT); + if (upload->error) + return; + + if ((mkdir(BASE_DATA_DIR, 0777) != 0 && errno != EEXIST) || + (mkdir(PAYLOADS_STORAGE_DIR, 0777) != 0 && errno != EEXIST)) { + upload->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + int n = snprintf(upload->temp_path, sizeof(upload->temp_path), + "%s/.upload-XXXXXX", PAYLOADS_STORAGE_DIR); + if (n < 0 || (size_t)n >= sizeof(upload->temp_path)) { + upload->temp_path[0] = '\0'; + upload->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + int fd = mkstemp(upload->temp_path); + if (fd < 0) { + upload->temp_path[0] = '\0'; /* No file was acquired to clean up. */ + upload->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + upload->fp = fdopen(fd, "wb"); + if (!upload->fp) { + close(fd); + upload->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + return; + } + pldmgr_log("[PLDMGR] Starting upload: %s (mode: %s)\n", filename, mode); +} + +static enum MHD_Result finish_request_error(struct MHD_Connection *conn, + struct RequestContext *request, + unsigned int status) { + request->finished = 1; + if (status == MHD_HTTP_CONTENT_TOO_LARGE) + return send_request_error(conn, status, "Request body exceeds the size limit"); + if (status == MHD_HTTP_BAD_REQUEST) + return send_request_error(conn, status, "Invalid or empty request"); + return send_request_error(conn, status, "Failed to receive or save request"); +} + +/* Build complete CSV entries within the output limit, including lines that + * span read chunks. Excess input is reported rather than silently truncated. */ +static int read_autoload_list(char *out, size_t size, int include_delays) { + if (!size) + return -1; + out[0] = '\0'; + FILE *file = fopen(AUTOLOAD_CONFIG_PATH, "r"); + if (!file) + return errno == ENOENT ? 0 : -1; + size_t pos = 0; + int line_start = 1, has_data = 0, skip = 0, result = 0; + int c; + while ((c = fgetc(file)) != EOF) { + if (c == '\0') { + result = -1; + break; + } + if (c == '\n') { + line_start = 1; has_data = 0; skip = 0; + continue; + } + if (line_start) { + skip = !include_delays && c == '!'; + line_start = 0; + } + if (c == '\r') + skip = 1; + if (skip) + continue; + size_t needed = (!has_data && pos ? 1 : 0) + 1; + if (needed >= size - pos) { + result = -1; + break; + } + if (!has_data && pos) + out[pos++] = ','; + out[pos++] = (char)c; + has_data = 1; + } + out[pos] = '\0'; + if (ferror(file)) + result = -1; + fclose(file); + return result; +} + /* ── Helper: Noisy routes ──────────────────────────────────── */ /* Filter out endpoints that the frontend polls automatically to reduce log spam */ @@ -212,188 +408,112 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn, /* Set flag that we received a request (meaning browser is open) */ log_server_set_active(); - /* ── Initial call for a new request ────────────────────── */ + /* The completion callback owns request state on success, error, disconnect, + * and daemon stop. Keep it alive until MHD finishes the request. */ if (*con_cls == NULL) { - if (strncmp(url, ROUTE_UPLOAD, strlen(ROUTE_UPLOAD)) == 0) { - struct UploadStatus *status = malloc(sizeof(struct UploadStatus)); - status->fp = NULL; - status->total_size = 0; - status->error = 0; - status->mode[0] = '\0'; - - const char *mode = - MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "mode"); - strncpy(status->mode, mode ? mode : "new", sizeof(status->mode) - 1); - status->mode[sizeof(status->mode) - 1] = '\0'; - - const char *filename = - MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename"); - if (filename && !strstr(filename, "/") && !strstr(filename, "..")) { - char path[512]; - mkdir(BASE_DATA_DIR, 0777); - mkdir(PAYLOADS_STORAGE_DIR, 0777); - snprintf(path, sizeof(path), "%s/%s.tmp", PAYLOADS_STORAGE_DIR, filename); - - strncpy(status->filename, filename, sizeof(status->filename) - 1); - status->filename[sizeof(status->filename) - 1] = '\0'; - strncpy(status->temp_path, path, sizeof(status->temp_path) - 1); - status->temp_path[sizeof(status->temp_path) - 1] = '\0'; - - status->fp = fopen(path, "wb"); - if (!status->fp) { - pldmgr_log("[PLDMGR] !!! FAILED to open temp file: %s\n", path); - status->error = 1; - } else { - pldmgr_log("[PLDMGR] Starting upload to temp: %s (mode: %s)\n", path, status->mode); - } - } else { - pldmgr_log("[PLDMGR] !!! Upload failed: Missing filename parameter\n"); - status->error = 1; + int upload = strncmp(url, ROUTE_UPLOAD, strlen(ROUTE_UPLOAD)) == 0; + int config = strcmp(url, ROUTE_SET_CONFIG) == 0 && strcmp(method, "POST") == 0; + int sources = strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0; + if (upload || config || sources) { + struct RequestContext *request = malloc(sizeof(*request)); + if (!request) { + *con_cls = REQUEST_MARKER; + return send_out_of_memory(conn); } - *con_cls = status; - return MHD_YES; + memset(request, 0, sizeof(*request)); + request->kind = upload ? REQUEST_UPLOAD : (config ? REQUEST_CONFIG : REQUEST_SOURCES); + *con_cls = request; + unsigned int error; + if (upload) { + prepare_upload(conn, &request->state.upload); + error = request->state.upload.error; + } else { + error = check_body_length(conn, HTTP_POST_BODY_LIMIT); + request->state.post.error = error; + } + if (error) + return finish_request_error(conn, request, error); + } else { + *con_cls = REQUEST_MARKER; } - - if (strcmp(url, ROUTE_SET_CONFIG) == 0 && strcmp(method, "POST") == 0) { - struct PostStatus *status = malloc(sizeof(struct PostStatus)); - status->data = NULL; - status->size = 0; - status->error = 0; - *con_cls = status; - return MHD_YES; - } - - if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) { - struct PostStatus *status = malloc(sizeof(struct PostStatus)); - status->data = NULL; - status->size = 0; - status->error = 0; - *con_cls = status; - return MHD_YES; - } - - *con_cls = (void *)1; return MHD_YES; } - /* ── Handle POST data for /set_config ──────────────────── */ - if (strcmp(url, ROUTE_SET_CONFIG) == 0 && strcmp(method, "POST") == 0) { - struct PostStatus *status = (struct PostStatus *)*con_cls; - if (*upload_data_size != 0) { - char *new_data = realloc(status->data, status->size + *upload_data_size + 1); - if (!new_data) { - status->error = 1; - } else { - status->data = new_data; - memcpy(status->data + status->size, upload_data, *upload_data_size); - status->size += *upload_data_size; - status->data[status->size] = '\0'; - } + if (*con_cls != REQUEST_MARKER) { + struct RequestContext *request = *con_cls; + if (request->finished) { *upload_data_size = 0; return MHD_YES; - } else { - /* Finished receiving JSON */ - pldmgr_log("[PLDMGR] Received config update: %s\n", - status->data ? status->data : "(null)"); - - if (status->data && !status->error) { - config_handle_set_json(status->data); + } + if (request->kind != REQUEST_UPLOAD) { + struct PostStatus *post = &request->state.post; + if (*upload_data_size) { + append_post_data(post, upload_data, *upload_data_size); + *upload_data_size = 0; + if (post->error) + return finish_request_error(conn, request, post->error); + return MHD_YES; } - - if (status->data) - free(status->data); - free(status); - *con_cls = NULL; - + if (!post->size) + return finish_request_error(conn, request, MHD_HTTP_BAD_REQUEST); + int rc = request->kind == REQUEST_CONFIG + ? config_handle_set_json(post->data) : sources_save(post->data, post->size); + if (rc != 0) + return finish_request_error(conn, request, MHD_HTTP_INTERNAL_SERVER_ERROR); + request->finished = 1; struct MHD_Response *resp = create_buffer_response( - strlen(MSG_OK), (void *)MSG_OK, MHD_RESPMEM_MUST_COPY); - + strlen(MSG_OK), (void *)MSG_OK, MHD_RESPMEM_PERSISTENT); + add_response_header(resp, "Content-Type", "text/plain"); return send_response(conn, MHD_HTTP_OK, resp); } - } - - /* ── Handle POST data for /sources_set ─────────────────── */ - if (strcmp(url, ROUTE_SOURCES_SET) == 0 && strcmp(method, "POST") == 0) { - struct PostStatus *status = (struct PostStatus *)*con_cls; - if (*upload_data_size != 0) { - char *nd = realloc(status->data, status->size + *upload_data_size + 1); - if (!nd) { - status->error = 1; + struct UploadStatus *status = &request->state.upload; + if (*upload_data_size) { + if (*upload_data_size > HTTP_UPLOAD_BODY_LIMIT - status->total_size) { + status->error = MHD_HTTP_CONTENT_TOO_LARGE; } else { - status->data = nd; - memcpy(status->data + status->size, upload_data, *upload_data_size); - status->size += *upload_data_size; - status->data[status->size] = '\0'; - } - *upload_data_size = 0; - return MHD_YES; - } else { - int ok = -1; - if (status->data && !status->error) - ok = sources_save(status->data, status->size); - if (status->data) - free(status->data); - free(status); - *con_cls = NULL; - - const char *msg = ok == 0 ? MSG_OK : "Failed to save sources"; - struct MHD_Response *resp_ss = create_buffer_response( - strlen(msg), (void *)msg, MHD_RESPMEM_MUST_COPY); - - return send_response( - conn, ok == 0 ? MHD_HTTP_OK : MHD_HTTP_INTERNAL_SERVER_ERROR, resp_ss); - } - } - - /* ── Chunked upload for /manage:upload ─────────────────── */ - if (strncmp(url, ROUTE_UPLOAD, strlen(ROUTE_UPLOAD)) == 0) { - struct UploadStatus *status = (struct UploadStatus *)*con_cls; - if (*upload_data_size != 0) { - if (status->fp && !status->error) { size_t written = fwrite(upload_data, 1, *upload_data_size, status->fp); - if (written != *upload_data_size) { - pldmgr_log("[PLDMGR] !!! Write error: expected %zu, got %zu\n", - *upload_data_size, written); - status->error = 1; - } status->total_size += written; + if (written != *upload_data_size) + status->error = MHD_HTTP_INTERNAL_SERVER_ERROR; } *upload_data_size = 0; + if (status->error) + return finish_request_error(conn, request, status->error); return MHD_YES; - } else { - /* Upload finished */ - if (status->fp) { - fflush(status->fp); - fclose(status->fp); - status->fp = NULL; - - if (!status->error) { - char msg[256]; - char out_slot[128]; - if (payload_mgr_import_to_storage_mode(status->filename, status->temp_path, - status->mode, "web_upload", "", - msg, sizeof(msg), - out_slot, sizeof(out_slot)) != 0) { - pldmgr_log("[PLDMGR] !!! Failed to commit upload: %s\n", msg); - status->error = 1; - } - } - } - pldmgr_log("[PLDMGR] Upload finished. Total bytes: %zu, Error: %d\n", - status->total_size, status->error); - - int err = status->error; - free(status); - *con_cls = NULL; - - const char *msg = err ? "Error during upload\n" : MSG_OK; - struct MHD_Response *resp = create_buffer_response( - strlen(msg), (void *)msg, MHD_RESPMEM_MUST_COPY); - - return send_response( - conn, err ? MHD_HTTP_INTERNAL_SERVER_ERROR : MHD_HTTP_OK, resp); } + + /* Both operations must run: a failed flush must not skip fclose. */ + if (fflush(status->fp) != 0) + status->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + if (fclose(status->fp) != 0) + status->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + status->fp = NULL; + if (!status->total_size && !status->error) + status->error = MHD_HTTP_BAD_REQUEST; + if (!status->error) { + char msg[256] = ""; + char out_slot[128]; + if (payload_mgr_import_to_storage_mode(status->filename, status->temp_path, + status->mode, "web_upload", "", + msg, sizeof(msg), out_slot, sizeof(out_slot)) != 0) { + pldmgr_log("[PLDMGR] !!! Failed to commit upload: %s\n", msg); + status->error = MHD_HTTP_INTERNAL_SERVER_ERROR; + } else { + /* Import consumed the temp file. Relinquish its pathname so + * completion cannot unlink a later owner of the same name. */ + status->temp_path[0] = '\0'; + } + } + pldmgr_log("[PLDMGR] Upload finished. Total bytes: %zu, Error: %u\n", + status->total_size, status->error); + if (status->error) + return finish_request_error(conn, request, status->error); + request->finished = 1; + struct MHD_Response *resp = create_buffer_response( + strlen(MSG_OK), (void *)MSG_OK, MHD_RESPMEM_PERSISTENT); + add_response_header(resp, "Content-Type", "text/plain"); + return send_response(conn, MHD_HTTP_OK, resp); } /* ── Log significant requests ──────────────────────────── */ @@ -773,21 +893,10 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn, int remaining = pldmgr_autoload_get_remaining_seconds(); long long remaining_ms = pldmgr_autoload_get_remaining_ms(); - char list_buf[4096] = ""; - FILE *f = fopen(AUTOLOAD_CONFIG_PATH, "r"); - if (f) { - char line[256]; - int first = 1; - while (fgets(line, sizeof(line), f)) { - line[strcspn(line, "\r\n")] = 0; - if (strlen(line) == 0 || line[0] == '!') - continue; - if (!first) strcat(list_buf, ","); - strcat(list_buf, line); - first = 0; - } - fclose(f); - } + char list_buf[4096]; + if (read_autoload_list(list_buf, sizeof(list_buf), 0) != 0) + return send_request_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, + "Autoload list is unreadable or exceeds the size limit"); PldmgrConfig cfg; config_read(&cfg); @@ -821,35 +930,14 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn, resp = create_buffer_response(strlen(msg), (void *)msg, MHD_RESPMEM_PERSISTENT); add_response_header(resp, "Content-Type", "text/plain"); - } else if (strcmp(url, "/autoload_status") == 0) { - char resp_buf[64]; - snprintf(resp_buf, sizeof(resp_buf), "{\"remaining\":%d}", - pldmgr_autoload_get_remaining_seconds()); - resp = create_buffer_response(strlen(resp_buf), - (void *)resp_buf, - MHD_RESPMEM_MUST_COPY); - add_response_header(resp, "Content-Type", "application/json"); } else if (strcmp(url, ROUTE_GET_CONFIG) == 0) { PldmgrConfig cfg; config_read(&cfg); - /* Get list */ - char list_buf[4096] = {0}; - FILE *f = fopen(AUTOLOAD_CONFIG_PATH, "r"); - if (f) { - char line[256]; - int first = 1; - while (fgets(line, sizeof(line), f)) { - line[strcspn(line, "\r\n")] = 0; - if (strlen(line) == 0) - continue; - if (!first) - strncat(list_buf, ",", sizeof(list_buf) - strlen(list_buf) - 1); - strncat(list_buf, line, sizeof(list_buf) - strlen(list_buf) - 1); - first = 0; - } - fclose(f); - } + char list_buf[4096]; + if (read_autoload_list(list_buf, sizeof(list_buf), 1) != 0) + return send_request_error(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, + "Autoload list is unreadable or exceeds the size limit"); char list_escaped[8192]; pldmgr_json_escape(list_buf, list_escaped, sizeof(list_escaped)); diff --git a/src/main.c b/src/main.c index e9e1380..81ac65f 100644 --- a/src/main.c +++ b/src/main.c @@ -83,6 +83,7 @@ static struct MHD_Daemon *start_http_daemon(unsigned int port) { return MHD_start_daemon( MHD_USE_THREAD_PER_CONNECTION | MHD_USE_DEBUG, port, NULL, NULL, &http_on_request, NULL, + MHD_OPTION_NOTIFY_COMPLETED, &http_request_completed, NULL, MHD_OPTION_THREAD_STACK_SIZE, (size_t)(1024 * 1024), MHD_OPTION_END); } diff --git a/tests/http_backend_stubs.c b/tests/http_backend_stubs.c index 7a29490..ee6d069 100644 --- a/tests/http_backend_stubs.c +++ b/tests/http_backend_stubs.c @@ -18,11 +18,18 @@ static size_t json(char *out, size_t size, const char *value) { return n < 0 ? 0 : (size_t)n; } +unsigned int test_config_saves, test_source_saves, test_imports; +size_t test_post_size; + void pldmgr_log(const char *fmt, ...) { (void)fmt; } void log_server_set_active(void) {} void config_read(PldmgrConfig *cfg) { memset(cfg, 0, sizeof(*cfg)); cfg->autoload_delay = 5; } int config_read_bool(const char *key, int fallback) { (void)key; return fallback; } -int config_handle_set_json(const char *data) { (void)data; return 0; } +int config_handle_set_json(const char *data) { + test_config_saves++; + test_post_size = strlen(data); + return strcmp(data, "fail_test") == 0 ? -1 : 0; +} int config_get_favorites_csv(char *out, size_t size, int *exists) { if (size) out[0] = '\0'; *exists = 0; @@ -47,7 +54,12 @@ int payload_mgr_import_to_storage_mode(const char *filename, const char *path, const char *mode, const char *origin, const char *detail, char *msg, size_t msg_size, char *slot, size_t slot_size) { (void)filename; (void)path; (void)mode; (void)origin; (void)detail; - snprintf(msg, msg_size, "Test import"); snprintf(slot, slot_size, "local/test"); return 0; + snprintf(msg, msg_size, "Test import"); snprintf(slot, slot_size, "local/test"); + test_imports++; + if (mode && strcmp(mode, "fail_test") == 0) return -1; + char destination[1024]; + snprintf(destination, sizeof(destination), "%s/%s", PAYLOADS_STORAGE_DIR, filename); + return rename(path, destination); } int payload_mgr_check_existing(const char *filename, char *out, size_t size) { (void)filename; json(out, size, "{\"status\":\"ready\"}"); return 0; @@ -73,7 +85,11 @@ int sources_repo_install(const char *url, const char *id, const char *mode, (void)url; (void)id; (void)mode; (void)replace; json(out, size, "{\"ok\":true}"); return 0; } int sources_list_json(char *out, size_t size) { json(out, size, "{\"sources\":[]}"); return 0; } -int sources_save(const char *data, size_t size) { (void)data; (void)size; return 0; } +int sources_save(const char *data, size_t size) { + test_source_saves++; + test_post_size = size; + return strcmp(data, "fail_test") == 0 ? -1 : 0; +} int sources_add(const char *url, char *msg, size_t size) { (void)url; snprintf(msg, size, "Test source"); return 0; } diff --git a/tests/http_responses_test.c b/tests/http_responses_test.c index 8beebf8..18d0eb0 100644 --- a/tests/http_responses_test.c +++ b/tests/http_responses_test.c @@ -4,12 +4,30 @@ #include #include #include +#include +#include +#include #include #include #include "http_server.h" +#include "pldmgr.h" + +/* Keep regression comparisons against pre-cleanup source snapshots runnable. */ +void http_request_completed(void *cls, struct MHD_Connection *connection, + void **context, enum MHD_RequestTerminationCode reason); +#pragma weak http_request_completed + +static void complete_request(void *cls, struct MHD_Connection *connection, + void **context, enum MHD_RequestTerminationCode reason) { + if (http_request_completed) + http_request_completed(cls, connection, context, reason); +} static int serving; static int fail_malloc, fail_create, fail_queue, fail_cors; +static int fail_realloc, fail_mkstemp, fail_fdopen, fail_write, fail_flush, fail_close; +static char last_temp[1024]; +static int last_fd = -1; static unsigned int queued_status, cors_count; static char body[256]; static _Atomic unsigned int created, destroyed; @@ -28,6 +46,38 @@ void *http_test_malloc(size_t size) { return malloc(size); } +void *http_test_realloc(void *pointer, size_t size) { + return fail_realloc ? NULL : realloc(pointer, size); +} + +int http_test_mkstemp(char *path) { + if (fail_mkstemp) { errno = ENOSPC; return -1; } + int fd = mkstemp(path); + if (!serving) { + snprintf(last_temp, sizeof(last_temp), "%s", path); + last_fd = fd; + } + return fd; +} + +FILE *http_test_fdopen(int fd, const char *mode) { + return fail_fdopen ? NULL : fdopen(fd, mode); +} + +size_t http_test_fwrite(const void *data, size_t size, size_t count, FILE *file) { + return fail_write ? 0 : fwrite(data, size, count, file); +} + +int http_test_fflush(FILE *file) { + int result = fflush(file); + return fail_flush ? EOF : result; +} + +int http_test_fclose(FILE *file) { + int result = fclose(file); + return fail_close ? EOF : result; +} + static void remember(struct MHD_Response *response) { if (!response) return; created++; @@ -125,10 +175,12 @@ static void run_request(struct Request request, int oom, int creation_failure, i size_t upload_size = 0; enum MHD_Result result = http_on_request(NULL, (struct MHD_Connection *)&request, request.url, request.method, "HTTP/1.1", "", &upload_size, &context); - if (strcmp(request.method, "OPTIONS") != 0 && result == MHD_YES) { + if (strcmp(request.method, "OPTIONS") != 0 && result == MHD_YES && !queued_status) { result = http_on_request(NULL, (struct MHD_Connection *)&request, request.url, request.method, "HTTP/1.1", "", &upload_size, &context); } + complete_request(NULL, (struct MHD_Connection *)&request, &context, + MHD_REQUEST_TERMINATED_COMPLETED_OK); unsigned int expected_status = oom ? MHD_HTTP_INTERNAL_SERVER_ERROR : request.status; int failed = (created != destroyed); if (creation_failure || fail_cors || (oom && strcmp(request.url, "/events") == 0)) { @@ -195,9 +247,9 @@ static int unit_tests(int ownership_only, int oom_only) { {"/abort", "GET", 200, {{0}}}, {"/get_config", "GET", 200, {{0}}}, {"/events", "GET", 200, {{0}}}, - {"/set_config", "POST", 200, {{0}}}, - {"/sources_set", "POST", 500, {{0}}}, - {"/manage:upload", "POST", 500, {{0}}}, + {"/set_config", "POST", 400, {{0}}}, + {"/sources_set", "POST", 400, {{0}}}, + {"/manage:upload", "POST", 400, {{0}}}, {"/shutdown", "GET", 200, {{0}}}, }; for (size_t i = 0; i < sizeof(requests) / sizeof(requests[0]); i++) { @@ -227,6 +279,9 @@ static int unit_tests(int ownership_only, int oom_only) { {"/autoload_status", "GET", 200, {{0}}}, {"/get_config", "GET", 200, {{0}}}, {"/events", "GET", 200, {{0}}}, + {"/set_config", "POST", 400, {{0}}}, + {"/sources_set", "POST", 400, {{0}}}, + {"/manage:upload", "POST", 400, {{0}}}, }; for (size_t i = 0; i < sizeof(allocated) / sizeof(allocated[0]); i++) { if (oom_only && strcmp(allocated[i].url, "/events") == 0) continue; @@ -239,13 +294,246 @@ static int unit_tests(int ownership_only, int oom_only) { return failures ? 1 : 0; } +extern unsigned int test_config_saves, test_source_saves, test_imports; +extern size_t test_post_size; + +static void expect(int condition, const char *description) { + checks++; + if (!condition) { + fprintf(stderr, "FAIL request lifecycle: %s\n", description); + failures++; + } +} + +struct Transaction { struct Request request; void *context; }; + +static enum MHD_Result invoke(struct Transaction *transaction, const char *data, size_t size) { + size_t remaining = size; + enum MHD_Result result = http_on_request(NULL, (struct MHD_Connection *)&transaction->request, + transaction->request.url, "POST", "HTTP/1.1", data, &remaining, &transaction->context); + expect(!size || !remaining, "body chunk consumed"); + return result; +} + +static struct Transaction begin(struct Request request) { + fail_malloc = fail_create = fail_queue = fail_cors = 0; + fail_realloc = fail_mkstemp = fail_fdopen = fail_write = fail_flush = fail_close = 0; + queued_status = cors_count = 0; + test_config_saves = test_source_saves = test_imports = 0; + test_post_size = 0; + last_temp[0] = '\0'; last_fd = -1; + struct Transaction transaction = {request, NULL}; + invoke(&transaction, "", 0); + return transaction; +} + +static void finish(struct Transaction *transaction, enum MHD_RequestTerminationCode reason) { + complete_request(NULL, (struct MHD_Connection *)&transaction->request, + &transaction->context, reason); + expect(transaction->context == NULL, "completion clears the context"); + complete_request(NULL, (struct MHD_Connection *)&transaction->request, + &transaction->context, reason); + expect(created == destroyed, "completion leaves no response references"); + if (last_temp[0]) expect(access(last_temp, F_OK) != 0, "owned temporary file removed"); + if (last_fd >= 0) expect(fcntl(last_fd, F_GETFD) == -1 && errno == EBADF, "upload descriptor closed"); +} + +static void request_tests(void) { + int initial_checks = checks; + struct Request upload = {"/manage:upload", "POST", 200, {{"filename", "test.elf"}}}; + for (int cancelled = 0; cancelled < 2; cancelled++) { + struct Transaction transaction = begin(upload); + expect(last_temp[0] && access(last_temp, F_OK) == 0, "upload owns a file"); + invoke(&transaction, "payload", 7); + if (!cancelled) { + invoke(&transaction, "", 0); + expect(queued_status == 200 && test_imports == 1, "successful upload imports once"); + invoke(&transaction, "", 0); + expect(test_imports == 1, "repeat callback does not import twice"); + } + finish(&transaction, cancelled ? MHD_REQUEST_TERMINATED_CLIENT_ABORT : MHD_REQUEST_TERMINATED_COMPLETED_OK); + if (cancelled) expect(test_imports == 0, "aborted upload never imports"); + } + + for (int fault = 0; fault < 6; fault++) { + struct Transaction transaction = begin(upload); + if (fault == 0) fail_write = 1; + if (fault == 1) fail_flush = 1; + if (fault == 2) fail_close = 1; + if (fault == 3) fail_create = 1; + if (fault == 4) fail_queue = 1; + if (fault == 5) fail_cors = 1; + invoke(&transaction, "payload", 7); + if (!queued_status) invoke(&transaction, "", 0); + if (fault < 3) { + expect(queued_status == 500, "write/flush/close error is HTTP 500"); + expect(test_imports == 0, "file I/O error prevents import"); + } + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + } + + struct Transaction committed = begin(upload); + invoke(&committed, "payload", 7); + invoke(&committed, "", 0); + char reused_path[1024]; + snprintf(reused_path, sizeof(reused_path), "%s", last_temp); + FILE *new_owner = fopen(reused_path, "w"); + assert(new_owner); + fputs("new owner", new_owner); + fclose(new_owner); + last_temp[0] = '\0'; + finish(&committed, MHD_REQUEST_TERMINATED_COMPLETED_OK); + expect(access(reused_path, F_OK) == 0, "successful import relinquishes the consumed temp pathname"); + remove(reused_path); + + for (int fault = 0; fault < 2; fault++) { + struct Transaction transaction = begin((struct Request){"/version", "GET", 200, {{0}}}); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + if (fault == 0) fail_mkstemp = 1; else fail_fdopen = 1; + transaction = (struct Transaction){upload, NULL}; + queued_status = 0; + invoke(&transaction, "", 0); + expect(queued_status == 500, "temporary-file creation/open failure is HTTP 500"); + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + } + + char length[32]; + snprintf(length, sizeof(length), "%zu", HTTP_UPLOAD_BODY_LIMIT + 1); + struct Request too_large = upload; + too_large.args[1] = (struct Argument){"Content-Length", length}; + struct Transaction transaction = begin(too_large); + expect(queued_status == 413 && !last_temp[0], "oversized declared upload opens no file"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + transaction = begin(upload); + invoke(&transaction, "x", HTTP_UPLOAD_BODY_LIMIT + 1); + expect(queued_status == 413 && !test_imports, "oversized chunk rejected before reading input"); + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + transaction = begin(upload); + invoke(&transaction, "", 0); + expect(queued_status == 400 && !test_imports, "empty upload cannot overwrite a payload"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + + const char *invalid_lengths[] = {"", "-1", "1x", "18446744073709551616"}; + for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); i++) { + struct Request invalid = upload; + invalid.args[1] = (struct Argument){"Content-Length", invalid_lengths[i]}; + transaction = begin(invalid); + expect(queued_status == 400 && !last_temp[0], "invalid declared length opens no file"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + } + char long_filename[257]; + memset(long_filename, 'a', sizeof(long_filename) - 1); + long_filename[sizeof(long_filename) - 1] = '\0'; + struct Request invalid_name = upload; + invalid_name.args[0].value = long_filename; + transaction = begin(invalid_name); + expect(queued_status == 400 && !last_temp[0], "overlong filename is rejected rather than truncated"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + + for (int sources = 0; sources < 2; sources++) { + struct Request post = {sources ? "/sources_set" : "/set_config", "POST", 200, {{0}}}; + transaction = begin(post); + invoke(&transaction, "{\"value\":", 9); + invoke(&transaction, "true}", 5); + invoke(&transaction, "", 0); + expect(queued_status == 200 && test_post_size == 14, "POST chunks accumulated and saved"); + invoke(&transaction, "", 0); + expect(test_config_saves + test_source_saves == 1, "POST saves only once"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + + transaction = begin(post); + invoke(&transaction, "partial", 7); + finish(&transaction, MHD_REQUEST_TERMINATED_CLIENT_ABORT); + expect(test_config_saves + test_source_saves == 0, "aborted POST is not saved"); + + transaction = begin(post); + invoke(&transaction, "fail_test", 9); + invoke(&transaction, "", 0); + expect(queued_status == 500, "backend save failure is propagated"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + + transaction = begin(post); + invoke(&transaction, "partial", 7); + fail_realloc = 1; + char data[5000] = {0}; + invoke(&transaction, data, sizeof(data)); + expect(queued_status == 500 && test_config_saves + test_source_saves == 0, + "reallocation failure preserves context without saving partial data"); + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + + transaction = begin(post); + invoke(&transaction, "x", HTTP_POST_BODY_LIMIT + 1); + expect(queued_status == 413 && test_config_saves + test_source_saves == 0, + "oversized unknown-length POST rejected before copying input"); + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + snprintf(length, sizeof(length), "%zu", HTTP_POST_BODY_LIMIT + 1); + post.args[0] = (struct Argument){"Content-Length", length}; + transaction = begin(post); + expect(queued_status == 413, "oversized declared POST rejected initially"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + + post.args[0] = (struct Argument){NULL, NULL}; + char *boundary = malloc(HTTP_POST_BODY_LIMIT); + assert(boundary); + memset(boundary, ' ', HTTP_POST_BODY_LIMIT); + boundary[HTTP_POST_BODY_LIMIT - 2] = '{'; + boundary[HTTP_POST_BODY_LIMIT - 1] = '}'; + transaction = begin(post); + invoke(&transaction, boundary, HTTP_POST_BODY_LIMIT / 2); + invoke(&transaction, boundary + HTTP_POST_BODY_LIMIT / 2, HTTP_POST_BODY_LIMIT / 2); + invoke(&transaction, "", 0); + expect(queued_status == 200 && test_post_size == HTTP_POST_BODY_LIMIT, + "POST at the exact body limit is complete and terminated"); + finish(&transaction, MHD_REQUEST_TERMINATED_COMPLETED_OK); + transaction = begin(post); + invoke(&transaction, boundary, HTTP_POST_BODY_LIMIT); + invoke(&transaction, "x", 1); + expect(queued_status == 413 && test_config_saves + test_source_saves == 0, + "cumulative POST limit prevents a partial save"); + finish(&transaction, MHD_REQUEST_TERMINATED_WITH_ERROR); + free(boundary); + } + + fail_malloc = fail_create = fail_queue = fail_cors = 0; + fail_realloc = fail_mkstemp = fail_fdopen = fail_write = fail_flush = fail_close = 0; + FILE *autoload = fopen(AUTOLOAD_CONFIG_PATH, "w"); + assert(autoload); + fputs("\r\nlocal/one\r\n!500\nlocal/two\n", autoload); + fclose(autoload); + run_request((struct Request){"/autoload_status", "GET", 200, {{0}}}, 0, 0, 0); + expect(strstr(body, "local/one,local/two") != NULL, "status excludes delays and CRLF blank lines"); + run_request((struct Request){"/get_config", "GET", 200, {{0}}}, 0, 0, 0); + expect(strstr(body, "local/one,!500,local/two") != NULL, "config retains delay entries"); + for (int size = 4095; size <= 4096; size++) { + autoload = fopen(AUTOLOAD_CONFIG_PATH, "w"); + assert(autoload); + for (int i = 0; i < size; i++) fputc('a', autoload); + fclose(autoload); + for (int config = 0; config < 2; config++) { + run_request((struct Request){config ? "/get_config" : "/autoload_status", "GET", + size == 4095 ? 200 : 500, {{0}}}, 0, 0, 0); + } + } + autoload = fopen(AUTOLOAD_CONFIG_PATH, "w"); + assert(autoload); + fputs("local/one", autoload); + fputc('\0', autoload); + fputs("local/two", autoload); + fclose(autoload); + run_request((struct Request){"/autoload_status", "GET", 500, {{0}}}, 0, 0, 0); + remove(AUTOLOAD_CONFIG_PATH); + printf("HTTP request lifecycle: %d additional checks, %d total failures\n", checks - initial_checks, failures); +} + static int serve(void) { serving = 1; struct sockaddr_in address = {0}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - struct MHD_Daemon *daemon = MHD_start_daemon(MHD_USE_INTERNAL_POLLING_THREAD, + struct MHD_Daemon *daemon = MHD_start_daemon( + MHD_USE_THREAD_PER_CONNECTION | MHD_USE_INTERNAL_POLLING_THREAD, 0, NULL, NULL, http_on_request, NULL, + MHD_OPTION_NOTIFY_COMPLETED, complete_request, NULL, MHD_OPTION_SOCK_ADDR, &address, MHD_OPTION_END); if (!daemon) return 1; const union MHD_DaemonInfo *info = MHD_get_daemon_info(daemon, MHD_DAEMON_INFO_BIND_PORT); @@ -259,6 +547,9 @@ static int serve(void) { int main(int argc, char **argv) { if (argc > 1 && strcmp(argv[1], "--serve") == 0) return serve(); - return unit_tests(argc > 1 && strcmp(argv[1], "--ownership-only") == 0, - argc > 1 && strcmp(argv[1], "--oom-only") == 0); + int selected = argc > 1; + int result = unit_tests(selected && strcmp(argv[1], "--ownership-only") == 0, + selected && strcmp(argv[1], "--oom-only") == 0); + if (!selected) request_tests(); + return result || failures ? 1 : 0; } diff --git a/tests/http_test_hooks.h b/tests/http_test_hooks.h index aeefc1d..286abc8 100644 --- a/tests/http_test_hooks.h +++ b/tests/http_test_hooks.h @@ -2,11 +2,18 @@ #define HTTP_TEST_HOOKS_H #include +#include #include /* Inject faults only into the production HTTP translation unit. The test * implementations use the real libmicrohttpd response objects/destructors. */ void *http_test_malloc(size_t size); +void *http_test_realloc(void *pointer, size_t size); +int http_test_mkstemp(char *path); +FILE *http_test_fdopen(int fd, const char *mode); +size_t http_test_fwrite(const void *data, size_t size, size_t count, FILE *file); +int http_test_fflush(FILE *file); +int http_test_fclose(FILE *file); struct MHD_Response *http_test_create_response(size_t size, void *buffer, enum MHD_ResponseMemoryMode mode); struct MHD_Response *http_test_create_callback(uint64_t size, size_t block_size, @@ -20,6 +27,12 @@ const char *http_test_lookup(struct MHD_Connection *connection, enum MHD_ValueKind kind, const char *key); #define malloc http_test_malloc +#define realloc http_test_realloc +#define mkstemp http_test_mkstemp +#define fdopen http_test_fdopen +#define fwrite http_test_fwrite +#define fflush http_test_fflush +#define fclose http_test_fclose #define MHD_create_response_from_buffer http_test_create_response #define MHD_create_response_from_callback http_test_create_callback #define MHD_queue_response http_test_queue diff --git a/tests/run_http_tests.py b/tests/run_http_tests.py index b868a2a..98e3ecb 100644 --- a/tests/run_http_tests.py +++ b/tests/run_http_tests.py @@ -10,8 +10,10 @@ import json import os from pathlib import Path import shlex +import socket import subprocess import tempfile +import time ROOT = Path(__file__).resolve().parents[1] @@ -20,14 +22,147 @@ def run(args): subprocess.run(args, cwd=ROOT, check=True) -def network_tests(binary): +def eventually(predicate, message, timeout=5): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.02) + raise AssertionError(message) + + +def lifecycle_tests(port, storage, server): + payloads = storage / "payloads" + + def temporary_uploads(): + return list(payloads.glob(".upload-*")) + list(payloads.glob("*.tmp")) + + def partial_upload(filename="cancelled.elf", size=100000): + sock = socket.create_connection(("127.0.0.1", port), timeout=10) + sock.sendall(f"POST /manage:upload?filename={filename} HTTP/1.1\r\n" + f"Host: localhost\r\nContent-Length: {size}\r\n\r\n".encode() + b"first chunk") + return sock + + # Abort after the daemon has opened its file, not before accepting the socket. + for _ in range(8): + sock = partial_upload() + try: + eventually(lambda: bool(temporary_uploads()), "Upload never opened a temporary file") + finally: + sock.close() + eventually(lambda: not temporary_uploads(), "Disconnected upload left a temporary file") + assert not (payloads / "cancelled.elf").exists() + + # Incomplete config/source bodies must be released without being saved. + fd_directory = Path(f"/proc/{server.pid}/fd") + baseline_fds = len(list(fd_directory.iterdir())) if fd_directory.exists() else None + for route in ["/set_config", "/sources_set"] * 8: + with socket.create_connection(("127.0.0.1", port), timeout=10) as sock: + sock.sendall(f"POST {route} HTTP/1.1\r\nHost: localhost\r\n" + "Content-Length: 100000\r\n\r\n".encode() + b'{"incomplete":') + if baseline_fds is not None: + eventually(lambda: len(list(fd_directory.iterdir())) <= baseline_fds, + "Aborted requests leaked file descriptors") + + # Two in-flight requests for the same destination must have separate files. + first = partial_upload("same.elf") + second = None + try: + eventually(lambda: len(temporary_uploads()) == 1, "First upload did not open") + second = partial_upload("same.elf") + eventually(lambda: len(temporary_uploads()) == 2, "Same-name uploads shared a temporary file") + first.close() + eventually(lambda: len(temporary_uploads()) == 1, "Cancelling one upload affected the other") + finally: + first.close() + if second: + second.close() + eventually(lambda: not temporary_uploads(), "Concurrent upload cleanup failed") + + # A normal import consumes the file; a failed import must still remove it. + for filename, mode, status in [("success.elf", "new", 200), ("failed.elf", "fail_test", 500)]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + connection.request("POST", f"/manage:upload?filename={filename}&mode={mode}", b"test payload") + response = connection.getresponse() + data = response.read() + assert response.status == status, (response.status, data) + finally: + connection.close() + eventually(lambda: not temporary_uploads(), "Completed upload left its temporary file") + assert (payloads / "success.elf").read_bytes() == b"test payload" + assert not (payloads / "failed.elf").exists() + + # Real chunked bodies exercise the unknown-length path, not just mock callbacks. + for route in ["/set_config", "/sources_set", "/manage:upload?filename=chunked.elf"]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + connection.request("POST", route, iter([b"{", b'"value":', b"true}"]), encode_chunked=True) + response = connection.getresponse() + assert response.status == 200, (response.status, response.read()) + response.read() + finally: + connection.close() + eventually(lambda: not temporary_uploads(), "Chunked upload left a temporary file") + assert (payloads / "chunked.elf").read_bytes() == b'{"value":true}' + + for route, limit in [("/set_config", 1024 * 1024), ("/sources_set", 1024 * 1024), + ("/manage:upload?filename=oversized.elf", 512 * 1024 * 1024)]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + connection.putrequest("POST", route) + connection.putheader("Content-Length", str(limit + 1)) + connection.endheaders() + response = connection.getresponse() + data = response.read() + assert response.status == 413, (response.status, data) + json.loads(data) + finally: + connection.close() + assert not (payloads / "oversized.elf").exists() + eventually(lambda: not temporary_uploads(), "Rejected oversized upload created a file") + + # Check large-list error responses using the actual HTTP transport and JSON parser. + autoload = storage / "autoload.txt" + try: + for size in [4095, 4096, 20000]: + autoload.write_text("a" * size) + for route in ["/get_config", "/autoload_status"]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + connection.request("GET", route) + response = connection.getresponse() + data = json.loads(response.read()) + assert response.status == (200 if size == 4095 else 500), (response.status, data) + if response.status == 200: + assert data["AUTOLOAD_LIST" if route == "/get_config" else "list"] == "a" * size + else: + assert "error" in data + finally: + connection.close() + finally: + autoload.unlink(missing_ok=True) + + # Daemon stop must clean requests whose clients remain connected. + pending = [partial_upload("shutdown.elf")] + eventually(lambda: bool(temporary_uploads()), "Shutdown test upload did not open") + for route in ["/set_config", "/sources_set"]: + sock = socket.create_connection(("127.0.0.1", port), timeout=10) + sock.sendall(f"POST {route} HTTP/1.1\r\nHost: localhost\r\n" + "Content-Length: 100000\r\n\r\n".encode() + b'{"incomplete":') + pending.append(sock) + return pending, temporary_uploads + + +def network_tests(binary, storage): server = subprocess.Popen([str(binary), "--serve"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) try: line = server.stdout.readline().strip() if not line.startswith("PORT "): raise RuntimeError(f"Test HTTP server did not start: {line}") - connection = http.client.HTTPConnection("127.0.0.1", int(line.split()[1]), timeout=10) + port = int(line.split()[1]) + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) routes = ["/manage:check?filename=test.elf", "/history_list", "/update_check", "/repository_payloads", "/sources_list", "/get_config", "/autoload_status"] try: @@ -42,12 +177,19 @@ def network_tests(binary): json.loads(data) finally: connection.close() + pending, temporary_uploads = lifecycle_tests(port, storage, server) server.stdin.write("\n") server.stdin.flush() - output = server.communicate(timeout=15)[0] + try: + output = server.communicate(timeout=15)[0] + finally: + for sock in pending: + sock.close() print(output.strip()) assert server.returncode == 0, "Unreleased responses or sanitizer failure during soak" + assert not temporary_uploads(), "Daemon stop left an upload temporary file" print("HTTP integration: 10,000 real loopback requests passed") + print("HTTP lifecycle: disconnects, concurrent uploads, failed commits, and daemon stop passed") finally: if server.poll() is None: server.kill() @@ -61,6 +203,7 @@ def main(): parser.add_argument("--oom-only", action="store_true", help="Reproduce OOM status/header bugs without deeper failure injection") parser.add_argument("--no-sanitize", action="store_true") + parser.add_argument("--network-only", action="store_true") parser.add_argument("--source", default="src/http_server.c", help="Alternate HTTP source snapshot for regression reproduction") args = parser.parse_args() @@ -98,9 +241,10 @@ def main(): run(common + cflags + [str(object_file), "src/utils.c", "tests/http_backend_stubs.c", "tests/http_responses_test.c", "-o", str(binary)] + flags) selected = ["--ownership-only"] if args.ownership_only else (["--oom-only"] if args.oom_only else []) - run([str(binary)] + selected) + if not args.network_only: + run([str(binary)] + selected) if not selected: - network_tests(binary) + network_tests(binary, storage) if __name__ == "__main__":