From c9df334eb9ab4910fbd13d732ffc8ce384388ade Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 13:01:05 -0400 Subject: [PATCH 1/2] Add isolated Quran failure qualification harness --- .github/workflows/README.md | 23 +- .github/workflows/firmware.yml | 11 +- .../quran-failure-qualification.md | 174 +++++++++++ .../quran-failure-validation-2026-10-11.md | 72 +++++ docs/development/quran-streaming.md | 2 + .../components/openathan_audio/quran_http.h | 8 + .../components/openathan_device/quran.cpp | 6 + .../openathan_quran_qualification/__init__.py | 58 ++++ .../qualification.cpp | 41 +++ .../qualification.h | 16 + .../waveshare/quran-qualification.yaml | 11 + tests/quran_qualification_tests.cpp | 39 +++ tests/quran_service_tests.cpp | 45 ++- tests/test_ci_build_policy.py | 2 +- tests/test_quran_qualification.py | 280 ++++++++++++++++++ tests/upgrade_stubs/esp_http_client.h | 25 +- tools/check_feasibility.py | 37 ++- tools/check_quran.py | 14 + tools/prepare_ci.py | 5 + tools/quran_qualification.py | 228 ++++++++++++++ tools/release_artifacts.py | 2 +- 21 files changed, 1069 insertions(+), 30 deletions(-) create mode 100644 docs/development/quran-failure-qualification.md create mode 100644 docs/development/quran-failure-validation-2026-10-11.md create mode 100644 firmware/esphome/components/openathan_quran_qualification/__init__.py create mode 100644 firmware/esphome/components/openathan_quran_qualification/qualification.cpp create mode 100644 firmware/esphome/components/openathan_quran_qualification/qualification.h create mode 100644 firmware/esphome/waveshare/quran-qualification.yaml create mode 100644 tests/quran_qualification_tests.cpp create mode 100644 tests/test_quran_qualification.py create mode 100644 tools/quran_qualification.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b2e2869..8d9cace 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -5,7 +5,7 @@ repository permissions and exact stable action revisions. It runs the C++ suite with UndefinedBehaviorSanitizer, the complete Python suite, and schema validation for the six developer configurations, the provisioning reference configuration, the isolated provisioning-validation configuration, and the isolated upgrade -qualification configuration. +qualification configuration and the isolated Quran qualification configuration. Waveshare V2 audio, display and attended diagnostic development configurations are also generated and compiled. These require isolated storage and disable every public update transport. Capacity inspection checks their 16 MiB headers explicitly; @@ -22,6 +22,8 @@ Separate jobs compile `scheduler/device.yaml`, `scheduler/validation.yaml` and the 1.5 MiB application budget and factory/OTA payload consistency. Two additional jobs build healthy upgrade qualification and forced startup-failure variants. Their generated test CA and signing key are ephemeral and never enter releases. +An additional Waveshare Quran qualification job compiles the private-fixture +transport and runtime observations, with isolated storage and updates disabled. The device build also tests the production settings JSON bridge with the exact resolved ArduinoJson library. The host suite exercises interrupted settings writes and the bridge against pinned ESPHome timezone conversion code. Additional host tests @@ -49,9 +51,9 @@ header sender with the SDK header implementation against a host transport. It checks that complete requests are transmitted, including the header-space boundary; it does not establish device TLS or runtime memory headroom. -Qualification C++ is a separate component compiled only into the two maintainer -variants. Host checks reject its inclusion in production or incomplete test -profiles. Capacity checks require generated definitions and verify component +Upgrade and Quran qualification C++ live in separate components compiled only +into their maintainer variants. Host checks reject inclusion in production or +incomplete test profiles. Capacity checks require generated definitions and verify component exclusion in production/ordinary isolated builds; release tests reject both qualification instrumentation and isolated storage markers in publishable images. @@ -85,7 +87,8 @@ the workflow or removing matrix entries, preserving all existing check names. ## Reproduce from a clean checkout Use a fresh disposable checkout and a Python 3.13 virtual environment. Install -CMake, a C++20 compiler and OpenSSL development headers (Linux only), then run: +CMake, a C++20 compiler, FFmpeg and OpenSSL development headers (Linux only), +then run: ```sh python -m pip install -r firmware/esphome/requirements.txt @@ -117,6 +120,10 @@ ESPHOME_BUILD_PATH=/tmp/openathan-ci-upgrade-failure python -m esphome \ Use the [upgrade runbook](../../firmware/esphome/upgrades/VALIDATION.md) for actual recordings, private trust setup, source-bound artifacts and attended hardware work. +Compile `waveshare/quran-qualification.yaml` separately for controlled Quran +failures; its image name is `openathan-test`. The +[Quran failure runbook](../../docs/development/quran-failure-qualification.md) +covers private trust, approved real recordings and attended recovery. CI establishes source/build behavior, not audible playback or hardware recovery. See the [dated device results](../../docs/development/feasibility-report.md). @@ -125,4 +132,8 @@ The device job also runs `tools/check_quran.py --arduinojson` against its resolved ArduinoJson headers. This compiles the production metadata worker and shared redirect policy under UBSan with deterministic HTTP/task adapters. It checks cancellation, Athan priority, URL restrictions, paging/search, malformed -responses and unavailable recordings; it does not establish physical streaming. +responses, transport failures, stalled/disconnected reads, fresh-Play recovery +and unavailable recordings. A separate UBSan executable checks qualification +routing and the original logical redirect policy. Python tests exercise real +local TLS rejection, served faults and profile/release exclusion. These checks +do not establish physical streaming or device TLS acceptance. diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index a969ac6..bfd56d5 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -26,7 +26,7 @@ jobs: - name: Install host dependencies run: | sudo apt-get update - sudo apt-get install -y cmake ninja-build g++ libssl-dev + sudo apt-get install -y cmake ninja-build g++ libssl-dev ffmpeg python -m pip install -r firmware/esphome/requirements.txt - name: Build and test core with UBSan run: | @@ -49,7 +49,7 @@ jobs: - name: Validate developer configurations run: | python tools/prepare_ci.py --output-dir "$RUNNER_TEMP/audio-fixture" - for config in feasibility/baseline feasibility/calculations feasibility/shared-audio scheduler/development scheduler/device scheduler/validation openathan provisioning/validation upgrades/qualification waveshare/production waveshare/audio waveshare/development waveshare/diagnostics; do + for config in feasibility/baseline feasibility/calculations feasibility/shared-audio scheduler/development scheduler/device scheduler/validation openathan provisioning/validation upgrades/qualification waveshare/production waveshare/audio waveshare/development waveshare/diagnostics waveshare/quran-qualification; do python -m esphome config "firmware/esphome/$config.yaml" > "$RUNNER_TEMP/config.log" 2>&1 || { cat "$RUNNER_TEMP/config.log"; exit 1; } done @@ -59,7 +59,7 @@ jobs: strategy: fail-fast: false matrix: - configuration: [device, validation, reference, provisioning-validation, upgrade-qualification, upgrade-startup-failure, waveshare-production, waveshare-audio, waveshare-display, waveshare-diagnostics] + configuration: [device, validation, reference, provisioning-validation, upgrade-qualification, upgrade-startup-failure, waveshare-production, waveshare-audio, waveshare-display, waveshare-diagnostics, quran-qualification] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -101,7 +101,7 @@ jobs: elif [ "$CONFIGURATION" = waveshare-production ]; then config=firmware/esphome/waveshare/production.yaml image=openathan - elif [ "$CONFIGURATION" = waveshare-audio ] || [ "$CONFIGURATION" = waveshare-display ] || [ "$CONFIGURATION" = waveshare-diagnostics ]; then + elif [ "$CONFIGURATION" = waveshare-audio ] || [ "$CONFIGURATION" = waveshare-display ] || [ "$CONFIGURATION" = waveshare-diagnostics ] || [ "$CONFIGURATION" = quran-qualification ]; then config=firmware/esphome/waveshare/audio.yaml if [ "$CONFIGURATION" = waveshare-display ]; then config=firmware/esphome/waveshare/development.yaml @@ -109,6 +109,9 @@ jobs: if [ "$CONFIGURATION" = waveshare-diagnostics ]; then config=firmware/esphome/waveshare/diagnostics.yaml fi + if [ "$CONFIGURATION" = quran-qualification ]; then + config=firmware/esphome/waveshare/quran-qualification.yaml + fi image=openathan-test else config="firmware/esphome/scheduler/$CONFIGURATION.yaml" diff --git a/docs/development/quran-failure-qualification.md b/docs/development/quran-failure-qualification.md new file mode 100644 index 0000000..133c49c --- /dev/null +++ b/docs/development/quran-failure-qualification.md @@ -0,0 +1,174 @@ +# Controlled Quran failure qualification + +This maintainer runbook prepares an attended Waveshare Box V2 session using +`firmware/esphome/waveshare/quran-qualification.yaml`. It exercises the ordinary +Quran worker and audio reader against a private HTTPS fixture server. The profile +adds transport routing and observations, uses isolated provisioning storage, and +disables firmware updates. It inherits encrypted diagnostic controls from the +Waveshare diagnostic profile. It cannot be packaged as release firmware. + +Host tests and successful builds establish fixture and source behavior. They do +not establish audible playback, device TLS verification, decoder recovery or +runtime memory headroom. Keep physical results in a dated validation record. +The [harness validation report](quran-failure-validation-2026-10-11.md) records +automated results and the pinned OTA comparison. +The separate continuous streaming soak still needs the ordinary MP3Quran path. + +## Prepare the private server and reviewed image + +Use the [pinned build environment](../../.github/workflows/README.md), including +FFmpeg for validation of test recordings. Run the C++/Python checks and +`tools/check_quran.py` with the resolved ArduinoJson headers. Rebase onto current +`main`, review the final diff, and pass the draft PR's checks before hardware use. +Retain source identity, dependency locks, OTA hashes and capacity results. + +Create a fresh directory outside Git. Replace the example IP with the build +host's reachable RFC1918 IPv4 address; use a port between 1024 and 65535. + +```sh +export QURAN_FIXTURE_DIR=/tmp/openathan-private-quran-session +python tools/quran_qualification.py init "$QURAN_FIXTURE_DIR" \ + --address 192.168.1.2 --port 8443 +``` + +The directory is mode 700; certificates, keys, controls and request logs are mode +600. Initialization refuses an existing directory. The CA expires after seven +days and server certificates after two days. Regenerate and rebuild when the +origin or CA changes, or certificates expire. The initial case is malformed +metadata, so a recording cannot start before explicit fixture selection. + +Copy the returned origin and **public** CA path into the ignored Waveshare +`secrets.yaml`, alongside the normal private build settings: + +```yaml +quran_qualification_origin: 'https://192.168.1.2:8443' +quran_qualification_ca_file: '/tmp/openathan-private-quran-session/ca.pem' +``` + +Build the reviewed source with a separate output directory: + +```sh +export OPENATHAN_BUILD_COMMIT=$(git rev-parse HEAD) +ESPHOME_BUILD_PATH=/tmp/openathan-quran-device python -m esphome compile \ + firmware/esphome/waveshare/quran-qualification.yaml \ + > /tmp/openathan-quran-device.log 2>&1 +python tools/check_feasibility.py \ + --build-dir /tmp/openathan-quran-device/openathan-test \ + --log /tmp/openathan-quran-device.log --audio-image "$APPROVED_AUDIO_IMAGE" +``` + +`APPROVED_AUDIO_IMAGE` is the independently validated shared audio already on the +device. CI's placeholder settings and non-decodable audio are compile-only; +never install those images or play their payloads. Use an approved real MP3 +outside Git for normal, truncated and stalled audio cases. The tool requires a +bounded `.mp3` file and runs a complete FFmpeg MP3 decode before selecting it. +It records the hash and refuses to serve a changed recording. It neither +fetches provider recordings nor bundles any third-party media. + +The fixture server serves only its two metadata routes and one recording route; +it never proxies a request. Each original provider URL is validated before being +mapped to the private origin, preserving its logical host and path. Redirects +return through the original policy before another connection is opened. TLS uses +the embedded private CA and verifies the **private origin's** hostname. This +session does not establish the public provider's certificate-chain acceptance. + +## Fresh attended device preflight + +Coordinate an operator and listener, then follow the +[scheduler](../../firmware/esphome/scheduler/VALIDATION.md) and +[provisioning hardware](../../firmware/esphome/provisioning/HARDWARE_TEST.md) +runbooks. Reidentify MAC, ESP32-S3, 16 MiB flash, 8 MiB PSRAM, partition layout, +selected application and OTA state. Inspect the actual current clock and prayer +window before interrupting operation. Preserve production settings, credentials, +consumption history, shared audio and recovery capability. + +Retain a freshly matched compatible application-only recovery image and a +reviewed write/preservation plan. Use only a scoped application write and +independent readback; do not restore historical full-flash settings or history. +Do not use the Atom upgrade-transition procedure for this different board. +Require `test_mode: true`, updates disabled, the reviewed source identity and +`quran.qualification.identity: OPENATHAN_QURAN_QUALIFICATION_V1` after startup. +Any storage fault, unexpected restart or identity mismatch stops the session. + +Initialize or reuse isolated setup through the existing device procedure. Keep +real time synchronization and select a conservative listening volume. Production +prayer settings/history remain untouched. Clear test settings/history only +through the scoped test maintenance procedure when necessary. + +## Failure and recovery matrix + +Start with normal fixtures and the approved recording: + +```sh +python tools/quran_qualification.py select "$QURAN_FIXTURE_DIR" normal \ + --media "$APPROVED_TEST_MP3" +python tools/quran_qualification.py serve "$QURAN_FIXTURE_DIR" +``` + +Load the local Quran page and select **Private qualification recording**, +**Fixture edition**, surah 1. Confirm clear audio and a normal finish. Retain +fresh playback/catalog observations, request logs and owner confirmation. +Selection changes affect future HTTP requests; they do not rewrite an active +response. For every fault, stop prior playback, select the case, then issue one +explicit Play with the current playback token. Catalog faults can also be +triggered by an explicit catalog refresh. Do not retry an uncertain Play. + +| Case | Required observation | +| --- | --- | +| `metadata-status`, `audio-status` | HTTP 503 fails without starting or retaining recording ownership. | +| `metadata-malformed`, `metadata-oversize` | Parsing fails, no recording starts, clients close, catalog or playback reports its own error. | +| `metadata-truncate` | Premature disconnect fails without accepting incomplete JSON. | +| `metadata-stall` | Partial body stalls; metadata returns an error within its I/O/deadline bounds. | +| `audio-content-type`, `audio-decode` | Wrong MIME type or deliberate text without MP3 frames fails; no persistent loading/playing state. | +| `audio-truncate` | Reader terminates and clears ownership; record whether the pipeline reports an error or a partial EOF. It cannot prove normal full-recording completion. | +| `audio-stall` | Partial audio stops or errors within the reader's bounds, without automatic retry or resumption. | +| `metadata-redirect-http`, `audio-redirect-http` | HTTPS downgrade is rejected before a second connection. | +| `metadata-redirect-host`, `audio-redirect-host` | Foreign logical host is rejected before a second connection. | +| `metadata-redirect-loop`, `audio-redirect-loop` | At most five requests occur (initial request plus four redirects); operation fails and releases clients. | + +Select faults in another terminal; only the audio truncation/stall cases require +media. Stall duration defaults to 40 seconds and is bounded to 45 seconds. + +```sh +python tools/quran_qualification.py select "$QURAN_FIXTURE_DIR" metadata-stall +python tools/quran_qualification.py select "$QURAN_FIXTURE_DIR" audio-stall \ + --media "$APPROVED_TEST_MP3" +``` + +For TLS faults, stop the server, keep the same origin/embedded CA, and restart +with `--tls-mode untrusted` or `--tls-mode wrong-host`. Both must fail before any +HTTP request is logged. Restart with `--tls-mode trusted` for recovery. An +unfinished handshake on one connection must not block other clients; the server +bounds workers to eight and handshake timeouts to five seconds. + +After **each** failure, observe at least 30 seconds of silence and stable state +without another HTTP/audio start. Select `normal` with the approved recording, +then explicitly Play again and confirm clear playback. Repeat Stop while a +metadata read is blocked and verify that its late result cannot start audio. +After normal catalog setup, repeat scheduled Athan preemption with the phone page +closed, and reject Quran Play during Athan. Keep the clock genuine and use only +isolated schedule offsets to arrange the attended test. Confirm exactly one +Athan, silence afterward and no Quran resumption. + +Snapshots under `quran.qualification` expose HTTP-attempt count, completed +metadata jobs, the metadata task's minimum unused stack in **bytes**, internal +free/largest/low-water heap, PSRAM free/largest block and reset reason. Sample +throughout faults, recovery and concurrent catalog activity; retain uptime and +source identity to identify restarts and sampling gaps. These observations apply +to the diagnostic qualification image. Static RAM and bounded tests do not prove +clean production runtime headroom or a leak-free continuous soak. + +## Restore and report + +Stop playback and the fixture server. Restore the freshly retained compatible +production application through its reviewed application-only plan. Independently +verify application bytes and preservation of settings, credentials, current +consumption history, audio, bootloader, partition table and unaffected flash. +After startup, verify synchronized standalone scheduling, the expected next +prayer and normal controls. Keep all private evidence outside Git. + +Record each case, TLS mode, logical URL, source/image hashes, elapsed bounds, +owner listening confirmation, memory samples and recovery outcome separately. +Mark unattended listening and untested cases explicitly. The server harness, +continuous real-provider soak, mono recording, cold-power preservation and +attended Wi-Fi interruption remain separate evidence gates. diff --git a/docs/development/quran-failure-validation-2026-10-11.md b/docs/development/quran-failure-validation-2026-10-11.md new file mode 100644 index 0000000..f3806e5 --- /dev/null +++ b/docs/development/quran-failure-validation-2026-10-11.md @@ -0,0 +1,72 @@ +# Quran failure harness validation — 2026-10-11 + +The controlled failure harness prepares an isolated Waveshare development profile +and private HTTPS fixtures. This report covers automated validation and actual +OTA capacity; the new profile has not been installed or physically qualified. +Use the [attended failure runbook](quran-failure-qualification.md) for that later +session. This change does not establish release readiness. + +## Automated validation + +- CMake/CTest: all 18 host cases passed under UndefinedBehaviorSanitizer. +- The complete pinned Python suite passed all 125 tests without skips. +- All 14 CI ESPHome configurations passed schema validation. +- The production Quran worker passed UBSan fault tests for HTTP initialization, + connection/header failures, malformed and incomplete JSON, disconnected and + stalled reads, slow trickles exceeding the absolute deadline, HTTP 503, Stop + during reads, absence of automatic retries and explicit fresh-Play recovery. +- A separate UBSan executable passed private-origin routing, preserved logical + redirect restrictions, redirect-loop bounds, client cleanup, private trust + selection and stack-observation aggregation. +- Python tests exercised real loopback TLS with trusted, untrusted and wrong-host + certificates; unfinished handshakes; HTTP 503, malformed/oversized metadata, + partial bodies, stalls, MIME/decode fixtures and logical redirect targets. + They also checked private-file permissions, full MP3 validation, media hash + immutability, actual profile generation, production component exclusion, + required isolated storage/disabled updates and release rejection. +- The updater host suite passed with the extended shared HTTP test adapter, + including production, isolated, disabled-update and upgrade qualification + variants. + +The HTTP audio payloads used by host tests are transport fixtures. They are +neither installed nor played. Decoder failure and audible recovery are pending +physical results; the fixture server's response is not such a result. + +## Matched pinned firmware measurements + +The baseline is merged `main` at +`2cea36869831f3e16f692bb2737cdea2b6fe0887`. Before/after source snapshots use +identical public compile-only settings, configurations, substitutions and build +identity, Python 3.13.5, ESPHome 2026.9.0, tzdata 2026.4, ESP-IDF 5.5.5, the reviewed +Xtensa toolchain and resolved component pins. Builds use separate disposable +output directories. Actual `firmware.ota.bin` payloads are measured; synthetic +shared-audio fixtures are compile-only and must never be installed or played. + +The existing capacity checker passed every image, including dependency locks, +factory/OTA consistency, exact partition table, qualification component exclusion +or inclusion, image identity and audio separation. Both 2,097,152-byte application +slots and the separate 3,670,016-byte shared-audio partition remain intact. + +| Configuration | Before OTA bytes | After OTA bytes | Delta | Remaining application budget | Free slot bytes | Static RAM bytes (delta) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Atom reference | 1,301,472 | 1,301,472 | 0 | 271,392 | 795,680 | 115,675 (0) | +| Waveshare production | 1,309,728 | 1,309,728 | 0 | 263,136 | 787,424 | 116,171 (0) | +| Waveshare diagnostics | 1,374,800 | 1,374,800 | 0 | 198,064 | 722,352 | 116,831 (0) | + +The new Quran qualification profile is **1,377,264 bytes**, adding **2,464 bytes** +over ordinary diagnostics. It retains **195,600 bytes** against the existing +1,572,864-byte application limit and **719,888 bytes** in its 2 MiB slot. Static +RAM is **116,839 bytes**, an eight-byte increase over diagnostics. Production and +ordinary diagnostic images have no OTA or static RAM growth from these guarded +hooks. Different build timestamps and paths +can change hashes without changing image size; these measurements are tied to +this comparison and do not replace prior dated measurements. + +The qualification image contains a session-specific public CA and observations; +its measurements are specific to that fixture configuration. Static RAM is +separate from runtime heap, fragmentation, PSRAM and stack headroom. No new +runtime or recovery claim follows from a successful build. + +No device connection, reset, flash write, listening, schedule change or release +operation occurred in this harness implementation session. Continuous real-provider +soak and attended device failure/recovery acceptance remain separate work. diff --git a/docs/development/quran-streaming.md b/docs/development/quran-streaming.md index 0f4de1a..11163f3 100644 --- a/docs/development/quran-streaming.md +++ b/docs/development/quran-streaming.md @@ -5,6 +5,8 @@ The local Quran page loads the live reciter catalog, searches across it, pages through matching reciters, and offers each selected recording edition's available surahs. This feature requires separate physical qualification before release; see the [implementation validation](quran-streaming-validation-2026-10-10.md). +The [controlled failure runbook](quran-failure-qualification.md) provides private +HTTPS fixtures and an isolated maintainer profile for subsequent hardware tests. ## Playback contract diff --git a/firmware/esphome/components/openathan_audio/quran_http.h b/firmware/esphome/components/openathan_audio/quran_http.h index 54092bb..fba9c7c 100644 --- a/firmware/esphome/components/openathan_audio/quran_http.h +++ b/firmware/esphome/components/openathan_audio/quran_http.h @@ -3,6 +3,9 @@ #include #include #include +#ifdef OPENATHAN_QURAN_QUALIFICATION +#include "esphome/components/openathan_quran_qualification/qualification.h" +#endif namespace esphome::openathan_device { // Shared by metadata and the pinned audio reader. Redirect validation happens @@ -21,6 +24,11 @@ class QuranHttp { if (audio ? !quran_audio_url(url) : !metadata) return false; esp_http_client_config_t config{}; config.url = url.c_str(); config.crt_bundle_attach = esp_crt_bundle_attach; +#ifdef OPENATHAN_QURAN_QUALIFICATION + const auto transport_url = openathan_quran_qualification::route(url); + config.url = transport_url.c_str(); + openathan_quran_qualification::configure_tls(config); +#endif config.disable_auto_redirect = true; config.timeout_ms = timeout_ms; config.buffer_size = buffer_size; config.buffer_size_tx = 1024; config.event_handler = event_; config.user_data = this; diff --git a/firmware/esphome/components/openathan_device/quran.cpp b/firmware/esphome/components/openathan_device/quran.cpp index 58a46bc..8116a33 100644 --- a/firmware/esphome/components/openathan_device/quran.cpp +++ b/firmware/esphome/components/openathan_device/quran.cpp @@ -127,6 +127,9 @@ void Quran::worker_(void *context) { std::unique_ptr>> work( static_cast> *>(context)); work->first->fetch_(*work->second); +#ifdef OPENATHAN_QURAN_QUALIFICATION + openathan_quran_qualification::worker_finished(uxTaskGetStackHighWaterMark(nullptr)); +#endif work->second->done = true; work.reset(); vTaskDelete(nullptr); @@ -220,6 +223,9 @@ void Quran::loop(bool connected, bool blocked) { } void Quran::shutdown() { if (job_) job_->cancel = true; resolving_ = false; } void Quran::snapshot(JsonObject root) { +#ifdef OPENATHAN_QURAN_QUALIFICATION + openathan_quran_qualification::snapshot(root); +#endif auto *playback = athan_ ? athan_->playback() : nullptr; root["supported"] = playback && playback->stream_supported(); root["playback_token"] = playback_token_(); diff --git a/firmware/esphome/components/openathan_quran_qualification/__init__.py b/firmware/esphome/components/openathan_quran_qualification/__init__.py new file mode 100644 index 0000000..61d4730 --- /dev/null +++ b/firmware/esphome/components/openathan_quran_qualification/__init__.py @@ -0,0 +1,58 @@ +"""Maintainer-only Quran transport, never included in release firmware.""" +from pathlib import Path +import ipaddress +from urllib.parse import urlsplit + +from esphome import codegen as cg, config_validation as cv, final_validate as fv + +DEPENDENCIES = ["openathan_device", "openathan_provisioning_validation"] + + +def origin(value): + value = cv.string_strict(value) + try: + parsed = urlsplit(value) + address = ipaddress.IPv4Address(parsed.hostname) + port = parsed.port + except (ValueError, TypeError): + raise cv.Invalid("Use https://PRIVATE_IPV4:PORT without a path") + private = any(address in ipaddress.IPv4Network(network) for network in + ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")) + if (not private or port is None or not 1024 <= port <= 65535 or + value != f"https://{address}:{port}"): + raise cv.Invalid("Use https://PRIVATE_IPV4:PORT without a path") + return value + + +CONFIG_SCHEMA = cv.Schema({cv.Required("origin"): origin, cv.Required("ca_file"): cv.file_}) + + +def validate(config): + full = fv.full_config.get() + if ("openathan_provisioning_validation" not in full or + full.get("esphome", {}).get("name") != "openathan-test" or + not full.get("esphome", {}).get("name_add_mac_suffix", False) or + full.get("openathan_device", {}).get("updates_enabled", True) or + "openathan_upgrade_qualification" in full): + raise cv.Invalid("Quran qualification requires isolated openathan-test firmware with updates disabled") + return config + + +FINAL_VALIDATE_SCHEMA = validate + + +async def to_code(config): + from cryptography import x509 + ca = Path(config["ca_file"]).read_bytes() + try: + text = ca.decode("ascii") + if len(ca) > 4096 or b"PRIVATE KEY" in ca or ca.count(b"-----BEGIN CERTIFICATE-----") != 1: + raise ValueError("embed only a bounded public CA certificate") + certificate = x509.load_pem_x509_certificate(ca) + if not certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca: + raise ValueError("not a CA") + except (ValueError, x509.ExtensionNotFound) as error: + raise cv.Invalid(f"Invalid Quran qualification CA: {error}") + cg.add_define("OPENATHAN_QURAN_QUALIFICATION") + cg.add_define("OPENATHAN_QURAN_QUALIFICATION_ORIGIN", config["origin"]) + cg.add_define("OPENATHAN_QURAN_QUALIFICATION_CA", text) diff --git a/firmware/esphome/components/openathan_quran_qualification/qualification.cpp b/firmware/esphome/components/openathan_quran_qualification/qualification.cpp new file mode 100644 index 0000000..7aeec4e --- /dev/null +++ b/firmware/esphome/components/openathan_quran_qualification/qualification.cpp @@ -0,0 +1,41 @@ +#include "qualification.h" +#include +#include +#include +#include + +namespace esphome::openathan_quran_qualification { +namespace { +constexpr auto MARKER = "OPENATHAN_QURAN_QUALIFICATION_V1"; +std::atomic stack_min{UINT32_MAX}, jobs{0}, requests{0}; +} +std::string route(const std::string &logical_url) { + // QuranHttp has already checked the original provider URL. Preserve its host + // and path so fixtures can return logical redirects through that same policy. + ++requests; + return std::string(OPENATHAN_QURAN_QUALIFICATION_ORIGIN) + "/" + MARKER + "/" + logical_url.substr(8); +} +void configure_tls(esp_http_client_config_t &config) { + config.crt_bundle_attach = nullptr; + config.cert_pem = OPENATHAN_QURAN_QUALIFICATION_CA; + // Hostname verification remains enabled for the private origin's certificate. +} +void worker_finished(unsigned minimum_free_bytes) { + ++jobs; + if (minimum_free_bytes < stack_min.load()) stack_min = minimum_free_bytes; +} +void snapshot(JsonObject root) { + auto result = root["qualification"].to(); + result["identity"] = MARKER; + result["origin"] = OPENATHAN_QURAN_QUALIFICATION_ORIGIN; + result["http_requests"] = requests.load(); + result["metadata_jobs"] = jobs.load(); + if (stack_min != UINT32_MAX) result["metadata_stack_min_free_bytes"] = stack_min.load(); + result["internal_free"] = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + result["internal_largest"] = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + result["internal_min"] = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + result["psram_free"] = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); + result["psram_largest"] = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); + result["reset_reason"] = int(esp_reset_reason()); +} +} diff --git a/firmware/esphome/components/openathan_quran_qualification/qualification.h b/firmware/esphome/components/openathan_quran_qualification/qualification.h new file mode 100644 index 0000000..82a0f87 --- /dev/null +++ b/firmware/esphome/components/openathan_quran_qualification/qualification.h @@ -0,0 +1,16 @@ +#pragma once +#include "esphome/core/defines.h" +#if !defined(OPENATHAN_QURAN_QUALIFICATION) || !defined(OPENATHAN_PROVISIONING_TEST_STORAGE) || \ + !defined(OPENATHAN_QURAN) || !defined(OPENATHAN_UPDATES_ENABLED) || OPENATHAN_UPDATES_ENABLED +#error "Quran qualification requires isolated Quran firmware with updates disabled" +#endif +#include +#include +#include "esphome/components/json/json_util.h" + +namespace esphome::openathan_quran_qualification { +std::string route(const std::string &logical_url); +void configure_tls(esp_http_client_config_t &config); +void worker_finished(unsigned minimum_free_bytes); +void snapshot(JsonObject root); +} diff --git a/firmware/esphome/waveshare/quran-qualification.yaml b/firmware/esphome/waveshare/quran-qualification.yaml new file mode 100644 index 0000000..9a515b2 --- /dev/null +++ b/firmware/esphome/waveshare/quran-qualification.yaml @@ -0,0 +1,11 @@ +# Maintainer-only controlled HTTPS fixtures; never install CI placeholder images. +packages: + diagnostic: !include diagnostics.yaml +external_components: + - source: + type: local + path: ../components + components: [openathan_quran_qualification] +openathan_quran_qualification: + origin: !secret quran_qualification_origin + ca_file: !secret quran_qualification_ca_file diff --git a/tests/quran_qualification_tests.cpp b/tests/quran_qualification_tests.cpp new file mode 100644 index 0000000..c1b0989 --- /dev/null +++ b/tests/quran_qualification_tests.cpp @@ -0,0 +1,39 @@ +#include "quran.h" +#include "../firmware/esphome/components/openathan_audio/quran_http.h" +#include "../firmware/esphome/components/openathan_quran_qualification/qualification.h" +#include +#include + +int64_t esp_timer_get_time() { return 1000000; } +using esphome::openathan_device::QuranHttp; +namespace fixture = esphome::openathan_quran_qualification; +int main() { + const std::string logical="https://cdn.mp3quran.net/qualification/001.mp3"; + const std::string mapped=std::string(OPENATHAN_QURAN_QUALIFICATION_ORIGIN)+ + "/OPENATHAN_QURAN_QUALIFICATION_V1/cdn.mp3quran.net/qualification/001.mp3"; + http_replies[mapped]={200,"","invalid audio",{},false,"audio/mpeg"}; + {QuranHttp response;assert(response.open(logical,true));assert(response.content_type=="audio/mpeg");} + assert(http_requests.size()==1&&http_requests[0].url==mapped&&!last_bundle&& + last_ca==OPENATHAN_QURAN_QUALIFICATION_CA&&http_requests[0].auto_redirect_disabled&&live_http_clients==0); + // The mapping never authorizes a private URL or a forbidden logical redirect. + for(const auto &destination:{"http://cdn.mp3quran.net/qualification/001.mp3","https://evil.example/a.mp3", + "https://192.168.1.2:8443/a.mp3"}){ + reset_http();http_replies[mapped]={302,destination,""}; + {QuranHttp response;assert(!response.open(logical,true));} + assert(http_requests.size()==1&&live_http_clients==0); + } + reset_http();{QuranHttp response;assert(!response.open(OPENATHAN_QURAN_QUALIFICATION_ORIGIN "/audio",true));} + assert(http_requests.empty()); + reset_http();http_replies[mapped]={302,logical,""}; + {QuranHttp response;assert(!response.open(logical,true));} + assert(http_requests.size()==5&&live_http_clients==0); + const std::string metadata="https://www.mp3quran.net/api/v3/reciters?language=eng"; + const auto target=fixture::route(metadata);reset_http();http_replies[target]={200,"","{\"reciters\":[]}"}; + {QuranHttp response;assert(response.open(metadata));} + fixture::worker_finished(4096);fixture::worker_finished(3056);fixture::worker_finished(3900); + JsonDocument state;fixture::snapshot(state.to()); + assert(state["qualification"]["identity"]=="OPENATHAN_QURAN_QUALIFICATION_V1"&& + state["qualification"]["metadata_stack_min_free_bytes"]==3056&& + state["qualification"]["metadata_jobs"]==3); + std::cout<<"Quran qualification routing, original redirect policy, isolated trust and observations passed\n"; +} diff --git a/tests/quran_service_tests.cpp b/tests/quran_service_tests.cpp index 958876b..bc2cd7d 100644 --- a/tests/quran_service_tests.cpp +++ b/tests/quran_service_tests.cpp @@ -6,7 +6,8 @@ #include #include -int64_t esp_timer_get_time() { return 1000000; } +int64_t clock_us = 1000000; +int64_t esp_timer_get_time() { return clock_us; } using namespace esphome::openathan_device; struct Audio : openathan::Playback { bool active{}, available{true}; uint32_t epoch{}; std::string url; @@ -27,7 +28,49 @@ int request_play(Quran &service, JsonDocument &selection, std::string &error) { return service.action("play", selection.as(), error); } void execute(Quran &service) { run_worker(); service.loop(true, false); assert(live_http_clients == 0); } +void failure_cases() { + const std::string metadata_url = "https://www.mp3quran.net/api/v3/reciters?language=eng&reciter=1"; + const std::string valid = R"({"reciters":[{"id":1,"moshaf":[{"id":9,"name":"Fixture","surah_list":"1","server":"https://cdn.mp3quran.net/example/"}]}]})"; + for (const auto failure : {HttpFailure::INIT, HttpFailure::OPEN, HttpFailure::HEADERS}) { + Audio audio; esphome::openathan_component::OpenAthan athan; athan.audio=&audio; + Quran service; service.begin(&athan,"failure");service.loop(true,false);reset_http(); + http_replies[metadata_url]={200,"",valid};http_failure=failure;http_failure_url=metadata_url; + auto play=input(R"({"reciter":1,"edition":9,"surah":1})");std::string error; + assert(request_play(service,play,error)==200);execute(service); + JsonDocument state;service.snapshot(state.to()); + assert(state["state"]=="error"&&!audio.active&&last_bundle); + // Time/loop changes cannot retry a failed request. A fresh explicit Play can. + const auto count=http_requests.size();for(int i=0;i<10;++i){clock_us+=10000000;service.loop(true,false);} + assert(http_requests.size()==count&&!audio.active); + http_failure=HttpFailure::NONE;assert(request_play(service,play,error)==200);execute(service);assert(audio.active); + } + for (unsigned scenario=0;scenario<7;++scenario) { + reset_http();clock_us=1000000; + Audio audio;esphome::openathan_component::OpenAthan athan;athan.audio=&audio; + Quran service;service.begin(&athan,"failure");service.loop(true,false); + auto &reply=http_replies[metadata_url];reply.data=valid; + if(scenario==0)reply.data=R"({"reciters":[{"id":)"; + if(scenario==1){reply.data=valid.substr(0,20);reply.incomplete=true;} + if(scenario==2)reply.read_steps={8,-1}; + if(scenario==3){reply.read_steps={8,0};reply.incomplete=true;} + if(scenario==4){reply.read_steps=std::vector(10,1);on_each_read=[](){clock_us+=10000000;};} + if(scenario==5){reply.read_steps=std::vector(10,1); + on_each_read=[&](){athan.stop();service.loop(true,false);on_each_read=nullptr;};} + if(scenario==6)reply.status=503; + auto play=input(R"({"reciter":1,"edition":9,"surah":1})");std::string error; + assert(request_play(service,play,error)==200);execute(service); + JsonDocument state;service.snapshot(state.to()); + assert(!audio.active&&audio.url.empty());assert(state["state"]==(scenario==5?"idle":"error")); + if(scenario==4)assert(clock_us<=41000000); // Absolute deadline stops slow trickles. + const auto calls=http_requests.size();for(int i=0;i<10;++i){clock_us+=10000000;service.loop(true,false);} + assert(http_requests.size()==calls); + on_each_read=nullptr;reply={200,"",valid};clock_us=1000000; + assert(request_play(service,play,error)==200);execute(service);assert(audio.active); + } + reset_http(); +} int main() { + failure_cases(); assert(quran_audio_url("https://cdn.mp3quran.net/audio/example/001.mp3")); assert(quran_audio_url("https://server15.mp3quran.net/example/001.mp3")); for (const char *url : {"http://cdn.mp3quran.net/a.mp3", "https://cdn.mp3quran.net.evil/a.mp3", diff --git a/tests/test_ci_build_policy.py b/tests/test_ci_build_policy.py index ca1e675..194b26c 100644 --- a/tests/test_ci_build_policy.py +++ b/tests/test_ci_build_policy.py @@ -178,7 +178,7 @@ def test_all_expensive_steps_require_successful_docs_only_detection_to_skip(self self.assertNotIn("if", build) self.assertEqual(build["strategy"]["matrix"]["configuration"], [ "device", "validation", "reference", "provisioning-validation", - "upgrade-qualification", "upgrade-startup-failure", "waveshare-production", "waveshare-audio", "waveshare-display", "waveshare-diagnostics"]) + "upgrade-qualification", "upgrade-startup-failure", "waveshare-production", "waveshare-audio", "waveshare-display", "waveshare-diagnostics", "quran-qualification"]) steps = build["steps"] self.assertEqual(steps[0]["with"]["fetch-depth"], 2) detector = next(step for step in steps if step.get("id") == "build_policy") diff --git a/tests/test_quran_qualification.py b/tests/test_quran_qualification.py new file mode 100644 index 0000000..1d35e61 --- /dev/null +++ b/tests/test_quran_qualification.py @@ -0,0 +1,280 @@ +"""Private Quran fixtures, verified TLS, profile isolation and release exclusion.""" +import asyncio +import importlib.util +import json +import os +from pathlib import Path +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +import check_feasibility +import quran_qualification as qualification +import release_artifacts +from release_fixtures import esp_image, firmware + +META = qualification.PREFIX + "www.mp3quran.net/api/v3/reciters?language=eng&reciter=1" +AUDIO = qualification.PREFIX + "cdn.mp3quran.net/qualification/001.mp3" + + +class QuranFixtures(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.directory = self.root / "feed" + qualification.initialize(self.directory, "192.168.1.2") + + def tls_feed(self, mode="trusted"): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(self.directory / f"server-{mode}.pem", self.directory / "server-key.pem") + server = qualification.tls_server(("127.0.0.1", 0), self.directory, context) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(lambda: thread.join(timeout=2)) + self.addCleanup(server.shutdown) + return server.server_address + + def fetch(self, address, path=META, timeout=2): + context = ssl.create_default_context(cafile=str(self.directory / "ca.pem")) + with socket.create_connection(address, timeout=timeout) as raw: + with context.wrap_socket(raw, server_hostname="192.168.1.2") as connection: + connection.sendall(f"GET {path} HTTP/1.0\r\nHost: 192.168.1.2\r\n\r\n".encode()) + chunks = [] + while chunk := connection.recv(8192): + chunks.append(chunk) + header, body = b"".join(chunks).split(b"\r\n\r\n", 1) + return header, body + + def test_private_files_and_fresh_initialization(self): + self.assertEqual(self.directory.stat().st_mode & 0o777, 0o700) + self.assertTrue(all(path.stat().st_mode & 0o777 == 0o600 for path in self.directory.iterdir())) + self.assertEqual(json.loads((self.directory / "control.json").read_text())["case"], "metadata-malformed") + with self.assertRaises(FileExistsError): + qualification.initialize(self.directory, "192.168.1.2") + for invalid in ("127.0.0.1", "0.0.0.0", "192.0.2.1", "8.8.8.8", "localhost"): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + qualification.initialize(self.root / "invalid", invalid) + for port in (0, 443, 65536): + with self.subTest(port=port), self.assertRaises(ValueError): + qualification.initialize(self.root / "invalid", "192.168.1.2", port) + self.directory.chmod(0o755) + with self.assertRaisesRegex(ValueError, "mode 700"): + qualification.select(self.directory, "metadata-malformed") + + def test_real_tls_verifies_ca_and_hostname(self): + for mode in ("wrong-host", "untrusted"): + with self.subTest(mode=mode), self.assertRaises(ssl.SSLCertVerificationError): + self.fetch(self.tls_feed(mode)) + header, body = self.fetch(self.tls_feed()) + self.assertIn(b"200", header) + self.assertTrue(body) + + def test_unfinished_handshake_cannot_block_other_clients(self): + address = self.tls_feed() + with socket.create_connection(address, timeout=2): + header, _ = self.fetch(address) + self.assertIn(b"200", header) + + def test_metadata_faults_and_redirect_targets(self): + address = self.tls_feed() + for case in qualification.CASES: + if not case.startswith("metadata-"): + continue + qualification.select(self.directory, case, stall_seconds=0.05) + header, body = self.fetch(address) + with self.subTest(case=case): + if case.endswith("status"): + self.assertIn(b"503", header); self.assertFalse(body) + elif "redirect" in case: + self.assertIn(b"302", header) + self.assertFalse(body) + target = ("http://www.mp3quran.net/api/v3/reciters?language=eng&reciter=1" if case.endswith("http") else + "https://evil.example/forbidden" if case.endswith("host") else + "https://www.mp3quran.net/api/v3/reciters?language=eng&reciter=1") + self.assertIn(f"Location: {target}".encode(), header) + elif case.endswith("malformed"): + with self.assertRaises(ValueError): json.loads(body) + elif case.endswith("oversize"): + self.assertGreater(len(body), 16384) + else: + declared = int(next(line.split(b": ")[1] for line in header.split(b"\r\n") if line.startswith(b"Content-Length:"))) + self.assertLess(len(body), declared) + requests = [json.loads(line) for line in (self.directory / "requests.jsonl").read_text().splitlines()] + self.assertEqual(len(requests), 8) + self.assertTrue(all(row["logical_url"].startswith("https://www.mp3quran.net/") for row in requests)) + + def test_audio_faults_and_validated_media_immutability(self): + address = self.tls_feed() + # Host HTTP payload only; never installed, decoded or played on a device. + media = self.root / "host-payload.mp3" + media.write_bytes(b"host transport fixture" * 100) + control = dict(media=str(media), media_sha256=qualification.hashlib.sha256(media.read_bytes()).hexdigest()) + with patch.object(qualification, "validate_media", return_value=control): + for case in ("normal", "audio-truncate", "audio-stall"): + qualification.select(self.directory, case, media, stall_seconds=0.05) + header, body = self.fetch(address, AUDIO) + self.assertIn(b"Content-Type: audio/mpeg", header) + if case == "normal": self.assertEqual(body, media.read_bytes()) + else: self.assertLess(len(body), media.stat().st_size) + media.write_bytes(b"changed after staging") + with self.assertRaises(ValueError): self.fetch(address, AUDIO) + for case in ("audio-status", "audio-content-type", "audio-decode", "audio-redirect-http", "audio-redirect-host", "audio-redirect-loop"): + qualification.select(self.directory, case) + header, body = self.fetch(address, AUDIO) + if case.endswith("status"): + self.assertIn(b"503", header); self.assertFalse(body) + elif "redirect" in case: + self.assertIn(b"302", header); self.assertFalse(body) + elif case.endswith("content-type"): self.assertIn(b"Content-Type: text/plain", header) + else: self.assertIn(b"No audio frames", body) + + def test_stall_really_leaves_the_body_incomplete(self): + qualification.select(self.directory, "metadata-stall", stall_seconds=0.5) + with self.assertRaises(TimeoutError): + self.fetch(self.tls_feed(), timeout=0.15) + + def test_selection_requires_real_media_and_valid_duration(self): + for case in qualification.MEDIA_CASES: + with self.subTest(case=case), self.assertRaisesRegex(ValueError, "approved real MP3"): + qualification.select(self.directory, case) + for duration in (-1, 46, float("nan"), float("inf")): + with self.subTest(duration=duration), self.assertRaises(ValueError): + qualification.select(self.directory, "metadata-malformed", stall_seconds=duration) + invalid = self.root / "not-a-recording.mp3" + invalid.write_bytes(b"deliberately invalid, no MP3 frames") + with self.assertRaises(subprocess.CalledProcessError): + qualification.validate_media(invalid) + with patch.object(qualification.subprocess, "run") as decode: + qualification.validate_media(invalid) + arguments = decode.call_args.args[0] + self.assertEqual(arguments[arguments.index("-i")-2:arguments.index("-i")], ["-f", "mp3"]) + self.assertTrue(decode.call_args.kwargs["check"]) + link = self.root / "link.mp3"; link.symlink_to(invalid) + with self.assertRaises(ValueError): qualification.validate_media(link) + with self.assertRaises(ValueError): qualification.validate_media(ROOT / "recording.mp3") + + +class QuranQualificationProfiles(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.directory = self.root / "feed" + qualification.initialize(self.directory, "192.168.1.2") + path = ROOT / "firmware/esphome/components/openathan_quran_qualification/__init__.py" + spec = importlib.util.spec_from_file_location("quran_qualification_config", path) + self.module = importlib.util.module_from_spec(spec); spec.loader.exec_module(self.module) + + def test_lan_origin_and_profile_validation(self): + self.assertEqual(self.module.origin("https://192.168.1.2:8443"), "https://192.168.1.2:8443") + for invalid in ("http://192.168.1.2:8443", "https://github.com:8443", "https://127.0.0.1:8443", + "https://192.0.2.1:8443", "https://192.168.1.2:8443/", "https://x@192.168.1.2:8443", + "https://192.168.1.2:8443?", "https://192.168.1.2:08443", "https://10.0.0.1:443", None): + with self.subTest(invalid=invalid), self.assertRaises(self.module.cv.Invalid): self.module.origin(invalid) + valid = dict(esphome=dict(name="openathan-test", name_add_mac_suffix=True), + openathan_device=dict(updates_enabled=False), openathan_provisioning_validation={}) + with patch.object(self.module.fv, "full_config") as full: + full.get.return_value = valid + self.assertEqual(self.module.validate({}), {}) + for override in (dict(esphome=dict(name="openathan", name_add_mac_suffix=True)), + dict(esphome=dict(name="openathan-test", name_add_mac_suffix=False)), + dict(openathan_device=dict(updates_enabled=True)), + dict(openathan_device={}), dict(openathan_upgrade_qualification={})): + full.get.return_value = {**valid, **override} + with self.assertRaises(self.module.cv.Invalid): self.module.validate({}) + full.get.return_value = {key: value for key, value in valid.items() if key != "openathan_provisioning_validation"} + with self.assertRaises(self.module.cv.Invalid): self.module.validate({}) + + def test_ca_validation_embeds_only_one_public_ca(self): + config = dict(origin="https://192.168.1.2:8443", ca_file=str(self.directory / "ca.pem")) + with patch.object(self.module.cg, "add_define") as define: + asyncio.run(self.module.to_code(config)) + self.assertEqual(define.call_count, 3) + for data in ((self.directory / "server-trusted.pem").read_bytes(), b"not a certificate", + (self.directory / "ca-key.pem").read_bytes(), + (self.directory / "ca.pem").read_bytes() * 2, b"x" * 4097): + invalid = self.root / "invalid.pem"; invalid.write_bytes(data) + with self.assertRaises(self.module.cv.Invalid): + asyncio.run(self.module.to_code({**config, "ca_file": str(invalid)})) + + def profile(self, defines, compiled=True, source=True): + build = self.root / "build" + (build / "src/esphome/core").mkdir(parents=True, exist_ok=True) + (build / "src/esphome/core/defines.h").write_text(defines) + (build / "build").mkdir(exist_ok=True) + component = build / "src/esphome/components/openathan_quran_qualification" + if source: component.mkdir(parents=True, exist_ok=True) + elif component.exists(): component.rmdir() + (build / "build/compile_commands.json").write_text( + "/openathan_quran_qualification/qualification.cpp" if compiled else "[]") + return build + + def test_ordinary_builds_exclude_qualification_definitions_and_code(self): + app = firmware()[1] + self.assertEqual(check_feasibility.inspect_build_profile( + self.profile("", compiled=False, source=False), app), (False, False)) + for defines in ('#define OPENATHAN_QURAN_QUALIFICATION_CA "test CA"\n', + '#define OPENATHAN_QURAN_QUALIFICATION 1\n', + '#define USE_OPENATHAN_QURAN_QUALIFICATION\n'): + with self.subTest(defines=defines), self.assertRaisesRegex(ValueError, "exclude"): + check_feasibility.inspect_build_profile(self.profile(defines, compiled=False, source=False), app) + with self.assertRaisesRegex(ValueError, "exclude"): + check_feasibility.inspect_build_profile(self.profile("", source=False), app) + with self.assertRaisesRegex(ValueError, "exclude"): + check_feasibility.inspect_build_profile(self.profile("", compiled=False), app) + + def test_capacity_profile_and_release_exclusion(self): + app = esp_image(b"x" * 128 + qualification.MARKER.encode()) + defines = '#define OPENATHAN_QURAN_QUALIFICATION\n#define OPENATHAN_PROVISIONING_TEST_STORAGE\n#define OPENATHAN_UPDATES_ENABLED 0\n' + self.assertEqual(check_feasibility.inspect_build_profile(self.profile(defines), app), (True, True)) + for invalid in (defines.replace('OPENATHAN_PROVISIONING_TEST_STORAGE', 'OTHER'), + defines.replace('UPDATES_ENABLED 0', 'UPDATES_ENABLED 1'), + defines + '#define OPENATHAN_UPGRADE_QUALIFICATION\n', + defines.replace('#define OPENATHAN_QURAN_QUALIFICATION\n', '')): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + check_feasibility.inspect_build_profile(self.profile(invalid), app) + with self.assertRaisesRegex(ValueError, "separate component"): + check_feasibility.inspect_build_profile(self.profile(defines, compiled=False), app) + with self.assertRaisesRegex(ValueError, "identity marker"): + check_feasibility.inspect_build_profile(self.profile(defines), firmware()[1]) + factory, _ = firmware() + with self.assertRaisesRegex(ValueError, "Qualification firmware"): + release_artifacts.validate_firmware_images(factory[:0x10000] + app, app) + + def test_real_profile_generation_and_production_exclusion(self): + config = self.root / "qualification.yaml" + config.write_text(f"packages:\n product: !include {ROOT}/firmware/esphome/waveshare/quran-qualification.yaml\n") + (self.root / "secrets.yaml").write_text( + 'diagnostic_api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=\n' + 'quran_qualification_origin: https://192.168.1.2:8443\n' + f'quran_qualification_ca_file: {self.directory / "ca.pem"}\n') + build = self.root / "generated" + def generate(): + result = subprocess.run([sys.executable, "-m", "esphome", "compile", "--only-generate", str(config)], + env={**os.environ, "ESPHOME_BUILD_PATH": str(build)}, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + generate() + generated = build / "openathan-test" + defines = (generated / "src/esphome/core/defines.h").read_text() + self.assertIn('#define OPENATHAN_QURAN_QUALIFICATION\n', defines) + self.assertIn('#define OPENATHAN_UPDATES_ENABLED 0\n', defines) + self.assertIn('#define OPENATHAN_PROVISIONING_TEST_STORAGE\n', defines) + self.assertTrue((generated / "src/esphome/components/openathan_quran_qualification/qualification.cpp").is_file()) + config.write_text(f"packages:\n product: !include {ROOT}/firmware/esphome/waveshare/production.yaml\n") + generate() + production = build / "openathan" + self.assertNotIn('OPENATHAN_QURAN_QUALIFICATION', (production / 'src/esphome/core/defines.h').read_text()) + self.assertFalse((production / 'src/esphome/components/openathan_quran_qualification').exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/upgrade_stubs/esp_http_client.h b/tests/upgrade_stubs/esp_http_client.h index 3b0e0cc..295e8b6 100644 --- a/tests/upgrade_stubs/esp_http_client.h +++ b/tests/upgrade_stubs/esp_http_client.h @@ -5,18 +5,20 @@ #include #include #include +#include using esp_err_t=int; constexpr int HTTP_EVENT_ON_HEADER=1; struct esp_http_client_event_t{int event_id;char *header_key,*header_value;void *user_data;}; struct esp_http_client_config_t{const char *url;const char *cert_pem;int(*crt_bundle_attach)(void *);int timeout_ms;bool disable_auto_redirect;int buffer_size,buffer_size_tx;int(*event_handler)(esp_http_client_event_t *);void *user_data;}; -struct HttpReply{int status{200};std::string location,data;}; +struct HttpReply{int status{200};std::string location,data;std::vector read_steps;bool incomplete{};std::string content_type;}; struct HttpRequest{std::string url;int tx,rx,timeout;bool auto_redirect_disabled;}; -struct Http{std::string data;size_t offset{};HttpReply reply;esp_http_client_config_t config;std::string url;}; +struct Http{std::string data;size_t offset{};HttpReply reply;esp_http_client_config_t config;std::string url;size_t read_calls{};}; using esp_http_client_handle_t=Http *; inline std::string descriptor_response,application_response; inline bool transfer_fail=false; inline void (*on_read)()=nullptr; inline void (*on_open)()=nullptr; +inline std::function on_each_read; inline std::string last_url,last_ca; inline bool last_bundle{}; inline std::map http_replies; @@ -25,7 +27,7 @@ enum class HttpFailure{NONE,INIT,OPEN,HEADERS}; inline HttpFailure http_failure=HttpFailure::NONE; inline std::string http_failure_url; inline unsigned live_http_clients{},http_cleanups{},live_http_tx_bytes{},peak_http_tx_bytes{}; -inline void reset_http(){assert(live_http_clients==0 && live_http_tx_bytes==0);http_replies.clear();http_requests.clear();http_failure=HttpFailure::NONE;http_failure_url.clear();http_cleanups=peak_http_tx_bytes=0;} +inline void reset_http(){assert(live_http_clients==0 && live_http_tx_bytes==0);http_replies.clear();http_requests.clear();http_failure=HttpFailure::NONE;http_failure_url.clear();http_cleanups=peak_http_tx_bytes=0;transfer_fail=false;on_read=on_open=nullptr;on_each_read=nullptr;} inline bool http_fails(HttpFailure stage,const std::string &url){return http_failure==stage && http_failure_url==url;} inline Http *esp_http_client_init(const esp_http_client_config_t *c){ last_url=c->url;last_ca=c->cert_pem?c->cert_pem:"";last_bundle=c->crt_bundle_attach!=nullptr; @@ -56,10 +58,23 @@ inline int esp_http_client_fetch_headers(Http *h){ esp_http_client_event_t event{HTTP_EVENT_ON_HEADER,key,h->reply.location.data(),h->config.user_data}; h->config.event_handler(&event); } + if(!h->reply.content_type.empty()){ + char key[]="Content-Type"; + esp_http_client_event_t event{HTTP_EVENT_ON_HEADER,key,h->reply.content_type.data(),h->config.user_data}; + h->config.event_handler(&event); + } return h->data.size(); } inline int esp_http_client_get_status_code(Http *h){return h->reply.status;} inline int esp_http_client_set_user_data(Http *h,void *value){h->config.user_data=value;return 0;} inline int esp_http_client_get_content_length(Http *h){return h->data.size();} -inline int esp_http_client_read(Http *h,char *out,int size){if(on_read){auto callback=on_read;on_read=nullptr;callback();}if(transfer_fail)return -1;size=std::min(size,h->data.size()-h->offset);memcpy(out,h->data.data()+h->offset,size);h->offset+=size;return size;} -inline bool esp_http_client_is_complete_data_received(Http *h){return h->offset==h->data.size();} +inline int esp_http_client_read(Http *h,char *out,int size){ + if(on_read){auto callback=on_read;on_read=nullptr;callback();} + if(on_each_read){auto callback=on_each_read;callback();} + if(transfer_fail)return -1; + if(h->read_callsreply.read_steps.size()){ + const int step=h->reply.read_steps[h->read_calls++];if(step<=0)return step;size=std::min(size,step); + } + size=std::min(size,h->data.size()-h->offset);memcpy(out,h->data.data()+h->offset,size);h->offset+=size;return size; +} +inline bool esp_http_client_is_complete_data_received(Http *h){return !h->reply.incomplete&&h->offset==h->data.size();} diff --git a/tools/check_feasibility.py b/tools/check_feasibility.py index 27f169d..be19c44 100644 --- a/tools/check_feasibility.py +++ b/tools/check_feasibility.py @@ -17,18 +17,31 @@ def inspect_build_profile(build_dir, app): from release_artifacts import reject_test_material defines = (build_dir / "src/esphome/core/defines.h").read_text() - qualification = "#define OPENATHAN_UPGRADE_QUALIFICATION\n" in defines + upgrade = "#define OPENATHAN_UPGRADE_QUALIFICATION\n" in defines + quran = "#define OPENATHAN_QURAN_QUALIFICATION\n" in defines + qualification = upgrade or quran isolated = "#define OPENATHAN_PROVISIONING_TEST_STORAGE\n" in defines - component = build_dir / "src/esphome/components/openathan_upgrade_qualification" - compiled = "/openathan_upgrade_qualification/qualification.cpp" in (build_dir / "build/compile_commands.json").read_text() - if qualification: - if not isolated: - raise ValueError("Qualification capacity checks require isolated storage") - if not component.is_dir() or not compiled: - raise ValueError("Qualification build must compile its separate component") - elif (component.exists() or compiled or - re.search(r"^#define (?:OPENATHAN_UPGRADE_QUALIFICATION|OPENATHAN_QUALIFICATION_\w+|USE_OPENATHAN_UPGRADE_QUALIFICATION)\b", defines, re.M)): - raise ValueError("Production/ordinary builds must exclude the qualification component and definitions") + if upgrade and quran: + raise ValueError("Qualification profiles are mutually exclusive") + if ((not upgrade and b"OPENATHAN_QUALIFICATION_V1" in app) or + (not quran and b"OPENATHAN_QURAN_QUALIFICATION_V1" in app)): + raise ValueError("Qualification identity must match the build profile") + commands = (build_dir / "build/compile_commands.json").read_text() + for kind, enabled, family in (("upgrade", upgrade, "OPENATHAN_QUALIFICATION_"), + ("quran", quran, "OPENATHAN_QURAN_QUALIFICATION_")): + component = build_dir / f"src/esphome/components/openathan_{kind}_qualification" + compiled = f"/openathan_{kind}_qualification/qualification.cpp" in commands + if enabled: + if not isolated: + raise ValueError("Qualification capacity checks require isolated storage") + if not component.is_dir() or not compiled: + raise ValueError("Qualification build must compile its separate component") + elif (component.exists() or compiled or re.search( + rf"^#define (?:OPENATHAN_{kind.upper()}_QUALIFICATION|{family}\w+|USE_OPENATHAN_{kind.upper()}_QUALIFICATION)\b", defines, re.M)): + raise ValueError("Production/ordinary builds must exclude the qualification component and definitions") + if quran and ('#define OPENATHAN_UPDATES_ENABLED 0\n' not in defines or + b"OPENATHAN_QURAN_QUALIFICATION_V1" not in app): + raise ValueError("Quran qualification requires disabled updates and its identity marker") reject_test_material(app, allow_qualification=qualification, allow_isolated=isolated) return qualification, isolated @@ -78,7 +91,7 @@ def inspect_firmware(build_dir, log, root=ROOT): qualification, isolated = inspect_build_profile(build_dir, app) defines = (build_dir / "src/esphome/core/defines.h").read_text() waveshare = '#define OPENATHAN_HARDWARE "waveshare-esp32-s3-touch-lcd-1_85c-box-v2"' in defines - if waveshare and isolated and (qualification or '#define OPENATHAN_UPDATES_ENABLED 0\n' not in defines): + if waveshare and isolated and ('#define OPENATHAN_UPGRADE_QUALIFICATION\n' in defines or '#define OPENATHAN_UPDATES_ENABLED 0\n' not in defines): raise ValueError("Waveshare capacity inspection requires isolated development with updates disabled") flash_bytes = 0x1000000 if waveshare else 0x800000 validate_firmware_images(factory, app, allow_qualification=qualification, allow_isolated=isolated, diff --git a/tools/check_quran.py b/tools/check_quran.py index ded2142..6209918 100644 --- a/tools/check_quran.py +++ b/tools/check_quran.py @@ -53,6 +53,20 @@ def check(arduinojson): str(worker), str(worker_controls), "-o", str(integration)], check=True) subprocess.run([str(integration)], check=True) print("Atom volume/Quran continuity, pending admission, zero, rapid changes, Stop and faults passed") + qualified = Path(directory) / "quran-qualification-tests" + subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-fsanitize=undefined", + "-fno-sanitize-recover=all", "-DOPENATHAN_QURAN", "-DOPENATHAN_QURAN_QUALIFICATION", + "-DOPENATHAN_PROVISIONING_TEST_STORAGE", "-DOPENATHAN_UPDATES_ENABLED=0", + '-DOPENATHAN_QURAN_QUALIFICATION_ORIGIN="https://192.168.1.2:8443"', + '-DOPENATHAN_QURAN_QUALIFICATION_CA="host test CA"', + "-I" + str(ROOT / "tests/upgrade_stubs"), "-I" + str(ROOT / "tests/stubs"), + "-I" + arduinojson, "-I" + str(ROOT / "firmware"), + "-I" + str(ROOT / "firmware/esphome/components/openathan_device"), + "-I" + str(ROOT / "lib/openathan-core/include"), + str(ROOT / "tests/quran_qualification_tests.cpp"), + str(ROOT / "firmware/esphome/components/openathan_quran_qualification/qualification.cpp"), + "-o", str(qualified)], check=True) + subprocess.run([str(qualified)], check=True) if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) diff --git a/tools/prepare_ci.py b/tools/prepare_ci.py index 5dd04ed..bba7bdd 100644 --- a/tools/prepare_ci.py +++ b/tools/prepare_ci.py @@ -31,6 +31,11 @@ def prepare(output_dir): with targets[2].open("ab") as handle: for key, value in qualification.items(): handle.write(f"qualification_{key}: {value!r}\n".encode()) + from quran_qualification import initialize as initialize_quran + quran = initialize_quran(output_dir / "quran-qualification", "192.168.1.2") + with targets[3].open("ab") as handle: + for key, value in quran.items(): + handle.write(f"quran_qualification_{key}: {value!r}\n".encode()) payloads = [b"".join(hashlib.sha256(f"{name}-{i}".encode()).digest() for i in range(256)) for name in ("normal", "fajr")] image, _ = build_image(*payloads) diff --git a/tools/quran_qualification.py b/tools/quran_qualification.py new file mode 100644 index 0000000..fdb3f42 --- /dev/null +++ b/tools/quran_qualification.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Private HTTPS fixtures for isolated Quran qualification; never a public feed.""" +import argparse +from datetime import datetime, timedelta, timezone +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import os +from pathlib import Path +import ssl +import subprocess +import threading +import time +from urllib.parse import urlsplit + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID +from audio_image import ROOT +from release_artifacts import require +from upgrade_qualification import lan_address, private_directory, write_private + +MARKER = "OPENATHAN_QURAN_QUALIFICATION_V1" +PREFIX = "/" + MARKER + "/" +CASES = ("normal", "metadata-status", "metadata-malformed", "metadata-oversize", "metadata-truncate", "metadata-stall", + "metadata-redirect-http", "metadata-redirect-host", "metadata-redirect-loop", + "audio-status", "audio-content-type", "audio-decode", "audio-truncate", "audio-stall", + "audio-redirect-http", "audio-redirect-host", "audio-redirect-loop") +MEDIA_CASES = ("normal", "audio-truncate", "audio-stall") +MAX_MEDIA = 32 * 1024 * 1024 + + +def initialize(directory, address, port=8443): + address = lan_address(address) + require(1024 <= port <= 65535, "Invalid HTTPS port") + directory = private_directory(directory, fresh=True) + now = datetime.now(timezone.utc) + ca_key, server_key = (ec.generate_private_key(ec.SECP256R1()) for _ in range(2)) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "OpenAthan Quran private qualification CA")]) + ca = (x509.CertificateBuilder().subject_name(ca_name).issuer_name(ca_name) + .public_key(ca_key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)).not_valid_after(now + timedelta(days=7)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension(x509.KeyUsage(False, False, False, False, False, True, True, False, False), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) + .sign(ca_key, hashes.SHA256())) + for mode, name, signer in (("trusted", address, ca_key), ("wrong-host", "192.0.2.1", ca_key), + ("untrusted", address, server_key)): + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)]) + certificate = (x509.CertificateBuilder().subject_name(subject) + .issuer_name(subject if mode == "untrusted" else ca_name) + .public_key(server_key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)).not_valid_after(now + timedelta(days=2)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.KeyUsage(True, False, False, False, False, False, False, False, False), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(signer.public_key()), critical=False) + .add_extension(x509.SubjectAlternativeName([ + x509.IPAddress(ipaddress.IPv4Address(name)), x509.DNSName(name)]), critical=False) + .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .sign(signer, hashes.SHA256())) + write_private(directory / f"server-{mode}.pem", certificate.public_bytes(serialization.Encoding.PEM)) + for name, key in (("ca-key.pem", ca_key), ("server-key.pem", server_key)): + write_private(directory / name, key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + write_private(directory / "ca.pem", ca.public_bytes(serialization.Encoding.PEM)) + write_private(directory / "feed.json", json.dumps(dict(address=address, port=port)).encode()) + write_private(directory / "control.json", json.dumps(dict(case="metadata-malformed", stall_seconds=40)).encode()) + return dict(origin=f"https://{address}:{port}", ca_file=str(directory / "ca.pem")) + + +def validate_media(path): + path = path.absolute() + require(not path.resolve().is_relative_to(ROOT.resolve()) and not path.is_symlink(), "Keep real test media outside Git") + require(path.suffix.lower() == ".mp3" and 0 < path.stat().st_size <= MAX_MEDIA, "Use a bounded real MP3 recording") + # A suffix or random format fixture is not proof of playable media. No skip + # switch is exposed: CI's non-decodable audio must never reach a device. + subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-xerror", "-f", "mp3", "-i", str(path), + "-map", "0:a:0", "-f", "null", "-"], check=True, timeout=120, capture_output=True) + return dict(media=str(path), media_sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + + +def select(directory, case, media=None, stall_seconds=40): + directory = private_directory(directory) + require(case in CASES and 0 <= stall_seconds <= 45, "Invalid fixture case or stall duration") + control = dict(case=case, stall_seconds=stall_seconds) + if case in MEDIA_CASES: + require(media is not None, "This case requires an approved real MP3; CI audio is forbidden") + control.update(validate_media(media)) + temporary = directory / f"control-{os.getpid()}-{threading.get_ident()}.tmp" + write_private(temporary, json.dumps(control).encode()) + os.replace(temporary, directory / "control.json") + + +def metadata(path): + if path == "/api/v3/suwar": + return json.dumps(dict(suwar=[dict(id=i, name=f"Qualification surah {i}") for i in range(1, 115)])).encode() + require(path == "/api/v3/reciters", "Unknown fixture metadata route") + return json.dumps(dict(reciters=[dict(id=1, name="Private qualification recording", moshaf=[ + dict(id=1, name="Fixture edition", server="https://cdn.mp3quran.net/qualification/", surah_list="1")])])).encode() + + +def handler(directory): + log_lock = threading.Lock() + + class Fixture(BaseHTTPRequestHandler): + def log_message(self, *args): + pass # Never record credentials, arbitrary headers or client addresses. + + def do_GET(self): + try: + require(self.path.startswith(PREFIX) and len(self.path) <= 1024, "Unknown route") + logical = "https://" + self.path[len(PREFIX):] + parsed = urlsplit(logical) + meta = parsed.hostname == "www.mp3quran.net" and parsed.path in ("/api/v3/reciters", "/api/v3/suwar") + audio = parsed.hostname == "cdn.mp3quran.net" and parsed.path == "/qualification/001.mp3" and not parsed.query + require(meta or audio, "Unknown fixture route") + control = json.loads((directory / "control.json").read_text()) + case = control["case"] + require(case in CASES and 0 <= control["stall_seconds"] <= 45, "Invalid control") + with log_lock: + log = directory / "requests.jsonl" + require(not log.is_symlink() and (not log.exists() or log.stat().st_size < 10 * 1024 * 1024), "Fixture log limit reached") + with log.open("a") as stream: + log.chmod(0o600) + stream.write(json.dumps(dict(utc=datetime.now(timezone.utc).isoformat(), logical_url=logical, case=case)) + "\n") + phase = "metadata" if meta else "audio" + if case == phase + "-status": + self.send_response(503); self.send_header("Content-Length", "0"); self.end_headers(); return + if case.startswith(phase + "-redirect-"): + destination = (logical.replace("https:", "http:", 1) if case.endswith("http") else + "https://evil.example/forbidden" if case.endswith("host") else logical) + self.send_response(302); self.send_header("Location", destination) + self.send_header("Content-Length", "0"); self.end_headers(); return + if meta: + data = metadata(parsed.path) + if case == "metadata-malformed": data = b'{"reciters":[{"id":' + if case == "metadata-oversize": data = b'{"reciters":[{"name":"' + b'x' * 17000 + b'"}]}' + content_type = "application/json" + elif case == "audio-decode": + data = b"Deliberately invalid MP3 input. No audio frames.\n"; content_type = "audio/mpeg" + elif case == "audio-content-type": + data = b"Not a recording"; content_type = "text/plain" + else: + path = Path(control["media"]) + require(0 < path.stat().st_size <= MAX_MEDIA, "Invalid staged media") + data = path.read_bytes() + require(hashlib.sha256(data).hexdigest() == control["media_sha256"], "Media changed since validation") + content_type = "audio/mpeg" + self.send_response(200); self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store"); self.end_headers() + truncate = case == phase + "-truncate" + stall = case == phase + "-stall" + partial = max(1, len(data)//2) + limit = partial if truncate else min(1024, partial) if stall else len(data) + self.wfile.write(data[:limit]); self.wfile.flush() + if stall: time.sleep(control["stall_seconds"]) + except (OSError, ValueError, KeyError, TypeError): + self.close_connection = True + finally: + self.close_connection = True + return Fixture + + +def tls_server(address, directory, context): + class TLSFixture(ThreadingHTTPServer): + daemon_threads = True + slots = threading.BoundedSemaphore(8) + + def get_request(self): + connection, peer = super().get_request(); connection.settimeout(5) + return connection, peer + + def process_request(self, connection, peer): + if not self.slots.acquire(blocking=False): + connection.close(); return + try: super().process_request(connection, peer) + except BaseException: + self.slots.release(); connection.close(); raise + + def process_request_thread(self, connection, peer): + try: + try: secured = context.wrap_socket(connection, server_side=True) + except (OSError, ssl.SSLError): + connection.close(); return + super().process_request_thread(secured, peer) + finally: + self.slots.release() + return TLSFixture(address, handler(directory)) + + +def server(directory, tls_mode="trusted"): + directory = private_directory(directory) + require(tls_mode in ("trusted", "wrong-host", "untrusted"), "Invalid TLS fixture") + config = json.loads((directory / "feed.json").read_text()) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(directory / f"server-{tls_mode}.pem", directory / "server-key.pem") + return tls_server((lan_address(config["address"]), config["port"]), directory, context) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + actions = parser.add_subparsers(dest="action", required=True) + init = actions.add_parser("init"); init.add_argument("directory", type=Path) + init.add_argument("--address", required=True); init.add_argument("--port", type=int, default=8443) + choose = actions.add_parser("select"); choose.add_argument("directory", type=Path) + choose.add_argument("case", choices=CASES); choose.add_argument("--media", type=Path) + choose.add_argument("--stall-seconds", type=float, default=40) + serve = actions.add_parser("serve"); serve.add_argument("directory", type=Path) + serve.add_argument("--tls-mode", choices=("trusted", "wrong-host", "untrusted"), default="trusted") + args = parser.parse_args() + os.umask(0o077) + try: + if args.action == "init": print(json.dumps(initialize(args.directory, args.address, args.port))) + elif args.action == "select": select(args.directory, args.case, args.media, args.stall_seconds) + else: + with server(args.directory, args.tls_mode) as feed: feed.serve_forever() + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/tools/release_artifacts.py b/tools/release_artifacts.py index c9cf987..f0af293 100644 --- a/tools/release_artifacts.py +++ b/tools/release_artifacts.py @@ -100,7 +100,7 @@ def esp_image(data, exact=False, *, flash_bytes=0x800000): raise ValueError("Truncated ESP image") from error -QUALIFICATION_MARKERS = (b"OPENATHAN_QUALIFICATION_V1", b"before_boot_selection\0", +QUALIFICATION_MARKERS = (b"OPENATHAN_QUALIFICATION_V1", b"OPENATHAN_QURAN_QUALIFICATION_V1", b"before_boot_selection\0", b"after_boot_selection\0", b"Qualification baseline", b"worker_stack_min_free\0") ISOLATED_STORAGE_MARKERS = (b"oa_test\0", b"oa_setup_test\0", b"oa_network_test\0", b"oa_upgrade_test\0") From 2607fe3ad1c3e298d514191eb8a08ee41294ad4e Mon Sep 17 00:00:00 2001 From: Abubakr Eirabie Date: Sun, 11 Oct 2026 14:32:57 -0400 Subject: [PATCH 2/2] Bound Quran fixture writes without limiting recording duration --- .../quran-failure-qualification.md | 8 +- .../quran-failure-validation-2026-10-11.md | 36 ++++- tests/test_quran_qualification.py | 132 +++++++++++++++++- tools/quran_qualification.py | 16 ++- 4 files changed, 186 insertions(+), 6 deletions(-) diff --git a/docs/development/quran-failure-qualification.md b/docs/development/quran-failure-qualification.md index 133c49c..6a4db14 100644 --- a/docs/development/quran-failure-qualification.md +++ b/docs/development/quran-failure-qualification.md @@ -139,7 +139,13 @@ For TLS faults, stop the server, keep the same origin/embedded CA, and restart with `--tls-mode untrusted` or `--tls-mode wrong-host`. Both must fail before any HTTP request is logged. Restart with `--tls-mode trusted` for recovery. An unfinished handshake on one connection must not block other clients; the server -bounds workers to eight and handshake timeouts to five seconds. +bounds workers to eight and handshake and request-read timeouts to five seconds. +Response bodies use 4 KiB writes, each with a five-second timeout. There is no +total recording-duration limit: normal transfers can continue while bounded +writes succeed. A blocked write or disconnected client closes the connection and +releases its worker without retrying. Intentional stall cases still send their +short prefix, wait the selected stall duration, and close; that wait is separate +from the socket timeout. After **each** failure, observe at least 30 seconds of silence and stable state without another HTTP/audio start. Select `normal` with the approved recording, diff --git a/docs/development/quran-failure-validation-2026-10-11.md b/docs/development/quran-failure-validation-2026-10-11.md index f3806e5..f2e4a1e 100644 --- a/docs/development/quran-failure-validation-2026-10-11.md +++ b/docs/development/quran-failure-validation-2026-10-11.md @@ -8,8 +8,9 @@ session. This change does not establish release readiness. ## Automated validation -- CMake/CTest: all 18 host cases passed under UndefinedBehaviorSanitizer. -- The complete pinned Python suite passed all 125 tests without skips. +- CMake/CTest: all 20 host cases passed under UndefinedBehaviorSanitizer after + rebasing onto `main` at `f12c15ecaa546edaae7267a74051e4b06598827f`. +- The complete pinned Python suite passed all 135 tests without skips. - All 14 CI ESPHome configurations passed schema validation. - The production Quran worker passed UBSan fault tests for HTTP initialization, connection/header failures, malformed and incomplete JSON, disconnected and @@ -32,6 +33,37 @@ The HTTP audio payloads used by host tests are transport fixtures. They are neither installed nor played. Decoder failure and audible recovery are pending physical results; the fixture server's response is not such a result. +## Normal fixture transfer regression + +The original whole-body write treated five seconds as a limit on sending an +entire recording. The fixture server now sends 4 KiB memoryview slices through +its unbuffered writer, with a five-second timeout per write and no total recording +deadline. Handshake and request-read timeouts remain five seconds; the eight-worker +limit, media validation/hash checks, headers, truncation and intentional stalls +are preserved. + +Real loopback TLS tests generate a valid **4,802,394-byte** MP3 outside Git and +select it through complete FFmpeg validation. Accepted-socket send buffers and +client receive buffers request 16 KiB; kernels may adjust the actual allocation. +The client reads at 128 KiB/s for eight seconds, then drains the response. The +updated server returns HTTP 200, the full declared length, identical bytes and +a matching SHA-256. Restoring the original whole-body write in the same test +fails: only **1,097,728 of 4,802,394 bytes** arrive. + +A reader that stops after the headers releases its worker within the five-second +write bound plus two seconds of scheduling tolerance, leaving an incomplete body. +Another client can fetch metadata while that audio worker is blocked. Disconnecting +an unread transfer releases the worker within two seconds, and subsequent metadata +requests succeed. Each scenario logs one audio request, with no automatic retry. +The existing TLS rejection, redirect, media-immutability, truncation and stall +tests continue to pass. + +This correction changes host tooling, tests and documentation only. The firmware +measurements below retain their original `2cea368` baseline; the rebase also +includes the separately measured [PR #44 audio changes](atom-volume-fence-2026-10-11.md). +Those dated comparisons are not new measurements of the rebased firmware. Current +PR CI rebuilds all affected variants and enforces the existing capacity contract. + ## Matched pinned firmware measurements The baseline is merged `main` at diff --git a/tests/test_quran_qualification.py b/tests/test_quran_qualification.py index 1d35e61..59a2f2c 100644 --- a/tests/test_quran_qualification.py +++ b/tests/test_quran_qualification.py @@ -1,5 +1,6 @@ """Private Quran fixtures, verified TLS, profile isolation and release exclusion.""" import asyncio +import hashlib import importlib.util import json import os @@ -10,6 +11,7 @@ import sys import tempfile import threading +import time import unittest from unittest.mock import patch @@ -31,11 +33,21 @@ def setUp(self): self.root = Path(self.temporary.name) self.directory = self.root / "feed" qualification.initialize(self.directory, "192.168.1.2") + self.servers = {} - def tls_feed(self, mode="trusted"): + def tls_feed(self, mode="trusted", constrained=False): context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(self.directory / f"server-{mode}.pem", self.directory / "server-key.pem") server = qualification.tls_server(("127.0.0.1", 0), self.directory, context) + if constrained: + get_request = server.get_request + def constrained_request(): + connection, peer = get_request() + connection.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 16384) + connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return connection, peer + server.get_request = constrained_request + self.servers[server.server_address] = server thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() self.addCleanup(server.server_close) @@ -54,6 +66,124 @@ def fetch(self, address, path=META, timeout=2): header, body = b"".join(chunks).split(b"\r\n\r\n", 1) return header, body + def large_media(self): + # Valid host-only transport fixture; never install or play on a device. + media = self.root / "host-only.mp3" + subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", + "anullsrc=r=44100:cl=stereo", "-t", "120", "-c:a", "libmp3lame", + "-b:a", "320k", str(media)], check=True, timeout=30, capture_output=True) + self.assertGreaterEqual(media.stat().st_size, 4 * 1024 * 1024) + qualification.select(self.directory, "normal", media) + return media.read_bytes() + + def audio_connection(self, address): + context = ssl.create_default_context(cafile=str(self.directory / "ca.pem")) + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.addCleanup(raw.close) + raw.settimeout(3) + # Set the receive window before TCP negotiation; shrinking it afterward + # can leave queued data larger than the newly selected buffer. + raw.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 16384) + raw.connect(address) + connection = context.wrap_socket(raw, server_hostname="192.168.1.2") + self.addCleanup(connection.close) + connection.sendall(f"GET {AUDIO} HTTP/1.0\r\nHost: 192.168.1.2\r\n\r\n".encode()) + response = bytearray() + while b"\r\n\r\n" not in response: + chunk = connection.recv(4096) + self.assertTrue(chunk, "Connection closed before audio response headers") + response.extend(chunk) + header, body = response.split(b"\r\n\r\n", 1) + self.assertTrue(header.startswith(b"HTTP/1.0 200 "), header) + self.assertIn(b"Content-Type: audio/mpeg", header) + declared = int(next(line.split(b": ", 1)[1] for line in header.split(b"\r\n") + if line.startswith(b"Content-Length:"))) + return connection, declared, body + + @staticmethod + def available_workers(server): + acquired = 0 + try: + while acquired < 8 and server.slots.acquire(blocking=False): + acquired += 1 + return acquired + finally: + for _ in range(acquired): + server.slots.release() + + def wait_for_workers(self, address, timeout): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self.available_workers(self.servers[address]) == 8: + return + time.sleep(0.01) + self.fail("Fixture worker was not released within its timeout") + + def assert_one_audio_request(self): + requests = [json.loads(line) for line in (self.directory / "requests.jsonl").read_text().splitlines()] + self.assertEqual(sum(row["logical_url"] == "https://cdn.mp3quran.net/qualification/001.mp3" + for row in requests), 1) + + def test_normal_recording_survives_slow_reader(self): + expected = self.large_media() + address = self.tls_feed(constrained=True) + connection, declared, body = self.audio_connection(address) + self.assertEqual(declared, len(expected)) + started = time.monotonic() + while time.monotonic() - started < 8: + chunk = connection.recv(16384) + if not chunk: + break + body.extend(chunk) + # TLS record sizes vary, and some kernels grow requested buffers. + # Pace bytes so both writers get the same progressing slow reader. + time.sleep(len(chunk) / (128 * 1024)) + while chunk := connection.recv(65536): + body.extend(chunk) + self.assertEqual(len(body), declared, "Normal fixture was truncated under backpressure") + self.assertEqual(body, expected) + self.assertEqual(hashlib.sha256(body).digest(), hashlib.sha256(expected).digest()) + self.assertGreaterEqual(time.monotonic() - started, 8) + connection.close() + self.wait_for_workers(address, 2) + self.assert_one_audio_request() + + def test_nonreading_client_times_out_and_releases_worker(self): + expected = self.large_media() + address = self.tls_feed(constrained=True) + connection, declared, body = self.audio_connection(address) + self.assertEqual(declared, len(expected)) + started = time.monotonic() + self.assertEqual(self.available_workers(self.servers[address]), 7) + # Another worker must remain usable while audio cannot make progress. + header, metadata = self.fetch(address) + self.assertIn(b"200", header) + self.assertEqual(json.loads(metadata)["reciters"][0]["id"], 1) + self.wait_for_workers(address, max(0, 7 - (time.monotonic() - started))) + self.assertLessEqual(time.monotonic() - started, 7) + while chunk := connection.recv(65536): + body.extend(chunk) + self.assertLess(len(body), declared) + connection.close() + header, _ = self.fetch(address) + self.assertIn(b"200", header) + self.wait_for_workers(address, 2) + self.assert_one_audio_request() + + def test_disconnected_client_releases_worker(self): + self.large_media() + address = self.tls_feed(constrained=True) + connection, _, _ = self.audio_connection(address) + # Let the small socket buffers fill while the body remains unread. + time.sleep(0.25) + self.assertEqual(self.available_workers(self.servers[address]), 7) + connection.close() + self.wait_for_workers(address, 2) + header, _ = self.fetch(address) + self.assertIn(b"200", header) + self.wait_for_workers(address, 2) + self.assert_one_audio_request() + def test_private_files_and_fresh_initialization(self): self.assertEqual(self.directory.stat().st_mode & 0o777, 0o700) self.assertTrue(all(path.stat().st_mode & 0o777 == 0o600 for path in self.directory.iterdir())) diff --git a/tools/quran_qualification.py b/tools/quran_qualification.py index fdb3f42..6dc7b4c 100644 --- a/tools/quran_qualification.py +++ b/tools/quran_qualification.py @@ -30,6 +30,8 @@ "audio-redirect-http", "audio-redirect-host", "audio-redirect-loop") MEDIA_CASES = ("normal", "audio-truncate", "audio-stall") MAX_MEDIA = 32 * 1024 * 1024 +SOCKET_TIMEOUT_SECONDS = 5 +BODY_CHUNK_BYTES = 4096 def initialize(directory, address, port=8443): @@ -110,6 +112,14 @@ class Fixture(BaseHTTPRequestHandler): def log_message(self, *args): pass # Never record credentials, arbitrary headers or client addresses. + def _write_body(self, data, limit): + # Each bounded write gets the socket timeout; an entire recording + # may take longer while its reader continues making progress. + with memoryview(data) as view: + for offset in range(0, limit, BODY_CHUNK_BYTES): + self.wfile.write(view[offset:min(offset + BODY_CHUNK_BYTES, limit)]) + self.wfile.flush() + def do_GET(self): try: require(self.path.startswith(PREFIX) and len(self.path) <= 1024, "Unknown route") @@ -157,7 +167,7 @@ def do_GET(self): stall = case == phase + "-stall" partial = max(1, len(data)//2) limit = partial if truncate else min(1024, partial) if stall else len(data) - self.wfile.write(data[:limit]); self.wfile.flush() + self._write_body(data, limit) if stall: time.sleep(control["stall_seconds"]) except (OSError, ValueError, KeyError, TypeError): self.close_connection = True @@ -172,7 +182,7 @@ class TLSFixture(ThreadingHTTPServer): slots = threading.BoundedSemaphore(8) def get_request(self): - connection, peer = super().get_request(); connection.settimeout(5) + connection, peer = super().get_request(); connection.settimeout(SOCKET_TIMEOUT_SECONDS) return connection, peer def process_request(self, connection, peer): @@ -187,6 +197,8 @@ def process_request_thread(self, connection, peer): try: secured = context.wrap_socket(connection, server_side=True) except (OSError, ssl.SSLError): connection.close(); return + # Bound request reads and each body chunk after TLS negotiation. + secured.settimeout(SOCKET_TIMEOUT_SECONDS) super().process_request_thread(secured, peer) finally: self.slots.release()