diff --git a/tests/test_web_sync_browser.py b/tests/test_web_sync_browser.py index 7df24a6..eb8f969 100644 --- a/tests/test_web_sync_browser.py +++ b/tests/test_web_sync_browser.py @@ -5,6 +5,7 @@ import json, os, signal, socket, sqlite3, subprocess, tempfile, time, unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui" GECKO = Path( os.environ.get("TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver") ) @@ -114,13 +115,13 @@ class BrowserSyncTest(unittest.TestCase): env.update( { "TRAINLOG_SYNC_MTP_ADAPTER": str( - ROOT / "build/tui/generation-mtp-adapter-double" + BUILD / "generation-mtp-adapter-double" ), "TRAINLOG_MTP_DOUBLE_ROOT": str(root / "device"), } ) server = subprocess.Popen( - [str(ROOT / "build/tui/trainlog"), "--web", "--port", str(port)], + [str(BUILD / "trainlog"), "--web", "--port", str(port)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index 95786ab..fb655f7 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -72,7 +72,11 @@ def run_adapter( diagnostic = ( result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed" ).strip() - if allow_missing_peer and "expected peer was not found" in diagnostic: + missing_peer = diagnostic in { + "double failed 6 expected Android peer not found", + "MTP adapter failed status=6 diagnostic=expected Android peer not found", + } + if allow_missing_peer and missing_peer: return False raise RuntimeError(diagnostic) return True @@ -276,4 +280,8 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + try: + raise SystemExit(main()) + except RuntimeError as error: + print(f"sync peer failed: {error}", file=sys.stderr) + raise SystemExit(1) from None diff --git a/tui/include/trainlog/web_server.h b/tui/include/trainlog/web_server.h index 9f38792..233e381 100644 --- a/tui/include/trainlog/web_server.h +++ b/tui/include/trainlog/web_server.h @@ -2,17 +2,25 @@ #define TRAINLOG_WEB_SERVER_H #include +#include #include #include #include "trainlog/database.h" +/* Serialize the exact successful sync-admission document. Both identities are + * borrowed, validated sy_ values. On failure output is empty. */ +bool trainlog_web_sync_accepted_serialize( + const char *request_id, const char *run_id, char *output, size_t capacity, size_t *output_size); + /* Blocking, single-threaded local HTTP adapter. Port zero is reserved for * controlled test harnesses and is rejected by the CLI. The database remains * borrowed and process-owned; this infrastructure route never reads it. */ -int trainlog_web_server_run(TrainlogDatabase *database, const char *database_path, - uint16_t port, - const volatile sig_atomic_t *stop_requested, - char *diagnostic, size_t diagnostic_capacity); +int trainlog_web_server_run(TrainlogDatabase *database, + const char *database_path, + uint16_t port, + const volatile sig_atomic_t *stop_requested, + char *diagnostic, + size_t diagnostic_capacity); #endif diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 35e0951..b03e835 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -32,6 +32,7 @@ #define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U #define TRAINLOG_HTTP_BACKLOG 32U #define TRAINLOG_HTTP_POLL_MAX_MS 250ULL +#define TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY 256U typedef struct TrainlogWebContext { uint16_t port; @@ -49,65 +50,137 @@ typedef struct TrainlogHttpRequestState { char body[TRAINLOG_HTTP_BODY_LIMIT + 1U]; } TrainlogHttpRequestState; -static void web_diagnostic(char *output, size_t capacity, const char *message) -{ - if (output != NULL && capacity > 0U) +static bool valid_sync_id(const char *value) { + return value != NULL && strlen(value) == 39U && strncmp(value, "sy_", 3U) == 0 && + uuid_parse(value + 3U, (unsigned char[16]){0}) == 0; +} + +bool trainlog_web_sync_accepted_serialize(const char *request_id, + const char *run_id, + char *output, + size_t capacity, + size_t *output_size) { + int written; + + if (output == NULL || capacity == 0U || output_size == NULL) { + return false; + } + output[0] = '\0'; + *output_size = 0U; + if (!valid_sync_id(request_id) || !valid_sync_id(run_id)) { + return false; + } + written = snprintf(output, + capacity, + "{\"api_version\":1,\"phase\":\"requested\",\"result\":\"running\"," + "\"progress_revision\":1,\"request_id\":\"%s\",\"run_id\":\"%s\"," + "\"status_url\":\"/api/v1/sync/status\"}\n", + request_id, + run_id); + if (written < 0 || (size_t)written >= capacity) { + output[0] = '\0'; + return false; + } + *output_size = (size_t)written; + return true; +} + +static void web_diagnostic(char *output, size_t capacity, const char *message) { + if (output != NULL && capacity > 0U) { (void)snprintf(output, capacity, "%s", message); + } } static enum MHD_Result queue_layout_json(struct MHD_Connection *connection, - unsigned int status, const char *body, uint64_t revision, - const char *csrf_token) -{ + unsigned int status, + const char *body, + uint64_t revision, + const char *csrf_token) { struct MHD_Response *response; enum MHD_Result result; char etag[32]; - response = MHD_create_response_from_buffer(strlen(body), (void *)body, - MHD_RESPMEM_MUST_COPY); - if (response == NULL) return MHD_NO; - (void)snprintf(etag, sizeof(etag), "\"%llu\"", - (unsigned long long)revision); - if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, - "application/json; charset=utf-8") != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, - "no-store") != MHD_YES || + response = MHD_create_response_from_buffer(strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY); + if (response == NULL) { + return MHD_NO; + } + (void)snprintf(etag, sizeof(etag), "\"%llu\"", (unsigned long long)revision); + if (MHD_add_response_header( + response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != + MHD_YES || MHD_add_response_header(response, - MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || - MHD_add_response_header(response, "Content-Security-Policy", - "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES || + "Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != + MHD_YES || MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag) != MHD_YES || MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES || MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) { - MHD_destroy_response(response); return MHD_NO; - } - result = MHD_queue_response(connection, status, response); - MHD_destroy_response(response); return result; -} - -static enum MHD_Result queue_json(struct MHD_Connection *connection, - unsigned int status, const char *body, const char *allow) -{ - struct MHD_Response *response; - enum MHD_Result result; - response = MHD_create_response_from_buffer(strlen(body), (void *)body, - MHD_RESPMEM_MUST_COPY); - if (response == NULL) return MHD_NO; - if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, - "application/json; charset=utf-8") != MHD_YES || - MHD_add_response_header(response, - MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, - "no-store") != MHD_YES || - MHD_add_response_header(response, "Content-Security-Policy", - "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != - MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, - "close") != MHD_YES) { MHD_destroy_response(response); return MHD_NO; } - if (allow != NULL && MHD_add_response_header(response, - MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) { + result = MHD_queue_response(connection, status, response); + MHD_destroy_response(response); + return result; +} + +static enum MHD_Result queue_json(struct MHD_Connection *connection, + unsigned int status, + const char *body, + const char *allow) { + struct MHD_Response *response; + enum MHD_Result result; + response = MHD_create_response_from_buffer(strlen(body), (void *)body, MHD_RESPMEM_MUST_COPY); + if (response == NULL) { + return MHD_NO; + } + if (MHD_add_response_header( + response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != + MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES || + MHD_add_response_header(response, + "Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != + MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) { + MHD_destroy_response(response); + return MHD_NO; + } + if (allow != NULL && + MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) { + MHD_destroy_response(response); + return MHD_NO; + } + result = MHD_queue_response(connection, status, response); + MHD_destroy_response(response); + return result; +} + +static enum MHD_Result queue_sync_json_sized(struct MHD_Connection *connection, + unsigned int status, + const char *body, + size_t body_size, + const char *csrf_token, + const char *location) { + struct MHD_Response *response = + MHD_create_response_from_buffer(body_size, (void *)body, MHD_RESPMEM_MUST_COPY); + enum MHD_Result result; + if (response == NULL) { + return MHD_NO; + } + if (MHD_add_response_header( + response, MHD_HTTP_HEADER_CONTENT_TYPE, "application/json; charset=utf-8") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, "no-store") != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != + MHD_YES || + MHD_add_response_header(response, + "Content-Security-Policy", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != + MHD_YES || + MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES || + (location != NULL && + MHD_add_response_header(response, MHD_HTTP_HEADER_LOCATION, location) != MHD_YES)) { MHD_destroy_response(response); return MHD_NO; } @@ -117,180 +190,226 @@ static enum MHD_Result queue_json(struct MHD_Connection *connection, } static enum MHD_Result queue_sync_json(struct MHD_Connection *connection, - unsigned int status, const char *body, const char *csrf_token, - const char *location) -{ - struct MHD_Response *response = MHD_create_response_from_buffer(strlen(body), - (void *)body, MHD_RESPMEM_MUST_COPY); - enum MHD_Result result; - if (response == NULL) return MHD_NO; - if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, - "application/json; charset=utf-8") != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, - "no-store") != MHD_YES || - MHD_add_response_header(response, - MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || - MHD_add_response_header(response, "Content-Security-Policy", - "default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES || - MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES || - (location != NULL && MHD_add_response_header(response, - MHD_HTTP_HEADER_LOCATION, location) != MHD_YES)) { - MHD_destroy_response(response); return MHD_NO; - } - result = MHD_queue_response(connection, status, response); - MHD_destroy_response(response); return result; + unsigned int status, + const char *body, + const char *csrf_token, + const char *location) { + return queue_sync_json_sized(connection, status, body, strlen(body), csrf_token, location); } -static bool read_bounded_file(const char *path, char *output, size_t capacity) -{ - int descriptor; struct stat info; size_t used = 0U; - if (path == NULL || output == NULL || capacity < 2U) return false; +static bool read_bounded_file(const char *path, char *output, size_t capacity) { + int descriptor; + struct stat info; + size_t used = 0U; + if (path == NULL || output == NULL || capacity < 2U) { + return false; + } descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - if (descriptor < 0) return false; - if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) || - info.st_size < 0 || (uintmax_t)info.st_size >= (uintmax_t)capacity) { - (void)close(descriptor); return false; + if (descriptor < 0) { + return false; + } + if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0 || + (uintmax_t)info.st_size >= (uintmax_t)capacity) { + (void)close(descriptor); + return false; } while (used < (size_t)info.st_size) { ssize_t count = read(descriptor, output + used, (size_t)info.st_size - used); - if (count < 0 && errno == EINTR) continue; - if (count <= 0) { (void)close(descriptor); return false; } + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + (void)close(descriptor); + return false; + } used += (size_t)count; } - output[used] = '\0'; (void)close(descriptor); return true; + output[used] = '\0'; + (void)close(descriptor); + return true; } -static bool sync_state_active(const char *json, char request_id[64]) -{ +static bool sync_state_active(const char *json, char request_id[64]) { yyjson_doc *document = yyjson_read(json, strlen(json), 0U); - yyjson_val *root; yyjson_val *phase; yyjson_val *request; + yyjson_val *root; + yyjson_val *phase; + yyjson_val *request; const char *text; bool active = false; - if (document == NULL) return false; - root = yyjson_doc_get_root(document); phase = yyjson_obj_get(root, "phase"); + if (document == NULL) { + return false; + } + root = yyjson_doc_get_root(document); + phase = yyjson_obj_get(root, "phase"); request = yyjson_obj_get(root, "request_id"); - if (yyjson_is_str(request) && strlen(yyjson_get_str(request)) < 64U) + if (yyjson_is_str(request) && strlen(yyjson_get_str(request)) < 64U) { (void)snprintf(request_id, 64U, "%s", yyjson_get_str(request)); + } if (yyjson_is_str(phase)) { text = yyjson_get_str(phase); active = strcmp(text, "requested") == 0 || - strcmp(text, "waiting_android_publication") == 0 || - strcmp(text, "running") == 0 || - strcmp(text, "local_import_committed") == 0 || - strcmp(text, "published") == 0 || - strcmp(text, "waiting_acknowledgement") == 0 || - strcmp(text, "peer_consumed") == 0; + strcmp(text, "waiting_android_publication") == 0 || strcmp(text, "running") == 0 || + strcmp(text, "local_import_committed") == 0 || strcmp(text, "published") == 0 || + strcmp(text, "waiting_acknowledgement") == 0 || strcmp(text, "peer_consumed") == 0; } - yyjson_doc_free(document); return active; + yyjson_doc_free(document); + return active; } -static bool valid_sync_request(const char *body, size_t size, char request_id[64]) -{ +static bool valid_sync_request(const char *body, size_t size, char request_id[64]) { yyjson_doc *document = yyjson_read(body, size, 0U); - yyjson_val *root; yyjson_val *request; yyjson_val *trigger; + yyjson_val *root; + yyjson_val *request; + yyjson_val *trigger; const char *id; - if (document == NULL) return false; + if (document == NULL) { + return false; + } root = yyjson_doc_get_root(document); if (!yyjson_is_obj(root) || yyjson_obj_size(root) != 2U) { - yyjson_doc_free(document); return false; + yyjson_doc_free(document); + return false; } - request = yyjson_obj_get(root, "request_id"); trigger = yyjson_obj_get(root, "trigger"); + request = yyjson_obj_get(root, "request_id"); + trigger = yyjson_obj_get(root, "trigger"); if (!yyjson_is_str(request) || !yyjson_is_str(trigger) || strcmp(yyjson_get_str(trigger), "web") != 0) { - yyjson_doc_free(document); return false; + yyjson_doc_free(document); + return false; } id = yyjson_get_str(request); if (strlen(id) != 39U || strncmp(id, "sy_", 3U) != 0 || uuid_parse(id + 3U, (unsigned char[16]){0}) != 0) { - yyjson_doc_free(document); return false; + yyjson_doc_free(document); + return false; } (void)snprintf(request_id, 64U, "%s", id); - yyjson_doc_free(document); return true; + yyjson_doc_free(document); + return true; +} + +static void generate_sync_run_id(char run_id[64]) { + uuid_t identifier; + char uuid[37]; + uuid_generate_random(identifier); + uuid_unparse_lower(identifier, uuid); + (void)snprintf(run_id, 64U, "sy_%s", uuid); } static bool write_initial_sync_state(const TrainlogWebContext *context, - const char *request_id, char run_id[64], char output[2048]) -{ - uuid_t identifier; char uuid[37]; char temporary[PATH_MAX + 64U]; - char timestamp[40]; time_t now = time(NULL); struct tm broken_down; - int descriptor; int written; ssize_t count; - uuid_generate_random(identifier); uuid_unparse_lower(identifier, uuid); - (void)snprintf(run_id, 64U, "sy_%s", uuid); + const char *request_id, + const char *run_id, + char output[2048]) { + char temporary[PATH_MAX + 64U]; + char timestamp[40]; + time_t now = time(NULL); + struct tm broken_down; + int descriptor; + int written; + ssize_t count; if (now == (time_t)-1 || gmtime_r(&now, &broken_down) == NULL || - strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &broken_down) == 0U) + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &broken_down) == 0U) { return false; - written = snprintf(output, 2048U, - "{\"ai_midpoint\":{\"result\":\"not_configured\"},\"ai_post_sync\":{\"result\":\"not_configured\"}," - "\"diagnostic\":\"\",\"domains\":{},\"drafts\":[],\"effective_mode\":\"full_generation_v1\"," + } + written = snprintf( + output, + 2048U, + "{\"ai_midpoint\":{\"result\":\"not_configured\"},\"ai_post_sync\":{\"result\":\"not_" + "configured\"}," + "\"diagnostic\":\"\",\"domains\":{},\"drafts\":[],\"effective_mode\":\"full_generation_" + "v1\"," "\"finished_at\":null,\"inbound_generation_id\":null,\"missing_capabilities\":[]," "\"outbound_generation_id\":null,\"phase\":\"requested\",\"producer_peer_id\":null," "\"consumer_peer_id\":null,\"progress_revision\":1,\"request_id\":\"%s\"," "\"requested_mode\":\"full_generation_v1\",\"result\":\"running\",\"run_id\":\"%s\"," - "\"sessions_reconciled\":null,\"started_at\":\"%s\",\"trigger\":\"web\",\"updated_at\":\"%s\"}\n", - request_id, run_id, timestamp, timestamp); - if (written < 0 || written >= 2048) return false; - if (snprintf(temporary, sizeof(temporary), "%s.new.%ld", context->state_path, - (long)getpid()) < 0) return false; + "\"sessions_reconciled\":null,\"started_at\":\"%s\",\"trigger\":\"web\",\"updated_at\":\"%" + "s\"}\n", + request_id, + run_id, + timestamp, + timestamp); + if (written < 0 || written >= 2048) { + return false; + } + if (snprintf(temporary, sizeof(temporary), "%s.new.%ld", context->state_path, (long)getpid()) < + 0) { + return false; + } descriptor = open(temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); - if (descriptor < 0) return false; + if (descriptor < 0) { + return false; + } count = write(descriptor, output, (size_t)written); if (count != written || fsync(descriptor) != 0 || close(descriptor) != 0 || rename(temporary, context->state_path) != 0) { - (void)close(descriptor); (void)unlink(temporary); return false; + (void)close(descriptor); + (void)unlink(temporary); + return false; } return true; } -static bool launch_sync_worker(TrainlogWebContext *context, const char *config, - const char *run_id, const char *request_id) -{ +static bool launch_sync_worker(TrainlogWebContext *context, + const char *config, + const char *run_id, + const char *request_id) { char orchestrator[PATH_MAX]; const char *tools = getenv("TRAINLOG_SYNC_TOOLS_DIR"); int path_length; - if (tools == NULL) tools = TRAINLOG_TOOLS_DIR; + if (tools == NULL) { + tools = TRAINLOG_TOOLS_DIR; + } /* CONTRACT: deployment may relocate the fixed shipped helper directory, * but an HTTP request can never select this executable or its arguments. */ - path_length = snprintf(orchestrator, sizeof(orchestrator), - "%s/sync_orchestrator.py", tools); - if (tools[0] != '/' || path_length < 0 || - (size_t)path_length >= sizeof(orchestrator)) return false; + path_length = snprintf(orchestrator, sizeof(orchestrator), "%s/sync_orchestrator.py", tools); + if (tools[0] != '/' || path_length < 0 || (size_t)path_length >= sizeof(orchestrator)) { + return false; + } pid_t child = fork(); - if (child < 0) return false; + if (child < 0) { + return false; + } if (child == 0) { (void)setpgid(0, 0); - execlp("python3", "python3", orchestrator, - "--database", context->database_path, "--state", context->state_path, - "--config", config, "--run-id", run_id, "--request-id", request_id, - (char *)NULL); + execlp("python3", + "python3", + orchestrator, + "--database", + context->database_path, + "--state", + context->state_path, + "--config", + config, + "--run-id", + run_id, + "--request-id", + request_id, + (char *)NULL); _exit(127); } - context->worker_pid = child; return true; + context->worker_pid = child; + return true; } static enum MHD_Result queue_asset(struct MHD_Connection *connection, - const TrainlogWebAsset *asset) -{ + const TrainlogWebAsset *asset) { static const char CSP[] = "default-src 'self'; script-src 'self'; " - "style-src 'self'; connect-src 'self'; img-src 'self'; " - "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; - const char *cache = asset->immutable - ? "public, max-age=31536000, immutable" : "no-cache"; - struct MHD_Response *response = MHD_create_response_from_buffer(asset->size, - (void *)asset->bytes, MHD_RESPMEM_PERSISTENT); + "style-src 'self'; connect-src 'self'; img-src 'self'; " + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; + const char *cache = asset->immutable ? "public, max-age=31536000, immutable" : "no-cache"; + struct MHD_Response *response = + MHD_create_response_from_buffer(asset->size, (void *)asset->bytes, MHD_RESPMEM_PERSISTENT); enum MHD_Result result; - if (response == NULL) return MHD_NO; - if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, - asset->mime) != MHD_YES || - MHD_add_response_header(response, - MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, - cache) != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, - asset->etag) != MHD_YES || - MHD_add_response_header(response, "Content-Security-Policy", CSP) != + if (response == NULL) { + return MHD_NO; + } + if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, asset->mime) != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES || - MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, - "close") != MHD_YES) { + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, cache) != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, asset->etag) != MHD_YES || + MHD_add_response_header(response, "Content-Security-Policy", CSP) != MHD_YES || + MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) { MHD_destroy_response(response); return MHD_NO; } @@ -299,94 +418,110 @@ static enum MHD_Result queue_asset(struct MHD_Connection *connection, return result; } -static bool is_ui_route(const char *url) -{ - static const char *const routes[] = { - "/", "/analyse", "/programmes", "/seances", "/exercices" - }; +static bool is_ui_route(const char *url) { + static const char *const routes[] = {"/", "/analyse", "/programmes", "/seances", "/exercices"}; size_t index; - for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) - if (strcmp(url, routes[index]) == 0) return true; + for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) { + if (strcmp(url, routes[index]) == 0) { + return true; + } + } return false; } -static bool valid_host(const TrainlogWebContext *context, const char *host) -{ +static bool valid_host(const TrainlogWebContext *context, const char *host) { char expected[32]; int written; - if (host == NULL) return false; - if (strcmp(host, "127.0.0.1") == 0) return true; - written = snprintf(expected, sizeof(expected), "127.0.0.1:%u", - (unsigned int)context->port); - return written > 0 && (size_t)written < sizeof(expected) && - strcmp(host, expected) == 0; + if (host == NULL) { + return false; + } + if (strcmp(host, "127.0.0.1") == 0) { + return true; + } + written = snprintf(expected, sizeof(expected), "127.0.0.1:%u", (unsigned int)context->port); + return written > 0 && (size_t)written < sizeof(expected) && strcmp(host, expected) == 0; } -static bool declared_body_too_large(struct MHD_Connection *connection) -{ - const char *value = MHD_lookup_connection_value(connection, - MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_LENGTH); +static bool declared_body_too_large(struct MHD_Connection *connection) { + const char *value = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_LENGTH); char *end = NULL; unsigned long long size; - if (value == NULL) return false; + if (value == NULL) { + return false; + } errno = 0; size = strtoull(value, &end, 10); - return errno != 0 || end == value || *end != '\0' || - size > TRAINLOG_HTTP_BODY_LIMIT; + return errno != 0 || end == value || *end != '\0' || size > TRAINLOG_HTTP_BODY_LIMIT; } -static bool valid_origin(const TrainlogWebContext *context, const char *origin) -{ +static bool valid_origin(const TrainlogWebContext *context, const char *origin) { char direct[48]; - if (origin == NULL) return false; - (void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u", - (unsigned int)context->port); + if (origin == NULL) { + return false; + } + (void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u", (unsigned int)context->port); return strcmp(origin, direct) == 0 || strcmp(origin, "http://trainlog.perf") == 0; } -static bool valid_csrf(const TrainlogWebContext *context, const char *provided) -{ - size_t index; unsigned char difference = 0U; - if (provided == NULL || strlen(provided) != 64U) return false; - for (index = 0U; index < 64U; ++index) +static bool valid_csrf(const TrainlogWebContext *context, const char *provided) { + size_t index; + unsigned char difference = 0U; + if (provided == NULL || strlen(provided) != 64U) { + return false; + } + for (index = 0U; index < 64U; ++index) { difference |= (unsigned char)(provided[index] ^ context->csrf_token[index]); + } return difference == 0U; } -static bool generate_csrf_token(char output[65]) -{ +static bool generate_csrf_token(char output[65]) { unsigned char random_bytes[32]; size_t used = 0U; size_t index; while (used < sizeof(random_bytes)) { ssize_t count = getrandom(random_bytes + used, sizeof(random_bytes) - used, 0); - if (count < 0 && errno == EINTR) continue; - if (count <= 0) return false; + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + return false; + } used += (size_t)count; } - for (index = 0U; index < sizeof(random_bytes); ++index) + for (index = 0U; index < sizeof(random_bytes); ++index) { (void)snprintf(output + index * 2U, 3U, "%02x", random_bytes[index]); + } return true; } -static bool parse_if_match(const char *value, uint64_t *revision) -{ - char *end = NULL; unsigned long long parsed; - if (value == NULL || value[0] != '"') return false; - errno = 0; parsed = strtoull(value + 1, &end, 10); +static bool parse_if_match(const char *value, uint64_t *revision) { + char *end = NULL; + unsigned long long parsed; + if (value == NULL || value[0] != '"') { + return false; + } + errno = 0; + parsed = strtoull(value + 1, &end, 10); if (errno != 0 || end == value + 1 || end[0] != '"' || end[1] != '\0' || - parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) return false; - *revision = (uint64_t)parsed; return true; + parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) { + return false; + } + *revision = (uint64_t)parsed; + return true; } static enum MHD_Result handle_request(void *closure, - struct MHD_Connection *connection, const char *url, const char *method, - const char *version, const char *upload_data, size_t *upload_data_size, - void **request_closure) -{ - static const char HEALTH[] = - "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," - "\"version\":\"" TRAINLOG_VERSION "\"}\n"; + struct MHD_Connection *connection, + const char *url, + const char *method, + const char *version, + const char *upload_data, + size_t *upload_data_size, + void **request_closure) { + static const char HEALTH[] = "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," + "\"version\":\"" TRAINLOG_VERSION "\"}\n"; TrainlogWebContext *context = closure; TrainlogHttpRequestState *state = *request_closure; const union MHD_ConnectionInfo *header_info; @@ -397,17 +532,20 @@ static enum MHD_Result handle_request(void *closure, (void)version; if (state == NULL) { state = calloc(1U, sizeof(*state)); - if (state == NULL) return MHD_NO; + if (state == NULL) { + return MHD_NO; + } state->body_too_large = declared_body_too_large(connection); *request_closure = state; return MHD_YES; } if (*upload_data_size > 0U) { - if (*upload_data_size > TRAINLOG_HTTP_BODY_LIMIT - - (state->body_size > TRAINLOG_HTTP_BODY_LIMIT - ? TRAINLOG_HTTP_BODY_LIMIT : state->body_size)) + if (*upload_data_size > + TRAINLOG_HTTP_BODY_LIMIT - (state->body_size > TRAINLOG_HTTP_BODY_LIMIT + ? TRAINLOG_HTTP_BODY_LIMIT + : state->body_size)) { state->body_too_large = true; - else { + } else { (void)memcpy(state->body + state->body_size, upload_data, *upload_data_size); state->body_size += *upload_data_size; state->body[state->body_size] = '\0'; @@ -415,93 +553,127 @@ static enum MHD_Result handle_request(void *closure, *upload_data_size = 0U; return MHD_YES; } - header_info = MHD_get_connection_info(connection, - MHD_CONNECTION_INFO_REQUEST_HEADER_SIZE); - if (header_info == NULL || header_info->header_size > - TRAINLOG_HTTP_HEADER_LIMIT) - return queue_json(connection, MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE, - "{\"error\":\"headers_too_large\"}\n", NULL); - if (state->body_too_large) - return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE, - "{\"error\":\"body_too_large\"}\n", NULL); - host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, - MHD_HTTP_HEADER_HOST); - if (!valid_host(context, host)) - return queue_json(connection, MHD_HTTP_BAD_REQUEST, - "{\"error\":\"invalid_host\"}\n", NULL); + header_info = MHD_get_connection_info(connection, MHD_CONNECTION_INFO_REQUEST_HEADER_SIZE); + if (header_info == NULL || header_info->header_size > TRAINLOG_HTTP_HEADER_LIMIT) { + return queue_json(connection, + MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE, + "{\"error\":\"headers_too_large\"}\n", + NULL); + } + if (state->body_too_large) { + return queue_json( + connection, MHD_HTTP_CONTENT_TOO_LARGE, "{\"error\":\"body_too_large\"}\n", NULL); + } + host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_HOST); + if (!valid_host(context, host)) { + return queue_json(connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_host\"}\n", NULL); + } is_get = strcmp(method, MHD_HTTP_METHOD_GET) == 0; is_head = strcmp(method, MHD_HTTP_METHOD_HEAD) == 0; if (strcmp(url, "/api/v1/health") == 0) { - if (!is_get) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "GET"); + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL); } if (strcmp(url, "/api/v1/sync/status") == 0) { static const char IDLE_DISABLED[] = "{\"api_version\":1,\"enabled\":false,\"phase\":\"idle\"," "\"result\":\"disabled\",\"last_success\":null}\n"; - static const char IDLE_ENABLED[] = - "{\"api_version\":1,\"enabled\":true,\"phase\":\"idle\"," - "\"result\":\"idle\",\"last_success\":null}\n"; + static const char IDLE_ENABLED[] = "{\"api_version\":1,\"enabled\":true,\"phase\":\"idle\"," + "\"result\":\"idle\",\"last_success\":null}\n"; char status_json[65537]; - if (!is_get) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "GET"); - if (!read_bounded_file(context->state_path, status_json, - sizeof(status_json))) - return queue_sync_json(connection, MHD_HTTP_OK, - getenv("TRAINLOG_SYNC_GENERATION_CONFIG") == NULL - ? IDLE_DISABLED : IDLE_ENABLED, - context->csrf_token, NULL); - return queue_sync_json(connection, MHD_HTTP_OK, status_json, - context->csrf_token, NULL); + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } + if (!read_bounded_file(context->state_path, status_json, sizeof(status_json))) { + return queue_sync_json(connection, + MHD_HTTP_OK, + getenv("TRAINLOG_SYNC_GENERATION_CONFIG") == NULL ? IDLE_DISABLED + : IDLE_ENABLED, + context->csrf_token, + NULL); + } + return queue_sync_json(connection, MHD_HTTP_OK, status_json, context->csrf_token, NULL); } if (strcmp(url, "/api/v1/sync") == 0) { - const char *origin; const char *csrf; const char *content_type; + const char *origin; + const char *csrf; + const char *content_type; const char *config = getenv("TRAINLOG_SYNC_GENERATION_CONFIG"); - char request_id[64] = ""; char existing_id[64] = ""; - char existing[65537]; char initial[2048]; char run_id[64]; - char accepted[256]; - if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "POST"); + char request_id[64] = ""; + char existing_id[64] = ""; + char existing[65537]; + char initial[2048]; + char run_id[64]; + char accepted[TRAINLOG_SYNC_ACCEPTED_JSON_CAPACITY]; + size_t accepted_size; + if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "POST"); + } origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin"); - csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, - "X-Trainlog-CSRF-Token"); - if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) - return queue_json(connection, MHD_HTTP_FORBIDDEN, - "{\"error\":\"mutation_forbidden\"}\n", NULL); - content_type = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, - MHD_HTTP_HEADER_CONTENT_TYPE); - if (content_type == NULL || strcmp(content_type, "application/json") != 0) - return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE, - "{\"error\":\"unsupported_media_type\"}\n", NULL); - if (!valid_sync_request(state->body, state->body_size, request_id)) - return queue_json(connection, MHD_HTTP_BAD_REQUEST, - "{\"error\":\"invalid_sync_request\"}\n", NULL); - if (config == NULL || config[0] != '/' || strlen(config) >= PATH_MAX) - return queue_json(connection, MHD_HTTP_CONFLICT, - "{\"error\":\"full_generation_disabled\"}\n", NULL); + csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token"); + if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) { + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); + } + content_type = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE); + if (content_type == NULL || strcmp(content_type, "application/json") != 0) { + return queue_json(connection, + MHD_HTTP_UNSUPPORTED_MEDIA_TYPE, + "{\"error\":\"unsupported_media_type\"}\n", + NULL); + } + if (!valid_sync_request(state->body, state->body_size, request_id)) { + return queue_json( + connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_sync_request\"}\n", NULL); + } + if (config == NULL || config[0] != '/' || strlen(config) >= PATH_MAX) { + return queue_json( + connection, MHD_HTTP_CONFLICT, "{\"error\":\"full_generation_disabled\"}\n", NULL); + } if (read_bounded_file(context->state_path, existing, sizeof(existing))) { bool active = sync_state_active(existing, existing_id); - if (strcmp(existing_id, request_id) == 0) - return queue_sync_json(connection, active ? MHD_HTTP_ACCEPTED : MHD_HTTP_OK, - existing, context->csrf_token, "/api/v1/sync/status"); - if (active) - return queue_json(connection, MHD_HTTP_CONFLICT, - "{\"error\":\"sync_already_running\"}\n", NULL); + if (strcmp(existing_id, request_id) == 0) { + return queue_sync_json(connection, + active ? MHD_HTTP_ACCEPTED : MHD_HTTP_OK, + existing, + context->csrf_token, + "/api/v1/sync/status"); + } + if (active) { + return queue_json( + connection, MHD_HTTP_CONFLICT, "{\"error\":\"sync_already_running\"}\n", NULL); + } } - if (!write_initial_sync_state(context, request_id, run_id, initial) || - !launch_sync_worker(context, config, run_id, request_id)) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"sync_worker_start_failed\"}\n", NULL); - (void)snprintf(accepted, sizeof(accepted), - "{\"api_version\":1,\"phase\":\"requested\",\"result\":\"running\",\"progress_revision\":1,\"request_id\":\"%s\"," - "\"run_id\":\"%s\",\"status_url\":\"/api/v1/sync/status\"}\n", - request_id, run_id); - return queue_sync_json(connection, MHD_HTTP_ACCEPTED, accepted, - context->csrf_token, "/api/v1/sync/status"); + generate_sync_run_id(run_id); + /* INVARIANT: a request is made durable only after its complete 202 response is known to + * fit, so no admitted job can become unreportable because of response truncation. */ + if (!trainlog_web_sync_accepted_serialize( + request_id, run_id, accepted, sizeof(accepted), &accepted_size) || + !write_initial_sync_state(context, request_id, run_id, initial) || + !launch_sync_worker(context, config, run_id, request_id)) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"sync_worker_start_failed\"}\n", + NULL); + } + return queue_sync_json_sized(connection, + MHD_HTTP_ACCEPTED, + accepted, + accepted_size, + context->csrf_token, + "/api/v1/sync/status"); } if (strcmp(url, "/api/v1/dashboard") == 0) { TrainlogWebDashboardQuery query; @@ -509,20 +681,30 @@ static enum MHD_Result handle_request(void *closure, char json[TRAINLOG_WEB_JSON_CAPACITY]; size_t json_size; time_t now; - if (!is_get) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "GET"); + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } now = time(NULL); - if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"clock_unavailable\"}\n", NULL); + if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"clock_unavailable\"}\n", + NULL); + } query.reference_unix_second = (int64_t)now; - if (trainlog_web_dashboard_load(context->database, &query, - &snapshot) != TRAINLOG_STATUS_OK || - trainlog_web_dashboard_serialize(&snapshot, json, sizeof(json), - &json_size) != TRAINLOG_STATUS_OK || json_size == 0U) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"dashboard_unavailable\"}\n", NULL); + if (trainlog_web_dashboard_load(context->database, &query, &snapshot) != + TRAINLOG_STATUS_OK || + trainlog_web_dashboard_serialize(&snapshot, json, sizeof(json), &json_size) != + TRAINLOG_STATUS_OK || + json_size == 0U) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"dashboard_unavailable\"}\n", + NULL); + } return queue_json(connection, MHD_HTTP_OK, json, NULL); } if (strcmp(url, "/api/v1/dashboard-layout") == 0) { @@ -543,97 +725,137 @@ static enum MHD_Result handle_request(void *closure, if (is_get) { layout_result = trainlog_dashboard_layout_load(&layout, &source); if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || - !trainlog_dashboard_layout_serialize(&layout, source, true, - json, sizeof(json), &json_size) || json_size == 0U) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"layout_unavailable\"}\n", NULL); + !trainlog_dashboard_layout_serialize( + &layout, source, true, json, sizeof(json), &json_size) || + json_size == 0U) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"layout_unavailable\"}\n", + NULL); + } if (source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED && !context->invalid_layout_reported) { - (void)fprintf(stderr, "Trainlog Web : agencement persisté invalide, défaut utilisé\n"); + (void)fprintf(stderr, + "Trainlog Web : agencement persisté invalide, défaut utilisé\n"); context->invalid_layout_reported = true; } - return queue_layout_json(connection, MHD_HTTP_OK, json, - layout.revision, context->csrf_token); + return queue_layout_json( + connection, MHD_HTTP_OK, json, layout.revision, context->csrf_token); } if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 && - strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "GET, PUT, DELETE"); + strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET, PUT, DELETE"); + } origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin"); - csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, - "X-Trainlog-CSRF-Token"); - if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) - return queue_json(connection, MHD_HTTP_FORBIDDEN, - "{\"error\":\"mutation_forbidden\"}\n", NULL); - if_match = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, - MHD_HTTP_HEADER_IF_MATCH); - if (!parse_if_match(if_match, &expected)) - return queue_json(connection, MHD_HTTP_PRECONDITION_REQUIRED, - "{\"error\":\"precondition_required\"}\n", NULL); + csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-CSRF-Token"); + if (!valid_origin(context, origin) || !valid_csrf(context, csrf)) { + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); + } + if_match = + MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH); + if (!parse_if_match(if_match, &expected)) { + return queue_json(connection, + MHD_HTTP_PRECONDITION_REQUIRED, + "{\"error\":\"precondition_required\"}\n", + NULL); + } if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) { - if (state->body_size != 0U) - return queue_json(connection, MHD_HTTP_BAD_REQUEST, - "{\"error\":\"unexpected_body\"}\n", NULL); + if (state->body_size != 0U) { + return queue_json( + connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"unexpected_body\"}\n", NULL); + } layout_result = trainlog_dashboard_layout_delete(expected); - if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) - return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED, - "{\"error\":\"revision_conflict\"}\n", NULL); - if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"layout_delete_failed\"}\n", NULL); + if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) { + return queue_json(connection, + MHD_HTTP_PRECONDITION_FAILED, + "{\"error\":\"revision_conflict\"}\n", + NULL); + } + if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"layout_delete_failed\"}\n", + NULL); + } trainlog_dashboard_layout_default(&layout); if (!trainlog_dashboard_layout_serialize(&layout, - TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, true, json, sizeof(json), &json_size)) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"layout_unavailable\"}\n", NULL); - return queue_layout_json(connection, MHD_HTTP_OK, json, 0U, - context->csrf_token); + TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, + true, + json, + sizeof(json), + &json_size)) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"layout_unavailable\"}\n", + NULL); + } + return queue_layout_json(connection, MHD_HTTP_OK, json, 0U, context->csrf_token); } { - const char *content_type = MHD_lookup_connection_value(connection, - MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE); - if (content_type == NULL || strcmp(content_type, "application/json") != 0) - return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE, - "{\"error\":\"unsupported_media_type\"}\n", NULL); + const char *content_type = MHD_lookup_connection_value( + connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE); + if (content_type == NULL || strcmp(content_type, "application/json") != 0) { + return queue_json(connection, + MHD_HTTP_UNSUPPORTED_MEDIA_TYPE, + "{\"error\":\"unsupported_media_type\"}\n", + NULL); + } + } + layout_result = trainlog_dashboard_layout_parse(state->body, state->body_size, &layout); + if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected) { + return queue_json( + connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_layout\"}\n", NULL); } - layout_result = trainlog_dashboard_layout_parse(state->body, - state->body_size, &layout); - if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected) - return queue_json(connection, MHD_HTTP_BAD_REQUEST, - "{\"error\":\"invalid_layout\"}\n", NULL); layout_result = trainlog_dashboard_layout_save(&layout, expected, &saved); - if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) - return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED, - "{\"error\":\"revision_conflict\"}\n", NULL); + if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT) { + return queue_json(connection, + MHD_HTTP_PRECONDITION_FAILED, + "{\"error\":\"revision_conflict\"}\n", + NULL); + } if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || !trainlog_dashboard_layout_serialize(&saved, - TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, true, json, sizeof(json), &json_size)) - return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR, - "{\"error\":\"layout_save_failed\"}\n", NULL); - return queue_layout_json(connection, MHD_HTTP_OK, json, saved.revision, - context->csrf_token); + TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, + true, + json, + sizeof(json), + &json_size)) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"layout_save_failed\"}\n", + NULL); + } + return queue_layout_json( + connection, MHD_HTTP_OK, json, saved.revision, context->csrf_token); } /* INVARIANT: API paths never fall through to the SPA index. */ - if (strncmp(url, "/api/", 5U) == 0) - return queue_json(connection, MHD_HTTP_NOT_FOUND, - "{\"error\":\"not_found\"}\n", NULL); - if (strstr(url, "..") != NULL) - return queue_json(connection, MHD_HTTP_NOT_FOUND, - "{\"error\":\"not_found\"}\n", NULL); - asset = is_ui_route(url) ? trainlog_web_index_asset() - : trainlog_web_asset_find(url); - if (asset == NULL) - return queue_json(connection, MHD_HTTP_NOT_FOUND, - "{\"error\":\"not_found\"}\n", NULL); - if (!is_get && !is_head) - return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED, - "{\"error\":\"method_not_allowed\"}\n", "GET, HEAD"); + if (strncmp(url, "/api/", 5U) == 0) { + return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); + } + if (strstr(url, "..") != NULL) { + return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); + } + asset = is_ui_route(url) ? trainlog_web_index_asset() : trainlog_web_asset_find(url); + if (asset == NULL) { + return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL); + } + if (!is_get && !is_head) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET, HEAD"); + } return queue_asset(connection, asset); } -static void request_completed(void *closure, struct MHD_Connection *connection, - void **request_closure, enum MHD_RequestTerminationCode termination) -{ +static void request_completed(void *closure, + struct MHD_Connection *connection, + void **request_closure, + enum MHD_RequestTerminationCode termination) { (void)closure; (void)connection; (void)termination; @@ -641,32 +863,35 @@ static void request_completed(void *closure, struct MHD_Connection *connection, *request_closure = NULL; } -static enum MHD_Result accept_loopback_only(void *closure, - const struct sockaddr *address, socklen_t address_length) -{ +static enum MHD_Result +accept_loopback_only(void *closure, const struct sockaddr *address, socklen_t address_length) { const struct sockaddr_in *ipv4; (void)closure; if (address == NULL || address_length < (socklen_t)sizeof(*ipv4) || - address->sa_family != AF_INET) return MHD_NO; + address->sa_family != AF_INET) { + return MHD_NO; + } ipv4 = (const struct sockaddr_in *)address; return ipv4->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? MHD_YES : MHD_NO; } -int trainlog_web_server_run(TrainlogDatabase *database, const char *database_path, - uint16_t port, - const volatile sig_atomic_t *stop_requested, - char *diagnostic, size_t diagnostic_capacity) -{ +int trainlog_web_server_run(TrainlogDatabase *database, + const char *database_path, + uint16_t port, + const volatile sig_atomic_t *stop_requested, + char *diagnostic, + size_t diagnostic_capacity) { struct sockaddr_in bind_address; struct MHD_Daemon *daemon; TrainlogWebContext context; int result = 0; if (database == NULL || database_path == NULL || database_path[0] != '/' || - stop_requested == NULL) return -1; + stop_requested == NULL) { + return -1; + } web_diagnostic(diagnostic, diagnostic_capacity, ""); if (!trainlog_web_assets_available()) { - web_diagnostic(diagnostic, diagnostic_capacity, - "support frontend absent de ce binaire"); + web_diagnostic(diagnostic, diagnostic_capacity, "support frontend absent de ce binaire"); return -1; } (void)memset(&bind_address, 0, sizeof(bind_address)); @@ -677,35 +902,50 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat context.database = database; context.database_path = database_path; context.worker_pid = -1; - if (snprintf(context.state_path, sizeof(context.state_path), "%s.sync-run.json", - database_path) < 0 || strlen(context.state_path) >= sizeof(context.state_path) - 1U) { + if (snprintf( + context.state_path, sizeof(context.state_path), "%s.sync-run.json", database_path) < + 0 || + strlen(context.state_path) >= sizeof(context.state_path) - 1U) { web_diagnostic(diagnostic, diagnostic_capacity, "chemin d'état sync trop long"); return -1; } context.invalid_layout_reported = false; if (!generate_csrf_token(context.csrf_token)) { - web_diagnostic(diagnostic, diagnostic_capacity, - "impossible de générer la protection CSRF"); + web_diagnostic(diagnostic, diagnostic_capacity, "impossible de générer la protection CSRF"); return -1; } errno = 0; daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY, - 0U, accept_loopback_only, NULL, handle_request, &context, - MHD_OPTION_SOCK_ADDR_LEN, (socklen_t)sizeof(bind_address), &bind_address, - MHD_OPTION_CONNECTION_LIMIT, TRAINLOG_HTTP_CONNECTION_LIMIT, - MHD_OPTION_PER_IP_CONNECTION_LIMIT, TRAINLOG_HTTP_PER_IP_LIMIT, - MHD_OPTION_CONNECTION_MEMORY_LIMIT, - (size_t)TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT, - MHD_OPTION_CONNECTION_TIMEOUT, TRAINLOG_HTTP_TIMEOUT_SECONDS, - MHD_OPTION_LISTEN_BACKLOG_SIZE, TRAINLOG_HTTP_BACKLOG, - MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL, - MHD_OPTION_END); + 0U, + accept_loopback_only, + NULL, + handle_request, + &context, + MHD_OPTION_SOCK_ADDR_LEN, + (socklen_t)sizeof(bind_address), + &bind_address, + MHD_OPTION_CONNECTION_LIMIT, + TRAINLOG_HTTP_CONNECTION_LIMIT, + MHD_OPTION_PER_IP_CONNECTION_LIMIT, + TRAINLOG_HTTP_PER_IP_LIMIT, + MHD_OPTION_CONNECTION_MEMORY_LIMIT, + (size_t)TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT, + MHD_OPTION_CONNECTION_TIMEOUT, + TRAINLOG_HTTP_TIMEOUT_SECONDS, + MHD_OPTION_LISTEN_BACKLOG_SIZE, + TRAINLOG_HTTP_BACKLOG, + MHD_OPTION_NOTIFY_COMPLETED, + request_completed, + NULL, + MHD_OPTION_END); if (daemon == NULL) { char message[256]; - (void)snprintf(message, sizeof(message), - "impossible d'écouter sur 127.0.0.1:%u%s%s", - (unsigned int)port, errno == 0 ? "" : " : ", - errno == 0 ? "" : strerror(errno)); + (void)snprintf(message, + sizeof(message), + "impossible d'écouter sur 127.0.0.1:%u%s%s", + (unsigned int)port, + errno == 0 ? "" : " : ", + errno == 0 ? "" : strerror(errno)); web_diagnostic(diagnostic, diagnostic_capacity, message); return -1; } @@ -721,38 +961,37 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat int selected; if (context.worker_pid > 0) { int worker_status; - if (waitpid(context.worker_pid, &worker_status, WNOHANG) == - context.worker_pid) + if (waitpid(context.worker_pid, &worker_status, WNOHANG) == context.worker_pid) { context.worker_pid = -1; + } } FD_ZERO(&read_set); FD_ZERO(&write_set); FD_ZERO(&except_set); - if (MHD_get_fdset(daemon, &read_set, &write_set, &except_set, - &maximum_fd) != MHD_YES) { - web_diagnostic(diagnostic, diagnostic_capacity, - "impossible de préparer la boucle HTTP"); + if (MHD_get_fdset(daemon, &read_set, &write_set, &except_set, &maximum_fd) != MHD_YES) { + web_diagnostic( + diagnostic, diagnostic_capacity, "impossible de préparer la boucle HTTP"); result = -1; break; } if (MHD_get_timeout(daemon, &timeout_ms) != MHD_YES || - timeout_ms > TRAINLOG_HTTP_POLL_MAX_MS) + timeout_ms > TRAINLOG_HTTP_POLL_MAX_MS) { timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS; + } timeout.tv_sec = (time_t)(timeout_ms / 1000ULL); timeout.tv_usec = (suseconds_t)((timeout_ms % 1000ULL) * 1000ULL); - selected = select(maximum_fd + 1, &read_set, &write_set, &except_set, - &timeout); + selected = select(maximum_fd + 1, &read_set, &write_set, &except_set, &timeout); if (selected < 0) { - if (errno == EINTR) continue; - web_diagnostic(diagnostic, diagnostic_capacity, - "échec de la boucle HTTP locale"); + if (errno == EINTR) { + continue; + } + web_diagnostic(diagnostic, diagnostic_capacity, "échec de la boucle HTTP locale"); result = -1; break; } - if (*stop_requested == 0 && MHD_run_from_select(daemon, &read_set, - &write_set, &except_set) != MHD_YES) { - web_diagnostic(diagnostic, diagnostic_capacity, - "échec du traitement HTTP local"); + if (*stop_requested == 0 && + MHD_run_from_select(daemon, &read_set, &write_set, &except_set) != MHD_YES) { + web_diagnostic(diagnostic, diagnostic_capacity, "échec du traitement HTTP local"); result = -1; break; } @@ -767,7 +1006,9 @@ int trainlog_web_server_run(TrainlogDatabase *database, const char *database_pat (void)kill(-context.worker_pid, SIGTERM); for (attempt = 0U; attempt < 50U; ++attempt) { waited = waitpid(context.worker_pid, &worker_status, WNOHANG); - if (waited == context.worker_pid || waited < 0) break; + if (waited == context.worker_pid || waited < 0) { + break; + } (void)nanosleep(&delay, NULL); } if (waited == 0) { diff --git a/tui/tests/bounded_path.h b/tui/tests/bounded_path.h new file mode 100644 index 0000000..b28eff4 --- /dev/null +++ b/tui/tests/bounded_path.h @@ -0,0 +1,33 @@ +#ifndef TRAINLOG_TEST_BOUNDED_PATH_H +#define TRAINLOG_TEST_BOUNDED_PATH_H + +#include +#include +#include + +/* CONTRACT: failure leaves output empty and no caller may perform filesystem + * work with a partial path. Lengths include the separator and trailing NUL. */ +static bool +trainlog_test_join_path(char *output, size_t capacity, const char *parent, const char *leaf) { + size_t parent_length; + size_t leaf_length; + + if (output == NULL || capacity == 0U || parent == NULL || leaf == NULL) { + return false; + } + output[0] = '\0'; + parent_length = strlen(parent); + leaf_length = strlen(leaf); + if (leaf_length > SIZE_MAX - 2U || parent_length > SIZE_MAX - leaf_length - 2U || + parent_length + leaf_length + 2U > capacity) { + return false; + } + + (void)memcpy(output, parent, parent_length); + output[parent_length] = '/'; + (void)memcpy(output + parent_length + 1U, leaf, leaf_length); + output[parent_length + leaf_length + 1U] = '\0'; + return true; +} + +#endif diff --git a/tui/tests/generation_mtp_adapter_double.c b/tui/tests/generation_mtp_adapter_double.c index 41e8d57..1807eae 100644 --- a/tui/tests/generation_mtp_adapter_double.c +++ b/tui/tests/generation_mtp_adapter_double.c @@ -1,10 +1,12 @@ #include +#include #include #include #include #include #include #include "trainlog/generation_mtp.h" +#include "bounded_path.h" typedef struct Node { uint32_t id, parent; @@ -15,13 +17,41 @@ typedef struct Node { static Node nodes[2048]; static size_t node_count; static const char *remote; +static bool scan_failed; +static uint32_t stable_path_id(const char *path) { + const unsigned char *cursor = (const unsigned char *)path; + uint32_t value = UINT32_C(2166136261); + while (*cursor != '\0') { + value ^= (uint32_t)*cursor++; + value *= UINT32_C(16777619); + } + return value == 0U || value == UINT32_MAX ? value ^ UINT32_C(0x80000000) : value; +} + static uint32_t scan(const char *path, uint32_t parent) { DIR *d; struct dirent *e; - uint32_t self = (uint32_t)(node_count + 1U); - Node *n = &nodes[node_count++]; + uint32_t self; + Node *n; struct stat st; - lstat(path, &st); + if (lstat(path, &st) != 0) { + if (errno != ENOENT) { + scan_failed = true; + } + return 0U; + } + if (node_count >= sizeof(nodes) / sizeof(nodes[0])) { + scan_failed = true; + return 0U; + } + self = stable_path_id(path); + for (size_t index = 0U; index < node_count; index++) { + if (nodes[index].id == self && strcmp(nodes[index].path, path) != 0) { + scan_failed = true; + return 0U; + } + } + n = &nodes[node_count++]; n->id = self; n->parent = parent; n->folder = S_ISDIR(st.st_mode); @@ -33,17 +63,23 @@ static uint32_t scan(const char *path, uint32_t parent) { while ((e = readdir(d))) { if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..")) { char child[1024]; - snprintf(child, sizeof(child), "%s/%s", path, e->d_name); + if (!trainlog_test_join_path(child, sizeof(child), path, e->d_name)) { + scan_failed = true; + break; + } scan(child, self); } } closedir(d); + } else if (n->folder && errno != ENOENT) { + scan_failed = true; } return self; } -static void rebuild(void) { +static bool rebuild(void) { node_count = 0U; - scan(remote, UINT32_MAX); + scan_failed = false; + return scan(remote, UINT32_MAX) != 0U && !scan_failed; } static Node *node(uint32_t id) { size_t i; @@ -87,7 +123,9 @@ static TrainlogStatus children(unsigned int b, (void)b; (void)d; (void)s; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } if (p == UINT32_MAX) { p = nodes[0].id; } @@ -121,12 +159,16 @@ static TrainlogStatus ensure(unsigned int b, (void)b; (void)d; (void)s; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } parent = node(p); if (!parent) { return TRAINLOG_STATUS_NOT_FOUND; } - snprintf(path, sizeof(path), "%s/%s", parent->path, name); + if (!trainlog_test_join_path(path, sizeof(path), parent->path, name)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } if (mkdir(path, 0700) == 0) { *created = true; } else if (access(path, F_OK) == 0) { @@ -134,7 +176,9 @@ static TrainlogStatus ensure(unsigned int b, } else { return TRAINLOG_STATUS_SYSTEM_ERROR; } - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } size_t i; for (i = 0; i < node_count; i++) { if (strcmp(nodes[i].path, path) == 0) { @@ -162,7 +206,9 @@ static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const Node *n; (void)b; (void)d; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } n = node(id); return n && copy(n->path, path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR; } @@ -178,16 +224,22 @@ static TrainlogStatus send_file(unsigned int b, (void)b; (void)d; (void)s; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } parent = node(p); if (!parent) { return TRAINLOG_STATUS_NOT_FOUND; } - snprintf(path, sizeof(path), "%s/%s", parent->path, name); + if (!trainlog_test_join_path(path, sizeof(path), parent->path, name)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } if (copy(local, path) != 0) { return TRAINLOG_STATUS_SYSTEM_ERROR; } - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } size_t i; for (i = 0; i < node_count; i++) { if (strcmp(nodes[i].path, path) == 0) { @@ -201,26 +253,38 @@ static TrainlogStatus remove_object(unsigned int b, unsigned int d, uint32_t id) Node *n; (void)b; (void)d; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } n = node(id); return n && unlink(n->path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR; } static TrainlogStatus rename_object(unsigned int b, unsigned int d, uint32_t id, const char *name) { Node *n; - char path[1024], *slash; + char parent[1024]; + char path[1024]; + char *slash; (void)b; (void)d; - rebuild(); + if (!rebuild()) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } n = node(id); if (!n) { return TRAINLOG_STATUS_NOT_FOUND; } - snprintf(path, sizeof(path), "%s", n->path); - slash = strrchr(path, '/'); + if (strlen(n->path) >= sizeof(parent)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + (void)memcpy(parent, n->path, strlen(n->path) + 1U); + slash = strrchr(parent, '/'); if (!slash) { return TRAINLOG_STATUS_SYSTEM_ERROR; } - snprintf(slash + 1, (size_t)(path + sizeof(path) - (slash + 1)), "%s", name); + *slash = '\0'; + if (!trainlog_test_join_path(path, sizeof(path), parent, name)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } return rename(n->path, path) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR; } static const TrainlogGenerationMtpIo io = { diff --git a/tui/tests/test_generation_mtp.c b/tui/tests/test_generation_mtp.c index d19f0d4..2220de3 100644 --- a/tui/tests/test_generation_mtp.c +++ b/tui/tests/test_generation_mtp.c @@ -4,6 +4,7 @@ #include #include #include "trainlog/generation_mtp.h" +#include "bounded_path.h" #define CHECK(x) \ do { \ @@ -239,21 +240,22 @@ static int test_pull_and_manifest_last(void) { CHECK(trainlog_generation_mtp_pull( &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) == TRAINLOG_STATUS_OK); - snprintf(path, - sizeof(path), - "%s/android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json", - root); + CHECK(trainlog_test_join_path( + path, + sizeof(path), + root, + "android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json")); CHECK(access(path, R_OK) == 0); - snprintf(path, sizeof(path), "%s/out", root); + CHECK(trainlog_test_join_path(path, sizeof(path), root, "out")); CHECK(mkdir(path, 0700) == 0); char gen[512]; - snprintf(gen, sizeof(gen), "%s/generation", path); + CHECK(trainlog_test_join_path(gen, sizeof(gen), path, "generation")); CHECK(mkdir(gen, 0700) == 0); char artifact[512]; - snprintf(artifact, sizeof(artifact), "%s/artifact.json", gen); + CHECK(trainlog_test_join_path(artifact, sizeof(artifact), gen, "artifact.json")); CHECK(write_file(artifact, "a") == 0); char marker[512]; - snprintf(marker, sizeof(marker), "%s/manifest.json", gen); + CHECK(trainlog_test_join_path(marker, sizeof(marker), gen, "manifest.json")); CHECK(write_file(marker, "m") == 0); CHECK(trainlog_generation_mtp_push( &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) == @@ -290,11 +292,36 @@ static int test_ambiguity_and_truncation(void) { status = trainlog_generation_mtp_pull( &io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)); CHECK(status != TRAINLOG_STATUS_OK); - snprintf(path, sizeof(path), "%s/bad.bin", root); + CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin")); CHECK(access(path, F_OK) != 0); return 0; } +static int test_bounded_path_join(void) { + char exact[8]; + char too_small[7] = "guard"; + char multibyte[16]; + char private_root[] = "/tmp/trainlog-path-bound-XXXXXX"; + char sentinel[128]; + char refused[8] = "guard"; + + CHECK(trainlog_test_join_path(exact, sizeof(exact), "abc", "def")); + CHECK(strcmp(exact, "abc/def") == 0); + CHECK(!trainlog_test_join_path(too_small, sizeof(too_small), "abc", "def")); + CHECK(too_small[0] == '\0'); + CHECK(trainlog_test_join_path(multibyte, sizeof(multibyte), "rép", "é")); + CHECK(strcmp(multibyte, "rép/é") == 0); + CHECK(mkdtemp(private_root) != NULL); + CHECK(trainlog_test_join_path(sentinel, sizeof(sentinel), private_root, "sentinel")); + CHECK(write_file(sentinel, "outside") == 0); + CHECK(!trainlog_test_join_path(refused, sizeof(refused), private_root, "overflow")); + CHECK(refused[0] == '\0'); + CHECK(access(sentinel, R_OK) == 0); + CHECK(unlink(sentinel) == 0); + CHECK(rmdir(private_root) == 0); + return 0; +} int main(void) { + CHECK(test_bounded_path_join() == 0); CHECK(test_pull_and_manifest_last() == 0); CHECK(test_ambiguity_and_truncation() == 0); return 0; diff --git a/tui/tests/test_schema_v16_migration.c b/tui/tests/test_schema_v16_migration.c index fb30bb5..9844a73 100644 --- a/tui/tests/test_schema_v16_migration.c +++ b/tui/tests/test_schema_v16_migration.c @@ -67,7 +67,7 @@ static bool run_test(void) char path[] = "/tmp/trainlog-v16-XXXXXX"; int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL; bool ok = false; - TrainlogPersistedExerciseDetail old[1] = {{0}}; + TrainlogPersistedExerciseDetail old[1]; TrainlogSessionSummary summary; size_t count = 0U; int version = 0; TrainlogExercise changed; @@ -75,6 +75,7 @@ static bool run_test(void) TrainlogSessionExerciseInput input = {0}; TrainlogSessionInput session = {0}; char diagnostic[512] = {0}; + (void)memset(old, 0, sizeof(old)); CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK); diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index e198cfa..5ee4430 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -149,6 +149,31 @@ static bool response_header(const char *response, const char *name, char *output return true; } +static bool test_sync_accepted_serialization(void) { + static const char REQUEST_ID[] = "sy_11111111-1111-4111-8111-111111111111"; + static const char RUN_ID[] = "sy_22222222-2222-4222-8222-222222222222"; + char output[256]; + char exact[256]; + char too_small[256]; + size_t output_size; + + CHECK(trainlog_web_sync_accepted_serialize( + REQUEST_ID, RUN_ID, output, sizeof(output), &output_size)); + CHECK(output_size == strlen(output)); + CHECK(strstr(output, "\"request_id\":\"sy_11111111-1111-4111-8111-111111111111\"") != NULL); + CHECK(strstr(output, "\"run_id\":\"sy_22222222-2222-4222-8222-222222222222\"") != NULL); + CHECK(strstr(output, "\"result\":\"running\"") != NULL); + CHECK(trainlog_web_sync_accepted_serialize( + REQUEST_ID, RUN_ID, exact, output_size + 1U, &output_size)); + CHECK(strcmp(exact, output) == 0); + CHECK(!trainlog_web_sync_accepted_serialize( + REQUEST_ID, RUN_ID, too_small, output_size, &output_size)); + CHECK(too_small[0] == '\0'); + CHECK(!trainlog_web_sync_accepted_serialize( + "sy_invalid", RUN_ID, output, sizeof(output), &output_size)); + return true; +} + static bool test_http_contract(TrainlogDatabase *database) { char response[32768]; char large_request[12000]; @@ -476,8 +501,8 @@ int main(void) { sizeof(diagnostic)) != 0 && strstr(diagnostic, "frontend") != NULL; } else { - passed = test_http_contract(database) && test_port_in_use(database) && - test_dashboard_core_error_translation(); + passed = test_sync_accepted_serialization() && test_http_contract(database) && + test_port_in_use(database) && test_dashboard_core_error_translation(); } trainlog_database_close(database); return passed ? 0 : 1;