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:
@@ -16,6 +16,8 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
|
||||
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and its [consolidated acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. The reduced Phase 9 focuses on intermittent trusted-network use, standard no-dump/silent-panic defaults and a short [operational checklist](docs/security_operations.md), without dependency patches or upgrades. **The reduced Phase 9 is complete by user sign-off on 2026-09-18.** The user waived a new device check based on prior Phase 8 validation; application code is unchanged, but crash-diagnostic defaults changed. No new hardware or panic-test pass is claimed.
|
||||
|
||||
**Phase 10 application firmware upload is complete by explicit user acceptance on 2026-09-18:** upload works and normal operation is verified. This does not claim specific fault-injection, NVS before/after comparison, power-loss or wired-recovery checks. Administrators can upload a locally built application from **Settings → HTTPS / Reboot → Firmware update**. Install this OTA-enabled firmware by wire first; future uploads use `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. See [firmware update and recovery](docs/roadmap.md#phase10-update-and-recovery) for compatibility, interruption and validation limits.
|
||||
|
||||
### Browser administration
|
||||
|
||||
Cookie login/logout supports both roles. Administrators also have an explicitly opened Admin shell sharing the canonical dispatcher, and typed **Serial, Accounts/password/SSH authorized keys, Network, Display, Broker, SSH, and HTTPS/Reboot** settings. Serial/Wi-Fi/client/writer quick controls reuse the same settings controllers. Navigation preserves terminal sessions and serial writer ownership; explicit transfer is confirmed and generation-safe. Browser-shell permissions remain narrower than typed Settings or UART0/admin SSH.
|
||||
@@ -32,6 +34,7 @@ Keep UART0 ready for administrative recovery and native USB for network-independ
|
||||
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
|
||||
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
|
||||
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [roadmap acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence) records sign-off and telemetry limits.
|
||||
- [Firmware update](docs/roadmap.md#phase-10--simple-admin-web-firmware-upload): admin-only application upload, NVS preservation, uncertain outcomes, wired recovery, acceptance evidence and reusable regression checks.
|
||||
- [Security operations](docs/security_operations.md): trusted-network use, credentials, identity verification, shutdown and recovery; physical-extraction limits and lightweight upstream maintenance.
|
||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||
|
||||
@@ -52,7 +55,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
|
||||
|
||||
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
|
||||
|
||||
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting.
|
||||
Admin HTTPS application upload uses the inactive OTA slot and `otadata`; NVS and other data partitions are untouched by the updater. It does not provide remote downloads, automatic rollback/health confirmation, core-dump handling, NVS encryption or filesystem mounting. Image validation does not guarantee a working application; compatible storage schemas and wired recovery remain the owner's responsibility.
|
||||
|
||||
### One-time migration from the default partition table
|
||||
|
||||
@@ -64,7 +67,7 @@ pio run --target upload
|
||||
pio device monitor -b 115200
|
||||
```
|
||||
|
||||
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. Subsequent ordinary uploads do not need a full erase.
|
||||
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. **Do not erase for subsequent ordinary wired or web application updates.** This destructive migration is only for the former default/factory layout, not for installing the updater on an existing custom-layout device.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
||||
|
||||
### HTTPS, WebSocket, and web serial
|
||||
|
||||
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
|
||||
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 40. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
|
||||
|
||||
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
|
||||
|
||||
@@ -122,6 +122,12 @@ Cookie login/logout replaces Basic/cache. Digest-only records carry copied princ
|
||||
|
||||
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
|
||||
|
||||
### Admin firmware upload
|
||||
|
||||
`web_firmware_update` streams a raw admin-only application image through HTTPD into the inactive OTA slot using standard ESP-IDF APIs. Existing Origin/CSRF/session checks precede body IO; raw length is bounded before HTTPD's narrowed length is trusted, SDK image validation and final principal currentness precede boot selection. One 4KiB buffer and a transient reboot task bound application storage; NVS and partition layout are untouched. HTTPD is occupied during upload, so normal web traffic can stall; deadlines bound receive progress, not flash-operation latency.
|
||||
|
||||
The server transition and identity reservations exclude competing lifecycle work. A separate atomic gate excludes ordinary reboot paths without depending on HTTPS initialization, preserving UART0 recovery. Successful response schedules delayed reset while retaining reservations; failed response after selection retains the selected-image latch but releases resources for manual reboot. A subsequent upload is refused until reset. No automatic retry, rollback, signing infrastructure or dependency patching. [Contract and validation limits](../roadmap.md#phase-10--simple-admin-web-firmware-upload).
|
||||
|
||||
### SSH
|
||||
|
||||
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
|
||||
|
||||
@@ -88,10 +88,16 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
### Browser admin backend
|
||||
|
||||
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
|
||||
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
|
||||
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 40 URI handlers.
|
||||
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
|
||||
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
|
||||
|
||||
## Firmware upload
|
||||
|
||||
- `src/web_firmware_update.{c,h}`: admin raw `POST /api/firmware`, cookie/Origin/CSRF admission via `web_cookie_auth`, registration in `web_server`, UI in `web_ui`. Standard SDK OTA into inactive app only; bounded4KiB buffer, validated raw length/header/final image and principal before boot selection. No NVS/layout writes or vendor patches.
|
||||
- HTTPD handles streaming synchronously; upload is deliberately disruptive to web traffic. Server/identity reservations fence lifecycle changes; atomic ordinary-reboot gate also covers console/SSH/browser/button reset paths. Success-response schedules delayed reset; response failure after selection latches uploads until deliberate manual reboot. No blind retries.
|
||||
- Tests: `tests/web_firmware_update/run.py` (active-build SDK5.5.0 headers and actual begin/abort contract), `tests/web_ui_session/run.py`, existing auth/lifecycle tests. [Usage, acceptance limits and reusable regression checks](../roadmap.md#phase-10--simple-admin-web-firmware-upload). Phase 10 complete by explicit user acceptance on 2026-09-18: upload works and normal operation verified; no specific fault/NVS-comparison/power-loss/recovery pass implied. Initial install by wire; subsequent upload is application `firmware.bin`, not full-flash image.
|
||||
|
||||
## Typed settings source and regression map
|
||||
|
||||
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
|
||||
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), [architecture](architecture.md) and [decisions](design-decisions.md).
|
||||
|
||||
## Phase 10 plan — simplified by user, 2026-09-18
|
||||
## Phase 10 COMPLETE — explicit user acceptance, 2026-09-18
|
||||
|
||||
- Plan only: admin HTTPS file picker/upload for a locally built ESP32-S3 application firmware.bin, existing auth/same-origin/CSRF, standard ESP-IDF OTA APIs and bounded streaming into inactive4MiBslot, SDK image/target/size validation before boot selection and controlled reboot.
|
||||
- Preserve NVS and all other data partitions; only inactiveapp/otadata writes. No wholeflash/bootloader/partitiontable uploads or erase. Storage bytes preserved, but user-selected firmware must remain schema-compatible. Wired USB-to-UART recovery if a valid image is nonfunctional.
|
||||
- Explicitly no signature/key infrastructure, antirollback/version rules, automatic rollback/healthconfirmation, remote downloadservice or dependency patches. This replaces the old broader OTA plan, not an implementation authorization. [Roadmap](../roadmap.md#phase-10--simple-admin-web-firmware-upload) is authoritative.
|
||||
- User confirmed after firmware upload implementation and the concise-UI fix: “That works perfectly. And the usual operation is also verified.” Acceptance establishes that upload works and normal operation is verified. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or recovery passes. The roadmap's compact regression guidance is reusable, not an acceptance blocker.
|
||||
- Latest concise-UI change was copy-only; reported UI regression **169 groups PASS**. No rebuild after that text change. The integration build below is historical, not validation of a newly rebuilt UI or this documentation update.
|
||||
|
||||
- User authorized simple OTA implementation; initial Git clean. New web_firmware_update module with standard SDK APIs, raw POST /api/firmware, admin cookie/Origin/CSRF and final principal currentness. Settings → HTTPS / Reboot has File/XHR upload progress, confirmation, session fencing and no automatic retries. Only inactive app/otadata writes; NVS/layout untouched. No signatures/antirollback/automaticrollback, dependencies or generated assets changed. Consolidated procedure, contract and regression guidance: [Phase 10](../roadmap.md#phase-10--simple-admin-web-firmware-upload); standalone guide removed.
|
||||
- 4KiB internal buffer + transient2048B-stack reboot owner allocated before erase; 10s stall/120s receive-loop budget, not totalflashdeadline. HTTPD synchronously blocks other web work during upload; networkserial maystall/drop, reboot disruptsall. No task/request/socket lifetime capture after handler. Service/identity reservation and atomic ordinary-reboot gate cover UI/UART0/SSH/browser/localbutton paths. Failed response after bootselect schedules noautomaticreset; selected latch rejects further uploads409, manual reboot available. Successful response schedules500ms reboot retaining reservations.
|
||||
- Review fixed two actualSDK5.5.0 edge cases: failed esp_ota_begin maypublishlivehandle beforeeraseerror (abortthat handle); rawContentLength64 canwrapHTTPDsize_t32 (overflow-safe actualslotbound/strictdecimal/equality check beforebody/erase). End consumes handle evenerror. SDKvalidation followed by exactparsedimage length includingSHA; basic header requiresS3appdescriptor/hash. Unrelated old/privateSDK code unpatched.
|
||||
- Parent final pio PASS **94,220 B RAM / 1,847,645 B flash**, +24RAM/+19,080flash vsPhase9, not runtimeheadroom. Parent newbackend88cases+actualSDKbeginfailurecontract, UI169groups+CSP, serverlifecycle44, admin25, consolelifecycle, SSHruntime, cookielifecyclePASS. Additionalbase/admin/display/lifecyclecookie, idle18, SSHmanagement/runtime/security agentPASS after adding missing rebootfake to adminfixture (no productionchange). Independent review final noactionablefindings; realbuilt firmware parsed with SDKmetadata bothOTAoffsets (notdeviceflashproof).
|
||||
- **Next:** Phase 11 BLE remains planned, not authorized by this acceptance update. Preserve small scope and existing uncommitted implementation/UI work; do not resurrect Phase 9 patches. This handoff changed documentation only; no build, test, upload, erase, device operation or commit was performed.
|
||||
|
||||
## Accepted state — 2026-09-18
|
||||
|
||||
- **Reduced Phase 9 complete by explicit user sign-off.** User waived a new whole-phase device check based on prior Phase 8 validation. Application code was unchanged, but no-core-dump/silent-panic defaults changed. Do not record the waived check as executed or claim a new panic/hardware pass.
|
||||
- Small scope: standard `sdkconfig.defaults` options, source-reviewed [operational checklist](../security_operations.md), README/roadmap guidance. No dependency patches, crypto policy replacement, allocator hooks, SDK migration, encryption, eFuse or partition changes. User abandoned the extensive earlier Phase 9 and restored baseline `f40c09c`; do not resurrect it.
|
||||
- Last actual build: normal `pio run` PASS on PlatformIO6.12.0 / IDF5.5.0 / original20241119 toolchains, **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirmed no dumps/silent panic; compilation inputs had no abandoned overlays/crypto guard. Existing generated config already selected these options. Defaults do not override saved sdkconfig; README explains verification. No new build is implied by this documentation consolidation.
|
||||
- Historical Phase 9 build: normal `pio run` PASS on PlatformIO6.12.0 / IDF5.5.0 / original20241119 toolchains, **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirmed no dumps/silent panic; compilation inputs had no abandoned overlays/crypto guard. Existing generated config already selected these options. Defaults do not override saved sdkconfig; README explains verification. No new build is implied by this documentation consolidation.
|
||||
- **Phase 8 complete:** 8A–C target validated; explicit 8D.22 user acceptance on 2026-09-13. Completion/telemetry and legacy compatibility now live in [roadmap acceptance](../roadmap.md#phase8-acceptance-evidence) and [storage compatibility](../roadmap.md#phase8-legacy-credential-compatibility). Separate acceptance/legacy history documents removed; active [web contracts](../web_administration.md) and [regression procedures](../user_administration_tests.md) retained.
|
||||
- Post-acceptance baseline includes PSRAM-only ping payload/user snapshot allocations and refined web quick panels. Prior focused host/geometry/build checks passed; those are not additional hardware claims. Preserve lazy-allocation failure isolation and unchanged serial hot path.
|
||||
|
||||
@@ -21,7 +26,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w
|
||||
- Previously accepted combined binary WS send: CPU160MHz / 230400 baud full mix including browser admin. Latest recorded telemetry has very low internal/DMA lifetime minima (2,052/460 B); these are nonblocking headroom follow-ups, not approved reserves or proof of simultaneous allocation failure. Full table, capture workload and counter limits are preserved in the roadmap.
|
||||
- TLS `-0x004C` means generic NET_RECV_FAILED, not OOM. Historical authentication/admission symptoms do not establish a cause. Do not invent fault, soak, timing or power-loss passes.
|
||||
- Credentials remain unencrypted; old flash contents are not erased. Intermittent trusted-network operation reduces exposure, not physical-extraction risk. Upstream upgrades are separate deliberate tasks, not an endless local backport programme.
|
||||
- Phase 10 is planned, not automatically authorized by acceptance. No device operations, branch/reset, commits or dependency upgrades are part of this documentation task.
|
||||
- Phase 10 is complete by the explicit acceptance above; detailed unreported regression scenarios remain unevidenced, not completion blockers. Device operations, branch/reset, commits and dependency upgrades remain outside this documentation task.
|
||||
|
||||
## Contracts to preserve
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
|
||||
| `reboot` | Drain console output briefly and restart the ESP32. |
|
||||
| `reboot` | Drain console output briefly and restart the ESP32; refused while a firmware upload or another reboot owns exclusion. |
|
||||
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
|
||||
|
||||
Firmware upload is an admin **Settings → HTTPS / Reboot** action, not a shell command. Ordinary UART0/admin-SSH/browser-shell/local-display and typed reboot paths are gated against an active upload. If upload boot selection succeeded but its response failed, no automatic restart is scheduled: the selected image remains, another admissible upload returns 409 until reset, and a deliberate manual `reboot` is permitted after reservations release. Inspect first; a lost response is not cancellation. See [firmware update and wired recovery](roadmap.md#phase10-update-and-recovery). Do not erase for an ordinary update; native USB is UART1 access, not recovery administration.
|
||||
|
||||
## Role-based users
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+36
-13
@@ -40,7 +40,7 @@ These constraints apply across all phases:
|
||||
| 7 | Local display and button interface | **Complete** |
|
||||
| 8 | Role-based users and administrative access | **Complete** |
|
||||
| 9 | Small intermittent-use security baseline | **Complete (user signoff 2026-09-18; new hardware check waived)** |
|
||||
| 10 | Simple admin web firmware upload | **Planned** |
|
||||
| 10 | Simple admin web firmware upload | **Complete (explicit user acceptance 2026-09-18; upload and normal operation verified)** |
|
||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
| 12 | Advanced network integration | **Under evaluation** |
|
||||
| 13 | Optional filesystem-backed features | **Under evaluation** |
|
||||
@@ -251,26 +251,49 @@ Small implementation:
|
||||
|
||||
**Acceptance (2026-09-18):** the user signed off this small Phase 9 and explicitly waived a new whole-phase hardware check of boot, UART0 recovery, native USB serial, HTTPS/SSH login and normal serial sharing. Application code is unchanged, **but crash-diagnostic defaults changed**; this is a waiver, not evidence of target or panic-path validation. The prior build/configuration evidence above remains the validation record; no new hardware test was performed. A separately controlled panic check with synthetic secrets remains optional, not a completion gate; do not add a production panic endpoint or erase flash. Disabling dumps does not erase old contents.
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
**Phases 8 and 9 are complete** for their accepted scopes; Phase 9 completion includes the explicit new-hardware-check waiver above. Phase 10 is planned, not implemented; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak or reserve measurement was performed for Phase 8 or that Phase 9 received new target validation.
|
||||
|
||||
### Phase 10 — Simple admin web firmware upload
|
||||
|
||||
**Plan only.** Add a file picker and upload button in the existing admin web interface. The owner builds the firmware locally and chooses the application `firmware.bin`; there is no release server, automatic download or update service. Treat an authenticated administrator as authorized to replace the application, as the physical owner can through wired flashing.
|
||||
**Complete — explicit user acceptance on 2026-09-18: firmware upload works and normal operation is verified.** The admin **Settings → HTTPS / Reboot** firmware card uploads the locally built `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. Install this OTA-enabled firmware by wire first, then use web application uploads. There is no release server, automatic download or update service. [Firmware update and recovery](#phase10-update-and-recovery) below covers operation; [regression guidance](#phase10-regression-guidance) covers future checks.
|
||||
|
||||
Planned work:
|
||||
Implemented scope:
|
||||
|
||||
- Reuse existing HTTPS admin authentication, same-origin and CSRF protections; ordinary `user` accounts cannot upload firmware. Require an explicit upload/reboot confirmation and show progress, success or an actionable error. Never automatically retry a possibly completed update.
|
||||
- Use standard ESP-IDF OTA APIs (`esp_ota_begin`, `esp_ota_write`, `esp_ota_end`, `esp_ota_set_boot_partition`) to stream one upload at a time through a bounded buffer into the inactive application slot. Use the existing two 4 MiB slots and `otadata`; no custom flash protocol or whole-image RAM buffer.
|
||||
- Accept an ESP32-S3 application binary for this board/layout, not an ELF, ZIP, merged full-flash image, bootloader or partition-table image. Check nonempty/complete upload, target/header compatibility and actual destination capacity; use SDK image validation before selecting the new boot partition. A `.bin` filename alone is not validation. Basic format/integrity checks do not prove authenticity or that the application will work.
|
||||
- On interrupted, invalid or failed uploads, abort the OTA operation and leave the current boot selection unchanged. Select the new slot only after successful final validation, report completion and reboot in a controlled way. Upload may disrupt network/serial activity; reboot interrupts every transport, so do not promise uninterrupted operation.
|
||||
- Raw `POST /api/firmware` requires exact `Content-Type: application/octet-stream`, known exact `Content-Length`, cookie, same-origin Origin and `X-CSRF-Token`; no multipart/JSON, Basic authentication or ordinary `user` access. Reject queries, ambiguous headers, chunked transfer and `Expect`; UI code leaves browser-managed headers alone. Admission precedes body/flash work; session/principal currentness is rechecked before boot selection. The card confirms upload/reboot and shows progress/results without automatic retries. HTTPD capacity is 40 method/path handlers.
|
||||
- Standard ESP-IDF OTA APIs (`esp_ota_begin`, `esp_ota_write`, `esp_ota_end`, `esp_ota_set_boot_partition`) stream one upload through a 4 KiB buffer into the inactive slot (two 4 MiB app slots). A reboot task with a 2 KiB stack is preallocated before erase. Lifecycle/identity reservations and ordinary software-reboot gating exclude competing work; no custom flash protocol or whole-image RAM buffer.
|
||||
- Accepts an ESP32-S3 application binary for this board/layout, not an ELF, ZIP, merged full-flash image, bootloader or partition-table image. Browser hints require a nonempty `.bin` of at most 4 MiB; the server bounds raw length against actual destination capacity before trusting HTTPD's narrowed length. Checks completeness, target/header and exact SDK image length/integrity, including mandatory appended SHA-256, before selection. The digest is not publisher authentication; neither it nor filename checks prove board compatibility or a working application.
|
||||
- Before selection, rejected/incomplete uploads and receive/write/validation failures abort any live OTA handle without selecting the candidate; the inactive slot may be erased/partially written. Boot-metadata failure (`firmware_commit_failed`) needs inspection and carries SDK transactional uncertainty. `200 {"ok":true,"rebooting":true}` means validation/selection succeeded; successful synchronous send schedules restart after 500 ms, retaining reservations, but proves neither browser receipt nor boot success. Response failure after selection schedules no restart, releases reservations for manual reboot and latches further admissible uploads to 409 `firmware_selected_reboot_required`; the latch survives HTTPS stop/start until device reset.
|
||||
- Synchronous HTTPD receive/flash blocks other HTTPD work: browser sessions can stall/drop. The 120-second total receive-loop and ten-second stall checks use the existing one-second socket timeout, not an absolute deadline: synchronous SDK erase/write/validation and scheduling are not preempted. The browser's 180-second timeout cannot cancel committed work. UART0/native USB remain independent paths, not guarantees of uninterrupted serial timing during flash; reboot interrupts all transports. Software exclusion cannot prevent physical reset, power loss or panic.
|
||||
- **Preserve NVS:** write only the inactive application slot and the OTA selection metadata. Do not erase the chip, rewrite the partition table/bootloader, or touch `nvs`, `nvs_key`, PHY, storage or other data partitions. Existing users, passwords, Wi-Fi/serial settings and HTTPS/SSH identities remain stored, as with an application-only wired update without erase. This preserves stored bytes; the uploaded firmware must still understand the existing schemas and must not itself erase/migrate them incompatibly.
|
||||
- Keep wired USB-to-UART flashing documented as recovery if the uploaded application does not boot or no longer serves the web UI. Basic image validation cannot prevent a valid but broken application from requiring wired recovery.
|
||||
|
||||
**Not in scope:** secure boot, image signatures/signing-key management, anti-rollback/version-downgrade enforcement, automatic rollback/post-boot health-confirmation machinery, remote release discovery, partition migration or NVS backup/restore. Use upstream OTA support without dependency patches. The administrator is responsible for selecting trusted, compatible firmware, including when deliberately installing an older build.
|
||||
|
||||
**Acceptance:** test a successful application upload/reboot, invalid/wrong-target/oversized and interrupted uploads, authorization rejection, and retained configuration/identities after update. Confirm wired recovery remains possible. These are future tests, not execution claims; no new cryptographic certification or exhaustive fault campaign is required.
|
||||
**Historical integration build and host evidence (parent-reported):** `pio run` **PASS**, **94,220 B linked RAM / 1,847,645 B flash**. Against Phase 9's 94,196 B / 1,828,565 B, this is **+24 B RAM / +19,080 B flash**. Final backend 88 cases plus the pinned SDK begin/abort failure-contract test, UI 169 groups, server lifecycle 44, admin transport 25, console lifecycle, SSH runtime and cookie lifecycle checks passed. Additional cookie base/admin/display/lifecycle, HTTPD idle cleanup 18, and SSH management/runtime/security checks passed; the extra cookie `--admin` run initially lacked a reboot symbol in its fixture, corrected in test-only code before passing. Host doubles are not target evidence. The later concise-UI fix was copy-only; its latest reported UI regression passed all **169 groups**, with no rebuild after the text change. The build figures above remain historical; this documentation update ran no build or tests.
|
||||
|
||||
**Acceptance (2026-09-18):** after the firmware upload implementation and concise-UI fix, the user confirmed: “That works perfectly. And the usual operation is also verified.” Phase 10 is complete by this explicit acceptance of working upload and verified normal operation. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or wired-recovery passes. The [regression guidance](#phase10-regression-guidance) below is reusable future guidance, not an acceptance blocker.
|
||||
|
||||
#### Phase10 update and recovery
|
||||
|
||||
1. Keep **USB-to-UART** available for UART0 administration/flashing; native USB CDC is network-independent UART1 access, not administration. Install the updater by wire first: older firmware without the route cannot install its own first web updater. The custom OTA layout is required; [one-time old-layout migration](../README.md#one-time-migration-from-the-default-partition-table) is separate and destructive.
|
||||
2. Build trusted, schema-compatible `esp32-s3-devkitc-1-n16r8` firmware with `pio run`; choose **`.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`**, never a merged image or a renamed non-application file. Observe the [downgrade warning](../README.md#legacy-credential-removal).
|
||||
3. Save desired RAM-only settings and record nonsecret configuration/public HTTPS/SSH fingerprints. Arrange a maintenance window and stable power/network; sign in as `admin` over trusted HTTPS, open **Settings → HTTPS / Reboot → Firmware update**, choose the file and confirm **Upload and reboot…**.
|
||||
4. Wait for validation/reboot, not just 100% transmitted bytes. Restore network reachability, reload and explicitly sign in; verify the running application, saved configuration and identities before another upload.
|
||||
5. A lost response, timeout, page close, sign-out or browser abort proves neither cancellation nor failure. The UI locks uncertain outcomes without replay; reload clears only browser locks, not the device latch. Inspect through UART0 and deliberately reboot if appropriate: any later reset can boot an already selected image.
|
||||
6. For initial wired installation or recovery when boot/HTTPS fails, connect USB-to-UART, select a known-good compatible checkout, run `pio run --target upload`, then `pio device monitor -b 115200`. **Do not erase for ordinary updates or recovery**; there is no automatic recovery flashing/rollback.
|
||||
|
||||
Replies: **400** malformed/incompatible/incomplete input; **401/403** authentication/Origin/CSRF or `admin_required`; **408** `firmware_timeout`; **409** selected-image latch; **413/415** destination capacity/content type; **500** `firmware_write_failed`/`firmware_commit_failed`; **503** busy/unavailable/resources. Unread rejected bodies close rather than drain; bounded secret-free JSON may never reach a disconnected browser.
|
||||
|
||||
#### Phase10 regression guidance
|
||||
|
||||
These are reusable checks, **not recorded passes or outstanding acceptance gates**. Record only performed outcomes; host flash/network/scheduling doubles are not hardware evidence. Focused host suites: `python3 tests/web_firmware_update/run.py` and `python3 tests/web_ui_session/run.py` (the SDK begin/abort case covers a live handle published on erase failure).
|
||||
|
||||
- Exercise wired-first install, known-good upload/reboot/new build and explicit reconnect; compare saved users/roles, password/key login, Wi-Fi/serial/display/hostname settings and public HTTPS/SSH fingerprints without recording secrets.
|
||||
- Interrupt/cancel receive; reject corrupt, truncated, wrong-target, non-application and oversized inputs without unintended selection/restart, then deliberately perform a valid update. Reject unauthenticated/ordinary-user, wrong-Origin and missing/wrong-CSRF requests before flash. Never attempt power interruption at commit without wired recovery ready.
|
||||
- Exercise competing upload, HTTPS lifecycle/identity and ordinary reboot exclusion; HTTPD requests may wait rather than promptly return busy. With controlled post-selection response-failure injection, verify no automatic restart, subsequent 409 (also after HTTPS restart), then manual reboot; an arbitrary disconnect does not establish this fault case.
|
||||
- Observe web stalls/drops and serial/network recovery, single-writer isolation, UART0 recovery and native USB UART1 access without networking; demonstrate known-good wired recovery without erase and recheck configuration/identities. Do not claim uninterrupted traffic.
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
**Phases 8, 9 and 10 are complete** for their accepted scopes. Phase 9 includes the explicit new-hardware-check waiver above; Phase 10 includes explicit user acceptance of upload and normal operation. Phase 11 remains planned; later work is under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak, recovery or reserve measurement was performed for completed phases.
|
||||
|
||||
### Phase 11 — BLE
|
||||
|
||||
@@ -332,7 +355,7 @@ The following are not implemented merely because flash partitions or library sup
|
||||
|
||||
- NVS, flash, or PSRAM encryption.
|
||||
- Secure boot or production eFuse provisioning.
|
||||
- Automatic OTA downloads, image signing, post-boot health confirmation and automatic rollback. The simple admin upload is planned in Phase 10.
|
||||
- Automatic OTA downloads, image signing, post-boot health confirmation and automatic rollback. The simple admin upload is complete in Phase 10; these advanced update features remain deferred.
|
||||
- Core-dump collection or secret-safe core-dump processing.
|
||||
- Filesystem mounting.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web administration contracts
|
||||
|
||||
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](roadmap.md#phase8-acceptance-evidence), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
|
||||
Current implementation contracts; [Phase 10 firmware upload is complete by explicit user acceptance on 2026-09-18](roadmap.md#phase-10--simple-admin-web-firmware-upload), confirming upload and normal operation, not specific fault or recovery checks. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](roadmap.md#phase8-acceptance-evidence), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
|
||||
|
||||
## Authentication and admission
|
||||
|
||||
@@ -9,7 +9,7 @@ Current, accepted firmware behavior. Phase status and executed-evidence limits b
|
||||
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
|
||||
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
|
||||
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
|
||||
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
|
||||
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 40 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
|
||||
|
||||
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
|
||||
|
||||
@@ -111,6 +111,14 @@ HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/stor
|
||||
|
||||
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
|
||||
|
||||
### Application firmware upload
|
||||
|
||||
The admin-only firmware card is in **Settings → HTTPS / Reboot**. It sends a raw `POST /api/firmware` with `application/octet-stream`, known length, session cookie, same-origin Origin and `X-CSRF-Token`; it is not a JSON Settings operation or dispatcher/result-slot workflow. Standard SDK OTA APIs stream through a 4 KiB buffer to the inactive application slot and select it only after validation/currentness checks. Only that slot and `otadata` are written; NVS/data partitions are untouched.
|
||||
|
||||
One upload reserves HTTPS lifecycle/identity and excludes ordinary software reboot. The synchronous HTTPD handler can stall/drop browser serial/admin sessions. Its 120-second total receive-loop and ten-second stall checks are not preemptive flash deadlines or uninterrupted-traffic guarantees. Successful response send schedules a delayed reboot, not proof of peer receipt. Failed response after commit leaves the image selected, schedules no automatic reboot, releases reservations for manual reboot, and latches subsequent admissible uploads to 409 `firmware_selected_reboot_required` until reset. Never automatically retry an uncertain outcome.
|
||||
|
||||
See [firmware update](roadmap.md#phase-10--simple-admin-web-firmware-upload) for wired-first installation, exact image selection, manual recovery, acceptance limits and reusable future hardware regression checks. Phase 10 is accepted; there are no signature/version policies or automatic rollback.
|
||||
|
||||
### SSH
|
||||
|
||||
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
|
||||
@@ -125,4 +133,4 @@ SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatch
|
||||
|
||||
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](roadmap.md#phase8-legacy-credential-compatibility). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
|
||||
|
||||
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
|
||||
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption or secure boot is implied by Phase 8 acceptance. Phase 10 application upload is complete by explicit user acceptance of upload and normal operation; unreported fault, NVS-comparison, power-loss and recovery checks are not implied. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
|
||||
|
||||
@@ -38,6 +38,7 @@ idf_component_register(
|
||||
"web_broker_settings.c"
|
||||
"web_ssh_settings.c"
|
||||
"web_lifecycle_settings.c"
|
||||
"web_firmware_update.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
@@ -59,6 +60,7 @@ idf_component_register(
|
||||
"mdns_console.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES
|
||||
app_update
|
||||
bootloader_support
|
||||
console
|
||||
esp_driver_gpio
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -1101,7 +1102,8 @@ static void execute_action(local_status_ui_state_t *state,
|
||||
}
|
||||
break;
|
||||
case LOCAL_STATUS_ACTION_REBOOT:
|
||||
state->restart_pending = true;
|
||||
error = web_firmware_update_reserve_reboot();
|
||||
state->restart_pending = error == ESP_OK;
|
||||
break;
|
||||
default:
|
||||
error = ESP_ERR_INVALID_ARG;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -265,6 +266,8 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
switch (action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "esp_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
@@ -57,6 +58,10 @@ static int command_reboot(int argc, char **argv)
|
||||
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||
return 0;
|
||||
}
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK) {
|
||||
printf("Reboot refused: firmware update or reboot in progress.\n");
|
||||
return 1;
|
||||
}
|
||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||
fflush(stdout);
|
||||
/* Give the UART driver time to transmit the acknowledgement before reset. */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "web_server.h"
|
||||
#include "web_security.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -114,6 +115,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -218,12 +218,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
return require(r, mutation, upgrade, 0, view, allowed);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||
esp_err_t web_cookie_auth_require_body(httpd_req_t *r, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return require(r, true, false, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return web_cookie_auth_require_body(r, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
static bool secret(char out[65])
|
||||
{
|
||||
uint8_t bytes[32];
|
||||
|
||||
@@ -17,6 +17,9 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
|
||||
bool upgrade, web_session_view_t *view,
|
||||
bool *allowed);
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
||||
/* Same mutation policy for a bounded raw body; caller validates content type. */
|
||||
esp_err_t web_cookie_auth_require_body(httpd_req_t *request, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed);
|
||||
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_firmware_update.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_app_format.h"
|
||||
#include "esp_image_format.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_security.h"
|
||||
|
||||
#define BUFFER_SIZE 4096U
|
||||
#define STALL_US 10000000LL
|
||||
#define TOTAL_US 120000000LL
|
||||
#define PREFIX_SIZE (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t))
|
||||
|
||||
/* A successful reservation is retained through reset, not a check-then-reset.
|
||||
* Only the upload owner accesses the selected latch while holding this gate. */
|
||||
static atomic_bool s_reboot_gate;
|
||||
static bool s_firmware_selected;
|
||||
|
||||
esp_err_t web_firmware_update_reserve_reboot(void)
|
||||
{
|
||||
bool expected = false;
|
||||
return atomic_compare_exchange_strong(&s_reboot_gate, &expected, true) ?
|
||||
ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Allocated before boot selection. No HTTPD stop, queue allocation, captured
|
||||
* request or socket in this owner. A successful send is not proof of receipt. */
|
||||
static void reboot_owner(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
uint32_t decision = 0;
|
||||
xTaskNotifyWait(0, UINT32_MAX, &decision, portMAX_DELAY);
|
||||
if (decision == 1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
esp_restart();
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
/* Reject without HTTPD's unbounded discard of the remaining request body. */
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t content_length(httpd_req_t *request, size_t limit)
|
||||
{
|
||||
/* Auth's adapter already rejects duplicate headers. IDF 5.5.0 parses a
|
||||
* uint64_t length then narrows it to size_t; never trust that value alone.
|
||||
* Its getters strip leading spaces only. Require 1..20 decimal digits. */
|
||||
char raw[21];
|
||||
size_t length = httpd_req_get_hdr_value_len(request, "Content-Length");
|
||||
if (!length || length >= sizeof(raw) ||
|
||||
httpd_req_get_hdr_value_str(request, "Content-Length", raw, sizeof(raw)) != ESP_OK ||
|
||||
strspn(raw, "0123456789") != length)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
size_t value = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
unsigned digit = (unsigned)(raw[i] - '0');
|
||||
if (value > limit / 10 || (value == limit / 10 && digit > limit % 10))
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
value = value * 10 + digit;
|
||||
}
|
||||
return value == request->content_len ? ESP_OK : ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
static bool application_prefix(const uint8_t *buffer)
|
||||
{
|
||||
esp_image_header_t header;
|
||||
esp_image_segment_header_t segment;
|
||||
uint32_t magic;
|
||||
memcpy(&header, buffer, sizeof(header));
|
||||
memcpy(&segment, buffer + sizeof(header), sizeof(segment));
|
||||
memcpy(&magic, buffer + sizeof(header) + sizeof(segment), sizeof(magic));
|
||||
/* App descriptor distinguishes app images from bootloader/full-flash data.
|
||||
* SDK end validation remains authoritative for revision, segments and hash. */
|
||||
return header.magic == ESP_IMAGE_HEADER_MAGIC &&
|
||||
header.chip_id == ESP_CHIP_ID_ESP32S3 &&
|
||||
header.segment_count > 0 && header.segment_count <= ESP_IMAGE_MAX_SEGMENTS &&
|
||||
header.hash_appended == 1 && segment.data_len >= sizeof(esp_app_desc_t) &&
|
||||
magic == ESP_APP_DESC_MAGIC_WORD;
|
||||
}
|
||||
|
||||
esp_err_t web_firmware_update_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false, reserved = false, active = false, gate_owned = false;
|
||||
uint32_t identity_token = 0;
|
||||
uint8_t *buffer = NULL;
|
||||
TaskHandle_t reboot_task = NULL;
|
||||
esp_ota_handle_t ota = 0;
|
||||
const char *status = "500 Internal Server Error";
|
||||
const char *body = "{\"error\":\"firmware_write_failed\"}";
|
||||
/* Auth rejects ambiguous headers/transfer encoding before any body IO.
|
||||
* The actual inactive partition, not a compiled slot size, bounds the body. */
|
||||
esp_err_t error = web_cookie_auth_require_body(request, SIZE_MAX, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
#define REJECT(s, b) do { status = (s); body = "{\"error\":\"" b "\"}"; goto failed; } while (0)
|
||||
if (view.principal.role != USER_ROLE_ADMIN)
|
||||
REJECT("403 Forbidden", "admin_required");
|
||||
char type[40] = {0};
|
||||
if (httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) != ESP_OK ||
|
||||
strcmp(type, "application/octet-stream"))
|
||||
REJECT("415 Unsupported Media Type", "firmware_content_type");
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *target = esp_ota_get_next_update_partition(NULL);
|
||||
if (!running || !target || target->type != ESP_PARTITION_TYPE_APP ||
|
||||
target->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_0 ||
|
||||
target->subtype > ESP_PARTITION_SUBTYPE_APP_OTA_15 ||
|
||||
target->address == running->address)
|
||||
REJECT("503 Service Unavailable", "firmware_unavailable");
|
||||
esp_err_t length_error = content_length(request, target->size);
|
||||
if (length_error == ESP_ERR_INVALID_SIZE)
|
||||
REJECT("413 Payload Too Large", "firmware_too_large");
|
||||
if (length_error != ESP_OK)
|
||||
REJECT("400 Bad Request", "invalid_request");
|
||||
if (request->content_len < PREFIX_SIZE)
|
||||
REJECT("400 Bad Request", "invalid_firmware");
|
||||
if (web_firmware_update_reserve_reboot() != ESP_OK)
|
||||
REJECT("503 Service Unavailable", "busy");
|
||||
gate_owned = true;
|
||||
if (s_firmware_selected)
|
||||
REJECT("409 Conflict", "firmware_selected_reboot_required");
|
||||
if (web_firmware_update_reserve(request->handle) != ESP_OK)
|
||||
REJECT("503 Service Unavailable", "busy");
|
||||
reserved = true;
|
||||
/* Also exclude direct canonical identity mutations which bypass server. */
|
||||
if (web_security_reserve_identity(0, false, &identity_token) != ESP_OK)
|
||||
REJECT("503 Service Unavailable", "busy");
|
||||
buffer = malloc(BUFFER_SIZE);
|
||||
if (!buffer || xTaskCreate(reboot_owner, "fw_reboot", 2048, NULL, 5, &reboot_task) != pdPASS)
|
||||
REJECT("503 Service Unavailable", "firmware_resources");
|
||||
|
||||
size_t received = 0, prefix = 0;
|
||||
int64_t started = esp_timer_get_time(), last_progress = started;
|
||||
while (received < request->content_len) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
|
||||
REJECT("408 Request Timeout", "firmware_timeout");
|
||||
/* Accumulate the entire prefix, even when TLS gives one byte at a time. */
|
||||
size_t want = active ? request->content_len - received : PREFIX_SIZE - prefix;
|
||||
if (want > BUFFER_SIZE) want = BUFFER_SIZE;
|
||||
int count = httpd_req_recv(request, (char *)buffer + (active ? 0 : prefix), want);
|
||||
now = esp_timer_get_time();
|
||||
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
|
||||
REJECT("408 Request Timeout", "firmware_timeout");
|
||||
if (count == HTTPD_SOCK_ERR_TIMEOUT) continue;
|
||||
if (count <= 0 || (size_t)count > want)
|
||||
REJECT("400 Bad Request", "firmware_incomplete");
|
||||
received += (size_t)count;
|
||||
last_progress = now;
|
||||
size_t write_size = (size_t)count;
|
||||
if (!active) {
|
||||
prefix += (size_t)count;
|
||||
if (prefix < PREFIX_SIZE) continue;
|
||||
if (!application_prefix(buffer))
|
||||
REJECT("400 Bad Request", "invalid_firmware");
|
||||
esp_err_t begin_error = esp_ota_begin(target, request->content_len, &ota);
|
||||
/* IDF 5.5.0 can publish a live handle before an erase failure. */
|
||||
active = ota != 0;
|
||||
if (begin_error != ESP_OK) goto failed;
|
||||
write_size = prefix;
|
||||
}
|
||||
if (esp_ota_write(ota, buffer, write_size) != ESP_OK) goto failed;
|
||||
}
|
||||
if (esp_timer_get_time() - started >= TOTAL_US)
|
||||
REJECT("408 Request Timeout", "firmware_timeout");
|
||||
/* esp_ota_end consumes the handle even on validation failure. */
|
||||
active = false;
|
||||
if (esp_ota_end(ota) != ESP_OK)
|
||||
REJECT("400 Bad Request", "invalid_firmware");
|
||||
/* SDK end validates flash, but does not compare its parsed image length to
|
||||
* our HTTP length. Reject truncation into old flash and appended garbage. */
|
||||
_Static_assert(sizeof(esp_image_metadata_t) <= BUFFER_SIZE, "metadata fits upload buffer");
|
||||
esp_image_metadata_t *metadata = (esp_image_metadata_t *)buffer;
|
||||
esp_partition_pos_t position = {.offset = target->address, .size = target->size};
|
||||
if (esp_image_get_metadata(&position, metadata) != ESP_OK || metadata->image_len != received)
|
||||
REJECT("400 Bad Request", "invalid_firmware");
|
||||
bool current = false;
|
||||
if (web_session_store_check_principal(view.id, &view.principal, ¤t) != ESP_OK || !current)
|
||||
REJECT("401 Unauthorized", "authentication_required");
|
||||
if (esp_ota_set_boot_partition(target) != ESP_OK)
|
||||
REJECT("500 Internal Server Error", "firmware_commit_failed");
|
||||
|
||||
s_firmware_selected = true;
|
||||
/* Commit is irreversible here. Failed send leaves the selected image for a
|
||||
* later reboot, but deliberately does not schedule this upload's reboot.
|
||||
* Never retry an upload automatically after a lost acknowledgement. */
|
||||
error = respond(request, "200 OK", "{\"ok\":true,\"rebooting\":true}");
|
||||
if (error == ESP_OK) {
|
||||
xTaskNotify(reboot_task, 1, eSetValueWithOverwrite);
|
||||
reboot_task = NULL;
|
||||
reserved = false; /* Retain service/identity reservations until reset. */
|
||||
identity_token = 0;
|
||||
gate_owned = false; /* Reboot owner retains exclusion until reset. */
|
||||
}
|
||||
goto done;
|
||||
failed:
|
||||
if (active) { esp_ota_abort(ota); active = false; }
|
||||
error = respond(request, status, body);
|
||||
done:
|
||||
if (reboot_task) xTaskNotify(reboot_task, 2, eSetValueWithOverwrite);
|
||||
if (identity_token) web_security_release_identity(identity_token);
|
||||
if (reserved) web_firmware_update_release();
|
||||
free(buffer);
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
if (gate_owned) atomic_store(&s_reboot_gate, false);
|
||||
return error;
|
||||
#undef REJECT
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#define WEB_FIRMWARE_UPDATE_URI "/api/firmware"
|
||||
|
||||
esp_err_t web_firmware_update_handler(httpd_req_t *request);
|
||||
|
||||
/* Atomically exclude uploads until reset. Call immediately before an ordinary
|
||||
* reboot (and before any acknowledgement delay); failure means do not reset.
|
||||
* Independent of HTTPS initialization, so UART0 recovery remains available. */
|
||||
esp_err_t web_firmware_update_reserve_reboot(void);
|
||||
|
||||
/* Internal HTTPS owner reservation. HTTPD takes before flash and releases on
|
||||
* failure; successful response transfers lifetime to the reboot owner. No
|
||||
* service mutex is held during receive/flash/response. Implemented by server. */
|
||||
esp_err_t web_firmware_update_reserve(httpd_handle_t server);
|
||||
void web_firmware_update_release(void);
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -573,6 +574,12 @@ static const httpd_uri_t s_logo_uri = {
|
||||
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_firmware_uri = {
|
||||
.uri = WEB_FIRMWARE_UPDATE_URI,
|
||||
.method = HTTP_POST,
|
||||
.handler = web_firmware_update_handler,
|
||||
};
|
||||
|
||||
static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_root_uri,
|
||||
&s_status_uri,
|
||||
@@ -583,6 +590,7 @@ static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_addon_fit_js_uri,
|
||||
&s_app_js_uri,
|
||||
&s_logo_uri,
|
||||
&s_firmware_uri,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_auth_uris[] = {
|
||||
@@ -944,6 +952,29 @@ esp_err_t web_server_restart_current(uint32_t expected_generation)
|
||||
return stop_server(expected_generation, true, false);
|
||||
}
|
||||
|
||||
/* The upload retains the existing lifecycle fence, not the mutex. This also
|
||||
* excludes canonical stop/start and service-coordinated identity replacement. */
|
||||
esp_err_t web_firmware_update_reserve(httpd_handle_t server)
|
||||
{
|
||||
if (!s_server_mutex || xSemaphoreTake(s_server_mutex, 0U) != pdTRUE)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
if (!server || s_server != server || s_transitioning || s_last_error != ESP_OK) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void web_firmware_update_release(void)
|
||||
{
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
}
|
||||
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
+52
-2
@@ -192,6 +192,7 @@ static const char s_index_html[] =
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
|
||||
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
|
||||
"<section aria-label=\"Firmware upload\"><h3>Firmware update</h3><p class=\"connection-detail\">Select the firmware.bin built for this device (max. 4 MiB). Saved settings are kept. Keep power connected during the update.</p><div class=\"settings-edit\"><label>Application .bin <input id=\"firmware-file\" type=\"file\" accept=\".bin\"></label></div><button id=\"firmware-upload\" class=\"button\" type=\"button\">Upload and reboot…</button><progress id=\"firmware-progress\" max=\"100\" value=\"0\" aria-label=\"Firmware upload progress\"></progress><p id=\"firmware-detail\" class=\"connection-detail\" role=\"status\">Choose a firmware file to begin.</p><a href=\"/\">Reconnect after reboot</a></section>"
|
||||
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
|
||||
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
|
||||
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
|
||||
@@ -895,11 +896,59 @@ static const char s_app_js[] =
|
||||
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
||||
" brokerButtons();\n"
|
||||
"});\n"
|
||||
"let firmwareWork = null, firmwareLocked = false;\n"
|
||||
"const firmwareUncertain = 'Update status unknown. The firmware may already be installed. Reconnect and check before retrying.';\n"
|
||||
"const firmwareWarning = 'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.';\n"
|
||||
"function firmwareButtons() {\n"
|
||||
" element('firmware-file').disabled = element('firmware-upload').disabled = !!firmwareWork || firmwareLocked || !!lifecycleAbort || lifecyclePending;\n"
|
||||
"}\n"
|
||||
"function cancelFirmware() {\n"
|
||||
" const work = firmwareWork; firmwareWork = null; element('firmware-file').value = '';\n"
|
||||
" if (work) { firmwareLocked = true; work.controller.abort(); work.xhr?.abort(); element('firmware-detail').textContent = firmwareUncertain; }\n"
|
||||
" firmwareButtons();\n"
|
||||
"}\n"
|
||||
"async function uploadFirmware() {\n"
|
||||
" if (firmwareWork || firmwareLocked || lifecycleAbort || lifecyclePending || selected !== 'settings' || settingsDomain !== 'lifecycle' || accountRole !== 'admin' || !sessionVerified || !csrf || suspended || unloading || navigating || loggingOut) return;\n"
|
||||
" const file = element('firmware-file').files?.[0], detail = element('firmware-detail'), progress = element('firmware-progress');\n"
|
||||
" if (!file || !/\\.bin$/i.test(file.name) || !Number.isSafeInteger(file.size) || file.size < 1 || file.size > 4 * 1024 * 1024) { detail.textContent = 'Choose a nonempty .bin file up to 4 MiB.'; return; }\n"
|
||||
" if (!window.confirm(firmwareWarning)) return;\n"
|
||||
" const generation = workGeneration, work = {controller:new AbortController(), xhr:null}; firmwareWork = work; lifecycleButtons();\n"
|
||||
" const current = () => firmwareWork === work && live(generation) && sessionVerified && accountRole === 'admin' && !suspended && !loggingOut;\n"
|
||||
" const finish = (message, locked) => { if (!current()) return; firmwareWork = null; firmwareLocked = locked; detail.textContent = message; element('firmware-file').value = ''; lifecycleButtons(); };\n"
|
||||
" progress.value = 0; detail.textContent = 'Checking session…';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, work.controller.signal, false)) { finish('Session changed; no upload sent. Sign in again.', false); return; }\n"
|
||||
" if (!current()) return;\n"
|
||||
" const xhr = new XMLHttpRequest(); work.xhr = xhr;\n"
|
||||
" // XHR uses CORS mode and same-origin cookies. File supplies the known body length; never set Origin or Content-Length manually.\n"
|
||||
" xhr.open('POST', '/api/firmware'); xhr.timeout = 180000;\n"
|
||||
" xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-CSRF-Token', csrf);\n"
|
||||
" xhr.upload.onprogress = event => {\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (event.lengthComputable && Number.isFinite(event.total) && event.total > 0 && Number.isFinite(event.loaded) && event.loaded >= 0) { progress.value = Math.min(100, Math.floor(event.loaded * 100 / event.total)); detail.textContent = progress.value === 100 ? 'Upload complete. Validating firmware…' : 'Uploading: ' + progress.value + '%'; }\n"
|
||||
" else detail.textContent = 'Uploading… Progress unavailable.';\n"
|
||||
" };\n"
|
||||
" xhr.onerror = xhr.ontimeout = xhr.onabort = () => finish(firmwareUncertain, true);\n"
|
||||
" xhr.onload = () => {\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (xhr.status === 401) { cancelFirmware(); login(); return; }\n"
|
||||
" let value; try { if (xhr.responseText.length > 128) throw new Error(); value = JSON.parse(xhr.responseText); } catch (_) { finish(firmwareUncertain, true); return; }\n"
|
||||
" if (xhr.status === 200 && value && Object.keys(value).length === 2 && value.ok === true && value.rebooting === true) { progress.value = 100; finish('Firmware accepted; rebooting. Reconnect and sign in shortly.', true); return; }\n"
|
||||
" const errors = {invalid_request:'Invalid upload request.', invalid_firmware:'Invalid or incompatible application image.', firmware_incomplete:'Incomplete firmware image.', authentication_required:'Session ended.', origin:'Origin security check failed.', csrf:'Session security check failed.', admin_required:'Administrator access required.', firmware_timeout:'Device upload deadline exceeded.', firmware_too_large:'Image exceeds the device partition capacity.', firmware_content_type:'Raw binary content type required.', firmware_write_failed:'Firmware write failed.', firmware_commit_failed:'Firmware commit failed; inspect the device before any reset or retry.', unavailable:'Service unavailable.', busy:'Device is busy.', firmware_unavailable:'Firmware update partition unavailable.', firmware_resources:'Insufficient device resources.'};\n"
|
||||
" const code = value && Object.keys(value).length === 1 && value.error;\n"
|
||||
" if (xhr.status >= 400 && Object.hasOwn(errors, code)) { finish(errors[code], ['authentication_required','origin','csrf','admin_required','firmware_commit_failed'].includes(code)); }\n"
|
||||
" else finish(firmwareUncertain, true);\n"
|
||||
" };\n"
|
||||
" detail.textContent = 'Uploading… Keep power connected.'; xhr.send(file);\n"
|
||||
" } catch (_) { finish(work.xhr ? firmwareUncertain : 'Session check failed; no upload sent. Reconnect and try again.', !!work.xhr); }\n"
|
||||
"}\n"
|
||||
"element('firmware-upload').addEventListener('click', uploadFirmware);\n"
|
||||
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
|
||||
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
|
||||
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
||||
"function lifecycleButtons() {\n"
|
||||
" const busy = !!lifecycleAbort;\n"
|
||||
" firmwareButtons();\n"
|
||||
" const busy = !!lifecycleAbort || !!firmwareWork || firmwareLocked;\n"
|
||||
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
||||
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
|
||||
"}\n"
|
||||
@@ -911,7 +960,7 @@ static const char s_app_js[] =
|
||||
" lifecycleButtons();\n"
|
||||
"}\n"
|
||||
"async function lifecycleRequest(action, snapshotRead = false) {\n"
|
||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || firmwareWork || firmwareLocked || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
|
||||
@@ -1639,6 +1688,7 @@ static const char s_app_js[] =
|
||||
"const requests = new Set();\n"
|
||||
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
|
||||
"const cancelWork = () => {\n"
|
||||
" cancelFirmware();\n"
|
||||
" clearSettings();\n"
|
||||
" ++workGeneration;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
|
||||
@@ -39,6 +39,8 @@ static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
|
||||
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||
static bool reboot_busy;
|
||||
static esp_err_t web_firmware_update_reserve_reboot(void) { return reboot_busy ? ESP_FAIL : ESP_OK; }
|
||||
static void esp_restart(void) { ++reboots; }
|
||||
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
|
||||
#define pdMS_TO_TICKS(ms) (ms)
|
||||
@@ -95,6 +97,9 @@ int main(void) {
|
||||
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
|
||||
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
|
||||
remote=false;
|
||||
reboot_busy=true;
|
||||
assert(command_reboot(1,NULL)==1 && !reboots && !waits && scheduled==5);
|
||||
reboot_busy=false;
|
||||
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
|
||||
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
|
||||
remote=web=true; schedule_result=ESP_OK;
|
||||
|
||||
@@ -15,6 +15,11 @@ static esp_err_t web_server_start(void) {
|
||||
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
|
||||
++web_starts; return web_start_result;
|
||||
}
|
||||
static bool reboot_busy;
|
||||
static esp_err_t web_firmware_update_reserve_reboot(void) {
|
||||
OUTSIDE(); assert(!httpd_owner);
|
||||
return reboot_busy ? ESP_ERR_INVALID_STATE : ESP_OK;
|
||||
}
|
||||
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
||||
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
||||
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||
|
||||
@@ -180,20 +180,20 @@ static void pipeline_tests(void) {
|
||||
reset(); lifecycle_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
|
||||
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 40);
|
||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(registered_count == 39 && unregister_calls == 1);
|
||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||
other_domains_complete(); ssh_complete();
|
||||
@@ -201,7 +201,7 @@ static void pipeline_tests(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 40 && web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 36:
|
||||
raise RuntimeError('Review URI extraction: expected 34 descriptors and two tables')
|
||||
if len(uri_tables) != 37:
|
||||
raise RuntimeError('Review URI extraction: expected 35 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
@@ -46,6 +46,7 @@ header = '\n'.join(line for line in header.splitlines()
|
||||
if not line.startswith(('#include', '#pragma once')))
|
||||
constants = define(SOURCE, 'WEB_SERVER_PORT')
|
||||
for filename, names in {
|
||||
'web_firmware_update.h': ('WEB_FIRMWARE_UPDATE_URI',),
|
||||
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
|
||||
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
|
||||
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
|
||||
@@ -104,7 +105,7 @@ static bool unregister_fail;
|
||||
static bool settings_fail;
|
||||
static unsigned settings_calls;
|
||||
static unsigned operation_calls, operation_fail_at;
|
||||
static const httpd_uri_t *registered[39];
|
||||
static const httpd_uri_t *registered[40];
|
||||
static char events[128]; static size_t event_length;
|
||||
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
|
||||
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
|
||||
@@ -134,6 +135,7 @@ static esp_err_t web_security_replace_reserved(uint32_t token) {
|
||||
}
|
||||
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
|
||||
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
|
||||
HANDLER(web_firmware_update_handler)
|
||||
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
|
||||
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
||||
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
|
||||
@@ -277,7 +279,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 39 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 40 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->user_cb == tls_session_callback);
|
||||
@@ -293,7 +295,7 @@ static esp_err_t register_one(httpd_handle_t server) {
|
||||
}
|
||||
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (!strcmp(uri->uri, "/api/settings/serial")) {
|
||||
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17);
|
||||
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 18);
|
||||
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
|
||||
++settings_calls;
|
||||
if (settings_fail) return ESP_ERR_NO_MEM;
|
||||
@@ -301,11 +303,11 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
||||
return ESP_OK;
|
||||
}
|
||||
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
|
||||
assert(registration_calls >= 16);
|
||||
assert(registration_calls >= 17);
|
||||
assert(serial_init_error != ESP_OK || serial_live);
|
||||
} else assert(registration_calls < 14);
|
||||
} else assert(registration_calls < 15);
|
||||
esp_err_t error = register_one(s);
|
||||
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
|
||||
if (error == ESP_OK) { assert(registered_count < 40); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
@@ -355,7 +357,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
}
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
assert((registration_calls == 19 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
@@ -372,15 +374,15 @@ static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri,
|
||||
}
|
||||
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
|
||||
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
|
||||
assert(registration_calls == 14 || registration_calls == 15);
|
||||
assert(registration_calls == 15 || registration_calls == 16);
|
||||
assert((code == 404 || code == 405) && handler == route_error_handler);
|
||||
return register_one(s);
|
||||
}
|
||||
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 17);
|
||||
++serial_attaches; serial_live = true; return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
|
||||
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 19); ++admin_inits; return admin_init_error; }
|
||||
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
|
||||
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
|
||||
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
|
||||
@@ -531,7 +533,8 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 39 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 40 && registration_calls == 19 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
@@ -569,23 +572,23 @@ int main(void) {
|
||||
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
|
||||
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
|
||||
|
||||
for (unsigned failure = 1; failure <= 16; ++failure) {
|
||||
for (unsigned failure = 1; failure <= 17; ++failure) {
|
||||
reset(); registration_fail_at = failure;
|
||||
assert(web_server_start() == ESP_FAIL);
|
||||
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
|
||||
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
|
||||
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
|
||||
}
|
||||
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
|
||||
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
|
||||
|
||||
for (unsigned failure = 17; failure <= 18; ++failure) {
|
||||
for (unsigned failure = 18; failure <= 19; ++failure) {
|
||||
reset(); registration_fail_at = failure;
|
||||
assert(web_server_start() == ESP_OK && registration_calls == failure);
|
||||
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 37 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 38 && unregister_calls == failure - 18);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||
@@ -594,13 +597,13 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 40 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
puts("PASS optional positions 18..19 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 38);
|
||||
reset(); registration_fail_at = 19; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 39);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
@@ -612,7 +615,7 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 40 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
@@ -634,7 +637,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 38);
|
||||
assert(settings_calls == 1 && registered_count == 39);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -642,7 +645,7 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 37 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 38 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
@@ -650,25 +653,27 @@ int main(void) {
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 36 : 37));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 37 : 38));
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39 && account_calls == 3);
|
||||
assert(registered_count == 40 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 39 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 38 && account_calls == 3);
|
||||
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
|
||||
assert(generation_calls == 1 && registered_count == 39 && account_calls == 3);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
@@ -680,12 +685,12 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 39);
|
||||
assert(generation_calls == 2 && registered_count == 40);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
reset(); keys_fail = true; start();
|
||||
assert(keys_calls == 1 && registered_count == 38 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 39 && account_calls == 3 && generation_calls == 1);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
@@ -700,7 +705,7 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
keys_fail = false; fresh_registration(); start();
|
||||
assert(keys_calls == 2 && registered_count == 39);
|
||||
assert(keys_calls == 2 && registered_count == 40);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||
@@ -725,7 +730,7 @@ int main(void) {
|
||||
reset(); network_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(network_calls == failed_route && network_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -733,13 +738,13 @@ int main(void) {
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); network_complete();
|
||||
assert(registered_count == 40); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(registered_count == 39 && unregister_calls == 1);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -749,7 +754,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); network_complete();
|
||||
assert(registered_count == 40); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -757,7 +762,7 @@ int main(void) {
|
||||
reset(); display_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(display_calls == failed_route && display_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -765,13 +770,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); display_complete();
|
||||
assert(registered_count == 40); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(registered_count == 39 && unregister_calls == 1);
|
||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -781,7 +786,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); display_complete();
|
||||
assert(registered_count == 40); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -789,7 +794,7 @@ int main(void) {
|
||||
reset(); broker_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(broker_calls == failed_route && broker_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -797,13 +802,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete(); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); broker_complete();
|
||||
assert(registered_count == 40); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(registered_count == 39 && unregister_calls == 1);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -813,7 +818,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); broker_complete();
|
||||
assert(registered_count == 40); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -821,7 +826,7 @@ int main(void) {
|
||||
reset(); ssh_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(registered_count == (failed_route == 1 ? 37 : 38));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
@@ -829,13 +834,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); ssh_complete();
|
||||
assert(registered_count == 40); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(registered_count == 39 && unregister_calls == 1);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
@@ -845,7 +850,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); ssh_complete();
|
||||
assert(registered_count == 40); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -927,7 +932,7 @@ static void management_tests(void) {
|
||||
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
|
||||
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
|
||||
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
|
||||
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
|
||||
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 40);
|
||||
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
|
||||
|
||||
@@ -241,6 +241,9 @@ int main(void) {
|
||||
assert(web_stops == 1 && !reboots && !closes);
|
||||
web_stop_result = ESP_OK;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
|
||||
reboot_busy = true;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE && !reboots);
|
||||
reboot_busy = false;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
|
||||
httpd_owner = true;
|
||||
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <sys/socket.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "web_firmware_update.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "freertos/task.h"
|
||||
@@ -14,7 +15,13 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
|
||||
static void *pending_argument;
|
||||
static httpd_req_t connected;
|
||||
static unsigned admin_closes;
|
||||
/* These endpoint tests never dispatch lifecycle commands. */
|
||||
static bool reboot_reservation_expected;
|
||||
esp_err_t web_firmware_update_reserve_reboot(void) {
|
||||
assert(reboot_reservation_expected);
|
||||
reboot_reservation_expected = false;
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
/* A rejected reboot reservation must never reach reset or other lifecycle effects. */
|
||||
void esp_restart(void) { assert(false); }
|
||||
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||
@@ -131,6 +138,11 @@ static void admin_tests(void) {
|
||||
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
|
||||
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
|
||||
if (mode == 0) {
|
||||
reboot_reservation_expected = true;
|
||||
assert(admin_owner->perform(&admin_token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE);
|
||||
assert(!reboot_reservation_expected && console_active && !admin_closes);
|
||||
assert(admin_owner->is_current(&admin_token, &administrator));
|
||||
puts("PASS: live admin deferred reboot propagates firmware reservation refusal without reset or session closure");
|
||||
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
|
||||
assert(!console_active); present(&other);
|
||||
} else if (mode == 1) stale_user = administrator.user_id;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,7 +14,15 @@ const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => se
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
|
||||
const fits = [];
|
||||
const fits = [], uploads = [];
|
||||
class Upload {
|
||||
constructor() { this.upload = {}; this.headers = {}; uploads.push(this); }
|
||||
open(method, url) { this.method = method; this.url = url; }
|
||||
setRequestHeader(key, value) { this.headers[key] = value; }
|
||||
send(file) { this.file = file; }
|
||||
abort() { this.aborted = true; this.onabort?.(); }
|
||||
reply(status, value) { this.status = status; this.responseText = typeof value === 'string' ? value : JSON.stringify(value); this.onload?.(); }
|
||||
}
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
@@ -60,7 +68,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
||||
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
||||
}},
|
||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
|
||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, XMLHttpRequest: Upload,
|
||||
fetch: async (url, options) => {
|
||||
// Apply the Origin regression guard to every mutation, including logout.
|
||||
assert.ok(Object.hasOwn(queues, url));
|
||||
@@ -83,7 +91,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||
};
|
||||
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, uploads,
|
||||
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
@@ -1399,5 +1407,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./firmware.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||
const snapshot = {generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true};
|
||||
const file = (extra={}) => ({name:'firmware.bin',size:4194304,type:'',...extra});
|
||||
const n = (b,id) => b.document.getElementById('firmware-'+id);
|
||||
async function open() {
|
||||
const b=await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues['/api/settings/lifecycle'].push(json(snapshot)); b.click('settings-lifecycle'); await tick();
|
||||
n(b,'file').files=[file()]; return b;
|
||||
}
|
||||
async function upload(b) { b.click('firmware-upload'); await tick(); return b.uploads.at(-1); }
|
||||
await test('Firmware admin-only card validates filename hint/size, not unreliable browser MIME, and requires explicit confirmation', async()=>{
|
||||
assert.match(html,/accept="\.bin"/); assert.match(html,/firmware\.bin built for this device/); assert.match(html,/Saved settings are kept/);
|
||||
const u=browser(); u.start(); await tick(); n(u,'file').files=[file()]; await upload(u); assert.equal(u.uploads.length,0);
|
||||
for(const f of [null,file({name:'flash.txt'}),file({size:0}),file({size:4194305}),file({size:NaN}),file({size:1.5})]) {
|
||||
const b=await open(); n(b,'file').files=f?[f]:[]; await upload(b); assert.equal(b.uploads.length,0); assert.match(n(b,'detail').textContent,/nonempty .bin/);
|
||||
}
|
||||
const b=await open(); let warning=''; b.window.confirm=s=>{warning=s;return false;}; await upload(b); assert.equal(b.uploads.length,0);
|
||||
assert.equal(warning,'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.');
|
||||
b.window.confirm=()=>true; n(b,'file').files=[file({name:'APP.BIN',type:'text/plain'})]; const x=await upload(b); assert.ok(x.file);
|
||||
});
|
||||
await test('Firmware sends original File raw with current CSRF and browser-managed headers; one flight and lifecycle controls gated',async()=>{
|
||||
const b=await open(), f=n(b,'file').files[0], x=await upload(b);
|
||||
assert.equal(x.method,'POST'); assert.equal(x.url,'/api/firmware'); assert.equal(x.file,f);
|
||||
assert.deepEqual(x.headers,{'Content-Type':'application/octet-stream','X-CSRF-Token':token}); assert.equal(x.timeout,180000);
|
||||
assert.ok(n(b,'file').disabled && n(b,'upload').disabled);
|
||||
for(const action of ['stop','restart','rotate','reboot']) {assert.ok(b.nodes['lifecycle-'+action].disabled); b.click('lifecycle-'+action);}
|
||||
await upload(b); assert.equal(b.uploads.length,1); assert.ok(!b.calls.some(c=>c.url.endsWith('lifecycle-operation')&&c.method==='POST'));
|
||||
x.upload.onprogress({lengthComputable:true,loaded:25,total:100}); assert.equal(n(b,'progress').value,25);
|
||||
x.upload.onprogress({lengthComputable:true,loaded:100,total:100}); assert.match(n(b,'detail').textContent,/Validating firmware/);
|
||||
for(const e of [{lengthComputable:false},{lengthComputable:true,loaded:NaN,total:100},{lengthComputable:true,loaded:1,total:0}]) { x.upload.onprogress(e); assert.match(n(b,'detail').textContent,/unavailable/); }
|
||||
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/accepted; rebooting/); assert.match(n(b,'detail').textContent,/Reconnect and sign in/);
|
||||
await upload(b); assert.equal(b.uploads.length,1); assert.ok(n(b,'upload').disabled);
|
||||
x.onerror(); assert.match(n(b,'detail').textContent,/accepted; rebooting/);
|
||||
});
|
||||
await test('Firmware fences delayed session validation, changed identity/role/CSRF and logout before sending',async()=>{
|
||||
for(const response of [failure(401),session({role:'user'}),session({role:'admin',username:'changed'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
|
||||
const b=await open(); b.queues['/api/session'].push(response); await upload(b); assert.equal(b.uploads.length,0); assert.ok(b.redirects.length);
|
||||
}
|
||||
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); await upload(b); await upload(b); assert.equal(b.uploads.length,0);
|
||||
b.click('sign-out'); await tick(); d.resolve(session({role:'admin'})); await tick(); assert.equal(b.uploads.length,0);
|
||||
});
|
||||
await test('Firmware session loss/pagehide/logout abort once and fence all late progress/success/error/401 callbacks',async()=>{
|
||||
for(const end of ['logout','pagehide','expiry','identity']) {
|
||||
const b=await open(), x=await upload(b);
|
||||
if(end==='logout') b.click('sign-out');
|
||||
else if(end==='pagehide') b.emit('pagehide');
|
||||
else if(end==='expiry') x.reply(401,{error:'authentication_required'});
|
||||
else { b.queues['/api/session'].push(session({role:'admin',username:'changed'})); b.click('select-serial'); b.click('connection-toggle'); await tick(); b.click('connection-toggle'); }
|
||||
await tick(); assert.ok(x.aborted,end); const detail=n(b,'detail').textContent, redirects=b.redirects.length;
|
||||
assert.match(detail,/may already be installed/);
|
||||
x.upload.onprogress({lengthComputable:true,loaded:50,total:100}); x.reply(200,{ok:true,rebooting:true}); x.reply(401,{}); x.onerror();
|
||||
assert.equal(n(b,'detail').textContent,detail); assert.equal(b.redirects.length,redirects); assert.equal(b.uploads.length,1);
|
||||
}
|
||||
});
|
||||
await test('Firmware navigation retains single upload and lifecycle gate without resending',async()=>{
|
||||
const b=await open(), x=await upload(b); b.click('settings-serial'); await tick(); b.click('settings-lifecycle'); await tick();
|
||||
assert.ok(!x.aborted); assert.ok(b.nodes['lifecycle-reboot'].disabled); await upload(b); assert.equal(b.uploads.length,1);
|
||||
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/rebooting/);
|
||||
});
|
||||
await test('Firmware maps every backend error safely, never displays arbitrary response text or retries',async()=>{
|
||||
const groups={400:['invalid_request','invalid_firmware','firmware_incomplete'],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']};
|
||||
for(const [status,codes] of Object.entries(groups)) for(const error of codes) {
|
||||
const b=await open(), x=await upload(b); x.reply(Number(status),{error}); assert.doesNotMatch(n(b,'detail').textContent,/Update status unknown/); assert.ok(n(b,'detail').textContent.length > 0 && n(b,'detail').textContent.length < 100); assert.equal(b.uploads.length,1);
|
||||
}
|
||||
for(const response of ['SECRET ERROR BODY','x'.repeat(129),{ok:true},{ok:true,rebooting:true,secret:'bad'},{error:'<script>'},{error:'toString'}]) {
|
||||
const b=await open(), x=await upload(b); x.reply(200,response); assert.match(n(b,'detail').textContent,/Update status unknown/); assert.doesNotMatch(n(b,'detail').textContent,/SECRET|script|bad/); assert.ok(n(b,'upload').disabled);
|
||||
}
|
||||
});
|
||||
await test('Firmware refuses competing lifecycle work and stale prior-upload callbacks cannot affect an explicit retry',async()=>{
|
||||
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.click('lifecycle-reboot'); await tick();
|
||||
await upload(b); assert.equal(b.uploads.length,0); assert.ok(n(b,'upload').disabled);
|
||||
b.queues['/api/settings/lifecycle-operation'].push(new Response(JSON.stringify({id:1,action:'reboot',state:'pending'}),{status:202}));
|
||||
d.resolve(session({role:'admin'})); await tick(); await upload(b); assert.equal(b.uploads.length,0);
|
||||
const retry=await open(), first=await upload(retry); first.reply(503,{error:'busy'});
|
||||
assert.equal(n(retry,'upload').disabled,false); n(retry,'file').files=[file()]; const second=await upload(retry);
|
||||
const message=n(retry,'detail').textContent; first.reply(401,{}); first.onerror(); first.upload.onprogress({lengthComputable:true,loaded:1,total:2});
|
||||
assert.equal(retry.redirects.length,0); assert.equal(n(retry,'detail').textContent,message); assert.ok(!second.aborted);
|
||||
second.reply(200,{ok:true,rebooting:true}); assert.match(n(retry,'detail').textContent,/accepted; rebooting/);
|
||||
const preflight=await open(); preflight.queues['/api/session'].push(()=>{throw Error('offline');}); await upload(preflight);
|
||||
assert.equal(preflight.uploads.length,0); assert.match(n(preflight,'detail').textContent,/no upload sent/); assert.equal(n(preflight,'upload').disabled,false);
|
||||
});
|
||||
await test('Firmware network/timeout/abort and lost acknowledgement are uncertain, locked, and never auto-replayed',async()=>{
|
||||
for(const event of ['onerror','ontimeout','onabort']) {
|
||||
const b=await open(), x=await upload(b); x[event](); assert.match(n(b,'detail').textContent,/Reconnect and check/); assert.ok(n(b,'upload').disabled);
|
||||
await upload(b); assert.equal(b.uploads.length,1); x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/Update status unknown/);
|
||||
}
|
||||
const fresh=await open(); assert.equal(fresh.uploads.length,0); assert.equal(n(fresh,'upload').disabled,false);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user