Add admin firmware upload support
Implement authenticated HTTPS OTA uploads with bounded streaming, image validation, reboot coordination, and lifecycle exclusion. Add the admin UI, regression tests, and Phase 10 acceptance documentation.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# Firmware upload backend host tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_firmware_update/run.py
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
```
|
||||
|
||||
The first compiles the unchanged production upload source, production auth
|
||||
admission helpers/parser, and production server upload reservation/typed reboot
|
||||
functions with `-Wall -Wextra -Werror` and trapping undefined-behavior checks.
|
||||
It uses installed ESP-IDF image-layout headers (`IDF_PATH`, otherwise the normal
|
||||
PlatformIO framework directory). It performs no firmware build or device action.
|
||||
HTTP IO, private header-adapter admission, database, OTA/flash and FreeRTOS
|
||||
scheduling are doubles. The existing cookie-auth suite separately exercises
|
||||
production private-adapter framing/duplicate-header handling and session storage.
|
||||
|
||||
The 61 cases cover:
|
||||
|
||||
- Cookie, Origin, CSRF, admin, query/method and readiness rejection before reads.
|
||||
- Raw content type, nonempty body, actual partition capacity and inactive OTA app
|
||||
selection; bytewise partial image prefix and bounded/exact streaming writes.
|
||||
- Wrong magic/chip/segment/descriptor/hash-header rejection before OTA erase.
|
||||
- Service/identity busy, allocation/task creation and OTA begin/write/end/metadata/
|
||||
boot-selection failures; handle consumption versus abort cleanup.
|
||||
- Parsed SDK image length mismatch, final session revocation/currentness failure.
|
||||
- EOF before/after OTA begin, stalled receive and total slow-drip deadlines.
|
||||
- Reservation rejection of competing upload/typed reboot, including during
|
||||
receive and boot selection; response failure at every response stage.
|
||||
- Successful commit followed by delayed reboot-owner action; failed response
|
||||
retains selection but does not reboot and releases reservations/resources.
|
||||
|
||||
These are deterministic failure-injection tests, not real SDK image-integrity,
|
||||
flash, concurrent scheduler, TLS, network, power-loss or hardware test evidence.
|
||||
Stop/start/rotation guard use is source-checked, not dynamically executed here.
|
||||
|
||||
## Parent integration contract
|
||||
|
||||
`POST /api/firmware`, raw `application/octet-stream`, known `Content-Length`,
|
||||
existing cookie + same-origin `Origin` + `X-CSRF-Token`, admin only. Use the
|
||||
existing browser CORS-mode/same-origin-credentials request pattern. No multipart,
|
||||
chunked transfer, `Expect`, query parameters, signature/version policy or filename
|
||||
validation. Standard ESP32-S3 application image with appended SDK SHA-256 digest
|
||||
is required. A filename alone is never trusted.
|
||||
|
||||
`200 {"ok":true,"rebooting":true}` means SDK validation and boot selection
|
||||
succeeded. After synchronous send success, the preallocated owner task waits
|
||||
500 ms and calls `esp_restart()` without stopping HTTPD or retaining a request
|
||||
or socket. It keeps service/identity reservations until reset. Send success is
|
||||
not peer receipt. Send failure after selection schedules **no reboot**, releases
|
||||
reservations, and leaves the accepted image selected for a later reset. A lost
|
||||
acknowledgement is therefore uncertain; never automatically retry.
|
||||
|
||||
All errors use `{"error":"code"}`:
|
||||
|
||||
| HTTP | Codes |
|
||||
| --- | --- |
|
||||
| 400 | `invalid_request`, `invalid_firmware`, `firmware_incomplete` |
|
||||
| 401 | `authentication_required` |
|
||||
| 403 | `origin`, `csrf`, `admin_required` |
|
||||
| 408 | `firmware_timeout` |
|
||||
| 413 | `firmware_too_large` |
|
||||
| 415 | `firmware_content_type` |
|
||||
| 500 | `firmware_write_failed`, `firmware_commit_failed` |
|
||||
| 503 | `unavailable`, `busy`, `firmware_unavailable`, `firmware_resources` |
|
||||
|
||||
Rejections with unread bodies close rather than asking HTTPD to discard the
|
||||
remaining upload. Responses are JSON, no-store, nosniff and no-referrer. Errors
|
||||
contain no SDK diagnostic text or secret material. A boot-selection API failure
|
||||
is not retried or followed by reboot; flash/power failures at metadata commit
|
||||
cannot offer transactional certainty beyond SDK guarantees.
|
||||
|
||||
The synchronous handler may block other HTTPD work for the upload duration.
|
||||
A 4 KiB heap buffer and a 2 KiB reboot-owner stack are prepared before flashing;
|
||||
no whole-image allocation. Receive checks enforce 10 seconds without progress
|
||||
and 120 seconds total, using the server's existing one-second receive timeout.
|
||||
These checks do not preempt SDK flash/validation calls or scheduler delays.
|
||||
Only the inactive app and SDK OTA metadata are written, not NVS/data partitions.
|
||||
|
||||
### Integration work outside backend ownership
|
||||
|
||||
- Server URI capacity is now **40**; `server_lifecycle.py` currently fails its
|
||||
hardcoded 36-initializer extraction check (now 37). Its old 39-handler capacity,
|
||||
required-registration positions 1–16 and optional positions 17–18 also need
|
||||
adjustment to 40, 1–17 and 18–19, respectively, plus a firmware-handler fake.
|
||||
That existing fixture is intentionally not edited here.
|
||||
- HTTPS stop/start/rotation and typed reboot share the upload reservation; direct
|
||||
identity mutation is separately reserved. **Direct `esp_restart()` callers in
|
||||
`system_console.c`, `ssh_transport.c`, `web_admin_transport.c`, and
|
||||
`local_status_ui.c` do not share this fence.** Those files were outside the
|
||||
backend's allowed ownership. The parent must coordinate those reboot paths
|
||||
for global software-reboot exclusion; do not claim that property yet.
|
||||
@@ -0,0 +1,96 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NOT_FOUND 2
|
||||
#define ESP_ERR_INVALID_STATE 3
|
||||
#define ESP_ERR_TIMEOUT 4
|
||||
#define ESP_ERR_INVALID_ARG 5
|
||||
#define ESP_ERR_NO_MEM 6
|
||||
#define ESP_ERR_INVALID_SIZE 7
|
||||
#define ESP_ERR_HTTPD_INVALID_REQ 8
|
||||
#define ESP_ERR_HTTPD_RESULT_TRUNC 9
|
||||
#define HTTPD_SOCK_ERR_TIMEOUT -3
|
||||
#define HTTP_POST 1
|
||||
#define HTTP_GET 0
|
||||
#define USER_ROLE_ADMIN 1
|
||||
#define USER_ROLE_USER 0
|
||||
#define pdPASS 1
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
#define taskENTER_CRITICAL(p) ((void)(p))
|
||||
#define taskEXIT_CRITICAL(p) ((void)(p))
|
||||
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct { const char *name, *value; } test_header_t;
|
||||
struct httpd_req_aux { char *scratch; size_t scratch_cur_size; unsigned req_hdrs_count; };
|
||||
typedef struct {
|
||||
const char *uri;
|
||||
int method;
|
||||
size_t content_len, received;
|
||||
httpd_handle_t handle;
|
||||
struct httpd_req_aux *aux;
|
||||
test_header_t headers[12];
|
||||
size_t header_count;
|
||||
bool headers_valid;
|
||||
} httpd_req_t;
|
||||
typedef struct { int role; uint32_t user_id; } user_principal_t;
|
||||
typedef uint64_t web_session_id_t;
|
||||
typedef struct { web_session_id_t id; user_principal_t principal; char csrf[65]; } web_session_view_t;
|
||||
typedef struct {
|
||||
uint32_t security_rejections, login_failures, throttled, capacity_rejections;
|
||||
} web_cookie_auth_snapshot_t;
|
||||
|
||||
typedef void *TaskHandle_t;
|
||||
typedef void *SemaphoreHandle_t;
|
||||
#define eSetValueWithOverwrite 1
|
||||
int xTaskCreate(void (*)(void *), const char *, unsigned, void *, unsigned, TaskHandle_t *);
|
||||
int xTaskNotify(TaskHandle_t, uint32_t, int);
|
||||
int xTaskNotifyWait(uint32_t, uint32_t, uint32_t *, uint32_t);
|
||||
void vTaskDelay(unsigned);
|
||||
void vTaskDelete(TaskHandle_t);
|
||||
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
|
||||
void xSemaphoreGive(SemaphoreHandle_t);
|
||||
void esp_restart(void);
|
||||
int64_t esp_timer_get_time(void);
|
||||
void secure_wipe(void *, size_t);
|
||||
|
||||
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
|
||||
int httpd_req_recv(httpd_req_t *, char *, size_t);
|
||||
bool web_httpd_headers_valid(httpd_req_t *);
|
||||
bool web_httpd_unread_body(httpd_req_t *);
|
||||
void web_httpd_wipe_request(httpd_req_t *, bool);
|
||||
esp_err_t web_session_store_lookup(const char *, size_t, const char *, size_t, web_session_view_t *);
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t, const user_principal_t *, bool *);
|
||||
esp_err_t web_cookie_auth_require_body(httpd_req_t *, size_t, web_session_view_t *, bool *);
|
||||
esp_err_t web_security_reserve_identity(uint32_t, bool, uint32_t *);
|
||||
void web_security_release_identity(uint32_t);
|
||||
|
||||
typedef uint32_t esp_ota_handle_t;
|
||||
typedef struct { uint32_t type, subtype, address, size, erase_size; } esp_partition_t;
|
||||
#define ESP_PARTITION_TYPE_APP 0
|
||||
#define ESP_PARTITION_SUBTYPE_APP_OTA_0 0x10
|
||||
#define ESP_PARTITION_SUBTYPE_APP_OTA_15 0x1f
|
||||
const esp_partition_t *esp_ota_get_running_partition(void);
|
||||
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *);
|
||||
esp_err_t esp_ota_begin(const esp_partition_t *, size_t, esp_ota_handle_t *);
|
||||
esp_err_t esp_ota_write(esp_ota_handle_t, const void *, size_t);
|
||||
esp_err_t esp_ota_end(esp_ota_handle_t);
|
||||
esp_err_t esp_ota_abort(esp_ota_handle_t);
|
||||
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *);
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host failure tests: real upload/auth/header admission and server reservation.
|
||||
|
||||
The backend uses OTA/HTTP IO/task/database doubles; a separate contract test
|
||||
executes pinned SDK begin/abort with injected flash/allocation failures. SDK
|
||||
headers/getters come only from verified IDF 5.5.0. No build or device operations.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--idf-path", type=Path, help="Explicit ESP-IDF 5.5.0 source directory")
|
||||
args = parser.parse_args()
|
||||
if args.idf_path:
|
||||
IDF = args.idf_path.expanduser().resolve()
|
||||
else:
|
||||
# Only this project's active build tree; never candidate builds or the
|
||||
# unversioned PlatformIO package, which may now contain another SDK.
|
||||
paths = set()
|
||||
for cache in (ROOT / ".pio/build").glob("*/CMakeCache.txt"):
|
||||
match = re.search(r"^esp-idf_SOURCE_DIR:[^=]+=(.+)$", cache.read_text(), re.M)
|
||||
if match:
|
||||
paths.add(Path(match.group(1)).resolve())
|
||||
if len(paths) != 1:
|
||||
parser.error("Cannot identify one active build SDK; pass --idf-path for ESP-IDF 5.5.0")
|
||||
IDF = paths.pop()
|
||||
try:
|
||||
version_header = (IDF / "components/esp_common/include/esp_idf_version.h").read_text()
|
||||
version = tuple(int(re.search(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$",
|
||||
version_header, re.M).group(1))
|
||||
for part in ("MAJOR", "MINOR", "PATCH"))
|
||||
except (OSError, AttributeError) as error:
|
||||
parser.error(f"Cannot verify SDK version at {IDF}: {error}")
|
||||
if version != (5, 5, 0):
|
||||
parser.error(f"ESP-IDF 5.5.0 required, found {'.'.join(map(str, version))} at {IDF}")
|
||||
print(f"Using ESP-IDF 5.5.0: {IDF}", flush=True)
|
||||
|
||||
|
||||
def function(source, name):
|
||||
match = re.search(r"^(?:static )?(?:bool|void|size_t|esp_err_t|ota_ops_entry_t\s*\*)\s*" + name + r"\([^;{}]*\)\n\{.*?^\}", source, re.M | re.S)
|
||||
if not match:
|
||||
raise RuntimeError("Production function shape changed: " + name)
|
||||
return match.group() + "\n"
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="web-firmware-update-") as directory:
|
||||
tmp = Path(directory)
|
||||
shutil.copy(HERE / "fakes.h", tmp / "fakes.h")
|
||||
shutil.copy(HERE / "test.c", tmp / "test.c")
|
||||
for name in ("esp_http_server.h", "esp_err.h", "esp_ota_ops.h", "esp_system.h",
|
||||
"esp_timer.h", "secure_random.h", "web_cookie_auth.h", "web_httpd_adapter.h",
|
||||
"web_security.h", "freertos/FreeRTOS.h", "freertos/task.h"):
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('#pragma once\n#include "fakes.h"\n')
|
||||
(tmp / "esp_assert.h").write_text('#define ESP_STATIC_ASSERT(c,m) _Static_assert(c,m)\n')
|
||||
(tmp / "esp_attr.h").write_text('#define FORCE_INLINE_ATTR static inline\n')
|
||||
(tmp / "esp_flash_partitions.h").write_text('#pragma once\n#include <stdint.h>\ntypedef struct { uint32_t offset, size; } esp_partition_pos_t;\n')
|
||||
for component, name in (("bootloader_support", "esp_app_format.h"),
|
||||
("bootloader_support", "esp_image_format.h"),
|
||||
("esp_app_format", "esp_app_desc.h")):
|
||||
shutil.copy(IDF / "components" / component / "include" / name, tmp / name)
|
||||
for name in ("web_firmware_update.c", "web_firmware_update.h", "web_auth_parse.c", "web_auth_parse.h"):
|
||||
shutil.copy(ROOT / "src" / name, tmp / name)
|
||||
auth = (ROOT / "src/web_cookie_auth.c").read_text()
|
||||
names = ("equal", "header", "origin", "cookie", "cookies_valid", "response", "failure",
|
||||
"require", "web_cookie_auth_require_body")
|
||||
constants = '\n'.join(line for line in auth.splitlines() if line.startswith(("#define SESSION_COOKIE", "#define PRELOGIN_COOKIE")))
|
||||
(tmp / "auth_production.h").write_text(constants + "\n" + '\n'.join(function(auth, n) for n in names))
|
||||
httpd = (IDF / "components/esp_http_server/src/httpd_parse.c").read_text()
|
||||
adapter = (ROOT / "src/web_httpd_adapter.c").read_text()
|
||||
(tmp / "httpd_production.h").write_text(
|
||||
'\n'.join(function(httpd, n) for n in ("httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str")) +
|
||||
'\n#define web_httpd_headers_valid adapter_headers_valid\n' +
|
||||
function(adapter, "web_httpd_headers_valid") + '\n#undef web_httpd_headers_valid\n')
|
||||
sdk_ota = (IDF / "components/app_update/esp_ota_ops.c").read_text()
|
||||
# Extract the actual SDK registry/type and begin/abort implementations, not
|
||||
# a reimplementation of their ordering. Flash and allocation are injected.
|
||||
registry = sdk_ota[sdk_ota.index('typedef struct ota_ops_entry_'):
|
||||
sdk_ota.index('const static char *TAG')]
|
||||
(tmp / "sdk_ota_production.h").write_text(registry + '\n' + '\n'.join(
|
||||
function(sdk_ota, n) for n in ("is_ota_partition", "esp_ota_init_entry", "esp_ota_begin",
|
||||
"get_ota_ops_entry", "esp_ota_abort")))
|
||||
shutil.copy(HERE / "sdk_contract.c", tmp / "sdk_contract.c")
|
||||
server = (ROOT / "src/web_server.c").read_text()
|
||||
(tmp / "server_production.h").write_text('\n'.join(function(server, n) for n in
|
||||
("web_firmware_update_reserve", "web_firmware_update_release", "web_server_reboot_current")))
|
||||
# Review guards used by the same reservation. Full lifecycle harness is owned
|
||||
# elsewhere; its hardcoded URI count must change from 39 to 40 upstream.
|
||||
assert "s_transitioning != reserved" in function(server, "stop_server")
|
||||
assert "s_transitioning != reserved" in function(server, "start_server")
|
||||
assert "if (s_transitioning ||" in function(server, "web_server_replace_identity")
|
||||
assert '.handler = web_firmware_update_handler' in server
|
||||
assert '&s_firmware_uri,' in server
|
||||
compiler = os.environ.get("CC", "cc")
|
||||
command = [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
"-fsanitize=undefined", "-fsanitize-undefined-trap-on-error", "-I", str(tmp),
|
||||
str(tmp / "test.c"), str(tmp / "web_auth_parse.c"), "-o", str(tmp / "test")]
|
||||
subprocess.run(command, check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=30)
|
||||
command = command[:command.index(str(tmp / "test.c"))] + [str(tmp / "sdk_contract.c"), "-o", str(tmp / "sdk_contract")]
|
||||
subprocess.run(command, check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "sdk_contract")], check=True, timeout=30)
|
||||
@@ -0,0 +1,69 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Execute pinned SDK begin/abort and its real linked registry with injected
|
||||
* allocation/erase failures. Not a flash or firmware-build test. */
|
||||
#include "fakes.h"
|
||||
#include <sys/queue.h>
|
||||
#define WORD_ALIGNED_ATTR
|
||||
#define ALIGN_UP(num, align) (((num) + ((align) - 1)) & ~((align) - 1))
|
||||
#define ESP_PARTITION_SUBTYPE_APP_OTA_MAX 0x20
|
||||
#define ESP_PARTITION_TYPE_BOOTLOADER 2
|
||||
#define ESP_PARTITION_TYPE_PARTITION_TABLE 3
|
||||
#define ESP_ERR_OTA_PARTITION_CONFLICT 10
|
||||
#define OTA_WITH_SEQUENTIAL_WRITES 0xfffffffeU
|
||||
#define OTA_SIZE_UNKNOWN 0xffffffffU
|
||||
|
||||
static const esp_partition_t running = {.type = ESP_PARTITION_TYPE_APP,
|
||||
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0, .erase_size = 4096, .size = 0x400000};
|
||||
static const esp_partition_t target = {.type = ESP_PARTITION_TYPE_APP,
|
||||
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0 + 1, .erase_size = 4096, .size = 0x400000};
|
||||
static bool fail_allocation, fail_erase;
|
||||
static unsigned live_allocations, erase_calls;
|
||||
static const esp_partition_t *esp_partition_verify(const esp_partition_t *p) { return p; }
|
||||
const esp_partition_t *esp_ota_get_running_partition(void) { return &running; }
|
||||
static void *sdk_calloc(size_t n, size_t size)
|
||||
{
|
||||
if (fail_allocation) return NULL;
|
||||
void *p = calloc(n, size); assert(p); ++live_allocations; return p;
|
||||
}
|
||||
static void sdk_free(void *p) { assert(p && live_allocations); --live_allocations; free(p); }
|
||||
static esp_err_t esp_partition_erase_range(const esp_partition_t *p, size_t offset, size_t size)
|
||||
{
|
||||
/* The actual registry allocation is still live when erase fails. */
|
||||
assert(p == &target && !offset && size == 12288 && live_allocations == 1);
|
||||
++erase_calls; return fail_erase ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static void esp_image_bootloader_offset_set(uint32_t offset) { (void)offset; assert(0); }
|
||||
static void *esp_flash_default_chip;
|
||||
static void esp_flash_set_dangerous_write_protection(void *chip, bool enabled)
|
||||
{
|
||||
(void)chip; (void)enabled; assert(0);
|
||||
}
|
||||
#define calloc sdk_calloc
|
||||
#define free sdk_free
|
||||
#include "sdk_ota_production.h"
|
||||
#undef calloc
|
||||
#undef free
|
||||
|
||||
int main(void)
|
||||
{
|
||||
esp_ota_handle_t handle = 0;
|
||||
assert(esp_ota_begin(NULL, 12000, &handle) == ESP_ERR_INVALID_ARG && !handle);
|
||||
assert(esp_ota_begin(&running, 12000, &handle) == ESP_ERR_OTA_PARTITION_CONFLICT && !handle);
|
||||
fail_allocation = true;
|
||||
assert(esp_ota_begin(&target, 12000, &handle) == ESP_ERR_NO_MEM && !handle);
|
||||
assert(!live_allocations && !erase_calls && LIST_EMPTY(&s_ota_ops_entries_head));
|
||||
fail_allocation = false; fail_erase = true;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
handle = 0;
|
||||
assert(esp_ota_begin(&target, 12000, &handle) == ESP_FAIL && handle);
|
||||
assert(get_ota_ops_entry(handle) && live_allocations == 1);
|
||||
assert(esp_ota_abort(handle) == ESP_OK);
|
||||
assert(!live_allocations && LIST_EMPTY(&s_ota_ops_entries_head));
|
||||
assert(esp_ota_abort(handle) == ESP_ERR_NOT_FOUND);
|
||||
}
|
||||
fail_erase = false; handle = 0;
|
||||
assert(esp_ota_begin(&target, 12000, &handle) == ESP_OK && handle && live_allocations == 1);
|
||||
assert(esp_ota_abort(handle) == ESP_OK && !live_allocations);
|
||||
puts("PASS actual ESP-IDF 5.5.0 begin/init/registry/abort: early failure has no handle; erase failure publishes live handle; abort frees it exactly once");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "fakes.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "esp_app_format.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_image_format.h"
|
||||
#include "web_firmware_update.h"
|
||||
|
||||
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
|
||||
static size_t strlcpy(char *out, const char *in, size_t size)
|
||||
{
|
||||
size_t length = strlen(in);
|
||||
if (size) { size_t n = length < size - 1 ? length : size - 1; memcpy(out, in, n); out[n] = 0; }
|
||||
return length;
|
||||
}
|
||||
/* IDF getters compare ptrdiff_t with size_t; retain their exact source. */
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wsign-compare"
|
||||
#include "httpd_production.h"
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
static int s_lock;
|
||||
static bool s_ready;
|
||||
static web_cookie_auth_snapshot_t s_counts;
|
||||
#include "auth_production.h"
|
||||
|
||||
static SemaphoreHandle_t s_server_mutex;
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_transitioning;
|
||||
static esp_err_t s_last_error;
|
||||
static uint32_t s_generation;
|
||||
#include "server_production.h"
|
||||
|
||||
static void *test_malloc(size_t);
|
||||
static void test_free(void *);
|
||||
#define malloc test_malloc
|
||||
#define free test_free
|
||||
#include "web_firmware_update.c"
|
||||
#undef malloc
|
||||
#undef free
|
||||
|
||||
static uint8_t image[12000], flash[12000];
|
||||
static httpd_req_t request;
|
||||
static esp_partition_t running, target;
|
||||
static bool missing_target, missing_running;
|
||||
static bool lookup_current, final_current, lookup_unavailable, final_unavailable;
|
||||
static int role, stage_fail, response_fail, response_step;
|
||||
static bool malloc_fail, task_fail, identity_busy, locked, mutex_busy;
|
||||
static unsigned allocations, allocations_live, tasks_created, tasks_deleted;
|
||||
static unsigned begins, writes, ends, aborts, metadata_reads, commits, receives, wipes;
|
||||
static unsigned restarts, response_sends, releases, identity_releases;
|
||||
static size_t flash_size, max_chunk, cutoff, stall_at, parsed_size;
|
||||
static int64_t now_us, read_us;
|
||||
static char response_status[64], response_body[160];
|
||||
static uint32_t notification, identity_token;
|
||||
static void (*task_entry)(void *);
|
||||
static int task_storage, server_storage, mutex_storage;
|
||||
static bool receive_race, commit_race, revoke_at_metadata;
|
||||
static unsigned cases;
|
||||
static bool ota_live;
|
||||
static char raw_length[32], header_scratch[2048];
|
||||
static struct httpd_req_aux request_aux;
|
||||
|
||||
static void *test_malloc(size_t size)
|
||||
{
|
||||
assert(size == BUFFER_SIZE); ++allocations;
|
||||
if (malloc_fail) return NULL;
|
||||
++allocations_live; return malloc(size);
|
||||
}
|
||||
static void test_free(void *p) { if (p) { assert(allocations_live == 1); --allocations_live; } free(p); }
|
||||
void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
int64_t esp_timer_get_time(void) { return now_us; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t m, unsigned ticks)
|
||||
{
|
||||
assert(m == &mutex_storage && !locked);
|
||||
if (mutex_busy) { assert(ticks == 0); return 0; }
|
||||
locked = true; return pdTRUE;
|
||||
}
|
||||
void xSemaphoreGive(SemaphoreHandle_t m) { assert(m == &mutex_storage && locked); locked = false; }
|
||||
|
||||
int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, void *arg,
|
||||
unsigned priority, TaskHandle_t *out)
|
||||
{
|
||||
assert(!strcmp(name, "fw_reboot") && stack == 2048 && !arg && priority == 5);
|
||||
assert(s_transitioning && identity_token && !commits && !task_entry);
|
||||
if (task_fail) return 0;
|
||||
task_entry = entry; *out = &task_storage; ++tasks_created; return pdPASS;
|
||||
}
|
||||
int xTaskNotify(TaskHandle_t t, uint32_t value, int action)
|
||||
{
|
||||
assert(t == &task_storage && task_entry && !notification && action == eSetValueWithOverwrite);
|
||||
assert(value == 1 || value == 2);
|
||||
if (value == 1) assert(commits == 1 && response_sends == 1 && !response_fail && !aborts);
|
||||
notification = value; return pdPASS;
|
||||
}
|
||||
int xTaskNotifyWait(uint32_t clear_in, uint32_t clear_out, uint32_t *value, uint32_t wait)
|
||||
{
|
||||
assert(!clear_in && clear_out == UINT32_MAX && wait == portMAX_DELAY && notification);
|
||||
*value = notification; return pdTRUE;
|
||||
}
|
||||
void vTaskDelay(unsigned ticks) { assert(ticks == 500 && notification == 1); }
|
||||
void vTaskDelete(TaskHandle_t task) { assert(!task); ++tasks_deleted; }
|
||||
void esp_restart(void) { assert(!locked); ++restarts; }
|
||||
static void finish_owner(void)
|
||||
{
|
||||
if (task_entry) {
|
||||
assert(notification); task_entry(NULL); task_entry = NULL;
|
||||
assert(tasks_deleted == tasks_created);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void set_header(const char *name, const char *value)
|
||||
{
|
||||
for (size_t i = 0; i < request.header_count; ++i) {
|
||||
if (!strcmp(name, request.headers[i].name)) { request.headers[i].value = value; return; }
|
||||
}
|
||||
assert(request.header_count < 12);
|
||||
request.headers[request.header_count++] = (test_header_t){name, value};
|
||||
}
|
||||
static void set_length(size_t length)
|
||||
{
|
||||
request.content_len = length;
|
||||
snprintf(raw_length, sizeof(raw_length), "%zu", length);
|
||||
set_header("Content-Length", raw_length);
|
||||
}
|
||||
bool web_httpd_headers_valid(httpd_req_t *r)
|
||||
{
|
||||
request_aux = (struct httpd_req_aux){.scratch = header_scratch};
|
||||
for (size_t i = 0; i < r->header_count; ++i) {
|
||||
if (!r->headers[i].value) continue;
|
||||
size_t used = request_aux.scratch_cur_size;
|
||||
int written = snprintf(header_scratch + used, sizeof(header_scratch) - used,
|
||||
"%s:%s", r->headers[i].name, r->headers[i].value);
|
||||
assert(written >= 0 && (size_t)written + 1 <= sizeof(header_scratch) - used);
|
||||
request_aux.scratch_cur_size += (size_t)written + 1;
|
||||
++request_aux.req_hdrs_count;
|
||||
}
|
||||
r->aux = &request_aux;
|
||||
return r->headers_valid && adapter_headers_valid(r);
|
||||
}
|
||||
bool web_httpd_unread_body(httpd_req_t *r) { return r->received < r->content_len; }
|
||||
void web_httpd_wipe_request(httpd_req_t *r, bool unread)
|
||||
{
|
||||
assert(unread == (r->received < r->content_len)); ++wipes;
|
||||
}
|
||||
static esp_err_t response_result(void) { return ++response_step == response_fail ? ESP_FAIL : ESP_OK; }
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s)
|
||||
{
|
||||
assert(r == &request && strlen(s) < sizeof(response_status)); strcpy(response_status, s);
|
||||
return response_result();
|
||||
}
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s)
|
||||
{
|
||||
assert(r == &request && !strcmp(s, "application/json; charset=utf-8")); return response_result();
|
||||
}
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value)
|
||||
{
|
||||
assert(r == &request && name && value); return response_result();
|
||||
}
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body)
|
||||
{
|
||||
assert(r == &request && strlen(body) < sizeof(response_body));
|
||||
++response_sends; strcpy(response_body, body); return response_result();
|
||||
}
|
||||
static void competing_lifecycle(void)
|
||||
{
|
||||
assert(s_transitioning && !locked);
|
||||
assert(web_firmware_update_reserve(request.handle) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(!restarts);
|
||||
uint32_t token = 0;
|
||||
assert(web_security_reserve_identity(0, false, &token) == ESP_ERR_INVALID_STATE && !token);
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t want)
|
||||
{
|
||||
assert(r == &request && want && want <= BUFFER_SIZE && s_transitioning && identity_token);
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||
++receives; now_us += read_us;
|
||||
if (receive_race) competing_lifecycle();
|
||||
if (r->received >= stall_at) return HTTPD_SOCK_ERR_TIMEOUT;
|
||||
if (r->received >= cutoff) return 0;
|
||||
size_t count = want < max_chunk ? want : max_chunk;
|
||||
if (count > cutoff - r->received) count = cutoff - r->received;
|
||||
assert(r->received + count <= sizeof(image));
|
||||
memcpy(out, image + r->received, count); r->received += count; return (int)count;
|
||||
}
|
||||
esp_err_t web_session_store_lookup(const char *token, size_t length, const char *origin,
|
||||
size_t origin_length, web_session_view_t *view)
|
||||
{
|
||||
assert(length == 64 && token[0] == 'a' && origin_length == strlen(origin));
|
||||
assert(!strcmp(origin, "https://device"));
|
||||
if (lookup_unavailable) return ESP_FAIL;
|
||||
if (!lookup_current) return ESP_ERR_NOT_FOUND;
|
||||
view->id = 42; view->principal.role = role; view->principal.user_id = 9;
|
||||
memset(view->csrf, 'b', 64); view->csrf[64] = 0; return ESP_OK;
|
||||
}
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *principal, bool *current)
|
||||
{
|
||||
assert(id == 42 && principal->role == USER_ROLE_ADMIN && principal->user_id == 9);
|
||||
assert(ends == 1 && metadata_reads == 1 && !commits);
|
||||
*current = final_current; return final_unavailable ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token)
|
||||
{
|
||||
assert(!expected && !reset && s_transitioning); *token = 0;
|
||||
if (identity_busy || identity_token) return ESP_ERR_INVALID_STATE;
|
||||
*token = identity_token = 7; return ESP_OK;
|
||||
}
|
||||
void web_security_release_identity(uint32_t token)
|
||||
{
|
||||
assert(token && token == identity_token && s_transitioning);
|
||||
identity_token = 0; ++identity_releases;
|
||||
}
|
||||
const esp_partition_t *esp_ota_get_running_partition(void) { return missing_running ? NULL : &running; }
|
||||
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p)
|
||||
{
|
||||
assert(!p); return missing_target ? NULL : ⌖
|
||||
}
|
||||
esp_err_t esp_ota_begin(const esp_partition_t *p, size_t size, esp_ota_handle_t *handle)
|
||||
{
|
||||
assert(p == &target && p->address != running.address && size == request.content_len);
|
||||
assert(size <= target.size && request.received == PREFIX_SIZE && s_transitioning && identity_token);
|
||||
assert(tasks_created == 1 && !locked); ++begins;
|
||||
assert(!ota_live && *handle == 0);
|
||||
if (stage_fail == 1) return ESP_FAIL;
|
||||
*handle = 123; ota_live = true;
|
||||
/* IDF publishes the handle before erasing; this models erase failure. */
|
||||
return stage_fail == 6 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
esp_err_t esp_ota_write(esp_ota_handle_t handle, const void *data, size_t size)
|
||||
{
|
||||
assert(handle == 123 && s_transitioning && !locked && size <= BUFFER_SIZE);
|
||||
assert(flash_size + size <= sizeof(flash)); ++writes;
|
||||
if (stage_fail == 2) return ESP_FAIL;
|
||||
memcpy(flash + flash_size, data, size); flash_size += size; return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_ota_end(esp_ota_handle_t handle)
|
||||
{
|
||||
assert(handle == 123 && flash_size == request.content_len && !memcmp(flash, image, flash_size));
|
||||
assert(!aborts && !locked && s_transitioning && tasks_created == 1 && ota_live); ++ends;
|
||||
ota_live = false; /* End consumes on success AND validation failure. */
|
||||
return stage_fail == 3 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
esp_err_t esp_ota_abort(esp_ota_handle_t handle)
|
||||
{
|
||||
assert(handle == 123 && begins == 1 && !ends && !commits && ota_live);
|
||||
ota_live = false; ++aborts; return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_image_get_metadata(const esp_partition_pos_t *p, esp_image_metadata_t *metadata)
|
||||
{
|
||||
assert(p->offset == target.address && p->size == target.size && ends == 1);
|
||||
++metadata_reads; metadata->image_len = (uint32_t)parsed_size;
|
||||
if (revoke_at_metadata) final_current = false;
|
||||
return stage_fail == 4 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *p)
|
||||
{
|
||||
assert(p == &target && ends == 1 && metadata_reads == 1 && final_current && !final_unavailable);
|
||||
assert(tasks_created == 1 && !response_sends && s_transitioning && identity_token && !locked);
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||
if (commit_race) competing_lifecycle();
|
||||
if (stage_fail == 5) return ESP_FAIL;
|
||||
++commits; return ESP_OK;
|
||||
}
|
||||
|
||||
static void reset(void)
|
||||
{
|
||||
assert(!allocations_live && !task_entry && !locked && !ota_live);
|
||||
atomic_store(&s_reboot_gate, false);
|
||||
s_firmware_selected = false;
|
||||
++cases;
|
||||
memset(&request, 0, sizeof(request));
|
||||
request.uri = WEB_FIRMWARE_UPDATE_URI; request.method = HTTP_POST;
|
||||
request.content_len = sizeof(image); request.handle = &server_storage; request.headers_valid = true;
|
||||
set_header("Host", "device"); set_header("Origin", "https://device");
|
||||
set_header("Content-Type", "application/octet-stream");
|
||||
set_length(sizeof(image));
|
||||
set_header("Cookie", SESSION_COOKIE "=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
set_header("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
memset(image, 0x55, sizeof(image)); memset(flash, 0, sizeof(flash));
|
||||
esp_image_header_t header = {.magic = ESP_IMAGE_HEADER_MAGIC, .chip_id = ESP_CHIP_ID_ESP32S3,
|
||||
.segment_count = 3, .hash_appended = 1};
|
||||
esp_image_segment_header_t segment = {.data_len = sizeof(esp_app_desc_t)};
|
||||
uint32_t magic = ESP_APP_DESC_MAGIC_WORD;
|
||||
memcpy(image, &header, sizeof(header)); memcpy(image + sizeof(header), &segment, sizeof(segment));
|
||||
memcpy(image + sizeof(header) + sizeof(segment), &magic, sizeof(magic));
|
||||
running = (esp_partition_t){.type = 0, .subtype = 0x10, .address = 0x10000, .size = 0x400000};
|
||||
target = (esp_partition_t){.type = 0, .subtype = 0x11, .address = 0x410000, .size = 0x400000};
|
||||
missing_target = missing_running = false;
|
||||
s_ready = lookup_current = final_current = true;
|
||||
s_server_mutex = &mutex_storage; s_server = &server_storage;
|
||||
s_transitioning = false; s_last_error = ESP_OK; s_generation = 11;
|
||||
lookup_unavailable = final_unavailable = malloc_fail = task_fail = identity_busy = mutex_busy = false;
|
||||
receive_race = commit_race = revoke_at_metadata = false;
|
||||
role = USER_ROLE_ADMIN; stage_fail = response_fail = response_step = 0;
|
||||
allocations = allocations_live = tasks_created = tasks_deleted = 0;
|
||||
begins = writes = ends = aborts = metadata_reads = commits = receives = wipes = 0;
|
||||
restarts = response_sends = releases = identity_releases = 0;
|
||||
flash_size = 0; max_chunk = BUFFER_SIZE; cutoff = stall_at = SIZE_MAX; parsed_size = sizeof(image);
|
||||
now_us = 0; read_us = 1000; notification = identity_token = 0;
|
||||
response_status[0] = response_body[0] = 0;
|
||||
}
|
||||
static void rejected(const char *status, const char *code)
|
||||
{
|
||||
bool pre_reserved = s_transitioning;
|
||||
bool gate_reserved = atomic_load(&s_reboot_gate);
|
||||
esp_err_t result = web_firmware_update_handler(&request);
|
||||
assert(!strcmp(response_status, status) && strstr(response_body, code));
|
||||
assert(result == (request.received < request.content_len ? ESP_FAIL : ESP_OK));
|
||||
assert(!commits && !allocations_live && !restarts && wipes == 1 && !ota_live);
|
||||
assert(s_transitioning == pre_reserved && !identity_token);
|
||||
assert(atomic_load(&s_reboot_gate) == gate_reserved);
|
||||
if (task_entry) assert(notification == 2);
|
||||
finish_owner(); assert(!restarts);
|
||||
}
|
||||
static void success(void)
|
||||
{
|
||||
assert(web_firmware_update_handler(&request) == ESP_OK);
|
||||
assert(!strcmp(response_status, "200 OK") && !strcmp(response_body, "{\"ok\":true,\"rebooting\":true}"));
|
||||
assert(commits == 1 && ends == 1 && !aborts && s_transitioning && identity_token);
|
||||
assert(!allocations_live && notification == 1 && !restarts && wipes == 1);
|
||||
assert(s_firmware_selected && web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||
assert(request.received == request.content_len && !memcmp(image, flash, sizeof(image)));
|
||||
finish_owner(); assert(restarts == 1);
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
reset();
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||
rejected("503 Service Unavailable", "busy");
|
||||
assert(!receives && !begins && !allocations);
|
||||
reset(); s_server_mutex = NULL; s_server = NULL;
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||
reset(); success();
|
||||
reset(); max_chunk = 1; receive_race = commit_race = true; success();
|
||||
assert(receives == sizeof(image));
|
||||
reset(); max_chunk = 17; success();
|
||||
puts("PASS bounded streaming, bytewise partial prefix, exact writes, delayed owner restart and reservation races");
|
||||
|
||||
reset(); lookup_current = false; rejected("401 Unauthorized", "authentication_required"); assert(!receives && !allocations);
|
||||
reset(); lookup_unavailable = true; rejected("503 Service Unavailable", "unavailable");
|
||||
reset(); role = USER_ROLE_USER; rejected("403 Forbidden", "admin_required"); assert(!receives && !allocations);
|
||||
reset(); set_header("X-CSRF-Token", NULL); rejected("403 Forbidden", "csrf");
|
||||
reset(); set_header("X-CSRF-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); rejected("403 Forbidden", "csrf");
|
||||
reset(); set_header("Origin", NULL); rejected("403 Forbidden", "origin");
|
||||
reset(); set_header("Origin", "null"); rejected("403 Forbidden", "origin");
|
||||
reset(); set_header("Origin", "https://attacker"); rejected("403 Forbidden", "origin");
|
||||
reset(); set_header("Sec-Fetch-Site", "cross-site"); rejected("403 Forbidden", "origin");
|
||||
reset(); set_header("Cookie", NULL); rejected("401 Unauthorized", "authentication_required");
|
||||
reset(); request.headers_valid = false; rejected("400 Bad Request", "invalid_request");
|
||||
reset(); request.uri = "/api/firmware?x=1"; rejected("400 Bad Request", "invalid_request");
|
||||
reset(); request.method = HTTP_GET; rejected("400 Bad Request", "invalid_request");
|
||||
reset(); s_ready = false; rejected("503 Service Unavailable", "unavailable");
|
||||
puts("PASS production cookie/Origin/CSRF/admin admission before receive or flash (SDK getters/adapter real, database doubled)");
|
||||
|
||||
const char *invalid_lengths[] = {NULL, "", " ", "+12000", "-12000", "12000x", "1.2e4",
|
||||
"12 000", "12000,12000", "12000 ", "12000\t", "\t12000", "11999", "12001",
|
||||
"000000000000000012000"};
|
||||
for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); ++i) {
|
||||
reset(); set_header("Content-Length", invalid_lengths[i]);
|
||||
rejected("400 Bad Request", "invalid_request");
|
||||
assert(!receives && !begins && !allocations && !notification);
|
||||
}
|
||||
const char *oversized_lengths[] = {"4194305", "4296815136", "4294967296",
|
||||
"18446744073709551615", "18446744073709551616", "99999999999999999999"};
|
||||
for (size_t i = 0; i < sizeof(oversized_lengths) / sizeof(oversized_lengths[0]); ++i) {
|
||||
reset(); set_header("Content-Length", oversized_lengths[i]);
|
||||
/* Explicitly model HTTPD's 64-bit -> ESP32 size_t narrowing. */
|
||||
request.content_len = i == 1 ? 1847840 : i == 2 ? 0 : sizeof(image);
|
||||
rejected("413 Payload Too Large", "firmware_too_large");
|
||||
assert(!receives && !begins && !allocations && !notification);
|
||||
}
|
||||
reset();
|
||||
request.headers[request.header_count++] = (test_header_t){"content-length", "12000"};
|
||||
rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations);
|
||||
reset(); set_header("Content-Length", " 12000"); success();
|
||||
reset(); set_header("Content-Length", "00000000000000012000"); success();
|
||||
puts("PASS raw Content-Length missing/duplicate/malformed/oversized/64-bit wrap and overflow reject before receive/erase; IDF leading-space semantics");
|
||||
|
||||
reset(); set_header("Content-Type", "multipart/form-data"); rejected("415 Unsupported Media Type", "firmware_content_type");
|
||||
reset(); set_header("Content-Type", NULL); rejected("415 Unsupported Media Type", "firmware_content_type");
|
||||
reset(); set_length(0); rejected("400 Bad Request", "invalid_firmware");
|
||||
reset(); set_length(PREFIX_SIZE - 1); rejected("400 Bad Request", "invalid_firmware");
|
||||
reset(); set_length(target.size + 1); rejected("413 Payload Too Large", "firmware_too_large"); assert(!allocations);
|
||||
reset(); target.size = sizeof(image); success();
|
||||
reset(); missing_target = true; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||
reset(); missing_running = true; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||
reset(); target.address = running.address; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||
reset(); target.type = 1; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||
reset(); target.subtype = 0; rejected("503 Service Unavailable", "firmware_unavailable");
|
||||
for (unsigned field = 0; field < 6; ++field) {
|
||||
reset(); esp_image_header_t header; memcpy(&header, image, sizeof(header));
|
||||
if (field == 0) header.magic = 0;
|
||||
if (field == 1) header.chip_id = ESP_CHIP_ID_ESP32;
|
||||
if (field == 2) header.segment_count = 0;
|
||||
if (field == 3) header.hash_appended = 0;
|
||||
memcpy(image, &header, sizeof(header));
|
||||
if (field == 4) memset(image + sizeof(header) + sizeof(esp_image_segment_header_t), 0, 4);
|
||||
if (field == 5) memset(image + sizeof(header), 0, sizeof(esp_image_segment_header_t));
|
||||
rejected("400 Bad Request", "invalid_firmware"); assert(!begins && !aborts);
|
||||
}
|
||||
puts("PASS content type, length/capacity, inactive app selection, format/chip/descriptor/hash header rejection before erase");
|
||||
|
||||
reset(); s_transitioning = true; rejected("503 Service Unavailable", "busy");
|
||||
reset(); identity_busy = true; rejected("503 Service Unavailable", "busy");
|
||||
reset(); mutex_busy = true; rejected("503 Service Unavailable", "busy");
|
||||
reset(); s_server = NULL; rejected("503 Service Unavailable", "busy");
|
||||
reset(); malloc_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
|
||||
reset(); task_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
|
||||
for (int stage = 1; stage <= 5; ++stage) {
|
||||
reset(); stage_fail = stage;
|
||||
rejected(stage == 3 || stage == 4 ? "400 Bad Request" : "500 Internal Server Error",
|
||||
stage == 3 || stage == 4 ? "invalid_firmware" : stage == 5 ? "firmware_commit_failed" : "firmware_write_failed");
|
||||
assert(aborts == (unsigned)(stage == 2));
|
||||
}
|
||||
reset(); stage_fail = 6;
|
||||
rejected("500 Internal Server Error", "firmware_write_failed");
|
||||
assert(begins == 1 && aborts == 1 && !writes && !ends && !ota_live);
|
||||
puts("PASS begin failure after handle publication aborts once; unpublished failure and consumed end never double-abort");
|
||||
reset(); parsed_size--; rejected("400 Bad Request", "invalid_firmware");
|
||||
reset(); parsed_size++; rejected("400 Bad Request", "invalid_firmware");
|
||||
reset(); revoke_at_metadata = true; rejected("401 Unauthorized", "authentication_required");
|
||||
reset(); final_unavailable = true; rejected("401 Unauthorized", "authentication_required");
|
||||
puts("PASS busy/resources/OTA begin-write-end-metadata-commit failures, final auth revocation and exact SDK image length");
|
||||
|
||||
reset(); cutoff = 10; rejected("400 Bad Request", "firmware_incomplete"); assert(!begins);
|
||||
reset(); cutoff = 300; rejected("400 Bad Request", "firmware_incomplete"); assert(aborts == 1);
|
||||
reset(); stall_at = 0; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins && receives == 10);
|
||||
reset(); stall_at = PREFIX_SIZE; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1);
|
||||
reset(); read_us = STALL_US; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins);
|
||||
reset(); max_chunk = 1; read_us = 40000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1 && now_us == TOTAL_US);
|
||||
puts("PASS incomplete body, stalled receive and total slow-drip deadline; no selection/reboot, handle abort when live");
|
||||
|
||||
for (int step = 1; step <= 6; ++step) {
|
||||
reset(); response_fail = step;
|
||||
assert(web_firmware_update_handler(&request) == ESP_FAIL);
|
||||
assert(commits == 1 && !s_transitioning && !identity_token && !allocations_live);
|
||||
assert(notification == 2 && !restarts && !aborts); finish_owner(); assert(!restarts);
|
||||
assert(s_firmware_selected && !atomic_load(&s_reboot_gate));
|
||||
request.received = 0; response_fail = 0;
|
||||
assert(web_firmware_update_handler(&request) == ESP_FAIL);
|
||||
assert(!strcmp(response_status, "409 Conflict") && strstr(response_body, "firmware_selected_reboot_required"));
|
||||
assert(commits == 1 && begins == 1 && !atomic_load(&s_reboot_gate));
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_OK);
|
||||
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
puts("PASS postcommit response failure: selected image retained, no automatic reboot, resources/reservations released");
|
||||
printf("PASS %u firmware backend cases; SDK validation, flash and scheduling are mocked, not device evidence\n", cases);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user