From d67fd327c995be4f59c730e87a5a5009d6686bb5 Mon Sep 17 00:00:00 2001 From: sasa Date: Sat, 29 Aug 2026 23:40:37 +0300 Subject: [PATCH] feat: harden errors, recovery, and resource usage --- PLANS.md | 15 +- docs/API_CONTRACT.md | 22 ++- docs/RESOURCE_BUDGET.md | 14 +- docs/openapi.yaml | 368 ++++++++++++++++++++++++++++++++++++ include/http_api.h | 1 + src/http_api.c | 4 +- src/main.c | 30 ++- test/host/Makefile | 9 +- test/host/test_http_api.c | 5 +- test/host/test_robustness.c | 89 +++++++++ 10 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 docs/openapi.yaml create mode 100644 test/host/test_robustness.c diff --git a/PLANS.md b/PLANS.md index f2ab968..f502031 100644 --- a/PLANS.md +++ b/PLANS.md @@ -823,7 +823,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 016 — Harden errors, recovery, and resource usage -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 015 ### Objective @@ -851,11 +851,22 @@ Make the integrated application resilient to malformed traffic, connection churn If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 017 from `BLOCKED` to `READY`. +### Execution record + +- Date: 2026-08-29 +- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2. +- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4. +- Result: PASS for automated hardening verification; pending on-device soak and recovery confirmation. +- Evidence: Rejection diagnostics are now saturating and protected against concurrent callback updates. `GET /api/health` includes the platform reset reason alongside uptime, heap, minimum heap, largest free block, connection count, and rejected-input count. Added bounded malformed-traffic coverage that repeatedly exercises all HTTP command parsers and 600 randomized WebSocket frames with open/close churn; invalid traffic produces bounded errors without lifecycle mutation. Existing fixed session, WebSocket hello timeout, delivery failure removal, reconnect/polling, and queue limits remain in effect. +- Measurements: `make -C test/host run` passed command queue, domain, bot strategy, lifecycle, presenter, HTTP API, synchronization, human-game integration, bot-game integration, and robustness suites. The robustness suite covers 400 malformed HTTP requests, oversized bodies, 600 randomized WebSocket frames, oversized WebSocket frames, and repeated connection-slot reuse. `node --check data/app.js`, gzip integrity checks, and compressed JavaScript syntax checks passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` and `pio run -e esp32-c6-devkitm-1` passed; firmware uses 39,588 / 327,680 B RAM (12.1%) and 1,018,786 / 2,097,152 B flash (48.6%), both within final budget limits. +- Issues or deviations: No firmware upload, Wi-Fi interruption, 30-minute soak, or live heap/latency measurement was performed. Those remain mandatory physical-device checks before release acceptance. +- Next action: Milestone 017 is ready. Do not start it unless explicitly requested. + --- ## Milestone 017 — Execute final MVP acceptance and create the release baseline -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 016 ### Objective diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 922b38f..e837d75 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,5 +1,7 @@ # API contract v1 +Machine-readable HTTP documentation: [openapi.yaml](openapi.yaml). + All JSON is UTF-8 and uses `Content-Type: application/json`. API responses set `Cache-Control: no-store`. A request exceeding its route limit is rejected before parsing with `PAYLOAD_TOO_LARGE`; malformed JSON is `MALFORMED_JSON`. @@ -7,15 +9,15 @@ All numeric fields are decimal JSON integers, never strings. ## Common values -| Type | Values / bound | -| ------------------- | --------------------------------------------------------------------------- | -| `role` | `player1`, `player2`, `spectator` | -| `mode` | `human`, `bot` | -| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` | -| `token` | exactly 32 lowercase hexadecimal characters | -| `gameId`, `version` | unsigned 32-bit integer | -| `x`, `y` | integer 0–9 | -| `name` | 1–20 scalar values, at most 80 UTF-8 bytes | +| Type | Values / bound | +| ------------------- | ---------------------------------------------------------------------------------------------- | +| `role` | `player1`, `player2`, `spectator` | +| `mode` | `human`, `bot` | +| `phase` | `lobby`, `preparing`, `in_progress`, `finished`, `rematch_wait` | +| `token` | exactly 32 lowercase hexadecimal characters | +| `gameId`, `version` | unsigned 32-bit integer | +| `x`, `y` | integer 0–9 | +| `name` | 1–20 scalar values, at most 80 UTF-8 bytes | | `board` | exactly 100 ASCII cells: `0` unknown/water, `1` revealed ship, `2` miss, `3` hit, `4` sunk hit | Every state-changing request has `token` and `gameId`; they must precede any @@ -46,7 +48,7 @@ Failure (maximum 160 encoded bytes): | Route | Maximum request | Response / maximum | | -------------------------- | --------------: | --------------------------------------------- | | `GET /api/info` | 128 B target | public device/slot state, 192 B | -| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B | +| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason | | `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B | | `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B | | `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope | diff --git a/docs/RESOURCE_BUDGET.md b/docs/RESOURCE_BUDGET.md index d79c290..c28c165 100644 --- a/docs/RESOURCE_BUDGET.md +++ b/docs/RESOURCE_BUDGET.md @@ -30,13 +30,13 @@ bounded snapshot at a time into the 512-byte transport buffer. ## Per-milestone budget gates -| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check | -|---|---:|---:|---:|---| -| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests | -| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests | -| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input | -| 009 transport/UI | 1,420,000 B | 250,000 B | 115,000 B | two players/eight spectators | -| MVP completion | 1,500,000 B | 250,000 B | 96,000 B | repeated on-board game test | +| Milestone | Firmware ceiling | LittleFS ceiling | Heap floor | Required check | +| ---------------- | ---------------: | ---------------: | ---------: | ---------------------------------- | +| 006 architecture | 1,080,000 B | 250,000 B | 220,000 B | clean build and host tests | +| 007 game core | 1,180,000 B | 250,000 B | 190,000 B | fleet and rules tests | +| 008 sessions/API | 1,300,000 B | 250,000 B | 150,000 B | role filtering and malformed input | +| 009 transport/UI | 1,420,000 B | 250,000 B | 115,000 B | two players/eight spectators | +| MVP completion | 1,500,000 B | 250,000 B | 96,000 B | repeated on-board game test | Each gate is a maximum permitted consumption or minimum required remaining heap. A missed gate blocks the next milestone pending a documented decision. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..b622c67 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,368 @@ +openapi: 3.0.3 +info: + title: Battleship ESP32 API + version: 1.0.0 + description: | + Local HTTP API for the ESP32-C6 Battleship MVP. All responses are JSON, + UTF-8, and carry `Cache-Control: no-store`. The ESP32 is authoritative. + WebSocket synchronization at `/ws` is documented in API_CONTRACT.md; + it is not an HTTP request/response operation and is therefore outside + this OpenAPI document. +servers: + - url: http://{device-address} + variables: + device-address: + default: 192.168.1.50 + description: DHCP address of the ESP32 on the local network. +paths: + /api/info: + get: + summary: Read public device and slot availability + responses: + '200': + description: Public game metadata. + content: + application/json: + schema: + $ref: '#/components/schemas/Info' + /api/health: + get: + summary: Read device diagnostics without secrets + responses: + '200': + description: Current resource and network diagnostics. + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /api/session/join: + post: + summary: Create a session and request a role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JoinRequest' + responses: + '200': + description: Session created. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionCreated' + '400': { $ref: '#/components/responses/BadRequest' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + /api/session/resume: + post: + summary: Resume a role using its opaque token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: Session resumed. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResumed' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + /api/game/config: + post: + summary: Set the game mode (Player 1, lobby only) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfigRequest' + responses: &commandResponses + '200': { $ref: '#/components/responses/CommandAccepted' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + '503': { $ref: '#/components/responses/ServerBusy' } + /api/game/start: + post: + summary: Start a configured game (Player 1) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GameRequest' + responses: *commandResponses + /api/game/shot: + post: + summary: Fire at an opponent cell + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShotRequest' + responses: *commandResponses + /api/game/rematch: + post: + summary: Confirm a rematch after a finished game + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GameRequest' + responses: *commandResponses + /api/game/abort: + post: + summary: Abort a human game with a disconnected opponent + description: Available only to Player 1 in the locked human-versus-human case. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GameRequest' + responses: *commandResponses + /api/state: + get: + summary: Get a complete role-safe state snapshot + parameters: + - $ref: '#/components/parameters/Version' + - $ref: '#/components/parameters/SessionToken' + responses: + '200': + description: State view for the token role; without a token, a spectator-safe view. + content: + application/json: + schema: + $ref: '#/components/schemas/State' + '401': { $ref: '#/components/responses/Unauthorized' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + /api/statistics: + get: + summary: Get match and reboot-scoped cumulative statistics + parameters: + - $ref: '#/components/parameters/SessionToken' + responses: + '200': + description: Statistics only; no board cells are returned. + content: + application/json: + schema: + $ref: '#/components/schemas/Statistics' + '401': { $ref: '#/components/responses/Unauthorized' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } +components: + parameters: + Version: + name: version + in: query + required: false + schema: { type: integer, minimum: 0, maximum: 4294967295 } + description: Last applied state version; a complete snapshot is always safe to consume. + SessionToken: + name: X-Session-Token + in: header + required: false + schema: { $ref: '#/components/schemas/Token' } + description: Omit only when a spectator-safe public view is intended. + responses: + CommandAccepted: + description: Command accepted by the authoritative application layer. + content: + application/json: + schema: { $ref: '#/components/schemas/CommandAccepted' } + BadRequest: + description: Malformed JSON or invalid input. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Unauthorized: + description: Invalid or expired token. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Forbidden: + description: The role may not perform this command. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Conflict: + description: Stale game, wrong phase, unavailable slot, or invalid turn/cell state. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + PayloadTooLarge: + description: Request body or target exceeded its route bound. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + ServerBusy: + description: Bounded command queue or serializer is unavailable. + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + schemas: + Token: + type: string + pattern: '^[0-9a-f]{32}$' + description: Opaque, reboot-scoped session token. + Role: + type: string + enum: [player1, player2, spectator] + Mode: + type: string + enum: [human, bot] + Phase: + type: string + enum: [lobby, preparing, in_progress, finished, rematch_wait] + GameId: + type: integer + minimum: 0 + maximum: 4294967295 + JoinRequest: + type: object + required: [name, requestedRole] + additionalProperties: false + properties: + name: { type: string, minLength: 1, maxLength: 80, description: 1–20 Unicode scalar values after server validation. } + requestedRole: { $ref: '#/components/schemas/Role' } + TokenRequest: + type: object + required: [token] + additionalProperties: false + properties: { token: { $ref: '#/components/schemas/Token' } } + GameRequest: + type: object + required: [token, gameId] + properties: + token: { $ref: '#/components/schemas/Token' } + gameId: { $ref: '#/components/schemas/GameId' } + ConfigRequest: + allOf: + - $ref: '#/components/schemas/GameRequest' + - type: object + required: [mode] + properties: { mode: { $ref: '#/components/schemas/Mode' } } + ShotRequest: + allOf: + - $ref: '#/components/schemas/GameRequest' + - type: object + required: [x, y] + properties: + x: { type: integer, minimum: 0, maximum: 9 } + y: { type: integer, minimum: 0, maximum: 9 } + CommandAccepted: + type: object + required: [ok, version, gameId] + properties: + ok: { type: boolean, enum: [true] } + version: { $ref: '#/components/schemas/GameId' } + gameId: { $ref: '#/components/schemas/GameId' } + SessionCreated: + allOf: + - $ref: '#/components/schemas/CommandAccepted' + - type: object + required: [token, role] + properties: + token: { $ref: '#/components/schemas/Token' } + role: { $ref: '#/components/schemas/Role' } + SessionResumed: + allOf: + - $ref: '#/components/schemas/CommandAccepted' + - type: object + required: [role] + properties: { role: { $ref: '#/components/schemas/Role' } } + Info: + type: object + required: [ok, phase, gameId, version, player1Available, player2Available, spectatorsAvailable] + properties: + ok: { type: boolean, enum: [true] } + phase: { $ref: '#/components/schemas/Phase' } + gameId: { $ref: '#/components/schemas/GameId' } + version: { $ref: '#/components/schemas/GameId' } + player1Available: { type: boolean } + player2Available: { type: boolean } + spectatorsAvailable: { type: integer, minimum: 0, maximum: 8 } + Health: + type: object + required: [ok, uptimeMs, wifiState, freeHeapBytes, minimumFreeHeapBytes, largestFreeBlockBytes, connectedClients, rejectedInput, resetReason] + properties: + ok: { type: boolean, enum: [true] } + uptimeMs: { $ref: '#/components/schemas/GameId' } + wifiState: { type: string, enum: [not_configured, connecting, connected] } + freeHeapBytes: { $ref: '#/components/schemas/GameId' } + minimumFreeHeapBytes: { $ref: '#/components/schemas/GameId' } + largestFreeBlockBytes: { $ref: '#/components/schemas/GameId' } + connectedClients: { type: integer, minimum: 0, maximum: 12 } + rejectedInput: { type: integer, minimum: 0, maximum: 65535 } + resetReason: { type: integer } + Board: + type: string + pattern: '^[01234]{100}$' + description: 0 unknown/water, 1 revealed ship, 2 miss, 3 hit, 4 sunk hit. + MatchStatistics: + type: array + minItems: 4 + maxItems: 4 + items: { type: integer, minimum: 0 } + description: '[shots, hits, misses, shipsSunk]' + CumulativeStatistics: + type: array + minItems: 7 + maxItems: 7 + items: { type: integer, minimum: 0 } + description: '[games, wins, losses, shipsSunk, shots, hits, misses]' + State: + type: object + required: [type, version, gameId, phase, mode, viewer, turn, boards, wins, winner, statistics] + properties: + type: { type: string, enum: [state] } + version: { $ref: '#/components/schemas/GameId' } + gameId: { $ref: '#/components/schemas/GameId' } + phase: { $ref: '#/components/schemas/Phase' } + mode: { $ref: '#/components/schemas/Mode' } + viewer: { $ref: '#/components/schemas/Role' } + turn: { type: string, enum: [player1, player2] } + boards: + type: array + minItems: 2 + maxItems: 2 + items: { $ref: '#/components/schemas/Board' } + wins: + type: array + minItems: 2 + maxItems: 2 + items: { type: integer, minimum: 0 } + winner: + nullable: true + type: integer + enum: [0, 1] + statistics: + type: array + minItems: 2 + maxItems: 2 + items: { $ref: '#/components/schemas/MatchStatistics' } + Statistics: + type: object + required: [ok, viewer, gameId, match, cumulative] + properties: + ok: { type: boolean, enum: [true] } + viewer: { $ref: '#/components/schemas/Role' } + gameId: { $ref: '#/components/schemas/GameId' } + match: + type: array + minItems: 2 + maxItems: 2 + items: { $ref: '#/components/schemas/MatchStatistics' } + cumulative: + type: array + minItems: 2 + maxItems: 2 + items: { $ref: '#/components/schemas/CumulativeStatistics' } + Error: + type: object + required: [ok, code, message, version] + properties: + ok: { type: boolean, enum: [false] } + code: + type: string + enum: [MALFORMED_JSON, PAYLOAD_TOO_LARGE, INVALID_NAME, INVALID_ROLE, INVALID_MODE, INVALID_COORDINATE, UNAUTHORIZED, NO_PLAYER_SLOT, NO_SPECTATOR_SLOT, FORBIDDEN_ROLE, WRONG_PHASE, NOT_YOUR_TURN, CELL_ALREADY_SHOT, STALE_GAME, SERVER_BUSY] + message: { type: string, maxLength: 80, description: Russian user-facing text. } + version: { $ref: '#/components/schemas/GameId' } diff --git a/include/http_api.h b/include/http_api.h index ef04ec3..9837bbd 100644 --- a/include/http_api.h +++ b/include/http_api.h @@ -29,6 +29,7 @@ typedef struct { uint32_t largest_free_block_bytes; uint8_t connected_clients; uint16_t rejected_input; + int8_t reset_reason; const char *wifi_state; } http_api_health_t; diff --git a/src/http_api.c b/src/http_api.c index 494b897..478bea5 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -277,10 +277,10 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) { response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32 ",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32 - ",\"connectedClients\":%u,\"rejectedInput\":%u}", api->health.uptime_ms, + ",\"connectedClients\":%u,\"rejectedInput\":%u,\"resetReason\":%d}", api->health.uptime_ms, api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes, api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes, - api->health.connected_clients, api->health.rejected_input); + api->health.connected_clients, api->health.rejected_input, api->health.reset_reason); return response->body_length <= 320U; } if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) { diff --git a/src/main.c b/src/main.c index 64ebbe2..14233b8 100644 --- a/src/main.c +++ b/src/main.c @@ -36,6 +36,7 @@ static const uint8_t kHttpMaxOpenSockets = 12U; typedef struct { bool configured; bool connected; bool retry_scheduled; uint8_t reconnect_attempt; } wifi_state_t; static portMUX_TYPE s_wifi_lock = portMUX_INITIALIZER_UNLOCKED; +static portMUX_TYPE s_diagnostics_lock = portMUX_INITIALIZER_UNLOCKED; static wifi_state_t s_wifi_state = {0}; static bool s_littlefs_mounted; static httpd_handle_t s_server; @@ -51,6 +52,20 @@ static esp_timer_handle_t s_reconnect_timer; static uint32_t platform_random(void *unused) { (void)unused; return esp_random(); } +static void record_rejected_input(void) { + portENTER_CRITICAL(&s_diagnostics_lock); + if (s_rejected_input < UINT16_MAX) ++s_rejected_input; + portEXIT_CRITICAL(&s_diagnostics_lock); +} + +static uint16_t rejected_input_snapshot(void) { + uint16_t rejected = 0; + portENTER_CRITICAL(&s_diagnostics_lock); + rejected = s_rejected_input; + portEXIT_CRITICAL(&s_diagnostics_lock); + return rejected; +} + static wifi_state_t wifi_state_snapshot(void) { wifi_state_t snapshot; portENTER_CRITICAL(&s_wifi_lock); @@ -75,7 +90,8 @@ static void refresh_health(void) { .minimum_free_heap_bytes = esp_get_minimum_free_heap_size(), .largest_free_block_bytes = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT), .connected_clients = (uint8_t)clients, - .rejected_input = s_rejected_input, + .rejected_input = rejected_input_snapshot(), + .reset_reason = (int8_t)esp_reset_reason(), .wifi_state = wifi_state_name(&wifi_state), }); } @@ -108,7 +124,7 @@ static void sync_broadcast_work(void *unused) { } static void queue_state_broadcast(void) { - if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) ++s_rejected_input; + if (s_server != NULL && httpd_queue_work(s_server, sync_broadcast_work, NULL) != ESP_OK) record_rejected_input(); } static bool schedule_bot_turn(void *unused, uint32_t delay_ms) { @@ -124,7 +140,7 @@ static void bot_turn_work(void *unused) { static void bot_timer_callback(void *unused) { (void)unused; - if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) ++s_rejected_input; + if (s_server != NULL && httpd_queue_work(s_server, bot_turn_work, NULL) != ESP_OK) record_rejected_input(); } static void sync_expire_work(void *unused) { @@ -167,18 +183,18 @@ static esp_err_t api_handler(httpd_req_t *request) { const size_t maximum = route_body_limit(route); size_t body_length = 0U; if (request->method == HTTP_POST) { - if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; ++s_rejected_input; } + if ((size_t)request->content_len > maximum) { body_length = maximum + 1U; record_rejected_input(); } else { while (body_length < (size_t)request->content_len) { const int received = httpd_req_recv(request, body + body_length, request->content_len - body_length); - if (received <= 0) { ++s_rejected_input; body_length = maximum + 1U; break; } + if (received <= 0) { record_rejected_input(); body_length = maximum + 1U; break; } body_length += (size_t)received; } body[body_length <= kRequestBodyCapacity ? body_length : 0U] = '\0'; } } const bool target_too_large = request->method == HTTP_GET && httpd_req_get_url_query_len(request) > 128U; - if (target_too_large) ++s_rejected_input; + if (target_too_large) record_rejected_input(); if (route == HTTP_API_ROUTE_STATE || route == HTTP_API_ROUTE_STATISTICS) { const size_t token_length = httpd_req_get_hdr_value_len(request, "X-Session-Token"); if (token_length > 0U && token_length < sizeof(token) && httpd_req_get_hdr_value_str(request, "X-Session-Token", token, sizeof(token)) != ESP_OK) token[0] = '\0'; @@ -205,7 +221,7 @@ static esp_err_t websocket_handler(httpd_req_t *request) { httpd_ws_frame_t frame = {0}; if (httpd_ws_recv_frame(request, &frame, 0U) != ESP_OK || frame.type != HTTPD_WS_TYPE_TEXT || frame.len > kWebSocketFrameCapacity) { - ++s_rejected_input; + record_rejected_input(); sync_service_close(&s_sync, client_id); httpd_sess_trigger_close(s_server, client_id); return ESP_OK; diff --git a/test/host/Makefile b/test/host/Makefile index 9c50478..abefb5f 100644 --- a/test/host/Makefile +++ b/test/host/Makefile @@ -1,7 +1,7 @@ CC ?= cc CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include -all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration +all: test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness test_command_queue: test_command_queue.c ../../src/command_queue.c $(CC) $(CFLAGS) $^ -o $@ @@ -16,6 +16,7 @@ run: all ./test_sync_service ./test_human_game_integration ./test_bot_game_integration + ./test_robustness test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ @@ -41,5 +42,9 @@ test_human_game_integration: test_human_game_integration.c ../../src/http_api.c test_bot_game_integration: test_bot_game_integration.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c $(CC) $(CFLAGS) $^ -o $@ +test_robustness: test_robustness.c ../../src/http_api.c ../../src/sync_service.c ../../src/application.c ../../src/command_queue.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c ../../src/state_presenter.c + + $(CC) $(CFLAGS) $^ -o $@ + clean: - rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration + rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle test_state_presenter test_http_api test_sync_service test_human_game_integration test_bot_game_integration test_robustness diff --git a/test/host/test_http_api.c b/test/host/test_http_api.c index 90c69e6..0a8633a 100644 --- a/test/host/test_http_api.c +++ b/test/host/test_http_api.c @@ -18,7 +18,7 @@ static http_api_t new_api(application_t *application, test_random_t *random) { http_api_init(&api, application); http_api_set_health(&api, &(http_api_health_t){.uptime_ms = 12U, .free_heap_bytes = 200000U, .minimum_free_heap_bytes = 190000U, .largest_free_block_bytes = 180000U, - .connected_clients = 2U, .rejected_input = 3U, .wifi_state = "connected"}); + .connected_clients = 2U, .rejected_input = 3U, .reset_reason = 11, .wifi_state = "connected"}); return api; } @@ -55,7 +55,8 @@ static void test_public_routes_and_parse_limits(void) { http_api_response_t response = call(&api, HTTP_API_ROUTE_INFO, HTTP_API_GET, NULL, NULL); assert(response.status == 200U && response.body_length <= 192U && strstr(response.body, "player1Available") != NULL); response = call(&api, HTTP_API_ROUTE_HEALTH, HTTP_API_GET, NULL, NULL); - assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL); + assert(response.status == 200U && response.body_length <= 320U && strstr(response.body, "connected") != NULL && + strstr(response.body, "\"resetReason\":11") != NULL); const http_api_request_t long_target = {.method = HTTP_API_GET, .route = HTTP_API_ROUTE_INFO, .target_too_large = true}; assert(http_api_handle(&api, &long_target, &response)); expect_code(&response, 413U, "PAYLOAD_TOO_LARGE"); diff --git a/test/host/test_robustness.c b/test/host/test_robustness.c new file mode 100644 index 0000000..bb24d08 --- /dev/null +++ b/test/host/test_robustness.c @@ -0,0 +1,89 @@ +#include +#include +#include + +#include "http_api.h" +#include "sync_service.h" + +typedef struct { uint32_t value; } test_random_t; + +static uint32_t next_random(void *context) { + test_random_t *random = context; + random->value = random->value * 1664525U + 1013904223U; + return random->value; +} + +static application_t new_application(test_random_t *random) { + application_t application; + application_init(&application, (random_source_t){.next_u32 = next_random, .context = random}); + return application; +} + +static void test_http_malformed_inputs_do_not_mutate(void) { + test_random_t random = {.value = 7U}; + application_t application = new_application(&random); + http_api_t api; + http_api_init(&api, &application); + const char *const corpus[] = {"", "{", "[]", "{\"token\":", "{\"token\":null}", + "{\"name\":\"A\"}", "{\"token\":\"00000000000000000000000000000000\",\"gameId\":-1}", + "{\"type\":\"shot\"}", "{\"name\":\"A\",\"requestedRole\":\"player1\",\"extra\":1}"}; + const http_api_route_t routes[] = {HTTP_API_ROUTE_JOIN, HTTP_API_ROUTE_RESUME, HTTP_API_ROUTE_CONFIG, + HTTP_API_ROUTE_START, HTTP_API_ROUTE_SHOT, HTTP_API_ROUTE_REMATCH, HTTP_API_ROUTE_ABORT}; + for (size_t index = 0; index < 400U; ++index) { + const char *body = corpus[index % (sizeof(corpus) / sizeof(corpus[0]))]; + const game_lifecycle_t before = application.lifecycle; + http_api_response_t response; + const http_api_request_t request = {.method = HTTP_API_POST, .route = routes[index % (sizeof(routes) / sizeof(routes[0]))], + .content_type_json = true, .body = body, .body_length = strlen(body)}; + assert(http_api_handle(&api, &request, &response)); + assert(response.status >= 400U && response.status < 600U && response.body_length < sizeof(response.body)); + assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0); + } + char oversized[kRequestBodyCapacity + 2U]; + memset(oversized, 'x', sizeof(oversized) - 1U); + oversized[sizeof(oversized) - 1U] = '\0'; + http_api_response_t response; + const http_api_request_t request = {.method = HTTP_API_POST, .route = HTTP_API_ROUTE_JOIN, .content_type_json = true, + .body = oversized, .body_length = strlen(oversized)}; + assert(http_api_handle(&api, &request, &response)); + assert(response.status == 413U); +} + +static void test_websocket_fuzz_and_connection_churn(void) { + test_random_t random = {.value = 19U}; + application_t application = new_application(&random); + sync_service_t service; + sync_service_init(&service, &application); + for (uint16_t attempt = 0; attempt < 600U; ++attempt) { + char frame[kWebSocketFrameCapacity + 1U]; + const size_t length = (size_t)(next_random(&random) % (kWebSocketFrameCapacity + 1U)); + for (size_t index = 0; index < length; ++index) frame[index] = (char)(next_random(&random) & 0x7fU); + frame[length] = '\0'; + const game_lifecycle_t before = application.lifecycle; + char output[kStateMessageCapacity] = {0}; + size_t output_length = 0U; + bool changed = true; + bool close = false; + assert(sync_service_open(&service, 1, attempt)); + assert(sync_service_receive(&service, 1, frame, length, attempt, output, &output_length, &changed, &close)); + assert(!changed && output_length < sizeof(output)); + assert(memcmp(&before, &application.lifecycle, sizeof(before)) == 0); + sync_service_close(&service, 1); + } + char too_large[kWebSocketFrameCapacity + 1U] = {0}; + char output[kStateMessageCapacity] = {0}; + size_t output_length = 0U; + bool changed = false; + bool close = false; + assert(sync_service_open(&service, 1, 0U)); + assert(sync_service_receive(&service, 1, too_large, sizeof(too_large), 0U, output, &output_length, &changed, &close)); + assert(!changed && strstr(output, "MALFORMED_JSON") != NULL); + sync_service_close(&service, 1); +} + +int main(void) { + test_http_malformed_inputs_do_not_mutate(); + test_websocket_fuzz_and_connection_churn(); + puts("robustness tests passed"); + return 0; +}