From 82ed71b5041dcec507feaf27fe8ec74e17b6ac22 Mon Sep 17 00:00:00 2001 From: Viwat Vchirawongkwin Date: Fri, 11 Sep 2026 23:45:19 +0700 Subject: [PATCH] [chore] consolidate published v0.6.1 platform for main Preserve the exact reviewed file tree from PR #32 head 940877822e1e2c4902060817a05a74a8a9391753: bf864f70c234085b0607cd8c1a8948020bcac0a1. Condense the 299-commit integration history to satisfy main's rebase-only and linear-history rules without changing any file contents or bypassing CI. The complete specification/red/green history and published firmware source c8f549eeabe6d2b8c2766eab022517944bb09c8c remain reachable through archive/pr32-reviewed-9408778. Includes firmware v0.6.1, App lifecycle/privacy updates, reviewed tutorials, the current website and README, and the tested host evidence-writer fix. Owner-confirmed publication remains distinct from incomplete automated HIL. Signed-off-by: Viwat Vchirawongkwin --- .github/workflows/ci.yml | 44 + .gitignore | 4 + AGENTS.md | 15 +- CHANGELOG.md | 10 + README.md | 157 +- app/lib/app/pages/about_page.dart | 15 + app/lib/app/pages/privacy_policy_page.dart | 80 + app/lib/ble/fbp_ble_link.dart | 21 +- app/lib/localization/arb/app_en.arb | 104 + .../localization/gen/app_localizations.dart | 156 + .../gen/app_localizations_en.dart | 92 + app/lib/pble/connection_manager.dart | 153 +- app/lib/pble/engine.dart | 28 +- app/lib/pble/hello.dart | 8 +- app/lib/pble/pble_connection.dart | 108 +- app/pubspec.yaml | 2 +- .../hello_negotiation_conformance_test.dart | 70 +- .../v061_session_corpus_coverage_test.dart | 326 ++ .../about_android_tablet_landscape.png | Bin 21899 -> 22418 bytes .../goldens/about_android_tablet_portrait.png | Bin 24155 -> 25153 bytes .../golden/goldens/about_ipad_landscape.png | Bin 26947 -> 28336 bytes .../golden/goldens/about_ipad_portrait.png | Bin 28911 -> 30254 bytes app/test/support/repo_paths.dart | 6 + app/test/unit/app_version_contract_test.dart | 4 +- .../unit/engine_cleanup_failure_test.dart | 158 + app/test/unit/native_link_cleanup_test.dart | 72 + .../unit/owned_connection_lifecycle_test.dart | 542 +++ app/test/widget/about_page_test.dart | 83 + app/test/widget/about_shell_test.dart | 216 +- docs/README.md | 8 + docs/ROADMAP.md | 29 +- ...standardize-official-workspaces-on-lfs2.md | 61 + docs/decisions/README.md | 3 +- .../firmware-v0.6.1-v0.7.0-roadmap.md | 546 +++ docs/publication/google-play-assets.md | 201 + docs/publication/google-play-data-safety.md | 226 ++ docs/publication/google-play-open-testing.md | 295 ++ .../publication/google-play-reviewer-guide.md | 110 + docs/publication/google-play-tester-guide.md | 130 + .../google-play/en-US/app-access.txt | 1 + .../google-play/en-US/full-description.txt | 33 + .../google-play/en-US/release-notes.txt | 9 + .../google-play/en-US/short-description.txt | 1 + .../google-play/en-US/tester-invitation.txt | 16 + docs/publication/google-play/en-US/title.txt | 1 + docs/specifications/App/TDD.md | 47 +- docs/specifications/App/specs.md | 4 +- docs/specifications/firmware.md | 115 +- docs/specifications/firmware/TDD.md | 501 ++- .../firmware/browser-flashing.md | 258 +- .../firmware/ports/esp32-c3-4mb.md | 17 +- .../firmware/ports/rpi-pico2-w.md | 157 +- docs/specifications/firmware/specs.md | 567 ++- docs/specifications/google-play-release.md | 74 + docs/specifications/hardware.md | 46 +- docs/specifications/prd.md | 42 +- docs/specifications/protocol.md | 283 +- docs/specifications/website.md | 239 +- ...0.6.1-five-board-engineering-2026-09-06.md | 1096 ++++++ ...re-v0.6.1-lenovo-engineering-2026-09-05.md | 114 + .../firmware-v0.6.1-publication-2026-09-11.md | 47 + docs/testing/google-play/0.2.0-build-8.md | 134 + .../platform-integration-2026-09-11.md | 67 + .../testing/pr32-ci-remediation-2026-09-11.md | 72 + ...-board-screenshot-provenance-2026-08-28.md | 150 + ...torial-screenshot-provenance-2026-08-28.md | 77 + ...tutorial-screenshot-provenance.template.md | 146 + ...unctional-diagram-deployment-2026-09-11.md | 49 + ...unctional-diagram-provenance-2026-09-01.md | 72 + ...unctional-diagram-provenance-2026-09-11.md | 56 + .../site-v061-consistency-2026-09-11.md | 68 + ...97b9cf642ab2078842ce8a321c3c84b6714b2.json | 1406 +++++++ firmware/board_overlays/esp32-c3/_boot.py | 17 +- firmware/board_overlays/esp32-c3/manifest.py | 6 +- .../board_overlays/esp32-c3/partitions.csv | 2 +- firmware/board_overlays/esp32-s3/_boot.py | 17 +- firmware/board_overlays/esp32-s3/manifest.py | 6 +- .../board_overlays/esp32-s3/partitions.csv | 2 +- firmware/board_overlays/esp32/_boot.py | 17 +- firmware/board_overlays/esp32/manifest.py | 6 +- firmware/board_overlays/esp32/partitions.csv | 2 +- firmware/board_overlays/rpi-pico2-w/_boot.py | 34 +- .../board_overlays/rpi-pico2-w/manifest.py | 1 + .../waveshare-esp32-s3-lcd-147b/_boot.py | 17 +- .../waveshare-esp32-s3-lcd-147b/manifest.py | 2 +- .../evidence/rp2/picotool/2.3.0/clipp-LICENSE | 20 + .../2.3.0/cmake-copying-cmake-scripts | 22 + .../2.3.0/distribution-attribution-v1.json | 341 ++ .../rp2/picotool/2.3.0/homebrew-libusb.rb | 52 + .../rp2/picotool/2.3.0/libusb-AUTHORS | 245 ++ .../rp2/picotool/2.3.0/libusb-COPYING | 504 +++ .../evidence/rp2/picotool/2.3.0/libusb-README | 32 + .../rp2/picotool/2.3.0/libusb-version.h | 18 + .../rp2/picotool/2.3.0/libusb-version_nano.h | 1 + .../rp2/picotool/2.3.0/littlefs-LICENSE.md | 25 + .../picotool/2.3.0/nlohmann-json-LICENSE.MIT | 21 + .../rp2/picotool/2.3.0/oofatfs-LICENSE | 22 + .../rp2/picotool/2.3.0/picotool-LICENSE.TXT | 21 + .../rp2/picotool/2.3.0/whereami-LICENSE.MIT | 18 + .../picotool/2.3.0/whereami-LICENSE.WTFPLv2 | 25 + .../rp2-build-tools-license-policy.json | 301 ++ firmware/licenses/rp2-license-policy.json | 2 +- firmware/licenses/texts/LGPL-2.1-or-later.txt | 504 +++ firmware/pyble/pyble_agent.py | 249 +- firmware/pyble/pyble_ble.py | 136 +- firmware/pyble/pyble_boot.py | 2 +- firmware/pyble/pyble_console.py | 76 +- firmware/pyble/pyble_device_config.py | 190 +- firmware/pyble/pyble_fs.py | 530 ++- firmware/pyble/pyble_proto.py | 307 +- firmware/pyble/pyble_runner.py | 135 +- firmware/pyble/pyble_workspace.py | 272 ++ firmware/qualification/oi1-gates.json | 52 +- .../v060_profile_release_gate.py | 278 +- .../v061_hardening_release_gate.py | 1704 +++++++++ firmware/release-tools.lock | 4 +- firmware/scripts/build_rp2.sh | 343 +- firmware/scripts/install_picotool.sh | 898 +++++ firmware/scripts/prepare.sh | 4 +- firmware/scripts/release_bundle.py | 3227 ++++++++++++++++- .../user_c_modules/pyble/micropython.cmake | 1 + firmware/user_c_modules/pyble/pble_ble.c | 383 +- firmware/user_c_modules/pyble/pble_ble.h | 6 + firmware/user_c_modules/pyble/pble_boot.c | 68 +- firmware/user_c_modules/pyble/pble_boot.h | 11 +- firmware/user_c_modules/pyble/pble_console.c | 49 +- firmware/user_c_modules/pyble/pble_console.h | 7 + .../user_c_modules/pyble/pble_device_config.c | 232 +- .../user_c_modules/pyble/pble_device_config.h | 4 + firmware/user_c_modules/pyble/pble_fs.c | 960 ++++- firmware/user_c_modules/pyble/pble_lock.c | 10 + firmware/user_c_modules/pyble/pble_proto.c | 170 +- firmware/user_c_modules/pyble/pble_proto.h | 6 +- firmware/user_c_modules/pyble/pble_runner.c | 31 +- .../user_c_modules/pyble/pble_termination.c | 9 + .../user_c_modules/pyble/pble_termination.h | 3 + firmware/user_c_modules/pyble/pble_wire.c | 466 +++ firmware/user_c_modules/pyble/pble_wire.h | 83 + firmware/versions.lock | 29 +- tests/firmware_tests/README.md | 48 +- tests/firmware_tests/gates/g1_check.sh | 22 +- tests/firmware_tests/gates/g1_s3_check.sh | 17 +- tests/firmware_tests/gates/g1_s4_check.sh | 45 +- tests/firmware_tests/gates/g1_s5_check.sh | 66 +- tests/firmware_tests/gates/g1_s6_check.sh | 63 +- tests/firmware_tests/hil/README.md | 137 +- tests/firmware_tests/hil/_pble_bench.py | 2 +- tests/firmware_tests/hil/_pble_central.py | 21 +- tests/firmware_tests/hil/_pble_wire.py | 1 + .../hil/_v061_workspace_hardware.py | 732 ++++ .../hil/_v061_workspace_pico.py | 418 +++ .../hil/f11_reliability_bench.py | 2 +- .../hil/file_roundtrip_bench.py | 31 +- tests/firmware_tests/hil/rp2_run_stop.py | 27 +- tests/firmware_tests/hil/rp2_smoke.py | 8 +- tests/firmware_tests/hil/target_run_stop.py | 22 +- tests/firmware_tests/hil/target_smoke.py | 6 +- .../hil/v061_hardening_bench.py | 2135 +++++++++++ .../hil/v061_workspace_acquire.py | 339 ++ .../firmware_tests/host/conformance/README.md | 27 + .../conformance/archive/pre-freeze/README.md | 25 + .../{ => archive/pre-freeze}/s3_pending.json | 0 .../{ => archive/pre-freeze}/s4_pending.json | 0 .../{ => archive/pre-freeze}/s5_pending.json | 0 .../{ => archive/pre-freeze}/s6_pending.json | 0 .../host/conformance/corpus.json | 2 +- .../host/conformance/v061_label_vectors.json | 38 + .../v061_payload_grammar_vectors.json | 125 + .../conformance/v061_session_vectors.json | 386 ++ .../failed_session_termination_harness.c | 25 + .../host/native/v061_pble_wire_harness.c | 126 + .../host/test_agent_version_lock.py | 2 +- .../host/test_build_rp2_retained_source.py | 344 +- .../host/test_esp_native_console_stdin.py | 171 + .../test_evidence_writer_inode_lifetime.py | 137 + .../host/test_failed_session_termination.py | 112 + .../host/test_firmware_roadmap_ledger.py | 112 + .../host/test_hil_put_command_ids_v061.py | 215 ++ .../host/test_install_picotool_retention.py | 886 +++++ .../host/test_oi1_profile_bench.py | 142 +- .../test_pble_central_response_correlation.py | 59 + .../host/test_pble_window_bump.py | 7 +- .../host/test_pico_mount_recovery_v061.py | 654 ++++ .../test_prepare_python_materialization.py | 14 +- tests/firmware_tests/host/test_pyble_agent.py | 253 +- .../firmware_tests/host/test_pyble_console.py | 22 +- .../host/test_pyble_device_config.py | 28 +- .../host/test_pyble_device_config_rp2.py | 78 +- tests/firmware_tests/host/test_pyble_fs.py | 44 + .../firmware_tests/host/test_pyble_fs_v061.py | 1113 ++++++ .../host/test_pyble_fs_v061_native.py | 541 +++ tests/firmware_tests/host/test_pyble_proto.py | 61 +- .../host/test_pyble_proto_version_guard.py | 45 +- .../firmware_tests/host/test_pyble_runner.py | 17 +- .../host/test_pyble_runner_rp2.py | 42 + .../host/test_release_bundle.py | 88 + .../host/test_release_evidence_assembly.py | 321 ++ .../host/test_release_finalization.py | 205 +- .../host/test_release_frozen_source_paths.py | 273 ++ .../host/test_release_hardening.py | 8 +- .../host/test_release_license_audit.py | 2 +- ...t_release_license_preflight_regressions.py | 3 +- .../host/test_release_v060_audit_closure.py | 4 +- .../host/test_release_v060_contract.py | 71 +- .../test_release_v060_license_generation.py | 20 + .../host/test_release_v060_private_gates.py | 98 +- .../test_release_v060_promotion_lifecycle.py | 83 +- .../host/test_rp2_arm_runtime_closure.py | 139 + .../host/test_rp2_picotool_pin.py | 276 ++ .../host/test_run_response_admission.py | 38 + .../host/test_runner_prestart_cancellation.py | 120 + .../host/test_target_hil_benches.py | 2 +- .../host/test_target_runtime_contract.py | 251 +- .../host/test_v060_physical_fact_lineage.py | 32 + .../host/test_v060_qualification_workflow.py | 438 ++- .../host/test_v061_config_hardening.py | 514 +++ .../host/test_v061_hardening_release_gate.py | 1200 ++++++ .../host/test_v061_hil_bench_contract.py | 591 +++ .../test_v061_hil_orchestration_behavior.py | 716 ++++ .../host/test_v061_hil_preflight_hardening.py | 782 ++++ .../test_v061_native_autorun_persistence.py | 356 ++ .../test_v061_native_closing_precedence.py | 365 ++ ...t_v061_native_device_config_persistence.py | 896 +++++ .../host/test_v061_native_fs_behavior.py | 1406 +++++++ .../host/test_v061_native_fs_session_race.py | 325 ++ .../host/test_v061_native_hardening.py | 625 ++++ .../test_v061_native_reassembly_behavior.py | 557 +++ ...st_v061_native_registration_idempotence.py | 415 +++ .../test_v061_native_run_source_boundary.py | 555 +++ .../host/test_v061_native_utf8_paths.py | 283 ++ .../host/test_v061_payload_grammar.py | 388 ++ .../host/test_v061_pble_wire_native.py | 266 ++ .../test_v061_picotool_license_admission.py | 888 +++++ .../test_v061_picotool_observer_toctou.py | 517 +++ ...061_picotool_production_evidence_schema.py | 350 ++ .../host/test_v061_protocol_session.py | 544 +++ .../host/test_v061_reassembly_hardening.py | 321 ++ .../test_v061_release_evidence_lifecycle.py | 1002 +++++ .../host/test_v061_release_version_routing.py | 307 ++ .../host/test_v061_runner_stdin_hardening.py | 531 +++ .../host/test_v061_workspace_acquisition.py | 480 +++ .../host/test_v061_workspace_collector.py | 268 ++ .../host/test_v061_workspace_hardware.py | 430 +++ .../host/test_v061_workspace_pico.py | 375 ++ ...st_v061_workspace_receipt_writer_safety.py | 328 ++ .../host/test_v061_workspace_usb_output.py | 349 ++ .../host/test_vm_epoch_lifecycle.py | 11 +- .../test_workspace_boot_observation_v061.py | 710 ++++ .../host/test_workspace_read_status_v061.py | 163 + tests/firmware_tests/test_build_matrix_rp2.sh | 4 +- .../test_firmware_roadmap_ledger.sh | 9 + tests/firmware_tests/test_no_leak.sh | 17 + .../test_pico_mount_recovery_v061.sh | 16 + tests/firmware_tests/test_pyble_console.sh | 3 +- .../test_pyble_device_config.sh | 5 +- tests/firmware_tests/test_pyble_fs_v061.sh | 16 + .../test_pyble_fs_v061_native.sh | 16 + tests/firmware_tests/test_pyble_info.sh | 5 +- .../test_pyble_proto_version_guard.sh | 5 +- tests/firmware_tests/test_pyble_runner.sh | 5 +- .../firmware_tests/test_rp2_overlay_policy.sh | 1 + tests/firmware_tests/test_spdx_header.sh | 32 + .../test_v061_config_hardening.sh | 14 + .../test_v061_native_hardening.sh | 14 + .../test_v061_pble_wire_native.sh | 15 + .../test_v061_protocol_session.sh | 15 + .../test_v061_reassembly_hardening.sh | 15 + .../test_v061_runner_stdin_hardening.sh | 14 + tests/publication/test_ci_contract.py | 102 + tests/publication/test_public_claims.py | 81 +- tools/ci/no_leak.sh | 1 + tools/ci/spdx_lint.sh | 3 + .../publication/prepare_google_play_assets.py | 154 + .../publication/pyble-google-play-feature.svg | 43 + tools/web/.prettierignore | 3 + tools/web/DEPLOYMENT.md | 13 + tools/web/README.md | 33 +- .../web/deploy/nginx/10-pyble-dev-https.conf | 10 + tools/web/deploy/vps/deploy.sh | 46 +- tools/web/package-lock.json | 60 + tools/web/public/AGENTS.md | 96 + ...-functional-block-diagram-473a85d475aa.svg | 303 ++ ...functional-block-diagram-0d2bb826c64f8.svg | 311 ++ .../firmware-v0.6.1-owner-confirmation.md | 19 + ...-5-blocks-hello-workspace-adc069d73fba.png | Bin 0 -> 162935 bytes ...-5-blocks-hello-workspace-c37bd12f3102.png | Bin 0 -> 163073 bytes ...-examples-import-complete-65fc3f839336.png | Bin 0 -> 100907 bytes ...-examples-import-complete-ab466a044a11.png | Bin 0 -> 119787 bytes ...files-multi-delete-review-42cfacddd965.png | Bin 0 -> 120808 bytes ...files-multi-delete-review-b1df22dfa70a.png | Bin 0 -> 128242 bytes ...st-program-editor-console-86552d68afaa.png | Bin 0 -> 85382 bytes ...st-program-editor-console-f6928eea293b.png | Bin 0 -> 81440 bytes ...d-5-github-branch-chooser-f0c52c24bc5b.png | Bin 0 -> 104707 bytes ...hub-import-branch-chooser-4514d2f0522b.png | Bin 0 -> 139089 bytes ...ub-import-prewrite-review-faa68602fcd0.png | Bin 0 -> 131208 bytes ...ld-5-github-pinned-source-68a784c12ada.png | Bin 0 -> 146003 bytes ...ld-5-github-target-review-16aafa28be77.png | Bin 0 -> 110711 bytes ...uild-5-identity-3dcb-chip-2b80ef41db8f.png | Bin 0 -> 18557 bytes ...ild-5-identity-3dcb-ready-613559d187f3.png | Bin 0 -> 7234 bytes ...uild-5-identity-5646-chip-03edb94bcf73.png | Bin 0 -> 18618 bytes ...ild-5-identity-5646-ready-6b8ef886ea40.png | Bin 0 -> 6700 bytes ...uild-5-identity-8c9e-chip-0aa751bf86a3.png | Bin 0 -> 17611 bytes ...ild-5-identity-8c9e-ready-3b64e4b0b84c.png | Bin 0 -> 7241 bytes ...uild-5-identity-c81a-chip-1c4d40085693.png | Bin 0 -> 18851 bytes ...ild-5-identity-c81a-ready-8dea0554c432.png | Bin 0 -> 7213 bytes ...uild-5-identity-da86-chip-03edb94bcf73.png | Bin 0 -> 18618 bytes ...ild-5-identity-da86-ready-49595991ba1f.png | Bin 0 -> 7049 bytes ...-setup-connected-identity-58f65b64a966.png | Bin 0 -> 69318 bytes ...d-5-setup-five-board-scan-a89dedab7efc.png | Bin 0 -> 64945 bytes ...uild-5-setup-scan-results-3558cadbe501.png | Bin 0 -> 43298 bytes .../reference/firmware-v0.6.1/protocol.md | 835 +++++ tools/web/public/sitemap.xml | 38 +- .../pyble-v061-og-297b270f-1200x630.png | Bin 0 -> 219939 bytes .../pyble-v061-og-d3bab0c0-1200x630.svg | 166 + tools/web/scripts/prepare-sites-output.js | 5 + .../web/scripts/process-tutorial-captures.sh | 387 ++ .../scripts/test-process-tutorial-captures.sh | 145 + tools/web/src/app/features/page.tsx | 520 +++ tools/web/src/app/flash/page.tsx | 22 +- tools/web/src/app/globals.css | 916 ++++- tools/web/src/app/learn/blocks/page.tsx | 2 + .../app/learn/configured-hardware/page.tsx | 30 + tools/web/src/app/learn/examples/page.tsx | 4 +- tools/web/src/app/learn/files/page.tsx | 35 +- .../web/src/app/learn/first-program/page.tsx | 14 +- .../web/src/app/learn/github-import/page.tsx | 16 +- tools/web/src/app/learn/hardware/page.tsx | 33 + tools/web/src/app/learn/page.tsx | 15 +- tools/web/src/app/learn/pico-2-w/page.tsx | 32 +- tools/web/src/app/learn/setup/page.tsx | 12 + .../src/app/learn/waveshare-lcd-147b/page.tsx | 9 + tools/web/src/app/page.tsx | 40 +- tools/web/src/app/support/page.tsx | 17 +- tools/web/src/components/flash-status.tsx | 42 +- tools/web/src/components/site-footer.tsx | 1 + .../src/components/tutorial-app-capture.tsx | 41 + .../tutorial-board-identity-gallery.tsx | 169 + .../components/tutorial-concept-figure.tsx | 40 + tools/web/src/components/tutorial-page.tsx | 17 +- tools/web/src/lib/firmware-integrity.ts | 4 +- .../web/src/lib/firmware-release-selection.ts | 4 +- tools/web/src/lib/firmware-release.ts | 20 +- tools/web/src/lib/site.ts | 7 +- tools/web/src/lib/tutorial-app-captures.ts | 287 ++ tools/web/src/lib/tutorials.ts | 8 +- tools/web/src/test/build-contract.test.ts | 27 + .../src/test/features-page-contract.test.tsx | 244 ++ tools/web/src/test/flash-installer.test.tsx | 2 +- .../src/test/learn-content-contract.test.tsx | 112 +- tools/web/src/test/site-contract.test.tsx | 48 +- .../src/test/site-v061-consistency.test.tsx | 71 + tools/web/src/test/sites-output.test.ts | 57 + .../test/tutorial-app-capture-assets.test.ts | 164 + .../src/test/vps-deployment-contract.test.ts | 38 + 354 files changed, 57744 insertions(+), 2018 deletions(-) create mode 100644 app/lib/app/pages/privacy_policy_page.dart create mode 100644 app/test/conformance/v061_session_corpus_coverage_test.dart create mode 100644 app/test/unit/engine_cleanup_failure_test.dart create mode 100644 app/test/unit/native_link_cleanup_test.dart create mode 100644 app/test/unit/owned_connection_lifecycle_test.dart create mode 100644 docs/decisions/0047-standardize-official-workspaces-on-lfs2.md create mode 100644 docs/planning/firmware-v0.6.1-v0.7.0-roadmap.md create mode 100644 docs/publication/google-play-assets.md create mode 100644 docs/publication/google-play-data-safety.md create mode 100644 docs/publication/google-play-open-testing.md create mode 100644 docs/publication/google-play-reviewer-guide.md create mode 100644 docs/publication/google-play-tester-guide.md create mode 100644 docs/publication/google-play/en-US/app-access.txt create mode 100644 docs/publication/google-play/en-US/full-description.txt create mode 100644 docs/publication/google-play/en-US/release-notes.txt create mode 100644 docs/publication/google-play/en-US/short-description.txt create mode 100644 docs/publication/google-play/en-US/tester-invitation.txt create mode 100644 docs/publication/google-play/en-US/title.txt create mode 100644 docs/specifications/google-play-release.md create mode 100644 docs/testing/firmware-v0.6.1-five-board-engineering-2026-09-06.md create mode 100644 docs/testing/firmware-v0.6.1-lenovo-engineering-2026-09-05.md create mode 100644 docs/testing/firmware-v0.6.1-publication-2026-09-11.md create mode 100644 docs/testing/google-play/0.2.0-build-8.md create mode 100644 docs/testing/platform-integration-2026-09-11.md create mode 100644 docs/testing/pr32-ci-remediation-2026-09-11.md create mode 100644 docs/testing/tutorial-five-board-screenshot-provenance-2026-08-28.md create mode 100644 docs/testing/tutorial-screenshot-provenance-2026-08-28.md create mode 100644 docs/testing/tutorial-screenshot-provenance.template.md create mode 100644 docs/testing/website/firmware-functional-diagram-deployment-2026-09-11.md create mode 100644 docs/testing/website/firmware-functional-diagram-provenance-2026-09-01.md create mode 100644 docs/testing/website/firmware-functional-diagram-provenance-2026-09-11.md create mode 100644 docs/testing/website/site-v061-consistency-2026-09-11.md create mode 100644 docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT create mode 100644 firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2 create mode 100644 firmware/licenses/rp2-build-tools-license-policy.json create mode 100644 firmware/licenses/texts/LGPL-2.1-or-later.txt create mode 100644 firmware/pyble/pyble_workspace.py create mode 100644 firmware/qualification/v061_hardening_release_gate.py create mode 100755 firmware/scripts/install_picotool.sh create mode 100644 firmware/user_c_modules/pyble/pble_wire.c create mode 100644 firmware/user_c_modules/pyble/pble_wire.h create mode 100644 tests/firmware_tests/hil/_v061_workspace_hardware.py create mode 100644 tests/firmware_tests/hil/_v061_workspace_pico.py create mode 100644 tests/firmware_tests/hil/v061_hardening_bench.py create mode 100644 tests/firmware_tests/hil/v061_workspace_acquire.py create mode 100644 tests/firmware_tests/host/conformance/README.md create mode 100644 tests/firmware_tests/host/conformance/archive/pre-freeze/README.md rename tests/firmware_tests/host/conformance/{ => archive/pre-freeze}/s3_pending.json (100%) rename tests/firmware_tests/host/conformance/{ => archive/pre-freeze}/s4_pending.json (100%) rename tests/firmware_tests/host/conformance/{ => archive/pre-freeze}/s5_pending.json (100%) rename tests/firmware_tests/host/conformance/{ => archive/pre-freeze}/s6_pending.json (100%) create mode 100644 tests/firmware_tests/host/conformance/v061_label_vectors.json create mode 100644 tests/firmware_tests/host/conformance/v061_payload_grammar_vectors.json create mode 100644 tests/firmware_tests/host/conformance/v061_session_vectors.json create mode 100644 tests/firmware_tests/host/native/v061_pble_wire_harness.c create mode 100644 tests/firmware_tests/host/test_evidence_writer_inode_lifetime.py create mode 100644 tests/firmware_tests/host/test_firmware_roadmap_ledger.py create mode 100644 tests/firmware_tests/host/test_hil_put_command_ids_v061.py create mode 100644 tests/firmware_tests/host/test_install_picotool_retention.py create mode 100644 tests/firmware_tests/host/test_pico_mount_recovery_v061.py create mode 100644 tests/firmware_tests/host/test_pyble_fs_v061.py create mode 100644 tests/firmware_tests/host/test_pyble_fs_v061_native.py create mode 100644 tests/firmware_tests/host/test_release_frozen_source_paths.py create mode 100644 tests/firmware_tests/host/test_rp2_picotool_pin.py create mode 100644 tests/firmware_tests/host/test_v061_config_hardening.py create mode 100644 tests/firmware_tests/host/test_v061_hardening_release_gate.py create mode 100644 tests/firmware_tests/host/test_v061_hil_bench_contract.py create mode 100644 tests/firmware_tests/host/test_v061_hil_orchestration_behavior.py create mode 100644 tests/firmware_tests/host/test_v061_hil_preflight_hardening.py create mode 100644 tests/firmware_tests/host/test_v061_native_autorun_persistence.py create mode 100644 tests/firmware_tests/host/test_v061_native_closing_precedence.py create mode 100644 tests/firmware_tests/host/test_v061_native_device_config_persistence.py create mode 100644 tests/firmware_tests/host/test_v061_native_fs_behavior.py create mode 100644 tests/firmware_tests/host/test_v061_native_fs_session_race.py create mode 100644 tests/firmware_tests/host/test_v061_native_hardening.py create mode 100644 tests/firmware_tests/host/test_v061_native_reassembly_behavior.py create mode 100644 tests/firmware_tests/host/test_v061_native_registration_idempotence.py create mode 100644 tests/firmware_tests/host/test_v061_native_run_source_boundary.py create mode 100644 tests/firmware_tests/host/test_v061_native_utf8_paths.py create mode 100644 tests/firmware_tests/host/test_v061_payload_grammar.py create mode 100644 tests/firmware_tests/host/test_v061_pble_wire_native.py create mode 100644 tests/firmware_tests/host/test_v061_picotool_license_admission.py create mode 100644 tests/firmware_tests/host/test_v061_picotool_observer_toctou.py create mode 100644 tests/firmware_tests/host/test_v061_picotool_production_evidence_schema.py create mode 100644 tests/firmware_tests/host/test_v061_protocol_session.py create mode 100644 tests/firmware_tests/host/test_v061_reassembly_hardening.py create mode 100644 tests/firmware_tests/host/test_v061_release_evidence_lifecycle.py create mode 100644 tests/firmware_tests/host/test_v061_release_version_routing.py create mode 100644 tests/firmware_tests/host/test_v061_runner_stdin_hardening.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_acquisition.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_collector.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_hardware.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_pico.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_receipt_writer_safety.py create mode 100644 tests/firmware_tests/host/test_v061_workspace_usb_output.py create mode 100644 tests/firmware_tests/host/test_workspace_boot_observation_v061.py create mode 100644 tests/firmware_tests/host/test_workspace_read_status_v061.py create mode 100755 tests/firmware_tests/test_firmware_roadmap_ledger.sh create mode 100755 tests/firmware_tests/test_pico_mount_recovery_v061.sh create mode 100755 tests/firmware_tests/test_pyble_fs_v061.sh create mode 100755 tests/firmware_tests/test_pyble_fs_v061_native.sh create mode 100755 tests/firmware_tests/test_v061_config_hardening.sh create mode 100755 tests/firmware_tests/test_v061_native_hardening.sh create mode 100755 tests/firmware_tests/test_v061_pble_wire_native.sh create mode 100755 tests/firmware_tests/test_v061_protocol_session.sh create mode 100755 tests/firmware_tests/test_v061_reassembly_hardening.sh create mode 100755 tests/firmware_tests/test_v061_runner_stdin_hardening.sh create mode 100644 tools/publication/prepare_google_play_assets.py create mode 100644 tools/publication/pyble-google-play-feature.svg create mode 100644 tools/web/public/AGENTS.md create mode 100644 tools/web/public/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg create mode 100644 tools/web/public/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg create mode 100644 tools/web/public/firmware-v0.6.1-owner-confirmation.md create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-adc069d73fba.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-c37bd12f3102.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-65fc3f839336.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-ab466a044a11.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-42cfacddd965.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-b1df22dfa70a.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-first-program-editor-console-86552d68afaa.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-first-program-editor-console-f6928eea293b.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-branch-chooser-f0c52c24bc5b.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-import-branch-chooser-4514d2f0522b.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-import-prewrite-review-faa68602fcd0.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-pinned-source-68a784c12ada.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-target-review-16aafa28be77.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-3dcb-chip-2b80ef41db8f.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-3dcb-ready-613559d187f3.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-chip-03edb94bcf73.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-ready-6b8ef886ea40.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-chip-0aa751bf86a3.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-ready-3b64e4b0b84c.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-c81a-chip-1c4d40085693.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-c81a-ready-8dea0554c432.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-da86-chip-03edb94bcf73.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-da86-ready-49595991ba1f.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-connected-identity-58f65b64a966.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-five-board-scan-a89dedab7efc.png create mode 100644 tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-scan-results-3558cadbe501.png create mode 100644 tools/web/public/reference/firmware-v0.6.1/protocol.md create mode 100644 tools/web/public/social/pyble-v061-og-297b270f-1200x630.png create mode 100644 tools/web/public/social/pyble-v061-og-d3bab0c0-1200x630.svg create mode 100755 tools/web/scripts/process-tutorial-captures.sh create mode 100755 tools/web/scripts/test-process-tutorial-captures.sh create mode 100644 tools/web/src/app/features/page.tsx create mode 100644 tools/web/src/components/tutorial-app-capture.tsx create mode 100644 tools/web/src/components/tutorial-board-identity-gallery.tsx create mode 100644 tools/web/src/components/tutorial-concept-figure.tsx create mode 100644 tools/web/src/lib/tutorial-app-captures.ts create mode 100644 tools/web/src/test/features-page-contract.test.tsx create mode 100644 tools/web/src/test/site-v061-consistency.test.tsx create mode 100644 tools/web/src/test/tutorial-app-capture-assets.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaef2e3e..dd549c36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -717,3 +717,47 @@ jobs: firmware/build/${{ matrix.target }}/bootloader/bootloader.bin firmware/build/${{ matrix.target }}/partition_table/partition-table.bin if-no-files-found: error + + build-rp2: + # Real, isolated RP2350 Arm build. Both host tools and every selected + # nested source are pinned; no ambient/Homebrew picotool is admitted. + needs: [no-leak, spdx, sha-drift, patches, firmware-host] + runs-on: macos-15 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Require the reviewed Apple Silicon host + run: | + test "$(uname -s)" = "Darwin" + test "$(uname -m)" = "arm64" + - name: Initialize only the pinned RP2 source graph + run: | + git submodule update --init --depth 1 -- firmware/upstream/micropython + git -C firmware/upstream/micropython submodule update --init --depth 1 -- \ + lib/btstack \ + lib/cyw43-driver \ + lib/lwip \ + lib/mbedtls \ + lib/micropython-lib \ + lib/pico-sdk \ + lib/tinyusb + - name: Install pinned ARM GNU toolchain + run: firmware/scripts/install_arm_toolchain.sh + - name: Install pinned official picotool distribution + run: firmware/scripts/install_picotool.sh + - name: Build rpi-pico2-w from retained inputs + run: firmware/scripts/build_rp2.sh rpi-pico2-w + - name: Retain RP2 build and provenance + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pyble-rpi-pico2-w + path: | + firmware/build/rpi-pico2-w/firmware.uf2 + firmware/build/rpi-pico2-w/firmware.bin + firmware/build/rpi-pico2-w/firmware.elf + firmware/build/rpi-pico2-w/firmware.elf.map + firmware/build/rpi-pico2-w/pyble-build-provenance.json + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 3709a4fc..e2d30614 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,10 @@ firmware/.esp-idf/ # [arm_gnu_toolchain] by firmware/scripts/install_arm_toolchain.sh (X-13). firmware/.arm-gnu/ .arm-gnu/ +# Official picotool distribution retained from versions.lock [picotool]. Only +# this exact firmware-local install root is excluded; authored lookalikes stay +# visible to source gates and Git. +firmware/.picotool/ # firmware/upstream/micropython/ is now a tracked git submodule (see .gitmodules); # its working tree is managed by git-submodule, not ignored here. diff --git a/AGENTS.md b/AGENTS.md index a0ccee2f..1efa721a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,10 @@ the project’s conformance and hardware-validation gates. 5. Third-party dependencies remain under their own licenses and must be recorded in the relevant dependency and notice files. -The pinned, gitignored compiler trees at `firmware/.esp-idf` and -`firmware/.arm-gnu` are third-party build inputs, not shippable source. Source -gates prune those exact roots while continuing to scan similarly named -authored directories. +The pinned, gitignored compiler/tool trees at `firmware/.esp-idf`, +`firmware/.arm-gnu`, and `firmware/.picotool` are third-party build inputs, +not shippable source. Source gates prune those exact roots while continuing to +scan similarly named authored directories. ## Spec-driven and test-driven development @@ -68,8 +68,11 @@ CHANGELOG.md CITATION.cff AGENTS.md CLAUDE.md .gitignore .gitmodules ``` Put specifications and decisions under `docs/`, scripts under `tools/` or -`firmware/scripts/`, and examples under `examples/`. Keep screenshots, signing -material, builds, assistant state, and local notes out of Git. +`firmware/scripts/`, and examples under `examples/`. Keep raw or unreviewed +screenshots, signing material, builds, assistant state, and local notes out of +Git. A deliberately reviewed, provenance-recorded image used by the public +website may live below `tools/web/public/`; its raw capture must remain in an +ignored workspace. ## Naming diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dc128b..ac3f78c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,15 @@ The current compatible app feature set now targets `0.2.0`; build `5` was handed off through Transporter for TestFlight. This candidate identity improves test and evidence traceability but does not designate a qualified store release or claim App Store Connect processing, beta-review approval, or availability. +The next prepared Android open-testing candidate is `0.2.0 (8)`; its bundle +and submission packet do not claim Play submission, approval, or rollout. ### App source +- Added a full offline Privacy policy route from About, with the published + policy, effective date and privacy contact. Reading it preserves the live + board session, editor, Blocks and console state, including at large text + sizes. Advanced the shared app candidate identity to `0.2.0+8`. - Added Python syntax highlighting and synchronized one-based line numbers to the editor while retaining the plain-field fallback, offline boundary, smart-punctuation protection, and safe external-keyboard Tab behavior. @@ -71,6 +77,10 @@ or claim App Store Connect processing, beta-review approval, or availability. ### Website and repository source +- Prepared Google Play open-testing listing text, reviewer hardware setup, + tester instructions, data-use assessment, and reproducible store artwork + with authentic screenshot provenance. Store availability remains a separate + Console handoff. - Added a static `/learn` center with ten ordered, accessible tutorials covering exact five-profile setup, the first BLE program, Files and multi-delete, pinned public GitHub import, Blocks, the complete 32-example development diff --git a/README.md b/README.md index 80dc6417..0c411659 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ MicroPython IDE. [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22064467.svg)](https://doi.org/10.5281/zenodo.22064467) [![Platforms](https://img.shields.io/badge/app-iPadOS%20%7C%20Android-2D5BFF.svg)](#app) [![Protocol](https://img.shields.io/badge/protocol-PBLE%2F1-0E7490.svg)](docs/specifications/protocol.md) -[![Firmware](https://img.shields.io/badge/firmware-v0.6.0%20qualified-15803D.svg)](https://pyble.dev/flash) +[![Firmware](https://img.shields.io/badge/firmware-v0.6.1%20published-15803D.svg)](https://pyble.dev/flash) PyBLE lets you edit, transfer, run, and stop MicroPython programs on a compatible microcontroller board over Bluetooth Low Energy. Its normal workflow needs no USB serial connection, Wi-Fi onboarding, cloud account, or telemetry. -- Qualified firmware v0.6.0: [installer and download](https://pyble.dev/flash) +- Published firmware v0.6.1: [installer and download](https://pyble.dev/flash) +- Tutorials: [learn PyBLE](https://pyble.dev/learn) +- Firmware features and functional block diagram: [architecture reference](https://pyble.dev/features) +- Official example collection: [PyBLE-dev/examples](https://github.com/PyBLE-dev/examples) - iPad external beta: [join with TestFlight](https://testflight.apple.com/join/yU4e8s6d) - Android invited testing: [see the app page](https://pyble.dev/app) @@ -45,16 +48,20 @@ The Flutter app currently provides: - a Python-highlighted MicroPython editor with always-visible, one-based line numbers, an adjustable 10–24 point code font, save, run, stop, soft reboot, and live console; -- wireless file browsing and transfer with integrity checks; +- wireless file browsing and transfer with integrity checks, reviewed + multi-file deletion, and a compact Files action menu; - an optional public GitHub example browser that resolves a repository/ref to an immutable commit, previews exact `.py` source-to-board paths, and imports selected files into the connected Files folder only after explicit review; + the official repository URL is prefilled and editable, with a branch chooser + and an optional manual tag/commit input; - an offline Blockly workspace with GPIO and standard MicroPython NeoPixel blocks, including explicit numeric GPIOs and bounded named `machine.Pin` identities; - editable beginner examples; - exact Blockly sidecar reopening and a bounded Python-to-blocks importer; -- an adaptive tablet interface for portrait and landscape use; and +- an adaptive tablet interface for portrait and landscape use; +- an offline privacy policy accessible from About; and - core editing, BLE, Files, Blocks, and Run operation without an account, analytics, or cloud dependency. GitHub is the sole optional network surface; its public, unauthenticated requests start only when the user opens the @@ -67,47 +74,64 @@ fetches and validates the complete batch, shows the exact destination paths, and asks separately before overwriting existing files. Import never creates a remote folder hierarchy, opens an editor document, or runs downloaded code. -The iPad build is available through public TestFlight. An invited Android -internal test is available through Google Play; this is not a public Play -release. Both platforms share the same Flutter source and app test gates. +The current App source version is `0.2.0+8`; App and firmware versions are +independent. iPad testing uses TestFlight, and Android distribution follows +the testing access shown on the [App page](https://pyble.dev/app). The +[Google Play build-8 handoff](docs/testing/google-play/0.2.0-build-8.md) +records prepared artifacts and their exact source; preparation does not +establish a live store release or mean those earlier artifacts contain later +connection-lifecycle fixes. Both platforms share the same Flutter source and +app test gates. The iOS/iPadOS project minimum is 15. ### Firmware The board-side agent is built with upstream MicroPython and exposes PBLE/1 as a BLE GATT peripheral. It supports: -- capability and device-information negotiation; -- run, stop, console, and soft-reboot control; -- filesystem operations with CRC validation and atomic uploads; -- resumable transfer behavior and bounded transport recovery; -- optional `main.py` auto-run protection; -- board naming and identify support; and +- capability and device-information negotiation with session-scoped HELLO; +- run, stop, console, and soft-reboot control, with fresh globals per file RUN + and stdin isolated to its owning run; +- LFS2 workspaces across all five profiles, CRC-checked resumable uploads, + atomic publication, storage-reserve checks, and serialized file mutations; +- bounded fragment reassembly and transport/session recovery; +- optional `main.py` auto-run protection and checked configuration persistence; +- board naming and identify support; +- an exact-board ST7789 display and boot splash on Waveshare's B-version; and - upstream MicroPython’s standard `neopixel` module. -The installer offers the qualified public v0.6.0 release across all five exact -release profiles. All five exact-byte HIL rows passed. The four ESP profiles -use Web Serial from a supported desktop Chromium browser; Pico 2 W uses a -browser-verified UF2 download followed by a manual BOOTSEL copy. - -| Profile | Exact hardware constraint | Provisioning | Public status | -| ----------------------------- | ----------------------------------------------------------------------- | ---------------------------------- | ---------------- | -| `esp32-4mb` | Classic ESP32; exactly 4 MiB external flash; no PSRAM required | ESP Web Serial | Qualified v0.6.0 | -| `esp32-s3-n16r8` | Lean ESP32-S3; exactly 16 MiB flash and 8 MiB Octal PSRAM | ESP Web Serial | Qualified v0.6.0 | -| `waveshare-esp32-s3-lcd-147b` | Exact ESP32-S3-LCD-1.47B B-version; 16 MiB flash and 8 MiB Octal PSRAM | ESP Web Serial | Qualified v0.6.0 | -| `esp32-c3-4mb` | ESP32-C3 revision v0.3 or newer; exactly 4 MiB flash; no PSRAM required | ESP Web Serial | Qualified v0.6.0 | -| `rpi-pico2-w` | Raspberry Pi Pico 2 W; RP2350 with CYW43439 | Verified UF2 + manual BOOTSEL copy | Qualified v0.6.0 | +The installer offers the owner-confirmed public v0.6.1 release across all five +exact release profiles. The owner confirmed completed qualification and +authorized publication on 2026-09-11. Existing automated qualification records +remain incomplete and are preserved unchanged; owner confirmation does not +mark missing automated measurements passed. The four ESP profiles use Web Serial +from a supported desktop Chromium browser; Pico 2 W uses a browser-verified UF2 +download followed by a manual BOOTSEL copy. + +| Profile | Exact hardware constraint | Provisioning | Public status | +| --- | --- | --- | --- | +| `esp32-4mb` | Classic ESP32; exactly 4 MiB external flash; no PSRAM required | ESP Web Serial | Owner-confirmed v0.6.1 | +| `esp32-s3-n16r8` | Lean ESP32-S3; exactly 16 MiB flash and 8 MiB Octal PSRAM | ESP Web Serial | Owner-confirmed v0.6.1 | +| `waveshare-esp32-s3-lcd-147b` | Exact ESP32-S3-LCD-1.47B B-version; 16 MiB flash and 8 MiB Octal PSRAM | ESP Web Serial | Owner-confirmed v0.6.1 | +| `esp32-c3-4mb` | ESP32-C3 revision v0.3 or newer; exactly 4 MiB flash; no PSRAM required | ESP Web Serial | Owner-confirmed v0.6.1 | +| `rpi-pico2-w` | Raspberry Pi Pico 2 W; RP2350 with CYW43439 | Verified UF2 + manual BOOTSEL copy | Owner-confirmed v0.6.1 | The immutable -[release descriptor](https://pyble.dev/firmware/v0.6.0/release.json), with +[release descriptor](https://pyble.dev/firmware/v0.6.1/release.json), with SHA-256 -`c2940281a14feddb55c48de15ac18087e9317d1b7130e514fab5a209b046a1e6`, -binds the five artifacts and passed HIL status to source commit -`0c7230d6708797c241160ba71fbd37e6b22f180a` and the annotated -[`firmware-v0.6.0` source tag](https://github.com/PyBLE-dev/PyBLE/tree/firmware-v0.6.0). -See the [release notes](https://pyble.dev/firmware/v0.6.0/RELEASE_NOTES.md), -[artifact checksums](https://pyble.dev/firmware/v0.6.0/SHA256SUMS), and -[recovery guide](https://pyble.dev/firmware/v0.6.0/RECOVERY.md) before -installing. +`71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd`, +binds the five artifacts to exact source +[`c8f549eeabe6d2b8c2766eab022517944bb09c8c`](https://github.com/PyBLE-dev/PyBLE/tree/c8f549eeabe6d2b8c2766eab022517944bb09c8c). +See the [owner confirmation](https://pyble.dev/firmware-v0.6.1-owner-confirmation.md), +[release notes](https://pyble.dev/firmware/v0.6.1/RELEASE_NOTES.md), +[artifact checksums](https://pyble.dev/firmware/v0.6.1/SHA256SUMS), and +[recovery guide](https://pyble.dev/firmware/v0.6.1/RECOVERY.md) before installing. + +Back up before moving from older workspace formats. v0.6.1 refuses nonblank or +uncertain media that it cannot mount safely; it does not silently reformat an +old workspace. That refusal requires USB recovery rather than a BLE session. +The prior [qualified v0.6.0 release](https://pyble.dev/firmware/v0.6.0/RELEASE_NOTES.md) +and its [source tag](https://github.com/PyBLE-dev/PyBLE/tree/firmware-v0.6.0) +remain historical references, not the current installer selection. These maintained release profiles are not an app-side chip or board allowlist. A future board is compatible when it has a maintained PyBLE agent port, BLE @@ -129,8 +153,9 @@ This repository is intentionally a monorepo: - [`app/`](app/) — the Flutter tablet application; - [`firmware/`](firmware/) — the portable agent, board overlays, and release tooling; -- [`examples/`](examples/) — clean-room, board-neutral MicroPython examples, - including the public GitHub-import fixtures; +- [`examples/`](examples/) — small source/conformance examples and GitHub-import + fixtures; the separately maintained [official example collection](https://github.com/PyBLE-dev/examples) + contains the user-facing examples for all five profiles; - [`docs/specifications/protocol.md`](docs/specifications/protocol.md) — the open PBLE/1 wire contract; - [`tests/`](tests/) — host, conformance, release, and HIL test runners; @@ -147,7 +172,7 @@ shared conformance corpus, documentation, and CI atomically. [TestFlight](https://testflight.apple.com/join/yU4e8s6d), or build the Flutter app locally. 2. Open [pyble.dev/flash](https://pyble.dev/flash) in a supported desktop - Chromium browser. Confirm that the qualified v0.6.0 release is selected, + Chromium browser. Confirm that the published v0.6.1 release is selected, then choose only the exact profile matching the board and memory topology. 3. Before provisioning, back up the board and accept every safety acknowledgement. Flashing erases the board and its existing workspace. @@ -155,15 +180,24 @@ shared conformance corpus, documentation, and CI atomically. For Pico 2 W, download the verified UF2 and copy it manually to the BOOTSEL mass-storage volume. iPadOS cannot perform either wired provisioning step. 5. Open PyBLE, scan for the provisioned board, and connect over BLE. -6. To try the optional public example importer, open the destination folder in - **Files**, choose **Import examples from GitHub**, enter - `https://github.com/PyBLE-dev/PyBLE` with ref `main`, then browse to - `examples/github-import`. -7. Select `hello.py` or `count.py`, review the displayed commit SHA and exact - board target, and confirm any overwrite separately. Import does not open or - run the file; open it from Files and choose Run only when you are ready. - -See [support and troubleshooting](https://pyble.dev/support) for browser, +6. In **Files**, create or open a writable subfolder such as `/examples` within + the board's advertised filesystem root. Do not assume `/` is writable on + every board; read the destination warning before importing there. +7. Choose **Import examples from GitHub**. The prefilled URL + `https://github.com/PyBLE-dev/examples` is editable. Let the branch chooser + load, select `main` (or another available branch), and browse to + `examples/portable/basics/hello_console`. If GitHub reports its public + request limit, wait for the displayed retry time rather than repeatedly + reloading. Advanced users can enter a tag or commit manually. +8. Select `pyble_hello_console.py`, review the displayed immutable commit SHA + and exact board destination, and confirm any overwrite separately. The + official URL does not bypass source review or imply validation of every + example. Import does not open or run the file; open it from Files and + choose Run only when you are ready. + +Follow the [guided tutorials](https://pyble.dev/learn), including real Lenovo +workflow screenshots, for the complete learning path. See +[support and troubleshooting](https://pyble.dev/support) for browser, Bluetooth, network, and recovery requirements. A GitHub account or token is not needed for the optional import; every other workflow remains available without it. @@ -194,6 +228,7 @@ Firmware host tests: ```sh tests/firmware_tests/run_tests.sh +python3 -m unittest discover -s tests/firmware_tests/host -p 'test_*.py' -v ``` Firmware build preparation and target builds: @@ -205,19 +240,20 @@ firmware/scripts/build.sh esp32-s3 firmware/scripts/build.sh waveshare-esp32-s3-lcd-147b firmware/scripts/build.sh esp32-c3 firmware/scripts/install_arm_toolchain.sh +firmware/scripts/install_picotool.sh firmware/scripts/build_rp2.sh rpi-pico2-w ``` -These are the five target builds in the qualified v0.6.0 release. The four ESP +These are the five target builds in the published v0.6.1 release. The four ESP targets produce Web Serial artifacts; `rpi-pico2-w` produces the UF2 used by the verified-download and BOOTSEL flow. The commands above build the current checkout. To start from the exact source -used for the public v0.6.0 artifacts, detach at the annotated release tag and +used for the public v0.6.1 artifacts, detach at the exact source commit and restore its pinned submodules: ```sh -git switch --detach firmware-v0.6.0 +git switch --detach c8f549eeabe6d2b8c2766eab022517944bb09c8c git submodule update --init --recursive ``` @@ -237,17 +273,26 @@ static export with no Node.js website process. The complete build requires the pinned toolchains documented in the relevant component README and specifications. -Firmware binaries are external release artifacts, not committed source. The -production website receives a qualified bundle and validates its descriptor -and hashes before enabling installation. A source-only website build remains -fail-closed unless an explicit validated release input is supplied. +Firmware binaries are external release artifacts, not committed source. +The public v0.6.1 installation uses its exact owner-confirmed release descriptor +and artifact hashes; the standard automated qualification gate remains +unchanged. A source-only website build remains fail-closed unless an explicit +validated release input is supplied. See the +[publication record](docs/testing/firmware-v0.6.1-publication-2026-09-11.md) +and [website deployment record](docs/testing/website/site-v061-consistency-2026-09-11.md) +for the release-specific deployment path; building this checkout does not +publish new firmware or replace the live site. ## Documentation - [Documentation index](docs/README.md) - [Changelog](CHANGELOG.md) -- [Qualified firmware v0.6.0 source](https://github.com/PyBLE-dev/PyBLE/tree/firmware-v0.6.0) -- [Qualified firmware v0.6.0 release notes](https://pyble.dev/firmware/v0.6.0/RELEASE_NOTES.md) +- [Tutorials with reviewed App screenshots](https://pyble.dev/learn) +- [Firmware features and functional block diagram](https://pyble.dev/features) +- [Official examples repository](https://github.com/PyBLE-dev/examples) +- [Published firmware v0.6.1 source](https://github.com/PyBLE-dev/PyBLE/tree/c8f549eeabe6d2b8c2766eab022517944bb09c8c) +- [Firmware v0.6.1 release notes](https://pyble.dev/firmware/v0.6.1/RELEASE_NOTES.md) +- [Firmware v0.6.1 publication record](docs/testing/firmware-v0.6.1-publication-2026-09-11.md) - [Product specification](docs/specifications/product.md) - [Architecture](docs/specifications/architecture.md) - [PBLE/1 protocol](docs/specifications/protocol.md) @@ -264,7 +309,7 @@ fail-closed unless an explicit validated release input is supplied. If you use PyBLE in research, teaching, or another published work, use the metadata in [CITATION.cff](CITATION.cff). The app and firmware are versioned independently, so cite the exact archived project snapshot or component -release you used; qualified firmware v0.6.0 is not the app version. +release you used; published firmware v0.6.1 is not the app version. The first whole-project citation snapshot is [`source-2026.08.23`](https://github.com/PyBLE-dev/PyBLE/releases/tag/source-2026.08.23). diff --git a/app/lib/app/pages/about_page.dart b/app/lib/app/pages/about_page.dart index de5594d6..47119498 100644 --- a/app/lib/app/pages/about_page.dart +++ b/app/lib/app/pages/about_page.dart @@ -10,6 +10,7 @@ import 'package:pyble/localization/localization.dart'; import 'package:pyble/theme/theme.dart'; import '../app_build_info.dart'; +import 'privacy_policy_page.dart'; const double _kAboutMaxWidth = 1080; const double _kAboutTwoColumnMinWidth = 840; @@ -360,6 +361,20 @@ class _PrivacyCard extends StatelessWidget { title: l10n.aboutPrivacyTitle, titleKey: const Key('aboutPrivacyHeading'), body: l10n.aboutPrivacyBody, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + key: const Key('aboutPrivacyPolicyAction'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: PrivacyPolicyPage.routeName), + builder: (BuildContext context) => const PrivacyPolicyPage(), + ), + ), + icon: const Icon(Icons.policy_outlined), + label: Text(l10n.aboutPrivacyPolicyAction), + ), + ), ); } } diff --git a/app/lib/app/pages/privacy_policy_page.dart b/app/lib/app/pages/privacy_policy_page.dart new file mode 100644 index 00000000..8e769a36 --- /dev/null +++ b/app/lib/app/pages/privacy_policy_page.dart @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +/// X-11 — the complete, bundled policy, available without a board or network. +library; + +import 'package:flutter/material.dart'; + +import 'package:pyble/localization/localization.dart'; +import 'package:pyble/theme/theme.dart'; + +/// Offline counterpart of https://pyble.dev/privacy (FR-ABOUT-9). +class PrivacyPolicyPage extends StatelessWidget { + const PrivacyPolicyPage({super.key}); + + static const String routeName = '/about/privacy'; + // Technical contact identifiers remain verbatim across locales (FR-I18N-4). + static const String _policyUrl = 'https://pyble.dev/privacy'; + static const String _contactEmail = 'viwat.v@chula.ac.th'; + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context); + final TextTheme textTheme = Theme.of(context).textTheme; + final List<({String title, String body})> + sections = <({String title, String body})>[ + (title: l10n.privacyMaintainerTitle, body: l10n.privacyMaintainerBody), + (title: l10n.privacyAppTitle, body: l10n.privacyAppBody), + (title: l10n.privacyGithubTitle, body: l10n.privacyGithubBody), + (title: l10n.privacyBoardTitle, body: l10n.privacyBoardBody), + (title: l10n.privacyTransportTitle, body: l10n.privacyTransportBody), + (title: l10n.privacyPermissionsTitle, body: l10n.privacyPermissionsBody), + (title: l10n.privacyRetentionTitle, body: l10n.privacyRetentionBody), + (title: l10n.privacyComponentsTitle, body: l10n.privacyComponentsBody), + (title: l10n.privacyWebsiteTitle, body: l10n.privacyWebsiteBody), + (title: l10n.privacyChangesTitle, body: l10n.privacyChangesBody), + (title: l10n.privacyContactTitle, body: l10n.privacyContactBody), + ]; + + return Scaffold( + key: const Key('privacyPolicyPage'), + appBar: AppBar(title: Text(l10n.privacyPolicyTitle)), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(SignalSpacing.lg), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.privacyPolicyIntroduction), + const SizedBox(height: SignalSpacing.md), + Text(l10n.privacyPolicyEffectiveDate), + const SizedBox(height: SignalSpacing.sm), + const Text(_policyUrl), + for (final ({String title, String body}) section + in sections) ...[ + const SizedBox(height: SignalSpacing.xl), + Semantics( + header: true, + child: Text(section.title, style: textTheme.titleLarge), + ), + const SizedBox(height: SignalSpacing.sm), + Text(section.body), + ], + const SizedBox(height: SignalSpacing.sm), + const Text(_contactEmail), + const SizedBox(height: SignalSpacing.lg), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/ble/fbp_ble_link.dart b/app/lib/ble/fbp_ble_link.dart index 7e1fc2ee..1720c660 100644 --- a/app/lib/ble/fbp_ble_link.dart +++ b/app/lib/ble/fbp_ble_link.dart @@ -47,8 +47,23 @@ class FbpBleLink implements BleLink { @override Future disconnect() async { - await _device.disconnect(); - _linkState.value = BleLinkState.disconnected; - await _connSub.cancel(); + Object? firstError; + StackTrace? firstStack; + try { + await _device.disconnect(); + _linkState.value = BleLinkState.disconnected; + } catch (error, stack) { + firstError = error; + firstStack = stack; + } + try { + await _connSub.cancel(); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + if (firstError != null) { + Error.throwWithStackTrace(firstError, firstStack!); + } } } diff --git a/app/lib/localization/arb/app_en.arb b/app/lib/localization/arb/app_en.arb index b68802c2..0d33a388 100644 --- a/app/lib/localization/arb/app_en.arb +++ b/app/lib/localization/arb/app_en.arb @@ -124,6 +124,110 @@ "@aboutPrivacyBody": { "description": "Truthful offline-first privacy summary, including the sole optional network workflow." }, + "aboutPrivacyPolicyAction": "Privacy policy", + "@aboutPrivacyPolicyAction": { + "description": "Action opening the complete offline privacy policy." + }, + "privacyPolicyTitle": "PyBLE Privacy Policy", + "@privacyPolicyTitle": { + "description": "Title of the bundled offline privacy policy, matching the public policy." + }, + "privacyPolicyIntroduction": "PyBLE is designed to work locally. This policy explains what the app handles on your tablet, what you choose to send to a board, and what happens when you visit this website.", + "@privacyPolicyIntroduction": { + "description": "Introduction matching the public privacy policy." + }, + "privacyPolicyEffectiveDate": "Effective 28 August 2026", + "@privacyPolicyEffectiveDate": { + "description": "Effective date of the bundled policy; update together with pyble.dev/privacy." + }, + "privacyMaintainerTitle": "Who maintains PyBLE", + "@privacyMaintainerTitle": { + "description": "Offline privacy policy section heading: Who maintains PyBLE." + }, + "privacyMaintainerBody": "PyBLE is an independent open-source project maintained by Viwat Vchirawongkwin under the SciLabPro project name. It is not an official Chulalongkorn University project or app.", + "@privacyMaintainerBody": { + "description": "Bundled disclosure matching the public privacy policy section: Who maintains PyBLE." + }, + "privacyAppTitle": "PyBLE app", + "@privacyAppTitle": { + "description": "Offline privacy policy section heading: PyBLE app." + }, + "privacyAppBody": "The PyBLE app has no account, advertising, analytics, telemetry, or crash reporting. It also has no payment or cloud service. The user-started public GitHub import is its only optional Internet workflow. Editing, BLE, Files, Blocks, and Run continue to work offline and remain independent of GitHub availability. The app does not send your project content to PyBLE, SciLabPro, or the maintainer.", + "@privacyAppBody": { + "description": "Bundled disclosure matching the public privacy policy section: PyBLE app." + }, + "privacyGithubTitle": "Public GitHub import", + "@privacyGithubTitle": { + "description": "Offline privacy policy section heading: Public GitHub import." + }, + "privacyGithubBody": "When you explicitly open Import examples from GitHub, the app makes unauthenticated HTTPS requests only to api.github.com. Those requests include the public repository owner and name, the requested ref, branch discovery operations, directory paths, paths identifying the selected public source files, and Git object identifiers. GitHub returns the selected file contents in its HTTPS responses. Requests also include a bounded PyBLE version in the User-Agent. The app sends no GitHub account or token, no board identity or board files, and no private project source.\n\nGitHub independently receives ordinary request metadata such as your IP address and user-agent under GitHub's own privacy policy. Import only reads the public files you select. It never automatically opens or runs imported code; review the source and choose any later board action yourself.", + "@privacyGithubBody": { + "description": "Bundled disclosure matching the public privacy policy section: Public GitHub import." + }, + "privacyBoardTitle": "Data sent to your board", + "@privacyBoardTitle": { + "description": "Offline privacy policy section heading: Data sent to your board." + }, + "privacyBoardBody": "When you choose Save, Run, a Files operation, or send console input, PyBLE sends the relevant source code, path or filename, file content, Blocks companion data, or console input directly over BLE to the board you selected. Board output, files, and device information can return to the app as part of the same requested workflow. This is direct communication with your selected hardware, not an upload to a PyBLE or SciLabPro server. PyBLE does not sell this content or share it with advertisers or data brokers.", + "@privacyBoardBody": { + "description": "Bundled disclosure matching the public privacy policy section: Data sent to your board." + }, + "privacyTransportTitle": "BLE transport security", + "@privacyTransportTitle": { + "description": "Offline privacy policy section heading: BLE transport security." + }, + "privacyTransportBody": "PBLE/1 does not require Bluetooth pairing or BLE link encryption, so a board connection may operate without an authenticated, encrypted link. Do not send passwords, API keys, tokens, private keys, or other secrets unless you have separately verified your physical environment and transport security.", + "@privacyTransportBody": { + "description": "Bundled disclosure matching the public privacy policy section: BLE transport security." + }, + "privacyPermissionsTitle": "Nearby boards and platform permissions", + "@privacyPermissionsTitle": { + "description": "Offline privacy policy section heading: Nearby boards and platform permissions." + }, + "privacyPermissionsBody": "To discover and connect, the app processes nearby PyBLE board names or labels, platform device identifiers or suffixes, capabilities, and Bluetooth signal strength locally on your tablet. The app uses this information for the connection interface and does not send it to the developer.\n\nOn Android 11 and earlier, the legacy Android location permission is requested only because those Android versions require it for BLE scanning. PyBLE does not derive, store, or transmit your physical location. Android 12 and later use Nearby devices permissions, and iPadOS uses Bluetooth permission, for the same find-and-connect purpose.\n\nIf a board has a custom label, that board may include it in a nearby Bluetooth advertisement visible to other devices in range. Do not put a person's name, email address, or other sensitive information in a board label.", + "@privacyPermissionsBody": { + "description": "Bundled disclosure matching the public privacy policy section: Nearby boards and platform permissions." + }, + "privacyRetentionTitle": "Retention and deletion", + "@privacyRetentionTitle": { + "description": "Offline privacy policy section heading: Retention and deletion." + }, + "privacyRetentionBody": "App working data stays on your tablet for use by the app. On Android, clear PyBLE's app storage or uninstall it; on iPadOS, delete the app. This removes data held only by the installed app but does not delete files on a board or exported copies. Device and operating-system backup behavior depends on your platform settings.\n\nFiles saved to a board remain there until you overwrite or delete them or erase the board. You can delete board files through Files → Delete in PyBLE or manage and erase the board directly. Because the developer receives no app project content, there is no PyBLE server-side copy of your app project content to retain or delete.", + "@privacyRetentionBody": { + "description": "Bundled disclosure matching the public privacy policy section: Retention and deletion." + }, + "privacyComponentsTitle": "Third-party components and platform services", + "@privacyComponentsTitle": { + "description": "Offline privacy policy section heading: Third-party components and platform services." + }, + "privacyComponentsBody": "The app contains no advertising, analytics, crash-reporting, or account SDK that receives your app project content. Your operating system and app-distribution service may independently process installation, system, or store information under their own privacy policies.", + "@privacyComponentsBody": { + "description": "Bundled disclosure matching the public privacy policy section: Third-party components and platform services." + }, + "privacyWebsiteTitle": "This website", + "@privacyWebsiteTitle": { + "description": "Offline privacy policy section heading: This website." + }, + "privacyWebsiteBody": "pyble.dev has no account, advertising, analytics, tracking pixel, contact form, or non-essential cookie. The site does not build a visitor profile.\n\nCloudflare and the VPS hosting infrastructure may process ordinary request data—such as your IP address, user-agent, requested URL, timestamp, and security signals—to deliver the pages, prevent abuse, and keep the service reliable.\n\nThe site does not load marketing scripts, remote web fonts, or embedded social media. Following a link to another site makes that site's policy apply.\n\nWebsite infrastructure may keep limited operational logs according to provider settings and legal obligations. No separate fixed retention period is promised. We use HTTPS and keep the public site static to reduce the data and attack surface involved.", + "@privacyWebsiteBody": { + "description": "Bundled disclosure matching the public privacy policy section: This website." + }, + "privacyChangesTitle": "Changes to this policy", + "@privacyChangesTitle": { + "description": "Offline privacy policy section heading: Changes to this policy." + }, + "privacyChangesBody": "If a future app or site feature handles information differently— such as analytics, a form, an account, another network integration, or embedded media—we will update this policy and its effective date before that feature is deployed.", + "@privacyChangesBody": { + "description": "Bundled disclosure matching the public privacy policy section: Changes to this policy." + }, + "privacyContactTitle": "Privacy and deletion questions", + "@privacyContactTitle": { + "description": "Offline privacy contact heading." + }, + "privacyContactBody": "Contact the project maintainer about this policy, a privacy concern, or deletion of website data under the maintainer’s control.", + "@privacyContactBody": { + "description": "Privacy contact scope matching the public policy; verified email is rendered verbatim." + }, "aboutProjectTitle": "Project", "@aboutProjectTitle": { "description": "About section heading for project provenance and canonical website." diff --git a/app/lib/localization/gen/app_localizations.dart b/app/lib/localization/gen/app_localizations.dart index 4508bd2a..7d83d6ad 100644 --- a/app/lib/localization/gen/app_localizations.dart +++ b/app/lib/localization/gen/app_localizations.dart @@ -253,6 +253,162 @@ abstract class AppLocalizations { /// **'PyBLE requires no account and sends no telemetry by default. Your work stays on your tablet and connected board unless you explicitly export it. A user-started public GitHub import is the only optional network workflow.'** String get aboutPrivacyBody; + /// Action opening the complete offline privacy policy. + /// + /// In en, this message translates to: + /// **'Privacy policy'** + String get aboutPrivacyPolicyAction; + + /// Title of the bundled offline privacy policy, matching the public policy. + /// + /// In en, this message translates to: + /// **'PyBLE Privacy Policy'** + String get privacyPolicyTitle; + + /// Introduction matching the public privacy policy. + /// + /// In en, this message translates to: + /// **'PyBLE is designed to work locally. This policy explains what the app handles on your tablet, what you choose to send to a board, and what happens when you visit this website.'** + String get privacyPolicyIntroduction; + + /// Effective date of the bundled policy; update together with pyble.dev/privacy. + /// + /// In en, this message translates to: + /// **'Effective 28 August 2026'** + String get privacyPolicyEffectiveDate; + + /// Offline privacy policy section heading: Who maintains PyBLE. + /// + /// In en, this message translates to: + /// **'Who maintains PyBLE'** + String get privacyMaintainerTitle; + + /// Bundled disclosure matching the public privacy policy section: Who maintains PyBLE. + /// + /// In en, this message translates to: + /// **'PyBLE is an independent open-source project maintained by Viwat Vchirawongkwin under the SciLabPro project name. It is not an official Chulalongkorn University project or app.'** + String get privacyMaintainerBody; + + /// Offline privacy policy section heading: PyBLE app. + /// + /// In en, this message translates to: + /// **'PyBLE app'** + String get privacyAppTitle; + + /// Bundled disclosure matching the public privacy policy section: PyBLE app. + /// + /// In en, this message translates to: + /// **'The PyBLE app has no account, advertising, analytics, telemetry, or crash reporting. It also has no payment or cloud service. The user-started public GitHub import is its only optional Internet workflow. Editing, BLE, Files, Blocks, and Run continue to work offline and remain independent of GitHub availability. The app does not send your project content to PyBLE, SciLabPro, or the maintainer.'** + String get privacyAppBody; + + /// Offline privacy policy section heading: Public GitHub import. + /// + /// In en, this message translates to: + /// **'Public GitHub import'** + String get privacyGithubTitle; + + /// Bundled disclosure matching the public privacy policy section: Public GitHub import. + /// + /// In en, this message translates to: + /// **'When you explicitly open Import examples from GitHub, the app makes unauthenticated HTTPS requests only to api.github.com. Those requests include the public repository owner and name, the requested ref, branch discovery operations, directory paths, paths identifying the selected public source files, and Git object identifiers. GitHub returns the selected file contents in its HTTPS responses. Requests also include a bounded PyBLE version in the User-Agent. The app sends no GitHub account or token, no board identity or board files, and no private project source.\n\nGitHub independently receives ordinary request metadata such as your IP address and user-agent under GitHub\'s own privacy policy. Import only reads the public files you select. It never automatically opens or runs imported code; review the source and choose any later board action yourself.'** + String get privacyGithubBody; + + /// Offline privacy policy section heading: Data sent to your board. + /// + /// In en, this message translates to: + /// **'Data sent to your board'** + String get privacyBoardTitle; + + /// Bundled disclosure matching the public privacy policy section: Data sent to your board. + /// + /// In en, this message translates to: + /// **'When you choose Save, Run, a Files operation, or send console input, PyBLE sends the relevant source code, path or filename, file content, Blocks companion data, or console input directly over BLE to the board you selected. Board output, files, and device information can return to the app as part of the same requested workflow. This is direct communication with your selected hardware, not an upload to a PyBLE or SciLabPro server. PyBLE does not sell this content or share it with advertisers or data brokers.'** + String get privacyBoardBody; + + /// Offline privacy policy section heading: BLE transport security. + /// + /// In en, this message translates to: + /// **'BLE transport security'** + String get privacyTransportTitle; + + /// Bundled disclosure matching the public privacy policy section: BLE transport security. + /// + /// In en, this message translates to: + /// **'PBLE/1 does not require Bluetooth pairing or BLE link encryption, so a board connection may operate without an authenticated, encrypted link. Do not send passwords, API keys, tokens, private keys, or other secrets unless you have separately verified your physical environment and transport security.'** + String get privacyTransportBody; + + /// Offline privacy policy section heading: Nearby boards and platform permissions. + /// + /// In en, this message translates to: + /// **'Nearby boards and platform permissions'** + String get privacyPermissionsTitle; + + /// Bundled disclosure matching the public privacy policy section: Nearby boards and platform permissions. + /// + /// In en, this message translates to: + /// **'To discover and connect, the app processes nearby PyBLE board names or labels, platform device identifiers or suffixes, capabilities, and Bluetooth signal strength locally on your tablet. The app uses this information for the connection interface and does not send it to the developer.\n\nOn Android 11 and earlier, the legacy Android location permission is requested only because those Android versions require it for BLE scanning. PyBLE does not derive, store, or transmit your physical location. Android 12 and later use Nearby devices permissions, and iPadOS uses Bluetooth permission, for the same find-and-connect purpose.\n\nIf a board has a custom label, that board may include it in a nearby Bluetooth advertisement visible to other devices in range. Do not put a person\'s name, email address, or other sensitive information in a board label.'** + String get privacyPermissionsBody; + + /// Offline privacy policy section heading: Retention and deletion. + /// + /// In en, this message translates to: + /// **'Retention and deletion'** + String get privacyRetentionTitle; + + /// Bundled disclosure matching the public privacy policy section: Retention and deletion. + /// + /// In en, this message translates to: + /// **'App working data stays on your tablet for use by the app. On Android, clear PyBLE\'s app storage or uninstall it; on iPadOS, delete the app. This removes data held only by the installed app but does not delete files on a board or exported copies. Device and operating-system backup behavior depends on your platform settings.\n\nFiles saved to a board remain there until you overwrite or delete them or erase the board. You can delete board files through Files → Delete in PyBLE or manage and erase the board directly. Because the developer receives no app project content, there is no PyBLE server-side copy of your app project content to retain or delete.'** + String get privacyRetentionBody; + + /// Offline privacy policy section heading: Third-party components and platform services. + /// + /// In en, this message translates to: + /// **'Third-party components and platform services'** + String get privacyComponentsTitle; + + /// Bundled disclosure matching the public privacy policy section: Third-party components and platform services. + /// + /// In en, this message translates to: + /// **'The app contains no advertising, analytics, crash-reporting, or account SDK that receives your app project content. Your operating system and app-distribution service may independently process installation, system, or store information under their own privacy policies.'** + String get privacyComponentsBody; + + /// Offline privacy policy section heading: This website. + /// + /// In en, this message translates to: + /// **'This website'** + String get privacyWebsiteTitle; + + /// Bundled disclosure matching the public privacy policy section: This website. + /// + /// In en, this message translates to: + /// **'pyble.dev has no account, advertising, analytics, tracking pixel, contact form, or non-essential cookie. The site does not build a visitor profile.\n\nCloudflare and the VPS hosting infrastructure may process ordinary request data—such as your IP address, user-agent, requested URL, timestamp, and security signals—to deliver the pages, prevent abuse, and keep the service reliable.\n\nThe site does not load marketing scripts, remote web fonts, or embedded social media. Following a link to another site makes that site\'s policy apply.\n\nWebsite infrastructure may keep limited operational logs according to provider settings and legal obligations. No separate fixed retention period is promised. We use HTTPS and keep the public site static to reduce the data and attack surface involved.'** + String get privacyWebsiteBody; + + /// Offline privacy policy section heading: Changes to this policy. + /// + /// In en, this message translates to: + /// **'Changes to this policy'** + String get privacyChangesTitle; + + /// Bundled disclosure matching the public privacy policy section: Changes to this policy. + /// + /// In en, this message translates to: + /// **'If a future app or site feature handles information differently— such as analytics, a form, an account, another network integration, or embedded media—we will update this policy and its effective date before that feature is deployed.'** + String get privacyChangesBody; + + /// Offline privacy contact heading. + /// + /// In en, this message translates to: + /// **'Privacy and deletion questions'** + String get privacyContactTitle; + + /// Privacy contact scope matching the public policy; verified email is rendered verbatim. + /// + /// In en, this message translates to: + /// **'Contact the project maintainer about this policy, a privacy concern, or deletion of website data under the maintainer’s control.'** + String get privacyContactBody; + /// About section heading for project provenance and canonical website. /// /// In en, this message translates to: diff --git a/app/lib/localization/gen/app_localizations_en.dart b/app/lib/localization/gen/app_localizations_en.dart index 849b5161..308b8d01 100644 --- a/app/lib/localization/gen/app_localizations_en.dart +++ b/app/lib/localization/gen/app_localizations_en.dart @@ -98,6 +98,98 @@ class AppLocalizationsEn extends AppLocalizations { String get aboutPrivacyBody => 'PyBLE requires no account and sends no telemetry by default. Your work stays on your tablet and connected board unless you explicitly export it. A user-started public GitHub import is the only optional network workflow.'; + @override + String get aboutPrivacyPolicyAction => 'Privacy policy'; + + @override + String get privacyPolicyTitle => 'PyBLE Privacy Policy'; + + @override + String get privacyPolicyIntroduction => + 'PyBLE is designed to work locally. This policy explains what the app handles on your tablet, what you choose to send to a board, and what happens when you visit this website.'; + + @override + String get privacyPolicyEffectiveDate => 'Effective 28 August 2026'; + + @override + String get privacyMaintainerTitle => 'Who maintains PyBLE'; + + @override + String get privacyMaintainerBody => + 'PyBLE is an independent open-source project maintained by Viwat Vchirawongkwin under the SciLabPro project name. It is not an official Chulalongkorn University project or app.'; + + @override + String get privacyAppTitle => 'PyBLE app'; + + @override + String get privacyAppBody => + 'The PyBLE app has no account, advertising, analytics, telemetry, or crash reporting. It also has no payment or cloud service. The user-started public GitHub import is its only optional Internet workflow. Editing, BLE, Files, Blocks, and Run continue to work offline and remain independent of GitHub availability. The app does not send your project content to PyBLE, SciLabPro, or the maintainer.'; + + @override + String get privacyGithubTitle => 'Public GitHub import'; + + @override + String get privacyGithubBody => + 'When you explicitly open Import examples from GitHub, the app makes unauthenticated HTTPS requests only to api.github.com. Those requests include the public repository owner and name, the requested ref, branch discovery operations, directory paths, paths identifying the selected public source files, and Git object identifiers. GitHub returns the selected file contents in its HTTPS responses. Requests also include a bounded PyBLE version in the User-Agent. The app sends no GitHub account or token, no board identity or board files, and no private project source.\n\nGitHub independently receives ordinary request metadata such as your IP address and user-agent under GitHub\'s own privacy policy. Import only reads the public files you select. It never automatically opens or runs imported code; review the source and choose any later board action yourself.'; + + @override + String get privacyBoardTitle => 'Data sent to your board'; + + @override + String get privacyBoardBody => + 'When you choose Save, Run, a Files operation, or send console input, PyBLE sends the relevant source code, path or filename, file content, Blocks companion data, or console input directly over BLE to the board you selected. Board output, files, and device information can return to the app as part of the same requested workflow. This is direct communication with your selected hardware, not an upload to a PyBLE or SciLabPro server. PyBLE does not sell this content or share it with advertisers or data brokers.'; + + @override + String get privacyTransportTitle => 'BLE transport security'; + + @override + String get privacyTransportBody => + 'PBLE/1 does not require Bluetooth pairing or BLE link encryption, so a board connection may operate without an authenticated, encrypted link. Do not send passwords, API keys, tokens, private keys, or other secrets unless you have separately verified your physical environment and transport security.'; + + @override + String get privacyPermissionsTitle => + 'Nearby boards and platform permissions'; + + @override + String get privacyPermissionsBody => + 'To discover and connect, the app processes nearby PyBLE board names or labels, platform device identifiers or suffixes, capabilities, and Bluetooth signal strength locally on your tablet. The app uses this information for the connection interface and does not send it to the developer.\n\nOn Android 11 and earlier, the legacy Android location permission is requested only because those Android versions require it for BLE scanning. PyBLE does not derive, store, or transmit your physical location. Android 12 and later use Nearby devices permissions, and iPadOS uses Bluetooth permission, for the same find-and-connect purpose.\n\nIf a board has a custom label, that board may include it in a nearby Bluetooth advertisement visible to other devices in range. Do not put a person\'s name, email address, or other sensitive information in a board label.'; + + @override + String get privacyRetentionTitle => 'Retention and deletion'; + + @override + String get privacyRetentionBody => + 'App working data stays on your tablet for use by the app. On Android, clear PyBLE\'s app storage or uninstall it; on iPadOS, delete the app. This removes data held only by the installed app but does not delete files on a board or exported copies. Device and operating-system backup behavior depends on your platform settings.\n\nFiles saved to a board remain there until you overwrite or delete them or erase the board. You can delete board files through Files → Delete in PyBLE or manage and erase the board directly. Because the developer receives no app project content, there is no PyBLE server-side copy of your app project content to retain or delete.'; + + @override + String get privacyComponentsTitle => + 'Third-party components and platform services'; + + @override + String get privacyComponentsBody => + 'The app contains no advertising, analytics, crash-reporting, or account SDK that receives your app project content. Your operating system and app-distribution service may independently process installation, system, or store information under their own privacy policies.'; + + @override + String get privacyWebsiteTitle => 'This website'; + + @override + String get privacyWebsiteBody => + 'pyble.dev has no account, advertising, analytics, tracking pixel, contact form, or non-essential cookie. The site does not build a visitor profile.\n\nCloudflare and the VPS hosting infrastructure may process ordinary request data—such as your IP address, user-agent, requested URL, timestamp, and security signals—to deliver the pages, prevent abuse, and keep the service reliable.\n\nThe site does not load marketing scripts, remote web fonts, or embedded social media. Following a link to another site makes that site\'s policy apply.\n\nWebsite infrastructure may keep limited operational logs according to provider settings and legal obligations. No separate fixed retention period is promised. We use HTTPS and keep the public site static to reduce the data and attack surface involved.'; + + @override + String get privacyChangesTitle => 'Changes to this policy'; + + @override + String get privacyChangesBody => + 'If a future app or site feature handles information differently— such as analytics, a form, an account, another network integration, or embedded media—we will update this policy and its effective date before that feature is deployed.'; + + @override + String get privacyContactTitle => 'Privacy and deletion questions'; + + @override + String get privacyContactBody => + 'Contact the project maintainer about this policy, a privacy concern, or deletion of website data under the maintainer’s control.'; + @override String get aboutProjectTitle => 'Project'; diff --git a/app/lib/pble/connection_manager.dart b/app/lib/pble/connection_manager.dart index 9753606e..ed1a487a 100644 --- a/app/lib/pble/connection_manager.dart +++ b/app/lib/pble/connection_manager.dart @@ -160,6 +160,10 @@ class PbleConnectionManager implements ConnectionManager { ScanHit? _selected; Object? _lastError; bool _disposed = false; + int _intent = 0; + Future? _disposeFuture; + final Set> _retirements = >{}; + final Map> _openings = >{}; @override Connection get connection => _facade; @@ -181,6 +185,7 @@ class PbleConnectionManager implements ConnectionManager { @override Future startScan({Duration? timeout}) async { + _ensureActive(); // Fire-and-forget: a broadcast-subscription cancel() future never completes // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in // every widget test. The listener is removed synchronously either way; the @@ -199,6 +204,7 @@ class PbleConnectionManager implements ConnectionManager { @override Future stopScan() async { + _ensureActive(); // Fire-and-forget: a broadcast-subscription cancel() future never completes // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in // every widget test. The listener is removed synchronously either way; the @@ -208,13 +214,18 @@ class PbleConnectionManager implements ConnectionManager { await _scanner.stopScan(); // Only fall back to idle when no board is live (a connected session keeps // its connected/connecting phase). - if (_liveBoard == null && _phase.value == ConnectPhase.scanning) { + if (!_disposed && + _liveBoard == null && + _phase.value == ConnectPhase.scanning) { _phase.value = ConnectPhase.idle; } } @override Future connect(String id) async { + _ensureActive(); + final int intent = ++_intent; + _detachAndRetire(); // Scanning and GATT do not run together — stop the scan first (FR-BLE-5). // Fire-and-forget: a broadcast-subscription cancel() future never completes // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in @@ -222,42 +233,97 @@ class PbleConnectionManager implements ConnectionManager { // completion signal is not needed here (matches _StableFacade.detach below). unawaited(_scanSub?.cancel()); _scanSub = null; - await _scanner.stopScan(); - _selected = _hits[id] ?? ScanHit(id: id, name: '', rssi: 0); _lastError = null; _phase.value = ConnectPhase.connecting; try { - final Connection board = await _connectionFactory(id); - _liveBoard = board; - _facade.attach(board); - // Derive the phase from the freshly attached board's live state (the - // listener also fires on the sync-up, but re-derive to cover a board that - // attaches already-ready with no state change). - _phase.value = _phaseForConn(_facade.state.value); + await _scanner.stopScan(); + // A previous Disconnect/replacement may still own a physical close. + // Do not open another board until those exact retirements complete. + await Future.wait(_retirements.toList()); + if (!_isCurrent(intent)) return; + if (_openings[id] case final Future previous) { + // Native wrappers with the same ID share one physical link. Finish + // the stale acquisition AND its teardown before opening that ID again. + // Its factory error belongs to its caller; a failed physical close is + // independently retained in _retirements and must still block us. + try { + await previous; + } catch (_) {} + await Future.wait(_retirements.toList()); + if (!_isCurrent(intent)) return; + } + final Future opening = Future.microtask( + () => _openAndPublish(id, intent), + ); + _openings[id] = opening; + try { + await opening; + } finally { + if (identical(_openings[id], opening)) _openings.remove(id); + } } catch (error) { - _lastError = error; - _phase.value = ConnectPhase.failed; + if (_isCurrent(intent)) { + _lastError = error; + _phase.value = ConnectPhase.failed; + } rethrow; } } + Future _openAndPublish(String id, int intent) async { + if (!_isCurrent(intent)) return; + final Connection board = await _connectionFactory(id); + if (!_isCurrent(intent)) { + await _retire(board); + return; + } + _liveBoard = board; + _facade.attach(board); + _phase.value = _phaseForConn(_facade.state.value); + } + @override Future disconnect() async { - final Connection? board = _liveBoard; - // Null the live board BEFORE detaching so the facade's reset to - // disconnected does not drive the phase (we set it to idle explicitly). - _liveBoard = null; - _facade.detach(); - await board?.dispose(); + _ensureActive(); + final int intent = ++_intent; + _detachAndRetire(); _selected = null; - _phase.value = ConnectPhase.idle; + try { + await Future.wait(_retirements.toList()); + if (_isCurrent(intent)) { + _lastError = null; + _phase.value = ConnectPhase.idle; + } + } catch (error) { + if (_isCurrent(intent)) { + _lastError = error; + _phase.value = ConnectPhase.failed; + } + rethrow; + } } @override - Future dispose() async { - if (_disposed) return; + Future dispose() { + if (_disposeFuture case final Future pending) return pending; _disposed = true; + _intent++; + return _disposeFuture = Future.microtask(_disposeResources); + } + + Future _disposeResources() async { + Object? firstError; + StackTrace? firstStack; + Future attempt(FutureOr Function() action) async { + try { + await action(); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + // Fire-and-forget: a broadcast-subscription cancel() future never completes // under `tester.pump()` (FakeAsync), which would stall connect/stopScan in // every widget test. The listener is removed synchronously either way; the @@ -265,18 +331,53 @@ class PbleConnectionManager implements ConnectionManager { unawaited(_scanSub?.cancel()); _scanSub = null; _facade.state.removeListener(_onFacadeState); + _detachAndRetire(); + _selected = null; + await Future.wait(>[ + attempt(_scanner.stopScan), + attempt(() => Future.wait(_retirements.toList())), + ]); + await attempt(_scanResults.close); + await attempt(_facade.dispose); + await attempt(_phase.dispose); + if (firstError != null) { + Error.throwWithStackTrace(firstError!, firstStack!); + } + } + + bool _isCurrent(int intent) => !_disposed && _intent == intent; + + void _ensureActive() { + if (_disposed) throw StateError('connection manager disposed'); + } + + void _detachAndRetire() { final Connection? board = _liveBoard; _liveBoard = null; - await board?.dispose(); - await _scanResults.close(); - await _facade.dispose(); - _phase.dispose(); + _facade.detach(); + if (board != null) _retire(board); + } + + Future _retire(Connection board) { + final Future closing = Future.sync(board.dispose); + _retirements.add(closing); + // Remove only successful retirements. A failed physical close is not proof + // that another link can safely be opened; later operations must still see + // that failure. Consume this bookkeeping branch's error without replacing + // the original future or issuing an automatic disconnect retry. + unawaited( + closing.then( + (_) => _retirements.remove(closing), + onError: (Object _, StackTrace _) {}, + ), + ); + return closing; } // Track the session phase off the stable facade's live ConnState. Only // meaningful while a board is attached; scan/failure phases own the idle path. void _onFacadeState() { - if (_liveBoard == null) return; + if (_disposed || _liveBoard == null) return; _phase.value = _phaseForConn(_facade.state.value); } diff --git a/app/lib/pble/engine.dart b/app/lib/pble/engine.dart index 454dd186..1c8a83e9 100644 --- a/app/lib/pble/engine.dart +++ b/app/lib/pble/engine.dart @@ -59,6 +59,15 @@ class PbleEngine { } final _PendingRequest pending = _PendingRequest(cmd.opcode, timeout); _pending[cmd.id] = pending; + // Disposal can reject this completion while an acknowledged write is + // still pending. Observe errors now without replacing the original future + // that the request later awaits (including its exact response/error). + unawaited( + pending.completer.future.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ), + ); try { final List packets = PbleFragmenter( mtu: _transport.mtu, @@ -71,6 +80,12 @@ class PbleEngine { remaining, onTimeout: () => throw const _RequestDeadlineExpired(), ); + if (_disposed) { + if (pending.completer.isCompleted) { + return await pending.completer.future; + } + throw const PbleTimeoutException('engine disposed'); + } } if (pending.completer.isCompleted) { return await pending.completer.future; @@ -109,6 +124,7 @@ class PbleEngine { ).fragment(encodeFrame(cmd)); for (final Uint8List packet in packets) { await _transport.send(packet, acknowledged: false); + if (_disposed) throw const PbleTimeoutException('engine disposed'); } } @@ -147,7 +163,14 @@ class PbleEngine { Future dispose() async { if (_disposed) return; _disposed = true; - await _inboundSub.cancel(); + Object? cancelError; + StackTrace? cancelStack; + try { + await _inboundSub.cancel(); + } catch (error, stack) { + cancelError = error; + cancelStack = stack; + } for (final _PendingRequest pending in _pending.values) { if (!pending.completer.isCompleted) { pending.completer.completeError( @@ -157,6 +180,9 @@ class PbleEngine { } _pending.clear(); await _events.close(); + if (cancelError != null) { + Error.throwWithStackTrace(cancelError, cancelStack!); + } } } diff --git a/app/lib/pble/hello.dart b/app/lib/pble/hello.dart index 7a46688b..fc84650d 100644 --- a/app/lib/pble/hello.dart +++ b/app/lib/pble/hello.dart @@ -79,11 +79,9 @@ class HelloNegotiator { /// Serializes the HELLO CMD request body. /// - /// NOTE: the §7 HELLO *request* byte-serialization is NOT frozen (no - /// conformance vector asserts it — the shared corpus treats HELLO payloads as - /// opaque). This mirrors the observed newline `key=value` caps form for - /// symmetry; it is provisional and will be reconciled when protocol.md §7 - /// freezes the payload encoding. + /// PBLE/1 §7 freezes this newline-delimited `key=value` serialization. The + /// v0.6.1 shared semantic corpus byte-verifies every client-applicable HELLO + /// request against portable Python, dependency-free native C, and Dart. Uint8List _encodeHelloRequest(List offer) { final String text = [ 'proto_versions=${offer.join(',')}', diff --git a/app/lib/pble/pble_connection.dart b/app/lib/pble/pble_connection.dart index e14b975e..f823dc7e 100644 --- a/app/lib/pble/pble_connection.dart +++ b/app/lib/pble/pble_connection.dart @@ -72,12 +72,14 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { required String appVersion, Duration dataTimeout = const Duration(seconds: 8), }) { + final BleByteTransport transport = BleByteTransport(link); final PbleConnection conn = PbleConnection( - engine: PbleEngine(BleByteTransport(link)), + engine: PbleEngine(transport), appName: appName, appVersion: appVersion, dataTimeout: dataTimeout, ); + conn._ownedTransport = transport; conn._observeLink(link); return conn; } @@ -97,14 +99,20 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { // fromLink observation (null when constructed directly over a transport). BleLink? _observedLink; + BleByteTransport? _ownedTransport; VoidCallback? _linkListener; + bool _disposed = false; + Future? _disposeFuture; + int _handshakeGeneration = 0; void _observeLink(BleLink link) { _observedLink = link; _linkListener = () { + if (_disposed) return; switch (link.linkState.value) { case BleLinkState.connecting: case BleLinkState.reconnecting: + _handshakeGeneration++; _state.value = ConnState.connecting; case BleLinkState.connected: // Re-negotiate HELLO on every (re)connect (TDD §7.3). A failed @@ -112,6 +120,8 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { // deliberately not rethrown here — nothing awaits a link callback. unawaited(handshake().catchError((_) {})); case BleLinkState.disconnected: + _handshakeGeneration++; + _hello = null; _state.value = ConnState.disconnected; // Abort any in-flight transfer NOW: a reconnect re-HELLOs and the // board clears its transfer context on disconnect, so an orphaned @@ -173,12 +183,28 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { /// `ready`; on any failure it returns to `disconnected` and the typed error /// (e.g. [UnsupportedProtocolException], a §8 subtype) is rethrown. Future handshake() async { + _ensureActive(); + final int generation = ++_handshakeGeneration; _state.value = ConnState.connecting; try { - _hello = await _negotiator.negotiate(); + final HelloResult hello = await _negotiator.negotiate(); + if (_disposed || generation != _handshakeGeneration) { + throw const NotConnectedException('connection changed during HELLO'); + } + _hello = hello; _state.value = ConnState.ready; } catch (_) { - _state.value = ConnState.disconnected; + if (!_disposed && generation == _handshakeGeneration) { + _state.value = ConnState.disconnected; + if (_observedLink != null) { + // An unusable owned session must not leave the radio connected. + // Keep the negotiation error as this operation's original failure; + // a close error remains available from the shared dispose future. + try { + await dispose(); + } catch (_) {} + } + } rethrow; } } @@ -277,6 +303,7 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { final PbleFrame begin = await _engine.request( _cmd(PbleOpcode.fileGetBegin, [..._u32(0), ..._pathField(path)]), ); + _ensureActive(); _checkStatus(begin); final int total = _readU32(begin.payload, 1); @@ -321,6 +348,7 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { ..._pathField(path), ]), ); + _ensureActive(); _checkStatus(begin); // resume_offset (FR-PBLE-10): a board holding a verified prefix returns it // so the window starts mid-file — never re-send the verified bytes. @@ -424,6 +452,7 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { /// runs while a REAL error is propagating, and a dead link (the common cause) /// would otherwise replace the caller's honest error with a timeout. Future _releaseBoardTransfer(int crc) async { + if (_disposed) return; try { await _engine .request( @@ -484,22 +513,55 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { } @override - Future dispose() async { - _downloadStallTimer?.cancel(); - _putStallTimer?.cancel(); - if (_linkListener != null) { - _observedLink?.linkState.removeListener(_linkListener!); + Future dispose() { + if (_disposeFuture case final Future pending) return pending; + _disposed = true; + _handshakeGeneration++; + // Store the shared future before notifying listeners or closing the link: + // either can re-enter dispose synchronously. + return _disposeFuture = Future.microtask(_disposeResources); + } + + Future _disposeResources() async { + Object? firstError; + StackTrace? firstStack; + Future attempt(FutureOr Function() action) async { + try { + await action(); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + + _hello = null; + _state.value = ConnState.disconnected; + _abortTransfers(const NotConnectedException('connection disposed')); + await attempt(() { + if (_linkListener != null) { + _observedLink?.linkState.removeListener(_linkListener!); + } + }); + // Begin protocol retirement and the exact owned physical close together; + // a failing subscription cleanup must not skip physical disconnect. + await Future.wait(>[ + attempt(_eventsSub.cancel), + attempt(_engine.dispose), + if (_observedLink case final BleLink link) attempt(link.disconnect), + ]); + await attempt(() => _ownedTransport?.dispose()); + await attempt(_runStateController.close); + await attempt(_consoleController.close); + await attempt(_state.dispose); + if (firstError != null) { + Error.throwWithStackTrace(firstError!, firstStack!); } - await _eventsSub.cancel(); - await _engine.dispose(); - await _runStateController.close(); - await _consoleController.close(); - _state.dispose(); } // --- inbound EVT routing (protocol.md §6 / §5) ----------------------------- void _onEvent(PbleFrame frame) { + if (_disposed) return; final int op = frame.opcode; final Uint8List p = frame.payload; @@ -593,12 +655,19 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { // --- helpers --------------------------------------------------------------- /// Builds a CMD frame on [op] with a fresh correlation ID. - PbleFrame _cmd(PbleOpcode op, List payload) => PbleFrame( - type: Pble.typeCmd, - opcode: op.code, - id: _engine.nextId(), - payload: Uint8List.fromList(payload), - ); + PbleFrame _cmd(PbleOpcode op, List payload) { + _ensureActive(); + return PbleFrame( + type: Pble.typeCmd, + opcode: op.code, + id: _engine.nextId(), + payload: Uint8List.fromList(payload), + ); + } + + void _ensureActive() { + if (_disposed) throw const NotConnectedException('connection disposed'); + } /// Throws the typed §8 exception for a non-OK RSP `[status]` byte; OK returns. void _checkStatus(PbleFrame rsp) { @@ -619,6 +688,7 @@ class PbleConnection implements Connection, ConnectionDirectoryListingSource { /// Claims the single active-transfer slot (SEC-2 / FR-CONN-9); a concurrent /// transfer is refused with [EBusy] BEFORE any BEGIN frame is sent. void _beginTransfer() { + _ensureActive(); if (_transferActive) { throw const EBusy('another file transfer is already active'); } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a0d6fa08..449c3422 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: 'none' # SemVer app version. Pre-1.0 through milestones M0–M3; v1.0.0 ships at the # at-parity App Store + Play release (story X-11). Build number after '+'. -version: 0.2.0+5 +version: 0.2.0+8 environment: sdk: ^3.12.1 diff --git a/app/test/conformance/hello_negotiation_conformance_test.dart b/app/test/conformance/hello_negotiation_conformance_test.dart index 47d44a75..bd9e0ebc 100644 --- a/app/test/conformance/hello_negotiation_conformance_test.dart +++ b/app/test/conformance/hello_negotiation_conformance_test.dart @@ -17,11 +17,11 @@ // PbleStatusException is REPLACED by the per-status subtypes, so eInternal // maps to EInternal — TDD §14.1, A-11 contract). // -// SCOPE (S2): the HELLO/caps PAYLOAD byte-serialization is DRAFT and is NOT in -// the shared corpus; this suite asserts against the HIL-observed newline -// key=value caps text (agent v0.4.0) with a TOLERANT parser. Byte-level HELLO -// firmware conformance is deferred until protocol.md §7 freezes the payload -// encoding. +// v0.6.1: request serialization is frozen and consumed from the SAME semantic +// corpus as portable Python and dependency-free native C (TDD D11). Dart owns +// the client expectations: it emits canonical request bytes and maps the +// board's frozen status/caps response; agent-only invalid-input admission is +// asserted by the Python/C consumers of that same file. // // CURRENTLY RED: `lib/pble/{engine,hello,pble_constants,pble_exception}.dart` // and the extended DeviceInfo do not exist yet. HAND-OFF: `lib/pble/**` → @@ -41,6 +41,8 @@ import 'package:pyble/pble/pble_constants.dart'; import 'package:pyble/pble/types.dart'; import '../support/fake_byte_transport.dart'; +import '../support/hex.dart'; +import '../support/repo_paths.dart'; /// The real agent's HIL-observed caps text (agent v0.4.0), with the chosen /// [proto] overridable to exercise the version-refusal path. @@ -72,6 +74,25 @@ Uint8List helloRspPayload({ ]); void main() { + final Map sessionCorpus = + jsonDecode(v061SessionCorpus().readAsStringSync()) + as Map; + final Map> helloVectors = + >{ + for (final Map value + in (sessionCorpus['hello_cases'] as List) + .cast>()) + value['name'] as String: value, + }; + + Uint8List vectorPayload(String name) { + final Map vector = helloVectors[name]!; + final String? ascii = vector['payload_ascii'] as String?; + return ascii != null + ? Uint8List.fromList(ascii.codeUnits) + : Uint8List.fromList(hexToBytes(vector['payload_hex'] as String)); + } + late FakeByteTransport transport; late PbleEngine engine; late HelloNegotiator negotiator; @@ -82,7 +103,7 @@ void main() { negotiator = HelloNegotiator( engine: engine, appName: 'PyBLE', - appVersion: '0.1.0', + appVersion: '0.2.0', ); }); @@ -103,6 +124,13 @@ void main() { final PbleFrame sent = transport.sentFrames.single; expect(sent.type, Pble.typeCmd); expect(sent.opcode, PbleOpcode.hello.code); + expect( + sent.payload, + orderedEquals(vectorPayload('canonical_current_app')), + reason: + 'Dart must emit the exact canonical HELLO bytes consumed by ' + 'portable Python and native C', + ); transport.deliverFrame( PbleFrame( @@ -123,6 +151,36 @@ void main() { }, ); + test( + 'offer [2] uses shared bytes and EUNSUPPORTED maps distinctly', + () async { + final Future pending = negotiator.negotiate( + offer: [2], + ); + await pumpEventQueue(); + final PbleFrame sent = transport.sentFrames.single; + final Map vector = + helloVectors['well_formed_offer_without_v1']!; + + expect( + sent.payload, + orderedEquals(vectorPayload(vector['name'] as String)), + ); + expect(vector['expected_status'], PbleStatus.eUnsupported.code); + transport.deliverFrame( + PbleFrame( + type: Pble.typeRsp, + opcode: PbleOpcode.hello.code, + id: sent.id, + payload: Uint8List.fromList([ + vector['expected_status'] as int, + ]), + ), + ); + await expectLater(pending, throwsA(isA())); + }, + ); + test( 'refuses an unsupported proto_version with UnsupportedProtocolException', () async { diff --git a/app/test/conformance/v061_session_corpus_coverage_test.dart b/app/test/conformance/v061_session_corpus_coverage_test.dart new file mode 100644 index 00000000..223e0605 --- /dev/null +++ b/app/test/conformance/v061_session_corpus_coverage_test.dart @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pyble/pble/engine.dart'; +import 'package:pyble/pble/frame.dart'; +import 'package:pyble/pble/hello.dart'; +import 'package:pyble/pble/pble_constants.dart'; +import 'package:pyble/pble/types.dart'; + +import '../support/fake_byte_transport.dart'; +import '../support/repo_paths.dart'; + +typedef JsonMap = Map; + +const String _clientEmits = 'dart-client-emits'; +const String _agentOnly = 'agent-only'; + +// Every shared semantic vector has an explicit Dart role. An added vector +// therefore fails this suite until its ownership is reviewed. Agent-only means +// that the valid app stack cannot originate the malformed/server-side input; +// it does not mean that Dart pretends to exercise the firmware admission path. +const Map _helloRoles = { + 'canonical_current_app': _clientEmits, + 'offer_order_unknown_key_and_trailing_lf': _agentOnly, + 'two_trailing_lf_are_not_canonical': _agentOnly, + 'well_formed_offer_without_v1': _clientEmits, + 'version_decimal_leading_zero_is_not_canonical': _agentOnly, + 'version_decimal_exceeds_uint8': _agentOnly, + 'more_than_eight_version_offers': _agentOnly, + 'missing_proto_versions': _agentOnly, + 'duplicate_required_key': _agentOnly, + 'duplicate_app_name': _agentOnly, + 'duplicate_app_version': _agentOnly, + 'empty_version_list_element': _agentOnly, + 'empty_interior_line': _agentOnly, + 'invalid_unknown_key_character': _agentOnly, + 'unknown_key_33_bytes': _agentOnly, + 'carriage_return_is_not_hello_ascii': _agentOnly, + 'non_ascii_app_name': _agentOnly, + 'app_name_33_bytes': _agentOnly, + 'empty_app_version': _agentOnly, + 'app_version_33_bytes': _agentOnly, + 'unknown_value_33_bytes': _agentOnly, + 'unknown_value_nonprintable': _agentOnly, + 'unknown_value_splits_at_first_equals': _agentOnly, + 'payload_193_bytes': _agentOnly, +}; + +const Map _inboundRoles = { + 'pre_hello_response_bearing_command': _agentOnly, + 'pre_hello_no_response_command_has_no_effect': _agentOnly, + 'post_hello_command_is_admitted': _clientEmits, + 'valid_crc_inbound_rsp_is_silent': _agentOnly, + 'valid_crc_inbound_evt_is_silent': _agentOnly, + 'valid_crc_command_id_zero_is_silent': _agentOnly, + 'bad_crc_command_precedes_session_gate': _agentOnly, + 'bad_crc_precedes_wrong_direction_gate': _agentOnly, + 'valid_crc_unsupported_frame_version': _agentOnly, + 'short_input_without_safe_correlation_is_silent': _agentOnly, + 'length_mismatch_with_safe_header_is_refused': _agentOnly, + 'length_mismatch_wrong_direction_is_silent': _agentOnly, + 'length_mismatch_zero_id_is_silent': _agentOnly, + 'length_mismatch_unsupported_version_is_silent': _agentOnly, + 'unknown_opcode_after_hello_is_not_a_violation': _agentOnly, + 'valid_crc_wrong_direction_precedes_unsupported_version': _agentOnly, + 'valid_crc_zero_id_precedes_unsupported_version': _agentOnly, + 'pre_hello_unknown_opcode_hits_session_gate_first': _agentOnly, +}; + +const Map _sequenceRoles = { + 'compatible_repeat_is_idempotent': _clientEmits, + 'failed_repeat_preserves_negotiated_state': _clientEmits, + 'malformed_repeat_preserves_negotiated_state': _agentOnly, + 'disconnect_and_reconnect_clear_negotiation': _agentOnly, + 'unpublished_hello_does_not_negotiate': _agentOnly, +}; + +const Map _violationRoles = { + 'eighth_pre_hello_command_terminates_once_without_reply': _agentOnly, +}; + +Set _names(List cases) => cases + .cast() + .map((JsonMap value) => value['name'] as String) + .toSet(); + +void _expectExactRoles(List cases, Map roles) { + expect(roles.keys.toSet(), _names(cases)); + expect(roles.values.toSet(), {_clientEmits, _agentOnly}); +} + +JsonMap _byName(List cases, String name) => + cases.cast().singleWhere((JsonMap value) => value['name'] == name); + +Uint8List _asciiPayload(JsonMap value) => + Uint8List.fromList(ascii.encode(value['payload_ascii'] as String)); + +Map _helloFields(JsonMap value) => { + for (final String line in (value['payload_ascii'] as String).split('\n')) + line.substring(0, line.indexOf('=')): line.substring(line.indexOf('=') + 1), +}; + +List _helloOffer(JsonMap value) => _helloFields( + value, +)['proto_versions']!.split(',').map(int.parse).toList(growable: false); + +Uint8List _responsePayload(int status) { + if (status != PbleStatus.ok.code) return Uint8List.fromList([status]); + return Uint8List.fromList([ + status, + ...utf8.encode( + [ + 'proto=1', + 'agent=0.6.1', + 'chip=esp32', + 'mpy=1.28.0', + 'fs_root=/', + 'mtu=247', + 'window=8', + 'chunk=229', + 'free_mem=8192', + 'has_sd=0', + 'has_identify=0', + 'identify_led=255', + 'auto_run=0', + 'device_id=1234', + 'label=', + ].join('\n'), + ), + ]); +} + +Future _answerHello({ + required FakeByteTransport transport, + required Future pending, + required int status, +}) async { + await pumpEventQueue(); + final PbleFrame command = transport.sentFrames.last; + expect(command.type, Pble.typeCmd); + expect(command.opcode, PbleOpcode.hello.code); + expect(command.id, isNot(Pble.evtId)); + transport.deliverFrame( + PbleFrame( + type: Pble.typeRsp, + opcode: command.opcode, + id: command.id, + payload: _responsePayload(status), + ), + ); + if (status == PbleStatus.ok.code) { + await pending; + } else { + await expectLater(pending, throwsA(isA())); + } +} + +void main() { + final JsonMap corpus = + jsonDecode(v061SessionCorpus().readAsStringSync()) as JsonMap; + final List helloCases = corpus['hello_cases'] as List; + final List inboundCases = corpus['inbound_cases'] as List; + final List sequences = corpus['session_sequences'] as List; + + group('v0.6.1 shared session corpus Dart ownership', () { + test('assigns every vector to the client or the agent exactly once', () { + _expectExactRoles(helloCases, _helloRoles); + _expectExactRoles(inboundCases, _inboundRoles); + _expectExactRoles(sequences, _sequenceRoles); + expect( + _violationRoles.keys.toSet(), + _names([corpus['violation_budget_case'] as JsonMap]), + ); + // The violation budget is an agent receive/admission obligation. A + // conforming app never emits the eight invalid pre-HELLO commands. + expect(_violationRoles.values.single, _agentOnly); + }); + + test('emits every client-applicable HELLO case byte-for-byte', () async { + for (final MapEntry role in _helloRoles.entries) { + if (role.value != _clientEmits) continue; + final JsonMap vector = _byName(helloCases, role.key); + final Map fields = _helloFields(vector); + final FakeByteTransport transport = FakeByteTransport(); + final PbleEngine engine = PbleEngine(transport); + final HelloNegotiator negotiator = HelloNegotiator( + engine: engine, + appName: fields['app_name']!, + appVersion: fields['app_version']!, + ); + try { + final Future pending = negotiator.negotiate( + offer: _helloOffer(vector), + ); + await pumpEventQueue(); + expect( + transport.sentFrames.single.payload, + orderedEquals(_asciiPayload(vector)), + reason: role.key, + ); + await _answerHello( + transport: transport, + pending: pending, + status: vector['expected_status'] as int, + ); + } finally { + await engine.dispose(); + await transport.dispose(); + } + } + }); + + test('emits every client-applicable post-HELLO command vector', () async { + final JsonMap vector = _byName( + inboundCases, + _inboundRoles.entries + .singleWhere( + (MapEntry role) => role.value == _clientEmits, + ) + .key, + ); + final FakeByteTransport transport = FakeByteTransport(); + final PbleEngine engine = PbleEngine(transport); + final HelloNegotiator negotiator = HelloNegotiator( + engine: engine, + appName: 'PyBLE', + appVersion: '0.2.0', + ); + try { + await _answerHello( + transport: transport, + pending: negotiator.negotiate(), + status: PbleStatus.ok.code, + ); + final JsonMap frame = vector['frame'] as JsonMap; + final Future pending = engine.request( + PbleFrame( + type: Pble.typeCmd, + opcode: frame['opcode'] as int, + id: engine.nextId(), + payload: Uint8List(0), + ), + ); + await pumpEventQueue(); + final PbleFrame sent = transport.sentFrames.last; + expect(sent.type, frame['type']); + expect(sent.opcode, frame['opcode']); + expect(sent.id, isNot(Pble.evtId)); + final JsonMap expected = vector['expected'] as JsonMap; + transport.deliverFrame( + PbleFrame( + type: Pble.typeRsp, + opcode: sent.opcode, + id: sent.id, + payload: Uint8List.fromList([expected['status'] as int]), + ), + ); + expect((await pending).payload.single, expected['status']); + } finally { + await engine.dispose(); + await transport.dispose(); + } + }); + + test('drives every client-applicable repeated-HELLO sequence', () async { + for (final MapEntry role in _sequenceRoles.entries) { + if (role.value != _clientEmits) continue; + final JsonMap sequence = _byName(sequences, role.key); + final FakeByteTransport transport = FakeByteTransport(); + final PbleEngine engine = PbleEngine(transport); + final HelloNegotiator negotiator = HelloNegotiator( + engine: engine, + appName: 'PyBLE', + appVersion: '0.2.0', + ); + try { + for (final JsonMap step + in (sequence['steps'] as List).cast()) { + final int status = step['status'] as int; + if (step['action'] == 'hello') { + final JsonMap vector = _byName( + helloCases, + step['case'] as String, + ); + await _answerHello( + transport: transport, + pending: negotiator.negotiate(offer: _helloOffer(vector)), + status: status, + ); + continue; + } + + expect(step['action'], 'command'); + final Future pending = engine.request( + PbleFrame( + type: Pble.typeCmd, + opcode: step['opcode'] as int, + id: engine.nextId(), + payload: Uint8List(0), + ), + ); + await pumpEventQueue(); + final PbleFrame sent = transport.sentFrames.last; + transport.deliverFrame( + PbleFrame( + type: Pble.typeRsp, + opcode: sent.opcode, + id: sent.id, + payload: Uint8List.fromList([status]), + ), + ); + expect((await pending).payload.single, status, reason: role.key); + } + } finally { + await engine.dispose(); + await transport.dispose(); + } + } + }); + }); +} diff --git a/app/test/golden/goldens/about_android_tablet_landscape.png b/app/test/golden/goldens/about_android_tablet_landscape.png index 62505d0e4e5cadb33a3b207a53236ffd78080aef..502931a3709da1577e69f08d1e112dee5ba563b5 100644 GIT binary patch delta 9443 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 S{}7fHIDgheFYiaEpZ+%yL~2q1 delta 8825 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 wb6Myp_aXHF5GB916K`PD>^GjQPL+4^<*7l>h($ delta 14156 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 diff --git a/app/test/golden/goldens/about_ipad_portrait.png b/app/test/golden/goldens/about_ipad_portrait.png index 7bf1859f4ea4fea9b0223d6df8b438a146a3becd..ba84d100c4d27f85144cf517c8a7e10c72b15dfc 100644 GIT binary patch literal 30254 zcmce;cR*9=);)Z{u?(UNf}+wKacne^Uc=aML_tvjsR|;}h0t5#cohK&q9RfQA|L|< zQCet01)~B1L^`2G2))-p;M)ghobkPLy>GeS&p!+~^*PVp&)RFRy-xy9>u7D@+RlX_ z$cA5zAJIdQ@8HkX`s>!f$ExBAI(&SGJ@m`Db@0!3-R0Zxodc_BUVOWc`#iU%Q`nT9#Knski3wlbVtyXX=*kcdfq{m_@c>+8~wyupVlJ=^oKe5dHl&Tc7%;F~i13fkp|PZazhBRDMae7q&` zp-Ft%x(>W^V5ouAg=xw!5?uLo_RLHT^Z^q>k;lHJ#R zNEU{Qzpij@3O*4$?JWAC$;pp?+(lNSAGq3ub{%jV(zHYt-*BY`hc4gfA(CL5C_cJ>B*q$xu$8kI-`mre<{U86`Ui4!J2kLhJ z(OpR`dM_?a?SQM|X%j26_y6d||HCJ_!>^8)*dB6BZhO^Lg=k)03|kt;?0Pu$9IGD} zaO;GO+(!(~U^JLB-YiqAxP7npR@l93#CmxL{pyh;A1ysS_udK!2o`Bc*D2 zkGyEUV^kG^gMBRB#M;|N4mo>>7aFl}mTmDKbJR?85>ZuG(n&Mz!$a!lktthk)Q}f^ z7YAp|KFF@tEN&m`5rvyxhs*ld9xW8vk9dzVdEuCvARbX}!>PR*ANf{mO1BH5Yi+nj z95*%+$QfVT7fU<>8#Gm?-+Q#bT8}sL`VqdypY`r4bmHMh)LmpzAaXq}?K>-3gp7Sc zhq%V{^EdbtQOO+Rj2AHa0sicJVm3Eo?76a*UE}@(eg@V-KICL)^E)j?D;DNbj54-V zT`6m7ddi;eTH3iY8Rw(Clcn4Se~(f$GI4eH?d5#}c=VF7|{!%#}H)#-BEQY-F+T?dgyD zlT5+sE8H6*jGC6OebgT$Pr|M{+|1GOV_E@>^~8<`s-nE(5g31I+nh!0)5bv=HMJO3 z^C_8_d}}oYrvlaGC1YPvxu~k!SITNmFDYlUH1@N$ZeeR|7ugo%EzZ0sNa$jlKeFG4 zn=YPZyvb-KPaef>$z@$=Sl^iVxI>HW&&@Gs&yVo;!anCIkEN$c4(SwY)3&0V2dc|G zYcZmj{4qw`{QFNV!y=a#&)A`vg2AoY;^axbK`H~sqQ53j^72yH3(E%`+bWmK6Y7=u z{kU<{;;jtDxTR?F`ex6hWtyulCN3|XwvWjd_gKQ==BwsLW@${C z0Q*AIfb|7wlh7c4(dh-5?>b-n{}m!ob4>kqOx7Yvfj`H4-@x>3??nEz5Iwo*qoG5z z6G^wj60UIb$Xj|US-9qJvrG)xx_`6!LGXg0C&=8kkYi;F6|b{Q#LI1qF%%iaXkNI6 z?X=#JVaD?4wL2RX6V+Po6uzI{IuO?3_u%n#qvz9)Eeb}$Kd1Jw^E@R2T57m+3PSYK z<{y5@5PjWH{jwWlXB2lBrl;PQrzq5@e!%PFg!1Vz>q`~H!^PLs_p|&yGO0h{tCP1e%0}t-o9>S~GFmczZ_J(+pPK?Ai)ZjlOGj>S_Mp!^sCqVK-iS8Izf76l9loZ|GOpcrVO^ z;~L|}CG#cB%lEeX9DNU%OdWvjeRrR5;dvfneCLFTm4k5a1Nryna~rQ7X)79*ZX@>g z4tHIZ3lc$T%kaaK9M@n(?^4*-_4fXAMgV*V4Danl214!;bFHuWSiQ}J!;m#6H+2L# z(RD1M92f@Zh8ueVo!;aWq%2wISR?_u;;$bZTU}~f%%8@EuPkyzkFe@okMD_+&hqja zMz26E6+ryo#}SS9{P8U$8FtEVH&cbvf0mhBjqe$9v6BlHSpoNhdQ*r>MjftvsTH^E zDIqE8;VNo(A&(@qJSSRQlQrDPM#1^wMeapuM1`$2^tfcv!?NQP1vVmEx2`frKatOo z^i?Tl6@JNOy4Wsj?!Ne$w2WiLX42kDrO5v96R^64dL|5g$Jfu67fcw|9_B>)xK?Wh zECz(yI}Oe(O)W563yRI;Jh}qLm)44wm9c!f+uv1X*)VPy=8;UMp5ZyLY*tmi*PwpU zZ2R;h_Dd7{W)~-WcH6_p0jrT3XDp`Zl)eNG z#PxwSayp;99&;xlAz`lElxJar$F-ihfA-C5Rf>?JQ+v=X$rK(DL4=c#_|v1N6p_4k z97d#N{9^54j!p6Lo;E^mM!QQEa?SqM|oBsZxWz9 zli#_MRKf(Cmv-tQr=Qlh*q%(Mr1V`v%9{GFN=VYBP&y%;2zQx2CbnDAX+L~-o>liK z|1fa^r4W5vS0gtkJ2CMB>EaQIB}US}9}m{Se}@Lgx7}sS6S0PK??O&SR?}5^s~2P0 z!h(FTBrvCu8iS=z86Mre8A&H2E7muvD2Tvm{s~CDbO>Q1RX_vB3|PC=6WS4MB-zR? z%8Pi|?XgtYQ6-=3vqzDtOERg75f2S{GjSrrjx4SIOmeA1)T}bbnBwo(R^%m%9l5?X zx10w%G>Rp*Zd&f7uzVu1PRsK|DrSMXyc0Xp?z4xn2SdK6Jlj<;UbS`mc3Mfb6j64$ z2RBZn9;S?~kHls!j(c=3drx*;rcGBsT)WG)8u9BFr%YY+og8hSw`O;+h!nmKc012O z2M;T+pO!{(v!gB{HjFnR&cjAC=7Me5S-SHed$#bQv6&fRlrpW~+XjSyG0{_D+S<{3 zD%!;_(%92OrctIX+u5=xf-#r#xRqJiv8cPmFj-wnT%NZlHD;UBwjua)p6hnNwEp)w z{;?nt>3iIL^|5@+F$%qb#73%1B zn4{*$pd(kTG5FX6L@>>!>U#n z;nzjfOdzgD%Erd7HO@rNHsSiOJ4DJt3}lHfpRsSt{}D_2{P=s3PU{C1J#($P!r+zJ z7hBo0s<#|wD$M5v$I<_e@~lGeTlOJFQ`Jl3qx|SNk-2kmD_jrqw$u5o+S*5XICeZV zu$4J@k9}byg0K(4syKG+Sa-2qv*@pDY8*v<$9}#O8Y-a9jo=AqU_rQeNjTDr&I9mT zfO$ts7D$f+IFK3{E`-aigNYGPbZid_Z?Q#}U2eYTPf@&Cmn{6?+vkN*Fi09Vq%4g* z@>*DnuDFjMKDd}H)|$V`U7#3FK`yCsfbBzt%Xn8^5^<)w_d&fK+}ob(sZ=I+le zRobUhm?Mu2ak{my;fhc798#MlTo4FZ1jl9SXo$P(!i|y1XcZq{|Aw|j9tL|Bb31T) zwu}|%W8qSlyf=bckc-=~p)s@L&Y+^xo9RJCOBE;4}JbSHhL_2AJVQ=?y$?6y@e38%z84?5x2Bh(udmS1ohnf9zmm zCtJWE+=Q3X2-lqWO#ImAuP}gP0e*p16?SVOLMg2&LH35SHbqA?f{`v>YA%*WZXd@6>xF7A}!r#8_(@@Xm>Xh9ZuHl;j?y^7FE_;ch_YwG^xJ#Ch#~_%LAS zO~F-fDnjxL+ir(-@n3;VR`bY_)ZC8}Q^uX|Ph`BMFUJ5M8qH7R(J*}y6xF6q8sj^HRii=9Sl7|$Q=4=wJ+rp2h&<+KrmRb2!S0;8qCPpE2~M$qt(GvF9&s60vfSNCmr_wJX~h_LMc zQaeJ)Gtyt+HmGmn%d{W~0j7pt$4mSdfeK{5A*r4-FHX7nywXUY|zD? z>|#IiNDtLmJbqGF*P2!<`sU5C^>hl#){uhuKF(4}^sm29@X&x{qdMLlnD098(rc1q z6f!?QUrAn@R-b=YEMj&aiI+G0#7|qWmkZUY3%QOV)+OU7drhWloljmO(CSsOLXB4l zcG_Jg^cOxJLZ024y=}uut&StbUW&n3&4x9#5;JEh%Oi;mQK5g{b_!O}e90DrC9m5eW&2Pbwn& ze0#GGQWL9%&m+5DePe3Ap*5nj2J%J=h%=~<^tMe*xM;mub^1G$;jbF~kffdEv0PF~ zPEAcE+*upH?!V_I#{0uPJ)E=b$Q$_hoY`+eD#`lK>2Zud-9n2hb5mKxV6+llZ@HL8 zUFJX{4`x$cU0o!$qN2iig1B_QlH8Zbc=U>FR33FE@F&-Q2+&k1$7?lPbc#H8N|~0#C>vzxTl)KJU`Jls z%1eEE+vB(0?nv41)~qDgWyTN`X|WYC;ueI@&ch?B!mldY5@|;SC{%zzJv}g(Sw1xM zc`bEUHp`k;oH})?ysgco*W^f9EtAl@A8C(@3_L4Me2-qHH6@oiPo9j#hK_ci1IA1J z$5ZFej~J)1c+Q0EEsu57PTExHK0ILL_`2+f3B_3qE49M$N)7B?u3MFDZu>)BqKZsYDnTkOoKtPbiWnewiA73vu==~-+UER?r_*stim-}>OnOhj{z%e$i zyPys1XhXO+OL_@ss=LhDnYl0~z;QGbg+^9rW*vO|JuH4>?f*a7wG~Lm1-b1ZSLLEW znHAgV&oW209!b*cPgxe;zQ4TYwA0KCsTz6m#GF)3d~(isfSu-AY!>QWd@*>Abf$RsvwZicn<5_vLtqY3QtnLyZqLmlFe$LM(Hfb%eAJAH363jXebLQL zetSsV@NP7_^i~J$_|6^I*~|@auV=ZB;a=yf*_`ps_HPDk>FgWG*tQ2JlD2N!w$#tQ z9ox;~Mu?_)r0NUPZBBfP3{FI&FqHDhBWh3<@pY#cXjs>uU9FjsJ0L26+&zOz*A6+B ztsZ`B@IxwoezZhuF1&Yf7rOK8i{_!XeDkJ6jvW*oQ{v=60%!cJKd2P!Pp{YVl+(ht)OH4B$qVe zov@k%|8uthtOXzMt`?*Jsd$an4;%}|UVAX;#I~!03VCg+ygVAuk}_Jq!#~K^Sf%N) z>pZepOJU`Wk9M%m80V1(yOwt>MdPl4hVjQC`70Q(DIR@k33*+?B*wLm($PPEKUd^^&1Q6WdxOtGYBRyu{q(DR_Ywa4u^)494^_DSuk^f{t z2Ytew>^#b>w57eh(%CCkN$pwV2!ak|9}Fa~-8{&X(su?yFcn}PmDiucFdnFTg5E+( zYoG(C<_U1!c-0d*NmpLH6~cjDLn1(Kty2NbVQ<^*^lQpcwa` zdFTVEa@OCHV>e#a!K%u2@_<#eGfL**LEFOI)~w-UM|=lo{V@uTZA}dh-sX$pIz=U= zDU-^V3+YXc`GlnhjW697+zPuo>>LzpR+oPGpyxYlj?3%2r0J-wtu5S~JX;>1GS1>8 z(wINcyH!{}X0BpaQW{$-{&(c%KMr8PhBWt_0-YhuIyBp_P&_Amue{hX>Kpq;chy|_ zT%XWLnL@F&#>c=$fjL)=0Q)?_)(wrd#0ixqM~b83qEE)~x2^oG8sw^CuF@R4#_nj! zbhJaq2oSO(a!?;D=DGIFv^9sWJCo6|z;4QSwlvObPOT2;doiveiXb0kzM5x5(#XhY z;H`q{^n8sIV^}fP(~kqu>--;aFXn6)aQT#b zZvXg5w*~MU!12Ixq2|{C8_Q_{?pJ74Me2Ogv1*aZ24yJe_hRN-&yFaKm0hnSw`%7x zn#LKC*e;XeM1ER5Kf$tHdE-u4`ZaD}(y2dlrN2p}R&p)eQz;3FveXD&E81omDteJD z9UUFuSdgla2|PEeHDTl{y#P2RgVDV=vGO+=k30xB-u~x242@{9axyZ!T5;+hW`;>0 z^&fw{vsGB|1!|^^PKp}d(jFGkv2(t+H3Ty!IglN~f6b;ukToBgnyzZk>zETMegmz} zJp=WBB(IP;{Uw|7^O*%HC(nvz3dB4{*+&6Ox~-giO=yxZT^k@8*x%n@N$%K_q_)iFNL zj6&8cyLb^l3ICo{&x|D;&YDb!jl{}2RhMb+(Y->;IY_mvru@KQfNfLEm4%&RR3nrd zb%EexteHCJlU*iNs*o$c15xw+3CTt;zSTvd2?pe^;>tHbl6&zW zzG0hLwA(F6LaDXng4~_7^H>vOOl+DL28$rx%}{^%<^hr4h&$YihY=2Z`{YztO7}_< z4cSpsj-5fHi{S3v-K-G7NNmRCw0c_-8|GbUVkiuo?G$ z3NmR{wCOr$HQ&C|L%vt7d)q2oWye3fyB~>VyOKtli&ZzOIq$%Z%zPMDa$of9HSxr> zpULY8opqpZul8j`Rw}oEu8FwP(=sVrUc7j*A~gidT35SDD$2`m#$vINSYF;Q$rD=+ zO?#`RZ!$6>BbBBnQY*>+Dg;QWh-rwSq2b%_jin4Mfe7%s`~z_al)ei%c{0YpEMv?*BmImmNg90ODU z0C505(iR!&?~$grWe;2H-@A(zQ8g+J@wB_LTc_A}KMJ3Yf2eY93rZ{EO9aDT+2FI8 zc(J~|%l@l14}t!B{8DU}|Aomqp-~CA^|LijT%b(>$Y>#WGlj9$ctDDWufWjjieHnt zkkb-89BARNI&(ZKqX-fv`E7LaC?Qww-I&qaTFKA=2J*XGP z8-f=;c}B9yYupz1haU9M4)^Cd0$aAY2=febcU)+%26Zp!oBU$=2Tde?R?TlNHQ#00 zwzuu!iJ#nuTk8K3bB>f1xF{PqR$CTY)+L{c#2&4bamsC_I=f8vRz`+SSCFGt95(oo zWsXr*4u*9fvd_MP8@eJA-s{xuzTl1MFI^_5E)hxt+E#?SKib;h8xhUQq)M9-tPDvlpI}h}n zMJFnYNMN9156fi{6?7Sw2zISmmRcjy1PyFM!?1F$i;xW+qR75T9PO8{m|R9v5GBn)zVM5)`LeV<0Sz z@m3%(!++n!vvn>2F`C3W{4EEo)f$X4l4j9_s5#Sh+l$x}WZT$FP;66#+sR`ZA9|$U zzki?1g%1xmBIQ^lhBnCOm`$PT|V235S?`|Dd{$~LDYary-IH;~J48kG+Sfbt|G4Ac((6PqQB{KL= z;-F*o2;I3ea8)EWFT#pg0RD?)_?bMJMWdp4G{f)+ z2Ng7K{W76H6~rmgzh%Gol@%dy4F)rS%BKI?8!sLV`6tq?TK=IFfUQl}&VQy(=&a$5#uXiZ z%Ktkg-e7|-M-4dcf=yMmf0QMc-B=W(g=Ir%4@Hov-{58lqHkcpfJQZ0=EH~xRj5Pk z{T2Lqt7n9iX%P626%CYBx65kC>F>VAS5$;980mklTOOWfAj^b&B+IUmMCwt1%xxO2 z5F5Sg{o|o|vq&Zcv1*Zb;Q&d4?|V$h>Y>AgQ_%`mw4`e!7*WvQp_%U!6DevVg_ZL- zp!UqsO6SkmR#>2m*JXSonG)dy9v$*UHC}On$;n9UKhY<&Iw55Fccq4l1h(rJANdQK ziDT7f7T3#YXv6`z9;Nz6GW?A5-pF=%?gn7$E(_k%{h_BK5(@!6_n?+h3QR8Rjj^ok zXMBRP4a%i1qZ9gvH2nv?Hu-A?WmdO$WdNcb6pR7KvuDrl4tjnrF;q?We&sXKONn2qDCq0pCWgnF{Ym>}=Yz9OK4b zrS{022d$a6mE9MVIFT9=2#8bP-i;u|%!yEG>ihR6dfNtUGsGCzA4o#Vm}56;Mf1WO zS_Y{GG7pcX_Kqs{diD6?lKJS4O1Aisc*@u84y_^7xP6mzDU2GTKwcRM1>7DNA+MR^ zB>OyOlP)#OD5JN<95BpH4Qe@(-Twu`LoQEz8kZ}lcAXh_CJPU}-^*1UEpLy}{AV}?-|4qPj z_ldn^H%1yRfhL+rC$;;^0)goQ!%CzoXGjLCYK`6_Ncr|T&iK|rZsQvs4yx`$Z`tKQ z-X056bCe0g)fcq4fo=j$3bX%TsZRVXnt`q<{jXG~mm{PUm5BKJkjuhWnG)$vv7FJS>_O6wunk@Bc4+;b)=-@fC=sA@KGhdqkq(Ho7G?@DX?5R8ZyQ!731E;thcl12xH{u@)H1{-`ayr zSVmr8;7^|~^o0Rtq`&l;A}5+kBA+i(WJx8onG0nE7AsJ**l2PJjtz5wSNK+K5^~5L zpu~Th8gXIM_0z~RYc=gwmb0+$1T^L$_)lNU==rNJZpd?_2L+FsRE7@H*`mC>RqmmXwLsqe?ABO5Tsrv*>&1mUmQJt}-Ac%+lkKdKb4oro;=5w}|%rY1v1q;Pppp5H6A zF+P)3ij3-ZmOo0*?X4tx97U+_s-m&d4&krJEz&@dRPTe7gQlsFgUT}X6crR&^h)ZW zv~&DSS|d{*q6~uU(*w^O-S=fYLoN~W_{;U7!fyaemj7=h%Q0y3iBFM`mZloZI#Gvs z{C_YYnS?~=IhI4fgQhE&s7HpPe0+Ro*dHP+8WTgydaI(LEp(SQOxx5yjPu$Ff$|S$4ReB_}H1| zvSK!JQESD1{BB*yvA1{!WkJldpFh%fZThZ3r_Y5DI5B)h z@s}=oRTVvlQI-=<%@I)6DJ7KBHhd>Y&2q9AyPh6)*Yo)C8^E#J%Q2JcY*j3+&lR@-E%zk{-P^|qo+I;2#(ErCQ6b%2bd+U~m!vLoImibl zIh8fQFb}49js=h7ikQ@HNvEZyY$}I-Q%|%)xo+{Y-x(=`ZayOG2I!%>KD*v>17p7% z<%DAf&P8YX%4>|Z7h15#uC*|bQ&S_V{&Kh0tYzq8CMh38Rv%1cNOLGHy&AveD3tx| z^o1}l1RoI+8cL1BEcp6%`gxQWxcS+Hu6oirBkEIvop1*iurj@b0RJMoV;N*p! znwd$4bLAr7;Z7;a%JNmKEtm`ybqU*qaz3%Yc5O$==qc51XcZmK*1AqWX=0S)@_pTM zw?;F*atkW)&N`ES2s`;6R?EWSS}ZlGgL8=^3nSi6F;S0>#~5SOgdpZ!HFCUq?SOOd z`Qhh;K^Dux)?3BdabL5$5jAdP5Tbi1B2bF2M>Oz8LW+*QJ=ru z=E?}M3FXMX9TH)-T~_vm49Ms=v^~waC8P`@(;Dl9;!WINs3V4srlv_h^%$_`76q5b zG3CmR@S*8u+&qD+mBRuV~N?JbiPL$;& zC9nO>XZJPmOqWX+Ew44O-;G>7&xN0C%ERG?=G)@R$n-*YYJ0%UZnVAiO;eL$H}sR_ z);c&}8@kwSlK3Jyi!kdy*M3VwD6h`F@|B8$kGb&n?Y2*my%FLY*g9fjxKOZ9Yo3z< za=A2b1|#^wx_6Tmf%wQM%L|DQN7as8gZ-PkpKo9XFI8so-G{b0Y41qvWO)84ZkIH1 z=gu_^8G&_Ytv5l#t;J!FpPl49%99g3XXeiaVvFs(Hwj2W@|^v^a5ibOq-91Z-|GvZfTG9un#n6mGVfkpbz>!57l6Kq`TU8 zpTU-H%{^sHG{A5X~NCkA}iwmWjvr2f*jNY7eJ70@#z0} zZs?7L0J6q)(Gm%zTvL1B&s>X6mZUN{Ob=jzKbR)<-Tb?z*^zG8*Uw`xe~gsQKr z+Kk#<$oeI2fWCbMup@bobCCe`?hkY^tNvPmd%Edb}wu z)$EROQ=_=I7<62t5As=ehdVe|2aMS4jlM~tx?i9synI=zCd%SkK(Ok6A9Ou_!nf?^@Q#TI}s;*z>o;ndI3OziJJ_{1kH?qH6)q|3nU_Tla z_efnfLhVuTZSPgmO&1-nK2ywfXuhjtAi-~u5Vfc>pKv`V%VF32;B=;OtP&rub5GBW z4Gyo{+v6U^o+@GgR&R9V2tU_5Vm4iBp)x!|wUDbN670|^R8SZ+9@iRssh%d)p>$P{ zb5ZQw;4g6V_~FmhnLkz|N*4Q}ANbt<@N6n#bR6{Vkr7fh6;DWpqoYW9d5W|cuYJQZ z_$>!jeRg~M_`sv6M8Q3-7Hk*{V3fV%Ls8}~L%QgP2*=|~kB)bCqYj@e*!=vbjRfHn zKHTv~0m{@^3ynv0b#+@3HMLN4ol<<>^P8td$I=Si^T)6DWeaxP4wE$g0GCJK=(}0~ z-8=b1&qK!hEX{xM;oo7E0yUgKzXgV5$Fek_{>o9u%T=9<)}$sDK=&0~*Aj;9dk?JFxQpQvxV`zvN)a;-(xKz-Ka8$mRZ z{EPxvZ-|qpU)$JB9(9k#^jcI1m{HQ?^P5?!E-&j3oXcBg(E$x3!_q422W>WxJ|kUjfo+KVOY8`i=2Yfs(C?MGTf_t8x2>kBw`h2IrZf*$^bPi0=r#Y+-eNiVtGcZ@1E8mK9R?9W_bF(MgV!1bNe4~lOJvh-{a*1<0=vCY9 zpFKN1gsrA_r4R{cQ+9VZ7&{Ug9VjZpwekJ@=*u*Q?!$&i(Li6Vb$7p*R`JLr^PM0PrP-bHP;E$fkVM50kfOXJ)gu6bXy zM`-)@+uFC*)Iq;S=i|!*`w+_jLRn;lSx`w%acyIs!WtIpQ66<>m^7VJF@(s-W+ioH zVHgN0fARU&KJy4qY1+}UIWl_YtW<<$_td$Qy47VLr%p~y zxl$V>qZ1A7Gj!dR*Zl2;5iBlwb9(i?bGDVY%Lc0mRNRN_ImuuwpAC0YQo+! zC&!b88|TM}&m|2^d5uX0JI$12L?$PDg5Ot0R5*8+dET)ne+a-ywG8HlzUjP(6VjL% zI%=(>d#b#$(!OY^CCzV+`PkLNqnw~e_s9K^oBQb1y&y_l9GmQS~zJbKUI{p~5Pg?0g!_YC21Us*hPW7g%9GVacAXx3U6@ z&G3qCwT6#`RH3Wi)YO!mm4*sczE#W8zeGqnF6y|?1FQQLJs+TdOXY;Pkg;kBqU3C$ z29%>`gI{p7OMN(6r0#j&ZOu_xP4Rs}2@6Y$QV@)_dYv?Ad1a<@HE!f%Y8@|cmQA1{ z&#vg8a*L^q;SexvvN{)1>gDFk0X*?GIFe21OFOWomQ+OW3v1>UHaViVcPDtUL28a8 z>F7fK$G%(b(5KnkcR1G&+!6b(Fbn`w*J##ZHvH z_0?c2?n7#RnV6r#_@`ig=F1;9w*%q(%mqWkDeEo2)$a}eP<(dxaOT}=>Z@1X9&(xI z?P`a)k%yi2R75{5c)74a%BbrU7vg$Pe{Z8Oodb{6|!y>6M>F$<4yS|dnr+)noo>HW}@SJ4y1*(nrMen>*%TsWjHn&3@(!RoX&FF&O8u?yLR zKR9D@cr}uJLto8(KSam%K!j@R&j*>LnUt+VB=-}Y=$2RPVu4L$XX~YI1=Rel-Zps~cej99G&qPA_$!x<&yZo#1+JMB5VhlXp)7DiIgTd3Mv9{dc*I)g;`S$!RqApnpCG`#t#Gmv~b86CzMkS0_AL z!kD@B+|SQXMqNEtDWalhyZ0@ZL^5<4d(vmp_?V=^nU=ChxwbJvo)&Jopo~1dVaY72 zn7y4|4U<$RUOn#EV5d+(FebIcDBZv1u{`QgR#tWdh&%2E^cTY#>W=YSScD=wpEXe9 zo1C4T1%-tS_Gq#Au#DladBxL`l2$7wHM;NX6E}wM+-ZvGX@8P0h#ttb>*$3R4QH_lH9#phnggKs}4dDI10E#KpxK=8e3v;zKlzT*Ao)BSsJIU7@=UsxHI@9nugr zFfW$Z&m6FMZ#Pjb?AJ?HQug03MSrHDkWTsZ7%LsKHI#1;)sj7S+P-9BVsmU5ZS3Rq ztVfaRe2K=HLGoz=l#ic8RTrmZWqf+Sj;0koO4mA1KM)VkpK)u2XdY`;oR;PlA@+;p0@>g_Sj54SQ+Ai1~J&T#Eq~la4(W`$4-l(u`nfUZ&IIetDxP2x8>D~ zJHWT1W>^>GWn{M1K~c!tMFq@j;yiC9Q$vv&chMon1z8g@TW%iu5s>Gx({RgHp&|3I zNE-|)aDvTGR@2_}*PT7H_mkz|!?edKDXQbo36yIGbqJKX_lgB}Ku0Q;e?D@AY(jvP zQAS;s@9bIGT3AQMdG-Vg=kb&%Ib&HaJQQ^6e_O~#G!yPR1@Gao`VHP=d!(ekjUgQu zbi(RK7}HsE)wC68DmN_tZ4ar%xBT8iXp8Cw04Q%JlMM}@vK|KFb^_V}qib)McP>v3 z(Kc#m^Hq-X88HMFaR%jTDgTA5F=Ourf$E}IpDq7ud>d=6&7W4 zjDk(l*CE1>A-1Du5S^Jg#Lz6`n*Dhc8diW4k4xTMl7!%OK5$X>;Tiu#x4%t;`RhdD zn|JHzzL^V7d8~KgHdGO2lq68Pk!5&$WMrhtw>)|87TLT)7_daNiIo)Hn)g9Pcye(= zQ-TjlSS=ua%!SjO=Zjs2db2b7)~Jc07vj-74Ol-I6q zZso5I$-{9FN>1#Cogjdyd}aA~h7)K0IfxeBic7DZ6dn{`zIwn+O+HMi5oI?nNj79j z0|!D%sL-hx<3e`_$V7M1irK8Z@{GK;7^St~Sq$JXr~iq=G!|YtRT=loQq00J?BwL+ zcv*=bh&76icNo;rJ+uu|v*PGrqAI@%Txdr3ubgsht0oekw7Ag0@^y9Fyvgc)&p=Ss zDkSz0OltOnW?3w+^#{nrRYJAVYpm#Zn!inudvL1HH&uLxcV}*bt>zW90n3+RrZWkritX=!Kuuy#1dF5czL4(W`Wlge)O^aQKu*N29S`oh==ad#?)e#PDKR`y6tiDClxNl&;j(bN~MEn@{!APWd_9d)MP~Mxw;Gc)3?x!D~uXlM{UODkMEl2at=+fS-`4 znj)9G(RUG%yKbKy_?upG(G7`DMVVm){LtG&KQhEAVC)voUZPo-$;0Hf+qXOBGdc($ zwOE-;X%<4k2stIMs3;KX*OQe^-La~-7kCp#d{RF8irK%=2Y=_FdeR6)q!uwmQ_IU%JW<-hj&@k3K$TbZ~6bE^49@4(Ui8!R4ejBi`%z-fL4iXF2 z-~6?33j`97Nt~8BAkv)V_<{F~#D3^3PX!tcj1Lw*eoLd?YLwwy)aJo^NB^+ggItp_ zg7|810#72{ljT4@mBcmv3A_VRN9bppWCVuzpQ9MIW-#NR0Pw#C5! zd^MPYIOO6lEs(#|S6F}dM13{#j31aZDpy{Z>3JyL^R`~>gsJI6J2G$PkR7sk!^Dc# z*ayMxJcw3kZmnZru&=&x)=ved`R|3E-xb{7E%dvF|L@)A_tPNa0YJtrX!x6>E$Pl9 zB-PP7c9~=aXQ+iNFnBQRejxO(Upu?_syRdVm8YlD8^O1PJ9o;uUW0<0OSn^(u*T(r zA+`lotWF|#Mb*tiYhAGEiEn4dPD26yz-Y>Hv+MR9>auUo?!Ls;f&17RgCNY1d?6reT?#$!GwT5{ZJB(%N%CfR*RE>C4XrI?O7h z<+Qc{Qg4fwmiq0$pJ@DiLp_We#oA|N6(l?yrEYCb|2qX$jaJAObab0!=w3_a96n*jj7Y!Z3QJIkD}sqVvJ9?LxnF!NYn|PeobUl4Ok%OX1^W&RgPUn?0jctd|Mz&FWw@`9F(@Cu6r-?# z=%YIFpOEt`TlG5*M6PxfkH!z7YOJf>Z4F^zO0IL)AXW63Al%+6B0?Bn^IS|!%sK!1 zg*roOsNbi=EZh3u%9LBj7wxH&b}4Q}HcVRna6{`R=ELri&<+7AK~y)!F2ygWmO?EY zDUu}>_qN3J3;vC1=KXb6E;u4@5$UU^0_vg~zaW(=eYS`dJ z-*SU(x5;Yv?&>m2T=Zfs9~^Tx`J@2yJ2zU(XBDTak$|2WKYY0WK0Y`v-ejDSir`~z z*|J~!AdjZ_(|TKwsW-G|7nQWt)dZp)6m@L)O_!5GO4oKcJI<2mD~F-v)l^Y&X0#0| zZWS6lvD8xtJlvOZA^N0IBYqymiaMuG?pmo5LR*}iWF9F~Iyo7XoQL-uQrw36M;!Nh z)P<;8Uc9)&ndYvBX?1q4GNUS*%(8BoWu_FWj?CAnZ?JmtEK?y$c-uCczG;;(n4YTz z|6I9wcH}}60nP;;Fqg`+c)89jbm(g)qKVG_ml_vZ28-_>S(x)ff5V`R90p$H3tqM2 zULQ3_zLX;c9I^?60RfCpbDn605#FY~qj)!mz_b>wcG~wxk<%z#X}0tRPnlS#MwC*- z&0)_vTC8(xM&3`fFexB&HLiam+V5pc1<5=Vs@CJ7HdR|&yH!w78@j!k6bdAv{&F(r z1)+lYf)HFd`+8R495U2O!OZwN>UNuK$z{~k+~thlU{!$3%~Gc4VO{Nd|Dd9Nb2RE1 z`X`dtFh(5kf?chQF1b5v(Evh!@ne`_J8qMRHwbQ^M_%>;jHa(@?X zHMKCSFS@uK^q)7tSv5x#f(*_(y?2Xtq?zN9veCr`CoLRq`MFe``@-7csf7@Q=sbF^ zAf%J$wFD4&w`aV`Kt$W;4naXtF>hx@Hr=ejk<#HF?U9sTHVR(-@cq9}A8KlK;Yg#p zZEoQdkQM ziwhCpqZg83z*W`D-O|u+fFRdIzDC8bDT}ZlUnyGiVjiBASa7Kbclk8Hn?xd=lvXsG z7tSm@=#UpN&(7^|vAp?ty1p>=UnYAv;>FzD!XG~HfLyvK?;~SUkNgcX>pFE7`8&Ss z)OnFC4nObnSH29!P8;mwya;bSh%CIfvx=41&x_OO8aSdloe5_ zeCW?{o!kG8XkT;H5$%l-)re8b%gs}~a>e=&s$@3$1#Q-9`V91;5T_1H3tjSuo4!zfJnSp7^g$)07zos$<{Z zKSJ=)aD^ecf$l4>9;;@UsrAX_R0Y}u0`R_zws~ScAAYx`uT4@2Rf}3v%6f*4@}@`m_Q;+N#Sp(TP{>Sftg?(RKaEUA`SH=XrT$)Uml;R*N;K8sEGQ z9a22JzH)4Gbm4mbc!q&u%-XkH|I?L$F04OU!0*!@WW)39!@rD1j-MHpE<}jm`HlzO z%NiPdassgn#5PgfTtC_pVI_>22@8Gm+xfIjOcBqq>z=<{cbMbJ?DJqJ@5OSOgHJNCF|JsSn^b6D{X5tF|};TdpDnj29G(ChqdPe ztdmTale9wCfPAYrh!-`!4T|}n*ICF%+69SWwyWy$QDsrZ2}u7~Hsf9OKWAk<{b>jj z1_4Lk5nZ*QTm=gjfNQJXo$p(fJI z<$>8G;woTb!~0fhMLOI(u)6N8IP1kFUTh7vC6Jd$=r@8oO+VS6Jld9l1D*yllNZn( zQLN0jNr>1iB?hkTz5j?~_l#L?yB<1U9xzc#hU&|?%F#3^uv;neeh>auQats2DKH4y zNVrh^)>(Hd#$+B0o@iCw#8>+ZYZY`{f|r-f^Qf60r=q+~h2B5J@3CvV{6navYQE#k z{h$xJ&tu|d;Kt@46e@Er-VRBQ?hw9$J|^7@kc4P>%v5Wr^E!FVMUp!eP!0M5*6Y#j zA*h_PDR$nL(waFF(chl(W7N{3ao6v_dk^hS+3R&4EeF6h9shO0elpkqW!~DQD8XkB z=-ci_zGhhl*w50KN1fMzIlvo&R5y>CvmeaRc`uwXRSl3dS{yHEl)%2$U6_~)D4chM z?xr-+CZrzgQW5Nza!gY~G=M;R){O+M`M`V~lXZU579c1@Ml3(?n43Gt4*7 zVW1@+v)Q%SlrYe}(Hfqk%IdmfN6i+zO~_uO4G+2`^bur99{L*rfiY-}!D|Ch zR}dM{jELp`D_VGZe|FeCLPuJ}0vQ*Y7n~13Vc5uE+r8pUc=OM1n~$j**RZw9%m@ZA zy)@LzqluFVG-Usy&#f(&-vohR0;%<`GWW8z)}Dl14F-eL&Rxzv2o%^sI&M~LB2Jq1 z8(VCu2qTS3c$P_ul=3}63Ig58aCVqQ@+*WYjHe5%6Fvw96KBryNip{}@ zg4X@X*p23xRv4`G@spar4|mdFO`@KgiL@Gl1@x5!6BTPVH4G;0(1PjNO$RJo_SuKl z#bPieqcU-`7RaL(@fuZB=nn*f^iEVBi)LKln~wB}Mb@P7%1N`#_C%a~@e?*X(L_Ab z9z`dLEH0aPn$eO1ot;xmRt>y98xUEN)eaoWa#v2Ve-!+1k*o-h1J#6 z&R3ywPKB;>e#i26pOgseQb6!%9=lt*nD^@$=PQsiLxeZ(Z&oX0hd6Pee*6Nh_gA^f z?WRCtjyR=!&!hJVGjmzcY`1?t89+0Gfi)m!C5jqSQdYpzKvb(&!|N1%JN=HQC;1;- z-_o9x&1T)|9^!KX$Wuby&*}7dsuPQ)x}V8BAF%7eW|GFa$?2chMA)ZT!t$~!+Dce- z5C|rjSo3nS1#9_p*i_8)e@At#f2$n;;MQ#8FTPkL4(+BvS%`4#c7L7*siai>A^UWD-LhrN zzDn5T0ZEcZe0lavWYbna5w^nztlt5*Z=CA`isA1Ae-ySm;KpWQfb;{Uo^ zEUztlRio}+8wVP4`mEEFtHMfKb@-B>Pq)M6C0C;AqTa|QJ5X~>e$fZvz&-2Z53{i%r z=&P%15P(`9jHEEj79oB-@Rk!R7IZegcxbjQ@EGr66UKRReVasV3>-zAzXM?id6*S- zZ;u)NBA}|TNuNF_6!Mqvj*oCqF_^LMbr;P~<5+^e2R5sAiSD}g)0*42SR zb)|Jw&DD*bpgo1;qI+bf+uJPy8_<)WK!32zPu7q#{vhM+Nt|fgnt~@!00ng)I*39 zKQ36gA^e0cA~SL z|C|uAmf%<0GhiZo8p>@B4PHUR2ALE5ss>9;Hqb_QUyw-72A$BC^k%xSH4Ke@0qGYA z%rD${l?x&BXp$+T_n2Ff_i6YQfqJ!O=pypA@SF_q{{yB?b8m|KOQpQ{Dr+#je{hOg z8F0Q8W*@TZw0o$YrI6=pOS;Hc!x=4RZUm&?GrU9%svnf4X<)A)?OJ>Fx?IQ)Uy+_3 zt%~>@Afh6Aum{~)ceYE*Ib|3?1pr=;`oWHA2zRZYP?pvM6HOcl-@o-mi;=|!(s6^} z^{NXao0XIAHrKo1Pd43NLC69YeM^4a=9KbbtcT9ns}}=pZ?0R+wxa*bUQZEyfXQ{X0+Q9I@z$k{p392@OcB*%z*@YK zqEC1Ycr$_9abDpNh6yGMWBm>#p&YPcrAMb`;O?l-vZoC>{GDfq5c)o=@TsbdMxoxY?H`hS96V$HmqGen*)(_d|7~0u9 z10)(v*x;dWLb>~sF2tW{`82z7U}D7El16$(%eQ>Zi^>&))kSR@EP7MR5H-DGvsJ0A zhgGo@bej0~#6tFRpub=zQv}MZC~}f74*roAy=DE3ZQC9*7<6bJmJny6VSPz6GGjrg z@n)A0@8*z4wKg)m60AV2`ClCV9U(fBdHv>146y5{=EEKOpyUL8vkRnBo!(K^+X3n# z*)gvmf@7g?$(t~aI@N6uv!ZsKjJ{57F!`Ou&kE>bgIFl3A>C^(Wg>Tque+c_5scw5 z@MoXB!x`P@mG7KE>Ab?o2oF+59QXF{KP^KKq&X`ib!n?D!!R=tw_ua>nZOn=;x7Wx zxPq!awHv7FjMZ}~&IZ>2GVVi?eF3zc8pnak$+2rcF=g*5mOJ7Fh)~#%15%E<7SYkU zupN?~RVZ?_*GspUjns32LI$zlK7FZZCi2s%zK;-t9lMqwg0JV)H#qp~qSk83|9KAL zTt>BU{RJXq=79(hc@2lWWI|0KNYYh2D{52;S>*jj1pR0DQm9jt5GfoHe0 z)XD)s0&3_Nj)lNj$4t%PgFCVc8QsW_&u?akc>cxrc>xsyWrTV?jREsL0U)7{g|oC# zf`Bxs6rC2i{x7NZpUlXohK-Cjm8L$^&0T+~3a?%4?Tf#-S9Rf$EekJT)RxWO_R*KI z+TF|b?XIX5YR`I=AChw9-p4!Uc{Ar+e|q@teaWw@=8xeR!=#bAwvOKFsPDlvtSE8^ zJW?drjoz8KC!8ne9m-R|4*l?qZt-~<$U${`26zn@@Jm<{sWFfVHvb08QX6OpJ+;q z4jfHT(%%BO&u_fp%W^aGleEghQYU`YC9Fkze>^KcKQJ{|UPZSZ?vq4xN!kXFJ2*tr zIq|G!jmRFMfFK;z;SH2@)p^;wV*9*J$_^{b49)s)^oDUbO*gGowDgD7A`(iep`05$ zQFx(Kwd_b8`x^}$4~ZCDg1W)S+UTnlqVOuk5`lbbg&v|H;CAp+fT+_U+E(NLGDZf; zCKeW%%v@%dL`bfSErG6xhm1j-x~>d%W`&SXKQkwLZgT7ihX~IeQDmT#qw$udWo12k z&`Uk)#3`;wL0^#>qjQm4Inan&D(hw0`1tYr?6X-uw4~bO!|ff{gTvP^vv+6+wZywH z`QlzK_gZjxfJbUNj~$+#-5d91rK@lO$&nS1&8@)CKbI)lbai*nNvto8$mu@-S_3h= zL5Iw;_v`)2no?HG$$RQ6;Po}hAjKgLI%en%6ec4{L z@j=0U3z8hZ4JOpVpUmP7oJbRlJYeRR@_PqOT{{dY4MYF+Q@9f% z+pNigIiZb9ggiTORmj!dx`CS$3k-{#yZjJ}E3Ee;X>?m!W6)@MuzbKP@7dTdE+P;Y zlvY)BNl2xoyVxqoASV=3Y6O{hUuz2#d69m^eO-g58z{r&wWB-B<$H^=v zduB}de^BG9<4>L_|z4PL+FBOyrh$Tbt6BBX)M7CEoYoCmlH#a*eZ}H~Z%0Or1g+ zvUZ@NJ;!8bQRaT9Eki=J-&ShCk^RX{wi8W3Zg@UqoJNv>BZ;Eo>OxQYq}AUEa%gI_ z3!_#S*C1o=F?9fGu%9I9ZbH9wYqsZZ-YAl2ZiX&TU!+g|*}?&>+WG1u_V1si+01bverNR_$>bRy*!QHsSjKsZuW#&3RCn3E zD+lskAcRpWD|@ir^gtfWm@qz8lcU=u@bAxVBYKJd-;_CVx5SnJ@m57^u$7sgyfjt0IB@f%=fl3cuHQL5pBgZ)!d+ za;$wXFC~u}?aOv3;klb(pe1Q}ZZlixJzkt&E#N?Dcy+QwhiU9yr*7-TRRM0qnOLp(VH)OiebPkZtHZqdk%uA0=bF;e}Ta-O@MrPS8e{vU&R(iXzq>uE{ zO`~SE(_4Rh?m|QFIzLj?|50@CYfKXEGpsYHu*Gj42-VxOfj888NAum33%Nlf_Esr+ z6@3RKt0gkFM@dvwX9$VgRUJ(0s|U5*eiP~F-4nyh$hvKnViuHJWnm_pXUsdHy#ubI zwb0GZ6S3+s(62If(n;Z!z>gJ%>pf#{2*3TFqFpF@JE+|^2CMYu^0|jphuiNn9McOH zlYK6wI-_t*cyWM=<1l`t-Y=oO5zjnes< zi(?19pU91!UH2rdb8^YTHqVJ(lH7!mit1j7aFJg0Z72R(mO<$`cxf6=zy5tp5CH)d z=4mIF(9;<_2I}4oozDxK=YN8;7J*x4Rw07y(uXAmK~tD5(aXUFY?dG(9aY@t1=$G} zt{Hp^>e~gWE8xqfH}#Rx7u5kgrQNqY@o?a(p`0NOre8-X>0I2S;24$Mx~l*Up~_Ox zNJq1?((BX(n>PkPUS=6Q0{#!nsyt!Pk(hM}9J_%)@Mxtqo&J!e_tE zTNM#b#hq#F(;3D);71k@SL2Z<&D$&QA{*tigaIR6T0e_eq>OCZCzB-#A4i2|kuu#@ z7IqMD(bA{HR%hNA!<_zHuyrAUa&g3{UOLY@y>BgCzwb7xFV8W)!3zrPku)PSv;G0~ z=A4Tsw=8yQcs#eGI8HBzuaz|j&6*=FbBBkLq!)47j7W1hh^!912Z#aw!Ms37XZsR3 z$}9+zBe?AGx9&-1=E2&@CtOs@*6oZqbOOSf-uiGEpU(Ie{q-^hTfhaaLtI|_&UcK2 zVHmnTm(r3cgE}n7#qI6vjw^TCybasba@C$YP=AWqcPa?8N=S~JMGJ|^!&DYB%)$xc z^~5kycVazhczb~q;+Od|;_|}Wv#*9p^BH7@VOZ{^3%(WN?0z1Vf;yiQJ0rSueMYr! z>u7gfo+}k`Z<9UYa=2_A<>`5>ed|-Dl4<|A?ELY!)6DR!ieXC4iMcn$2l7@R4X5Hc zG&||E`4b@_ArSe$cW z$Ik^}mLGgq@!)7Si>1;)e7Q9=DswMMco)fK*Jk810gwNf)evyW~ZVKh(}yf0B~NPhFwe=1CUKE_!P0 z0$N}yfh1lMo97u%BYJ zo0)~tX2g9I40YXg9l{ezuu@pl^WM7*3ei{&ZYz4} zV%B0!K0C@+2}=!Tlia)6_+fMFOyS#BE0B%+=tM47qj;!vU;bddN_X=dW9l*H{P`_DqqBq2gKi^j>kYHahFce( zJ5nMCEiugD6ccQ|1N=sTIG>wLCf1s>N$eaGjR+CJ!%ki-Fh@Za44;xW^*MygU z#jxK^P_%V}tMTyCkZC2bl&(_6 zF{8})uV25;ck0%r7wDz&Wf-K}xHj4D_3Fgv*;+n#dVzP)^OO|&}RweqfG<}DkkZ1h~^X0T11*ij=){kFu5|FbU&!^PX9LPh~ zNv2N-iPkOd;zs;_{RNahIXRg*f@>B3$BKrF;@+db9yxM^4-ypqma1FQxP7d&ilfvz<$^L2SP`5)xrrLGSteQNPOM>yIIkXJiLrhH8s2u%_kbVZMQZ z%oBo7PtQz>Lu|_5%!{-U($;U$ea5amloF_-ur~;UBFwdiHX+-?pLK;Df^29-m1x9+ zEvThHwByj!sxXb&;In*m{p~~IB?fTa)lug-{q%NN)`&{mYt%)mEZc$(wmllzxXJRZ zhx|y}2LrzebeNc`V)dm{Y3M1IR{0|&AyKY5sP1Q>EIQtQ7X%{le#ReDbywE92NmjX{G!Na-&JK^ zRoZ7{_t~CCS%<4v;CP(Rh>HoqYcH0XovdTUSis4X>q=yWNeN1~2pFB*4URHW=QD!? z9~z`*-%fsRkY4%n9nztO*^bT>&gqK*Tlhw8a&wX3BZj~d) z;g}{xUQA26GAm!*s?Dff_Q5E@He^uvKD_a%RjCS?VuY86i`AQOC-}oeipQ&KWEB*m z2qQQ42RO$G2?`2!53RD1Cm+?-CC<-#(+dIvsc#Fr-^{1fx^nY~gs=qb!9kPa)d*Y& zE5F2ylq`!Gs(8E2c{!3rs;4W{YrAJdoUA;FcV6~BlFf9yUUlc?oUCKk$&)8Jkpk=c zeJCpP8~GVt{|Cg7#k0N@elFAkS4rtoS0zJ0QP)EO5!bqt62fW9i~96}E8J_{Qb5Cn ztTC`&UvfCWt?8%EaiynEY;xCqQG%URVb-?8#Bgd=eDp>k1-Egsj~YNGdI8xCC$%^} zA$~4XK73HVYUJI6OFnz5Cf2+WI_aT7AD+0!Efx|T9bN0X`sxxW1`CI|iEUR#F8wua zgC+P!sNn=H?s{RK=i%^#5TlhKA3smO z6+LL_N0;@NRW?wt79-n)3?A+@!h5h673cdpYhClA9Eq<_rpl2IaIo&Y6yok*@EGcA zY{9YHYh803YpYl`_2)7Vcgm;6$;NxrTkqVt^XgQM1qx=O%siFobh;c_RQ1yCdUHiu zPCv*i0TM%9u%Nt zx?oM^$r+CyyQGNwZ9>DZke6Ym$u+Kkwbq$2@$n;vG>`+|uE6%Ds52RxgpE*NfLl2k zmqWYeo@rWRlJ|!unlpce=wdLHRClClf|BMLN~8mTsMpUJ3kT72nW>q>XB((xT8 zC+nqsc(f(vKc^^fjU+Z5Q)k&jm_C=OGSx-!nCPmkbv+Q0ZJbf17xvytC%meCZi@bX z+mbzf?F~9EUxB&J#5aBA2Y%K~c*TztsjCRvm@CE*G6Q8t;@d@|=rZRsaUU8`e~yd? zqL}AD$^(W7P?3wcu9ohPcI&2}p6y60iEna5ozZOF2?O^2Y}{6YO?C;27Qav|CNquqIdV16kW-K97@q^M0o;D!>@U=KhbAp zuMRS0&=FnUzBh@U<^}KBeJ3Llrcn{f1un>Zwu_*5m;}0C;^Uo0TYu5_mFb ztW7c*2}VP2qE&UzjlL_r>;vP6k#*u*HZ6?mYr*lpf9j>ZQ=l#CXe>u~d6ScuuD8NG zO+P3*yD^C}y&TVv04Na>9ZqmT>G-ieq8+7VCDN6=&cfTfRt8-?Aw>y^1>8}Jd8y8{ zl9g3*qy4^QV0_X~xhTNdCnY@M81s>sEo}AQX)6$BqwzHf2_Sj)BsE?TprWbjJU7g( z#H)qo1^Da5j8Ac^y>?D(P_vFy-ilOL_`=mPqQOIR6yA_I zG?N=c&=g!4X>hOZalTzs>D}EizZyw+{EEf0Y0S$*&^kv>YhLRzgzpi$7EM8ojqN@Z z^h<-11M~BzC#W(A5Y3M>_O@yKfF%41E8f^RMjFu>66m!-riZ*-ed-@Ed}2DnApsdu zFWJr5_vLsNr=J(TPFxVVegdxSh0m~~xcjW|8!Ev2)F(Tl^msq~>{VKmt!vM<`Fav^ z-8`?JF|i4(fnX$2oB?#+ukT*3a)V*|LVSLFH@6A7 zuy3w+8!xi^m9pnCR~VqaFA8hh$)Q?zwzJLH1L=7E5@|Rcr zEuEr*Tl?>60uZDPhaDIDhCSv|6_g9=MBR0(@aFFu8yz?`55>YH6mG6W7W>$XhD?ST z+es&nz_`rbzF1i5o}Wxv=672W=H!@KGu9-CcPfo7+hw~A8o+N0Cx?{N&`REckz zof?Pc0D4K4n`@E9Dhc1cCHYv~gS)=I6L&RsBZQt}xyp`j5Er*gZwJLt&Z4rgg@2=+77q;sq(&bqW&!s1(Fuv~O7s1EmD)@iVd=y3_$Q$e|;jL58KvS)2uCbJWfPfsC zYM5Rbqci|Wf;ss{56y6Fb2l7TvJDqi+kj)xVhEVY&{mJdT7c~bLS+3tzs{Q6aWihQ zs;)b+rium%w_j%8eW^+p?cR;@(rQz0BxQ(gZ6-^}NY%`$UYvn~nwlt!#fl!>w8>4E z5q6jrB}Z0s?mM2Tq5nD4;+KW2;$wycXc+20nKL}Zfvb9o1}EySnG3l?lZv&`W z>IL{Fx%qd38_GGly#4UuoQUs$2QjbE*gRBE($e0$&CP-AR#-%x?kjE!b#Uuu{j%b* zIgty|RQ|5%Q`I$mdxzY+;_S$Bdo`7qsZ~W z?r={WzV9-(m9_O&EVa7&Q0xqmz5N@pS}8ok`Cy_zv{#Rpf?v6wl*EOQ(1rfKhuQtE z2H`{&`p2=U-PKL$(Sx?R#=CayI+t0PWe}`@eb8_=fYaE&)l1mH|3oV{v)R`#E zRoSch(gCZxRe6j>+gMpzZo*Otf~3lHK^dSj@1wB2?yJ1{iw5wG`ux)eWj?${QVh+) z2n52{DYBQBGQGD~o_s)S=qr?b&qPopI5^m8(myA9@csJkYpJ>)Rw8Bzadl0D8|$oA z5^dYf(0pk*0vW3b@#xYR|82jrd%OlQw(-C>uQ#J@tb;5Je?TO?D!&AhFqRJ;qGJw} z10Ks4sSmEsa=cD&%q}R|6n4tWgtk#M-BSaLAhXebgWp##>_<5K7FiY9#O^^0Qw^Ci{q48Vkk`1|k$d)*ex7)>yS}0k<%*_&{R@unO%l|AdFArs3zcl@w zT-cEPrGMA-z{9Z&R^+dg;FF5hy7KY@;~MGwZtWW(-9P6@FwO6%2*^>qy;NhZhL<9i zI^08X8he*k>)N@Hf6uH`{7WX@w;HUp0V`8H=3HA>cRo|aqr)P$^iqk9>)W?)Wi>Ps z2-2G@r^ZL}_xSF>Qa6S&QU1jA(&x+ybDhpa>6qmRs0zU>=2H&+Q75vFdT3xCk{Lub z+~g0)%01&8ey&%^HgTQ6I;cYSpE)>@y+rs}?Pb`kMKm>pq1vQe9%mDGc10z+&MCXK zq1sgrYx!4+8oJOYjch!QH_z{>3QgHv#n;?%^lS*aZp0su2GUh^E4u*1gm0?{VhAxd7Bjq91M+q!2% zg~2M)MDKq`YruPx7r~-YcST2O1#z#A7qxqtjm*g2+#eahgv~A}0$W4hpsWKnIo@`I z7{*8CTz)q?`WjF$NtC|B!1wUerr(o;G08HgcJgN(74*<}@OMo>s>{|PeT289xG+h> zEbGklCfn>MPp%V2N{pr2YfpTl27px2iv2{In7EHrVwnS#!An`_M^{rEPsF*6glsFF zIfv2!R(qF3B_Lb%CH=ngamI(UibSwj>=HR8)GHIqI87ZNcc~?jY#_;1WR72hGRI&} z&PV4FB-B09Pj$Mu2ocPT)E%#eb2b;@sfLIobW+hESg2bfVC|2{PjcfNnkxra^HkqW zsLIdg12%3$(GG}epdCUS$ZTr^Hv-}8@=laUjZ)(opfY!& zqjwanQNM{sd7;ZZgm3UYpndfhfNo5D2A!99p3HGzd>f=W5bu9T8fI2p-p{>6qPnA` zq@Nq~_4viBEVT9KJ5Sis3&20FS_PEjNP1IOortT-qb3jB#T{7FzX?y9suT~hz7wJ3 zt0t&9SR2)ux^41ge^M+k!Xd-862mCN`agmshy^S_eaWSLSW{jrk^7!N#$7WJB%-YD zT^@)LOB$v)TYK6(i`Y`H`lbY8iJlSp8F_HE#(3Ek!em~W-nDLBQPL^9;pPeO8byO| zn{9JVh_Rl)Lz6@hBxo<{td5e&gRWO4BMy0Y+nF<37cI!eg(47pMtRZj{THOL&};x= z%);V5iPNKt3r^FG<x@Zm{cfa8X2UE<3=vuzMJtHSdeYQ?%wjjJ z8sb+%zObhY+Kvde?z)T-4~^Y{-x>&-_w^CBg{g@#ObR2H==-_J;lb0?NGS37pd~&x z+a(QavdvXoe8)slrPFn~)5(;Z=_8$4BjFGi&u79iDj5`>qzuX)bAX!P)p}lZ{r(&d zSBQgVesXA!_^N&=|GPGozw%$53v=D0$pP|Ys#%HXHr=>t;Brr77+75d9gLIB%nJO> z0)OSuKI&>#U6}3O_Ig>t6J@F!x@W-)eX3w!hi|=*fI0se4B2GH+`U`v+L>Qq9G)W+ zTlY0_TrWg6tPs*w#cwi1MrViYGx2o1!-LdRD<2D1tAE@VDw3GB3K?2HJSxHcC! zk@UKzcPCP2F}6+<=J|b+X+L{inOT;+>Ongn00t1)$UcNiQ!-EI%8c%@&0_?W7w)QH}_0#SCo3s@zUat

Bn^K9(XfAU0l4CVwt3eFc+uSN(dI_$k-{{f@WV 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 Umsy&0baig|I?%7`tLS6@1nG>GFaQ7m diff --git a/app/test/support/repo_paths.dart b/app/test/support/repo_paths.dart index d6b457c5..1387facc 100644 --- a/app/test/support/repo_paths.dart +++ b/app/test/support/repo_paths.dart @@ -41,3 +41,9 @@ String gateScript(String name) => '${repoRoot().path}/tools/ci/$name'; File conformanceCorpus() => File( '${repoRoot().path}/tests/firmware_tests/host/conformance/corpus.json', ); + +/// Shared PBLE/1 v0.6.1 HELLO/session semantic vectors (TDD D11). +File v061SessionCorpus() => File( + '${repoRoot().path}/tests/firmware_tests/host/conformance/' + 'v061_session_vectors.json', +); diff --git a/app/test/unit/app_version_contract_test.dart b/app/test/unit/app_version_contract_test.dart index 3db66282..8906b841 100644 --- a/app/test/unit/app_version_contract_test.dart +++ b/app/test/unit/app_version_contract_test.dart @@ -13,7 +13,7 @@ import 'package:pyble/app/app_info.dart'; import '../support/repo_paths.dart'; void main() { - test('app 0.2.0 beta starts at globally monotonic build 5', () { + test('app 0.2.0 open-testing candidate uses monotonic build 8', () { final String pubspec = File( '${appPackageRoot().path}/pubspec.yaml', ).readAsStringSync(); @@ -30,7 +30,7 @@ void main() { final RegExpMatch declaration = declarations.single; expect(declaration.group(1), '0.2.0'); - expect(declaration.group(2), '5'); + expect(declaration.group(2), '8'); expect( kAppVersion, declaration.group(1), diff --git a/app/test/unit/engine_cleanup_failure_test.dart b/app/test/unit/engine_cleanup_failure_test.dart new file mode 100644 index 00000000..9c5f1301 --- /dev/null +++ b/app/test/unit/engine_cleanup_failure_test.dart @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/pble/engine.dart'; +import 'package:pyble/pble/frame.dart'; +import 'package:pyble/pble/fragment.dart'; +import 'package:pyble/pble/pble_constants.dart'; +import 'package:pyble/pble/pble_exception.dart'; + +import '../support/fake_byte_transport.dart'; + +class _CancelFailureTransport extends FakeByteTransport { + _CancelFailureTransport(Object failure) + : input = StreamController(onCancel: () async => throw failure); + final StreamController input; + @override + Stream get inbound => input.stream; +} + +class _HeldTransport extends FakeByteTransport { + _HeldTransport() : super(mtu: 23); + final Completer held = Completer(); + int submissions = 0; + @override + Future send(Uint8List packet, {required bool acknowledged}) async { + submissions++; + if (submissions == 1) await held.future; + await super.send(packet, acknowledged: acknowledged); + } +} + +void main() { + for (final bool fire in [false, true]) { + test( + 'retired ${fire ? 'fire' : 'request'} never submits more held fragments', + () async { + final Completer finished = Completer(); + final List uncaught = []; + Object? operationError; + int before = 0; + int after = 0; + runZonedGuarded(() async { + final _HeldTransport transport = _HeldTransport(); + final PbleEngine engine = PbleEngine(transport); + final PbleFrame command = PbleFrame( + type: Pble.typeCmd, + opcode: (fire ? PbleOpcode.consoleInput : PbleOpcode.run).code, + id: engine.nextId(), + payload: Uint8List(100), + ); + final Future observed = + (fire ? engine.fire(command) : engine.request(command)) + .then( + (_) {}, + onError: (Object error) => operationError = error, + ); + await pumpEventQueue(); + before = transport.submissions; + await engine.dispose(); + await pumpEventQueue(); + transport.held.complete(); + await observed; + await pumpEventQueue(); + after = transport.submissions; + await transport.dispose(); + finished.complete(); + }, (Object error, StackTrace _) => uncaught.add(error)); + await finished.future.timeout(const Duration(seconds: 2)); + expect(before, 1); + expect(after, 1); + expect(uncaught, isEmpty); + expect(operationError, isA()); + }, + ); + } + + test( + 'on-time response before retirement still wins over held write completion', + () async { + final _HeldTransport transport = _HeldTransport(); + final PbleEngine engine = PbleEngine(transport); + final PbleFrame command = PbleFrame( + type: Pble.typeCmd, + opcode: PbleOpcode.deviceInfo.code, + id: engine.nextId(), + payload: Uint8List(0), + ); + final Future result = engine.request(command); + await pumpEventQueue(); + final PbleFrame response = PbleFrame( + type: Pble.typeRsp, + opcode: command.opcode, + id: command.id, + payload: Uint8List.fromList([0]), + ); + for (final Uint8List packet in PbleFragmenter( + mtu: 23, + ).fragment(encodeFrame(response))) { + transport.deliverRawPacket(packet); + } + await pumpEventQueue(); + await engine.dispose(); + transport.held.complete(); + expect((await result).payload, response.payload); + expect(transport.submissions, 1); + await transport.dispose(); + }, + ); + + test( + 'inbound cancellation failure still fails pending work and closes events', + () async { + final StateError failure = StateError('inbound cancellation failed'); + final _CancelFailureTransport transport = _CancelFailureTransport( + failure, + ); + final PbleEngine engine = PbleEngine(transport); + bool eventsDone = false; + Object? requestFailure; + final StreamSubscription events = engine.events.listen( + (_) {}, + onDone: () => eventsDone = true, + ); + addTearDown(() async { + await events.cancel(); + await transport.input.close(); + await transport.dispose(); + }); + unawaited( + engine + .request( + PbleFrame( + type: Pble.typeCmd, + opcode: PbleOpcode.deviceInfo.code, + id: engine.nextId(), + payload: Uint8List(0), + ), + timeout: const Duration(milliseconds: 200), + ) + .then( + (_) {}, + onError: (Object error) { + requestFailure = error; + }, + ), + ); + await pumpEventQueue(); + await expectLater(engine.dispose(), throwsA(same(failure))); + await pumpEventQueue(); + expect(requestFailure, isA()); + expect(eventsDone, isTrue); + }, + ); +} diff --git a/app/test/unit/native_link_cleanup_test.dart b/app/test/unit/native_link_cleanup_test.dart new file mode 100644 index 00000000..7447007b --- /dev/null +++ b/app/test/unit/native_link_cleanup_test.dart @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/ble/ble_link.dart'; +import 'package:pyble/ble/fbp_ble_link.dart'; +import 'package:pyble/pble/pble_constants.dart'; + +class _Device extends BluetoothDevice { + _Device(this.failure, {Object? cancelFailure}) + : changes = StreamController( + onCancel: () async { + if (cancelFailure != null) throw cancelFailure; + }, + ), + super( + remoteId: const DeviceIdentifier( + '00000000-0000-0000-0000-000000000001', + ), + ); + final Object failure; + final StreamController changes; + int disconnectCalls = 0; + @override + Stream get connectionState => changes.stream; + @override + Future disconnect({ + int timeout = 35, + bool queue = true, + int androidDelay = 2000, + }) async { + disconnectCalls++; + throw failure; + } +} + +void main() { + for (final bool failCancel in [false, true]) { + test( + 'native disconnect failure releases subscription (cancel failure $failCancel)', + () async { + final StateError failure = StateError('physical disconnect failed'); + final _Device device = _Device( + failure, + cancelFailure: failCancel + ? StateError('subscription cancel failed') + : null, + ); + BluetoothCharacteristic characteristic(String uuid) => + BluetoothCharacteristic( + remoteId: device.remoteId, + serviceUuid: Guid(Pble.serviceUuid), + characteristicUuid: Guid(uuid), + ); + final FbpBleLink link = FbpBleLink( + device, + characteristic(Pble.rxUuid), + characteristic(Pble.txUuid), + ); + expect(device.changes.hasListener, isTrue); + await expectLater(link.disconnect(), throwsA(same(failure))); + expect(device.disconnectCalls, 1); + expect(device.changes.hasListener, isFalse); + expect(link.linkState.value, BleLinkState.connected); + await device.changes.close(); + }, + ); + } +} diff --git a/app/test/unit/owned_connection_lifecycle_test.dart b/app/test/unit/owned_connection_lifecycle_test.dart new file mode 100644 index 00000000..ed9ebee8 --- /dev/null +++ b/app/test/unit/owned_connection_lifecycle_test.dart @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pyble/ble/ble.dart'; +import 'package:pyble/pble/ble_byte_transport.dart'; +import 'package:pyble/pble/connection.dart'; +import 'package:pyble/pble/connection_manager.dart'; +import 'package:pyble/pble/conn_state.dart'; +import 'package:pyble/pble/engine.dart'; +import 'package:pyble/pble/fragment.dart'; +import 'package:pyble/pble/frame.dart'; +import 'package:pyble/pble/pble_connection.dart'; +import 'package:pyble/pble/pble_constants.dart'; +import 'package:pyble/pble/pble_exception.dart'; +import 'package:pyble/pble/scanner.dart'; + +import '../support/fake_readiness_seam.dart'; + +class _TrackedState extends ValueNotifier { + _TrackedState() : super(BleLinkState.disconnected); + final Set listeners = {}; + @override + void addListener(VoidCallback listener) { + listeners.add(listener); + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + listeners.remove(listener); + super.removeListener(listener); + } +} + +/// Actual encoded HELLO/engine traffic, with only the physical byte pipe faked. +class _OwnedLink implements BleLink { + final _TrackedState state = _TrackedState(); + final StreamController> incoming = + StreamController>.broadcast(); + final List> writes = >[]; + int disconnectCalls = 0; + Completer? closeGate; + Object? closeFailure; + @override + Stream> get inbound => incoming.stream; + @override + ValueListenable get linkState => state; + @override + int get mtu => 247; + @override + Future write(List bytes, {required bool withoutResponse}) async { + writes.add(List.of(bytes)); + } + + @override + Future disconnect() async { + disconnectCalls++; + await closeGate?.future; + if (closeFailure case final Object failure) throw failure; + state.value = BleLinkState.disconnected; + } + + Future releaseFixture() async { + await incoming.close(); + state.dispose(); + } + + List get frames { + final PbleReassembler parser = PbleReassembler(); + return [ + for (final List packet in writes) + if (parser.offer(Uint8List.fromList(packet)) case final Uint8List raw) + decodeFrame(raw), + ]; + } + + void deliver(PbleFrame frame) { + for (final Uint8List packet in PbleFragmenter( + mtu: mtu, + ).fragment(encodeFrame(frame))) { + incoming.add(packet); + } + } + + void answerHello({int proto = 1}) { + final PbleFrame hello = frames.lastWhere( + (PbleFrame f) => f.opcode == PbleOpcode.hello.code, + ); + deliver( + PbleFrame( + type: Pble.typeRsp, + opcode: hello.opcode, + id: hello.id, + payload: Uint8List.fromList([ + 0, + ...utf8.encode( + 'proto=$proto\nchip=esp32-c3\nmpy=1.28.0\nfs_root=/\nmtu=247', + ), + ]), + ), + ); + } +} + +Future<({PbleConnection connection, _OwnedLink link})> _ready({ + Duration dataTimeout = const Duration(seconds: 8), +}) async { + final _OwnedLink link = _OwnedLink(); + final PbleConnection connection = PbleConnection.fromLink( + link: link, + appName: 'PyBLE', + appVersion: '0.2.0', + dataTimeout: dataTimeout, + ); + link.state.value = BleLinkState.connected; + await pumpEventQueue(); + link.answerHello(); + await pumpEventQueue(); + expect(connection.state.value, ConnState.ready); + addTearDown(() async { + if (link.closeGate case final Completer gate when !gate.isCompleted) { + gate.complete(); + } + try { + await connection.dispose(); + } catch (_) {} + await link.releaseFixture(); + }); + return (connection: connection, link: link); +} + +PbleConnectionManager _manager(ConnectionFactory factory) { + final PbleConnectionManager manager = PbleConnectionManager( + scanner: FakeScanner(), + readiness: FakeSeamReadiness(BleReadiness.ready), + connectionFactory: factory, + ); + addTearDown(() async { + try { + await manager.dispose(); + } catch (_) {} + }); + return manager; +} + +void main() { + group('FR-CONNECT-7 owned physical link teardown', () { + test( + 'HELLO-ready dispose closes exact link and removes both listeners', + () async { + final r = await _ready(); + expect(r.link.state.listeners, hasLength(2)); + await r.connection.dispose(); + expect(r.link.disconnectCalls, 1); + expect(r.link.state.value, BleLinkState.disconnected); + expect(r.link.state.listeners, isEmpty); + }, + ); + + test( + 'concurrent and repeated disposal shares physical close completion', + () async { + final r = await _ready(); + r.link.closeGate = Completer(); + final Future first = r.connection.dispose(); + final Future second = r.connection.dispose(); + bool done = false; + unawaited( + second.then((_) { + done = true; + }), + ); + await pumpEventQueue(); + expect(identical(first, second), isTrue); + expect(done, isFalse); + expect(r.link.disconnectCalls, 1); + r.link.closeGate!.complete(); + await first; + await r.connection.dispose(); + expect(r.link.disconnectCalls, 1); + }, + ); + + test( + 'close failure still releases streams/listeners and remains a failure', + () async { + final r = await _ready(); + final StateError failure = StateError('physical close rejected'); + r.link.closeFailure = failure; + final Future consoleDone = r.connection.console.drain(); + final Future runDone = r.connection.runState.drain(); + await expectLater(r.connection.dispose(), throwsA(same(failure))); + await Future.wait(>[consoleDone, runDone]); + expect(r.link.state.listeners, isEmpty); + expect(r.link.state.value, BleLinkState.connected); + await expectLater(r.connection.dispose(), throwsA(same(failure))); + expect(r.link.disconnectCalls, 1); + }, + ); + + test( + 'direct engine constructor does not own external byte transport', + () async { + final _OwnedLink link = _OwnedLink(); + final BleByteTransport transport = BleByteTransport(link); + final PbleConnection connection = PbleConnection( + engine: PbleEngine(transport), + appName: 'PyBLE', + appVersion: '0.2.0', + ); + await connection.dispose(); + expect(link.disconnectCalls, 0); + expect(link.state.listeners, hasLength(1)); + transport.dispose(); + await link.releaseFixture(); + }, + ); + + test('late HELLO after disposal never republishes ready', () async { + final _OwnedLink link = _OwnedLink(); + final PbleConnection connection = PbleConnection.fromLink( + link: link, + appName: 'PyBLE', + appVersion: '0.2.0', + ); + link.state.value = BleLinkState.connected; + await pumpEventQueue(); + final List states = []; + connection.state.addListener(() { + states.add(connection.state.value); + }); + await connection.dispose(); + link.answerHello(); + await pumpEventQueue(); + expect(states, isNot(contains(ConnState.ready))); + expect(link.disconnectCalls, 1); + expect(link.state.listeners, isEmpty); + await link.releaseFixture(); + }); + + test( + 'disposal aborts an active download without another command', + () async { + final r = await _ready(); + final Future download = r.connection.getFile('owned.bin'); + final Future rejected = expectLater( + download, + throwsA(isA()), + ); + await pumpEventQueue(); + final PbleFrame begin = r.link.frames.last; + r.link.deliver( + PbleFrame( + type: Pble.typeRsp, + opcode: begin.opcode, + id: begin.id, + payload: Uint8List.fromList([0, 1, 0, 0, 0]), + ), + ); + await pumpEventQueue(); + final int count = r.link.writes.length; + await r.connection.dispose(); + await rejected; + await expectLater( + r.connection.runFile('owned.bin'), + throwsA(isA()), + ); + expect(r.link.writes, hasLength(count)); + }, + ); + + test('queued GET begin cannot create a transfer after disposal', () async { + final r = await _ready(dataTimeout: const Duration(milliseconds: 20)); + final Future download = r.connection.getFile('owned.bin'); + final Future rejected = expectLater( + download, + throwsA(isA()), + ); + await pumpEventQueue(); + final PbleFrame begin = r.link.frames.last; + r.link.deliver( + PbleFrame( + type: Pble.typeRsp, + opcode: begin.opcode, + id: begin.id, + payload: Uint8List.fromList([0, 1, 0, 0, 0]), + ), + ); + await r.connection.dispose(); + await rejected; + }); + + test( + 'terminal owned HELLO refusal closes physical link automatically', + () async { + final _OwnedLink link = _OwnedLink(); + final PbleConnection connection = PbleConnection.fromLink( + link: link, + appName: 'PyBLE', + appVersion: '0.2.0', + ); + link.state.value = BleLinkState.connected; + await pumpEventQueue(); + link.answerHello(proto: 2); + await pumpEventQueue(); + expect(link.disconnectCalls, 1); + expect(link.state.value, BleLinkState.disconnected); + await connection.dispose(); + await link.releaseFixture(); + }, + ); + }); + + group('FR-CONNECT-7 manager ownership and late factories', () { + test('disposal stops the owned scanner once', () async { + final FakeScanner scanner = FakeScanner(); + final manager = PbleConnectionManager( + scanner: scanner, + readiness: FakeSeamReadiness(BleReadiness.ready), + connectionFactory: (_) => throw StateError('not requested'), + ); + addTearDown(scanner.dispose); + await manager.startScan(); + expect(scanner.scanning, isTrue); + await manager.dispose(); + await manager.dispose(); + expect(scanner.scanning, isFalse); + expect(scanner.stopScanCount, 1); + }); + + test( + 'same physical ID waits for pending acquisition and stale close', + () async { + final a = await _ready(); + final Completer firstFactory = Completer(); + int factoryCalls = 0; + final manager = _manager((_) async { + factoryCalls++; + if (factoryCalls == 1) return firstFactory.future; + // Wrappers share one physical radio, as the native plugin does by ID. + a.link.state.value = BleLinkState.connected; + return PbleConnection.fromLink( + link: a.link, + appName: 'PyBLE', + appVersion: '0.2.0', + ); + }); + final Future first = manager.connect('same-board'); + await pumpEventQueue(); + final Future second = manager.connect('same-board'); + await pumpEventQueue(); + final int beforeFirstResult = factoryCalls; + a.link.closeGate = Completer(); + firstFactory.complete(a.connection); + await pumpEventQueue(); + final int beforeStaleClose = factoryCalls; + a.link.closeGate!.complete(); + await Future.wait(>[first, second]); + await pumpEventQueue(); + a.link.answerHello(); + await pumpEventQueue(); + expect(beforeFirstResult, 1); + expect(beforeStaleClose, 1); + expect(factoryCalls, 2); + expect(a.link.disconnectCalls, 1); + expect(a.link.state.value, BleLinkState.connected); + expect(manager.phase.value, ConnectPhase.connected); + }, + ); + + test('ordinary manager Disconnect closes the real ready link', () async { + final r = await _ready(); + final manager = _manager((_) async => r.connection); + await manager.connect('board-a'); + await manager.disconnect(); + expect(r.link.disconnectCalls, 1); + expect(r.link.state.value, BleLinkState.disconnected); + expect(manager.phase.value, ConnectPhase.idle); + }); + + test( + 'replacement waits for old physical close before opening new board', + () async { + final a = await _ready(); + final b = await _ready(); + bool openedB = false; + final manager = _manager((String id) async { + if (id == 'a') return a.connection; + openedB = true; + return b.connection; + }); + await manager.connect('a'); + a.link.closeGate = Completer(); + final Future replacement = manager.connect('b'); + await pumpEventQueue(); + expect(openedB, isFalse); + expect(a.link.disconnectCalls, 1); + a.link.closeGate!.complete(); + await replacement; + expect(openedB, isTrue); + expect(b.link.disconnectCalls, 0); + expect(manager.selected?.id, 'b'); + }, + ); + + test( + 'late connection after Disconnect is closed and never attached', + () async { + final r = await _ready(); + final Completer pending = Completer(); + final manager = _manager((_) => pending.future); + final Future connect = manager.connect('late'); + await pumpEventQueue(); + await manager.disconnect(); + pending.complete(r.connection); + await connect; + expect(r.link.disconnectCalls, 1); + expect(manager.connection.state.value, ConnState.disconnected); + expect(manager.selected, isNull); + }, + ); + + test('newest connection wins and stale result cannot close it', () async { + final a = await _ready(); + final b = await _ready(); + final Completer pendingA = Completer(); + final Completer pendingB = Completer(); + final manager = _manager( + (String id) => id == 'a' ? pendingA.future : pendingB.future, + ); + final Future first = manager.connect('a'); + await pumpEventQueue(); + final Future second = manager.connect('b'); + await pumpEventQueue(); + pendingB.complete(b.connection); + await second; + pendingA.complete(a.connection); + await first; + expect(a.link.disconnectCalls, 1); + expect(b.link.disconnectCalls, 0); + expect(manager.selected?.id, 'b'); + expect(manager.phase.value, ConnectPhase.connected); + }); + + test('old disconnect completion cannot clear a newer session', () async { + final a = await _ready(); + final b = await _ready(); + final manager = _manager( + (String id) async => id == 'a' ? a.connection : b.connection, + ); + await manager.connect('a'); + a.link.closeGate = Completer(); + final Future oldDisconnect = manager.disconnect(); + await pumpEventQueue(); + final Future nextConnect = manager.connect('b'); + a.link.closeGate!.complete(); + await Future.wait(>[oldDisconnect, nextConnect]); + expect(manager.selected?.id, 'b'); + expect(manager.phase.value, ConnectPhase.connected); + expect(a.link.disconnectCalls, 1); + expect(b.link.disconnectCalls, 0); + }); + + test( + 'manager disposal closes a factory that completes after disposal', + () async { + final r = await _ready(); + final Completer pending = Completer(); + final manager = _manager((_) => pending.future); + final Future connecting = manager.connect('late'); + await pumpEventQueue(); + await manager.dispose(); + pending.complete(r.connection); + await expectLater(connecting, completes); + expect(r.link.disconnectCalls, 1); + await expectLater(manager.connect('forbidden'), throwsStateError); + }, + ); + + test('close errors are not reported as successful Disconnect', () async { + final r = await _ready(); + final StateError failure = StateError('close failure'); + final manager = _manager((_) async => r.connection); + await manager.connect('a'); + r.link.closeFailure = failure; + await expectLater(manager.disconnect(), throwsA(same(failure))); + expect(manager.phase.value, ConnectPhase.failed); + expect(manager.lastError, same(failure)); + expect(r.link.state.listeners, isEmpty); + }); + + test('failed old physical close blocks replacement factory', () async { + final a = await _ready(); + final b = await _ready(); + final StateError failure = StateError('old board still connected'); + bool openedB = false; + final manager = _manager((String id) async { + if (id == 'a') return a.connection; + openedB = true; + return b.connection; + }); + await manager.connect('a'); + a.link.closeFailure = failure; + await expectLater(manager.connect('b'), throwsA(same(failure))); + expect(openedB, isFalse); + expect(manager.phase.value, ConnectPhase.failed); + expect(manager.lastError, same(failure)); + expect(a.link.state.value, BleLinkState.connected); + // An already-failed retirement is not proof of a physically closed link. + await expectLater(manager.connect('b'), throwsA(same(failure))); + expect(openedB, isFalse); + expect(a.link.disconnectCalls, 1); + }); + + test( + 'manager disposal shares failure and still closes facade streams', + () async { + final r = await _ready(); + final StateError failure = StateError('close failure'); + final manager = _manager((_) async => r.connection); + await manager.connect('a'); + r.link.closeFailure = failure; + final Future streams = Future.wait(>[ + manager.connection.console.drain(), + manager.connection.runState.drain(), + manager.scanResults.drain(), + ]); + final Future first = manager.dispose(); + expect(identical(first, manager.dispose()), isTrue); + await expectLater(first, throwsA(same(failure))); + await streams; + await expectLater(manager.dispose(), throwsA(same(failure))); + expect(r.link.disconnectCalls, 1); + }, + ); + }); +} diff --git a/app/test/widget/about_page_test.dart b/app/test/widget/about_page_test.dart index f17eb6ec..445a4a14 100644 --- a/app/test/widget/about_page_test.dart +++ b/app/test/widget/about_page_test.dart @@ -235,4 +235,87 @@ void main() { semantics.dispose(); }); }); + + group('X-11 offline privacy policy', () { + testWidgets('About opens the complete policy without a board or browser', ( + WidgetTester tester, + ) async { + await _pumpAbout(tester); + await tester.pumpAndSettle(); + final Finder policy = find.byKey(const Key('aboutPrivacyPolicyAction')); + expect(policy, findsOneWidget); + expect(tester.getSize(policy).height, greaterThanOrEqualTo(48)); + await tester.ensureVisible(policy); + await tester.tap(policy); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('privacyPolicyPage')), findsOneWidget); + expect(find.text('PyBLE Privacy Policy'), findsOneWidget); + expect(find.text('Effective 28 August 2026'), findsOneWidget); + expect(find.text('https://pyble.dev/privacy'), findsOneWidget); + for (final String heading in [ + 'Who maintains PyBLE', + 'PyBLE app', + 'Public GitHub import', + 'Data sent to your board', + 'BLE transport security', + 'Nearby boards and platform permissions', + 'Retention and deletion', + 'Third-party components and platform services', + 'This website', + 'Changes to this policy', + 'Privacy and deletion questions', + ]) { + expect(find.text(heading), findsOneWidget); + } + expect(find.textContaining('api.github.com'), findsOneWidget); + expect(find.textContaining('IP address and user-agent'), findsOneWidget); + expect( + find.textContaining( + 'does not require Bluetooth pairing or BLE link encryption', + ), + findsOneWidget, + ); + expect(find.textContaining('backup behavior depends'), findsOneWidget); + expect(find.text('viwat.v@chula.ac.th'), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('aboutPage')), findsOneWidget); + expect(find.text('Version 1.2.3 (45)'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final Size size in [ + const Size(320, 480), + const Size(1280, 800), + ]) { + testWidgets('policy remains readable at $size with large text', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await _pumpAbout(tester, size: size, textScale: 2, highContrast: true); + await tester.pumpAndSettle(); + final Finder policy = find.byKey(const Key('aboutPrivacyPolicyAction')); + expect(policy, findsOneWidget); + await tester.ensureVisible(policy); + await tester.tap(policy); + await tester.pumpAndSettle(); + + expect( + tester + .getSemantics(find.text('Who maintains PyBLE')) + .getSemanticsData() + .flagsCollection + .isHeader, + isTrue, + ); + await tester.ensureVisible(find.text('viwat.v@chula.ac.th')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('viwat.v@chula.ac.th').hitTestable(), findsOneWidget); + semantics.dispose(); + }); + } + }); } diff --git a/app/test/widget/about_shell_test.dart b/app/test/widget/about_shell_test.dart index 61b4487e..4e58709e 100644 --- a/app/test/widget/about_shell_test.dart +++ b/app/test/widget/about_shell_test.dart @@ -177,111 +177,129 @@ void main() { }, ); - testWidgets('About and licenses preserve the complete live Blocks session', ( - WidgetTester tester, - ) async { - const String editorSource = 'print("keep editor buffer")\n'; - const String blocksSource = 'print("keep visual workspace")\n'; - final _CompleteRecordingConnection connection = - _CompleteRecordingConnection(initial: ConnState.running); - await pumpShell(tester, connection: connection, surface: ipadLandscape); - final ProviderContainer container = _containerOf(tester); - - container.read(editorDocumentProvider.notifier).setContent(editorSource); - container.read(selectedSurfaceProvider.notifier).state = AppSurface.blocks; - await tester.pump(); - - final BlocksBridgeResult admitted = container - .read(blocksDocumentProvider.notifier) - .receiveBridgeMessage( - jsonEncode({ - 'version': kBlocksBridgeVersion, - 'type': 'snapshot', - 'revision': 7, - 'source': blocksSource, - 'workspace': { - 'blocks': { - 'languageVersion': 0, - 'blocks': [ - { - 'type': 'text_print', - 'id': 'about-round-trip', - }, - ], + testWidgets( + 'About, licenses and privacy preserve the complete live Blocks session', + (WidgetTester tester) async { + const String editorSource = 'print("keep editor buffer")\n'; + const String blocksSource = 'print("keep visual workspace")\n'; + final _CompleteRecordingConnection connection = + _CompleteRecordingConnection(initial: ConnState.running); + await pumpShell(tester, connection: connection, surface: ipadLandscape); + final ProviderContainer container = _containerOf(tester); + + container.read(editorDocumentProvider.notifier).setContent(editorSource); + container.read(selectedSurfaceProvider.notifier).state = + AppSurface.blocks; + await tester.pump(); + + final BlocksBridgeResult admitted = container + .read(blocksDocumentProvider.notifier) + .receiveBridgeMessage( + jsonEncode({ + 'version': kBlocksBridgeVersion, + 'type': 'snapshot', + 'revision': 7, + 'source': blocksSource, + 'workspace': { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'text_print', + 'id': 'about-round-trip', + }, + ], + }, }, - }, - }), - ); - expect(admitted, BlocksBridgeResult.snapshotAccepted); - - // Subscribe before scripting the broadcast event so the provider retains - // the live execution state throughout the nested route round trip. - container.read(runStateProvider); - connection.emitConsole( - ConsoleStream.stdout, - 'live output stays\n'.codeUnits, - ); - connection.emitRunState(RunState.running); - await tester.pump(); - await tester.pump(); - - final EditorDocument editorBefore = container.read(editorDocumentProvider); - final BlocksDocument blocksBefore = container.read(blocksDocumentProvider); - final List consoleBefore = container.read( - consoleBufferProvider, - ); - final int verbsBeforeAbout = connection.verbCalls; + }), + ); + expect(admitted, BlocksBridgeResult.snapshotAccepted); + + // Subscribe before scripting the broadcast event so the provider retains + // the live execution state throughout the nested route round trip. + container.read(runStateProvider); + connection.emitConsole( + ConsoleStream.stdout, + 'live output stays\n'.codeUnits, + ); + connection.emitRunState(RunState.running); + await tester.pump(); + await tester.pump(); - expect(container.read(selectedSurfaceProvider), AppSurface.blocks); - expect(editorBefore.content, editorSource); - expect(editorBefore.dirty, isTrue); - expect(blocksBefore.program?.source, blocksSource); - expect(blocksBefore.retainedWorkspaceJson, contains('about-round-trip')); - expect(consoleBefore, hasLength(1)); - expect(find.textContaining('live output stays'), findsWidgets); - expect(container.read(runStateProvider).value, RunState.running); - expect(connection.state.value, ConnState.running); - expect( - find.byKey(const ValueKey('blocksWorkspaceHost-0')), - findsOneWidget, - ); + final EditorDocument editorBefore = container.read( + editorDocumentProvider, + ); + final BlocksDocument blocksBefore = container.read( + blocksDocumentProvider, + ); + final List consoleBefore = container.read( + consoleBufferProvider, + ); + final int verbsBeforeAbout = connection.verbCalls; + + expect(container.read(selectedSurfaceProvider), AppSurface.blocks); + expect(editorBefore.content, editorSource); + expect(editorBefore.dirty, isTrue); + expect(blocksBefore.program?.source, blocksSource); + expect(blocksBefore.retainedWorkspaceJson, contains('about-round-trip')); + expect(consoleBefore, hasLength(1)); + expect(find.textContaining('live output stays'), findsWidgets); + expect(container.read(runStateProvider).value, RunState.running); + expect(connection.state.value, ConnState.running); + expect( + find.byKey(const ValueKey('blocksWorkspaceHost-0')), + findsOneWidget, + ); - await tester.tap(find.byTooltip('About PyBLE')); - await tester.pumpAndSettle(); - expect(find.byType(AboutPage), findsOneWidget); + await tester.tap(find.byTooltip('About PyBLE')); + await tester.pumpAndSettle(); + expect(find.byType(AboutPage), findsOneWidget); - final Finder licenses = find.byKey( - const Key('aboutOpenSourceLicensesAction'), - ); - await tester.ensureVisible(licenses); - await tester.pumpAndSettle(); - await tester.tap(licenses); - await tester.pumpAndSettle(); - expect(find.byType(LicensePage), findsOneWidget); + final Finder licenses = find.byKey( + const Key('aboutOpenSourceLicensesAction'), + ); + await tester.ensureVisible(licenses); + await tester.pumpAndSettle(); + await tester.tap(licenses); + await tester.pumpAndSettle(); + expect(find.byType(LicensePage), findsOneWidget); - await tester.pageBack(); - await tester.pumpAndSettle(); - expect(find.byType(AboutPage), findsOneWidget); - expect(find.byType(LicensePage), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(AboutPage), findsOneWidget); + expect(find.byType(LicensePage), findsNothing); - await tester.pageBack(); - await tester.pumpAndSettle(); - expect(find.byType(AboutPage), findsNothing); - expect(container.read(selectedSurfaceProvider), AppSurface.blocks); - expect(container.read(editorDocumentProvider), editorBefore); - expect(container.read(blocksDocumentProvider), same(blocksBefore)); - expect(container.read(consoleBufferProvider), same(consoleBefore)); - expect(container.read(runStateProvider).value, RunState.running); - expect(container.read(connectionProvider), same(connection)); - expect(connection.state.value, ConnState.running); - expect(connection.verbCalls, verbsBeforeAbout); - _expectNoRecordedMutations(connection); - expect(find.textContaining('live output stays'), findsWidgets); - expect( - find.byKey(const ValueKey('blocksWorkspaceHost-0')), - findsOneWidget, - ); - }); + final Finder privacy = find.byKey(const Key('aboutPrivacyPolicyAction')); + expect(privacy, findsOneWidget); + await tester.ensureVisible(privacy); + await tester.pumpAndSettle(); + await tester.tap(privacy); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('privacyPolicyPage')), findsOneWidget); + expect(connection.verbCalls, verbsBeforeAbout); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(AboutPage), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(AboutPage), findsNothing); + expect(container.read(selectedSurfaceProvider), AppSurface.blocks); + expect(container.read(editorDocumentProvider), editorBefore); + expect(container.read(blocksDocumentProvider), same(blocksBefore)); + expect(container.read(consoleBufferProvider), same(consoleBefore)); + expect(container.read(runStateProvider).value, RunState.running); + expect(container.read(connectionProvider), same(connection)); + expect(connection.state.value, ConnState.running); + expect(connection.verbCalls, verbsBeforeAbout); + _expectNoRecordedMutations(connection); + expect(find.textContaining('live output stays'), findsWidgets); + expect( + find.byKey(const ValueKey('blocksWorkspaceHost-0')), + findsOneWidget, + ); + }, + ); testWidgets('compact Soft-reboot menu item retains its Connection action', ( WidgetTester tester, diff --git a/docs/README.md b/docs/README.md index bfe24e70..ecfd75e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,12 @@ maintainer guidance beside the source they govern. - [Hardware compatibility](specifications/hardware.md) - [Public roadmap](ROADMAP.md) +## Proposed release plans + +- [Firmware v0.6.1 and v0.7.0 roadmap](planning/firmware-v0.6.1-v0.7.0-roadmap.md) + — v0.6.1 hardening is approved for implementation; the Reliable Projects + v0.7.0 scope remains proposed + ## Detailed specifications - [`specifications/App/`](specifications/App/) — app requirements, test design, @@ -52,6 +58,8 @@ bundled offline examples required by its own product contracts. ## Testing and release handoff +- [Google Play open-testing submission packet](publication/google-play-open-testing.md) + — app `0.2.0 (8)`, listing fields, graphics, declarations and tester setup - [PyBLE 0.2.0 (5) TestFlight description and test plan](testing/testflight/0.2.0-build-5.md) - [PyBLE 0.1.0 (4) TestFlight description and test diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4773dc40..06c5f9fb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,23 +3,36 @@ PyBLE is developed in public. This roadmap communicates direction rather than promising dates; accepted work is tracked through GitHub issues and milestones. +The detailed [firmware v0.6.1 and v0.7.0 proposal](planning/firmware-v0.6.1-v0.7.0-roadmap.md) +records the post-v0.6.0 contract-hardening and Reliable Projects candidates. +The v0.6.1 hardening implementation has been published on owner confirmation; +v0.7.0 remains non-normative and requires separate maintainer approval. + ## Available now - iPad external beta through TestFlight - PBLE/1 editing, run/stop, console, and file workflows over BLE - Offline Blockly with beginner GPIO and NeoPixel examples -- Browser installation for the exact `esp32-4mb` and `esp32-s3-n16r8` profiles - as the v0.4.2 hardware-tested beta; production Chrome install/recovery passed - on both profiles +- The published firmware v0.6.1 covers five exact profiles: four ESP + Web Serial installers and the verified Pico 2 W UF2/manual-BOOTSEL path. + Publication follows the owner's qualification confirmation, recorded in the + [owner confirmation](https://pyble.dev/firmware-v0.6.1-owner-confirmation.md). + Incomplete automated evidence is preserved unchanged; the qualified v0.6.0 + release remains a separate historical baseline. +- Guided [tutorials](https://pyble.dev/learn) with reviewed Lenovo captures, + the [firmware functional reference](https://pyble.dev/features), and the + separate [official example collection](https://github.com/PyBLE-dev/examples) - MIT-licensed app, agent firmware, protocol, website, tests, and release tools ## Near term -- Qualify the app 0.2.0 beta train, starting at globally monotonic build 5, - across the retained iPad/Android and five-profile hardware test scope -- Complete the app, PBLE/1, resource, and remaining firmware release - qualification for the exact v0.4.2 bytes -- Complete real-hardware qualification before enabling the ESP32-C3 installer +- Continue the app 0.2.0 beta train; source is currently `0.2.0+8`. The + [prepared build-8 handoff](testing/google-play/0.2.0-build-8.md) is not proof + of store publication or validation of subsequent source changes. +- Reconcile the original automated qualification tracker + [#24](https://github.com/PyBLE-dev/PyBLE/issues/24) with the recorded + owner-confirmed v0.6.1 publication without inventing missing measurements. +- Review the proposed v0.7.0 Reliable Projects scope before implementation. - Expand user-facing setup, recovery, and board-specific wiring guidance - Open and document the Android beta distribution path - Convert remaining pre-public planning references into focused GitHub issues diff --git a/docs/decisions/0047-standardize-official-workspaces-on-lfs2.md b/docs/decisions/0047-standardize-official-workspaces-on-lfs2.md new file mode 100644 index 00000000..15e951ca --- /dev/null +++ b/docs/decisions/0047-standardize-official-workspaces-on-lfs2.md @@ -0,0 +1,61 @@ +# ADR-0047 — Standardize official firmware workspaces on LFS2 + +- Status: **Accepted** +- Date: 2026-09-03 +- Builds on: [ADR-0033](0033-qualify-v060-as-five-profile-heterogeneous-release.md) +- Normative contracts: + [PBLE/1 §5](../specifications/protocol.md#5-file-transfer-the-reliability-core), + [firmware requirements §4.4](../specifications/firmware/specs.md#44-filesystem-bridge--workspace-jail-fr-fs), + and [firmware TDD §9.1](../specifications/firmware/TDD.md#91-vfs--littlefs) + +## Context + +The v0.6.1 roadmap originally asked upload admission and scratch safety to work +"across FAT and LFS2." That wording did not match the official profile design. +Pico 2 W already constructs an LFS2 workspace, while ESP partition tables use +the ESP-IDF subtype token `data,fat` with the label `vfs`. MicroPython uses that +label for LFS2 first-use provisioning; the subtype token is block-container +metadata and does not require an on-media FAT filesystem. + +Leaving generic VFS auto-detection in the ESP boot path would nevertheless let +an old or foreign FAT workspace mount. It would also make PBLE/1's verified +scratch-to-target replacement depend on filesystem-specific rename behavior. +Automatically formatting incompatible nonblank media is not an acceptable way +to remove that ambiguity because it can destroy user files. + +## Decision + +1. Source-selected v0.6.1 and later official images use LFS2 for the internal + workspace on all five supported profiles. +2. Every official overlay reaches one shared, frozen mount authority that + constructs `VfsLfs2` explicitly. Generic filesystem auto-detection is not an + official boot path. +3. First-use formatting is permitted only after a normal LFS2 mount fails and a + complete read-only scan conclusively proves every device byte is erased. + Invalid geometry, an unreadable block, an allocation failure, a non-erased + byte, or any other uncertainty performs no format. +4. A nonblank incompatible or corrupt workspace fails closed without starting + the PBLE/1 agent. Recovery or migration is an explicit operator action. +5. The ESP `data,fat` partition subtype remains unchanged because it identifies + the flash block container; the `vfs` label and explicit constructor define + the on-media LFS2 format. +6. The immutable qualified v0.6.0 release is not rebuilt, repackaged, or + reinterpreted by this decision. Its fresh-install evidence remains + historical; v0.6.1 requires new exact-profile qualification. +7. A future port may propose another VFS only through a specification-first + decision defining atomic replacement, ordering, recovery, resource, and HIL + semantics. It cannot silently broaden this five-profile LFS2 contract. + +## Consequences + +- PBLE/1 upload admission has one official filesystem geometry and reserve + interpretation across the five-profile release set. +- Successful scratch-to-target rename relies on LFS2's atomic commit rather + than a FAT delete-then-rename fallback. +- Devices carrying an incompatible nonblank workspace need an explicit backup, + migration, or erase rather than automatic destructive repair. +- Release and HIL evidence must prove LFS2 mount, erased-media provisioning, + incompatible-media refusal, and workspace preservation independently on all + five profiles before v0.6.1 publication. + + diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9fbbfaf8..4bc44cd5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,5 +50,6 @@ Each ADR captures one significant decision: its context, the choice, and the con | [0044](0044-start-app-v020-beta-train-at-build-5.md) | Start the app 0.2.0 beta train at build 5 with synchronized package, PBLE/1 HELLO, and GitHub request identities | Accepted | | [0045](0045-adopt-ios-15-deployment-floor.md) | Adopt iOS and iPadOS 15 as the maintained source and compiled distribution floor while preserving build 5 as historical evidence | Accepted | | [0046](0046-publish-release-bound-static-learning-center.md) | Publish a static `/learn` center whose app, firmware, examples, validation, privacy, and deployment claims remain release-bound | Accepted | +| [0047](0047-standardize-official-workspaces-on-lfs2.md) | Standardize all five official firmware workspaces on explicit, fail-closed LFS2 mounting and provisioning | Accepted | -Decided 2026-06-30 (0001–0005); 0006 accepted 2026-07-01; 0007, 0008, 0009 and 0010 accepted 2026-07-02; 0011 accepted 2026-07-03; 0012 accepted 2026-07-25; 0013 accepted 2026-07-27; 0014, 0015, 0016, and 0017 accepted 2026-07-28; 0018–0022 accepted 2026-07-29; 0023 and 0024 accepted 2026-08-01; 0025 and 0026 accepted 2026-08-02; 0027–0029 accepted 2026-08-03; 0030 accepted 2026-08-11; 0031–0033 accepted 2026-08-12; 0034 accepted 2026-08-13; 0035 and 0036 accepted 2026-08-16; 0037 and 0038 accepted 2026-08-20; 0039 accepted 2026-08-21; 0040 accepted 2026-08-22; 0041 accepted 2026-08-25; 0042–0045 accepted 2026-08-27; 0046 accepted 2026-08-28. +Decided 2026-06-30 (0001–0005); 0006 accepted 2026-07-01; 0007, 0008, 0009 and 0010 accepted 2026-07-02; 0011 accepted 2026-07-03; 0012 accepted 2026-07-25; 0013 accepted 2026-07-27; 0014, 0015, 0016, and 0017 accepted 2026-07-28; 0018–0022 accepted 2026-07-29; 0023 and 0024 accepted 2026-08-01; 0025 and 0026 accepted 2026-08-02; 0027–0029 accepted 2026-08-03; 0030 accepted 2026-08-11; 0031–0033 accepted 2026-08-12; 0034 accepted 2026-08-13; 0035 and 0036 accepted 2026-08-16; 0037 and 0038 accepted 2026-08-20; 0039 accepted 2026-08-21; 0040 accepted 2026-08-22; 0041 accepted 2026-08-25; 0042–0045 accepted 2026-08-27; 0046 accepted 2026-08-28; 0047 accepted 2026-09-03. diff --git a/docs/planning/firmware-v0.6.1-v0.7.0-roadmap.md b/docs/planning/firmware-v0.6.1-v0.7.0-roadmap.md new file mode 100644 index 00000000..aef5532f --- /dev/null +++ b/docs/planning/firmware-v0.6.1-v0.7.0-roadmap.md @@ -0,0 +1,546 @@ + + +# Firmware v0.6.1 and v0.7.0 roadmap + +Status: **v0.6.1 APPROVED for implementation; v0.7.0 remains PROPOSED** + +Baseline: **qualified firmware v0.6.0** + +Last updated: **2026-09-03** + +## 1. Purpose and status + +This document records the firmware direction after v0.6.0. It is a public plan +whose per-release approval state is recorded above. It promises no date, +approves no wire encoding, and makes no release, qualification, installer, or +hardware-support claim. + +On 2026-09-02, the maintainer approved the v0.6.1 hardening scope for +specification-first and test-first implementation. That approval does not +approve v0.7.0 implementation, new opcode numbers, release qualification, or +publication. + +The proposal separates two kinds of work: + +1. **v0.6.1 — contract hardening:** correct and test behavior already promised + by PBLE/1 and the v0.6.0 firmware contracts, and add compatible defensive + hardening without adding a public operation or changing a frozen payload. +2. **v0.7.0 — Reliable Projects:** add capability-gated, backward-compatible + project, recovery, status, and filesystem operations. + +The next feature release should strengthen the five supported profiles before +adding another board family or a broad board-peripheral stack. + +Every accepted item remains subject to specification-first design, a failing +`[red]` test commit, the minimum `[green]` implementation, optional +`[refactor]`, shared conformance, exact-profile HIL, DCO sign-off, dependency +review, and `tools/ci/no_leak.sh`. + +## 2. v0.6.0 baseline + +The proposal builds on the exact five-profile release admitted by +[ADR-0033](../decisions/0033-qualify-v060-as-five-profile-heterogeneous-release.md): + +1. `esp32-4mb` +2. `esp32-s3-n16r8` +3. `waveshare-esp32-s3-lcd-147b` +4. `esp32-c3-4mb` +5. `rpi-pico2-w` + +Firmware v0.6.0 already supplies the following common platform surface: + +- BLE GATT RX, TX, and INFO characteristics with PBLE/1 framing, + fragmentation, CRC-32, bounded reassembly, and negotiated MTU support; +- HELLO and DEVICE_INFO capability discovery; +- file list/stat, offset download, CRC-verified windowed upload, reconnect + resume, delete, mkdir, and rename inside the workspace jail; +- one active transfer, upload scratch files, cumulative acknowledgements, and + atomic destination replacement after successful verification; +- one active program, file/source RUN, STOP, soft reboot, opt-in autorun, and + idle/running/done/error events; +- tagged stdout/stderr and bounded stdin over BLE; +- persisted device labels and optional Identify support; +- upstream MicroPython v1.28.0 with no PyBLE patch to upstream MicroPython; +- native ESP32-family and portable frozen-Python Pico 2 W agent + implementations; and +- exact-byte build, resource, protocol, provisioning, recovery, and physical + iPad/Android HIL gates. + +This roadmap preserves those operations. It does not replace PBLE/1, weaken +the workspace jail, make identity an authorization signal, or turn a firmware +image identity into physical-board detection. + +## 3. Gaps that motivate the roadmap + +The priorities below come from concrete limits in the current contracts and +implementations: + +1. **Incomplete large-directory enumeration.** Legacy `FILE_LIST` is bounded + by one approximately 480-byte worker response. It reports `more=1` when + truncated but has no continuation operation. The official example catalog + therefore uses bounded category directories instead of one flat directory. +2. **Transition-only run state.** `RUN_STATE` events created without a live + connection are omitted. A client reconnecting during autorun or a manual + run cannot query an authoritative snapshot. +3. **v0.6.0 execution-isolation discrepancy.** The native ESP runner executes in a + reused module/global context, while the Pico runner creates a fresh + `{"__name__": "__main__"}` dictionary. File RUN does not define portable + `__file__`, working-directory, sibling-import, or project-module cleanup + semantics. +4. **v0.6.0 input and console loss are not attributable.** The bounded stdin ring can + accept input while idle and can drop overflow. Bounded console output may + also be dropped under sustained congestion without a user-visible loss + counter. +5. **Transfers cannot be inspected or explicitly aborted.** Disconnect + preserves PUT scratch for resume, but there is no status/abort operation or + complete storage preflight. Stale scratch can consume space. +6. **Resolved for v0.6.1 — capability-contract discrepancy.** Historical + semantic prose named `max_file_size`, but it was absent from the frozen + serialized-key list and both v0.6.0 agent payloads. The v0.6.1 protocol and + firmware amendments preserve that wire reality: no such key is invented, + and `FILE_PUT_BEGIN` performs dynamic, overflow-checked free-space admission + with the specified filesystem reserve. +7. **Protocol and persistence hardening gaps.** HELLO version offers and some + inbound frame invariants need stronger agent enforcement; native and + portable CRC-error behavior need executable parity; configuration writes + and label-byte validation need fail-closed durability. +8. **Recovery remains incomplete for hostile user code.** User code can + disable keyboard interrupts with `micropython.kbd_intr(-1)`. The existing + STOP contract and recovery ladder need a tested, explicit resolution rather + than an undocumented reset side effect. + +The relevant frozen contracts are [PBLE/1 file transfer](../specifications/protocol.md#5-file-transfer-the-reliability-core), +[Run/Stop/Console](../specifications/protocol.md#6-run--stop--console), +[HELLO and capabilities](../specifications/protocol.md#7-hello--capabilities), +and the [Pico 2 W port contract](../specifications/firmware/ports/rpi-pico2-w.md). + +## 4. Approved v0.6.1 — contract hardening + +v0.6.1 should introduce no new public operation or payload. Its purpose is to +make the existing PBLE/1 and firmware contracts consistently true on both +agent implementations and add explicitly specified, wire-compatible defensive +bounds. + +### 4.1 PBLE/1 negotiation and dispatch parity + +Before implementation, clarify in `docs/specifications/protocol.md` the exact +observable ordering between connection, TX notification readiness, and HELLO +so an implementation can both enforce HELLO-first and deliver its response. + +Then: + +- parse the HELLO request and refuse it when `proto_versions[]` does not offer + PBLE/1; +- keep per-connection negotiation state and clear it on disconnect or VM-epoch + change; +- reject inbound frames whose type is not `CMD` or whose request ID is `0`; +- apply bounded fragment deadlines and malformed-frame budgets so a partial + sender cannot retain reassembly state indefinitely, after the exact bounds + and refusal/disconnect behavior are specified; +- make the native CRC-error response identical to the frozen PBLE/1 behavior; + and +- run the same semantic vectors against Dart, portable Python, and compiled + native C rather than treating the Python twin as sufficient proof for C. + +Current conforming app versions already offer PBLE/1 and use nonzero command +IDs. The stricter firmware must therefore preserve the supported app workflow +while refusing malformed or nonconforming third-party traffic. + +### 4.2 Run and stdin isolation + +- Execute each ordinary RUN with fresh user globals on every profile. +- Prevent a prior run's variables from affecting a later run. +- Clear stdin at RUN admission, accepted STOP, terminal state, disconnect, and + VM reset. +- Discard `CONSOLE_INPUT` while no program is active. +- Ensure overflow in one run cannot be consumed by another run. +- Preserve a future, separately specified interactive-session feature if a + deliberately persistent namespace is ever desired. + +Fresh execution changes observable behavior and therefore requires a firmware +specification amendment and compatibility tests even though it adds no opcode. +The frozen amendment scopes fresh globals to ordinary and autorun execution; +it does not prohibit a future capability-gated persistent interactive mode, +which would need its own specification and must not silently weaken ordinary +`RUN` isolation. + +### 4.3 Configuration durability and byte validation + +- Accept only well-formed UTF-8 labels within the existing 24-byte encoded + limit. +- Reject embedded NUL, newline, carriage return, and other disallowed control + bytes before persistence or capability serialization. +- Check all ESP NVS set/erase and commit results. +- Keep the old in-memory label, Identify configuration, autorun state, and + advertisement when persistence fails. +- Replace Pico's direct JSON overwrite with a versioned temporary-write, + flush, and atomic-rename transaction, with integrity validation on load. +- Distinguish missing first-boot configuration from corrupt persisted + configuration, fail closed, and retain a bounded internal fault marker for + the separately proposed v0.7.0 diagnostic surface. + +### 4.4 Transfer scratch and mutation safety + +- Omit reserved `.pbltmp` artifacts from every user-visible listing. +- Serialize DELETE, RENAME, and other conflicting mutations against an active + transfer. +- Resolve and enforce the effective upload size and a filesystem safety + reserve on the official LFS2 workspace. +- Recover safely from malformed scratch state without damaging the previous + destination file. +- Never automatically format a nonblank workspace merely because mounting or + configuration loading failed. + +The official images standardize on LFS2 across all five profiles under +[ADR-0047](../decisions/0047-standardize-official-workspaces-on-lfs2.md). The +ESP partition-table `data,fat` subtype is container metadata, not an on-media +FAT claim. Explicit LFS2 construction and fail-closed incompatible-media +handling resolve this roadmap's former FAT/LFS2 ambiguity without changing the +qualified v0.6.0 bytes. + +## 5. Proposed v0.7.0 — Reliable Projects + +All wire names and opcode numbers in this section are placeholders for +specification review. Each operation must be additive, explicitly advertised +by capability, ignored safely by old clients, and unavailable to a new client +when the capability is absent. + +| Priority | Proposed capability | Required outcome | +| --- | --- | --- | +| P0 | Complete directory enumeration | Every visible entry can be retrieved without silent truncation | +| P0 | Queryable run/session status | A reconnecting app can reconstruct the board's current execution state | +| P0 | Project-aware file execution | Multi-file examples and sibling imports behave identically on all profiles | +| P0 | Transfer status and abort | A user can inspect or cancel GET/PUT without corrupting an existing destination | +| P1 | Storage/build diagnostics | The app can preflight space and report the exact installed source artifact | +| P1 | Loss-aware console/input | Bounded loss remains possible but never silent | +| P1 | Explicit recovery ladder | A novice has a defined recovery from code that defeats ordinary STOP | + +### 5.1 Complete directory enumeration + +Preserve legacy `FILE_LIST` byte-for-byte. Add a bounded streaming operation, +provisionally shaped as: + +- `DIR_LIST_BEGIN` +- `DIR_LIST_DATA` +- `DIR_LIST_END` + +Streaming is preferred over a nominal seek cursor because the supported +MicroPython VFS implementations do not provide one universal stable directory +position. The specification must define: + +- whether a mutation during enumeration causes an explicit restart-required + error or produces documented best-effort results; +- the single-transfer interaction and cancellation behavior; +- connection/VM-epoch binding and disconnect cleanup; +- LFS2 ordering expectations for the five official profiles, with any future + alternative VFS requiring its own explicit semantics and qualification; +- bounded memory and notification pacing; and +- exclusion of internal scratch/configuration artifacts. + +### 5.2 Queryable run/session snapshot + +Add a capability-gated `RUN_STATUS` or `RUN_INFO` request. Its response should +contain only bounded diagnostic metadata: + +- a boot nonce or equivalent boot identity; +- a monotonically increasing run ID within that boot; +- current state: idle, running, done, or error; +- origin: file, inline source, or autorun; +- last terminal reason/status; +- relevant console/input loss counters; +- active recovery or pending reboot state; and +- elapsed monotonic time where the port can provide it safely. + +Do not echo inline source, console contents, secrets, or unnecessary file +contents. Existing `RUN_STATE` events remain unchanged. + +### 5.3 Project-aware file RUN semantics + +Define one portable file-execution transaction: + +- `__name__ == "__main__"`; +- `__file__` is the exact executed workspace path; +- the script's parent directory is available for sibling imports only for the + duration of the run; +- current directory and `sys.path` are restored after done, error, or STOP; +- project-local modules loaded by the run cannot survive into an unrelated + later run; and +- system, frozen, and standard-library modules are not incorrectly removed. + +Inline-source RUN remains isolated and receives a documented virtual filename. +This feature enables real multi-file examples without automatic board/pin +detection or a hidden hardware profile. + +### 5.4 Transfer status, abort, and capacity + +Add capability-gated operations, provisionally: + +- `TRANSFER_STATUS` +- idempotent `TRANSFER_ABORT` +- `FS_INFO`, or equivalent additive storage fields + +The resulting contract must: + +- report source path, total size, and current offset for GET, without inventing + an up-front expected CRC that PBLE/1 deliberately does not provide; +- report destination, total size, expected whole-file CRC, and contiguous + watermark for PUT; +- bind resumable PUT metadata to destination, total size, and expected CRC; +- keep the old destination byte-for-byte intact after abort or failure; +- remove only the explicitly aborted PUT's scratch data; +- preserve resumable PUT state after an ordinary disconnect; +- return an idempotent success when aborting an already-idle transfer; and +- report filesystem total, free space, safety reserve, and effective maximum + upload size so the app can preflight a batch. + +### 5.5 Loss-aware console and acknowledged input + +The console must remain bounded so output cannot exhaust memory or starve STOP +and file acknowledgements. v0.7.0 should report loss rather than promise a +lossless log. + +Candidate additions are: + +- a cumulative, saturating console-lost byte/chunk counter; +- a sequence or `CONSOLE_GAP` signal that cannot create an event storm; +- counters included in the queryable run snapshot; and +- a capability-gated `CONSOLE_INPUT_V2` carrying a run ID and returning the + accepted byte count and remaining input credit. + +Legacy `CONSOLE_INPUT` remains available for old apps and retains its existing +wire encoding. A new app should prefer the acknowledged path when advertised. + +### 5.6 Diagnostics and recovery + +Expose bounded, non-personal diagnostic data useful for support and release +verification: + +- immutable build/source fingerprint; +- flashed image identity and agent version; +- uptime and tested reset cause; +- filesystem totals and effective upload limit; +- current and minimum free heap where supported; +- queue high-water marks and console loss counters; and +- last boot stage or last bounded agent error. + +Image identity describes only the installed artifact. It must not become +physical-board inference, a pin map, an authorization signal, or an app routing +allowlist. + +First verify on every profile that the existing acknowledged SOFT_REBOOT path +recovers from `micropython.kbd_intr(-1)`, a Python tight loop, console flood, +and documented native-blocking cases. If a gap remains, specify an explicit, +capability-gated `FORCE_RESET` as the last recovery rung: + +1. STOP +2. SOFT_REBOOT +3. explicit FORCE_RESET + +Do not silently redefine every STOP as a hardware reset without an approved +PBLE/1 contract decision. Recovery must preserve the workspace and valid +configuration unless the user separately confirms a destructive repair. + +## 6. Cross-version compatibility contract + +The v0.7.0 design must satisfy all of the following: + +- keep all 24 v0.6.0 operation numbers and payload encodings unchanged; +- use new opcodes only for additive behavior permitted by PBLE/1 versioning; +- advertise an explicit capability for every new operation or semantic mode; +- make old apps safely ignore unknown capability keys; +- let the current app that supports v0.6.0 firmware complete its existing + workflow against candidate v0.7.0 firmware; +- let an updated app connect to v0.6.0 firmware and fall back when a new + capability is absent; +- keep firmware versions independent from Flutter app versions; and +- require PBLE/2 for any change that is genuinely backward-incompatible. + +The capability registry and exact wire encodings must be added to +`docs/specifications/protocol.md` before `[red]` implementation tests. App and +firmware specifications must consume that one contract rather than redefine it. + +## 7. Acceptance and HIL gates + +Every candidate must pass on the same five exact profiles listed in section 2. +No result from one profile, agent implementation, board, build, filesystem, or +provisioning method may fill another row. + +### 7.1 Run isolation and project execution + +- Run two programs sequentially without reset; a sentinel created by run 1 is + absent from run 2. +- File RUN observes the specified `__name__` and exact `__file__`. +- A project entry point imports a sibling helper. +- Replacing that helper before the next RUN loads the new contents rather than + a stale `sys.modules` entry. +- Working directory and `sys.path` equal their pre-run values after done, + error, and STOP. +- Fifty sequential bounded programs remain above the existing profile resource + floors with no monotonic leak. + +### 7.2 Input and console isolation + +- Input sent while idle cannot satisfy a later `input()` call. +- Input sent only after a prompt is consumed by that exact run. +- STOP, terminal state, disconnect, and overflow cannot carry bytes into a + successor run. +- Every dropped output/input byte is represented by a monotonic or saturating + counter, without a gap-event storm. +- Console flooding does not regress the existing control-response and STOP + latency gates. + +### 7.3 Complete directory enumeration + +- Populate at least 100 mixed files/directories whose legacy encoding exceeds + the one-response ceiling. +- Legacy `FILE_LIST` still reports truncation exactly as v0.6.0 specifies. +- For an unchanged directory, the new operation returns every visible entry + exactly once at ATT MTU 23 and negotiated MTU 247. +- Cover empty directories, valid maximum-length paths/names, UTF-8 names, + congestion, disconnect, and VM reset. +- For mutation during enumeration, prove the separately approved snapshot, + restart-required error, or documented best-effort semantics exactly. +- Exercise LFS2 on all five official profiles and prove internal artifacts are + never returned; any future alternative VFS requires separate qualification. + +### 7.4 Transfer status and abort + +- Start a partial PUT over an existing sentinel, abort, and prove the old + target is byte-identical. +- Prove explicit abort removes the applicable scratch state and permits a new + transfer immediately. +- Abort GET and immediately complete another GET/PUT. +- Duplicate abort is idempotent. +- Disconnect still preserves a valid resumable PUT; explicit abort deletes it. +- Cover full storage, stale/corrupt scratch metadata, conflicting mutations, + same-path/same-size/different-CRC content, and power interruption at each + commit boundary. + +### 7.5 State, diagnostics, and recovery + +- Connect during autorun and reconnect during every manual run/terminal state. +- Verify boot identity changes after reset and run IDs cannot be confused + across boot identities. +- Verify HELLO, DEVICE_INFO, and INFO expose identical shared additive values. +- Confirm `0 <= fs_free <= fs_total` and observed allocation changes remain + within the documented filesystem-block tolerance. +- Bind the build fingerprint to the immutable release manifest without a + personal identifier. +- Exercise STOP and the complete recovery ladder against a tight loop, + `micropython.kbd_intr(-1)`, console flood, and every separately claimed + blocking case. +- After recovery, HELLO, list, upload, RUN, and STOP must work again within the + approved reset/discovery SLO. + +### 7.6 Compatibility and release admission + +- Run the current app against candidate v0.7.0 firmware through every legacy + workflow. +- Run the updated app against v0.6.0 and prove capability fallback. +- Run the updated end-to-end workflow on one physical iPadOS device and one + physical Android device for every firmware profile. +- Re-run the complete shared conformance, resource, recovery, deterministic + build, provenance, installer, and exact-byte release gates. +- Run `tools/ci/no_leak.sh` and all relevant license/SBOM gates. + +## 8. Work explicitly deferred from v0.7.0 + +### 8.1 v0.8.0 candidate scope + +- Atomic multi-file deployment with a manifest, staging area, commit, abort, + and boot-time power-loss journal recovery. +- Workspace diff/verification using source SHA-256 while retaining CRC-32 for + PBLE/1 transport-corruption detection. +- Optional Pico parity increments that survive independent resource/HIL review. + +Atomic project deployment must prove the visible project is wholly old or +wholly new after every interruption. It must not be rushed into the same +release as the lower-level transfer status/abort foundation it depends on. + +### 8.2 Separate security milestone + +PBLE/1 currently trusts a connected client. An authenticated-owner mode needs +a written threat model covering physical-presence bootstrap, secret storage, +nonce/replay protection, downgrade behavior, session expiry, lost-client +recovery, credential reset, rate limiting, and which read-only operations +remain public. + +Bluetooth NoInputNoOutput "Just Works" pairing does not by itself authenticate +the peer against an active man-in-the-middle. Making application authentication +mandatory would break existing clients and is therefore likely a PBLE/2 +boundary. An optional PBLE/1 mode still requires a complete recovery design +before implementation. See the official +[Bluetooth Security Manager specification](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host/security-manager-specification.html). + +### 8.3 Other deferred work + +- Universal BLE firmware OTA/DFU. ESP safe OTA requires suitable partition and + rollback design, while RP2 uses a different UF2/BOOTSEL recovery model. See + the official [ESP-IDF OTA documentation](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/ota.html) + and [Raspberry Pi MicroPython/UF2 documentation](https://www.raspberrypi.com/documentation/microcontrollers/micropython.html). +- A sixth board family or automatic board/pin detection. +- Broad Waveshare peripheral additions, Pico core1 transfer concurrency, + built-in LED Identify, or NeoPixel claims without separate exact-hardware + qualification. +- On-board network package installation, compiled `.mpy` examples, Wi-Fi + credentials, cloud accounts, telemetry, or remote code storage. A future + app-side pinned source-only dependency workflow may be considered separately; + see [MicroPython package management](https://docs.micropython.org/en/latest/reference/packages.html). +- Recursive delete by default, raw REPL tunneling, unbounded console buffering, + or a debugger/breakpoint protocol before run/session foundations stabilize. + +## 9. Dependencies before implementation + +An approved release increment requires, in order: + +1. maintainer review and explicit scope selection from this proposal; +2. focused GitHub issues/milestones with acceptance criteria and owners; +3. protocol/specification amendments for every changed contract; +4. an ADR for material decisions such as directory mutation semantics, project + import cleanup, recovery escalation, or authenticated ownership; +5. `[red]` tests and cross-language conformance vectors; +6. minimal `[green]` implementation and optional `[refactor]` cleanup; +7. app integration behind capability fallback; +8. exact five-profile automated and physical HIL evidence; and +9. deterministic release build, provenance, SBOM/license, no-leak, DCO, and + publication review. + +### 9.1 Recorded v0.6.1 process deviation + +Step 2 was not completed before v0.6.1 implementation began. Milestone +[Firmware v0.6.1 — Contract hardening](https://github.com/PyBLE-dev/PyBLE/milestone/1) +and focused issues [#24](https://github.com/PyBLE-dev/PyBLE/issues/24), +[#25](https://github.com/PyBLE-dev/PyBLE/issues/25), +[#26](https://github.com/PyBLE-dev/PyBLE/issues/26), +[#27](https://github.com/PyBLE-dev/PyBLE/issues/27), and +[#28](https://github.com/PyBLE-dev/PyBLE/issues/28) were created +retrospectively on 2026-09-03. This restores explicit ownership, acceptance +criteria, and remaining-work tracking, but it does not retroactively satisfy +the required ordering and must not be represented as doing so. Issues #25–#28 +record source/host completion; #24 remains open and exclusively owns fresh +candidate-bound five-profile physical qualification and release admission. +Firmware v0.6.1 therefore remains source-selected, unqualified, unpublished, +and unavailable to installers until #24 closes. This deviation approves no +v0.7.0 scope and creates no precedent for a future release. + +## 10. Maintainer review checklist + +Before implementation begins, the maintainer should explicitly approve or +revise: + +- [x] the v0.6.1 hardening / v0.7.0 feature-release split; +- [ ] v0.7.0's P0 versus P1 scope; +- [ ] streaming directory enumeration and mutation behavior; +- [ ] fresh per-run globals and project-local import cleanup semantics; +- [ ] run/boot identity fields and their privacy bounds; +- [ ] transfer-abort and scratch-retention behavior; +- [ ] loss-counter and acknowledged-input behavior; +- [ ] the STOP/SOFT_REBOOT/optional FORCE_RESET recovery ladder; +- [ ] the diagnostic build/image identity wording and non-routing rule; +- [ ] the v0.8 atomic-deployment dependency; and +- [ ] whether authenticated ownership starts as a separate PBLE/2 design + milestone. + +Approval of this roadmap still does not approve opcode numbers, payload bytes, +implementation, qualification, or publication. Those decisions follow the +repository's specification-driven and test-driven process. diff --git a/docs/publication/google-play-assets.md b/docs/publication/google-play-assets.md new file mode 100644 index 00000000..e9a24668 --- /dev/null +++ b/docs/publication/google-play-assets.md @@ -0,0 +1,201 @@ + + + +# Google Play graphics and screenshot package + +Prepared for the English open-testing listing on 2026-09-10. The submission +instructions and store copy are in +[google-play-open-testing.md](google-play-open-testing.md). + +## Upload requirements checked on 2026-09-10 + +| Asset | Format and dimensions | +| --- | --- | +| App icon | 512 × 512, 32-bit PNG, at most 1024 KB | +| Feature graphic | 1024 × 500, JPEG or 24-bit PNG without alpha | +| Screenshots | At least two across device types; up to eight per type; JPEG or 24-bit PNG without alpha; 320–3840 px; longest side at most twice the shortest | + +Google recommends four app screenshots at 1080 px or higher in 16:9 landscape +or 9:16 portrait for screenshot-based promotion. Its large-screen guidance +also calls for four screenshots and those aspect ratios. Provide accurate +device-specific captures and concise alt text. These are separate from the +basic screenshot upload limits. See the official +[preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). + +## Artwork + +The canonical icon is +[`app/assets/branding/pyble-google-play-512.png`](../../app/assets/branding/pyble-google-play-512.png). +The package copies it without changes. It is derived from the project's MIT +Prompt Chip SVG; see the +[branding provenance](../../app/assets/branding/README.md). + +The feature graphic is authored as +[`pyble-google-play-feature.svg`](../../tools/publication/pyble-google-play-feature.svg) +and exported as `pyble-feature-1024x500.png`. The text is: + +> PyBLE +> +> MicroPython. Over Bluetooth. +> +> Build ideas on a compatible board. +> +> Editor · Blocks · Console · Files + +This is a fresh editorial vector composition using the canonical PyBLE mark, +navy/blue palette, and a simple code/wireless illustration. It is not an app +screenshot. It includes no stock artwork, generated raster artwork, third-party +logos, store badges, or iPad imagery. Text uses the installed Helvetica Neue +and Menlo system fonts during raster export; no font files are redistributed. + +| Asset | Alt text | +| --- | --- | +| Icon | PyBLE blue microcontroller with a white terminal prompt on a navy background. | +| Feature graphic | PyBLE: MicroPython over Bluetooth, with a code prompt and wireless signal illustration. | + +## Reproducible local package + +The completed package contains twelve upload PNGs, plus the asset manifest +and SHA-256 list, in the ignored workspace: + +```text +local/publication/google-play/open-testing-2026-09-10/store-assets/ + pyble-icon-512.png + pyble-feature-1024x500.png + phone/ # 3 current signed-build captures + tablet-7-inch/ # 3 current signed-build captures + tablet-10-inch/ # 4 reviewed physical-device feature captures + asset-manifest.json + SHA256SUMS +``` + +To export the icon and feature graphic alone: + +```sh +python3 tools/publication/prepare_google_play_assets.py \ + --output-dir local/publication/google-play/open-testing-2026-09-10/artwork-new +``` + +The script requires Python 3.9+, Inkscape, and ImageMagick. It refuses to +overwrite an existing output directory. It writes an `asset-manifest.json` +with dimensions, SHA-256 hashes, source provenance, and alt text, plus +`SHA256SUMS`. The PNG outputs have the required color formats; the feature +graphic has no alpha channel. + +For a screenshot set, pass `--capture-manifest` with a local JSON file whose +`environment` records app/package/build/device/source provenance and whose +`screenshots` contains `source`, `device_type`, `order`, `key`, and `alt_text` +for every capture. Source paths are relative to the manifest. Device types are +`phone`, `tablet-7-inch`, and `tablet-10-inch`. An optional `crop` records exact +`x`, `y`, `width`, and `height` to remove OS bars. The script checks dimensions, +strips metadata, converts to 24-bit PNG, and requires zero changed pixels +against every source or exact crop. It does not resize, annotate, or retouch +app content. + +To reproduce the complete package, retaining the existing reviewed output: + +```sh +python3 tools/publication/prepare_google_play_assets.py \ + --output-dir local/publication/google-play/open-testing-2026-09-10/store-assets-new \ + --capture-manifest local/publication/google-play/open-testing-2026-09-10/capture-manifest.json +``` + +## Physical tablet captures + +The four reviewed captures below come from a physical Lenovo TB-J616X running +Android 12, PyBLE 0.2.0 build 5, on 2026-08-28. The installed capture package was +`dev.pyble.pyble.tutorialcapture`, built from +`4df378fc76c919f0e2481eb5b668f14115d38587`. These are real board sessions, with +the displayed PyBLE agent version 0.6.0. The capture build is not a Play binary. + +These four app surfaces remain representative of the current source: the only +`app/lib/` change between that capture revision and preparation baseline +`ebbb427a17aacf58bdd7ea150520e63a6fb48acd` is a documentation-only change to +`pble/hello.dart`. The open-testing preparation adds an About privacy action +and policy page; it does not change the four depicted surfaces. Preserve this +provenance instead of relabeling these as newly captured build-8 images. + +The source provenance contains original raw hashes, crop geometry, and device +and board session details: +[five-board screenshot record](../testing/tutorial-five-board-screenshot-provenance-2026-08-28.md). +Each reviewed frame is the original 2000 × 1092 app region, with only Android +system bars removed by the earlier reviewed crop. Those dimensions satisfy +the basic upload limits, but do not have the preferred 16:9 promotional ratio. +Retain the honest geometry; a future physical capture at 16:9 can replace it. + +Upload these under **10-inch tablet screenshots**, in this order: + +| Order / output filename | Source under `tools/web/public/learn/app/` | Alt text | +| --- | --- | --- | +| `01-editor-console.png` | `pyble-app-0.2.0-build-5-first-program-editor-console-f6928eea293b.png` | MicroPython editor, board files and Console showing Hello from PyBLE on a connected board. | +| `02-blocks-python.png` | `pyble-app-0.2.0-build-5-blocks-hello-workspace-adc069d73fba.png` | Blocks workspace with a Hello, PyBLE print block and the generated Python shown alongside it. | +| `03-board-discovery.png` | `pyble-app-0.2.0-build-5-setup-five-board-scan-a89dedab7efc.png` | Bluetooth discovery lists five nearby PyBLE boards with signal strength and a Scan again control. | +| `04-example-import.png` | `pyble-app-0.2.0-build-5-examples-import-complete-65fc3f839336.png` | A public Python example has downloaded to the board; the app confirms it was not opened or run automatically. | + +The images were visually reviewed again for this package. They show only public +tutorial content, intentional documentation-board identifiers, and expected +app state. No account, private repository, token, notification, personal file, +debug badge, or device overlay appears in the selected app regions. + +There is no video, Chromebook, TV, Wear OS, car, or XR asset in this package. +Do not duplicate tablet images into phone slots or describe +the existing iPad website hero as an Android capture. + +## Current phone and 7-inch tablet captures + +The production app shows its connection screen until a real compatible board +is ready; the IDE cannot be reached without that connection. Emulator captures +must preserve this actual state. The local Android emulator can supply current +connection/About images, but cannot supply genuine connected board, Python +execution, or file-transfer evidence. + +The completed local manifest records exact capture builds, display geometry, +density, and screenshot filenames. Use its `phone/` output directory for the +phone slot and `tablet-7-inch/` for the 7-inch slot. Emulator screenshots +supplement the physical tablet feature images; they do not replace hardware +validation for the release candidate. + +The six current screenshots were captured on 2026-09-10 from the signed +production APK, `dev.pyble.pyble`, version **0.2.0 (8)**: + +- Source commit: `768fafe45beee7731d1a38480cc0463a2f5680cd`. +- App tree: `27fd1a264a576e68126c38f966db328559660249`. +- APK SHA-256: `3c98983a995c657f10a398d7b0f65a07c2d2af18e07c30e7d5dfc094eefcbdcf`. +- OS: Android 14 / API 34, using the installed Google APIs ARM64 system image. +- Phone: isolated phone AVD based on the Pixel 3a profile, 420 dpi, raw runtime + display 1080 × 2046. The exact `1080x1920+0+63` crop removes 63-pixel status + and navigation bars and retains the complete app region. +- 7-inch tablet: isolated Nexus 7 2013 AVD, 320 dpi, native 1200 × 1920, + captured in landscape at 1920 × 1200. The exact `1920x1032+0+48` crop removes + the 48-pixel status bar and 120-pixel Android taskbar. This preserves the + entire app region and excludes other apps' taskbar icons. +- Default font scale 1.0, real disconnected app state, no account or board. + No source code, providers, or connection results were changed for capture. + +The phone PNGs are 1080 × 1920. The 7-inch PNGs are 1920 × 1032 and meet the +basic screenshot limits; they do not meet the preferred 1080-pixel minimum +short edge and 16:9 ratio for large-screen promotion. Neither the three-image +phone set nor the three-image 7-inch set claims the four-image promotional gate. + +For **each** of the phone and 7-inch slots, upload in this order: + +| Filename | Alt text | +| --- | --- | +| `01-setup.png` | PyBLE connection screen explains that a compatible board running the PyBLE agent is needed to edit and run MicroPython. | +| `02-about.png` | About PyBLE shows version 0.2.0 (8) and describes the open-source MicroPython IDE. | +| `03-privacy.png` | The in-app PyBLE Privacy Policy explains local work, project ownership and the optional public GitHub import. | + +The APK installed and launched successfully on both configurations. The native +flow **Setup → About → Privacy policy → Android Back → About → Android Back → +Setup** passed on both, with visible titles verified in UI hierarchy captures. +The About page displayed the correct version and build. Visual review found no +clipped labels, debug badge, app crash, private content, or unrelated device +overlay in the exported app regions. Raw images and UI evidence remain under +`local/publication/google-play/open-testing-2026-09-10/raw/`; the result is +recorded in `native-ui-smoke.json` beside `capture-manifest.json`. + +All ten screenshot exports passed exact pixel comparisons after the documented +system-bar crops and RGB conversion. These checks establish capture integrity +and native UI navigation; they do not establish BLE behavior on physical +hardware. The isolated emulator instances were stopped after capture without +saving snapshots; the user's original AVD configurations were not edited. diff --git a/docs/publication/google-play-data-safety.md b/docs/publication/google-play-data-safety.md new file mode 100644 index 00000000..5124afb3 --- /dev/null +++ b/docs/publication/google-play-data-safety.md @@ -0,0 +1,226 @@ +# PyBLE Google Play Data safety working assessment + +Prepared 2026-09-10 for the Android open-testing candidate. This is a source +audit and a proposed declaration worksheet, not a submitted or certified Play +Console form. Reconcile it with the exact signed bundle, all active versions +covered by the package-level form, endpoint behavior and the final Console +questions before submission. + +## Why the internal-testing answer needs review + +The move to open testing requires a Data safety form. Google defines +collection by off-device transmission, including direct third-party requests. +A user-initiated-transfer exception to **sharing** does not itself exempt +**collection**. Local-only processing is different from transmission to a +nearby board. [Google Data safety guidance](https://support.google.com/googleplay/android-developer/answer/10787469?hl=en). + +PyBLE's lack of accounts, telemetry and a developer backend is verified by the +reviewed app source; those facts alone cannot justify checking "no data +collected." Optional GitHub HTTP traffic and intentional BLE transfers need +their own assessment. + +## Observed app data flows + +| Flow | App source behavior | Destination and retention boundary | +| --- | --- | --- | +| Python editing | In-memory document state, selection and editor settings; syntax highlighting is local | App memory; no implemented durable local project database. | +| BLE discovery | Scan filtered to PyBLE service; display board name, radio signal and connection identity | Nearby board-to-app communication; app does not send scan results to an internet endpoint. | +| Files and Run | Source text, board paths, filenames and control commands go to the chosen board after user action | Connected board; saved files persist there until removed, changed or erased. | +| Blocks | Bundled Blockly assets run locally; generated Python and companion JSON are saved to the board explicitly | Local WebView and selected board; no remote Blockly script or analytics endpoint. | +| Console input | User-entered stdin is sent to the board | Board/user program; user code can retain input. | +| Existing board labels | Displayed from nearby board advertisements; no implemented app rename/set-label flow | Local discovery UI; the board may advertise a label configured outside this app. | +| Console output and board information | Board output and facts displayed in the app | Board to app; no authored automatic crash/diagnostic upload. | +| Public GitHub import | HTTPS GET to exact `api.github.com`, only after opening importer; public repository/ref/tree/blob selectors and app-version User-Agent | GitHub handles network/request metadata independently; no credentials, cookies, board facts or private project source are sent. | +| About/privacy/licenses | Bundled/offline content and policy URL | No new HTTP client required for reading the in-app policy. | +| Support email/public issues | User chooses to contact the maintainer outside the app | Contact provider/project support process, distinct from an automatic in-app upload. | + +Sources: [`app/pubspec.yaml`](../../app/pubspec.yaml), +[`AndroidManifest.xml`](../../app/android/app/src/main/AndroidManifest.xml), +[`app/lib/github_import/`](../../app/lib/github_import/), +[`app/lib/blocks/`](../../app/lib/blocks/), +[`app/lib/data/`](../../app/lib/data/), +[`app/lib/pble/`](../../app/lib/pble/), +[PBLE/1 security boundary](../specifications/protocol.md#10-security-note-v1) +and the [public privacy policy](https://pyble.dev/privacy). + +The current manifest does not opt out of Android backup defaults. Do not +claim that operating-system backup or device-provider handling is disabled. +The public privacy policy separates platform/store handling from the app. + +## Proposed form answers + +This is a complete recommended declaration for owner review. The board and +GitHub tables below specify all five proposed data types and their handling. +Where a provider's published policy is broader than its API documentation, +the recommendation identifies the conservative inference explicitly. The +owner must review and attest the final Console form against the artifact. + +| Console topic | Proposed answer | Basis or outstanding decision | +| --- | --- | --- | +| Does the app collect or share any required user-data types? | **Yes**, under a conservative interpretation | Source files and related user input leave the Android device for the board; optional GitHub requests also leave it. | +| Account creation | **No account creation** | No PyBLE login, authentication provider, credentials or subscription. | +| All collected data encrypted in transit | **No** | GitHub uses HTTPS, but PBLE/1 does not require paired/encrypted BLE. Do not claim universal encryption. | +| Independent security review | **No** | No qualifying independent security assessment has been established. Project tests are not this badge. | +| Families commitment | **Do not assert pending audience/compliance review** | No review establishes the applicable Families obligations for the selected ages/countries. | +| Account-deletion URL | **Not applicable to account creation** | No accounts exist; do not invent an account-deletion service. | +| Data deletion request mechanism | Proposed **No** for a centralized request mechanism covering the data types declared here | Board-file deletion and local app clearing are user controls; the maintainer cannot delete the user's board contents or GitHub metadata. The public support contact does not establish that capability. | +| Advertising/marketing collection | **No in the audited app source** | No ads, advertising SDK or advertising identifier access. Reassess any independently disclosed endpoint purpose. | + +Google's collection, encryption, optionality and deletion questions use +specific definitions. A feature controlled by the user is not automatically +"optional" when its data is required for the app's principal function. +[Google Data safety guidance](https://support.google.com/googleplay/android-developer/answer/10787469?hl=en). + +### Board transfer categories + +The following mapping is an inference from the app's implemented transfers, +not a ruling from Google about PyBLE: + +| Data type | Collected | Shared | Required/optional | Purpose | Ephemeral | +| --- | --- | --- | --- | --- | --- | +| Files and docs | Proposed **Yes** for source files, paths, names and Blocks companion records sent to the board | Proposed **No**, where only the user's explicit transfer to their selected board occurs | Conservatively **Required**: board-file operations are central to this connection-gated IDE | App functionality | **No**; board files persist. | +| Other user-generated content | Proposed **Yes** for console input | Proposed **No**, where the transfer is clearly user-initiated and expected | **Optional** for stdin; user can edit/run programs without supplying console input | App functionality | **No** as a blanket answer; user code may retain it. | + +The no-sharing proposal relies on the actual user action and destination being +clear. It must not be generalized to unrelated SDK handling or independent +endpoint processing. Do not label source files "ephemeral" because the app +buffer is in memory when the board deliberately saves them. + +### GitHub metadata: complete conservative recommendation + +**API-specific facts.** The app makes public repository/ref/tree/blob requests +with an app-version User-Agent. GitHub associates unauthenticated REST calls +with their originating IP and enforces a 60-request hourly limit to protect +service availability and prevent abuse. Request counting spans individual +responses, so this is not solely transient packet routing. +[GitHub REST rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10). + +**Broader published handling.** GitHub's policy covers service use through +IDEs, describes request/IP usage data, IP-based general-location inference, +service improvement and security processing. Its policy does not provide an +endpoint-by-endpoint inventory. The analytics and approximate-location +entries below are conservative inferences from that statement, not evidence +that every unauthenticated PyBLE request is geolocated or used for analytics. +[GitHub privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement). + +| Data type | Collected | Shared | Required/optional | Collection and sharing purposes | Ephemeral | +| --- | --- | --- | --- | --- | --- | +| App activity → App interactions | **Yes**: repository/branch/tree/file request activity | **Yes** | **Optional** | App functionality; Analytics; Fraud prevention, security, and compliance | **No** | +| Device or other IDs | **Yes**: originating IP used to associate requests; conservative mapping to this category | **Yes** | **Optional** | App functionality; Analytics; Fraud prevention, security, and compliance | **No** | +| Location → Approximate location | **Yes**, conservatively covering GitHub's published IP-location inference | **Yes** | **Optional** | Analytics; Fraud prevention, security, and compliance | **No** | + +For each of these rows, select the listed purposes for both collection and +sharing if Console asks separately. The app-functionality purpose belongs to +fetching public source; PyBLE does not need physical location for that feature. +Security and request association have API-specific support. Analytics and +IP-derived location are deliberately broader provider-policy inferences. +GitHub also documents IP-derived location for trade compliance, although its +examples concern accounts and do not establish anonymous-API implementation. +[GitHub trade controls](https://docs.github.com/en/site-policy/other-site-policies/github-and-trade-controls). + +The **Shared: Yes** recommendation avoids asserting that GitHub's independent +metadata handling is covered by the explicit-user-transfer or service-provider +exception. It does not change the separate no-sharing proposal for source +sent only to the user's board. **Optional** reflects that the entire GitHub +workflow can be skipped. **Ephemeral: No** covers the documented request +counter and makes no unsupported real-time-only retention promise for logs +or inferred data. No exact log-retention period is claimed. + +Use **App interactions** for these fetch/selection events; do not also select +In-app search history or Web browsing history for the same activity. The +importer is not a general browser or a separate search-history feature. Do not +select User IDs merely because a public repository owner's name appears in a +request: that is a resource selector and need not identify the app user. + +This proposal does not select advertising/marketing, personalization, account +management or developer communications for the integration. The reviewed +requests contain no cookies, login credentials or user contact details and +load no marketing pages, ads or personalized recommendations. GitHub's +website-cookie and enterprise-marketing descriptions should not automatically +be assigned to this API flow. That evidence boundary is not a guarantee about +undocumented provider processing. + +**Owner attestation:** accept or revise this complete recommendation, record +the rationale, and compare the final form with the public and in-app policy. +Those policies already disclose GitHub request/IP metadata under GitHub's +own policy; verify that the final presentation makes the provider's role +clear alongside the app's lack of a telemetry SDK. If GitHub supplies narrower +authoritative API guidance, update the form and policy together as needed. +A network trace can confirm outbound fields and destinations, but cannot +establish the provider's retention or secondary uses. + +Users can delete board files in Files and clear/uninstall local app data. +Requests concerning GitHub-controlled data can be sent to +`privacy@github.com` under GitHub's rights process. The PyBLE maintainer cannot +promise to erase those third-party records. No app account-deletion service +is needed because PyBLE creates no app account. + +### Types not observed in the authored app + +No implemented app feature requests contacts, calendar, camera, microphone, +photos/videos, SMS/call logs, health data, financial data, race/religion/sexual +orientation, installed-app inventories, an advertising ID or precise device +location. There is no automatic analytics/crash-reporting SDK in the reviewed +dependency set. User-written Python can contain arbitrary text; do not claim +the app analyzes that text to infer these sensitive categories. + +This observation is limited to the audited source and dependencies. It does +not cancel the GitHub metadata issue above or replace the merged release +manifest/native SDK inventory review. + +## Android permission worksheet + +| Declared permission/feature | Scope and truthful explanation | +| --- | --- | +| `BLUETOOTH_SCAN` | Discover compatible nearby PyBLE boards; declares `neverForLocation`. | +| `BLUETOOTH_CONNECT` | Connect and communicate with the board the user selects. | +| `BLUETOOTH`, `BLUETOOTH_ADMIN` | Legacy Bluetooth permission path through Android 11/API 30. | +| `ACCESS_FINE_LOCATION` | Legacy BLE scan requirement through API 30; no app location derivation. | +| `ACCESS_COARSE_LOCATION` | Legacy scan compatibility through API 28. | +| `INTERNET` | User-started public GitHub import using HTTPS; ordinary editing/BLE/Files/Blocks work offline. | +| BLE hardware | Required feature; classic Bluetooth and location hardware are explicitly optional. | + +No background location, all-files access, foreground service, VPN, +accessibility service or advertising-ID permission is declared in the audited +main manifest. Review the **merged release manifest** after building because +dependencies can add permissions. Runtime accessibility support through +TalkBack is not an accessibility service. + +The candidate's merged manifest also includes +`dev.pyble.pyble.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`, declared with +`signature` protection and requested by the app for AndroidX receiver +protection. This is an app-defined permission, not another dangerous Android +permission or a grant to access personal data. Record it alongside the seven +Android permissions above in the final artifact inventory. + +## Audience dependency + +The proposed ages 13–15, 16–17 and 18+ describe the intended open-test audience +only if the owner confirms them. The project personas do not establish ages. +Google notes that teenagers may still be considered children in some locales; +age choices and supported countries must be assessed together. +[Target audience guidance](https://support.google.com/googleplay/android-developer/answer/9867159?hl=en). + +The current BLE adapter uses scanning directly, not Android Companion Device +Manager. Google's Families policy includes a Companion Device Manager +condition for Bluetooth requests in apps within Families scope, including +mixed audiences containing children. Adding child-directed audiences therefore +requires a specific implementation and policy review; selecting ages solely +to avoid that review would not resolve it. +[Families policy](https://support.google.com/googleplay/android-developer/answer/9893335?hl=en). + +## Retained attestation + +Before submitting, record outside Git: + +- reviewer/owner name, date, package, final version code and AAB SHA-256; +- inspected merged manifest and dependency/native SDK inventory; +- each final Data safety category, purpose, required/optional and sharing + choice, including the GitHub endpoint determination; +- policy URLs and in-app policy screenshots for the exact artifact; +- accepted audience/countries and applicable Families determination; +- final Console preview/export, submission status and later review feedback. + +Keep screenshots containing private account/Console details in the ignored +publication workspace. Reassess this worksheet whenever a feature, dependency, +endpoint, audience or release artifact changes. diff --git a/docs/publication/google-play-open-testing.md b/docs/publication/google-play-open-testing.md new file mode 100644 index 00000000..a08fddc7 --- /dev/null +++ b/docs/publication/google-play-open-testing.md @@ -0,0 +1,295 @@ +# PyBLE Google Play open-testing submission packet + +Prepared **2026-09-10** for candidate **`0.2.0 (8)`**, package +**`dev.pyble.pyble`**. The owner reports that open testing is enabled. This +packet prepares the app information and release handoff; it does not claim +that Play Console has received, approved or published a release. + +Build `8` is a proposed fresh identity after locally retained builds `5`, `6` +and `7`. Confirm it exceeds every version code already used in Play Console, +including discarded uploads, before finalizing the bundle. If not, select the +next unused code and update every candidate reference here before building. + +## Files to use + +| Material | File | +| --- | --- | +| Store app name | [`title.txt`](google-play/en-US/title.txt) | +| Short description | [`short-description.txt`](google-play/en-US/short-description.txt) | +| Full description | [`full-description.txt`](google-play/en-US/full-description.txt) | +| Release notes, including Console language tags | [`release-notes.txt`](google-play/en-US/release-notes.txt) | +| Concise reviewer instructions | [`app-access.txt`](google-play/en-US/app-access.txt) | +| Detailed reviewer setup and review flow | [Reviewer guide](google-play-reviewer-guide.md) | +| Tester onboarding, scenarios, feedback template and exit criteria | [Tester guide](google-play-tester-guide.md) | +| Invitation draft, to complete after the track is live | [`tester-invitation.txt`](google-play/en-US/tester-invitation.txt) | +| Data safety and permission assessment | [Data safety worksheet](google-play-data-safety.md) | +| Icon, feature graphic and screenshot preparation/provenance | [Asset guide](google-play-assets.md) | +| Built AAB/APK, hashes, signer and completed validation | [Build 8 artifact handoff](../testing/google-play/0.2.0-build-8.md) | + +Text files contain the field content only. Paste the release-note language +tags as provided; omit filenames/headings. The other files are maintainer +guidance, not text to paste wholesale into the listing. + +The invitation is a separate draft message, not a Play listing field. Replace +its opt-in placeholder, confirm the installed version and send it only after +the release is actually available. Preparing this file does not send it. + +## Store settings + +| Field | Prepared value or required decision | +| --- | --- | +| Existing app | Use the existing `dev.pyble.pyble` Play Console app; do not create another package. | +| App/game | **App** | +| Display name | **PyBLE: MicroPython over BLE** | +| Default language | **English (United States), en-US** | +| Category | Proposed **Tools**, reflecting the general-purpose MicroPython IDE. Education is an alternative if the owner chooses that positioning. | +| Tags | Choose only available Console tags matching programming/development and the actual feature set; do not fabricate tags or use unrelated discovery terms. | +| Price | **Free**; no in-app products or subscriptions. | +| Website | `https://pyble.dev` | +| Support website | `https://pyble.dev/support` | +| Support email | `viwat.v@chula.ac.th` — already public in the project's site configuration. | +| Public phone | Optional; leave blank unless the owner chooses a maintained public number. | +| Privacy policy | `https://pyble.dev/privacy` | +| Developer identity/address/verification | Keep the verified account's real legal/contact information. Repository branding does not establish what is verified in Console. | + +Google's listing limits are 30 characters for name, 80 for short description +and 4,000 for full description; the contact email is required. The listing is +shared across tracks, so review its effect on existing testers too. +[Create and set up your app](https://support.google.com/googleplay/android-developer/answer/9859152?hl=en). + +Only the English UI locale is currently implemented. This packet provides +English metadata and does not advertise translated app interfaces. The full +description prominently states that the connected IDE requires a compatible +physical board and initial computer-based firmware setup. + +## Open-testing settings + +These are prepared defaults pending the owner's country/audience choices and +the account's live Console availability: + +| Setting | Prepared value | +| --- | --- | +| Track | **Testing → Open testing** | +| Suggested release name | **`0.2.0 (8) - open testing`** | +| Countries/regions | **All available countries/regions**, after checking distribution availability and audience obligations for those countries. | +| Tester count | **Unlimited** | +| Feedback channel | **`viwat.v@chula.ac.th`**; public support URL is an alternative. | +| Proposed target age groups | **13–15, 16–17, 18 and over**, only if these are the genuine designed audiences. | +| Opt-in link | Copy the actual link from the published open-test track. No verified live link is available in this packet. | +| Listing URL | `https://play.google.com/store/apps/details?id=dev.pyble.pyble` — existing package address, not proof of open-test availability. | + +Google allows unlimited open-test participants; a numeric cap must be at least +1,000. The feedback channel appears on the opt-in page. Internal-test members +must leave that track before receiving open-test builds. Publish/review status +controls when the opt-in page becomes available. +[Testing-track setup](https://support.google.com/googleplay/android-developer/answer/9845334?hl=en). + +Audience is a product decision, separate from the IARC content rating. Do not +check all ages simply to maximize reach or claim a rating before completing +the questionnaire. Child-status rules vary by country; Google asks developers +targeting users under 21 to assess applicable local obligations. The Data +safety worksheet records the audience-dependent Families/BLE review. +[Audience guidance](https://support.google.com/googleplay/android-developer/answer/9867159?hl=en). + +## App content declarations + +These values follow the audited app source. The owner must attest that they +also describe the exact artifact and all relevant live behavior. Console may +show additional declarations based on its current dashboard or the selected +countries/category. + +| Form | Prepared answer or action | +| --- | --- | +| Privacy policy | Use `https://pyble.dev/privacy`; verify live access without login and matching in-app full policy. | +| Ads | **No**; no advertising UI or ad SDK is present. | +| App access | **Some functionality needs special access**: the connected IDE needs external BLE hardware. Use the reviewer instructions; no login credentials exist. | +| Content rating | Complete the IARC questionnaire; no resulting rating is preselected or claimed. Use the factual worksheet below. | +| Target audience/content | Owner confirmation of proposed 13–15, 16–17, 18+ and corresponding country obligations. | +| Data safety | Review and attest the complete five-type recommendation in the dedicated worksheet, including its clearly marked provider-policy inferences. **Do not copy a blanket "no data collected" answer from internal testing.** | +| Advertising ID | **No** in the audited source; check the merged release manifest and SDK inventory. | +| Account creation/deletion | **No account creation**; account-deletion requirement is not triggered by a nonexistent account. Board-file deletion is a separate workflow. | +| Financial features | **No financial features**. | +| Health apps | **No health features**. | +| Government app | **No**. | +| News app | **No**. | +| Foreground service / background location / all-files access / VPN / accessibility service / photo-video permissions | None declared in the audited main manifest; answer only if the final bundle or Console actually triggers a declaration, after inspecting it. | + +App content is where Google collects privacy, ads, access, rating and other +policy declarations. Complete every applicable "Needs attention" item before +sending the release for review. +[Prepare your app for review](https://support.google.com/googleplay/android-developer/answer/9859455?hl=en). + +### Content-rating worksheet + +Use a maintained contact email (`viwat.v@chula.ac.th`) and the questionnaire +category matching an ordinary utility/IDE, such as its other-app category if +offered. Answer the actual questions, which can change with earlier answers. + +| Factual question area | App facts to use | +| --- | --- | +| Violence, sexual content, profanity, horror, drugs, gambling in authored content | None in the reviewed bundled app/examples. | +| Real-money or simulated gambling, betting, prizes, purchases | No implemented feature. | +| User-to-user communication, chat, social network | No implemented in-app feature. BLE console is communication with the user's microcontroller. | +| Sharing current physical location with other users | No implemented feature; legacy Android BLE permission does not mean the app derives/shares location. | +| User-created/downloaded content | **Present**: users edit arbitrary Python and can import arbitrary public GitHub `.py` content. Disclose it wherever the question's scope includes this behavior. | +| Unrestricted web browsing | No general web browser; importer is restricted to public GitHub API requests, and bundled Blockly runs locally. | +| Content filtering of imported source | No promise that arbitrary public repository content is age-filtered or curated. The official examples default does not prevent choosing another public repository. | + +Retain the returned rating certificate and review whether the questionnaire's +downloaded-content questions introduce further requirements. No "Everyone," +"PEGI 3," "Teacher Approved" or similar label is asserted by this packet. + +## Build and technical handoff + +The signed candidate has been built. Use the +[artifact handoff](../testing/google-play/0.2.0-build-8.md) for its verified +source, filenames, SHA-256 values, signer and test results; the steps below +also document how to prepare a replacement if Console requires another code. + +Use the **production release App Bundle**, package `dev.pyble.pyble`. The +`.integrationtest` flavor, debug APKs and CI's temporary signing key are not +upload artifacts. The current pinned Flutter toolchain resolves minimum API +24 and target/compile API 36; verify these in the resulting bundle. + +Since 2026-08-31, Google requires API 36 or newer for new phone/tablet apps and +updates. Do not rely on the lower existing-app availability threshold. +[Target API requirements](https://support.google.com/googleplay/android-developer/answer/11926878?hl=en). + +1. Confirm version code `8` is unused and higher than all prior uploads. Update + source identity if another code is needed; do not label different bytes as + the same already distributed build. +2. Use the existing owner-controlled upload key and secure local signing + configuration. The release Gradle contract reads + `PYBLE_ANDROID_KEYSTORE_PATH`, `PYBLE_ANDROID_KEYSTORE_PASSWORD`, + `PYBLE_ANDROID_KEY_ALIAS` and `PYBLE_ANDROID_KEY_PASSWORD`. Keep their + values and the keystore outside Git and logs. Do not create a replacement + identity when the established key is needed. +3. Run the app checks in [`app/README.md`](../../app/README.md), the Android + integration gate and appropriate release validation; retain logs outside + Git. Run `tools/ci/no_leak.sh` before any later commit and use DCO sign-off. +4. Build from the approved source using the pinned toolchain and locked + dependencies: + + ```sh + cd app + flutter pub get --enforce-lockfile + flutter build appbundle --release --flavor production --target lib/main.dart + ``` + +5. Verify AAB signature, established upload certificate, package, version, + minimum/target SDK, native ABIs, merged permissions and bundletool + validation. Confirm the upload certificate against Console → App integrity; + it differs from Google's app-signing certificate used for delivered APKs. +6. Check both native ELF segment alignment and generated APK ZIP alignment for + 16 KB devices; run a compatible emulator/device smoke test. Flutter embeds + native libraries, so a Dart-only feature set is not an exemption. + [Android 16 KB compatibility](https://developer.android.com/guide/practices/page-sizes). +7. Perform physical Android BLE acceptance against the exact final bundle and + firmware, plus fresh-install and Play-upgrade checks. An upload-key-signed + local APK may not update a Google app-signing-key-installed copy; test the + store upgrade through Play rather than interpreting this expected signing + difference as an app regression. + +The earlier Android build-4 handoff records this upload-certificate SHA-256: + +```text +DC:A9:5C:50:60:CD:F4:93:6F:19:FD:8C:32:12:A8:71:C2:23:E2:BA:3F:B6:7B:62:26:17:9A:FF:DA:62:DD:57 +``` + +Historical source: +`build/google-play-0.1.0-build-4:docs/testing/google-play/0.1.0-build-4.md`. +It is a useful continuity check, not confirmation of the current Console +certificate or proof that the old bundle was published. + +Record the final filename, size, SHA-256, source commit/diff identity, signing +certificate, version name/code, SDKs, ABIs and test results in the ignored +handoff directory. Retain native debug symbols/mapping outputs if generated; +upload applicable symbols through Console to make crash reports actionable. + +## Visual assets + +Use the [asset guide](google-play-assets.md) for actual files, provenance and +remaining captures. Required listing materials include a 512×512 PNG icon, a +1024×500 JPEG or opaque PNG feature graphic, and at least two screenshots +across supported device types. Screenshots must be 320–3840 pixels per side, +with the longer dimension no more than twice the shorter. Google allows up to +eight per device type. Follow the separate tablet-specific and promotion +recommendations in the live Console. [Preview asset specifications](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). + +Show the actual Android app. A useful set covers editor/console, Files, +Blocks, GitHub review, Connect and privacy. Only publish captures with reviewed +state and privacy; do not use iPad screenshots, test-fake boards or UI goldens. +The asset guide identifies authentic historical tablet captures whose depicted +surfaces remain representative of the current UI. Preserve that provenance; +they are listing illustrations, not this candidate's hardware evidence. A +feature graphic may use the canonical brand artwork; it must not imply awards, +rank or a production release. Promotional video is optional. + +## Review and rollout checklist + +Perform the preparation below before the final upload/review decision: + +- [ ] Owner confirms final unused version code and candidate identity. +- [x] Exact signed production AAB, checksums, signature and technical checks + are recorded and tied to current source. +- [ ] Physical BLE smoke, fresh install, upgrade and Android 16/layout checks + are recorded; failures affecting core operation are fixed. +- [ ] Main listing, free price, category/tags and maintained contact completed. +- [x] Icon, feature graphic and ten phone/tablet screenshots validated and + reviewed; historical core-feature captures retain their original provenance. +- [x] Full privacy policy available in-app and at the live URL. +- [ ] Data safety's recommended categories, purposes and sharing choices + reviewed and attested, including conservative GitHub policy inferences. +- [ ] Owner confirms target ages and countries; relevant Families/BLE review + complete. +- [ ] All Console App content forms completed truthfully and IARC rating + certificate retained. +- [ ] Reviewer has an agreed workable path to a compatible physical board; + instructions reference the production app and no imaginary demo account. + +Then perform the Console handoff: + +1. Select the existing app and inspect App integrity, Policy status, developer + verification and the currently used version codes. +2. Open **Testing → Open testing**, configure countries, testers and feedback, + and create the named release. Add the exact approved production AAB. +3. Paste [`release-notes.txt`](google-play/en-US/release-notes.txt). Google + permits 500 Unicode characters per language, and the internal release name + is not user-facing. [Release preparation](https://support.google.com/googleplay/android-developer/answer/9859348?hl=en). +4. Inspect generated device support, App Bundle Explorer, integrity findings, + permission declarations, policy errors and pre-launch reports. Automated + crawling cannot establish BLE-board operation; attach retained hardware + evidence where review requests it. +5. Review the full change list and submit for review/rollout to **Open + testing**. Record the actual submission and publishing state; do not infer + approval from an accepted upload. Use managed publishing if available and + appropriate for the account's release flow. +6. After the release is actually available, copy and test the open-test opt-in + link using a non-internal tester account in a selected country. Verify that + the installed About screen shows the intended build. +7. Share the tester guide and verified link, then update the website's invited + internal-testing copy, QR destination, relevant website specification and + tests in a separate verified publication change. Current website wording + must not be switched before the open track is live. +8. Monitor Android vitals, pre-launch results, private Play feedback and the + support inbox. Apply the tester guide's exit criteria before expanding to + production. This packet does not designate version `1.0.0` or a production + release; retain the project's cross-platform release/parity requirements. + +## Remaining owner and Console information + +The following items require the owner or live Console state; preparation does +not fabricate their answers: + +- actual highest uploaded version code and registered upload certificate; +- physical release-candidate BLE and Play-delivered upgrade evidence; +- confirmed countries, target ages and any verification/account declarations; +- owner-attested Data safety form/export and generated IARC ratings; +- reviewer hardware arrangement; +- actual open-test opt-in URL, submission/review/availability status; +- screenshots of account-only settings and any support-case outcome. + +The source packet, metadata files and reproducible assets can be reviewed +before these account-only fields are finalized. Keep private Console records, +signing material, raw screenshots and builds in the ignored local publication +workspace. diff --git a/docs/publication/google-play-reviewer-guide.md b/docs/publication/google-play-reviewer-guide.md new file mode 100644 index 00000000..4538699a --- /dev/null +++ b/docs/publication/google-play-reviewer-guide.md @@ -0,0 +1,110 @@ +# Google Play reviewer access: PyBLE + +Prepared for the Android open-testing candidate `0.2.0 (8)` on 2026-09-10. +Verify the actual version in About. This guide describes the production app; +it does not describe an integration-test flavor or a simulated board. + +## App access form + +Choose the option for functionality requiring special access and create an +instruction named **BLE board access; no login**. Paste +[`app-access.txt`](google-play/en-US/app-access.txt) into the instructions +field. Leave credential fields unused when the Console permits it and explain +that the restriction is external hardware, not authentication. Do not invent +credentials. + +The app is connection-gated. About and the offline privacy information can be +reviewed before connecting, but the editor, Files, Blocks, Run and Console need +a real BLE connection. There is no shipped demo mode. Physical board access +remains a review dependency even though PyBLE has no login. + +Google requires information needed to review restricted functionality through +the app-content workflow. If the available App access fields cannot convey +the board arrangement, use Play Console support to agree a workable review +method. A recording can explain the flow but does not itself provide access to +the IDE. [Google app review preparation](https://support.google.com/googleplay/android-developer/answer/9859455?hl=en). + +## Board and device setup + +1. Use an Android phone or tablet with BLE and Android 7.0/API 24 or newer. + A tablet provides the intended roomy editing layout. Use the final artifact + device catalog to confirm actual compatibility. +2. Use a Raspberry Pi Pico 2 W, or a board matching an exact supported profile + at [pyble.dev/flash](https://pyble.dev/flash). Profile names are + `esp32-4mb`, `esp32-s3-n16r8`, `esp32-c3-4mb`, + `waveshare-esp32-s3-lcd-147b`, and `rpi-pico2-w` for the independently + qualified firmware `0.6.0`. Other firmware versions shown on the website + keep their own release status; do not relabel a beta as qualified. +3. Back up the board before flashing. Provisioning can erase its existing + workspace. Use a computer and data cable: supported desktop Chromium for + the ESP installer, or BOOTSEL/UF2 copy for Pico 2 W. Follow the exact + [setup tutorial](https://pyble.dev/learn/setup). +4. Power the prepared board and keep it near the Android device. Close other + clients connected to it. No sensor, LED wiring, display, Wi-Fi setup or + GitHub account is required for the core review below. +5. Enable Bluetooth. Open PyBLE and choose Connect. On Android 12 or newer, + allow Nearby devices when requested. Android 11 and earlier may require + the BLE scan location permission and system Location to be enabled; PyBLE + does not derive device location. +6. Choose the expected `PyBLE-` board in the app scan list. Wait until Ready + and verify the board identity and agent version. Do not pair it as a + keyboard or headset in Android settings. + +Hardware/setup contact: **viwat.v@chula.ac.th**. Before submission, the +maintainer must record how the reviewer will obtain and use compatible +hardware. No reviewer-hardware delivery or agreement has been recorded by +this packet. + +## Core review without electrical wiring + +1. Open About and check the installed app version/build and privacy policy. + Return to the connected workspace. +2. In Files, create a disposable folder named `play_review`, then enter it. + Choose New file, enter `hello.py`, and put the following in the editor: + + ```python + print("Hello from PyBLE!") + ``` + +3. Save, then choose Run. Console should show `Hello from PyBLE!`. Save alone + does not execute the program. Change the message, save and run once more. +4. To exercise input, save this as another file and run it. Enter `review` + through Console when the prompt appears: + + ```python + name = input("Name: ") + print("Hello", name) + ``` + +5. To exercise Stop, run the following bounded-output loop and press Stop + after a few lines. No GPIO access occurs: + + ```python + import time + + while True: + print("PyBLE review") + time.sleep(1) + ``` + +6. In Blocks, load a language-only bundled example, inspect its generated + Python, create an editable copy when prompted, then save it under the + disposable folder. Run only after review. No internet is needed. +7. In Files, make two disposable files. Select both, choose Delete selected, + inspect the confirmation and cancel once. Repeat and confirm. The named + files should be deleted; protected files and folders are excluded from + bulk selection. +8. With internet access enabled, open Import examples from GitHub. Keep the + official `https://github.com/PyBLE-dev/examples` repository and choose a + public branch. Browse `examples/portable/basics/hello_console`, select + `pyble_hello_console.py`, review the pinned commit and destination under + `/play_review`, and import. The file must not open or run automatically. + This optional step requires no GitHub login or token. +9. Disconnect and reconnect. Recheck identity and reopen the saved file from + Files. Rotate the screen and exercise large text. Ordinary BLE operations + should continue when internet is disabled. + +If the board is absent from the scan list, verify power, exact firmware, +Bluetooth permission, whether another client is connected, and that the +device supports BLE. Stock MicroPython does not provide the PyBLE service. +The [support page](https://pyble.dev/support) contains current setup help. diff --git a/docs/publication/google-play-tester-guide.md b/docs/publication/google-play-tester-guide.md new file mode 100644 index 00000000..de6a3879 --- /dev/null +++ b/docs/publication/google-play-tester-guide.md @@ -0,0 +1,130 @@ +# PyBLE Android open-testing guide + +Candidate: app `0.2.0 (8)`; confirm the version in About after installation. +This guide can be shared once the open-testing release is available. The +maintainer must insert the actual opt-in link copied from Play Console before +sending an invitation. No live open-test link is asserted here. + +## Join and prepare + +1. Open the maintainer's Google Play opt-in link while signed in with the same + Google account used by Play Store on the Android device. Join the test, + install PyBLE from Play and check About. Android 7.0/API 24 or newer and BLE + hardware are required; Play confirms device compatibility. +2. Existing internal testers must leave the internal test before joining the + open test. Internal-track membership overrides other test tracks. An + installed version with a higher version code can also prevent a downgrade; + report the installed About version if the expected build does not arrive. + [Google testing-track rules](https://support.google.com/googleplay/android-developer/answer/9845334?hl=en). +3. Prepare a compatible MicroPython board with the PyBLE agent using + [Setup](https://pyble.dev/learn/setup) and + [Firmware](https://pyble.dev/flash). Use the exact board profile. Back up + existing board files first; firmware installation can erase them. A + computer and data cable are needed for initial provisioning. +4. Power the board nearby, enable Bluetooth and grant Nearby devices when + Android requests it. On Android 11 or earlier, grant the legacy BLE scan + permission and enable system Location if scanning needs it. PyBLE does not + derive your location. Connect through the app, then verify Board info. +5. Use disposable programs in a child folder named `open_test`. Save work to + the board before leaving; the current editor buffer is not a durable local + project backup. Start with the wiring-free tests below. + +App and firmware versions are independent. Firmware `0.6.0` is the documented +qualified five-profile baseline. The app's open-testing status does not +qualify a newer firmware beta. Note the exact firmware version and status +used in every report. + +## What to test + +| Area | Exercise | Expected result | +| --- | --- | --- | +| Install and identity | Fresh install, update from the earlier Play version, launch, About | Correct candidate version/build; app starts without an account. | +| Privacy | Open About and privacy while disconnected and offline | Readable policy and canonical URL; return to previous screen works. | +| Permission recovery | Deny Nearby devices, then enable it in Android settings; test Bluetooth off/on | Clear recovery guidance, no crash or invisible permission loop. | +| Connect | Scan, connect to intended board, disconnect, reconnect | Correct Board ID and firmware; connected actions appear only after Ready. | +| Editor and console | Create `hello.py`, enter `print("Hello from PyBLE!")`, Save, Run | Exact saved program runs once; Console shows the message. | +| Editor sizing | Change font size; rotate; try external-keyboard Tab and large system text | Code, line numbers, selection and unsaved state remain consistent. | +| Input and Stop | Follow the wiring-free input and loop examples in the reviewer guide | Console input reaches the board; Stop interrupts active work. | +| Files | Create folder/file; open, rename, upload and delete disposable files | Correct board and folder; honest results and refreshed listing. | +| Multiple deletion | Select two files, cancel exact confirmation, then confirm | Cancel preserves both; confirmation deletes only named eligible files. | +| Session change | Disconnect while selection or confirmation is open, then reconnect | Stale selection and previous-board actions are cleared. | +| GitHub browse | Open importer, use official repository, choose branch, try advanced ref | Visible pinned commit; changing repository/ref clears old selection. | +| GitHub import | Import `pyble_hello_console.py` into `/open_test`; repeat for overwrite | Exact source/destination review; separate overwrite consent; no automatic open/run. | +| GitHub unavailable | Disconnect internet or observe actual request-limit response | Useful error/retry state; ordinary BLE editing and Files continue. | +| Blocks | Load bundled language-only example; preview, save and reopen companion | Offline workspace works; Python and matching Blocks record survive reopen. | +| GPIO blocks | Only on suitable hardware, enter numeric GPIO or valid named pin such as `LED` | Generated Python preserves the explicit choice; no inferred wiring. | +| Android layout | Phone and tablet; portrait/landscape; keyboard; TalkBack; 2× text | Actions remain visible or accessible through named overflow menus. | +| Android lifecycle | Background/resume, screen off/on, board restart, BLE interruption | UI accurately reflects connection and recovers through reconnect. | + +Detailed wiring-free examples are in the +[reviewer guide](google-play-reviewer-guide.md). The +[learning center](https://pyble.dev/learn) covers normal use. + +## Known boundaries + +- No board means no connected IDE workspace; there is no onboard Android + Python interpreter or demo board. +- GitHub import is limited to selected lowercase `.py` files from the current + public remote folder. It does not clone a repository, create the remote + folder tree, authenticate, upload your projects or automatically run code. +- Protected root names such as `pyble_*` need a regular child-folder + destination. Do not alter board-control files. +- Multi-delete is permanent and sequential. Earlier successful deletions can + remain after a later failure; there is no trash or automatic rollback. +- Blocks conversion accepts a bounded Python subset; not every Python program + can be converted back into editable blocks. +- Stop and soft reboot may require reconnect depending on firmware behavior. + Verify the board identity again after recovery. +- Treat BLE as a nearby personal-board workflow. PBLE/1 does not guarantee an + encrypted link; avoid credentials or sensitive information in code, console + input and advertising labels. + +## Send useful feedback + +Email **viwat.v@chula.ac.th**, use the +[support page](https://pyble.dev/support), or submit a +[public bug report](https://github.com/PyBLE-dev/PyBLE/issues/new?template=bug.yml). +Google Play also provides private testing feedback. Public issues are visible +to everyone; send sensitive details privately. + +Copy this report template: + +```text +Subject: [Android open test] Short description +PyBLE version and build from About: +Install path: fresh / updated / switched from internal testing +Android device model and OS version: +Android System WebView version, for Blocks problems: +Board model, exact firmware profile and agent version: +Connection state when the problem began: +Steps to reproduce: +1. +2. +3. +Expected result: +Actual result and exact error: +Reproducibility: every time / sometimes / once +GitHub public repository, ref and pinned commit, if relevant: +Affected disposable filename/folder, if relevant: +Rotation, text size, TalkBack or keyboard involved: +Screenshot or short recording, with private information removed: +``` + +Share board identifiers only when needed to diagnose a connection issue; omit +them from public screenshots when unnecessary. Do not send secrets, private +code or tokens. PyBLE does not require a GitHub token. + +## Maintainer exit criteria + +Retain dated results with exact app artifact hash, device/OS, board profile and +firmware version. Before broadening distribution, pass fresh-install and +upgrade smoke tests, core BLE Save/Run/Stop/Input, Files and Blocks on physical +Android hardware, and resolve reproducible crashes, data loss, wrong-board +writes, unusable permissions or inaccessible required controls. Collect +portrait/landscape and large-text evidence plus an Android 16/API 36 run. + +Automated tests and emulator screenshots do not establish radio or physical +board behavior. Existing firmware qualification and older app captures do not +prove this candidate's hardware result. If a defect appears after publication, +triage incoming reports, pause the affected track if needed, prepare a fix +with a higher version code and record its validation before the next release. diff --git a/docs/publication/google-play/en-US/app-access.txt b/docs/publication/google-play/en-US/app-access.txt new file mode 100644 index 00000000..a4c7b5a7 --- /dev/null +++ b/docs/publication/google-play/en-US/app-access.txt @@ -0,0 +1 @@ +No login, account, subscription or code is required. The IDE requires a nearby BLE board running the PyBLE agent; stock MicroPython is insufficient. Use Raspberry Pi Pico 2 W or an exact supported ESP32 profile. Provision from https://pyble.dev/flash using https://pyble.dev/learn/setup. Enable Bluetooth, allow Nearby devices and connect in PyBLE. Follow https://pyble.dev/learn/first-program. About and Privacy are available before connection. Hardware assistance: viwat.v@chula.ac.th. diff --git a/docs/publication/google-play/en-US/full-description.txt b/docs/publication/google-play/en-US/full-description.txt new file mode 100644 index 00000000..2a99b2ff --- /dev/null +++ b/docs/publication/google-play/en-US/full-description.txt @@ -0,0 +1,33 @@ +Code on your MicroPython board from an Android tablet with PyBLE, a free, open-source IDE that connects over Bluetooth Low Energy. + +A compatible board running the PyBLE agent is required to use the editor, files, Blocks and console. Python programs run on the connected board. PyBLE does not run Python on your Android device. + +EDIT, SAVE AND RUN +Write MicroPython with syntax highlighting, line numbers and adjustable editor text size. Save programs wirelessly, run them when you choose, stop execution and read live console output. Use console input with programs that request it. + +MANAGE BOARD FILES +Browse folders, create and open files, transfer code and manage the board workspace. Review exact filenames before deleting multiple selected files. Board-control files are protected. + +BUILD WITH BLOCKS +Use bundled offline Blocks examples, GPIO and NeoPixel blocks to create MicroPython programs. Preview generated Python, save the program with its Blocks companion and run it explicitly. Hardware blocks require the correct pins, wiring and board capabilities. + +IMPORT PUBLIC EXAMPLES +Optionally browse a public GitHub repository, choose a branch or enter an advanced ref, and review a pinned commit and exact destination before importing selected .py files. No GitHub account or token is needed. Downloaded code never opens or runs automatically. + +DESIGNED FOR TABLETS +Switch between portrait and landscape layouts and adjust code size for your workspace. The current app interface is in English. + +BEFORE YOU CONNECT +Install the matching PyBLE agent firmware on your board using the instructions at https://pyble.dev/flash. Initial provisioning needs a computer and a suitable cable. Back up the board first because firmware installation can erase its existing files. + +The documented firmware profiles include classic ESP32 with 4 MiB flash, ESP32-S3 N16R8, ESP32-C3 with 4 MiB flash, the exact Waveshare ESP32-S3-LCD-1.47B, and Raspberry Pi Pico 2 W. Match the exact hardware and profile. Stock MicroPython alone is insufficient; other boards need a compatible PBLE/1 agent. + +LOCAL WORK, OPTIONAL INTERNET +No PyBLE account, ads, subscriptions or in-app purchases. Editing, BLE, board files, Blocks and Run work without internet once a compatible board is connected. Public GitHub import is optional and needs internet access. Your programs travel directly to your selected board over BLE. See the privacy policy for Bluetooth and optional GitHub data handling. + +OPEN TESTING +This is a pre-release version. Keep backups and use disposable files when testing deletion. Report problems through https://pyble.dev/support or viwat.v@chula.ac.th. + +Setup and tutorials: https://pyble.dev/learn +Privacy policy: https://pyble.dev/privacy +Source code: https://github.com/PyBLE-dev/PyBLE diff --git a/docs/publication/google-play/en-US/release-notes.txt b/docs/publication/google-play/en-US/release-notes.txt new file mode 100644 index 00000000..ce667e3e --- /dev/null +++ b/docs/publication/google-play/en-US/release-notes.txt @@ -0,0 +1,9 @@ + +Open testing for PyBLE 0.2.0. +- Edit MicroPython with syntax highlighting, line numbers and adjustable text size. +- Review public GitHub examples at a pinned commit before importing. +- Select and delete multiple visible board files with exact confirmation. +- View board identity, firmware and an offline privacy policy. +- Use offline Blocks with numeric GPIOs or named pins. +A compatible board running the PyBLE agent is required. Initial firmware setup needs a computer. + diff --git a/docs/publication/google-play/en-US/short-description.txt b/docs/publication/google-play/en-US/short-description.txt new file mode 100644 index 00000000..8912e047 --- /dev/null +++ b/docs/publication/google-play/en-US/short-description.txt @@ -0,0 +1 @@ +Edit, run and manage MicroPython on compatible boards over Bluetooth Low Energy. diff --git a/docs/publication/google-play/en-US/tester-invitation.txt b/docs/publication/google-play/en-US/tester-invitation.txt new file mode 100644 index 00000000..c8ae60a9 --- /dev/null +++ b/docs/publication/google-play/en-US/tester-invitation.txt @@ -0,0 +1,16 @@ +Subject: Join the PyBLE Android open test + +PyBLE 0.2.0 is ready for Android open testing. PyBLE lets you edit and run MicroPython programs on a compatible board over Bluetooth Low Energy. + +Join using this verified Google Play opt-in link: +[PASTE THE LIVE OPEN-TEST OPT-IN LINK FROM PLAY CONSOLE] + +Use the same Google account on the opt-in page and your Android device's Play Store. Install or update PyBLE, then check that About shows 0.2.0 (8). If you are already an internal tester, leave the internal test before joining this open test. + +You need an Android device with Bluetooth Low Energy and a compatible board running the PyBLE agent. Stock MicroPython alone is insufficient. Initial board setup needs a computer and data cable; back up board files first. Setup: https://pyble.dev/learn/setup + +Please test Connect, Save, Run, Stop, Console, Files, offline Blocks and optional public GitHub example import. Start with the wiring-free first program: https://pyble.dev/learn/first-program + +Use disposable files for deletion tests. Send the app version/build, Android device and OS, board/firmware profile, steps to reproduce and expected versus actual result to viwat.v@chula.ac.th. Remove private code and secrets from screenshots. Support: https://pyble.dev/support + +PyBLE is free and requires no account. This is a pre-release test; keep backups of work you care about. diff --git a/docs/publication/google-play/en-US/title.txt b/docs/publication/google-play/en-US/title.txt new file mode 100644 index 00000000..29879106 --- /dev/null +++ b/docs/publication/google-play/en-US/title.txt @@ -0,0 +1 @@ +PyBLE: MicroPython over BLE diff --git a/docs/specifications/App/TDD.md b/docs/specifications/App/TDD.md index 2477dd64..95548199 100644 --- a/docs/specifications/App/TDD.md +++ b/docs/specifications/App/TDD.md @@ -180,7 +180,7 @@ abstract interface class BleSession { **Public interface (sketch):** internal codec types (`Frame`, `Fragmenter`, `Reassembler`, `Crc32`, `Correlator`, `FileTransfer`, `HelloNegotiator`) plus the public `PbleConnection implements Connection` ([§5.1](#51-the-connection-interface)). -**Key data structures / state:** the protocol constants mirror (D7); the pending-request table keyed by `ID` (1–255) with completers and timeouts; the inbound reassembly accumulator (FIRST/LAST/index-mod-64); negotiated `caps` from HELLO (chip, mpy_version, fs_root, max_file_size, put_window `W`, chunk_size, has_sd, free_mem); the active upload/download context ([§8.4](#84-file-transfer-state-machine)); the console `StreamController` and run-state `ValueNotifier`. +**Key data structures / state:** the protocol constants mirror (D7); the pending-request table keyed by `ID` (1–255) with completers and timeouts; the inbound reassembly accumulator (FIRST/LAST/index-mod-64); the exact negotiated `caps` from HELLO (`proto`, `agent`, `chip`, `mpy`, `fs_root`, `mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led`, `auto_run`, `device_id`, `label`); the active upload/download context with dynamic `FILE_PUT_BEGIN` capacity admission ([§8.4](#84-file-transfer-state-machine)); the console `StreamController` and run-state `ValueNotifier`. **Dependencies:** `lib/ble/` (byte transport) below; presents `Connection` (neutral types) above. Authored fresh, clean-room (FR-PBLE-15). @@ -506,6 +506,51 @@ UI reads down (watch providers) and writes up (call `Connection` methods / DAO m - **Test compatibility:** a test overriding `connectionProvider` directly with a `FakeConnection` still **wins** (the override replaces the derived body), so the existing widget/golden suite (`shell_harness.pumpShell`) is untouched. New connect-flow tests inject `FakeScanner` + a `ConnectionFactory` returning `FakeConnection` + a fake `BleReadinessSource` through a `PbleConnectionManager`, or override `connectionManagerProvider` with a fake manager — no fake radio / fake `BleLink` needed (FR-CONN-7, D5). - The `ConnectController` (`Notifier`) projecting the manager into `ConnectState` for the connect surface lives in `lib/connect` ([§4.8](#48-libconnect--scanconnect-flow-ui)); it is the one place with the connect-flow intents. +**Owned-link teardown clarification (2026-09-06, FR-CONNECT-7).** + +The same physical device may be represented by more than one asynchronous +factory result. A replacement for the same device ID must wait until an older +pending acquisition and its stale-result teardown have finished before invoking +another factory for that ID. Wrapper identity alone is not physical ownership. +Manager disposal must also stop its owned scanner, including a scan still waiting +for adapter readiness. A native link's connection-state subscription must be +released even when physical disconnect fails; the physical error remains visible. + +An already-submitted byte write may finish after retirement, but its continuation +must not submit another fragment. Pending request rejection must already have an +error observer while a write is awaiting completion. A complete on-time response +recorded before retirement remains authoritative; normal deadlines are unchanged. + +`PbleConnection.fromLink` owns the exact supplied `BleLink` and the +`BleByteTransport` it constructs. Its first `dispose()` retires the connection, +removes link/event listeners, aborts in-flight work, calls that link's +`disconnect()` exactly once, and releases the owned byte adapter and protocol +resources. Concurrent/repeated disposals await the same completion, including +the same failure. Cleanup must still attempt the remaining resources if one +step fails; preserve the first failure and its stack. A terminal HELLO failure +on this owned-link path must close it, and a late HELLO/event must never publish +`ready` or touch disposed notifiers. The direct `PbleConnection(engine: ...)` +constructor retains its existing protocol-engine lifetime semantics but does +not disconnect or dispose a caller-owned byte transport. + +The manager retires its old facade binding before awaiting teardown, and waits +for the old owned connection's close before opening a replacement. Every +connect/disconnect/dispose intent invalidates earlier pending connect results. +If an uncancellable factory returns a board after supersession, close that +exact stale board once without attaching it or changing the current selected +board, phase, or error. Failures from stale work remain with that operation; +they must not overwrite a newer session. Manager disposal prevents new work, +releases its streams/notifiers even on board-close failure, and is idempotent. +No new wire command, permission flow, board identity gate, reset, version bump, +or automatic connection retry is introduced by this clarification. + +Host regressions must exercise the real HELLO-ready `PbleConnection` over a +recording fake `BleLink`, including physical-disconnect call/state, shared +dispose completion, late HELLO, cleanup failure, external transport ownership, +replacement and late factory results after disconnect/disposal. Existing +connection/facade and wire-conformance tests remain required. Host tests alone +do not fill the physical ordinary-disconnect/reconnect rows on either tablet. + ### 6.3 Single active writer / serialization The app is one client to one board (SEC-2). `PbleConnection` serializes mutating operations (`runFile`/`runSource`/`putFile`/`delete`/`mkdir`/`rename`/`softReboot`) through an internal queue so commands cannot interleave; a `RUN` while running surfaces `EBUSY` as `EBusy` ([§14](#14-error-handling--mapping)) rather than corrupting state (FR-RUN-3). Read-only ops (`listDir`, `deviceInfo`) and `stop` are not blocked behind a long transfer beyond correctness. diff --git a/docs/specifications/App/specs.md b/docs/specifications/App/specs.md index c963e3cc..6716610b 100644 --- a/docs/specifications/App/specs.md +++ b/docs/specifications/App/specs.md @@ -1002,6 +1002,7 @@ implementation: - **FR-CONNECT-4** — The screen MUST list **saved boards** and reconnect them by remembered identifier; on link loss the app MUST auto-reattempt and in-flight transfers MUST resume per [protocol.md §5](../protocol.md#5-file-transfer-the-reliability-core). MUST (*source: PRD §8.1, §19.3, §13.1; verify: integration; story: A-03/A-22*) - **FR-CONNECT-5** — The flow MUST be scan → connect → use only: no pairing-code step, no account, no session-lock/heartbeat, and no board-identity gating (the non-goals of [PRD §4.3](../prd.md)). MUST (*source: PRD §8.1, §9.6, §19.3, §4.3; verify: widget; story: A-22*) - **FR-CONNECT-6** — The connect flow MUST refuse a board with an unsupported `proto_version` and prompt to update the firmware/app rather than failing silently. MUST (*source: PRD §18.3; verify: widget/integration; story: A-10/A-22*) +- **FR-CONNECT-7** — Ordinary Disconnect, replacement of the selected board, and disposal of an owned connection MUST close that connection's actual BLE link, not merely detach the UI. Teardown MUST be idempotent, abort pending protocol/transfer work and release owned subscriptions; a close failure MUST remain an error, not a successful physical-disconnect claim. A superseded or cancelled connection attempt MUST NOT publish its late result or disturb a newer session, and MUST close any link it eventually acquires. Direct engine-backed connections MUST NOT acquire ownership of externally supplied transports. MUST (*source: FR-BLE-4/5, FR-CONN-5/7, SEC-2; verify: actual protocol engine with recording fake links, manager races, real iPadOS/Android disconnect/reconnect HIL*) **Connected Board-info identity slice — FROZEN (`[docs]` 2026-08-22, additive to FR-CONNECT-2).** The testable acceptance criteria are: @@ -1026,7 +1027,8 @@ finished. - **FR-ABOUT-5** — The page MUST state the frozen privacy posture: no account, no telemetry by default, user work remains on the tablet/connected board unless explicitly exported, and user-started public GitHub import is the sole optional network workflow. MUST (*source: PRD §13.5, §14.2; verify: widget/locale; story: A-28/X-11*) - **FR-ABOUT-6** — Every display and semantics string MUST come from ARB; technical identifiers, versions, and URLs remain ASCII. MUST (*source: PRD §9.10, §13.7; verify: locale; story: X-12/X-11*) - **FR-ABOUT-7** — The route MUST use `SafeArea`, bounded scrollable content, semantic headings, labelled ≥48 dp actions, standard keyboard/Back navigation, and MUST NOT overflow at phone, iPad, or Android-tablet portrait/landscape sizes through 2× text scale and high contrast. MUST (*source: PRD §13.2, §13.6, §13.7, §19; verify: widget/golden; story: A-26/X-11*) -- **FR-ABOUT-8** — Opening About or Open-source licenses MUST preserve the current editor/Blocks document, selected surface, console state, and live board session. MUST (*source: PRD §13.1, §19; verify: widget/integration; story: X-11*) +- **FR-ABOUT-8** — Opening About, Open-source licenses, or Privacy policy MUST preserve the current editor/Blocks document, selected surface, console state, and live board session. MUST (*source: PRD §13.1, §19; verify: widget/integration; story: X-11*) +- **FR-ABOUT-9 — Offline privacy policy (`[docs]` 2026-09-10).** About's privacy card MUST expose a clearly labelled, ≥48 dp **Privacy policy** action opening a full offline route, available without a board or Internet connection. Its ARB-sourced policy MUST identify PyBLE and its maintainer, show the effective date, and reproduce the disclosures at `https://pyble.dev/privacy`: no account/advertising/analytics/telemetry/crash reporting/payment/cloud service; the sole optional public GitHub API workflow and request metadata; direct user-started board transfers; the absence of guaranteed BLE pairing/encryption; locally processed board discovery data and legacy Android location permission; publicly advertised board labels; distinct app, board, export, and operating-system-backup retention/deletion; third-party components/platform services; website infrastructure handling; policy changes; and the current verified privacy contact `viwat.v@chula.ac.th`. The page MUST display the canonical policy URL as selectable text, perform no network request or board verb, and inherit FR-ABOUT-7's responsive/accessibility requirements. Back MUST return to About and the prior IDE session unchanged. A future policy change MUST update the public and bundled disclosures together. MUST (*source: [website §5](../website.md#5-privacy-contract), [Google Play User Data policy](https://support.google.com/googleplay/android-developer/answer/10144311?hl=en); verify: widget/locale/integration; story: X-11*) ## 5. Non-Functional Requirements diff --git a/docs/specifications/firmware.md b/docs/specifications/firmware.md index f6334636..7f9fcbbb 100644 --- a/docs/specifications/firmware.md +++ b/docs/specifications/firmware.md @@ -1,6 +1,6 @@ # PyBLE — Agent Firmware -Status: **DRAFT** · Last updated: 2026-08-20 +Status: **DRAFT** · Last updated: 2026-09-02 The PyBLE agent is small board-side firmware that turns a compatible MicroPython target into a PyBLE-speaking board: it advertises the BLE service, @@ -97,41 +97,44 @@ exactly `esp32-4mb`, `esp32-s3-n16r8`, and `waveshare-esp32-s3-lcd-147b`; its source identity and retained evidence remain immutable history. -The v0.6.0 qualified-release contract now freezes one atomic five-profile -order: `esp32-4mb`, `esp32-s3-n16r8`, +The qualified public v0.6.0 release contains one atomic five-profile order: +`esp32-4mb`, `esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, and `rpi-pico2-w` ([ADR-0033](../decisions/0033-qualify-v060-as-five-profile-heterogeneous-release.md)). The first four use profile-scoped ESP Web Serial merged images; Pico uses a -browser-verified UF2 download followed by manual BOOTSEL copy. This is a -qualification target, not a claim that any v0.6.0 gate has passed. Fresh -two-clean-build reproducibility, license audit, resource policy, exact-byte -HIL, both-platform app HIL, install/recovery, and copy-on-write finalization -remain required for all five profiles before activation. - -ADR-0038 replaces the unpublished local candidate tagged at `719b211…` in -place: no origin tag, GitHub Release, or canonical v0.6.0 website release ever -existed, so the first public version remains `0.6.0`. The predecessor source -era ends at inclusive commit `5620f2f…`; strict descendants use the +browser-verified UF2 download followed by manual BOOTSEL copy. All five exact +v0.6.0 rows completed their two-clean-build reproducibility, license, resource, +exact-byte HIL, both-platform app-HIL, install/recovery, and copy-on-write +finalization gates. The source-selected v0.6.1 tree retains the same profile +order but is a new exact-byte candidate: every one of those gates requires +fresh v0.6.1 evidence before its bytes can be qualified or activated. + +ADR-0038 governed replacement of the unpublished local candidate tagged at +`719b211…`: at that decision point no origin tag, GitHub Release, or canonical +v0.6.0 website release existed, so the first public version remained `0.6.0`. +The predecessor source era ends at inclusive commit `5620f2f…`; strict +descendants use the ADR-0037 fixed-SLO contract, and strict descendants of the second boundary `7d85328…` use the ADR-0039 second-replacement contract, which additionally fixes the Waveshare largest-block heap floor at `98304` under `floor-min-1024-waveshare-block-98304-v2` and permits byte-identity-conditioned -carry-forward of already-passed physical evidence (ADR-0039 item 6). All old candidate bytes and HIL are invalid for -the replacement, while superseded metadata and immutable baseline evidence -remain retained. After source/docs/RED/GREEN, build, reproducibility, license, -source, and audit gates pass, the local tag may be replaced so it peels to -candidate `HEAD`; audited candidate creation then precedes fresh HIL and -finalization. Push, publication, and activation remain forbidden until those -later gates pass. - -The ESP32-C3-MINI-1-N4 v0.4/4 MiB/no-PSRAM reference and its still-pending -C3-G0…C3-G6 gates remain frozen in -[firmware/ports/esp32-c3-4mb.md](firmware/ports/esp32-c3-4mb.md). C3 enters -release metadata and selection only after those gates and the common v0.6.0 -matrix pass. ESP Web Tools detects the chip family but cannot by that fact -alone prove the required flash/PSRAM topology or distinguish the two S3 -images. The full compatibility, heterogeneous-artifact, historical -public-beta, and candidate-gate contracts are frozen in +carry-forward of already-passed physical evidence (ADR-0039 item 6). All old +candidate bytes and HIL were invalid for that replacement, while superseded +metadata and immutable baseline evidence remain retained. The replacement +completed its source/docs/RED/GREEN, build, reproducibility, license, source, +audit, fresh-HIL, and finalization gates and became the qualified public v0.6.0 +release. That completed evidence remains historical and cannot qualify the +source-selected v0.6.1 tree. + +The ESP32-C3-MINI-1-N4 v0.4/4 MiB/no-PSRAM reference and its C3-G0…C3-G6 gate +definitions remain frozen in +[firmware/ports/esp32-c3-4mb.md](firmware/ports/esp32-c3-4mb.md). Those gates +passed for the exact qualified v0.6.0 bytes; the source-selected v0.6.1 bytes +must pass fresh C3-G0…C3-G6 and common five-profile gates. ESP Web Tools detects +the chip family but cannot by that fact alone prove the required flash/PSRAM +topology or distinguish the two S3 images. The full compatibility, +heterogeneous-artifact, historical public-beta, and candidate-gate contracts +are frozen in [firmware/browser-flashing.md](firmware/browser-flashing.md). The Waveshare ESP32-S3-LCD-1.47B uses its own @@ -226,14 +229,21 @@ contract, and HIL matrix live in matrix remains `esp32`, lean `esp32-s3`, exact-board `waveshare-esp32-s3-lcd-147b`, and `esp32-c3`). `versions.lock` gains `[targets_rp2]` (PyBLE - target → upstream rp2 board) and `[arm_gnu_toolchain]` (pinned ARM GNU + target → upstream rp2 board), `[arm_gnu_toolchain]` (pinned ARM GNU release + SHA-256; the build verifies the compiler version and fails cleanly - on mismatch — never a silent substitution, BLD-4 equivalent). pico-sdk, + on mismatch — never a silent substitution, BLD-4 equivalent), and + `[picotool]` (official macOS package, archive/extracted-file identities, + exact version line, source/distribution commits, and package-config path). + The RP2 build supplies the verified package through explicit + `picotool_DIR`, disables FetchContent fallback, and rejects an ambient PATH, + Homebrew, CMake-registry, or SDK-downloaded picotool. pico-sdk, BTstack, cyw43-driver etc. are pinned transitively as `lib/` submodules of the one `[micropython]` commit — no new external pin. Artifacts: `firmware.uf2` (primary, BOOTSEL/picotool-flashable), `firmware.elf`, - `firmware.bin`, provenance JSON (`port: "rp2"`), with a hard image-size gate - of 1,572,864 bytes. + `firmware.bin`, `firmware.elf.map`, provenance JSON (`port: "rp2"`), with a + hard image-size gate of 1,572,864 bytes. Pull requests run this as a real + isolated `macos-15` arm64 build and retain all five outputs; a plan-only or + host-model check cannot satisfy the fifth-profile build gate. - The first source identity that combines this RP2 port with the four existing ESP build variants is agent version **0.6.0**. Version `0.5.1` remains the earlier Waveshare/ESP source candidate and MUST NOT be retagged with different @@ -254,6 +264,14 @@ The `rpi-pico2-w` release audit MUST distinguish a tool or file being used by the build from bytes being incorporated into the installable firmware. These terms are normative: +Firmware v0.6.1 applies that same distinction to its pinned picotool package. +Its additional private `build-tools` evidence role recursively accounts for +the official archive and its composite executable plus bundled libusb, while +excluding both host tools from the firmware's redistributed notice. The +version-routed schema, exact owners, and provenance rules are frozen in +[browser-flashing.md §6](firmware/browser-flashing.md#6-licensing-and-release-notes). +Firmware v0.6.0 retains its exact seven-role audit and bytes. + - A **build tool** is an executable which transforms or links inputs, including the GCC drivers and their resolved GCC/binutils helpers. It is part of the reproducibility and Eligible Compilation closure, but is not thereby a @@ -426,14 +444,31 @@ BIN, and UF2 digests. An absent recipe, extra Target Code object, changed tool or helper, unknown flag path, unmapped member/header, or source/archive mismatch MUST fail closed before a public candidate can be assembled. +The RP2 build-driver environment proof MUST parse the one marked `unset` +command independently of later CMake setup. Its one-name-per-line grammar +accepts case-sensitive shell identifiers, including `picotool_DIR`, requires +the exact continuation/terminal structure, rejects duplicate names, and still +requires every GCC resolution and launcher override to be scrubbed. For +v0.6.1 and later, all pinned-picotool discovery controls MUST also be scrubbed; +the complete intervening assignment/export block before the first retained +`mpy-cross` build MUST equal the reviewed offline package configuration. It +sets the verified `PICOTOOL_PACKAGE_DIR`, typed +`FETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON`, disabled package registries and +disabled forced fetching. Missing or additional commands, changed values, +substitutions, or incomplete exports MUST fail; the auditor MUST NOT execute +the shell or ignore an unknown intervening block. Earlier source versions +retain the scrub-only form without this later package setup. This parser +correction does not change build provenance, candidate-source identity, +Eligible Compilation evidence, or qualification-root requirements. + ## 7. Footprint budget (provisional, per target) The measurement method is frozen in [firmware/specs.md §5.3](firmware/specs.md#53-footprint-gates-nfr-fp); the v0.4.2 two-profile policy and evidence remain immutable history. The exact-board split invalidates any pre-split v0.5 baseline for current-source -qualification. The unfinished v0.5.1 candidate evidence cannot qualify the -source-selected v0.6.0 tree, which requires a fresh controlled five-profile +qualification. The qualified v0.6.0 evidence cannot qualify the +source-selected v0.6.1 tree, which requires a fresh controlled five-profile refresh defined there. No current-source numeric qualification is claimed until the retained baseline, schema-3 policy, and final-candidate records exist. @@ -441,11 +476,11 @@ The scope is profile-specific: | Profile | Current numeric status | Release effect | |---|---|---| -| `esp32-4mb` | v0.4.2 browser install/recovery passed; refresh the current-source baseline and verify the final candidate | Required before any v0.6.0-derived candidate qualification and installer activation | -| `esp32-s3-n16r8` | v0.4.2 browser install/recovery passed; measure the lean N16R8 bytes/runtime independently, derive thresholds, then verify the final candidate | Required before any v0.6.0-derived candidate qualification and installer activation | -| `waveshare-esp32-s3-lcd-147b` | No public exact-byte qualification; measure the exact-board bytes/runtime independently, derive thresholds, then verify the final candidate and display gate | Required before any v0.6.0-derived candidate qualification and installer activation | -| `esp32-c3-4mb` | Engineering contract frozen; all observations, threshold, and HIL rows pending ([derived contract](firmware/ports/esp32-c3-4mb.md)) | Blocks the atomic v0.6.0 qualified release until C3-G0…C3-G6 and the common final-candidate row pass | -| `rpi-pico2-w` | GP2, the RP2 resource row, both-platform app HIL, verified-UF2 install, and BOOTSEL recovery all pending ([derived contract](firmware/ports/rpi-pico2-w.md)) | Blocks the atomic v0.6.0 qualified release until GP2 and the common final-candidate row pass | +| `esp32-4mb` | Qualified for v0.6.0; refresh the current-source baseline and verify the v0.6.1 final candidate | Required before any v0.6.1 candidate qualification and installer activation | +| `esp32-s3-n16r8` | Qualified for v0.6.0; measure the current lean N16R8 bytes/runtime independently, derive thresholds, then verify the v0.6.1 final candidate | Required before any v0.6.1 candidate qualification and installer activation | +| `waveshare-esp32-s3-lcd-147b` | Qualified for v0.6.0; measure the exact-board bytes/runtime independently, then verify the v0.6.1 final candidate and display gate | Required before any v0.6.1 candidate qualification and installer activation | +| `esp32-c3-4mb` | Qualified for v0.6.0; refresh C3-G0…C3-G6 and the common final-candidate row against the v0.6.1 source identity ([derived contract](firmware/ports/esp32-c3-4mb.md)) | Blocks atomic v0.6.1 qualification until the fresh exact-profile evidence passes | +| `rpi-pico2-w` | Qualified for v0.6.0; refresh GP2, resource, app-HIL, verified-UF2, and BOOTSEL evidence for v0.6.1 ([derived contract](firmware/ports/rpi-pico2-w.md)) | Blocks atomic v0.6.1 qualification until the fresh exact-profile evidence passes | The enforced metrics are: diff --git a/docs/specifications/firmware/TDD.md b/docs/specifications/firmware/TDD.md index 5eda45c8..0d4d7292 100644 --- a/docs/specifications/firmware/TDD.md +++ b/docs/specifications/firmware/TDD.md @@ -1,6 +1,6 @@ # PyBLE Agent Firmware — Technical Design Document (TDD) -Status: **DRAFT** · Owner: project maintainer · Last updated: 2026-08-20 +Status: **DRAFT** · Owner: project maintainer · Last updated: 2026-09-02 > **Frozen at G0 (2026-07-01, `[docs]`):** the source-tree layout ([§10.5](#105-source-layout-frozen)), which realizes the frozen NFR-MAINT-2 six-module design and the [specs.md](specs.md) §5.1/§5.6/§6/§8 freeze. Design narrative elsewhere in this doc remains DRAFT and is pinned per-story by its `[red]` tests ([§4](#4-module-design)). > @@ -13,8 +13,14 @@ Status: **DRAFT** · Owner: project maintainer · Last updated: 2026-08-20 > release schema 4, and HIL V5. Immutable v0.4.2 replay retains its exact > two-profile release/HIL V2 contract. ADR-0037 fixes replacement-v0.6.0 > profile reset and product-wide transfer SLOs; ADR-0038 routes predecessor and -> replacement v0.6.0 derivations by source ancestry. Every replacement result -> remains pending. +> replacement v0.6.0 derivations by source ancestry. The replacement v0.6.0 +> result is the qualified five-profile baseline; every later source-selected +> candidate, including v0.6.1, begins with fresh pending evidence. +> +> **Frozen v0.6.1 hardening design (2026-09-02, `[docs]`):** +> [§2.11](#211-v061-hardening-architecture) fixes the implementation seams, +> transaction cuts, and host/HIL proof boundary for the approved v0.6.1 +> roadmap. It adds no PBLE/1 wire surface. > > **Frozen optional ST7789 user-runtime design (2026-08-01, `[docs]`, > [ADR-0023](../../decisions/0023-explicit-st7789-user-runtime.md)):** @@ -236,6 +242,101 @@ workers are created only after the frozen path is restored. — *(implements ADR-0024; satisfies FR-SPLASH-1…9 and preserves FR-BOOT-4/6.)* +### 2.11 v0.6.1 hardening architecture + +**Decision (D11, 2026-09-02):** v0.6.1 changes existing semantics without +changing PBLE/1 bytes. Portable Python and native C MUST consume the same HELLO +and frame-guard vectors. The production native CRC/frame/HELLO decisions live +in a dependency-free `pble_wire.c/.h` unit compiled both into firmware and by +the host test harness; a source-text proxy or Python twin is not evidence for +native behavior. Structural decode is separate from CRC, direction, version, +and session admission so the protocol §3 precedence is executable rather than +an accidental call order. + +The protocol layer owns one `UNNEGOTIATED`/`NEGOTIATED(v1)` state keyed by the +full BLE session token and VM epoch. HELLO parsing is bounded and allocation- +free on native C. A response-pool item records whether successful completion +commits HELLO; only final-fragment local Notify acceptance may publish that +state. The portable TX completion callback is the equivalent cut. Disconnect +and VM reset clear it before new RX admission. Invalid repeated HELLO cannot +clear a valid state. + +The BLE reassembler adds `started_ms`, `active`, `expected_index`, and +`discard_tail` plus an exact-session `violation_count`. Its clock is injectable +in portable tests and uses the port monotonic clock in production. Deadline +checks use wrap-safe elapsed arithmetic and the inclusive 5000 ms boundary. +All violation sites call one reducer; its eighth result closes admission and +routes to the existing bounded termination mechanism without sending the +triggering error. RX storage remains statically/boundedly allocated. + +Every runner execution saves the worker's globals/locals, allocates a fresh +dictionary containing only `__name__ = "__main__"`, installs it as both globals +and locals, then restores the saved dictionaries in both normal and NLR error +paths. Console stdin has a separate active predicate under its existing ring +lock and three operations: `begin` clears+activates, `end` clears+deactivates, +and `clear` preserves activity. RUN/autorun admission, accepted control, +terminal, disconnect, and VM reset call only the operation assigned by +protocol §6; failed response admission calls none. + +The filesystem bridge treats `.pbltmp` as invisible control-plane state. One +resume helper returns explicit `{status, offset, crc}` and never maps malformed +scratch silently to a fresh upload. Namespace mutations resolve paths before +the active-PUT gate. Admission reads `statvfs`, uses checked 64-bit block +arithmetic and a constant 65,536-byte reserve, and occurs before scratch +creation/growth. The active state records a latched boundary/write status so a +chunk crossing `total_size` cannot partially write. The native worker retains +its existing session/VFS bracketing; the portable implementation applies the +same semantic reducer. Both implementations add a filesystem-local monotonic +transfer generation. Queue insertion snapshots it; disconnect/VM invalidation +advances it under the same transfer-state synchronization; and publication of +BEGIN state, DATA/END ownership checks, successor stale-state reclamation, and +final state clearing all compare it in the same critical cut. Portable PUT +BEGIN/DATA/END perform every VFS effect in the supervisor-polled bounded +mailbox, never in the BTstack RX callback; its depth is at least advertised +window plus two. Native request storage accepts the full 260-byte legal +`FILE_RENAME` body. Both reject a VFS read count outside `0..requested` before +using the buffer; portable binary reads additionally require an exact `bytes` +result. A short or invalid read before response admission returns `EIO`. After +a successful `FILE_GET_BEGIN` response, it cancels the stream without a +completion event, so partial bytes can never be blessed by `FILE_GET_END`. +Both validate every path as strict scalar UTF-8 at the jail chokepoint and +recognize a regular scratch only when the complete file-type field equals a +regular file. + +Configuration is candidate-first. ESP label, autorun, and Identify paths check +every NVS set/erase/commit and change RAM/GPIO/advertising only after commit. +Identify uses one authoritative raw four-byte `id_cfg` blob +`[1, enabled, gpio, active_level]`; an absent blob alone permits validated +legacy-pair loading, while an invalid present blob fails closed. Pico keeps +`/pyble_conf.json` but v1 is exactly the key set +`{version,label,autorun,crc32}`, at most 256 encoded bytes. Its IEEE CRC-32 is +over `b"PBLECFG" + b"\x01" + bytes((autorun, label_len)) + label_utf8`. +It writes `/.pyble_conf.json.pbltmp`, flushes/closes, calls `os.sync()`, and +atomically renames; rename success is the only commit cut. A valid primary wins +and stale temp is removed best-effort. Missing primary means defaults/no fault; +invalid primary means defaults plus a bounded RAM fault marker and is never +overwritten or replaced from temp. The exact validated legacy two-key object is +accepted and migrates only on the next successful setting change. Once a +corrupt/read/persistence fault is observed, its bounded RAM marker remains +latched until a successful repair of that same configuration domain; an absent +first-boot key does not create or clear a fault. Internal read-only getters make +the marker testable without adding a PBLE/1 field. + +The Pico boot overlay isolates mount choice in a host-testable helper. A normal +mount performs no scan. Only an `OSError` mount-constructor failure enters a +complete, read-only block scan with one reused buffer; format occurs once only +when all bytes are conclusively `0xFF`. Any nonblank byte, bad geometry/read, +allocation failure, unexpected exception, or post-format remount failure makes +zero further writes, emits one bounded USB recovery message, and skips agent +and autorun startup. One frozen `pyble_workspace` helper is the shared authority +for all five overlays. ESP overlays use that helper's explicit LFS2 constructor +and full-device erased scan rather than upstream `inisetup`'s first-block +heuristic. Geometry must provide exact positive integer program/block/count +values, at least two blocks, `block_size >= 128`, and a derived cache size that +is divisible by both 32 and the program size and itself divides the block size; +otherwise provisioning fails before the first media read or write. It never +changes upstream MicroPython. + ## 3. Architecture overview ### 3.1 The four layers @@ -335,7 +436,7 @@ class BleLink: def on_disconnect(self, cb) -> None ``` -**Key data structures / state:** the GATT table (Service/RX/TX/INFO UUIDs from the [protocol.md §2](../protocol.md#2-ble-transport-gatt) constants mirror); a single **reassembly buffer** (static, sized `max_message`, see [§7.3](#73-buffer-sizing)); a fragment-index tracker (`FIRST`/`LAST`/`index mod 64`); negotiated MTU; connection handle plus monotonic boot-local connection generation and `CLOSED`/`OPEN`/`CLOSING`/`CLEANING`/`RESTARTING` state; logical TX owner plus mutex-protected stream generation; a pre-created generic-response callout; a pre-created task-dispatched failed-session watchdog; the **advertised name** — the device label when set, else `PyBLE-` + `device_id` (the last two BLE-MAC bytes in uppercase hex), used for the name only, never for access control ([§4.8](#48-device-config-store--label--identify-led-nvs), CON-7/SEC-7/SEC-11). +**Key data structures / state:** the GATT table (Service/RX/TX/INFO UUIDs from the [protocol.md §2](../protocol.md#2-ble-transport-gatt) constants mirror); a single **reassembly buffer** (static, sized `max_message`, see [§7.3](#73-buffer-sizing)); a fragment-index tracker (`FIRST`/`LAST`/`index mod 64`); negotiated MTU; connection handle plus monotonic boot-local connection generation, an exact-session terminal-admission latch/start time, and `CLOSED`/`OPEN`/`CLOSING`/`CLEANING`/`RESTARTING` state; logical TX owner plus mutex-protected stream generation; a pre-created generic-response callout; a pre-created task-dispatched failed-session watchdog; the **advertised name** — the device label when set, else `PyBLE-` + `device_id` (the last two BLE-MAC bytes in uppercase hex), used for the name only, never for access control ([§4.8](#48-device-config-store--label--identify-led-nvs), CON-7/SEC-7/SEC-11). **VM-safe RX ownership:** the RX GATT callback calls the same non-blocking lifecycle-entry seam used by other host callbacks before it reads the fragment @@ -367,23 +468,36 @@ ticket before normal re-advertising. A callback already queued after stop still revalidates inactive/token state and emits nothing. **Bounded failed-session teardown:** response-capacity refusal and publication -expiry enter one shared exact-token state machine. Under the session critical -section, `OPEN → CLOSING` happens first and repeated requests against the same -token are no-ops. Public snapshot/live-for-admission checks return false for -`CLOSING`; GATT dispatch, ticket reserve/validate, all response/event/control -TX, and specialized `RUN`/`STOP`/`SOFT_REBOOT` paths therefore admit no later -work or byte. This is a logical non-live gate only: retained tickets, work, and -the token are physically cancelled/invalidated later, after exact cleanup has -successfully stopped any required watchdog. Each TX attempt carries the -originating full token to the sole Notify exit; taking a fresh generation -snapshot from a reused numeric handle is not an ownership check. The physical -recursive TX mutex is acquired before the session critical section wherever -both are needed. The sole Notify exit keeps that mutex across its final exact -token check and `ble_gatts_notify_custom`; connect/open, `OPEN → CLOSING`, and -disconnect/reset cleanup claims use the same serialization. Thus a lifecycle -transition cannot land between the final check and Notify. Lifecycle cleanup -uses an internal exact-token match that can still recognize `CLOSING`; it does -not make the token live again. +expiry enter one shared exact-token state machine. Required termination first +claims an exact-session terminal-admission latch and records the start of the +one absolute termination deadline under the session critical section; repeated +requests against that same token are no-ops. Public snapshot/live-for-admission +checks treat the latch as non-live immediately, so GATT dispatch, ticket +reserve/validate, all response/event/control TX, and specialized +`RUN`/`STOP`/`SOFT_REBOOT` paths admit no later work or byte. The host then +acquires the physical recursive TX mutex using only the deadline residual. A +Notify that already passed its final exact-token check may finish during this +bounded drain, but no new admission may begin. With TX ownership, the host +revalidates the latch and, under the normal TX-then-session lock order, claims +`OPEN → CLOSING`; failure to acquire the mutex by the deadline instead +atomically claims `RESTARTING` and restarts. `CLOSING` remains a logical +non-live gate only: retained tickets, work, and the token are physically +cancelled/invalidated later, after exact cleanup has successfully stopped any +required watchdog. Each TX attempt carries the originating full token to the +sole Notify exit; taking a fresh generation snapshot from a reused numeric +handle is not an ownership check. That exit keeps the TX mutex across its final +exact-token check and `ble_gatts_notify_custom`; connect/open, the latched +`OPEN → CLOSING` claim, and disconnect/reset cleanup use the same +serialization. Thus a lifecycle transition cannot land between the final check +and Notify. Lifecycle cleanup uses an internal exact-token match that can still +recognize `CLOSING`; it does not make the token live again. + +The accepted-soft-reboot global closing gate is evaluated after structural, +CRC, direction/ID, and version validation but before HELLO grammar, negotiation, +opcode dispatch, or violation accounting. It maps every response-bearing CMD +to `EBUSY` and drops no-response CMDs, even if a numeric handle is re-presented +during the delivery grace. This keeps reconnects and duplicate reboot requests +from bypassing the global VM boundary. Cold native initialization calls the reducer initializer exactly once per chip boot and pre-creates exactly one `ESP_TIMER_TASK` one-shot before NimBLE can @@ -397,13 +511,16 @@ attempt while the reducer is not `CLOSED`, including numeric-handle reuse while an old token is `CLOSING`, `CLEANING`, or `RESTARTING`, invokes public non-returning `esp_restart()` instead of overwriting the retained token. -On `OPEN → CLOSING`, the host reads `esp_timer_get_time()` once and stores one -absolute deadline 2500 ms ahead. Its initial `esp_timer_start_once` interval is -the positive residual `deadline - esp_timer_get_time()`, never a new 2500 ms; -an already-reached deadline claims `RESTARTING` without arming or attempting -GAP. Reducer begin, residual calculation, physical arm, and reducer arm -acknowledgement are one uninterrupted session-critical transaction, so the -task callback cannot consume the one-shot before logical arm acknowledgement. +The terminal-admission latch reads `esp_timer_get_time()` once and records the +start of the one absolute termination deadline before the TX drain. The later +`OPEN → CLOSING` reducer step receives that same start and stores its deadline +2500 ms ahead. Its initial `esp_timer_start_once` interval is the positive +residual `deadline - esp_timer_get_time()`, never a new 2500 ms; a deadline +reached before TX ownership or arm atomically claims `RESTARTING` without +arming or attempting GAP. Reducer begin, immutable watchdog-ticket capture, +residual calculation, physical arm, and reducer arm acknowledgement are one +uninterrupted session-critical transaction under TX ownership, so the task +callback cannot consume the one-shot before logical arm acknowledgement. After that transaction releases, exactly one `ble_gap_terminate` call occurs. An arm failure atomically claims `RESTARTING` and calls public non-returning `esp_restart()` without a GAP attempt. This order matters: the pinned HCI @@ -670,7 +787,12 @@ no-op. A separate resolved snapshot followed by a later terminal lock acquisition is forbidden because it recreates the race. Reservation-time cleanup may clear only an older, resolved idle-STOP flag/pending exception and never the unresolved gate. The same rules cover command RUN and the direct -auto-run reservation. +auto-run reservation. Terminal classification also deactivates and clears +stdin before it releases the single-run reservation, under that same runner- +domain cut. A successor reservation therefore cannot race with delayed +predecessor cleanup, and the predecessor event uses a terminal-state value +captured before admission can reopen. The stdin clear path mutates only +preallocated ring state and remains allocation-free. Once admitted, the worker authors `RUN_STATE(running)` (FR-RUN-1). Each new `RUN_STATE` event atomically snapshots the then-current live full session token @@ -727,7 +849,14 @@ class FsBridge: def _resolve(self, path) -> str # jail enforcement (FR-FS-10/11) ``` -**Key data structures / state:** active-upload context (`path`, `tmp_path`, `total_size`, `expected_crc`, `ack_offset`, running CRC accumulator, window bookkeeping); the static file I/O buffer; `fs_root` constant. Only **one** transfer is active at a time (serialized by the single-writer lock). +**Key data structures / state:** active-upload context (`path`, `tmp_path`, +`total_size`, `expected_crc`, `ack_offset`, running CRC accumulator, window +bookkeeping); active-download ownership; the static file I/O buffer; `fs_root` +constant. Only **one** transfer is active at a time (serialized by the +single-writer lock). After payload parsing and jail resolution, every +DELETE/MKDIR/RENAME is rejected with `EBUSY` while either transfer kind is +active, even for an unrelated valid path; read-only FILE_LIST/FILE_STAT remain +available. **Jail design ([§9.3](#93-path-jail-enforcement)):** every path is normalized and verified to resolve inside `fs_root`; `..` traversal or absolute escape → `EACCES` (FR-FS-10). Layer-2/Layer-3 paths are a forbidden set → `EACCES` (FR-FS-11, SEC-4, CON-10). Only `.py`/data artifacts accepted; `.mpy`/`.pyc` rejected (FR-FS-12, CON-3). @@ -773,6 +902,26 @@ indivisible operation validly started before invalidation may finish, but the mandatory post-operation check suppresses every later VFS operation, response, or dependent event. +The active transfer record includes the filesystem-local generation described +by D11. The queue item snapshots that generation under the transfer mutex. +Disconnect only advances the generation and invalidates ownership; it never +directly clears worker-owned PUT fields. A worker may publish BEGIN state only +after an exact session/generation post-check in the same critical section as +all field writes. DATA/END require exact ownership, and a successor may close +and reclaim only a record whose stored generation is stale. This prevents a +paused predecessor from clearing or adopting successor state. + +The native worker marks an accepted GET active under that same transfer mutex +before committing its successful response and clears the exact generation on +every stream exit. Because that worker remains occupied until the stream ends, +a concurrently admitted namespace mutation snapshots the active-GET bit in its +mailbox item. Once dequeued it still parses and jail-resolves all paths first, +then returns `EBUSY` from the immutable snapshot before any namespace stat or +mutation. A mutation admitted before GET activation is naturally serialized +after the stream and is not retrospectively rejected. This preserves jail +status precedence without letting queue serialization erase the fact that the +command arrived during an active download. + **Frozen-vs-native plan:** frozen for orchestration; the **chunk write + incremental CRC** inner loop is a native candidate on C3 (D1). **Satisfies:** FR-FS-1…16, IF-FS, NFR-PERF-2, NFR-REL-2/3, SEC-3/4, CON-3/10. @@ -820,8 +969,8 @@ class Info: def device_info(self) -> bytes # chip, mpy ver, free_mem, fs_root, MTU, # device_id, label (FR-INFO-1) def hello_reply(self, offered_versions) -> tuple # (proto_version, caps) | refuse (FR-INFO-5) - def caps(self) -> dict # chip, mpy_version, fs_root, max_file_size, - # put_window W, chunk_size, has_sd, free_mem, + def caps(self) -> dict # frozen short keys: proto/agent/chip/mpy, + # fs_root/mtu/window/chunk/has_sd/free_mem, # device_id, label, has_identify, identify_led def info_payload(self) -> bytes # DEVICE_INFO-equivalent for INFO read (FR-INFO-4) def set_label(self, payload) -> bytes # 0x50 -> DeviceConfig.set_label (FR-IDENT-1) @@ -831,7 +980,13 @@ class Info: **Key data structures / state:** the `caps` dict (FR-INFO-3); supported `proto_versions`; SD-presence detection result (FR-INFO-6); the auto-run capability flag (opt-in, FR-BOOT-3 — flag name owned by [protocol.md §7](../protocol.md#7-hello--capabilities), OI-5); a handle to the device-config store ([§4.8](#48-device-config-store--label--identify-led-nvs)) supplying `device_id`/`label`/`has_identify`/`identify_led`. All chip-varying values read at runtime (D2) from `os.uname()`, `gc.mem_free()`, `sys.platform`. -**Negotiation:** `HELLO` is the first exchange after connect (FR-INFO-2); a client whose offered versions cannot be satisfied is refused rather than silently mis-spoken (FR-INFO-5). The agent advertises only what it implements (FR-PROTO-10). +**Negotiation:** TX subscription precedes the first RX frame. The bounded +protocol §7 parser accepts only the canonical request grammar, and one +connection/VM session stays unnegotiated until final local acceptance of its +successful HELLO response. The dispatch gate rejects or drops pre-HELLO work, +disconnect/reset clears state, and compatible repeat is idempotent. INFO reads +do not negotiate. The agent advertises only what it implements +(FR-PROTO-10), and v0.6.1 does not add `max_file_size`. **Frozen-vs-native plan:** frozen. @@ -853,6 +1008,13 @@ class Agent: **Key data structures / state:** the lifecycle FSM state; the single-writer lock (`_thread.allocate_lock` or asyncio lock); references to all module instances; a fail-safe handler that returns the board to advertising on a control-plane fault (FR-BOOT-6, NFR-REL-1). +**Portable event ownership:** one `emit(..., expected_session)` chokepoint sends +every event against the exact session captured when the logical event was +created. Runner state, console chunks, upload ACKs, and download data/end never +take a fresh connection snapshot at final TX. Disconnect or handle reuse makes +the send fail/silent; it cannot retarget a successor. Deferred filesystem items +carry that same immutable session through their supervisor mailbox. + **Boot policy:** initialize and request advertising (FR-BOOT-1). The exact-board variant then attempts its separately guarded splash; lean generic S3, classic, and C3 proceed directly. All variants start runner and filesystem workers, @@ -1455,13 +1617,21 @@ Outbound EVTs (`0x13/0x14 FILE_GET_*`, `0x30 CONSOLE_DATA`, `0x40 RUN_STATE`, `0 ### 7.3 Buffer sizing -- **Reassembly buffer:** sized to `max_message` = the largest legal §3.1 message the agent accepts, derived from `max_file_size`/`chunk_size` in `caps`. Single static allocation (D3). +- **Reassembly buffer:** one static allocation (D3) sized for the largest legal + §3.1 command. In v0.6.1 it MUST be at least 2059 bytes so `RUN` containing + `[mode=source] + 2048 source bytes` plus the six-byte header and four-byte CRC + reaches the runner; 2049 source bytes reaches that handler and returns + `ERANGE` rather than being dropped by transport. The reference native buffer + is 4096 bytes, and its footprint is measured on ESP32-C3. - **Per-fragment payload:** `MTU − 3` (ATT) − 1 (frag header), tracked from the negotiated MTU (FR-BLE-8). At MTU 247 this is 243 bytes. - **File I/O buffer:** one MTU-sized chunk (NFR-PERF-2, chunk = one MTU). - **TX staging:** bounded console + event queue ([§5.4](#54-console-backpressure)). - **Generic responses:** exactly two static 491-byte encoded-frame slots plus fixed metadata. At ATT MTU 23, `ceil(491 / 19) = 26` fragments. Neither command concurrency nor backpressure can allocate an unbounded buffer. +- **Filesystem work item:** payload storage is at least 260 bytes for + `FILE_RENAME` with two maximum 128-byte paths; PUT mailbox depth is at least + advertised window plus two. ### 7.4 Windowed-upload state machine (resume + CRC) @@ -1499,12 +1669,12 @@ v0.5.1 source-candidate contract measures the three exact profiles `esp32-4mb`, `esp32-s3-n16r8`, and `waveshare-esp32-s3-lcd-147b` independently. S3 PSRAM is useful Python headroom but MUST NOT conceal internal-RAM pressure, so the gate records Python GC memory -and internal ESP-IDF heap separately. The v0.6.0 five-profile candidate -includes the **ESP32-C3 floor** (single-core RISC-V, ~400 KB SRAM — -[hardware.md §1](../hardware.md#1-supported-chip-families-v1)). Its -candidate-bound measurement is mandatory and remains pending until the C3 -resource and HIL gates pass; source support or attached hardware alone is not -qualification evidence. +and internal ESP-IDF heap separately. The qualified v0.6.0 five-profile +release includes the **ESP32-C3 floor** (single-core RISC-V, ~400 KB SRAM — +[hardware.md §1](../hardware.md#1-supported-chip-families-v1)) and completed +its candidate-bound C3 resource and HIL measurements. Every later candidate, +including source-selected v0.6.1, must repeat that exact-profile measurement; +source support or attached hardware alone is not qualification evidence. ### 8.2 Static vs dynamic @@ -1708,6 +1878,13 @@ generated bytes exist. exact profiles. Profiles or successful samples from different baselines MUST NOT be mixed. +After a controlled refresh, the active-policy readiness test MUST validate the +currently referenced canonical baseline, its digest and filename/source binding, +and every baseline-derived threshold through the maintained release validator. +It MUST retain the source-era fixed performance SLOs and the fixed Waveshare +largest-block floor, rather than pinning a previous active policy's bytes or +measurement-derived resource values. Historical baseline immutability is a +separate assertion and remains unchanged when the active reference advances. Historical pre-`0.5.0` and v0.5.x policies retain their exact V1 and V3/V2 derivation identifiers and arithmetic. The predecessor v0.6.0 contract also retains V3/V2 when its bound source is at or before @@ -1757,9 +1934,9 @@ A schema-3 policy has exactly the five ordered threshold-bearing rows in specs.md §5.3.5. Four ESP rows use the existing application/partition, five-field heap, and NimBLE link facts. Pico uses raw-image limit/headroom, two-field GC snapshots, and BTstack facts. No row may borrow a passing result -from another target. Fixed reset/goodput values are closed above; replacement -image/headroom/heap values and every final-candidate observation remain -pending. +from another target. Fixed reset/goodput values are closed above. The qualified +v0.6.0 image/headroom/heap and final-candidate observations are complete; every +controlled current-source refresh begins with those observations pending. #### 8.5.3 Release evidence and failure semantics @@ -1769,12 +1946,15 @@ the source-era record selected by browser-flashing.md §9: immutable `v0.4.2` replay retains `PYBLE_HIL_RECORDS_V2` schema 2; the rejected pre-split v0.5 engineering shape is V3 and cannot publish split bytes; v0.5.1 retains `PYBLE_HIL_RECORDS_V4` schema 4; v0.6.0 uses -`PYBLE_HIL_RECORDS_V5` schema 5. Current v0.6.0 observations remain pending. +`PYBLE_HIL_RECORDS_V5` schema 5. The qualified v0.6.0 observations are +finalized; every source-selected v0.6.1 observation requires fresh exact-byte +evidence and remains pending until that candidate is finalized. The `719b211…` candidate's V5 bytes, HIL, and physical lineage are predecessor history and MUST NOT be replayed into the replacement. Source/docs/RED/GREEN, -build, reproducibility, license, source, and audit gates precede replacement -of the local unpublished tag; that annotated tag must then peel to candidate -HEAD before audited candidate creation. Fresh HIL and finalization follow. +build, reproducibility, license, source, and audit gates preceded replacement +of the local unpublished v0.6.0 tag; its annotated release tag then peeled to +candidate HEAD before audited candidate creation, fresh HIL, and finalization. +The v0.6.1 candidate must repeat that ordering with its own source and bytes. Finalization may fill observations, operator fields, and derived checks only; it must prove the policy and build portions remain byte/semantically equal to the candidate. @@ -1783,6 +1963,87 @@ evidence, copies candidate-frozen fields from the pending report, derives the footprint/reliability pass from validated observations, and emits the completed source-era report atomically before finalization. +For exact version `0.6.1`, the protected V5 record also starts with +`v061_hardening: null` and a pending hardening check. The physical hardening +runner receives the protected candidate, the exact profile, two separately +acquired candidate-bound sacrificial workspace receipts, a new raw-log path, +and a new result path. Before BLE access it validates the complete committed +qualification dependency closure and retains the candidate/receipt/source +snapshot that the final writer must consume. After negotiation and before any +mutation it refuses a board containing either `/v061_hil` or `/main.py`; the +former protects an existing namespace and the latter prevents the autorun +durability reboots from executing owner code. The live invocation also carries +the private reviewed manufacturer, model, and module-marking attestation for +the selected exact physical profile because PBLE/1 intentionally exposes no +carrier-board runtime identity. It runs the seven scenarios in the frozen order, +including exactly 50 sequential RUNs, broad configuration durability, and +filesystem hardening. The stdin VM-reset subcase uses a non-expiring pre-input +barrier, bounds both the complete stale-input write and the SOFT_REBOOT response, +and at the accepted `RSP{OK}` cut requires the predecessor to remain running +with no terminal/idle state and no stale-input echo. Only after that cut may it +wait for disconnect, reconnect, negotiate, and prove the successor stays blocked +until fresh input arrives. This prevents a slow accepted reboot from consuming +the supposedly stale line before the reset. The runner validates both workspace +receipts; then publishes one canonical mode-`0600` private result without +replacement through that same preflight snapshot. The raw/result/receipt outputs +are outside both candidate and qualification Git trees, and exclusive +publication performs a final descriptor-relative reopen after all input +callbacks. Any failed or interrupted operation retains no passing result. + +The same reviewed `v061_hardening_bench.py` executable exposes a separate +workspace-receipt creation mode for each prerequisite boot. It accepts one +exclusive canonical raw boot log in the exact grammar frozen by +specs.md §5.3.4 and derives the candidate, install, source, qualification, and +raw-log identities into a no-replace mode-`0600` receipt. The two receipt +operations remain separate from the BLE scenario run because the decisive +mount/format/refusal behavior occurs before the service exists. The later +scenario run consumes both receipts; it never fabricates or silently skips +them. + +The 2026-09-06 measured-boot amendment adds red-to-green coverage for the +optional observed mount, actual initial-constructor/mkfs/remount ordering, +attempt versus completion counts, direct read-method binding, and wrapped +write/erase attempts including failed and idempotent operations. Each test +uses a fresh module instance to model a VM. Cover missing/bad entropy, +inspection/allocation/format/remount/attachment failures, counter saturation, +one-shot initialization and terminal sealing, immutable detached snapshots, +strict fresh challenge echo, and zero additional recovery emissions. Normal +unobserved callers retain their existing behavior and block-device identity. +All five overlay tests prove observed mounting, attachment sealing only after +`vfs.mount`, and bounded recovery sealing before any possible agent startup. +Erased-scan status tests cover both observed and unobserved callers: `None`, +exact integer zero, and boolean `True` succeed, while `False`, floating zero, +and custom equality-to-zero objects fail without formatting. Collector tests +round-trip the actual firmware getter through JSON with reordered mapping +keys; exact key sets remain required without imposing MicroPython dict order. + +The workspace acquisition tests MUST exercise the live collector's event +sequence and reject an expected four-line summary without measured siblings. +Fixtures prove exact native-media geometry and pre/post bytes, real response +parsing with challenge/boot identity, actual counter predicates, scanner +start/callback/end ordering, missing/incomplete watches, and interrupted +acquisition. Every retained sibling participates in immutable acquisition and +receipt bindings. Mutation, link substitution, wrong target/candidate, +replayed response, false zero-write inference from unchanged bytes alone, +and any manually supplied operation count cannot produce a passing receipt. +Private-result and finalization tests reopen this entire closure rather than +trust its previously copied digest. Host simulations validate the harness; +only its execution on the exact physical candidate supplies HIL evidence. + +`create-hil-completion` opens and validates that profile's private hardening +result alongside the OI observation and existing target gate result. It +derives the public hardening summary and check; neither may occur in operator +input. `assemble-hil-report` accepts exactly five distinct completion +fragments and preserves each derived private-result digest. The v0.6.1 +finalizer additionally receives exactly five private hardening-result paths, +reopens each as a stable regular mode-`0600` file, revalidates its canonical +bytes and candidate/profile/install/qualification identities, and requires its +digest and derived summary to equal the completed report. It repeats those +snapshots immediately before atomic publication so a post-validation mutation +cannot pass. A missing, extra, duplicated, swapped, stale, reordered, or +mutated file leaves no public output. Version `0.6.0` takes none of these +inputs and retains its historical V5 record/completion byte shape unchanged. + The validator recomputes image/headroom arithmetic, sample counts, heap minima, latency maximum, goodput from recorded durations, threshold comparisons, reliability totals, and the profile-exact transfer-link facts. It @@ -1800,7 +2061,23 @@ If the frozen-Python agent does not fit C3's flash/heap with usable user-code he ### 9.1 VFS / LittleFS -The workspace is a MicroPython VFS (LittleFS) rooted at `fs_root` (IF-FS). The agent does not reformat or repartition at runtime; the partition table is part of the per-chip build artifact (BLD-5). +The workspace is a MicroPython LFS2 VFS rooted at `fs_root` (IF-FS) on every +official profile. On ESP, the partition-table `data,fat` subtype is historical +ESP-IDF block-container metadata, not the on-media filesystem: its `vfs` label +selects MicroPython's LFS2 first-use provisioning. Every overlay calls the same +frozen `pyble_workspace.mount_lfs2` authority. It constructs `VfsLfs2` +explicitly and scans the complete device before erased-media provisioning, so +generic VFS autodetection and ESP upstream `inisetup`'s first-block heuristic +cannot silently admit FAT or format media with a nonblank later block. +A nonblank incompatible/corrupt ESP workspace fails closed without formatting +or starting the agent; recovery/migration is an explicit operator action. + +The agent never repartitions at runtime. The shared helper may perform one +first-use format only after a failed normal mount and a complete block scan +proves every byte erased (`0xFF`); it never formats nonblank or uncertain media. +Invalid geometry fails before scanning. This narrow provisioning case is the +only runtime format path. Partition geometry remains part of the per-profile +build artifact (BLD-5). ### 9.2 Workspace layout @@ -1820,11 +2097,29 @@ This is Layer 4 — served, not part of the agent. `.mpy`/`.pyc` are neither req ### 9.4 Atomicity -Uploads write to a `.tmp` sibling and `os.rename` over the target only after whole-file CRC verification, so a target file is never corrupted mid-transfer or by a dropped link (FR-FS-9, NFR-REL-2/3). FS errors map to PBLE/1 status codes (FR-FS-15). +Uploads write to the reserved `.pbltmp` sibling and rename over the target only +after whole-file CRC verification, so a target file is never corrupted +mid-transfer or by a dropped link (FR-FS-9, NFR-REL-2/3). Listings hide that +suffix. Resume accepts only a stable, completely CRC-scanned regular-file +prefix; malformed scratch follows the non-recursive recovery reducer in +protocol §5. Admission preflights block-rounded remaining bytes plus the +65,536-byte reserve. Every failed PUT closes, non-recursively removes, and +absence-verifies its scratch; cleanup failure overrides the originating status +with `EIO` while preserving the old target. FS errors map to PBLE/1 status +codes (FR-FS-15). ### 9.5 Device config persistence (NVS) -The device label and identify-LED config persist in a small **NVS namespace** (`esp32.NVS`) — **not** in the LittleFS workspace and **not** under `fs_root`. This keeps the per-device UX state stable across reboot (FR-IDENT-5) while staying **outside** the path jail ([§9.3](#93-path-jail-enforcement)) and outside anything user code or PBLE/1 file opcodes can reach. It is read at boot to derive the advertised name and the identity caps, and written only by `SET_LABEL`/`SET_IDENTIFY_LED`. It is **not** a routing/pin profile or capability map (D7, CON-13, FR-IDENT-6). +On ESP, the device label, autorun byte, and authoritative four-byte Identify +record persist in the NVS namespace `pyble`, outside `fs_root`. Every change is +candidate-first and visible in RAM/GPIO/advertising only after checked commit. +On Pico, the protected `/pyble_conf.json` uses D11's version/CRC and atomic +temp-write/flush/sync/rename transaction. Invalid persisted state selects safe +defaults plus a bounded internal marker and is never rewritten at boot. Both +stores remain device UX state, not a routing/pin profile or capability map +(D7, CON-13, FR-IDENT-5/6). A domain's bounded fault marker latches across +missing reads and failed writes until a successful durable repair of that +domain; it is internal-only in v0.6.1. ## 10. Build system design @@ -1910,6 +2205,13 @@ to `.sources/rpi-pico2-w/micropython/ports/rp2`. A build description that names the canonical submodule, another profile's checkout, or an escaped/symlinked location is fatal. +The Pico configure boundary passes +`-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON` and its post-build audit requires +the exact `FETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON` cache line. Host fixtures +emit the same type produced by real CMake and independently replace it with +`UNINITIALIZED=ON`, `BOOL=OFF`, and no entry; every replacement MUST fail +before provenance admission and fail-clean the new output and retained source. + All profile-scoped mutable preparation, including board-copy, ESP-IDF submodule/managed-component materialization, and RP2 frozen-module generation, runs inside that profile's checkout and build directory. No profile may share, @@ -1932,9 +2234,10 @@ HIL report, activation, and rollback are frozen in both at the versioned `pyble.dev` path and through the matching GitHub Release, with exact source-era profile parity: two hardware-tested beta profiles in v0.4.2, retained as immutable history; the unfinished three-profile v0.5.1 source candidate -retained only as history; and exactly five profiles in the pending v0.6.0 -candidate. No v0.6.0 profile is published until the atomic five-profile gate -passes (BLD-7/17…22). `DEVICE_INFO`/HELLO, +retained only as history; exactly five profiles in the qualified v0.6.0 +release; and the same five profiles in the source-selected v0.6.1 increment. +No v0.6.1 profile is published until its fresh atomic five-profile gate passes +(BLD-7/17…22). `DEVICE_INFO`/HELLO, `manifest.json`/`release.json`, tag, and release notes make agent/protocol/ upstream/source/artifact versions recoverable (BLD-13); the agent follows SemVer (BLD-12). @@ -1972,6 +2275,7 @@ firmware/ micropython/ # Layer 1 — pinned submodule, pristine (CON-1/2) [build-smith / .gitmodules] pyble/ # Layer 3 — frozen-Python agent package (NFR-MAINT-2) __init__.py # empty placeholder package at S1 (do NOT populate) [build-smith scaffold] + _version.py # selected agent identity [build-smith] pyble_agent.py # boot + dispatch surface + single-writer lock [runtime-engineer] pyble_ble.py # NimBLE peripheral, GATT, fragmentation [ble-transport-engineer] pyble_proto.py # frame codec, CRC32, dispatch [protocol-engineer] @@ -1979,10 +2283,17 @@ firmware/ pyble_fs.py # filesystem bridge + workspace jail [storage-engineer] pyble_console.py # stdout/stderr tee + stdin feed [runtime-engineer] pyble_info.py # DEVICE_INFO/HELLO/caps + device-config store [identity-engineer] + pyble_boot.py # autorun and boot helpers [runtime-engineer] + pyble_device_config.py # durable portable label/autorun record [identity-engineer] + pyble_workspace.py # shared guarded LFS2 mount/recovery authority [storage-engineer] python_modules/ # optional Layer-4 frozen user runtimes pyble_st7789.py # explicit ST7789 driver (exact-board variant only) [runtime-engineer] user_c_modules/ - pyble/ # Layer 3 native hot paths — LATER, OI-3 (pble_*.c) [ble-/protocol-engineer] + pyble/ # Layer 3 native ESP agent [ble-/protocol-engineer] + micropython.cmake # exact USER_C_MODULES source inventory [build-smith] + pble_{ble,boot,console,device_config,fs,info,lock,proto,runner,termination,vm_lifecycle,wire}.c + pble_{ble,boot,console,device_config,fs,info,lock,proto,runner,termination,vm_lifecycle,wire}.h + pble_version.h # selected native agent identity [build-smith] board_overlays/ esp32/ esp32-s3/ esp32-c3/ # lean Layer-2 variants, copied at build prep [build-smith] waveshare-esp32-s3-lcd-147b/ # exact-board esp32s3 build variant [build-smith] @@ -2125,6 +2436,26 @@ pico-sdk, BTstack and its actually linked third parties, CYW43, TinyUSB, and every contributing ARM GNU/newlib runtime archive. Deleting or substituting one class fails even when the remaining evidence is canonically rehashed. +ESP frozen-payload tests MUST reach the production observer with a real +descriptor-captured retained board layout and literal manifest selections. +They distinguish a flat workspace destination from its canonical-source-relative +`pyble/` copy, and preserve already-prefixed and historical package selections. +Missing nested copies, matching flat decoys, stale bytes, file/directory +symlinks, and duplicate frozen destinations are rejection cases. The existing +lock-generated version exception and overlay byte checks remain independently +covered; tests must not replace the snapshot or byte comparison with a model. + +RP2 environment-proof tests MUST run the actual checked-in build driver through +the production Arm observer's parser boundary. They separately cover the strict +`unset` command and the exact pinned offline CMake assignment/export block, +including the case-sensitive `picotool_DIR` name. Missing/duplicate markers or +variables, malformed names/continuations, lost GCC or package-discovery scrub +coverage, inserted shell commands, changed offline values and incomplete exports +are red cases. The historical scrub-only form remains source-version-selected; +it cannot satisfy the v0.6.1 pinned-picotool contract. Existing cache, compile +recipe, helper/archive, depfile, runtime notice and source-identity gates remain +unchanged. + The same fixtures derive every compiler depfile belonging to the linked firmware target from its C/C++ DependInfo records; assembly records, for which CMake does not emit `.o.d`, bind their source bytes directly. Unlinked host @@ -2213,9 +2544,36 @@ frozen inputs/output, checkout metadata, policy, license/notice bytes, and toolchain inputs before the eight ESP runs and repeats the full semantic observation immediately before publication. A byte change with restored timestamps is fatal. Tests require schema-v2 receipt coverage of all eight ESP -identities and exactly the seven RP2 roles, plus the canonical five-profile -schema-v1 release inventory; missing, extra, reordered, cross-profile, and -self-consistently rehashed substitutions fail. +identities and the candidate-version-selected RP2 roles: the historical seven +for v0.6.0, or those same seven plus `build-tools` for v0.6.1. The eighth role +must reopen the exact retained picotool archive/tree/lock/version identities, +the two reviewed build-tool owners, and all component, license, attribution, +and source/distribution provenance bytes. It never alters the generated +firmware notice. Tests also require the canonical five-profile schema-v1 +release inventory; missing, extra, reordered, cross-profile, and +self-consistently rehashed substitutions fail. A v0.6.0 candidate carrying the +eighth role or a v0.6.1 candidate lacking it fails without publication. + +The build-tools seam additionally tests synchronized in-place and atomic +mutations of the executable, package directory, and retained archive at the +runtime probe boundary, including byte-identical inode replacement and an +unsafe retained-root mode. Production must require exact mode `0700` on the +retained root, run only a write-locked private tree reconstructed from verified +archive bytes while retaining no-follow root and executable descriptors, and +resolve the executable relative to the descriptor-anchored child working +directory. Tests transiently replace and restore the private root and prove +that substituted code is never executed. Production rechecks both descriptors +and the complete private tree after execution. It then +descriptor-reopens the full original archive/tree and compares node identities +before acceptance. A positive test proves both original-tree observations +occur and that the retained executable pathname is never launched. Separate +routing tests prove +that explicit candidate version selects tool-lock inputs without consulting +the validator checkout version, and that the compare CLI derives and forwards +the strict source version from its required repository root. The complete +checked-in policy test freezes canonical policy/attribution digests, exact +owner/member coverage, and every recursively referenced evidence asset; a +four-key compact attribution must fail. Pinned nested manifests are resolved by their literal package/module selections, and the result must equal generated frozen content. Archive member @@ -2426,21 +2784,23 @@ Chip facts are owned by [hardware.md §1](../hardware.md#1-supported-chip-famili ([§5.4](#54-console-backpressure)) must be validated here first. The selected ESP32-C3-MINI-1-N4 v0.4/4 MiB/no-PSRAM engineering reference, complete gate matrix, and public-admission boundary live in the - [derived C3 contract](ports/esp32-c3-4mb.md); every result remains pending. If - frozen-Python does not fit, hot paths go native (D1, + [derived C3 contract](ports/esp32-c3-4mb.md). Its exact v0.6.0 results passed; + every source-selected v0.6.1 result starts pending. If frozen-Python does not + fit, hot paths go native (D1, [§8.6](#86-esp32-c3-mitigation)). The known `esp32-c3-4mb` provisioning - profile is defined only for C3 silicon revision v0.3 or newer but remains - unavailable in the current v0.4.2 public beta pending exact-profile HIL. Its - exact image revision window appears in pending v0.6.0 schema-4 metadata, but - its action remains inactive until C3-G0…C3-G6 and common HIL pass. + profile is defined only for C3 silicon revision v0.3 or newer. It was absent + from the historical v0.4.2 public beta, then qualified in v0.6.0 after its + schema-4 revision-window metadata, C3-G0…C3-G6, and common HIL passed. New + v0.6.1 bytes remain inactive until those gates pass again. -- **rpi-pico2-w (selected, qualification pending):** single-core-agent model on RP2350 — +- **rpi-pico2-w (qualified v0.6.0; v0.6.1 refresh pending):** single-core-agent model on RP2350 — BTstack SYNC events on the main thread, supervisor-owned execution, STOP via the dupterm interrupt-char channel. Design is owned by the derived port spec ([ports/rpi-pico2-w.md](ports/rpi-pico2-w.md)); nothing in §5 (ESP32 task - model) applies to it. Its v0.6.0 row uses verified UF2/manual BOOTSEL, - RP2-specific resource/BTstack evidence, and remains inactive until GP2 and - common V5 HIL pass. + model) applies to it. Its v0.6.0 row qualified with verified UF2/manual + BOOTSEL, RP2-specific resource/BTstack evidence, GP2, and common V5 HIL. Its + source-selected v0.6.1 row remains inactive until fresh instances of those + gates pass. ## 12. Error handling & status mapping @@ -2890,9 +3250,10 @@ Design element → satisfied requirement IDs. Each `FR-*` block has at least one ## 16. Risks & open questions -- **R1 — Footprint on ESP32-C3 (binding).** C3 real-hardware resource numbers - remain pending, so the selected v0.6.0 row cannot pass and the atomic release - cannot activate. Mitigation: run the same +- **R1 — Footprint on ESP32-C3 (binding).** The qualified v0.6.0 C3 + real-hardware resource numbers are baseline evidence only. Fresh + source-selected v0.6.1 numbers remain pending, so that candidate's C3 row and + atomic release cannot pass yet. Mitigation: run the same frozen method, and use native `USER_C_MODULE` hot paths if needed ([§8.6](#86-esp32-c3-mitigation)). Other profile results do not waive or predict the C3 result (OI-1, NFR-FP-CLOSE). diff --git a/docs/specifications/firmware/browser-flashing.md b/docs/specifications/firmware/browser-flashing.md index 01acac56..4cce8216 100644 --- a/docs/specifications/firmware/browser-flashing.md +++ b/docs/specifications/firmware/browser-flashing.md @@ -1,12 +1,14 @@ # PyBLE Firmware Browser Provisioning and Release Bundle -Status: **FROZEN v1.32** · Owner: project maintainer · Frozen: -2026-08-20 (`[docs]`; ADR-0033 adds the exact five-profile v0.6.0 successor -with release schema 4, HIL V5, OI policy schema 3, four ESP Web Serial images, -and one verified-UF2/manual-BOOTSEL path; every new gate remains pending; -ADR-0037 fixes replacement-era reset and transfer product SLOs; ADR-0038 -replaces an unpublished local v0.6.0 candidate by source ancestry; immutable -v0.4.2, v0.5.1, and predecessor-v0.6.0 contracts are preserved) +Status: **FROZEN v1.33** · Owner: project maintainer · Frozen: +2026-09-02 (`[docs]`; ADR-0033 established the exact five-profile v0.6.0 +successor with release schema 4, HIL V5, OI policy schema 3, four ESP Web +Serial images, and one verified-UF2/manual-BOOTSEL path; ADR-0037 fixes +replacement-era reset and transfer product SLOs; ADR-0038 replaces an +unpublished local v0.6.0 candidate by source ancestry; the qualified v0.6.0 +result is the baseline; the v0.6.1 amendment retains V5 while requiring fresh, +exact-version evidence; immutable v0.4.2, v0.5.1, and predecessor-v0.6.0 +contracts are preserved) This document is the source of truth for the initial browser-provisioning release bundle. It refines @@ -45,8 +47,10 @@ The fifth row is deliberately heterogeneous: The exact profile order is `esp32-4mb`, `esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, `rpi-pico2-w`. All five are -release-blocking. C3-G0…C3-G6 and Pico GP2 remain pending prerequisites; no -profile becomes active until the complete exact-byte v0.6.0 matrix passes. +release-blocking. The complete exact-byte v0.6.0 matrix, including C3-G0…C3-G6 +and Pico GP2, passed and is the qualified baseline. The v0.6.1 candidate must +repeat every gate with fresh evidence; no v0.6.1 profile becomes active until +that complete atomic matrix passes. ### 1.1 Loopback-only five-target approval preview @@ -187,17 +191,19 @@ relative source layout until the license audit, candidate validation, and two-root byte comparison have completed: ```text -.sources/esp32-4mb/micropython/ -.sources/esp32-s3-n16r8/micropython/ +.sources/esp32/micropython/ +.sources/esp32-s3/micropython/ .sources/waveshare-esp32-s3-lcd-147b/micropython/ -.sources/esp32-c3-4mb/micropython/ +.sources/esp32-c3/micropython/ .sources/rpi-pico2-w/micropython/ ``` -Each path is an independent MicroPython checkout for exactly the named release -profile. The generic and Waveshare S3 variants both compile IDF target -`esp32s3`, but MUST NOT share mutable source/build state or collapse to one -artifact. +Each path is an independent MicroPython checkout for exactly the selected build +target. Release profiles `esp32-4mb`, `esp32-s3-n16r8`, and `esp32-c3-4mb` map +to targets `esp32`, `esp32-s3`, and `esp32-c3`, respectively; the Waveshare and +Pico profile names already equal their build targets. The generic and Waveshare +S3 variants both compile IDF target `esp32s3`, but MUST NOT share mutable +source/build state or collapse to one artifact. Its `HEAD` MUST equal the full `versions.lock [micropython].commit`, its `origin` URL MUST equal the canonical `versions.lock [micropython].repo`, and its tracked tree MUST be clean when admitted and validated. Each target's @@ -710,14 +716,16 @@ The generator MUST: **Independent RP2 policy and observer.** `firmware/licenses/rp2-license-policy.json` is a separate reviewed, hash-bound input. Its exact top-level keys are `schema_version`, `profile_id`, `target`, -and `source_owners`; `schema_version` is the exact JSON integer `1`, and the +and `source_owners`; `schema_version` is the exact JSON integer `2`, and the identity is exactly `rpi-pico2-w`. Each canonical, identifier-sorted owner has exactly `id`, `source_roots`, `source_ref`, `source_url`, `source_spdx_expression`, `selected_spdx_expression`, `copyright`, -`license_texts`, `notice_files`, and `disposition`. A source root has exactly -`namespace` and `path`, where the namespace is `repo`, `micropython`, or -`arm-gnu-toolchain`; a license-text record has exactly `identifier`, `path`, -and `sha256`; and a notice record has exactly `path` and `sha256`. Lists are +`license_texts`, `notice_files`, and `disposition`, except the five exact ARM +GNU owners that additionally carry their frozen `component_kind`. A source +root has exactly `namespace` and `path`, where the namespace is `repo`, +`micropython`, or `arm-gnu-toolchain`; a license-text record has exactly +`identifier`, `path`, and `sha256`; and a notice record has exactly `path` and +`sha256`. Lists are nonempty where applicable, unique, and canonical. The most-specific lexical root in one namespace owns an input; no owner or equal-specificity ambiguity is fatal. Every owner MUST contribute an observed input, while every observed @@ -729,6 +737,89 @@ dependency class below but is not an unsafe fixed-count allowlist. A boolean or numerically equal fractional schema version is invalid. The policy MUST NOT import an ESP package identity or resolution as proof for an RP2 input. +Firmware v0.6.1 adds a second, independently reviewed input, +`firmware/licenses/rp2-build-tools-license-policy.json`, only to audit the +host picotool distribution used by its RP2 build. Firmware v0.6.0 never opens +or interprets this file. The new policy has exact top-level keys +`schema_version`, `profile_id`, `target`, `distribution_provenance`, +`source_owners`, and `nonsoftware_members`; its schema is exact integer `1` +and its two identities are `rpi-pico2-w`. Its source-owner shape is the RP2 +schema-2 shape with required `component_kind: "build-tool"`, but its only +namespace is `picotool-distribution` and its exact, sorted owner IDs are +`picotool-composite-build-tool` and `picotool-libusb-build-tool`. The former +owns each regular software member except libusb and selects the owner-scoped +`LicenseRef-PyBLE-Picotool-2.3.0-Composite`; its complete reviewed component +manifest retains picotool and Pico SDK BSD-3-Clause, clipp and nlohmann JSON +MIT, littlefs BSD-3-Clause, the exact ooFatFs R0.13c grant, selected MIT for +whereami, selected Apache-2.0 for Mbed TLS, and the CMake package-script +grant. The latter owns only `picotool/libusb-1.0.0.dylib`, selects +`LGPL-2.1-or-later`, and binds libusb tag `v1.0.30`, commit +`87a55632db62c9bdc58cd31d3ccfa673f1bb017f`, source-archive SHA-256 +`fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf`, +and the complete COPYING plus attribution inputs. + +`distribution_provenance` binds the exact `[picotool]` lock table, official +archive bytes and 12-entry member inventory frozen by the Pico 2 W port +contract, runtime version line, source repo/tag/commit, and distribution +repo/tag/commit. It also binds the reviewed +`firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json`. +Its exact keys are `picotool_lock`, `retained_archive`, `member_inventory`, +`runtime_version_line`, `source`, `distribution`, and `attribution`. +`picotool_lock` is an exact copy of the frozen lock table; +`retained_archive` has exactly `path`, `bytes`, and `sha256`; every member has +exactly `path`, `kind`, `mode`, `bytes`, and `sha256`; `source` and +`distribution` each have exactly `repo`, `ref`, and `commit`; and +`attribution` has exactly `path` and `sha256`. Every nonsoftware-member record +copies its complete member record and adds only +`classification: "packaging-metadata"`. Array order is the archive order +frozen by the port contract, except owner roots and nonsoftware members, which +are lexically path-sorted. +Production admission accepts only the complete seven-key attribution document +(`schema_version`, `archive`, `archive_members`, `components`, +`distribution_provenance`, `libusb_binary_provenance`, and `source`) and the +exact frozen canonical bytes of that document and its policy. A compact or +self-consistently rehashed test attribution is never a production schema. The +validator recursively reopens every referenced component, license, notice, +source-archive, and packaging-provenance asset and requires its recorded +digest before admitting the policy. +That record distinguishes exact vendored-tree identities from upstream origin +statements and records pico-sdk-tools' Apache-2.0 license as packaging +provenance only. The archive is an aggregate container, not an Apache-2.0 +owner. The directory entry and `.keep` are the exact sorted +`nonsoftware_members` and are explicitly classified as packaging metadata; +they cannot disappear, acquire an owner, or mask an unowned software member. +Both build-tool owners and all their license inputs participate in the audit +receipt but are excluded from the redistributed firmware +`THIRD_PARTY_LICENSES.txt`, because neither host tool is present in the +firmware image. + +The build-tools observer opens the retained install below one no-follow +directory descriptor, requires the install root itself to be a directory with +exact private mode `0700`, rejects linked regular members, and proves exact +member types, modes, bytes, and node identities. It reconstructs and validates +a private byte-for-byte probe tree from the verified archive, then removes +every write bit and retains no-follow descriptors for its root directory and +executable across the probe. The child resolves the relative executable only +after entering that descriptor-anchored root, so transient substitution of the +private tree's pathname cannot select code. It executes `picotool version` only +from that write-locked private tree; it never executes the pathname in the +mutable retained install. Both descriptors and the complete locked probe tree +must have the same bytes and identities after execution. The observer then +reopens the retained archive +and complete installed tree and requires the same identities before minting +evidence. A file rewrite, atomic replacement, mode change, link substitution, +path-component substitution, or archive mutation at the probe boundary is +fatal. + +Candidate replay passes the candidate firmware version into tool-lock loading +before any version-specific policy path is opened. A newer validator checkout +may contain later locked input pairs, but an older candidate neither opens nor +receives them. Conversely, a candidate requiring a missing version-specific +pair fails closed. The `compare` command requires `--repo-root`, derives the +strict source version from that checkout's `versions.lock`, and passes both +the root and version to reproducibility validation; its CLI has no +versionless v0.6.1 path. + The observer starts from the exact retained `firmware.elf`, its `CMakeFiles/firmware.dir/link.txt`, `firmware.elf.map`, CMake cache, build provenance, archives, and direct objects. All are regular non-symlink files @@ -772,7 +863,8 @@ GNU roots is fatal. Every dependency is attributed to one most-specific RP2 policy owner. A generated build dependency is admitted only through an exact recorded derivation whose source/configuration inputs are themselves owned and hashed; generated bytes never create an inferred license owner. The semantic -hash, all seven RP2 role documents where applicable, and public replay cover +hash, all release-selected RP2 role documents where applicable, and public +replay cover the depfile bytes, object/source relationship, ordered dependency records, owner identity, and dependency bytes. @@ -967,8 +1059,16 @@ fatal. The v0.6 audit emits a canonical `audit-receipt.json` with exact JSON integer `schema_version: 2`. It binds the notice, complete input/evidence hashes, execution identity, release inventory, all eight ESP role identities, and -exactly these seven RP2 evidence roles: `linked-inputs`, `frozen-modules`, -`pico-sdk`, `btstack`, `cyw43`, `tinyusb`, and `arm-gnu-runtime`. The canonical +one firmware-version-selected RP2 role list. Firmware v0.6.0 retains exactly +these seven roles and their historical bytes: `linked-inputs`, +`frozen-modules`, `pico-sdk`, `btstack`, `cyw43`, `tinyusb`, and +`arm-gnu-runtime`. Firmware v0.6.1 appends exactly one eighth role, +`build-tools`. Its evidence document binds the retained archive, all 12 +members and modes, exact lock and runtime identity, reviewed +provenance/component manifests, two build-tool owners, and every required +license/attribution byte. The same candidate-derived firmware version selects +the role list, policy, and evidence schema throughout; the validator +checkout's current lock cannot silently reinterpret a candidate. The canonical schema-v1 `release-inventory.json` contains exactly the five release profiles in policy order, binds each packaged provenance, and distinguishes each ESP application/bootloader raw and reviewed document from the Pico role @@ -1047,8 +1147,8 @@ The following ESP-IDF resolution rules are part of that fail-closed mapping: file, an admitted described directory marker, or a `CONFIG_ONLY` component root, except for these pinned application-`main` inputs: - - the eleven repository-owned files - `firmware/user_c_modules/pyble/{pble_proto,pble_ble,pble_info,pble_device_config,pble_runner,pble_console,pble_fs,pble_lock,pble_boot,pble_vm_lifecycle,pble_termination}.c`; + - the twelve repository-owned files + `firmware/user_c_modules/pyble/{pble_proto,pble_wire,pble_ble,pble_info,pble_device_config,pble_runner,pble_console,pble_fs,pble_lock,pble_boot,pble_vm_lifecycle,pble_termination}.c`; - the fourteen files in the selected target's immutable retained `.sources//micropython/lib/berkeley-db-1.xx` tree: `btree/{bt_close,bt_conv,bt_debug,bt_delete,bt_get,bt_open,bt_overflow,bt_page,bt_put,bt_search,bt_seq,bt_split,bt_utils}.c` @@ -1086,7 +1186,7 @@ The following ESP-IDF resolution rules are part of that fail-closed mapping: into the application ELF. The selected output-basename multiset MUST equal the parsed archive-member multiset exactly. Each selected source MUST also belong to that same component's exact described file or directory marker; - an application `main` archive may additionally own the exact eleven pinned + an application `main` archive may additionally own the exact twelve pinned PyBLE sources above. A file or root described by one component MUST NOT authorize a linked output owned by another component, and the pinned Berkeley DB sources remain direct-object-only. @@ -1123,7 +1223,7 @@ The following ESP-IDF resolution rules are part of that fail-closed mapping: - `CMakeFiles/.dir/project_elf_src_.c.obj` for the zero-byte role-root ELF anchor; - application `CMakeFiles/micropython.elf.dir/.obj` - for the eleven repository-owned PyBLE C sources; and + for the twelve repository-owned PyBLE C sources; and - application `esp-idf/main/CMakeFiles/micropy_extmod_btree.dir/.obj` for the fourteen retained Berkeley DB sources. @@ -1473,6 +1573,19 @@ The following ESP-IDF resolution rules are part of that fail-closed mapping: tree; materializing such an ambient tree is neither an audit prerequisite nor substitute evidence for bytes used by the build. + A frozen destination is not necessarily its generated board source path. + `prepare.sh` materializes a selected canonical `firmware/pyble/` + source at `BOARD_DIR/pyble/`, independently of the manifest's + destination. In particular, the flat frozen `pyble_workspace.py` destination + uses `BOARD_DIR/pyble/pyble_workspace.py`; already-prefixed `pyble/...` + destinations MUST NOT gain a second `pyble/` prefix. The audit MUST derive + that physical copy path from the canonical source-relative path, retain the + original frozen destination, and compare the exact retained copy bytes. + A flat matching decoy cannot replace a missing or changed nested copy. + The lock-generated `_version.py` exception, overlay copies, separately + admitted first-party modules, and upstream selections keep their existing + validation rules. + The collector MUST open every component of that exact retained `BOARD_DIR` descriptor-relatively without following links, capture its complete bytes and node identities, and materialize a byte-identical execution snapshot in @@ -1705,7 +1818,12 @@ Automated release tests MUST cover: flash-capacity checks; - exact manifest schema, paths, profile parity, and forbidden redirect/origin cases; -- exact v0.6.0 five-profile resource-policy schema 3 and HIL V5 schema, plus +- exact v0.6.0/v0.6.1 five-profile resource-policy schema 3 and HIL V5 schema, + with every current-candidate version binding checked end to end, plus + the version-routed v0.6.1 seven-scenario hardening result, exactly 50 + sequential runs, two candidate-bound workspace-provisioning receipts, + private-result digest/TOCTOU validation, and proof that v0.6.0 retains its + prior record/check bytes, plus historical schema-2/V4 replay, baseline/policy/candidate hash binding, derivation arithmetic, threshold-boundary and one-unit-crossing @@ -1737,10 +1855,11 @@ wrong source-era marker, an additional marker, or keys not defined below are invalid. These are admission requirements; they do not assert that a v0.5.1 HIL report or qualified public bundle exists. -The v0.6.0 report instead contains exactly one +The v0.6.0 and v0.6.1 reports instead contain exactly one `PYBLE_HIL_RECORDS_V5` schema-5 object with five records in §1 order and the -target-discriminated contract in §9.5. V5 is required only for v0.6.0 and MUST -NOT reclassify V2/V4 history. +target-discriminated contract in §9.5. V5 admission is limited to release cores +`0.6.0` and `0.6.1`; it MUST NOT reclassify V2/V4 history or pre-authorize the +proposed v0.7.0 contract. ```text + +# Google Play open-testing preparation + +Status: candidate preparation, 10 September 2026. This contract supplements +the [app distribution requirements](App/specs.md#9-build-versioning--distribution--bld) +and [ADR-0044](../decisions/0044-start-app-v020-beta-train-at-build-5.md). + +The owner has reported that open testing is available for the existing Play +application. Preparation does not imply a submitted or approved release. + +## Candidate identity + +The next candidate is **0.2.0 (8)**, owned by `app/pubspec.yaml`, for production +package `dev.pyble.pyble`. Build 5 remains a historical handoff; retained local +builds 6 and 7 must not be relabelled or replaced. The automated app-version +contract must bind build 8 to base version 0.2.0, PBLE/1 HELLO, and the existing +GitHub User-Agent. If Play Console has already consumed code 8 or higher, +increase this declaration and the version contract before rebuilding. + +## App and submission contract + +- About must expose the full app privacy policy offline, with the public + policy URL and contact. Opening and leaving it must preserve the IDE and + connection state. All UI copy must follow the existing localization and + accessibility requirements. +- The app remains free, account-free, and BLE-first. This preparation adds no + analytics, advertising SDK, remote execution, demo transport, or new network + workflow. App features must remain at iOS/Android source parity. +- The Android bundle must use the production entry point and the existing + upload key through BLD-12, with target API at least 36. Record its source + commit, app-tree identity, version, SHA-256, signer, SDKs and native ABIs. + Check the AAB signature, bundle structure, and 16 KiB native alignment. + No signing material or generated binary belongs in Git. +- Store listing copy must describe implemented capabilities and the need for + a compatible, separately provisioned board. Use the qualified firmware + available on `pyble.dev/flash`; engineering v0.6.1 evidence must not be + presented as published firmware qualification. +- Graphics must use the existing original PyBLE identity. Screenshots must + be authentic and record device, source and build provenance. Historical + captures, debug builds and emulators must be identified accurately; they + cannot establish release-binary or physical BLE validation. +- The submission packet must supply English listing text, release notes, + suggested app-content declarations with evidence and owner decisions, + hardware-aware review instructions, tester setup, feedback guidance, test + cases, artifact handoff, and the remaining Console steps. Official policy + references must include their verification date. +- No data-collection exemption, all-transfers-encrypted answer, IARC rating, + country selection, age declaration, reviewer hardware access, Play prelaunch + result or store approval may be inferred from an account-free app. Record + outstanding decisions explicitly. + +## Verification and handoff + +Run the focused privacy and version tests red before implementation, then the +app analyzer, ordinary tests, golden tests and repository source gates. Record +any stale golden requiring a reviewed update. Physical release-candidate BLE +checks and iOS distribution parity remain separate release gates; an emulator +or historical engineering report does not close them. + +Keep the public website's existing testing status until open testing is +actually available. Console declarations, reviewer arrangements, upload, +review submission and rollout remain explicit handoff steps. This preparation +does not authorize sending tester messages or publishing a production release. + +## Official references + +Verified 10 September 2026: + +- [Open testing](https://support.google.com/googleplay/android-developer/answer/9845334) +- [Target API levels](https://support.google.com/googleplay/android-developer/answer/11926878) +- [User Data policy](https://support.google.com/googleplay/android-developer/answer/10144311) +- [Data safety](https://support.google.com/googleplay/android-developer/answer/10787469) +- [16 KiB page sizes](https://developer.android.com/guide/practices/page-sizes) diff --git a/docs/specifications/hardware.md b/docs/specifications/hardware.md index f0f080fb..e58324b0 100644 --- a/docs/specifications/hardware.md +++ b/docs/specifications/hardware.md @@ -1,6 +1,6 @@ # PyBLE — Hardware Support & Pin Guidance -Status: **DRAFT** · Last updated: 2026-08-12 +Status: **DRAFT** · Last updated: 2026-09-02 PyBLE's platform scope is any microcontroller board that can run MicroPython and provide a Bluetooth Low Energy peripheral stack capable of hosting a @@ -43,11 +43,11 @@ under the same PBLE/1 protocol. | Image profile | Required memory configuration | Provisioning check/action | Release status | Public compatibility claim | |---|---|---|---|---| -| `esp32-4mb` | Classic ESP32; 4 MiB external SPI flash; no PSRAM assumed | `ESP32` | v0.4.2 hardware-tested beta; browser install/recovery passed; qualification pending. Current v0.6.0 source requires fresh exact-byte qualification. | Only boards whose module documentation confirms this flash layout | -| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM; lean board-neutral payload | `ESP32-S3` | v0.4.2 hardware-tested beta; browser install/recovery passed; qualification pending. Current v0.6.0 source requires independent exact-byte qualification. | N16R8-class modules only; no bundled TFT driver or splash | -| `waveshare-esp32-s3-lcd-147b` | Exact Waveshare ESP32-S3-LCD-1.47B; 16 MiB flash; 8 MiB Octal PSRAM | ESP Web Serial · `ESP32-S3` | Selected for v0.6.0; exact-board qualification pending | B-version board only after the full candidate passes; exact image bundles the ST7789 runtime and fresh-install QR splash | -| `esp32-c3-4mb` | ESP32-C3 revision v0.3 or newer; 4 MiB addressable flash; no PSRAM assumed | ESP Web Serial · `ESP32-C3` | Selected for v0.6.0; C3-G0…C3-G6 and common qualification pending | Exact generic profile after the full candidate passes | -| `rpi-pico2-w` | Raspberry Pi Pico 2 W; RP2350 + CYW43439 | Browser-verified `firmware.uf2`; manual BOOTSEL copy | Selected for v0.6.0; GP2 and common qualification pending | Exact Pico 2 W profile after the full candidate passes | +| `esp32-4mb` | Classic ESP32; 4 MiB external SPI flash; no PSRAM assumed | `ESP32` | Qualified in v0.6.0; current v0.6.1 source requires fresh exact-byte qualification. | Only boards whose module documentation confirms this flash layout | +| `esp32-s3-n16r8` | ESP32-S3; 16 MiB flash; 8 MiB Octal PSRAM; lean board-neutral payload | `ESP32-S3` | Qualified in v0.6.0; current v0.6.1 source requires independent exact-byte qualification. | N16R8-class modules only; no bundled TFT driver or splash | +| `waveshare-esp32-s3-lcd-147b` | Exact Waveshare ESP32-S3-LCD-1.47B; 16 MiB flash; 8 MiB Octal PSRAM | ESP Web Serial · `ESP32-S3` | Qualified in v0.6.0; fresh exact-board v0.6.1 qualification pending | Exact B-version board in v0.6.0; the image bundles the ST7789 runtime and fresh-install QR splash | +| `esp32-c3-4mb` | ESP32-C3 revision v0.3 or newer; 4 MiB addressable flash; no PSRAM assumed | ESP Web Serial · `ESP32-C3` | Qualified in v0.6.0; fresh v0.6.1 C3-G0…C3-G6 and common qualification pending | Exact generic profile in v0.6.0; new v0.6.1 bytes require the fresh full-candidate pass | +| `rpi-pico2-w` | Raspberry Pi Pico 2 W; RP2350 + CYW43439 | Browser-verified `firmware.uf2`; manual BOOTSEL copy | Qualified in v0.6.0; fresh v0.6.1 GP2 and common qualification pending | Exact Pico 2 W profile in v0.6.0; new v0.6.1 bytes require the fresh full-candidate pass | The installer family check cannot establish flash capacity, PSRAM type, USB wiring, or power integrity. The user therefore selects and confirms the exact @@ -58,23 +58,25 @@ different flash size or Quad/no PSRAM, are not covered by Matching the N16R8 memory tuple also does not imply that an onboard display or other peripheral exists. The Waveshare row is separate even though ESP Web Tools reports the same family for both S3 images and cannot distinguish them. -The C3 and Pico profiles remain inactive while their status is pending; owning -or building either target is not a substitute for HIL. The -ESP32-C3-MINI-1-N4 v0.4/4 MiB/no-PSRAM module is the selected physical -engineering reference for that generic profile, with all gates still pending -under [its derived qualification contract](firmware/ports/esp32-c3-4mb.md). +All five qualified v0.6.0 profiles are active. Their source-selected v0.6.1 +successors remain unavailable while fresh exact-byte qualification is pending; +owning or building a target is not a substitute for candidate-bound HIL. The +ESP32-C3-MINI-1-N4 v0.4/4 MiB/no-PSRAM module remains the selected physical +engineering reference for that generic profile. Its gates passed for v0.6.0 +and must be rerun for v0.6.1 under +[its derived qualification contract](firmware/ports/esp32-c3-4mb.md). The carrier's reported GPIO8 NeoPixel is an operator-supplied test input only, not a generic pin promise or board-routing profile. The immutable v0.4.2 machine-readable resource policy and HIL ledger contain exactly the first two profile IDs and no C3 thresholds or record. The [supplemental production-browser attestation](../validation/browser-flashing/v0.4.2-production.md) records the two completed browser rows; the ledger's other formal rows remain -pending. The unfinished v0.5.1 candidate policy/HIL work is historical and -cannot qualify the source-selected v0.6.0 tree. ADR-0033 selects one atomic -five-profile v0.6.0 candidate; it MUST generate fresh schema-3 resource policy -and V5 HIL evidence for every row. Pre-split or earlier-candidate evidence -cannot qualify it. C3-G0…C3-G6 and Pico GP2 remain mandatory, and either -pending/failed profile blocks the whole qualified release. +pending. The unfinished v0.5.1 candidate policy/HIL work is historical and did +not qualify v0.6.0. The atomic five-profile v0.6.0 release selected by ADR-0033 +generated and passed its schema-3 resource policy and V5 HIL evidence for every +row. Pre-split or earlier-candidate evidence did not qualify it and cannot +qualify v0.6.1. C3-G0…C3-G6 and Pico GP2 remain mandatory for the fresh v0.6.1 +candidate; either pending or failed profile blocks that entire increment. These are **provisioning image profiles**, not board-routing profiles. They exist solely to keep destructive flash layouts honest. They do not define GPIO @@ -120,15 +122,15 @@ the documented pins. Constructing `machine.SPI(2)` reset the pinned ESP32-S3 runtime, so the named-board Blocky example and TFT HIL workload MUST use bus 1. Other boards still supply their own explicit bus identifier. -### 1.3 Selected ports pending qualification +### 1.3 Current-source requalification status | Board / family | Upstream port · board | BLE stack | Status | |---|---|---|---| -| Raspberry Pi **Pico 2 W** (RP2350 + CYW43439 radio) | `rp2` · `RPI_PICO2_W` | BTstack via MicroPython `bluetooth` | Selected for the v0.6.0 candidate by ADR-0033; GP2, schema-3 resource evidence, verified-UF2/BOOTSEL recovery, and V5 HIL all remain pending ([firmware/ports/rpi-pico2-w.md](firmware/ports/rpi-pico2-w.md)). It is not an active qualified target until all pass. | +| Raspberry Pi **Pico 2 W** (RP2350 + CYW43439 radio) | `rp2` · `RPI_PICO2_W` | BTstack via MicroPython `bluetooth` | Qualified and active in v0.6.0 after GP2, schema-3 resource, verified-UF2/BOOTSEL recovery, and V5 HIL passed. The same gates require fresh v0.6.1 evidence ([firmware/ports/rpi-pico2-w.md](firmware/ports/rpi-pico2-w.md)). | -A selected-pending row may appear in candidate metadata and a visibly -unqualified loopback preview, but never widens an active qualified selector or -support claim before its gates pass. The port's PBLE/1 `chip` token is +A current-source-pending row may appear in candidate metadata and a visibly +unqualified loopback preview, but it never replaces or widens the qualified +v0.6.0 selector before its fresh gates pass. The port's PBLE/1 `chip` token is `rpi-pico2-w`; per §3, absent pin guidance MUST NOT block connection. ## 2. Requirements for a board to work with PyBLE diff --git a/docs/specifications/prd.md b/docs/specifications/prd.md index ba57df4e..4b90c913 100644 --- a/docs/specifications/prd.md +++ b/docs/specifications/prd.md @@ -1,6 +1,6 @@ # PyBLE — Product Requirements Document -Status: **DRAFT** · Owner: project maintainer · Last updated: 2026-08-03 +Status: **DRAFT** · Owner: project maintainer · Last updated: 2026-09-02 Project phase: **Implementation**. This is the apex requirements document; where it overlaps a deeper spec (protocol.md, firmware.md, app.md, hardware.md, architecture.md), the more specific spec wins on its own topic. @@ -174,19 +174,20 @@ Each story selects the applicable categories; the protocol and firmware stories - **Hardware-in-the-loop (HIL)** — on every exact profile claimed by the release: connect, `DEVICE_INFO`, run/stop, console streaming, and a clean multi-file upload without dropping the link, plus resume-on-reconnect and - the resource measurements above. The current v0.4.2 public-beta profile set - is exactly `esp32-4mb` plus `esp32-s3-n16r8`; production-browser installation - and interrupted-flash recovery passed, but the other formal HIL rows remain - pending. The earlier v0.5.1 source-candidate matrix was exactly + the resource measurements above. The historical v0.4.2 public-beta profile + set is exactly `esp32-4mb` plus `esp32-s3-n16r8`; production-browser + installation and interrupted-flash recovery passed, but its other formal HIL + rows remain pending. The earlier v0.5.1 source-candidate matrix was exactly `esp32-4mb`, `esp32-s3-n16r8`, and `waveshare-esp32-s3-lcd-147b`; no exact-byte qualification was completed for - it. The current v0.6.0 contract is the atomic five-profile order + it. The qualified public v0.6.0 release has the atomic five-profile order `esp32-4mb`, `esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, - `esp32-c3-4mb`, and `rpi-pico2-w`; every row remains release-blocking. The - two S3 profiles require independent evidence because - their candidate contracts produce different immutable bytes. No v0.6.0 - exact-byte qualification is asserted here. A milestone is gated by a working - HIL demo, not by merged code alone. + `esp32-c3-4mb`, and `rpi-pico2-w`; every exact v0.6.0 row completed its + release-blocking gates. The two S3 profiles required independent evidence + because their candidate contracts produced different immutable bytes. The + source-selected v0.6.1 tree retains the same atomic order, but every row is + release-blocking again and no v0.6.1 exact-byte qualification is asserted + here. A milestone is gated by a working HIL demo, not by merged code alone. ### §1B.4 SDD+TDD interlock @@ -882,7 +883,12 @@ Conformance requirements: - The agent MUST return the correct PBLE/1 status code ([PBLE/1 §8](protocol.md#8-status--error-codes-1-byte-status-in-rsp)) for every command, including the error cases (`ENOENT`, `EACCES`, `ENOSPC`, `EBUSY`, `ECRC`, `ERANGE`, `EUNSUPPORTED`, etc.). - File transfer MUST implement the windowed-upload + CRC + resume model and download streaming + whole-file CRC verification from [PBLE/1 §5](protocol.md#5-file-transfer-the-reliability-core). A transfer MUST be reported `OK` only after a full-file CRC match. - The console MUST be **observe-anywhere**: `stdout`/`stderr` MUST stream regardless of which client triggered the run. -- The agent MUST advertise its capabilities in HELLO (`chip`, `mpy_version`, `fs_root`, `max_file_size`, `put_window`, `chunk_size`, `has_sd`, `free_mem`) and MUST reject use of any feature it did not advertise. +- The agent MUST advertise the frozen HELLO capability set (`proto`, `agent`, + `chip`, `mpy`, `fs_root`, `mtu`, `window`, `chunk`, `free_mem`, `has_sd`, + `has_identify`, `identify_led`, `auto_run`, `device_id`, and `label`) and MUST + reject use of any feature it did not advertise. PBLE/1 has no serialized + `max_file_size`; upload capacity is admitted dynamically by + [PBLE/1 §5](protocol.md#5-file-transfer-the-reliability-core). ### §10.9 Version pinning @@ -1419,7 +1425,11 @@ PyBLE depends on third-party code at two layers (firmware upstream and Flutter p Compatibility is negotiated on the wire, not assumed (see [protocol.md §7](protocol.md#7-hello--capabilities)). -- The first message after connect MUST be **HELLO**: the app sends `proto_versions[]` (the versions it supports) + `app_name`/`app_version`; the board replies with the chosen `proto_version` and a `caps` set (`chip`, `mpy_version`, `fs_root`, `max_file_size`, `put_window`, `chunk_size`, `has_sd`, `free_mem`). +- The first message after connect MUST be **HELLO**: the app sends + `proto_versions[]` (the versions it supports) plus `app_name`/`app_version`; + the board replies with the chosen `proto` and the exact capability set named + in §10.8. There is no `max_file_size` key; `FILE_PUT_BEGIN` performs the + dynamic storage admission specified by PBLE/1 §5. - The app MUST refuse (with a clear message) a board whose `proto_version` it does not support, and MUST NOT use any feature the board did not advertise in `caps`. - The **INFO characteristic** read MUST return a `DEVICE_INFO`-equivalent payload so a client can identify a board (chip, MicroPython version, free memory) before subscribing (see [protocol.md §2](protocol.md#2-ble-transport-gatt)). - Each app release MUST declare a **minimum supported PBLE/1 capability baseline** and a minimum agent version it interoperates with; the agent likewise declares its minimum app expectations via capabilities. A mismatch surfaces as a "please update the firmware/app" prompt, never a silent failure. @@ -1575,7 +1585,7 @@ Until every profile passes, no public qualification is asserted. |---|---|---|---| | **Time-to-connect** | Median wall-clock from "Connect" tap to editor-ready (TX subscribed, MTU negotiated, HELLO/DEVICE_INFO shown). | SHOULD be < 10 s on a first connect; < 5 s for a saved board. | Validate on HIL. | | **BLE connection success rate** | Fraction of connect attempts to an in-range, advertising board that reach editor-ready without manual retry. | SHOULD be > 95%, measured separately on iOS and Android. | Validate on HIL. | -| **Upload/transfer reliability** | Fraction of file `put`/`get` operations that complete with a verified whole-file CRC match (no silent corruption). | MUST be > 99% for files up to the negotiated `max_file_size`. | Validate on HIL. | +| **Upload/transfer reliability** | Fraction of file `put`/`get` operations that complete with a verified whole-file CRC match (no silent corruption). | MUST be > 99% for files accepted by the dynamic PBLE/1 §5 storage admission and within the release bench sizes. | Validate on HIL. | | **Multi-file upload integrity** | A back-to-back multi-file upload completes with zero dropped connections and every file CRC-verified. | MUST pass the reliability bench with zero drops (story F-11). | Validate on HIL. | | **Recovery success rate** | Fraction of cases where a runaway program (`while True`) is interrupted by **Stop** and the board returns to idle with BLE responsive throughout. | MUST be 100% barring hardware failure. | Validate on HIL. | | **Resume-on-reconnect** | Fraction of transfers interrupted by a simulated link drop that resume from the verified offset and finish CRC-clean. | SHOULD be 100% (story F-10). | Validate on HIL. | @@ -1724,11 +1734,11 @@ The foundational product decisions are resolved and recorded as Architecture Dec MicroPython/ESP-IDF lock bytes are candidate-frozen for v0.4.2, but the per-target footprint budgets and formal approval (especially **ESP32-C3**) remain open under §10.13. Candidate-freezing is not approval. The same - exact source-selected v0.6.0 lock file MUST be deliberately frozen as the + exact source-selected v0.6.1 lock file MUST be deliberately frozen as the immutable release-build/HIL input; historical v0.5.1 selection does not qualify a new candidate. The same candidate MUST then pass the complete exact-profile HIL matrix before its pins and resource gates are - approved. The replacement v0.6.0 prospective public set is exactly the five + approved. The v0.6.1 prospective public set remains exactly the five profiles in §10.12 (§10.9, §17.1, §21.2). A pin change creates a new candidate. New ADRs are added if a pin or budget changes materially. diff --git a/docs/specifications/protocol.md b/docs/specifications/protocol.md index 3f7eab0f..be63fc24 100644 --- a/docs/specifications/protocol.md +++ b/docs/specifications/protocol.md @@ -1,6 +1,6 @@ # PBLE/1 — PyBLE BLE Wire Protocol -Status: **§2–§10 FROZEN for v1.0 (complete)** · Version: 1 · Last updated: 2026-08-15 +Status: **§2–§10 FROZEN for v1.0 (complete)** · Version: 1 · Last updated: 2026-09-03 > PBLE/1 is a **clean-room, original** protocol authored for PyBLE. It reuses no closed-source wire format, opcodes, or UUIDs. It carries PyBLE's app↔board messages over a BLE GATT service. > @@ -23,18 +23,24 @@ The 2026-08-15 runner-event amendment binds each newly created `RUN_STATE` and `CONSOLE_DATA` event to the live connection session at that instant. It changes no PBLE/1 byte. +The 2026-09-02 v0.6.1 hardening amendment freezes HELLO request syntax and +session state, inbound-validation precedence, bounded hostile-fragment +handling, fresh RUN namespaces and stdin boundaries, upload admission and +scratch recovery, and label-byte validation. It adds no opcode, status, +capability key, payload field, UUID, or other PBLE/1 wire byte. + **Freeze ledger (per-section):** | Section | Freeze status | Freeze act | |---|---|---| -| §2 BLE transport (GATT) — Service/RX/TX/INFO UUID base, advertising, MTU | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; default-MTU delivery · 2026-08-15 · `[docs]` | -| §3 Framing — §3.1 message frame, §3.2 fragmentation | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; restart/delivery semantics · 2026-08-15 · `[docs]` | +| §2 BLE transport (GATT) — Service/RX/TX/INFO UUID base, advertising, MTU | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; default-MTU delivery · 2026-08-15; negotiation ordering · 2026-09-02 · `[docs]` | +| §3 Framing — §3.1 message frame, §3.2 fragmentation | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; restart/delivery semantics · 2026-08-15; inbound bounds/precedence · 2026-09-02 · `[docs]` | | §4 Opcodes — the v1.0 opcode set + numbers | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` (closes OI-4) | | §8 Status / error codes — the 1-byte status set + numbers | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | -| §6 Run/Stop/Console — RUN{file,source}, RUN_STATE, EBUSY, STOP, SOFT_REBOOT, CONSOLE_DATA/INPUT | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; transactional RUN admission · 2026-08-14; default-MTU STOP/SOFT_REBOOT admission and per-event session binding · 2026-08-15 · `[docs]` (RUN-file at S3; STOP / console / RUN-source at S4) | -| §7 HELLO & capabilities — caps field set, HELLO-first, INFO==DEVICE_INFO, label max = 24 B (label half of OI-6) | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | +| §6 Run/Stop/Console — RUN{file,source}, RUN_STATE, EBUSY, STOP, SOFT_REBOOT, CONSOLE_DATA/INPUT | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; transactional RUN admission · 2026-08-14; default-MTU STOP/SOFT_REBOOT admission and per-event session binding · 2026-08-15; namespace/stdin isolation · 2026-09-02 · `[docs]` (RUN-file at S3; STOP / console / RUN-source at S4) | +| §7 HELLO & capabilities — caps field set, HELLO-first, INFO==DEVICE_INFO, label max = 24 B (label half of OI-6) | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; request/session syntax and label-byte validation · 2026-09-02 · `[docs]` | | §9 Versioning policy — accept only `VER 0x01`, refuse unsatisfiable, additive caps | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | -| §5 File transfer — read + windowed upload + workspace jail | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | +| §5 File transfer — read + windowed upload + workspace jail | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; scratch/mutation/admission hardening · 2026-09-02 · `[docs]` | | §10 Security — pairing/encryption baseline (non-gating), connected-client-trust, single active writer, no PII/MAC-gating/telemetry | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | The §2 GATT UUID base (`7079626c-…`), the §3 frame + fragmentation bytes, the §4 opcode set + numbers, and the §8 status set + numbers are now **stable inputs** to the firmware M1 stories (F-01, F-02) and the app `pble` client. The §4/§8 freeze **closes OI-4**: opcode and status numbers no longer change within v1.0. No wire bytes changed at this freeze — it flips status only. **Note:** freezing §4 fixes the *opcode set and its numbers*; the label / identify **payload encodings** (`SET_LABEL` max byte-length, `SET_IDENTIFY_LED` GPIO+active-level encoding, `IDENTIFY` blink-duration bound) remain **OI-6** — owned by §4/§7 and frozen before their S3/S4 stories (F-22, F-23), not at this freeze. @@ -80,6 +86,13 @@ PyBLE defines one primary GATT service with a PyBLE-owned 128-bit UUID base. The fragment progress MUST NOT restart or extend it. - **INFO characteristic:** a read returns the same payload as a `DEVICE_INFO` response (chip, MicroPython version, free memory, `fs_root`, MTU, the stable `device_id`, and the `label`), so a client can identify a board before subscribing. +GATT discovery, enabling the TX characteristic's CCCD, an INFO read, and an +optional MTU exchange are transport setup, not PBLE/1 message exchanges. A +client MUST enable TX notifications before writing its first PBLE/1 frame so +the board can deliver the HELLO response. The first complete PBLE/1 `CMD` in +each connection/VM epoch is HELLO; reading INFO neither negotiates nor changes +that ordering. + ## 3. Framing > **FROZEN for v1.0 (G0 · 2026-07-01 · `[docs]`).** The §3.1 message frame and §3.2 fragmentation bytes are stable; amend only via a `[docs]` commit before dependent code. (`OPCODE` *values* are §4-owned and remain DRAFT; the frame *structure* is frozen.) @@ -100,6 +113,16 @@ PyBLE defines one primary GATT service with a PyBLE-owned 128-bit UUID base. The - `LEN` — payload length, little-endian `uint16` (≤ 65535). - `CRC32` — IEEE CRC-32 over `VER…PAYLOAD` (the header + payload, excluding the CRC itself), little-endian. +**Host qualification-tool clarification (2026-09-06).** The nonzero `CMD` +request-ID rule also applies to no-response commands such as `FILE_PUT_DATA`. +Absence of a `RSP` does not make its ID irrelevant. Valid transfer benches +MUST allocate every data submission, including a Go-Back-N resend, from the +same 1–255 wrapping allocator as their transfer-control commands. They MUST +NOT let an unbounded counter truncate to zero at wire encoding. This changes +no wire field, response behavior, chunk, window, ACK watermark, retransmit +policy, or measurement boundary. Raw malformed-ID construction remains +available solely for explicit negative protocol tests, not valid transfers. + For a response-bearing command, the connection-local correlation key is the originating `{OPCODE, ID}` pair and the matching frame's `TYPE` is `RSP`. A pending command MUST complete only from a frame with all three exact values. @@ -125,6 +148,39 @@ A message larger than one packet is split across consecutive RX writes (or TX no `FRAG_HDR` bits: `bit7 = FIRST`, `bit6 = LAST`, `bits5..0 = index mod 64`. The receiver concatenates `FRAGMENT DATA` from the `FIRST` packet through the `LAST` packet (indices increasing mod 64) to reconstruct the §3.1 message, then validates the CRC. Receipt of `FIRST` always abandons any incomplete fragment run and starts a new one; a non-`FIRST` packet with no active run or with the wrong next index is dropped. This restart rule lets a sender restart an identical whole frame after its logical message ownership was preempted, without completing a stale prefix; ordinary transient pressure alone retries only the unaccepted fragment. A frame whose CRC fails is dropped and answered with `EVT ERROR(ECRC)` referencing the opcode if known. +**Inbound hardening (amended 2026-09-02).** Each `FIRST` starts one absolute +**5000 ms** receive-reassembly deadline. Fragment progress never extends it. +The receiver checks that deadline before accepting every continuation; an +expired continuation discards the whole run. A new `FIRST` is always permitted +to abandon and replace an incomplete or expired run. Reassembly storage remains +bounded by the port's advertised/defined maximum message size; an oversize run +is discarded as one run, and its trailing non-`FIRST` fragments are suppressed +rather than counted repeatedly. + +The agent validates one completed inbound frame in this order: exact structural +length, CRC, direction/request ID, `VER`, HELLO/session state, then opcode and +handler payload. CRC failure is always the existing `EVT` with the offending +opcode where safely available, `ID=0`, and payload `ECRC`; it is never a `RSP`. +A structurally invalid frame receives `RSP{EBADREQ}` only when the complete +six-byte header is safely present and identifies `VER=1`, `TYPE=CMD`, and a +nonzero request ID; +otherwise it is silently dropped. After structural and CRC validation, an +inbound frame whose `TYPE` is not `CMD` or whose request ID is zero is silently +dropped, preventing response loops. A different `VER` in an otherwise valid +`CMD` receives `RSP{EBADREQ}`. An unknown opcode after successful negotiation +receives `RSP{EUNSUPPORTED}`. + +Each exact connection/VM epoch has a monotonic malformed-input budget of +**8 protocol violations**. The counted units are one per discarded bad +fragment run/frame or rejected command: expired continuation, fragment gap or +wrong index, oversize run, structural/CRC/`VER`/direction/ID failure, malformed +HELLO syntax, and a non-HELLO command before negotiation. A valid but +unsupported HELLO version offer, an unsupported opcode after negotiation, and +handler-level payload/status errors do not consume this budget. On the eighth +violation the agent clears RX state and terminates that exact session without +attempting another error frame. Only disconnect or VM-epoch rotation clears +the counter. + The ESP reference agent lifecycle-gates the entire RX write callback, not only complete-message dispatch. Before reading or copying any fragment byte, the host callback makes one non-blocking lifecycle-activity entry. A closed or @@ -209,13 +265,19 @@ reserved for exact `CLEANING` after any required watchdog stop succeeds. Repeated close requests for the same token are idempotent. Every TX attempt carries its originating full token to the sole Notify exit; a new snapshot of a reused numeric handle cannot substitute for that ownership. The physical -recursive TX mutex has one lock order with lifecycle state: TX mutex first, -then the session critical section. The final exact-token check and -`ble_gatts_notify_custom` remain inside that TX ownership, and every -connect/open, `OPEN → CLOSING`, and disconnect/reset cleanup claim uses the -same serialization. A Notify therefore either completes while its token is -still `OPEN`, or a winning lifecycle transition makes it fail before the -NimBLE call. The state machine is `CLOSED`/`OPEN`/`CLOSING`/`CLEANING`/ +recursive TX mutex has one lock order with lifecycle state for ordinary TX, +connect/open, and disconnect/reset cleanup: TX mutex first, then the session +critical section. The final exact-token check and `ble_gatts_notify_custom` +remain inside that TX ownership. Required termination first claims an exact- +session terminal-admission latch under the session critical section and records +the start of the one absolute termination deadline. Every live/admission check +treats that latch as non-live before the host waits for the physical TX mutex +using only the deadline residual. A Notify that already passed its final exact- +token check may finish during that bounded drain; no new CMD, ticket, or TX +admission can begin. With TX ownership, the host revalidates the latch and uses +the normal TX-then-session lock order to claim `OPEN → CLOSING`. Failure to +acquire the TX mutex by the deadline claims `RESTARTING` and restarts. The state +machine is `CLOSED`/`OPEN`/`CLOSING`/`CLEANING`/ `RESTARTING`, and neither a reused handle nor a later lifecycle event can open or clean a terminal `RESTARTING` token. @@ -227,17 +289,20 @@ a nonzero generation and opens the exact token under the session critical section before exposing it. If the state is not `CLOSED`, the board restarts instead of overwriting an old token. -The `OPEN → CLOSING` reducer step reads `esp_timer_get_time()` once and stores -one absolute deadline 2500 ms ahead. The initial physical arm uses only the -positive residual `deadline - esp_timer_get_time()`, never a fresh 2500 ms -interval; reaching the deadline before the arm instead claims `RESTARTING`. -The reducer begin, residual calculation, `esp_timer_start_once`, and reducer -arm acknowledgement form one uninterrupted session-critical transaction. A -task-dispatched callback therefore cannot consume the physical one-shot while -the reducer still considers it unarmed. Only after a successful acknowledgement -does the host make exactly one `ble_gap_terminate` call, outside that critical -section. The deadline never moves. Initial arm failure claims `RESTARTING` and -restarts without attempting GAP. Return `0` and `BLE_HS_EALREADY` mean only +The terminal-admission latch reads `esp_timer_get_time()` once. The later +`OPEN → CLOSING` reducer step receives that same start time and therefore +stores one absolute deadline 2500 ms ahead. The initial physical arm uses only +the positive residual `deadline - esp_timer_get_time()`, never a fresh 2500 ms +interval; reaching the deadline before mutex acquisition or arm instead claims +`RESTARTING`. Reducer begin, immutable watchdog-ticket capture, residual +calculation, `esp_timer_start_once`, and reducer arm acknowledgement form one +uninterrupted session-critical transaction while the TX mutex remains owned. A +task-dispatched callback therefore cannot consume the physical +one-shot while the reducer still considers it unarmed. Only after a successful +acknowledgement does the host make exactly one `ble_gap_terminate` call, outside +both locks. The deadline never moves. TX-drain timeout or initial arm failure +claims `RESTARTING` and restarts without attempting GAP. Return `0` and +`BLE_HS_EALREADY` mean only that GAP teardown is already pending; the agent waits for exact disconnect or NimBLE reset while the watchdog remains armed. Any other return claims `RESTARTING` and invokes public non-returning `esp_restart()` immediately. A @@ -421,9 +486,19 @@ execution, interrupt, or reset side effect. | `0x40` | RUN_STATE | EVT | State transition: `idle` / `running` / `done` / `error`. | | `0x41` | FILE_PUT_ACK | EVT | Cumulative-offset acknowledgement for uploads (§5). | | `0x50` | SET_LABEL | CMD/RSP | Set the persisted device label (UTF-8, bounded length); it becomes the advertised name and `DEVICE_INFO.label`. Empty clears it back to `PyBLE-XXXX`. | -| `0x51` | SET_IDENTIFY_LED | CMD/RSP | Configure the **single** optional identify status-LED: payload `[gpio:u8][active_level:u8]` (`active_level` 0=active-low, 1=active-high), persisted; **empty payload clears it**. `ERANGE` if `gpio` out of range, `EBADREQ` if `active_level`∉{0,1}. Device config only — **not** a routing/pin profile, never exposed to user code. | +| `0x51` | SET_IDENTIFY_LED | CMD/RSP | Configure the **single** optional identify status-LED: payload is **exactly** `[gpio:u8][active_level:u8]` (`active_level` 0=active-low, 1=active-high), persisted; **exactly empty** payload clears it. Every other length, including trailing bytes, is `EBADREQ`; `ERANGE` if `gpio` is out of range, `EBADREQ` if `active_level`∉{0,1}. Device config only — **not** a routing/pin profile, never exposed to user code. | | `0x52` | IDENTIFY | CMD/RSP | Blink the configured identify LED (5 Hz) for an optional `[duration_ds:u8]` (1–50 deciseconds; absent/0 → default 20 = 2 s; >50 clamped). Non-blocking — `RSP{OK}` returns immediately. `EUNSUPPORTED` if no identify LED is configured. | +Every opcode payload above and in §§5–7 is an exact grammar. A structured or +fixed-length payload MUST be consumed in full; otherwise trailing bytes are +`EBADREQ` with no handler side effect. This includes empty `DEVICE_INFO`, +`STOP`, and `SOFT_REBOOT`; all path and offset/CRC forms; one-byte +`SET_AUTORUN`; zero-or-two-byte `SET_IDENTIFY_LED`; and zero-or-one-byte +`IDENTIFY`. A field explicitly defined as the remaining bytes—RUN file/source, +`FILE_PUT_DATA` data, `CONSOLE_INPUT`, or `SET_LABEL`—naturally consumes that +remainder. PBLE/1 has no implicit extension bytes; a future additive form needs +a capability/opcode or a documented amendment before implementation. + The ESP reference `IDENTIFY` timer must also close a task-dispatch race that is not visible on the wire. In the pinned ESP-IDF, a due periodic timer is reinserted and its callback/argument are copied before the timer-list lock is @@ -458,7 +533,7 @@ before wrap, so the quiescence seam cannot reintroduce ABA. ### Read (F-08) -- **`FILE_LIST` (0x10)** `[plen][path]` → `RSP [status]`; on `OK`, then `[more:u8][count:u16]` and count× `{[etype:u8][esize:u32][nlen:u16][name]}` (`etype` 0=file / 1=dir; `more`=1 if the listing was truncated to the worker buffer). +- **`FILE_LIST` (0x10)** `[plen][path]` → `RSP [status]`; on `OK`, then `[more:u8][count:u16]` and count× `{[etype:u8][esize:u32][nlen:u16][name]}` (`etype` 0=file / 1=dir; `more`=1 if at least one **visible** entry was truncated to the worker buffer). Every entry whose basename ends with the case-sensitive reserved `.pbltmp` suffix is omitted before stat, count, and response-budget accounting, whether that entry is a file or directory. - **`FILE_STAT` (0x11)** `[plen][path]` → `RSP [status]`; on `OK`, then `[size:u32][crc32:u32]`. Missing path → `ENOENT`. - **`FILE_GET_BEGIN` (0x12)** `[offset:u32][plen][path]` → `RSP [status]`; on `OK`, then `[total_size:u32]`. The whole-file CRC is delivered in `FILE_GET_END` (not up front — avoids a pre-scan double read); `FILE_STAT` first if you want it early. - **`FILE_GET_DATA` (0x13, EVT, id 0)** `[offset:u32][bytes]` — one BLE packet each. @@ -466,23 +541,89 @@ before wrap, so the quiescence seam cannot reintroduce ABA. ### Windowed upload (F-09) — sliding window, cumulative ACK, Go-Back-N -- **`FILE_PUT_BEGIN` (0x15)** `[total_size:u32][crc32:u32][plen][path]` → `RSP [status]`; on `OK`, then `[resume_offset:u32]` (0 in S5; resume fills it later). Opens a jailed temp `.pbltmp` (truncated); watermark = 0. -- **`FILE_PUT_DATA` (0x16, CMD, no RSP)** `[offset:u32][bytes]`. `offset==watermark` → write + advance + `ACK{watermark}`; `offsetwatermark` → gap → drop + `ACK{watermark}` (app resends from there). No out-of-order buffering. +- **`FILE_PUT_BEGIN` (0x15)** `[total_size:u32][crc32:u32][plen][path]` → `RSP [status]`; on `OK`, then `[resume_offset:u32]`. It prepares the jailed sibling `.pbltmp`, verifies any resumable prefix, applies the admission rule below, and sets the watermark to `resume_offset`. +- **`FILE_PUT_DATA` (0x16, CMD, no RSP)** `[offset:u32][bytes]`. `offset==watermark` and `offset + len(bytes) <= total_size` → write + advance + `ACK{watermark}`; `offsetwatermark` → gap → drop + `ACK{watermark}` (app resends from there). A chunk that would cross `total_size` writes no byte and latches `ERANGE` for `FILE_PUT_END`. Watermark and CRC advance only after the VFS reports exact, positive progress covering the entire input chunk; `None`, zero, an impossible count, or an incomplete write latches `EIO`. No out-of-order buffering. - **`FILE_PUT_ACK` (0x41, EVT, id 0)** `[ack_offset:u32]` = highest contiguous byte written = next expected offset. -- **`FILE_PUT_END` (0x17)** `[crc32:u32]` → `RSP [status]`. `watermark ≠ total_size` → `ERANGE`; a latched write error → `ENOSPC`/`EIO`; temp CRC ≠ `crc32` → `ECRC`. In **every** failure the temp is deleted and **the old file is kept** (FR-FS-14). Else fsync + `rename(temp,dest)` (atomic on LittleFS) → `OK`. +- **`FILE_PUT_END` (0x17)** `[crc32:u32]` → `RSP [status]`. `watermark ≠ total_size` → `ERANGE`; a latched write error → `ENOSPC`/`EIO`; temp CRC ≠ `crc32` → `ECRC`. For every such failure, abort closes the scratch object, removes the scratch path non-recursively, and confirms absence before reporting the originating status; **the old file is kept** (FR-FS-14). A close/removal/absence-verification failure returns `EIO` instead, retains the old target, and never reports successful cleanup; any unremovable scratch remains reserved and hidden. Else fsync + `rename(temp,dest)` (atomic on LittleFS) → `OK`. - **`FILE_DELETE` (0x18)** `[plen][path]`: file → remove; empty dir → rmdir; non-empty dir → `EACCES` (no recursive delete); missing → `ENOENT`. - **`MKDIR` (0x19)** `[plen][path]`: already-a-dir → `OK` (idempotent); an existing file → `EBADREQ`; missing parent → `ENOENT`. -- **`FILE_RENAME` (0x1A)** `[slen][src][dlen][dst]`: both jailed; src missing → `ENOENT`; dst a non-empty dir → `EACCES`; else atomic rename → `OK`. - -**Resume on reconnect (F-10):** a link drop mid-`PUT` resets the in-RAM transfer state, but the jailed `.pbltmp` + its watermark persist on flash. On reconnect a `FILE_PUT_BEGIN` for the same dest returns `resume_offset` = the existing temp length (the contiguous prefix the board itself wrote; the worker re-CRCs `temp[0,len)` to re-seed the running whole-file CRC and set the watermark). `temp_len > total_size` → truncate to 0 (`resume_offset = 0`). The app resumes `FILE_PUT_DATA` from `resume_offset`; the whole-file CRC at `FILE_PUT_END` stays the only correctness gate — a bad/foreign prefix → `ECRC`, temp deleted, **old file kept byte-for-byte** (never a silent corruption or restart-from-zero). +- **`FILE_RENAME` (0x1A)** `[slen][src][dlen][dst]`: both jailed; src missing → `ENOENT`; dst a non-empty dir → `EACCES`; else atomic rename → `OK`. Each path independently retains the 128-byte maximum, so the receiver and any deferred-work envelope MUST accept the legal 260-byte request payload (`2 + 128 + 2 + 128`); 129 bytes in either path is `ERANGE`. + +The three namespace mutations parse and jail-resolve every supplied path first. +While a PUT or GET is active they then return `EBUSY` before any stat or +mutation; therefore malformed payloads still take `EBADREQ`, forbidden paths +still take `EACCES`, and a valid mutation takes `EBUSY` independent of path +existence. This is deliberately conservative: DELETE, MKDIR, and RENAME are +all serialized even when their valid paths do not name the file being +transferred. `FILE_LIST` and `FILE_STAT` remain available during either +transfer. This serialization is only for the PBLE/1 bridge; ordinary user code +retains direct VFS access. + +**Resume on reconnect (F-10):** a link drop mid-`PUT` resets the in-RAM transfer state, but the jailed `.pbltmp` prefix persists on flash. A scratch prefix is resumable only when it is a regular file, `0 < length <= total_size`, exactly that many bytes are read and CRC'd, and its type and length remain stable through that scan. Only then may `FILE_PUT_BEGIN` return the verified length as a nonzero `resume_offset`, re-seed the running whole-file CRC, and set the watermark. A missing or zero-length regular scratch means a fresh upload. An oversized, short-read, unreadable, changed, or type-invalid scratch is malformed: the agent removes a malformed regular file or empty scratch directory, confirms absence, and restarts at zero. It never recursively removes, truncates, renames, or otherwise changes a nonempty scratch directory or the old destination; a nonempty scratch directory or failed safe removal returns `EIO`. A structurally valid but foreign prefix may resume, but the whole-file CRC at `FILE_PUT_END` remains the content-identity gate: `ECRC` deletes the scratch and keeps the old destination byte-for-byte. + +Every queued or in-flight transfer operation owns the exact `{handle, +connection generation, VM epoch}` from its command. Disconnect/VM invalidation +atomically advances a transfer generation before successor work can publish +state. In particular, a delayed `FILE_PUT_BEGIN` may finish one indivisible VFS +call that began while its token was live, but after the mandatory revalidation +it may only close its own newly opened object: it MUST NOT install or clear +active-transfer state, clear/adopt successor state, emit an ACK, or publish a +response. Transfer-state publication and its final ownership check form one +indivisible cut. A later session may reclaim an active record only when that +record's stored generation is stale; DATA and END require an exact generation +match. These rules prevent a predecessor disconnect race from erasing or +adopting a successor upload and change no PBLE/1 byte. + +Any VFS stream count is validated before the corresponding buffer range is +read, CRC'd, copied, or exposed. It MUST be an integer in `0..requested`; a +negative, non-integer, or over-reported count is `EIO`. A zero count before the +promised extent is complete is a short read and is also `EIO`. For the portable +binary-file API, the exact equivalent is an exact `bytes` result whose length +is in `0..requested`; any other result is `EIO`. A fault discovered before a +response is committed returns `EIO` normally. Once a successful +`FILE_GET_BEGIN` response is committed, a read fault aborts that stream, emits +no later `FILE_GET_DATA`, and MUST NOT emit `FILE_GET_END`; the client therefore +uses its existing missing-END/extent timeout and reports an unsuccessful +download. No replacement response or new error event is invented within +PBLE/1. + +**Upload admission (amended 2026-09-02):** PBLE/1 retains the existing +`total_size:u32`; v0.6.1 adds no fixed-size capability. Before creating or +growing scratch, `FILE_PUT_BEGIN` reads `statvfs(fs_root)` and applies a +**65,536-byte safety reserve** using checked 64-bit arithmetic: + +```text +unit = f_frsize +free = f_bavail * unit +remaining = total_size - resume_offset +needed = ceil(remaining / unit) * unit +accept iff free >= 65,536 + needed +``` -**Workspace jail (F-17):** every path is canonicalized against `fs_root` at a **single chokepoint** before any vfs op; traversal (`../`) / absolute escapes outside `fs_root`, any component ending with the case-sensitive reserved `.pbltmp` suffix, and a case-sensitive reserved first canonical component relative to `fs_root` → `EACCES` (SEC-4). The reserved first component is any lowercase name beginning `pyble` or `pble`, or exact `boot.py` / `_boot.py`; the same basename below an ordinary first component is allowed, and ordinary root names such as `main.py` remain allowed. **`.py` / data only** — the agent never requires, generates, or accepts `.mpy` / `.pyc` transfer artifacts; no server-side compilation. +Zero/invalid geometry, invalid counters, or arithmetic overflow returns `EIO`; +insufficient space returns `ENOSPC`. Existing scratch allocation is already +reflected in `free`, so only its verified remainder is charged. Space occupied +by the old destination is never credited because that file remains until the +atomic commit. The reserve is admission headroom, not a promise about the exact +post-write free count because VFS metadata can consume additional blocks. + +**Workspace filesystem.** Official PyBLE images mount their internal workspace +as LFS2 on all five profiles. The ESP partition-table `data,fat` subtype is only +ESP-IDF block-container metadata; the label `vfs` selects MicroPython's LFS2 +first-use provisioning and the on-media format is LFS2. ESP boot must construct +the LFS2 VFS explicitly instead of accepting the generic FAT fallback. A +nonblank incompatible or corrupt workspace fails closed without formatting or +starting the PBLE/1 agent; migration/recovery is an explicit operator action. +This is what makes the successful scratch-to-target replacement above an +atomic filesystem commit rather than FAT's delete-then-rename sequence. + +**Workspace jail (F-17):** every path is strict scalar UTF-8 and is canonicalized against `fs_root` at a **single chokepoint** before any vfs op; malformed UTF-8 → `EBADREQ`, while traversal (`../`) / absolute escapes outside `fs_root`, any component ending with the case-sensitive reserved `.pbltmp` suffix, and a case-sensitive reserved first canonical component relative to `fs_root` → `EACCES` (SEC-4). The reserved first component is any lowercase name beginning `pyble` or `pble`, or exact `boot.py` / `_boot.py`; the same basename below an ordinary first component is allowed, and ordinary root names such as `main.py` remain allowed. Scratch names are also hidden from listings as specified above. **`.py` / data only** — the agent never requires, generates, or accepts `.mpy` / `.pyc` transfer artifacts; no server-side compilation. ## 6. Run / Stop / Console > **FROZEN for v1.0 (amended)** — RUN-file at G1 · S3; **`RUN{source}`, `STOP`, `SOFT_REBOOT`, `CONSOLE_DATA`, `CONSOLE_INPUT` frozen at G1 · S4 (2026-07-01 · `[docs]`)**; transactional RUN admission clarified 2026-08-14; default-MTU `STOP`/`SOFT_REBOOT` response-before-side-effect admission, per-event session binding, the runner pickup/control-resolution cut, and the bounded current-message TX-boundary wait clarified 2026-08-15. Wire below; amend only via a `[docs]` commit before dependent code. -- **`RUN` (0x20)** payload `[mode:u8][data]` — `mode` 0=file (`data` = UTF-8 path), 1=source (`data` = UTF-8 snippet). → `RSP{status}` (`OK` | `EBUSY` if one already running, FR-RUN-4 | `EBADREQ` bad mode | `ERANGE` over-length), then `RUN_STATE(running)`. Both modes share one lifecycle. Completion → `RUN_STATE(done)`; an uncaught exception → `CONSOLE_DATA(stderr, traceback)` then `RUN_STATE(error)`. A missing/inaccessible file surfaces asynchronously (`CONSOLE_DATA(stderr)` + `RUN_STATE(error)`), not as the `RSP`. +- **`RUN` (0x20)** payload `[mode:u8][data]` — `mode` 0=file (`data` = UTF-8 path), 1=source (`data` = UTF-8 snippet). → `RSP{status}` (`OK` | `EBUSY` if one already running, FR-RUN-4 | `EBADREQ` bad mode | `ERANGE` over-length), then `RUN_STATE(running)`. Both modes share one lifecycle. Completion → `RUN_STATE(done)`; an uncaught exception → `CONSOLE_DATA(stderr, traceback)` then `RUN_STATE(error)`. A missing/inaccessible file surfaces asynchronously (`CONSOLE_DATA(stderr)` + `RUN_STATE(error)`), not as the `RSP`. Every ordinary or autorun execution receives a fresh globals/locals dictionary containing `__name__ = "__main__"`; variables created by an earlier run are absent. This fresh-globals rule is limited to ordinary and autorun execution: it preserves the possibility of a future, separately specified, capability-gated persistent interactive mode, which MUST NOT silently change ordinary `RUN` isolation. PBLE/1 v0.6.1 does not yet define `__file__`, working-directory changes, sibling-import setup, or `sys.modules` cleanup. An otherwise valid, non-busy RUN is admitted transactionally. The ESP reference agent makes a provisional, non-observable reservation and copies @@ -565,8 +706,17 @@ before wrap, so the quiescence seam cannot reintroduce ABA. the gate, before timer arm. On failure the provisional FS/VM gates are aborted before pickup is released without stop intent. Timer-arm failure remains the documented post-acceptance non-returning restart exception. + + While an accepted `SOFT_REBOOT` keeps global command admission closed, that + closing state has precedence over HELLO/session negotiation and malformed- + session violation accounting, including if a numeric handle is re-presented + before the 250 ms grace ends. Every otherwise structurally valid response- + bearing CMD, including `DEVICE_INFO`, `HELLO`, and a duplicate + `SOFT_REBOOT`, returns `EBUSY`; a no-response command is silently discarded. + No handler side effect or violation debit occurs. Only VM initialization may + reopen admission. - **`CONSOLE_DATA` (0x30, EVT, id 0)** payload `[stream:u8][bytes]` — `stream` 0=stdout, 1=stderr (FR-CON-1/2). -- **`CONSOLE_INPUT` (0x31, CMD, no RSP)** payload `[bytes]` — appended to the running program's `stdin` (`input()`/`sys.stdin`); fire-and-forget, no reply frame (FR-CON-3). +- **`CONSOLE_INPUT` (0x31, CMD, no RSP)** payload `[bytes]` — appended to the active program's bounded `stdin` (`input()`/`sys.stdin`); fire-and-forget, no reply frame (FR-CON-3). Input received while no program is active is silently discarded. A successful RUN response-admission cut clears stdin before waking the runner; autorun admission does the same. Accepted STOP clears it after response handoff, and every terminal transition, disconnect, and VM reset clears it. Disconnect does not itself stop a continuing run, so the active flag remains true and a newly negotiated client may feed that same run; only the queued bytes from before the disconnect are lost. These boundaries prevent idle input and overflow from one run being consumed by another. - **`RUN_STATE` (0x40, EVT, id 0)** payload `[state:u8]` — 0 idle / 1 running / 2 done / 3 error (FR-RUN-7). The console is **observe-anywhere**: `stdout`/`stderr` stream regardless of @@ -582,13 +732,64 @@ It does not change `RUN` command admission: the matching response and execution cut remain bound to the command's originating session. USB is a local-debug mirror only — never a runtime transport (FR-CON-5). -## 7. HELLO & capabilities +Terminal state publication, stdin deactivation/clear, and release of the +single-run reservation are one ordered lifecycle cut. A successor RUN cannot +become reservable until its predecessor's stdin is empty and inactive; +terminal cleanup from the predecessor cannot later erase bytes admitted for +the successor. The clear/deactivate operation is allocation-free, including +under memory pressure. -> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`).** The capability field set below, the **HELLO-is-the-first-exchange** rule, and the invariant that an **INFO-characteristic read returns the same `DEVICE_INFO` payload** are stable — the DoR for F-03 `pyble_info` and F-16. **Closes the label half of OI-6: the `SET_LABEL` / `label` maximum is 24 bytes (UTF-8 encoded); an over-length label is rejected with `ERANGE` and not stored, and the same bound applies to the advertised name.** **Closes the OI-6 remainder at S4:** `SET_IDENTIFY_LED` = `[gpio:u8][active_level:u8]` (empty clears), `IDENTIFY` = optional `[duration_ds:u8]` (1–50 ds, default 20, 5 Hz). Amend only via a `[docs]` commit before dependent code. - -`HELLO { proto_versions[], app_name, app_version }` is the **first exchange after connect** (before subscribing to TX) → `RSP { proto_version, caps }` where `caps` includes: `chip` (the PBLE/1 wire key for a port-defined ASCII target identifier; the v1 reference agents emit `esp32`/`esp32-s3`/`esp32-c3`), `mpy_version`, `agent_version` (the PyBLE agent firmware SemVer), `fs_root`, `max_file_size`, `put_window W`, `chunk_size`, `has_sd`, `free_mem`, `device_id` (a stable, non-personal port-defined suffix; MAC-derived on the ESP32 reference port), `label` (the user-set device label, or empty; **max 24 bytes UTF-8, else `ERANGE`**), `has_identify` (the board supports `IDENTIFY`), `identify_led` (the configured identify-LED GPIO as a byte, or `0xFF` = none), and `auto_run` (whether `/main.py` auto-runs at boot: 0=off default, 1=on; set via `SET_AUTORUN`). Reading the **INFO characteristic returns the same `DEVICE_INFO` caps payload** (no subscription needed). +## 7. HELLO & capabilities -**Caps payload serialization (frozen 2026-07-02 · `[docs]`, S2-app coordination):** the caps payload is newline-separated ASCII `key=value` text, one pair per line, with the short key tokens the reference agent emits — `proto`, `agent`, `chip`, `mpy`, `fs_root`, `mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led` (integer GPIO; `255` = none), `auto_run`, `device_id`, `label` (may be empty). Booleans are `0`/`1`; integers are decimal. Clients MUST parse tolerantly — unknown keys are ignored (additive caps, §9) and key order is not significant. The `HELLO` RSP payload is `[status:u8]` followed by this same caps text. +> **FROZEN for v1.0 (G1 · 2026-07-01; amended 2026-09-02 · `[docs]`).** The capability field set below, the **HELLO-is-the-first-exchange** rule, and the invariant that an **INFO-characteristic read returns the same `DEVICE_INFO` payload** are stable — the DoR for F-03 `pyble_info` and F-16. **Closes the label half of OI-6: the `SET_LABEL` / `label` maximum is 24 bytes (UTF-8 encoded); an over-length label is rejected with `ERANGE` and not stored, and the same bound applies to the advertised name.** **Closes the OI-6 remainder at S4:** `SET_IDENTIFY_LED` = `[gpio:u8][active_level:u8]` (empty clears), `IDENTIFY` = optional `[duration_ds:u8]` (1–50 ds, default 20, 5 Hz). Amend only via a `[docs]` commit before dependent code. + +`HELLO` is the first complete PBLE/1 frame after the §2 transport setup. Its +request payload is newline-separated ASCII `key=value` text no longer than +**192 bytes**. A valid request contains exactly one each of the required keys +`proto_versions`, `app_name`, and `app_version`; key order is insignificant. +Lines use LF (`0x0A`), one optional trailing LF is accepted, and empty interior +lines are invalid. Each line is split at its first `=`. Keys match +`[a-z][a-z0-9_]{0,31}`. Unknown well-formed keys with printable ASCII values no +longer than 32 bytes are ignored. A duplicate or missing required key, +CR/NUL/non-ASCII byte, malformed key/value line, or over-bound payload/value is +`EBADREQ`. + +`proto_versions` is 1–8 comma-separated unsigned canonical decimal integers +in `0..255`; empty elements, leading zeroes except the single token `0`, and +non-digits are invalid. `app_name` and `app_version` are each 1–32 bytes of +printable ASCII (`0x20..0x7E`). The v1 agent chooses version 1 exactly when the +offer contains 1. A syntactically valid offer without 1 returns +`EUNSUPPORTED`; malformed syntax returns `EBADREQ`. The agent neither persists, +logs, nor echoes the two app-identification values. + +Each exact `{connection generation, VM epoch}` begins `UNNEGOTIATED`. INFO +reads remain allowed and do not change it. Before negotiation, HELLO is the +only command admitted to an opcode handler. Any other response-bearing command +returns `EBADREQ` without side effects; `FILE_PUT_DATA` and `CONSOLE_INPUT`, +whose opcodes define no `RSP`, are silently dropped without side effects. The +session becomes `NEGOTIATED(v1)` only after the final fragment of its +`HELLO RSP{OK}` is accepted by the bounded, session-bound local Notify path. +Repeating a compatible HELLO is idempotent and returns current caps. A failed +initial HELLO leaves the session unnegotiated; a failed repeated HELLO leaves +an already negotiated session negotiated. Disconnect and VM-epoch rotation +clear negotiation before later RX admission. + +The response contains the frozen caps text below; `proto=1` is the selected +version and no additional selected-version byte exists. The fields describe +`chip` (a port-defined target token), `agent` (agent SemVer), `mpy`, `fs_root`, +`mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led`, +`auto_run`, `device_id`, and `label`. Reading INFO returns the same caps text +without subscription or negotiation. `max_file_size` was never a serialized +PBLE/1 key and is not added in v0.6.1; §5 defines dynamic upload admission. + +**Caps payload serialization (frozen 2026-07-02; clarified 2026-09-02 · `[docs]`, S2-app coordination):** the caps payload is newline-separated UTF-8 `key=value` text, one pair per line, with the short key tokens the reference agent emits — `proto`, `agent`, `chip`, `mpy`, `fs_root`, `mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led` (integer GPIO; `255` = none), `auto_run`, `device_id`, `label` (may be empty). Keys and every non-label value are ASCII; `label` is the sole UTF-8 value. Booleans are `0`/`1`; integers are decimal. Clients MUST parse tolerantly — unknown keys are ignored (additive caps, §9) and key order is not significant. The `HELLO` RSP payload is `[status:u8]` followed by this same caps text. + +For `SET_LABEL`, encoded length is checked first: more than 24 bytes returns +`ERANGE`. Otherwise nonempty input must be strict, well-formed UTF-8 and must +not contain Unicode control code points U+0000–U+001F or U+007F–U+009F; +failure returns `EBADREQ`. Empty input remains the clear operation. Rejected or +failed persistence leaves the stored label, caps value, and advertisement +unchanged. The client MUST treat `chip` as display/reference metadata rather than an allowlist: an unknown value does not block a conforming connection. It must not @@ -621,7 +822,7 @@ client simply ignores them. ## 9. Versioning policy -> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`).** v1.0 supports **exactly `VER = 0x01`** (`PBLE_PROTO_VERSION = 1`): a frame with any other `VER` is refused (`EBADREQ`), and a `HELLO` whose `proto_versions[]` does not include `1` is refused rather than served — the DoR for F-16 (FR-PROTO-7). Capabilities are additive within v1.0 (an older client ignores unknown caps). Amend only via a `[docs]` commit before dependent code. +> **FROZEN for v1.0 (G1 · 2026-07-01; clarified 2026-09-02 · `[docs]`).** v1.0 supports **exactly `VER = 0x01`** (`PBLE_PROTO_VERSION = 1`). Under §3.1 response eligibility, an otherwise valid inbound `CMD` frame with another `VER` receives `EBADREQ`; a syntactically valid HELLO offer without version 1 receives `EUNSUPPORTED`; malformed HELLO syntax receives `EBADREQ`. A compatible repeated HELLO is idempotent. Capabilities are additive within v1.0 (an older client ignores unknown caps). Amend only via a `[docs]` commit before dependent code. `VER` and the HELLO `proto_versions[]` exchange let either side refuse or downgrade gracefully. Backward-incompatible changes bump the protocol to PBLE/2; additive opcodes are gated behind capability flags so old clients keep working. No silent wire-format changes within version 1. diff --git a/docs/specifications/website.md b/docs/specifications/website.md index 56944149..c3128a77 100644 --- a/docs/specifications/website.md +++ b/docs/specifications/website.md @@ -1,7 +1,7 @@ # PyBLE — Public Website Specification Status: **FROZEN (pre-v1 and v1 launch surface)** · Owner: project maintainer · Last updated: -2026-08-28 +2026-09-01 This document is the source of truth for the first public website at `pyble.dev`. It specifies only the public site; the Flutter app, PBLE/1, and @@ -11,9 +11,9 @@ firmware remain governed by their own specifications. The website MUST explain PyBLE accurately, help a beta user get started or ask for support, publish the app's privacy posture, and provide a gated browser -firmware installer for the exact initial ESP32-family image profiles. The -installer MUST distinguish a hardware-tested public beta from a fully -qualified public release. +firmware installer for exact validated release profiles. The installer MUST +distinguish a hardware-tested public beta from a fully qualified public +release. It MUST NOT imply that: @@ -42,6 +42,7 @@ The launch surface is: | `/privacy` | Separate, plain-language disclosures for the Flutter app and public website | | `/support` | Concise troubleshooting, diagnostics checklist, and a direct support contact | | `/flash` | Versioned browser provisioning, compatibility/erase consent, integrity status, recovery, and install action gated by §7 | +| `/features` | Release-bound PBLE/1 firmware architecture, complete operation surface, five exact profiles, limits, and evidence | | `/learn` | Ordered tutorial hub, learning tracks, compatibility/validation legend, and start/continue actions | | `/learn/setup` | Exact-profile selection, provisioning boundary, BLE connection, and identity check | | `/learn/first-program` | Hardware-free editor, Save, Run, Stop, reconnect, and Console workflow | @@ -69,12 +70,12 @@ The home page MAY make these verified claims: - It is a free, MIT-licensed, tablet-first MicroPython IDE designed for microcontroller boards that can run MicroPython and host a compatible Bluetooth Low Energy PyBLE agent. -- The selected, still-pending `v0.6.0` qualified-release candidate has five - exact profiles in release order: +- The active qualified public `v0.6.0` release has five exact profiles in + release order: `esp32-4mb`, `esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, - `esp32-c3-4mb`, and `rpi-pico2-w`. This source/build posture is not five - public support claims: every row remains behind exact-byte qualification and - activation, including C3-G0…C3-G6 and Pico GP2. + `esp32-c3-4mb`, and `rpi-pico2-w`. All five exact-byte HIL rows passed. This + is still exact-profile support, not a chip-family or carrier-board allowlist; + the page may make the claim only while that descriptor is selected. - After one-time wired firmware provisioning, its normal workflow is BLE-first. - The app can scan/connect, edit, save, run, stop, soft reboot, exchange board files, and provide a live console over PBLE/1. @@ -96,16 +97,18 @@ Compatibility copy MUST distinguish platform scope from current support: - actual support requires a published firmware image for the exact target and a truthful release state; browser-installation validation for a beta is narrower than complete release qualification; -- the current public-beta list remains the exact `v0.4.2` `esp32-4mb` and - `esp32-s3-n16r8` profiles; that frozen two-profile exception MUST NOT be - interpreted as the unqualified `v0.5.1` or selected `v0.6.0` shape; -- the selected `v0.6.0` qualified-release contract requires the exact five - ordered profiles in §7; this is an activation contract, not a claim that - those release bytes are qualified or public, and every gate remains pending; +- the exact `v0.4.2` `esp32-4mb` and `esp32-s3-n16r8` public-beta exception is + retained only for authenticated carry-forward validation; it is not the + current public release and MUST NOT be interpreted as the `v0.5.1` or + `v0.6.0` shape; +- the selected qualified public `v0.6.0` release contains all five ordered + profiles in §7 after their common and target-specific gates passed; a future + candidate MUST remain inactive until the same applicable gates pass; - the earlier local `firmware-v0.6.0` tag/candidate at `719b211…` was never present at origin, in GitHub Releases, or at the canonical same-origin - release path and is abandoned. Only ADR-0038's source-era-routed replacement - may become the first public v0.6.0, after fresh candidate HIL/finalization; + release path and is abandoned. ADR-0038's source-era-routed replacement is + the qualified public release bound to annotated tag `firmware-v0.6.0` and + source `0c7230d6708797c241160ba71fbd37e6b22f180a`; - the generic `esp32-s3-n16r8` image MUST NOT bundle `pyble_st7789`, `pyble_waveshare_lcd147b`, exact-board pin data, or the boot splash; the Waveshare profile MUST use its own manifest and different immutable firmware @@ -342,6 +345,80 @@ render without client JavaScript and provide: and - previous/next navigation plus a return to `/learn`. +Every tutorial route MUST also contain at least one purposeful instructional +visual. A visual may be a privacy-reviewed app capture, an exact-board +photograph, or an authored diagram when that form best explains the lesson; a +decorative repetition of the same workspace does not satisfy this requirement. +The initial app-workflow lessons (`setup`, `first-program`, `files`, +`github-import`, `blocks`, and `examples`) MUST use physical-tablet captures of +the reviewed app surface. Hardware lessons MUST prefer actual board imagery for +physical identity and wiring, and MAY pair it with a physical-tablet capture +only when the app state adds distinct instructional evidence. + +Each published app capture MUST: + +- be a static, same-origin, content-versioned asset with explicit pixel + dimensions and a recorded SHA-256 digest; +- retain a local provenance record naming the physical device model, operating + system, app version and build, capture date, depicted state, and source raw + capture without publishing the raw workspace; +- use a semantic figure, a useful alternative description, and a visible + caption that accurately identifies the physical device and depicted state; +- be privacy-reviewed for board identifiers, custom labels, notifications, + accounts, private repositories, tokens, unpublished code, and unrelated + system chrome; +- remove only unrelated system bars or empty framing through deterministic + crop, resize, orientation, and metadata-stripping operations; and +- leave every app pixel truthful: no retouching, generative fill, fabricated + state, or removal of an in-app warning, error, or identity. + +The reviewed five-board Android capture set MUST use only the physical Lenovo +TB-J616X and MUST keep the following evidence boundaries explicit: + +- discovery uses a stopped, stable scan with exactly five distinct + `PyBLE-XXXX` advertisements and `Scan: Idle`; an advertisement proves only a + nearby PBLE agent, not its provisioning profile; +- each connected identity is represented by two deterministic crops from the + same raw frame: the Ready status containing board ID and agent version, and + the read-only runtime-chip panel. The crops MAY be paired only through + semantic HTML/CSS; they MUST NOT be baked into an annotated or retouched + raster composite; +- the connected set records the observed pairs `5646` / `esp32-s3`, `8C9E` / + `esp32`, `C81A` / `esp32-c3`, `DA86` / `esp32-s3`, and `3DCB` / + `rpi-pico2-w`. These are documentation-session observations, not release + qualification or proof of installed profile bytes; +- the two ESP32-S3 observations MUST remain visibly indistinguishable at the + runtime-token layer. A maintainer's physical-board or installer record MAY + identify one of those sessions separately, but the app image MUST NOT be + cited as proof of either `esp32-s3-n16r8` or + `waveshare-esp32-s3-lcd-147b`; and +- full-frame workflow captures MUST show only the intentional disposable + tutorial state. Connected-identity derivatives MUST crop out unrelated board + file lists rather than publish or retouch them. + +The Setup tutorial MUST use the stable five-board discovery frame and all five +paired identity observations. The Hardware safety tutorial MUST use the same +identity set specifically to teach the difference between a runtime token and +an exact profile. Configured hardware, Pico 2 W, and Waveshare tutorials MAY +reuse only the identity observations that add that same boundary evidence; +their existing physical or authored visuals remain necessary for exact-board, +pin, and wiring instruction. The six app-workflow tutorials MUST otherwise use +their clean, task-specific Lenovo captures; repeating an identity crop as +decoration does not satisfy their visual requirement. + +Debug, integration-test, simulator, emulator, and golden-renderer frames MUST +NOT be presented as physical-device captures. A debug or integration-test +label disqualifies the frame from public tutorial use even when the underlying +UI otherwise matches production. Simulator, emulator, or golden images MAY be +used only when their caption names that provenance and the lesson does not +claim physical-device evidence. Tutorial instructions and stop conditions MUST +remain complete when images are unavailable or cannot be seen. + +Raw capture workspaces remain ignored and outside the website package. Only a +separately reviewed derivative and its non-sensitive provenance record MAY be +committed below `tools/web/public/`; publication MUST NOT add a runtime +third-party image request. + Firmware claims MUST derive from the same build-selected release descriptor as the home and `/flash` routes. Stable profile explanations MUST keep the exact release order `esp32-4mb`, `esp32-s3-n16r8`, @@ -377,6 +454,108 @@ Each linked example state MUST distinguish designed profiles from validated profiles, and no future copy may promote an example until its immutable tag and HIL/live-import evidence exist. +### 3.6 Release-bound firmware feature reference + +`/features` is the canonical human-readable overview of what the qualified +PyBLE firmware provides. **Features** MUST be a primary-navigation item, and +the home-page feature summary MUST provide a contextual path to this reference. +The page complements `/flash`: it explains a release, while `/flash` remains +the sole authority for the version, exact profiles, qualification state, and +actions currently selected at build time. + +The current reference snapshot is owner-confirmed public firmware `v0.6.1` +and PBLE/1; the historical v0.6.0 SVG MUST remain available unchanged. +The v0.6.1 reference MUST distinguish owner confirmation from incomplete +automated HIL records and link the publication confirmation. It MUST NOT +carry forward v0.6.0's five passed HIL rows, measured throughput, or reference +board identities as new v0.6.1 measurements. +It MUST visibly call itself a versioned snapshot rather than a live +installer promise, link to `/flash` for current availability, and avoid +describing a later or unselected release as current. Its architecture diagram +MUST say that it is a functional diagram, not a physical board drawing, +schematic, pinout, or automatic board detector. + +The diagram MUST be an original, deterministic, same-origin SVG with selectable +text, an intrinsic `1920 × 1470` view box, an accessible title and long +description, and a provenance record binding the reviewed SHA-256. It MUST NOT +contain a rasterized or generated board likeness, vendor artwork, external +image reference, or inferred carrier-board appearance. The page MUST place it +in a semantic figure with useful alternative text and a visible caption, offer +an operable same-origin full-size link, and allow deliberate two-dimensional +inspection without forcing the whole diagram into unreadable mobile text. +Material information in the diagram MUST also appear in reflowing semantic HTML +so the rasterized visual representation is never the only source. + +The HTML reference MUST cover all of these release-bound surfaces: + +- the BLE discovery and GATT transport boundary, PBLE/1 framing and dispatch, + identity/capability negotiation, filesystem service, program runner, live + console, boot/lifecycle behavior, and user MicroPython runtime; +- all 24 PBLE/1 operation identities, grouped without changing their names: + `HELLO`, `DEVICE_INFO`; `FILE_LIST`, `FILE_STAT`, `FILE_GET_BEGIN`, + `FILE_GET_DATA`, `FILE_GET_END`, `FILE_PUT_BEGIN`, `FILE_PUT_DATA`, + `FILE_PUT_END`, `FILE_DELETE`, `MKDIR`, `FILE_RENAME`, `FILE_PUT_ACK`; + `RUN`, `STOP`, `SOFT_REBOOT`, `SET_AUTORUN`, `RUN_STATE`; `CONSOLE_DATA`, + `CONSOLE_INPUT`; and `SET_LABEL`, `SET_IDENTIFY_LED`, `IDENTIFY`; +- the exact release profile order `esp32-4mb`, `esp32-s3-n16r8`, + `waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, and `rpi-pico2-w`, including + the lean-generic versus exact-board S3 distinction and Pico's distinct + UF2/BOOTSEL and portable-agent path; and +- operational boundaries that affect correct use: exact-profile installation, + one connected trusted client, one active program/transfer class, no recursive + delete, source `.py` rather than compiled `.mpy`/`.pyc` transfer, no SD-card + claim in this release, runtime-dependent Stop behavior, and possible console + drops under sustained output. + +The v0.6.1 diagram and its reflowing description MUST describe unchanged +24-opcode PBLE/1 compatibility, per-session negotiation and bounded fragments, +fresh RUN globals and run-owned stdin, CRC-backed upload resume and mutation +exclusion, checked persistent settings, and LFS2 on all five official profiles. +Only conclusively erased media may be formatted after mount failure; nonblank +or uncertain media must be preserved for USB recovery without agent startup. +Neither the diagram nor its HTML may promise BLE access through that refusal. + +The reference MUST link to the immutable public `v0.6.1` release descriptor, +available version-bound source references, and the PBLE/1 specification. +An unpublished GitHub tag MUST NOT be presented as a working source link. +It MUST not duplicate +firmware binaries or fetch release data at runtime. Calls to action MUST send a +user to `/flash` to install an exact selected profile and `/learn` for guided +use. The route, its full-size diagram asset, and its content MUST render without +client JavaScript or a third-party runtime request. + +### 3.7 Site-wide current-release consistency + +All current-release surfaces MUST agree with the same build-selected release: +home-page introduction and workflow, target cards, support, and Flash. +The exact owner-confirmed v0.6.1 descriptor MUST be presented as available +on all five profiles, never as a protected candidate or unavailable installer. +This does not turn its pending automated records into passed HIL. + +Tutorial guidance MUST identify firmware v0.6.1 separately from historical App +screenshots and the immutable examples snapshot's original v0.6.0 baseline. +Depicted versions, historical asset hashes and snapshot provenance MUST remain +unchanged. Shared tutorial context MUST explain that distinction. Current +guidance MUST cover fresh RUN globals and LFS2 migration/recovery boundaries. + +Social metadata MUST select a new content-addressed card with accurate current +release wording. Do not retouch historical App pixels or claim newly measured +hardware validation. Preserve older content-addressed assets for existing links. + +Publish a coherent full static site from one release selector; partial page +overlays must not leave other current-release pages on an older version. +Preserve versioned firmware artifacts, all tutorials and capture assets. + +The repository README and public roadmap MUST identify the current published +v0.6.1 firmware separately from historical v0.6.0 qualification and citation +snapshots. The README MUST link the exact v0.6.1 source, descriptor digest, +release/recovery material and owner confirmation without claiming completed +automated HIL. Getting-started guidance MUST use the editable official examples +URL and branch chooser, describe a writable board subfolder and review before +import, and retain explicit destructive-provisioning warnings. The App source +version and prepared store handoffs MUST NOT be presented as proof of a live +store release. Link the tutorial center and firmware functional reference. + ## 4. Brand and visual contract The canonical prompt-chip SVG in `app/assets/branding/` is the source asset. A @@ -553,7 +732,7 @@ VPS origin. The origin MUST: release and confirm it only after the public smoke suite succeeds; transient service-manager jobs MUST receive their embedded shell programs verbatim, with manager-side environment expansion disabled; -- map `/app`, `/privacy`, `/support`, `/flash`, `/learn`, and every tutorial +- map `/app`, `/privacy`, `/support`, `/flash`, `/features`, `/learn`, and every tutorial route in §3.5 to their exported HTML without changing the canonical slashless URL; - normalize the corresponding trailing-slash URLs permanently, preserve query @@ -622,7 +801,7 @@ retrieval check. The exception bypasses only structured parsing of that one legacy report. It MUST NOT admit the same bytes through fresh-public, candidate, audited-candidate, protected-preview, or local-preview validation; MUST NOT accept another version or a one-byte change to any file; and MUST NOT -qualify or promote any pending `v0.6.0` candidate. It is continuity for the +qualify or promote any unselected candidate. It is continuity for the already-active immutable selection, not authority to create or reclassify a release. @@ -677,8 +856,8 @@ the exact `firmware.uf2` size/SHA-256, downloads those already verified bytes, and presents manual BOOTSEL-copy instructions. ESP Web Tools' family detection is necessary but not sufficient to establish memory topology, silicon revision, or board peripherals; in particular, it cannot distinguish the two -S3 profiles. C3 and Pico remain pending/inactive until their target gates and -the complete five-profile candidate pass. +S3 profiles. A future candidate keeps C3 and Pico pending/inactive until their +target gates and the complete five-profile candidate pass. The `/flash` page MUST make the exact Waveshare ESP32-S3-LCD-1.47B discoverable without weakening that profile boundary. When—and only when—the build-selected @@ -918,11 +1097,11 @@ or omit that staging directory; preview bytes MUST never enter `out/`, `dist/`, the canonical `/firmware/v/` tree, a candidate/public selector, or a deployment carry-forward marker. -The production `/flash` contract now selects the same five ordered v0.6.0 -profiles under release schema 4, policy schema 3, and HIL V5. C3 and Pico may -appear only as pending/inactive candidate rows until C3-G0…C3-G6, Pico GP2, -and the complete common exact-byte matrix pass; no install/download action or -support claim is active for either before then. When no audited beta or fully +The production `/flash` contract selects the five ordered qualified public +v0.6.0 profiles under release schema 4, policy schema 3, and HIL V5. Their +complete common exact-byte matrix, C3-G0…C3-G6, and Pico GP2 gates passed. A +candidate presents any not-yet-qualified C3 or Pico row as pending/inactive +with no install/download action or support claim. When no audited beta or fully qualified release is selected, production continues to fail closed with no public action. Local preview success is engineering evidence only and MUST NOT alter any of those states. @@ -968,6 +1147,10 @@ The v1 site is releasable when: - `/learn` and all ten ordered tutorials satisfy §3.5, expose truthful release/validation state, cover every qualified firmware profile without inventing a generic carrier pinout, and retain immutable examples provenance; +- `/features` satisfies §3.6, renders its reviewed functional SVG and equivalent + semantic HTML without client JavaScript, contains the complete 24-operation + and five-profile `v0.6.0` reference, links current actions back to `/flash`, + and never presents a generated board likeness, schematic, or pinout; - each learning route is canonical and present in the sitemap, static export, Sites delegation/prerender allowlists, Nginx exact mapping and normalization, deployment inventory, and byte-for-byte public smoke suite; @@ -994,8 +1177,8 @@ The v1 site is releasable when: exposes no firmware action or artifact metadata; and contains neither stale release copy nor the historical Waveshare photograph; - production builds reject or exclude every preview descriptor and staged - artifact, keep pending C3 and Pico 2 W rows inactive, and retain the - fail-closed no-release state until all five v0.6.0 rows pass; + artifact, keep any pending C3 and Pico 2 W candidate rows inactive, and + retain the fail-closed no-release state until all five v0.6.0 rows pass; - a qualified `v0.6.0` active selector names `waveshare-esp32-s3-lcd-147b` separately from the lean `esp32-s3-n16r8`, gives each its own manifest and firmware bytes, and requires diff --git a/docs/testing/firmware-v0.6.1-five-board-engineering-2026-09-06.md b/docs/testing/firmware-v0.6.1-five-board-engineering-2026-09-06.md new file mode 100644 index 00000000..9861f2d2 --- /dev/null +++ b/docs/testing/firmware-v0.6.1-five-board-engineering-2026-09-06.md @@ -0,0 +1,1096 @@ + + +# Firmware v0.6.1 five-board engineering checks — 2026-09-06 + +Status: **Archived engineering chronology. Automated final-candidate qualification was stopped at the owner's request; v0.6.1 was subsequently published on the owner's confirmation.** +Last updated: **2026-09-11**. + +The progress statements below describe their historical observation times, not +current publication status. See the [publication record](firmware-v0.6.1-publication-2026-09-11.md) +and [complete website deployment](website/site-v061-consistency-2026-09-11.md). +Incomplete or failed automated evidence remains unchanged; owner confirmation +does not retroactively turn it into a passing automated result. + +## Scope and exact inputs + +The maintainer resumed the paused five-board qualification with VPN +disconnected and both Lenovo Android and iPad available. The existing explicit +authorization for disposable-media qualification covers backup followed by +full-flash erase. Owner flash backups and all unsuccessful attempts are retained +privately; no raw captures, firmware binaries, or device identifiers belong in +this document or Git. + +The historical physical checks below use the clean qualification source +`f0b05f4c0e7a0d07a51e00ca104a0b716d1e4eb6` and the already admitted, reproducible +v0.6.1 baseline inputs. Those inputs are **not** a protected final candidate. +The earlier Lenovo-only application-preservation tests remain separate +[engineering evidence](firmware-v0.6.1-lenovo-engineering-2026-09-05.md). + +## Historical physical progress before the observer correction + +| Exact profile | Installation/readback | Live BLE identity smoke | +| --- | --- | --- | +| `esp32-4mb` | Passed: exact image and complete 4 MiB readback before first boot | Passed: v0.6.1, MTU 247, chunk 229, window 8 | +| `esp32-s3-n16r8` | Passed: exact image and complete 16 MiB erased-tail proof before first boot | Passed on a separately retained second smoke attempt: v0.6.1, MTU 247, chunk 229, window 8 | +| `waveshare-esp32-s3-lcd-147b` | Full erase and firmware write/hash verified; final full readback interrupted by USB disappearance, so installation result remains failed | Passed after rediscovery: v0.6.1, MTU 247, chunk 229, window 8 | +| `esp32-c3-4mb` | Passed: exact image and complete 4 MiB readback before first boot | Passed: v0.6.1, MTU 247, chunk 229, window 8 | +| `rpi-pico2-w` | Passed: exact UF2 payload, documented loader padding, and complete 4 MiB readback before first boot | Passed: v0.6.1, MTU 247, chunk 229, window 4 | + +A passing installation or smoke test is not an OI-1 baseline fragment, +seven-scenario hardening result, provisioning receipt, or physical app-platform +completion. No final iPad/Android qualification row has been emitted. + +The generic S3's first eight-second service-filtered scan missed its +advertisement. A later fresh scan observed it, a passive UART capture showed a +healthy v0.6.1 boot without a commanded reset, and a distinct second smoke +attempt passed. The failed first attempt is retained; its cause has not been +established, and the retry is not a substitute for the frozen no-retry OI-1 +timing workload. + +Waveshare's two original 16 MiB backups match. The maintained loader completed +the exact 1,798,416-byte firmware write and reported its data hash verified. +The subsequent whole-flash read lost the USB endpoint, and the attempted +cleanup reset could not reach it. This is not a complete whole-flash +before-first-boot proof. The unsuccessful result and logs are retained, with +no automatic re-erase and no assumed cause for the USB loss. +Its subsequent service advertisement and full maintained HELLO/DEVICE_INFO/INFO +smoke passed on v0.6.1. That runtime success does not repair or replace the +interrupted full-readback evidence. + +Read-only device metadata identifies the available Lenovo capture app as +`0.2.0+5` and the iPad app as `0.2.0+6`. Availability and installed version alone +do not establish a passing app-platform test. Neither app was reinstalled. + +Pico's reviewed loader footprint includes the pinned picotool's explicit +sector-aligned zero padding: 859,904 UF2 payload bytes plus 256 zero bytes, +followed by erased bytes through the end of flash. This behavior was verified +against the [pinned upstream loader source](https://github.com/raspberrypi/picotool/blob/6f6458d792b93685a11423b244a585eaa99eafcf/main.cpp#L5299-L5308), +not assumed from a failed comparison. The original stricter comparison failure +and the fresh, corrected full readback remain separate private records. + +## Release-test observability correction + +The [frozen workspace receipt contract](../specifications/firmware/specs.md) +requires physically acquired first-boot format/remount counts and zero write +operations during incompatible-media refusal. The historical baseline's shared +`pyble_workspace` helper and five boot overlays expose neither retained counts +nor a complete measurement interface. The actual builds do not enable the +upstream trace/counter alternatives inspected during this audit. + +The receipt-creation tool validates supplied canonical observations; it does +not acquire those facts from a board. An accessible filesystem after a blank +boot does not distinguish one format from repeated formats. Unchanged flash +bytes do not rule out idempotent writes. Therefore those observations must not +be converted into invented operation counters or passing receipts. + +The maintainer subsequently authorized continuous completion. The isolated +`release/v0.6.1-qualification` worktree now contains specification-first, +red-to-green work on a bounded retained boot observer in PyBLE-authored code, +all five boot overlays, actual flash/response acquisition, and complete +private evidence validation. The erased scan's status guard also now rejects +boolean `False` and noninteger values that compare equal to zero. No upstream +source or release gate has been weakened. + +Focused observer/read-status tests pass 44 methods; all 77 existing firmware +shell-test files and the source checks pass during integration. Hardware +adapter regression/review is complete: core 28, Pico 20, acquisition 19 and +collector 13 focused tests pass. The measured-observer implementation is signed +commit `8dd3cbac3f4497803af598b9761e424af4ed3351`; a one-line finalization +test-fixture correction is signed commit +`96497b9cf642ab2078842ce8a321c3c84b6714b2`. The qualification checkout is clean +and frozen at the latter source for new builds. + +The first complete host run executed 1,892 tests with two fixture errors and +19 skips. Both errors came from old synthetic finalization fixtures missing +the newly required workspace evidence closure; the focused correction passes +all three affected result-set tests. The fresh full run **passed all 1,892 +tests with 19 documented skips** in 1,137.103 seconds. Eighteen skipped tests +are superseded schema-v1 fixtures covered by the active schema-v2 suites; the +generated-runtime class was separately run against both complete build roots, +passing all six tests for each root. One +intermediate rerun was explicitly interrupted after a restrictive process +umask changed synthetic pinned-package modes; an unchanged focused test passes +under the fixture's expected umask. No production permission check was relaxed. + +Both fresh five-target builds completed, including their generated-runtime +checks. All five install artifacts are byte-identical across the independent +roots. Maintained baseline-input admission and the private installer's all-five +source/tool/input checks passed. All five new-source baseline installations now +pass: fresh full-chip backup, complete erased-byte proof, exact admitted image +write, full-chip readback before first boot, one runtime reset/boot request and +adapter cleanup. These are pre-policy baseline bytes, not a final candidate. + +| New-source profile | Full-flash installation | Live retained boot observer | +| --- | --- | --- | +| `esp32-4mb` | Passed | Passed; 2 MiB workspace | +| `esp32-s3-n16r8` | Passed | Passed; 14,614,528-byte workspace | +| `waveshare-esp32-s3-lcd-147b` | Passed on separately retained attempt 02 | Healthy first-boot record acquired over USB; retained BLE connection ended after closing tablet PyBLE sessions | +| `esp32-c3-4mb` | Passed | Passed; 2 MiB workspace | +| `rpi-pico2-w` | Passed on separately retained attempt 02 | Passed; 2.5 MiB workspace | + +Each passing live check independently verified the exact silicon identity, +fresh challenge, attached workspace and a complete fault-free retained +first-boot record: one format/completion and one remount/completion. +The four BLE checks verified v0.6.1 HELLO; Waveshare's USB fallback verified +the v0.6.1 runtime banner, not a successful BLE HELLO. +These engineering getter checks do not create final workspace receipts. + +Waveshare and Pico attempt 01 stopped at BLE advertisement discovery before +any flash access, erase, write or reboot. A retained 20-second passive scan did +not establish their presence or explain the absence; narrowly scoped Lenovo +inspection did not prove an active connection. After the maintainer confirmed +manual bootloader entry, actual USB/loader identity was checked independently +before each distinct attempt 02. No failed attempt was overwritten or promoted. +The new Waveshare readback success does not repair its historical failed run. +Its expected application USB identity later reappeared after a period of +absence. A distinct Mac BLE lookup still timed out before connecting. USB then +independently confirmed the exact silicon identity and v0.6.1 board banner. +The first long diagnostic print was truncated and remains a failed acquisition. +A fresh readout using bounded, paced hexadecimal chunks yielded complete +nonce/length/offset-checked diagnostic bytes without rebooting or changing +firmware. The retained workspace record proves erased media, one completed +format/remount, attached filesystem, two program/two erase calls and no fault, +overflow or recovery. Its workspace has 14,614,528 bytes. + +The same Waveshare readout reports BLE ready and the retained splash sequence +through frame-show, resources-released and backlight-high, returning true. +The maintainer first reported a blank LCD, then explicitly confirmed that the +Waveshare device showed the correct information on its LCD. This confirms the +current visible display recovered, without another flash write. It is not the +complete repeated-reboot/independent-QR-scan display qualification gate, which +remains incomplete. The maintainer's original photograph subsequently showed +the v0.6.1 splash, green BLE READY indicator and installation QR. Apple Vision +decoded the unchanged JPEG's QR as `https://pyble.dev/app`; a different decoder's +earlier non-decode remains retained. This is photo-based engineering evidence, +not a replacement for the final independent-device QR/reboot gate. + +Live diagnostic snapshots then established an active physical BLE epoch, +despite the qualification apps showing logical Disconnected state. A fresh +snapshot after narrowly closing the two iPad PyBLE processes and the Lenovo +qualification process showed that same epoch ended and no active connection. +No Bluetooth-wide reset, board reboot, app-data clearing or firmware write was +used. This establishes physical closure after app-process termination, not a +passing ordinary UI Disconnect test or identification of the individual peer. +Earlier discovery failures remain failed observations. A separate 25-second +Mac scan after closure observed all five exact bound identifiers, with no +overflow, connection requests or controller changes; scanning stopped cleanly. +A separate, read-only +generic-S3 adapter commissioning run retained a complete 16 MiB read with device +MD5 checks before and after. Immutable old firmware matched; differences in +mutable NVS were recorded separately. One reset returned normal runtime and the +serial descriptors were closed. This does not qualify the new build. + +These host checks and build results do not +qualify changed firmware bytes; a clean source freeze, fresh baseline/final +builds, OI-1 and hardening/provisioning/recovery gates, and all ten physical +app-platform rows remain required. + +A separate test-only iPad automation runner successfully performed one +foreground-only inspection of the installed official app after an earlier +setup timeout. It captured the disconnected PyBLE screen and accessibility +tree without tapping controls; PyBLE itself remains 0.2.0+6, not reinstalled. +This establishes a usable automation route, not a final app-platform pass. +Raw artifacts remain private. No physical power-cycle, visual confirmation, +wire timing, or candidate result has been inferred from these checks. + +A later iPad connect-inspection pilot verified the actual About version but +failed a navigation-settle guard before scanning or connecting. The outgoing +About accessibility tree was still present after Back. Its failure evidence is +retained; a private test-harness wait is being corrected without changing the +official app. The unmodified app's screen cannot prove STOP response ordering +or the frozen 500 ms wire bound. A separately identified qualification-only +real-app tracing derivative has therefore been prepared and tested. Its default +suite passes 921 tests, with three enabled-only tests separately covered in a +19-test enabled subset. The reviewed iOS and clean Android packages have +distinct qualification-only application identifiers; at that preparation stage +neither was installed or counted as a passing physical-app result. An Android incremental-packaging +mismatch was caught before installation, retained as failed audit evidence and +corrected with a clean build whose packaged AOT matches the fresh intermediates. + +Root subsequently independently rechecked the reviewed artifacts, installed the +separate `PyBLE Qual` copies on both physical tablets and launched them. The +official iPad app's queried identity/version/install URL remained exactly +unchanged; Lenovo's installed qualification APK was pulled back and matched the +reviewed package byte-for-byte. Lenovo About reports 0.2.0 (6), and the private +trace controls render correctly. No trace was armed or program run by these +app pilots. iPad's minimal no-taps screen/prompt inspection passed one test +in 1.342 seconds, with both queried app identities unchanged. It showed +Disconnected, Bluetooth On, Scan Idle and five board entries; the pilot did not +initiate the scan, and the list is not proof of a current board connection. +Its raw screenshot framing remains imperfect and is not publication-ready. +No system permission +has been automatically accepted by the automation. + +A source/evidence review corrected an unnecessary prerequisite in the private +plan: the frozen bench inventory explicitly accepts electronically identified +generic ESP boards using retained esptool chip/revision observations. Those +facts and exact-device installation guards already exist, so new carrier photos +are not required to begin their OI baseline runs. This does not waive actual +physical power/reset observations or C3's separate final board/pin confirmation. + +Independent review caught an offline analyzer mismatch with the ordinary app's +actual Run callback → verified upload → file RUN sequence. A separate private, +specification-first red/green correction admits only the exact UI-owned verified +upload before RUN, preserving all timing, source, lifecycle and unrelated-control +guards. The corrected analyzer passes 33 Python test groups; a host test using +the actual RunController/ProgramActions confirms upload-before-RUN behavior. +These are test-tool results, not hardware qualification. + +A private exact-file app trace collector is also prepared. It restricts reads +to the identified qualification package and actual UI-exported filename, +preserves two unchanged byte copies and checks source, installation metadata, +external bindings and the strict analyzer. Review reproduced a timeout cleanup +gap when a subprocess leader exited before its descendant. A narrow correction +terminates that owned process group on failure and preserves the original raw +failure evidence. All 31 temporary-fixture tests pass, including the parent's +independent rerun. No actual app trace was collected by this preparation. + +After the navigation fix, a distinct C3 connect-inspection test passed in the +official iPad app: actual Ready state, firmware 0.6.1 and the expected chip were +visible, with no file/editor/Run actions. A subsequent single UI Disconnect +test also passed. **That cleanup proved logical UI detachment, not physical +BLE closure.** Source inspection and a separate HELLO-ready host diagnostic +confirm that `PbleConnection.dispose()` never calls its owned link's +`disconnect()`; the recording link remained connected. The user has been asked +whether to fix this app issue in a separate branch. No application behavior fix +or complete disconnect/reconnect qualification is claimed. Private trace close +events must preserve this distinction. + +## Evidence and publication boundaries + +The first maintained OI baseline runs on the frozen observer source now retain +actual failed hardware results: + +| Profile | Controlled reset / HELLO | Transfer workload | Overall baseline | +| --- | --- | --- | --- | +| `esp32-4mb` | All 10 acquired; advertisement latency 1,124–1,450 ms | Physical BLE link lost during the first 64 KiB PUT's DATA phase | Failed; no fragment emitted | +| `esp32-s3-n16r8` | All 10 acquired; heap and settled-link facts acquired | Physical BLE link lost during the first 64 KiB PUT's DATA phase | Failed; no fragment emitted | + +Both runs reached successful directory creation and PUT_BEGIN before the DATA +failure. The backend's cleared service cache and nonconnected peripheral state +establish connection loss, not an omitted initial service-discovery operation. +The generic S3 cleanup also attempted its retained-link probe on the lost link; +the complete chained traceback remains retained. Neither raw log establishes +the underlying firmware reset, controller, or radio cause. No failed sample is +trimmed, replaced, or promoted into passing evidence. Neither run reached its +final physical power-cycle observation. + +A separate single-transfer engineering diagnostic subsequently captured the +cause without changing firmware. It independently checked the exact live +silicon identity and flash size, then retained original UART, CoreBluetooth +disconnect, DATA-command and ACK observations. Eight complete DATA sends used +command ID zero; no ACK arrived. UART reported local-host termination +`0x216`, with zero TX mbuf starvation and no subsequent panic/reboot observed +in the retained capture. CoreBluetooth reported peripheral disconnect. + +The original maintained benchmark hard-codes ID zero for valid PUT_DATA traffic, whereas +[PBLE/1](../specifications/protocol.md) requires command IDs 1–255. +The actual firmware wire reducer drops zero-ID commands as violations and +terminates the exact session on the eighth. This explains the captured eight +chunks/no ACK/local termination; relaxing firmware admission is not the fix. +The five affected valid-transfer helpers are now corrected in the separate +host-tool checkout at signed commit +`6f70155f596f83e47cb08157927810b261eb7c42`. All 301 affected host checks pass, +including actual sender frames checked by the unchanged compiled native wire +admission code. Independent review and the parent's seven-test rerun pass. +Data and retry commands use the caller's shared nonzero-ID allocator; timing, +payloads, window/ACK behavior and intentional malformed-ID tests are unchanged. +Neither the diagnosis nor a later corrected run repairs the failed records. + +A fresh complete classic ESP32 baseline has started with that clean, frozen +host executor. The firmware proof checkout remains clean at `96497b9`; its +admitted images and actual installations are unchanged. The baseline contract +binds firmware/build provenance to the proof checkout, not to the host helper's +HEAD, so this host-only correction does not require another baseline rebuild. +Separate private executor source and file-hash records preserve that distinction. +The fresh classic run completed all ten reset/HELLO checks (1,130–1,449 ms), +settled-link capture, all five 64 KiB roundtrips and all twenty 16 KiB reliability +transfers. Canonical PUT goodput is 7,356–7,612 bytes/s; GET goodput is 14,266–14,756 +bytes/s. All payloads verified, with zero retransmissions, integrity failures, +failed statuses or unexpected disconnects. The operator subsequently confirmed +actual power removal and reconnection; the maintained silence/fresh-advertisement +check passed, the complete run exited zero, and its baseline fragment was emitted. +Its raw-log digest and before/after executor records match. The same-session +terminal TX-mbuf starvation count is 5,371; it is retained as the contract's +report-only quantity, not concealed or used to replace measured goodput. + +The generic S3 and C3 then each completed a separate full automated workload +using the same frozen executor and unchanged exact-profile installed images. +At 06:05 UTC all three runs were waiting at their first final physical-power-off +prompt. All three subsequently completed their operator-confirmed physical +power cycles, with actual silence and fresh advertisements checked: + +| Profile | 10 reset samples, range | 5 PUT/GET roundtrips | PUT / GET goodput range, bytes/s | Reliability | Final physical cycle | +| --- | --- | --- | --- | --- | --- | +| `esp32-4mb` | 1,130–1,449 ms | All verified | 7,356–7,612 / 14,266–14,756 | 20/20 verified | Passed; complete baseline emitted | +| `esp32-s3-n16r8` | 853–1,516 ms | All verified | 14,278–20,604 / 28,331–35,790 | 20/20 verified | Passed; complete baseline emitted | +| `esp32-c3-4mb` | 529–1,181 ms | All verified | 12,628–13,403 / 19,147–20,422 | 20/20 verified | Passed; complete baseline emitted | +| `rpi-pico2-w` | 13–50 ms, operator-acknowledgement proxy | All verified | 13,910–14,374 / 7,182–7,327 | 20/20 verified | Passed; complete baseline emitted | +| `waveshare-esp32-s3-lcd-147b` | 8–21 ms, operator-acknowledgement proxy | All verified | 14,185–14,657 / 27,633–28,353 | 20/20 verified | Passed in the separate fresh attempt; complete baseline emitted | + +Every row retains all sixteen heap snapshots, zero retransmissions/rewinds, +zero integrity failures, zero failed statuses and zero unexpected disconnects. +The additional same-session report-only starvation counts are 2,854 (S3) and +3,870 (C3). All five complete baseline fragments have now been emitted; this +does not constitute final-candidate qualification. Actual disconnect, +sealed link facts, the first prompt and closed USB endpoints were checked before +the next board started. That first prompt holds no active scan or measured +timer; its following reconnect prompt would hold a scanner and cannot be shared. +No physical answer has been supplied on the operator's behalf. Waveshare and +Pico full OI runs require the documented manual reset/power sequence. Waveshare +completed all ten actual operator-held/released RESET samples and their HELLO, +heap and link-settlement checks. Its recorded 10–47 ms values use the frozen +operator-acknowledgement-to-next-advertisement proxy, not the physical switch +edge or physical boot time. No native-USB reset substitute was used. + +That Wave attempt then **failed its first 64 KiB GET**, timing out after +39,846 verified bytes. It emitted no roundtrip, reliability, final power-cycle, +completion or baseline fragment. All 44 raw events and its exit-one result +remain retained; unchanged before/after executor records do not turn the failed +workload into a pass. Neither host RX loss, a filesystem read exception nor +firmware TX backpressure has yet been established as the cause. The board and +owned test file were preserved for the separate read-only diagnostic below. + +Pico completed all ten actual operator-confirmed reset samples, all five +64 KiB PUT/GET roundtrips and all twenty 16 KiB reliability files. Its retained +events show zero retransmissions, rewinds, integrity failures, failed statuses +or unexpected disconnects. The operator separately confirmed final all-power-off +and normal power-on, after which the runner recorded the physical-cycle pass, +emitted the complete baseline and exited zero. Its 13–50 ms reset values use the +same explicitly limited acknowledgement-to-callback proxy, not physical boot +times. Independent host audit confirmed exact replay of all 58 raw events, +the complete observation, source/executor/build/install pins, UF2/raw-image +agreement and derived thresholds. Final qualification and publication remain +pending. + +A separate, explicitly non-qualification Wave diagnostic subsequently read +the preserved failed-transfer file on one connection without resetting, +reflashing or writing/deleting files. Exact STAT size/CRC and the complete +65,536-byte GET passed in approximately 2.01 seconds, with no retransmissions. +Before/after identity and boot observations agreed; raw notifications, +reassembly observations and passive UART bytes were retained, and BLE/USB +cleanup completed without errors or capture overflow. The preceding ended +link reported 536 mbuf-starvation observations; the diagnostic link reported +267 after GET. These report-only counters do not establish the cause of the +original interruption. The retained native USB output is incomplete Python +stdout, not low-level firmware error coverage: this build sends ESP-IDF logs +to UART0 and hardware USB Serial/JTAG, while the connected application endpoint +uses TinyUSB CDC. Missing native USB error text therefore cannot exclude an +earlier firmware error. This successful engineering read neither +repairs the failed baseline nor qualifies Wave for release. It preceded the +bounded engineering series and separate complete baseline acquisition below. + +One separately reviewed, bounded Wave engineering sequence then completed +ten fresh 64 KiB PUT/GET roundtrips on a single connection. The original +maintained helper's PUT, STAT verification and GET sequence, window, chunk, +timeouts and internal retransmission behavior were unchanged. A fresh, +previously absent nonce-owned directory isolated all temporary files from the +original failed-run file. Each transfer verified all 65,536 bytes in both +directions, with zero reported retransmissions or rewinds. PUT durations were +approximately 4.89–5.18 seconds; GET durations were 2.93–3.31 seconds. Same-boot +identity and settled link facts were retained before/after; the link ended +only during explicit host cleanup. The capture contains 17,602 records and +reports no overflow, observation fault or cleanup error. All ten successfully +verified generated files were removed by the unchanged helper; the original +failed-run file was untouched and the isolated empty directory remains. + +The diagnostic driver itself received a prelaunch two-test red/green correction: +recording a command-start event must not exhaust the trace and still allow an +unrecorded backend write. Both parent and independent reviewer passed all +eleven host guards on the corrected driver before its single hardware run. +This private host-only correction changed no firmware or maintained helper. + +These engineering successes do not explain the original interruption or supply +any missing baseline samples. A separately planned, complete fresh Wave +baseline is permitted by the frozen attempt rules. One such new run has now +completed all ten actual operator-held/released RESET samples, all five +64 KiB roundtrips and all twenty 16 KiB reliability files. It records zero +retransmissions, rewinds, integrity failures, failed statuses or unexpected +disconnects, and retains its measured link facts. The operator confirmed final +all-power-off at 10:15:45 UTC and reconnect at 10:24:10 UTC. The runner then +recorded physical-cycle success, emitted the complete Wave fragment and exited +zero. Independent audit checked all 58 raw events, exact replay, build/install +and source/executor pins, and thresholds. None of the failed attempt's samples +or engineering diagnostic transfers were reused. The earlier intermittent +failure remains retained and its cause has not been established. + +At 10:45:42 UTC, the maintained assembler admitted all five complete fragments +on clean proof source `96497b9cf642ab2078842ce8a321c3c84b6714b2`, producing +`docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json` +and mechanically updating `firmware/qualification/oi1-gates.json` in the +separate qualification worktree. The baseline SHA256 is +`007e19f0c0281801a3c065f01742ba9606e340bce07b5e67920f036cea375c76`. +The fixed product transfer/reset limits and derivation rules are unchanged; +resource limits derive from these newly measured firmware images. Review and +host tests precede committing this policy and freezing the combined final source. + +The maintainer explicitly approved the separate App Disconnect lifecycle fix. +Root reviewed and accepted clean source +`4f7cdce1752dc45e30b13a1d9b2544009e92e0fd`: 922 author full-suite Flutter tests, +44 independently selected tests and 25 root lifecycle tests passed, with clean +analysis. A fresh isolated tracing derivative is being prepared from that exact +source; neither tablet has received the new artifact yet. Ordinary Android/iPad +disconnect/reconnect qualification is not complete. These baselines and host +tests are not final release evidence; publication remains gated on the full +final-candidate evidence set. + +The separate native-USB output correction has completed specification-first, +signed red/green work through `6ff052b`, with 185 affected host test methods +passing and two independent reviews. It emits the same device-generated +observation line using bounded paced output, preserving raw bytes, identity, +nonce, deadlines, control-line policy and refusal behavior. It changes no +firmware image or app source and still requires final physical acquisition. + +A separate Wave-only healthy USB diagnostic subsequently exercised the exact +committed native-output emitter on hardware. The parent read the complete +private diagnostic and reran all eight host guards before its single physical +execution. Exact board identity, installed-image/source bindings and the loaded +frozen package's v0.6.1 version were checked. The unmodified paced emitter +returned a complete 742-byte raw transcript and one fresh-nonce, healthy attached +boot record with no fault or overflow. The boot ID agrees with the earlier +retained record. No reset, flash write, configuration change, candidate phase +or refused-media state was manufactured. This verifies healthy Wave USB output, +not final refused-media qualification. Healthy Pico was deliberately not probed +through USB REPL: its running supervisor owns that execution path. + +Private acquisition scripts, guard tests, backups, readbacks, failures, and +results are retained below the ignored +`local/qualification/v0.6.1-20260905/` workspace. The resumed run is indexed by +`resume-20260906-Z3M4lD/RESUME.md` and its workspace-observability audit. + +## Final source and updated private app checks + +The independently measured baseline and mechanically derived policy have now +been integrated with the reviewed host-acquisition and App Disconnect fixes. +The separate final-candidate worktree is clean at +`6cc74e2101d4733a3abc69cfec081f6e2836fd07`. Seven signed, test-only integration +commits align active-policy and completion fixtures with the exact measured +baseline; historical fixtures and fixed product limits remain intact. The +initial failed integration run is retained, not relabeled as passing. + +The complete final-source host run passed: 1,924 tests in 1,077.321 seconds, +with 19 documented skips. Eighteen are superseded license fixtures; the +generated-runtime class must separately pass against each new build matrix. +All 77 firmware shell-test files also passed, as did the standalone clean-room, +license-header and patch-policy gates. Both complete five-profile build matrices +have since passed, as have all six generated-runtime tests in each root without +skips and the maintained five-profile reproducibility comparison. + +The actual offline license audit then stopped because its RP2 build-driver +parser rejected the current script: it accepted uppercase-only variable names +and incorrectly included the later pinned offline CMake configuration in the +`unset` variable list. A separate signed specification/red/green correction +accepts case-sensitive shell identifiers and validates that configuration as +an exact, separate block. Missing scrub coverage, duplicate names, malformed +continuations, injected commands and changed offline settings remain rejected; +the actual checked-in driver now reaches the next observer stage. All 31 Arm +closure tests pass independently. The firmware and build driver are unchanged. +The correction is being independently reviewed before a clearly labeled +diagnostic audit of the preserved builds. Final evidence will require a new +clean source freeze, fresh build matrices and a successful audit; the earlier +failed audit will not be overwritten or relabeled. + +The separate diagnostic audit passed the corrected parser boundary, then +failed while looking up `pyble_workspace.py` in the generated ESP board +snapshot. The retained file exists under the board's `pyble/` directory and +matches the reviewed canonical source byte-for-byte. The reconstruction loop +instead looks at the board root for this flat frozen-module destination. +That distinct source-to-copy mapping defect is being corrected separately; +the unsuccessful diagnostic and its explicitly different tool/build-source +identities remain retained. No final source freeze or new build has followed it. + +The reviewed production App source also passed its full 922-test suite and +static analysis. A new, separately identified tracing derivative was built +from the accepted Disconnect fix, audited and installed as private +`PyBLE Qual` version 0.2.0 (7) on both tablets. Lenovo's installed APK was read +back and matched the admitted artifact exactly. Official apps were preserved: +the current exact package queries show Lenovo 0.1.0 (4) and iPad 0.2.0 (6). +The earlier Lenovo 0.2.0+5 statement above does not establish the current +official package identity; no official app was changed by this update. + +Fresh no-taps inspections of both private apps succeeded and showed +Disconnected, Bluetooth On and Scan Idle, without a permission prompt or +board operation. The iPad inspection passed one actual test in 2.663 seconds; +its screenshot still has framing limitations and is not suitable for public +tutorial use. No trace has been armed or exported by these new app checks. +The private trace collector's 41 host tests pass, including exact installed-app +identity and rejection before copying on an incorrect APK hash. + +The subsequent iPad engineering lifecycle test passed in 22.591 seconds. It +verified the actual build-7 About screen, connected to the exact generic S3, +used ordinary Disconnect, connected to that same board again and disconnected +normally once more. Both connections showed the expected v0.6.1 identity and +idle controls; no files, programs or trace controls were touched. The scoped +captures and unchanged installed-app identities are retained. This functional +UI result is not independent radio-event evidence or a final-candidate row. + +The equivalent Lenovo pilot stopped before any tap or board connection because +the private app was already scanning, contrary to its idle setup precondition. +Its failed setup and actual screen observations remain retained. Source review +finds no automatic scan-start hook, but the evidence does not identify who or +what initiated that scan. No firmware failure or passing Lenovo row is inferred. + +A separately reviewed setup operation then used one ordinary Stop scan action +and verified Disconnected, Bluetooth On and Scan Idle. A fresh attempt of the +unchanged Lenovo lifecycle test passed all eight planned UI actions: actual +build-7 About verification, two exact generic-S3 connections and two ordinary +disconnects. Before/after installed-app identities and source guards agree. +The original failed setup is retained. This is an engineering functional pass, +not a final-candidate or independent physical-radio receipt. + +A separate iPad helper for ordinary file creation, editing, saving and exact +readback has built successfully and passed 19 host guards. The preflight review +corrected two helper assumptions: the compact iOS dirty indicator includes both +its accessibility label and tooltip, and a new folder's listing settles after +its path changes. The planned pilot uses only a fresh nonce-owned directory and +two retained test files, without Run, Stop, trace, deletion or owner-document +replacement. Its first physical execution reached the exact S3 and entered the +new folder name, but stopped before pressing Create because the on-screen +keyboard covered the dialog action. The authored capture shows the single +enabled button behind the keyboard; no folder or file was created. The failed +attempt and unchanged before/after app metadata are retained. + +A fresh helper adds a normal, uniquely identified Hide keyboard action before +Create, with an unchanged-name check before and after. Six actual-capture and +scope regressions pass independently. A separately gated setup will cancel only +the exact known unsubmitted dialog and disconnect; it is not automatic failure +cleanup or a passing file-operation result. Neither that setup nor the corrected +full pilot has run yet. Select All and exact edit/save/readback remain physically +uncommissioned. + +The VPS's existing password-protected candidate site was checked read-only; +anonymous page and firmware requests are denied. Its old candidate and the +public site have not been replaced. There is still no new protected final +candidate, release tag, passing final receipt, GitHub publication or website +deployment from this run. Public `/flash` and `/learn` are unchanged. + +## September 11 resume + +The maintainer explicitly resumed the September 6 pause. Read-only checks found +all five board USB interfaces and both tablets available. Their current firmware +and UI state are not inferred from their availability or from five-day-old +observations. Both exact iPad app metadata records still agree with the retained +admissions. The fresh no-tap iPad inspection subsequently stopped in Xcode's +automation startup: the runner launched, but timed out enabling automation +before any actual test began. It produced zero executed tests and no screen +attachments. This is not an App or firmware test failure, nor evidence of the +current dialog state. No dialog cancellation or board action followed; the +maintainer was asked to check the iPad screen and any automation prompt. + +The initial isolated final source was clean at `6cc74e2`. Both complete build matrices, +all ten source-provenance records and the saved test/audit log hashes remain +intact. Pinned source trees, compilers and offline audit inputs are available. +The source-to-copy path correction now has signed red/green commits, twelve +passing focused regressions and independent review. A new diagnostic license +audit passed, with all evidence hashes checked independently. Its executing +auditor differs from the preserved build source, so this output is explicitly +diagnostic and cannot serve as final release evidence. + +The isolated release worktree was then fast-forwarded to reviewed, clean +`c8f549e`. Its fresh 1,947-test host suite passed in 1,111.682 seconds, with +19 documented skips: 18 superseded schema-v1 fixtures and the build-dependent +generated-runtime class. All 77 shell/conformance test files and standalone +source-policy checks also passed. The generated checks subsequently passed all +six tests against each fresh matrix, without skips. + +Both fresh build matrices completed all five profiles. Their maintained +reproducibility comparison passed. Independent reviews also found identical +provenance, matching source/tool pins, and passing image/partition capacity +checks for both matrices. The same-source release license audit passed, covering +all five profiles and sixteen license roles. +The previous failed audits and complete earlier builds remain intact. + +An early website dependency audit failed on newly indexed Next.js and sharp +security advisories, plus a moderate Vitest advisory. Deployment is held; +neither forced dependency repair nor a bypass of the audit gate is used. The +Windows-hosted and image-optimization conditions in the upstream advisories +do not establish exposure of this Linux/Nginx static deployment, but the +maintained dependency gate still requires remediation before the new build. + +Security updates in a separate website-only checkout passed installation with +the original package manager, a zero-vulnerability dependency audit, all 336 +website tests, and both static/Sites builds. The +existing staging code supports a website checkout distinct from the exact +firmware source root; independent code-path review confirmed that this retains +the same firmware license/qualification authority. Actual staging must still +pass the unmodified production validator. Firmware acquisition, completion and +finalization remain in the clean, frozen firmware checkout. Website and firmware +commits will be recorded separately; no firmware rebuild is inferred necessary +from website-only dependency changes. + +The fresh Lenovo no-tap inspection passed. Actual installed private APK bytes +matched the admitted build both before and after inspection; private and official +package identities remained unchanged. The captured App screen shows Bluetooth +on, disconnected, scanning idle and no boards yet seen. No scan, connection, +file operation or firmware action was requested. The owned-file pilot is being +prepared separately; this screen observation is not a passing qualification row. + +The subsequent owned-file pilot connected to the exact generic S3, created a +fresh test directory and two empty files, and focused the editor. It stopped +before typing code because the keyboard reduced the viewport and clipped the +chip label used by a test guard. A separately reviewed keyboard-hide step sent +one normal Back key. The follow-up guard then stopped on a test expectation of +`0 bytes` instead of the app's actual `0 B` file-size label. Both unsuccessful +attempts are retained, with no automatic retry or file cleanup. + +A fresh read-only check independently confirmed that the keyboard is hidden, +the complete chip/Ready guards are restored, the owned editor is empty, clean +and focused, and both test files remain listed. Installed APK bytes, source +closure, and private/official app identities remain unchanged. A separately +reviewed continuation then typed the exact owned test source. Android reopened +the keyboard during typing, again clipping the chip label; the subsequent +helper observation timed out before Save. The actual captured editor contains +the exact expected dirty text and still reports the same Ready board. No Save, +Run, additional file operation or automatic cleanup followed. This failed stage +is preserved separately; these engineering observations are not final-candidate +HIL. + +A new, independently reviewed keyboard-aware continuation subsequently passed +its complete remaining sequence. It saved the exact created buffer, replaced +only the known owned text, saved the edited buffer, hid the keyboard once, +opened the empty test file and reopened the ordinary test file through actual +GET operations. The reopened clean editor matched the expected edited text +exactly, followed by ordinary Disconnect and a disconnected UI observation. +Fresh before/after installed APK bytes, private/official identities and source +checks remained unchanged. This is a completed engineering continuation, not a +retrospective pass for prior failed stages, independent physical-radio proof, +Run/Stop result or final-candidate qualification row. The two test files remain +on the board; no owner file was removed. + +The main workspace now contains separate Google Play build-8 work. That work is +preserved; its application artifacts do not replace the admitted private +qualification build. Firmware qualification continues in the existing isolated +worktrees. Read-only SSH checks confirm the public and protected-candidate site +selectors still point to their previous releases. No deployment, firmware write +or app reinstall has occurred during the resumed work so far. + +The immutable five-profile candidate was subsequently created from clean +`c8f549ee`, with a local-only annotated `firmware-v0.6.1` tag. All 28 listed +candidate checksums passed a separate byte check. Its release metadata SHA-256 +is `71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd`. +All five hardware records remain pending. The additional audited validation +passed. Candidate-selected local website staging and full web checks have now +started; the tag has not been pushed and no VPS content has changed. + +The separately frozen website source is `06f98641`; it does not replace the +firmware provenance. The split-source snapshot helper passed all 24 fixture +tests independently, and its actual source-root/tag/validator-byte checks +passed. No real snapshot has been composed or transferred yet. + +The retained five physical binding files also pass the current schema and +candidate-artifact checks. Read-only USB enumeration and alias-ownership checks +confirm the five application endpoints are present and idle. No serial port +was opened and no ROM identity, flash write or hardware qualification result is +inferred from this preflight. + +The candidate's protected local stage subsequently passed its two audited +validations. Its selector binds the same release metadata and all five pending +profiles. Candidate-selected website formatting, lint, types, dependency audit +(zero vulnerabilities), license checks, all 336 tests and static route generation +have passed; the final Sites packaging validation is still running. This is not +yet a completed full web check or VPS deployment. + +The Lenovo multiline engineering check also passed all 17 ordinary UI actions. +Actual Save and a subsequent GET reopened exactly `while True:\n pass\n` +(21 UTF-8 bytes), including four indentation spaces and its final LF. A separate +GET confirmed the ordinary test file's unchanged 46 bytes. The app then +disconnected normally. Its APK/source and private/official identities remained +unchanged. No loop was run, no trace was armed and no final-candidate hardware +row was marked passed. + +The complete candidate-selected website check subsequently exited successfully, +including final Sites packaging and clean-source postchecks. Its retained log +SHA-256 is `550d37e3ffe9c8bc09be743b456ff07a95d6f34b588ddf0267ca2d8e054f8ee0`. +The reviewed split-source composer then created and verified the real local +snapshot: 208 files, 25,577,284 bytes, inventory SHA-256 +`165f589c58becb5cde50b367828b1ead19308584afb33f29b2d6fb806d179ba1`. +It includes Flash, Learn, all ten tutorial pages and their reviewed assets. +Canonical post-composition stage validation is running; no remote upload or +activation has occurred yet. + +A fresh full 4 MiB owner-preservation backup of the exact Classic board also +completed. Device MD5 checks before and after the read matched the saved bytes; +the independently rehashed backup SHA-256 is +`2aa2280b9fa0d66bbd60e73d0858e8df62f8025aa1e93f1c900ea86eb36aca3e`. +The helper performed no persistent flash writes, then made one explicit runtime +reset and bounded capture before closing the port. This proves backup and +handoff completion, not runtime boot correctness or final-candidate HIL. + +The Lenovo's longer multiline text test subsequently passed all 31 actions. +The exact 72-byte flood example was saved and reloaded through an actual GET; +the ordinary file remained exactly 46 bytes. Normal Disconnect and unchanged +application/source checks passed. This was text-entry and file-transfer +commissioning only: no RUN/STOP, trace, or final-candidate qualification claim. + +Canonical post-composition stage validation passed. The first protected upload +then completed, but its incoming-tree verification failed: the laptop's Apple +openrsync preserved private 0700-directory/0600-file modes despite `--chmod`. +The candidate was not activated. The failed incoming tree and logs are retained; +the public and protected current links remain unchanged. A separate serving-mode +transport copy and new incoming name are being prepared, with no change to the +original private snapshot or acceptance checks. + +The separate second-attempt transport copy passed complete byte, directory and +permission checks. Its corrected transfer retained no-overwrite and timeout +protections. The new protected incoming tree then passed verification, followed +by a successful protected-only activation at 05:21:08–09 UTC on 2026-09-11. +The activated release was independently reverified against the complete local +inventories and unchanged public/configuration guards. The previous candidate +and first failed incoming directory remain preserved. No public deployment, +authenticated-browser result or hardware qualification is inferred from this +staging activation. Browser authentication and final five-board HIL remain. + +Post-activation HTTP and SSH checks passed: public Flash/Learn bytes, the public +current link and the three Nginx configuration hashes remain unchanged; the +protected routes still require their existing Basic authentication. A dedicated +Chrome window was opened for operator sign-in. Its first automated launch +admission stopped because the Mac listener tool resolved 127.0.0.1 to `localhost`. +A scoped numeric-address check confirmed the same owned browser listens only on +127.0.0.1. The original failure is retained; a separate numeric-only observation +variant passes its host tests but has not been used to observe the browser. +No authenticated-page proof or final firmware qualification follows. Staging +credentials have not been changed, collected or substituted with other accounts. + +### 2026-09-11 06:01 UTC — private browser access and iPad readiness + +The canonical public validator was run against the actual final candidate and +exited with `esp32-4mb is not HIL-passed`. All five final-candidate profiles and +their 40 check fields remain pending. Previous engineering tests do not qualify +the new final binaries. The public flasher still serves qualified v0.6.0. + +The staging password obstacle was removed from the testing workflow without +changing the public website or the existing protected service. A separate HTTPS +listener binds only to the VPS loopback address, serves the exact immutable +candidate, and is reached through the laptop's existing authenticated SSH +connection. The laptop forwarding listener is also loopback-only. There was no +firewall opening, public DNS change, credential reset, certificate bypass, or +change to the three existing Nginx configuration files. The new configuration +passed `nginx -t`; independent checks confirmed public Flash/Learn byte identity, +continued Basic authentication on the original endpoint, rejection of external +access and wrong SNI/Host, and denial of hidden metadata paths. + +A separate owned Chrome profile now displays the candidate without a password +prompt, using valid HTTPS and an exact-profile hostname mapping. The obsolete +owned password-prompt browser was closed; its profile and failed evidence were +preserved. A page-only screenshot was captured. Because the candidate's +`no-store` policy prevents cached HTML retrieval, its served HTML was verified +by an independent HTTPS GET, not misrepresented as a browser-cache hash. This +is browser-access evidence, not a board-installation or HIL pass. + +After the user confirmed readiness, one unchanged iPad inspect-only test passed +(one test, zero failures). The private App reports Disconnected, Bluetooth On, +and Scan Idle, without a permission prompt. Both PyBLE App installations remain +unchanged. The screenshot is clipped; the retained accessibility tree is the +authoritative UI-state observation, not a substitute for workflow testing. + +The next requested operator step is the Classic ESP32 browser connection using +`usbserial-0001`, stopping before erase/install confirmation. Its full owner +backup is preserved. The exact C3 carrier/model remains unconfirmed; GPIO8 has +not been driven. No final firmware row has been marked passed and no public +release has been deployed. + +### 2026-09-11 07:04 UTC — Classic browser flash and runtime checks + +The user reported successful `ESP32 · 4 MiB flash` installation through the +SSH-only HTTPS candidate. An explicit erase-stage observation was not supplied +and is not inferred from that report. The later page capture no longer displayed the +completion dialog, so it is not being presented as a captured install result. + +Two actual runs of the maintained target-neutral BLE smoke passed, before and +after a diagnostic verification reset: chip `esp32`, agent `0.6.1`, negotiated +MTU 247, chunk 229 and window 8. Advertising identity, HELLO, DEVICE_INFO and the +read-only INFO characteristic agreed. Existing esptool verification also found +matching device/local checksums for all three frozen firmware components: +bootloader, partition table and application. No flash was erased or rewritten +by these diagnostic checks. + +A preceding whole-merged-image checksum did not match. That span includes +mutable NVS/PHY storage; the exact differing bytes were not acquired, so the +record retains this diagnostic without asserting an NVS-only cause. The three +immutable-component checks passed independently. These results are not a +complete final-candidate HIL pass. + +The next operator step is deliberate interruption during another Classic +browser write, followed by recovery with the same verified image and explicit +full-chip erase. Remaining workspace, OI, hardening and both-platform App +checks are still required. The public v0.6.0 flasher is unchanged. + +At 07:07 UTC, the user reported deliberate Classic interruption. Both serial +endpoints were absent, and the captured browser dialog showed installation +failure with a stopped serial data stream. Recovery has been requested using +the same profile and the documented BOOT/RESET sequence, with the completion +dialog left open for observation. No serial reflash was substituted. Inspection +of the pinned installer confirms that this flow erases before writing and has +no erase checkbox; its confirmation states that all device data will be erased. + +### 2026-09-11 07:31 UTC — Classic recovery and storage-test tooling defect + +The user reported successful browser recovery. The later screenshot showed the +selected Classic v0.6.1 dashboard, not the completion banner. An actual BLE +smoke after recovery passed with chip `esp32` and agent `0.6.1`. + +The subsequent controlled nonblank-storage acquisition at 460800 baud failed +during firmware readback. A fresh attempt at the original 115200 baud verified +the complete firmware image, wrote the deliberately incompatible workspace +prefix and read the complete workspace before boot. It then failed its runtime +USB identity wait. Neither attempt produced a passing storage receipt; both +sets of partial evidence remain preserved. + +Read-only diagnosis found a host-tool defect: when runtime and loader use the +same UART bridge, the matcher treats the correctly present runtime endpoint as +the alternate loader endpoint. The Classic binding and physical USB identity +agree. A minimal regression-tested qualification-tool correction is being +prepared separately from the frozen firmware candidate. The firmware, release +thresholds and public website have not changed. Storage acquisition must be +rerun with the corrected tool before the remaining board and App workflows. + +### 2026-09-11 07:53 UTC — measured recovery startup and serial handoff defect + +The UART identity correction passed 106 host workspace tests in a separate, +clean qualification checkout. Its fresh physical acquisition passed complete +firmware and pre-boot workspace readback, then reached the recovery console +step and timed out. It did not produce a qualification receipt. + +A passive, retained-serial-handle diagnostic subsequently observed the actual +recovery message, v0.6.1 banner and friendly REPL at approximately 22.56 seconds +after reset. The complete-storage scan runs before the console becomes ready. +The acquisition tool had captured six seconds, closed the serial connection, +then reopened it and allowed twelve seconds for console entry. The reopened +connection produced another ROM boot banner. Two preceding shorter diagnostics +also failed and remain preserved. + +The diagnosis is a premature host-tool serial handoff, not a demonstrated +firmware hang. A separate, tested correction is being prepared to retain the +connection through first-boot observation while preserving the existing outer +acquisition deadlines and strict stdout framing/deadline. No diagnostic is being +promoted into a passing first-boot receipt. The frozen firmware and public +v0.6.0 deployment remain unchanged. + +### 2026-09-11 08:18 UTC — corrected storage collector frozen + +The retained-UART correction is frozen in a separate qualification checkout at +`3cbfc21032f5b0fa749742179600d8375ac3bf70`. All 122 workspace host tests and +an independent 65-test focused review passed. Firmware bytes and qualification +thresholds are unchanged. + +Its first physical attempt failed during loader synchronization, before any +flash erase or write. A single read-only loader check subsequently identified +the expected Classic MAC and 4 MiB flash successfully. A fresh controlled +storage acquisition is now running at the original 115200 baud. The failed +attempt remains recorded; there is not yet a passing final storage receipt. +The public flasher remains on v0.6.0 pending complete qualification. + +### 2026-09-11 08:30 UTC — Classic incompatible-storage check passed + +The corrected collector completed the actual nonblank-media-refusal test. +The full 2 MiB workspace readbacks before and after boot match. The real +recovery observation reports no format, erase, program or remount operation, +and the workspace was not attached. Recovery startup completed in about +22.75 seconds on the retained serial connection. + +The canonical receipt and required raw siblings validate against the unchanged +v0.6.1 candidate and exact Classic board binding. Its receipt SHA-256 is +`9cdc1b9cbdc8dfedf10d85d9bea8d18e77d10493d048a03272598c080038a050`. +The separate erased-media first-boot acquisition is now running. This is one +passing storage check, not completed board or five-board qualification. + +### 2026-09-11 08:37 UTC — erased-storage attempt interrupted by USB loss + +The erased-storage acquisition passed the firmware write and complete image +readback, then failed during the pre-boot workspace read with `Device not +configured`. macOS independently recorded removal of Classic's CP2102 bridge +at 08:35:31.486 UTC and its return 3.507 seconds later. The cause of that USB +interruption is not yet established; the same identity and port are present +and idle again. + +No erased-storage receipt was produced, and all partial evidence is retained. +The completed incompatible-storage pass remains valid and independently +reviewed. The cable/power situation needs checking before a fresh erased test; +no automatic retry or publication has occurred. + +### 2026-09-11 08:54 UTC — Classic workspace pair complete + +After the user confirmed reconnection, a fresh erased-storage acquisition +completed successfully. Both receipts and all 18 required evidence files pass +independent validation against the same physical Classic board, unchanged +firmware candidate and frozen qualification tools. + +The erased-storage test demonstrated exactly one completed format/remount and +a usable attached workspace; the incompatible-storage test demonstrated no +formatting or writes. The successful erased receipt SHA-256 is +`0f7fad51693f762626843352598be2154828418462a064d37fee6fe9fdb30f26`. +The interrupted attempt remains retained as a failure, not reused as evidence. + +The measured BLE workload is now running. Its first ten reset-to-advertising +samples are 1111–1460 ms. Transfer, reliability, physical power-cycle, +hardening and real App checks still need to complete before board admission. +The public flasher is unchanged. + +### 2026-09-11 09:44 UTC — Classic workload passed; hardening remains blocked + +The actual off/on sequence completed after the user's separate power-off and +reconnection confirmations. The measured OI workload exited successfully and +passed independent review: ten reset samples, five 64 KiB PUT/GET round trips, +twenty verified 16 KiB reliability transfers, resource measurements and the +fresh physical power cycle. No transfer retransmissions or integrity failures +were observed. The observation SHA-256 is +`bf10ec5d40e3eb68f74417118ae68e41aa1c8597a7b210a9b648ed1265dbd987`. + +The subsequent seven-scenario hardening run failed the GC free-memory floor at +sample two. Only its transport-session, fragment-hardening and run-isolation +scenarios passed. It produced no completed hardening result. The failure and +all subsequent diagnostic captures remain preserved; qualification is blocked. + +Bounded diagnostics examined VM setup effects without changing the firmware +or resource policy. The latest diagnostic used one verified soft-reboot +boundary, ten fixed setup probes and fifty measured probes. All fifty measured +GC free-memory values were exactly 106608 bytes and passed the unchanged floors +and trend check. This is diagnostic evidence, not a qualification pass. A +documented, regression-tested host measurement-setup correction is being +prepared in a separate checkout. Its new tool revision will require fresh +storage receipts; existing receipts will not be relabelled. + +Separately, the Lenovo ordinary-App attempt stopped after its first Save because +the UI helper required a transient saved-message notification. Repeated captures +showed the correct board, exact document text and clean editor state, but no +notification. No Run or Stop occurred, and the complete workflow did not pass. +The saved owned file and failed attempt are preserved. An actual guarded App +Disconnect succeeded. Corrections retain real subsequent file GET checks before +execution; the iPad workflow has not yet run. The public flasher remains on +v0.6.0 until the complete five-board release qualifies. + +At 09:48 UTC, Classic also passed the maintained target RUN/STOP bench on the +unchanged candidate: real stdin round trip, tight-loop and print-flood Stop +responses followed by idle within the strict 500 ms deadline, same-link nonce +follow-ups, idempotent idle Stop and bounded file-mode Run. A fresh identity +and file-collision preflight preceded this run. Its retained log SHA-256 is +`ce02758fb61a8cb834a0eddf60ff4c6e46ed71c26c6993761a52f6050364ffde`. +This does not replace ordinary tablet workflows or the failed hardening check. + +### 2026-09-11 10:26 UTC — Measurement tooling corrected; tablet checks incomplete + +The isolated qualification-tool revision +`4f7fa2f7a42d87521ce4e466709f77e32164e7ef` now passes 200 relevant host tests +and independent review. It introduces the documented single VM boundary and +ten fixed setup probes before the fifty measured probes. Every resource floor +and the trend check remain unchanged, and every numeric sample is retained. +Firmware bytes are unchanged. This host result is not a hardware pass; fresh +source-bound workspace receipts and an actual hardening run are still required. + +Lenovo successfully read back the first saved file, edited it and saved the +exact 76-byte replacement. Subsequent UI-helper attempts stopped on overly +specific editor geometry and focus expectations. The latest actual state shows +the newly created loop file empty, clean and focused; Run/Stop has not started. +These attempts remain failed and are being continued from their verified state, +without recreating files or substituting host tests for App evidence. + +The first iPad attempt stopped immediately after foregrounding because the +accessibility view was not ready. No board controls or file operations were +dispatched. A bounded initial-view wait is prepared but not yet exercised. +Neither tablet workflow is complete, and the public flasher remains unchanged. + +At 10:36 UTC, the Lenovo continuation completed all remaining actions and +exited successfully. Actual App observations cover file readback, NeoPixel +import and nonce output, loop Run/Stop, reconnect, soft reboot, post-reboot +readback/execution and final Disconnect. Private and official App identities +and the installed APK/source checks remained unchanged. The result SHA-256 is +`86044b6e8fb479adc5c261ff5a18b41c882a951e6c1b3806121cfd80d87fca48`. +The original saved-file readback is explicitly retained from its predecessor; +earlier failed automation attempts are not relabelled. This ordinary workflow +does not claim strict UI Stop timing or whole-release qualification. + +After verifying Lenovo's final disconnected view, the reviewed iPad workflow +was started. The existing Apps and firmware were not replaced. A separate, +small positive-prefix upload-resume demonstration is also being prepared to +cover the existing FR-FS-7 requirement; it has not yet run. + +The iPad attempt subsequently stopped before its first Save or Run. It had +connected to Classic and created its owned empty file, but typing the entire +Python expression caused extra auto-paired closing punctuation. The exact-text +guard correctly refused the altered buffer. Actual screenshots, accessibility +data and the failed result are retained; App identities remained unchanged. +A continuation with pair-aware text entry is being prepared from that same +owned unsaved buffer. The iPad still holds the Classic connection; no storage +or host BLE test may run until it explicitly disconnects. + +### 2026-09-11 11:04 UTC — Genuine iPad transfer failure + +Pair-aware text entry successfully saved the expected created and edited files. +However, creating the empty loop file then produced an actual App transfer +timeout during automatic opening. The file list shows `loop.py` at 0 bytes; +the editor still shows the previous clean `ordinary.py` at 76 bytes. No loop +Run/Stop or reboot occurred. This attempt remains failed, with its complete +result, screenshot and accessibility evidence preserved. + +Source inspection identified a possible early-download-event race while the +App awaits BLE write completion. A host regression is being prepared in an +isolated checkout; the UI evidence alone does not prove the precise wire cause. +The iPad workflow remains incomplete. Firmware bytes and the public v0.6.0 +flasher remain unchanged. + +At 11:08 UTC, after actual iPad Disconnect, Classic passed a positive-prefix +upload-resume demonstration: ACK 229, real link loss and reconnect, unchanged +old-file readback, resumed BEGIN at 229, final ACK 458 and complete CRC/byte +verification. Its owned fixture was removed and absence verified. Evidence +SHA-256: `2180be1bcf912da7fa05f7cf24af839277ad340bad403a0ef68fec301e7a53df`. +This satisfies that demonstrated resume behavior, not whole-release approval. + +The suspected App event-ordering defect is now reproducible in host tests for +both empty and nonempty files. A minimal correction is under independent review +in an isolated checkout. Fresh storage/hardening acquisition is proceeding on +the unchanged Classic firmware; the earlier failures remain preserved. + +At 11:18 UTC, the new nonblank-media attempt failed during ROM synchronization +for its final readback. The candidate and complete pre-boot filesystem had been +verified, and the actual boot reported refusal with no format/program/erase. +However, no post-boot media bytes were acquired, so no passing receipt exists. +A subsequent read-only, no-reset ROM query succeeded with the expected physical +board identity and 4 MiB flash; this does not complete the failed acquisition. +Serial-loader handoff diagnosis is continuing before another storage attempt. + +The isolated App GET correction is now committed as +`af4d96782c2c340cb1b6a2ad5e7fc44adace9d20`, following a failing regression +commit and independent review. Host tests pass: 761 default unit/conformance, +766 trace-enabled unit/conformance and 201 widget tests, with documented +compile-variant skips; static analysis reports no issues. Corrected private +test builds are being prepared. Official installed Apps remain unchanged. + +At 11:33 UTC, one bounded diagnostic successfully used deasserted serial +controls before opening, one explicit sequential reset and a no-reset ROM +connection. Actual board identity, security state and flash ID matched, and +the owned handle closed cleanly without programming flash. This supports +testing that alternative handoff; it does not establish the exact failure +mechanism or qualify storage. A narrowly scoped macOS Classic host-tool change +is being prepared with fresh source-bound acquisitions still required. + +Both corrected private Apps have now built and passed artifact verification, +including signatures and packaged code matching the new build outputs. They +have not been installed. Official Apps and the public flasher remain unchanged. + +At 11:55 UTC, the narrowly scoped loader correction was frozen in clean signed +commit `98e72f57c646e56144e864334e17f2b17f2bf05e`, with independent review and +177 relevant tests passing. A new Classic nonblank-media acquisition is running +against that exact tool revision; it has not yet produced a passing receipt. + +The corrected private Lenovo App has now been installed after fresh disconnected +UI and export-preservation checks. Its actual installed APK matches SHA-256 +`582a18974933f475a3fb0b85d80c7dfeae99ae947be4976a62a43b1dc2424871`. +Private permissions and first-install time, and the official App identity, remain +unchanged. It has not yet been launched or hardware-tested. The iPad update is +awaiting a fresh no-tap screen inspection. No public release has been activated. + +At 11:59 UTC, the iPad inspection passed and its complete accessibility tree +and screenshot showed a disconnected, non-editing App. The corrected private +iPad App was then installed with its signed artifact linked to the actual OS +installation receipt; the official App remained unchanged. Lenovo's corrected +App also opened and showed its initial disconnected screen. Neither corrected +App has yet completed its new hardware workflow. + +At 12:03 UTC, the new Classic storage acquisition again failed at post-boot ROM +synchronization. Its full candidate/pre-media readbacks and actual no-write +refusal observation passed, but no post-media readback or completion receipt +exists. The host handoff change is therefore not yet proven effective. A +separate no-reset ROM query subsequently succeeded; it is diagnostic evidence, +not a replacement for the failed acquisition. The next diagnostic isolates the +runtime-to-loader transition without another erase/program cycle. Publication +remains blocked on genuine hardware qualification, not on App build readiness. diff --git a/docs/testing/firmware-v0.6.1-lenovo-engineering-2026-09-05.md b/docs/testing/firmware-v0.6.1-lenovo-engineering-2026-09-05.md new file mode 100644 index 00000000..a6cba978 --- /dev/null +++ b/docs/testing/firmware-v0.6.1-lenovo-engineering-2026-09-05.md @@ -0,0 +1,114 @@ + + +# Firmware v0.6.1 — Lenovo engineering checks, 2026-09-05 + +Status: **archived partial engineering validation from September 5**. +Later publication is recorded separately in the +[September 11 publication record](firmware-v0.6.1-publication-2026-09-11.md). +Statements about remaining work below describe this historical session only. +The ESP32-C3 baseline image completed the observed Android UI checks below. +This is not a final-candidate HIL report, an admitted Android release row, or +approval to publish the five-profile firmware release. + +## Identified inputs + +- Physical tablet: Lenovo TB J616X; Android 12, API 31. +- App: PyBLE 0.2.0, build 5; identified real-UI tutorial-capture build. + This is not a new distribution build or a fake-transport test. +- App source: `4df378fc76c919f0e2481eb5b668f14115d38587`. +- Firmware profile: `esp32-c3-4mb`; ESP32-C3 revision v0.4, 4 MiB flash, + no PSRAM required; observed firmware agent version `0.6.1`. +- Engineering source: `f0b05f4c0e7a0d07a51e00ca104a0b716d1e4eb6`. +- Frozen application: 1,689,024 bytes at flash offset `0x10000`; SHA-256 + `a8ad1b3d1afb2d173d5786f7a297d56d5c342bb26a1865e71892f08d9fb52968`. + +These are admitted baseline inputs, not the subsequently rebuilt, tagged, +protected final candidate required by the release procedure. Android actions +used the ordinary app UI, driven and observed through USB ADB. The laptop VPN +was left unchanged; no iPad was used. + +## Installation and owner-data protection + +Two matching complete C3 flash backups were retained privately. A guarded, +application-only engineering installation used pinned esptool 4.12.0 after +checking the exact physical target, security state, bootloader and partition +table. No full-chip erase or merged-image install was performed. + +A fresh full read preceded programming. A second full read, taken through the +same loader connection before application boot, proved the exact application +bytes and equality of all 2,502,656 bytes outside sectors +`[0x10000, 0x1ad000)`, including NVS and the complete VFS. An independent +retained-byte audit agreed. Reset and serial close then completed normally. +Fresh BLE HELLO and a recursive listing subsequently showed version `0.6.1`, +the expected target, unchanged configuration, and the same pre-existing paths. + +This is preservation evidence at the stated observation boundaries, not a +destructive-provisioning receipt. UI writes were confined to a newly created +test-owned directory and its Python fixture. Autorun, Identify, device label +and GPIO configuration were not changed. + +## Observed Android checks + +“Observed” below means the stated real UI behavior was captured, not that its +complete wire-level qualification gate passed. Private capture numbers identify +the retained session sequence; raw captures are not published with this report. + +| Check | Observed outcome | Private evidence sequence | +| --- | --- | --- | +| Fresh discovery and connection | Ordinary scan/connect reached Ready with firmware `0.6.1` and the correct `esp32-c3` target. | 07–09 | +| Create, edit, save and reopen | A test-owned file was created through Files, edited, saved, refreshed and reopened with exact expected source. | 10–14 | +| File RUN and live stdout | `LENOVO_V061_HELLO` then `42`; Finished, Ready, Run available and Stop disabled. | 15 | +| Fresh globals on consecutive file RUNs | Each run printed `FRESH True` then `SET 1`. | 16–17 | +| Idle stdin isolation | After verified idle submission, the successor stayed Running at `WAIT_INPUT`; only fresh input completed it with `ECHO:LENOVO_FRESH`. | 20–22 | +| Tight-loop Stop | Test input was submitted during a running tight loop, then the real Stop action reached Idle/Ready with Stop disabled. | 23–25 | +| Post-Stop globals and stdin isolation | Successor printed `FRESH True`, waited without consuming stale input, then completed with `ECHO:AFTER_STOP`. | 26–27 | +| Print-flood Stop and recovery | Live repeated output was visible; Stop reached Idle/Ready, followed by a successful HELLO/42 program without reboot recovery. | Raw capture 28; 29–30 | +| Expected exception and recovery | Deliberate `ValueError: LENOVO_EXPECTED` remained visible with Error; next run printed `FRESH True`, HELLO and `42`, then Finished. | 31–32 | +| Final fixture readback | Refreshed and reopened the exact 84-byte test fixture through Files. | 33 | +| Disconnect/reconnect persistence | Explicit disconnect, fresh scan and reconnect; the 84-byte file was reopened from Files and ran normally. Cached editor text alone was not treated as readback. | 34–38 | +| Soft-reboot functional recovery | After the UI action, a later observation showed Disconnected; fresh scan/reconnect, exact file reopen and normal RUN succeeded. | 39–43 | +| Supplemental laptop final inventory | Not completed: laptop BLE discovery/connect timed out before HELLO. A separate fresh laptop scan did not find C3; a subsequent Lenovo connection did reach Ready with `0.6.1`. Cause unresolved; no final inventory pass inferred. | Failed inventory attempt; 44–45 | +| Final Lenovo review state | The owned file was reopened and run again successfully; Lenovo was left connected to C3 with expected output and Finished/Ready. The test fixture was retained for review. | 46–47 | + +## Excluded attempts and interpretation limits + +- Initial editor auto-pairing and selection artifacts were corrected before + measured RUNs. A malformed unsaved buffer was refused by the input preflight; + it is a test-setup issue, not a demonstrated firmware defect. +- Capture 19 did **not** establish idle-input submission: the keyboard obscured + Send and the field still held the text. Only the corrected sequence 20–22 + supports the idle-isolation observation. Ordinary MicroPython input echo was + distinguished from the fixture's explicit `ECHO:` output. +- Continuous print output prevented UIAutomator from obtaining an idle + hierarchy. A raw live screenshot and subsequent stopped/recovery captures + were retained; no successful hierarchy snapshot 28 is claimed. +- An Android connection error 133 was visible before this firmware installation. + Its cause is unresolved; later deliberate connections succeeded. It is not + attributed to v0.6.1. +- UI snapshots do not establish PBLE response/event ordering or Stop latency + below 500 ms. The gap between the initial post-reboot Ready observation and + later Disconnected observation does not establish reboot latency either, + including the hardening bench's separate 12-second disconnect expectation. +- The supplemental laptop discovery failure is retained separately from the + successful Lenovo workflow. It does not prove a firmware defect or a + VPN-related cause. Final session-wide inventory verification remains open. + +## Remaining release work + +Only C3 received the v0.6.1 engineering image in these observations. Classic +ESP32 now has two matching read-only full-flash backups at unchanged 115200 +baud, with matching staged bootloader/partition geometry; it was not updated. +The generic S3's earlier RAM-stub acquisition failed. Waveshare still needs a +controlled BOOT/reset loader entry, and Pico needs BOOTSEL entry and backup. +None of those four profiles has a v0.6.1 qualification pass from this session. + +Full qualification still requires all five fresh baseline measurements and +final-candidate builds; candidate-bound hardening, provisioning/recovery, +resource/performance and exact-board gates; and physical Android **and iPad** +app rows. Destructive media tests require appropriately designated test media. +This report does not replace the C3 full-chip-erased installation requirement, +prove inline-source RUN, or claim the seven-scenario hardening suite completed. + +Authoritative procedure: [firmware roadmap](../planning/firmware-v0.6.1-v0.7.0-roadmap.md), +[HIL guide](../../tests/firmware_tests/hil/README.md), and +[C3 qualification contract](../specifications/firmware/ports/esp32-c3-4mb.md). diff --git a/docs/testing/firmware-v0.6.1-publication-2026-09-11.md b/docs/testing/firmware-v0.6.1-publication-2026-09-11.md new file mode 100644 index 00000000..8853c1dc --- /dev/null +++ b/docs/testing/firmware-v0.6.1-publication-2026-09-11.md @@ -0,0 +1,47 @@ + + +# Firmware v0.6.1 public deployment — 2026-09-11 + +Published at https://pyble.dev/flash at approximately 12:41 UTC. + +This records the initial Flash publication. Later page updates and the current +deployment are recorded in the +[complete website deployment report](website/site-v061-consistency-2026-09-11.md). +The source, paths and page hashes below remain historical evidence. + +The project owner explicitly stopped further testing, confirmed that their +qualification was complete, and requested immediate publication. Publication +relies on that owner-provided confirmation. Incomplete automated HIL records +were preserved unchanged; no missing results were marked passed. + +- Firmware source: `c8f549eeabe6d2b8c2766eab022517944bb09c8c`. +- Flash source: `415d4776827da8fb51475463dc31068d61ac234a`, local branch + `release/v061-owner-confirmed`, separate worktree `web-v061-owner-confirmed`. +- Deployment: `/srv/pyble/releases/20260911-v061-owner-confirmed`. +- Previous deployment retained for rollback: + `/srv/pyble/releases/20260901T124313Z-bccdf3deef97`. +- All five original v0.6.1 packages published under `/srv/pyble/firmware/v0.6.1`. + Firmware, release metadata, manifests, checksum list, licenses and existing + HIL report were not modified. All 28 listed artifact checksums matched on VPS. +- Public Flash HTTP content SHA-256: + `3dbcbe67dce7976f3c29e1760bec99d9659ef9aa962c440cbc469beb48d462e3`. +- Public release.json SHA-256: + `71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd`. +- All five public firmware download endpoints and the new Flash JavaScript + bundle returned HTTP 200. These were deployment delivery checks, not board + qualification tests. No boards or tablets were operated on during publication. +- `/learn` and its tutorial directory were preserved byte-for-byte. Public + `/learn` SHA-256 remains + `7985529abf7960687fa0e59e5b669f649b881ee04c432e1bbb36e5ae8ff8a677`. +- Other existing pages, assets and old firmware versions retained. Additional + web dependency notices appended to the retained website license document. + +Public confirmation record: +https://pyble.dev/firmware-v0.6.1-owner-confirmation.md + +No GitHub release, source push, PR merge, App Store upload, new firmware build, +or additional hardware qualification was performed in this publication step. +The standard automated-qualified deployment mode remains unchanged; the new +owner-confirmed mode is restricted to this exact version, metadata digest and +five-profile set. Future automated deployment must explicitly account for this +owner-confirmed release rather than misrepresenting it as collector-complete. diff --git a/docs/testing/google-play/0.2.0-build-8.md b/docs/testing/google-play/0.2.0-build-8.md new file mode 100644 index 00000000..7cd415f0 --- /dev/null +++ b/docs/testing/google-play/0.2.0-build-8.md @@ -0,0 +1,134 @@ + + +# PyBLE 0.2.0 (8) Google Play open-testing handoff + +Prepared **10 September 2026**. The production Android bundle is built and +locally validated. It has not been uploaded or submitted to Play Console by +this preparation. The owner reports that open testing is enabled. + +Start with the [submission packet](../../publication/google-play-open-testing.md) +for listing fields, declarations, graphics, reviewer setup and tester guidance. + +## Artifact identity + +| Field | Verified value | +| --- | --- | +| Application ID | `dev.pyble.pyble` | +| Version | `0.2.0`, Android version code `8` | +| Flavor and entry point | `productionRelease`, `lib/main.dart` | +| Source commit | `768fafe45beee7731d1a38480cc0463a2f5680cd` | +| App tree | `27fd1a264a576e68126c38f966db328559660249` | +| AAB filename | `PyBLE-0.2.0-build-8-production-release.aab` | +| AAB size | `56,735,956` bytes | +| AAB SHA-256 | `ee2439589e2c2195ef4c861b524f871b0e5168e84fbea60a1cd379319c6eb5d7` | +| Locally installable APK | `PyBLE-0.2.0-build-8-production-release.apk` | +| APK size | `58,891,654` bytes | +| APK SHA-256 | `3c98983a995c657f10a398d7b0f65a07c2d2af18e07c30e7d5dfc094eefcbdcf` | +| Minimum / target / compile SDK | `24` / `36` / `36` | +| Native ABIs | `arm64-v8a`, `armeabi-v7a`, `x86_64` | +| Flutter / Dart | `3.44.1` / `3.12.1` | +| Android Gradle Plugin / NDK | `9.0.1` / `28.2.13676358` | +| Protocol / qualified firmware baseline | `PBLE/1` / independently released agent `0.6.0` | + +Upload-certificate SHA-256: + +```text +DC:A9:5C:50:60:CD:F4:93:6F:19:FD:8C:32:12:A8:71:C2:23:E2:BA:3F:B6:7B:62:26:17:9A:FF:DA:62:DD:57 +``` + +Both artifacts were signed using the existing upload key supplied through the +BLD-12 environment interface. Its certificate equals the earlier Play build-4 +handoff and the retained owner upload certificate. Passwords were obtained +locally through the existing secret manager and were not written to source or +logs. Confirm the current upload certificate in Console's App integrity page +before upload; Google's delivered-app signing certificate is a different role. + +Artifacts, validation logs and raw captures live under the ignored directory +`local/publication/google-play/open-testing-2026-09-10/`. Binary files and +signing material are deliberately absent from Git. The app tree was clean at +build time. Pre-existing untracked firmware engineering reports and upstream +working files are unrelated to the Android input and were not included in its +source commits. Later documentation/artwork commits do not change this app +tree or relabel the binary's source commit. + +## Changes since the retained app build 5 + +The new candidate adds the complete offline Privacy policy from About and a +distinct build identity. The policy identifies the maintainer, effective date, +contact, optional GitHub traffic, direct BLE transfers and transport security, +permissions, retention/deletion, platform handling and website infrastructure. +Reading it preserves the running board session and editor/Blocks/console state. +The current app source also retains the named-pin Blocks support and iOS 15 +deployment floor already present before this preparation. + +No Android runtime dependency, BLE protocol, firmware image, analytics feature, +account, or network workflow was added for this handoff. + +## Completed validation + +- `flutter pub get --enforce-lockfile` succeeded with the tracked dependency + lockfile unchanged. +- `flutter test --exclude-tags golden`: **869 passed**. +- `flutter test --tags golden`: **31 passed**. Four affected About tablet + baselines were reviewed; the existing phone baseline remained unchanged. +- `flutter analyze`: no issues. Dart format: **182 files**, zero changes. +- No-leak, SPDX, import-boundary and locale-parity gates passed. SPDX ran on + a clean export of authored source plus the new publication files, avoiding + traversal of ignored local firmware build inputs. App boundary/locale gates + also ran against the working app directory. +- Publication checks: **41 tests passed** and all repository-local Markdown + targets resolved. +- Production release APK and AAB builds succeeded through the established + signing path, using `lib/main.dart` and the production flavor. +- `jarsigner -verify` accepted the AAB; its certificate matches the established + upload certificate. `apksigner verify` accepted the APK. +- Official **bundletool 1.18.3** validation passed. The tool JAR's SHA-256 was + checked against Google's GitHub release asset digest: + `a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29`. +- Bundle configuration declares `PAGE_ALIGNMENT_16K`. All **six** packaged + native shared libraries have 16 KiB-compatible ELF LOAD-segment alignment. +- Both the direct production APK and an upload-key-signed universal APK + generated from this exact AAB passed `zipalign -c -P 16 -v 4`. +- Package, version, SDKs, ABIs, features and permissions were inspected from + generated artifacts. The merged manifest adds only the expected AndroidX + signature-protected `dev.pyble.pyble.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` + to the seven authored platform permissions. It adds no advertising ID, + billing, background location, foreground service, all-files, camera, + microphone or accessibility-service permission. +- The signed production APK passed native launch → About → Privacy policy → + Back checks on fresh API 34 phone and 7-inch tablet emulators. About showed + `0.2.0 (8)`; no app crash or layout overflow was observed. Six current + screenshots and four provenance-recorded physical Lenovo feature captures + accompany the icon and feature graphic. Emulator checks used no fake board. + +See the [asset guide](../../publication/google-play-assets.md) for the exact +signed APK's emulator capture/smoke observations and historical physical-tablet +image provenance. An ordinary API 34 emulator does not prove 16 KiB runtime +operation or physical BLE behavior. + +## Remaining release checks + +1. Confirm that code `8` is unused and greater than every uploaded code in Play + Console. Local evidence cannot establish the account's version history. +2. Confirm the proposed country, age and feedback settings. Attest the completed + [Data safety worksheet](../../publication/google-play-data-safety.md), + including the stated conservative GitHub metadata inferences, against the + account's final form and public/in-app policy. +3. Arrange compatible board access for Google review. The app requires a real + board for the connected IDE; the supplied instructions do not prove that a + reviewer has the hardware. +4. Perform candidate-bound physical Android BLE checks, fresh install and + Play-delivered upgrade testing, and review Play prelaunch/device reports. + Test native runtime on an available 16 KiB device/emulator. This session + had no physical Android/board connection and creates no new HIL pass. +5. Keep iOS/Android source capability parity; the existing iOS artifact does + not establish a distribution build with this new privacy page. Apply the + project's distribution-parity gate before external release. +6. Upload this AAB to **Open testing**, resolve Console issues, submit for + review and roll out only after the recorded checks. Record the actual + tester opt-in link and status. Update public website availability and send + the prepared tester invitation only when the test is live. + +No store approval, audience certification, resulting IARC rating, reviewer +access, runtime 16 KiB test, physical HIL acceptance or iOS upload is inferred +from the successful local Android build. diff --git a/docs/testing/platform-integration-2026-09-11.md b/docs/testing/platform-integration-2026-09-11.md new file mode 100644 index 00000000..b26f2067 --- /dev/null +++ b/docs/testing/platform-integration-2026-09-11.md @@ -0,0 +1,67 @@ + + +# PyBLE platform source consolidation — 2026-09-11 + +## Scope + +The maintainer requested local commits, recoverable worktree/branch cleanup, +and a pushed pull request targeting `main`. + +The integration combines: + +- Primary platform work through `66f1916`: reviewed Lenovo tutorial captures, + firmware features and diagram, site-wide v0.6.1 guidance, and the App build-8 + offline privacy policy and publication handoff. +- Current `main` through `2ce0e8c`, including patched website dependencies. +- Exact published firmware source `c8f549eeabe6d2b8c2766eab022517944bb09c8c`, + its measured baseline, specifications, host tooling and tests, and the + previously reviewed App connection-lifecycle fixes. + +After integration, the complete firmware and firmware-test trees, firmware +specification directory and protocol match `c8f549e` exactly. The website tree +initially matched the deployed primary source exactly. Subsequent pre-PR +formatting changes are whitespace-only; the frozen protocol bytes are retained. + +The public protocol copy's relative `../../AGENTS.md` link lacked its target. +`tools/web/public/AGENTS.md` now supplies the exact contributor-guideline bytes +from `c8f549e`, SHA-256 +`2462fdb3823bdec527de4da8323fc44570ca95e68f453ac3c469e5c4494072db`. +Both source snapshots are excluded from automatic formatting to preserve their +provenance. Existing publication/link tests caught the missing target and pass +with it present. + +## Local verification + +- Flutter analysis: no issues. +- Flutter non-golden suite: 894 tests passed. +- Flutter pixel-golden suite: 31 tests passed; Dart formatting passed. +- Complete website check: formatting, lint, type checking, zero-vulnerability + dependency audit, license checks, 342 tests in 23 files, compatibility check, + and both static and Sites builds passed. +- Publication suite: 41 tests passed; all repository-local Markdown targets + exist. +- Clean-room, SPDX, import-boundary, localization, patch-policy and PR-range + DCO checks passed. SPDX ran against a tracked-source export so ignored + qualification workspaces were not recursively scanned as product source. + +The exact firmware source already has its retained full host/build evidence, +described in the [engineering chronology](firmware-v0.6.1-five-board-engineering-2026-09-06.md). +This Git integration does not claim new board qualification. No hardware was +operated, firmware rebuilt, store artifact uploaded or website redeployed. +The [owner-confirmed publication record](firmware-v0.6.1-publication-2026-09-11.md) +and its distinction from incomplete automated HIL remain unchanged. + +## Cleanup boundary + +All 18 auxiliary worktree directories, including dirty edits and ignored +backups, were archived intact outside the active repository. Git metadata, a +verified bundle, original branch/HEAD mappings and restoration instructions +accompany them in the sibling `PyBLE-Archives/git-cleanup-20260911/` directory. +Twenty-seven obsolete local branch names were removed after preservation; +only `main` and `release/v061-platform-consolidation` remain active locally. +Private qualification experiments are preserved, not silently merged into the +production branch. The primary generated MicroPython board inputs are untouched. + +The two remote documentation branch names may be removed after their complete +history is reachable from the pushed integration branch. Opening this PR does +not itself merge or bypass any checks on `main`. diff --git a/docs/testing/pr32-ci-remediation-2026-09-11.md b/docs/testing/pr32-ci-remediation-2026-09-11.md new file mode 100644 index 00000000..b802a9b9 --- /dev/null +++ b/docs/testing/pr32-ci-remediation-2026-09-11.md @@ -0,0 +1,72 @@ + + +# PR #32 — CI correction and README refresh + +## Observed failure + +Both initial CI runs at `a6144f4` failed only the complete firmware host suite: +[pull-request run](https://github.com/PyBLE-dev/PyBLE/actions/runs/34618742875) +and [branch-push run](https://github.com/PyBLE-dev/PyBLE/actions/runs/34618673864). +Each ran 1,947 tests with one error and 24 skips. The failing test was +`test_visible_replacement_during_callback_is_rejected_and_preserved` in +`test_v061_hil_preflight_hardening.py`: its replacement file had disappeared. +The firmware shell phase and Android WebView checks passed. Firmware builds +were skipped because their host-test prerequisite failed; they did not report +compiler failures. + +The host evidence writer closed its original descriptor before invoking the +validation callback. Linux could reuse that unlinked file's inode number for a +replacement. Cleanup then mistook the replacement for its own output and +removed it. The same lifetime gap existed in the shared v0.6.0-profile writer. + +## Correction and boundaries + +Both writers now retain the original descriptor until publication checks and +failure cleanup finish. The inode remains reserved while ownership is checked. +Existing replacement files, including replacements with identical bytes, are +rejected without deletion. Descriptor cleanup still occurs on success/failure. + +Specification commit `8c7a1ff`, regression commit `0bde1e6`, and implementation +commit `8771c8b` preserve the specification-first red/green sequence. New tests +observe real descriptor lifetime without relying on filesystem inode-reuse +timing. They failed before the correction on this macOS host as well. + +Only host qualification tools changed. Firmware runtime, native modules, +board overlays and version pins still match published source `c8f549e` exactly. +No release threshold was relaxed, failed evidence rewritten, board operated, +firmware rebuilt or live website changed. + +## Verification before the update push + +- Three deterministic inode-lifetime test methods passed for both writers, + including success, failure cleanup and same-byte replacement cases. +- The affected five-file writer/gate suite passed all 80 tests under Python + 3.12, including the original failing regression. +- All 42 publication tests and the Markdown link gate passed. +- All six firmware roadmap/ledger tests passed after correcting the old + expectation that v0.6.0 must still be the current public release. +- Website tests: 342 passed across 23 files. +- Clean-room, whitespace and PR-range DCO checks passed. + +The full Python 3.12 host suite is also rerun independently. Its final outcome +and the subsequent Ubuntu CI results belong to the PR checks; focused host +tests above are not hardware qualification evidence. + +## README and issue #24 + +The README now links the published v0.6.1 descriptor, exact source, owner +confirmation, recovery material, tutorials, diagram and official examples. +It documents the editable repository URL, branch selection, writable-folder +guidance, multi-file deletion, App source version and offline privacy policy. +Historical v0.6.0 citation metadata remains historical. Prepared store artifacts +are not advertised as proof of a live release or later source changes. + +[Issue #24](https://github.com/PyBLE-dev/PyBLE/issues/24) describes the original +automated release-admission checklist, including five hardening results, ten +storage receipts and ten physical App/profile rows. The +[owner-confirmed publication](firmware-v0.6.1-publication-2026-09-11.md) is recorded, +but its retained descriptor still has five pending automated HIL rows. This PR +does not certify all of #24's original criteria and must not auto-close it as +completed. The maintainer may instead explicitly close that original workflow +as superseded by the owner-confirmed publication decision, preserving the +distinction. This follow-up neither closes the issue nor resumes hardware tests. diff --git a/docs/testing/tutorial-five-board-screenshot-provenance-2026-08-28.md b/docs/testing/tutorial-five-board-screenshot-provenance-2026-08-28.md new file mode 100644 index 00000000..cfca88a5 --- /dev/null +++ b/docs/testing/tutorial-five-board-screenshot-provenance-2026-08-28.md @@ -0,0 +1,150 @@ + + +# Tutorial five-board screenshot provenance — 2026-08-28 + +This record covers the Lenovo-only, five-board app captures prepared for local +review of the ten-part learning path. Technical integrity and privacy review +are complete. Final content approval and production publication remain +separate actions. + +## Capture environment + +- Capture date: `2026-08-28` (`Asia/Bangkok`; the UTC date was also + `2026-08-28`) +- Physical capture device: `Lenovo TB-J616X` +- ADB serial and transport: `HA1HM0X8` over USB; wireless debugging was not + enabled +- Operating system: `Android 12 (API 31)` +- App surface: `PyBLE 0.2.0 beta`, build `5` +- Capture-only package: `dev.pyble.pyble.tutorialcapture` +- App source revision: + `4df378fc76c919f0e2481eb5b668f14115d38587` +- Capture APK SHA-256: + `ad0a15a5f7f80005aac86f422ac53b888162fbca2b96920f4eacf1f1819fc679` +- PyBLE agent version on every connected board: `0.6.0` +- Raw directory (ignored): + `ScreenShots/tutorials/android/lenovo-tb-j616x/0.2.0-beta/five-boards/` +- Reviewed directory (ignored): + `ScreenShots/tutorials/android/lenovo-tb-j616x/0.2.0-beta/five-boards/reviewed-tutorial-set/` +- Processor: `ImageMagick 7.1.2-21 Q16-HDRI aarch64` + +The release-mode capture package used a disposable local signing key and a +capture-only application ID so it could coexist with another installed build. +It is not a distributable release artifact. The key and generated build output +remain outside Git. + +No iPad was used in this capture workflow. App installation, interaction, and +ADB capture targeted only the Lenovo tablet. The laptop-side processing and +review used only Lenovo raw inputs; no iPad capture entered the set. The Lenovo +stylus overlay was stopped before every selected raw frame. No selected +derivative contains the stylus affordance. + +## Five-board session map and evidence boundary + +The stopped discovery frame records exactly five distinct advertisements and +`Scan: Idle`. An advertisement proves only that a nearby PBLE agent advertised; +it does not prove installed firmware-profile bytes. Each connection frame +records a Ready status with board ID and agent version plus a read-only runtime +token. + +| Advertisement / board ID | Maintained physical-session context | Captured state | Captured runtime token | Evidence boundary | +| --- | --- | --- | --- | --- | +| `PyBLE-5646` / `5646` | Generic ESP32-S3 N16R8, explicitly confirmed by the user | `Ready` / agent `0.6.0` | `esp32-s3` | The user-supplied physical context identifies this board; the runtime token alone is not generic-profile proof. | +| `PyBLE-8C9E` / `8C9E` | Generic/classic ESP32 session | `Ready` / agent `0.6.0` | `esp32` | The app does not prove flash capacity, carrier, pin map, or installed profile bytes. | +| `PyBLE-C81A` / `C81A` | Generic ESP32-C3 session | `Ready` / agent `0.6.0` | `esp32-c3` | The app does not prove flash capacity, carrier, pin map, or installed profile bytes. | +| `PyBLE-DA86` / `DA86` | Waveshare ESP32-S3-LCD-1.47B physical-session context | `Ready` / agent `0.6.0` | `esp32-s3` | The maintained physical-session context identifies the exact board; the runtime token alone does not prove the carrier or Waveshare profile. | +| `PyBLE-3DCB` / `3DCB` | Raspberry Pi Pico 2 W physical-session context | `Ready` / agent `0.6.0` | `rpi-pico2-w` | Exact-board/profile evidence remains the physical marking and installer record, not the token alone. | + +The `5646` and `DA86` runtime derivatives intentionally have identical pixels +and the same SHA-256. That is expected evidence: both sessions report only +`esp32-s3` at this layer, so the app cannot distinguish their provisioning +profiles. The paired Ready and runtime derivatives are composed by semantic +HTML/CSS; no raster collage was produced. + +These observations are tutorial-documentation evidence only. They are not +PBLE conformance, firmware release qualification, installed-byte proof, or +GPIO, display, wiring, electrical, or other hardware-in-the-loop evidence. No +GPIO or display behavior was exercised or qualified during this capture pass. + +## Deterministic processing + +[`process-tutorial-captures.sh`](../../tools/web/scripts/process-tutorial-captures.sh) +validated all 13 distinct raw images as one 2000×1200, 8-bit PNG each and +produced 18 derivatives: + +| Derivative class | Distinct raw frames | Derivatives | Exact crop | +| --- | ---: | ---: | --- | +| Full app frame | 8 | 8 | `2000x1092+0+36` | +| Connected Ready status | 5 | 5 | `400x84+0+36` | +| Connected read-only runtime panel | Same 5 connected frames | 5 | `512x390+1488+330` | +| Total | 13 | 18 | Three fixed geometries | + +The full-frame crop removes 36 pixels of Android status bar from the top and +72 pixels of Android navigation area from the bottom. The processor crops the +two identity regions independently from each connection raw, strips metadata, +forces 8-bit PNG output, excludes date/time chunks, and uses fixed PNG +compression settings. It does not resize, annotate, blur, recolor, generate, +or retouch app pixels. Every output passed an exact crop comparison with zero +changed pixels. + +The review directory contains an 18-row `SHA256SUMS`, a deduplicated 13-row +`SOURCE_SHA256SUMS`, and the 18-row `tutorial-captures.tsv` used for the table +below. Reviewed filenames contain the app version, build, stable key, and first +12 characters of the reviewed digest. + +## Interaction, writes, and cleanup + +- The five-board scan was allowed to settle, then stopped before capture. It + showed the five intended advertisements and `Scan: Idle`. +- `5646` was used for read-only identity and GitHub browsing/review frames. No + GitHub file was downloaded to that board. +- On `8C9E`, `/tutorial-capture/hello.py` was created with 26 bytes and run + once. The program was a hardware-free Console greeting. Two empty disposable + files, `/tutorial-capture/delete_me_one.py` and + `/tutorial-capture/delete_me_two.py`, were then created for the exact + permanent-delete review. The confirmation shown in the selected frame was + canceled and both files remained. A second selection of the same exact two + files was then confirmed; only those two named disposable files were + deleted. `/tutorial-capture/hello.py` and its folder were retained. +- On `C81A`, `/tutorial-capture` was created and the Hello PyBLE Blocks starter + was opened in the editable workspace. It was not saved to the board and was + not run. +- On `DA86`, `/examples` was created and + `pyble_hello_console.py` was imported from the public examples repository at + immutable commit `8f4529b3cd0d62e8d53d7deb4f37e5cd2a171fd1`. The imported + file was not opened or run. +- `3DCB` was used only for read-only connection identity. +- The app was left disconnected after the capture session. No other board file + was created, overwritten, or deleted as part of the selected workflow. + +## Reviewed capture manifest + +The raw and reviewed digests below are copied from the generated +`tutorial-captures.tsv` without abbreviation. + +| Stable capture key | Crop | Raw filename | Raw SHA-256 | Reviewed filename | Reviewed SHA-256 | +| --- | --- | --- | --- | --- | --- | +| `setup-five-board-scan` | Full | `07-five-board-scan-stopped.raw.png` | `1d4fbf31f94f80d4d241b321137f316a42dc4770bd62985ec18f4d0b042fc71f` | `pyble-app-0.2.0-build-5-setup-five-board-scan-a89dedab7efc.png` | `a89dedab7efc472332e4280ba145400edf0be2cf2f68acb50d26dca1d2023bcf` | +| `identity-5646-ready` | Ready | `02-connected-5646.raw.png` | `66e63f77decae9a3654a0319b9c194db27c503be43fecbe33c2884fdf12c450e` | `pyble-app-0.2.0-build-5-identity-5646-ready-6b8ef886ea40.png` | `6b8ef886ea4039288e7af1c18921ac41b1b22a10f336a59f96cbf28a74cdb4d2` | +| `identity-5646-chip` | Runtime | `02-connected-5646.raw.png` | `66e63f77decae9a3654a0319b9c194db27c503be43fecbe33c2884fdf12c450e` | `pyble-app-0.2.0-build-5-identity-5646-chip-03edb94bcf73.png` | `03edb94bcf730b72028952962893a3ac26959a3d7c4bf948fbc252757a86e452` | +| `identity-8c9e-ready` | Ready | `03-connected-8c9e.raw.png` | `0af4ea76d1b8af6b920f213ae58d28e1b14bfac2176611927a9d2abad77cbdbc` | `pyble-app-0.2.0-build-5-identity-8c9e-ready-3b64e4b0b84c.png` | `3b64e4b0b84c2d1a81c32b190bfc45d0f294758998bdef1d3c1776d0a1879045` | +| `identity-8c9e-chip` | Runtime | `03-connected-8c9e.raw.png` | `0af4ea76d1b8af6b920f213ae58d28e1b14bfac2176611927a9d2abad77cbdbc` | `pyble-app-0.2.0-build-5-identity-8c9e-chip-0aa751bf86a3.png` | `0aa751bf86a3e7f247a731ecc9ac65b7b2fcb61188201e79c327ba0ca561a3a8` | +| `identity-c81a-ready` | Ready | `04-connected-c81a.raw.png` | `039c2c95dc2cba2a6d6c2848ab6dbf29a782cc519003da54e2561035a19bce01` | `pyble-app-0.2.0-build-5-identity-c81a-ready-8dea0554c432.png` | `8dea0554c432558f5f411a67a2175a5ff762e96c055fd9117ee3fb97906a1fd1` | +| `identity-c81a-chip` | Runtime | `04-connected-c81a.raw.png` | `039c2c95dc2cba2a6d6c2848ab6dbf29a782cc519003da54e2561035a19bce01` | `pyble-app-0.2.0-build-5-identity-c81a-chip-1c4d40085693.png` | `1c4d400856933184a8d37548240469c46850cafc9a76207f17d7b4f9b424c468` | +| `identity-da86-ready` | Ready | `05-connected-da86.raw.png` | `1a086f30edff93085e82326e1605424ab77c2ece69068b8b5f078f01efdcee58` | `pyble-app-0.2.0-build-5-identity-da86-ready-49595991ba1f.png` | `49595991ba1f422ca8e840594b198953d00e3938c1d13d6c9c5e2586d50a0972` | +| `identity-da86-chip` | Runtime | `05-connected-da86.raw.png` | `1a086f30edff93085e82326e1605424ab77c2ece69068b8b5f078f01efdcee58` | `pyble-app-0.2.0-build-5-identity-da86-chip-03edb94bcf73.png` | `03edb94bcf730b72028952962893a3ac26959a3d7c4bf948fbc252757a86e452` | +| `identity-3dcb-ready` | Ready | `06-connected-3dcb.raw.png` | `6e37f11806fc65798670e30c5f49b11d181bb41c7389cc20848384252e8407bf` | `pyble-app-0.2.0-build-5-identity-3dcb-ready-613559d187f3.png` | `613559d187f330e7c7ba3375f86123dbfc4a7ea51b7cb703d34659d9972463e2` | +| `identity-3dcb-chip` | Runtime | `06-connected-3dcb.raw.png` | `6e37f11806fc65798670e30c5f49b11d181bb41c7389cc20848384252e8407bf` | `pyble-app-0.2.0-build-5-identity-3dcb-chip-2b80ef41db8f.png` | `2b80ef41db8f86d5d0d1b79aebf97041fffbba2178817a2a4598ed038c717d96` | +| `first-program-editor-console` | Full | `08-first-program-classic-esp32.raw.png` | `c1e1eb4e6e09605c21e2c79088da290027f8c1ed60cdcf3cd37533d49b872e9c` | `pyble-app-0.2.0-build-5-first-program-editor-console-f6928eea293b.png` | `f6928eea293bd20bcddab94e3e56034033b74a6470cab0f9b018b6a31b969203` | +| `files-multi-delete-review` | Full | `17-files-multi-delete-classic-esp32-idle.raw.png` | `ed3841d421be85009e1019221170d43d6d9e8af61e129d4093d7101a0aa96471` | `pyble-app-0.2.0-build-5-files-multi-delete-review-42cfacddd965.png` | `42cfacddd9657146ba0edfb20273e87e56b5cf8ed9d646c6fd5878f77a728e31` | +| `github-branch-chooser` | Full | `16-github-branch-chooser-generic-s3-clean.raw.png` | `08fbe3108985dc8045d2212c1f6e84ac742bd0709504731979ad809679fb745a` | `pyble-app-0.2.0-build-5-github-branch-chooser-f0c52c24bc5b.png` | `f0c52c24bc5be7412d5e62e2cdbd2708037ed538949c543d121d31aaae5810d7` | +| `github-pinned-source` | Full | `12-github-pinned-source-generic-s3.raw.png` | `a282823b7f1c71d74c6e28b3dc82f9af435c82747ea3fc595769d1179bef3a81` | `pyble-app-0.2.0-build-5-github-pinned-source-68a784c12ada.png` | `68a784c12ada7ae3690e7268cf8b724268ecd81e024c0d92e20ad7870329e372` | +| `github-target-review` | Full | `11-github-target-review-generic-s3.raw.png` | `e13344241f035e46c760abd1ea587d9c70d1e52b218aa6a2ec3c74ed6768c620` | `pyble-app-0.2.0-build-5-github-target-review-16aafa28be77.png` | `16aafa28be77af34696eb61882610a02aa3f8943f5c130de40801b8112b26118` | +| `examples-import-complete` | Full | `14-examples-import-waveshare.raw.png` | `77fa143b86b5267b5c4ccf1e3b101910348279358156b9a0b499fc5628d208dd` | `pyble-app-0.2.0-build-5-examples-import-complete-65fc3f839336.png` | `65fc3f8393366948ee56d183b5ecfdbaf5550a6339e6519c31d0bb6d0ebc8b32` | +| `blocks-hello-workspace` | Full | `13-blocks-hello-c3-idle.raw.png` | `f85a8b92bb5b2567509450b3d7fbeb9ac61935fce27341fe015d11f9e1d17a8a` | `pyble-app-0.2.0-build-5-blocks-hello-workspace-adc069d73fba.png` | `adc069d73fbaab648f2e6b55dc536b38dade609c31aa03f18eb528a634a02f76` | + +Technical and privacy review found no account, token, private repository, +notification, personal filename, debug label, integration-test label, or +unrelated board file in the selected derivatives. The public board IDs, +official examples URL, immutable public commit, tutorial source, and named +disposable tutorial files are intentional instructional evidence. diff --git a/docs/testing/tutorial-screenshot-provenance-2026-08-28.md b/docs/testing/tutorial-screenshot-provenance-2026-08-28.md new file mode 100644 index 00000000..9e201550 --- /dev/null +++ b/docs/testing/tutorial-screenshot-provenance-2026-08-28.md @@ -0,0 +1,77 @@ + + +# Tutorial screenshot provenance — 2026-08-28 + +> **Superseded and corrected:** use the +> [five-board Lenovo capture record](tutorial-five-board-screenshot-provenance-2026-08-28.md) +> for the current tutorial set. A later audit found the Lenovo stylus +> affordance in seven old raw frames (`06` through `12`), not six. This record +> is retained only as historical evidence for the earlier eight-image set. + +This record covers the physical Android captures prepared for local review of +the ten-part learning path. Technical integrity and privacy review are +complete. Production publication remains pending explicit user approval of the +local website. + +## Capture environment + +- Capture date: `2026-08-28` (`Asia/Bangkok`; the UTC date was also + `2026-08-28`) +- Physical device: `Lenovo TB-J616X` +- Operating system: `Android 12 (API 31)` +- App surface: `PyBLE 0.2.0 beta`, build `5` +- Capture-only package: `dev.pyble.pyble.tutorialcapture` +- App source revision: + `4df378fc76c919f0e2481eb5b668f14115d38587` +- Capture APK SHA-256: + `ad0a15a5f7f80005aac86f422ac53b888162fbca2b96920f4eacf1f1819fc679` +- Documentation board: `PyBLE-5646`, firmware `0.6.0`, runtime chip + `esp32-s3` +- Raw directory (ignored): + `ScreenShots/tutorials/android/lenovo-tb-j616x/0.2.0-beta/` +- Reviewed directory (ignored): + `ScreenShots/tutorials/android/lenovo-tb-j616x/0.2.0-beta/reviewed-tutorial-set/` +- Processor: `ImageMagick 7.1.2-21 Q16-HDRI aarch64` + +The release-mode capture package used a disposable local signing key and a +capture-only application ID so it could coexist with the installed Play build. +It is not a distributable release artifact. The key and build output remain +outside Git. + +## Processing and interaction boundaries + +[`process-tutorial-captures.sh`](../../tools/web/scripts/process-tutorial-captures.sh) +validated each raw image as one 2000×1200 8-bit PNG, cropped exactly +`2000x1092+0+36` to remove the Android status and navigation bars, stripped +metadata, and verified zero changed pixels against that crop. It did not resize, +annotate, blur, recolor, generate, or retouch app content. Reviewed filenames +contain the app version, build, stable capture key, and the first 12 characters +of the reviewed SHA-256. + +The two-file deletion confirmation was canceled. The official example was +downloaded from immutable examples commit +`8f4529b3cd0d62e8d53d7deb4f37e5cd2a171fd1` into the disposable +`/examples/tutorial-capture` board folder and was neither opened nor run. The +Blocks starter was copied into the editable workspace but was not run. + +## Reviewed captures + +| Purpose | Raw capture | Published derivative | Reviewed SHA-256 | Privacy and state review | +| --- | --- | --- | --- | --- | +| Setup — board discovery | `06-setup-scan-results-production-release.raw.png` | `pyble-app-0.2.0-build-5-setup-scan-results-3558cadbe501.png` | `3558cadbe501d1865aafbacf4fe5dd9624ae969a2fb0ff7bbd457029bcf2c41c` | Pass; one documentation-board advertisement; Lenovo stylus edge control retained | +| Setup — connected identity | `07-setup-connected-identity-production-release.raw.png` | `pyble-app-0.2.0-build-5-setup-connected-identity-58f65b64a966.png` | `58f65b64a966d4ef2dcd0148442bf07dbe6b2008eda502f9d1e14cda4b7b36ce` | Pass; documentation board only; Lenovo stylus edge control retained | +| First program — Editor and Console | `08-first-program-editor-console-production-release.raw.png` | `pyble-app-0.2.0-build-5-first-program-editor-console-86552d68afaa.png` | `86552d68afaa7f9ea8b1151e0ffc812685815f3009bb64b7a3c823090ef42152` | Pass; public tutorial source/output only; Lenovo stylus edge control retained | +| Files — multi-delete review | `09-files-multi-delete-review-production-release.raw.png` | `pyble-app-0.2.0-build-5-files-multi-delete-review-b1df22dfa70a.png` | `b1df22dfa70a449356c05f34189ff897decdfbd418bf4630ae251365b6ed6b86` | Pass; disposable filenames; Delete was canceled; Lenovo stylus edge control retained | +| GitHub — branch chooser | `10-github-import-branch-chooser-production-release.raw.png` | `pyble-app-0.2.0-build-5-github-import-branch-chooser-4514d2f0522b.png` | `4514d2f0522bb3600ba38a75d9ed581255877d1949a8c153341af2d720da8ba7` | Pass; official public URL and public branch only; Lenovo stylus edge control retained | +| GitHub — pre-write review | `11-github-import-prewrite-review-production-release.raw.png` | `pyble-app-0.2.0-build-5-github-import-prewrite-review-faa68602fcd0.png` | `faa68602fcd0ef2761573e726c490fcbbfd3d953eecaff60d246511cfa0cb047` | Pass; immutable public source and disposable target; Lenovo stylus edge control retained | +| Examples — completed import | `12-examples-import-complete-production-release.raw.png` | `pyble-app-0.2.0-build-5-examples-import-complete-ab466a044a11.png` | `ab466a044a115a20bef8b1bf355d49867a5a6d439333e51ff3b8deb11d3c9317` | Pass; successful single-file result; no open or Run action; Lenovo stylus edge control retained | +| Blocks — generated Python | `20-blocks-hello-workspace-production-release.raw.png` | `pyble-app-0.2.0-build-5-blocks-hello-workspace-c37bd12f3102.png` | `c37bd12f31021c0a8dfcf1ecd8f592c1d8083c081a5d1d74582dbc8cf9998ee4` | Pass; public starter only; stylus overlay stopped before capture | + +No capture contains an account, token, private repository, notification, +personal filename, debug label, integration-test label, or unrelated user board +file. The small edge control retained in seven frames belongs to Lenovo's +`com.lenovo.styluspen` system package; it contains no user data. It was left +truthful rather than removed by image retouching. The overlay was stopped +reversibly before the final Blocks capture. A future physical recapture may +replace those seven frames with new content-versioned assets after the tablet is +reconnected; existing published bytes must never be overwritten in place. diff --git a/docs/testing/tutorial-screenshot-provenance.template.md b/docs/testing/tutorial-screenshot-provenance.template.md new file mode 100644 index 00000000..921b89d3 --- /dev/null +++ b/docs/testing/tutorial-screenshot-provenance.template.md @@ -0,0 +1,146 @@ + + +# Tutorial five-board screenshot provenance — TEMPLATE + +Copy this template into the release review record. Raw captures and generated +review candidates remain below the repository's ignored `ScreenShots/` +workspace. Only an explicitly reviewed, provenance-recorded derivative may be +promoted to `tools/web/public/`. + +## Capture environment + +- Capture date and time zone: `YYYY-MM-DD — TIME_ZONE` +- Technical/privacy reviewer and date: `NAME — YYYY-MM-DD` +- Final content reviewer and date: `NAME — YYYY-MM-DD` or `pending` +- Physical capture device: `Lenovo TB-J616X` +- Android release / API level: `Android 12 / API 31` +- ADB transport and serial: `USB — SERIAL` +- PyBLE version and build: `VERSION (BUILD)` +- Installed package identity: `PACKAGE` +- APK source commit and SHA-256: `COMMIT — SHA256` +- PyBLE agent version: `VERSION` +- Raw directory (ignored): `ScreenShots/.../five-boards/` +- Review directory (ignored): + `ScreenShots/.../five-boards/reviewed-tutorial-set/` +- ImageMagick version: output of `magick -version` +- Tablet-use boundary: `Confirm that only the Lenovo was used; no iPad use` +- Stylus-overlay state: `Confirm stopped before every selected raw frame` + +## Five-board observation map + +Record the app observation separately from the physical-board or installer +record. A Ready identity and runtime token do not prove installed profile +bytes, flash capacity, carrier wiring, or release qualification. + +| Advertisement / board ID | Maintained physical-session context | Ready / agent | Observed runtime token | Evidence source and limitation reviewed | +| --- | --- | --- | --- | --- | +| `PyBLE-5646` / `5646` | Generic ESP32-S3 N16R8 | `Ready` / `VERSION` | `esp32-s3` | `yes/no` | +| `PyBLE-8C9E` / `8C9E` | Generic ESP32 | `Ready` / `VERSION` | `esp32` | `yes/no` | +| `PyBLE-C81A` / `C81A` | Generic ESP32-C3 | `Ready` / `VERSION` | `esp32-c3` | `yes/no` | +| `PyBLE-DA86` / `DA86` | Waveshare ESP32-S3-LCD-1.47B | `Ready` / `VERSION` | `esp32-s3` | `yes/no` | +| `PyBLE-3DCB` / `3DCB` | Raspberry Pi Pico 2 W | `Ready` / `VERSION` | `rpi-pico2-w` | `yes/no` | + +Document who supplied each physical-session context. In particular, record +the user's confirmation that `5646` is the generic ESP32-S3 N16R8. Keep the +`5646` and `DA86` runtime-token evidence visibly equivalent: their shared +`esp32-s3` token cannot distinguish the generic and Waveshare profiles. + +## Deterministic processing + +Run from the repository root, using explicit absolute paths: + +```sh +tools/web/scripts/process-tutorial-captures.sh \ + --input-dir /ABSOLUTE/PATH/TO/RAW-CAPTURES \ + --output-dir /ABSOLUTE/PATH/TO/RAW-CAPTURES/reviewed-tutorial-set +``` + +The processor accepts exactly 13 distinct named 2000×1200, 8-bit PNG inputs +and produces exactly 18 derivatives: + +| Derivative class | Distinct raw frames | Derivatives | Exact crop | +| --- | ---: | ---: | --- | +| Full app frame | 8 | 8 | `2000x1092+0+36` | +| Connected Ready status | 5 | 5 | `400x84+0+36` | +| Connected read-only runtime panel | Same 5 connected frames | 5 | `512x390+1488+330` | +| Total | 13 | 18 | Three fixed geometries | + +The full-frame crop removes 36 pixels from the top and 72 from the bottom. The +two connected derivatives for each board come from the same raw frame and may +be paired only with semantic HTML/CSS, never as a baked, annotated raster +collage. The processor strips metadata, excludes time-dependent PNG chunks, +and uses fixed PNG compression settings. It does not resize, annotate, blur, +recolor, generate, or retouch app pixels. It compares each derivative against +the exact source crop with a zero-pixel-difference requirement. + +The command writes an 18-row reviewed `SHA256SUMS`, a 13-row deduplicated raw +`SOURCE_SHA256SUMS`, and `tutorial-captures.tsv`, which maps each stable key to +its raw file, content-versioned reviewed filename, and both full SHA-256 +values. Reviewed filenames include the PyBLE app version, build, and first 12 +hash characters. + +Use `--force` only after reviewing the current directory. It replaces only a +directory containing the processor's managed filenames; any unmanaged entry +causes a safe failure. Publication remains a separate, deliberate action. +Preserve older review directories as historical local evidence. + +Verify the processor contract itself with: + +```sh +tools/web/scripts/test-process-tutorial-captures.sh +``` + +Verify an actual local set from its raw directory with: + +```sh +capture_root=/ABSOLUTE/PATH/TO/RAW-CAPTURES +review_dir="$capture_root/reviewed-tutorial-set" +(cd "$review_dir" && shasum -a 256 -c SHA256SUMS) +(cd "$capture_root" && \ + shasum -a 256 -c "$review_dir/SOURCE_SHA256SUMS") +test "$(wc -l < "$review_dir/SHA256SUMS" | tr -d ' ')" -eq 18 +test "$(wc -l < "$review_dir/SOURCE_SHA256SUMS" | tr -d ' ')" -eq 13 +``` + +## Per-image review + +Fill the reviewed filename and full raw/reviewed digests from +`tutorial-captures.tsv`. Review each derivative at its native dimensions. + +| Stable capture key | Raw filename | Crop | Reviewed filename | Raw SHA-256 | Reviewed SHA-256 | State/privacy review | Approved | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `setup-five-board-scan` | `07-five-board-scan-stopped.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-5646-ready` | `02-connected-5646.raw.png` | Ready | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-5646-chip` | `02-connected-5646.raw.png` | Runtime | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-8c9e-ready` | `03-connected-8c9e.raw.png` | Ready | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-8c9e-chip` | `03-connected-8c9e.raw.png` | Runtime | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-c81a-ready` | `04-connected-c81a.raw.png` | Ready | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-c81a-chip` | `04-connected-c81a.raw.png` | Runtime | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-da86-ready` | `05-connected-da86.raw.png` | Ready | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-da86-chip` | `05-connected-da86.raw.png` | Runtime | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-3dcb-ready` | `06-connected-3dcb.raw.png` | Ready | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `identity-3dcb-chip` | `06-connected-3dcb.raw.png` | Runtime | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `first-program-editor-console` | `08-first-program-classic-esp32.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `files-multi-delete-review` | `17-files-multi-delete-classic-esp32-idle.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `github-branch-chooser` | `16-github-branch-chooser-generic-s3-clean.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `github-pinned-source` | `12-github-pinned-source-generic-s3.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `github-target-review` | `11-github-target-review-generic-s3.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `examples-import-complete` | `14-examples-import-waveshare.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | +| `blocks-hello-workspace` | `13-blocks-hello-c3-idle.raw.png` | Full frame | from manifest | `...` | `...` | `pass/fail` | `yes/no` | + +Confirm that the selected scan is stopped and stable with exactly five +distinct advertisements, the identity crops show the expected Ready/agent and +runtime-token pairs, and each workflow frame shows only its stated action. +Record every board write, Run action, cancellation, cleanup, and retained +disposable artifact. State explicitly that the screenshots are documentation +evidence, not PBLE conformance, release qualification, GPIO, display, wiring, +or other HIL evidence. + +Confirm that no selected image contains private notifications, account +details, credentials, personal filenames, private repositories, unpublished +code, debug labels, or integration-test labels. Intentionally documented +public board IDs are allowed. The Lenovo stylus affordance must be stopped +before every selected raw frame; do not remove it or any in-app pixel through +retouching. Confirm that no iPad was used for installation, interaction, +capture, processing, or selection in this set. Captions and alternative text +must describe the exact visible state before promotion. diff --git a/docs/testing/website/firmware-functional-diagram-deployment-2026-09-11.md b/docs/testing/website/firmware-functional-diagram-deployment-2026-09-11.md new file mode 100644 index 00000000..f3c72a57 --- /dev/null +++ b/docs/testing/website/firmware-functional-diagram-deployment-2026-09-11.md @@ -0,0 +1,49 @@ +# Firmware v0.6.1 functional diagram publication + +Published on 2026-09-11 at https://pyble.dev/features. + +- Source commit: `44717bdac281903912a5106f7dc1d37971197bf2`. +- Source worktree: + `/Users/vyv/Working/SciLabPro/PyBLE-worktrees/web-v061-owner-confirmed`. +- Source branch: `release/v061-owner-confirmed` (local; no push performed). +- New SVG: `/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg`. +- SVG SHA-256: + `0d2bb826c64f8ba24f34bc70d2a9ba5750bb77b1a7f6a0998a9baccd0123b768`. +- Source-backed changes include LFS2 on all five profiles, session admission, + fresh RUN globals, run-owned stdin, CRC-backed resume, and checked settings. +- The page explains the nonblank-media USB-recovery exception and distinguishes + owner qualification confirmation from incomplete automated HIL records. +- Historical v0.6.0 diagram retained unchanged. + +Focused website validation: expected RED (5 failures, 1 pass), followed by +46 passing tests in the page, Sites-output, and VPS-contract suites. Production +static build completed. The SVG, desktop page, and emulated 390-pixel mobile +viewport were visually reviewed. Mobile document width was 390 pixels; only +the diagram viewport scrolled (360-pixel viewport, 1040-pixel inner content). +Raw PNGs remain in the ignored local qualification workspace. + +Active VPS release: `/srv/pyble/releases/20260911-v061-functional-diagram`. +Previous owner-confirmed release retained for rollback. Only Features documents, +its new SVG/protocol snapshot, and supporting static bundles were overlaid. +Flash HTML/RSC, release selection, Learn documents, and older firmware artifacts +were preserved; no firmware, App, or hardware qualification action occurred. + +Public response hashes after activation: + +- Features HTML: `244e463ccc4f78fa77a9e8e336d20cf6fa39fcb231e64208688995e676650f9c`. +- Flash unchanged: `3dbcbe67dce7976f3c29e1760bec99d9659ef9aa962c440cbc469beb48d462e3`. +- Learn unchanged: `7985529abf7960687fa0e59e5b669f649b881ee04c432e1bbb36e5ae8ff8a677`. +- Protocol snapshot: `e88387706bf3705edb0d889fd9870800aa95306d4a17b55398cacbb364fa5c2d`. + +## Primary checkout synchronized + +The finished Features changes were subsequently integrated into +`/Users/vyv/Working/SciLabPro/PyBLE`, including the updated SVG under +`tools/web/public/features`, page source, specification, provenance, protocol +snapshot, owner-confirmation document, and focused tests/deployment references. +All 46 focused website tests passed in that checkout. The updated tracked page +and deployment-reference files exactly match the published source commit. + +The live Features HTML and SVG were already current and matched the recorded +hashes. No redundant deployment, firmware change, Flash change, or Learn change +was made during this synchronization. Unrelated local changes were preserved. diff --git a/docs/testing/website/firmware-functional-diagram-provenance-2026-09-01.md b/docs/testing/website/firmware-functional-diagram-provenance-2026-09-01.md new file mode 100644 index 00000000..e73994ac --- /dev/null +++ b/docs/testing/website/firmware-functional-diagram-provenance-2026-09-01.md @@ -0,0 +1,72 @@ + + + +# Firmware functional diagram provenance — 2026-09-01 + +Status: **reviewed and approved for public website use** + +## Published asset + +- Public path: + `/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg` +- Intrinsic dimensions: `1920 × 1470` +- Size: `21,995` bytes +- SHA-256: + `473a85d475aa90b1031ba100c0ab05c9227d44462134e92feee8d15b9d15003a` +- Release scope: qualified PyBLE firmware `v0.6.0`, agent `0.6.0`, and + protocol `PBLE/1` + +The public asset is a byte-for-byte promotion of the final reviewed SVG from +the ignored local review workspace. No PNG review rendering is published. + +## Authorship and clean-room review + +This is an original PyBLE-authored functional architecture diagram. Its visual +organization uses ordinary engineering-document conventions: a system +boundary, a central protocol bus, orthogonal connectors, and labeled rectangular +modules. The design contains no vendor artwork and no generated board image, +physical board likeness, schematic, pinout, proprietary identifier, or copied +vendor content. A public ST product page was considered only as an example of +the general functional-block-diagram form; none of its text, artwork, layout +measurements, symbols, or semiconductor content was reused. + +Earlier AI-generated board renderings were rejected during local review because +their invented hardware could confuse visitors. Those rejected images and +their prompts remain in the ignored review workspace and are not used by this +asset or the website. + +## Evidence and factual review + +The diagram was checked against: + +- the immutable public + [`v0.6.0` release descriptor](https://pyble.dev/firmware/v0.6.0/release.json); +- annotated source tag + [`firmware-v0.6.0`](https://github.com/PyBLE-dev/PyBLE/releases/tag/firmware-v0.6.0), + which resolves to source commit + `0c7230d6708797c241160ba71fbd37e6b22f180a`; and +- the version-bound + [PBLE/1 protocol specification](https://github.com/PyBLE-dev/PyBLE/blob/firmware-v0.6.0/docs/specifications/protocol.md). + +Review confirmed the complete 24-operation surface, the exact ordered five +release profiles, and the separate generic-memory-profile versus exact-board +boundaries. Final wording also records `.pbltmp` upload commit behavior, file +and empty-directory deletion, ESP Just Works pairing that is not access-gating, +and the narrower statement that the PyBLE agent sends no telemetry and needs no +cloud service. + +## Accessibility and asset safety + +- The SVG has an internal `` and `<desc>`, selectable text, and no + embedded raster image. +- It contains no script, `foreignObject`, external URL, `href`, or + `xlink:href`. +- Small-screen review proved that whole-image fitting is too dense. The public + page therefore supplies a keyboard-focusable inspection viewport, a + full-size SVG link, and a complete reflowing HTML equivalent. +- The page-level alternative text and visible caption explicitly say that this + is a functional diagram, not a board drawing, automatic detector, schematic, + or pinout. + +The maintainer approved the functional-diagram direction and publication on +2026-09-01. diff --git a/docs/testing/website/firmware-functional-diagram-provenance-2026-09-11.md b/docs/testing/website/firmware-functional-diagram-provenance-2026-09-11.md new file mode 100644 index 00000000..805139dd --- /dev/null +++ b/docs/testing/website/firmware-functional-diagram-provenance-2026-09-11.md @@ -0,0 +1,56 @@ +<!-- SPDX-License-Identifier: MIT --> +<!-- Part of PyBLE (https://pyble.dev) — see /LICENSE. --> + +# Firmware functional diagram provenance — 2026-09-11 + +Original PyBLE functional architecture SVG, updated from the preserved +v0.6.0 diagram. No vendor artwork and no generated board likeness is included. +Existing engineering-document styling, selectable text, orthogonal connections, +and exact profile boundaries are retained. + +- Public path: `/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg`. +- Dimensions: 1920 × 1470; size: 22,446 bytes. +- SHA-256: `0d2bb826c64f8ba24f34bc70d2a9ba5750bb77b1a7f6a0998a9baccd0123b768`. +- Firmware: 0.6.1; source: `c8f549eeabe6d2b8c2766eab022517944bb09c8c`. +- Release metadata: `/firmware/v0.6.1/release.json`, SHA-256 + `71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd`. +- Publication basis: project-owner qualification confirmation on 2026-09-11, + recorded at `/firmware-v0.6.1-owner-confirmation.md`. No missing automated + HIL result is represented as passed. + +## Source review + +The unchanged PBLE/1 surface is 24 operations and 15 serialized capability keys. +Actual overlays at `firmware/board_overlays/*/_boot.py` now mount LFS2 through +`firmware/pyble/pyble_workspace.py`; ESP partition metadata named `data,fat` +does not establish the mounted filesystem type. + +The revised modules correspond to the v0.6.1 amendments in +`docs/specifications/firmware/specs.md`: FR-BLE-10 (bounded fragments); +FR-PROTO-2/3/5/7/8/9 (session and command admission); FR-RUN-1/2/9 (fresh globals); +FR-CON-3 (run-owned input); FR-FS-1/4/7/8/9/14/15 (resume, reserve, mutations); +FR-BOOT-3/6 (durable settings and non-destructive mount refusal). +Both native C and portable Python implementations were consulted. + +The public protocol snapshot `/reference/firmware-v0.6.1/protocol.md` is +byte-identical to the protocol specification at the firmware source commit. +SHA-256: `e88387706bf3705edb0d889fd9870800aa95306d4a17b55398cacbb364fa5c2d`. +The source commit/tag is not yet available through the public GitHub API, so +the page links the published release notes/source identity and exact protocol +snapshot instead of inventing an available GitHub source URL. + +## Review and publication boundaries + +The SVG's title, description, page alternative text, visible caption, and +reflowing HTML cover the new behavior. Nonblank or uncertain failed mounts +require USB recovery without agent startup. Fresh globals do not imply a full +VM or hardware reset. The diagram does not claim authentication, lossless +console output, additional opcodes, or new board-specific hardware APIs. + +Header/body spacing and the provisioning warning were adjusted for legibility. +Raw PNG layout renders remain in the ignored local review workspace. +The SVG has no scripts, embedded rasters, external references, or foreignObject. +The historical v0.6.0 SVG and its provenance remain unchanged. + +This website-only update does not change firmware bytes, the selected Flash +release, App binaries, or qualification evidence, and performs no hardware tests. diff --git a/docs/testing/website/site-v061-consistency-2026-09-11.md b/docs/testing/website/site-v061-consistency-2026-09-11.md new file mode 100644 index 00000000..2ce8cd9f --- /dev/null +++ b/docs/testing/website/site-v061-consistency-2026-09-11.md @@ -0,0 +1,68 @@ +<!-- SPDX-License-Identifier: MIT --> +<!-- Part of PyBLE (https://pyble.dev) — see /LICENSE. --> + +# Whole-site v0.6.1 consistency — 2026-09-11 + +## Reason and scope + +Earlier route-only deployments updated Flash and Features but preserved an old +homepage, support page, and tutorial baseline. A coherent full static build +now uses the same exact owner-confirmed v0.6.1 selector on every release-aware +route. Source is maintained in the primary PyBLE checkout. + +Home, support and target cards recognize the owner-confirmed release as +available on all five exact profiles. Tutorial guidance references v0.6.1 and +explains fresh RUN globals, LFS2 migration and non-destructive mount refusal. +The example snapshot's original v0.6.0 baseline and screenshot version labels +remain historical facts, explicitly distinguished from current guidance. +The Flash artifact verifier, consent, manifests and firmware bytes are unchanged. + +The primary checkout also incorporates the already-deployed patched website +dependencies and exact owner-confirmed release policy, without changing App +or firmware sources. Its previous dependency installation is retained in the +ignored local work directory, not deleted. + +## Social image provenance + +The existing PyBLE-authored social SVG is reused with new title/description +and badge wording: “FIRMWARE v0.6.1 AVAILABLE”. Original App and logo source +image bytes, placement and clipping are unchanged. No hardware rendering or +retouched App screen was created. The App beta status is separate from the +firmware version. Historical card assets remain available. + +- SVG: `/social/pyble-v061-og-d3bab0c0-1200x630.svg`; + SHA-256 `d3bab0c05e2c07783b6f7006a7cd8152b4b0a8798f4ade55e31d28076cb9bb1f`. +- PNG: `/social/pyble-v061-og-297b270f-1200x630.png`; + SHA-256 `297b270fd4ec7637752ff954416cf4ca69570992b28d48b4530982db07c03af9`. +- Dimensions: 1200 × 630. +- Rasterization: existing pinned Sharp package; exact same-origin image + references embedded in memory for rendering, without changing source pixels. + The first local render lacked resolved image references and was rejected. + The final PNG was visually inspected and includes the real App screenshot. +- All shared Open Graph/Twitter metadata selects the new content-addressed PNG. + +## Validation and deployment + +The added consistency cases first failed (five expected failures), then the +full website suite passed (342 tests). No hardware tests are part of this task. +Deployment uses a new complete static site, preserves old assets/downloads, +and atomically switches the site symlink with the previous release retained. +Production route and artifact delivery checks are recorded in the local +deployment workspace; qualification claims remain owner-provided. + +## Published result + +Activated `/srv/pyble/releases/20260911-v061-complete-site` on 2026-09-11. +Website source: `14139e945053fabaa783a25233c83035b4aa87f2`. +The previous `/srv/pyble/releases/20260911-v061-functional-diagram` is retained +for rollback. All 17 public route responses matched the complete local build +byte-for-byte. The diagram, new social PNG, and protocol snapshot matched their +published hashes; all five firmware download endpoints returned HTTP 200. + +Public homepage SHA-256: +`3c0789790a2f9224a92b9e98746686f0707fd69c3531accd79eed51ca690a86f`. +Firmware release.json remains unchanged at +`71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd`. +The final 342-test website run passed after updating the two no-selection +fallback-copy assertions. No firmware tests, device actions, source push, or +App publication occurred. diff --git a/docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json b/docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json new file mode 100644 index 00000000..6f4f6666 --- /dev/null +++ b/docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json @@ -0,0 +1,1406 @@ +{ + "created_at": "2026-09-06T10:45:42Z", + "firmware_version": "0.6.1", + "measurement_contract": "oi1-five-profile-v1", + "profile_order": [ + "esp32-4mb", + "esp32-s3-n16r8", + "waveshare-esp32-s3-lcd-147b", + "esp32-c3-4mb", + "rpi-pico2-w" + ], + "profiles": [ + { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32 development board", + "device_flash_capacity_bytes": 4194304, + "device_psram_capacity_bytes": 0, + "environment": { + "ble_adapter": "Apple BCM_4388 PCIe (vendor 0x004C, product 0x4A12, firmware 23.5.574.4414)", + "ble_backend": "Bleak CoreBluetooth 3.0.2", + "desktop_os": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "python_version": "3.13.5" + }, + "install_sha256": "7f96fe9b03e2d74f14fe52b90e46913f406446762187f2a00ea52a8facb2312a", + "manifest_sha256": "ee36b2d27d4bcabb81ee9164cfd1fcacd13455f6da8ac962299d89f2ee9ed635", + "module_marking": "ESP32-D0WD revision v1.0 (esptool)", + "oi1_build": { + "application_headroom_bytes": 299712, + "application_image_bytes": 1731904, + "factory_partition_bytes": 2031616 + }, + "oi1_observation": { + "get_duration_ns": [ + 4593572709, + 4441099458, + 4531027375, + 4470674292, + 4498429542 + ], + "get_offset_sequences_validated": 5, + "get_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_unique_verified_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "get_verified_goodput_bytes_per_second": [ + 14266, + 14756, + 14463, + 14659, + 14568 + ], + "heap_default_free_post_hello_bytes": [ + 80348, + 80348, + 80348, + 80348, + 80348, + 80348, + 80348, + 80220, + 80344, + 80348 + ], + "heap_post_hello": [ + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80220, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80196 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80344, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80324 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 106704, + "idf_internal_free_bytes": 80348, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80328 + } + ], + "heap_post_reliability": { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + }, + "heap_post_roundtrip": [ + { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + }, + { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + }, + { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + }, + { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + }, + { + "gc_allocated_bytes": 4720, + "gc_free_bytes": 106576, + "idf_internal_free_bytes": 80172, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 80128 + } + ], + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 8, + "physical_power_cycle_advertising": "passed", + "put_committed_goodput_bytes_per_second": [ + 7612, + 7356, + 7532, + 7559, + 7610 + ], + "put_duration_ns": [ + 8608549042, + 8908493500, + 8699930833, + 8669308959, + 8611260750 + ], + "put_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_unique_committed_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "raw_log_sha256": "307d03fca573b73404e535e66a4645d695ce61fb0a11462b04e3c97b4e742237", + "reliability": { + "attempted_files": 20, + "bytes_per_file": 16384, + "completed_files": 20, + "failed_statuses": 0, + "integrity_failures": 0, + "retransmitted_bytes": 0, + "retransmitted_chunks": 0, + "rewinds": 0, + "total_payload_bytes": 327680, + "unexpected_disconnects": 0, + "verified_files": 20 + }, + "reset_to_service_advertisement_ms": [ + 1189, + 1184, + 1436, + 1131, + 1449, + 1184, + 1185, + 1130, + 1134, + 1133 + ], + "roundtrip_integrity_failures": 0, + "roundtrip_integrity_verified": 5, + "roundtrip_unexpected_disconnects": 0, + "transfer_link_facts": { + "connection_parameters": { + "request_return_codes": [ + 0, + 2 + ], + "settled_interval_units": 24, + "updates": [ + { + "interval_units": 24, + "status": 0 + } + ] + }, + "dle": { + "max_tx_octets": 251, + "max_tx_time_us": 2120, + "request_attempts": 1 + }, + "phy": { + "request_attempts": 0, + "required_2m": false, + "settled_rx": 0, + "settled_tx": 0, + "updates": [] + }, + "tx_mbuf_starve_count": 5371 + } + }, + "profile_id": "esp32-4mb", + "resource_kind": "esp-idf", + "target": "esp32" + }, + { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-S3 development board", + "device_flash_capacity_bytes": 16777216, + "device_psram_capacity_bytes": 8388608, + "environment": { + "ble_adapter": "Apple BCM_4388 PCIe (vendor 0x004C, product 0x4A12, firmware 23.5.574.4414)", + "ble_backend": "Bleak CoreBluetooth 3.0.2", + "desktop_os": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "python_version": "3.13.5" + }, + "install_sha256": "b6178d672aea241e0bd3ae48191ef8ab84f77a9299996d07fb4ab99cb9a9d242", + "manifest_sha256": "2b96497b18016cc0b88ce1c220359468b49aa0756dd98756a6334cf9979279d3", + "module_marking": "ESP32-S3 QFN56 revision v0.1 (esptool)", + "oi1_build": { + "application_headroom_bytes": 368240, + "application_image_bytes": 1728912, + "factory_partition_bytes": 2097152 + }, + "oi1_observation": { + "get_duration_ns": [ + 1923229125, + 1831123708, + 2309431125, + 2313163083, + 2309748583 + ], + "get_offset_sequences_validated": 5, + "get_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_unique_verified_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "get_verified_goodput_bytes_per_second": [ + 34076, + 35790, + 28377, + 28331, + 28373 + ], + "heap_default_free_post_hello_bytes": [ + 8463668, + 8463668, + 8463668, + 8463668, + 8463668, + 8463668, + 8463668, + 8463668, + 8463668, + 8463668 + ], + "heap_post_hello": [ + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + }, + { + "gc_allocated_bytes": 4592, + "gc_free_bytes": 8316944, + "idf_internal_free_bytes": 175327, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174475 + } + ], + "heap_post_reliability": { + "gc_allocated_bytes": 4816, + "gc_free_bytes": 8316720, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + }, + "heap_post_roundtrip": [ + { + "gc_allocated_bytes": 4864, + "gc_free_bytes": 8316672, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + }, + { + "gc_allocated_bytes": 4816, + "gc_free_bytes": 8316720, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + }, + { + "gc_allocated_bytes": 4816, + "gc_free_bytes": 8316720, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + }, + { + "gc_allocated_bytes": 4816, + "gc_free_bytes": 8316720, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + }, + { + "gc_allocated_bytes": 4816, + "gc_free_bytes": 8316720, + "idf_internal_free_bytes": 175151, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174123 + } + ], + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 8, + "physical_power_cycle_advertising": "passed", + "put_committed_goodput_bytes_per_second": [ + 17905, + 20604, + 14557, + 14467, + 14278 + ], + "put_duration_ns": [ + 3660069958, + 3180720042, + 4501811417, + 4530019625, + 4589924500 + ], + "put_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_unique_committed_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "raw_log_sha256": "4ad8ba2be1a097eef4d7c9839283c2bf3204d69b8ae47ca9c5fb6e8c66e256d2", + "reliability": { + "attempted_files": 20, + "bytes_per_file": 16384, + "completed_files": 20, + "failed_statuses": 0, + "integrity_failures": 0, + "retransmitted_bytes": 0, + "retransmitted_chunks": 0, + "rewinds": 0, + "total_payload_bytes": 327680, + "unexpected_disconnects": 0, + "verified_files": 20 + }, + "reset_to_service_advertisement_ms": [ + 865, + 864, + 866, + 859, + 1442, + 1516, + 1174, + 865, + 856, + 853 + ], + "roundtrip_integrity_failures": 0, + "roundtrip_integrity_verified": 5, + "roundtrip_unexpected_disconnects": 0, + "transfer_link_facts": { + "connection_parameters": { + "request_return_codes": [ + 0, + 2 + ], + "settled_interval_units": 24, + "updates": [ + { + "interval_units": 24, + "status": 0 + } + ] + }, + "dle": { + "max_tx_octets": 251, + "max_tx_time_us": 17040, + "request_attempts": 1 + }, + "phy": { + "request_attempts": 1, + "required_2m": true, + "settled_rx": 2, + "settled_tx": 2, + "updates": [ + { + "rx": 2, + "status": 0, + "tx": 2 + }, + { + "rx": 2, + "status": 0, + "tx": 2 + } + ] + }, + "tx_mbuf_starve_count": 2854 + } + }, + "profile_id": "esp32-s3-n16r8", + "resource_kind": "esp-idf", + "target": "esp32-s3" + }, + { + "board_manufacturer": "Waveshare", + "board_model": "ESP32-S3-LCD-1.47B", + "device_flash_capacity_bytes": 16777216, + "device_psram_capacity_bytes": 8388608, + "environment": { + "ble_adapter": "Apple BCM_4388 PCIe (vendor 0x004C, product 0x4A12, firmware 23.5.574.4414)", + "ble_backend": "Bleak CoreBluetooth 3.0.2", + "desktop_os": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "python_version": "3.13.5" + }, + "install_sha256": "f2ce91d0579496442854ebc5ab15ff51f3036c48df995673fb7ead672d8fb464", + "manifest_sha256": "2b96497b18016cc0b88ce1c220359468b49aa0756dd98756a6334cf9979279d3", + "module_marking": "ESP32-S3R8", + "oi1_build": { + "application_headroom_bytes": 361232, + "application_image_bytes": 1735920, + "factory_partition_bytes": 2097152 + }, + "oi1_observation": { + "get_duration_ns": [ + 2311358209, + 2338821833, + 2338392458, + 2371627166, + 2343949125 + ], + "get_offset_sequences_validated": 5, + "get_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_unique_verified_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "get_verified_goodput_bytes_per_second": [ + 28353, + 28020, + 28026, + 27633, + 27959 + ], + "heap_default_free_post_hello_bytes": [ + 8463556, + 8463544, + 8463556, + 8463544, + 8463556, + 8463556, + 8463544, + 8463556, + 8463544, + 8463556 + ], + "heap_post_hello": [ + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175203, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174351 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175203, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174351 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175203, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174351 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175203, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174351 + }, + { + "gc_allocated_bytes": 6032, + "gc_free_bytes": 8315504, + "idf_internal_free_bytes": 175215, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174363 + } + ], + "heap_post_reliability": { + "gc_allocated_bytes": 6128, + "gc_free_bytes": 8315408, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + }, + "heap_post_roundtrip": [ + { + "gc_allocated_bytes": 6176, + "gc_free_bytes": 8315360, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + }, + { + "gc_allocated_bytes": 6128, + "gc_free_bytes": 8315408, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + }, + { + "gc_allocated_bytes": 6128, + "gc_free_bytes": 8315408, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + }, + { + "gc_allocated_bytes": 6128, + "gc_free_bytes": 8315408, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + }, + { + "gc_allocated_bytes": 6128, + "gc_free_bytes": 8315408, + "idf_internal_free_bytes": 175039, + "idf_internal_largest_block_bytes": 102400, + "idf_internal_minimum_free_bytes": 174011 + } + ], + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 8, + "physical_power_cycle_advertising": "passed", + "put_committed_goodput_bytes_per_second": [ + 14657, + 14466, + 14463, + 14185, + 14468 + ], + "put_duration_ns": [ + 4471073958, + 4530066500, + 4531098750, + 4619860542, + 4529623541 + ], + "put_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_unique_committed_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "raw_log_sha256": "1c217cdee17d4be4cdbb2ad04bbd09b730234104dc14d1ee78aa02423c6f7d24", + "reliability": { + "attempted_files": 20, + "bytes_per_file": 16384, + "completed_files": 20, + "failed_statuses": 0, + "integrity_failures": 0, + "retransmitted_bytes": 0, + "retransmitted_chunks": 0, + "rewinds": 0, + "total_payload_bytes": 327680, + "unexpected_disconnects": 0, + "verified_files": 20 + }, + "reset_to_service_advertisement_ms": [ + 21, + 16, + 13, + 13, + 8, + 15, + 14, + 11, + 15, + 11 + ], + "roundtrip_integrity_failures": 0, + "roundtrip_integrity_verified": 5, + "roundtrip_unexpected_disconnects": 0, + "transfer_link_facts": { + "connection_parameters": { + "request_return_codes": [ + 0, + 2, + 2 + ], + "settled_interval_units": 24, + "updates": [ + { + "interval_units": 24, + "status": 0 + } + ] + }, + "dle": { + "max_tx_octets": 251, + "max_tx_time_us": 17040, + "request_attempts": 1 + }, + "phy": { + "request_attempts": 1, + "required_2m": true, + "settled_rx": 2, + "settled_tx": 2, + "updates": [ + { + "rx": 2, + "status": 0, + "tx": 2 + }, + { + "rx": 2, + "status": 0, + "tx": 2 + } + ] + }, + "tx_mbuf_starve_count": 3185 + } + }, + "profile_id": "waveshare-esp32-s3-lcd-147b", + "resource_kind": "esp-idf", + "target": "waveshare-esp32-s3-lcd-147b" + }, + { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-C3 development board", + "device_flash_capacity_bytes": 4194304, + "device_psram_capacity_bytes": 0, + "environment": { + "ble_adapter": "Apple BCM_4388 PCIe (vendor 0x004C, product 0x4A12, firmware 23.5.574.4414)", + "ble_backend": "Bleak CoreBluetooth 3.0.2", + "desktop_os": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "python_version": "3.13.5" + }, + "install_sha256": "0134d0a67f30ede440b698d608595ad6a3b97ff60bf07a19bd4bc95c5c9000e1", + "manifest_sha256": "89a515626d510c4aa885dc2227a1baf76eb438f526db266719d72fad5678d8ff", + "module_marking": "ESP32-C3 QFN32 revision v0.4 (esptool)", + "oi1_build": { + "application_headroom_bytes": 339536, + "application_image_bytes": 1692080, + "factory_partition_bytes": 2031616 + }, + "oi1_observation": { + "get_duration_ns": [ + 3422645208, + 3212319750, + 3303317084, + 3208977708, + 3271416542 + ], + "get_offset_sequences_validated": 5, + "get_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_unique_verified_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "get_verified_goodput_bytes_per_second": [ + 19147, + 20401, + 19839, + 20422, + 20032 + ], + "heap_default_free_post_hello_bytes": [ + 64920, + 64920, + 64920, + 64920, + 64920, + 64920, + 64920, + 64920, + 64920, + 64920 + ], + "heap_post_hello": [ + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 4576, + "gc_free_bytes": 114720, + "idf_internal_free_bytes": 64920, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + } + ], + "heap_post_reliability": { + "gc_allocated_bytes": 5760, + "gc_free_bytes": 113536, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + "heap_post_roundtrip": [ + { + "gc_allocated_bytes": 5760, + "gc_free_bytes": 113536, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 5760, + "gc_free_bytes": 113536, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 5776, + "gc_free_bytes": 113520, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 5776, + "gc_free_bytes": 113520, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + }, + { + "gc_allocated_bytes": 5776, + "gc_free_bytes": 113520, + "idf_internal_free_bytes": 64744, + "idf_internal_largest_block_bytes": 55296, + "idf_internal_minimum_free_bytes": 61152 + } + ], + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 8, + "physical_power_cycle_advertising": "passed", + "put_committed_goodput_bytes_per_second": [ + 12628, + 13081, + 13241, + 13161, + 13403 + ], + "put_duration_ns": [ + 5189471709, + 5009847458, + 4949218791, + 4979516459, + 4889536000 + ], + "put_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_unique_committed_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "raw_log_sha256": "b32533d58469d7292a9cd0a0c0171cc3b8f3cb4c09370e772ceb482b815983e0", + "reliability": { + "attempted_files": 20, + "bytes_per_file": 16384, + "completed_files": 20, + "failed_statuses": 0, + "integrity_failures": 0, + "retransmitted_bytes": 0, + "retransmitted_chunks": 0, + "rewinds": 0, + "total_payload_bytes": 327680, + "unexpected_disconnects": 0, + "verified_files": 20 + }, + "reset_to_service_advertisement_ms": [ + 591, + 536, + 847, + 1181, + 846, + 1163, + 529, + 594, + 587, + 897 + ], + "roundtrip_integrity_failures": 0, + "roundtrip_integrity_verified": 5, + "roundtrip_unexpected_disconnects": 0, + "transfer_link_facts": { + "connection_parameters": { + "request_return_codes": [ + 0, + 2 + ], + "settled_interval_units": 24, + "updates": [ + { + "interval_units": 24, + "status": 0 + } + ] + }, + "dle": { + "max_tx_octets": 251, + "max_tx_time_us": 17040, + "request_attempts": 1 + }, + "phy": { + "request_attempts": 1, + "required_2m": true, + "settled_rx": 2, + "settled_tx": 2, + "updates": [ + { + "rx": 2, + "status": 0, + "tx": 2 + }, + { + "rx": 2, + "status": 0, + "tx": 2 + } + ] + }, + "tx_mbuf_starve_count": 3870 + } + }, + "profile_id": "esp32-c3-4mb", + "resource_kind": "esp-idf", + "target": "esp32-c3" + }, + { + "board_manufacturer": "Raspberry Pi Ltd", + "board_model": "Raspberry Pi Pico 2 W", + "device_flash_capacity_bytes": 4194304, + "device_psram_capacity_bytes": 0, + "environment": { + "ble_adapter": "Apple BCM_4388 PCIe (vendor 0x004C, product 0x4A12, firmware 23.5.574.4414)", + "ble_backend": "Bleak CoreBluetooth 3.0.2", + "desktop_os": "macOS-26.6.2-arm64-arm-64bit-Mach-O", + "python_version": "3.13.5" + }, + "install_sha256": "d09df0662f2a35e2ada9f177eb33c4da43efcfa8c673ec51693e3966d5bbec24", + "module_marking": "RP2350 + CYW43439", + "oi1_build": { + "firmware_bin_bytes": 862492, + "firmware_image_headroom_bytes": 710372, + "firmware_image_limit_bytes": 1572864 + }, + "oi1_observation": { + "get_duration_ns": [ + 9031291375, + 9061709250, + 9091691833, + 8944117167, + 9124569167 + ], + "get_offset_sequences_validated": 5, + "get_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "get_unique_verified_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "get_verified_goodput_bytes_per_second": [ + 7256, + 7232, + 7208, + 7327, + 7182 + ], + "heap_default_free_post_hello_bytes": [ + 295776, + 295776, + 295840, + 295776, + 295776, + 295776, + 295776, + 295840, + 295776, + 295776 + ], + "heap_post_hello": [ + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + } + ], + "heap_post_reliability": { + "gc_allocated_bytes": 27200, + "gc_free_bytes": 429968 + }, + "heap_post_roundtrip": [ + { + "gc_allocated_bytes": 27152, + "gc_free_bytes": 430016 + }, + { + "gc_allocated_bytes": 27168, + "gc_free_bytes": 430000 + }, + { + "gc_allocated_bytes": 27168, + "gc_free_bytes": 430000 + }, + { + "gc_allocated_bytes": 27200, + "gc_free_bytes": 429968 + }, + { + "gc_allocated_bytes": 27200, + "gc_free_bytes": 429968 + } + ], + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 4, + "physical_power_cycle_advertising": "passed", + "put_committed_goodput_bytes_per_second": [ + 14091, + 14374, + 14179, + 14367, + 13910 + ], + "put_duration_ns": [ + 4650681958, + 4559058500, + 4621833875, + 4561430083, + 4711151875 + ], + "put_retransmitted_bytes": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_retransmitted_chunks": [ + 0, + 0, + 0, + 0, + 0 + ], + "put_unique_committed_bytes": [ + 65536, + 65536, + 65536, + 65536, + 65536 + ], + "raw_log_sha256": "04ea58a1ed75170f45d706d5c68953b3bff02dd7f95dd1ff4fae7a7e4817b782", + "reliability": { + "attempted_files": 20, + "bytes_per_file": 16384, + "completed_files": 20, + "failed_statuses": 0, + "integrity_failures": 0, + "retransmitted_bytes": 0, + "retransmitted_chunks": 0, + "rewinds": 0, + "total_payload_bytes": 327680, + "unexpected_disconnects": 0, + "verified_files": 20 + }, + "reset_to_service_advertisement_ms": [ + 50, + 13, + 24, + 21, + 20, + 14, + 25, + 16, + 22, + 18 + ], + "roundtrip_integrity_failures": 0, + "roundtrip_integrity_verified": 5, + "roundtrip_unexpected_disconnects": 0, + "transfer_link_facts": { + "ble_host": "btstack", + "console_tx_budget_ms": 103, + "observed_att_mtu": 247, + "observed_chunk_bytes": 229, + "observed_window": 4 + } + }, + "profile_id": "rpi-pico2-w", + "resource_image_sha256": "36220898a8bfef521453eb04b3dd01b005a13f34275ab57b8cb060e1c0b535e2", + "resource_kind": "rp2", + "target": "rpi-pico2-w" + } + ], + "schema_version": 2, + "source_commit": "96497b9cf642ab2078842ce8a321c3c84b6714b2" +} diff --git a/firmware/board_overlays/esp32-c3/_boot.py b/firmware/board_overlays/esp32-c3/_boot.py index 5a96901a..591003b6 100644 --- a/firmware/board_overlays/esp32-c3/_boot.py +++ b/firmware/board_overlays/esp32-c3/_boot.py @@ -13,17 +13,22 @@ import gc import vfs from flashbdev import bdev +import pyble_workspace +workspace_ready = False try: - if bdev: - vfs.mount(bdev, "/") -except OSError: - import inisetup - - inisetup.setup() + if not bdev: + raise OSError("workspace block device unavailable") + vfs.mount(pyble_workspace.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True), "/") + pyble_workspace.boot_attached() + workspace_ready = True +except Exception: + pyble_workspace.boot_recovery() # PyBLE native agent auto-start (firmware-embedded, un-deletable). try: + if not workspace_ready: + raise RuntimeError("workspace unavailable") import pble_ble pble_ble.init_agent() diff --git a/firmware/board_overlays/esp32-c3/manifest.py b/firmware/board_overlays/esp32-c3/manifest.py index ed628d5d..79499f12 100644 --- a/firmware/board_overlays/esp32-c3/manifest.py +++ b/firmware/board_overlays/esp32-c3/manifest.py @@ -7,9 +7,8 @@ # modules + asyncio + the PyBLE agent, and OVERRIDE _boot.py so the native BLE # agent auto-starts from the image (un-deletable), not a vfs main.py. -# Essential esp32 boot modules (mount the vfs) — our _boot.py imports these. +# Essential ESP block device plus the shared explicit-LFS2 mount authority. module("flashbdev.py", base_path="$(PORT_DIR)/modules", opt=3) -module("inisetup.py", base_path="$(PORT_DIR)/modules", opt=3) include("$(MPY_DIR)/extmod/asyncio") # Standard upstream WS2812/NeoPixel user-code driver; pins remain explicit. @@ -19,9 +18,10 @@ # (pble_ble.init_agent). Overrides upstream ports/esp32/modules/_boot.py. module("_boot.py", base_path="$(BOARD_DIR)", opt=3) -# Freeze only the four on-device scaffolds. Pure native-twin host seams live +# Freeze only the five on-device scaffolds. Pure native-twin host seams live # beside them in firmware/pyble but are deliberately excluded from the image. module("_version.py", base_path="$(BOARD_DIR)/pyble", opt=3) module("pyble/__init__.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_ble.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_proto.py", base_path="$(BOARD_DIR)", opt=3) +module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3) diff --git a/firmware/board_overlays/esp32-c3/partitions.csv b/firmware/board_overlays/esp32-c3/partitions.csv index 9844478b..7b8e7fa1 100644 --- a/firmware/board_overlays/esp32-c3/partitions.csv +++ b/firmware/board_overlays/esp32-c3/partitions.csv @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT # PyBLE ESP32-C3 partition table (4 MB flash). NVS + phy_init + factory app + a -# FAT vfs for the user workspace. The C3 is the binding footprint target. +# LFS2-formatted vfs workspace. The C3 is the binding footprint target. # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, diff --git a/firmware/board_overlays/esp32-s3/_boot.py b/firmware/board_overlays/esp32-s3/_boot.py index 5a96901a..591003b6 100644 --- a/firmware/board_overlays/esp32-s3/_boot.py +++ b/firmware/board_overlays/esp32-s3/_boot.py @@ -13,17 +13,22 @@ import gc import vfs from flashbdev import bdev +import pyble_workspace +workspace_ready = False try: - if bdev: - vfs.mount(bdev, "/") -except OSError: - import inisetup - - inisetup.setup() + if not bdev: + raise OSError("workspace block device unavailable") + vfs.mount(pyble_workspace.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True), "/") + pyble_workspace.boot_attached() + workspace_ready = True +except Exception: + pyble_workspace.boot_recovery() # PyBLE native agent auto-start (firmware-embedded, un-deletable). try: + if not workspace_ready: + raise RuntimeError("workspace unavailable") import pble_ble pble_ble.init_agent() diff --git a/firmware/board_overlays/esp32-s3/manifest.py b/firmware/board_overlays/esp32-s3/manifest.py index bc3aa847..0867b866 100644 --- a/firmware/board_overlays/esp32-s3/manifest.py +++ b/firmware/board_overlays/esp32-s3/manifest.py @@ -7,9 +7,8 @@ # modules + asyncio + the PyBLE agent, and OVERRIDE _boot.py so the native BLE # agent auto-starts from the image (un-deletable), not a vfs main.py. -# Essential esp32 boot modules (mount the vfs) — our _boot.py imports these. +# Essential ESP block device plus the shared explicit-LFS2 mount authority. module("flashbdev.py", base_path="$(PORT_DIR)/modules", opt=3) -module("inisetup.py", base_path="$(PORT_DIR)/modules", opt=3) include("$(MPY_DIR)/extmod/asyncio") # Standard upstream WS2812/NeoPixel user-code driver; pins remain explicit. @@ -19,9 +18,10 @@ # (pble_ble.init_agent). Overrides upstream ports/esp32/modules/_boot.py. module("_boot.py", base_path="$(BOARD_DIR)", opt=3) -# Freeze only the four on-device scaffolds. Pure native-twin host seams live +# Freeze only the five on-device scaffolds. Pure native-twin host seams live # beside them in firmware/pyble but are deliberately excluded from the image. module("_version.py", base_path="$(BOARD_DIR)/pyble", opt=3) module("pyble/__init__.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_ble.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_proto.py", base_path="$(BOARD_DIR)", opt=3) +module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3) diff --git a/firmware/board_overlays/esp32-s3/partitions.csv b/firmware/board_overlays/esp32-s3/partitions.csv index 27fddb89..2ad11538 100644 --- a/firmware/board_overlays/esp32-s3/partitions.csv +++ b/firmware/board_overlays/esp32-s3/partitions.csv @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MIT # PyBLE ESP32-S3 partition table (16 MB — the board's full flash). Explicit `vfs` # partition (flashbdev.py mounts the partition LABELLED "vfs"): 2 MB factory app -# (fits the ~1.7 MB agent image) + a ~13.9 MB FAT vfs for the user workspace. +# (fits the ~1.7 MB agent image) + a ~13.9 MB LFS2-formatted vfs workspace. # Requires CONFIG_ESPTOOLPY_FLASHSIZE=16MB (set in sdkconfig.board). # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, diff --git a/firmware/board_overlays/esp32/_boot.py b/firmware/board_overlays/esp32/_boot.py index 5a96901a..591003b6 100644 --- a/firmware/board_overlays/esp32/_boot.py +++ b/firmware/board_overlays/esp32/_boot.py @@ -13,17 +13,22 @@ import gc import vfs from flashbdev import bdev +import pyble_workspace +workspace_ready = False try: - if bdev: - vfs.mount(bdev, "/") -except OSError: - import inisetup - - inisetup.setup() + if not bdev: + raise OSError("workspace block device unavailable") + vfs.mount(pyble_workspace.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True), "/") + pyble_workspace.boot_attached() + workspace_ready = True +except Exception: + pyble_workspace.boot_recovery() # PyBLE native agent auto-start (firmware-embedded, un-deletable). try: + if not workspace_ready: + raise RuntimeError("workspace unavailable") import pble_ble pble_ble.init_agent() diff --git a/firmware/board_overlays/esp32/manifest.py b/firmware/board_overlays/esp32/manifest.py index c71846ae..85fe2410 100644 --- a/firmware/board_overlays/esp32/manifest.py +++ b/firmware/board_overlays/esp32/manifest.py @@ -7,9 +7,8 @@ # modules + asyncio + the PyBLE agent, and OVERRIDE _boot.py so the native BLE # agent auto-starts from the image (un-deletable), not a vfs main.py. -# Essential esp32 boot modules (mount the vfs) — our _boot.py imports these. +# Essential ESP block device plus the shared explicit-LFS2 mount authority. module("flashbdev.py", base_path="$(PORT_DIR)/modules", opt=3) -module("inisetup.py", base_path="$(PORT_DIR)/modules", opt=3) include("$(MPY_DIR)/extmod/asyncio") # Standard upstream WS2812/NeoPixel user-code driver; pins remain explicit. @@ -19,9 +18,10 @@ # (pble_ble.init_agent). Overrides upstream ports/esp32/modules/_boot.py. module("_boot.py", base_path="$(BOARD_DIR)", opt=3) -# Freeze only the four on-device scaffolds. Pure native-twin host seams live +# Freeze only the five on-device scaffolds. Pure native-twin host seams live # beside them in firmware/pyble but are deliberately excluded from the image. module("_version.py", base_path="$(BOARD_DIR)/pyble", opt=3) module("pyble/__init__.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_ble.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_proto.py", base_path="$(BOARD_DIR)", opt=3) +module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3) diff --git a/firmware/board_overlays/esp32/partitions.csv b/firmware/board_overlays/esp32/partitions.csv index ccceed9a..46d4da25 100644 --- a/firmware/board_overlays/esp32/partitions.csv +++ b/firmware/board_overlays/esp32/partitions.csv @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT # PyBLE ESP32 partition table (4 MB flash). Standard MicroPython layout: NVS + -# phy_init + a factory app + a FAT vfs for the user workspace. +# phy_init + a factory app + an LFS2-formatted vfs user workspace. # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, diff --git a/firmware/board_overlays/rpi-pico2-w/_boot.py b/firmware/board_overlays/rpi-pico2-w/_boot.py index 42d04bde..f771a8bc 100644 --- a/firmware/board_overlays/rpi-pico2-w/_boot.py +++ b/firmware/board_overlays/rpi-pico2-w/_boot.py @@ -14,25 +14,33 @@ # never starts user code itself. import rp2 import vfs +import pyble_boot +import pyble_workspace -# Stock LFS2 mount (keep in sync with upstream ports/rp2/modules/_boot.py). -# The flash requires the programming size to be aligned to 256 bytes. +# The flash requires the programming size to be aligned to 256 bytes. A +# healthy mount remains the stock constructor path; the helper permits one +# format only after proving the complete device is erased. bdev = rp2.Flash() +workspace_ready = False try: - fs = vfs.VfsLfs2(bdev, progsize=256) -except: # noqa: E722 - bare except mirrors upstream (first boot: no fs yet) - vfs.VfsLfs2.mkfs(bdev, progsize=256) - fs = vfs.VfsLfs2(bdev, progsize=256) -vfs.mount(fs, "/") -del vfs, bdev, fs + fs = pyble_boot.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True) + vfs.mount(fs, "/") + pyble_workspace.boot_attached() + workspace_ready = True +except Exception: + # Fixed, bounded USB-only guidance: never print storage contents or the + # variable exception, and never start agent/autorun on uncertain media. + pyble_workspace.boot_recovery() # PyBLE agent auto-start (firmware-embedded, un-deletable). main() is the # supervisor loop and normally never returns; if the agent is not frozen yet # (bring-up images) or the supervisor hits a fatal fault, fall through to the # REPL rather than wedging the board. -try: - import pyble_agent +if workspace_ready: + del vfs, bdev, fs + try: + import pyble_agent - pyble_agent.main() -except Exception: - pass + pyble_agent.main() + except Exception: + pass diff --git a/firmware/board_overlays/rpi-pico2-w/manifest.py b/firmware/board_overlays/rpi-pico2-w/manifest.py index 81a41631..baae7f67 100644 --- a/firmware/board_overlays/rpi-pico2-w/manifest.py +++ b/firmware/board_overlays/rpi-pico2-w/manifest.py @@ -37,6 +37,7 @@ "pyble_info.py", "pyble_proto.py", "pyble_runner.py", + "pyble_workspace.py", ), opt=3, ) diff --git a/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/_boot.py b/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/_boot.py index 949e1537..bc43173f 100644 --- a/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/_boot.py +++ b/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/_boot.py @@ -4,16 +4,21 @@ import gc import vfs from flashbdev import bdev +import pyble_workspace +workspace_ready = False try: - if bdev: - vfs.mount(bdev, "/") -except OSError: - import inisetup - - inisetup.setup() + if not bdev: + raise OSError("workspace block device unavailable") + vfs.mount(pyble_workspace.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True), "/") + pyble_workspace.boot_attached() + workspace_ready = True +except Exception: + pyble_workspace.boot_recovery() try: + if not workspace_ready: + raise RuntimeError("workspace unavailable") import pble_ble pble_ble.init_agent() diff --git a/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/manifest.py b/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/manifest.py index ddfecfa5..08bcadbc 100644 --- a/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/manifest.py +++ b/firmware/board_overlays/waveshare-esp32-s3-lcd-147b/manifest.py @@ -3,7 +3,6 @@ # Exact Waveshare ESP32-S3-LCD-1.47B frozen manifest (ADR-0028). module("flashbdev.py", base_path="$(PORT_DIR)/modules", opt=3) -module("inisetup.py", base_path="$(PORT_DIR)/modules", opt=3) include("$(MPY_DIR)/extmod/asyncio") require("neopixel") @@ -16,3 +15,4 @@ module("pyble/__init__.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_ble.py", base_path="$(BOARD_DIR)", opt=3) module("pyble/pyble_proto.py", base_path="$(BOARD_DIR)", opt=3) +module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3) diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE b/firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE new file mode 100644 index 00000000..d523342d --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2017 André Müller; foss@andremueller-online.de + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts b/firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts new file mode 100644 index 00000000..ab3c4d25 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts @@ -0,0 +1,22 @@ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json b/firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json new file mode 100644 index 00000000..ea521a03 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json @@ -0,0 +1,341 @@ +{ + "archive": { + "asset_created_at": "2026-07-03T17:55:20Z", + "asset_id": 465646274, + "bytes": 1980457, + "filename": "picotool-2.3.0-mac.zip", + "sha256": "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e", + "url": "https://github.com/raspberrypi/pico-sdk-tools/releases/download/v2.3.0-0/picotool-2.3.0-mac.zip" + }, + "archive_members": [ + { + "bytes": 0, + "kind": "directory", + "mode": "040755", + "path": "picotool/", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "bytes": 767, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolTargets-release.cmake", + "sha256": "e40085002e565de850554e772c90f1a1c4cffb545f853eaa231b145e498f7c0f" + }, + { + "bytes": 2303, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolConfigVersion.cmake", + "sha256": "61485a18a59a186d7cb82ddf7686c5f1f4def81726568ae49fd0033db368f7e3" + }, + { + "bytes": 367931, + "kind": "regular", + "mode": "100644", + "path": "picotool/rp2350_otp_contents.json", + "sha256": "1838713d5f94316c4c61558cb82d3346831235dc41f9b0f02151e6eda3f22fc2" + }, + { + "bytes": 328336, + "kind": "regular", + "mode": "100444", + "path": "picotool/libusb-1.0.0.dylib", + "sha256": "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" + }, + { + "bytes": 29484, + "kind": "regular", + "mode": "100644", + "path": "picotool/enc_bootloader_mbedtls.elf", + "sha256": "c5d17fcbb4f1ee41751dc782d86c1fc6629d8da3484e1a1cadc3ba4bec4d6044" + }, + { + "bytes": 3856, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolTargets.cmake", + "sha256": "043655d4bc215fb3a05d4a04f92cabc0be9bbdb0834717b0211e80106b4d1aae" + }, + { + "bytes": 96, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolConfig.cmake", + "sha256": "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" + }, + { + "bytes": 34004, + "kind": "regular", + "mode": "100644", + "path": "picotool/xip_ram_perms.elf", + "sha256": "afac0e166ee9b632f84717c7db02e7e5e648f03a51cc4795938adcd282c6103b" + }, + { + "bytes": 5673800, + "kind": "regular", + "mode": "100755", + "path": "picotool/picotool", + "sha256": "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" + }, + { + "bytes": 20104, + "kind": "regular", + "mode": "100644", + "path": "picotool/enc_bootloader.elf", + "sha256": "9ce3c424d61226225d2c1f5b80a8854f15174b09dd447e58e4439240b274fb3d" + }, + { + "bytes": 0, + "kind": "regular", + "mode": "100644", + "path": ".keep", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "components": [ + { + "copyright": "Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "", + "ref": "2.3.0", + "repo": "https://github.com/raspberrypi/picotool.git" + }, + "id": "picotool", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT", + "sha256": "483f865953435b66c443dee7558debe3cc3cf8fcbb6a112fd9fc6a795d53f1f6" + } + ], + "review_note": "Authored picotool code; this record does not relabel the composite executable.", + "selected_spdx_expression": "BSD-3-Clause", + "source_spdx_expression": "BSD-3-Clause" + }, + { + "copyright": "Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd. and contributors", + "exact_source_identity": { + "commit": "98a542c1a62fb549ffb5d66a3e5892b06276b670", + "path": "", + "ref": "2.3.0", + "repo": "https://github.com/raspberrypi/pico-sdk.git" + }, + "id": "pico-sdk", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT", + "sha256": "483f865953435b66c443dee7558debe3cc3cf8fcbb6a112fd9fc6a795d53f1f6" + } + ], + "review_note": "Pico SDK code and data embedded in picotool and the shipped helper ELF/JSON members.", + "selected_spdx_expression": "BSD-3-Clause", + "source_spdx_expression": "BSD-3-Clause" + }, + { + "copyright": "Copyright The Mbed TLS Contributors", + "exact_source_identity": { + "commit": "0bebf8b8c7f07abe3571ded48a11aa907a1ffb20", + "path": "", + "ref": "pico-sdk-2.3.0-gitlink", + "repo": "https://github.com/Mbed-TLS/mbedtls.git" + }, + "id": "mbedtls", + "license_files": [ + { + "path": "firmware/licenses/evidence/mbedtls-LICENSE.txt", + "sha256": "9b405ef4c89342f5eae1dd828882f931747f71001cfba7d114801039b52ad09b" + } + ], + "review_note": "Apache-2.0 is selected from the upstream dual-license choice for the exact Pico SDK gitlink.", + "selected_spdx_expression": "Apache-2.0", + "source_spdx_expression": "(Apache-2.0 OR GPL-2.0-or-later)" + }, + { + "copyright": "Copyright (c) 2017 Andre Mueller", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "clipp", + "repo": "https://github.com/raspberrypi/picotool.git", + "tree": "042fa829db68727e8ed5c03a1b0aecc51e136236" + }, + "id": "clipp", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE", + "sha256": "72c5d1a6f8c76c88b4559d26710c3a298a0ba9822ab3d187d0b4a5b2e8e2b219" + } + ], + "review_note": "Exact reduced/modified vendored tree; the declared upstream 1.2.3 version is not asserted as byte-identical source.", + "selected_spdx_expression": "MIT", + "source_spdx_expression": "MIT" + }, + { + "copyright": "Copyright (c) 2013-2022 Niels Lohmann", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "lib/nlohmann_json", + "repo": "https://github.com/raspberrypi/picotool.git", + "tree": "e9fc73b9e9ed7a95db493725b6687048cbbec5aa" + }, + "id": "nlohmann-json", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT", + "sha256": "86b998c792894ccb911a1cb7994f7a9652894e7a094c0b5e45be2f553f45cf14" + } + ], + "review_note": "Exact modified vendored v3.11.3 tree. The Apache Abseil fallback block is excluded by the reviewed C++14 build.", + "selected_spdx_expression": "MIT", + "source_spdx_expression": "MIT" + }, + { + "copyright": "Copyright (c) 2022, The littlefs authors; Copyright (c) 2017, Arm Limited", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "lib/littlefs", + "repo": "https://github.com/raspberrypi/picotool.git", + "tree": "6b1d6244884ec1db461b373d59d6c27288a637cc" + }, + "id": "littlefs", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md", + "sha256": "0cb4ff1daf5fdc1359c6a6ee3116092f08fc100c9d58b1b77ab17bfd801f856d" + } + ], + "review_note": "Vendored source and license match upstream v2.11.1 commit 8e251dd675da00342d45dac78b6f627f119aed03.", + "selected_spdx_expression": "BSD-3-Clause", + "source_spdx_expression": "BSD-3-Clause" + }, + { + "copyright": "Copyright (C) 2016, ChaN and modification authors", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "lib/oofatfs", + "repo": "https://github.com/raspberrypi/picotool.git", + "tree": "97e9a825d481041708e447adf24b07103e25d384" + }, + "id": "oofatfs", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE", + "sha256": "4449f912502843f47a0a258e740054f916a21586f849a27ce40f936c095e2f6c" + } + ], + "review_note": "The moving work-R0.13c branch is origin metadata only; this exact vendored tree is the source identity.", + "selected_spdx_expression": "LicenseRef-PyBLE-ooFatFs-R0.13c", + "source_spdx_expression": "LicenseRef-PyBLE-ooFatFs-R0.13c" + }, + { + "copyright": "Copyright Gregory Pakosz; Raspberry Pi additions under the picotool grant", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "lib/whereami", + "repo": "https://github.com/raspberrypi/picotool.git", + "tree": "cf68ed7e2730ecc30d972f12d218319fe63e6de2" + }, + "id": "whereami", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT", + "sha256": "3633afb48d2f5fb9a1942eead37adbe0ab6c70a79468c1b69e5a1e771ec4b119" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2", + "sha256": "8b3d9ef07a62cfaa0194bdd64fa1bb61bbc18e4a156a83f8a52d937bd945239b" + } + ], + "review_note": "Upstream commit f5e3eac441acbb4ec1fe3e2c32646248ae463398 proves the dual license but is not claimed as an exact code ref; MIT is selected.", + "selected_spdx_expression": "MIT", + "source_spdx_expression": "(MIT OR WTFPL)" + }, + { + "copyright": "See the exact CMake package-script grant", + "exact_source_identity": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "path": "cmake/copying-cmake-scripts", + "repo": "https://github.com/raspberrypi/picotool.git" + }, + "id": "cmake-package-scripts", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts", + "sha256": "8c33ed647ccfaa4e682ffe56b677f4d5d0dc51e2ccc40038015df784c7be51dd" + } + ], + "review_note": "The exact generated package-member hashes are retained; no unrecorded CMake generator patch provenance is invented.", + "selected_spdx_expression": "LicenseRef-PyBLE-CMake-Package-Scripts", + "source_spdx_expression": "LicenseRef-PyBLE-CMake-Package-Scripts" + }, + { + "copyright": "Copyright libusb authors; see AUTHORS", + "exact_source_identity": { + "commit": "87a55632db62c9bdc58cd31d3ccfa673f1bb017f", + "ref": "v1.0.30", + "repo": "https://github.com/libusb/libusb.git" + }, + "id": "libusb", + "license_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING", + "sha256": "5df07007198989c622f5d41de8d703e7bef3d0e79d62e24332ee739a452af62a" + } + ], + "review_note": "The bundled dylib reports 1.0.30.12037; the exact official source archive and formula evidence are bound below.", + "selected_spdx_expression": "LGPL-2.1-or-later", + "source_spdx_expression": "LGPL-2.1-or-later" + } + ], + "distribution_provenance": { + "commit": "ad9e4a8375253cf4886bf168ea1a8d2746aadf24", + "copyright": null, + "license": { + "identifier": "Apache-2.0", + "path": "firmware/licenses/texts/Apache-2.0.txt", + "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + }, + "notice": null, + "ownership": "packaging-provenance-only", + "ref": "v2.3.0-0", + "repo": "https://github.com/raspberrypi/pico-sdk-tools.git" + }, + "libusb_binary_provenance": { + "binary_runtime_version": "1.0.30.12037", + "binary_sha256": "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237", + "evidence_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h", + "sha256": "8ef05de838404213f63f3a27a073aee0e2bc072dc4415734772e756d34def7f8" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h", + "sha256": "243da8ad18f946e4ee896b296ac56834bf6b50c6211af1b62603483b60f58d50" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README", + "sha256": "1d3dc1c6be134b121b0697ad7190f8c20911d509f2dfef69f72484c95b8a2b87" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS", + "sha256": "27fd82b016838197d5276ba98d2187ed5a1ad0e171767322f356f0a9f6395941" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb", + "sha256": "5da3b9c199276c00cd14ef7da6a10e4dc7fd8b65eaa638ab356a049597491d74" + } + ], + "formula_commit": "889a613d996eec7ff15837e38ab4b81c47cfe63e", + "source_archive": { + "bytes": 656112, + "sha256": "fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf", + "url": "https://github.com/libusb/libusb/releases/download/v1.0.30/libusb-1.0.30.tar.bz2" + } + }, + "schema_version": 1, + "source": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "ref": "2.3.0", + "repo": "https://github.com/raspberrypi/picotool.git" + } +} diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb b/firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb new file mode 100644 index 00000000..2ab6da7f --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb @@ -0,0 +1,52 @@ +class Libusb < Formula + desc "Library for USB device access" + homepage "https://libusb.info/" + url "https://github.com/libusb/libusb/releases/download/v1.0.30/libusb-1.0.30.tar.bz2" + sha256 "fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf" + license "LGPL-2.1-or-later" + compatibility_version 1 + + livecheck do + url :stable + strategy :github_latest + end + + bottle do + sha256 cellar: :any, arm64_tahoe: "184daaa6108f0a56eb72c58cc4124dbbb0b54a655632dffdf8334569d71e2a34" + sha256 cellar: :any, arm64_sequoia: "a8d271bd5d9e7065987960caa52a9130d7fe6321ff1bad751499e465d0413e38" + sha256 cellar: :any, arm64_sonoma: "74fa9ed0291e2d3e7827a06ea836a57c96d8861a7079544d47be231f08eb4c02" + sha256 cellar: :any, sonoma: "1387aea9bbed3a1e57884b5b43166fc83cfdae415e5f3803a8259ff77a4ba613" + sha256 cellar: :any_skip_relocation, arm64_linux: "d5789dcd1c84d316fa7f4ffec90277f0967f70a499cb239cefb37d919a3e5207" + sha256 cellar: :any_skip_relocation, x86_64_linux: "6ad452e6a96823fcef03781dd0980147b91690d0bb34c97f44e57130de8878f6" + end + + head do + url "https://github.com/libusb/libusb.git", branch: "master" + + depends_on "autoconf" => :build + depends_on "automake" => :build + depends_on "libtool" => :build + end + + on_linux do + depends_on "systemd" + end + + def install + args = %W[--disable-dependency-tracking --prefix=#{prefix}] + + system "./autogen.sh" if build.head? + system "./configure", *args + system "make", "install" + (pkgshare/"examples").install Dir["examples/*"] - Dir["examples/Makefile*"] + end + + test do + cp_r (pkgshare/"examples"), testpath + cd "examples" do + system ENV.cc, "listdevs.c", "-L#{lib}", "-I#{include}/libusb-1.0", + "-lusb-1.0", "-o", "test" + system "./test" + end + end +end diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS new file mode 100644 index 00000000..c02bbd78 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS @@ -0,0 +1,245 @@ +Copyright © 2001 Johannes Erdfelt <johannes@erdfelt.com> +Copyright © 2007-2009 Daniel Drake <dsd@gentoo.org> +Copyright © 2010-2012 Peter Stuge <peter@stuge.se> +Copyright © 2008-2016 Nathan Hjelm <hjelmn@users.sourceforge.net> +Copyright © 2009-2013 Pete Batard <pete@akeo.ie> +Copyright © 2009-2013 Ludovic Rousseau <ludovic.rousseau@gmail.com> +Copyright © 2010-2012 Michael Plante <michael.plante@gmail.com> +Copyright © 2011-2013 Hans de Goede <hdegoede@redhat.com> +Copyright © 2012-2013 Martin Pieuchot <mpi@openbsd.org> +Copyright © 2012-2013 Toby Gray <toby.gray@realvnc.com> +Copyright © 2013-2018 Chris Dickens <christopher.a.dickens@gmail.com> + +Other contributors: +Aaron Luft +Adam Korcz +Addison Crump +Adrian Bunk +Adrien Bertrand +Adrien Destugues +Akshay Jaggi +Alan Ott +Alan Stern +Aleksandr Mezin +Alexander Mot +Alexander Pyhalov +Alexander Schlarb +Alexander Stein +Alex Birkett +Alex Feinman +Alex Vatchenko +Andrew Aldridge +Andrew Fernandes +Andrew Goodney +Andy Chunyu +Andy McFadden +Angus Gratton +Anil Nair +Ankur Verma +Anthony Clay +Antoine Moreau +Antonio Ospite +Artem Egorkine +Aurelien Jarno +Axel Gembe +Aymeric Vincent +Baruch Siach +Bastien Nocera +Bei Zhang +Bence Csokas +Benjamin Berg +Benjamin Dobell +Bohdan Tymkiv +Brad Smith +Brent Rector +Bruno Harbulot +Carl Karsten +Christophe Zeitouny +Chris Zhu +Chunyu Xie +Colin Walters +Craig Hutchinson +Dave Camarillo +David Engraf +Davidlohr Bueso +David Moore +Dmitry Fleytman +Dmitry Kostjuchenko +Dmitry Zakablukov +Dominik Boehi +Doug Johnston +Edgar Fuß +Evan Hunter +Evan Miller +Fabien Sanglard +Fabrice Fontaine +Federico Manzan +Felipe Balbi +Florian Albrechtskirchinger +Francesco Montorsi +Francisco Facioni +Francis Hart +Frank Li +Frederik Carlier +Freek Dijkstra +Gaurav Gupta +Graeme Gill +Greg Kroah-Hartman +Gustavo Zacarias +Haidong Zheng +Hannes Franke +Hans Ulrich Niedermann +Harry Mallon +Hartwig Wiesmann +Hector Martin +Hoi-Ho Chan +Ido Yariv +Igor Anokhin +Ihor Dutchak +Ilya Konstantinov +Ingvar Stepanyan +Jakub Klama +James Hanko +Jeffrey Nichols +Jesse Taube +Jie Zhang +Jim Chen +Johann Richard +John Keeping +John Sheu +Jon Beniston +Jonas Malaco +Jonathon Jongsma +Joost Muller +Josh Gao +Joshua Blake +Joshua Hou +Joshua M. Clulow +Juan Cruz Viotti +Julian Scheel +Justin Bischoff +Karsten Koenig +Keith Ahluwalia +Kenjiro Tsuji +Kimura Masaru +Konrad Rzepecki +Kuangye Guo +Lars Kanis +Lars Wirzenius +Lei Chen +Léo Lam +Liang Yunwang +Logan Gallois +Lonnie Abelbeck +Luca Longinotti +Luz Paz +Łukasz Bartosik +Mac Wang +Marco Trevisan (Treviño) +Marcus Meissner +Mario Kleiner +Mark Kuo +Mark Lee +Markus Heidelberg +Martin Ettl +Martin Koegler +Martin Ling +Martin Thierer +Mathias Hjärtström +Matt Liberty +Matthew Stapleton +Matthias Bolte +Matthijs Lavrijsen +Michael Dickens +Michel Zou +Mike Frysinger +Mikhail Gusarov +Mikolaj Kucharski +Morgan Leborgne +Moritz Fischer +Nancy Li +Nia Alarie +Nicholas Corgan +Nicolas Bats +Niklas Gürtler +Ole André Vadla Ravnås +Oleksand Radovenchyk +Omri Iluz +Orhan aib Kavrakoglu +Orin Eman +Ozkan Sezer +Pablo Prietz +Patrick Stewart +Paul Cercueil +Paul Fertser +Paul Qureshi +Pekka Nikander +Petr Pazourek +Philémon Favrod +Pino Toscano +Radu Vele +Rob Walker +Romain Vimont +Roman Kalashnikov +Rosen Penev +Ryan Hileman +Ryan Metcalfe +Ryan Schmidt +Saleem Rashid +Sameeh Jubran +Sean McBride +Sebastian Pipping +Sebastian von Ohr +Sergey Serb +Shawn Hoffman +Simon Chan +Simon Haggett +Simon Newton +Slash Gordon +Stefan Agner +Stefan Tauner +Steinar H. Gunderson +Stephen Groat +Sylvain Fasel +Theo Buehler +Theodor Engøy +Thomas Röfer +Tim Hutt +Tim Roberts +Tobias Klauser +Toby Peterson +Tormod Volden +Trygve Laugstøl +Uri Lublin +Uwe Bonnes +Vadim Mikhailov +Vasily Khoruzhick +Vegard Storheil Eriksen +Venkatesh Shukla +Vianney le Clément de Saint-Marcq +Victor Toso +Vinicius Tinti +Vitali Lovich +Vladimir Beloborodov +Vladimir Gladkov +William Orr +William Skellenger +Xiaofan Chen +Yegor Yefremov +Yiwei Lin +Zeng Guang +Zhiqiang Liu +Zoltán Kovács +Сергей Валерьевич +Ларионов Даниил +Роман Донченко +jonner +orbitcowboy +osy +parafin +RipleyTom +Seneral +saur0n +SomeAlphabetGuy +winterrace +xloem diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING new file mode 100644 index 00000000..5ab7695a --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + <one line to give the library's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + <signature of Ty Coon>, 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + + diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README new file mode 100644 index 00000000..9340c71f --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README @@ -0,0 +1,32 @@ +# libusb + +libusb is a library for USB device access from Linux, macOS, +Windows, OpenBSD/NetBSD, Haiku, Solaris userspace, and WebAssembly +via WebUSB. +It is written in C (Haiku backend in C++) and licensed under the GNU +Lesser General Public License version 2.1 or, at your option, any later +version (see [COPYING](COPYING)). + +libusb is abstracted internally in such a way that it can hopefully +be ported to other operating systems. Please see the [PORTING](PORTING) +file for more information. + +libusb homepage: +https://libusb.info/ + +Developers will wish to consult the API documentation: +http://api.libusb.info + +Use the mailing list for questions, comments, etc: +http://mailing-list.libusb.info + +- Tormod Volden +- Sylvain Fasel +- Sean McBride +- Nathan Hjelm +- Xiaofan Chen +- Hans de Goede +- Ludovic Rousseau +- Chris Dickens + +(Please use the mailing list rather than mailing developers directly) diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h new file mode 100644 index 00000000..2a926583 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h @@ -0,0 +1,18 @@ +/* This file is parsed by m4 and windres and RC.EXE so please keep it simple. */ +#include "version_nano.h" +#ifndef LIBUSB_MAJOR +#define LIBUSB_MAJOR 1 +#endif +#ifndef LIBUSB_MINOR +#define LIBUSB_MINOR 0 +#endif +#ifndef LIBUSB_MICRO +#define LIBUSB_MICRO 30 +#endif +#ifndef LIBUSB_NANO +#define LIBUSB_NANO 0 +#endif +/* LIBUSB_RC is the release candidate suffix. Should normally be empty. */ +#ifndef LIBUSB_RC +#define LIBUSB_RC "" +#endif diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h new file mode 100644 index 00000000..f009126a --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h @@ -0,0 +1 @@ +#define LIBUSB_NANO 12037 diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md b/firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md new file mode 100644 index 00000000..e6c3a7ba --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md @@ -0,0 +1,25 @@ +Copyright (c) 2022, The littlefs authors. +Copyright (c) 2017, Arm Limited. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. +- Neither the name of ARM nor the names of its contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT b/firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT new file mode 100644 index 00000000..1c1f7a69 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2022 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE b/firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE new file mode 100644 index 00000000..4368817f --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE @@ -0,0 +1,22 @@ +The original license of this software is given below. The patches and +modifications made to the original software are made available under the same +license, but in some cases have a different copyright holder. + +/*----------------------------------------------------------------------------/ +/ FatFs - Generic FAT file system module Rx.xx / +/-----------------------------------------------------------------------------/ +/ +/ Copyright (C) 2016, ChaN, all right reserved. +/ +/ FatFs module is an open source software. Redistribution and use of FatFs in +/ source and binary forms, with or without modification, are permitted provided +/ that the following condition is met: + +/ 1. Redistributions of source code must retain the above copyright notice, +/ this condition and the following disclaimer. +/ +/ This software is provided by the copyright holder and contributors "AS IS" +/ and any warranties related to this software are DISCLAIMED. +/ The copyright owner or contributors be NOT LIABLE for any damages caused +/ by use of this software. +/----------------------------------------------------------------------------*/ diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT b/firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT new file mode 100644 index 00000000..e8a64f19 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT @@ -0,0 +1,21 @@ +Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the +following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following + disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT b/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT new file mode 100644 index 00000000..53697861 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT @@ -0,0 +1,18 @@ +Copyright Gregory Pakosz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2 b/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2 new file mode 100644 index 00000000..ad8b9ab2 --- /dev/null +++ b/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2 @@ -0,0 +1,25 @@ +-------------------------------------------------------------------------------- + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar <sam@hocevar.net> + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + 1. Bla bla bla + 2. Montesqieu et camembert, vive la France, zut alors! + +-------------------------------------------------------------------------------- + +WTFPLv2 is very permissive, see http://www.wtfpl.net/faq/ + +However, if this WTFPLV2 is REALLY a blocker and is the reason you can't use +this project, contact me and I'll dual license it. + +-------------------------------------------------------------------------------- diff --git a/firmware/licenses/rp2-build-tools-license-policy.json b/firmware/licenses/rp2-build-tools-license-policy.json new file mode 100644 index 00000000..0d535b7b --- /dev/null +++ b/firmware/licenses/rp2-build-tools-license-policy.json @@ -0,0 +1,301 @@ +{ + "distribution_provenance": { + "attribution": { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json", + "sha256": "9d96bdff715a08582506220d3ba1e332cc3b86dd9eaa13e7d7169856734a6e88" + }, + "distribution": { + "commit": "ad9e4a8375253cf4886bf168ea1a8d2746aadf24", + "ref": "v2.3.0-0", + "repo": "https://github.com/raspberrypi/pico-sdk-tools.git" + }, + "member_inventory": [ + { + "bytes": 0, + "kind": "directory", + "mode": "040755", + "path": "picotool/", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "bytes": 767, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolTargets-release.cmake", + "sha256": "e40085002e565de850554e772c90f1a1c4cffb545f853eaa231b145e498f7c0f" + }, + { + "bytes": 2303, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolConfigVersion.cmake", + "sha256": "61485a18a59a186d7cb82ddf7686c5f1f4def81726568ae49fd0033db368f7e3" + }, + { + "bytes": 367931, + "kind": "regular", + "mode": "100644", + "path": "picotool/rp2350_otp_contents.json", + "sha256": "1838713d5f94316c4c61558cb82d3346831235dc41f9b0f02151e6eda3f22fc2" + }, + { + "bytes": 328336, + "kind": "regular", + "mode": "100444", + "path": "picotool/libusb-1.0.0.dylib", + "sha256": "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" + }, + { + "bytes": 29484, + "kind": "regular", + "mode": "100644", + "path": "picotool/enc_bootloader_mbedtls.elf", + "sha256": "c5d17fcbb4f1ee41751dc782d86c1fc6629d8da3484e1a1cadc3ba4bec4d6044" + }, + { + "bytes": 3856, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolTargets.cmake", + "sha256": "043655d4bc215fb3a05d4a04f92cabc0be9bbdb0834717b0211e80106b4d1aae" + }, + { + "bytes": 96, + "kind": "regular", + "mode": "100644", + "path": "picotool/picotoolConfig.cmake", + "sha256": "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" + }, + { + "bytes": 34004, + "kind": "regular", + "mode": "100644", + "path": "picotool/xip_ram_perms.elf", + "sha256": "afac0e166ee9b632f84717c7db02e7e5e648f03a51cc4795938adcd282c6103b" + }, + { + "bytes": 5673800, + "kind": "regular", + "mode": "100755", + "path": "picotool/picotool", + "sha256": "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" + }, + { + "bytes": 20104, + "kind": "regular", + "mode": "100644", + "path": "picotool/enc_bootloader.elf", + "sha256": "9ce3c424d61226225d2c1f5b80a8854f15174b09dd447e58e4439240b274fb3d" + }, + { + "bytes": 0, + "kind": "regular", + "mode": "100644", + "path": ".keep", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "picotool_lock": { + "archive_bytes": 1980457, + "archive_filename": "picotool-2.3.0-mac.zip", + "archive_format": "zip", + "bundled_libusb_path": "picotool/libusb-1.0.0.dylib", + "bundled_libusb_sha256": "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237", + "cmake_config_path": "picotool/picotoolConfig.cmake", + "cmake_config_sha256": "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3", + "cmake_dir": "picotool", + "distribution_commit": "ad9e4a8375253cf4886bf168ea1a8d2746aadf24", + "distribution_ref": "v2.3.0-0", + "distribution_repo": "https://github.com/raspberrypi/pico-sdk-tools.git", + "executable_path": "picotool/picotool", + "executable_sha256": "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5", + "sha256": "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e", + "source_commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "source_ref": "2.3.0", + "source_repo": "https://github.com/raspberrypi/picotool.git", + "url": "https://github.com/raspberrypi/pico-sdk-tools/releases/download/v2.3.0-0/picotool-2.3.0-mac.zip", + "version": "2.3.0", + "version_line": "picotool v2.3.0 (Darwin, AppleClang-15.0.0.15000309, Release)" + }, + "retained_archive": { + "bytes": 1980457, + "path": "firmware/.picotool/.pyble-dist/picotool-2.3.0-mac.zip", + "sha256": "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e" + }, + "runtime_version_line": "picotool v2.3.0 (Darwin, AppleClang-15.0.0.15000309, Release)", + "source": { + "commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "ref": "2.3.0", + "repo": "https://github.com/raspberrypi/picotool.git" + } + }, + "nonsoftware_members": [ + { + "bytes": 0, + "classification": "packaging-metadata", + "kind": "regular", + "mode": "100644", + "path": ".keep", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "bytes": 0, + "classification": "packaging-metadata", + "kind": "directory", + "mode": "040755", + "path": "picotool/", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "profile_id": "rpi-pico2-w", + "schema_version": 1, + "source_owners": [ + { + "component_kind": "build-tool", + "copyright": "See distribution-attribution-v1.json for exact component copyrights.", + "disposition": "allow", + "id": "picotool-composite-build-tool", + "license_texts": [ + { + "identifier": "LicenseRef-PyBLE-Picotool-2.3.0-Composite", + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json", + "sha256": "9d96bdff715a08582506220d3ba1e332cc3b86dd9eaa13e7d7169856734a6e88" + } + ], + "notice_files": [ + { + "path": "firmware/licenses/evidence/mbedtls-LICENSE.txt", + "sha256": "9b405ef4c89342f5eae1dd828882f931747f71001cfba7d114801039b52ad09b" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE", + "sha256": "72c5d1a6f8c76c88b4559d26710c3a298a0ba9822ab3d187d0b4a5b2e8e2b219" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/cmake-copying-cmake-scripts", + "sha256": "8c33ed647ccfaa4e682ffe56b677f4d5d0dc51e2ccc40038015df784c7be51dd" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md", + "sha256": "0cb4ff1daf5fdc1359c6a6ee3116092f08fc100c9d58b1b77ab17bfd801f856d" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/nlohmann-json-LICENSE.MIT", + "sha256": "86b998c792894ccb911a1cb7994f7a9652894e7a094c0b5e45be2f553f45cf14" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE", + "sha256": "4449f912502843f47a0a258e740054f916a21586f849a27ce40f936c095e2f6c" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT", + "sha256": "483f865953435b66c443dee7558debe3cc3cf8fcbb6a112fd9fc6a795d53f1f6" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT", + "sha256": "3633afb48d2f5fb9a1942eead37adbe0ab6c70a79468c1b69e5a1e771ec4b119" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.WTFPLv2", + "sha256": "8b3d9ef07a62cfaa0194bdd64fa1bb61bbc18e4a156a83f8a52d937bd945239b" + } + ], + "selected_spdx_expression": "LicenseRef-PyBLE-Picotool-2.3.0-Composite", + "source_ref": "picotool-2.3.0-composite-v1", + "source_roots": [ + { + "namespace": "picotool-distribution", + "path": "picotool/enc_bootloader.elf" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/enc_bootloader_mbedtls.elf" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/picotool" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/picotoolConfig.cmake" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/picotoolConfigVersion.cmake" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/picotoolTargets-release.cmake" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/picotoolTargets.cmake" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/rp2350_otp_contents.json" + }, + { + "namespace": "picotool-distribution", + "path": "picotool/xip_ram_perms.elf" + } + ], + "source_spdx_expression": "LicenseRef-PyBLE-Picotool-2.3.0-Composite", + "source_url": "https://github.com/raspberrypi/picotool.git" + }, + { + "component_kind": "build-tool", + "copyright": "Copyright libusb authors; see the exact AUTHORS evidence.", + "disposition": "allow", + "id": "picotool-libusb-build-tool", + "license_texts": [ + { + "identifier": "LGPL-2.1-or-later", + "path": "firmware/licenses/texts/LGPL-2.1-or-later.txt", + "sha256": "5df07007198989c622f5d41de8d703e7bef3d0e79d62e24332ee739a452af62a" + } + ], + "notice_files": [ + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/distribution-attribution-v1.json", + "sha256": "9d96bdff715a08582506220d3ba1e332cc3b86dd9eaa13e7d7169856734a6e88" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/homebrew-libusb.rb", + "sha256": "5da3b9c199276c00cd14ef7da6a10e4dc7fd8b65eaa638ab356a049597491d74" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS", + "sha256": "27fd82b016838197d5276ba98d2187ed5a1ad0e171767322f356f0a9f6395941" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING", + "sha256": "5df07007198989c622f5d41de8d703e7bef3d0e79d62e24332ee739a452af62a" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README", + "sha256": "1d3dc1c6be134b121b0697ad7190f8c20911d509f2dfef69f72484c95b8a2b87" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h", + "sha256": "8ef05de838404213f63f3a27a073aee0e2bc072dc4415734772e756d34def7f8" + }, + { + "path": "firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h", + "sha256": "243da8ad18f946e4ee896b296ac56834bf6b50c6211af1b62603483b60f58d50" + } + ], + "selected_spdx_expression": "LGPL-2.1-or-later", + "source_ref": "v1.0.30@87a55632db62c9bdc58cd31d3ccfa673f1bb017f", + "source_roots": [ + { + "namespace": "picotool-distribution", + "path": "picotool/libusb-1.0.0.dylib" + } + ], + "source_spdx_expression": "LGPL-2.1-or-later", + "source_url": "https://github.com/libusb/libusb.git" + } + ], + "target": "rpi-pico2-w" +} diff --git a/firmware/licenses/rp2-license-policy.json b/firmware/licenses/rp2-license-policy.json index 4226da63..e4e44e6d 100644 --- a/firmware/licenses/rp2-license-policy.json +++ b/firmware/licenses/rp2-license-policy.json @@ -1019,7 +1019,7 @@ ], "notice_files": [], "selected_spdx_expression": "MIT", - "source_ref": "0.6.0", + "source_ref": "0.6.1", "source_roots": [ { "namespace": "repo", diff --git a/firmware/licenses/texts/LGPL-2.1-or-later.txt b/firmware/licenses/texts/LGPL-2.1-or-later.txt new file mode 100644 index 00000000..5ab7695a --- /dev/null +++ b/firmware/licenses/texts/LGPL-2.1-or-later.txt @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + <one line to give the library's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + <signature of Ty Coon>, 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + + diff --git a/firmware/pyble/pyble_agent.py b/firmware/pyble/pyble_agent.py index e793b973..63326702 100644 --- a/firmware/pyble/pyble_agent.py +++ b/firmware/pyble/pyble_agent.py @@ -76,6 +76,12 @@ def _ticks_diff(a, b): _RUNNING = pyble_runner.RUNNING +# The portable receiver advertises a four-chunk window. BEGIN + W DATA + END +# must therefore fit one bounded supervisor mailbox without any VFS work or +# response transmission from the synchronous BTstack callback. +FS_MAILBOX_DEPTH = 6 +_CURRENT_SESSION = object() + def _noop(): return None @@ -163,6 +169,7 @@ def __init__(self, link, fs_root, unique_id=b"\x00\x00", reset=None, self._clock = clock if clock is not None else _ticks_ms self._reboot_at = None # ms deadline for the deferred reset self._control_after_rsp = None # P3/P4: provisional until TX succeeds + self._run_reserved_after_rsp = False # Retained as a cleared compatibility sentinel for older host fixtures; # control staging above supersedes the former interrupt-only one-shot. self._interrupt_after_rsp = False @@ -178,23 +185,30 @@ def __init__(self, link, fs_root, unique_id=b"\x00\x00", reset=None, self._console_emit, notify if notify is not None else _noop, clock=self._clock, stop_fail=self._reset) self._terminal_session = _NO_TERMINAL_SESSION + self._dispatch_session = None # Filesystem bridge (F-08/F-09/F-17): EVT emission via emit(). self._fs = pyble_fs.FsService( self._root, self.emit, chunk_size=pyble_info.chunk_size(link.mtu())) + self._fs_mailbox = [None] * FS_MAILBOX_DEPTH + self._fs_mailbox_head = 0 + self._fs_mailbox_tail = 0 + self._fs_mailbox_count = 0 + self._fs_overflow_rsp = None # Runner (P2/P3): handler reserves inline; service() executes on the # supervisor with the console stderr path for tracebacks. self._runner = pyble_runner.Runner( self._emit_run_state, pyble_runner.make_exec_fn(write_stderr=self._stderr), - emit_terminal=self._emit_terminal_run_state) + emit_terminal=self._emit_terminal_run_state, + end_input=self.console.end_input) # Dispatcher: EVERY §4 CMD opcode gets a registered handler — none may # fall through to the EUNSUPPORTED default (that default is only for # opcodes outside the frozen §4 set, FR-PROTO-9). - d = pyble_proto.Dispatcher() + d = pyble_proto.Dispatcher(link) d.register(_OP["HELLO"], self._h_info) d.register(_OP["DEVICE_INFO"], self._h_info) d.register(_OP["FILE_LIST"], self._h_list) @@ -246,13 +260,23 @@ def adv_name(self): def autorun(self): """maybe_autorun (called LAST by main(), after the link is up).""" - return pyble_boot.maybe_autorun(self._config, self._runner, self._root) + accepted = pyble_boot.maybe_autorun( + self._config, self._runner, self._root) + if accepted: + generation = self.console.begin_input() + self._runner.bind_reserved_input(generation) + return accepted # -- outbound paths -------------------------------------------------------- - def emit(self, opcode, payload): - """The agent's EVT path: TYPE=EVT, ID=0, valid §3.1 CRC.""" - self._link.send_message( - pyble_proto.encode(pyble_proto.EVT, opcode, 0, payload)) + def emit(self, opcode, payload, expected_session=_CURRENT_SESSION): + """Encode and send one event against its immutable creation session.""" + if expected_session is _CURRENT_SESSION: + expected_session = self._link.session_token() + if expected_session is None: + return False + return self._link.send_message( + pyble_proto.encode(pyble_proto.EVT, opcode, 0, payload), + expected_session=expected_session) def _console_emit(self, stream, data): self.emit(_OP["CONSOLE_DATA"], bytes((stream,)) + data) @@ -263,12 +287,21 @@ def _stderr(self, data): def _emit_run_state(self, state): # The run-active console gate (P8) tracks the lifecycle exactly: # opened at RUN_STATE(running), closed at every terminal state. - self.console.set_run_active(state == _RUNNING) - self.emit(_OP["RUN_STATE"], bytes((state,))) + # Event ownership begins when this event is created, not when the RUN + # command was admitted. A reconnect between those cuts therefore owns + # the newly-created state while the immutable token still prevents a + # later TX race from retargeting it. + expected_session = self._link.session_token() + input_generation = self._runner.event_input_generation() + self.console.set_run_active( + state == _RUNNING, expected_generation=input_generation) + self.emit(_OP["RUN_STATE"], bytes((state,)), expected_session) def _emit_terminal_run_state(self, state, published): """Emit stopped IDLE with a receipt at BleLink's local TX cut.""" - self.console.set_run_active(False) + input_generation = self._runner.event_input_generation() + self.console.set_run_active( + False, expected_generation=input_generation) if self._terminal_session is _NO_TERMINAL_SESSION: self._terminal_session = self._link.session_token() session = self._terminal_session @@ -289,13 +322,97 @@ def committed(): self._terminal_session = _NO_TERMINAL_SESSION return accepted + # -- portable PUT supervisor mailbox -------------------------------------- + def _enqueue_put(self, frame): + """Capture one PUT command without touching VFS or transmitting.""" + item = (frame.opcode, frame.id, bytes(frame.payload), + self._dispatch_session, self._fs.generation_token()) + if self._fs_mailbox_count >= FS_MAILBOX_DEPTH: + # DATA is response-free and safely dropped for Go-Back-N retry. Keep + # one bounded deferred rejection slot for a response-bearing abuse + # command; expected window traffic fits the six normal slots. A + # second response-bearing overflow has no bounded response slot, so + # close its exact session instead of silently dropping a reply on a + # still-live connection. Invalidate and clear queued VFS ownership + # synchronously; the later GAP callback repeats this idempotently. + if frame.opcode != _OP["FILE_PUT_DATA"]: + if self._fs_overflow_rsp is None: + self._fs_overflow_rsp = ( + frame.opcode, frame.id, self._dispatch_session) + elif self._link.terminate_session(self._dispatch_session): + self._fs.on_disconnect() + self._clear_fs_mailbox() + return None + self._fs_mailbox[self._fs_mailbox_tail] = item + self._fs_mailbox_tail = ( + self._fs_mailbox_tail + 1) % FS_MAILBOX_DEPTH + self._fs_mailbox_count += 1 + return None + + def _clear_fs_mailbox(self): + while self._fs_mailbox_count: + self._fs_mailbox[self._fs_mailbox_head] = None + self._fs_mailbox_head = ( + self._fs_mailbox_head + 1) % FS_MAILBOX_DEPTH + self._fs_mailbox_count -= 1 + self._fs_mailbox_head = 0 + self._fs_mailbox_tail = 0 + self._fs_overflow_rsp = None + + def _service_put_mailbox(self): + """Execute at most one queued PUT operation in supervisor context.""" + if self._fs_mailbox_count == 0: + rejected = self._fs_overflow_rsp + self._fs_overflow_rsp = None + if rejected is None: + return False + opcode, request_id, session = rejected + if session == self._link.session_token(): + self._link.send_message( + pyble_proto.encode( + pyble_proto.RSP, opcode, request_id, bytes((EBUSY,))), + expected_session=session) + return True + + item = self._fs_mailbox[self._fs_mailbox_head] + self._fs_mailbox[self._fs_mailbox_head] = None + self._fs_mailbox_head = ( + self._fs_mailbox_head + 1) % FS_MAILBOX_DEPTH + self._fs_mailbox_count -= 1 + opcode, request_id, payload, session, generation = item + + # The queue item's exact connection and transfer generation own every + # effect and response. A disconnect invalidates both before successor + # work can publish. + if (session != self._link.session_token() or + generation != self._fs.generation_token()): + return True + self._fs.set_operation_owner(generation, session) + try: + if opcode == _OP["FILE_PUT_BEGIN"]: + response = self._fs.handle_put_begin(payload) + elif opcode == _OP["FILE_PUT_DATA"]: + response = self._fs.handle_put_data(payload) + else: + response = self._fs.handle_put_end(payload) + finally: + self._fs.clear_operation_owner() + + if response is not None: + self._link.send_message( + pyble_proto.encode( + pyble_proto.RSP, opcode, request_id, response), + expected_session=session) + return True + # -- link callbacks -------------------------------------------------------- def _closing_admission(self, msg): """Return (gated, response) for the P4 pre-dispatch closing gate. Malformed, wrong-version, and bad-CRC frames retain Dispatcher error - semantics. Every valid non-SOFT command is refused before any handler; - protocol no-response commands are silently dropped. + semantics. Every valid command is refused before any handler; protocol + no-response commands are silently dropped. This includes a duplicate + SOFT after reconnect, before the successor can negotiate HELLO. """ if self._reboot_at is None: return (False, None) @@ -304,13 +421,12 @@ def _closing_admission(self, msg): frame = pyble_proto.decode(msg) except pyble_proto.ProtocolError: return (False, None) - if frame.ver != pyble_proto.VER or frame.type != pyble_proto.CMD: + if (frame.ver != pyble_proto.VER or frame.type != pyble_proto.CMD or + frame.id == 0): return (False, None) got_crc = int.from_bytes(msg[-4:], "little") if pyble_proto.crc32(msg[:-4]) != got_crc: return (False, None) - if frame.opcode == _OP["SOFT_REBOOT"]: - return (False, None) # handler returns duplicate EBUSY if frame.opcode in _NO_RSP_COMMANDS: return (True, None) rsp = pyble_proto.encode( @@ -321,7 +437,8 @@ def _on_message(self, msg): gated, closing_rsp = self._closing_admission(msg) if gated: if closing_rsp is not None: - self._link.send_message(closing_rsp) + self._link.send_message( + closing_rsp, expected_session=self._link.session_token()) return # STOP/SOFT_REBOOT handlers only stage command-local control. The run, @@ -329,35 +446,80 @@ def _on_message(self, msg): # encoding (inside Dispatcher) and link handoff succeed. `finally` # discards provisional state on every exceptional exit. control_after_rsp = None + run_reserved_after_rsp = False + response_published = [False] + session = self._link.session_token() + self._dispatch_session = session self._control_after_rsp = None + self._run_reserved_after_rsp = False self._interrupt_after_rsp = False try: rsp = self._dispatcher.on_message(msg) + receipt = self._dispatcher.take_publish_callback() control_after_rsp = self._control_after_rsp + run_reserved_after_rsp = self._run_reserved_after_rsp if rsp is not None: - self._link.send_message(rsp) + def _published(): + if receipt is not None: + receipt() + response_published[0] = True + + accepted = self._link.send_message( + rsp, on_published=_published, expected_session=session) + if accepted is False: + response_published[0] = False finally: + # A handler may reserve RUN and then fail while the dispatcher is + # encoding its response. In that case the local copy above was + # never assigned, but the reservation still must be rolled back. + run_reserved_after_rsp = ( + run_reserved_after_rsp or self._run_reserved_after_rsp) self._control_after_rsp = None + self._run_reserved_after_rsp = False self._interrupt_after_rsp = False - if control_after_rsp is not None: - self._commit_control(control_after_rsp) + self._dispatch_session = None + if run_reserved_after_rsp: + if response_published[0]: + generation = self.console.begin_input() + self._runner.bind_reserved_input(generation) + else: + self._runner.cancel_reserved_run() + if control_after_rsp is not None and response_published[0]: + self.console.end_input() + self._commit_control(control_after_rsp) def _on_connect(self): + self._dispatcher.reset_session() + if self._reboot_at is not None: + # The reboot deadline is device-global. A successor connection + # during its grace inherits transport-level closing so malformed + # fragments retain classification without consuming its budget. + begin_closing = getattr( + self._link, "begin_command_closing", None) + if begin_closing is not None: + begin_closing(self._link.session_token()) # Refresh the INFO read for the new session (mtu/free_mem/label live). self._link.set_info_payload(self.info_payload()) def _on_disconnect(self): - # F-10: reset the in-RAM transfer state ONLY — the jailed - # <dest>.pbltmp stays on flash as the durable resume watermark. + # Invalidate transfer ownership before clearing queued work. This is + # RAM-only and never closes, stats, removes, or otherwise enters VFS. self._fs.on_disconnect() + self._clear_fs_mailbox() + self._dispatcher.reset_session() + self.console.clear_input() def _on_oversize(self, head): # P6: an oversize reassembled message was dropped — answer RSP ERANGE # with the best-effort opcode/id echo carried in the §3.1 header bytes. - opcode = head[2] if len(head) > 2 else 0x00 - id_ = head[3] if len(head) > 3 else 0x00 + head = bytes(head) + if (len(head) < 6 or head[0] != pyble_proto.VER or + head[1] != pyble_proto.CMD or head[3] == 0): + return self._link.send_message( - pyble_proto.encode(pyble_proto.RSP, opcode, id_, bytes((ERANGE,)))) + pyble_proto.encode( + pyble_proto.RSP, head[2], head[3], bytes((ERANGE,))), + expected_session=self._link.session_token()) # -- helpers --------------------------------------------------------------- def _run_active(self): @@ -366,11 +528,20 @@ def _run_active(self): def _commit_control(self, action): """Commit an acknowledged STOP/SOFT action after response handoff.""" + if action == _CONTROL_SOFT_REBOOT: + # This cut follows successful local publication of RSP{OK}. Close + # transport-level violation accounting before any further reboot + # work, while retaining ordinary malformed-frame classification. + begin_closing = getattr( + self._link, "begin_command_closing", None) + if begin_closing is not None: + begin_closing(self._link.session_token()) + self._reboot_at = self._clock() + REBOOT_FLUSH_MS + self._runner.handle_stop() executing = self._runner.is_executing() if action == _CONTROL_SOFT_REBOOT: - self._reboot_at = self._clock() + REBOOT_FLUSH_MS if self._arm_reset is not None: try: self._arm_reset(REBOOT_FLUSH_MS) @@ -390,10 +561,9 @@ def _commit_control(self, action): # -- §4 handlers (handler(frame) -> RSP payload bytes | None) -------------- def _h_info(self, frame): # HELLO RSP == DEVICE_INFO RSP == [OK] + the single caps source (§7). - # The request payload is not parsed: the §9 VER gate in dispatch - # already refused any non-v1 frame (pble_info.c twin). The INFO read - # and the GET chunk size are refreshed here so both track the - # negotiated MTU (HELLO is the first post-MTU-exchange request). + # Dispatcher already validated HELLO's request grammar and PBLE/1 + # offer. The INFO read and GET chunk size are refreshed here so both + # track the negotiated MTU (HELLO is the first post-MTU exchange). mtu, free_mem, device_id, label, auto_run = self._caps_args() self._fs._chunk = pyble_info.chunk_size(mtu) # own-module seam (P2) self._link.set_info_payload(pyble_info.info_char_payload( @@ -410,18 +580,23 @@ def _h_stat(self, frame): def _h_get_begin(self, frame): if self._run_active(): return bytes((EBUSY,)) # transfers-during-RUN (P2) - return self._fs.handle_get_begin(frame.payload) + self._fs.set_operation_owner( + self._fs.generation_token(), self._dispatch_session) + try: + return self._fs.handle_get_begin(frame.payload) + finally: + self._fs.clear_operation_owner() def _h_put_begin(self, frame): if self._run_active(): return bytes((EBUSY,)) # transfers-during-RUN (P2) - return self._fs.handle_put_begin(frame.payload) + return self._enqueue_put(frame) def _h_put_data(self, frame): - return self._fs.handle_put_data(frame.payload) # ACK-only: None + return self._enqueue_put(frame) # ACK-only: None def _h_put_end(self, frame): - return self._fs.handle_put_end(frame.payload) + return self._enqueue_put(frame) def _h_delete(self, frame): return self._fs.handle_delete(frame.payload) @@ -437,7 +612,10 @@ def _h_run(self, frame): # supervisor work — RSP-before-RUN_STATE is structural. if self._reboot_at is not None: return bytes((EBUSY,)) # acknowledged SOFT closes admission - return bytes((self._runner.handle_run(frame.payload),)) + status = self._runner.handle_run( + frame.payload, owner=self._dispatch_session) + self._run_reserved_after_rsp = status == OK + return bytes((status,)) def _h_stop(self, frame): # §6/P3: OK is idempotent, but cancellation/interrupt is provisional @@ -463,7 +641,7 @@ def _h_set_identify_led(self, frame): payload = bytes(frame.payload) if len(payload) == 0: return bytes((OK,)) # empty payload clears the identify LED - if len(payload) < 2: + if len(payload) != 2: return bytes((EBADREQ,)) if payload[0] > _IDENTIFY_GPIO_MAX: return bytes((ERANGE,)) @@ -501,6 +679,7 @@ def poll(self): # already cancelled by SOFT; it never admits new RUN source. self._runner.service() if self._reboot_at is None: + self._service_put_mailbox() self._fs.pump() except KeyboardInterrupt: # A deferred 0x03 may land after exec but before the terminal diff --git a/firmware/pyble/pyble_ble.py b/firmware/pyble/pyble_ble.py index 4d7eb1e2..bbdab43d 100644 --- a/firmware/pyble/pyble_ble.py +++ b/firmware/pyble/pyble_ble.py @@ -58,6 +58,19 @@ # <= 2048 plus headers with margin. An oversize message is dropped and reported # via `on_oversize` so the agent can answer RSP ERANGE with an opcode/id echo. RX_MAX = 4096 +RX_REASSEMBLY_DEADLINE_MS = 5000 +SESSION_VIOLATION_LIMIT = 8 + +try: + from time import ticks_ms as _ticks_ms, ticks_diff as _ticks_diff +except ImportError: # CPython host suite + from time import monotonic as _monotonic + + def _ticks_ms(): + return int(_monotonic() * 1000) + + def _ticks_diff(end, start): + return end - start def _noop_published(): @@ -126,7 +139,7 @@ class BleLink: the Layer-2 board overlay's; this class reads the BLE MAC at runtime and is otherwise identical across esp32 / esp32-s3 / esp32-c3 (sets up M3).""" - def __init__(self): + def __init__(self, clock=None, ticks_diff=None): self._ble = None # bluetooth.BLE(), bound in start() self._conn_handle = None self._session_generation = 0 @@ -143,6 +156,16 @@ def __init__(self): self._rx_buf = bytearray() self._rx_active = False self._rx_next_index = 0 + self._rx_started_ms = 0 + self._rx_discard_tail = False + self._clock = _ticks_ms if clock is None else clock + self._ticks_diff = _ticks_diff if ticks_diff is None else ticks_diff + # One exact-connection malformed-input budget. The counter is reset + # only when a new connection/VM-owned BleLink begins. + self._violation_count = 0 + self._session_closed = False + self._termination_requested = False + self._command_closing = False # up-calls into pyble_proto / pyble_agent (registered before start()): self._on_message = None self._on_connect = None @@ -187,6 +210,48 @@ def session_token(self): return None return (self._conn_handle, self._session_generation) + def terminate_session(self, expected_session=None): + """Close one exact connection once and make later RX/TX silent.""" + current = self.session_token() + if (current is None or self._termination_requested or + (expected_session is not None and expected_session != current)): + return False + self._session_closed = True + self._termination_requested = True + self._reset_reassembly() + self._ble.gap_disconnect(current[0]) + return True + + def record_protocol_violation(self, expected_session=None): + """Debit the shared session budget before an error response/up-call. + + True admits the caller's response or oversize callback (violations + one through seven). False means stale/offline/closed, or that this was + the eighth violation which atomically requested termination. + """ + current = self.session_token() + if (current is None or self._session_closed or + (expected_session is not None and expected_session != current)): + return False + if self._command_closing: + # An accepted SOFT_REBOOT keeps normal fragment/frame + # classification but has precedence over budget accounting. + return True + self._violation_count += 1 + if self._violation_count >= SESSION_VIOLATION_LIMIT: + self.terminate_session(current) + return False + return True + + def begin_command_closing(self, expected_session=None): + """Freeze violation accounting for one exact accepted-SOFT session.""" + current = self.session_token() + if (current is None or self._session_closed or + (expected_session is not None and expected_session != current)): + return False + self._command_closing = True + return True + def set_info_payload(self, payload): """FR-BLE-4: update the INFO characteristic's served bytes (authored by pyble_info). Served verbatim; not decoded here.""" @@ -224,7 +289,8 @@ def send_message(self, msg, on_published=None, expected_session=None): from pyble_proto — never inspected/decoded here. An optional receipt is called immediately after local acceptance of the final fragment.""" current_session = self.session_token() - if self._ble is None or current_session is None: + if (self._ble is None or current_session is None or + self._session_closed): return False if expected_session is None: expected_session = current_session @@ -302,12 +368,20 @@ def _irq(self, event, data): if event == _IRQ_CENTRAL_CONNECT: self._session_generation += 1 self._conn_handle = data[0] + self._violation_count = 0 + self._session_closed = False + self._termination_requested = False + self._command_closing = False self._reset_reassembly() if self._on_connect is not None: self._on_connect() elif event == _IRQ_CENTRAL_DISCONNECT: self._conn_handle = None self._mtu = DEFAULT_MTU + self._violation_count = 0 + self._session_closed = False + self._termination_requested = False + self._command_closing = False self._reset_reassembly() if self._on_disconnect is not None: try: @@ -330,12 +404,35 @@ def _reset_reassembly(self): self._rx_buf = bytearray() self._rx_active = False self._rx_next_index = 0 + self._rx_started_ms = 0 + self._rx_discard_tail = False + + def _discard_reassembly_tail(self): + self._reset_reassembly() + self._rx_discard_tail = True + + def _reject_fragment_run(self, session): + self._discard_reassembly_tail() + if session is not None: + self.record_protocol_violation(session) def _on_rx(self, packet): """Reassemble §3.2: strip the 1-byte FRAG_HDR, concatenate FRAGMENT DATA from FIRST through LAST (index increasing mod 64), then hand the complete §3.1 message up via on_message. CRC/structure validation is pyble_proto's.""" + session = self.session_token() + # Direct host-unit reassembly remains a session-less low-level seam; + # device IRQ delivery always has a live connection token. + if self._session_closed: + return if not packet: + # An empty GATT value has no fragment header and therefore cuts + # ownership of an incomplete run. With no active run it is one + # standalone violation and must not absorb a later orphan run. + if self._rx_active: + self._discard_reassembly_tail() + if session is not None: + self.record_protocol_violation(session) return hdr = packet[0] first = bool(hdr & FRAG_FIRST) @@ -346,25 +443,34 @@ def _on_rx(self, packet): self._rx_buf = bytearray() self._rx_active = True self._rx_next_index = index + self._rx_started_ms = self._clock() + self._rx_discard_tail = False + elif self._rx_discard_tail: + return elif not self._rx_active or index != self._rx_next_index: - self._reset_reassembly() # gap/out-of-order: drop; app retransmits + self._reject_fragment_run(session) + return + elif self._ticks_diff(self._clock(), self._rx_started_ms) >= \ + RX_REASSEMBLY_DEADLINE_MS: + self._reject_fragment_run(session) return - self._rx_buf += packet[1:] - self._rx_next_index = (index + 1) % FRAG_INDEX_MOD - - if len(self._rx_buf) > RX_MAX: - # P6: oversize — report ONCE (head carries the §3.1 header bytes so - # the agent can echo opcode/id in RSP ERANGE), drop the message, - # and recover on the next FIRST fragment. Trailing fragments of the - # same message fall into the inactive/gap branch above and stay - # silent (no further report). - head = bytes(self._rx_buf[:8]) - self._reset_reassembly() - if self._on_oversize is not None: + data = packet[1:] + if len(self._rx_buf) + len(data) > RX_MAX: + needed = 8 - len(self._rx_buf) + if needed > 0: + head = bytes(self._rx_buf + data[:needed]) + else: + head = bytes(self._rx_buf[:8]) + self._discard_reassembly_tail() + if ((session is None or self.record_protocol_violation(session)) and + self._on_oversize is not None): self._on_oversize(head) return + self._rx_buf += data + self._rx_next_index = (index + 1) % FRAG_INDEX_MOD + if last: msg = bytes(self._rx_buf) self._reset_reassembly() diff --git a/firmware/pyble/pyble_boot.py b/firmware/pyble/pyble_boot.py index 366bd1e5..03bfd88c 100644 --- a/firmware/pyble/pyble_boot.py +++ b/firmware/pyble/pyble_boot.py @@ -23,12 +23,12 @@ import os import pyble_runner +from pyble_workspace import mount_lfs2 MAIN_PATH = "/main.py" # the single opt-in auto-run entry at fs_root _S_IFDIR = 0x4000 # stat st_mode directory bit (POSIX + MicroPython) - def _is_file(path): """True iff `path` exists and is a regular file (mp_import_stat FILE twin) — a directory named main.py must not be handed to the runner.""" diff --git a/firmware/pyble/pyble_console.py b/firmware/pyble/pyble_console.py index 306d860e..85a30cf2 100644 --- a/firmware/pyble/pyble_console.py +++ b/firmware/pyble/pyble_console.py @@ -87,13 +87,23 @@ def __init__(self, emit, notify, clock=None, tx_capacity=None, self._tokens = self._cap # bucket starts at capacity self._last_ms = self._clock() self._run_active = False # gate starts INACTIVE + self._input_active = False # stdin belongs to no run at boot self._stop_armed = False self._stop_inflight = False - self._ring = bytearray() # logical stdin ring (bounded) + # One permanently allocated circular buffer. Terminal/reset paths run + # at control-plane cuts where allocating a replacement bytearray could + # fail and accidentally leave predecessor input live. + self._ring = bytearray(STDIN_RING) self._head = 0 + self._tail = 0 + self._count = 0 + self._input_generation = 0 # -- run gate -------------------------------------------------------------- - def set_run_active(self, flag): + def set_run_active(self, flag, expected_generation=None): + if (expected_generation is not None and + expected_generation != self._input_generation): + return False self._run_active = bool(flag) if not self._run_active: # A terminal transition invalidates queued dupterm notifications @@ -101,6 +111,7 @@ def set_run_active(self, flag): # no byte to turn into a late KeyboardInterrupt. self._stop_armed = False self._stop_inflight = False + return True def set_stop_retry(self, retry): """Install the device-only two-checkpoint dupterm retry seam (P3).""" @@ -113,6 +124,41 @@ def stop_retry_failed(self): if self._stop_fail is not None: self._stop_fail() + # -- run-scoped stdin admission -------------------------------------------- + def _clear_ring(self): + self._head = 0 + self._tail = 0 + self._count = 0 + + def begin_input(self): + """Start one run's stdin lifetime, discarding every older byte.""" + self._input_generation += 1 + self._clear_ring() + self._stop_armed = False + self._stop_inflight = False + self._input_active = True + return self._input_generation + + def end_input(self, expected_generation=None): + """End one run's stdin lifetime and reject input until the next RUN. + + A terminal callback may resume after a successor RUN has already been + admitted. Its lease then no longer matches and it must not clear the + successor's input. + """ + if (expected_generation is not None and + expected_generation != self._input_generation): + return False + self._input_active = False + self._clear_ring() + self._stop_armed = False + self._stop_inflight = False + return True + + def clear_input(self): + """Drop queued bytes without ending a run (the disconnect boundary).""" + self._clear_ring() + # -- stdout/stderr tee -> CONSOLE_DATA (the single emit chokepoint) -------- def write(self, b): # dupterm stdout entry: ALWAYS report the bytes consumed (tee_write @@ -185,16 +231,17 @@ def _take_tokens(self, n): # -- stdin ring + STOP channel -------------------------------------------- def feed_input(self, b): # CONSOLE_INPUT is fire-and-forget: no RSP frame (§6) -> returns None. - if b: + if self._input_active and b: if isinstance(b, str): b = b.encode("utf-8") - if self._head: - # Compact so the bound below counts only undrained bytes. - self._ring = self._ring[self._head:] - self._head = 0 - room = STDIN_RING - len(self._ring) - if room > 0: - self._ring.extend(b[:room]) # append until full, drop the tail + # Append until full and drop the excess tail. No slicing, + # compaction, growth, or replacement allocation occurs here. + for value in b: + if self._count >= STDIN_RING: + break + self._ring[self._tail] = value + self._tail = (self._tail + 1) % STDIN_RING + self._count += 1 return None def readinto(self, buf): @@ -205,12 +252,13 @@ def readinto(self, buf): self._stop_armed = False # delivered once self._stop_inflight = True # identifies write-swallowed KBI return 1 - if self._head < len(self._ring): + if self._input_active and self._count: buf[0] = self._ring[self._head] - self._head += 1 - if self._head == len(self._ring): - self._ring = bytearray() + self._head = (self._head + 1) % STDIN_RING + self._count -= 1 + if self._count == 0: self._head = 0 + self._tail = 0 return 1 except Exception: pass diff --git a/firmware/pyble/pyble_device_config.py b/firmware/pyble/pyble_device_config.py index 67061586..16ecfa17 100644 --- a/firmware/pyble/pyble_device_config.py +++ b/firmware/pyble/pyble_device_config.py @@ -11,6 +11,9 @@ # Runs on MicroPython v1.28 and CPython 3.9+ (host suite). import json +import os + +import pyble_proto PBLE_LABEL_MAX = 24 # OI-6 architect freeze (UTF-8 encoded bytes) @@ -20,11 +23,28 @@ ERANGE = 0x09 CONF_NAME = "pyble_conf.json" # port spec P5: lives at <fs_root>/pyble_conf.json +CONF_TMP_NAME = ".pyble_conf.json.pbltmp" +CONFIG_VERSION = 1 +CONFIG_RECORD_MAX = 256 + +CONFIG_FAULT_NONE = 0 +CONFIG_FAULT_CORRUPT = 1 + +_CRC_PREFIX = b"PBLECFG" def label_status(utf8): - """Return the native label-bound decision for encoded UTF-8 bytes.""" - return OK if len(utf8) <= PBLE_LABEL_MAX else ERANGE + """Validate the frozen encoded-length, UTF-8, and control-scalar rules.""" + if len(utf8) > PBLE_LABEL_MAX: + return ERANGE + label = pyble_proto.strict_utf8_decode(utf8) + if label is None: + return EBADREQ + for char in label: + scalar = ord(char) + if scalar <= 0x1F or 0x7F <= scalar <= 0x9F: + return EBADREQ + return OK def adv_name(device_id, label): @@ -44,57 +64,171 @@ class DeviceConfig: def __init__(self, root, device_id, set_adv_name=None): if root.endswith("/"): self.conf_path = root + CONF_NAME + self.conf_tmp_path = root + CONF_TMP_NAME else: self.conf_path = root + "/" + CONF_NAME + self.conf_tmp_path = root + "/" + CONF_TMP_NAME self._device_id = device_id self._set_adv_name = set_adv_name self.label = "" # frozen defaults: never raise at boot (P5) self.auto_run = 0 + self.config_fault = CONFIG_FAULT_NONE + self._load() + + @staticmethod + def _canonical_crc(label, autorun): + encoded = label.encode("utf-8") + canonical = (_CRC_PREFIX + bytes((CONFIG_VERSION, autorun, len(encoded))) + + encoded) + return pyble_proto.crc32(canonical) & 0xFFFFFFFF + + @staticmethod + def _valid_autorun(value): + return type(value) is int and (value == 0 or value == 1) + + @staticmethod + def _missing_error(exc): + code = getattr(exc, "errno", None) + if code is None and getattr(exc, "args", None): + code = exc.args[0] + return code == 2 + + @staticmethod + def _valid_label_text(label): + if type(label) is not str: + return False + try: + encoded = label.encode("utf-8") + except Exception: + return False + return label_status(encoded) == OK + + def _decode_record(self, data): + if type(data) is not dict: + return None + keys = set(data) + if keys == {"label", "autorun"}: + label = data["label"] + autorun = data["autorun"] + if self._valid_label_text(label) and self._valid_autorun(autorun): + return label, autorun + return None + if keys != {"version", "label", "autorun", "crc32"}: + return None + version = data["version"] + label = data["label"] + autorun = data["autorun"] + crc = data["crc32"] + if type(version) is not int or version != CONFIG_VERSION: + return None + if not self._valid_label_text(label) or not self._valid_autorun(autorun): + return None + if type(crc) is not int or crc < 0 or crc > 0xFFFFFFFF: + return None + if crc != self._canonical_crc(label, autorun): + return None + return label, autorun + + def _remove_temp(self): try: - with open(self.conf_path, "r") as fh: - data = json.load(fh) - label = data.get("label") - if isinstance(label, str) and len(label.encode("utf-8")) <= PBLE_LABEL_MAX: - self.label = label - if data.get("autorun") == 1: - self.auto_run = 1 - except Exception: # missing/corrupt conf -> defaults, no crash + os.remove(self.conf_tmp_path) + except Exception: pass - def _save(self): + def _load(self): + try: + with open(self.conf_path, "rb") as stream: + encoded = stream.read(CONFIG_RECORD_MAX + 1) + except OSError as exc: + if not self._missing_error(exc): + self.config_fault = CONFIG_FAULT_CORRUPT + return + except Exception: + self.config_fault = CONFIG_FAULT_CORRUPT + return + if len(encoded) > CONFIG_RECORD_MAX: + self.config_fault = CONFIG_FAULT_CORRUPT + return + try: + data = json.loads(encoded.decode("utf-8")) + loaded = self._decode_record(data) + except Exception: + self.config_fault = CONFIG_FAULT_CORRUPT + return + if loaded is None: + self.config_fault = CONFIG_FAULT_CORRUPT + return + self.label, self.auto_run = loaded + self._remove_temp() # a valid primary wins; cleanup is best-effort + + def _record_bytes(self, label, autorun): + record = { + "version": CONFIG_VERSION, + "label": label, + "autorun": autorun, + "crc32": self._canonical_crc(label, autorun), + } + return json.dumps(record).encode("utf-8") + + def _save_candidate(self, label, autorun): try: - with open(self.conf_path, "w") as fh: - fh.write(json.dumps({"label": self.label, "autorun": self.auto_run})) - return OK + encoded = self._record_bytes(label, autorun) except Exception: + self.config_fault = CONFIG_FAULT_CORRUPT return EIO + if len(encoded) > CONFIG_RECORD_MAX: + self.config_fault = CONFIG_FAULT_CORRUPT + return EIO + + stream = None + try: + stream = open(self.conf_tmp_path, "wb") + written = stream.write(encoded) + if written != len(encoded): + raise OSError("short config write") + stream.flush() + stream.close() + stream = None + os.sync() + os.rename(self.conf_tmp_path, self.conf_path) + except Exception: + if stream is not None: + try: + stream.close() + except Exception: + pass + self._remove_temp() + self.config_fault = CONFIG_FAULT_CORRUPT + return EIO + return OK def set_label(self, utf8): """SET_LABEL persist: >24 encoded bytes -> ERANGE unstored; empty clears to default; on OK (and only OK) push the advertised name.""" - if len(utf8) > PBLE_LABEL_MAX: - return ERANGE + status = label_status(utf8) + if status != OK: + return status try: label = bytes(utf8).decode("utf-8") except Exception: return EBADREQ - old = self.label - self.label = label - st = self._save() + st = self._save_candidate(label, self.auto_run) if st != OK: - self.label = old # a failed persist must not lie in RAM return st + self.label = label + self.config_fault = CONFIG_FAULT_NONE if self._set_adv_name is not None: self._set_adv_name(adv_name(self._device_id, self.label)) return OK def set_autorun(self, enable): - """SET_AUTORUN persist: any nonzero normalizes to 1 (pble_boot.c twin).""" - old = self.auto_run - self.auto_run = 1 if enable else 0 - st = self._save() - if st != OK: - self.auto_run = old + """Persist only the exact frozen Boolean integer domain, 0 or 1.""" + if not self._valid_autorun(enable): + return EBADREQ + st = self._save_candidate(self.label, enable) + if st == OK: + self.auto_run = enable + self.config_fault = CONFIG_FAULT_NONE return st # --- Dispatcher handler contract: handler(frame) -> RSP payload bytes ---- @@ -104,8 +238,8 @@ def handle_set_label(self, frame): return bytes((self.set_label(bytes(frame.payload)),)) def handle_set_autorun(self, frame): - """0x23 SET_AUTORUN [enable:u8] — shorter payload is malformed.""" + """0x23 SET_AUTORUN requires exactly one byte whose value is 0 or 1.""" payload = bytes(frame.payload) - if len(payload) < 1: + if len(payload) != 1 or payload[0] > 1: return bytes((EBADREQ,)) return bytes((self.set_autorun(payload[0]),)) diff --git a/firmware/pyble/pyble_fs.py b/firmware/pyble/pyble_fs.py index e92dbeb5..b3e98c8a 100644 --- a/firmware/pyble/pyble_fs.py +++ b/firmware/pyble/pyble_fs.py @@ -29,12 +29,11 @@ # incrementally across PUT_DATA so PUT_END need not re-scan the temp # (NFR-PERF-2). # -# Execution model (ports/rpi-pico2-w.md P2): the fast ops (LIST/STAT/DELETE/ -# MKDIR/RENAME + every PUT step) answer inline in scheduled context; the GET -# stream is MAILBOXED — handle_get_begin() only validates + reserves and -# returns RSP{OK}[total]; the supervisor later calls pump() to emit the -# DATA*/END events on the main thread. pyble_agent owns RSP-before-EVT wire -# ordering (it sends the returned RSP, then calls pump()). +# Execution model (ports/rpi-pico2-w.md P2): pyble_agent defers every PUT step +# through its bounded supervisor mailbox, so these handlers and their VFS calls +# run only on the supervisor. GET_BEGIN validates + reserves inline, while the +# supervisor later calls pump() to emit DATA*/END. Other small filesystem +# operations retain their scheduled-context behavior. # # Injectable seams (host suite drives this without BLE): # emit(opcode, payload) — EVT sink (id 0): FILE_GET_DATA 0x13, @@ -52,8 +51,10 @@ FS_PATH_MAX = 128 # max path bytes on the wire (§5), else ERANGE TMP_SUFFIX = ".pbltmp" # reserved transfer-scratch suffix (jailed) RSP_MAX = 480 # LIST truncation budget (C twin PBLE_RSP_MAX) +FS_SAFETY_RESERVE = 65536 # bytes kept free after PUT admission (D11) _READ_BLK = 256 # streaming read block (CRC/resume scans) +_UINT64_MAX = (1 << 64) - 1 # --- §8 statuses (single-sourced from the frozen proto table) ----------------- _ST = pyble_proto.STATUS @@ -73,7 +74,9 @@ _OP_GET_END = pyble_proto.OPCODES["FILE_GET_END"] # 0x14 _OP_PUT_ACK = pyble_proto.OPCODES["FILE_PUT_ACK"] # 0x41 -_S_IFDIR = 0x4000 # stat st_mode directory bit (POSIX + MicroPython) +_S_IFMT = 0xF000 # complete POSIX/MicroPython file-type field +_S_IFDIR = 0x4000 +_S_IFREG = 0x8000 # --- Streaming IEEE CRC-32 (bit-identical to pyble_proto.crc32) --------------- @@ -190,9 +193,8 @@ def resolve(path): for byte in path: if byte == 0: return (EBADREQ, None) # NUL injection - try: - text = path.decode("utf-8") - except Exception: + text = pyble_proto.strict_utf8_decode(path) + if text is None: return (EBADREQ, None) # Canonicalize: split on '/', drop ''/'.', pop on '..' (never above root). @@ -226,7 +228,8 @@ class FsService: payload bytes and return the RSP payload (payload[0] = §8 status); handle_put_data returns None (ACK-only, no RSP path).""" - def __init__(self, root, emit, chunk_size=229, open_fn=None): + def __init__(self, root, emit, chunk_size=229, open_fn=None, + statvfs_fn=None): root = root or "/" if root != "/" and root.endswith("/"): root = root[:-1] @@ -234,16 +237,60 @@ def __init__(self, root, emit, chunk_size=229, open_fn=None): self._emit = emit self._chunk = chunk_size self._open = open_fn if open_fn is not None else open - # Active-GET mailbox slot (P2): (host_path, offset) or None. + self._statvfs = (statvfs_fn if statvfs_fn is not None + else getattr(os, "statvfs", None)) + self._transfer_generation = 0 + self._operation_generation = None + self._operation_session = None + # Active-GET mailbox slot (P2): + # (host_path, offset, advertised_total, generation, session) or None. self._get = None # PUT state machine (worker-local twin of pble_fs.c g_put_*). The open - # temp file object is kept SEPARATE from the resettable state (the - # rooted-pointer twin): on_disconnect resets flags but the orphan file - # is flushed + closed only by the next PUT_BEGIN. + # temp file object is kept separate from resettable state. The + # synchronous BLE disconnect callback clears only RAM ownership; the + # next worker-context PUT_BEGIN closes the old object before scanning + # its durable resumable prefix. self._put_file = None self._reset_put() # -- state helpers --------------------------------------------------------- + def generation_token(self): + return self._transfer_generation + + def set_operation_owner(self, generation, session): + """Install the immutable owner of one supervisor mailbox item.""" + self._operation_generation = generation + self._operation_session = session + + def clear_operation_owner(self): + self._operation_generation = None + self._operation_session = None + + def _operation_valid(self): + generation = self._operation_generation + return (generation is None or + generation == self._transfer_generation) + + def _owner_generation(self): + generation = self._operation_generation + return (self._transfer_generation if generation is None else generation) + + def _put_owned(self): + if not self._put_active or not self._operation_valid(): + return False + return (self._put_generation == self._owner_generation() and + self._put_session == self._operation_session) + + def _reconcile_stale_put(self): + """Make a disconnected predecessor inert before successor admission. + + The old stream remains in ``_put_file`` and is closed in this same + supervisor operation before any resume scan. No VFS call occurs here. + """ + if (self._put_active and + self._put_generation != self._transfer_generation): + self._reset_put() + def _reset_put(self): self._put_active = False self._put_temp = None # host path of "<dest>.pbltmp" @@ -253,30 +300,54 @@ def _reset_put(self): self._put_watermark = 0 # highest contiguous byte written self._put_crc_run = 0 # streaming whole-file CRC (finalized form) self._put_latched = 0 # 0, or a latched §8 write status + self._put_generation = None + self._put_session = None def _close_put_file(self): f = self._put_file - if f is not None: - self._put_file = None - try: - f.close() # flush to storage (fsync on LittleFS close) - except Exception: - pass - - def _abort_put(self): - """Abort: close + DELETE the temp, KEEP the old target (FR-FS-14).""" - self._close_put_file() + if f is None: + return OK + self._put_file = None + try: + f.close() # flush to storage (fsync on LittleFS close) + except Exception: + return EIO + return OK + + def _abort_put(self, originating_status): + """Abort the exactly owned PUT and return its authoritative status. + + Cleanup failure overrides the originating protocol/write status with + EIO. If ownership becomes stale during a VFS call, the caller emits no + response and never clears successor state. + """ + if not self._put_owned(): + return None + generation = self._put_generation + session = self._put_session temp = self._put_temp - if temp: - try: - os.remove(temp) - except OSError: - pass + close_status = self._close_put_file() + if (not self._operation_valid() or not self._put_active or + self._put_generation != generation or + self._put_session != session): + return None + cleanup_status = self._remove_scratch( + temp, valid=self._put_owned) + if cleanup_status is None or not self._put_owned(): + return None self._reset_put() + if close_status != OK or cleanup_status != OK: + return EIO + return originating_status def _busy(self): return self._put_active or self._get is not None + def _emit_owned(self, opcode, payload, session): + if session is None: + return self._emit(opcode, payload) + return self._emit(opcode, payload, session) + def _host(self, jailed): """Map a canonical jailed path onto the injected root.""" root = self._root @@ -294,26 +365,38 @@ def _stat(self, hpath): return (errno_to_status(_oserrno(exc)), 0, False) return (OK, st[6], (st[0] & _S_IFDIR) != 0) - def _crc_file(self, hpath): - """(status, whole-file CRC-32) streamed in _READ_BLK blocks.""" + def _crc_file(self, hpath, expected): + """CRC exactly the stat-advertised extent, rejecting hostile reads.""" try: f = self._open(hpath, "rb") except OSError as exc: return (errno_to_status(_oserrno(exc)), 0) + except Exception: + return (EIO, 0) crc = 0 + status = OK try: - while True: - block = f.read(_READ_BLK) - if not block: + remaining = expected + while remaining > 0: + want = _READ_BLK if remaining > _READ_BLK else remaining + block = f.read(want) + if (type(block) is not bytes or len(block) == 0 or + len(block) > want): + status = EIO break crc = crc32_update(block, crc) + remaining -= len(block) except OSError as exc: - return (errno_to_status(_oserrno(exc)), 0) + status = errno_to_status(_oserrno(exc)) + except Exception: + status = EIO finally: try: f.close() except Exception: - pass + status = EIO + if status != OK: + return (status, 0) return (OK, crc) @staticmethod @@ -344,6 +427,11 @@ def handle_list(self, payload): more = 0 count = 0 for name in names: + # Transport scratch is control-plane state, whether a backend + # reports it as a file or directory. Skip it before it can consume + # stat work, response bytes, count, or the truncation flag. + if name.endswith(TMP_SUFFIX): + continue nbytes = name.encode("utf-8") need = 1 + 4 + 2 + len(nbytes) if 4 + len(body) + need > RSP_MAX: @@ -380,7 +468,7 @@ def handle_stat(self, payload): return bytes((st,)) crc = 0 if not isdir: - st, crc = self._crc_file(hpath) # whole-file CRC-32 + st, crc = self._crc_file(hpath, size) if st != OK: return bytes((st,)) return bytes((OK,)) + _p32(size) + _p32(crc) @@ -407,7 +495,8 @@ def handle_get_begin(self, payload): return bytes((EACCES,)) # cannot GET a directory if offset > total: return bytes((ERANGE,)) - self._get = (hpath, offset) # mailboxed: supervisor calls pump() + self._get = (hpath, offset, total, self._owner_generation(), + self._operation_session) return bytes((OK,)) + _p32(total) def pump(self): @@ -415,78 +504,264 @@ def pump(self): (bytes ≥ offset only) then FILE_GET_END [crc32] over the WHOLE file (a skipped prefix is CRC'd too), then free the transfer slot. No-op when idle. RSP{OK} already went out — a mid-stream fault cannot - retract it, so END still goes out with the partial CRC (C twin).""" + retract it, so it omits END and lets the client timeout the incomplete + transfer (C twin).""" if self._get is None: return - hpath, offset = self._get + transfer = self._get + hpath, offset, total, generation, session = transfer + if generation != self._transfer_generation: + if self._get is transfer: + self._get = None + return try: crc = 0 pos = 0 + remaining = total + stream_complete = False try: f = self._open(hpath, "rb") - except OSError: + except Exception: f = None if f is not None: try: - while True: - block = f.read(self._chunk) - if not block: + read_ok = True + while remaining > 0: + if generation != self._transfer_generation: + read_ok = False + break + want = self._chunk if remaining > self._chunk else remaining + block = f.read(want) + if generation != self._transfer_generation: + read_ok = False + break + if (type(block) is not bytes or len(block) == 0 or + len(block) > want): + read_ok = False break crc = crc32_update(block, crc) # whole-file (from 0) end = pos + len(block) if end > offset: # emit only bytes ≥ offset start = offset if pos < offset else pos - self._emit(_OP_GET_DATA, - _p32(start) + block[start - pos:]) + self._emit_owned( + _OP_GET_DATA, + _p32(start) + block[start - pos:], session) + if generation != self._transfer_generation: + read_ok = False + break pos = end - except OSError: - pass # partial CRC → app detects the mismatch + remaining -= len(block) + if read_ok and remaining == 0: + stream_complete = True + except Exception: + stream_complete = False try: f.close() except Exception: - pass - self._emit(_OP_GET_END, _p32(crc)) + stream_complete = False + if (stream_complete and + generation == self._transfer_generation): + self._emit_owned(_OP_GET_END, _p32(crc), session) finally: - self._get = None # the transfer slot is free again + if self._get is transfer: + self._get = None # never clear a successor transfer # -- FILE_PUT_BEGIN (0x15): [st](+[resume_offset:u32]) --------------------- - def _resume_prefix(self, temp, total): - """F-10 resume: re-derive the verified prefix from storage. Returns - (resume_offset, running_crc). No/empty/over-size/unreadable temp → - (0, 0) and the caller's "wb" open truncates (the over-size guard).""" + @staticmethod + def _path_absent(path, valid=None): + if valid is not None and not valid(): + return None + try: + os.stat(path) + except OSError as exc: + absent = errno_to_status(_oserrno(exc)) == ENOENT + else: + absent = False + if valid is not None and not valid(): + return None + return absent + + def _remove_scratch(self, temp, st=None, valid=None): + """Remove one malformed scratch object without recursion. + + A backend reporting success while leaving the object behind is an I/O + failure, not permission to truncate or replace it on the next open. + """ + if valid is not None and not valid(): + return None + if st is None: + try: + st = os.stat(temp) + except OSError as exc: + if errno_to_status(_oserrno(exc)) == ENOENT: + return OK + return EIO + if valid is not None and not valid(): + return None + try: + kind = st[0] & _S_IFMT + except (IndexError, TypeError): + return EIO + if kind != _S_IFDIR and kind != _S_IFREG: + return EIO # never mutate symlink/device/unknown + try: + if kind == _S_IFDIR: + os.rmdir(temp) # deliberately never recursive + else: + os.remove(temp) + except (OSError, ValueError, TypeError): + return EIO + if valid is not None and not valid(): + return None + absent = self._path_absent(temp, valid=valid) + if absent is None: + return None + return OK if absent else EIO + + def _resume_prefix(self, temp, total, valid=None): + """Return ``(status, resume_offset, running_crc)`` for durable scratch. + + Every nonzero resume offset is backed by an exact-length CRC scan and + an unchanged regular-file re-stat. Malformed state is removed only by + the bounded non-recursive helper; failed cleanup is fail-closed. + """ + if valid is not None and not valid(): + return (None, 0, 0) try: st = os.stat(temp) - except OSError: - return (0, 0) # no temp → fresh upload - if (st[0] & _S_IFDIR) != 0: - return (0, 0) + except OSError as exc: + if errno_to_status(_oserrno(exc)) == ENOENT: + return (OK, 0, 0) # no temp → fresh upload + return (EIO, 0, 0) + if valid is not None and not valid(): + return (None, 0, 0) + try: + kind = st[0] & _S_IFMT + except (IndexError, TypeError): + return (EIO, 0, 0) + if kind == _S_IFDIR: + cleaned = self._remove_scratch(temp, st, valid=valid) + return (cleaned, 0, 0) + if kind != _S_IFREG: + return (EIO, 0, 0) # fail closed; leave special untouched length = st[6] - if length == 0 or length > total: - return (0, 0) # nothing to resume / over-size guard + if type(length) is not int or length < 0: + cleaned = self._remove_scratch(temp, st, valid=valid) + return (cleaned, 0, 0) + if length == 0: + return (OK, 0, 0) + if length > total: + cleaned = self._remove_scratch(temp, st, valid=valid) + return (cleaned, 0, 0) + if valid is not None and not valid(): + return (None, 0, 0) try: f = self._open(temp, "rb") except OSError: - return (0, 0) + if valid is not None and not valid(): + return (None, 0, 0) + cleaned = self._remove_scratch(temp, st, valid=valid) + return (cleaned, 0, 0) + if valid is not None and not valid(): + try: + f.close() # stale worker may close its local object + except Exception: + pass + return (None, 0, 0) crc = 0 + scan_ok = True try: remaining = length while remaining > 0: + if valid is not None and not valid(): + scan_ok = False + break want = _READ_BLK if remaining > _READ_BLK else remaining block = f.read(want) - if not block: - break # temp shorter than stat → CRC what exists + if valid is not None and not valid(): + scan_ok = False + break + if type(block) is not bytes: + scan_ok = False + break + if len(block) != want: + scan_ok = False # premature EOF/short read is malformed + break crc = crc32_update(block, crc) remaining -= len(block) + except Exception: + scan_ok = False + try: + f.close() + except Exception: + scan_ok = False + + if valid is not None and not valid(): + return (None, 0, 0) + + try: + current = os.stat(temp) except OSError: - return (0, 0) # unreadable prefix → fresh (wb truncates) - finally: - try: - f.close() - except Exception: - pass - return (length, crc) # durable prefix re-derived from flash + current = None + if valid is not None and not valid(): + return (None, 0, 0) + try: + current_kind = current[0] & _S_IFMT + except (IndexError, TypeError): + current_kind = None + unchanged = (current_kind == _S_IFREG and current[6] == length) + if scan_ok and unchanged: + return (OK, length, crc) + + if current is not None and current_kind not in (_S_IFREG, _S_IFDIR): + return (EIO, 0, 0) # changed-to-special remains untouched + + # Re-stat once more inside cleanup so a file→directory race is + # normalized with the operation appropriate to its current type. + cleaned = self._remove_scratch(temp, current, valid=valid) + return (cleaned, 0, 0) + + def _space_status(self, remaining, valid=None): + """Checked-u64 PUT capacity admission using frsize and bavail.""" + if valid is not None and not valid(): + return None + if self._statvfs is None: + return EIO + try: + values = self._statvfs(self._root) + if len(values) <= 4: + return EIO + unit = values[1] + available = values[4] + except Exception: + return EIO + if valid is not None and not valid(): + return None + if (type(unit) is not int or type(available) is not int + or unit <= 0 or available < 0 + or unit > _UINT64_MAX or available > _UINT64_MAX): + return EIO + if available != 0 and unit > _UINT64_MAX // available: + return EIO + free_bytes = unit * available + + if remaining == 0: + rounded = 0 + else: + addend = unit - 1 + if remaining > _UINT64_MAX - addend: + return EIO + rounded = ((remaining + addend) // unit) * unit + if rounded > _UINT64_MAX: + return EIO + if rounded > _UINT64_MAX - FS_SAFETY_RESERVE: + return EIO + required = rounded + FS_SAFETY_RESERVE + return OK if free_bytes >= required else ENOSPC def handle_put_begin(self, payload): + if not self._operation_valid(): + return None payload = bytes(payload) if len(payload) < 10: return bytes((EBADREQ,)) @@ -500,25 +775,52 @@ def handle_put_begin(self, payload): return bytes((st,)) if _forbidden_artifact(jailed): return bytes((EACCES,)) # .mpy/.pyc never accepted (FR-FS-12) + self._reconcile_stale_put() if self._busy(): return bytes((EBUSY,)) # single active transfer (§5) - # Flush + close any file orphaned by a link-dropped transfer - # (on_disconnect resets flags only) so the resume scan below sees the - # full verified prefix length on storage (F-10). - self._close_put_file() + # A disconnect cannot enter VFS from the synchronous BLE callback. + # Close the prior object here, in the next PUT_BEGIN's worker context, + # before the resume scan derives its durable prefix (F-10). + close_status = self._close_put_file() + if not self._operation_valid(): + return None + if close_status != OK: + self._reset_put() + return bytes((close_status,)) dest = self._host(jailed) temp = dest + TMP_SUFFIX # same jailed dir; resolve() forbids the # suffix on the wire by design - resume, crc_run = self._resume_prefix(temp, total) + st, resume, crc_run = self._resume_prefix( + temp, total, valid=self._operation_valid) + if st is None: + return None + if st != OK: + self._reset_put() + return bytes((st,)) + st = self._space_status( + total - resume, valid=self._operation_valid) + if st is None: + return None + if st != OK: + self._reset_put() + return bytes((st,)) mode = "ab" if resume > 0 else "wb" # wb truncates any stale temp try: - self._put_file = self._open(temp, mode) + opened = self._open(temp, mode) except OSError as exc: + if not self._operation_valid(): + return None self._reset_put() # temp (if any) persists on storage return bytes((errno_to_status(_oserrno(exc)),)) + if not self._operation_valid(): + try: + opened.close() # only the stale worker's local object + except Exception: + pass + return None - self._put_active = True + self._put_file = opened self._put_temp = temp self._put_dest = dest self._put_total = total @@ -526,15 +828,21 @@ def handle_put_begin(self, payload): self._put_watermark = resume self._put_crc_run = crc_run self._put_latched = 0 + self._put_generation = self._owner_generation() + self._put_session = self._operation_session + # Publish active last. All fields above are inert until this cut. + self._put_active = True return bytes((OK,)) + _p32(resume) # -- FILE_PUT_DATA (0x16): Go-Back-N; ACK-only, no RSP --------------------- def _ack(self): # ack_offset = next expected = highest contiguous (cumulative ACK) - self._emit(_OP_PUT_ACK, _p32(self._put_watermark)) + if self._put_owned(): + self._emit_owned( + _OP_PUT_ACK, _p32(self._put_watermark), self._put_session) def handle_put_data(self, payload): - if not self._put_active: + if not self._put_owned(): return None # no transfer → nothing to ack payload = bytes(payload) if len(payload) < 4: @@ -546,11 +854,24 @@ def handle_put_data(self, payload): self._ack() # latched error → keep acking; END reports return None if offset == self._put_watermark and data: + if (self._put_watermark > self._put_total + or len(data) > self._put_total - self._put_watermark): + self._put_latched = ERANGE + self._ack() + return None try: - self._put_file.write(data) + written = self._put_file.write(data) except OSError as exc: + if not self._put_owned(): + return None self._put_latched = errno_to_status(_oserrno(exc)) else: + if not self._put_owned(): + return None + if type(written) is not int or written != len(data): + self._put_latched = EIO + self._ack() + return None # Advance + extend the streaming whole-file CRC together, only # on a full contiguous write, so the running CRC stays in # lockstep with the watermark (a dup/gap never double-counts). @@ -563,35 +884,44 @@ def handle_put_data(self, payload): # -- FILE_PUT_END (0x17): verify + atomic rename --------------------------- def handle_put_end(self, payload): - if not self._put_active: + if not self._put_owned(): return bytes((EBADREQ,)) # END without an active BEGIN payload = bytes(payload) if len(payload) < 4: - self._abort_put() - return bytes((EBADREQ,)) + status = self._abort_put(EBADREQ) + return None if status is None else bytes((status,)) declared = _u32(payload, 0) if self._put_latched: st = self._put_latched # ENOSPC / EIO latched during DATA - self._abort_put() - return bytes((st,)) + status = self._abort_put(st) + return None if status is None else bytes((status,)) if self._put_watermark != self._put_total: - self._abort_put() - return bytes((ERANGE,)) # short/over transfer - self._close_put_file() # flush the temp to storage + status = self._abort_put(ERANGE) + return None if status is None else bytes((status,)) + close_status = self._close_put_file() # durability cut before rename + if not self._put_owned(): + return None + if close_status != OK: + status = self._abort_put(close_status) + return None if status is None else bytes((status,)) # The streaming whole-file CRC (re-seeded over any resumed prefix) is # the ONLY correctness gate — a foreign prefix fails HERE (FR-FS-14). crc = self._put_crc_run if crc != self._put_crc_target or crc != declared: - self._abort_put() - return bytes((ECRC,)) # mismatch → target untouched + status = self._abort_put(ECRC) + return None if status is None else bytes((status,)) temp = self._put_temp dest = self._put_dest try: os.rename(temp, dest) # atomic replace (LittleFS / POSIX) except OSError as exc: + if not self._put_owned(): + return None st = errno_to_status(_oserrno(exc)) - self._abort_put() - return bytes((st,)) + status = self._abort_put(st) + return None if status is None else bytes((status,)) + if not self._put_owned(): + return None self._reset_put() # temp is gone (renamed) — clear only return bytes((OK,)) @@ -604,6 +934,8 @@ def handle_delete(self, payload): st, jailed = resolve(path) if st != OK: return bytes((st,)) + if self._busy(): + return bytes((EBUSY,)) hpath = self._host(jailed) st, _size, isdir = self._stat(hpath) if st != OK: @@ -626,6 +958,8 @@ def handle_mkdir(self, payload): st, jailed = resolve(path) if st != OK: return bytes((st,)) + if self._busy(): + return bytes((EBUSY,)) hpath = self._host(jailed) st, _size, isdir = self._stat(hpath) if st == OK: @@ -652,6 +986,8 @@ def handle_rename(self, payload): st, djail = resolve(dst) if st != OK: return bytes((st,)) + if self._busy(): + return bytes((EBUSY,)) spath = self._host(sjail) dpath = self._host(djail) st, _size, _isdir = self._stat(spath) @@ -665,9 +1001,7 @@ def handle_rename(self, payload): # -- F-10 link-drop hook --------------------------------------------------- def on_disconnect(self): - """Reset in-RAM transfer state; KEEP <dest>.pbltmp on disk (its - on-flash length is the durable watermark a resuming PUT_BEGIN - re-derives). The orphan file object is flushed + closed later, by the - next PUT_BEGIN — never here. Idempotent.""" + """Reset RAM only; defer VFS close and preserve resumable scratch.""" + self._transfer_generation += 1 self._get = None self._reset_put() diff --git a/firmware/pyble/pyble_proto.py b/firmware/pyble/pyble_proto.py index 95fe4925..f9b59752 100644 --- a/firmware/pyble/pyble_proto.py +++ b/firmware/pyble/pyble_proto.py @@ -80,6 +80,126 @@ _HEADER_LEN = 6 # VER + TYPE + OPCODE + ID + LEN(2) _CRC_LEN = 4 _MIN_FRAME = _HEADER_LEN + _CRC_LEN # smallest valid frame (empty payload) +_HELLO_MAX = 192 +_HELLO_FIELD_MAX = 32 +_HELLO_OFFERS_MAX = 8 +_NO_RSP_COMMANDS = (OPCODES["FILE_PUT_DATA"], OPCODES["CONSOLE_INPUT"]) + + +def strict_utf8_decode(data): + """Decode only shortest-form Unicode scalar UTF-8, else return ``None``. + + Some supported MicroPython builds accept overlong encodings or surrogate + code points in ``bytes.decode``. Wire text therefore validates the bytes + explicitly before asking the runtime to construct the string. + """ + try: + data = bytes(data) + except Exception: + return None + + length = len(data) + index = 0 + while index < length: + first = data[index] + if first <= 0x7F: + index += 1 + continue + + if 0xC2 <= first <= 0xDF: + if index + 1 >= length: + return None + second = data[index + 1] + if not 0x80 <= second <= 0xBF: + return None + index += 2 + continue + + if 0xE0 <= first <= 0xEF: + if index + 2 >= length: + return None + second = data[index + 1] + third = data[index + 2] + if first == 0xE0: + second_ok = 0xA0 <= second <= 0xBF + elif first == 0xED: + second_ok = 0x80 <= second <= 0x9F + else: + second_ok = 0x80 <= second <= 0xBF + if not second_ok or not 0x80 <= third <= 0xBF: + return None + index += 3 + continue + + if 0xF0 <= first <= 0xF4: + if index + 3 >= length: + return None + second = data[index + 1] + third = data[index + 2] + fourth = data[index + 3] + if first == 0xF0: + second_ok = 0x90 <= second <= 0xBF + elif first == 0xF4: + second_ok = 0x80 <= second <= 0x8F + else: + second_ok = 0x80 <= second <= 0xBF + if (not second_ok or not 0x80 <= third <= 0xBF or + not 0x80 <= fourth <= 0xBF): + return None + index += 4 + continue + + return None + + try: + return data.decode("utf-8") + except Exception: + # The byte grammar above is complete, but fail closed if a particular + # runtime still cannot materialize one otherwise-valid scalar. + return None + + +def _path_end(payload, offset): + """Return the byte after one `[plen:u16][path]`, or None if truncated.""" + if offset < 0 or offset + 2 > len(payload): + return None + path_len = payload[offset] | (payload[offset + 1] << 8) + end = offset + 2 + path_len + return end if end <= len(payload) else None + + +def _payload_exact(opcode, payload): + """PBLE/1's fixed/structured payload-consumption grammar. + + Semantic range and path-jail checks stay in opcode handlers. This reducer + only proves that every declared field consumes the complete payload, so a + valid prefix cannot hide extension bytes from the handler. + """ + length = len(payload) + if opcode in (OPCODES["DEVICE_INFO"], OPCODES["STOP"], + OPCODES["SOFT_REBOOT"]): + return length == 0 + if opcode in (OPCODES["FILE_LIST"], OPCODES["FILE_STAT"], + OPCODES["FILE_DELETE"], OPCODES["MKDIR"]): + return _path_end(payload, 0) == length + if opcode == OPCODES["FILE_GET_BEGIN"]: + return length >= 4 and _path_end(payload, 4) == length + if opcode == OPCODES["FILE_PUT_BEGIN"]: + return length >= 8 and _path_end(payload, 8) == length + if opcode == OPCODES["FILE_PUT_END"]: + return length == 4 + if opcode == OPCODES["FILE_RENAME"]: + first = _path_end(payload, 0) + return first is not None and _path_end(payload, first) == length + if opcode == OPCODES["SET_AUTORUN"]: + return length == 1 + if opcode == OPCODES["SET_IDENTIFY_LED"]: + return length in (0, 2) + if opcode == OPCODES["IDENTIFY"]: + return length <= 1 + # HELLO owns its text grammar. RUN, PUT_DATA, CONSOLE_INPUT, and SET_LABEL + # explicitly consume their last field as every remaining byte. + return True class ProtocolError(ValueError): @@ -179,6 +299,75 @@ def _crc_ok(msg): return crc32(msg[:-_CRC_LEN]) == got +def _valid_hello_key(key): + if not key or len(key) > _HELLO_FIELD_MAX: + return False + first = key[0] + if first < 0x61 or first > 0x7A: + return False + for value in key[1:]: + if not (0x61 <= value <= 0x7A or 0x30 <= value <= 0x39 or + value == 0x5F): + return False + return True + + +def hello_status(payload): + """Return the §8 status for one bounded, canonical HELLO payload.""" + payload = bytes(payload) + if len(payload) > _HELLO_MAX: + return STATUS["EBADREQ"] + for value in payload: + if value != 0x0A and not 0x20 <= value <= 0x7E: + return STATUS["EBADREQ"] + + lines = payload.split(b"\n") + if lines and lines[-1] == b"": + lines.pop() # one optional trailing LF + if not lines or any(line == b"" for line in lines): + return STATUS["EBADREQ"] + + required = {} + required_keys = (b"proto_versions", b"app_name", b"app_version") + for line in lines: + split = line.find(b"=") + if split <= 0: + return STATUS["EBADREQ"] + key = line[:split] + value = line[split + 1:] + if (not _valid_hello_key(key) or + len(value) > _HELLO_FIELD_MAX): + return STATUS["EBADREQ"] + if key in required_keys: + if key in required: + return STATUS["EBADREQ"] + required[key] = value + + if any(key not in required for key in required_keys): + return STATUS["EBADREQ"] + app_name = required[b"app_name"] + app_version = required[b"app_version"] + if not app_name or not app_version: + return STATUS["EBADREQ"] + + offers = required[b"proto_versions"].split(b",") + if not 1 <= len(offers) <= _HELLO_OFFERS_MAX: + return STATUS["EBADREQ"] + supports_v1 = False + for token in offers: + if (not token or (len(token) > 1 and token[0] == 0x30) or + any(value < 0x30 or value > 0x39 for value in token)): + return STATUS["EBADREQ"] + number = 0 + for value in token: + number = number * 10 + value - 0x30 + if number > 255: + return STATUS["EBADREQ"] + if number == VER: + supports_v1 = True + return STATUS["OK"] if supports_v1 else STATUS["EUNSUPPORTED"] + + # --- §3.2 fragmentation ------------------------------------------------------ def fragment(msg, mtu): """Split a §3.1 message into §3.2 packets: 1-byte FRAG_HDR + up to (MTU-4) @@ -222,8 +411,46 @@ class Dispatcher: * malformed frame -> RSP payload[0]=EBADREQ. """ - def __init__(self): + def __init__(self, link=None): self._handlers = {} + self._link = link + self._negotiated_session = None + self._publish_callback = None + + def reset_session(self): + """Clear per-connection negotiation and any unpublished receipt.""" + self._negotiated_session = None + self._publish_callback = None + + def take_publish_callback(self): + """Return and consume the receipt belonging to the latest response.""" + callback = self._publish_callback + self._publish_callback = None + return callback + + def _session_token(self): + if self._link is None: + return None + return self._link.session_token() + + def _negotiated(self, session): + return session is not None and session == self._negotiated_session + + def _record_violation(self, session): + if self._link is None: + return True + record = getattr(self._link, "record_protocol_violation", None) + if record is None: + return True + return record(session) + + def _arm_hello_commit(self, session): + def _commit(): + if (self._link is not None and session is not None and + self._link.session_token() == session): + self._negotiated_session = session + + self._publish_callback = _commit def register(self, opcode, handler): """Bind a handler to an opcode. handler(frame) -> RSP payload bytes.""" @@ -231,31 +458,81 @@ def register(self, opcode, handler): def on_message(self, msg): msg = bytes(msg) - # 1. Structural parse. A malformed frame -> EBADREQ (FR-PROTO-8). We - # best-effort recover opcode/id for the RSP header when present. + # Receipts are response-local. A caller that failed/raised before + # consuming the prior one cannot accidentally publish it later. + self._publish_callback = None + session = self._session_token() + + # 1. Exact structural length. Refuse only a complete safely correlated + # v1/CMD/nonzero-ID header; all malformed inputs still debit once. try: frame = decode(msg) except ProtocolError: - opcode = msg[2] if len(msg) > 2 else 0x00 - id_ = msg[3] if len(msg) > 3 else 0x00 - return encode(RSP, opcode, id_, bytes((STATUS["EBADREQ"],))) - # 2. Version gate (FR-PROTO-7): PBLE/1 serves only VER=0x01. Refuse a - # structurally-valid other-version frame before opcode dispatch. - if frame.ver != VER: - return encode(RSP, frame.opcode, frame.id, - bytes((STATUS["EBADREQ"],))) - # 3. CRC gate (FR-PROTO-3): a mismatch drops the frame and answers + admitted = self._record_violation(session) + if (admitted and len(msg) >= _HEADER_LEN and msg[0] == VER and + msg[1] == CMD and msg[3] != 0): + return encode( + RSP, msg[2], msg[3], bytes((STATUS["EBADREQ"],))) + return None + + # 2. CRC gate (FR-PROTO-3): a mismatch drops the frame and answers # EVT ERROR(ECRC) referencing the opcode; the handler is NOT called. if not _crc_ok(msg): + if not self._record_violation(session): + return None return encode(EVT, frame.opcode, 0, bytes((STATUS["ECRC"],))) - # 4. Dispatch by opcode (FR-PROTO-5). Unknown/unregistered opcode -> + + # 3. Direction/request correlation precedes VER so inbound responses + # can never create response loops. + if frame.type != CMD or frame.id == 0: + self._record_violation(session) + return None + + # 4. Version gate (FR-PROTO-7): PBLE/1 serves only VER=0x01. + if frame.ver != VER: + if not self._record_violation(session): + return None + return encode(RSP, frame.opcode, frame.id, + bytes((STATUS["EBADREQ"],))) + + # 5. HELLO and per-session admission. A session-less Dispatcher remains + # a strict low-level codec seam and deliberately skips this policy. + if self._link is not None: + if frame.opcode == OPCODES["HELLO"]: + status = hello_status(frame.payload) + if status != STATUS["OK"]: + if (status == STATUS["EBADREQ"] and + not self._record_violation(session)): + return None + return encode(RSP, frame.opcode, frame.id, bytes((status,))) + elif not self._negotiated(session): + if not self._record_violation(session): + return None + if frame.opcode in _NO_RSP_COMMANDS: + return None + return encode(RSP, frame.opcode, frame.id, + bytes((STATUS["EBADREQ"],))) + + # 6. Dispatch by opcode (FR-PROTO-5). Unknown/unregistered opcode -> # EUNSUPPORTED (FR-PROTO-9), echoing the request id + opcode. handler = self._handlers.get(frame.opcode) if handler is None: return encode(RSP, frame.opcode, frame.id, bytes((STATUS["EUNSUPPORTED"],))) + if not _payload_exact(frame.opcode, frame.payload): + if frame.opcode in _NO_RSP_COMMANDS: + return None + return encode(RSP, frame.opcode, frame.id, + bytes((STATUS["EBADREQ"],))) rsp_payload = handler(frame) if rsp_payload is None: return None # handler emits its own events (e.g. CMD-only opcodes) - # 5. RSP echoes the request opcode + ID (FR-PROTO-4). - return encode(RSP, frame.opcode, frame.id, bytes(rsp_payload)) + # 7. RSP echoes the request opcode + ID (FR-PROTO-4). Successful HELLO + # only arms an exact-session callback; the Agent publishes it at the + # final-fragment local acceptance cut. + rsp_payload = bytes(rsp_payload) + response = encode(RSP, frame.opcode, frame.id, rsp_payload) + if (self._link is not None and frame.opcode == OPCODES["HELLO"] and + rsp_payload and rsp_payload[0] == STATUS["OK"]): + self._arm_hello_commit(session) + return response diff --git a/firmware/pyble/pyble_runner.py b/firmware/pyble/pyble_runner.py index fef2a2c5..e8f6c814 100644 --- a/firmware/pyble/pyble_runner.py +++ b/firmware/pyble/pyble_runner.py @@ -77,6 +77,7 @@ def on_started(self): def on_finished(self, ok): self.state = DONE if ok else ERROR self.events.append(self.state) + return self.state def on_stopped(self): # STOP terminal: a stopped run returns to IDLE (§6 STOP -> idle; @@ -91,16 +92,28 @@ class Runner: for every RUN_STATE transition; `exec_fn(mode, data)` executes user code in supervisor context (see make_exec_fn).""" - def __init__(self, emit_state, exec_fn, emit_terminal=None): + def __init__(self, emit_state, exec_fn, emit_terminal=None, + end_input=None): self.rsm = RunStateMachine() self._emit_state = emit_state self._emit_terminal = (emit_terminal if emit_terminal is not None else self._default_emit_terminal) self._exec_fn = exec_fn self._pending = None # captured (mode, data) awaiting pickup + self._reserved_prior_state = None self._stop_requested = False self._executing = False # reserved/pending is deliberately distinct self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self._pending_owner = None + self._active_owner = None + self._terminal_owner = None + self._event_owner = None + self._pending_input_generation = None + self._active_input_generation = None + self._terminal_input_generation = None + self._event_input_generation = None + self._end_input = end_input def _default_emit_terminal(self, state, published): """Compatibility adapter for host users without a TX receipt seam.""" @@ -113,7 +126,51 @@ def _terminal_published(self): # exception check. self._terminal_phase = _TERMINAL_PUBLISHED - def handle_run(self, payload): + def event_owner(self): + """Immutable session owner of the event currently being emitted.""" + return self._event_owner + + def event_input_generation(self): + """Console lease belonging to the event currently being emitted.""" + return self._event_input_generation + + def bind_reserved_input(self, generation): + """Bind successful RUN-response stdin admission to its reservation.""" + if self._pending is None or self._executing: + return False + self._pending_input_generation = generation + return True + + def _emit_owned_state(self, state, owner, input_generation): + prior_owner = self._event_owner + prior_input = self._event_input_generation + self._event_owner = owner + self._event_input_generation = input_generation + try: + return self._emit_state(state) + finally: + self._event_owner = prior_owner + self._event_input_generation = prior_input + + def _emit_owned_terminal(self, state, published): + prior_owner = self._event_owner + prior_input = self._event_input_generation + self._event_owner = self._terminal_owner + self._event_input_generation = self._terminal_input_generation + try: + return self._emit_terminal(state, published) + finally: + self._event_owner = prior_owner + self._event_input_generation = prior_input + + def _end_active_input(self): + generation = self._active_input_generation + self._active_input_generation = None + self._terminal_input_generation = generation + if self._end_input is not None and generation is not None: + self._end_input(generation) + + def handle_run(self, payload, owner=None): # Validate BEFORE reserving (pble_runner_run twin) — the only place the # §6 RUN payload is parsed. payload = bytes(payload) @@ -128,13 +185,39 @@ def handle_run(self, payload): return ERANGE elif len(data) > RUN_SOURCE_MAX: return ERANGE + prior_state = self.rsm.state status = self.rsm.on_run() if status != OK: return status # EBUSY: no capture, no RUN_STATE self._stop_requested = False # fresh reservation owns fresh control self._pending = (mode, data) # reserved -> single writer of the capture + self._reserved_prior_state = prior_state + self._pending_owner = owner + self._pending_input_generation = None return OK # RSP only; RUN_STATE is supervisor work + def cancel_reserved_run(self): + """Roll back a provisional RUN whose response handoff was rejected. + + This is valid only before supervisor pickup. Restoring the captured + predecessor matters because a new RUN may be reserved from DONE/ERROR, + not just IDLE. + """ + if self._pending is None or self._executing: + return False + prior_state = self._reserved_prior_state + if prior_state is None: + return False + self._pending = None + self._reserved_prior_state = None + self._pending_owner = None + self._pending_input_generation = None + self._stop_requested = False + self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self.rsm.state = prior_state + return True + def handle_stop(self): # Idempotent while idle/terminal. While RUNNING this intent applies to # the current reservation or execution; service() consumes it before a @@ -157,24 +240,38 @@ def _terminalize_stopped(self): The RSM transition and wire publication are deliberately restartable: a deferred KBI can land immediately before or after `on_stopped()`. """ - self._pending = None - self._executing = False - self._stop_requested = False if self._terminal_phase == _TERMINAL_NONE: return False if self._terminal_phase == _TERMINAL_PUBLISHED: self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self._terminal_owner = None + self._terminal_input_generation = None return False + if not self._terminal_prepared: + self._executing = False + self._stop_requested = False + # End the predecessor's stdin lease before its RSM state becomes + # IDLE and therefore admits a successor RUN. A publication retry + # must not touch a successor reservation or its STOP/input state. + self._end_active_input() + self._terminal_prepared = True if self.rsm.state != IDLE: self.rsm.on_stopped() - accepted = self._emit_terminal(IDLE, self._terminal_published) + accepted = self._emit_owned_terminal(IDLE, self._terminal_published) if self._terminal_phase == _TERMINAL_PUBLISHED: self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self._terminal_owner = None + self._terminal_input_generation = None elif accepted is False: # No live session, or the captured session went stale: omit instead # of retaining the terminal for a future connection (protocol.md # §6). self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self._terminal_owner = None + self._terminal_input_generation = None return True def service_interrupted(self): @@ -191,8 +288,18 @@ def service(self): return self._terminalize_stopped() return False mode, data = self._pending + owner = self._pending_owner + input_generation = self._pending_input_generation self._pending = None + self._reserved_prior_state = None + self._pending_owner = None + self._pending_input_generation = None + self._active_owner = owner + self._active_input_generation = input_generation + self._terminal_owner = owner + self._terminal_input_generation = input_generation self._terminal_phase = _TERMINAL_UNPUBLISHED + self._terminal_prepared = False # Linearization cut: a control callback before this store leaves the # marker false and sets cancellation intent; one after it observes an @@ -205,7 +312,7 @@ def service(self): try: self.rsm.on_started() - self._emit_state(RUNNING) + self._emit_owned_state(RUNNING, owner, input_generation) self._exec_fn(mode, bytes(data)) ok = True except KeyboardInterrupt: @@ -221,10 +328,20 @@ def service(self): if self._stop_requested: self._terminalize_stopped() else: - self.rsm.on_finished(ok) + # The predecessor owns this terminal value and console lease. End + # its input before exposing a terminal RSM state that a scheduled + # BLE callback may immediately replace with a successor RUN. + self._end_active_input() + terminal = self.rsm.on_finished(ok) self._stop_requested = False - self._emit_state(self.rsm.state) + self._emit_owned_state( + terminal, self._terminal_owner, + self._terminal_input_generation) self._terminal_phase = _TERMINAL_NONE + self._terminal_prepared = False + self._terminal_owner = None + self._terminal_input_generation = None + self._active_owner = None return True diff --git a/firmware/pyble/pyble_workspace.py b/firmware/pyble/pyble_workspace.py new file mode 100644 index 00000000..e89b3573 --- /dev/null +++ b/firmware/pyble/pyble_workspace.py @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Shared, fail-closed LFS2 workspace mount authority. + +All supported profiles use this helper so first-use provisioning cannot drift +between ESP and RP2. A healthy mount is returned without inspecting storage. +Only an ``OSError`` from that first constructor admits the bounded, read-only +complete-device erased scan and its single permitted format attempt. +""" + +_BDEV_IOCTL_BLOCK_COUNT = 4 +_BDEV_IOCTL_BLOCK_SIZE = 5 +_LFS_READ_SIZE = 32 +_LFS_MIN_BLOCK_SIZE = 128 + +_COUNTER_LIMIT = 65535 +_COUNTER_NAMES = ( + "mount_attempts", "format_attempts", "format_completions", + "remount_attempts", "remount_completions", "program_calls", "erase_calls", + "recovery_attempts", "recovery_emissions", +) +_RECOVERY_MESSAGE = "PyBLE workspace recovery is required; reconnect by USB." +_boot_observation = None +_observation_started = False +_unobserved_recovery_attempted = False + + +class _BootObservation: + """One bounded VM-local measurement; never an admission authority.""" + + def __init__(self): + import os + from binascii import hexlify + + identity = os.urandom(16) + if type(identity) is not bytes or len(identity) != 16: + raise ValueError("unavailable workspace boot identity") + self.boot_id = hexlify(identity).decode() + self.counts = [0] * len(_COUNTER_NAMES) + self.workspace_attached = False + self.complete = False + self.fault = False + self.overflow = False + self.media_state = "uninspected" + self.sealed = False + self.mount_returned = False + self.nonblank_refusal = False + + def increment(self, index): + if self.sealed: + return + if self.counts[index] == _COUNTER_LIMIT: + self.overflow = True + self.fault = True + raise OverflowError("workspace observation counter overflow") + self.counts[index] += 1 + + def mark_fault(self): + if not self.sealed: + self.fault = True + + +class _ObservedBlockDevice: + """Observe the native program/erase boundary without copying read data.""" + + def __init__(self, bdev, observation): + # VfsLfs2 caches this original bound method directly. Reads do not + # acquire a Python forwarding layer or qualification-owned buffer. + self.readblocks = bdev.readblocks + self._writeblocks = bdev.writeblocks + self._ioctl = bdev.ioctl + self._observation = observation + + def writeblocks(self, block, data, *offset): + observation = self._observation + observation.increment(5) + try: + result = self._writeblocks(block, data, *offset) + except BaseException: + observation.mark_fault() + raise + # Preserve the pinned VFS protocol's return value, including failed + # and idempotent writes. A false/invalid result cannot look healthy. + if result is False or (result is not None and result is not True and result != 0): + observation.mark_fault() + return result + + def ioctl(self, command, argument): + observation = self._observation + if command == 6: + observation.increment(6) + try: + result = self._ioctl(command, argument) + except BaseException: + observation.mark_fault() + raise + if command == 6 and result is not None and result != 0: + observation.mark_fault() + return result + + +def read_boot_observation(challenge): + """Read detached retained state; never rerun storage or change a count.""" + if (type(challenge) is not str or len(challenge) != 32 or + any(value not in "0123456789abcdef" for value in challenge)): + raise ValueError("invalid workspace observation challenge") + observation = _boot_observation + if observation is None: + raise RuntimeError("workspace boot observation unavailable") + value = { + "schema_version": 1, + "boot_id": observation.boot_id, + "challenge": challenge, + } + for index in range(len(_COUNTER_NAMES)): + value[_COUNTER_NAMES[index]] = observation.counts[index] + value["workspace_attached"] = observation.workspace_attached + value["complete"] = observation.complete + value["fault"] = observation.fault + value["overflow"] = observation.overflow + value["media_state"] = observation.media_state + return value + + +def boot_attached(): + """Seal only after the overlay's actual VFS attachment succeeded.""" + observation = _boot_observation + if observation is None or observation.sealed: + raise RuntimeError("workspace observation cannot attach") + if not observation.mount_returned or observation.fault or observation.overflow: + observation.mark_fault() + raise RuntimeError("workspace observation is not attachable") + observation.workspace_attached = True + observation.complete = True + observation.sealed = True + + +def boot_recovery(): + """Emit the bounded local message once, measuring the real print call.""" + global _unobserved_recovery_attempted + observation = _boot_observation + if observation is None: + # An allocation/entropy fault may precede record creation. Guidance + # still exists, but no zero-filled or otherwise passing record does. + if _unobserved_recovery_attempted: + raise RuntimeError("workspace recovery already attempted") + _unobserved_recovery_attempted = True + print(_RECOVERY_MESSAGE) + return + if observation.sealed: + raise RuntimeError("workspace recovery already sealed") + if not observation.nonblank_refusal or observation.mount_returned: + observation.mark_fault() + observation.increment(7) + try: + print(_RECOVERY_MESSAGE) + observation.increment(8) + except BaseException: + observation.mark_fault() + observation.sealed = True + raise + observation.complete = True + observation.sealed = True + + +def _recovery_geometry(bdev, progsize): + """Return validated ``(count, block_size)`` for destructive recovery.""" + if type(progsize) is not int or progsize <= 0: + raise ValueError("uncertain LFS2 program geometry") + + count = bdev.ioctl(_BDEV_IOCTL_BLOCK_COUNT, 0) + block_size = bdev.ioctl(_BDEV_IOCTL_BLOCK_SIZE, 0) + if (type(count) is not int or type(block_size) is not int or + count < 2 or block_size < _LFS_MIN_BLOCK_SIZE): + raise ValueError("uncertain block-device geometry") + + cache_size = min( + block_size, + 4 * max(_LFS_READ_SIZE, progsize), + ) + if (cache_size % _LFS_READ_SIZE != 0 or + cache_size % progsize != 0 or + block_size % cache_size != 0): + raise ValueError("LFS2-incompatible block-device geometry") + return count, block_size + + +def mount_lfs2(bdev, vfs_module, progsize=256, *, observe_boot=False): + """Mount LFS2, formatting once only when every device byte is erased. + + Unexpected constructor faults propagate without inspection. Invalid or + unreadable geometry and any non-erased byte perform no write. The original + mount ``OSError`` is re-raised for nonblank media so the board overlay can + enter its bounded operator-recovery path. + """ + global _boot_observation, _observation_started + if type(observe_boot) is not bool: + raise ValueError("invalid workspace observation mode") + if not observe_boot: + return _mount_lfs2(bdev, vfs_module, progsize, None) + if _observation_started: + raise RuntimeError("workspace boot already observed") + _observation_started = True + observation = _BootObservation() + _boot_observation = observation + try: + return _mount_lfs2(_ObservedBlockDevice(bdev, observation), + vfs_module, progsize, observation) + except BaseException: + if not observation.nonblank_refusal: + observation.mark_fault() + if observation.media_state == "uninspected": + observation.media_state = "uncertain" + raise + + +def _mount_lfs2(bdev, vfs_module, progsize, observation): + if observation is not None: + observation.increment(0) + try: + mounted = vfs_module.VfsLfs2(bdev, progsize=progsize) + except OSError as error: + mount_failure = error + else: + if observation is not None: + observation.mount_returned = True + return mounted + + if observation is not None: + if observation.fault or observation.overflow: + raise mount_failure + observation.media_state = "uncertain" + + count, block_size = _recovery_geometry(bdev, progsize) + block = bytearray(block_size) + erased = True + for block_number in range(count): + # Poison every byte before reuse. A backend that mutates only a prefix + # cannot leave a stale erased-looking suffix from an earlier read. + for index in range(block_size): + block[index] = 0 + result = bdev.readblocks(block_number, block) + read_succeeded = (result is None or result is True or + (type(result) is int and result == 0)) + if not read_succeeded or len(block) != block_size: + raise ValueError("uncertain block-device read") + for value in block: + if value != 0xFF: + erased = False + + if not erased: + if observation is not None: + observation.media_state = "nonblank" + observation.nonblank_refusal = True + raise mount_failure + + if observation is not None: + observation.media_state = "erased" + if observation.fault or observation.overflow: + raise RuntimeError("uncertain workspace observation") + observation.increment(1) + vfs_module.VfsLfs2.mkfs(bdev, progsize=progsize) + if observation is not None: + observation.increment(2) + if observation.fault or observation.overflow: + raise RuntimeError("uncertain workspace format observation") + observation.increment(3) + mounted = vfs_module.VfsLfs2(bdev, progsize=progsize) + if observation is not None: + observation.increment(4) + observation.mount_returned = True + return mounted diff --git a/firmware/qualification/oi1-gates.json b/firmware/qualification/oi1-gates.json index 2e7b8281..64d86bee 100644 --- a/firmware/qualification/oi1-gates.json +++ b/firmware/qualification/oi1-gates.json @@ -1,7 +1,7 @@ { "baseline_evidence": { - "path": "docs/validation/firmware/oi1/a8be631df46590166307aa41afaea30b39e29230.json", - "sha256": "8a7dbf328ba8d70f5161582b56d1566821f20b7a259ff29dc7d6fe6bb75a6044" + "path": "docs/validation/firmware/oi1/96497b9cf642ab2078842ce8a321c3c84b6714b2.json", + "sha256": "007e19f0c0281801a3c065f01742ba9606e340bce07b5e67920f036cea375c76" }, "derivation": { "application_headroom": "factory-minus-application-v1", @@ -23,13 +23,13 @@ "resource_kind": "esp-idf", "target": "esp32", "thresholds": { - "application_headroom_min_bytes": 315360, - "application_image_max_bytes": 1716256, - "gc_free_min_bytes": 108544, + "application_headroom_min_bytes": 299712, + "application_image_max_bytes": 1731904, + "gc_free_min_bytes": 106496, "get_verified_goodput_min_bytes_per_second": 6600, - "idf_internal_free_min_bytes": 81920, + "idf_internal_free_min_bytes": 79872, "idf_internal_largest_block_min_bytes": 55296, - "idf_internal_minimum_free_min_bytes": 81920, + "idf_internal_minimum_free_min_bytes": 79872, "put_committed_goodput_min_bytes_per_second": 6600, "reset_to_service_advertisement_max_ms": 3000 }, @@ -45,13 +45,13 @@ "resource_kind": "esp-idf", "target": "esp32-s3", "thresholds": { - "application_headroom_min_bytes": 384064, - "application_image_max_bytes": 1713088, - "gc_free_min_bytes": 8317952, + "application_headroom_min_bytes": 368240, + "application_image_max_bytes": 1728912, + "gc_free_min_bytes": 8315904, "get_verified_goodput_min_bytes_per_second": 6600, - "idf_internal_free_min_bytes": 177152, + "idf_internal_free_min_bytes": 175104, "idf_internal_largest_block_min_bytes": 102400, - "idf_internal_minimum_free_min_bytes": 177152, + "idf_internal_minimum_free_min_bytes": 174080, "put_committed_goodput_min_bytes_per_second": 6600, "reset_to_service_advertisement_max_ms": 3000 }, @@ -67,13 +67,13 @@ "resource_kind": "esp-idf", "target": "waveshare-esp32-s3-lcd-147b", "thresholds": { - "application_headroom_min_bytes": 377024, - "application_image_max_bytes": 1720128, - "gc_free_min_bytes": 8316928, + "application_headroom_min_bytes": 361232, + "application_image_max_bytes": 1735920, + "gc_free_min_bytes": 8314880, "get_verified_goodput_min_bytes_per_second": 6600, - "idf_internal_free_min_bytes": 177152, + "idf_internal_free_min_bytes": 174080, "idf_internal_largest_block_min_bytes": 98304, - "idf_internal_minimum_free_min_bytes": 177152, + "idf_internal_minimum_free_min_bytes": 173056, "put_committed_goodput_min_bytes_per_second": 6600, "reset_to_service_advertisement_max_ms": 3000 }, @@ -89,13 +89,13 @@ "resource_kind": "esp-idf", "target": "esp32-c3", "thresholds": { - "application_headroom_min_bytes": 352880, - "application_image_max_bytes": 1678736, - "gc_free_min_bytes": 117760, + "application_headroom_min_bytes": 339536, + "application_image_max_bytes": 1692080, + "gc_free_min_bytes": 112640, "get_verified_goodput_min_bytes_per_second": 6600, - "idf_internal_free_min_bytes": 66560, - "idf_internal_largest_block_min_bytes": 57344, - "idf_internal_minimum_free_min_bytes": 63488, + "idf_internal_free_min_bytes": 64512, + "idf_internal_largest_block_min_bytes": 55296, + "idf_internal_minimum_free_min_bytes": 60416, "put_committed_goodput_min_bytes_per_second": 6600, "reset_to_service_advertisement_max_ms": 3000 }, @@ -111,9 +111,9 @@ "resource_kind": "rp2", "target": "rpi-pico2-w", "thresholds": { - "firmware_bin_max_bytes": 848216, - "firmware_image_headroom_min_bytes": 724648, - "gc_free_min_bytes": 433152, + "firmware_bin_max_bytes": 862492, + "firmware_image_headroom_min_bytes": 710372, + "gc_free_min_bytes": 429056, "get_verified_goodput_min_bytes_per_second": 6600, "put_committed_goodput_min_bytes_per_second": 6600, "reset_to_service_advertisement_max_ms": 7000 diff --git a/firmware/qualification/v060_profile_release_gate.py b/firmware/qualification/v060_profile_release_gate.py index 0d5dc5d9..d612a1b1 100644 --- a/firmware/qualification/v060_profile_release_gate.py +++ b/firmware/qualification/v060_profile_release_gate.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Part of PyBLE (https://pyble.dev) — see /LICENSE. -"""Pure release boundary for v0.6.0 C3 and Pico qualification results. +"""Pure V5 release boundary for v0.6.0/v0.6.1 C3 and Pico results. Physical runners own device access, operator interaction, and detailed bench evidence. This standard-library-only module admits a deliberately small, @@ -34,6 +34,7 @@ class QualificationError(RuntimeError): MAX_RESULT_BYTES = 64 * 1024 C3_PROFILE_ID = "esp32-c3-4mb" PICO_PROFILE_ID = "rpi-pico2-w" +V5_RELEASE_CORES = ((0, 6, 0), (0, 6, 1)) # Frozen C3 post-OI NVS raw-slice identity (ports/esp32-c3-4mb.md): the only # passing capture is the fully erased partition at the frozen geometry. C3_NVS_PARTITION_OFFSET = 0x9000 @@ -42,6 +43,11 @@ class QualificationError(RuntimeError): "1df8949b2e345ab8c00cb81fb6b83686e20a4080f969e5cd8b8d520a07cdaba2" ) C3_POST_OI_NVS_RESET_SAMPLES = 10 +C3_POST_OI_NVS_ACQUISITION_LOG = ( + b"c3-post-oi-nvs-acquisition-v1\n" + b"offset=0x9000 size=0x6000 captured=post-workload pre-evaluation\n" +) +MAX_OI_RAW_LOG_BYTES = 64 * 1024 * 1024 _POST_OI_NVS_BINDING_KEYS = {"receipt_sha256", "receipt_size_bytes", "summary"} _POST_OI_NVS_SUMMARY_KEYS = { "schema_version", @@ -162,6 +168,22 @@ def _require(condition: bool, message: str) -> None: raise QualificationError(message) +def _require_v5_release_version(value: Any, label: str) -> str: + """Admit canonical SemVer whose release core is approved for V5.""" + + _require( + type(value) is str and _SEMVER_RE.fullmatch(value) is not None, + "%s firmware version is not canonical SemVer" % label, + ) + release = value.split("+", 1)[0].split("-", 1)[0] + release_core = tuple(int(part) for part in release.split(".")) + _require( + release_core in V5_RELEASE_CORES, + "%s requires firmware release core 0.6.0 or 0.6.1" % label, + ) + return value + + def _exact_dict(value: Any, keys: set[str], label: str) -> dict[str, Any]: _require(type(value) is dict and set(value) == keys, "%s shape changed" % label) return value @@ -300,10 +322,9 @@ def _validate_expected_inputs( expected_profile_id in _GATES_BY_PROFILE, "expected qualification profile is unsupported", ) - _require( - type(expected_version) is str - and _SEMVER_RE.fullmatch(expected_version) is not None, - "expected firmware version is invalid", + _require_v5_release_version( + expected_version, + "expected V5 qualification", ) _require( type(candidate_release_json_sha256) is str @@ -469,6 +490,10 @@ def _reopen_c3_post_oi_nvs_evidence( exclusive=True, ) slice_digest = _validate_c3_post_oi_nvs_slice_bytes(slice_raw) + _require( + log_raw == C3_POST_OI_NVS_ACQUISITION_LOG, + "C3 post-OI NVS acquisition marker changed", + ) receipt = _exact_dict( _strict_json(receipt_raw), {"summary", "inventory"}, @@ -1017,7 +1042,7 @@ def _validate_candidate_release( artifact_size: int, artifact_sha256: str, ) -> str: - """Validate the frozen V4 five-profile metadata used by this gate writer.""" + """Validate the frozen V5 five-profile metadata used by this gate writer.""" value = _exact_dict( release, @@ -1030,14 +1055,17 @@ def _validate_candidate_release( {"version", "tag", "agent_version", "protocol_version", "built_at"}, "candidate release identity", ) + version = _require_v5_release_version( + identity["version"], + "candidate V5 qualification", + ) _require( - identity["version"] == "0.6.0" - and identity["tag"] == "firmware-v0.6.0" - and identity["agent_version"] == "0.6.0" + identity["tag"] == "firmware-v%s" % version + and identity["agent_version"] == version and identity["protocol_version"] == "PBLE/1" and type(identity["built_at"]) is str and _UTC_RE.fullmatch(identity["built_at"]) is not None, - "candidate is not the exact v0.6.0 release source era", + "candidate is not an approved exact V5 release source", ) _validate_candidate_provenance(value["provenance"]) _require( @@ -1186,7 +1214,7 @@ def _validate_candidate_release( expected_path=expected_path, label="candidate document %s" % key, ) - return identity["version"] + return version def _candidate_snapshot( @@ -1268,6 +1296,96 @@ def _candidate_identity( return snapshot[0], snapshot[1], snapshot[2], snapshot[3] +def _c3_post_oi_nvs_receipt_source_snapshot( + *, + candidate_dir: Path, + observation_path: Path, + oi1_raw_log_path: Path, + post_oi_nvs_slice_path: Path, + post_oi_nvs_acquisition_log_path: Path, +) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Reopen, validate, and bind every input used to derive one receipt.""" + + candidate_snapshot = _candidate_snapshot(Path(candidate_dir), C3_PROFILE_ID) + release_sha256 = candidate_snapshot[1] + artifact_sha256 = candidate_snapshot[3] + observation_raw, observation_identity = _stable_regular_bytes( + Path(observation_path), + label="C3 OI verify observation", + maximum=MAX_RESULT_BYTES, + exclusive=True, + ) + observation = _strict_json(observation_raw) + raw_log, raw_log_identity = _stable_regular_bytes( + Path(oi1_raw_log_path), + label="C3 OI verify raw log", + maximum=MAX_OI_RAW_LOG_BYTES, + exclusive=True, + ) + slice_raw, slice_identity = _stable_regular_bytes( + Path(post_oi_nvs_slice_path), + label="C3 post-OI NVS raw slice", + maximum=C3_NVS_PARTITION_SIZE, + exclusive=True, + ) + acquisition_raw, acquisition_identity = _stable_regular_bytes( + Path(post_oi_nvs_acquisition_log_path), + label="C3 post-OI NVS acquisition log", + maximum=len(C3_POST_OI_NVS_ACQUISITION_LOG), + exclusive=True, + ) + + raw_log_sha256 = hashlib.sha256(raw_log).hexdigest() + observed_raw_log_sha256 = observation.get("raw_log_sha256") + reset_samples = observation.get("reset_to_service_advertisement_ms") + _require( + type(observed_raw_log_sha256) is str + and _SHA256_RE.fullmatch(observed_raw_log_sha256) is not None + and observed_raw_log_sha256 == raw_log_sha256, + "C3 OI verify observation does not bind the reopened raw log", + ) + _require( + type(reset_samples) is list + and len(reset_samples) == C3_POST_OI_NVS_RESET_SAMPLES, + "C3 OI verify observation must contain exactly %d reset samples" + % C3_POST_OI_NVS_RESET_SAMPLES, + ) + slice_sha256 = _validate_c3_post_oi_nvs_slice_bytes(slice_raw) + _require( + acquisition_raw == C3_POST_OI_NVS_ACQUISITION_LOG, + "C3 post-OI NVS acquisition marker changed", + ) + summary = { + "schema_version": RECEIPT_SCHEMA_VERSION, + "profile_id": C3_PROFILE_ID, + "candidate_release_json_sha256": release_sha256, + "candidate_firmware_sha256": artifact_sha256, + "oi1_raw_sha256": raw_log_sha256, + "reset_samples": len(reset_samples), + "partition_offset": C3_NVS_PARTITION_OFFSET, + "partition_size": C3_NVS_PARTITION_SIZE, + "nvs_slice_sha256": slice_sha256, + "nvs_slice_size_bytes": len(slice_raw), + "acquisition_log_sha256": hashlib.sha256(acquisition_raw).hexdigest(), + "acquisition_log_size_bytes": len(acquisition_raw), + "integrity": "passed", + "written_namespaces": [], + } + _validate_post_oi_nvs_summary( + summary, + candidate_release_json_sha256=release_sha256, + artifact_sha256=artifact_sha256, + ) + source_snapshot = ( + candidate_snapshot, + (observation_raw, observation_identity), + (raw_log, raw_log_identity), + (slice_raw, slice_identity), + (acquisition_raw, acquisition_identity), + ) + return source_snapshot, summary + + def _remove_created_result( parent_descriptor: int, name: str, @@ -1351,8 +1469,8 @@ def _write_exclusive_result( and (after.st_dev, after.st_ino) == created_identity, "qualification result output is unsafe", ) - os.close(descriptor) - descriptor = -1 + # Pin this inode through validation and rollback. Otherwise unlinking + # the output can let a replacement reuse its device/inode identity. _verify_output_parent(chain) read_flags = os.O_RDONLY | os.O_NOFOLLOW @@ -1404,20 +1522,96 @@ def _write_exclusive_result( os.close(reader) except OSError: pass - if descriptor >= 0: - try: - os.close(descriptor) - except OSError: - pass if created and not preserve: _remove_created_result( parent_descriptor, output.name, created_identity, ) + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass _close_directory_chain(chain) +def create_post_oi_nvs_receipt_file( + *, + candidate_dir: Path, + observation_path: Path, + oi1_raw_log_path: Path, + post_oi_nvs_slice_path: Path, + post_oi_nvs_acquisition_log_path: Path, + output_path: Path, +) -> Path: + """Derive one canonical candidate-bound C3 post-OI NVS receipt.""" + + candidate = Path(candidate_dir) + observation = Path(observation_path) + raw_log = Path(oi1_raw_log_path) + nvs_slice = Path(post_oi_nvs_slice_path) + acquisition_log = Path(post_oi_nvs_acquisition_log_path) + source_snapshot, summary = _c3_post_oi_nvs_receipt_source_snapshot( + candidate_dir=candidate, + observation_path=observation, + oi1_raw_log_path=raw_log, + post_oi_nvs_slice_path=nvs_slice, + post_oi_nvs_acquisition_log_path=acquisition_log, + ) + candidate_snapshot = source_snapshot[0] + release_sha256 = candidate_snapshot[1] + artifact_sha256 = candidate_snapshot[3] + candidate_root = candidate_snapshot[4] + output = _absolute_lexical_path( + output_path, + "C3 post-OI NVS receipt", + ) + _require( + not output.is_relative_to(candidate_root), + "C3 post-OI NVS receipt must not be inside the candidate", + ) + receipt_raw = _expected_post_oi_nvs_receipt_bytes(summary) + expected_binding = { + "receipt_sha256": hashlib.sha256(receipt_raw).hexdigest(), + "receipt_size_bytes": len(receipt_raw), + "summary": summary, + } + + def validate_before_preserve() -> None: + _require( + _reopen_c3_post_oi_nvs_evidence( + output, + nvs_slice, + acquisition_log, + candidate_release_json_sha256=release_sha256, + artifact_sha256=artifact_sha256, + ) + == expected_binding, + "C3 post-OI NVS receipt changed while it was created", + ) + repeated_snapshot, repeated_summary = ( + _c3_post_oi_nvs_receipt_source_snapshot( + candidate_dir=candidate, + observation_path=observation, + oi1_raw_log_path=raw_log, + post_oi_nvs_slice_path=nvs_slice, + post_oi_nvs_acquisition_log_path=acquisition_log, + ) + ) + _require( + repeated_snapshot == source_snapshot and repeated_summary == summary, + "C3 post-OI NVS receipt inputs changed while it was created", + ) + + _write_exclusive_result( + output, + receipt_raw, + post_write_check=validate_before_preserve, + ) + return output + + def create_result_file( *, candidate_dir: Path, @@ -1525,8 +1719,9 @@ def _main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description=__doc__, epilog=( - "create-result derives candidate identity and accepts one " - "--passed-gate per frozen profile gate" + "Receipt creation derives every claim from retained private " + "evidence; create-result accepts one --passed-gate per frozen " + "profile gate" ), ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -1561,17 +1756,41 @@ def _main(argv: list[str] | None = None) -> int: dest="post_oi_nvs_acquisition_log", help="C3-only exclusive mode-0600 NVS acquisition log", ) + receipt_parser = subparsers.add_parser("create-post-oi-nvs-receipt") + receipt_parser.add_argument("candidate_dir", type=Path) + receipt_parser.add_argument("observation_path", type=Path) + receipt_parser.add_argument("oi1_raw_log_path", type=Path) + receipt_parser.add_argument("post_oi_nvs_slice_path", type=Path) + receipt_parser.add_argument( + "post_oi_nvs_acquisition_log_path", + type=Path, + ) + receipt_parser.add_argument("output_path", type=Path) args = parser.parse_args(argv) try: - created = create_result_file( - candidate_dir=args.candidate_dir, - profile_id=args.profile_id, - passed_gates=args.passed_gates, - output_path=args.output_path, - post_oi_nvs_receipt_path=args.post_oi_nvs_receipt, - post_oi_nvs_slice_path=args.post_oi_nvs_slice, - post_oi_nvs_acquisition_log_path=args.post_oi_nvs_acquisition_log, - ) + if args.command == "create-post-oi-nvs-receipt": + created = create_post_oi_nvs_receipt_file( + candidate_dir=args.candidate_dir, + observation_path=args.observation_path, + oi1_raw_log_path=args.oi1_raw_log_path, + post_oi_nvs_slice_path=args.post_oi_nvs_slice_path, + post_oi_nvs_acquisition_log_path=( + args.post_oi_nvs_acquisition_log_path + ), + output_path=args.output_path, + ) + else: + created = create_result_file( + candidate_dir=args.candidate_dir, + profile_id=args.profile_id, + passed_gates=args.passed_gates, + output_path=args.output_path, + post_oi_nvs_receipt_path=args.post_oi_nvs_receipt, + post_oi_nvs_slice_path=args.post_oi_nvs_slice, + post_oi_nvs_acquisition_log_path=( + args.post_oi_nvs_acquisition_log + ), + ) except QualificationError as exc: print("qualification result creation failed: %s" % exc, file=sys.stderr) return 1 @@ -1587,6 +1806,7 @@ def _main(argv: list[str] | None = None) -> int: "PICO_PROFILE_ID", "QualificationError", "canonical_json_bytes", + "create_post_oi_nvs_receipt_file", "create_result_file", "post_oi_nvs_evidence_paths", "private_result_snapshot", diff --git a/firmware/qualification/v061_hardening_release_gate.py b/firmware/qualification/v061_hardening_release_gate.py new file mode 100644 index 00000000..8cc5d29d --- /dev/null +++ b/firmware/qualification/v061_hardening_release_gate.py @@ -0,0 +1,1704 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Candidate-bound private evidence authority for v0.6.1 hardening HIL. + +This module deliberately accepts only bounded, canonical, privacy-safe +machine evidence. It is shared by the physical bench and release admission +so that neither path can silently reinterpret an operator log. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import types +from typing import Any, Callable + + +class QualificationError(RuntimeError): + """The supplied evidence cannot be admitted.""" + + +PROFILE_TARGETS = { + "esp32-4mb": "esp32", + "esp32-s3-n16r8": "esp32-s3", + "waveshare-esp32-s3-lcd-147b": "waveshare-esp32-s3-lcd-147b", + "esp32-c3-4mb": "esp32-c3", + "rpi-pico2-w": "rpi-pico2-w", +} +SCENARIO_ORDER = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) +WORKSPACE_PROVISIONING_ORDER = ( + "erased-media-first-boot", + "nonblank-media-refusal", +) +RESULT_KEYS = ( + "schema_version", + "measurement_contract", + "profile_id", + "target", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "install_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + "scenario_order", + "scenarios", + "workspace_provisioning", + "raw_log_sha256", + "status", +) +PUBLIC_SUMMARY_KEYS = ( + "measurement_contract", + "scenario_order", + "scenarios", + "sequential_runs", + "workspace_provisioning", + "private_result_sha256", +) +WORKSPACE_RECEIPT_KEYS = ( + "schema_version", + "measurement_contract", + "observation_kind", + "profile_id", + "target", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "install_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + "acquisition_sha256", + "raw_log_sha256", + "status", +) + +_RESULT_CONTRACT = "v061-hardening-seven-scenario-v1" +_RECEIPT_CONTRACT = "v061-workspace-provisioning-receipt-v1" +WORKSPACE_ACQUISITION_CONTRACT = "v061-workspace-acquisition-v1" +WORKSPACE_COLLECTOR_RELATIVE = "tests/firmware_tests/hil/v061_workspace_acquire.py" +WORKSPACE_MEDIA = { + "esp32-4mb": {"offset": 0x200000, "size": 0x200000, "block_size": 4096}, + "esp32-s3-n16r8": {"offset": 0x210000, "size": 0xDF0000, "block_size": 4096}, + "waveshare-esp32-s3-lcd-147b": {"offset": 0x210000, "size": 0xDF0000, "block_size": 4096}, + "esp32-c3-4mb": {"offset": 0x200000, "size": 0x200000, "block_size": 4096}, + "rpi-pico2-w": {"offset": 0x180000, "size": 0x280000, "block_size": 4096}, +} +WORKSPACE_ACQUISITION_KEYS = ( + "schema_version", "measurement_contract", "observation_kind", + "profile_id", "target", "firmware_version", "source_commit", + "candidate_release_json_sha256", "install_sha256", + "qualification_source_commit", "qualification_executable_sha256", + "collector_sha256", "challenge", "boot_id", "media", "transport", + "board_binding", "install_readback_sha256", "pre_media_sha256", + "post_media_sha256", "response_sha256", "advertisements_sha256", + "measurement_sha256", "status", +) +WORKSPACE_RAW_SIBLINGS = ( + ("install.bin", "install_readback_sha256"), + ("pre.bin", "pre_media_sha256"), + ("post.bin", "post_media_sha256"), + ("response.bin", "response_sha256"), + ("advertisements.jsonl", "advertisements_sha256"), + ("measurement.jsonl", "measurement_sha256"), +) +WORKSPACE_RESPONSE_PREFIX = b"PYBLE_WORKSPACE_OBSERVATION:" +WORKSPACE_SERVICE_UUID = "7079626c-1ab1-4d50-9e3a-000000000001" +_BOOT_OBSERVATION_KEYS = ( + "schema_version", "boot_id", "challenge", "mount_attempts", + "format_attempts", "format_completions", "remount_attempts", + "remount_completions", "program_calls", "erase_calls", + "workspace_attached", "recovery_attempts", "recovery_emissions", + "complete", "fault", "overflow", "media_state", +) +_VERSION = "0.6.1" +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +_MAX_JSON_BYTES = 2 * 1024 * 1024 +_MAX_LOG_BYTES = 64 * 1024 +_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 +_QUALIFICATION_SOURCE_PATHS = ( + "firmware/qualification/v061_hardening_release_gate.py", + WORKSPACE_COLLECTOR_RELATIVE, + "tests/firmware_tests/hil/_v061_workspace_hardware.py", + "tests/firmware_tests/hil/_v061_workspace_pico.py", + "tests/firmware_tests/hil/v061_hardening_bench.py", + "tests/firmware_tests/hil/_pble_bench.py", + "tests/firmware_tests/hil/_pble_central.py", + "tests/firmware_tests/hil/_pble_wire.py", + "tests/firmware_tests/hil/target_smoke.py", + "firmware/qualification/oi1-gates.json", + "firmware/versions.lock", +) +_QUALIFICATION_EXECUTABLE_RELATIVE = ( + "tests/firmware_tests/hil/v061_hardening_bench.py" +) +_FILE_ID_FIELDS = ( + "st_dev", + "st_ino", + "st_mode", + "st_nlink", + "st_size", + "st_mtime_ns", + "st_ctime_ns", +) + + +class _ResultInputPreflight: + """Opaque lease over the exact long-lived inputs to one physical run.""" + + __slots__ = ( + "candidate_dir", + "profile_id", + "candidate", + "qualification_root", + "qualification", + "validation", + "erased_path", + "erased_summary", + "erased_snapshot", + "nonblank_path", + "nonblank_summary", + "nonblank_snapshot", + ) + + def __init__( + self, + *, + candidate_dir, + profile_id, + candidate, + qualification_root, + qualification, + validation, + erased_path, + erased_summary, + erased_snapshot, + nonblank_path, + nonblank_summary, + nonblank_snapshot, + ) -> None: + self.candidate_dir = candidate_dir + self.profile_id = profile_id + self.candidate = candidate + self.qualification_root = qualification_root + self.qualification = qualification + self.validation = validation + self.erased_path = erased_path + self.erased_summary = erased_summary + self.erased_snapshot = erased_snapshot + self.nonblank_path = nonblank_path + self.nonblank_summary = nonblank_summary + self.nonblank_snapshot = nonblank_snapshot + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise QualificationError(message) + + +def canonical_json_bytes(value: Any) -> bytes: + """Return the frozen private-evidence JSON representation.""" + + try: + text = json.dumps( + value, + indent=2, + sort_keys=False, + ensure_ascii=False, + allow_nan=False, + ) + except (TypeError, ValueError) as exc: + raise QualificationError("evidence is not canonical JSON") from exc + return (text + "\n").encode("utf-8") + + +def _reject_constant(value: str) -> None: + raise QualificationError("evidence contains non-JSON %s" % value) + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise QualificationError("evidence duplicates a JSON key") + result[key] = value + return result + + +def _decode_json(raw: bytes, label: str, *, canonical: bool) -> dict[str, Any]: + try: + value = json.loads( + raw.decode("utf-8", errors="strict"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except QualificationError: + raise + except (UnicodeDecodeError, TypeError, ValueError) as exc: + raise QualificationError("%s is not strict UTF-8 JSON" % label) from exc + _require(type(value) is dict, "%s must be a JSON object" % label) + if canonical: + _require(raw == canonical_json_bytes(value), "%s is not canonical" % label) + return value + + +def _canonical_json_line(value: dict[str, Any]) -> bytes: + try: + return ( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise QualificationError("raw evidence is not canonical JSON Lines") from exc + + +def _absolute_lexical_path(path: Path, label: str) -> Path: + _require(os.name == "posix", "%s requires POSIX no-follow support" % label) + _require( + hasattr(os, "O_DIRECTORY") and hasattr(os, "O_NOFOLLOW"), + "%s requires POSIX no-follow support" % label, + ) + try: + raw = os.fspath(path) + _require(type(raw) is str and bool(raw), "%s path is invalid" % label) + _require( + all(part not in (".", "..") for part in raw.split(os.sep)), + "%s path contains lexical navigation" % label, + ) + value = Path(os.path.abspath(raw)) + # Darwin exposes /var as a fixed system alias for /private/var. Test + # and operator temporary directories commonly retain the /var + # spelling, so normalize that one OS-owned alias without resolving any + # caller-controlled component farther down the path. + if value.parts[:2] == (os.sep, "var"): + try: + if os.readlink("/var") == "private/var": + value = Path("/private/var").joinpath(*value.parts[2:]) + except OSError: + pass + except (OSError, TypeError, ValueError) as exc: + raise QualificationError("%s path is invalid" % label) from exc + return value + + +def _directory_identity(value: os.stat_result) -> tuple[int, int, int]: + return value.st_dev, value.st_ino, value.st_mode + + +def _close_directory_chain( + chain: list[tuple[int, Path, tuple[int, int, int]]], +) -> None: + for descriptor, _path, _identity in reversed(chain): + try: + os.close(descriptor) + except OSError: + pass + + +def _verify_directory_chain( + chain: list[tuple[int, Path, tuple[int, int, int]]], label: str +) -> None: + for descriptor, path, expected in chain: + try: + held = os.fstat(descriptor) + visible = path.lstat() + except OSError as exc: + raise QualificationError("%s directory chain changed" % label) from exc + _require( + stat.S_ISDIR(held.st_mode) + and stat.S_ISDIR(visible.st_mode) + and not stat.S_ISLNK(visible.st_mode) + and _directory_identity(held) == expected + and _directory_identity(visible) == expected, + "%s directory chain changed" % label, + ) + + +def _open_directory_chain( + path: Path, *, label: str +) -> list[tuple[int, Path, tuple[int, int, int]]]: + directory = _absolute_lexical_path(path, label) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + chain: list[tuple[int, Path, tuple[int, int, int]]] = [] + try: + descriptor = os.open(os.sep, flags) + root_stat = os.fstat(descriptor) + chain.append((descriptor, Path(os.sep), _directory_identity(root_stat))) + current = Path(os.sep) + for component in directory.parts[1:]: + descriptor = os.open(component, flags, dir_fd=chain[-1][0]) + opened = os.fstat(descriptor) + current = current / component + _require(stat.S_ISDIR(opened.st_mode), "%s ancestor is unsafe" % label) + chain.append((descriptor, current, _directory_identity(opened))) + _verify_directory_chain(chain, label) + return chain + except QualificationError: + _close_directory_chain(chain) + raise + except OSError as exc: + _close_directory_chain(chain) + raise QualificationError("%s directory chain is unsafe" % label) from exc + + +def _read_open_regular( + descriptor: int, + *, + label: str, + maximum: int, + private: bool, +) -> tuple[bytes, tuple[int, ...]]: + before = os.fstat(descriptor) + _require(stat.S_ISREG(before.st_mode), "%s must be a regular file" % label) + if private: + _require( + before.st_nlink == 1 and stat.S_IMODE(before.st_mode) == 0o600, + "%s must be one private mode-0600 regular file" % label, + ) + _require(0 < before.st_size <= maximum, "%s size is outside its bound" % label) + + def read_once() -> bytes: + chunks: list[bytes] = [] + remaining = maximum + 1 + while remaining: + chunk = os.read(descriptor, min(remaining, 1024 * 1024)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + raw = read_once() + os.lseek(descriptor, 0, os.SEEK_SET) + repeated = read_once() + after = os.fstat(descriptor) + identity = tuple(getattr(before, field) for field in _FILE_ID_FIELDS) + _require( + len(raw) == before.st_size + and raw == repeated + and identity == tuple(getattr(after, field) for field in _FILE_ID_FIELDS), + "%s changed while it was read" % label, + ) + return raw, identity + + +def _stable_regular_bytes( + path: Path, + *, + label: str, + maximum: int, + private: bool, +) -> tuple[bytes, tuple[int, ...]]: + value = _absolute_lexical_path(path, label) + chain = _open_directory_chain(value.parent, label=label + " parent") + descriptor = -1 + flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + descriptor = os.open(value.name, flags, dir_fd=chain[-1][0]) + raw, identity = _read_open_regular( + descriptor, label=label, maximum=maximum, private=private + ) + _verify_directory_chain(chain, label + " parent") + return raw, identity + except QualificationError: + raise + except OSError as exc: + raise QualificationError("%s is missing or unsafe" % label) from exc + finally: + if descriptor >= 0: + os.close(descriptor) + _close_directory_chain(chain) + + +def _file_snapshot(path: Path, *, label: str, maximum: int, private: bool): + raw, identity = _stable_regular_bytes( + path, label=label, maximum=maximum, private=private + ) + return raw, identity, hashlib.sha256(raw).hexdigest() + + +def _expected_workspace_values(observation_kind: str) -> list[dict[str, Any]]: + if observation_kind == WORKSPACE_PROVISIONING_ORDER[0]: + return [ + {"event": "media-precondition", "state": "erased"}, + {"count": 1, "event": "lfs2-format"}, + {"count": 1, "event": "lfs2-remount"}, + {"event": "service-advertisement", "status": "observed"}, + ] + if observation_kind == WORKSPACE_PROVISIONING_ORDER[1]: + return [ + {"event": "media-precondition", "state": "nonblank-incompatible"}, + {"count": 0, "event": "media-write"}, + {"count": 1, "event": "recovery-message"}, + {"event": "service-advertisement", "status": "absent"}, + ] + raise QualificationError("workspace observation kind is unsupported") + + +def _workspace_sibling(receipt_path: Path, suffix: str) -> Path: + _require(receipt_path.suffix == ".json", "workspace receipt must end in .json") + return receipt_path.with_name(receipt_path.stem + "-" + suffix) + + +def _workspace_evidence_maximum(path: Path) -> int: + if path.name.endswith(("-install.bin", "-pre.bin", "-post.bin")): + return _MAX_ARTIFACT_BYTES + return _MAX_JSON_BYTES if path.suffix == ".json" else _MAX_LOG_BYTES + + +def _json_line_records(raw: bytes, label: str) -> list[dict[str, Any]]: + _require(bool(raw) and raw.endswith(b"\n"), "%s is empty or unterminated" % label) + records = [] + for line in raw.splitlines(keepends=True): + value = _decode_json(line, label, canonical=False) + _require(line == _canonical_json_line(value), "%s is not canonical JSONL" % label) + records.append(value) + return records + + +def parse_workspace_response(raw: bytes) -> dict[str, Any]: + """Parse exactly one actual firmware reply; never fill missing fields.""" + _require(type(raw) is bytes and 0 < len(raw) <= _MAX_LOG_BYTES, + "workspace response is empty or oversized") + matches = [line[len(WORKSPACE_RESPONSE_PREFIX):] for line in raw.splitlines() + if line.startswith(WORKSPACE_RESPONSE_PREFIX)] + _require(len(matches) == 1, "workspace response requires exactly one observation") + reply = _decode_json(matches[0], "workspace response", canonical=False) + _require(set(reply) == {"observation", "workspace_probe"}, "workspace response shape changed") + return reply + + +def _validate_boot_observation(value, reply, kind): + observed = reply["observation"] + _require(type(observed) is dict and set(observed) == set(_BOOT_OBSERVATION_KEYS), + "boot observation shape changed") + _require(type(observed["schema_version"]) is int and observed["schema_version"] == 1, + "boot observation schema changed") + for field in ("boot_id", "challenge"): + _require(observed[field] == value[field], "boot observation %s mismatch" % field) + counters = ("mount_attempts", "format_attempts", "format_completions", + "remount_attempts", "remount_completions", "program_calls", + "erase_calls", "recovery_attempts", "recovery_emissions") + for field in counters: + _require(type(observed[field]) is int and 0 <= observed[field] <= 65535, + "boot observation %s is not a bounded measured count" % field) + for field in ("workspace_attached", "complete", "fault", "overflow"): + _require(type(observed[field]) is bool, "boot observation %s is not boolean" % field) + _require(observed["complete"] and not observed["fault"] and not observed["overflow"], + "boot observation is incomplete, faulted, or overflowed") + erased = kind == WORKSPACE_PROVISIONING_ORDER[0] + _require(observed["mount_attempts"] == 1, "boot initial mount count changed") + _require(observed["media_state"] == ("erased" if erased else "nonblank"), + "boot media precondition was not measured") + for field in ("format_attempts", "format_completions", "remount_attempts", "remount_completions"): + _require(observed[field] == int(erased), "boot measured %s failed" % field) + for field in ("recovery_attempts", "recovery_emissions"): + _require(observed[field] == int(not erased), "boot measured %s failed" % field) + _require(observed["workspace_attached"] is erased, "boot attachment observation failed") + writes = observed["program_calls"] + observed["erase_calls"] + _require(writes > 0 if erased else writes == 0, "boot measured program/erase requests failed") + probe = reply["workspace_probe"] + if erased: + _require(type(probe) is list and len(probe) == 10 + and all(type(item) is int and item >= 0 for item in probe) + and probe[0] > 0 and probe[1] > 0 and probe[2] > 0, + "successful workspace has no actual statvfs probe") + else: + _require(probe is None, "refused workspace must not run a filesystem probe") + + +def _record_shape(row, fields, label): + _require(type(row) is dict and set(row) == set(fields), "%s shape changed" % label) + stamp = row.get("monotonic_ns") + _require(type(stamp) is int and 0 < stamp < 2**63, "%s timestamp is invalid" % label) + + +def validate_workspace_binding(value): + """Use the maintained, stdlib-only physical schema without opening I/O. + + Execute only bytes read from the fixed authored source path. The helper's + pure validation imports no serial/BLE tools and opens no hardware. This + shares the exact USB/ROM/geometry authority with physical acquisition. + """ + path = Path(__file__).resolve().parents[2] / "tests/firmware_tests/hil/_v061_workspace_hardware.py" + prior = _file_snapshot(path, label="workspace physical binding authority", + maximum=_MAX_JSON_BYTES, private=False) + module = types.ModuleType("_pyble_v061_workspace_binding_authority") + module.__file__ = os.fspath(path) + try: + exec(compile(prior[0], os.fspath(path), "exec"), module.__dict__) + validated = module.validate_binding(value) + except Exception as exc: + raise QualificationError("workspace physical binding is invalid: %s" % exc) from exc + _same_file_snapshot(path, prior, label="workspace physical binding authority", + maximum=_MAX_JSON_BYTES, private=False) + _require(type(validated) is dict and validated == value, + "workspace physical binding was reinterpreted") + return validated + + +def _validate_workspace_measurement(value, measurement, watch): + _require(len(measurement) == 8, "workspace measurement sequence is incomplete") + events = ("acquisition-start", "install-verified", "media-read", "boot-start", + "boot-complete", "observation-read", "media-read", "acquisition-end") + shapes = ( + ("event", "challenge", "monotonic_ns", "binding"), + ("event", "device_id", "install_readback_sha256", "monotonic_ns"), + ("event", "phase", "offset", "size", "sha256", "monotonic_ns"), + ("event", "device_id", "monotonic_ns"), + ("event", "monotonic_ns"), + ("event", "challenge", "boot_id", "transport", "response_sha256", "monotonic_ns"), + ("event", "phase", "offset", "size", "sha256", "monotonic_ns"), + ("event", "status", "monotonic_ns"), + ) + stamps = [] + for row, event, fields in zip(measurement, events, shapes): + _record_shape(row, fields, "workspace %s" % event) + _require(row["event"] == event, "workspace measurement order changed") + stamps.append(row["monotonic_ns"]) + _require(all(left < right for left, right in zip(stamps, stamps[1:])), + "workspace measurement chronology is not strictly increasing") + _require(measurement[0]["challenge"] == value["challenge"], "acquisition challenge mismatch") + binding = validate_workspace_binding(measurement[0]["binding"]) + _require(type(binding) is dict and binding.get("schema_version") == 1 + and type(binding.get("schema_version")) is int + and binding.get("profile_id") == value["profile_id"], + "acquisition physical binding is missing or cross-profile") + for key in ("device_id", "ble_address"): + _require(binding.get(key) == value["board_binding"][key], "acquisition physical %s mismatch" % key) + for index in (1, 3): + _require(measurement[index]["device_id"] == value["board_binding"]["device_id"], + "workspace physical device changed") + _require(measurement[1]["install_readback_sha256"] == value["install_readback_sha256"], + "workspace install measurement mismatch") + for index, phase in ((2, "pre"), (6, "post")): + row = measurement[index] + _require(row["phase"] == phase and type(row["offset"]) is int + and type(row["size"]) is int and row["offset"] == value["media"]["offset"] + and row["size"] == value["media"]["size"] + and row["sha256"] == value[phase + "_media_sha256"], + "workspace %s readback measurement mismatch" % phase) + for field in ("challenge", "boot_id", "transport", "response_sha256"): + _require(measurement[5][field] == value[field], "workspace response measurement %s mismatch" % field) + _require(measurement[-1]["status"] == "complete", "workspace acquisition did not complete") + _require(len(watch) >= 2, "workspace advertisement watch is incomplete") + _record_shape(watch[0], ("event", "challenge", "monotonic_ns"), "watch start") + _record_shape(watch[-1], ("event", "challenge", "monotonic_ns", "status"), "watch end") + _require(watch[0]["event"] == "watch-start" and watch[-1]["event"] == "watch-end" + and watch[-1]["status"] == "complete", "workspace advertisement watch was interrupted") + _require(watch[0]["challenge"] == watch[-1]["challenge"] == value["challenge"], + "workspace advertisement watch challenge mismatch") + start, end = watch[0]["monotonic_ns"], watch[-1]["monotonic_ns"] + _require(stamps[2] < start < stamps[3] and stamps[5] < end < stamps[6] + and end - stamps[3] >= 10_000_000_000, + "workspace advertisement watch does not cover ten post-boot seconds") + last = start + matches = 0 + for row in watch[1:-1]: + _record_shape(row, ("event", "monotonic_ns", "address", "local_name", "service_uuids"), "advertisement") + _require(row["event"] == "advertisement" and last <= row["monotonic_ns"] <= end, + "workspace advertisement chronology changed") + _require(type(row["address"]) is str and 0 < len(row["address"]) <= 128 + and (row["local_name"] is None or (type(row["local_name"]) is str and len(row["local_name"]) <= 128)) + and type(row["service_uuids"]) is list and len(row["service_uuids"]) <= 32 + and all(type(item) is str and len(item) <= 64 for item in row["service_uuids"]), + "workspace advertisement callback is invalid") + last = row["monotonic_ns"] + if row["address"] == value["board_binding"]["ble_address"] and WORKSPACE_SERVICE_UUID in row["service_uuids"]: + _require(row["monotonic_ns"] >= stamps[3], "selected advertisement predates the measured boot") + matches += 1 + erased = value["observation_kind"] == WORKSPACE_PROVISIONING_ORDER[0] + _require(matches > 0 if erased else matches == 0, "selected workspace advertisement observation failed") + + +def _workspace_acquisition_snapshot(receipt_path, observation_kind, validation): + """Reopen and independently validate all retained physical measurements.""" + receipt_path = _absolute_lexical_path(Path(receipt_path), "workspace receipt") + path = _workspace_sibling(receipt_path, "acquisition.json") + snapshot = _file_snapshot(path, label="workspace acquisition", maximum=_MAX_JSON_BYTES, private=True) + value = _decode_json(snapshot[0], "workspace acquisition", canonical=True) + _require(tuple(value) == WORKSPACE_ACQUISITION_KEYS, "workspace acquisition shape or key order changed") + _require(type(value["schema_version"]) is int and value["schema_version"] == 1 + and value["measurement_contract"] == WORKSPACE_ACQUISITION_CONTRACT, + "workspace acquisition schema or contract changed") + _require(observation_kind in WORKSPACE_PROVISIONING_ORDER + and value["observation_kind"] == observation_kind, "workspace acquisition kind mismatch") + _validate_expected_identity(value, **validation) + for field in ("challenge", "boot_id"): + _require(type(value[field]) is str and re.fullmatch(r"[0-9a-f]{32}", value[field]) is not None, + "workspace acquisition %s is invalid" % field) + _validate_sha(value["collector_sha256"], "workspace collector") + collector = Path(__file__).resolve().parents[2] / WORKSPACE_COLLECTOR_RELATIVE + collector_raw, _ = _stable_regular_bytes(collector, label="workspace collector", maximum=_MAX_JSON_BYTES, private=False) + _require(hashlib.sha256(collector_raw).hexdigest() == value["collector_sha256"], "workspace collector digest mismatch") + media = value["media"] + _require(type(media) is dict and tuple(media) == ("offset", "size", "block_size") + and all(type(number) is int for number in media.values()) + and media == WORKSPACE_MEDIA[value["profile_id"]], "workspace media geometry changed") + _require(value["transport"] in ("usb-repl", "pble-run"), "workspace observation transport changed") + if observation_kind == WORKSPACE_PROVISIONING_ORDER[1]: + _require(value["transport"] == "usb-repl", "refused workspace cannot use BLE") + binding = value["board_binding"] + _require(type(binding) is dict and tuple(binding) == ("device_id", "ble_address") + and all(type(item) is str and 0 < len(item) <= 128 for item in binding.values()), + "workspace selected physical binding is invalid") + _require(value["status"] == "passed", "workspace acquisition did not pass") + snapshots = [(path, snapshot)] + raw = {} + for suffix, field in WORKSPACE_RAW_SIBLINGS: + _validate_sha(value[field], "workspace " + field) + sibling = _workspace_sibling(receipt_path, suffix) + captured = _file_snapshot(sibling, label="workspace " + suffix, + maximum=_workspace_evidence_maximum(sibling), private=True) + _require(captured[2] == value[field], "workspace %s digest mismatch" % suffix) + raw[suffix] = captured[0] + snapshots.append((sibling, captured)) + _require(value["install_readback_sha256"] == value["install_sha256"], "physical install readback differs from candidate") + pre, post = raw["pre.bin"], raw["post.bin"] + _require(len(pre) == len(post) == media["size"], "workspace readback is incomplete") + if observation_kind == WORKSPACE_PROVISIONING_ORDER[0]: + _require(pre == b"\xff" * len(pre), "erased workspace pre-read is not completely blank") + _require(pre != post, "erased workspace has no changed post-read") + else: + first = 2 * media["block_size"] + incompatible = pre == b"\0" * len(pre) or (pre[:first] == b"\0" * first and pre[first:] == b"\xff" * (len(pre) - first)) + _require(incompatible, "nonblank workspace is not a deliberately incompatible precondition") + _require(pre == post, "refused workspace media changed") + reply = parse_workspace_response(raw["response.bin"]) + _validate_boot_observation(value, reply, observation_kind) + if observation_kind == WORKSPACE_PROVISIONING_ORDER[0]: + probe = reply["workspace_probe"] + blocks = media["size"] // media["block_size"] + _require(probe[0] == probe[1] == media["block_size"] and probe[2] == blocks + and 0 <= probe[3] <= blocks and probe[4] == probe[3] + and probe[5:9] == [0, 0, 0, 0] and probe[9] == 255, + "workspace statvfs geometry differs from measured native media") + _validate_workspace_measurement(value, + _json_line_records(raw["measurement.jsonl"], "workspace measurement"), + _json_line_records(raw["advertisements.jsonl"], "workspace advertisement watch")) + for sibling, prior in snapshots: + _same_file_snapshot(sibling, prior, label="workspace acquisition evidence", + maximum=_workspace_evidence_maximum(sibling), private=True) + return snapshot[2], tuple(snapshots) + + +def _validate_exact_json_lines( + raw: bytes, expected: list[dict[str, Any]], label: str +) -> None: + wanted = b"".join(_canonical_json_line(value) for value in expected) + _require(raw == wanted, "%s does not match its exact canonical grammar" % label) + + +def _expected_scenario_log() -> list[dict[str, Any]]: + return [ + ( + {"scenario": name, "sequential_runs": 50, "status": "passed"} + if name == "resource-stability" + else {"scenario": name, "status": "passed"} + ) + for name in SCENARIO_ORDER + ] + + +def _validate_sha(value: Any, label: str) -> None: + _require( + type(value) is str and _SHA256_RE.fullmatch(value) is not None, + "%s digest is invalid" % label, + ) + + +def _validate_commit(value: Any, label: str) -> None: + _require( + type(value) is str and _COMMIT_RE.fullmatch(value) is not None, + "%s commit is invalid" % label, + ) + + +def _validate_expected_identity( + value: dict[str, Any], + *, + expected_profile_id: str, + expected_target: str, + expected_version: str, + expected_source_commit: str, + candidate_release_json_sha256: str, + expected_install_sha256: str, + expected_qualification_source_commit: str, + expected_qualification_executable_sha256: str, +) -> None: + _require(expected_profile_id in PROFILE_TARGETS, "expected profile is unsupported") + _require(expected_target == PROFILE_TARGETS[expected_profile_id], "expected target changed") + _require(expected_version == _VERSION, "hardening evidence requires version 0.6.1") + _validate_commit(expected_source_commit, "expected source") + _validate_sha(candidate_release_json_sha256, "candidate release.json") + _validate_sha(expected_install_sha256, "expected install") + _validate_commit(expected_qualification_source_commit, "qualification source") + _validate_sha( + expected_qualification_executable_sha256, "qualification executable" + ) + expected = { + "profile_id": expected_profile_id, + "target": expected_target, + "firmware_version": expected_version, + "source_commit": expected_source_commit, + "candidate_release_json_sha256": candidate_release_json_sha256, + "install_sha256": expected_install_sha256, + "qualification_source_commit": expected_qualification_source_commit, + "qualification_executable_sha256": expected_qualification_executable_sha256, + } + for key, wanted in expected.items(): + _require(type(value.get(key)) is str and value.get(key) == wanted, "%s mismatch" % key) + + +def validate_workspace_receipt_payload( + value, + observation_kind, + expected_profile_id, + expected_target, + expected_version, + expected_source_commit, + candidate_release_json_sha256, + expected_install_sha256, + expected_qualification_source_commit, + expected_qualification_executable_sha256, +): + """Validate and summarize one exact private workspace receipt.""" + + _require( + type(value) is dict and tuple(value) == WORKSPACE_RECEIPT_KEYS, + "workspace receipt shape or key order changed", + ) + _require(type(value["schema_version"]) is int and value["schema_version"] == 1, "workspace receipt schema changed") + _require(value["measurement_contract"] == _RECEIPT_CONTRACT, "workspace receipt contract changed") + _require(observation_kind in WORKSPACE_PROVISIONING_ORDER, "workspace observation kind is unsupported") + _require(value["observation_kind"] == observation_kind, "workspace observation kind mismatch") + _validate_expected_identity( + value, + expected_profile_id=expected_profile_id, + expected_target=expected_target, + expected_version=expected_version, + expected_source_commit=expected_source_commit, + candidate_release_json_sha256=candidate_release_json_sha256, + expected_install_sha256=expected_install_sha256, + expected_qualification_source_commit=expected_qualification_source_commit, + expected_qualification_executable_sha256=expected_qualification_executable_sha256, + ) + _validate_sha(value["acquisition_sha256"], "workspace acquisition") + _validate_sha(value["raw_log_sha256"], "workspace raw log") + _require(value["status"] == "passed", "workspace receipt did not pass") + raw = canonical_json_bytes(value) + return { + "status": "passed", + "receipt_sha256": hashlib.sha256(raw).hexdigest(), + "acquisition_sha256": value["acquisition_sha256"], + "raw_log_sha256": value["raw_log_sha256"], + } + + +def _workspace_raw_path(receipt_path: Path) -> Path: + _require(receipt_path.suffix == ".json", "workspace receipt must end in .json") + return receipt_path.with_name(receipt_path.stem + "-raw.jsonl") + + +def _workspace_receipt_file_snapshot( + path: Path, + observation_kind: str, + validation: dict[str, str], +): + receipt_path = _absolute_lexical_path(path, "workspace receipt") + receipt_raw, receipt_identity, receipt_digest = _file_snapshot( + receipt_path, + label="workspace receipt", + maximum=_MAX_JSON_BYTES, + private=True, + ) + value = _decode_json(receipt_raw, "workspace receipt", canonical=True) + summary = validate_workspace_receipt_payload( + value, observation_kind=observation_kind, **validation + ) + _require(summary["receipt_sha256"] == receipt_digest, "workspace receipt digest mismatch") + raw_path = _workspace_raw_path(receipt_path) + boot_raw, boot_identity, boot_digest = _file_snapshot( + raw_path, + label="workspace raw boot log", + maximum=_MAX_LOG_BYTES, + private=True, + ) + _validate_exact_json_lines( + boot_raw, + _expected_workspace_values(observation_kind), + "workspace raw boot log", + ) + _require(boot_digest == value["raw_log_sha256"], "workspace receipt does not bind its raw log") + acquisition_digest, acquisition_snapshots = _workspace_acquisition_snapshot( + receipt_path, observation_kind, validation + ) + _require(acquisition_digest == value["acquisition_sha256"], "workspace receipt does not bind its acquisition") + return summary, ( + (receipt_path, (receipt_raw, receipt_identity, receipt_digest)), + (raw_path, (boot_raw, boot_identity, boot_digest)), + ) + acquisition_snapshots + + +def validate_workspace_receipt_file( + path, + observation_kind, + expected_profile_id, + expected_target, + expected_version, + expected_source_commit, + candidate_release_json_sha256, + expected_install_sha256, + expected_qualification_source_commit, + expected_qualification_executable_sha256, +): + validation = { + "expected_profile_id": expected_profile_id, + "expected_target": expected_target, + "expected_version": expected_version, + "expected_source_commit": expected_source_commit, + "candidate_release_json_sha256": candidate_release_json_sha256, + "expected_install_sha256": expected_install_sha256, + "expected_qualification_source_commit": expected_qualification_source_commit, + "expected_qualification_executable_sha256": expected_qualification_executable_sha256, + } + summary, _snapshot = _workspace_receipt_file_snapshot( + Path(path), observation_kind, validation + ) + return summary + + +def _validate_scenarios(value: Any) -> None: + _require(type(value) is dict and tuple(value) == SCENARIO_ORDER, "scenario order or inventory changed") + for name in SCENARIO_ORDER: + row = value[name] + keys = ("status", "sequential_runs") if name == "resource-stability" else ("status",) + _require(type(row) is dict and tuple(row) == keys, "%s result shape changed" % name) + _require(row["status"] == "passed", "%s did not pass" % name) + if name == "resource-stability": + _require(type(row["sequential_runs"]) is int and row["sequential_runs"] == 50, "resource stability must contain exactly 50 runs") + + +def _validate_workspace_summary(value: Any) -> None: + _require(type(value) is dict and tuple(value) == WORKSPACE_PROVISIONING_ORDER, "workspace evidence order or inventory changed") + for name in WORKSPACE_PROVISIONING_ORDER: + row = value[name] + _require( + type(row) is dict + and tuple(row) == ("status", "receipt_file", "receipt_sha256", "acquisition_sha256", "raw_log_sha256"), + "%s workspace summary shape changed" % name, + ) + _require(row["status"] == "passed", "%s workspace evidence did not pass" % name) + _require(type(row["receipt_file"]) is str + and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json", row["receipt_file"]) is not None, + "%s workspace receipt filename is unsafe" % name) + _validate_sha(row["receipt_sha256"], "%s receipt" % name) + _validate_sha(row["acquisition_sha256"], "%s acquisition" % name) + _validate_sha(row["raw_log_sha256"], "%s raw log" % name) + first = value[WORKSPACE_PROVISIONING_ORDER[0]] + second = value[WORKSPACE_PROVISIONING_ORDER[1]] + _require(first["receipt_sha256"] != second["receipt_sha256"], "workspace receipts must be distinct") + _require(first["receipt_file"] != second["receipt_file"], "workspace receipt filenames must be distinct") + _require(first["acquisition_sha256"] != second["acquisition_sha256"], "workspace acquisitions must be distinct") + _require(first["raw_log_sha256"] != second["raw_log_sha256"], "workspace raw logs must be distinct") + + +def validate_result_payload( + value, + expected_profile_id, + expected_target, + expected_version, + expected_source_commit, + candidate_release_json_sha256, + expected_install_sha256, + expected_qualification_source_commit, + expected_qualification_executable_sha256, +): + """Validate a private result and derive its privacy-safe public summary.""" + + _require(type(value) is dict and tuple(value) == RESULT_KEYS, "hardening result shape or key order changed") + _require(type(value["schema_version"]) is int and value["schema_version"] == 1, "hardening result schema changed") + _require(value["measurement_contract"] == _RESULT_CONTRACT, "hardening result contract changed") + _validate_expected_identity( + value, + expected_profile_id=expected_profile_id, + expected_target=expected_target, + expected_version=expected_version, + expected_source_commit=expected_source_commit, + candidate_release_json_sha256=candidate_release_json_sha256, + expected_install_sha256=expected_install_sha256, + expected_qualification_source_commit=expected_qualification_source_commit, + expected_qualification_executable_sha256=expected_qualification_executable_sha256, + ) + _require(type(value["scenario_order"]) is list and tuple(value["scenario_order"]) == SCENARIO_ORDER, "scenario_order changed") + _validate_scenarios(value["scenarios"]) + _validate_workspace_summary(value["workspace_provisioning"]) + _validate_sha(value["raw_log_sha256"], "hardening raw log") + _require(value["status"] == "passed", "hardening evidence did not pass") + result_raw = canonical_json_bytes(value) + return { + "measurement_contract": _RESULT_CONTRACT, + "scenario_order": list(SCENARIO_ORDER), + "scenarios": {name: "passed" for name in SCENARIO_ORDER}, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in WORKSPACE_PROVISIONING_ORDER + }, + "private_result_sha256": hashlib.sha256(result_raw).hexdigest(), + } + + +def validate_result_file( + path, + artifact_path, + expected_profile_id, + expected_target, + expected_version, + expected_source_commit, + candidate_release_json_sha256, + expected_qualification_source_commit, + expected_qualification_executable_sha256, +): + result_path = _absolute_lexical_path(Path(path), "hardening result") + result_raw, result_identity, result_digest = _file_snapshot( + result_path, label="hardening result", maximum=_MAX_JSON_BYTES, private=True + ) + artifact_raw, _artifact_identity, install_sha256 = _file_snapshot( + Path(artifact_path), + label="candidate install artifact", + maximum=_MAX_ARTIFACT_BYTES, + private=False, + ) + _require(bool(artifact_raw), "candidate install artifact is empty") + value = _decode_json(result_raw, "hardening result", canonical=True) + validation = dict( + expected_profile_id=expected_profile_id, + expected_target=expected_target, + expected_version=expected_version, + expected_source_commit=expected_source_commit, + candidate_release_json_sha256=candidate_release_json_sha256, + expected_install_sha256=install_sha256, + expected_qualification_source_commit=expected_qualification_source_commit, + expected_qualification_executable_sha256=expected_qualification_executable_sha256, + ) + summary = validate_result_payload(value, **validation) + receipt_snapshots = [] + for kind in WORKSPACE_PROVISIONING_ORDER: + row = value["workspace_provisioning"][kind] + receipt_path = result_path.parent / row["receipt_file"] + observed, snapshots = _workspace_receipt_file_snapshot(receipt_path, kind, validation) + _require({key: item for key, item in row.items() if key != "receipt_file"} == observed, + "hardening result differs from reopened workspace receipt") + receipt_snapshots.append(snapshots) + _validate_workspace_acquisition_pair(*receipt_snapshots) + for snapshots in receipt_snapshots: + for evidence_path, prior in snapshots: + _same_file_snapshot(evidence_path, prior, label="hardening workspace closure", + maximum=_workspace_evidence_maximum(evidence_path), private=True) + _same_file_snapshot(result_path, (result_raw, result_identity, result_digest), + label="hardening result", maximum=_MAX_JSON_BYTES, private=True) + _same_file_snapshot(Path(artifact_path), (artifact_raw, _artifact_identity, install_sha256), + label="candidate install artifact", maximum=_MAX_ARTIFACT_BYTES, private=False) + return summary + + +def _validate_workspace_acquisition_pair(erased_snapshot, nonblank_snapshot): + acquisitions = [] + for snapshots in (erased_snapshot, nonblank_snapshot): + matches = [entry for path, entry in snapshots if path.name.endswith("-acquisition.json")] + _require(len(matches) == 1, "workspace acquisition closure is incomplete") + acquisitions.append(_decode_json(matches[0][0], "workspace acquisition", canonical=True)) + erased, nonblank = acquisitions + _require(erased["board_binding"] == nonblank["board_binding"], "workspace observations used different physical boards") + _require(erased["challenge"] != nonblank["challenge"], "workspace observations reused a host challenge") + _require(erased["boot_id"] != nonblank["boot_id"], "workspace observations reused a VM boot") + + +def _candidate_snapshot(candidate_dir: Path, profile_id: str): + _require(profile_id in PROFILE_TARGETS, "candidate profile is unsupported") + candidate = _absolute_lexical_path(candidate_dir, "candidate") + chain = _open_directory_chain(candidate, label="candidate") + candidate_identity = tuple((os.fspath(path), identity) for _fd, path, identity in chain) + _close_directory_chain(chain) + release_raw, release_identity, release_sha256 = _file_snapshot( + candidate / "release.json", + label="candidate release.json", + maximum=_MAX_JSON_BYTES, + private=False, + ) + release = _decode_json(release_raw, "candidate release.json", canonical=False) + identity = release.get("identity") + _require(type(identity) is dict, "candidate identity is missing") + _require(identity.get("version") == _VERSION and identity.get("agent_version") == _VERSION, "candidate version is not 0.6.1") + _require(identity.get("tag") == "firmware-v0.6.1", "candidate tag is not firmware-v0.6.1") + _require(identity.get("protocol_version") == "PBLE/1", "candidate protocol changed") + provenance = release.get("provenance") + _require(type(provenance) is dict and type(provenance.get("pyble")) is dict, "candidate provenance is missing") + source_commit = provenance["pyble"].get("commit") + _validate_commit(source_commit, "candidate source") + matches = [row for row in release.get("profiles", ()) if type(row) is dict and row.get("id") == profile_id] + _require(len(matches) == 1, "candidate profile inventory is invalid") + profile = matches[0] + _require(profile.get("target") == PROFILE_TARGETS[profile_id], "candidate profile target changed") + install = profile.get("install") + _require(type(install) is dict, "candidate install record is missing") + expected_name = "firmware.uf2" if profile_id == "rpi-pico2-w" else "firmware.bin" + expected_relative = "%s/%s" % (profile_id, expected_name) + _require(install.get("path") == expected_relative, "candidate install path changed") + artifact = candidate / profile_id / expected_name + artifact_raw, artifact_identity, install_sha256 = _file_snapshot( + artifact, + label="candidate install artifact", + maximum=_MAX_ARTIFACT_BYTES, + private=False, + ) + _require(type(install.get("size")) is int and not isinstance(install.get("size"), bool) and install["size"] == len(artifact_raw), "candidate install size mismatch") + _require(install.get("sha256") == install_sha256, "candidate install digest mismatch") + return { + "candidate": candidate, + "candidate_identity": candidate_identity, + "release_identity": release_identity, + "release_sha256": release_sha256, + "artifact": artifact, + "artifact_identity": artifact_identity, + "install_sha256": install_sha256, + "source_commit": source_commit, + } + + +def _git_command_bytes(root: Path, arguments: list[str], label: str) -> bytes: + try: + completed = subprocess.run( + ["git", *arguments], + cwd=root, + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise QualificationError("%s is unavailable" % label) from exc + return completed.stdout + + +def _git_commit(root: Path) -> str: + try: + commit = _git_command_bytes( + root, + ["rev-parse", "--verify", "HEAD"], + "qualification checkout commit", + ).decode("ascii", errors="strict").strip() + except UnicodeDecodeError as exc: + raise QualificationError("qualification checkout commit is invalid") from exc + _validate_commit(commit, "qualification source") + return commit + + +def _committed_qualification_file(root: Path, commit: str, relative: str): + tree_raw = _git_command_bytes( + root, + ["ls-tree", "-z", commit, "--", relative], + "committed qualification source", + ) + records = [record for record in tree_raw.split(b"\0") if record] + _require(len(records) == 1, "qualification source inventory changed") + try: + metadata, encoded_path = records[0].split(b"\t", 1) + mode, kind, object_id = metadata.decode("ascii", errors="strict").split(" ") + observed_path = encoded_path.decode("utf-8", errors="strict") + except (UnicodeDecodeError, ValueError) as exc: + raise QualificationError("committed qualification source is invalid") from exc + _require( + observed_path == relative + and kind == "blob" + and mode in ("100644", "100755") + and _COMMIT_RE.fullmatch(object_id) is not None, + "qualification source inventory changed", + ) + raw = _git_command_bytes( + root, + ["cat-file", "blob", object_id], + "committed qualification source bytes", + ) + _require( + 0 < len(raw) <= _MAX_JSON_BYTES, + "committed qualification source size is outside its bound", + ) + return mode, object_id, raw + + +def _qualification_snapshot(qualification_repo_root: Path): + root = _absolute_lexical_path(qualification_repo_root, "qualification checkout") + chain = _open_directory_chain(root, label="qualification checkout") + root_identity = tuple((os.fspath(path), identity) for _fd, path, identity in chain) + _close_directory_chain(chain) + try: + top = _git_command_bytes( + root, + ["rev-parse", "--show-toplevel"], + "qualification checkout root", + ).decode("utf-8", errors="strict").strip() + except UnicodeDecodeError as exc: + raise QualificationError("qualification checkout root is invalid") from exc + _require( + _absolute_lexical_path(Path(top), "qualification Git root") == root, + "qualification checkout must be the Git root", + ) + commit = _git_commit(root) + sources = [] + for relative in _QUALIFICATION_SOURCE_PATHS: + mode, object_id, committed_raw = _committed_qualification_file( + root, commit, relative + ) + path = root / Path(relative) + raw, identity, digest = _file_snapshot( + path, + label="qualification source %s" % relative, + maximum=_MAX_JSON_BYTES, + private=False, + ) + executable = bool(identity[2] & 0o111) + _require( + raw == committed_raw + and executable == (mode == "100755"), + "qualification source differs from HEAD: %s" % relative, + ) + sources.append((relative, path, identity, digest, mode, object_id)) + _require( + _git_commit(root) == commit, + "qualification checkout changed while it was inspected", + ) + executable_record = [ + record + for record in sources + if record[0] == _QUALIFICATION_EXECUTABLE_RELATIVE + ] + _require(len(executable_record) == 1, "qualification executable is missing") + _relative, executable, executable_identity, executable_digest, _mode, _oid = ( + executable_record[0] + ) + return { + "root": root, + "root_identity": root_identity, + "commit": commit, + "sources": tuple(sources), + "executable": executable, + "executable_identity": executable_identity, + "executable_sha256": executable_digest, + } + + +def _inside(path: Path, directory: Path) -> bool: + try: + return os.path.commonpath((os.fspath(path), os.fspath(directory))) == os.fspath(directory) + except ValueError: + return False + + +def _remove_created(parent_fd: int, name: str, identity: tuple[int, int] | None) -> None: + try: + visible = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if identity is not None and (visible.st_dev, visible.st_ino) != identity: + return + os.unlink(name, dir_fd=parent_fd) + try: + os.fsync(parent_fd) + except OSError: + pass + except OSError: + pass + + +def _write_exclusive( + path: Path, raw: bytes, *, post_write_check: Callable[[], None] | None = None +) -> None: + output = _absolute_lexical_path(path, "evidence output") + _require(output.name not in ("", ".", ".."), "evidence output name is unsafe") + chain = _open_directory_chain(output.parent, label="evidence output parent") + parent_fd = chain[-1][0] + descriptor = -1 + reader = -1 + created = False + preserve = False + created_identity: tuple[int, int] | None = None + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + _verify_directory_chain(chain, "evidence output parent") + try: + descriptor = os.open(output.name, flags, 0o600, dir_fd=parent_fd) + except FileExistsError as exc: + raise QualificationError("evidence output already exists") from exc + created = True + opened = os.fstat(descriptor) + created_identity = opened.st_dev, opened.st_ino + _require(stat.S_ISREG(opened.st_mode), "evidence output is unsafe") + os.fchmod(descriptor, 0o600) + offset = 0 + while offset < len(raw): + written = os.write(descriptor, raw[offset:]) + if written <= 0: + raise OSError("short evidence write") + offset += written + os.fsync(descriptor) + after = os.fstat(descriptor) + _require( + stat.S_ISREG(after.st_mode) + and stat.S_IMODE(after.st_mode) == 0o600 + and after.st_nlink == 1 + and after.st_size == len(raw) + and (after.st_dev, after.st_ino) == created_identity, + "evidence output is unsafe", + ) + # Pin this inode through validation and rollback. Otherwise unlinking + # the output can let a replacement reuse its device/inode identity. + _verify_directory_chain(chain, "evidence output parent") + read_flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + read_flags |= os.O_CLOEXEC + reader = os.open(output.name, read_flags, dir_fd=parent_fd) + written, written_identity = _read_open_regular( + reader, + label="evidence output", + maximum=_MAX_JSON_BYTES, + private=True, + ) + _require(written == raw and (written_identity[0], written_identity[1]) == created_identity, "evidence write verification failed") + os.close(reader) + reader = -1 + if post_write_check is not None: + post_write_check() + _verify_directory_chain(chain, "evidence output parent") + reader = os.open(output.name, read_flags, dir_fd=parent_fd) + final_raw, final_identity = _read_open_regular( + reader, + label="evidence output", + maximum=_MAX_JSON_BYTES, + private=True, + ) + _require( + final_raw == raw + and (final_identity[0], final_identity[1]) == created_identity, + "evidence output changed before publication completed", + ) + os.close(reader) + reader = -1 + _verify_directory_chain(chain, "evidence output parent") + preserve = True + try: + os.fsync(parent_fd) + except OSError: + pass + except QualificationError: + raise + except OSError as exc: + raise QualificationError("evidence output could not be created safely") from exc + finally: + if reader >= 0: + os.close(reader) + if created and not preserve: + _remove_created(parent_fd, output.name, created_identity) + if descriptor >= 0: + os.close(descriptor) + _close_directory_chain(chain) + + +def _same_file_snapshot(path: Path, prior, *, label: str, maximum: int, private: bool) -> None: + current = _file_snapshot(path, label=label, maximum=maximum, private=private) + _require(current == prior, "%s changed before publication completed" % label) + + +def _same_candidate(candidate_dir: Path, profile_id: str, prior) -> None: + current = _candidate_snapshot(candidate_dir, profile_id) + for key in ( + "candidate", + "candidate_identity", + "release_identity", + "release_sha256", + "artifact", + "artifact_identity", + "install_sha256", + "source_commit", + ): + _require(current[key] == prior[key], "candidate changed before publication completed") + + +def _same_qualification(root: Path, prior) -> None: + current = _qualification_snapshot(root) + for key in ( + "root", + "root_identity", + "commit", + "sources", + "executable", + "executable_identity", + "executable_sha256", + ): + _require(current[key] == prior[key], "qualification checkout changed before publication completed") + + +def _validation_from(candidate, qualification): + profile_id = candidate["profile_id"] if "profile_id" in candidate else None + _require(profile_id in PROFILE_TARGETS, "candidate profile is unsupported") + return { + "expected_profile_id": profile_id, + "expected_target": PROFILE_TARGETS[profile_id], + "expected_version": _VERSION, + "expected_source_commit": candidate["source_commit"], + "candidate_release_json_sha256": candidate["release_sha256"], + "expected_install_sha256": candidate["install_sha256"], + "expected_qualification_source_commit": qualification["commit"], + "expected_qualification_executable_sha256": qualification["executable_sha256"], + } + + +def create_workspace_receipt( + candidate_dir, + profile_id, + observation_kind, + raw_boot_log, + output_path, + qualification_repo_root, +): + """Create one exclusive candidate-bound workspace observation receipt.""" + + _require(observation_kind in WORKSPACE_PROVISIONING_ORDER, "workspace observation kind is unsupported") + output = _absolute_lexical_path(Path(output_path), "workspace receipt output") + raw_path = _absolute_lexical_path(Path(raw_boot_log), "workspace raw boot log") + _require(output.suffix == ".json", "workspace receipt output must end in .json") + _require(raw_path == _workspace_raw_path(output), "workspace raw boot log name must be derived from its receipt") + candidate = _candidate_snapshot(Path(candidate_dir), profile_id) + candidate["profile_id"] = profile_id + qualification = _qualification_snapshot(Path(qualification_repo_root)) + _require(not _inside(output, candidate["candidate"]), "workspace receipt output must be outside the candidate") + _require(not _inside(raw_path, candidate["candidate"]), "workspace raw log must be outside the candidate") + _require( + not _inside(output, qualification["root"]), + "workspace receipt output must be outside the qualification checkout", + ) + _require( + not _inside(raw_path, qualification["root"]), + "workspace raw log must be outside the qualification checkout", + ) + raw_snapshot = _file_snapshot( + raw_path, + label="workspace raw boot log", + maximum=_MAX_LOG_BYTES, + private=True, + ) + _validate_exact_json_lines( + raw_snapshot[0], + _expected_workspace_values(observation_kind), + "workspace raw boot log", + ) + validation = _validation_from(candidate, qualification) + acquisition_digest, acquisition_snapshots = _workspace_acquisition_snapshot( + output, observation_kind, validation + ) + value = { + "schema_version": 1, + "measurement_contract": _RECEIPT_CONTRACT, + "observation_kind": observation_kind, + "profile_id": profile_id, + "target": PROFILE_TARGETS[profile_id], + "firmware_version": _VERSION, + "source_commit": candidate["source_commit"], + "candidate_release_json_sha256": candidate["release_sha256"], + "install_sha256": candidate["install_sha256"], + "qualification_source_commit": qualification["commit"], + "qualification_executable_sha256": qualification["executable_sha256"], + "acquisition_sha256": acquisition_digest, + "raw_log_sha256": raw_snapshot[2], + "status": "passed", + } + validate_workspace_receipt_payload( + value, observation_kind=observation_kind, **validation + ) + encoded = canonical_json_bytes(value) + + def unchanged() -> None: + _same_candidate(Path(candidate_dir), profile_id, candidate) + _same_qualification(Path(qualification_repo_root), qualification) + _same_file_snapshot( + raw_path, + raw_snapshot, + label="workspace raw boot log", + maximum=_MAX_LOG_BYTES, + private=True, + ) + for evidence_path, prior in acquisition_snapshots: + _same_file_snapshot(evidence_path, prior, label="workspace acquisition evidence", + maximum=_workspace_evidence_maximum(evidence_path), private=True) + + _write_exclusive(output, encoded, post_write_check=unchanged) + return Path(output_path) + + +def _require_private_evidence_outside( + path: Path, *, label: str, candidate: dict[str, Any], qualification: dict[str, Any] +) -> None: + _require( + not _inside(path, candidate["candidate"]), + "%s must be outside the candidate" % label, + ) + _require( + not _inside(path, qualification["root"]), + "%s must be outside the qualification checkout" % label, + ) + + +def preflight_result_inputs( + candidate_dir, + profile_id, + workspace_erased_receipt, + workspace_nonblank_receipt, + qualification_repo_root, +): + """Validate and lease every long-lived input before physical BLE work.""" + + candidate_path = Path(candidate_dir) + qualification_root = Path(qualification_repo_root) + candidate = _candidate_snapshot(candidate_path, profile_id) + candidate["profile_id"] = profile_id + qualification = _qualification_snapshot(qualification_root) + validation = _validation_from(candidate, qualification) + erased_path = _absolute_lexical_path( + Path(workspace_erased_receipt), "erased workspace receipt" + ) + nonblank_path = _absolute_lexical_path( + Path(workspace_nonblank_receipt), "nonblank workspace receipt" + ) + _require(erased_path != nonblank_path, "workspace receipts must be separate files") + for receipt_path in (erased_path, nonblank_path): + _require_private_evidence_outside( + receipt_path, + label="workspace receipt", + candidate=candidate, + qualification=qualification, + ) + _require_private_evidence_outside( + _workspace_raw_path(receipt_path), + label="workspace raw log", + candidate=candidate, + qualification=qualification, + ) + erased_summary, erased_snapshot = _workspace_receipt_file_snapshot( + erased_path, WORKSPACE_PROVISIONING_ORDER[0], validation + ) + nonblank_summary, nonblank_snapshot = _workspace_receipt_file_snapshot( + nonblank_path, WORKSPACE_PROVISIONING_ORDER[1], validation + ) + _validate_workspace_acquisition_pair(erased_snapshot, nonblank_snapshot) + return _ResultInputPreflight( + candidate_dir=candidate_path, + profile_id=profile_id, + candidate=candidate, + qualification_root=qualification_root, + qualification=qualification, + validation=validation, + erased_path=erased_path, + erased_summary=erased_summary, + erased_snapshot=erased_snapshot, + nonblank_path=nonblank_path, + nonblank_summary=nonblank_summary, + nonblank_snapshot=nonblank_snapshot, + ) + + +def revalidate_result_inputs(preflight): + """Reopen the exact leased inputs; a caller cannot substitute a new set.""" + + _require( + type(preflight) is _ResultInputPreflight, + "hardening result preflight token is invalid", + ) + _same_candidate( + preflight.candidate_dir, + preflight.profile_id, + preflight.candidate, + ) + _same_qualification( + preflight.qualification_root, + preflight.qualification, + ) + for receipt_path, prior, kind, wanted_summary in ( + ( + preflight.erased_path, + preflight.erased_snapshot, + WORKSPACE_PROVISIONING_ORDER[0], + preflight.erased_summary, + ), + ( + preflight.nonblank_path, + preflight.nonblank_snapshot, + WORKSPACE_PROVISIONING_ORDER[1], + preflight.nonblank_summary, + ), + ): + for evidence_path, evidence_snapshot in prior: + _same_file_snapshot( + evidence_path, + evidence_snapshot, + label="workspace evidence", + maximum=_workspace_evidence_maximum(evidence_path), + private=True, + ) + summary, current = _workspace_receipt_file_snapshot( + receipt_path, kind, preflight.validation + ) + _require( + summary == wanted_summary and current == prior, + "workspace evidence changed before publication completed", + ) + return preflight + + +def create_result_from_preflight( + preflight, + scenario_results, + raw_log, + output_path, +): + """Publish one result from the exact pre-BLE input lease.""" + + revalidate_result_inputs(preflight) + output = _absolute_lexical_path(Path(output_path), "hardening result output") + raw_path = _absolute_lexical_path(Path(raw_log), "hardening raw log") + _require(output.suffix == ".json", "hardening result output must end in .json") + _require(raw_path.suffix == ".jsonl", "hardening raw log must end in .jsonl") + _require(output != raw_path, "hardening result and raw log must differ") + _require(output.parent == preflight.erased_path.parent == preflight.nonblank_path.parent, + "hardening result and workspace receipts must share one private directory") + for path, label in ((output, "hardening result"), (raw_path, "hardening raw log")): + _require_private_evidence_outside( + path, + label=label, + candidate=preflight.candidate, + qualification=preflight.qualification, + ) + raw_snapshot = _file_snapshot( + raw_path, + label="hardening raw log", + maximum=_MAX_LOG_BYTES, + private=True, + ) + _validate_exact_json_lines( + raw_snapshot[0], _expected_scenario_log(), "hardening raw log" + ) + value = { + "schema_version": 1, + "measurement_contract": _RESULT_CONTRACT, + "profile_id": preflight.profile_id, + "target": PROFILE_TARGETS[preflight.profile_id], + "firmware_version": _VERSION, + "source_commit": preflight.candidate["source_commit"], + "candidate_release_json_sha256": preflight.candidate["release_sha256"], + "install_sha256": preflight.candidate["install_sha256"], + "qualification_source_commit": preflight.qualification["commit"], + "qualification_executable_sha256": preflight.qualification[ + "executable_sha256" + ], + "scenario_order": list(SCENARIO_ORDER), + "scenarios": scenario_results, + "workspace_provisioning": { + kind: { + "status": "passed", "receipt_file": path.name, + "receipt_sha256": summary["receipt_sha256"], + "acquisition_sha256": summary["acquisition_sha256"], + "raw_log_sha256": summary["raw_log_sha256"], + } + for kind, path, summary in ( + (WORKSPACE_PROVISIONING_ORDER[0], preflight.erased_path, preflight.erased_summary), + (WORKSPACE_PROVISIONING_ORDER[1], preflight.nonblank_path, preflight.nonblank_summary), + ) + }, + "raw_log_sha256": raw_snapshot[2], + "status": "passed", + } + validate_result_payload(value, **preflight.validation) + encoded = canonical_json_bytes(value) + + def unchanged() -> None: + revalidate_result_inputs(preflight) + _same_file_snapshot( + raw_path, + raw_snapshot, + label="hardening raw log", + maximum=_MAX_LOG_BYTES, + private=True, + ) + + _write_exclusive(output, encoded, post_write_check=unchanged) + return Path(output_path) + + +def create_result( + candidate_dir, + profile_id, + scenario_results, + workspace_erased_receipt, + workspace_nonblank_receipt, + raw_log, + output_path, + qualification_repo_root, +): + """Create the exclusive private seven-scenario hardening result.""" + + preflight = preflight_result_inputs( + candidate_dir=candidate_dir, + profile_id=profile_id, + workspace_erased_receipt=workspace_erased_receipt, + workspace_nonblank_receipt=workspace_nonblank_receipt, + qualification_repo_root=qualification_repo_root, + ) + return create_result_from_preflight( + preflight=preflight, + scenario_results=scenario_results, + raw_log=raw_log, + output_path=output_path, + ) + + +__all__ = ( + "QualificationError", + "PROFILE_TARGETS", + "SCENARIO_ORDER", + "WORKSPACE_PROVISIONING_ORDER", + "RESULT_KEYS", + "PUBLIC_SUMMARY_KEYS", + "WORKSPACE_RECEIPT_KEYS", + "canonical_json_bytes", + "validate_workspace_receipt_payload", + "validate_workspace_receipt_file", + "validate_result_payload", + "validate_result_file", + "create_workspace_receipt", + "preflight_result_inputs", + "revalidate_result_inputs", + "create_result_from_preflight", + "create_result", +) diff --git a/firmware/release-tools.lock b/firmware/release-tools.lock index b2aca4c5..02c6c680 100644 --- a/firmware/release-tools.lock +++ b/firmware/release-tools.lock @@ -16,7 +16,9 @@ excluded_cves_sha256 = "77c02542775c018c5f32c0dcb6e88050adb0e69dbcd95e84ad7be128 license_policy_path = "firmware/licenses/license-policy.json" license_policy_sha256 = "8ca42bd5e0f79b8204961dab2d737b8eb8ba1eea4e14c9420df74ccd0c0a9d78" rp2_license_policy_path = "firmware/licenses/rp2-license-policy.json" -rp2_license_policy_sha256 = "83f1b1ffbcb7f21c9d98b3529f79ad5497355ab675b37adad37f62a82e6a0e38" +rp2_license_policy_sha256 = "8578456d8684e210f5db94c8152e42d7de4fab6c43b75a0f82ca390de2d1d718" +rp2_build_tools_license_policy_path = "firmware/licenses/rp2-build-tools-license-policy.json" +rp2_build_tools_license_policy_sha256 = "d0b5920297c78280d509b93cc0ab356839e8b2adfacaab42f352150390b645e9" [[artifacts]] name = "esp-idf-sbom" diff --git a/firmware/scripts/build_rp2.sh b/firmware/scripts/build_rp2.sh index 46b21d6b..e8b32d0c 100755 --- a/firmware/scripts/build_rp2.sh +++ b/firmware/scripts/build_rp2.sh @@ -41,6 +41,7 @@ REPO_ROOT="$(cd "$FW/.." && pwd -P)" LOCK="${PYBLE_LOCK_FILE:-$FW/versions.lock}" CANONICAL_UPSTREAM="${PYBLE_CANONICAL_UPSTREAM_DIR:-$FW/upstream/micropython}" TOOLCHAIN_DIR="${PYBLE_ARM_TOOLCHAIN_DIR:-$FW/.arm-gnu}" +PICOTOOL_DIR="${PYBLE_PICOTOOL_DIR:-$FW/.picotool}" BUILD_ROOT="${PYBLE_BUILD_ROOT:-$FW/build}" SHA_DRIFT="$REPO_ROOT/tools/ci/sha_drift.sh" PREPARE="$HERE/prepare.sh" @@ -243,6 +244,249 @@ assert_regular_file() { fi } +# Verify the retained official picotool ZIP and every extracted member before +# any compiler/build command runs. The build-side replay requires the same +# types, exact modes, and bytes admitted by the installer; links, injected +# paths, missing package files, or changed content fail closed. +verify_picotool_tree() { + python3 - "$LOCK" "$PICOTOOL_DIR" <<'PY' +from __future__ import annotations + +import hashlib +import os +from pathlib import Path, PurePosixPath +import re +import stat +import sys +import tomllib +import zipfile + + +lock_path = Path(sys.argv[1]) +root = Path(sys.argv[2]) + + +def reject(message: str) -> "NoReturn": + raise SystemExit("build_rp2.sh: " + message) + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def regular(path: Path) -> bool: + try: + mode = path.lstat().st_mode + except FileNotFoundError: + return False + return stat.S_ISREG(mode) and not path.is_symlink() + + +def safe_relative(value: object, label: str, *, basename: bool = False) -> str: + if not isinstance(value, str) or not value: + reject(f"[picotool] {label} is missing") + path = PurePosixPath(value) + if ( + path.is_absolute() + or value.endswith("/") + or any(part in ("", ".", "..") for part in path.parts) + or (basename and len(path.parts) != 1) + ): + reject(f"[picotool] {label} is unsafe") + return value + + +try: + document = tomllib.loads(lock_path.read_text(encoding="utf-8")) +except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + reject(f"cannot read versions.lock: {error}") +pin = document.get("picotool") +if not isinstance(pin, dict): + reject("versions.lock has no [picotool] pin") + +required_keys = { + "version", + "version_line", + "source_repo", + "source_ref", + "source_commit", + "distribution_repo", + "distribution_ref", + "distribution_commit", + "url", + "archive_filename", + "archive_bytes", + "archive_format", + "sha256", + "cmake_dir", + "executable_path", + "executable_sha256", + "cmake_config_path", + "cmake_config_sha256", + "bundled_libusb_path", + "bundled_libusb_sha256", +} +if set(pin) != required_keys: + reject("[picotool] fields differ from the reviewed pin schema") +if pin.get("archive_format") != "zip": + reject("[picotool] archive_format must be zip") +if not isinstance(pin.get("archive_bytes"), int) or isinstance( + pin.get("archive_bytes"), bool +) or pin["archive_bytes"] <= 0: + reject("[picotool] archive_bytes is invalid") +for key in ( + "sha256", + "executable_sha256", + "cmake_config_sha256", + "bundled_libusb_sha256", +): + if not isinstance(pin.get(key), str) or re.fullmatch( + r"[0-9a-f]{64}", pin[key] + ) is None: + reject(f"[picotool] {key} is invalid") + +archive_name = safe_relative(pin.get("archive_filename"), "archive_filename", basename=True) +cmake_dir = safe_relative(pin.get("cmake_dir"), "cmake_dir") +executable_path = safe_relative(pin.get("executable_path"), "executable_path") +config_path = safe_relative(pin.get("cmake_config_path"), "cmake_config_path") +libusb_path = safe_relative(pin.get("bundled_libusb_path"), "bundled_libusb_path") +if cmake_dir != "picotool": + reject("[picotool] cmake_dir differs from the reviewed package root") +for value, label in ( + (executable_path, "executable_path"), + (config_path, "cmake_config_path"), + (libusb_path, "bundled_libusb_path"), +): + if PurePosixPath(value).parts[0] != cmake_dir: + reject(f"[picotool] {label} escapes cmake_dir") + +try: + root_mode = root.lstat().st_mode +except OSError as error: + reject(f"pinned picotool tree is unavailable: {error}") +if not stat.S_ISDIR(root_mode) or stat.S_ISLNK(root_mode): + reject("pinned picotool root is symlinked or not a directory") + +retained = root / ".pyble-dist" / archive_name +if not regular(retained): + reject("retained picotool archive is missing or not regular") +if retained.stat().st_size != pin["archive_bytes"]: + reject("retained picotool archive size differs from versions.lock") +if digest(retained) != pin["sha256"]: + reject("retained picotool archive digest differs from versions.lock") + +expected: dict[str, tuple[str, int, bytes | None]] = { + ".pyble-dist": ("dir", 0o755, None), + f".pyble-dist/{archive_name}": ( + "file", + 0o644, + retained.read_bytes(), + ), +} +seen: set[str] = set() +required_members = { + ".keep", + "picotool/", + executable_path, + config_path, + "picotool/picotoolConfigVersion.cmake", + "picotool/picotoolTargets.cmake", + "picotool/picotoolTargets-release.cmake", + libusb_path, +} +try: + with zipfile.ZipFile(retained, "r") as archive: + for info in archive.infolist(): + name = info.filename + if name in seen: + reject(f"retained picotool ZIP has duplicate member {name}") + seen.add(name) + if info.flag_bits & 0x1: + reject(f"retained picotool ZIP has encrypted member {name}") + path = PurePosixPath(name) + if ( + path.is_absolute() + or not path.parts + or any(part in ("", ".", "..") for part in path.parts) + or not (name == ".keep" or path.parts[0] == "picotool") + ): + reject(f"retained picotool ZIP has unsafe member {name}") + unix_mode = info.external_attr >> 16 + kind = stat.S_IFMT(unix_mode) + relative = name.rstrip("/") + if info.is_dir(): + if kind not in (0, stat.S_IFDIR) or not name.endswith("/"): + reject(f"retained picotool ZIP directory is invalid: {name}") + expected[relative] = ( + "dir", + stat.S_IMODE(unix_mode) or 0o755, + None, + ) + else: + if name.endswith("/") or kind not in (0, stat.S_IFREG): + reject(f"retained picotool ZIP type is unsupported: {name}") + expected[relative] = ( + "file", + stat.S_IMODE(unix_mode) or 0o644, + archive.read(info), + ) + bad = archive.testzip() + if bad is not None: + reject(f"retained picotool ZIP CRC failed: {bad}") +except (OSError, zipfile.BadZipFile, RuntimeError) as error: + reject(f"retained picotool ZIP is invalid: {error}") +missing_archive_members = required_members - seen +if missing_archive_members: + reject( + "retained picotool ZIP lacks required members: " + + ", ".join(sorted(missing_archive_members)) + ) + +observed: set[str] = set() +for current, directories, files in os.walk(root, topdown=True, followlinks=False): + current_path = Path(current) + for name in [*directories, *files]: + child = current_path / name + relative = child.relative_to(root).as_posix() + observed.add(relative) + wanted = expected.get(relative) + if wanted is None: + reject(f"pinned picotool tree has unexpected member {relative}") + mode = child.lstat().st_mode + wanted_kind, wanted_mode, wanted_bytes = wanted + if wanted_kind == "dir": + if not stat.S_ISDIR(mode) or stat.S_ISLNK(mode): + reject(f"pinned picotool directory type changed: {relative}") + else: + if not stat.S_ISREG(mode) or stat.S_ISLNK(mode): + reject(f"pinned picotool file type changed: {relative}") + if child.read_bytes() != wanted_bytes: + reject(f"pinned picotool member bytes changed: {relative}") + if stat.S_IMODE(mode) != wanted_mode: + reject(f"pinned picotool member mode changed: {relative}") +if observed != set(expected): + reject( + "pinned picotool tree lacks members: " + + ", ".join(sorted(set(expected) - observed)) + ) + +for relative, wanted, label in ( + (executable_path, pin["executable_sha256"], "executable"), + (config_path, pin["cmake_config_sha256"], "CMake config"), + (libusb_path, pin["bundled_libusb_sha256"], "bundled libusb"), +): + path = root / relative + if not regular(path) or digest(path) != wanted: + reject(f"pinned picotool {label} identity differs from versions.lock") +if not os.access(root / executable_path, os.X_OK): + reject("pinned picotool executable lacks owner execute permission") +PY +} + assert_build_subdirectory() { path="$1" label="$2" @@ -411,6 +655,46 @@ case "$GOT_GCC" in esac echo "build_rp2.sh: pinned ARM toolchain OK — $GOT_GCC" +# The build consumes only the official, retained picotool package selected by +# versions.lock [picotool]. Verify its archive/member closure before creating a +# retained MicroPython source tree or invoking any make/CMake process. +if [ -L "$PICOTOOL_DIR" ] || [ ! -d "$PICOTOOL_DIR" ]; then + echo "build_rp2.sh: pinned picotool is not installed at $PICOTOOL_DIR" >&2 + echo " run: firmware/scripts/install_picotool.sh" >&2 + exit 1 +fi +if ! PICOTOOL_DIR="$(cd "$PICOTOOL_DIR" 2>/dev/null && pwd -P)"; then + echo "build_rp2.sh: cannot resolve the pinned picotool tree" >&2 + exit 1 +fi +PYBLE_PICOTOOL_DIR="$PICOTOOL_DIR" +export PYBLE_PICOTOOL_DIR +PICOTOOL_CMAKE_DIR="$(lock_field picotool cmake_dir)" +PICOTOOL_EXECUTABLE_PATH="$(lock_field picotool executable_path)" +PICOTOOL_VERSION_LINE="$(lock_field "picotool" "version_line")" +if [ -z "$PICOTOOL_CMAKE_DIR" ] || [ -z "$PICOTOOL_EXECUTABLE_PATH" ] \ + || [ -z "$PICOTOOL_VERSION_LINE" ]; then + echo "build_rp2.sh: versions.lock [picotool] lacks cmake_dir, executable_path, or version_line" >&2 + exit 1 +fi +verify_picotool_tree || { + echo "build_rp2.sh: retained picotool distribution failed verification" >&2 + exit 1 +} +PICOTOOL_EXECUTABLE="$PICOTOOL_DIR/$PICOTOOL_EXECUTABLE_PATH" +PICOTOOL_PACKAGE_DIR="$PICOTOOL_DIR/$PICOTOOL_CMAKE_DIR" +if ! PICOTOOL_VER="$("$PICOTOOL_EXECUTABLE" version 2>/dev/null)"; then + echo "build_rp2.sh: pinned tool identity probe failed" >&2 + exit 1 +fi +if [ "$PICOTOOL_VER" != "$PICOTOOL_VERSION_LINE" ]; then + echo "build_rp2.sh: pinned tool identity differs from versions.lock" >&2 + echo " pinned : $PICOTOOL_VERSION_LINE" >&2 + echo " found : ${PICOTOOL_VER:-none}" >&2 + exit 1 +fi +echo "build_rp2.sh: pinned picotool OK — $PICOTOOL_VER" + # A missing or mismatched pinned compiler is the first actionable build # prerequisite. Validate the complete nested source graph only after that # prerequisite passes, so a fresh checkout cannot mask the install guidance @@ -567,7 +851,38 @@ unset \ CMAKE_CXX_COMPILER_LAUNCHER \ CMAKE_ASM_COMPILER_LAUNCHER \ RULE_LAUNCH_COMPILE \ - RULE_LAUNCH_LINK + RULE_LAUNCH_LINK \ + CMAKE_ARGS \ + picotool_DIR \ + FETCHCONTENT_FULLY_DISCONNECTED \ + FETCHCONTENT_SOURCE_DIR_PICOTOOL \ + PICOTOOL_FETCH_FROM_GIT_PATH \ + PICOTOOL_FORCE_FETCH_FROM_GIT \ + CMAKE_FIND_USE_PACKAGE_REGISTRY \ + CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY \ + CMAKE_PREFIX_PATH + +# Bind Pico SDK's CMake configure to the verified package and make every +# fallback/network or ambient package-registry path unavailable. These values +# are supplied both in CMAKE_ARGS (the MicroPython rp2 Makefile interface) and +# the environment so nested configure steps cannot inherit hostile settings. +CMAKE_ARGS="-Dpicotool_DIR=$PICOTOOL_PACKAGE_DIR \ +-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON \ +-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ +-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ +-DPICOTOOL_FORCE_FETCH_FROM_GIT=OFF" +picotool_DIR="$PICOTOOL_PACKAGE_DIR" +FETCHCONTENT_FULLY_DISCONNECTED=ON +PICOTOOL_FORCE_FETCH_FROM_GIT=OFF +CMAKE_FIND_USE_PACKAGE_REGISTRY=OFF +CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF +export \ + CMAKE_ARGS \ + picotool_DIR \ + FETCHCONTENT_FULLY_DISCONNECTED \ + PICOTOOL_FORCE_FETCH_FROM_GIT \ + CMAKE_FIND_USE_PACKAGE_REGISTRY \ + CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY make -C "$RETAINED_UPSTREAM/mpy-cross" BUILD=build || { echo "build_rp2.sh: mpy-cross build failed" >&2 @@ -618,7 +933,12 @@ CMAKE_CACHE="$OUT/CMakeCache.txt" for expected_cache_line in \ "CMAKE_HOME_DIRECTORY:INTERNAL=$PORT_DIR" \ "MICROPY_BOARD_DIR:UNINITIALIZED=$PORT_DIR/boards/$BOARD" \ - "PICO_SDK_PATH:PATH=$RETAINED_UPSTREAM/lib/pico-sdk" + "PICO_SDK_PATH:PATH=$RETAINED_UPSTREAM/lib/pico-sdk" \ + "picotool_DIR:UNINITIALIZED=$PICOTOOL_PACKAGE_DIR" \ + "FETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON" \ + "PICOTOOL_FORCE_FETCH_FROM_GIT:UNINITIALIZED=OFF" \ + "CMAKE_FIND_USE_PACKAGE_REGISTRY:UNINITIALIZED=OFF" \ + "CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY:UNINITIALIZED=OFF" do grep -Fx "$expected_cache_line" "$CMAKE_CACHE" >/dev/null || { echo "build_rp2.sh: CMake cache does not bind retained source: $expected_cache_line" >&2 @@ -674,11 +994,20 @@ assert_source_graph "$RETAINED_UPSTREAM" retained-final || exit 1 # and mpy-cross output. Any tracked mutation still fails via assert_source_graph; # nested checkouts remain exactly clean and independently attributable. -PICOTOOL_VER="$(picotool version 2>/dev/null | head -n 1 || true)" -case "$PICOTOOL_VER" in - "picotool v"[0-9]*.[0-9]*.[0-9]*) : ;; - *) echo "build_rp2.sh: picotool version identity is missing or invalid" >&2; exit 1 ;; -esac +# Replay the complete retained-tool identity after compilation so provenance +# cannot admit a build whose picotool tree changed mid-run. +verify_picotool_tree || { + echo "build_rp2.sh: retained picotool distribution changed during build" >&2 + exit 1 +} +if ! PICOTOOL_VER="$("$PICOTOOL_EXECUTABLE" version 2>/dev/null)"; then + echo "build_rp2.sh: pinned tool identity probe failed after build" >&2 + exit 1 +fi +if [ "$PICOTOOL_VER" != "$PICOTOOL_VERSION_LINE" ]; then + echo "build_rp2.sh: pinned tool identity changed during build" >&2 + exit 1 +fi PROVENANCE="$OUT/pyble-build-provenance.json" PROVENANCE_TMP="$OUT/.pyble-build-provenance.json.$$" diff --git a/firmware/scripts/install_picotool.sh b/firmware/scripts/install_picotool.sh new file mode 100755 index 00000000..d13e1f62 --- /dev/null +++ b/firmware/scripts/install_picotool.sh @@ -0,0 +1,898 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# Install and retain the exact official picotool distribution pinned by the +# [picotool] section of firmware/versions.lock. Admission is atomic: the ZIP, +# every extracted member, required component hashes, executable mode, and the +# exact runtime version line are verified in a fresh sibling before rename. +# Existing installs are accepted only after complete byte-for-byte +# revalidation and are otherwise preserved for operator inspection. +# +# install_picotool.sh [DEST] (default: firmware/.picotool) +# +# PYBLE_PICOTOOL_ARCHIVE=<path> use a local ZIP without downloading it +# (the same pin checks still apply) +# PYBLE_LOCK_FILE=<path> override versions.lock for test/provisioning + +set -eu + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd -P)" +FW="$(cd "$HERE/.." && pwd -P)" +LOCK="${PYBLE_LOCK_FILE:-$FW/versions.lock}" +DEST="${1:-${PYBLE_PICOTOOL_DIR:-$FW/.picotool}}" +ARCHIVE_OVERRIDE="${PYBLE_PICOTOOL_ARCHIVE:-}" + +# The Python standard library supplies strict TOML and ZIP readers on every +# supported build host. Keeping extraction here avoids platform-specific +# `unzip` path/link behavior while the shell remains the public entry point. +python3 - "$LOCK" "$DEST" "$ARCHIVE_OVERRIDE" "$FW/.picotool" <<'PY' +from __future__ import annotations + +import ctypes +import errno +import hashlib +import os +from pathlib import Path, PurePosixPath +import re +import secrets +import shutil +import stat +import subprocess +import sys +import time +import tomllib +import urllib.parse +import urllib.request +import zipfile + + +lock_path = Path(sys.argv[1]) +destination = Path(sys.argv[2]) +archive_override = sys.argv[3] +default_destination = sys.argv[4] + + +def fail(message: str) -> "NoReturn": + raise SystemExit(f"install_picotool: {message}") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_relative(value: object, label: str, *, basename: bool = False) -> str: + if not isinstance(value, str) or not value: + fail(f"[picotool] {label} must be a nonempty string") + path = PurePosixPath(value) + if ( + path.is_absolute() + or value.endswith("/") + or any(part in ("", ".", "..") for part in path.parts) + or (basename and len(path.parts) != 1) + ): + fail(f"[picotool] {label} is unsafe: {value!r}") + return value + + +def regular_file(path: Path) -> bool: + try: + mode = path.lstat().st_mode + except FileNotFoundError: + return False + return stat.S_ISREG(mode) and not path.is_symlink() + + +def reject_symlinked_existing_ancestors(path: Path, label: str) -> None: + probe = path.absolute() + while True: + try: + mode = probe.lstat().st_mode + except FileNotFoundError: + pass + else: + # Darwin exposes long-standing root aliases such as /var -> + # /private/var and /tmp -> /private/tmp. Those privileged, + # root-owned aliases are part of the platform path layout; a + # symlink at any deeper caller-controlled component is not. + if stat.S_ISLNK(mode) and probe.parent != Path(probe.anchor): + fail(f"{label} has a symlinked ancestor: {probe}") + if probe.parent == probe: + break + probe = probe.parent + + +def absolute_lexical_path(path: Path, label: str) -> Path: + """Normalize a path without following caller-controlled symlinks.""" + + if os.name != "posix" or not hasattr(os, "O_DIRECTORY") or not hasattr( + os, "O_NOFOLLOW" + ): + fail(f"{label} requires reviewed POSIX dirfd support") + try: + raw = os.fspath(path) + if not isinstance(raw, str) or not raw: + fail(f"{label} path is invalid") + if any(component == ".." for component in raw.split(os.sep)): + fail(f"{label} contains lexical navigation") + value = Path(os.path.abspath(raw)) + # Darwin's system-owned /var alias is part of the platform layout and + # is commonly returned by tempfile. Normalize only that fixed alias; + # every caller-controlled component remains subject to O_NOFOLLOW. + if value.parts[:2] == (os.sep, "var"): + try: + if os.readlink("/var") == "private/var": + value = Path("/private/var").joinpath(*value.parts[2:]) + except OSError: + pass + return value + except (OSError, TypeError, ValueError) as error: + fail(f"{label} path is invalid: {error}") + + +def directory_identity(value: os.stat_result) -> tuple[int, int, int]: + return value.st_dev, value.st_ino, value.st_mode + + +def close_directory_chain( + chain: list[tuple[int, Path, tuple[int, int, int]]], +) -> None: + for descriptor, _path, _identity in reversed(chain): + try: + os.close(descriptor) + except OSError: + pass + + +def verify_directory_chain( + chain: list[tuple[int, Path, tuple[int, int, int]]], + label: str, +) -> None: + for descriptor, path, expected in chain: + try: + held = os.fstat(descriptor) + visible = path.lstat() + except OSError as error: + fail(f"{label} identity changed: {error}") + if not ( + stat.S_ISDIR(held.st_mode) + and stat.S_ISDIR(visible.st_mode) + and not stat.S_ISLNK(visible.st_mode) + and directory_identity(held) == expected + and directory_identity(visible) == expected + ): + fail(f"{label} identity changed") + + +def open_directory_chain( + path: Path, + label: str, +) -> list[tuple[int, Path, tuple[int, int, int]]]: + """Hold every existing directory component through no-follow dirfds.""" + + directory = absolute_lexical_path(path, label) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + chain: list[tuple[int, Path, tuple[int, int, int]]] = [] + try: + descriptor = os.open(os.sep, flags) + opened = os.fstat(descriptor) + chain.append((descriptor, Path(os.sep), directory_identity(opened))) + current = Path(os.sep) + for component in directory.parts[1:]: + descriptor = os.open(component, flags, dir_fd=chain[-1][0]) + opened = os.fstat(descriptor) + current = current / component + if not stat.S_ISDIR(opened.st_mode): + fail(f"{label} has a non-directory component: {current}") + chain.append((descriptor, current, directory_identity(opened))) + verify_directory_chain(chain, label) + return chain + except SystemExit: + close_directory_chain(chain) + raise + except OSError as error: + close_directory_chain(chain) + fail(f"{label} is unsafe: {error}") + + +def new_sibling_name( + parent_descriptor: int, + prefix: str, + suffix: str, + *, + directory: bool, +) -> tuple[str, tuple[int, int]]: + """Create an unpredictable sibling through the held parent descriptor.""" + + for _attempt in range(128): + name = f"{prefix}{secrets.token_hex(8)}{suffix}" + try: + if directory: + os.mkdir(name, 0o700, dir_fd=parent_descriptor) + else: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + descriptor = os.open( + name, + flags, + 0o600, + dir_fd=parent_descriptor, + ) + try: + os.fchmod(descriptor, 0o600) + finally: + os.close(descriptor) + except FileExistsError: + continue + opened = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) + expected_kind = stat.S_ISDIR if directory else stat.S_ISREG + if not expected_kind(opened.st_mode): + fail("new staging member has an unsafe type") + return name, (opened.st_dev, opened.st_ino) + fail("could not allocate a unique staging member") + + +def remove_owned_file( + parent_descriptor: int, + name: str | None, + identity: tuple[int, int] | None, +) -> None: + if name is None or identity is None: + return + try: + visible = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) + if ( + not stat.S_ISREG(visible.st_mode) + or (visible.st_dev, visible.st_ino) != identity + ): + return + os.unlink(name, dir_fd=parent_descriptor) + except OSError: + pass + + +def remove_owned_tree( + parent_descriptor: int, + name: str | None, + identity: tuple[int, int] | None, +) -> None: + if name is None or identity is None: + return + try: + visible = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) + if ( + not stat.S_ISDIR(visible.st_mode) + or stat.S_ISLNK(visible.st_mode) + or (visible.st_dev, visible.st_ino) != identity + ): + return + if not shutil.rmtree.avoids_symlink_attacks: + return + shutil.rmtree(name, dir_fd=parent_descriptor) + except OSError: + pass + + +def atomic_publish_no_replace( + parent_descriptor: int, + source_name: str, + destination_name: str, +) -> None: + """Rename siblings through one held parent while refusing replacement.""" + + libc = ctypes.CDLL(None, use_errno=True) + source = os.fsencode(source_name) + destination_value = os.fsencode(destination_name) + if sys.platform == "darwin": + operation = getattr(libc, "renameatx_np", None) + if operation is None: + fail("cannot prove atomic no-replace publication on Darwin") + operation.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + operation.restype = ctypes.c_int + arguments = ( + parent_descriptor, + source, + parent_descriptor, + destination_value, + 0x00000004, # RENAME_EXCL + ) + elif sys.platform.startswith("linux"): + operation = getattr(libc, "renameat2", None) + if operation is None: + fail("cannot prove atomic no-replace publication on Linux") + operation.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + operation.restype = ctypes.c_int + arguments = ( + parent_descriptor, + source, + parent_descriptor, + destination_value, + 0x00000001, # RENAME_NOREPLACE + ) + else: + fail(f"no reviewed atomic no-replace operation exists on {sys.platform}") + + ctypes.set_errno(0) + if operation(*arguments) == 0: + return + error_number = ctypes.get_errno() or errno.EIO + if error_number in (errno.EEXIST, errno.ENOTEMPTY, errno.EISDIR, errno.ENOTDIR): + fail("destination appeared during atomic admission") + fail(f"atomic no-replace publication failed: {os.strerror(error_number)}") + + +try: + lock = tomllib.loads(lock_path.read_text(encoding="utf-8")) +except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + fail(f"cannot read lock file {lock_path}: {error}") + +raw_pin = lock.get("picotool") +if not isinstance(raw_pin, dict): + fail(f"could not read [picotool] from {lock_path}") + +required_keys = { + "version", + "version_line", + "source_repo", + "source_ref", + "source_commit", + "distribution_repo", + "distribution_ref", + "distribution_commit", + "url", + "archive_filename", + "archive_bytes", + "archive_format", + "sha256", + "cmake_dir", + "executable_path", + "executable_sha256", + "cmake_config_path", + "cmake_config_sha256", + "bundled_libusb_path", + "bundled_libusb_sha256", +} +if set(raw_pin) != required_keys: + fail("[picotool] fields differ from the reviewed pin schema") + +pin = raw_pin +for key in required_keys - {"archive_bytes"}: + if not isinstance(pin[key], str) or not pin[key]: + fail(f"[picotool] {key} must be a nonempty string") +if not isinstance(pin["archive_bytes"], int) or isinstance( + pin["archive_bytes"], bool +) or pin["archive_bytes"] <= 0: + fail("[picotool] archive_bytes must be a positive integer") +if pin["archive_format"] != "zip": + fail("[picotool] archive_format must be zip") +for key in ( + "source_commit", + "distribution_commit", +): + if re.fullmatch(r"[0-9a-f]{40}", pin[key]) is None: + fail(f"[picotool] {key} is not a full lowercase Git commit") +for key in ( + "sha256", + "executable_sha256", + "cmake_config_sha256", + "bundled_libusb_sha256", +): + if re.fullmatch(r"[0-9a-f]{64}", pin[key]) is None: + fail(f"[picotool] {key} is not a lowercase SHA-256") + +archive_filename = safe_relative( + pin["archive_filename"], "archive_filename", basename=True +) +cmake_dir = safe_relative(pin["cmake_dir"], "cmake_dir") +executable_path = safe_relative(pin["executable_path"], "executable_path") +config_path = safe_relative(pin["cmake_config_path"], "cmake_config_path") +libusb_path = safe_relative( + pin["bundled_libusb_path"], "bundled_libusb_path" +) +url_path = urllib.parse.unquote(urllib.parse.urlsplit(pin["url"]).path) +if PurePosixPath(url_path).name != archive_filename: + fail("archive_filename disagrees with the pinned URL basename") +if cmake_dir != "picotool": + fail("cmake_dir differs from the reviewed picotool package root") +for value, label in ( + (executable_path, "executable_path"), + (config_path, "cmake_config_path"), + (libusb_path, "bundled_libusb_path"), +): + if PurePosixPath(value).parts[0] != cmake_dir: + fail(f"[picotool] {label} escapes cmake_dir") + +required_members = { + ".keep", + "picotool/", + executable_path, + config_path, + "picotool/picotoolConfigVersion.cmake", + "picotool/picotoolTargets.cmake", + "picotool/picotoolTargets-release.cmake", + libusb_path, +} + + +def checked_members(archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, int]]: + members: list[tuple[zipfile.ZipInfo, int]] = [] + seen: set[str] = set() + for info in archive.infolist(): + name = info.filename + if name in seen: + fail(f"duplicate ZIP member: {name}") + seen.add(name) + if info.flag_bits & 0x1: + fail(f"encrypted ZIP member is not supported: {name}") + path = PurePosixPath(name) + if ( + path.is_absolute() + or not path.parts + or any(part in ("", ".", "..") for part in path.parts) + ): + fail(f"unsafe ZIP member path: {name}") + if not (name == ".keep" or path.parts[0] == "picotool"): + fail(f"unexpected top-level ZIP member: {name}") + mode = info.external_attr >> 16 + kind = stat.S_IFMT(mode) + if info.is_dir(): + if kind not in (0, stat.S_IFDIR) or not name.endswith("/"): + fail(f"invalid ZIP directory type: {name}") + mode = stat.S_IFDIR | (stat.S_IMODE(mode) or 0o755) + else: + if name.endswith("/") or kind not in (0, stat.S_IFREG): + fail(f"unsupported ZIP member type: {name}") + mode = stat.S_IFREG | (stat.S_IMODE(mode) or 0o644) + members.append((info, mode)) + missing = required_members - seen + if missing: + fail("required ZIP members are missing: " + ", ".join(sorted(missing))) + executable = next(mode for info, mode in members if info.filename == executable_path) + if not executable & stat.S_IXUSR: + fail("pinned picotool member is not executable") + return members + + +def validate_archive(path: Path) -> list[tuple[zipfile.ZipInfo, int]]: + if not regular_file(path): + fail(f"archive is not a regular non-symlink file: {path}") + size = path.stat().st_size + if size != pin["archive_bytes"]: + fail(f"archive size mismatch: wanted {pin['archive_bytes']}, got {size}") + actual = sha256(path) + if actual != pin["sha256"]: + fail(f"archive SHA-256 mismatch: wanted {pin['sha256']}, got {actual}") + try: + with zipfile.ZipFile(path, "r") as archive: + members = checked_members(archive) + bad = archive.testzip() + if bad is not None: + fail(f"ZIP member failed CRC verification: {bad}") + return members + except (OSError, zipfile.BadZipFile, RuntimeError) as error: + fail(f"invalid ZIP archive: {error}") + + +def copy_download_bounded(response: object, output: object) -> None: + """Copy at most the pinned archive size plus one detection byte.""" + + total = 0 + while True: + allowance = pin["archive_bytes"] + 1 - total + if allowance <= 0: + fail("download exceeded the pinned archive_bytes bound") + chunk = response.read(min(1024 * 1024, allowance)) + if not chunk: + return + if not isinstance(chunk, bytes): + fail("download returned non-byte content") + output.write(chunk) + total += len(chunk) + if total > pin["archive_bytes"]: + fail("download exceeded the pinned archive_bytes bound") + + +def expected_tree_from_archive(path: Path) -> dict[str, tuple[str, int, bytes | None]]: + expected: dict[str, tuple[str, int, bytes | None]] = { + ".pyble-dist": ("dir", 0o755, None), + f".pyble-dist/{archive_filename}": ("file", 0o644, path.read_bytes()), + } + with zipfile.ZipFile(path, "r") as archive: + for info, mode in checked_members(archive): + relative = info.filename.rstrip("/") + if info.is_dir(): + expected[relative] = ("dir", stat.S_IMODE(mode), None) + else: + expected[relative] = ( + "file", + stat.S_IMODE(mode), + archive.read(info), + ) + return expected + + +def validate_component(path: Path, wanted: str, label: str) -> None: + if not regular_file(path): + fail(f"{label} is missing, non-regular, or symlinked: {path}") + actual = sha256(path) + if actual != wanted: + fail(f"{label} SHA-256 mismatch: wanted {wanted}, got {actual}") + + +def validate_version(root: Path) -> None: + executable = root / executable_path + if not os.access(executable, os.X_OK): + fail(f"pinned picotool is not executable: {executable}") + try: + completed = subprocess.run( + [str(executable), "version"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as error: + fail(f"could not execute pinned picotool: {error}") + if completed.returncode != 0 or completed.stdout != pin["version_line"] + "\n": + fail( + "picotool version mismatch: wanted {!r}, got {!r}".format( + pin["version_line"], completed.stdout.rstrip("\n") + ) + ) + + +def validate_installed_tree(root: Path) -> None: + if root.is_symlink() or not root.is_dir(): + fail(f"install root is missing, symlinked, or not a directory: {root}") + retained = root / ".pyble-dist" / archive_filename + validate_archive(retained) + expected = expected_tree_from_archive(retained) + observed: set[str] = set() + for current, directories, files in os.walk(root, topdown=True, followlinks=False): + current_path = Path(current) + for name in [*directories, *files]: + child = current_path / name + relative = child.relative_to(root).as_posix() + observed.add(relative) + try: + mode = child.lstat().st_mode + except OSError as error: + fail(f"cannot inspect installed member {relative}: {error}") + wanted = expected.get(relative) + if wanted is None: + fail(f"installed tree has an unexpected member: {relative}") + wanted_kind, wanted_mode, wanted_bytes = wanted + if wanted_kind == "dir": + if not stat.S_ISDIR(mode) or stat.S_ISLNK(mode): + fail(f"installed directory type changed: {relative}") + else: + if not stat.S_ISREG(mode) or stat.S_ISLNK(mode): + fail(f"installed file type changed: {relative}") + if child.read_bytes() != wanted_bytes: + fail(f"installed member bytes changed: {relative}") + if stat.S_IMODE(mode) != wanted_mode: + fail(f"installed member mode changed: {relative}") + if observed != set(expected): + missing = sorted(set(expected) - observed) + fail("installed tree is incomplete: " + ", ".join(missing)) + validate_component(root / executable_path, pin["executable_sha256"], "picotool") + validate_component(root / config_path, pin["cmake_config_sha256"], "CMake config") + validate_component(root / libusb_path, pin["bundled_libusb_sha256"], "bundled libusb") + + +def validate_installed(root: Path) -> None: + # The executable probe is an intentionally bounded external callback. A + # complete second tree pass after it prevents a concurrent mutation during + # that callback from being admitted as an exact retained distribution. + validate_installed_tree(root) + validate_version(root) + validate_installed_tree(root) + + +def validate_installed_descriptor( + descriptor: int, + expected_identity: tuple[int, int, int], + label: str, +) -> None: + """Revalidate one held directory inode, independent of path replacement.""" + + opened = os.fstat(descriptor) + if ( + not stat.S_ISDIR(opened.st_mode) + or directory_identity(opened) != expected_identity + ): + fail(f"{label} identity changed before revalidation") + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + original_working_directory = os.open(".", flags) + try: + os.fchdir(descriptor) + validate_installed(Path(".")) + finally: + try: + os.fchdir(original_working_directory) + finally: + os.close(original_working_directory) + reopened = os.fstat(descriptor) + if directory_identity(reopened) != expected_identity: + fail(f"{label} identity changed during revalidation") + + +destination = absolute_lexical_path(destination, "destination") +if destination.name in ("", ".", ".."): + fail("destination name is unsafe") +reject_symlinked_existing_ancestors(destination, "destination") +parent = destination.parent +reject_symlinked_existing_ancestors(parent, "destination parent") +parent.mkdir(parents=True, exist_ok=True) +reject_symlinked_existing_ancestors(parent, "destination parent") +if not parent.is_dir(): + fail(f"destination parent is not a directory: {parent}") + +parent_chain = open_directory_chain(parent, "destination parent") +parent_descriptor = parent_chain[-1][0] +temporary_download: Path | None = None +temporary_download_name: str | None = None +temporary_download_identity: tuple[int, int] | None = None +incoming: Path | None = None +incoming_name: str | None = None +incoming_identity: tuple[int, int] | None = None +incoming_descriptor: int | None = None +published = False +try: + verify_directory_chain(parent_chain, "destination parent") + if os.path.lexists(destination): + existing = os.stat( + destination.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if not stat.S_ISDIR(existing.st_mode) or stat.S_ISLNK(existing.st_mode): + fail(f"existing destination is unsafe and was preserved: {destination}") + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + directory_flags |= os.O_CLOEXEC + existing_descriptor = os.open( + destination.name, + directory_flags, + dir_fd=parent_descriptor, + ) + try: + try: + validate_installed_descriptor( + existing_descriptor, + directory_identity(existing), + "existing destination", + ) + except SystemExit as error: + fail( + "existing destination does not match the complete pin; " + f"preserved ({error})" + ) + finally: + os.close(existing_descriptor) + verify_directory_chain(parent_chain, "destination parent") + reopened = os.stat( + destination.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + visible = destination.lstat() + if not ( + directory_identity(existing) == directory_identity(reopened) + and directory_identity(reopened) == directory_identity(visible) + ): + fail("existing destination identity changed during revalidation") + verify_directory_chain(parent_chain, "destination parent") + print( + "install_picotool: already installed and matching the complete pin at " + f"{destination}" + ) + raise SystemExit(0) + + if archive_override: + archive_path = absolute_lexical_path( + Path(archive_override), + "input archive", + ) + reject_symlinked_existing_ancestors(archive_path, "input archive") + else: + temporary_download_name, temporary_download_identity = new_sibling_name( + parent_descriptor, + ".picotool-download.", + ".zip", + directory=False, + ) + temporary_download = parent / temporary_download_name + last_error: Exception | None = None + for attempt in range(3): + try: + flags = os.O_WRONLY | os.O_TRUNC | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + output_descriptor = os.open( + temporary_download_name, + flags, + dir_fd=parent_descriptor, + ) + opened = os.fstat(output_descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) + != temporary_download_identity + ): + os.close(output_descriptor) + fail("download staging file identity changed") + with os.fdopen(output_descriptor, "wb") as output: + with urllib.request.urlopen(pin["url"], timeout=60) as response: + copy_download_bounded(response, output) + last_error = None + break + except Exception as error: # network errors vary by Python/platform + last_error = error + if attempt != 2: + time.sleep(1 << attempt) + if last_error is not None: + fail(f"download failed after three attempts: {last_error}") + archive_path = temporary_download + + verify_directory_chain(parent_chain, "destination parent") + members = validate_archive(archive_path) + verify_directory_chain(parent_chain, "destination parent") + incoming_name, incoming_identity = new_sibling_name( + parent_descriptor, + f".{destination.name}.incoming.", + "", + directory=True, + ) + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + directory_flags |= os.O_CLOEXEC + incoming_descriptor = os.open( + incoming_name, + directory_flags, + dir_fd=parent_descriptor, + ) + original_working_directory = os.open(".", directory_flags) + try: + opened_incoming = os.fstat(incoming_descriptor) + if ( + not stat.S_ISDIR(opened_incoming.st_mode) + or (opened_incoming.st_dev, opened_incoming.st_ino) + != incoming_identity + ): + fail("staging directory identity changed") + verify_directory_chain(parent_chain, "destination parent") + os.fchdir(incoming_descriptor) + incoming = Path(".") + with zipfile.ZipFile(archive_path, "r") as archive: + for info, mode in members: + target = incoming.joinpath(*PurePosixPath(info.filename).parts) + if info.is_dir(): + target.mkdir(parents=True, exist_ok=False) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info, "r") as source, target.open( + "xb" + ) as output: + shutil.copyfileobj(source, output) + target.chmod(stat.S_IMODE(mode)) + + retained_dir = incoming / ".pyble-dist" + retained_dir.mkdir(mode=0o755) + retained_dir.chmod(0o755) + retained_archive = retained_dir / archive_filename + with archive_path.open("rb") as source, retained_archive.open( + "xb" + ) as output: + shutil.copyfileobj(source, output) + retained_archive.chmod(0o644) + + validate_installed(incoming) + finally: + os.fchdir(original_working_directory) + os.close(original_working_directory) + verify_directory_chain(parent_chain, "destination parent") + if os.path.lexists(destination): + fail(f"destination appeared during admission: {destination}") + verify_directory_chain(parent_chain, "destination parent") + atomic_publish_no_replace( + parent_descriptor, + incoming_name, + destination.name, + ) + published = True + admitted = os.stat( + destination.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISDIR(admitted.st_mode) + or stat.S_ISLNK(admitted.st_mode) + or (admitted.st_dev, admitted.st_ino) != incoming_identity + ): + fail("published destination identity changed") + verify_directory_chain(parent_chain, "destination parent") + visible = destination.lstat() + if directory_identity(visible) != directory_identity(admitted): + fail("published destination is not visible through the held parent") + verify_directory_chain(parent_chain, "destination parent") + validate_installed_descriptor( + incoming_descriptor, + directory_identity(admitted), + "published destination", + ) + admitted = os.stat( + destination.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISDIR(admitted.st_mode) + or stat.S_ISLNK(admitted.st_mode) + or (admitted.st_dev, admitted.st_ino) != incoming_identity + ): + fail("published destination identity changed during revalidation") + verify_directory_chain(parent_chain, "destination parent") + visible = destination.lstat() + if directory_identity(visible) != directory_identity(admitted): + fail("revalidated destination is not visible through the held parent") + verify_directory_chain(parent_chain, "destination parent") + os.close(incoming_descriptor) + incoming_descriptor = None + incoming = None + incoming_name = None + incoming_identity = None +finally: + if incoming_descriptor is not None: + try: + os.close(incoming_descriptor) + except OSError: + pass + if published: + # A post-publication parent/identity failure removes only the inode we + # admitted through the held parent. A contender in a replacement + # visible parent is never addressed by path and is therefore kept. + remove_owned_tree( + parent_descriptor, + destination.name, + incoming_identity, + ) + else: + remove_owned_tree( + parent_descriptor, + incoming_name, + incoming_identity, + ) + remove_owned_file( + parent_descriptor, + temporary_download_name, + temporary_download_identity, + ) + close_directory_chain(parent_chain) + +print(f"install_picotool: picotool {pin['version']} ready at {destination}") +PY diff --git a/firmware/scripts/prepare.sh b/firmware/scripts/prepare.sh index b857a694..2bb4937d 100755 --- a/firmware/scripts/prepare.sh +++ b/firmware/scripts/prepare.sh @@ -104,9 +104,9 @@ cp -R "$OVERLAY"/. "$BOARD_DST"/ # scratch notes, or a future module that has not been admitted by the manifest # must not become a release input merely because it is beside these sources. if [ "$PORT" = "rp2" ]; then - PYBLE_FILES="__init__.py _version.py pyble_agent.py pyble_ble.py pyble_boot.py pyble_console.py pyble_device_config.py pyble_fs.py pyble_info.py pyble_proto.py pyble_runner.py" + PYBLE_FILES="__init__.py _version.py pyble_agent.py pyble_ble.py pyble_boot.py pyble_console.py pyble_device_config.py pyble_fs.py pyble_info.py pyble_proto.py pyble_runner.py pyble_workspace.py" else - PYBLE_FILES="__init__.py _version.py pyble_ble.py pyble_proto.py" + PYBLE_FILES="__init__.py _version.py pyble_ble.py pyble_proto.py pyble_workspace.py" fi mkdir "$BOARD_DST/pyble" for pyble_file in $PYBLE_FILES; do diff --git a/firmware/scripts/release_bundle.py b/firmware/scripts/release_bundle.py index 0f813751..6c5481b2 100755 --- a/firmware/scripts/release_bundle.py +++ b/firmware/scripts/release_bundle.py @@ -81,12 +81,36 @@ def _load_v060_profile_gate() -> Any: return module +def _load_v061_hardening_gate() -> Any: + source = ( + Path(__file__).resolve().parents[1] + / "qualification" + / "v061_hardening_release_gate.py" + ).resolve(strict=True) + spec = importlib.util.spec_from_file_location( + "_pyble_release_v061_hardening_gate", + source, + ) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load the v0.6.1 hardening validator") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + _WAVESHARE_LCD147B_GATE = _load_waveshare_lcd147b_gate() _V060_PROFILE_GATE = _load_v060_profile_gate() _V060_PROFILE_GATE_SOURCE = Path(_V060_PROFILE_GATE.__file__).resolve(strict=True) _V060_PROFILE_GATE_SOURCE_SHA256 = hashlib.sha256( _V060_PROFILE_GATE_SOURCE.read_bytes() ).hexdigest() +_V061_HARDENING_GATE: Any | None = None +_V061_HARDENING_GATE_SOURCE = ( + Path(__file__).resolve().parents[1] + / "qualification" + / "v061_hardening_release_gate.py" +).resolve() +_V061_HARDENING_GATE_SOURCE_SHA256: str | None = None class ReleaseError(RuntimeError): @@ -104,8 +128,35 @@ class ReleaseError(RuntimeError): "esp32-c3-4mb", "rpi-pico2-w", ) -# The unqualified source-selected candidate is v0.6.0. Source-era helpers -# below retain the immutable v0.4.2 and v0.5.x orders explicitly. +V5_RELEASE_CORES = ((0, 6, 0), (0, 6, 1)) +V061_HARDENING_VERSION = "0.6.1" +V061_HARDENING_MEASUREMENT_CONTRACT = "v061-hardening-seven-scenario-v1" +V061_HARDENING_SCENARIO_ORDER = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) +V061_HARDENING_WORKSPACE_ORDER = ( + "erased-media-first-boot", + "nonblank-media-refusal", +) +V061_HARDENING_SUMMARY_FIELDS = ( + "measurement_contract", + "scenario_order", + "scenarios", + "sequential_runs", + "workspace_provisioning", + "private_result_sha256", +) +V061_HARDENING_EXECUTABLE_RELATIVE = ( + "tests/firmware_tests/hil/v061_hardening_bench.py" +) +# The unqualified source-selected candidate is v0.6.1. Source-era helpers below +# retain the immutable v0.4.2, v0.5.x, and qualified v0.6.0 lineage explicitly. RELEASE_PROFILE_ORDER = V060_RELEASE_PROFILE_ORDER QUALIFICATION_POLICY_RELATIVE = "firmware/qualification/oi1-gates.json" QUALIFICATION_BASELINE_RE = re.compile( @@ -228,6 +279,90 @@ class ReleaseError(RuntimeError): "tinyusb", "arm-gnu-runtime", ) +RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE = "build-tools" +_PICOTOOL_BUILD_TOOLS_POLICY_PATH = ( + "firmware/licenses/rp2-build-tools-license-policy.json" +) +_PICOTOOL_ATTRIBUTION_PATH = ( + "firmware/licenses/evidence/rp2/picotool/2.3.0/" + "distribution-attribution-v1.json" +) +_PICOTOOL_V061_ATTRIBUTION_SHA256 = ( + "9d96bdff715a08582506220d3ba1e332cc3b86dd9eaa13e7d7169856734a6e88" +) +_PICOTOOL_V061_POLICY_SHA256 = ( + "d0b5920297c78280d509b93cc0ab356839e8b2adfacaab42f352150390b645e9" +) +_PICOTOOL_LOCK_KEYS = { + "version", + "version_line", + "source_repo", + "source_ref", + "source_commit", + "distribution_repo", + "distribution_ref", + "distribution_commit", + "url", + "archive_filename", + "archive_bytes", + "archive_format", + "sha256", + "cmake_dir", + "executable_path", + "executable_sha256", + "cmake_config_path", + "cmake_config_sha256", + "bundled_libusb_path", + "bundled_libusb_sha256", +} +_PICOTOOL_V061_LOCK = { + "version": "2.3.0", + "version_line": ( + "picotool v2.3.0 " + "(Darwin, AppleClang-15.0.0.15000309, Release)" + ), + "source_repo": "https://github.com/raspberrypi/picotool.git", + "source_ref": "2.3.0", + "source_commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "distribution_repo": "https://github.com/raspberrypi/pico-sdk-tools.git", + "distribution_ref": "v2.3.0-0", + "distribution_commit": "ad9e4a8375253cf4886bf168ea1a8d2746aadf24", + "url": ( + "https://github.com/raspberrypi/pico-sdk-tools/releases/download/" + "v2.3.0-0/picotool-2.3.0-mac.zip" + ), + "archive_filename": "picotool-2.3.0-mac.zip", + "archive_bytes": 1_980_457, + "archive_format": "zip", + "sha256": "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e", + "cmake_dir": "picotool", + "executable_path": "picotool/picotool", + "executable_sha256": ( + "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" + ), + "cmake_config_path": "picotool/picotoolConfig.cmake", + "cmake_config_sha256": ( + "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" + ), + "bundled_libusb_path": "picotool/libusb-1.0.0.dylib", + "bundled_libusb_sha256": ( + "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" + ), +} +_PICOTOOL_V061_MEMBERS = ( + ("picotool/", "directory", "040755", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ("picotool/picotoolTargets-release.cmake", "regular", "100644", 767, "e40085002e565de850554e772c90f1a1c4cffb545f853eaa231b145e498f7c0f"), + ("picotool/picotoolConfigVersion.cmake", "regular", "100644", 2_303, "61485a18a59a186d7cb82ddf7686c5f1f4def81726568ae49fd0033db368f7e3"), + ("picotool/rp2350_otp_contents.json", "regular", "100644", 367_931, "1838713d5f94316c4c61558cb82d3346831235dc41f9b0f02151e6eda3f22fc2"), + ("picotool/libusb-1.0.0.dylib", "regular", "100444", 328_336, "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237"), + ("picotool/enc_bootloader_mbedtls.elf", "regular", "100644", 29_484, "c5d17fcbb4f1ee41751dc782d86c1fc6629d8da3484e1a1cadc3ba4bec4d6044"), + ("picotool/picotoolTargets.cmake", "regular", "100644", 3_856, "043655d4bc215fb3a05d4a04f92cabc0be9bbdb0834717b0211e80106b4d1aae"), + ("picotool/picotoolConfig.cmake", "regular", "100644", 96, "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3"), + ("picotool/xip_ram_perms.elf", "regular", "100644", 34_004, "afac0e166ee9b632f84717c7db02e7e5e648f03a51cc4795938adcd282c6103b"), + ("picotool/picotool", "regular", "100755", 5_673_800, "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5"), + ("picotool/enc_bootloader.elf", "regular", "100644", 20_104, "9ce3c424d61226225d2c1f5b80a8854f15174b09dd447e58e4439240b274fb3d"), + (".keep", "regular", "100644", 0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), +) _RP2_LICENSE_POLICY_KEYS = { "schema_version", "profile_id", @@ -618,6 +753,7 @@ class ReleaseError(RuntimeError): "root_pointers.h", ) _AUDIT_PYBLE_C_SOURCES = ( + "pble_wire.c", "pble_proto.c", "pble_ble.c", "pble_info.c", @@ -863,6 +999,17 @@ def _exact_keys(value: Any, expected: set[str], label: str) -> dict[str, Any]: return value +def _validate_v061_picotool_lock(value: Any, label: str) -> dict[str, Any]: + """Require the exact source- and distribution-pinned v0.6.1 tool record.""" + + record = _exact_keys(value, _PICOTOOL_LOCK_KEYS, label) + _require( + record == _PICOTOOL_V061_LOCK, + "%s differs from the frozen v0.6.1 picotool distribution" % label, + ) + return copy.deepcopy(record) + + def _git_output(checkout: Path, label: str, *args: str) -> str: environment = { "GIT_CONFIG_GLOBAL": os.devnull, @@ -1745,6 +1892,7 @@ def _validate_rp2_build_provenance( *, firmware_bin_bytes: int, repo_root: Path | None = None, + firmware_version: str | None = None, ) -> dict[str, Any]: """Validate the RP2-specific build attestation without inventing IDF facts.""" @@ -1817,12 +1965,25 @@ def _validate_rp2_build_provenance( and PLACEHOLDER_RE.fullmatch(arm[key].strip()) is None, "%s ARM GNU %s identity is missing" % (target, key), ) - _require( - isinstance(record["picotool"], str) - and re.match(r"^picotool v[0-9]+(?:\.[0-9]+){2}\b", record["picotool"]) - is not None, - "%s picotool version is missing or invalid" % target, + strict_picotool = ( + firmware_version is not None + and _firmware_release_core(firmware_version, "firmware source version") + == (0, 6, 1) ) + if strict_picotool: + _require( + record["picotool"] == _PICOTOOL_V061_LOCK["version_line"], + "%s picotool version differs from the frozen v0.6.1 line" % target, + ) + else: + _require( + isinstance(record["picotool"], str) + and re.match( + r"^picotool v[0-9]+(?:\.[0-9]+){2}\b", record["picotool"] + ) + is not None, + "%s picotool version is missing or invalid" % target, + ) _require( type(record["firmware_bin_bytes"]) is int and record["firmware_bin_bytes"] == firmware_bin_bytes, @@ -1832,6 +1993,15 @@ def _validate_rp2_build_provenance( if repo_root is not None: root = Path(repo_root) lock = _read_lock(root) + if strict_picotool: + picotool_lock = lock.get("picotool") + _require( + isinstance(picotool_lock, dict) + and picotool_lock.get("version_line") + == _PICOTOOL_V061_LOCK["version_line"] + and record["picotool"] == picotool_lock["version_line"], + "%s picotool provenance disagrees with versions.lock" % target, + ) _require( micropython["commit"] == lock["micropython"]["commit"], "%s MicroPython provenance disagrees with versions.lock" % target, @@ -1948,6 +2118,8 @@ def validate_rp2_build( target: str, build_dir: Path, repo_root: Path | None = None, + *, + firmware_version: str | None = None, ) -> dict[str, Any]: """Validate one RP2 build without routing it through ESP-IDF semantics.""" @@ -2006,6 +2178,7 @@ def validate_rp2_build( target, firmware_bin_bytes=len(raw_image), repo_root=repo_root, + firmware_version=firmware_version, ) return { "profile_id": profile_id, @@ -2019,6 +2192,29 @@ def validate_rp2_build( } +def _validate_rp2_build_for_firmware_version( + target: str, + build_dir: Path, + *, + repo_root: Path | None, + firmware_version: str | None, +) -> dict[str, Any]: + """Route strict v0.6.1 provenance without changing v0.6.0 call shape.""" + + if ( + firmware_version is not None + and _firmware_release_core(firmware_version, "firmware source version") + == (0, 6, 1) + ): + return validate_rp2_build( + target, + build_dir, + repo_root=repo_root, + firmware_version=firmware_version, + ) + return validate_rp2_build(target, build_dir, repo_root=repo_root) + + def compare_build_roots( left: Path, right: Path, @@ -2079,10 +2275,11 @@ def compare_build_roots( if include_rp2: for root in (left_root, right_root): validated.append( - validate_rp2_build( + _validate_rp2_build_for_firmware_version( "rpi-pico2-w", root / "rpi-pico2-w", repo_root=repo_root, + firmware_version=firmware_version, ) ) _require_one_build_source_identity(validated) @@ -3219,7 +3416,11 @@ def _audit_artifact_requirements(artifact: dict[str, Any]) -> list[str]: return value -def _audit_load_tool_lock(repo_root: Path) -> dict[str, Any]: +def _audit_load_tool_lock( + repo_root: Path, + *, + firmware_version: str | None = None, +) -> dict[str, Any]: path = repo_root / "firmware" / "release-tools.lock" try: with path.open("rb") as handle: @@ -3362,34 +3563,101 @@ def _audit_load_tool_lock(repo_root: Path) -> dict[str, Any]: "executed-artifact receipts require one selected artifact per package", ) - input_keys = { + base_input_keys = { "excluded_cves_path", "excluded_cves_sha256", "license_policy_path", "license_policy_sha256", } - versions_path = Path(repo_root) / "firmware" / "versions.lock" - has_rp2_binding = any( - key.startswith("rp2_license_policy_") - for key in lock["inputs"] + rp2_input_keys = { + "rp2_license_policy_path", + "rp2_license_policy_sha256", + } + rp2_build_tools_input_keys = { + "rp2_build_tools_license_policy_path", + "rp2_build_tools_license_policy_sha256", + } + raw_inputs = lock["inputs"] + _require( + isinstance(raw_inputs, dict), + "release-tools.lock [inputs] must be a table", ) - if versions_path.is_file(): - source_version = _read_lock(Path(repo_root))["pyble"]["agent_version"] - has_rp2_binding = has_rp2_binding or tuple( - _release_profile_order_for_version(source_version) + raw_input_keys = set(raw_inputs) + all_input_keys = ( + base_input_keys | rp2_input_keys | rp2_build_tools_input_keys + ) + _require( + base_input_keys <= raw_input_keys <= all_input_keys, + "release-tools.lock [inputs] contains an unknown or incomplete schema", + ) + for optional_keys, label in ( + (rp2_input_keys, "RP2 policy"), + (rp2_build_tools_input_keys, "RP2 build-tools policy"), + ): + present = raw_input_keys & optional_keys + _require( + not present or present == optional_keys, + "release-tools.lock [inputs] has an incomplete %s binding" % label, + ) + + if firmware_version is None: + versions_path = Path(repo_root) / "firmware" / "versions.lock" + if versions_path.is_file(): + selected_version = _read_lock(Path(repo_root))["pyble"][ + "agent_version" + ] + has_rp2_binding = tuple( + _release_profile_order_for_version(selected_version) + ) == V060_RELEASE_PROFILE_ORDER + has_rp2_build_tools_binding = ( + _firmware_release_core( + selected_version, + "firmware source version", + ) + == (0, 6, 1) + ) + else: + has_rp2_binding = rp2_input_keys <= raw_input_keys + has_rp2_build_tools_binding = ( + rp2_build_tools_input_keys <= raw_input_keys + ) + else: + # Candidate replay must never borrow version authority from the + # validator checkout. A newer checkout may carry additional locked + # inputs, but they are neither opened nor returned for an older + # candidate. + has_rp2_binding = tuple( + _release_profile_order_for_version(firmware_version) ) == V060_RELEASE_PROFILE_ORDER - if has_rp2_binding: - input_keys.update( - {"rp2_license_policy_path", "rp2_license_policy_sha256"} + has_rp2_build_tools_binding = ( + _firmware_release_core( + firmware_version, + "candidate firmware version", + ) + == (0, 6, 1) ) - inputs = _exact_keys( - lock["inputs"], - input_keys, - "release-tools.lock [inputs]", + + input_keys = set(base_input_keys) + if has_rp2_binding: + input_keys.update(rp2_input_keys) + if has_rp2_build_tools_binding: + input_keys.update(rp2_build_tools_input_keys) + _require( + input_keys <= raw_input_keys, + "release-tools.lock lacks candidate-version policy bindings", ) + if firmware_version is None: + _require( + raw_input_keys == input_keys, + "release-tools.lock [inputs] differs from the source-version schema", + ) + inputs = {key: raw_inputs[key] for key in sorted(input_keys)} + lock["inputs"] = inputs prefixes = ["excluded_cves", "license_policy"] if has_rp2_binding: prefixes.append("rp2_license_policy") + if has_rp2_build_tools_binding: + prefixes.append("rp2_build_tools_license_policy") for prefix in prefixes: relative = inputs.get("%s_path" % prefix) expected_hash = inputs.get("%s_sha256" % prefix) @@ -7971,72 +8239,1752 @@ def _audit_validate_rp2_license_policy( == sorted(normalized_notices, key=lambda item: item["path"]), "RP2 owner %s notices are not canonical" % identifier, ) - normalized_owner = { - "id": identifier, - "source_roots": normalized_roots, - "source_ref": source_ref, - "source_url": source_url, - "source_spdx_expression": source_expression, - "selected_spdx_expression": selected_expression, - "copyright": copyright_value, - "license_texts": normalized_licenses, - "notice_files": normalized_notices, - "disposition": disposition, - } - if component_kind is not None: - normalized_owner["component_kind"] = component_kind - owners.append(normalized_owner) + normalized_owner = { + "id": identifier, + "source_roots": normalized_roots, + "source_ref": source_ref, + "source_url": source_url, + "source_spdx_expression": source_expression, + "selected_spdx_expression": selected_expression, + "copyright": copyright_value, + "license_texts": normalized_licenses, + "notice_files": normalized_notices, + "disposition": disposition, + } + if component_kind is not None: + normalized_owner["component_kind"] = component_kind + owners.append(normalized_owner) + _require( + [owner["id"] for owner in owners] + == sorted(owner["id"] for owner in owners), + "RP2 license policy owners are not canonical", + ) + return { + "schema_version": schema_version, + "profile_id": "rpi-pico2-w", + "target": "rpi-pico2-w", + "source_owners": owners, + } + + +def _audit_load_rp2_license_policy( + repo_root: Path, + build_root: Path, + lock: dict[str, Any], +) -> dict[str, Any]: + inputs = lock.get("inputs") + _require(isinstance(inputs, dict), "release tool lock inputs are missing") + relative = inputs.get("rp2_license_policy_path") + expected_hash = inputs.get("rp2_license_policy_sha256") + _require( + isinstance(relative, str) + and isinstance(expected_hash, str) + and SHA256_RE.fullmatch(expected_hash) is not None, + "release tool lock lacks the RP2 license-policy binding", + ) + path = _audit_repo_file( + Path(repo_root), + relative, + "RP2 release license policy", + ) + raw = _read_regular_file_bytes(path, "RP2 release license policy") + try: + value = json.loads(raw.decode("utf-8", errors="strict")) + except (UnicodeError, TypeError, ValueError) as exc: + raise ReleaseError("RP2 release license policy is invalid JSON") from exc + _require( + raw == _canonical_json_bytes(value), + "RP2 release license policy is not canonical JSON", + ) + _require( + _sha256_bytes(raw) == expected_hash, + "RP2 release license policy differs from release-tools.lock", + ) + return _audit_validate_rp2_license_policy( + value, + repo_root=Path(repo_root), + build_root=Path(build_root), + ) + + +def _picotool_v061_member_inventory() -> list[dict[str, Any]]: + return [ + { + "path": path, + "kind": kind, + "mode": mode, + "bytes": byte_count, + "sha256": digest, + } + for path, kind, mode, byte_count, digest in _PICOTOOL_V061_MEMBERS + ] + + +def _audit_validate_picotool_attribution( + value: Any, + *, + repo_root: Path, + build_root: Path, + picotool_lock: dict[str, Any], + member_inventory: list[dict[str, Any]], +) -> set[tuple[str, str]]: + """Validate the complete, reviewed picotool attribution manifest.""" + + _require(isinstance(value, dict), "picotool attribution must be an object") + referenced_assets: set[tuple[str, str]] = set() + attribution = _exact_keys( + value, + { + "schema_version", + "archive", + "archive_members", + "components", + "distribution_provenance", + "libusb_binary_provenance", + "source", + }, + "picotool distribution attribution", + ) + _require( + type(attribution["schema_version"]) is int + and attribution["schema_version"] == 1, + "picotool attribution schema_version must be the exact integer 1", + ) + archive = _exact_keys( + attribution["archive"], + {"asset_created_at", "asset_id", "bytes", "filename", "sha256", "url"}, + "picotool attribution archive", + ) + _require( + type(archive["asset_id"]) is int + and archive["asset_id"] == 465_646_274 + and archive["asset_created_at"] == "2026-07-03T17:55:20Z" + and archive["bytes"] == picotool_lock["archive_bytes"] + and type(archive["bytes"]) is int + and archive["filename"] == picotool_lock["archive_filename"] + and archive["sha256"] == picotool_lock["sha256"] + and archive["url"] == picotool_lock["url"], + "picotool attribution archive identity changed", + ) + _require( + attribution["archive_members"] == member_inventory, + "picotool attribution member inventory changed", + ) + source = _exact_keys( + attribution["source"], + {"repo", "ref", "commit"}, + "picotool attribution source", + ) + _require( + source + == { + "repo": picotool_lock["source_repo"], + "ref": picotool_lock["source_ref"], + "commit": picotool_lock["source_commit"], + }, + "picotool attribution source identity changed", + ) + distribution = _exact_keys( + attribution["distribution_provenance"], + {"repo", "ref", "commit", "copyright", "license", "notice", "ownership"}, + "picotool packaging provenance", + ) + _require( + distribution["repo"] == picotool_lock["distribution_repo"] + and distribution["ref"] == picotool_lock["distribution_ref"] + and distribution["commit"] == picotool_lock["distribution_commit"] + and distribution["copyright"] is None + and distribution["notice"] is None + and distribution["ownership"] == "packaging-provenance-only", + "picotool packaging provenance changed", + ) + + def validate_asset(raw: Any, label: str) -> tuple[str, str]: + record = _exact_keys(raw, {"path", "sha256"}, label) + logical, path = _audit_rp2_logical_file( + record["path"], + repo_root=repo_root, + build_root=build_root, + label=label, + ) + digest = _audit_v060_digest(record["sha256"], "%s digest" % label) + _require(_sha256_path(path) == digest, "%s changed" % label) + identity = (logical, digest) + referenced_assets.add(identity) + return identity + + packaging_license = _exact_keys( + distribution["license"], + {"identifier", "path", "sha256"}, + "picotool packaging license", + ) + _require( + packaging_license["identifier"] == "Apache-2.0", + "picotool packaging license selection changed", + ) + validate_asset( + {"path": packaging_license["path"], "sha256": packaging_license["sha256"]}, + "picotool packaging license", + ) + + expected_components = ( + ("picotool", "BSD-3-Clause"), + ("pico-sdk", "BSD-3-Clause"), + ("mbedtls", "Apache-2.0"), + ("clipp", "MIT"), + ("nlohmann-json", "MIT"), + ("littlefs", "BSD-3-Clause"), + ("oofatfs", "LicenseRef-PyBLE-ooFatFs-R0.13c"), + ("whereami", "MIT"), + ("cmake-package-scripts", "LicenseRef-PyBLE-CMake-Package-Scripts"), + ("libusb", "LGPL-2.1-or-later"), + ) + components = attribution["components"] + _require( + isinstance(components, list) + and len(components) == len(expected_components), + "picotool component attribution coverage changed", + ) + for index, ((expected_id, expected_selected), raw_component) in enumerate( + zip(expected_components, components) + ): + component = _exact_keys( + raw_component, + { + "id", + "copyright", + "exact_source_identity", + "license_files", + "review_note", + "selected_spdx_expression", + "source_spdx_expression", + }, + "picotool component attribution %d" % index, + ) + _require( + component["id"] == expected_id + and component["selected_spdx_expression"] == expected_selected + and isinstance(component["source_spdx_expression"], str) + and bool(component["source_spdx_expression"]) + and isinstance(component["copyright"], str) + and bool(component["copyright"]) + and isinstance(component["review_note"], str) + and bool(component["review_note"]) + and isinstance(component["exact_source_identity"], dict) + and bool(component["exact_source_identity"]), + "picotool component attribution changed for %s" % expected_id, + ) + license_files = component["license_files"] + _require( + isinstance(license_files, list) and bool(license_files), + "picotool component %s lacks complete license evidence" % expected_id, + ) + for asset_index, asset in enumerate(license_files): + validate_asset( + asset, + "picotool component %s license %d" % (expected_id, asset_index), + ) + + libusb = _exact_keys( + attribution["libusb_binary_provenance"], + { + "binary_runtime_version", + "binary_sha256", + "evidence_files", + "formula_commit", + "source_archive", + }, + "picotool bundled libusb provenance", + ) + _require( + libusb["binary_runtime_version"] == "1.0.30.12037" + and libusb["binary_sha256"] == picotool_lock["bundled_libusb_sha256"] + and libusb["formula_commit"] + == "889a613d996eec7ff15837e38ab4b81c47cfe63e", + "picotool bundled libusb binary identity changed", + ) + source_archive = _exact_keys( + libusb["source_archive"], + {"url", "bytes", "sha256"}, + "picotool bundled libusb source archive", + ) + _require( + source_archive + == { + "url": ( + "https://github.com/libusb/libusb/releases/download/" + "v1.0.30/libusb-1.0.30.tar.bz2" + ), + "bytes": 656_112, + "sha256": ( + "fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf" + ), + }, + "picotool bundled libusb source archive changed", + ) + evidence_files = libusb["evidence_files"] + _require( + isinstance(evidence_files, list) and len(evidence_files) == 5, + "picotool bundled libusb evidence coverage changed", + ) + for index, asset in enumerate(evidence_files): + validate_asset(asset, "picotool bundled libusb evidence %d" % index) + return referenced_assets + + +def _audit_validate_rp2_build_tools_license_policy( + value: Any, + *, + repo_root: Path, + build_root: Path, + picotool_lock: dict[str, Any], +) -> dict[str, Any]: + """Validate the independent, nonredistributed v0.6.1 picotool policy.""" + + root = Path(repo_root) + builds = Path(build_root) + lock = _validate_v061_picotool_lock( + picotool_lock, + "v0.6.1 picotool lock", + ) + policy = _exact_keys( + value, + { + "schema_version", + "profile_id", + "target", + "distribution_provenance", + "source_owners", + "nonsoftware_members", + }, + "RP2 build-tools license policy", + ) + _require( + type(policy["schema_version"]) is int + and policy["schema_version"] == 1, + "RP2 build-tools policy schema_version must be the exact integer 1", + ) + _require( + policy["profile_id"] == "rpi-pico2-w" + and policy["target"] == "rpi-pico2-w", + "RP2 build-tools policy identity is substituted", + ) + provenance = _exact_keys( + policy["distribution_provenance"], + { + "picotool_lock", + "retained_archive", + "member_inventory", + "runtime_version_line", + "source", + "distribution", + "attribution", + }, + "RP2 build-tools distribution provenance", + ) + _require( + provenance["picotool_lock"] == lock, + "RP2 build-tools policy lock copy changed", + ) + retained = _exact_keys( + provenance["retained_archive"], + {"path", "bytes", "sha256"}, + "RP2 build-tools retained archive", + ) + _require( + retained + == { + "path": ( + "firmware/.picotool/.pyble-dist/" + lock["archive_filename"] + ), + "bytes": lock["archive_bytes"], + "sha256": lock["sha256"], + } + and type(retained["bytes"]) is int, + "RP2 build-tools retained archive identity changed", + ) + expected_members = _picotool_v061_member_inventory() + members = provenance["member_inventory"] + _require( + isinstance(members, list) and len(members) == len(expected_members), + "RP2 build-tools member inventory coverage changed", + ) + normalized_members: list[dict[str, Any]] = [] + for index, raw_member in enumerate(members): + member = _exact_keys( + raw_member, + {"path", "kind", "mode", "bytes", "sha256"}, + "RP2 build-tools member %d" % index, + ) + _require( + type(member["bytes"]) is int + and member["bytes"] >= 0 + and isinstance(member["sha256"], str) + and SHA256_RE.fullmatch(member["sha256"]) is not None, + "RP2 build-tools member %d has invalid byte identity" % index, + ) + normalized_members.append(copy.deepcopy(member)) + _require( + normalized_members == expected_members, + "RP2 build-tools member inventory differs from the frozen archive", + ) + _require( + provenance["runtime_version_line"] == lock["version_line"], + "RP2 build-tools runtime version changed", + ) + for key, prefix in (("source", "source"), ("distribution", "distribution")): + identity = _exact_keys( + provenance[key], + {"repo", "ref", "commit"}, + "RP2 build-tools %s identity" % key, + ) + _require( + identity + == { + "repo": lock["%s_repo" % prefix], + "ref": lock["%s_ref" % prefix], + "commit": lock["%s_commit" % prefix], + }, + "RP2 build-tools %s identity changed" % key, + ) + attribution_record = _exact_keys( + provenance["attribution"], + {"path", "sha256"}, + "RP2 build-tools attribution binding", + ) + _require( + attribution_record["path"] == _PICOTOOL_ATTRIBUTION_PATH, + "RP2 build-tools attribution path changed", + ) + attribution_logical, attribution_path = _audit_rp2_logical_file( + attribution_record["path"], + repo_root=root, + build_root=builds, + label="RP2 build-tools attribution", + ) + attribution_digest = _audit_v060_digest( + attribution_record["sha256"], + "RP2 build-tools attribution digest", + ) + attribution_raw = _read_regular_file_bytes( + attribution_path, + "RP2 build-tools attribution", + ) + _require( + _sha256_bytes(attribution_raw) == attribution_digest, + "RP2 build-tools attribution changed", + ) + try: + attribution = json.loads(attribution_raw.decode("utf-8", errors="strict")) + except (UnicodeError, TypeError, ValueError) as exc: + raise ReleaseError("RP2 build-tools attribution is invalid JSON") from exc + _require( + attribution_raw == _canonical_json_bytes(attribution), + "RP2 build-tools attribution is not canonical JSON", + ) + production_attribution_keys = { + "schema_version", + "archive", + "archive_members", + "components", + "distribution_provenance", + "libusb_binary_provenance", + "source", + } + _require( + set(attribution) == production_attribution_keys, + "RP2 build-tools attribution is not the complete reviewed schema", + ) + _require( + attribution_digest == _PICOTOOL_V061_ATTRIBUTION_SHA256 + and _sha256_bytes(_canonical_json_bytes(policy)) + == _PICOTOOL_V061_POLICY_SHA256, + "RP2 build-tools reviewed policy/provenance bytes changed", + ) + attribution_assets = _audit_validate_picotool_attribution( + attribution, + repo_root=root, + build_root=builds, + picotool_lock=lock, + member_inventory=expected_members, + ) + + raw_owners = policy["source_owners"] + _require( + isinstance(raw_owners, list) and len(raw_owners) == 2, + "RP2 build-tools policy must contain exactly two owners", + ) + owner_contract = { + "picotool-composite-build-tool": { + "source_ref": "picotool-2.3.0-composite-v1", + "source_url": lock["source_repo"], + "expression": "LicenseRef-PyBLE-Picotool-2.3.0-Composite", + "roots": sorted( + item["path"] + for item in expected_members + if item["kind"] == "regular" + and item["path"] + not in {".keep", lock["bundled_libusb_path"]} + ), + }, + "picotool-libusb-build-tool": { + "source_ref": ( + "v1.0.30@87a55632db62c9bdc58cd31d3ccfa673f1bb017f" + ), + "source_url": "https://github.com/libusb/libusb.git", + "expression": "LGPL-2.1-or-later", + "roots": [lock["bundled_libusb_path"]], + }, + } + _require( + [owner.get("id") for owner in raw_owners] + == sorted(owner_contract), + "RP2 build-tools owners are missing, substituted, or reordered", + ) + normalized_owners: list[dict[str, Any]] = [] + owned_members: set[str] = set() + policy_assets: set[tuple[str, str]] = { + (attribution_logical, attribution_digest) + } + for index, raw_owner in enumerate(raw_owners): + owner = _exact_keys( + raw_owner, + _RP2_LICENSE_OWNER_V2_KEYS, + "RP2 build-tools owner %d" % index, + ) + identifier = owner["id"] + contract = owner_contract[identifier] + _require( + owner["component_kind"] == "build-tool" + and owner["disposition"] == "allow" + and owner["source_ref"] == contract["source_ref"] + and owner["source_url"] == contract["source_url"] + and owner["source_spdx_expression"] == contract["expression"] + and owner["selected_spdx_expression"] == contract["expression"] + and isinstance(owner["copyright"], str) + and bool(owner["copyright"]) + and owner["copyright"] == owner["copyright"].strip(), + "RP2 build-tools owner contract changed for %s" % identifier, + ) + roots = owner["source_roots"] + _require( + isinstance(roots, list) and bool(roots), + "RP2 build-tools owner %s lacks source roots" % identifier, + ) + normalized_roots: list[dict[str, str]] = [] + for root_index, raw_source_root in enumerate(roots): + source_root = _exact_keys( + raw_source_root, + {"namespace", "path"}, + "RP2 build-tools owner %s root %d" % (identifier, root_index), + ) + path = _safe_relative_path( + source_root["path"], + "RP2 build-tools owner %s root" % identifier, + ) + _require( + source_root["namespace"] == "picotool-distribution" + and path not in owned_members, + "RP2 build-tools source root is invalid or ambiguous", + ) + owned_members.add(path) + normalized_roots.append( + {"namespace": "picotool-distribution", "path": path} + ) + _require( + [item["path"] for item in normalized_roots] == contract["roots"], + "RP2 build-tools source roots changed for %s" % identifier, + ) + + expected_license_identifier = contract["expression"] + license_texts = owner["license_texts"] + _require( + isinstance(license_texts, list) and len(license_texts) == 1, + "RP2 build-tools owner %s must bind one complete license" % identifier, + ) + raw_license = _exact_keys( + license_texts[0], + {"identifier", "path", "sha256"}, + "RP2 build-tools owner %s license" % identifier, + ) + _require( + raw_license["identifier"] == expected_license_identifier, + "RP2 build-tools owner %s license identifier changed" % identifier, + ) + license_logical, license_path = _audit_rp2_logical_file( + raw_license["path"], + repo_root=root, + build_root=builds, + label="RP2 build-tools owner %s license" % identifier, + ) + license_digest = _audit_v060_digest( + raw_license["sha256"], + "RP2 build-tools owner %s license digest" % identifier, + ) + _require( + _sha256_path(license_path) == license_digest, + "RP2 build-tools owner %s license changed" % identifier, + ) + normalized_licenses = [ + { + "identifier": expected_license_identifier, + "path": license_logical, + "sha256": license_digest, + } + ] + policy_assets.add((license_logical, license_digest)) + + notices = owner["notice_files"] + _require( + isinstance(notices, list) and bool(notices), + "RP2 build-tools owner %s lacks attribution evidence" % identifier, + ) + normalized_notices: list[dict[str, str]] = [] + seen_notice_paths: set[str] = set() + for notice_index, raw_notice in enumerate(notices): + notice = _exact_keys( + raw_notice, + {"path", "sha256"}, + "RP2 build-tools owner %s notice %d" + % (identifier, notice_index), + ) + logical, notice_path = _audit_rp2_logical_file( + notice["path"], + repo_root=root, + build_root=builds, + label="RP2 build-tools owner %s notice" % identifier, + ) + digest = _audit_v060_digest( + notice["sha256"], + "RP2 build-tools owner %s notice digest" % identifier, + ) + _require( + logical not in seen_notice_paths + and _sha256_path(notice_path) == digest, + "RP2 build-tools owner %s notice is duplicated or changed" + % identifier, + ) + seen_notice_paths.add(logical) + normalized_notices.append({"path": logical, "sha256": digest}) + policy_assets.add((logical, digest)) + _require( + normalized_notices + == sorted(normalized_notices, key=lambda item: item["path"]), + "RP2 build-tools owner %s notices are not canonical" % identifier, + ) + normalized_owners.append( + { + "id": identifier, + "source_roots": normalized_roots, + "source_ref": owner["source_ref"], + "source_url": owner["source_url"], + "source_spdx_expression": owner["source_spdx_expression"], + "selected_spdx_expression": owner["selected_spdx_expression"], + "copyright": owner["copyright"], + "license_texts": normalized_licenses, + "notice_files": normalized_notices, + "disposition": "allow", + "component_kind": "build-tool", + } + ) + expected_software = { + item["path"] + for item in expected_members + if item["kind"] == "regular" and item["path"] != ".keep" + } + _require( + owned_members == expected_software, + "RP2 build-tools policy does not own every software member exactly once", + ) + # Packaging provenance is deliberately not a payload owner. Its exact + # license byte and every component evidence byte are validated above and + # are added independently to the private observer receipt. + policy_assets.update(attribution_assets) + + raw_nonsoftware = policy["nonsoftware_members"] + expected_nonsoftware = [ + {**item, "classification": "packaging-metadata"} + for item in sorted(expected_members, key=lambda item: item["path"]) + if item["path"] in {".keep", "picotool/"} + ] + _require( + isinstance(raw_nonsoftware, list) + and raw_nonsoftware == expected_nonsoftware, + "RP2 build-tools nonsoftware member classification changed", + ) + for index, record in enumerate(raw_nonsoftware): + _exact_keys( + record, + {"path", "kind", "mode", "bytes", "sha256", "classification"}, + "RP2 build-tools nonsoftware member %d" % index, + ) + _require( + type(record["bytes"]) is int, + "RP2 build-tools nonsoftware byte count must be an exact integer", + ) + return { + "schema_version": 1, + "profile_id": "rpi-pico2-w", + "target": "rpi-pico2-w", + "distribution_provenance": { + "picotool_lock": copy.deepcopy(lock), + "retained_archive": copy.deepcopy(retained), + "member_inventory": expected_members, + "runtime_version_line": provenance["runtime_version_line"], + "source": copy.deepcopy(provenance["source"]), + "distribution": copy.deepcopy(provenance["distribution"]), + "attribution": { + "path": attribution_logical, + "sha256": attribution_digest, + }, + }, + "source_owners": normalized_owners, + "nonsoftware_members": copy.deepcopy(expected_nonsoftware), + } + + +def _audit_load_rp2_build_tools_license_policy( + repo_root: Path, + build_root: Path, + lock: dict[str, Any], +) -> dict[str, Any]: + """Load the separately locked v0.6.1-only picotool policy.""" + + inputs = lock.get("inputs") + _require(isinstance(inputs, dict), "release tool lock inputs are missing") + relative = inputs.get("rp2_build_tools_license_policy_path") + expected_hash = inputs.get("rp2_build_tools_license_policy_sha256") + _require( + relative == _PICOTOOL_BUILD_TOOLS_POLICY_PATH + and isinstance(expected_hash, str) + and SHA256_RE.fullmatch(expected_hash) is not None, + "release tool lock lacks the RP2 build-tools policy binding", + ) + path = _audit_repo_file( + Path(repo_root), + relative, + "RP2 build-tools license policy", + ) + raw = _read_regular_file_bytes(path, "RP2 build-tools license policy") + try: + value = json.loads(raw.decode("utf-8", errors="strict")) + except (UnicodeError, TypeError, ValueError) as exc: + raise ReleaseError("RP2 build-tools policy is invalid JSON") from exc + _require( + raw == _canonical_json_bytes(value), + "RP2 build-tools policy is not canonical JSON", + ) + _require( + _sha256_bytes(raw) == expected_hash, + "RP2 build-tools policy differs from release-tools.lock", + ) + versions = _read_lock(Path(repo_root)) + picotool_lock = _validate_v061_picotool_lock( + versions.get("picotool"), + "versions.lock [picotool]", + ) + return _audit_validate_rp2_build_tools_license_policy( + value, + repo_root=Path(repo_root), + build_root=Path(build_root), + picotool_lock=picotool_lock, + ) + + +def _audit_picotool_policy_input_hashes( + *, + policy: dict[str, Any], + repo_root: Path, + build_root: Path, +) -> dict[str, str]: + """Return every reviewed policy/provenance/license byte by logical path.""" + + root = Path(repo_root) + builds = Path(build_root) + result: dict[str, str] = {} + + def add(raw_path: str, raw_digest: str, label: str) -> None: + logical, path = _audit_rp2_logical_file( + raw_path, + repo_root=root, + build_root=builds, + label=label, + ) + persisted = _audit_rp2_policy_asset_logical(logical) + digest = _audit_v060_digest(raw_digest, "%s digest" % label) + _require(_sha256_path(path) == digest, "%s changed" % label) + previous = result.setdefault(persisted, digest) + _require(previous == digest, "%s has inconsistent digests" % label) + + policy_path = _audit_repo_file( + root, + _PICOTOOL_BUILD_TOOLS_POLICY_PATH, + "RP2 build-tools policy input", + ) + result["repo/" + _PICOTOOL_BUILD_TOOLS_POLICY_PATH] = _sha256_path( + policy_path + ) + attribution_record = policy["distribution_provenance"]["attribution"] + add( + attribution_record["path"], + attribution_record["sha256"], + "RP2 build-tools attribution input", + ) + for owner in policy["source_owners"]: + for record in (*owner["license_texts"], *owner["notice_files"]): + add( + record["path"], + record["sha256"], + "RP2 build-tools owner asset", + ) + + attribution_path = _audit_repo_file( + root, + attribution_record["path"], + "RP2 build-tools attribution input", + ) + attribution = _read_json(attribution_path, "RP2 build-tools attribution input") + + def walk(value: Any) -> None: + if isinstance(value, dict): + if ( + set(value) >= {"path", "sha256"} + and isinstance(value.get("path"), str) + and value["path"].startswith(("firmware/", "repo/", "build/")) + and isinstance(value.get("sha256"), str) + ): + add( + value["path"], + value["sha256"], + "RP2 build-tools nested attribution asset", + ) + for nested in value.values(): + walk(nested) + elif isinstance(value, list): + for nested in value: + walk(nested) + + walk(attribution) + return dict(sorted(result.items())) + + +def _audit_picotool_archive_inventory( + archive_path: Path, + expected_members: list[dict[str, Any]], + *, + expected_bytes: int, + expected_sha256: str, +) -> dict[str, bytes]: + """Reopen the retained ZIP and return exact regular member payloads.""" + + try: + archive_stat = archive_path.lstat() + archive_bytes = _audit_stable_regular_file_bytes( + archive_path, + "retained picotool archive", + ) + except OSError as exc: + raise ReleaseError("cannot read retained picotool archive") from exc + _require( + stat_module.S_ISREG(archive_stat.st_mode) + and not stat_module.S_ISLNK(archive_stat.st_mode) + and archive_stat.st_nlink == 1 + and len(archive_bytes) == expected_bytes + and _sha256_bytes(archive_bytes) == expected_sha256, + "retained picotool archive identity changed", + ) + try: + archive = zipfile.ZipFile(io.BytesIO(archive_bytes), "r") + except zipfile.BadZipFile as exc: + raise ReleaseError("retained picotool archive is not a valid ZIP") from exc + payloads: dict[str, bytes] = {} + with archive: + infos = archive.infolist() + _require( + len(infos) == len(expected_members), + "retained picotool archive member coverage changed", + ) + for index, (info, expected) in enumerate(zip(infos, expected_members)): + raw_mode = info.external_attr >> 16 + if info.is_dir(): + kind = "directory" + mode = stat_module.S_IFDIR | (stat_module.S_IMODE(raw_mode) or 0o755) + payload = b"" + else: + kind = "regular" + mode = stat_module.S_IFREG | (stat_module.S_IMODE(raw_mode) or 0o644) + try: + payload = archive.read(info) + except (OSError, RuntimeError, zipfile.BadZipFile) as exc: + raise ReleaseError( + "cannot read retained picotool ZIP member" + ) from exc + payloads[info.filename] = payload + actual = { + "path": info.filename, + "kind": kind, + "mode": "%06o" % mode, + "bytes": len(payload), + "sha256": _sha256_bytes(payload), + } + _require( + not bool(info.flag_bits & 0x1) + and actual == expected, + "retained picotool ZIP member %d changed" % index, + ) + _require( + archive.testzip() is None, + "retained picotool archive fails CRC validation", + ) + return payloads + + +def _audit_picotool_stat_identity(value: os.stat_result) -> tuple[int, ...]: + """Return the immutable identity fields used by the picotool observer.""" + + return ( + value.st_dev, + value.st_ino, + value.st_mode, + value.st_nlink, + value.st_size, + value.st_mtime_ns, + value.st_ctime_ns, + ) + + +def _audit_picotool_descriptor_bytes( + descriptor: int, + label: str, +) -> bytes: + """Read one already-open, unlinked-alias-free regular file.""" + + try: + os.lseek(descriptor, 0, os.SEEK_SET) + before = os.fstat(descriptor) + _require( + stat_module.S_ISREG(before.st_mode) and before.st_nlink == 1, + "%s must be one unlinked-alias-free regular file" % label, + ) + chunks: list[bytes] = [] + while True: + chunk = os.read(descriptor, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + after = os.fstat(descriptor) + except OSError as exc: + raise ReleaseError("%s changed while read" % label) from exc + _require( + _audit_picotool_stat_identity(before) + == _audit_picotool_stat_identity(after), + "%s changed while read" % label, + ) + payload = b"".join(chunks) + _require( + len(payload) == before.st_size, + "%s changed size while read" % label, + ) + return payload + + +def _audit_picotool_regular_path_snapshot( + path: Path, + label: str, + *, + expected_mode: int, +) -> tuple[bytes, tuple[int, ...]]: + """Read a regular path and retain its stable no-follow identity.""" + + source = Path(path) + descriptor: int | None = None + try: + path_before = source.lstat() + descriptor = os.open( + source, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0), + ) + opened = os.fstat(descriptor) + payload = _audit_picotool_descriptor_bytes(descriptor, label) + closed = os.fstat(descriptor) + path_after = source.lstat() + except OSError as exc: + raise ReleaseError("%s is missing, linked, or changed" % label) from exc + finally: + if descriptor is not None: + os.close(descriptor) + identity = _audit_picotool_stat_identity(path_before) + _require( + stat_module.S_ISREG(path_before.st_mode) + and path_before.st_nlink == 1 + and stat_module.S_IMODE(path_before.st_mode) == expected_mode + and identity + == _audit_picotool_stat_identity(opened) + == _audit_picotool_stat_identity(closed) + == _audit_picotool_stat_identity(path_after), + "%s identity or mode changed" % label, + ) + return payload, identity + + +def _audit_picotool_tree_snapshot( + *, + tree_root: Path, + expected: dict[str, tuple[str, int, bytes | None]], + root_mode: int, + label: str, +) -> dict[str, Any]: + """Observe one exact descriptor-anchored tree including node identities.""" + + observed: dict[ + str, + tuple[str, int, bytes | None, tuple[int, ...]], + ] = {} + root_descriptor: int | None = None + + def walk(descriptor: int, prefix: str) -> None: + try: + before = os.fstat(descriptor) + names_before = sorted(os.listdir(descriptor)) + except OSError as exc: + raise ReleaseError("cannot enumerate %s" % label) from exc + _require( + stat_module.S_ISDIR(before.st_mode), + "%s directory descriptor changed type" % label, + ) + for name in names_before: + _require( + isinstance(name, str) + and name not in {"", ".", ".."} + and "/" not in name + and "\x00" not in name, + "%s contains an unsafe member name" % label, + ) + relative = "%s/%s" % (prefix, name) if prefix else name + child_descriptor: int | None = None + try: + child_descriptor = os.open( + name, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0), + dir_fd=descriptor, + ) + child_before = os.fstat(child_descriptor) + if stat_module.S_ISDIR(child_before.st_mode): + observed[relative] = ( + "directory", + stat_module.S_IMODE(child_before.st_mode), + None, + _audit_picotool_stat_identity(child_before), + ) + walk(child_descriptor, relative) + elif stat_module.S_ISREG(child_before.st_mode): + payload = _audit_picotool_descriptor_bytes( + child_descriptor, + "pinned picotool member %s" % relative, + ) + observed[relative] = ( + "regular", + stat_module.S_IMODE(child_before.st_mode), + payload, + _audit_picotool_stat_identity(child_before), + ) + else: + raise ReleaseError( + "%s contains a special member: %s" % (label, relative) + ) + child_after = os.fstat(child_descriptor) + _require( + _audit_picotool_stat_identity(child_before) + == _audit_picotool_stat_identity(child_after), + "%s member changed while observed: %s" % (label, relative), + ) + except OSError as exc: + raise ReleaseError( + "%s member is missing, linked, or changed: %s" + % (label, relative) + ) from exc + finally: + if child_descriptor is not None: + os.close(child_descriptor) + try: + names_after = sorted(os.listdir(descriptor)) + after = os.fstat(descriptor) + except OSError as exc: + raise ReleaseError("%s changed while observed" % label) from exc + _require( + names_after == names_before + and _audit_picotool_stat_identity(before) + == _audit_picotool_stat_identity(after), + "%s directory changed while observed" % label, + ) + + try: + path_before = tree_root.lstat() + root_descriptor = os.open( + tree_root, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + root_opened = os.fstat(root_descriptor) + path_opened = tree_root.lstat() + _require( + stat_module.S_ISDIR(root_opened.st_mode) + and stat_module.S_IMODE(root_opened.st_mode) == root_mode + and _audit_picotool_stat_identity(path_before) + == _audit_picotool_stat_identity(root_opened) + == _audit_picotool_stat_identity(path_opened), + "%s root is unsafe or has the wrong mode" % label, + ) + walk(root_descriptor, "") + root_closed = os.fstat(root_descriptor) + path_closed = tree_root.lstat() + _require( + _audit_picotool_stat_identity(root_opened) + == _audit_picotool_stat_identity(root_closed) + == _audit_picotool_stat_identity(path_closed), + "%s root changed while observed" % label, + ) + except OSError as exc: + raise ReleaseError( + "%s root is missing, linked, or changed" % label + ) from exc + finally: + if root_descriptor is not None: + os.close(root_descriptor) + + _require( + set(observed) == set(expected), + "%s is incomplete or has unexpected members" % label, + ) + normalized: dict[ + str, + tuple[str, int, bytes | None, tuple[int, ...]], + ] = {} + for relative, (expected_kind, expected_mode, expected_payload) in expected.items(): + actual_kind, actual_mode, actual_payload, actual_identity = observed[relative] + _require( + ( + (expected_kind == "directory" and actual_kind == "directory") + or (expected_kind != "directory" and actual_kind == "regular") + ) + and actual_mode == expected_mode + and actual_payload == expected_payload, + "%s member differs from expected bytes: %s" % (label, relative), + ) + normalized[relative] = ( + expected_kind, + expected_mode, + actual_payload, + actual_identity, + ) + return { + "root_identity": _audit_picotool_stat_identity(root_opened), + "members": dict(sorted(normalized.items())), + } + + +def _audit_picotool_installed_tree( + *, + install_root: Path, + archive_payloads: dict[str, bytes], + member_inventory: list[dict[str, Any]], + archive_filename: str, + archive_bytes: bytes, +) -> dict[str, Any]: + """Read and validate the exact private install and its node identities.""" + + expected: dict[str, tuple[str, int, bytes | None]] = { + ".pyble-dist": ("directory", 0o755, None), + ".pyble-dist/" + archive_filename: ( + "archive", + 0o644, + archive_bytes, + ), + } + for member in member_inventory: + relative = member["path"].rstrip("/") + expected[relative] = ( + member["kind"], + int(member["mode"][-3:], 8), + archive_payloads.get(member["path"]), + ) + return _audit_picotool_tree_snapshot( + tree_root=install_root, + expected=expected, + root_mode=0o700, + label="pinned picotool install", + ) + + +def _audit_expected_rp2_build_tools_role( + *, + build_root: Path, + repo_root: Path, + provenance: dict[str, Any], + policy: dict[str, Any], +) -> tuple[dict[str, str], dict[str, Any]]: + """Construct the exact private role receipt from validated inputs.""" + + root = Path(repo_root) + builds = Path(build_root) + provenance_path = builds / "rpi-pico2-w" / "pyble-build-provenance.json" + provenance_bytes = _read_regular_file_bytes( + provenance_path, + "RP2 build provenance", + ) + try: + persisted_provenance = json.loads( + provenance_bytes.decode("utf-8", errors="strict") + ) + except (UnicodeError, TypeError, ValueError) as exc: + raise ReleaseError("RP2 build provenance is invalid JSON") from exc + _require( + persisted_provenance == provenance + and provenance.get("target") == "rpi-pico2-w" + and provenance.get("port") == "rp2" + and provenance.get("picotool") + == policy["distribution_provenance"]["runtime_version_line"], + "RP2 build provenance does not bind the pinned picotool runtime", + ) + input_hashes = { + "repo/firmware/versions.lock": _sha256_path( + root / "firmware" / "versions.lock" + ), + **_audit_picotool_policy_input_hashes( + policy=policy, + repo_root=root, + build_root=builds, + ), + } + distribution = policy["distribution_provenance"] + picotool_lock = distribution["picotool_lock"] + input_hashes[ + "picotool-archive/" + picotool_lock["archive_filename"] + ] = picotool_lock["sha256"] + input_hashes.update( + { + "picotool-member/" + member["path"]: member["sha256"] + for member in distribution["member_inventory"] + } + ) + input_hashes = dict(sorted(input_hashes.items())) + license_inputs = { + _audit_rp2_policy_asset_logical(record["path"]): { + "logical_path": _audit_rp2_policy_asset_logical(record["path"]), + "sha256": record["sha256"], + } + for owner in policy["source_owners"] + for record in (*owner["license_texts"], *owner["notice_files"]) + } + role_document = { + "schema_version": 1, + "profile_id": "rpi-pico2-w", + "target": "rpi-pico2-w", + "resource_kind": "rp2", + "role": RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE, + "build_provenance_sha256": _sha256_bytes(provenance_bytes), + "distribution_provenance": copy.deepcopy(distribution), + "owners": copy.deepcopy(policy["source_owners"]), + "nonsoftware_members": copy.deepcopy(policy["nonsoftware_members"]), + "inputs": [ + {"logical_path": logical, "sha256": digest} + for logical, digest in input_hashes.items() + ], + "license_inputs": sorted( + license_inputs.values(), + key=lambda item: item["logical_path"], + ), + } + return input_hashes, role_document + + +def _audit_run_picotool_snapshot( + *, + archive_payloads: dict[str, bytes], + member_inventory: list[dict[str, Any]], + executable_path: str, +) -> subprocess.CompletedProcess[str]: + """Execute a write-locked private tree while anchoring its executable.""" + + expected_regular = { + member["path"] + for member in member_inventory + if member["kind"] == "regular" + } + _require( + set(archive_payloads) == expected_regular, + "picotool snapshot payload coverage changed", + ) + with tempfile.TemporaryDirectory( + prefix="pyble-picotool-version-probe-" + ) as temporary: + probe_root = Path(temporary) + member_directories: list[Path] = [] + executable_descriptor: int | None = None + root_descriptor: int | None = None + try: + probe_root.chmod(0o700) + exact_expected: dict[str, tuple[str, int, bytes | None]] = {} + for member in sorted( + ( + item + for item in member_inventory + if item["kind"] == "directory" + ), + key=lambda item: len(PurePosixPath(item["path"]).parts), + ): + relative = _safe_relative_path( + member["path"].rstrip("/"), + "picotool snapshot directory", + ) + directory = probe_root / relative + directory.mkdir(mode=0o700) + directory.chmod(int(member["mode"][-3:], 8)) + member_directories.append(directory) + exact_expected[relative] = ( + "directory", + int(member["mode"][-3:], 8), + None, + ) + + for member in ( + item + for item in member_inventory + if item["kind"] == "regular" + ): + relative = _safe_relative_path( + member["path"], + "picotool snapshot member", + ) + target = probe_root / relative + _require( + target.parent.is_dir() + and not target.parent.is_symlink(), + "picotool snapshot member parent is missing", + ) + descriptor: int | None = None + try: + descriptor = os.open( + target, + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + payload = archive_payloads[member["path"]] + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + _require( + written > 0, + "picotool snapshot member write made no progress", + ) + view = view[written:] + os.fchmod(descriptor, int(member["mode"][-3:], 8)) + os.fsync(descriptor) + finally: + if descriptor is not None: + os.close(descriptor) + exact_expected[relative] = ( + "regular", + int(member["mode"][-3:], 8), + archive_payloads[member["path"]], + ) + + _audit_picotool_tree_snapshot( + tree_root=probe_root, + expected=exact_expected, + root_mode=0o700, + label="private picotool snapshot", + ) + + # Remove every write bit before opening the executable. Darwin + # cannot execute a Mach-O image through /dev/fd, and picotool's + # @loader_path dependency also requires its real private path. + # A write-locked directory chain plus a retained no-follow file + # descriptor therefore forms the portable execution anchor. + locked_expected: dict[str, tuple[str, int, bytes | None]] = {} + for relative, (kind, mode, payload) in exact_expected.items(): + locked_mode = mode & ~0o222 + (probe_root / relative).chmod(locked_mode) + locked_expected[relative] = (kind, locked_mode, payload) + probe_root.chmod(0o500) + locked_snapshot = _audit_picotool_tree_snapshot( + tree_root=probe_root, + expected=locked_expected, + root_mode=0o500, + label="locked private picotool snapshot", + ) + _require( + os.name == "posix" and hasattr(os, "fchdir"), + "private picotool probe requires descriptor-anchored POSIX cwd", + ) + root_descriptor = os.open( + probe_root, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + root_before = os.fstat(root_descriptor) + _require( + _audit_picotool_stat_identity(root_before) + == locked_snapshot["root_identity"], + "private picotool snapshot root is not anchored", + ) + + executable_relative = _safe_relative_path( + executable_path, + "picotool snapshot executable", + ) + executable = probe_root / executable_relative + executable_descriptor = os.open( + executable, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0), + ) + executable_before = os.fstat(executable_descriptor) + executable_bytes = _audit_picotool_descriptor_bytes( + executable_descriptor, + "private picotool snapshot executable", + ) + executable_record = locked_snapshot["members"].get( + executable_relative + ) + _require( + executable_record is not None + and executable_record[0] == "regular" + and executable_record[2] == executable_bytes + and executable_record[3] + == _audit_picotool_stat_identity(executable_before), + "private picotool snapshot executable is not anchored", + ) + command = ("./" + executable_relative, "version") + + def enter_anchored_probe_root() -> None: + os.fchdir(root_descriptor) + + completed = subprocess.run( + command, + cwd=probe_root, + env={ + "HOME": os.devnull, + "LANG": "C", + "LC_ALL": "C", + "PATH": "/usr/bin:/bin", + }, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=30, + pass_fds=(root_descriptor,), + preexec_fn=enter_anchored_probe_root, + ) + root_after = os.fstat(root_descriptor) + executable_after = os.fstat(executable_descriptor) + final_executable_bytes = _audit_picotool_descriptor_bytes( + executable_descriptor, + "private picotool snapshot executable after probe", + ) + final_locked_snapshot = _audit_picotool_tree_snapshot( + tree_root=probe_root, + expected=locked_expected, + root_mode=0o500, + label="locked private picotool snapshot after probe", + ) + _require( + isinstance(completed, subprocess.CompletedProcess) + and completed.args == command + and _audit_picotool_stat_identity(root_before) + == _audit_picotool_stat_identity(root_after) + and _audit_picotool_stat_identity(executable_before) + == _audit_picotool_stat_identity(executable_after) + and final_executable_bytes == executable_bytes + and final_locked_snapshot == locked_snapshot, + "private picotool snapshot changed during version probe", + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ReleaseError("cannot execute private picotool snapshot") from exc + finally: + if executable_descriptor is not None: + os.close(executable_descriptor) + if root_descriptor is not None: + os.close(root_descriptor) + # Restore only directory write/search permission so the private + # temporary tree can be removed; none of these bytes are reused. + try: + probe_root.chmod(0o700) + for directory in member_directories: + directory.chmod(0o700) + except OSError: + pass + return completed + + +def _audit_observe_rp2_build_tools_license_inputs( + *, + build_root: Path, + repo_root: Path, + provenance: dict[str, Any], + policy: dict[str, Any], +) -> dict[str, Any]: + """Reopen the exact host picotool distribution used by the RP2 build.""" + + root = Path(repo_root).absolute() + builds = Path(build_root).absolute() + lock = _validate_v061_picotool_lock( + _read_lock(root).get("picotool"), + "versions.lock [picotool]", + ) + validated = _audit_validate_rp2_build_tools_license_policy( + policy, + repo_root=root, + build_root=builds, + picotool_lock=lock, + ) + distribution = validated["distribution_provenance"] + retained_logical, archive_path = _audit_rp2_logical_file( + distribution["retained_archive"]["path"], + repo_root=root, + build_root=builds, + label="retained picotool archive", + ) + _require( + retained_logical == distribution["retained_archive"]["path"], + "retained picotool archive namespace changed", + ) + archive_bytes, archive_identity = _audit_picotool_regular_path_snapshot( + archive_path, + "retained picotool archive", + expected_mode=0o644, + ) + archive_payloads = _audit_picotool_archive_inventory( + archive_path, + distribution["member_inventory"], + expected_bytes=lock["archive_bytes"], + expected_sha256=lock["sha256"], + ) + install_root = root / "firmware" / ".picotool" + installed_snapshot = _audit_picotool_installed_tree( + install_root=install_root, + archive_payloads=archive_payloads, + member_inventory=distribution["member_inventory"], + archive_filename=lock["archive_filename"], + archive_bytes=archive_bytes, + ) + completed = _audit_run_picotool_snapshot( + archive_payloads=archive_payloads, + member_inventory=distribution["member_inventory"], + executable_path=lock["executable_path"], + ) + _require( + completed.returncode == 0 + and completed.stdout == lock["version_line"] + "\n", + "retained picotool runtime version changed", + ) + input_hashes, role_document = _audit_expected_rp2_build_tools_role( + build_root=builds, + repo_root=root, + provenance=provenance, + policy=validated, + ) + final_archive_bytes, final_archive_identity = ( + _audit_picotool_regular_path_snapshot( + archive_path, + "retained picotool archive after version probe", + expected_mode=0o644, + ) + ) + final_archive_payloads = _audit_picotool_archive_inventory( + archive_path, + distribution["member_inventory"], + expected_bytes=lock["archive_bytes"], + expected_sha256=lock["sha256"], + ) + final_installed_snapshot = _audit_picotool_installed_tree( + install_root=install_root, + archive_payloads=final_archive_payloads, + member_inventory=distribution["member_inventory"], + archive_filename=lock["archive_filename"], + archive_bytes=final_archive_bytes, + ) _require( - [owner["id"] for owner in owners] - == sorted(owner["id"] for owner in owners), - "RP2 license policy owners are not canonical", + final_archive_bytes == archive_bytes + and final_archive_identity == archive_identity + and final_archive_payloads == archive_payloads + and final_installed_snapshot == installed_snapshot, + "retained picotool distribution changed during version probe", ) + payload = { + "input_sha256": input_hashes, + "owners": copy.deepcopy(validated["source_owners"]), + "notice_records": [], + "role_document": role_document, + } return { - "schema_version": schema_version, - "profile_id": "rpi-pico2-w", - "target": "rpi-pico2-w", - "source_owners": owners, + "semantic_sha256": _sha256_bytes(_canonical_json_bytes(payload)), + **payload, } -def _audit_load_rp2_license_policy( - repo_root: Path, - build_root: Path, - lock: dict[str, Any], +def _audit_merge_rp2_build_tools_observation( + base: dict[str, Any], + build_tools: dict[str, Any], ) -> dict[str, Any]: - inputs = lock.get("inputs") - _require(isinstance(inputs, dict), "release tool lock inputs are missing") - relative = inputs.get("rp2_license_policy_path") - expected_hash = inputs.get("rp2_license_policy_sha256") + """Append private build-tool semantics without altering public notices.""" + + required = { + "semantic_sha256", + "input_sha256", + "owners", + "notice_records", + "role_document", + } _require( - isinstance(relative, str) - and isinstance(expected_hash, str) - and SHA256_RE.fullmatch(expected_hash) is not None, - "release tool lock lacks the RP2 license-policy binding", + isinstance(build_tools, dict) + and set(build_tools) == required, + "RP2 build-tools observation shape changed", ) - path = _audit_repo_file( - Path(repo_root), - relative, - "RP2 release license policy", + build_payload = { + key: copy.deepcopy(value) + for key, value in build_tools.items() + if key != "semantic_sha256" + } + _require( + build_tools["semantic_sha256"] + == _sha256_bytes(_canonical_json_bytes(build_payload)) + and build_tools["notice_records"] == [], + "RP2 build-tools semantic observation changed", + ) + combined = copy.deepcopy(base) + _require( + isinstance(combined.get("input_sha256"), dict) + and isinstance(combined.get("owners"), list) + and isinstance(combined.get("role_documents"), dict) + and RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE + not in combined["role_documents"], + "RP2 base observation cannot accept the build-tools role", + ) + for logical, digest in build_tools["input_sha256"].items(): + previous = combined["input_sha256"].get(logical) + _require( + previous is None or previous == digest, + "RP2 observations disagree on a shared input", + ) + combined["input_sha256"][logical] = digest + combined["owners"].extend(copy.deepcopy(build_tools["owners"])) + combined["role_documents"][RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE] = copy.deepcopy( + build_tools["role_document"] + ) + payload = { + key: copy.deepcopy(value) + for key, value in combined.items() + if key != "semantic_sha256" + } + combined["semantic_sha256"] = _sha256_bytes(_canonical_json_bytes(payload)) + return combined + + +def _audit_validate_persisted_rp2_build_tools_role( + *, + observation: dict[str, Any], + build_root: Path, + repo_root: Path, +) -> dict[str, Any]: + """Reject a self-consistently rehashed private role before publication.""" + + root = Path(repo_root) + builds = Path(build_root) + lock = _validate_v061_picotool_lock( + _read_lock(root).get("picotool"), + "versions.lock [picotool]", + ) + policy_path = _audit_repo_file( + root, + _PICOTOOL_BUILD_TOOLS_POLICY_PATH, + "RP2 build-tools license policy", + ) + policy_raw = _read_regular_file_bytes( + policy_path, + "RP2 build-tools license policy", ) - raw = _read_regular_file_bytes(path, "RP2 release license policy") try: - value = json.loads(raw.decode("utf-8", errors="strict")) + policy_value = json.loads(policy_raw.decode("utf-8", errors="strict")) except (UnicodeError, TypeError, ValueError) as exc: - raise ReleaseError("RP2 release license policy is invalid JSON") from exc + raise ReleaseError("RP2 build-tools policy is invalid JSON") from exc _require( - raw == _canonical_json_bytes(value), - "RP2 release license policy is not canonical JSON", + policy_raw == _canonical_json_bytes(policy_value), + "RP2 build-tools policy is not canonical JSON", ) + policy = _audit_validate_rp2_build_tools_license_policy( + policy_value, + repo_root=root, + build_root=builds, + picotool_lock=lock, + ) + provenance = _read_json( + builds / "rpi-pico2-w" / "pyble-build-provenance.json", + "RP2 build provenance", + ) + expected_inputs, expected_document = _audit_expected_rp2_build_tools_role( + build_root=builds, + repo_root=root, + provenance=provenance, + policy=policy, + ) + role_documents = observation.get("role_documents") _require( - _sha256_bytes(raw) == expected_hash, - "RP2 release license policy differs from release-tools.lock", + isinstance(role_documents, dict) + and role_documents.get(RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE) + == expected_document, + "RP2 build-tools evidence differs from reviewed inputs", ) - return _audit_validate_rp2_license_policy( - value, - repo_root=Path(repo_root), - build_root=Path(build_root), + input_hashes = observation.get("input_sha256") + _require( + isinstance(input_hashes, dict) + and all( + input_hashes.get(key) == digest + for key, digest in expected_inputs.items() + ), + "RP2 build-tools receipt input hashes changed", + ) + owners = observation.get("owners") + build_owner_ids = {owner["id"] for owner in policy["source_owners"]} + _require( + isinstance(owners, list) + and [owner for owner in owners if owner.get("id") in build_owner_ids] + == policy["source_owners"] + and all( + record.get("id") not in build_owner_ids + for record in observation.get("notice_records", []) + ), + "RP2 build-tool owners leaked into or changed outside private evidence", ) + semantic_payload = { + key: copy.deepcopy(value) + for key, value in observation.items() + if key != "semantic_sha256" + } + _require( + observation.get("semantic_sha256") + == _sha256_bytes(_canonical_json_bytes(semantic_payload)), + "RP2 combined semantic observation changed", + ) + return expected_document def _audit_validate_rp2_arm_runtime_closure( @@ -8694,6 +10642,91 @@ def _audit_rp2_reject_driver_overrides( ) +def _audit_rp2_build_environment( + builder_text: str, + *, + firmware_version: str, +) -> list[str]: + """Parse one scrub command and its source-era-specific offline setup.""" + + marker = ( + "# Never allow ambient compiler/make flags to influence the pinned build.\n" + "unset \\\n" + ) + end = '\n\nmake -C "$RETAINED_UPSTREAM/mpy-cross"' + _require( + isinstance(builder_text, str) + and builder_text.count(marker) == 1 + and builder_text.count(end) == 1, + "RP2 build driver environment scrub is missing or ambiguous", + ) + before_make = builder_text.split(marker, 1)[1] + _require(end in before_make, "RP2 environment scrub does not precede mpy-cross") + section = before_make.split(end, 1)[0] + scrub, separator, setup = section.partition("\n\n") + lines = scrub.splitlines() + variables: list[str] = [] + for index, line in enumerate(lines): + continued = line.endswith(" \\") + value = line[2:-2] if continued else line[2:] + _require( + line.startswith(" ") + and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value) is not None + and continued == (index < len(lines) - 1), + "RP2 build driver environment scrub is malformed", + ) + variables.append(value) + _require( + len(variables) == len(set(variables)) + and set(_RP2_GCC_ENVIRONMENT_OVERRIDES) <= set(variables), + "RP2 build driver does not scrub every GCC resolution override", + ) + pinned_picotool = _firmware_release_core( + firmware_version, "RP2 environment source version" + ) >= (0, 6, 1) + if pinned_picotool: + _require( + { + "CMAKE_ARGS", "picotool_DIR", "FETCHCONTENT_FULLY_DISCONNECTED", + "FETCHCONTENT_SOURCE_DIR_PICOTOOL", "PICOTOOL_FETCH_FROM_GIT_PATH", + "PICOTOOL_FORCE_FETCH_FROM_GIT", "CMAKE_FIND_USE_PACKAGE_REGISTRY", + "CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY", "CMAKE_PREFIX_PATH", + } <= set(variables), + "RP2 build driver does not scrub every pinned package override", + ) + expected_setup = r'''# Bind Pico SDK's CMake configure to the verified package and make every +# fallback/network or ambient package-registry path unavailable. These values +# are supplied both in CMAKE_ARGS (the MicroPython rp2 Makefile interface) and +# the environment so nested configure steps cannot inherit hostile settings. +CMAKE_ARGS="-Dpicotool_DIR=$PICOTOOL_PACKAGE_DIR \ +-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON \ +-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ +-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ +-DPICOTOOL_FORCE_FETCH_FROM_GIT=OFF" +picotool_DIR="$PICOTOOL_PACKAGE_DIR" +FETCHCONTENT_FULLY_DISCONNECTED=ON +PICOTOOL_FORCE_FETCH_FROM_GIT=OFF +CMAKE_FIND_USE_PACKAGE_REGISTRY=OFF +CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF +export \ + CMAKE_ARGS \ + picotool_DIR \ + FETCHCONTENT_FULLY_DISCONNECTED \ + PICOTOOL_FORCE_FETCH_FROM_GIT \ + CMAKE_FIND_USE_PACKAGE_REGISTRY \ + CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY''' + _require( + separator == "\n\n" and setup == expected_setup, + "RP2 build driver pinned offline environment setup changed", + ) + else: + _require( + not separator and not setup, + "historical RP2 scrub has unexpected intervening commands", + ) + return variables + + def _audit_observe_rp2_arm_runtime_closure( *, repo_root: Path, @@ -8729,33 +10762,9 @@ def _audit_observe_rp2_arm_runtime_closure( builder_text = _read_regular_file_bytes( builder_path, "RP2 retained-source build driver" ).decode("utf-8", errors="strict") - scrub_marker = ( - "# Never allow ambient compiler/make flags to influence the pinned build.\n" - "unset \\\n" - ) - scrub_end = '\n\nmake -C "$RETAINED_UPSTREAM/mpy-cross"' - _require( - builder_text.count(scrub_marker) == 1 - and builder_text.count(scrub_end) == 1, - "RP2 build driver environment scrub is missing or ambiguous", - ) - scrub_source = builder_text.split(scrub_marker, 1)[1].split(scrub_end, 1)[0] - scrub_lines = scrub_source.splitlines() - scrubbed_variables: list[str] = [] - for index, line in enumerate(scrub_lines): - continued = line.endswith(" \\") - value = line[:-2] if continued else line - _require( - line.startswith(" ") - and re.fullmatch(r"[A-Z][A-Z0-9_]*", value.strip()) is not None - and continued == (index < len(scrub_lines) - 1), - "RP2 build driver environment scrub is malformed", - ) - scrubbed_variables.append(value.strip()) - _require( - len(scrubbed_variables) == len(set(scrubbed_variables)) - and set(_RP2_GCC_ENVIRONMENT_OVERRIDES) <= set(scrubbed_variables), - "RP2 build driver does not scrub every GCC resolution override", + _audit_rp2_build_environment( + builder_text, + firmware_version=_read_lock(root)["pyble"]["agent_version"], ) cache_text = _read_regular_file_bytes(cache, "RP2 CMake cache").decode( @@ -10263,6 +12272,7 @@ def _audit_rp2_retained_board_inputs( overlay.is_dir() and pyble.is_dir(), "RP2 retained board copy lacks canonical source directories", ) + source_lock = _read_lock(Path(repo_root)) overlay_names = ( "_boot.py", @@ -10283,6 +12293,11 @@ def _audit_rp2_retained_board_inputs( "pyble_proto.py", "pyble_runner.py", ) + if _firmware_release_core( + source_lock["pyble"]["agent_version"], + "firmware source version", + ) >= (0, 6, 1): + pyble_names += ("pyble_workspace.py",) expected_copies = { **{name: overlay / name for name in overlay_names}, **{"pyble/%s" % name: pyble / name for name in pyble_names}, @@ -10344,7 +12359,6 @@ def _audit_rp2_retained_board_inputs( } ) - source_lock = _read_lock(Path(repo_root)) expected_version = ( "# SPDX-License-Identifier: MIT\n" "# GENERATED by firmware/scripts/build_rp2.sh from " @@ -12024,9 +14038,12 @@ def _audit_compose_v060_license_evidence( repo_root: Path, esp_notice: str, rp2_observation: dict[str, Any], + firmware_version: str, ) -> dict[str, Any]: """Compose canonical heterogeneous evidence from two independently replayed audits.""" + _require_v5_release_version(firmware_version, "heterogeneous license composition") + profile_order = _release_profile_order_for_version(firmware_version) _require(not destination.exists(), "v0.6 composition destination exists") esp_receipt = _read_json( esp_evidence / "audit-receipt.json", "ESP license audit receipt" @@ -12049,11 +14066,18 @@ def _audit_compose_v060_license_evidence( "v0.6 composition requires schema-1 ESP evidence", ) role_documents = rp2_observation["role_documents"] + selected_rp2_roles = _rp2_license_audit_roles_for_version(firmware_version) _require( isinstance(role_documents, dict) - and list(role_documents) == list(RP2_LICENSE_AUDIT_ROLES), + and list(role_documents) == list(selected_rp2_roles), "RP2 role documents are missing or reordered", ) + if RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE in selected_rp2_roles: + _audit_validate_persisted_rp2_build_tools_role( + observation=rp2_observation, + build_root=Path(build_root), + repo_root=Path(repo_root), + ) notice = _audit_merge_release_notices( esp_notice=esp_notice, rp2_notice_records=rp2_observation["notice_records"], @@ -12061,7 +14085,7 @@ def _audit_compose_v060_license_evidence( destination.mkdir() (destination / "raw").mkdir() (destination / "spdx").mkdir() - for profile_id in V060_RELEASE_PROFILE_ORDER[:-1]: + for profile_id in profile_order[:-1]: for role in ("application", "bootloader"): candidates = [ esp_evidence / "raw" / ("%s--%s.%s" % (profile_id, role, extension)) @@ -12080,14 +14104,14 @@ def _audit_compose_v060_license_evidence( ) rp2_dir = destination / "rp2" rp2_dir.mkdir() - for role in RP2_LICENSE_AUDIT_ROLES: + for role in selected_rp2_roles: (rp2_dir / ("rpi-pico2-w--%s.json" % role)).write_bytes( _canonical_json_bytes(role_documents[role]) ) profiles: list[dict[str, Any]] = [] identities: list[dict[str, str]] = [] - for contract in _release_license_inventory_for_version("0.6.0"): + for contract in _release_license_inventory_for_version(firmware_version): profile_id = contract["profile_id"] target = contract["target"] provenance = Path(build_root) / target / "pyble-build-provenance.json" @@ -12136,8 +14160,8 @@ def _audit_compose_v060_license_evidence( notice_digest = _sha256_bytes(notice.encode("utf-8")) inventory = { "schema_version": 1, - "firmware_version": "0.6.0", - "profile_order": list(V060_RELEASE_PROFILE_ORDER), + "firmware_version": firmware_version, + "profile_order": list(profile_order), "notice_sha256": notice_digest, "profiles": profiles, } @@ -14547,13 +16571,13 @@ def resolve( source = repo_root / "firmware" / destination elif ( base_value == "$(BOARD_DIR)/pyble" - and destination == "_version.py" + and destination in {"_version.py", "pyble_workspace.py"} and not source.exists() ): - # The reviewed source is a dev-only template. ESP - # builds overwrite the copied board-tree module from - # versions.lock before freezing it; payload proof - # below validates those exact generated bytes. + # prepare.sh materializes both canonical modules in the + # generated ESP board tree. _version.py is overwritten + # from versions.lock; payload proof below validates its + # exact generated bytes. source = repo_root / "firmware" / "pyble" / destination elif ( base_value == "$(BOARD_DIR)" @@ -14848,6 +16872,92 @@ def _firmware_release_core( return int(major), int(minor), int(patch) +def _require_v5_release_version(firmware_version: str, label: str) -> str: + """Admit canonical SemVer whose release core is approved for V5.""" + + release_core = _firmware_release_core(firmware_version, label) + _require( + release_core in V5_RELEASE_CORES, + "%s requires firmware release core 0.6.0 or 0.6.1" % label, + ) + return firmware_version + + +def _is_v061_hardening_version(firmware_version: str) -> bool: + """Return whether an exact source identity admits v0.6.1 hardening.""" + + return firmware_version == V061_HARDENING_VERSION + + +def _v061_hardening_gate() -> Any: + """Load the repository-owned v0.6.1 gate without extending sys.path.""" + + global _V061_HARDENING_GATE + global _V061_HARDENING_GATE_SOURCE_SHA256 + if _V061_HARDENING_GATE is not None: + return _V061_HARDENING_GATE + try: + module = _load_v061_hardening_gate() + source = Path(module.__file__).resolve(strict=True) + source_raw = source.read_bytes() + except Exception as exc: + raise ReleaseError( + "cannot load the v0.6.1 hardening qualification validator" + ) from exc + expected_targets = { + profile_id: PROFILE_SPECS[profile_id]["target"] + for profile_id in V060_RELEASE_PROFILE_ORDER + } + _require( + source == _V061_HARDENING_GATE_SOURCE + and module.PROFILE_TARGETS == expected_targets + and tuple(module.SCENARIO_ORDER) == V061_HARDENING_SCENARIO_ORDER + and tuple(module.WORKSPACE_PROVISIONING_ORDER) + == V061_HARDENING_WORKSPACE_ORDER, + "v0.6.1 hardening validator authority differs from the release contract", + ) + _V061_HARDENING_GATE_SOURCE_SHA256 = hashlib.sha256(source_raw).hexdigest() + _V061_HARDENING_GATE = module + return module + + +def _validate_v061_hardening_summary( + value: Any, + label: str, +) -> dict[str, Any]: + """Validate the exact privacy-safe summary derived by the private gate.""" + + summary = _exact_keys( + value, + set(V061_HARDENING_SUMMARY_FIELDS), + label, + ) + scenarios = _exact_keys( + summary["scenarios"], + set(V061_HARDENING_SCENARIO_ORDER), + "%s scenarios" % label, + ) + workspace = _exact_keys( + summary["workspace_provisioning"], + set(V061_HARDENING_WORKSPACE_ORDER), + "%s workspace provisioning" % label, + ) + _require( + summary["measurement_contract"] + == V061_HARDENING_MEASUREMENT_CONTRACT + and summary["scenario_order"] + == list(V061_HARDENING_SCENARIO_ORDER) + and all(scenarios[name] == "passed" for name in scenarios) + and type(summary["sequential_runs"]) is int + and summary["sequential_runs"] == 50 + and all(workspace[name] == "passed" for name in workspace) + and type(summary["private_result_sha256"]) is str + and SHA256_RE.fullmatch(summary["private_result_sha256"]) is not None, + "%s is incomplete, reordered, or not passing" % label, + ) + return summary + + def _release_profile_order_for_version( firmware_version: str, ) -> tuple[str, ...]: @@ -14906,6 +17016,17 @@ def _require_source_era_evidence_count( return evidence +def _rp2_license_audit_roles_for_version( + firmware_version: str, +) -> tuple[str, ...]: + """Select the immutable RP2 evidence roles from the candidate version.""" + + core = _firmware_release_core(firmware_version, "firmware source version") + if core == (0, 6, 1): + return (*RP2_LICENSE_AUDIT_ROLES, RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE) + return RP2_LICENSE_AUDIT_ROLES + + def _release_license_inventory_for_version( firmware_version: str, ) -> list[dict[str, Any]]: @@ -14921,7 +17042,7 @@ def _release_license_inventory_for_version( "target": spec["target"], "resource_kind": "rp2" if is_rp2 else "esp-idf", "roles": ( - list(RP2_LICENSE_AUDIT_ROLES) + list(_rp2_license_audit_roles_for_version(firmware_version)) if is_rp2 else ["application", "bootloader"] ), @@ -15122,12 +17243,16 @@ def _audit_verify_rp2_semantic_replay( build_root: Path, repo_root: Path, provenance: dict[str, Any], + firmware_version: str = "0.6.0", ) -> dict[str, Any]: """Repeat the private RP2 closure observation at public validation time.""" root = Path(repo_root) builds = Path(build_root) - tool_lock = _audit_load_tool_lock(root) + tool_lock = _audit_load_tool_lock( + root, + firmware_version=firmware_version, + ) policy = _audit_load_rp2_license_policy(root, builds, tool_lock) replay = _audit_observe_rp2_license_inputs( build_root=builds, @@ -15135,6 +17260,24 @@ def _audit_verify_rp2_semantic_replay( provenance=provenance, policy=policy, ) + if ( + RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE + in _rp2_license_audit_roles_for_version(firmware_version) + ): + build_tools_policy = _audit_load_rp2_build_tools_license_policy( + root, + builds, + tool_lock, + ) + replay = _audit_merge_rp2_build_tools_observation( + replay, + _audit_observe_rp2_build_tools_license_inputs( + build_root=builds, + repo_root=root, + provenance=provenance, + policy=build_tools_policy, + ), + ) if policy["schema_version"] == 2: _require( replay.get("arm_runtime_closure", {}).get("eligible_compilation") @@ -15181,17 +17324,17 @@ def _audit_verify_release_inventory_evidence( notice: str, firmware_version: str, ) -> dict[str, Any]: - """Recompute the persisted heterogeneous v0.6 license inventory. + """Recompute the persisted heterogeneous V5 license inventory. The eight ESP raw/reviewed documents remain the outputs of the pinned ESP-IDF audit. RP2 evidence is deliberately port-discriminated and binds real build/source/license bytes; it is never synthesized from ESP SBOM - records. This verifier only admits the exact schema frozen for v0.6.0. + records. This verifier admits only the exact approved V5 release identity. """ - _require( - firmware_version == "0.6.0", - "heterogeneous release-license evidence is exact to v0.6.0", + _require_v5_release_version( + firmware_version, + "heterogeneous release-license evidence", ) evidence = Path(evidence_dir) builds = Path(build_root) @@ -15475,11 +17618,62 @@ def _audit_verify_release_inventory_evidence( and _sha256_path(actual_evidence[expected_path]) == evidence_digest, "RP2 %s evidence is missing or changed" % role, ) + raw_document = _audit_v060_canonical_json( + actual_evidence[expected_path], + "RP2 %s evidence" % role, + ) + if role == RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE: + document = _exact_keys( + raw_document, + { + "schema_version", + "profile_id", + "target", + "resource_kind", + "role", + "build_provenance_sha256", + "distribution_provenance", + "owners", + "nonsoftware_members", + "inputs", + "license_inputs", + }, + "RP2 build-tools evidence", + ) + picotool_lock = _validate_v061_picotool_lock( + lock.get("picotool"), + "versions.lock [picotool]", + ) + policy_value = _read_json( + root / _PICOTOOL_BUILD_TOOLS_POLICY_PATH, + "RP2 build-tools license policy", + ) + build_tools_policy = ( + _audit_validate_rp2_build_tools_license_policy( + policy_value, + repo_root=root, + build_root=builds, + picotool_lock=picotool_lock, + ) + ) + _inputs, expected_document = _audit_expected_rp2_build_tools_role( + build_root=builds, + repo_root=root, + provenance=provenance, + policy=build_tools_policy, + ) + _require( + document == expected_document + and document["build_provenance_sha256"] == provenance_digest, + "RP2 build-tools evidence differs from reviewed inputs", + ) + rp2_documents[role] = copy.deepcopy(document) + expected_identities.append( + {"profile_id": profile_id, "role": role} + ) + continue document = _exact_keys( - _audit_v060_canonical_json( - actual_evidence[expected_path], - "RP2 %s evidence" % role, - ), + raw_document, { "schema_version", "profile_id", @@ -15644,6 +17838,7 @@ def _audit_verify_release_inventory_evidence( build_root=builds, repo_root=root, provenance=rp2_provenance, + firmware_version=firmware_version, ) return inventory @@ -16399,7 +18594,9 @@ def _audit_frozen_payload_proof( if resolved_source.is_relative_to(overlay_root): copied_relative = destination elif resolved_source.is_relative_to(pyble_root): - copied_relative = destination + copied_relative = ( + "pyble/" + resolved_source.relative_to(pyble_root).as_posix() + ) else: continue copied_bytes = _audit_retained_generated_board_snapshot_file( @@ -19498,7 +21695,11 @@ def audit_release_licenses( "license evidence must be outside source and build roots", ) - lock = _audit_load_tool_lock(root) + firmware_version = _read_lock(root)["pyble"]["agent_version"] + lock = _audit_load_tool_lock( + root, + firmware_version=firmware_version, + ) excluded = _audit_repo_file( root, lock["inputs"]["excluded_cves_path"], @@ -19514,15 +21715,27 @@ def audit_release_licenses( "excluded CVE input must remain semantically empty", ) policy = _audit_load_policy(root, lock) - firmware_version = _read_lock(root)["pyble"]["agent_version"] if tuple(_release_profile_order_for_version(firmware_version)) == ( V060_RELEASE_PROFILE_ORDER ): + _require_v5_release_version( + firmware_version, + "heterogeneous release-license audit", + ) _require( not evidence.exists() and evidence.parent.is_dir(), "v0.6 license evidence destination must not exist", ) rp2_policy = _audit_load_rp2_license_policy(root, builds, lock) + include_rp2_build_tools = ( + RP2_BUILD_TOOLS_LICENSE_AUDIT_ROLE + in _rp2_license_audit_roles_for_version(firmware_version) + ) + rp2_build_tools_policy = ( + _audit_load_rp2_build_tools_license_policy(root, builds, lock) + if include_rp2_build_tools + else None + ) rp2_provenance = _read_json( builds / "rpi-pico2-w" / "pyble-build-provenance.json", "RP2 build provenance", @@ -19533,6 +21746,16 @@ def audit_release_licenses( provenance=rp2_provenance, policy=rp2_policy, ) + if rp2_build_tools_policy is not None: + initial_rp2 = _audit_merge_rp2_build_tools_observation( + initial_rp2, + _audit_observe_rp2_build_tools_license_inputs( + build_root=builds, + repo_root=root, + provenance=rp2_provenance, + policy=rp2_build_tools_policy, + ), + ) _require( all( owner.get("disposition") != "review-required" @@ -19563,6 +21786,16 @@ def audit_release_licenses( provenance=rp2_provenance, policy=rp2_policy, ) + if rp2_build_tools_policy is not None: + final_rp2 = _audit_merge_rp2_build_tools_observation( + final_rp2, + _audit_observe_rp2_build_tools_license_inputs( + build_root=builds, + repo_root=root, + provenance=rp2_provenance, + policy=rp2_build_tools_policy, + ), + ) required_semantics = { "semantic_sha256", "input_sha256", @@ -19596,13 +21829,14 @@ def audit_release_licenses( repo_root=root, esp_notice=esp_result["third_party_licenses"], rp2_observation=initial_rp2, + firmware_version=firmware_version, ) _audit_verify_release_inventory_evidence( evidence_dir=composed, build_root=builds, repo_root=root, notice=result["third_party_licenses"], - firmware_version="0.6.0", + firmware_version=firmware_version, ) _atomic_publish_no_replace( composed, @@ -19734,7 +21968,12 @@ def _audit_verify_packaged_build( target = spec["target"] target_build = build_root / target validated = ( - validate_rp2_build(target, target_build) + _validate_rp2_build_for_firmware_version( + target, + target_build, + repo_root=None, + firmware_version=version, + ) if spec["port"] == "rp2" else validate_build(target, target_build) ) @@ -19819,6 +22058,7 @@ def _audit_verify_esp_release_evidence( repo_root: Path | None, bundle: Path | None = None, release: dict[str, Any] | None = None, + firmware_version: str | None = None, ) -> None: _require( evidence_dir is not None and build_root is not None and repo_root is not None, @@ -19828,11 +22068,15 @@ def _audit_verify_esp_release_evidence( evidence = Path(evidence_dir) builds = Path(build_root) root = Path(repo_root) - firmware_version = ( - release["identity"]["version"] - if release is not None - else _read_lock(root)["pyble"]["agent_version"] - ) + if release is not None: + release_version = release["identity"]["version"] + _require( + firmware_version is None or firmware_version == release_version, + "explicit firmware version differs from packaged release", + ) + firmware_version = release_version + elif firmware_version is None: + firmware_version = _read_lock(root)["pyble"]["agent_version"] source_profile_order = _release_profile_order_for_version(firmware_version) release_profile_order = ( V060_RELEASE_PROFILE_ORDER[:-1] @@ -19954,7 +22198,10 @@ def _audit_verify_esp_release_evidence( "license evidence profile/role identities are incomplete", ) - lock = _audit_load_tool_lock(root) + lock = _audit_load_tool_lock( + root, + firmware_version=firmware_version, + ) policy = _audit_load_policy(root, lock) _require( receipt["executed_artifacts"] == dict(sorted(lock["_artifact_hashes"].items())), @@ -20191,6 +22438,7 @@ def _audit_verify_release_evidence( evidence_dir=Path(evidence_dir), build_root=Path(build_root), repo_root=root, + firmware_version=firmware_version, ) return _audit_verify_esp_release_evidence( @@ -20209,6 +22457,7 @@ def _audit_verify_v060_esp_semantic_replay( evidence_dir: Path, build_root: Path, repo_root: Path, + firmware_version: str, ) -> None: """Replay the complete ESP verifier against the ESP half of v0.6 evidence.""" @@ -20295,6 +22544,7 @@ def _audit_verify_v060_esp_semantic_replay( evidence_dir=replay_evidence, build_root=Path(build_root), repo_root=Path(repo_root), + firmware_version=firmware_version, ) @@ -22066,6 +24316,10 @@ def _validate_qualification_observation( "OI-1 raw log digest must be lowercase 64-hex", ) if has_physical_fact_lineage: + _require( + firmware_version == "0.6.0", + "physical-fact lineage is exact to firmware version 0.6.0", + ) lineage = _validate_physical_fact_lineage_summary( observation["physical_fact_lineage"] ) @@ -22532,6 +24786,8 @@ def _validate_hil_source_era( } expected_keys = set(base_keys) schema_version = _hil_schema_version_for_version(firmware_version) + if schema_version == 5: + _require_v5_release_version(firmware_version, "V5 HIL validation") if schema_version == 4: expected_keys.add("waveshare_lcd147b_qualification") elif schema_version == 5: @@ -22582,10 +24838,13 @@ def _bind_v060_hil_qualification_summaries( """Perform V5's sole atomic null-to-three-summary transition.""" _validate_hil_source_era(payload, firmware_version) + _require_v5_release_version( + firmware_version, + "V5 qualification summary binding", + ) _require( - firmware_version == "0.6.0" - and payload["schema_version"] == 5, - "V5 qualification summaries require exact firmware version 0.6.0", + payload["schema_version"] == 5, + "V5 qualification summaries require an approved exact release version", ) summary_inputs = { "waveshare_lcd147b_qualification": waveshare_lcd147b_summary, @@ -22606,6 +24865,37 @@ def _bind_v060_hil_qualification_summaries( == list(V060_RELEASE_PROFILE_ORDER), "V5 qualification summary record parity changed", ) + if _is_v061_hardening_version(firmware_version): + hardening_digests: list[str] = [] + for record in records: + checks = record.get("checks") + _require( + type(checks) is dict + and checks.get("v061_hardening") == "passed" + and "v061_hardening" in record, + "v0.6.1 qualification binding requires completed hardening evidence", + ) + hardening = _validate_v061_hardening_summary( + record["v061_hardening"], + "V5 %s v0.6.1 hardening summary" % record["profile_id"], + ) + hardening_digests.append(hardening["private_result_sha256"]) + _require( + len(set(hardening_digests)) == len(V060_RELEASE_PROFILE_ORDER), + "v0.6.1 qualification binding requires five distinct hardening results", + ) + else: + _require( + all( + "v061_hardening" not in record + and ( + type(record.get("checks")) is not dict + or "v061_hardening" not in record["checks"] + ) + for record in records + ), + "historical V5 qualification binding rejects v0.6.1 hardening evidence", + ) by_id = {record["profile_id"]: record for record in records} _require( all( @@ -22730,6 +25020,8 @@ def _validate_hil( policy_by_id = { entry["profile_id"]: entry for entry in policy["profiles"] } + v061_hardening = _is_v061_hardening_version(identity["version"]) + v061_hardening_digests: list[str] = [] required = { "profile_id", "status", @@ -22803,6 +25095,8 @@ def _validate_hil( "oi1_observation", "redacted_console_log", } + if v061_hardening: + v5_required.add("v061_hardening") if not is_rp2: v5_required.add("manifest_sha256") _exact_keys(record, v5_required, "HIL %s" % profile_id) @@ -22849,6 +25143,8 @@ def _validate_hil( "filesystem_resume_reliability", "footprint_reliability", } + if v061_hardening: + v5_checks.add("v061_hardening") checks = _exact_keys( record["checks"], v5_checks, "HIL %s checks" % profile_id ) @@ -22914,6 +25210,14 @@ def _validate_hil( profile_id, firmware_version=identity["version"], ) + if v061_hardening: + hardening_summary = _validate_v061_hardening_summary( + record["v061_hardening"], + "HIL %s v0.6.1 hardening summary" % profile_id, + ) + v061_hardening_digests.append( + hardening_summary["private_result_sha256"] + ) expected_gates = ( tuple("C3-G%d" % index for index in range(7)) if profile_id == "esp32-c3-4mb" @@ -22949,7 +25253,11 @@ def _validate_hil( and all(value == "pending" for value in checks.values()) and app_hil == {"ipad": None, "android": None} and record["profile_gate_summary"] is None - and record["oi1_observation"] is None, + and record["oi1_observation"] is None + and ( + not v061_hardening + or record["v061_hardening"] is None + ), "candidate HIL fields must remain pending for %s" % profile_id, ) continue @@ -23071,6 +25379,13 @@ def _validate_hil( record["oi1_observation"] is None, "candidate HIL OI-1 observation must remain null", ) + if v061_hardening and public: + _require( + len(v061_hardening_digests) == len(V060_RELEASE_PROFILE_ORDER) + and len(set(v061_hardening_digests)) + == len(V060_RELEASE_PROFILE_ORDER), + "public HIL requires five distinct v0.6.1 hardening results", + ) if payload["schema_version"] == 5: summaries = ( payload["waveshare_lcd147b_qualification"], @@ -23286,6 +25601,8 @@ def validate_bundle( metadata_schema_version = ( _release_metadata_schema_version_for_version(source_version) ) + if metadata_schema_version == 4: + _require_v5_release_version(source_version, "V5 release validation") _require( actual_files == _expected_bundle_files(profile_order), "release bundle layout is not exact", @@ -23634,6 +25951,8 @@ def _candidate_hil_report( bundle: Path, ) -> str: hil_schema_version = _hil_schema_version_for_version(version) + if hil_schema_version == 5: + _require_v5_release_version(version, "V5 candidate HIL generation") policy_by_id = { entry["profile_id"]: entry for entry in qualification_policy["profiles"] @@ -23687,6 +26006,9 @@ def _candidate_hil_report( "oi1_observation": None, "redacted_console_log": "", } + if _is_v061_hardening_version(version): + record["checks"]["v061_hardening"] = "pending" + record["v061_hardening"] = None if not is_rp2: record["manifest_sha256"] = profile["manifest"]["sha256"] records.append(record) @@ -24314,7 +26636,12 @@ def create_baseline_inputs( spec = PROFILE_SPECS[profile_id] target = spec["target"] validated[target] = ( - validate_rp2_build(target, builds_root / target, repo_root=root) + _validate_rp2_build_for_firmware_version( + target, + builds_root / target, + repo_root=root, + firmware_version=version, + ) if spec["port"] == "rp2" else validate_build(target, builds_root / target, repo_root=root) ) @@ -24355,7 +26682,12 @@ def create_baseline_inputs( spec = PROFILE_SPECS[profile_id] target = spec["target"] revalidated[target] = ( - validate_rp2_build(target, builds_root / target, repo_root=root) + _validate_rp2_build_for_firmware_version( + target, + builds_root / target, + repo_root=root, + firmware_version=version, + ) if spec["port"] == "rp2" else validate_build(target, builds_root / target, repo_root=root) ) @@ -24792,6 +27124,8 @@ def create_bundle( lock = _read_lock(root) version = lock["pyble"]["agent_version"] profile_order = _release_profile_order_for_version(version) + if tuple(profile_order) == V060_RELEASE_PROFILE_ORDER: + _require_v5_release_version(version, "V5 candidate creation") compare_build_roots( builds_root, reproducibility_root, @@ -24815,10 +27149,11 @@ def create_bundle( spec = PROFILE_SPECS[profile_id] target = spec["target"] validated[target] = ( - validate_rp2_build( + _validate_rp2_build_for_firmware_version( target, builds_root / target, repo_root=validation_root, + firmware_version=version, ) if spec["port"] == "rp2" else validate_build( @@ -25161,6 +27496,7 @@ def _validate_hil_promotion_envelope( "oi1_build", ) ) + hardening_digests: list[str] = [] for candidate_record, completed_record in zip( candidate_records, completed_records, @@ -25218,6 +27554,53 @@ def _validate_hil_promotion_envelope( "completed HIL has unexpected profile gates for %s" % profile_id, ) + record_is_v061 = _is_v061_hardening_version( + candidate_record["firmware_version"] + ) + candidate_checks = candidate_record.get("checks") + completed_checks = completed_record.get("checks") + _require( + type(candidate_checks) is dict + and type(completed_checks) is dict, + "completed HIL check maps are invalid for %s" % profile_id, + ) + if record_is_v061: + _require( + candidate_record.get("v061_hardening", object()) is None + and candidate_checks.get("v061_hardening") == "pending" + and "v061_hardening" in completed_record + and completed_checks.get("v061_hardening") == "passed", + "v0.6.1 hardening evidence is not in its required phase for %s" + % profile_id, + ) + summary = _validate_v061_hardening_summary( + completed_record["v061_hardening"], + "completed HIL %s v0.6.1 hardening summary" % profile_id, + ) + hardening_digests.append(summary["private_result_sha256"]) + else: + _require( + "v061_hardening" not in candidate_record + and "v061_hardening" not in completed_record + and "v061_hardening" not in candidate_checks + and "v061_hardening" not in completed_checks, + "historical V5 HIL rejects the v0.6.1 hardening extension", + ) + if candidate_payload["schema_version"] == 5: + record_versions = [ + record.get("firmware_version") for record in candidate_records + ] + if any(_is_v061_hardening_version(value) for value in record_versions): + _require( + all( + _is_v061_hardening_version(value) + for value in record_versions + ) + and len(hardening_digests) == len(V060_RELEASE_PROFILE_ORDER) + and len(set(hardening_digests)) + == len(V060_RELEASE_PROFILE_ORDER), + "completed HIL requires five distinct v0.6.1 hardening summaries", + ) def _completed_hil_report(payload: dict[str, Any]) -> bytes: @@ -25431,6 +27814,209 @@ def _read_canonical_json_object( return value, snapshot +def _v061_hardening_qualification_binding( + qualification_repo_root: Path, + *, + require_clean: bool, +) -> dict[str, Any]: + """Freeze the reviewed checkout and loaded v0.6.1 qualification bytes.""" + + root = Path(qualification_repo_root) + if require_clean: + _require_checkout_clean(root, "v0.6.1 hardening qualification") + gate = _v061_hardening_gate() + try: + expected_gate_source = ( + root + / "firmware" + / "qualification" + / "v061_hardening_release_gate.py" + ).resolve(strict=True) + executable = (root / V061_HARDENING_EXECUTABLE_RELATIVE).resolve( + strict=True + ) + except OSError as exc: + raise ReleaseError( + "v0.6.1 hardening qualification sources are unavailable" + ) from exc + _require( + expected_gate_source == _V061_HARDENING_GATE_SOURCE + and Path(gate.__file__).resolve(strict=True) == expected_gate_source, + "loaded v0.6.1 hardening validator is outside the qualification checkout", + ) + gate_raw, gate_snapshot = _stable_completion_bytes( + expected_gate_source, + "loaded v0.6.1 hardening validator", + maximum=2 * 1024 * 1024, + ) + executable_raw, executable_snapshot = _stable_completion_bytes( + executable, + "v0.6.1 hardening qualification executable", + maximum=2 * 1024 * 1024, + ) + _require( + _V061_HARDENING_GATE_SOURCE_SHA256 is not None + and hashlib.sha256(gate_raw).hexdigest() + == _V061_HARDENING_GATE_SOURCE_SHA256, + "loaded v0.6.1 hardening validator changed", + ) + try: + closure_snapshot = gate._qualification_snapshot(root) + except gate.QualificationError as exc: + raise ReleaseError( + "v0.6.1 hardening acquisition source closure changed: %s" % exc + ) from exc + return { + "source_commit": _git_output( + root, + "v0.6.1 hardening qualification", + "rev-parse", + "HEAD", + ), + "executable_sha256": hashlib.sha256(executable_raw).hexdigest(), + "gate_snapshot": gate_snapshot, + "executable_snapshot": executable_snapshot, + "closure_snapshot": closure_snapshot, + } + + +def _validate_v061_hardening_result( + path: Path, + *, + artifact_path: Path, + expected_profile_id: str, + expected_target: str, + expected_version: str, + expected_source_commit: str, + candidate_release_json_sha256: str, + expected_qualification_source_commit: str, + expected_qualification_executable_sha256: str, + label: str, +) -> tuple[dict[str, Any], tuple[Any, ...]]: + """Reopen one private result and return only its derived public summary.""" + + gate = _v061_hardening_gate() + try: + stable_path = gate._absolute_lexical_path(Path(path), label) + except gate.QualificationError as exc: + raise ReleaseError("%s path is unsafe: %s" % (label, exc)) from exc + _private_raw, private_snapshot = _stable_completion_bytes( + stable_path, + label, + maximum=getattr(gate, "MAX_RESULT_BYTES", _V5_COMPLETION_INPUT_MAX_BYTES), + exclusive=True, + ) + try: + summary = gate.validate_result_file( + stable_path, + artifact_path=Path(artifact_path), + expected_profile_id=expected_profile_id, + expected_target=expected_target, + expected_version=expected_version, + expected_source_commit=expected_source_commit, + candidate_release_json_sha256=candidate_release_json_sha256, + expected_qualification_source_commit=( + expected_qualification_source_commit + ), + expected_qualification_executable_sha256=( + expected_qualification_executable_sha256 + ), + ) + except gate.QualificationError as exc: + raise ReleaseError("%s is invalid: %s" % (label, exc)) from exc + summary = _validate_v061_hardening_summary( + summary, + "%s public summary" % label, + ) + _require( + summary["private_result_sha256"] == private_snapshot[3], + "%s digest differs from its stable private bytes" % label, + ) + return copy.deepcopy(summary), private_snapshot + + +def _validate_v061_hardening_result_set( + *, + result_paths: list[Path], + candidate_dir: Path, + completed_hil: dict[str, Any], + firmware_version: str, + source_commit: str, + candidate_release_json_sha256: str, + qualification_source_commit: str, + qualification_executable_sha256: str, +) -> tuple[tuple[str, tuple[Any, ...], dict[str, Any]], ...]: + """Validate the exact ordered five-result authority used by finalization.""" + + _require( + _is_v061_hardening_version(firmware_version), + "private v0.6.1 hardening results require exact firmware version 0.6.1", + ) + _require( + type(result_paths) is list + and len(result_paths) == len(V060_RELEASE_PROFILE_ORDER), + "v0.6.1 finalization requires exactly five hardening results", + ) + records = completed_hil.get("records") + _require( + type(records) is list + and len(records) == len(V060_RELEASE_PROFILE_ORDER) + and all(type(record) is dict for record in records) + and [record.get("profile_id") for record in records] + == list(V060_RELEASE_PROFILE_ORDER), + "completed HIL hardening record order/parity changed", + ) + validated: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] + lexical_paths: list[str] = [] + result_digests: list[str] = [] + candidate = Path(candidate_dir) + for profile_id, path, record in zip( + V060_RELEASE_PROFILE_ORDER, + result_paths, + records, + ): + _require( + isinstance(path, Path), + "v0.6.1 hardening result paths must be pathlib Paths", + ) + summary, snapshot = _validate_v061_hardening_result( + path, + artifact_path=( + candidate + / profile_id + / PROFILE_SPECS[profile_id]["primary_artifact"] + ), + expected_profile_id=profile_id, + expected_target=PROFILE_SPECS[profile_id]["target"], + expected_version=firmware_version, + expected_source_commit=source_commit, + candidate_release_json_sha256=candidate_release_json_sha256, + expected_qualification_source_commit=qualification_source_commit, + expected_qualification_executable_sha256=( + qualification_executable_sha256 + ), + label="private v0.6.1 hardening result for %s" % profile_id, + ) + reported = _validate_v061_hardening_summary( + record.get("v061_hardening"), + "completed HIL %s v0.6.1 hardening summary" % profile_id, + ) + _require( + summary == reported, + "private v0.6.1 hardening result differs from completed HIL for %s" + % profile_id, + ) + lexical_paths.append(snapshot[0]) + result_digests.append(summary["private_result_sha256"]) + validated.append((profile_id, snapshot, summary)) + _require( + len(set(lexical_paths)) == len(V060_RELEASE_PROFILE_ORDER) + and len(set(result_digests)) == len(V060_RELEASE_PROFILE_ORDER), + "v0.6.1 finalization requires five distinct hardening result files", + ) + return tuple(validated) + + def _lineage_raw_records(raw: bytes) -> list[dict[str, Any]]: _require( type(raw) is bytes @@ -26313,6 +28899,7 @@ def _load_v5_completion_observation( *, candidate_dir: Path, profile_id: str, + firmware_version: str, qualification_repo_root: Path, policy: dict[str, Any], derivation: dict[str, str], @@ -26329,7 +28916,12 @@ def _load_v5_completion_observation( value, snapshot = _read_canonical_json_object( Path(path), "V5 OI-1 verify observation" ) + _require_v5_release_version(firmware_version, "V5 completion observation") if value.get("kind") == _PHYSICAL_FACT_LINEAGE_KIND: + _require( + firmware_version == "0.6.0", + "physical-fact lineage cannot qualify v0.6.1 candidate bytes", + ) return _consume_physical_fact_lineage( Path(path), candidate_dir=Path(candidate_dir), @@ -26360,7 +28952,7 @@ def _load_v5_completion_observation( value, policy["thresholds"], profile_id, - firmware_version="0.6.0", + firmware_version=firmware_version, ) return value, snapshot @@ -26452,12 +29044,22 @@ def _validate_v5_completion_fragment( value: Any, *, profile_id: str, + firmware_version: str, observation: dict[str, Any], gate_summary: dict[str, str] | None, + v061_hardening_summary: dict[str, Any] | None, ) -> dict[str, Any]: + _require_v5_release_version( + firmware_version, + "V5 HIL completion fragment validation", + ) + v061_hardening = _is_v061_hardening_version(firmware_version) + fragment_fields = set(_V5_COMPLETION_FRAGMENT_FIELDS) + if v061_hardening: + fragment_fields.add("v061_hardening") fragment = _exact_keys( value, - _V5_COMPLETION_FRAGMENT_FIELDS, + fragment_fields, "V5 HIL completion fragment for %s" % profile_id, ) _require( @@ -26466,16 +29068,52 @@ def _validate_v5_completion_fragment( ) _validate_v5_operator_input( { - field: copy.deepcopy(fragment[field]) - for field in (*_V5_OPERATOR_FIELDS, "checks", "app_hil") + **{ + field: copy.deepcopy(fragment[field]) + for field in _V5_OPERATOR_FIELDS + }, + "checks": { + name: copy.deepcopy(fragment["checks"][name]) + for name in _V5_OPERATOR_CHECKS + if name in fragment["checks"] + }, + "app_hil": copy.deepcopy(fragment["app_hil"]), }, profile_id, ) + expected_checks = set(_V5_OPERATOR_CHECKS) + if v061_hardening: + expected_checks.add("v061_hardening") + checks = _exact_keys( + fragment["checks"], + expected_checks, + "V5 HIL completion checks for %s" % profile_id, + ) _require( fragment["oi1_observation"] == observation and fragment["profile_gate_summary"] == gate_summary, "V5 HIL completion derived fields changed", ) + if v061_hardening: + derived = _validate_v061_hardening_summary( + v061_hardening_summary, + "derived V5 %s v0.6.1 hardening summary" % profile_id, + ) + supplied = _validate_v061_hardening_summary( + fragment["v061_hardening"], + "V5 %s completion hardening summary" % profile_id, + ) + _require( + checks["v061_hardening"] == "passed" and supplied == derived, + "V5 HIL completion hardening evidence changed", + ) + else: + _require( + v061_hardening_summary is None + and "v061_hardening" not in fragment + and "v061_hardening" not in checks, + "historical V5 completion rejects v0.6.1 hardening evidence", + ) return fragment @@ -26633,8 +29271,9 @@ def create_hil_completion_fragment( output_path: Path, qualification_repo_root: Path, profile_qualification_result: Path | None, + v061_hardening_result: Path | None = None, ) -> Path: - """Derive one candidate-bound V5 completion fragment without gate input.""" + """Derive one candidate-bound V5 completion fragment from private gates.""" try: candidate = _V060_PROFILE_GATE._absolute_lexical_path( @@ -26669,12 +29308,13 @@ def create_hil_completion_fragment( public=False, qualification_repo_root=qualification_root, ) + firmware_version = release["identity"]["version"] + _require_v5_release_version(firmware_version, "V5 HIL completion") _require( - release["identity"]["version"] == "0.6.0" - and [profile["id"] for profile in release["profiles"]] + [profile["id"] for profile in release["profiles"]] == list(V060_RELEASE_PROFILE_ORDER) and all(profile["hil_status"] == "pending" for profile in release["profiles"]), - "HIL completion requires the exact fully pending v0.6.0 candidate", + "HIL completion requires an approved fully pending V5 candidate", ) _require( _release_tree_snapshot(candidate, "candidate release") @@ -26694,14 +29334,14 @@ def create_hil_completion_fragment( completion_derivation = _qualification_derivation_for_source( qualification_root, candidate_source_commit, - firmware_version="0.6.0", + firmware_version=firmware_version, ) try: pending_report_text = pending_report_raw.decode("utf-8", errors="strict") except UnicodeDecodeError as exc: raise ReleaseError("candidate HIL report is not strict UTF-8") from exc pending_payload = _parse_hil_report(pending_report_text) - _validate_hil_source_era(pending_payload, "0.6.0") + _validate_hil_source_era(pending_payload, firmware_version) _require( pending_payload["schema_version"] == 5 and pending_payload["qualification_policy"]["schema_version"] == 3 @@ -26759,12 +29399,57 @@ def create_hil_completion_fragment( Path(oi1_observation_path), candidate_dir=candidate, profile_id=profile_id, + firmware_version=firmware_version, qualification_repo_root=qualification_root, policy=policy_by_id[profile_id], derivation=completion_derivation, ) candidate_release_digest = hashlib.sha256(release_raw).hexdigest() + hardening_result_path = ( + Path(v061_hardening_result) + if v061_hardening_result is not None + else None + ) + v061_hardening = _is_v061_hardening_version(firmware_version) + _require( + v061_hardening == (hardening_result_path is not None), + ( + "v0.6.1 HIL completion requires its profile hardening result" + if v061_hardening + else "historical V5 HIL completion rejects a v0.6.1 hardening result" + ), + ) + hardening_binding: dict[str, Any] | None = None + hardening_snapshot: tuple[Any, ...] | None = None + hardening_summary: dict[str, Any] | None = None + if hardening_result_path is not None: + hardening_binding = _v061_hardening_qualification_binding( + qualification_root, + require_clean=False, + ) + hardening_summary, hardening_snapshot = ( + _validate_v061_hardening_result( + hardening_result_path, + artifact_path=( + candidate + / profile_id + / PROFILE_SPECS[profile_id]["primary_artifact"] + ), + expected_profile_id=profile_id, + expected_target=PROFILE_SPECS[profile_id]["target"], + expected_version=firmware_version, + expected_source_commit=candidate_source_commit, + candidate_release_json_sha256=candidate_release_digest, + expected_qualification_source_commit=hardening_binding[ + "source_commit" + ], + expected_qualification_executable_sha256=hardening_binding[ + "executable_sha256" + ], + label="private v0.6.1 hardening result", + ) + ) result_path = ( Path(profile_qualification_result) if profile_qualification_result is not None @@ -26810,7 +29495,7 @@ def create_hil_completion_fragment( candidate / profile_id / PROFILE_SPECS[profile_id]["primary_artifact"] ), expected_profile_id=profile_id, - expected_version="0.6.0", + expected_version=firmware_version, candidate_release_json_sha256=candidate_release_digest, ) if profile_id == "esp32-c3-4mb": @@ -26830,7 +29515,7 @@ def create_hil_completion_fragment( / profile_id / PROFILE_SPECS[profile_id]["primary_artifact"] ), - expected_version="0.6.0", + expected_version=firmware_version, candidate_release_json_sha256=candidate_release_digest, observation=observation, post_oi_nvs_receipt_path=c3_evidence_paths[ @@ -26865,11 +29550,16 @@ def create_hil_completion_fragment( "profile_gate_summary": gate_summary, "oi1_observation": copy.deepcopy(observation), } + if v061_hardening: + fragment["checks"]["v061_hardening"] = "passed" + fragment["v061_hardening"] = copy.deepcopy(hardening_summary) _validate_v5_completion_fragment( fragment, profile_id=profile_id, + firmware_version=firmware_version, observation=observation, gate_summary=gate_summary, + v061_hardening_summary=hardening_summary, ) raw = _canonical_json_bytes(fragment) @@ -26909,6 +29599,7 @@ def unchanged() -> None: Path(oi1_observation_path), candidate_dir=candidate, profile_id=profile_id, + firmware_version=firmware_version, qualification_repo_root=qualification_root, policy=policy_by_id[profile_id], derivation=completion_derivation, @@ -26920,6 +29611,47 @@ def unchanged() -> None: and _current_observation == observation, "HIL completion input changed while it was used", ) + if hardening_result_path is not None: + _require( + hardening_binding is not None + and hardening_snapshot is not None + and hardening_summary is not None, + "v0.6.1 hardening completion authority disappeared", + ) + current_hardening_binding = ( + _v061_hardening_qualification_binding( + qualification_root, + require_clean=False, + ) + ) + current_hardening_summary, current_hardening_snapshot = ( + _validate_v061_hardening_result( + hardening_result_path, + artifact_path=( + candidate + / profile_id + / PROFILE_SPECS[profile_id]["primary_artifact"] + ), + expected_profile_id=profile_id, + expected_target=PROFILE_SPECS[profile_id]["target"], + expected_version=firmware_version, + expected_source_commit=candidate_source_commit, + candidate_release_json_sha256=candidate_release_digest, + expected_qualification_source_commit=hardening_binding[ + "source_commit" + ], + expected_qualification_executable_sha256=hardening_binding[ + "executable_sha256" + ], + label="private v0.6.1 hardening result", + ) + ) + _require( + current_hardening_binding == hardening_binding + and current_hardening_snapshot == hardening_snapshot + and current_hardening_summary == hardening_summary, + "v0.6.1 hardening result or qualification source changed while used", + ) if result_path is not None: try: current_gate_raw, current_gate_source_snapshot = ( @@ -26946,7 +29678,7 @@ def unchanged() -> None: / PROFILE_SPECS[profile_id]["primary_artifact"] ), expected_profile_id=profile_id, - expected_version="0.6.0", + expected_version=firmware_version, candidate_release_json_sha256=candidate_release_digest, ) if profile_id == "esp32-c3-4mb": @@ -26958,7 +29690,7 @@ def unchanged() -> None: / profile_id / PROFILE_SPECS[profile_id]["primary_artifact"] ), - expected_version="0.6.0", + expected_version=firmware_version, candidate_release_json_sha256=( candidate_release_digest ), @@ -27000,8 +29732,10 @@ def post_write_validate() -> None: _validate_v5_completion_fragment( written, profile_id=profile_id, + firmware_version=firmware_version, observation=observation, gate_summary=gate_summary, + v061_hardening_summary=hardening_summary, ) unchanged() @@ -27092,12 +29826,26 @@ def assemble_completed_hil_report( firmware_version = release["identity"]["version"] profile_order = _release_profile_order_for_version(firmware_version) hil_schema_version = _hil_schema_version_for_version(firmware_version) + v061_hardening = _is_v061_hardening_version(firmware_version) if hil_schema_version == 5: + _require_v5_release_version(firmware_version, "V5 HIL report assembly") operator_checks = v5_operator_checks - all_checks = (*v5_operator_checks, "footprint_reliability") + completion_checks = ( + (*v5_operator_checks, "v061_hardening") + if v061_hardening + else v5_operator_checks + ) + all_checks = ( + (*v5_operator_checks, "footprint_reliability", "v061_hardening") + if v061_hardening + else (*v5_operator_checks, "footprint_reliability") + ) completion_fields.update({"app_hil", "profile_gate_summary"}) + if v061_hardening: + completion_fields.add("v061_hardening") else: operator_checks = legacy_operator_checks + completion_checks = operator_checks all_checks = ( *legacy_operator_checks[:-1], "footprint_reliability", @@ -27123,6 +29871,7 @@ def assemble_completed_hil_report( except (OSError, UnicodeError) as exc: raise ReleaseError("candidate HIL report is not UTF-8") from exc pending_payload = _parse_hil_report(pending_report_text) + _validate_hil_source_era(pending_payload, firmware_version) evidence_by_id: dict[str, dict[str, Any]] = {} for index, evidence_path in enumerate(evidence_paths): @@ -27158,16 +29907,17 @@ def assemble_completed_hil_report( item["profile_id"]: item for item in completed_payload["qualification_policy"]["profiles"] } + hardening_digests: list[str] = [] for record in completed_payload["records"]: profile_id = record["profile_id"] completion = evidence_by_id[profile_id] checks = _exact_keys( completion["checks"], - set(operator_checks), + set(completion_checks), "HIL completion checks for %s" % profile_id, ) _require( - all(checks[name] == "passed" for name in operator_checks), + all(checks[name] == "passed" for name in completion_checks), "HIL operator checks are incomplete for %s" % profile_id, ) _validate_qualification_observation( @@ -27185,6 +29935,22 @@ def assemble_completed_hil_report( completion["oi1_observation"] ) if hil_schema_version == 5: + hardening_summary = ( + _validate_v061_hardening_summary( + completion["v061_hardening"], + "HIL completion %s v0.6.1 hardening summary" % profile_id, + ) + if v061_hardening + else None + ) + _validate_v5_completion_fragment( + completion, + profile_id=profile_id, + firmware_version=firmware_version, + observation=completion["oi1_observation"], + gate_summary=completion["profile_gate_summary"], + v061_hardening_summary=hardening_summary, + ) app_results = _exact_keys( completion["app_hil"], {"ipad", "android"}, @@ -27211,6 +29977,23 @@ def assemble_completed_hil_report( record["profile_gate_summary"] = copy.deepcopy( completion["profile_gate_summary"] ) + if v061_hardening: + _require( + hardening_summary is not None, + "v0.6.1 hardening summary disappeared during assembly", + ) + record["v061_hardening"] = copy.deepcopy(hardening_summary) + hardening_digests.append( + hardening_summary["private_result_sha256"] + ) + + if v061_hardening: + _require( + len(hardening_digests) == len(V060_RELEASE_PROFILE_ORDER) + and len(set(hardening_digests)) + == len(V060_RELEASE_PROFILE_ORDER), + "completed HIL requires five distinct v0.6.1 hardening summaries", + ) _validate_hil_promotion_envelope(pending_payload, completed_payload) report_bytes = _completed_hil_report(completed_payload) @@ -27268,6 +30051,7 @@ def finalize_public_bundle( waveshare_lcd147b_qualification_result: Path | None = None, esp32_c3_qualification_result: Path | None = None, rpi_pico2_w_qualification_result: Path | None = None, + v061_hardening_result_paths: list[Path] | None = None, ) -> Path: """Promote one audited, HIL-qualified candidate without mutating it.""" @@ -27292,6 +30076,11 @@ def finalize_public_bundle( if rpi_pico2_w_qualification_result is not None else None ) + hardening_result_paths = ( + [Path(path) for path in v061_hardening_result_paths] + if v061_hardening_result_paths is not None + else None + ) _require( isinstance(candidate_release_json_sha256, str) @@ -27380,18 +30169,52 @@ def finalize_public_bundle( _validate_hil_source_era(completed_hil, firmware_version) _validate_hil_promotion_envelope(candidate_hil, completed_hil) - v060 = tuple(profile_order) == V060_RELEASE_PROFILE_ORDER - if v060: + v061_hardening = _is_v061_hardening_version(firmware_version) + _require( + v061_hardening == (hardening_result_paths is not None), + ( + "v0.6.1 finalization requires its exact five hardening results" + if v061_hardening + else "historical finalization rejects v0.6.1 hardening results" + ), + ) + hardening_qualification: dict[str, Any] | None = None + hardening_validation: tuple[ + tuple[str, tuple[Any, ...], dict[str, Any]], ... + ] | None = None + if hardening_result_paths is not None: + hardening_qualification = _v061_hardening_qualification_binding( + root, + require_clean=True, + ) + hardening_validation = _validate_v061_hardening_result_set( + result_paths=hardening_result_paths, + candidate_dir=candidate, + completed_hil=completed_hil, + firmware_version=firmware_version, + source_commit=candidate_release["provenance"]["pyble"]["commit"], + candidate_release_json_sha256=candidate_release_json_sha256, + qualification_source_commit=hardening_qualification[ + "source_commit" + ], + qualification_executable_sha256=hardening_qualification[ + "executable_sha256" + ], + ) + + v5 = tuple(profile_order) == V060_RELEASE_PROFILE_ORDER + if v5: + _require_v5_release_version(firmware_version, "V5 release finalization") _require( c3_qualification_result is not None and pico_qualification_result is not None, - "v0.6.0 finalization requires both C3 and Pico private " + "V5 finalization requires both C3 and Pico private " "qualification results", ) else: _require( c3_qualification_result is None and pico_qualification_result is None, - "pre-v0.6.0 finalization rejects C3/Pico qualification results", + "pre-V5 finalization rejects C3/Pico qualification results", ) lcd_capable = _waveshare_lcd147b_capable_version(firmware_version) @@ -27431,11 +30254,11 @@ def finalize_public_bundle( completed_report_bytes, qualification_result, ) - if v060: + if v5: _require( c3_qualification_result is not None and pico_qualification_result is not None, - "v0.6.0 private qualification inputs disappeared", + "V5 private qualification inputs disappeared", ) completed_c3_record = next( ( @@ -27505,7 +30328,7 @@ def finalize_public_bundle( ) except _V060_PROFILE_GATE.QualificationError as exc: raise ReleaseError( - "private v0.6.0 profile qualification result is invalid: %s" + "private V5 profile qualification result is invalid: %s" % exc ) from exc promoted_hil = _bind_v060_hil_qualification_summaries( @@ -27625,9 +30448,12 @@ def finalize_public_bundle( validated_public == promoted_release, "late public validation returned different release metadata", ) + staged_notice = staging / "THIRD_PARTY_LICENSES.txt" _audit_verify_release_evidence( - notice=(staging / "THIRD_PARTY_LICENSES.txt").read_text( - encoding="utf-8", errors="strict" + notice=( + staged_notice.read_text(encoding="utf-8", errors="strict") + if staged_notice.is_file() + else "" ), evidence_dir=evidence, build_root=builds, @@ -27661,11 +30487,11 @@ def finalize_public_bundle( == qualification_snapshot, "private LCD qualification result changed during finalization", ) - if v060: + if v5: _require( c3_qualification_result is not None and pico_qualification_result is not None, - "v0.6.0 private qualification inputs disappeared", + "V5 private qualification inputs disappeared", ) try: current_c3_snapshot = ( @@ -27702,7 +30528,7 @@ def finalize_public_bundle( ) except _V060_PROFILE_GATE.QualificationError as exc: raise ReleaseError( - "private v0.6.0 qualification result changed or became unsafe: %s" + "private V5 qualification result changed or became unsafe: %s" % exc ) from exc _require( @@ -27711,6 +30537,43 @@ def finalize_public_bundle( and current_c3_summary == c3_qualification_summary, "private C3/Pico qualification result changed during finalization", ) + if hardening_result_paths is not None: + _require( + hardening_qualification is not None + and hardening_validation is not None, + "v0.6.1 hardening finalization authority disappeared", + ) + current_hardening_qualification = ( + _v061_hardening_qualification_binding( + root, + require_clean=True, + ) + ) + current_hardening_validation = ( + _validate_v061_hardening_result_set( + result_paths=hardening_result_paths, + candidate_dir=candidate, + completed_hil=completed_hil, + firmware_version=firmware_version, + source_commit=candidate_release["provenance"]["pyble"][ + "commit" + ], + candidate_release_json_sha256=( + candidate_release_json_sha256 + ), + qualification_source_commit=hardening_qualification[ + "source_commit" + ], + qualification_executable_sha256=hardening_qualification[ + "executable_sha256" + ], + ) + ) + _require( + current_hardening_qualification == hardening_qualification + and current_hardening_validation == hardening_validation, + "private v0.6.1 hardening results or qualification source changed", + ) _atomic_publish_no_replace(staging, output, "public release") staging = None return output @@ -27767,6 +30630,7 @@ def _main(argv: list[str] | None = None) -> int: compare_parser = subparsers.add_parser("compare") compare_parser.add_argument("left", type=Path) compare_parser.add_argument("right", type=Path) + compare_parser.add_argument("--repo-root", required=True, type=Path) validate_parser = subparsers.add_parser("validate") validate_parser.add_argument("bundle", type=Path) @@ -27880,6 +30744,11 @@ def _main(argv: list[str] | None = None) -> int: "--rpi-pico2-w-qualification-result", type=Path, ) + finalize_parser.add_argument( + "--v061-hardening-result", + action="append", + type=Path, + ) hil_assembly_parser = subparsers.add_parser("assemble-hil-report") hil_assembly_parser.add_argument("candidate_dir", type=Path) @@ -27913,6 +30782,10 @@ def _main(argv: list[str] | None = None) -> int: "--profile-qualification-result", type=Path, ) + hil_completion_parser.add_argument( + "--v061-hardening-result", + type=Path, + ) lineage_parser = subparsers.add_parser("create-physical-fact-lineage") lineage_parser.add_argument("candidate_dir", type=Path) @@ -27931,7 +30804,15 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "validate-build": validate_build(args.target, args.build_dir) elif args.command == "compare": - compare_build_roots(args.left, args.right) + firmware_version = _read_lock(args.repo_root)["pyble"][ + "agent_version" + ] + compare_build_roots( + args.left, + args.right, + repo_root=args.repo_root, + firmware_version=firmware_version, + ) elif args.command == "validate": evidence_arguments = ( args.license_evidence_dir, @@ -28053,6 +30934,7 @@ def _main(argv: list[str] | None = None) -> int: rpi_pico2_w_qualification_result=( args.rpi_pico2_w_qualification_result ), + v061_hardening_result_paths=args.v061_hardening_result, ) print(output) elif args.command == "assemble-hil-report": @@ -28072,6 +30954,7 @@ def _main(argv: list[str] | None = None) -> int: output_path=args.output_path, qualification_repo_root=args.qualification_repo_root, profile_qualification_result=args.profile_qualification_result, + v061_hardening_result=args.v061_hardening_result, ) print(output) elif args.command == "create-physical-fact-lineage": diff --git a/firmware/user_c_modules/pyble/micropython.cmake b/firmware/user_c_modules/pyble/micropython.cmake index e4f924e7..8bbd1c2a 100644 --- a/firmware/user_c_modules/pyble/micropython.cmake +++ b/firmware/user_c_modules/pyble/micropython.cmake @@ -9,6 +9,7 @@ add_library(usermod_pyble INTERFACE) target_sources(usermod_pyble INTERFACE + ${CMAKE_CURRENT_LIST_DIR}/pble_wire.c ${CMAKE_CURRENT_LIST_DIR}/pble_proto.c ${CMAKE_CURRENT_LIST_DIR}/pble_ble.c ${CMAKE_CURRENT_LIST_DIR}/pble_info.c diff --git a/firmware/user_c_modules/pyble/pble_ble.c b/firmware/user_c_modules/pyble/pble_ble.c index 19e12622..d3457b26 100644 --- a/firmware/user_c_modules/pyble/pble_ble.c +++ b/firmware/user_c_modules/pyble/pble_ble.c @@ -25,6 +25,7 @@ // API. No proprietary source is referenced. #include <stdint.h> #include <stdbool.h> +#include <limits.h> #include <string.h> #include <stdio.h> @@ -101,8 +102,11 @@ // Bounded static reassembly buffer for a whole §3.1 message (D3: no per-message // heap churn on the hot path). A message that would overflow is dropped; the app // retransmits. Sized to comfortably hold caps/HELLO/DEVICE_INFO and directory -// listings while staying small for the ESP32-C3. -#define PBLE_MSG_MAX 2048 +// listings while staying bounded for the ESP32-C3. This must carry the full +// §3.1 frame around the documented 2048-byte RUN source, not merely its payload. +#define PBLE_MSG_MAX 4096 +#define PBLE_RX_REASSEMBLY_DEADLINE_US 5000000LL +#define PBLE_SESSION_VIOLATION_LIMIT 8u #define PBLE_INFO_MAX 512 // INFO/DEVICE_INFO read scratch #define PBLE_NAME_MAX 32 // "PyBLE-XXXX" or a bounded label (<= 24 B) // Largest single fragment packet = 1 (FRAG_HDR) + (MTU_MAX - 4) FRAGMENT DATA. @@ -227,15 +231,10 @@ static portMUX_TYPE pble_oi1_mux = portMUX_INITIALIZER_UNLOCKED; #include "pble_lock.h" // S6 F-18: single-writer token (SEC-3) #include "pble_boot.h" // S6 F-12: SET_AUTORUN registration -// S6 F-18 link-teardown hooks owned by peer modules (declared extern here — we -// only invoke them from the GAP DISCONNECT path; they are NOT ours). On link -// loss the single-writer token(s) MUST be released so a resuming reconnect is -// not locked out (SEC-3, FR-FS-16): the runtime frees its RUN/XFER writer lock -// and the fs bridge finalizes/aborts any in-flight transfer. Compatible -// forward declarations — pble_lock/pble_fs may later publish these in their own -// headers without conflict. +// S6 F-18 link teardown is owned by pble_lock. It releases W_XFER and invokes +// the storage bridge's one disconnect hook, so this layer must call it exactly +// once per exact-session cleanup (SEC-3, FR-FS-16). extern void pble_lock_on_disconnect(uint16_t conn); // runtime-engineer (pble_lock) -extern void pble_fs_on_disconnect(void); // storage-engineer (pble_fs) // PBLE/1 UUIDs 7079626c-1ab1-4d50-9e3a-0000000000{01..04}, in NimBLE's // little-endian on-air byte order (the string reversed). Only the final byte @@ -264,6 +263,14 @@ static uint64_t pble_conn_generation; static uint64_t pble_conn_generation_counter; static uint64_t pble_session_vm_epoch; static uint64_t pble_vm_epoch_seed; +static uint8_t pble_protocol_violation_count; +// Logical close request retained before the physical TX mutex can be acquired. +// The full token prevents a delayed driver from affecting a reused handle/VM. +static bool pble_termination_pending; +static bool pble_termination_driver_claimed; +static pble_session_token_t pble_termination_pending_session; +static int64_t pble_termination_begin_us; +static int64_t pble_termination_deadline_us; static portMUX_TYPE pble_session_mux = portMUX_INITIALIZER_UNLOCKED; static pble_term_state_t pble_term_state; static bool pble_term_initialized; @@ -594,18 +601,12 @@ static uint8_t pble_rx_buf[PBLE_MSG_MAX]; static size_t pble_rx_len; static bool pble_rx_active; static uint8_t pble_rx_next_index; - -// Header of the message being reassembled, captured from its FIRST fragment so -// an OVER-LONG message can still be REFUSED BY ID instead of vanishing. Without -// this, a RUN{source} carrying an editor buffer above the reassembly bound got -// no reply at all: the app waited out its 5 s RSP timeout with no status to -// explain it (measured cliff: source >= ~2038 B on a 2048 B buffer). -static bool pble_rx_hdr_valid; -static uint8_t pble_rx_hdr_type; -static uint8_t pble_rx_hdr_op; -static uint8_t pble_rx_hdr_id; +static int64_t pble_rx_started_us; +static bool pble_rx_discard_tail; static void pble_advertise(void); +static bool pble_session_admits_locked( + const pble_session_token_t *session); // --- §3.2 byte transport helpers -------------------------------------------- @@ -621,17 +622,27 @@ static void pble_reset_reassembly(void) { pble_rx_len = 0; pble_rx_active = false; pble_rx_next_index = 0; - pble_rx_hdr_valid = false; + pble_rx_started_us = 0; + pble_rx_discard_tail = false; +} + +static void pble_discard_reassembly_tail(void) { + pble_reset_reassembly(); + pble_rx_discard_tail = true; } // Reassemble one RX packet (§3.2): strip the 1-byte FRAG_HDR, concatenate // FRAGMENT DATA from FIRST through LAST (index increasing mod 64), then hand the // complete §3.1 message up to the protocol engine. CRC/structure validation is -// pble_proto's — never done here. Gap/out-of-order fragments reset the buffer -// (the app retransmits); an over-long message is dropped, not truncated. +// pble_proto's — never done here. A bad/expired run is discarded once and its +// non-FIRST tail stays suppressed; an over-long message is never truncated. static void pble_rx_ingest(const uint8_t *pkt, size_t len, const pble_session_token_t *session) { if (len == 0) { + if (pble_rx_active) { + pble_discard_reassembly_tail(); + } + (void)pble_ble_record_protocol_violation(session); return; } uint8_t hdr = pkt[0]; @@ -640,19 +651,20 @@ static void pble_rx_ingest(const uint8_t *pkt, size_t len, uint8_t index = hdr & PBLE_FRAG_IDX_MASK; if (first) { - pble_rx_len = 0; + pble_reset_reassembly(); pble_rx_active = true; pble_rx_next_index = index; - // Latch the §3.1 header (VER TYPE OPCODE ID LEN) while it is still in - // hand, so an over-long message can be answered by ID (below). - pble_rx_hdr_valid = (len - 1) >= 4; - if (pble_rx_hdr_valid) { - pble_rx_hdr_type = pkt[2]; - pble_rx_hdr_op = pkt[3]; - pble_rx_hdr_id = pkt[4]; - } + pble_rx_started_us = esp_timer_get_time(); + } else if (pble_rx_discard_tail) { + return; } else if (!pble_rx_active || index != pble_rx_next_index) { - pble_reset_reassembly(); + pble_discard_reassembly_tail(); + (void)pble_ble_record_protocol_violation(session); + return; + } else if (esp_timer_get_time() - pble_rx_started_us >= + PBLE_RX_REASSEMBLY_DEADLINE_US) { + pble_discard_reassembly_tail(); + (void)pble_ble_record_protocol_violation(session); return; } @@ -662,12 +674,30 @@ static void pble_rx_ingest(const uint8_t *pkt, size_t len, // originating id with ERANGE (§8 "bad offset/length") so the client // gets a typed refusal instead of an unexplained timeout (§9 requires // a refusal, not silence). EVTs/RSPs have no one to answer, so they - // just drop. The remaining fragments of this message are ignored: they - // are non-FIRST and land on !pble_rx_active below. - bool answerable = pble_rx_hdr_valid && pble_rx_hdr_type == PBLE_TYPE_CMD; - uint8_t op = pble_rx_hdr_op, id = pble_rx_hdr_id; - pble_reset_reassembly(); - if (answerable) { + // just drop. The remaining non-FIRST fragments stay suppressed until + // a fresh FIRST establishes new message ownership. + // Correlate only from a complete six-byte header. It may span FIRST + // and later fragments, so assemble only these bounded bytes from the + // retained prefix plus the fragment that crossed the cap. + uint8_t head[6]; + size_t head_len = pble_rx_len < sizeof(head) + ? pble_rx_len : sizeof(head); + memcpy(head, pble_rx_buf, head_len); + size_t head_tail = sizeof(head) - head_len; + if (head_tail > data_len) { + head_tail = data_len; + } + memcpy(head + head_len, pkt + 1, head_tail); + head_len += head_tail; + bool answerable = head_len == sizeof(head) && + head[0] == PBLE_PROTO_VERSION && + head[1] == PBLE_TYPE_CMD && head[3] != 0; + uint8_t op = answerable ? head[2] : 0; + uint8_t id = answerable ? head[3] : 0; + pble_discard_reassembly_tail(); + bool violation_admitted = + pble_ble_record_protocol_violation(session); + if (violation_admitted && answerable) { pble_proto_refuse(op, id, PBLE_ERANGE, session); } return; @@ -714,11 +744,9 @@ static int pble_notify_packet(const uint8_t *pkt, size_t len, } bool admitted; taskENTER_CRITICAL(&pble_session_mux); - admitted = session != NULL && + admitted = pble_session_admits_locked(session) && pble_term_admits(&pble_term_state, session->conn, - session->generation) && - session->vm_epoch != 0 && - session->vm_epoch == pble_session_vm_epoch; + session->generation); taskEXIT_CRITICAL(&pble_session_mux); if (!admitted) { return PBLE_TX_NO_CONN; @@ -764,19 +792,106 @@ static bool pble_session_matches_locked(const pble_session_token_t *session) { pble_session_vm_epoch == session->vm_epoch; } +static bool pble_session_token_equal(const pble_session_token_t *left, + const pble_session_token_t *right) { + return left != NULL && right != NULL && left->conn == right->conn && + left->generation == right->generation && + left->vm_epoch == right->vm_epoch; +} + +static bool pble_termination_pending_matches_locked( + const pble_session_token_t *session) { + return pble_termination_pending && + pble_session_token_equal(&pble_termination_pending_session, session); +} + +static bool pble_session_admits_locked( + const pble_session_token_t *session) { + return pble_session_matches_locked(session) && + !pble_termination_pending_matches_locked(session) && + pble_term_admits(&pble_term_state, session->conn, + session->generation); +} + +static void pble_termination_clear_locked(void) { + pble_termination_pending = false; + pble_termination_driver_claimed = false; + memset(&pble_termination_pending_session, 0, + sizeof(pble_termination_pending_session)); + pble_termination_begin_us = 0; + pble_termination_deadline_us = 0; +} + +// Latch only the current exact OPEN token. Repeated requests retain the first +// timestamp/deadline and can never retarget or extend the termination bound. +static bool pble_termination_latch_locked( + const pble_session_token_t *session, int64_t request_now_us, + int64_t budget_us) { + if (pble_termination_pending) { + return pble_termination_pending_matches_locked(session); + } + if (!pble_session_matches_locked(session) || + !pble_term_admits(&pble_term_state, session->conn, + session->generation)) { + return false; + } + pble_termination_pending = true; + pble_termination_pending_session = *session; + pble_termination_begin_us = request_now_us; + pble_termination_deadline_us = + request_now_us > INT64_MAX - budget_us + ? INT64_MAX + : request_now_us + budget_us; + return true; +} + +// Exactly one caller drives the physical TX-serialized reducer transition. +// Violation eight may already have installed the logical latch in its counter +// cut; ordinary response-failure callers install it here. +static bool pble_termination_claim_driver_locked( + const pble_session_token_t *session, int64_t request_now_us, + int64_t budget_us, int64_t *begin_us, int64_t *deadline_us) { + if (!pble_termination_latch_locked(session, request_now_us, budget_us) || + pble_termination_driver_claimed || begin_us == NULL || + deadline_us == NULL) { + return false; + } + pble_termination_driver_claimed = true; + *begin_us = pble_termination_begin_us; + *deadline_us = pble_termination_deadline_us; + return true; +} + +static bool pble_termination_restart_if_current( + const pble_session_token_t *session) { + pble_term_effects_t effect = PBLE_TERM_EFFECT_NONE; + taskENTER_CRITICAL(&pble_session_mux); + if (pble_session_matches_locked(session) && + pble_termination_pending_matches_locked(session)) { + effect = pble_term_preclose_failed( + &pble_term_state, session->conn, session->generation); + } + taskEXIT_CRITICAL(&pble_session_mux); + return effect == PBLE_TERM_EFFECT_RESTART; +} + bool pble_ble_session_snapshot(uint16_t expected_conn, pble_session_token_t *session) { bool live; taskENTER_CRITICAL(&pble_session_mux); + pble_session_token_t current = { + .conn = pble_conn_handle, + .generation = pble_conn_generation, + .vm_epoch = pble_session_vm_epoch, + }; live = session != NULL && expected_conn != BLE_HS_CONN_HANDLE_NONE && pble_conn_handle == expected_conn && + pble_session_admits_locked(¤t) && pble_term_admits(&pble_term_state, pble_conn_handle, pble_conn_generation) && pble_session_vm_epoch != 0; if (live) { - session->conn = pble_conn_handle; - session->generation = pble_conn_generation; - session->vm_epoch = pble_session_vm_epoch; + *session = current; } taskEXIT_CRITICAL(&pble_session_mux); return live; @@ -785,9 +900,15 @@ bool pble_ble_session_snapshot(uint16_t expected_conn, bool pble_ble_session_snapshot_current(pble_session_token_t *session) { bool live; taskENTER_CRITICAL(&pble_session_mux); + pble_session_token_t current = { + .conn = pble_conn_handle, + .generation = pble_conn_generation, + .vm_epoch = pble_session_vm_epoch, + }; live = session != NULL && pble_conn_handle != BLE_HS_CONN_HANDLE_NONE && pble_conn_generation != 0 && + pble_session_admits_locked(¤t) && pble_term_admits(&pble_term_state, pble_conn_handle, pble_conn_generation) && pble_session_vm_epoch != 0; @@ -803,7 +924,7 @@ bool pble_ble_session_snapshot_current(pble_session_token_t *session) { bool pble_ble_session_live(const pble_session_token_t *session) { bool live; taskENTER_CRITICAL(&pble_session_mux); - live = pble_session_matches_locked(session) && + live = pble_session_admits_locked(session) && pble_term_admits(&pble_term_state, session->conn, session->generation); taskEXIT_CRITICAL(&pble_session_mux); @@ -813,48 +934,126 @@ bool pble_ble_session_live(const pble_session_token_t *session) { bool pble_ble_session_closing(void) { bool closing; taskENTER_CRITICAL(&pble_session_mux); - closing = pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || + closing = pble_termination_pending || + pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || + pble_term_state.phase == PBLE_TERM_PHASE_CLEANING || pble_term_state.phase == PBLE_TERM_PHASE_RESTARTING; taskEXIT_CRITICAL(&pble_session_mux); return closing; } +bool pble_ble_record_protocol_violation( + const pble_session_token_t *session) { + // Accepted SOFT_REBOOT closing keeps normal structural/CRC classification, + // but that globally gated traffic cannot consume this session's budget. + if (!pble_vm_reboot_command_admitted(false)) { + return true; + } + bool admitted = false; + bool terminate = false; + taskENTER_CRITICAL(&pble_session_mux); + if (pble_session_admits_locked(session) && + pble_protocol_violation_count < PBLE_SESSION_VIOLATION_LIMIT) { + pble_protocol_violation_count++; + if (pble_protocol_violation_count == + PBLE_SESSION_VIOLATION_LIMIT) { + terminate = pble_termination_latch_locked( + session, esp_timer_get_time(), PBLE_TERM_WATCHDOG_US); + } + admitted = !terminate; + } + taskEXIT_CRITICAL(&pble_session_mux); + + if (terminate) { + // The eighth violation is terminal and never publishes a response. + // Clear retained bytes before beginning exact-session teardown. + pble_reset_reassembly(); + pble_ble_terminate_session(session); + } + return admitted; +} + void pble_ble_terminate_session(const pble_session_token_t *session) { - if (session == NULL || pble_tx_mutex == NULL) { + if (session == NULL) { + return; + } + const int64_t termination_budget_us = PBLE_TERM_WATCHDOG_US; + int64_t request_now_us = esp_timer_get_time(); + int64_t begin_now_us = 0; + int64_t deadline_us = 0; + bool drive; + taskENTER_CRITICAL(&pble_session_mux); + drive = pble_termination_claim_driver_locked( + session, request_now_us, termination_budget_us, &begin_now_us, + &deadline_us); + taskEXIT_CRITICAL(&pble_session_mux); + if (!drive) { + return; + } + if (pble_tx_mutex == NULL) { + if (pble_termination_restart_if_current(session)) { + esp_restart(); + } return; } - int64_t begin_now_us = esp_timer_get_time(); + + int64_t lock_now_us = esp_timer_get_time(); + if (lock_now_us >= deadline_us) { + if (pble_termination_restart_if_current(session)) { + esp_restart(); + } + return; + } + int64_t residual_us = deadline_us - lock_now_us; + TickType_t residual_ticks = pdMS_TO_TICKS( + (uint32_t)(residual_us / INT64_C(1000))); pble_term_effects_t effect = PBLE_TERM_EFFECT_NONE; - if (xSemaphoreTakeRecursive(pble_tx_mutex, portMAX_DELAY) != pdTRUE) { - esp_restart(); + if (xSemaphoreTakeRecursive(pble_tx_mutex, residual_ticks) != pdTRUE) { + if (pble_termination_restart_if_current(session)) { + esp_restart(); + } return; } - taskENTER_CRITICAL(&pble_session_mux); - if (pble_session_matches_locked(session)) { - effect = pble_term_begin(&pble_term_state, session->conn, - session->generation, begin_now_us); - if (effect == PBLE_TERM_EFFECT_ARM_WATCHDOG && - pble_term_watchdog_ticket(&pble_term_state, session->conn, - session->generation, - &pble_term_armed_ticket)) { - int64_t initial_remaining_us = pble_term_remaining_us( - &pble_term_state, &pble_term_armed_ticket, - esp_timer_get_time()); - esp_err_t arm_rc = ESP_FAIL; - if (initial_remaining_us <= 0) { - arm_rc = ESP_FAIL; - } else { - arm_rc = esp_timer_start_once(pble_term_watchdog, - initial_remaining_us); + bool tx_deadline_live = esp_timer_get_time() < deadline_us; + bool restart_preclose = false; + if (!tx_deadline_live) { + restart_preclose = pble_termination_restart_if_current(session); + } else { + taskENTER_CRITICAL(&pble_session_mux); + bool exact_pending = pble_session_matches_locked(session) && + pble_termination_pending_matches_locked(session); + if (exact_pending) { + effect = pble_term_begin(&pble_term_state, session->conn, + session->generation, begin_now_us); + if (effect == PBLE_TERM_EFFECT_ARM_WATCHDOG && + pble_term_watchdog_ticket(&pble_term_state, session->conn, + session->generation, + &pble_term_armed_ticket)) { + int64_t initial_remaining_us = pble_term_remaining_us( + &pble_term_state, &pble_term_armed_ticket, + esp_timer_get_time()); + esp_err_t arm_rc = ESP_FAIL; + if (initial_remaining_us <= 0) { + arm_rc = ESP_FAIL; + } else { + arm_rc = esp_timer_start_once(pble_term_watchdog, + initial_remaining_us); + } + effect = pble_term_watchdog_armed( + &pble_term_state, session->conn, session->generation, + arm_rc == ESP_OK); } - effect = pble_term_watchdog_armed( - &pble_term_state, session->conn, session->generation, - arm_rc == ESP_OK); } + taskEXIT_CRITICAL(&pble_session_mux); } - taskEXIT_CRITICAL(&pble_session_mux); xSemaphoreGiveRecursive(pble_tx_mutex); + if (!tx_deadline_live) { + if (restart_preclose) { + esp_restart(); + } + return; + } if (effect == PBLE_TERM_EFFECT_RESTART) { esp_restart(); return; @@ -1258,6 +1457,8 @@ static void pble_ble_vm_seed_cold(uint64_t vm_epoch) { taskENTER_CRITICAL(&pble_session_mux); pble_vm_epoch_seed = vm_epoch; pble_session_vm_epoch = 0; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); taskEXIT_CRITICAL(&pble_session_mux); } @@ -1292,7 +1493,8 @@ void pble_ble_vm_reset(uint64_t vm_epoch) { old_session.conn = pble_conn_handle; old_session.generation = pble_conn_generation; old_session.vm_epoch = pble_session_vm_epoch; - if (pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || + if (pble_termination_pending || + pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || pble_term_state.phase == PBLE_TERM_PHASE_RESTARTING || pble_term_state.phase == PBLE_TERM_PHASE_CLEANING) { restart_required = true; @@ -1310,12 +1512,16 @@ void pble_ble_vm_reset(uint64_t vm_epoch) { pble_conn_generation = pble_conn_generation_counter; pble_session_vm_epoch = vm_epoch; pble_vm_epoch_seed = vm_epoch; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); } } else if (pble_term_state.phase == PBLE_TERM_PHASE_CLOSED && pble_conn_handle == BLE_HS_CONN_HANDLE_NONE && pble_conn_generation == 0) { pble_session_vm_epoch = 0; pble_vm_epoch_seed = vm_epoch; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); } else { restart_required = true; } @@ -1352,10 +1558,12 @@ void pble_ble_vm_invalidate_session(void) { old_session.conn = pble_conn_handle; old_session.generation = pble_conn_generation; old_session.vm_epoch = pble_session_vm_epoch; - restart_required = pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || + restart_required = pble_termination_pending || + pble_term_state.phase == PBLE_TERM_PHASE_CLOSING || pble_term_state.phase == PBLE_TERM_PHASE_RESTARTING || pble_term_state.phase == PBLE_TERM_PHASE_CLEANING; pble_session_vm_epoch = 0; + pble_protocol_violation_count = 0; taskEXIT_CRITICAL(&pble_session_mux); pble_rsp_cancel_session(&old_session); (void)pble_rsp_owner_release_if_idle(); @@ -1389,10 +1597,7 @@ bool pble_ble_vm_tx_lock(int64_t deadline_us) { } int64_t residual_us = deadline_us - now_us; TickType_t residual_ticks = pdMS_TO_TICKS( - (uint32_t)((residual_us + INT64_C(999)) / INT64_C(1000))); - if (residual_ticks == 0) { - residual_ticks = 1; - } + (uint32_t)(residual_us / INT64_C(1000))); if (xSemaphoreTakeRecursive(pble_tx_mutex, residual_ticks) != pdTRUE) { return false; } @@ -1453,13 +1658,11 @@ static int pble_rx_access(uint16_t conn_handle, uint16_t attr_handle, pble_session_token_t session; bool admitted; taskENTER_CRITICAL(&pble_session_mux); - admitted = pble_conn_handle == conn_handle && - pble_term_admits(&pble_term_state, pble_conn_handle, - pble_conn_generation) && - pble_session_vm_epoch != 0; session.conn = pble_conn_handle; session.generation = pble_conn_generation; session.vm_epoch = pble_session_vm_epoch; + admitted = pble_conn_handle == conn_handle && + pble_session_admits_locked(&session); taskEXIT_CRITICAL(&pble_session_mux); if (!admitted) { pble_vm_callback_leave(&activity); @@ -1660,6 +1863,8 @@ static int pble_gap_event(struct ble_gap_event *event, void *arg) { pble_conn_generation = pble_conn_generation_counter; pble_session_vm_epoch = pble_vm_epoch_seed; pble_conn_handle = event->connect.conn_handle; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); } taskEXIT_CRITICAL(&pble_session_mux); xSemaphoreGiveRecursive(pble_tx_mutex); @@ -1748,13 +1953,17 @@ static int pble_gap_event(struct ble_gap_event *event, void *arg) { (void)pble_rsp_owner_release_if_idle(); pble_reset_reassembly(); pble_lock_on_disconnect(ended.conn); - pble_fs_on_disconnect(); + pble_console_stdin_clear(); taskENTER_CRITICAL(&pble_session_mux); pble_conn_handle = BLE_HS_CONN_HANDLE_NONE; pble_conn_generation = 0; pble_session_vm_epoch = 0; + pble_protocol_violation_count = 0; effect = pble_term_cleanup_complete( &pble_term_state, ended.conn, ended.generation); + if (pble_term_state.phase == PBLE_TERM_PHASE_CLOSED) { + pble_termination_clear_locked(); + } taskEXIT_CRITICAL(&pble_session_mux); cleanup_completed = true; } @@ -1977,13 +2186,17 @@ static void pble_on_reset(int reason) { (void)pble_rsp_owner_release_if_idle(); pble_reset_reassembly(); pble_lock_on_disconnect(reset_session.conn); - pble_fs_on_disconnect(); + pble_console_stdin_clear(); taskENTER_CRITICAL(&pble_session_mux); pble_conn_handle = BLE_HS_CONN_HANDLE_NONE; pble_conn_generation = 0; pble_session_vm_epoch = 0; + pble_protocol_violation_count = 0; effect = pble_term_cleanup_complete( &pble_term_state, reset_session.conn, reset_session.generation); + if (pble_term_state.phase == PBLE_TERM_PHASE_CLOSED) { + pble_termination_clear_locked(); + } taskEXIT_CRITICAL(&pble_session_mux); cleanup_completed = true; } diff --git a/firmware/user_c_modules/pyble/pble_ble.h b/firmware/user_c_modules/pyble/pble_ble.h index f7ce15a3..9ddd106e 100644 --- a/firmware/user_c_modules/pyble/pble_ble.h +++ b/firmware/user_c_modules/pyble/pble_ble.h @@ -84,6 +84,12 @@ bool pble_ble_session_snapshot(uint16_t expected_conn, bool pble_ble_session_snapshot_current(pble_session_token_t *session); bool pble_ble_session_live(const pble_session_token_t *session); bool pble_ble_session_closing(void); +// Debit one violation against the exact live session. Violations 1..7 return +// true so a safely-correlatable refusal may be published. Violation 8 begins +// exact-session teardown and returns false; stale/closed tokens also return +// false without affecting a newer session. +bool pble_ble_record_protocol_violation( + const pble_session_token_t *session); void pble_ble_terminate_session(const pble_session_token_t *session); // Schedule the pre-created response callout after a slot enters the ready FIFO. diff --git a/firmware/user_c_modules/pyble/pble_boot.c b/firmware/user_c_modules/pyble/pble_boot.c index f9fd589d..be723ba8 100644 --- a/firmware/user_c_modules/pyble/pble_boot.c +++ b/firmware/user_c_modules/pyble/pble_boot.c @@ -33,29 +33,80 @@ #define BOOT_KEY_AUTORUN "autorun" // u8: 0 = off (default), 1 = on #define BOOT_MAIN_PATH "/main.py" // the opt-in auto-run entry at fs_root +enum { + BOOT_CONFIG_OK = 0, + BOOT_CONFIG_CORRUPT = 1, +}; +static uint8_t boot_config_fault; +static bool boot_autorun_loaded; +static bool boot_autorun_cached; + // --- Persisted opt-in flag (FR-BOOT-3) --------------------------------------- bool pble_boot_autorun_enabled(void) { + if (boot_autorun_loaded) { + return boot_autorun_cached; + } uint8_t v = 0; // default off if the key is absent nvs_handle_t h; - if (nvs_open(BOOT_NS, NVS_READONLY, &h) == ESP_OK) { - (void)nvs_get_u8(h, BOOT_KEY_AUTORUN, &v); // leaves 0 if not found - nvs_close(h); + esp_err_t open_rc = nvs_open(BOOT_NS, NVS_READONLY, &h); + if (open_rc == ESP_ERR_NVS_NOT_FOUND) { + // A never-created namespace is the clean erased-device first boot, + // not evidence of corrupt persisted configuration. + boot_autorun_loaded = true; + return false; + } + if (open_rc != ESP_OK) { + boot_config_fault = BOOT_CONFIG_CORRUPT; + boot_autorun_loaded = true; + return false; + } + esp_err_t rc = nvs_get_u8(h, BOOT_KEY_AUTORUN, &v); + nvs_close(h); + if (rc == ESP_ERR_NVS_NOT_FOUND) { + boot_autorun_loaded = true; + return false; } - return v != 0; + if (rc != ESP_OK || v > 1) { + boot_config_fault = BOOT_CONFIG_CORRUPT; + boot_autorun_loaded = true; + return false; + } + boot_autorun_cached = v == 1; + boot_autorun_loaded = true; + return v == 1; +} + +uint8_t pble_boot_config_fault(void) { + return boot_config_fault; } uint8_t pble_boot_set_autorun(bool enable) { + // Establish the prior runtime value before beginning the transaction. NVS + // write/commit failure must leave that value observable through caps, + // DEVICE_INFO, and boot admission for the rest of this boot. + if (!boot_autorun_loaded) { + (void)pble_boot_autorun_enabled(); + } nvs_handle_t h; if (nvs_open(BOOT_NS, NVS_READWRITE, &h) != ESP_OK) { + boot_config_fault = BOOT_CONFIG_CORRUPT; return PBLE_EIO; } uint8_t st = PBLE_OK; if (nvs_set_u8(h, BOOT_KEY_AUTORUN, enable ? 1 : 0) != ESP_OK) { st = PBLE_EIO; } else { - (void)nvs_commit(h); + esp_err_t commit_rc = nvs_commit(h); + if (commit_rc != ESP_OK) { + st = PBLE_EIO; + } } nvs_close(h); + if (st == PBLE_OK) { + boot_autorun_cached = enable; + boot_autorun_loaded = true; + } + boot_config_fault = st == PBLE_OK ? BOOT_CONFIG_OK : BOOT_CONFIG_CORRUPT; return st; } @@ -67,11 +118,12 @@ uint8_t pble_boot_set_autorun_cmd(const pble_frame_t *req, uint8_t *rsp, if (rsp_len) { *rsp_len = 0; // RSP{status} carries no extra bytes } - // §4 SET_AUTORUN payload = [enable:u8]; anything shorter is malformed. - if (req == NULL || req->payload == NULL || req->len < 1) { + // §4 SET_AUTORUN payload is exactly one Boolean-domain byte. + if (req == NULL || req->payload == NULL || req->len != 1 || + req->payload[0] > 1) { return PBLE_EBADREQ; } - return pble_boot_set_autorun(req->payload[0] != 0); + return pble_boot_set_autorun(req->payload[0] == 1); } // --- Opt-in auto-run at boot (FR-BOOT-3/4/6, FR-MODE-1) ----------------------- diff --git a/firmware/user_c_modules/pyble/pble_boot.h b/firmware/user_c_modules/pyble/pble_boot.h index ac1d7324..6bc7713e 100644 --- a/firmware/user_c_modules/pyble/pble_boot.h +++ b/firmware/user_c_modules/pyble/pble_boot.h @@ -28,15 +28,20 @@ extern "C" { #endif // The persisted opt-in auto-run flag (NVS "pyble"/"autorun", default 0 = off). -// Read by pble_info to serialize the auto_run capability (FR-BOOT-3). Cheap enough -// to call at boot; no caching required. +// Read by pble_info to serialize the auto_run capability (FR-BOOT-3). The first +// call loads NVS once; later reads use the boot-lifetime cache so failed repair +// transactions cannot change the previously observable runtime value. bool pble_boot_autorun_enabled(void); +// Bounded internal persistence-fault marker; no PBLE/1 field in v0.6.1. +uint8_t pble_boot_config_fault(void); + // Persist the auto-run flag. PBLE_OK on success; PBLE_EIO if NVS write fails. uint8_t pble_boot_set_autorun(bool enable); // 0x23 SET_AUTORUN dispatch handler (registered into pble_proto). Payload -// [enable:u8] (0=off, non-zero=on) -> persist -> RSP{status}. Short payload -> EBADREQ. +// [enable:u8] (exactly 0=off or 1=on) -> persist -> RSP{status}. Any other +// length/value is EBADREQ. uint8_t pble_boot_set_autorun_cmd(const pble_frame_t *req, uint8_t *rsp, size_t *rsp_len, const pble_session_token_t *session); diff --git a/firmware/user_c_modules/pyble/pble_console.c b/firmware/user_c_modules/pyble/pble_console.c index 5ac81f3a..80510adf 100644 --- a/firmware/user_c_modules/pyble/pble_console.c +++ b/firmware/user_c_modules/pyble/pble_console.c @@ -85,6 +85,7 @@ static uint8_t g_ring[PBLE_STDIN_RING]; static uint16_t g_ring_head; // next write static uint16_t g_ring_tail; // next read static uint16_t g_ring_count; +static bool g_stdin_active; static int64_t g_console_next_notify_us; // Emit staging: [stream][chunk]. Built only on the worker thread (single-writer), @@ -219,6 +220,32 @@ const mp_print_t pble_console_stderr_print = { NULL, console_stderr_strn }; // ============================================================================ // stdin ring: CONSOLE_INPUT (0x31) -> ring -> worker getchar // ============================================================================ +static inline void stdin_ring_clear_locked(void) { + g_ring_head = 0; + g_ring_tail = 0; + g_ring_count = 0; +} + +void pble_console_stdin_begin(void) { + taskENTER_CRITICAL(&g_ring_mux); + stdin_ring_clear_locked(); + g_stdin_active = true; + taskEXIT_CRITICAL(&g_ring_mux); +} + +void pble_console_stdin_end(void) { + taskENTER_CRITICAL(&g_ring_mux); + g_stdin_active = false; + stdin_ring_clear_locked(); + taskEXIT_CRITICAL(&g_ring_mux); +} + +void pble_console_stdin_clear(void) { + taskENTER_CRITICAL(&g_ring_mux); + stdin_ring_clear_locked(); + taskEXIT_CRITICAL(&g_ring_mux); +} + // 0x31 host task: append bytes, dropping any overflow (bounded — never grows the // heap, never blocks the host task). Fire-and-forget: no RSP frame. uint8_t pble_console_input(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, @@ -230,13 +257,15 @@ uint8_t pble_console_input(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, } if (req != NULL && req->payload != NULL && req->len > 0) { taskENTER_CRITICAL(&g_ring_mux); - for (uint16_t i = 0; i < req->len; i++) { - if (g_ring_count >= PBLE_STDIN_RING) { - break; // full: drop the overflow tail (bounded) + if (g_stdin_active) { + for (uint16_t i = 0; i < req->len; i++) { + if (g_ring_count >= PBLE_STDIN_RING) { + break; // full: drop the overflow tail (bounded) + } + g_ring[g_ring_head] = req->payload[i]; + g_ring_head = (uint16_t)((g_ring_head + 1) % PBLE_STDIN_RING); + g_ring_count++; } - g_ring[g_ring_head] = req->payload[i]; - g_ring_head = (uint16_t)((g_ring_head + 1) % PBLE_STDIN_RING); - g_ring_count++; } taskEXIT_CRITICAL(&g_ring_mux); } @@ -246,8 +275,11 @@ uint8_t pble_console_input(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, // Worker stdin drain for input()/sys.stdin: next byte, or -1 when empty. int pble_console_stdin_getchar(void) { int c = -1; + if (!on_worker()) { + return c; + } taskENTER_CRITICAL(&g_ring_mux); - if (g_ring_count > 0) { + if (g_stdin_active && g_ring_count > 0) { c = g_ring[g_ring_tail]; g_ring_tail = (uint16_t)((g_ring_tail + 1) % PBLE_STDIN_RING); g_ring_count--; @@ -259,7 +291,7 @@ int pble_console_stdin_getchar(void) { static bool stdin_ring_readable(void) { bool readable; taskENTER_CRITICAL(&g_ring_mux); - readable = g_ring_count > 0; + readable = g_stdin_active && g_ring_count > 0; taskEXIT_CRITICAL(&g_ring_mux); return readable; } @@ -270,6 +302,7 @@ void pble_console_vm_detach(void) { void pble_console_vm_reset(void) { taskENTER_CRITICAL(&g_ring_mux); + g_stdin_active = false; g_ring_head = 0; g_ring_tail = 0; g_ring_count = 0; diff --git a/firmware/user_c_modules/pyble/pble_console.h b/firmware/user_c_modules/pyble/pble_console.h index aa9eb142..08c5bbe3 100644 --- a/firmware/user_c_modules/pyble/pble_console.h +++ b/firmware/user_c_modules/pyble/pble_console.h @@ -54,6 +54,13 @@ extern const mp_print_t pble_console_stderr_print; uint8_t pble_console_input(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, const pble_session_token_t *session); +// Run-scoped stdin lifecycle. RUN/autorun admission clears+activates; accepted +// STOP and every terminal/VM-reset clear+deactivate; disconnect clears while +// retaining activity so a continuing run may accept only successor-session data. +void pble_console_stdin_begin(void); +void pble_console_stdin_end(void); +void pble_console_stdin_clear(void); + // Worker stdin drain for input()/sys.stdin: next byte, or -1 when empty. int pble_console_stdin_getchar(void); diff --git a/firmware/user_c_modules/pyble/pble_device_config.c b/firmware/user_c_modules/pyble/pble_device_config.c index 6053e482..7e3f7456 100644 --- a/firmware/user_c_modules/pyble/pble_device_config.c +++ b/firmware/user_c_modules/pyble/pble_device_config.c @@ -5,6 +5,7 @@ // Clean-room vs protocol.md §2/§4/§7; identity is display-only (SEC-11). #include "pble_device_config.h" #include "pble_ble.h" // pble_ble_set_adv_name +#include "pble_wire.h" // shared strict UTF-8/control label decision #include <string.h> #include <stdio.h> @@ -25,10 +26,20 @@ #define DC_KEY_LABEL "label" #define DC_KEY_IDGPIO "id_gpio" // identify-LED GPIO number #define DC_KEY_IDAL "id_al" // identify-LED active level (0/1) +#define DC_KEY_IDCFG "id_cfg" +#define DC_IDCFG_VERSION 1 +#define DC_IDCFG_LEN 4 + +enum { + DC_CONFIG_FAULT_NONE = 0, + DC_CONFIG_FAULT_LABEL = 1, + DC_CONFIG_FAULT_IDENTIFY = 2, +}; static char dc_device_id[5]; // "XXXX" + NUL static char dc_label[PBLE_LABEL_MAX + 1]; // persisted label or "" static bool dc_inited = false; +static uint8_t dc_config_fault; // --- Identify LED (F-23): the single optional status LED (CON-13) ------------ static bool dc_id_configured; @@ -62,6 +73,10 @@ static bool dc_gpio_is_valid_output(int gpio) { GPIO_IS_VALID_OUTPUT_GPIO(gpio); } +uint8_t pble_dc_label_status(const uint8_t *utf8, size_t len) { + return pble_wire_label_status(utf8, len); +} + // Drive the LED to logical on/off honouring the active level. static void dc_led_write(bool on) { gpio_set_level(dc_id_gpio, dc_id_active_high ? (on ? 1 : 0) : (on ? 0 : 1)); @@ -95,21 +110,93 @@ void pble_dc_init(void) { esp_read_mac(mac, ESP_MAC_BT); // BT MAC == the advertised BLE address snprintf(dc_device_id, sizeof(dc_device_id), "%02X%02X", mac[4], mac[5]); - dc_label[0] = '\0'; + memset(dc_label, 0, sizeof(dc_label)); dc_id_configured = false; + dc_config_fault = DC_CONFIG_FAULT_NONE; + bool identify_candidate = false; + uint8_t identify_gpio = 0; + uint8_t identify_active_high = 0; nvs_handle_t h; - if (nvs_open(DC_NS, NVS_READONLY, &h) == ESP_OK) { + esp_err_t open_rc = nvs_open(DC_NS, NVS_READONLY, &h); + if (open_rc == ESP_OK) { size_t n = sizeof(dc_label); - (void)nvs_get_str(h, DC_KEY_LABEL, dc_label, &n); // leaves "" if absent - uint8_t g, al; - if (nvs_get_u8(h, DC_KEY_IDGPIO, &g) == ESP_OK && - nvs_get_u8(h, DC_KEY_IDAL, &al) == ESP_OK && - dc_gpio_is_valid_output(g)) { - dc_id_gpio = g; - dc_id_active_high = al ? 1 : 0; - dc_id_configured = true; + esp_err_t label_rc = nvs_get_str(h, DC_KEY_LABEL, dc_label, &n); + if (label_rc == ESP_OK) { + bool shaped = n > 0 && n <= sizeof(dc_label) && + dc_label[n - 1] == '\0' && strlen(dc_label) == n - 1; + if (!shaped || + pble_dc_label_status((const uint8_t *)dc_label, + shaped ? n - 1 : 0) != PBLE_OK) { + dc_label[0] = '\0'; + dc_config_fault |= DC_CONFIG_FAULT_LABEL; + } + } else if (label_rc != ESP_ERR_NVS_NOT_FOUND) { + dc_config_fault |= DC_CONFIG_FAULT_LABEL; + } + + // The versioned blob is authoritative whenever present. Only its exact + // absence permits a complete, validated legacy-pair fallback. + uint8_t id_cfg[DC_IDCFG_LEN] = {0}; + size_t id_len = 0; + esp_err_t id_rc = nvs_get_blob(h, DC_KEY_IDCFG, NULL, &id_len); + if (id_rc == ESP_OK) { + bool valid = false; + if (id_len == DC_IDCFG_LEN) { + size_t actual = sizeof(id_cfg); + id_rc = nvs_get_blob(h, DC_KEY_IDCFG, id_cfg, &actual); + if (id_rc == ESP_OK && actual == DC_IDCFG_LEN && + id_cfg[0] == DC_IDCFG_VERSION && id_cfg[1] <= 1 && + id_cfg[3] <= 1) { + if (id_cfg[1] == 0) { + valid = id_cfg[2] == 0 && id_cfg[3] == 0; + } else { + valid = true; + identify_candidate = true; + identify_gpio = id_cfg[2]; + identify_active_high = id_cfg[3]; + } + } + } + if (!valid) { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + } + } else if (id_rc == ESP_ERR_NVS_NOT_FOUND) { + uint8_t g = 0; + uint8_t al = 0; + esp_err_t gpio_rc = nvs_get_u8(h, DC_KEY_IDGPIO, &g); + esp_err_t active_rc = nvs_get_u8(h, DC_KEY_IDAL, &al); + if (gpio_rc == ESP_OK && active_rc == ESP_OK) { + if (al <= 1) { + identify_candidate = true; + identify_gpio = g; + identify_active_high = al; + } else { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + } + } else if (gpio_rc != ESP_ERR_NVS_NOT_FOUND || + active_rc != ESP_ERR_NVS_NOT_FOUND) { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + } + } else { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; } nvs_close(h); + // Both the authoritative blob and the complete legacy pair converge + // on this one bounded check. Never pass an untrusted stored byte to + // ESP-IDF's target-specific output-capability macro before bounding it. + if (identify_candidate) { + if (dc_gpio_is_valid_output(identify_gpio)) { + dc_id_gpio = identify_gpio; + dc_id_active_high = identify_active_high; + dc_id_configured = true; + } else { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + } + } + } else if (open_rc != ESP_ERR_NVS_NOT_FOUND) { + // A missing namespace is clean first boot; every other open failure is + // uncertain persisted state and therefore selects bounded safe faults. + dc_config_fault = DC_CONFIG_FAULT_LABEL | DC_CONFIG_FAULT_IDENTIFY; } if (dc_id_configured) { dc_led_configure(); @@ -117,6 +204,11 @@ void pble_dc_init(void) { dc_inited = true; } +uint8_t pble_dc_config_fault(void) { + pble_dc_init(); + return dc_config_fault; +} + const char *pble_dc_device_id(void) { pble_dc_init(); return dc_device_id; @@ -145,12 +237,21 @@ size_t pble_dc_adv_name(char *out, size_t cap) { } uint8_t pble_dc_set_label(const uint8_t *utf8, size_t len) { - if (len > PBLE_LABEL_MAX) { - return PBLE_ERANGE; // OI-6: over-length rejected, not stored + uint8_t validation = pble_dc_label_status(utf8, len); + if (validation != PBLE_OK) { + return validation; } pble_dc_init(); + + char candidate[PBLE_LABEL_MAX + 1]; + if (len > 0) { + memcpy(candidate, utf8, len); + } + candidate[len] = '\0'; + nvs_handle_t h; if (nvs_open(DC_NS, NVS_READWRITE, &h) != ESP_OK) { + dc_config_fault |= DC_CONFIG_FAULT_LABEL; return PBLE_EIO; } uint8_t st = PBLE_OK; @@ -158,30 +259,30 @@ uint8_t pble_dc_set_label(const uint8_t *utf8, size_t len) { esp_err_t e = nvs_erase_key(h, DC_KEY_LABEL); // clear to default if (e != ESP_OK && e != ESP_ERR_NVS_NOT_FOUND) { st = PBLE_EIO; - } else { - dc_label[0] = '\0'; } } else { - char tmp[PBLE_LABEL_MAX + 1]; - memcpy(tmp, utf8, len); - tmp[len] = '\0'; - if (nvs_set_str(h, DC_KEY_LABEL, tmp) != ESP_OK) { + if (nvs_set_str(h, DC_KEY_LABEL, candidate) != ESP_OK) { st = PBLE_EIO; - } else { - memcpy(dc_label, tmp, len + 1); } } if (st == PBLE_OK) { - (void)nvs_commit(h); + esp_err_t commit_rc = nvs_commit(h); + if (commit_rc != ESP_OK) { + st = PBLE_EIO; + } } nvs_close(h); - if (st == PBLE_OK) { - char name[PBLE_LABEL_MAX + 8]; // "PyBLE-XXXX" or a bounded label - pble_dc_adv_name(name, sizeof(name)); - pble_ble_set_adv_name(name); // re-advertise pre-connect (FR-BLE-12) + if (st != PBLE_OK) { + dc_config_fault |= DC_CONFIG_FAULT_LABEL; + return st; } - return st; + memcpy(dc_label, candidate, len + 1); + dc_config_fault &= (uint8_t)~DC_CONFIG_FAULT_LABEL; + char name[PBLE_LABEL_MAX + 8]; // "PyBLE-XXXX" or a bounded label + pble_dc_adv_name(name, sizeof(name)); + pble_ble_set_adv_name(name); // re-advertise pre-connect (FR-BLE-12) + return PBLE_OK; } uint8_t pble_dc_set_label_cmd(const pble_frame_t *req, uint8_t *rsp, @@ -340,14 +441,50 @@ static void dc_blink_cb(void *arg) { uint8_t pble_dc_set_identify_led(const uint8_t *payload, size_t len) { pble_dc_init(); + if (len != 0 && len != 2) { + return PBLE_EBADREQ; + } + bool enable = len == 2; + uint8_t gpio = 0; + uint8_t al = 0; + if (enable) { + if (payload == NULL) { + return PBLE_EBADREQ; + } + gpio = payload[0]; + al = payload[1]; + if (al > 1) { + return PBLE_EBADREQ; // active_level ∉ {0,1} + } + if (!dc_gpio_is_valid_output(gpio)) { + return PBLE_ERANGE; // gpio out of range + } + } + + uint8_t candidate[DC_IDCFG_LEN] = { + DC_IDCFG_VERSION, enable ? 1 : 0, gpio, al, + }; nvs_handle_t h; - if (len == 0) { // clear / disable - if (nvs_open(DC_NS, NVS_READWRITE, &h) == ESP_OK) { - (void)nvs_erase_key(h, DC_KEY_IDGPIO); - (void)nvs_erase_key(h, DC_KEY_IDAL); - (void)nvs_commit(h); - nvs_close(h); + if (nvs_open(DC_NS, NVS_READWRITE, &h) != ESP_OK) { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + return PBLE_EIO; + } + uint8_t st = PBLE_OK; + if (nvs_set_blob(h, DC_KEY_IDCFG, candidate, sizeof(candidate)) != ESP_OK) { + st = PBLE_EIO; + } + if (st == PBLE_OK) { + esp_err_t commit_rc = nvs_commit(h); + if (commit_rc != ESP_OK) { + st = PBLE_EIO; } + } + nvs_close(h); + if (st != PBLE_OK) { + dc_config_fault |= DC_CONFIG_FAULT_IDENTIFY; + return st; + } + if (!enable) { taskENTER_CRITICAL(&dc_blink_mux); if (dc_blink_timer != NULL) { (void)esp_timer_stop(dc_blink_timer); @@ -362,33 +499,9 @@ uint8_t pble_dc_set_identify_led(const uint8_t *payload, size_t len) { dc_led_write(false); } dc_id_configured = false; + dc_config_fault &= (uint8_t)~DC_CONFIG_FAULT_IDENTIFY; taskEXIT_CRITICAL(&dc_blink_mux); - return PBLE_OK; - } - if (len != 2) { - return PBLE_EBADREQ; - } - uint8_t gpio = payload[0]; - uint8_t al = payload[1]; - if (al > 1) { - return PBLE_EBADREQ; // active_level ∉ {0,1} - } - if (!dc_gpio_is_valid_output(gpio)) { - return PBLE_ERANGE; // gpio out of range - } - if (nvs_open(DC_NS, NVS_READWRITE, &h) != ESP_OK) { - return PBLE_EIO; - } - uint8_t st = PBLE_OK; - if (nvs_set_u8(h, DC_KEY_IDGPIO, gpio) != ESP_OK || - nvs_set_u8(h, DC_KEY_IDAL, al) != ESP_OK) { - st = PBLE_EIO; - } - if (st == PBLE_OK) { - (void)nvs_commit(h); - } - nvs_close(h); - if (st == PBLE_OK) { + } else { taskENTER_CRITICAL(&dc_blink_mux); if (dc_blink_timer != NULL) { (void)esp_timer_stop(dc_blink_timer); @@ -405,10 +518,11 @@ uint8_t pble_dc_set_identify_led(const uint8_t *payload, size_t len) { dc_id_gpio = gpio; dc_id_active_high = al; dc_id_configured = true; + dc_config_fault &= (uint8_t)~DC_CONFIG_FAULT_IDENTIFY; taskEXIT_CRITICAL(&dc_blink_mux); dc_led_configure(); } - return st; + return PBLE_OK; } uint8_t pble_dc_set_identify_led_cmd(const pble_frame_t *req, uint8_t *rsp, diff --git a/firmware/user_c_modules/pyble/pble_device_config.h b/firmware/user_c_modules/pyble/pble_device_config.h index 3563e2d9..a72146ec 100644 --- a/firmware/user_c_modules/pyble/pble_device_config.h +++ b/firmware/user_c_modules/pyble/pble_device_config.h @@ -21,6 +21,9 @@ extern "C" { // call once at boot before advertising (NVS must already be initialised). void pble_dc_init(void); +// Bounded internal persistence-fault bitset; no PBLE/1 field in v0.6.1. +uint8_t pble_dc_config_fault(void); + // The stable device_id: the last two BT-MAC bytes as uppercase hex ("XXXX"). const char *pble_dc_device_id(void); @@ -29,6 +32,7 @@ size_t pble_dc_label(char *out, size_t cap); // Persist a label (FR-IDENT-1): len > PBLE_LABEL_MAX → ERANGE (not stored); // len == 0 clears to the default. On success re-advertises via pble_ble_set_adv_name. +uint8_t pble_dc_label_status(const uint8_t *utf8, size_t len); uint8_t pble_dc_set_label(const uint8_t *utf8, size_t len); // The advertised name: the label if set, else "PyBLE-" + device_id (FR-BLE-12). diff --git a/firmware/user_c_modules/pyble/pble_fs.c b/firmware/user_c_modules/pyble/pble_fs.c index 83ad7e39..ccfd4d60 100644 --- a/firmware/user_c_modules/pyble/pble_fs.c +++ b/firmware/user_c_modules/pyble/pble_fs.c @@ -61,9 +61,10 @@ #define PBLE_FS_PATH_MAX 128 // max path bytes on the wire (§5), else ERANGE #define PBLE_FS_PATH_BUF 160 // resolved/temp/dest buffer (leading '/' + ".pbltmp") #define PBLE_FS_TMP_SUFFIX ".pbltmp" // reserved transfer-scratch suffix (jailed) +#define PBLE_FS_SAFETY_RESERVE 65536u // bytes kept free after PUT admission // --- Internal design bounds (architect: queue depth = W+2, W=8 2026-07-04) ---- -#define PBLE_FS_ITEM_PAYLOAD 248 // ≥ [offset:4] + max chunk (mtu-4 ≤ 243) +#define PBLE_FS_ITEM_PAYLOAD 260 // [slen:2]+src[128]+[dlen:2]+dst[128] #define PBLE_FS_QDEPTH 10 // W + 2 unacknowledged PUT_DATA + control (W=PBLE_FS_PUT_WINDOW) #define PBLE_FS_SCRATCH 512 // worker-only static TX/read buffer @@ -83,6 +84,8 @@ typedef struct { uint8_t id; pble_session_token_t session; uint64_t vm_epoch; + uint64_t transfer_generation; + bool get_active_at_enqueue; pble_rsp_ticket_t ticket; uint16_t len; uint8_t payload[PBLE_FS_ITEM_PAYLOAD]; @@ -100,6 +103,17 @@ static bool g_fs_admission_open; static bool g_fs_worker_busy; static uint32_t g_fs_outstanding; static bool g_fs_dequeue_claim; +static bool g_fs_registered; +static uint64_t g_fs_registration_epoch; +static portMUX_TYPE g_fs_transfer_mux = portMUX_INITIALIZER_UNLOCKED; +static uint64_t g_fs_transfer_generation = 1; +static bool g_fs_transfer_exhausted; + +// GET streams occupy the one fs worker until completion. Namespace mutations +// admitted concurrently snapshot this generation-qualified bit so that their +// later dequeue cannot erase the admission-time EBUSY decision. +static bool g_get_active; +static uint64_t g_get_generation; // Worker-local single-active-transfer PUT state machine (§5 windowed upload). static bool g_put_active; @@ -110,13 +124,30 @@ static uint32_t g_put_crc_target; // declared whole-file crc32 static uint32_t g_put_watermark; // highest contiguous byte written static uint32_t g_put_crc_running; // streaming whole-file CRC, PRE-final-xor static uint8_t g_put_latched; // 0, or a latched write status +static uint64_t g_put_generation; // exact FS generation that owns g_put_* // The open temp file object must survive GC across PUT_DATA calls → rooted. MP_REGISTER_ROOT_POINTER(mp_obj_t pble_fs_put_file); +static bool fs_put_owned(const pble_fs_req_t *it); +static bool fs_put_active_current(const pble_fs_req_t *it); + +static bool pble_fs_transfer_valid(const pble_fs_req_t *it) { + if (it == NULL) { + return false; + } + bool valid; + taskENTER_CRITICAL(&g_fs_transfer_mux); + valid = !g_fs_transfer_exhausted && + it->transfer_generation == g_fs_transfer_generation; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return valid; +} + static bool pble_fs_item_valid(const pble_fs_req_t *it) { return it != NULL && pble_ble_session_live(&it->session) && - pble_vm_epoch_valid(it->vm_epoch); + pble_vm_epoch_valid(it->vm_epoch) && + pble_fs_transfer_valid(it); } static bool pble_fs_ticket_valid(const pble_fs_req_t *it) { @@ -202,10 +233,59 @@ static bool fs_reserved_prefix(const char *c) { } // True if `name` ends with the reserved ".pbltmp" transfer-scratch suffix. -static bool fs_has_tmp_suffix(const char *name) { - size_t nl = strlen(name); +static bool fs_has_tmp_suffix(const char *name, size_t nl) { size_t sl = sizeof(PBLE_FS_TMP_SUFFIX) - 1; - return nl >= sl && strcmp(name + nl - sl, PBLE_FS_TMP_SUFFIX) == 0; + return nl >= sl && + memcmp(name + nl - sl, PBLE_FS_TMP_SUFFIX, sl) == 0; +} + +// Accept only Unicode scalar values in their shortest UTF-8 encoding. Paths +// are byte-counted on the wire, so validation must happen before strtok/strlen +// or any VFS call can reinterpret malformed byte sequences. +static bool fs_utf8_valid(const char *input, size_t len) { + const uint8_t *bytes = (const uint8_t *)input; + size_t i = 0; + while (i < len) { + uint8_t first = bytes[i++]; + if (first <= 0x7fu) { + if (first == 0u) { + return false; + } + continue; + } + if (first >= 0xc2u && first <= 0xdfu) { + if (i >= len || (bytes[i] & 0xc0u) != 0x80u) { + return false; + } + ++i; + continue; + } + if (first >= 0xe0u && first <= 0xefu) { + if (i + 1u >= len || + (bytes[i] & 0xc0u) != 0x80u || + (bytes[i + 1u] & 0xc0u) != 0x80u || + (first == 0xe0u && bytes[i] < 0xa0u) || + (first == 0xedu && bytes[i] >= 0xa0u)) { + return false; + } + i += 2u; + continue; + } + if (first >= 0xf0u && first <= 0xf4u) { + if (i + 2u >= len || + (bytes[i] & 0xc0u) != 0x80u || + (bytes[i + 1u] & 0xc0u) != 0x80u || + (bytes[i + 2u] & 0xc0u) != 0x80u || + (first == 0xf0u && bytes[i] < 0x90u) || + (first == 0xf4u && bytes[i] > 0x8fu)) { + return false; + } + i += 3u; + continue; + } + return false; + } + return true; } uint8_t pble_fs_resolve(const char *in, size_t inlen, char *out, size_t outcap) { @@ -215,10 +295,8 @@ uint8_t pble_fs_resolve(const char *in, size_t inlen, char *out, size_t outcap) if (inlen > PBLE_FS_PATH_MAX) { return PBLE_ERANGE; } - for (size_t i = 0; i < inlen; i++) { - if (in[i] == '\0') { - return PBLE_EBADREQ; // embedded NUL — reject NUL injection - } + if (!fs_utf8_valid(in, inlen)) { + return PBLE_EBADREQ; } // Mutable NUL-terminated copy for tokenization. @@ -243,7 +321,7 @@ uint8_t pble_fs_resolve(const char *in, size_t inlen, char *out, size_t outcap) np--; continue; } - if (fs_has_tmp_suffix(tok)) { + if (fs_has_tmp_suffix(tok, strlen(tok))) { return PBLE_EACCES; // reserved transfer-scratch suffix } parts[np++] = tok; @@ -290,6 +368,22 @@ static mp_obj_t fs_str(const char *s) { return mp_obj_new_str(s, strlen(s)); } +// Close an exact local VFS object regardless of whether the request identity +// that opened it is still current. Cancellation forbids stale protocol +// effects, but it must not turn already-acquired worker resources into leaks. +// Close is best-effort because cleanup must preserve the original status (or +// silent cancellation) when the VFS itself raises while unwinding. +static void fs_close_local(mp_obj_t file) { + if (file == MP_OBJ_NULL) { + return; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + mp_stream_close(file); + nlr_pop(); + } +} + // Open a jailed path via the VFS with the given mode ("rb"/"wb"). Raises on error. static mp_obj_t fs_open(const pble_fs_req_t *it, pble_fs_validator_t validator, @@ -302,6 +396,10 @@ static mp_obj_t fs_open(const pble_fs_req_t *it, } mp_obj_t opened = mp_vfs_open(2, args, &kw); if (!validator(it)) { + // The open itself may have raced invalidation. Closing this exact local + // object is the sole VFS cleanup a stale BEGIN may still perform. + fs_close_local(opened); + (void)validator(it); return MP_OBJ_NULL; } return opened; @@ -323,6 +421,10 @@ static uint32_t fs_chunk(void) { return c; } +static bool fs_mode_is_regular(mp_int_t mode) { + return (mode & 0xf000) == MP_S_IFREG; +} + // mp_vfs_stat under its own nlr → size + is-dir. Returns §8 status. static uint8_t fs_stat_path(const pble_fs_req_t *it, const char *path, uint32_t *size, bool *isdir) { @@ -342,8 +444,14 @@ static uint8_t fs_stat_path(const pble_fs_req_t *it, const char *path, size_t n; mp_obj_get_array(r, &n, &f); mp_int_t mode = (n > 0) ? mp_obj_get_int(f[0]) : 0; + bool isreg = fs_mode_is_regular(mode); + bool directory = (mode & 0xf000) == MP_S_IFDIR; + if (!isreg && !directory) { + nlr_pop(); + return PBLE_EACCES; + } *size = (n > 6) ? (uint32_t)mp_obj_get_int(f[6]) : 0; - *isdir = (mode & MP_S_IFDIR) ? true : false; + *isdir = directory; nlr_pop(); st = PBLE_OK; } else { @@ -352,68 +460,77 @@ static uint8_t fs_stat_path(const pble_fs_req_t *it, const char *path, return st; } -// Whole-file CRC-32 over [0, size). Opens "rb", streams into g_scratch, always +// Whole-file CRC-32 over exactly the stat-advertised extent. Opens "rb", +// streams into g_scratch, rejects premature EOF/overreported reads, and always // closes. Returns §8 status; *out set on OK. static uint8_t fs_crc_file(const pble_fs_req_t *it, const char *path, - uint32_t *out) { + uint32_t expected, uint32_t *out) { nlr_buf_t nlr; uint8_t st; volatile mp_obj_t f = MP_OBJ_NULL; if (nlr_push(&nlr) == 0) { f = fs_open(it, pble_fs_ticket_valid, path, "rb"); if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } const mp_stream_p_t *sp = mp_get_stream((mp_obj_t)f); uint32_t crc = 0xFFFFFFFFu; - for (;;) { + uint32_t remaining = expected; + while (remaining > 0) { int err; if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } - mp_uint_t n = sp->read((mp_obj_t)f, g_scratch, PBLE_FS_SCRATCH, &err); + mp_uint_t want = (remaining > PBLE_FS_SCRATCH) + ? PBLE_FS_SCRATCH + : (mp_uint_t)remaining; + mp_uint_t n = sp->read((mp_obj_t)f, g_scratch, want, &err); if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } if (n == MP_STREAM_ERROR) { mp_raise_OSError(err); } + if (n > PBLE_FS_SCRATCH || n > want) { + mp_raise_OSError(MP_EIO); + } if (n == 0) { - break; + mp_raise_OSError(MP_EIO); } crc = crc32_update(crc, g_scratch, n); + remaining -= (uint32_t)n; } if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } mp_stream_close((mp_obj_t)f); + f = MP_OBJ_NULL; if (!pble_fs_ticket_valid(it)) { nlr_pop(); return PBLE_NO_RSP; } - f = MP_OBJ_NULL; *out = crc ^ 0xFFFFFFFFu; nlr_pop(); st = PBLE_OK; } else { if (f != MP_OBJ_NULL) { - nlr_buf_t n2; - if (nlr_push(&n2) == 0) { - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - mp_stream_close((mp_obj_t)f); - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - nlr_pop(); - } + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; + } + if (!pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; } st = fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val)); } @@ -432,6 +549,8 @@ static uint8_t fs_crc_prefix(const pble_fs_req_t *it, const char *path, if (nlr_push(&nlr) == 0) { f = fs_open(it, pble_fs_ticket_valid, path, "rb"); if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } @@ -442,51 +561,54 @@ static uint8_t fs_crc_prefix(const pble_fs_req_t *it, const char *path, uint32_t want = (remaining < PBLE_FS_SCRATCH) ? remaining : PBLE_FS_SCRATCH; int err; if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } mp_uint_t n = sp->read((mp_obj_t)f, g_scratch, want, &err); if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } if (n == MP_STREAM_ERROR) { mp_raise_OSError(err); } + if (n > want) { + mp_raise_OSError(MP_EIO); + } if (n == 0) { - break; // temp shorter than stat → CRC what exists + // Premature EOF is malformed scratch, never a verified resume + // prefix. MP_EIO maps to the required PBLE_EIO cleanup path. + mp_raise_OSError(MP_EIO); } crc = crc32_update(crc, g_scratch, n); remaining -= (uint32_t)n; } if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } mp_stream_close((mp_obj_t)f); + f = MP_OBJ_NULL; if (!pble_fs_ticket_valid(it)) { nlr_pop(); return PBLE_NO_RSP; } - f = MP_OBJ_NULL; *running = crc; // NOT finalized — continued by PUT_DATA nlr_pop(); st = PBLE_OK; } else { if (f != MP_OBJ_NULL) { - nlr_buf_t n2; - if (nlr_push(&n2) == 0) { - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - mp_stream_close((mp_obj_t)f); - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - nlr_pop(); - } + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; + } + if (!pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; } st = fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val)); } @@ -552,6 +674,9 @@ static uint8_t fs_do_list(const pble_fs_req_t *it, size_t *extra) { mp_obj_get_array(item, &nf, &fld); size_t nlen; const char *name = mp_obj_str_get_data(fld[0], &nlen); + if (fs_has_tmp_suffix(name, nlen)) { + continue; // internal scratch consumes no LIST budget + } mp_int_t type = (nf > 1) ? mp_obj_get_int(fld[1]) : 0; uint8_t etype = (type & MP_S_IFDIR) ? 1 : 0; @@ -635,7 +760,7 @@ static uint8_t fs_do_stat(const pble_fs_req_t *it, size_t *extra) { } uint32_t crc = 0; if (!isdir) { - st = fs_crc_file(it, path, &crc); + st = fs_crc_file(it, path, size, &crc); if (st != PBLE_OK) { return st; } @@ -695,7 +820,7 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { if (rc != PBLE_OK) { return rc; } - if (g_put_active) { + if (g_put_active && fs_put_active_current(it)) { return PBLE_EBUSY; // single active transfer } @@ -718,11 +843,31 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { return PBLE_ERANGE; } + // From this cut until every stream exit, concurrent namespace mutations + // must retain that they were admitted during this GET. The worker itself + // is single-threaded, but host-task enqueue runs concurrently. + bool get_claimed = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (!g_fs_transfer_exhausted && + it->transfer_generation == g_fs_transfer_generation && + (!g_get_active || g_get_generation != g_fs_transfer_generation)) { + g_get_active = true; + g_get_generation = it->transfer_generation; + get_claimed = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + if (!get_claimed) { + return PBLE_NO_RSP; + } + if (!pble_fs_ticket_valid(it)) { + goto get_done; + } + // RSP{OK}[total_size:u32] must fully complete before dependent events. g_scratch[0] = PBLE_OK; le32(g_scratch + 1, total); if (!pble_rsp_expect_completion(&it->ticket)) { - return PBLE_NO_RSP; + goto get_done; } if (!pble_rsp_publish(&it->ticket, PBLE_OP_FILE_GET_BEGIN, it->id, g_scratch, 5)) { @@ -732,50 +877,65 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { bool rsp_delivered = pble_rsp_wait(&it->ticket); MP_THREAD_GIL_ENTER(); if (!rsp_delivered || !pble_fs_item_valid(it)) { - return PBLE_NO_RSP; + goto get_done; } uint32_t chunk = fs_chunk(); uint32_t crc = 0xFFFFFFFFu; + uint32_t remaining = total; nlr_buf_t nlr; volatile mp_obj_t f = MP_OBJ_NULL; volatile int tx_dead = 0; // link gone / backpressure budget exhausted + volatile int stream_complete = 0; if (nlr_push(&nlr) == 0) { if (!pble_fs_item_valid(it)) { nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } f = fs_open(it, pble_fs_item_valid, path, "rb"); if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } const mp_stream_p_t *sp = mp_get_stream((mp_obj_t)f); uint32_t pos = 0; - for (;;) { + while (remaining > 0) { if (!pble_fs_item_valid(it)) { tx_dead = 1; break; } int err; + mp_uint_t want = (remaining > chunk) + ? (mp_uint_t)chunk + : (mp_uint_t)remaining; // Read into g_scratch+4 so a [offset:u32] header can sit contiguously // in front of the emitted slice. if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } - mp_uint_t n = sp->read((mp_obj_t)f, g_scratch + 4, chunk, &err); + mp_uint_t n = sp->read((mp_obj_t)f, g_scratch + 4, want, &err); if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } if (n == MP_STREAM_ERROR) { mp_raise_OSError(err); } + if (n > chunk || n > want) { + mp_raise_OSError(MP_EIO); + } if (n == 0) { - break; + mp_raise_OSError(MP_EIO); } crc = crc32_update(crc, g_scratch + 4, n); // whole-file CRC (from 0) + remaining -= (uint32_t)n; uint32_t blk_end = pos + (uint32_t)n; if (blk_end > offset) { // emit only bytes ≥ offset uint32_t from = (pos > offset) ? pos : offset; @@ -786,14 +946,18 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { // stream — never silently dropped (the app's stall watchdog // then surfaces a typed timeout instead of hanging). if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } int emit_rc = fs_emit_paced(it, PBLE_OP_FILE_GET_DATA, dp - 4, (size_t)(4 + elen)); if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } if (emit_rc != PBLE_TX_OK) { tx_dead = 1; @@ -803,48 +967,48 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { pos = blk_end; } if (!pble_fs_item_valid(it)) { + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } mp_stream_close((mp_obj_t)f); + f = MP_OBJ_NULL; if (!pble_fs_item_valid(it)) { nlr_pop(); - return PBLE_NO_RSP; + goto get_done; } - f = MP_OBJ_NULL; + stream_complete = (remaining == 0); nlr_pop(); } else { + stream_complete = 0; if (f != MP_OBJ_NULL) { - nlr_buf_t n2; - if (nlr_push(&n2) == 0) { - if (!pble_fs_item_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - mp_stream_close((mp_obj_t)f); - if (!pble_fs_item_valid(it)) { - nlr_pop(); - return PBLE_NO_RSP; - } - nlr_pop(); - } + fs_close_local((mp_obj_t)f); + f = MP_OBJ_NULL; } - // RSP{OK} already went out; a mid-stream fault cannot retract it. Emit END - // with the partial CRC so the app detects the mismatch (HIL-verified). + // RSP{OK} already went out; omit END so the client times out the + // incomplete transfer instead of accepting a partial CRC. } - if (!tx_dead && pble_fs_item_valid(it)) { + if (stream_complete && !tx_dead && pble_fs_item_valid(it)) { uint32_t fcrc = crc ^ 0xFFFFFFFFu; le32(g_scratch, fcrc); if (!pble_fs_item_valid(it)) { - return PBLE_NO_RSP; + goto get_done; } (void)fs_emit_paced(it, PBLE_OP_FILE_GET_END, g_scratch, 4); if (!pble_fs_item_valid(it)) { - return PBLE_NO_RSP; + goto get_done; } } // tx_dead: the link is gone or saturated beyond the retry budget — an END // cannot usefully be delivered; the app recovers via its data timeout. +get_done: + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (g_get_active && g_get_generation == it->transfer_generation) { + g_get_active = false; + g_get_generation = 0; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); return PBLE_NO_RSP; } @@ -852,38 +1016,74 @@ static uint8_t fs_do_get(const pble_fs_req_t *it) { // Worker op: windowed upload PUT_BEGIN/DATA/END (0x15/0x16/0x41/0x17) // ============================================================================ static void fs_put_ack(const pble_fs_req_t *it) { - if (!pble_fs_item_valid(it)) { + if (!fs_put_owned(it)) { return; } uint8_t b[4]; - le32(b, g_put_watermark); // ack_offset = next expected = highest contiguous + uint32_t watermark; + taskENTER_CRITICAL(&g_fs_transfer_mux); + bool owned = g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == g_fs_transfer_generation; + watermark = g_put_watermark; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + if (!owned) { + return; + } + le32(b, watermark); // next expected = highest contiguous // Paced: a dropped ACK stalls the (client-paced) upload for a full client // timeout; under backpressure wait for the pool rather than dropping. (void)fs_emit_paced(it, PBLE_OP_FILE_PUT_ACK, b, 4); } -static void fs_put_close(const pble_fs_req_t *it) { +static bool fs_put_latch_owner(const pble_fs_req_t *it, uint8_t status) { + if (!pble_fs_item_valid(it)) { + return false; + } + bool latched = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == g_fs_transfer_generation) { + g_put_latched = status; + latched = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return latched; +} + +static uint8_t fs_put_close(const pble_fs_req_t *it) { mp_obj_t f = MP_STATE_VM(pble_fs_put_file); - if (f != MP_OBJ_NULL) { - nlr_buf_t nlr; - if (nlr_push(&nlr) == 0) { - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return; - } - mp_stream_close(f); - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - return; - } + if (f == MP_OBJ_NULL) { + return PBLE_OK; + } + uint8_t st = PBLE_OK; + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + if (!pble_fs_ticket_valid(it)) { nlr_pop(); + return PBLE_NO_RSP; } + mp_stream_close(f); + bool cancelled = !pble_fs_ticket_valid(it); + nlr_pop(); MP_STATE_VM(pble_fs_put_file) = MP_OBJ_NULL; + if (cancelled) { + return PBLE_NO_RSP; + } + } else { + st = PBLE_EIO; + } + MP_STATE_VM(pble_fs_put_file) = MP_OBJ_NULL; + if (!pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; } + return st; } -static void fs_put_reset(void) { +static void fs_put_reset_locked(void) { g_put_active = false; + g_put_generation = 0; g_put_temp[0] = '\0'; g_put_dest[0] = '\0'; g_put_total = g_put_crc_target = g_put_watermark = 0; @@ -891,73 +1091,373 @@ static void fs_put_reset(void) { g_put_latched = 0; } -// F-10 link-drop hook — see pble_fs.h. HOST-TASK SAFE: plain C state only, so the -// NimBLE disconnect callback can call it directly. It intentionally does NOT close -// or delete the temp: `<dest>.pbltmp` and its on-flash length persist as the -// durable watermark a resuming FILE_PUT_BEGIN re-derives. Any file object left -// open by the interrupted transfer is flushed + closed later, on the worker, by -// that next FILE_PUT_BEGIN (fs_put_close there) — never from this host-task hook. -// Idempotent. +static void fs_put_reset(void) { + taskENTER_CRITICAL(&g_fs_transfer_mux); + fs_put_reset_locked(); + taskEXIT_CRITICAL(&g_fs_transfer_mux); +} + +static bool fs_put_owned(const pble_fs_req_t *it) { + if (!pble_fs_item_valid(it)) { + return false; + } + bool owned; + taskENTER_CRITICAL(&g_fs_transfer_mux); + owned = !g_fs_transfer_exhausted && g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == g_fs_transfer_generation; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return owned; +} + +static bool fs_put_active_current(const pble_fs_req_t *it) { + bool active; + taskENTER_CRITICAL(&g_fs_transfer_mux); + active = it != NULL && !g_fs_transfer_exhausted && g_put_active && + g_put_generation == g_fs_transfer_generation && + it->transfer_generation == g_fs_transfer_generation; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return active; +} + +static bool fs_put_reset_owner(const pble_fs_req_t *it) { + if (!pble_fs_ticket_valid(it)) { + return false; + } + bool reset = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (it != NULL && !g_fs_transfer_exhausted && g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == g_fs_transfer_generation) { + fs_put_reset_locked(); + reset = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return reset; +} + +static void fs_put_close_local(const pble_fs_req_t *it, mp_obj_t file) { + // A BEGIN invalidated after opening must still close the exact object it + // owns. Observe the ticket without making cleanup conditional on a ticket + // that is expected to be stale here. + (void)pble_fs_ticket_valid(it); + fs_close_local(file); + (void)pble_fs_ticket_valid(it); +} + +// A successor worker may close and discard only a stale active record. The +// host disconnect path advances the generation but never races these fields. +static uint8_t fs_put_reconcile_stale(const pble_fs_req_t *it) { + uint64_t stale_generation = 0; + bool stale = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + bool request_current = it != NULL && !g_fs_transfer_exhausted && + it->transfer_generation == + g_fs_transfer_generation; + if (request_current && g_put_active && + g_put_generation != g_fs_transfer_generation) { + stale_generation = g_put_generation; + stale = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + if (!request_current || !pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; + } + if (!stale) { + return PBLE_OK; + } + + uint8_t close_status = fs_put_close(it); + if (close_status == PBLE_NO_RSP) { + return PBLE_NO_RSP; + } + + bool ticket_current = pble_fs_ticket_valid(it); + bool reclaimed = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (ticket_current && !g_fs_transfer_exhausted && + it->transfer_generation == g_fs_transfer_generation && + g_put_active && g_put_generation == stale_generation) { + fs_put_reset_locked(); + reclaimed = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return reclaimed ? close_status : PBLE_NO_RSP; +} + +// F-10 link-drop hook. The host callback invalidates ownership only; it never +// writes worker-owned g_put_* or touches an MP/VFS object. A later worker closes +// the stale object and re-derives the durable prefix from `<dest>.pbltmp`. void pble_fs_on_disconnect(void) { - fs_put_reset(); + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (g_fs_transfer_generation == UINT64_MAX) { + g_fs_transfer_exhausted = true; + } else { + g_fs_transfer_generation++; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); } -// F-10 resume-offset — see pble_fs.h. WORKER-ONLY (mp_vfs_* under nlr). Always -// (re)initializes the worker-local running whole-file CRC + watermark. -static uint32_t pble_fs_resume_prefix(const pble_fs_req_t *it, - const char *dest_vfs_path, - uint32_t total_size) { - g_put_watermark = 0; - g_put_crc_running = 0xFFFFFFFFu; // fresh-upload default +// Remove exactly one malformed scratch object. This is deliberately +// non-recursive and verifies absence after a nominally successful backend call. +static uint8_t fs_remove_malformed_scratch(const pble_fs_req_t *it, + const char *temp) { + uint32_t size; + bool isdir; + uint8_t st = fs_stat_path(it, temp, &size, &isdir); + if (st == PBLE_ENOENT) { + return PBLE_OK; + } + if (st != PBLE_OK) { + return st == PBLE_NO_RSP ? st : PBLE_EIO; + } + + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + if (!pble_fs_ticket_valid(it)) { + nlr_pop(); + return PBLE_NO_RSP; + } + if (isdir) { + mp_vfs_rmdir(fs_str(temp)); + if (!pble_fs_ticket_valid(it)) { + nlr_pop(); + return PBLE_NO_RSP; + } + } else { + mp_vfs_remove(fs_str(temp)); + if (!pble_fs_ticket_valid(it)) { + nlr_pop(); + return PBLE_NO_RSP; + } + } + nlr_pop(); + } else { + return pble_fs_ticket_valid(it) ? PBLE_EIO : PBLE_NO_RSP; + } + + st = fs_stat_path(it, temp, &size, &isdir); + if (st == PBLE_ENOENT) { + return PBLE_OK; + } + return st == PBLE_NO_RSP ? st : PBLE_EIO; +} + +// Dynamic capacity admission for only the bytes remaining after an exact +// verified resume prefix. Geometry and every intermediate use checked u64. +static uint8_t fs_put_space_status(const pble_fs_req_t *it, + uint32_t remaining) { + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + if (!pble_fs_ticket_valid(it)) { + nlr_pop(); + return PBLE_NO_RSP; + } + mp_obj_t result = mp_vfs_statvfs(fs_str(PBLE_FS_ROOT)); + if (!pble_fs_ticket_valid(it)) { + nlr_pop(); + return PBLE_NO_RSP; + } + mp_obj_t *fields; + size_t count; + mp_obj_get_array(result, &count, &fields); + if (count <= 4 || !mp_obj_is_int(fields[1]) || + !mp_obj_is_int(fields[4])) { + nlr_pop(); + return PBLE_EIO; + } + mp_int_t unit_value; + mp_int_t available_value; + if (!mp_obj_get_int_maybe(fields[1], &unit_value) || + !mp_obj_get_int_maybe(fields[4], &available_value) || + unit_value <= 0 || available_value < 0) { + nlr_pop(); + return PBLE_EIO; + } + + uint64_t unit = (uint64_t)unit_value; + uint64_t available = (uint64_t)available_value; + if (available != 0 && unit > UINT64_MAX / available) { + nlr_pop(); + return PBLE_EIO; + } + uint64_t free_bytes = unit * available; + uint64_t rounded = 0; + if (remaining != 0) { + uint64_t addend = unit - 1u; + if ((uint64_t)remaining > UINT64_MAX - addend) { + nlr_pop(); + return PBLE_EIO; + } + uint64_t sum = (uint64_t)remaining + addend; + rounded = (sum / unit) * unit; + } + if (rounded > UINT64_MAX - PBLE_FS_SAFETY_RESERVE) { + nlr_pop(); + return PBLE_EIO; + } + uint64_t required = rounded + PBLE_FS_SAFETY_RESERVE; + nlr_pop(); + return free_bytes >= required ? PBLE_OK : PBLE_ENOSPC; + } + return PBLE_EIO; +} + +// F-10 resume-offset — see pble_fs.h. WORKER-ONLY (mp_vfs_* under nlr). +// Returns explicit status and initializes the running CRC/watermark only from +// an exact-length scan whose regular-file type and length survive a re-stat. +static uint8_t pble_fs_resume_prefix(const pble_fs_req_t *it, + const char *dest_vfs_path, + uint32_t total_size, + uint32_t *resume_out, + uint32_t *running_out) { + *resume_out = 0; + *running_out = 0xFFFFFFFFu; // fresh-upload default // Build "<dest>.pbltmp" (same jailed sibling the transfer writes). char temp[PBLE_FS_PATH_BUF]; size_t dl = strlen(dest_vfs_path); if (dl + (sizeof(PBLE_FS_TMP_SUFFIX) - 1) + 1 > sizeof(temp)) { - return 0; + return PBLE_ERANGE; } memcpy(temp, dest_vfs_path, dl); memcpy(temp + dl, PBLE_FS_TMP_SUFFIX, sizeof(PBLE_FS_TMP_SUFFIX)); // incl NUL uint32_t len; bool isdir; - if (fs_stat_path(it, temp, &len, &isdir) != PBLE_OK) { - return 0; // no temp / unreadable → fresh + uint8_t st = fs_stat_path(it, temp, &len, &isdir); + if (st == PBLE_ENOENT) { + return PBLE_OK; + } + if (st != PBLE_OK) { + return st == PBLE_NO_RSP ? st : PBLE_EIO; } - if (isdir || len == 0 || len > total_size) { - return 0; // nothing to resume / over-size guard → - // caller's "wb" open truncates to 0 + bool isreg = !isdir; // fs_stat_path admits only exact REG or exact DIR. + if (!isreg || len > total_size) { + return fs_remove_malformed_scratch(it, temp); + } + if (len == 0) { + return PBLE_OK; } uint32_t running; - if (fs_crc_prefix(it, temp, len, &running) != PBLE_OK) { - return 0; // unreadable prefix → fresh (wb truncates) + st = fs_crc_prefix(it, temp, len, &running); + if (st != PBLE_OK) { + if (st == PBLE_NO_RSP) { + return st; + } + return fs_remove_malformed_scratch(it, temp); } - g_put_crc_running = running; // re-seed the streaming whole-file CRC - g_put_watermark = len; // durable prefix re-derived from flash - return len; + + uint32_t checked_len; + bool checked_isdir = false; + st = fs_stat_path(it, temp, &checked_len, &checked_isdir); + bool checked_isreg = st == PBLE_OK && !checked_isdir; + if (st != PBLE_OK || !checked_isreg || checked_len != len) { + if (st == PBLE_NO_RSP) { + return st; + } + return fs_remove_malformed_scratch(it, temp); + } + *running_out = running; // continued by later PUT_DATA + *resume_out = len; + return PBLE_OK; } -// Abort a transfer: close the temp, delete it (KEEP the old target), reset state. -static void fs_put_abort(const pble_fs_req_t *it) { - fs_put_close(it); - if (g_put_temp[0] != '\0') { +// Abort one exactly-owned transfer. Cleanup failure overrides the originating +// protocol status with EIO. Cancellation stops further VFS effects and leaves a +// stale record for successor reconciliation instead of clearing newer state. +static uint8_t fs_put_abort(const pble_fs_req_t *it, uint8_t originating) { + if (!fs_put_owned(it)) { + return PBLE_NO_RSP; + } + + char temp[PBLE_FS_PATH_BUF]; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (!g_put_active || + g_put_generation != it->transfer_generation || + it->transfer_generation != g_fs_transfer_generation) { + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return PBLE_NO_RSP; + } + memcpy(temp, g_put_temp, sizeof(temp)); + temp[sizeof(temp) - 1u] = '\0'; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + + bool cleanup_failed = false; + uint8_t close_status = fs_put_close(it); + if (close_status == PBLE_NO_RSP) { + return PBLE_NO_RSP; + } + if (close_status != PBLE_OK) { + cleanup_failed = true; + } + + if (temp[0] == '\0') { + cleanup_failed = true; + } else { + if (!pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; + } nlr_buf_t nlr; if (nlr_push(&nlr) == 0) { - if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - fs_put_reset(); - return; + mp_vfs_remove(fs_str(temp)); + bool cancelled = !pble_fs_ticket_valid(it); + nlr_pop(); + if (cancelled) { + return PBLE_NO_RSP; } - mp_vfs_remove(fs_str(g_put_temp)); + } else { if (!pble_fs_ticket_valid(it)) { - nlr_pop(); - fs_put_reset(); - return; + return PBLE_NO_RSP; } - nlr_pop(); + cleanup_failed = true; + } + + uint32_t remaining_size; + bool remaining_isdir; + uint8_t absence = fs_stat_path( + it, temp, &remaining_size, &remaining_isdir); + if (absence == PBLE_NO_RSP) { + return PBLE_NO_RSP; + } + if (absence != PBLE_ENOENT) { + cleanup_failed = true; } } - fs_put_reset(); + + if (!fs_put_reset_owner(it)) { + return PBLE_NO_RSP; + } + return cleanup_failed ? PBLE_EIO : originating; +} + +static bool fs_put_publish(const pble_fs_req_t *it, mp_obj_t file, + const char *temp, const char *dest, + uint32_t total, uint32_t crc_target, + uint32_t watermark, uint32_t crc_running) { + if (!pble_fs_ticket_valid(it)) { + return false; + } + bool published = false; + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (!g_fs_transfer_exhausted && !g_put_active && + it->transfer_generation == g_fs_transfer_generation) { + g_put_generation = it->transfer_generation; + g_put_active = true; + memset(g_put_temp, 0, sizeof(g_put_temp)); + memset(g_put_dest, 0, sizeof(g_put_dest)); + memcpy(g_put_temp, temp, strlen(temp) + 1u); + memcpy(g_put_dest, dest, strlen(dest) + 1u); + g_put_total = total; + g_put_crc_target = crc_target; + g_put_watermark = watermark; + g_put_crc_running = crc_running; + g_put_latched = 0; + MP_STATE_VM(pble_fs_put_file) = file; + published = true; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + return published; } static uint8_t fs_do_put_begin(const pble_fs_req_t *it, size_t *extra) { @@ -980,63 +1480,80 @@ static uint8_t fs_do_put_begin(const pble_fs_req_t *it, size_t *extra) { if (fs_is_forbidden_artifact(dest)) { return PBLE_EACCES; // .mpy/.pyc never accepted (FR-FS-12) } - if (g_put_active) { - return PBLE_EBUSY; // single active transfer (§5) + + // A reconnecting session may close/reclaim only an older generation. Do + // that before deciding whether the single-transfer record is genuinely busy. + uint8_t reconcile_status = fs_put_reconcile_stale(it); + if (reconcile_status != PBLE_OK) { + return reconcile_status; } - // Flush + close any file object orphaned by a prior link-dropped transfer - // (pble_fs_on_disconnect resets the flags on the host task but cannot touch - // MP objects). This is worker-side + VM-safe and syncs the last buffered block - // to flash so resume_prefix below sees the full verified prefix length (F-10). - fs_put_close(it); - if (!pble_fs_ticket_valid(it)) { - return PBLE_NO_RSP; + if (fs_put_active_current(it)) { + return PBLE_EBUSY; // single active transfer (§5) } // temp = "<dest>.pbltmp" (same jailed directory; never routed through resolve, // which forbids the reserved suffix by design). + char temp[PBLE_FS_PATH_BUF] = {0}; size_t dl = strlen(dest); - if (dl + (sizeof(PBLE_FS_TMP_SUFFIX) - 1) + 1 > sizeof(g_put_temp)) { + if (dl + (sizeof(PBLE_FS_TMP_SUFFIX) - 1) + 1 > sizeof(temp)) { return PBLE_ERANGE; } - memcpy(g_put_dest, dest, dl + 1); - memcpy(g_put_temp, dest, dl); - memcpy(g_put_temp + dl, PBLE_FS_TMP_SUFFIX, sizeof(PBLE_FS_TMP_SUFFIX)); // incl NUL + memcpy(temp, dest, dl); + memcpy(temp + dl, PBLE_FS_TMP_SUFFIX, sizeof(PBLE_FS_TMP_SUFFIX)); - // Resume-on-reconnect (F-10): re-derive any verified prefix from flash. This - // sets g_put_watermark + re-seeds g_put_crc_running (or leaves them fresh). + // Resume-on-reconnect (F-10): keep all candidate state local until the + // generation-qualified publication cut below. // resume>0 → append to the existing prefix; resume==0 → "wb" truncates any // stale/over-size temp to 0 (the frozen over-size guard). - uint32_t resume = pble_fs_resume_prefix(it, g_put_dest, total); + uint32_t resume; + uint32_t running; + uint8_t resume_status = pble_fs_resume_prefix( + it, dest, total, &resume, &running); + if (resume_status != PBLE_OK) { + return resume_status; + } if (!pble_fs_ticket_valid(it)) { return PBLE_NO_RSP; } + uint8_t space_status = fs_put_space_status(it, total - resume); + if (space_status != PBLE_OK) { + return space_status; + } const char *mode = (resume > 0) ? "ab" : "wb"; // Open the temp under nlr. nlr_buf_t nlr; uint8_t st; + volatile mp_obj_t opened = MP_OBJ_NULL; if (nlr_push(&nlr) == 0) { - mp_obj_t f = fs_open(it, pble_fs_ticket_valid, g_put_temp, mode); + opened = fs_open(it, pble_fs_ticket_valid, temp, mode); if (!pble_fs_ticket_valid(it)) { + fs_close_local((mp_obj_t)opened); + opened = MP_OBJ_NULL; nlr_pop(); return PBLE_NO_RSP; } - MP_STATE_VM(pble_fs_put_file) = f; nlr_pop(); st = PBLE_OK; } else { + fs_close_local((mp_obj_t)opened); + opened = MP_OBJ_NULL; st = fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val)); } if (st != PBLE_OK) { - fs_put_reset(); // clears watermark/CRC; temp persists on flash + if (!pble_fs_ticket_valid(it)) { + return PBLE_NO_RSP; + } return st; } - g_put_active = true; - g_put_total = total; - g_put_crc_target = crc; - g_put_latched = 0; - // g_put_watermark + g_put_crc_running were set by pble_fs_resume_prefix. + if (!fs_put_publish(it, (mp_obj_t)opened, temp, dest, total, crc, + resume, running)) { + fs_put_close_local(it, (mp_obj_t)opened); + opened = MP_OBJ_NULL; + return PBLE_NO_RSP; + } + opened = MP_OBJ_NULL; // the rooted active record now owns it le32(g_scratch + 1, resume); // resume_offset = verified prefix length (§5) *extra = 4; @@ -1046,7 +1563,7 @@ static uint8_t fs_do_put_begin(const pble_fs_req_t *it, size_t *extra) { // PUT_DATA: [offset:u32][bytes]. Go-Back-N, no reorder buffer. Always re-ACKs the // current watermark; emits no RSP. static void fs_do_put_data(const pble_fs_req_t *it) { - if (!g_put_active) { + if (!fs_put_owned(it)) { return; // no transfer → nothing to ack } if (it->len < 4) { @@ -1062,6 +1579,22 @@ static void fs_do_put_data(const pble_fs_req_t *it) { return; } if (offset == g_put_watermark && n > 0) { + if (g_put_watermark > g_put_total || + n > (g_put_total - g_put_watermark)) { + taskENTER_CRITICAL(&g_fs_transfer_mux); + bool owned = g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == g_fs_transfer_generation; + if (owned) { + g_put_latched = PBLE_ERANGE; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + if (!owned) { + return; + } + fs_put_ack(it); + return; + } nlr_buf_t nlr; if (nlr_push(&nlr) == 0) { mp_obj_t f = MP_STATE_VM(pble_fs_put_file); @@ -1079,7 +1612,15 @@ static void fs_do_put_data(const pble_fs_req_t *it) { return; } if (w == MP_STREAM_ERROR) { - g_put_latched = pble_fs_errno_to_status(err); + (void)fs_put_latch_owner( + it, pble_fs_errno_to_status(err)); + break; + } + if (w == 0 || w > n - done) { + // A nominally successful stream write must make bounded + // forward progress. Otherwise this worker could spin + // forever or advance beyond the admitted input chunk. + (void)fs_put_latch_owner(it, PBLE_EIO); break; } done += (uint32_t)w; @@ -1088,12 +1629,27 @@ static void fs_do_put_data(const pble_fs_req_t *it) { // Advance + extend the streaming whole-file CRC together, only on a // full contiguous write, so the running CRC stays in lockstep with // the watermark (Go-Back-N never double-counts a dup/gap). - g_put_crc_running = crc32_update(g_put_crc_running, bytes, n); - g_put_watermark += n; + uint32_t next_crc = crc32_update( + g_put_crc_running, bytes, n); + taskENTER_CRITICAL(&g_fs_transfer_mux); + bool commit = g_put_active && + g_put_generation == it->transfer_generation && + it->transfer_generation == + g_fs_transfer_generation; + if (commit) { + g_put_crc_running = next_crc; + g_put_watermark += n; + } + taskEXIT_CRITICAL(&g_fs_transfer_mux); + if (!commit) { + nlr_pop(); + return; + } } nlr_pop(); } else { - g_put_latched = fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val)); + (void)fs_put_latch_owner( + it, fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val))); } } // offset < watermark (duplicate) and offset > watermark (gap) both re-ACK the @@ -1105,28 +1661,36 @@ static void fs_do_put_data(const pble_fs_req_t *it) { // then fsync(close)+rename. On ANY failure delete temp + KEEP old file (FR-FS-14). static uint8_t fs_do_put_end(const pble_fs_req_t *it, size_t *extra) { *extra = 0; - if (!g_put_active) { - return PBLE_EBADREQ; // END without an active BEGIN + if (!fs_put_owned(it)) { + bool record_active; + taskENTER_CRITICAL(&g_fs_transfer_mux); + record_active = g_put_active; + taskEXIT_CRITICAL(&g_fs_transfer_mux); + // Preserve the frozen END-without-BEGIN response. An old-generation END + // remains silent and cannot act on any active successor record. + return !record_active && pble_fs_ticket_valid(it) + ? PBLE_EBADREQ + : PBLE_NO_RSP; } if (it->len < 4) { - fs_put_abort(it); - return PBLE_EBADREQ; + return fs_put_abort(it, PBLE_EBADREQ); } uint32_t declared = rd32(it->payload); if (g_put_latched) { uint8_t st = g_put_latched; // ENOSPC / EIO latched during DATA - fs_put_abort(it); - return st; + return fs_put_abort(it, st); } if (g_put_watermark != g_put_total) { - fs_put_abort(it); - return PBLE_ERANGE; // short/over transfer + return fs_put_abort(it, PBLE_ERANGE); // short/over transfer } - fs_put_close(it); // flush the temp to storage - if (!pble_fs_ticket_valid(it)) { - return PBLE_NO_RSP; + uint8_t close_status = fs_put_close(it); // durability cut before rename + if (close_status != PBLE_OK) { + if (close_status == PBLE_NO_RSP) { + return PBLE_NO_RSP; + } + return fs_put_abort(it, PBLE_EIO); } // Finalize the streaming whole-file CRC (maintained across PUT_DATA + re-seeded @@ -1136,8 +1700,7 @@ static uint8_t fs_do_put_end(const pble_fs_req_t *it, size_t *extra) { uint32_t crc = g_put_crc_running ^ 0xFFFFFFFFu; uint8_t st; if (crc != g_put_crc_target || crc != declared) { - fs_put_abort(it); - return PBLE_ECRC; // mismatch → target untouched (FR-FS-14) + return fs_put_abort(it, PBLE_ECRC); // target remains untouched } // Atomic replace on LittleFS. @@ -1158,10 +1721,11 @@ static uint8_t fs_do_put_end(const pble_fs_req_t *it, size_t *extra) { st = fs_exc_to_status(MP_OBJ_FROM_PTR(nlr.ret_val)); } if (st != PBLE_OK) { - fs_put_abort(it); - return st; + return fs_put_abort(it, st); + } + if (!fs_put_reset_owner(it)) { + return PBLE_NO_RSP; } - fs_put_reset(); // temp is gone (renamed) — clear, do not delete return PBLE_OK; } @@ -1182,6 +1746,9 @@ static uint8_t fs_do_delete(const pble_fs_req_t *it, size_t *extra) { if (rc != PBLE_OK) { return rc; } + if (fs_put_active_current(it) || it->get_active_at_enqueue) { + return PBLE_EBUSY; + } uint32_t sz; bool isdir; uint8_t st = fs_stat_path(it, path, &sz, &isdir); @@ -1234,6 +1801,9 @@ static uint8_t fs_do_mkdir(const pble_fs_req_t *it, size_t *extra) { if (rc != PBLE_OK) { return rc; } + if (fs_put_active_current(it) || it->get_active_at_enqueue) { + return PBLE_EBUSY; + } nlr_buf_t nlr; uint8_t st; if (nlr_push(&nlr) == 0) { @@ -1294,6 +1864,9 @@ static uint8_t fs_do_rename(const pble_fs_req_t *it, size_t *extra) { if (rc != PBLE_OK) { return rc; } + if (fs_put_active_current(it) || it->get_active_at_enqueue) { + return PBLE_EBUSY; + } uint32_t sz; bool isdir; uint8_t st = fs_stat_path(it, spath, &sz, &isdir); @@ -1426,7 +1999,16 @@ void pble_fs_vm_reset(void) { while (xSemaphoreTake(g_fs_work, 0) == pdTRUE) { } } - fs_put_reset(); + taskENTER_CRITICAL(&g_fs_transfer_mux); + if (g_fs_transfer_generation == UINT64_MAX) { + g_fs_transfer_exhausted = true; + } else { + g_fs_transfer_generation++; + } + g_get_active = false; + g_get_generation = 0; + fs_put_reset_locked(); + taskEXIT_CRITICAL(&g_fs_transfer_mux); MP_STATE_VM(pble_fs_put_file) = MP_OBJ_NULL; } @@ -1472,6 +2054,13 @@ static uint8_t fs_enqueue(const pble_frame_t *req, g_enq.id = req->id; g_enq.session = *session; g_enq.vm_epoch = pble_vm_epoch_current(); + taskENTER_CRITICAL(&g_fs_transfer_mux); + g_enq.transfer_generation = g_fs_transfer_generation; + bool generation_available = !g_fs_transfer_exhausted; + g_enq.get_active_at_enqueue = generation_available && + g_get_active && + g_get_generation == g_fs_transfer_generation; + taskEXIT_CRITICAL(&g_fs_transfer_mux); if (ticket != NULL) { g_enq.ticket = *ticket; } else { @@ -1481,7 +2070,8 @@ static uint8_t fs_enqueue(const pble_frame_t *req, if (req->len > 0 && req->payload != NULL) { memcpy(g_enq.payload, req->payload, req->len); } - if (xQueueSend(g_fs_q, &g_enq, 0) == pdTRUE) { + if (generation_available && + xQueueSend(g_fs_q, &g_enq, 0) == pdTRUE) { g_fs_outstanding++; queued = true; } @@ -1547,6 +2137,10 @@ uint8_t pble_fs_rename(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, } void pble_fs_register(void) { + uint64_t epoch = pble_vm_epoch_current(); + if (g_fs_registered && g_fs_registration_epoch == epoch) { + return; + } if (g_fs_q == NULL) { g_fs_q = xQueueCreate(PBLE_FS_QDEPTH, sizeof(pble_fs_req_t)); } @@ -1574,6 +2168,8 @@ void pble_fs_register(void) { pble_proto_register_deferred(PBLE_OP_FILE_DELETE, pble_fs_delete); pble_proto_register_deferred(PBLE_OP_MKDIR, pble_fs_mkdir); pble_proto_register_deferred(PBLE_OP_FILE_RENAME, pble_fs_rename); + g_fs_registration_epoch = epoch; + g_fs_registered = true; } // ============================================================================ diff --git a/firmware/user_c_modules/pyble/pble_lock.c b/firmware/user_c_modules/pyble/pble_lock.c index c4df7c4f..7def21ae 100644 --- a/firmware/user_c_modules/pyble/pble_lock.c +++ b/firmware/user_c_modules/pyble/pble_lock.c @@ -18,6 +18,7 @@ #include "freertos/task.h" #include "pble_lock.h" +#include "pble_vm_lifecycle.h" // pble_fs_on_disconnect is owned by storage-engineer (pble_fs.c, F-10 resume). // Weak reference: until that strong definition ships, the symbol resolves to NULL @@ -34,6 +35,8 @@ typedef struct { static lock_slot_t g_run_slot; // W_RUN static lock_slot_t g_xfer_slot; // W_XFER static portMUX_TYPE g_lock_mux = portMUX_INITIALIZER_UNLOCKED; +static bool g_lock_registered; +static uint64_t g_lock_registration_epoch; // Select the slot for a writer class, or NULL for an invalid class. static lock_slot_t *lock_slot_for(pble_writer_t who) { @@ -90,10 +93,17 @@ void pble_lock_on_disconnect(uint16_t conn) { } void pble_lock_register(void) { + uint64_t epoch = pble_vm_epoch_current(); taskENTER_CRITICAL(&g_lock_mux); + if (g_lock_registered && g_lock_registration_epoch == epoch) { + taskEXIT_CRITICAL(&g_lock_mux); + return; + } g_run_slot.held = false; g_run_slot.conn = 0; g_xfer_slot.held = false; g_xfer_slot.conn = 0; + g_lock_registration_epoch = epoch; + g_lock_registered = true; taskEXIT_CRITICAL(&g_lock_mux); } diff --git a/firmware/user_c_modules/pyble/pble_proto.c b/firmware/user_c_modules/pyble/pble_proto.c index 195ecd0b..cd6a99ea 100644 --- a/firmware/user_c_modules/pyble/pble_proto.c +++ b/firmware/user_c_modules/pyble/pble_proto.c @@ -117,6 +117,9 @@ typedef struct { uint64_t ready_order; bool retain_completion; bool completion_ok; + // Set only for a syntactically valid, v1-compatible HELLO RSP{OK}. + // Negotiation is committed when its final fragment is locally accepted. + bool commit_hello; } pble_rsp_slot_t; static pble_rsp_slot_t s_rsp_pool[PBLE_RSP_POOL_DEPTH]; @@ -128,6 +131,52 @@ static int8_t s_rsp_active_slot = -1; static uint64_t s_rsp_ready_order; static bool s_rsp_tx_owned; +// PBLE negotiation belongs to one exact connection-generation/VM-epoch +// identity. The response callout and host RX task share this state through +// the existing response-pool critical section. +static pble_wire_session_t s_wire_session; +static pble_session_token_t s_wire_session_token; +static bool s_wire_session_bound; + +static bool pble_proto_session_matches( + const pble_session_token_t *left, const pble_session_token_t *right) { + return left != NULL && right != NULL && left->conn == right->conn && + left->generation == right->generation && + left->vm_epoch == right->vm_epoch; +} + +static void pble_proto_wire_session_snapshot( + const pble_session_token_t *token, pble_wire_session_t *wire_session) { + taskENTER_CRITICAL(&s_rsp_mux); + if (!s_wire_session_bound || + !pble_proto_session_matches(&s_wire_session_token, token)) { + pble_wire_session_reset(&s_wire_session); + s_wire_session_token = *token; + s_wire_session_bound = true; + } + *wire_session = s_wire_session; + taskEXIT_CRITICAL(&s_rsp_mux); +} + +static void pble_proto_wire_session_commit_locked( + const pble_session_token_t *token) { + if (s_wire_session_bound && + pble_proto_session_matches(&s_wire_session_token, token)) { + pble_wire_session_commit_v1(&s_wire_session); + } +} + +static void pble_proto_wire_session_reset_locked( + const pble_session_token_t *token) { + if (token == NULL || + (s_wire_session_bound && + pble_proto_session_matches(&s_wire_session_token, token))) { + pble_wire_session_reset(&s_wire_session); + memset(&s_wire_session_token, 0, sizeof(s_wire_session_token)); + s_wire_session_bound = false; + } +} + static bool pble_rsp_has_pending_locked(void) { for (uint8_t i = 0; i < PBLE_RSP_POOL_DEPTH; i++) { if (s_rsp_pool[i].state == PBLE_RSP_READY) { @@ -163,6 +212,7 @@ static void pble_rsp_recycle_locked(pble_rsp_slot_t *slot) { slot->index = 0; slot->retain_completion = false; slot->completion_ok = false; + slot->commit_hello = false; slot->incarnation++; if (slot->incarnation == 0) { slot->incarnation = 1; @@ -211,6 +261,7 @@ bool pble_rsp_reserve(const pble_session_token_t *session, slot->ticket.session = *session; slot->ticket.vm_epoch = vm_epoch; slot->retain_completion = false; + slot->commit_hello = false; slot->state = PBLE_RSP_CLAIMED; pble_rsp_ticket_t claim_ticket = slot->ticket; taskEXIT_CRITICAL(&s_rsp_mux); @@ -275,6 +326,19 @@ bool pble_rsp_expect_completion(const pble_rsp_ticket_t *ticket) { return valid; } +static void pble_rsp_mark_hello_commit(const pble_rsp_ticket_t *ticket) { + if (ticket == NULL || ticket->slot >= PBLE_RSP_POOL_DEPTH) { + return; + } + taskENTER_CRITICAL(&s_rsp_mux); + pble_rsp_slot_t *slot = &s_rsp_pool[ticket->slot]; + if (pble_rsp_ticket_matches_locked(slot, ticket) && + slot->state == PBLE_RSP_RESERVED) { + slot->commit_hello = true; + } + taskEXIT_CRITICAL(&s_rsp_mux); +} + bool pble_rsp_publish(const pble_rsp_ticket_t *ticket, uint8_t opcode, uint8_t id_, const uint8_t *payload, size_t len) { if (ticket == NULL || ticket->slot >= PBLE_RSP_POOL_DEPTH || @@ -392,6 +456,7 @@ void pble_rsp_cancel_session(const pble_session_token_t *session) { } uint32_t wake_mask = 0; taskENTER_CRITICAL(&s_rsp_mux); + pble_proto_wire_session_reset_locked(session); for (uint8_t i = 0; i < PBLE_RSP_POOL_DEPTH; i++) { pble_rsp_slot_t *slot = &s_rsp_pool[i]; if (slot->state != PBLE_RSP_FREE && @@ -499,6 +564,10 @@ void pble_rsp_tx_result(const pble_rsp_tx_t *tx, uint32_t stream_generation, slot->offset = (uint16_t)(offset + accepted); slot->index = (uint8_t)(index + 1); if (slot->offset >= slot->frame_len) { + if (slot->commit_hello) { + pble_proto_wire_session_commit_locked( + &slot->ticket.session); + } s_rsp_active_slot = -1; if (slot->retain_completion) { slot->completion_ok = true; @@ -551,6 +620,9 @@ bool pble_rsp_release_owner_if_idle(void) { } void pble_proto_vm_reset(void) { + taskENTER_CRITICAL(&s_rsp_mux); + pble_proto_wire_session_reset_locked(NULL); + taskEXIT_CRITICAL(&s_rsp_mux); if (!s_rsp_initialized) { return; } @@ -618,54 +690,83 @@ void pble_proto_refuse(uint8_t opcode, uint8_t id_, uint8_t status, // entire operation, including fire-and-forget and special RUN handlers. static void pble_proto_dispatch_admitted( const uint8_t *msg, size_t len, const pble_session_token_t *session) { + pble_wire_session_t wire_session; + pble_proto_wire_session_snapshot(session, &wire_session); + pble_wire_decision_t decision; + bool reboot_closing = !pble_vm_reboot_command_admitted(false); + pble_wire_decide_with_closing(&wire_session, msg, len, reboot_closing, + &decision); + + // The transport owns one reducer for fragment and complete-frame faults. + // It closes admission on violation eight, so no response/event from that + // terminal violation may be queued afterward. + if (decision.violation && !reboot_closing && + !pble_ble_record_protocol_violation(session)) { + return; + } + if (decision.action == PBLE_WIRE_DROP) { + return; + } + if (decision.action == PBLE_WIRE_EVT) { + (void)pble_proto_emit(decision.opcode, &decision.status, 1u, session); + return; + } + pble_frame_t f = {0}; - int rc = pble_proto_decode(msg, len, &f); + int rc = -1; bool invoke_handler = false; - uint8_t opcode = (len > 2) ? msg[2] : 0; - uint8_t id_ = (len > 3) ? msg[3] : 0; - uint8_t status = PBLE_EBADREQ; + uint8_t opcode = decision.opcode; + uint8_t id_ = decision.id; + uint8_t status = decision.status; size_t extra = 0; pble_handler_t h = NULL; pble_deferred_handler_t deferred = NULL; pble_handler_kind_t kind = PBLE_HANDLER_GENERIC; - if (rc >= 0) { + // A compatible HELLO uses its registered handler to append the capability + // text. Every other PBLE_WIRE_RSP is already a status-only guard result. + if (decision.action == PBLE_WIRE_DISPATCH || decision.commit_hello) { + rc = pble_proto_decode(msg, len, &f); + if (rc < 0) { + return; // pble_wire admitted only a complete v1 frame. + } opcode = f.opcode; id_ = f.id; uint32_t got = (uint32_t)msg[len - 4] | ((uint32_t)msg[len - 3] << 8) | ((uint32_t)msg[len - 2] << 16) | ((uint32_t)msg[len - 1] << 24); + // Retain this local invariant as defense in depth. pble_wire already + // applied CRC before direction, version, session, and opcode gates. if (pble_proto_crc32(msg, len - PBLE_CRC_LEN) != got) { - status = PBLE_ECRC; - } else { - kind = (pble_handler_kind_t)s_handler_kinds[f.opcode]; - h = s_handlers[f.opcode]; - deferred = s_deferred_handlers[f.opcode]; - - if (!pble_vm_reboot_command_admitted( - f.opcode == PBLE_OP_SOFT_REBOOT)) { - // During an accepted SOFT_REBOOT grace, response-bearing - // commands receive a bounded EBUSY refusal while fire-and- - // forget commands remain effect-free and response-free. - if (kind == PBLE_HANDLER_NO_RESPONSE) { - return; - } - status = PBLE_EBUSY; - } else if (kind == PBLE_HANDLER_NO_RESPONSE && h != NULL) { - (void)h(&f, s_rsp + 1, &extra, session); + return; + } + + kind = (pble_handler_kind_t)s_handler_kinds[f.opcode]; + h = s_handlers[f.opcode]; + deferred = s_deferred_handlers[f.opcode]; + + if (!pble_vm_reboot_command_admitted( + f.opcode == PBLE_OP_SOFT_REBOOT)) { + // During an accepted SOFT_REBOOT grace, response-bearing commands + // receive EBUSY while fire-and-forget commands remain effect-free. + if (kind == PBLE_HANDLER_NO_RESPONSE) { + return; + } + status = PBLE_EBUSY; + } else if (kind == PBLE_HANDLER_NO_RESPONSE && h != NULL) { + (void)h(&f, s_rsp + 1, &extra, session); + return; + } else if (kind == PBLE_HANDLER_SPECIAL && h != NULL) { + pble_handler_t special_handler = h; + status = special_handler(&f, s_rsp + 1, &extra, session); + if (status == PBLE_NO_RSP) { return; - } else if (kind == PBLE_HANDLER_SPECIAL && h != NULL) { - pble_handler_t special_handler = h; - status = special_handler(&f, s_rsp + 1, &extra, session); - if (status == PBLE_NO_RSP) { - return; - } - } else if (h == NULL && deferred == NULL) { - status = PBLE_EUNSUPPORTED; - } else { - invoke_handler = true; } + } else if (h == NULL && deferred == NULL) { + status = PBLE_EUNSUPPORTED; + } else { + invoke_handler = true; } } @@ -692,10 +793,13 @@ static void pble_proto_dispatch_admitted( extra = PBLE_RSP_MAX; } s_rsp[0] = status; + if (decision.commit_hello && status == PBLE_OK) { + pble_rsp_mark_hello_commit(&ticket); + } (void)pble_rsp_publish(&ticket, opcode, id_, s_rsp, 1 + extra); } -// Decode → §9/CRC gates → category-aware route → reserved generic RSP. +// Exact-session wire admission → category-aware route → reserved generic RSP. void pble_proto_dispatch(const uint8_t *msg, size_t len, uint16_t conn) { pble_session_token_t session; if (!pble_ble_session_snapshot(conn, &session)) { diff --git a/firmware/user_c_modules/pyble/pble_proto.h b/firmware/user_c_modules/pyble/pble_proto.h index 411bbd30..08d7bc78 100644 --- a/firmware/user_c_modules/pyble/pble_proto.h +++ b/firmware/user_c_modules/pyble/pble_proto.h @@ -13,6 +13,7 @@ #include <stdint.h> #include "pble_termination.h" +#include "pble_wire.h" #ifdef __cplusplus extern "C" { @@ -20,7 +21,7 @@ extern "C" { // --- Frozen constants -------------------------------------------------------- #define PBLE_PROTO_VERSION 0x01 // §9: the single supported wire version (F-16) -#define PBLE_LABEL_MAX 24 // §7/OI-6: max label bytes (UTF-8), else ERANGE +#define PBLE_LABEL_MAX PBLE_WIRE_LABEL_MAX // Frame TYPE (§3.1) #define PBLE_TYPE_CMD 0x01 @@ -145,7 +146,8 @@ void pble_proto_register_deferred(uint8_t opcode, pble_deferred_handler_t h); void pble_proto_register_no_response(uint8_t opcode, pble_handler_t h); void pble_proto_register_special(uint8_t opcode, pble_handler_t h); void pble_proto_init(void); -// Decode → §9 VER guard → CRC gate → route → RSP (via pble_ble_notify). +// Shared wire guard (structure → CRC → direction/ID → VER → session/opcode) +// → route → bounded response/event delivery. void pble_proto_dispatch(const uint8_t *msg, size_t len, uint16_t conn); // Bounded status-only refusal for an RX run that exceeded reassembly capacity. void pble_proto_refuse(uint8_t opcode, uint8_t id_, uint8_t status, diff --git a/firmware/user_c_modules/pyble/pble_runner.c b/firmware/user_c_modules/pyble/pble_runner.c index 2d45cad2..5ba71365 100644 --- a/firmware/user_c_modules/pyble/pble_runner.c +++ b/firmware/user_c_modules/pyble/pble_runner.c @@ -191,6 +191,11 @@ static void inject_worker_kbd_interrupt(void) { // acceptance publishes stop intent; either outcome wakes a pickup waiter after // the predicate is final and the runner lock has been released. static void runner_control_attempt_resolve(bool accepted) { + if (accepted != false) { + // The response handoff is already committed. Close this run's stdin + // before releasing a pickup/terminal waiter into the accepted control. + pble_console_stdin_end(); + } taskENTER_CRITICAL(&g_mux); configASSERT(g_control_unresolved); if (accepted) { @@ -235,7 +240,17 @@ bool pble_runner_stop_requested(void) { static bool runner_exec(uint8_t mode, const char *data, size_t len) { nlr_buf_t nlr; bool ok; + mp_obj_dict_t *saved_globals = mp_globals_get(); + mp_obj_dict_t *saved_locals = mp_locals_get(); if (nlr_push(&nlr) == 0) { + mp_obj_t fresh_obj = mp_obj_new_dict(1); + mp_obj_dict_store(fresh_obj, + MP_OBJ_NEW_QSTR(MP_QSTR___name__), + MP_OBJ_NEW_QSTR(MP_QSTR___main__)); + mp_obj_dict_t *fresh_globals = MP_OBJ_TO_PTR(fresh_obj); + mp_globals_set(fresh_globals); + mp_locals_set(fresh_globals); + qstr src_name; mp_lexer_t *lex; if (mode == PBLE_RUN_MODE_FILE) { @@ -251,8 +266,14 @@ static bool runner_exec(uint8_t mode, const char *data, size_t len) { // Deliver a STOP that raced code completion (pending KBI) so it lands. mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS); nlr_pop(); + mp_globals_set(saved_globals); + mp_locals_set(saved_locals); ok = true; } else { + // The per-run dictionary is installed before any compile/execute work. + // Restore the worker context before traceback output or control waits. + mp_globals_set(saved_globals); + mp_locals_set(saved_locals); // STOP's injected KeyboardInterrupt is expected lifecycle control, not // a user failure. Suppress its traceback so it cannot compete with the // ordered STOP response and terminal idle event. Real exceptions keep @@ -280,6 +301,10 @@ static int runner_terminal_transition(bool ok) { unresolved = g_control_unresolved; if (!unresolved) { bool stopped = g_stop_requested; + // Lock order is runner g_mux -> console ring mux. Clear this + // predecessor's stdin before the RSM becomes reservable, so a + // successor RUN cannot begin and then be erased by late cleanup. + pble_console_stdin_end(); term = stopped ? pble_rsm_on_stopped(&g_rsm) : pble_rsm_on_finished(&g_rsm, ok); if (g_worker_state != NULL) { @@ -353,9 +378,7 @@ void pble_runner_worker(void) { // Terminal transition: STOP -> idle; otherwise done/error. try/finally is // implicit — runner_exec always returns, so we always emit a terminal. int term = runner_terminal_transition(ok); - if (!stopped_before_start) { - runner_emit_state(term); // RUN_STATE(idle | done | error) - } + runner_emit_state(term); // RUN_STATE(idle | done | error) } } @@ -434,6 +457,7 @@ uint8_t pble_runner_run(const pble_frame_t *req, uint8_t *rsp, size_t *rlen, return PBLE_NO_RSP; } + pble_console_stdin_begin(); BaseType_t gave = xSemaphoreGive(g_run_sem); configASSERT(gave == pdTRUE); // single reservation => binary sem was empty (void)gave; @@ -600,6 +624,7 @@ uint8_t pble_runner_run_file(const char *path) { g_run_mode = PBLE_RUN_MODE_FILE; g_run_len = plen; memcpy(g_run_buf, path, plen); + pble_console_stdin_begin(); xSemaphoreGive(g_run_sem); // RUN_STATE(running) follows from the worker return PBLE_OK; } diff --git a/firmware/user_c_modules/pyble/pble_termination.c b/firmware/user_c_modules/pyble/pble_termination.c index 05c43b5e..45168453 100644 --- a/firmware/user_c_modules/pyble/pble_termination.c +++ b/firmware/user_c_modules/pyble/pble_termination.c @@ -96,6 +96,15 @@ pble_term_effects_t pble_term_begin(pble_term_state_t *state, uint16_t conn, return PBLE_TERM_EFFECT_ARM_WATCHDOG; } +pble_term_effects_t pble_term_preclose_failed(pble_term_state_t *state, + uint16_t conn, + uint64_t generation) { + if (!pble_term_admits(state, conn, generation)) { + return PBLE_TERM_EFFECT_NONE; + } + return pble_term_restart(state); +} + bool pble_term_watchdog_ticket(const pble_term_state_t *state, uint16_t conn, uint64_t generation, pble_term_watchdog_ticket_t *ticket) { diff --git a/firmware/user_c_modules/pyble/pble_termination.h b/firmware/user_c_modules/pyble/pble_termination.h index 0c1d2f8a..31f5fb45 100644 --- a/firmware/user_c_modules/pyble/pble_termination.h +++ b/firmware/user_c_modules/pyble/pble_termination.h @@ -67,6 +67,9 @@ bool pble_term_rotate_open(pble_term_state_t *state, uint16_t conn, uint64_t new_generation); pble_term_effects_t pble_term_begin(pble_term_state_t *state, uint16_t conn, uint64_t generation, int64_t now_us); +pble_term_effects_t pble_term_preclose_failed(pble_term_state_t *state, + uint16_t conn, + uint64_t generation); bool pble_term_watchdog_ticket(const pble_term_state_t *state, uint16_t conn, uint64_t generation, pble_term_watchdog_ticket_t *ticket); diff --git a/firmware/user_c_modules/pyble/pble_wire.c b/firmware/user_c_modules/pyble/pble_wire.c new file mode 100644 index 00000000..482a71dd --- /dev/null +++ b/firmware/user_c_modules/pyble/pble_wire.c @@ -0,0 +1,466 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +#include "pble_wire.h" + +#include <string.h> + +#define PBLE_WIRE_HEADER_LEN 6u +#define PBLE_WIRE_CRC_LEN 4u + +static uint32_t pble_wire_crc32(const uint8_t *data, size_t len) { + uint32_t crc = 0xffffffffu; + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (unsigned bit = 0; bit < 8u; ++bit) { + crc = (crc & 1u) != 0u + ? (crc >> 1) ^ 0xedb88320u + : crc >> 1; + } + } + return crc ^ 0xffffffffu; +} + +void pble_wire_session_reset(pble_wire_session_t *session) { + if (session != NULL) { + session->negotiated_v1 = false; + } +} + +bool pble_wire_session_negotiated(const pble_wire_session_t *session) { + return session != NULL && session->negotiated_v1; +} + +void pble_wire_session_commit_v1(pble_wire_session_t *session) { + if (session != NULL) { + session->negotiated_v1 = true; + } +} + +static void pble_wire_set_decision(pble_wire_decision_t *decision, + pble_wire_action_t action, + uint8_t opcode, uint8_t id, uint8_t status, + bool violation, bool commit_hello) { + decision->action = action; + decision->opcode = opcode; + decision->id = id; + decision->status = status; + decision->violation = violation; + decision->commit_hello = commit_hello; +} + +static bool pble_wire_ascii_value(const uint8_t *value, size_t len, + bool allow_empty) { + if ((!allow_empty && len == 0u) || + len > PBLE_WIRE_HELLO_FIELD_MAX) { + return false; + } + for (size_t i = 0; i < len; ++i) { + if (value[i] < 0x20u || value[i] > 0x7eu) { + return false; + } + } + return true; +} + +static bool pble_wire_key_valid(const uint8_t *key, size_t len) { + if (len == 0u || len > PBLE_WIRE_HELLO_FIELD_MAX || + key[0] < (uint8_t)'a' || + key[0] > (uint8_t)'z') { + return false; + } + for (size_t i = 1; i < len; ++i) { + uint8_t ch = key[i]; + if (!((ch >= (uint8_t)'a' && ch <= (uint8_t)'z') || + (ch >= (uint8_t)'0' && ch <= (uint8_t)'9') || ch == '_')) { + return false; + } + } + return true; +} + +static bool pble_wire_key_equals(const uint8_t *key, size_t len, + const char *literal) { + size_t literal_len = strlen(literal); + return len == literal_len && memcmp(key, literal, len) == 0; +} + +static bool pble_wire_versions_valid(const uint8_t *value, size_t len, + bool *offers_v1) { + if (len == 0u || len > PBLE_WIRE_HELLO_FIELD_MAX) { + return false; + } + size_t start = 0u; + unsigned count = 0u; + *offers_v1 = false; + while (start < len) { + size_t end = start; + while (end < len && value[end] != (uint8_t)',') { + ++end; + } + size_t token_len = end - start; + if (token_len == 0u || ++count > PBLE_WIRE_HELLO_OFFERS_MAX || + (token_len > 1u && value[start] == (uint8_t)'0')) { + return false; + } + unsigned number = 0u; + for (size_t i = start; i < end; ++i) { + if (value[i] < (uint8_t)'0' || value[i] > (uint8_t)'9') { + return false; + } + number = number * 10u + (unsigned)(value[i] - (uint8_t)'0'); + if (number > 255u) { + return false; + } + } + if (number == 1u) { + *offers_v1 = true; + } + if (end == len) { + break; + } + start = end + 1u; + if (start == len) { + return false; + } + } + return count != 0u; +} + +static uint8_t pble_wire_hello_status(const uint8_t *payload, size_t len) { + if (payload == NULL || len == 0u || len > PBLE_WIRE_HELLO_MAX) { + return PBLE_WIRE_EBADREQ; + } + + // One trailing LF is grammar, not an empty line. A second trailing LF + // remains an empty interior line and is rejected by the loop below. + size_t content_len = len; + if (payload[content_len - 1u] == (uint8_t)'\n') { + --content_len; + } + if (content_len == 0u || + payload[content_len - 1u] == (uint8_t)'\n') { + return PBLE_WIRE_EBADREQ; + } + + bool seen_versions = false; + bool seen_name = false; + bool seen_app_version = false; + bool offers_v1 = false; + size_t line_start = 0u; + + while (line_start < content_len) { + size_t line_end = line_start; + while (line_end < content_len && + payload[line_end] != (uint8_t)'\n') { + ++line_end; + } + if (line_end == line_start) { + return PBLE_WIRE_EBADREQ; + } + + size_t equals = line_start; + while (equals < line_end && payload[equals] != (uint8_t)'=') { + ++equals; + } + if (equals == line_end) { + return PBLE_WIRE_EBADREQ; + } + const uint8_t *key = payload + line_start; + size_t key_len = equals - line_start; + const uint8_t *value = payload + equals + 1u; + size_t value_len = line_end - equals - 1u; + if (!pble_wire_key_valid(key, key_len) || + !pble_wire_ascii_value(value, value_len, true)) { + return PBLE_WIRE_EBADREQ; + } + + if (pble_wire_key_equals(key, key_len, "proto_versions")) { + bool line_offers_v1 = false; + if (seen_versions || + !pble_wire_versions_valid(value, value_len, + &line_offers_v1)) { + return PBLE_WIRE_EBADREQ; + } + seen_versions = true; + offers_v1 = line_offers_v1; + } else if (pble_wire_key_equals(key, key_len, "app_name")) { + if (seen_name || !pble_wire_ascii_value(value, value_len, false)) { + return PBLE_WIRE_EBADREQ; + } + seen_name = true; + } else if (pble_wire_key_equals(key, key_len, "app_version")) { + if (seen_app_version || + !pble_wire_ascii_value(value, value_len, false)) { + return PBLE_WIRE_EBADREQ; + } + seen_app_version = true; + } + + if (line_end == content_len) { + break; + } + line_start = line_end + 1u; + } + + if (!seen_versions || !seen_name || !seen_app_version) { + return PBLE_WIRE_EBADREQ; + } + return offers_v1 ? PBLE_WIRE_OK : PBLE_WIRE_EUNSUPPORTED; +} + +static bool pble_wire_no_response_opcode(uint8_t opcode) { + return opcode == 0x16u || opcode == 0x31u; +} + +static bool pble_wire_command_opcode(uint8_t opcode) { + switch (opcode) { + case 0x02u: // DEVICE_INFO + case 0x10u: // FILE_LIST + case 0x11u: // FILE_STAT + case 0x12u: // FILE_GET_BEGIN + case 0x15u: // FILE_PUT_BEGIN + case 0x16u: // FILE_PUT_DATA + case 0x17u: // FILE_PUT_END + case 0x18u: // FILE_DELETE + case 0x19u: // MKDIR + case 0x1au: // FILE_RENAME + case 0x20u: // RUN + case 0x21u: // STOP + case 0x22u: // SOFT_REBOOT + case 0x23u: // SET_AUTORUN + case 0x31u: // CONSOLE_INPUT + case 0x50u: // SET_LABEL + case 0x51u: // SET_IDENTIFY_LED + case 0x52u: // IDENTIFY + return true; + default: + return false; + } +} + +static bool pble_wire_path_end(const uint8_t *payload, size_t len, + size_t offset, size_t *end) { + if (payload == NULL || end == NULL || offset > len || len - offset < 2u) { + return false; + } + size_t path_len = (size_t)payload[offset] | + ((size_t)payload[offset + 1u] << 8); + offset += 2u; + if (path_len > len - offset) { + return false; + } + *end = offset + path_len; + return true; +} + +static bool pble_wire_payload_exact(uint8_t opcode, + const uint8_t *payload, size_t len) { + size_t first_end; + size_t second_end; + switch (opcode) { + case 0x02u: // DEVICE_INFO + case 0x21u: // STOP + case 0x22u: // SOFT_REBOOT + return len == 0u; + case 0x10u: // FILE_LIST + case 0x11u: // FILE_STAT + case 0x18u: // FILE_DELETE + case 0x19u: // MKDIR + return pble_wire_path_end(payload, len, 0u, &first_end) && + first_end == len; + case 0x12u: // FILE_GET_BEGIN: offset + path + return len >= 4u && + pble_wire_path_end(payload, len, 4u, &first_end) && + first_end == len; + case 0x15u: // FILE_PUT_BEGIN: total + crc + path + return len >= 8u && + pble_wire_path_end(payload, len, 8u, &first_end) && + first_end == len; + case 0x17u: // FILE_PUT_END + return len == 4u; + case 0x1au: // FILE_RENAME: path + path + return pble_wire_path_end(payload, len, 0u, &first_end) && + pble_wire_path_end(payload, len, first_end, &second_end) && + second_end == len; + case 0x23u: // SET_AUTORUN + return len == 1u; + case 0x51u: // SET_IDENTIFY_LED + return len == 0u || len == 2u; + case 0x52u: // IDENTIFY + return len <= 1u; + default: + // HELLO has its own grammar. RUN, PUT_DATA, CONSOLE_INPUT, and + // SET_LABEL consume their final field as all remaining bytes; + // semantic minimum/range validation remains in their handlers. + return true; + } +} + +void pble_wire_decide_with_closing(const pble_wire_session_t *session, + const uint8_t *message, size_t message_len, + bool closing, + pble_wire_decision_t *decision) { + if (decision == NULL) { + return; + } + pble_wire_set_decision(decision, PBLE_WIRE_DROP, 0u, 0u, 0u, + false, false); + + // Exact structural length is first. Correlate an EBADREQ only from a + // complete, safe v1/CMD/nonzero six-byte header. + bool structure_valid = message != NULL && + message_len >= PBLE_WIRE_HEADER_LEN + + PBLE_WIRE_CRC_LEN; + uint16_t payload_len = 0u; + if (structure_valid) { + payload_len = (uint16_t)((uint16_t)message[4] | + ((uint16_t)message[5] << 8)); + structure_valid = message_len == PBLE_WIRE_HEADER_LEN + + (size_t)payload_len + + PBLE_WIRE_CRC_LEN; + } + if (!structure_valid) { + if (message != NULL && message_len >= PBLE_WIRE_HEADER_LEN && + message[0] == PBLE_WIRE_VERSION && + message[1] == PBLE_WIRE_TYPE_CMD && message[3] != 0u) { + pble_wire_set_decision(decision, PBLE_WIRE_RSP, message[2], + message[3], PBLE_WIRE_EBADREQ, true, false); + } else { + decision->violation = true; + } + return; + } + + uint8_t opcode = message[2]; + uint8_t id = message[3]; + size_t crc_offset = message_len - PBLE_WIRE_CRC_LEN; + uint32_t received_crc = (uint32_t)message[crc_offset] | + ((uint32_t)message[crc_offset + 1u] << 8) | + ((uint32_t)message[crc_offset + 2u] << 16) | + ((uint32_t)message[crc_offset + 3u] << 24); + if (pble_wire_crc32(message, crc_offset) != received_crc) { + pble_wire_set_decision(decision, PBLE_WIRE_EVT, opcode, 0u, + PBLE_WIRE_ECRC, true, false); + return; + } + + if (message[1] != PBLE_WIRE_TYPE_CMD || id == 0u) { + pble_wire_set_decision(decision, PBLE_WIRE_DROP, opcode, id, 0u, + true, false); + return; + } + if (message[0] != PBLE_WIRE_VERSION) { + pble_wire_set_decision(decision, PBLE_WIRE_RSP, opcode, id, + PBLE_WIRE_EBADREQ, true, false); + return; + } + + if (closing) { + pble_wire_set_decision( + decision, + pble_wire_no_response_opcode(opcode) ? PBLE_WIRE_DROP + : PBLE_WIRE_RSP, + opcode, id, PBLE_WIRE_EBUSY, false, false); + return; + } + + if (opcode == PBLE_WIRE_OP_HELLO) { + uint8_t hello_status = pble_wire_hello_status( + message + PBLE_WIRE_HEADER_LEN, payload_len); + bool malformed = hello_status == PBLE_WIRE_EBADREQ; + pble_wire_set_decision(decision, PBLE_WIRE_RSP, opcode, id, + hello_status, malformed, + hello_status == PBLE_WIRE_OK); + return; + } + + if (!pble_wire_session_negotiated(session)) { + pble_wire_set_decision( + decision, + pble_wire_no_response_opcode(opcode) ? PBLE_WIRE_DROP + : PBLE_WIRE_RSP, + opcode, id, PBLE_WIRE_EBADREQ, true, false); + return; + } + + if (!pble_wire_command_opcode(opcode)) { + pble_wire_set_decision(decision, PBLE_WIRE_RSP, opcode, id, + PBLE_WIRE_EUNSUPPORTED, false, false); + return; + } + const uint8_t *payload = message + PBLE_WIRE_HEADER_LEN; + if (!pble_wire_payload_exact(opcode, payload, payload_len)) { + pble_wire_set_decision( + decision, + pble_wire_no_response_opcode(opcode) ? PBLE_WIRE_DROP + : PBLE_WIRE_RSP, + opcode, id, PBLE_WIRE_EBADREQ, false, false); + return; + } + pble_wire_set_decision(decision, PBLE_WIRE_DISPATCH, opcode, id, + PBLE_WIRE_OK, false, false); +} + +void pble_wire_decide(const pble_wire_session_t *session, + const uint8_t *message, size_t message_len, + pble_wire_decision_t *decision) { + pble_wire_decide_with_closing(session, message, message_len, false, + decision); +} + +uint8_t pble_wire_label_status(const uint8_t *label, size_t label_len) { + if (label_len > PBLE_WIRE_LABEL_MAX) { + return PBLE_WIRE_ERANGE; + } + if (label_len != 0u && label == NULL) { + return PBLE_WIRE_EBADREQ; + } + + size_t i = 0u; + while (i < label_len) { + uint32_t scalar; + uint8_t first = label[i++]; + if (first <= 0x7fu) { + scalar = first; + } else if (first >= 0xc2u && first <= 0xdfu) { + if (i >= label_len || (label[i] & 0xc0u) != 0x80u) { + return PBLE_WIRE_EBADREQ; + } + scalar = ((uint32_t)(first & 0x1fu) << 6) | + (uint32_t)(label[i++] & 0x3fu); + } else if (first >= 0xe0u && first <= 0xefu) { + if (i + 1u >= label_len || (label[i] & 0xc0u) != 0x80u || + (label[i + 1u] & 0xc0u) != 0x80u || + (first == 0xe0u && label[i] < 0xa0u) || + (first == 0xedu && label[i] >= 0xa0u)) { + return PBLE_WIRE_EBADREQ; + } + scalar = ((uint32_t)(first & 0x0fu) << 12) | + ((uint32_t)(label[i] & 0x3fu) << 6) | + (uint32_t)(label[i + 1u] & 0x3fu); + i += 2u; + } else if (first >= 0xf0u && first <= 0xf4u) { + if (i + 2u >= label_len || (label[i] & 0xc0u) != 0x80u || + (label[i + 1u] & 0xc0u) != 0x80u || + (label[i + 2u] & 0xc0u) != 0x80u || + (first == 0xf0u && label[i] < 0x90u) || + (first == 0xf4u && label[i] > 0x8fu)) { + return PBLE_WIRE_EBADREQ; + } + scalar = ((uint32_t)(first & 0x07u) << 18) | + ((uint32_t)(label[i] & 0x3fu) << 12) | + ((uint32_t)(label[i + 1u] & 0x3fu) << 6) | + (uint32_t)(label[i + 2u] & 0x3fu); + i += 3u; + } else { + return PBLE_WIRE_EBADREQ; + } + + if (scalar <= 0x1fu || + (scalar >= 0x7fu && scalar <= 0x9fu)) { + return PBLE_WIRE_EBADREQ; + } + } + return PBLE_WIRE_OK; +} diff --git a/firmware/user_c_modules/pyble/pble_wire.h b/firmware/user_c_modules/pyble/pble_wire.h new file mode 100644 index 00000000..dd9cf0bc --- /dev/null +++ b/firmware/user_c_modules/pyble/pble_wire.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. +// +// Dependency-free PBLE/1 wire admission. This unit intentionally depends +// only on the C standard library so the exact firmware parser is also compiled +// by the host conformance harness. +#ifndef PBLE_WIRE_H +#define PBLE_WIRE_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +#ifdef __cplusplus +extern "C" { +#endif + +#define PBLE_WIRE_VERSION 0x01u +#define PBLE_WIRE_TYPE_CMD 0x01u +#define PBLE_WIRE_TYPE_RSP 0x02u +#define PBLE_WIRE_TYPE_EVT 0x03u +#define PBLE_WIRE_OP_HELLO 0x01u +#define PBLE_WIRE_OK 0x00u +#define PBLE_WIRE_EBADREQ 0x01u +#define PBLE_WIRE_EBUSY 0x07u +#define PBLE_WIRE_ECRC 0x08u +#define PBLE_WIRE_ERANGE 0x09u +#define PBLE_WIRE_EUNSUPPORTED 0x0au +#define PBLE_WIRE_HELLO_MAX 192u +#define PBLE_WIRE_HELLO_FIELD_MAX 32u +#define PBLE_WIRE_HELLO_OFFERS_MAX 8u +#define PBLE_WIRE_LABEL_MAX 24u + +typedef enum { + PBLE_WIRE_DROP = 0, + PBLE_WIRE_DISPATCH, + PBLE_WIRE_RSP, + PBLE_WIRE_EVT, +} pble_wire_action_t; + +typedef struct { + bool negotiated_v1; +} pble_wire_session_t; + +// A decision is deliberately pure with respect to successful HELLO. The +// caller commits `commit_hello` only after the final response fragment has +// been accepted by the local Notify path. +typedef struct { + pble_wire_action_t action; + uint8_t opcode; + uint8_t id; + uint8_t status; + bool violation; + bool commit_hello; +} pble_wire_decision_t; + +void pble_wire_session_reset(pble_wire_session_t *session); +bool pble_wire_session_negotiated(const pble_wire_session_t *session); +void pble_wire_session_commit_v1(pble_wire_session_t *session); + +// Apply PBLE/1's complete-frame validation precedence and session gate. +void pble_wire_decide(const pble_wire_session_t *session, + const uint8_t *message, size_t message_len, + pble_wire_decision_t *decision); + +// Same pure reducer with the accepted-SOFT_REBOOT global admission boundary. +// `closing` is consulted only after structural/CRC/CMD/ID/version validation +// and before HELLO/session/opcode admission. The compatibility wrapper above +// passes false for host callers that do not model VM lifecycle. +void pble_wire_decide_with_closing(const pble_wire_session_t *session, + const uint8_t *message, size_t message_len, + bool closing, + pble_wire_decision_t *decision); + +// Validate SET_LABEL bytes: length first, then strict UTF-8 and control scalar +// rejection. Empty input is the defined clear operation. +uint8_t pble_wire_label_status(const uint8_t *label, size_t label_len); + +#ifdef __cplusplus +} +#endif + +#endif // PBLE_WIRE_H diff --git a/firmware/versions.lock b/firmware/versions.lock index f04640fa..061f4241 100644 --- a/firmware/versions.lock +++ b/firmware/versions.lock @@ -7,7 +7,7 @@ # pins. See docs/specifications/firmware.md §6 (Build & distribution) and the # sibling note in firmware/upstream/README.md. # -# SOURCE-SELECTED for agent v0.6.0. These exact bytes pin the MicroPython and +# SOURCE-SELECTED for agent v0.6.1. These exact bytes pin the MicroPython and # ESP-IDF source inputs, but do not freeze or qualify a release candidate. # Candidate status requires fresh reproducible builds, license audit, OI-1 # measurement, and HIL; retained v0.4.2 binaries or evidence cannot substitute. @@ -45,7 +45,7 @@ mpy_cross = "rebuilt-from-pinned-micropython" [pyble] # Board identity remains in [targets] and board overlays rather than protocol # routing fields; generic and exact-board variants share the PBLE/1 contract. -agent_version = "0.6.0" +agent_version = "0.6.1" protocol_version = "PBLE/1" # --- rpi-pico2-w port (ADR-0030) -------------------------------------------- @@ -84,3 +84,28 @@ source_archive_format = "tar.xz" source_archive_sha256 = "e6405f20f8a817a50d92dbf7974d0ee77708dfdf9e79900a59c5d343b464ef9c" source_manifest_path = "firmware/licenses/evidence/rp2/arm-gnu-toolchain/14.2.rel1/source-manifest.txt" source_manifest_sha256 = "470cdb8bae9f5fed96c17b10834bbd22820e933cfad99914c3f37997cae36745" + +[picotool] +# Official Raspberry Pi Pico SDK Tools macOS distribution used by the rp2 +# configure/build and provisioning flow. The archive itself is retained below +# the gitignored install tree so every build can revalidate the exact input. +version = "2.3.0" +version_line = "picotool v2.3.0 (Darwin, AppleClang-15.0.0.15000309, Release)" +source_repo = "https://github.com/raspberrypi/picotool.git" +source_ref = "2.3.0" +source_commit = "6f6458d792b93685a11423b244a585eaa99eafcf" +distribution_repo = "https://github.com/raspberrypi/pico-sdk-tools.git" +distribution_ref = "v2.3.0-0" +distribution_commit = "ad9e4a8375253cf4886bf168ea1a8d2746aadf24" +url = "https://github.com/raspberrypi/pico-sdk-tools/releases/download/v2.3.0-0/picotool-2.3.0-mac.zip" +archive_filename = "picotool-2.3.0-mac.zip" +archive_bytes = 1980457 +archive_format = "zip" +sha256 = "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e" +cmake_dir = "picotool" +executable_path = "picotool/picotool" +executable_sha256 = "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" +cmake_config_path = "picotool/picotoolConfig.cmake" +cmake_config_sha256 = "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" +bundled_libusb_path = "picotool/libusb-1.0.0.dylib" +bundled_libusb_sha256 = "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" diff --git a/tests/firmware_tests/README.md b/tests/firmware_tests/README.md index 418d724d..752fccff 100644 --- a/tests/firmware_tests/README.md +++ b/tests/firmware_tests/README.md @@ -1,21 +1,21 @@ # Firmware gate tests (`tests/firmware_tests/`) -Host-runnable `[red]` gate tests + fixtures for the firmware track. **S1 / M0 / -G0** is the build-machinery slice (`test_*.sh` gate tests); **S2 / M1 / G1** adds -the PBLE/1 protocol + BLE agent slice as **CPython host unit + conformance tests** -under `host/`. No hardware, no ESP-IDF, and no MicroPython submodule fetch are -required — these run on a plain host and pin the *behaviour* the `[green]` -production modules must satisfy. The four-variant/three-chip cross-compile and -the HIL demo run later on a toolchain machine / real hardware and are reported -as **DEFERRED**. +Host-runnable tests and fixtures for the complete firmware track. **S1 / M0 / +G0** introduced the build-machinery gates; **S2–S6 / M1 / G1** added the PBLE/1 +protocol and BLE-agent slices; later suites cover five-profile release and +v0.6.1 hardening contracts. No hardware is required for the host suites. Exact +cross-build, resource, recovery, and physical HIL evidence remain separate +release gates and are never inferred from host success. -## Layout (S2 additions) +## Selected layout ``` host/ _support.py # path + MicroPython-guard wiring; corpus loader; red-with-a-reason _fakes/{bluetooth,machine}.py # HOST-ONLY import guards (NimBLE/machine NEVER faked — HIL only) - conformance/corpus.json # SHARED cross-language PBLE/1 byte corpus (firmware <-> Dart pble) + conformance/corpus.json # retained shared PBLE/1 frame/CRC corpus + conformance/v061_*.json # active v0.6.1 semantic parity vectors + conformance/archive/ # immutable byte-less pre-freeze planning ledgers test_pyble_proto.py # F-02 [red] — frame codec, CRC-32, fragmentation, dispatch, status test_pyble_ble.py # F-01 [red] — PURE helpers only (name/INFO/MTU); peripheral is HIL test_pyble_proto.sh # wrapper so run_tests.sh drives the F-02 CPython suite @@ -23,9 +23,10 @@ test_pyble_ble.sh # wrapper so run_tests.sh drives the F-01 CPython gates/g1_check.sh # G1 (M1) exit-gate checklist — S2 slice (cumulative: re-runs G0) ``` -Authored by **firmware-test-author** (sole author of `[red]` tests). The -production gate scripts these tests invoke are owed by **build-smith** -(`[green]`) and do **not** exist yet — so the suite is **RED by design** at S1. +Historical `[red]` banners preserve the commit-stage provenance of each test; +they do not mean the current branch is expected to fail. The corresponding +production modules and gate scripts now exist, and a host failure is a current +regression. ## Run @@ -39,13 +40,16 @@ tests/firmware_tests/run_tests.sh test_no_leak.sh # the G0 / G1 exit-gate checklists (PASS / FAIL / DEFERRED per criterion) tests/firmware_tests/gates/g0_check.sh tests/firmware_tests/gates/g1_check.sh +tests/firmware_tests/gates/g1_s6_check.sh # the S2 host suites directly (CPython) python3 tests/firmware_tests/host/test_pyble_proto.py python3 tests/firmware_tests/host/test_pyble_ble.py +python3 tests/firmware_tests/host/test_firmware_roadmap_ledger.py ``` -Exit code is non-zero while any gate is unmet (expected at S1). +A nonzero host exit is a regression. `DEFERRED-HIL` rows remain honest physical +work and do not become passed merely because the script itself exits zero. ## Story → test mapping @@ -64,16 +68,18 @@ Exit code is non-zero while any gate is unmet (expected at S1). | F-02 | `host/test_pyble_proto.py` | §3.1 frame encode/decode vs the shared corpus (FR-PROTO-2), IEEE CRC-32 over VER…PAYLOAD (FR-PROTO-3), CRC-fail→`EVT ERROR(ECRC)` ref opcode + drop (FR-PROTO-3), ID correlation / EVT ID=0 (FR-PROTO-4), opcode dispatch (FR-PROTO-5), full §4 opcode set (FR-PROTO-1) + §8 status set (FR-PROTO-6), malformed→`EBADREQ` (FR-PROTO-8), unknown→`EUNSUPPORTED` (FR-PROTO-9, fwd F-16), fragment/reassemble byte-identical over the MTU matrix incl. index-mod-64 wrap (FR-BLE-8/10). | | F-11 | `hil/f11_reliability_bench.py` | **HIL bench** (firmware-test-author, no firmware `[green]`): uploads N files back-to-back at MTU 247 over BLE (`bleak`) and asserts every file's whole-file CRC (`FILE_PUT_END`) + an independent `FILE_STAT` re-verify (NFR-REL-5, FR-FS-6/14); reports a throughput baseline (NFR-PERF-1 / NFR-FP-TPUT) — **never** a hardcoded ceiling (OI-1: floor applied only via `--tput-floor-bps`). `--relabel` proves identity never gates access (SEC-11). HIL-only. | | F-11 | `host/conformance/test_hil_wire.py` (`test_hil_wire.sh`) | **Host-runnable NOW:** the bench's PBLE/1 codec (`hil/_pble_wire.py`) matches the SHARED corpus byte-for-byte across the MTU matrix incl. the index-mod-64 wrap (FR-PROTO-2/3, FR-BLE-8/10) — the HIL harness cannot drift from the firmware↔Dart wire. | -| F-10 / F-12 / F-18 | `host/conformance/s6_pending.json` | **DoR-BLOCKED** semantic obligations (no frame bytes): F-10 resume (verified prefix → `resume_offset>0`, byte-identical resume, ECRC-keeps-old-file); F-12 cold-boot advertise-and-wait + opt-in auto-run + fail-safe; F-18 pairing-active-not-access-gating + single-writer + no-MAC-gating/no-telemetry/no-PII-adv. Blocked on three `[docs]` freezes (§5 resume behaviour; §10 Security/§9 SEC; OI-5 auto_run+SET_AUTORUN/§4.7 FR-BOOT). | +| v0.6.1 protocol/session | `host/conformance/v061_session_vectors.json`, `host/test_v061_protocol_session.py`, `host/test_v061_pble_wire_native.py` | Shared exact HELLO grammar, negotiation, inbound direction/ID, validation precedence, and native/portable semantic parity. | +| v0.6.1 payload/label | `host/conformance/v061_payload_grammar_vectors.json`, `host/conformance/v061_label_vectors.json` | Existing-opcode payload validation and strict UTF-8 label semantics without adding a wire key or operation. | +| Archived S3–S6 ledgers | `host/conformance/archive/pre-freeze/` | Byte-identical historical planning records only. Their pre-freeze status and obsolete `max_file_size` references are not current requirements. | | G0 | `gates/g0_check.sh` | Enumerates the five G0 exit-gate criteria as PASS / FAIL / DEFERRED. | -| G1 | `gates/g1_check.sh` | S2 slice of the G1 (M1) ladder: host-PASS (F-01/F-02) vs HIL-DEFERRED (peripheral, HELLO/DEVICE_INFO bench) vs DEFERRED-STORY (S3–S6). Cumulative: re-runs `g0_check.sh` and refuses to sign while G0 regresses. | -| G1 | `gates/g1_s6_check.sh` | **S6 slice (CLOSES G1):** F-11 host wire-guard PASS + bench present; F-10/F-12/F-18 DEFERRED-DOCS (three freezes) + DEFERRED-HIL; native coverage DEFERRED-NATIVE (F-20). Cumulative: re-runs G0 + G1 S2, reports S3/S4/S5 informationally. | +| G1 | `gates/g1_check.sh` | Retained S2 slice of the G1 (M1) ladder: implemented host checks plus explicit HIL deferrals. Cumulative: re-runs `g0_check.sh` and refuses to sign while G0 regresses. | +| G1 | `gates/g1_s6_check.sh` | **S6 slice (closes the host ladder):** frozen specification prerequisites and implemented host checks pass; physical F-10/F-11/F-12/F-18 rows remain `DEFERRED-HIL`, and true native-device coverage remains separately identified. | -## Production-code contracts (HAND-OFF to build-smith) +## Retained production-code contracts -These tests define, and assert against, the interfaces the `[green]` scripts must -provide. All are missing at S1 (the RED reason). To keep the tests hermetic and -CI-scoped, several accept env/flag overrides: +These tests define and assert the interfaces supplied by the production +scripts. Historical ownership notes explain the original red→green handoff. +To keep the tests hermetic and CI-scoped, several accept env/flag overrides: | Script | Interface the tests assert | |---|---| diff --git a/tests/firmware_tests/gates/g1_check.sh b/tests/firmware_tests/gates/g1_check.sh index 456dd8fc..907e1cfc 100755 --- a/tests/firmware_tests/gates/g1_check.sh +++ b/tests/firmware_tests/gates/g1_check.sh @@ -11,14 +11,15 @@ # and refuses to sign any G1 item if G0 regressed. A milestone gate is green ONLY # on a real-hardware HIL demo; this script verifies only the HOST-runnable slice. # -# SCOPE: G1 spans sprints S2–S6. THIS sprint (S2) delivers the HELLO/DEVICE_INFO +# SCOPE: G1 spans sprints S2–S6. This retained script checks the S2 +# HELLO/DEVICE_INFO # + framing SLICE (F-01 pyble_ble pure helpers, F-02 pyble_proto codec/CRC/ # fragmentation/dispatch/conformance). Later G1 rows (F-03 info, F-04..07 run/ # console, F-08..11/17 fs+jail, F-12 boot, F-18 pairing, F-22/23 label+identify) -# are reported DEFERRED-STORY until their sprints. **G1 does NOT close at S2.** +# are checked by the sibling g1_s3…g1_s6 scripts. **G1 does NOT close at S2.** # -# Exit non-zero if any HOST-verifiable S2-slice criterion FAILs (RED is expected -# now: the pyble_* modules are not yet implemented). DEFERRED does not fail. +# Exit non-zero if any HOST-verifiable S2-slice criterion fails. Physical rows +# remain deferred and do not affect this host-only exit code. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" @@ -66,17 +67,14 @@ row "DEFERRED-HIL" "NimBLE only, Bluedroid not built; fully offline; no HW-outpu hdr "G1.4 From a test client on a classic ESP32: HELLO + DEVICE_INFO succeed [F-01, F-02, F-03]" row "DEFERRED-HIL" "end-to-end HELLO + DEVICE_INFO round-trip on a classic ESP32 (also needs F-03 pyble_info, S3)" -# --- Remaining G1 criteria (later sprints S3–S6) ----------------------------- -hdr "G1.5 Remaining G1 criteria — DEFERRED to later M1 sprints (S3–S6)" -row "DEFERRED-STORY" "put/get/list/delete file round-trip + CRC + resume, reliability bench (F-08..11, S5–S6)" -row "DEFERRED-STORY" "RUN(file|source) / STOP vs while-True / live console (F-04..07, S3–S4)" -row "DEFERRED-STORY" "workspace jail rejects escapes; BLE pairing baseline (F-17, F-18, S6)" -row "DEFERRED-STORY" "device label sets/clears PyBLE-XXXX pre-connect; cap-gated IDENTIFY; identity never gates access (F-22, F-23, S3–S4)" +# --- Remaining G1 criteria are owned by current sibling scripts -------------- +hdr "G1.5 Remaining G1 host slices — checked by g1_s3 through g1_s6" +note "run g1_s3_check.sh, g1_s4_check.sh, g1_s5_check.sh, and g1_s6_check.sh; this S2 script deliberately avoids a recursive aggregate" printf '\n----------------------------------------\n' if [ "$G1_FAIL" -ne 0 ]; then - printf 'G1 (S2 slice): %d host-verifiable criteria FAILING — expected RED until [green] pyble_proto/pyble_ble land. Plus DEFERRED HIL/STORY items. G1 NOT met.\n' "$G1_FAIL" + printf 'G1 (S2 slice): %d host-verifiable criteria FAILING. Plus DEFERRED-HIL items. G1 NOT met.\n' "$G1_FAIL" exit 1 fi -printf 'G1 (S2 slice): host-verifiable S2 criteria PASS. G1 still requires the DEFERRED HIL demo + later-sprint stories (S3–S6) before sign-off.\n' +printf 'G1 (S2 slice): host-verifiable S2 criteria PASS. G1 still requires the sibling S3–S6 checks and physical HIL before sign-off.\n' exit 0 diff --git a/tests/firmware_tests/gates/g1_s3_check.sh b/tests/firmware_tests/gates/g1_s3_check.sh index 8d531c8b..57388607 100755 --- a/tests/firmware_tests/gates/g1_s3_check.sh +++ b/tests/firmware_tests/gates/g1_s3_check.sh @@ -13,11 +13,10 @@ # S2-slice host checks and REFUSES to sign any S3 item if either regressed. # # A milestone gate is green ONLY on a real-hardware HIL demo. This script -# verifies the HOST-runnable S3 conformance slice and reports the HIL items as -# DEFERRED-HIL. RED is EXPECTED now: the S3 pyble_* modules are not yet [green], -# AND the [red] S3 conformance suites are DoR-BLOCKED until protocol.md §6/§7/§9 -# freeze + the specs.md FR mirror lands (see host/test_pyble_*.py banners and -# host/conformance/s3_pending.json). Do not sign S3 conformance before that. +# verifies the implemented HOST-runnable S3 conformance slice and reports the +# physical items as DEFERRED-HIL. PBLE/1 §6/§7/§9 and their firmware mirrors +# are frozen; their former pre-freeze planning ledger is retained only in the +# conformance archive. # # Exit non-zero if any cumulative earlier slice regressed, OR if a host-runnable # S3 criterion FAILs. DEFERRED does not fail. @@ -62,7 +61,7 @@ fi hdr "G1.S3.1 SDD precondition — protocol.md §6/§7/§9 FROZEN before S3 conformance" PROTO="$REPO_ROOT/docs/specifications/protocol.md" chk_frozen() { # SECTION_REGEX LABEL - if grep -qiE "$1.*FROZEN" "$PROTO" 2>/dev/null; then row PASS "$2"; else row DEFERRED-DOCS "$2 (still DRAFT — architect/project-architect [docs] freeze)"; fi + if grep -qiE "$1.*FROZEN" "$PROTO" 2>/dev/null; then row PASS "$2"; else row FAIL "$2 (frozen-contract marker missing)"; fi } chk_frozen '§6 Run' "protocol.md §6 (Run/Console) FROZEN — DoR for F-04 [red]" chk_frozen '§7 HELLO' "protocol.md §7 (HELLO/caps) FROZEN — DoR for F-03/F-16/F-22 [red]" @@ -81,12 +80,12 @@ row "$(run_test test_pyble_device_config.sh)" "default PyBLE-XXXX; label replace hdr "G1.S3.5 F-04 RUN file + RUN_STATE + EBUSY conformance [pyble_runner — runtime-engineer]" row "$(run_test test_pyble_runner.sh)" "run->running->done/error state machine; RUN_STATE per transition; single-program EBUSY (FR-RUN-1/4/7/9)" -note "the four rows above are RED until [green]; they are also DoR-BLOCKED until §6/§7/§9 freeze — see the file banners" +note "the four rows above exercise the implemented host surfaces; physical behavior remains separately deferred below" # --- Shared cross-language corpus (firmware <-> Dart pble) -------------------- hdr "G1.S3.6 Shared PBLE/1 corpus advanced for S3 (frozen frame-level only)" row "$(run_test test_pyble_proto.sh)" "corpus.json still byte-verifies incl. run_rsp_*/set_label_rsp_* frozen frame vectors" -note "payload-semantic S3 vectors (caps/RUN/SET_LABEL body) held in conformance/s3_pending.json until §6/§7/§9 freeze" +note "pre-freeze planning vectors are archived; current semantics are owned by the named host suites and frozen specifications" # --- No-leak (cumulative clean-room gate) ------------------------------------ hdr "G1.S3.7 Clean-room no-leak still clean over the test tree [F-22 gate carries no-leak]" @@ -111,7 +110,7 @@ if [ "$CUM_FAIL" -ne 0 ]; then exit 1 fi if [ "$S3_FAIL" -ne 0 ]; then - printf 'G1 S3 slice: %d host criteria FAILING — EXPECTED RED until the S3 pyble_* modules land [green] (and only after §6/§7/§9 freeze). Plus DEFERRED-HIL items. G1 NOT advanced.\n' "$S3_FAIL" + printf 'G1 S3 slice: %d host criteria FAILING. Plus DEFERRED-HIL items. G1 NOT advanced.\n' "$S3_FAIL" exit 1 fi printf 'G1 S3 slice: host-runnable S3 criteria PASS. G1 still requires the DEFERRED-HIL demo before sign-off, and remains open through S6.\n' diff --git a/tests/firmware_tests/gates/g1_s4_check.sh b/tests/firmware_tests/gates/g1_s4_check.sh index 3f4ca072..27813cf5 100755 --- a/tests/firmware_tests/gates/g1_s4_check.sh +++ b/tests/firmware_tests/gates/g1_s4_check.sh @@ -18,13 +18,10 @@ # and the G1 S3 slice and REFUSES to sign any S4 item if any regressed. # # A milestone gate is green ONLY on a real-hardware HIL demo. This script verifies -# the HOST-runnable S4 slice and reports the HIL items as DEFERRED-HIL. RED / -# DEFERRED is EXPECTED now: the S4 pyble_*/pble_* modules are not yet [green], AND -# the S4 payload-semantic conformance vectors are DoR-BLOCKED until protocol.md §6 -# (STOP/SOFT_REBOOT/RUN{source}/CONSOLE_DATA/CONSOLE_INPUT) + §4/§7 (OI-6 identify -# encodings) FREEZE and the specs.md FR mirror + OI-6 close land. Those pending -# vectors carry NO frame bytes on purpose (host/conformance/s4_pending.json) — -# fabricating draft wire is forbidden. Do not sign S4 conformance before freeze. +# the implemented HOST-runnable S4 slice and reports physical items as +# DEFERRED-HIL. PBLE/1 §6 and the §4/§7 OI-6 identify encodings are frozen; +# their former pre-freeze planning ledger is retained only in the conformance +# archive. # # Exit non-zero if any cumulative earlier slice regressed, OR if a host-runnable # S4 criterion FAILs. DEFERRED does not fail. @@ -54,10 +51,8 @@ printf '# repo: %s\n' "$REPO_ROOT" # --- Cumulative guard: G0 + G1 S2 slice must still hold ----------------------- # CUMULATIVE RULE binds SIGNED/green gates: the hard blockers are the genuinely- -# green earlier slices (G0 + G1 S2). The G1 S3 slice is IN-FLIGHT within the same -# open G1 gate (its pyble_* modules are not yet [green]) — its own exit code -# cannot distinguish "regressed" from "not-yet-green", so like g1_s3_check.sh -# only hard-blocks on g0+g1(S2), the S3 status is reported INFORMATIONALLY here. +# green earlier slices (G0 + G1 S2–S3). A current S3 host failure is a +# cumulative regression and hard-blocks this slice. hdr "G1.S4.0 CUMULATIVE — G0 and the G1 S2 slice must not regress (signed/green earlier slices)" if bash "$FT/gates/g0_check.sh" >/dev/null 2>&1; then crow PASS "G0 host slice still green" @@ -72,7 +67,7 @@ fi if bash "$FT/gates/g1_s3_check.sh" >/dev/null 2>&1; then note "G1 S3 slice host-runnable checks currently GREEN" else - note "G1 S3 slice not yet fully green (S3 pyble_* modules in flight) — tracked by gates/g1_s3_check.sh; NOT a signed-gate regression, so it does not block S4 authoring. Re-verify it turns green before G1 sign-off (cumulative rule)." + crow FAIL "G1 S3 slice REGRESSED — current host criteria must pass before S4 can be signed" fi # --- Spec-freeze precondition (SDD): §6 S4 semantics + §4/§7 OI-6 must FREEZE - @@ -80,14 +75,12 @@ hdr "G1.S4.1 SDD precondition — protocol.md S4 payload encodings FROZEN before PROTO="$REPO_ROOT/docs/specifications/protocol.md" SPECS="$REPO_ROOT/docs/specifications/firmware/specs.md" # §4/§8 opcode+status numbers and §6 RUN-FILE lifecycle are already frozen (G1). -# What S4 needs is the DRAFT REMAINDER: §6 STOP/SOFT_REBOOT/RUN{source}/CONSOLE_* -# payload+semantics, and the OI-6 identify-LED/blink encodings. Detect freeze by -# the ABSENCE of the DRAFT marker (avoids false positives from the DRAFT -# signature prose): while the marker is present the section is DRAFT and the -# payload-semantic [red] suites remain DoR-BLOCKED. +# The historical pre-freeze markers must remain absent. Reappearance is a +# contract regression and therefore fails this current gate rather than +# deferring an already completed specification decision. frozen_when_absent() { # DRAFT_REGEX FILE LABEL if grep -qiE "$1" "$2" 2>/dev/null; then - row DEFERRED-DOCS "$3 (still DRAFT — project-architect [docs] freeze; firmware-architect mirrors)" + row FAIL "$3 (obsolete pre-freeze marker reappeared)" else row PASS "$3" fi @@ -100,25 +93,25 @@ frozen_when_absent 'freeze there|freeze at S4' "$PROTO" "protocol.md §6 STOP/SO frozen_when_absent 'frozen at S4 before F-23' "$PROTO" "protocol.md §4/§7 OI-6 identify (SET_IDENTIFY_LED + IDENTIFY + caps) encodings FROZEN — DoR for F-23 [red]" # specs.md OI-6 remainder must flip from "Still OPEN" to closed. frozen_when_absent 'Still \*\*OPEN|Still OPEN' "$SPECS" "specs.md §5 OI-6 remainder CLOSED (FR-IDENT-2/3/4 mirror) — DoR for F-23 [red]" -note "while the rows above are DEFERRED-DOCS, the S4 payload-semantic vectors stay in host/conformance/s4_pending.json (no frame bytes) — see the file _blocked_on ledger" +note "pre-freeze planning vectors are archived byte-for-byte and are not active requirements" -# --- S4 host conformance slice (payload-semantic — DoR-BLOCKED until freeze) -- +# --- S4 host conformance slice ------------------------------------------------ hdr "G1.S4.2 F-05 RUN{mode:source} same lifecycle as file [pyble_runner — runtime-engineer]" -row "DEFERRED-DOCS" "RUN{source} -> RSP{OK} -> RUN_STATE(running) -> done/error; EBADREQ bad mode; ERANGE over-length; EBUSY while running (FR-RUN-2/4/7) — s4_pending.json:run_source_lifecycle" +row "$(run_test test_pyble_runner.sh)" "RUN{source} -> RSP{OK} -> RUN_STATE(running) -> done/error; EBADREQ bad mode; ERANGE over-length; EBUSY while running (FR-RUN-2/4/7)" hdr "G1.S4.3 F-06 STOP + SOFT_REBOOT [pyble_runner — runtime-engineer]" -row "DEFERRED-DOCS" "STOP RSP{OK} ALWAYS (idle no-op + running interrupt->RUN_STATE(idle)); SOFT_REBOOT RSP{OK}->VM reset->RUN_STATE(idle) (FR-RUN-5/6/8/10) — s4_pending.json:stop_idempotent_ok,soft_reboot_clears_to_idle" +row "$(run_test test_pyble_agent.sh)" "portable agent integration covers idempotent STOP, active-run interruption ordering, and response-before-SOFT_REBOOT (FR-RUN-5/6/8/10)" hdr "G1.S4.4 F-07 console tee stdout/stderr + CONSOLE_INPUT stdin [pyble_console — runtime-engineer]" -row "DEFERRED-DOCS" "CONSOLE_DATA [stream][bytes] stdout=0/stderr=1 observe-anywhere; CONSOLE_INPUT NO-RSP (PBLE_NO_RSP never on wire); traceback+RUN_STATE(error) on uncaught (FR-CON-1..5, FR-RUN-9) — s4_pending.json:console_*" +row "$(run_test test_pyble_console.sh)" "portable console oracle covers stream tags, bounded input/output, and preserved traceback bytes (FR-CON-1..3, FR-RUN-9)" hdr "G1.S4.5 F-23 cap-gated IDENTIFY [pyble_device_config + pyble_info — identity-engineer]" -row "DEFERRED-DOCS" "SET_IDENTIFY_LED [gpio][active_level] persist/clear + ERANGE/EBADREQ; IDENTIFY bounded non-blocking blink + EUNSUPPORTED unset; has_identify/identify_led additive caps (FR-IDENT-2/3/4, §7) — s4_pending.json:set_identify_led_body_persists,identify_blink_bounded,caps_has_identify_and_identify_led" +row "$(run_test test_v061_native_hardening.sh)" "native guards cover the authoritative versioned Identify configuration and exact payload validation; timing/GPIO/caps remain HIL below (FR-IDENT-2/3/4, §7)" # --- Shared cross-language corpus (firmware <-> Dart pble) -------------------- hdr "G1.S4.6 Shared PBLE/1 corpus — frozen frame-level S4 vectors still verify" row "$(run_test test_pyble_proto.sh)" "corpus.json still byte-verifies incl. run_cmd_source_opaque_250b / run_rsp_* / stop_rsp_ebusy / run_state_evt_running / console_data_evt / identify_rsp_eunsupported (opaque payload, §4/§8 frozen)" -note "payload-SEMANTIC S4 vectors are held in conformance/s4_pending.json until protocol.md §6/§4/§7 freeze; they carry NO frame_hex (wire is referenced, never redefined)" +note "the shared corpus retains its frame-level vectors; current payload semantics are exercised by the named host suites without claiming retrospective corpus promotion" # --- No-leak (cumulative clean-room gate) ------------------------------------ hdr "G1.S4.7 Clean-room no-leak still clean over the test tree" @@ -152,5 +145,5 @@ if [ "$S4_FAIL" -ne 0 ]; then printf 'G1 S4 slice: %d host criteria FAILING. G1 NOT advanced.\n' "$S4_FAIL" exit 1 fi -printf 'G1 S4 slice: host-runnable checks PASS; S4 payload-semantic conformance is DoR-BLOCKED (protocol.md §6/§4/§7 + specs.md OI-6 not yet frozen) and the behaviours are DEFERRED-HIL. G1 remains OPEN through S6.\n' +printf 'G1 S4 slice: host-runnable checks PASS; physical behaviours remain DEFERRED-HIL. G1 remains OPEN through S6.\n' exit 0 diff --git a/tests/firmware_tests/gates/g1_s5_check.sh b/tests/firmware_tests/gates/g1_s5_check.sh index 4785e25b..0845aa97 100755 --- a/tests/firmware_tests/gates/g1_s5_check.sh +++ b/tests/firmware_tests/gates/g1_s5_check.sh @@ -23,29 +23,18 @@ # # CUMULATIVE RULE (02_milestones.md / PRD §1B.7): a later gate is never green # while an earlier one regressed. This script FIRST re-runs G0 and the G1 S2 slice -# and REFUSES to sign any S5 item if either regressed; the G1 S3/S4 slices are -# reported INFORMATIONALLY (their pyble_*/pble_* modules are in flight — an -# in-flight slice's exit code cannot distinguish "regressed" from "not-yet-green", -# same choice g1_s3_check / g1_s4_check made). +# and REFUSES to sign any S5 item if an earlier implemented host slice regressed. # # A milestone gate is green ONLY on a real-hardware HIL demo. This script verifies -# the HOST-runnable S5 slice and reports the HIL items as DEFERRED-HIL. RED / -# DEFERRED is EXPECTED now: the native pble_fs module is not yet [green], AND the -# S5 payload-semantic conformance vectors are DoR-BLOCKED until protocol.md §5 -# (File transfer, read + upload halves) FREEZES, the specs.md §4.4 FR-FS payload -# mirror lands, AND the specs.md F-17 jail definition (chokepoint + forbidden set) -# FREEZES. Those pending vectors carry NO frame bytes on purpose -# (host/conformance/s5_pending.json) — fabricating draft wire is forbidden. §5 -# ALSO currently CONTRADICTS the architect freeze_proposal (GET_BEGIN up-front -# crc32; Go-Back-N window). Do not sign S5 conformance before freeze. +# the implemented HOST-runnable S5 slice and reports physical items as +# DEFERRED-HIL. Protocol §5, its FR-FS mirror, and the F-17 jail are frozen; +# their former planning ledger is retained only in the conformance archive. # # NOTE (native-C reality, ADR-0006): pble_fs is native C — there is NO host- # importable Python module for it (unlike the S2/S3 pyble_*.py modules). So the -# host-runnable-NOW S5 slice is only (a) the shared FRAME-CODEC corpus still -# byte-verifying and (b) the no-leak grep. Native-C UNIT coverage (whole-file -# crc32 oracle, pble_fs_resolve canonicalization + forbidden-set, list/stat/get -# payload build) is Unity-on-native and arrives with F-20; it is DEFERRED-NATIVE -# here, not FAIL. +# portable twin supplies host-executable filesystem semantics and focused source +# guards cover native hardening. A true Unity-on-native harness remains +# DEFERRED-NATIVE and is not substituted by those host checks. # # Exit non-zero if any cumulative earlier slice regressed, OR if a host-runnable # S5 criterion FAILs. DEFERRED does not fail. @@ -75,10 +64,7 @@ printf '# repo: %s\n' "$REPO_ROOT" # --- Cumulative guard: G0 + G1 S2 slice must still hold ----------------------- # CUMULATIVE RULE binds SIGNED/green gates: the hard blockers are the genuinely- -# green earlier slices (G0 + G1 S2). The G1 S3 and S4 slices are IN-FLIGHT within -# the same open G1 gate — their own exit codes cannot distinguish "regressed" -# from "not-yet-green", so (like g1_s3_check / g1_s4_check) they are reported -# INFORMATIONALLY and do not hard-block S5 authoring. +# green earlier slices (G0 + G1 S2–S4). hdr "G1.S5.0 CUMULATIVE — G0 and the G1 S2 slice must not regress (signed/green earlier slices)" if bash "$FT/gates/g0_check.sh" >/dev/null 2>&1; then crow PASS "G0 host slice still green" @@ -93,12 +79,12 @@ fi if bash "$FT/gates/g1_s3_check.sh" >/dev/null 2>&1; then note "G1 S3 slice host-runnable checks currently GREEN" else - note "G1 S3 slice not yet fully green (S3 pyble_* modules in flight) — tracked by gates/g1_s3_check.sh; NOT a signed-gate regression. Re-verify green before G1 sign-off (cumulative rule)." + crow FAIL "G1 S3 slice REGRESSED — current host criteria must pass before S5 can be signed" fi if bash "$FT/gates/g1_s4_check.sh" >/dev/null 2>&1; then note "G1 S4 slice host-runnable checks currently GREEN" else - note "G1 S4 slice not yet fully green (S4 pyble_*/pble_* modules in flight) — tracked by gates/g1_s4_check.sh; NOT a signed-gate regression. Re-verify green before G1 sign-off (cumulative rule)." + crow FAIL "G1 S4 slice REGRESSED — current host criteria must pass before S5 can be signed" fi # --- Spec-freeze precondition (SDD): §5 payloads + FR-FS mirror + jail -------- @@ -106,18 +92,11 @@ hdr "G1.S5.1 SDD precondition — protocol.md §5 + specs.md FR-FS mirror + jail PROTO="$REPO_ROOT/docs/specifications/protocol.md" SPECS="$REPO_ROOT/docs/specifications/firmware/specs.md" # §4 opcode numbers (0x10..0x1A, 0x41) and §8 status are already frozen (G1). -# What S5 needs is protocol.md §5 (File transfer — read + upload halves) to gain -# its "FROZEN for v1.0" banner AND the specs.md payload-level FR-FS to STOP -# tracking §5, PLUS the specs.md F-17 jail definition to freeze. Detection: -# (a) §5 read/upload: specs.md carries the DRAFT tracking marker -# "still tracks §5"; freeze = that marker is ABSENT (avoids false-positives -# from any partial banner — §5 today has NO 'FROZEN' banner at all). -# (b) jail: the freeze INTRODUCES the concrete forbidden set into specs.md; a -# reliable anchor is the reserved transfer-scratch suffix ".pbltmp" (or the -# chokepoint symbol pble_fs_resolve) — absent today, so PRESENCE = frozen. +# The historical pre-freeze marker must remain absent and the concrete jail +# anchor must remain present. Either regression fails this current gate. frozen_when_marker_absent() { # DRAFT_REGEX FILE LABEL if grep -qiE "$1" "$2" 2>/dev/null; then - row DEFERRED-DOCS "$3 (still DRAFT — project-architect [docs] freeze of protocol.md §5; firmware-architect mirrors specs.md §4.4)" + row FAIL "$3 (obsolete pre-freeze marker reappeared)" else row PASS "$3" fi @@ -126,22 +105,23 @@ frozen_when_anchor_present() { # ANCHOR_REGEX FILE LABEL if grep -qiE "$1" "$2" 2>/dev/null; then row PASS "$3" else - row DEFERRED-DOCS "$3 (not yet frozen — firmware-architect owns the specs.md jail [docs] commit: chokepoint + concrete forbidden set)" + row FAIL "$3 (frozen jail anchor missing)" fi } frozen_when_marker_absent 'still tracks §5' "$SPECS" "protocol.md §5 File transfer (read + upload) payload FROZEN + specs.md §4.4 FR-FS payload mirror — DoR for F-08/F-09 [red]" frozen_when_anchor_present '\.pbltmp|pble_fs_resolve' "$SPECS" "specs.md F-17 jail definition FROZEN (single chokepoint pble_fs_resolve + forbidden set incl. reserved .pbltmp suffix + reserved agent-prefix) — DoR for F-17 [red]" -note "while the rows above are DEFERRED-DOCS, the S5 payload-semantic vectors stay in host/conformance/s5_pending.json (no frame bytes) — see the file _blocked_on ledger + _deviations_from_current_prose (GET_BEGIN drops up-front crc32; windowed PUT is Go-Back-N)" +note "pre-freeze planning vectors are archived byte-for-byte and are not active requirements" -# --- S5 host conformance slice (payload-semantic — DoR-BLOCKED until freeze) -- +# --- S5 host conformance slice ------------------------------------------------ +FS_HOST_STATUS="$(run_test test_pyble_fs.sh)" hdr "G1.S5.2 F-08 filesystem read (FILE_LIST / FILE_STAT / FILE_GET_*) [pble_fs — storage-engineer]" -row "DEFERRED-DOCS" "LIST rooted at fs_root (ENOENT on missing dir); STAT {size,crc} + ENOENT on missing; GET streams BEGIN{total_size}->DATA->END{whole-file crc over [0,total_size)}; errno->§8 mapping (FR-FS-1/2/3/15) — s5_pending.json:file_list_rooted_at_fs_root,file_stat_size_crc_and_enoent,file_get_whole_file_crc,file_get_missing_enoent,fs_errno_to_status_mapping" +row "$FS_HOST_STATUS" "portable semantic oracle: LIST root/ENOENT, STAT size+CRC, GET extent+whole-file CRC, and errno mapping (FR-FS-1/2/3/15)" hdr "G1.S5.3 F-09 filesystem write (windowed FILE_PUT + DELETE/MKDIR/RENAME) [pble_fs — storage-engineer]" -row "DEFERRED-DOCS" "windowed PUT W=4 Go-Back-N cumulative ACK{watermark}; PUT_END whole-file CRC OK-only; ECRC KEEPS old file; temp-write-then-rename atomic; resume_offset field (S5=0); single-active-transfer EBUSY; DELETE/MKDIR/RENAME status; .py/data-only no .mpy/.pyc (FR-FS-4/5/6/8/9/12/14, CON-3, NFR-PERF-2) — s5_pending.json:put_*,file_delete_semantics,mkdir_semantics,file_rename_semantics,py_data_only_no_mpy_pyc,single_active_transfer_ebusy" +row "$FS_HOST_STATUS" "portable semantic oracle: windowed PUT, cumulative ACK, CRC-gated replace, mutations, resume field, transfer exclusion, and source/data-only policy (FR-FS-4/5/6/8/9/12/14)" hdr "G1.S5.4 F-17 workspace-jail enforcement [pble_fs_resolve chokepoint — storage-engineer]" -row "DEFERRED-DOCS" "traversal ('../') + absolute escape -> EACCES; forbidden set (fs_root confinement + reserved .pbltmp suffix + reserved agent-prefix) -> EACCES; NUL/empty -> EBADREQ; overlong(>128B) -> ERANGE; SINGLE unbypassable chokepoint; user-code FS unaffected (FR-FS-10/11/16, SEC-4) — s5_pending.json:jail_traversal_and_absolute_escape_eacces,jail_forbidden_set_eacces,jail_single_chokepoint_unbypassable,jail_user_code_fs_unaffected" +row "$FS_HOST_STATUS" "portable semantic oracle: traversal/absolute/reserved rejection, malformed and overlong bounds, and bridge-only jail behavior (FR-FS-10/11/16, SEC-4)" # --- Native-C unit coverage (Unity-on-native, arrives F-20) ------------------- hdr "G1.S5.5 Native-C unit coverage — pble_fs.c (Unity, arrives with F-20)" @@ -150,7 +130,7 @@ row "DEFERRED-NATIVE" "whole-file crc32 oracle, pble_fs_resolve canonicalization # --- Shared cross-language corpus (firmware <-> Dart pble) -------------------- hdr "G1.S5.6 Shared PBLE/1 corpus — frozen frame-level FS vectors still verify" row "$(run_test test_pyble_proto.sh)" "corpus.json still byte-verifies incl. file_put_data_cmd (0x16, opaque payload) — the FS opcode frame codec + CRC + fragmentation (§4/§3 frozen)" -note "payload-SEMANTIC S5 vectors are held in conformance/s5_pending.json until protocol.md §5 freeze; they carry NO frame_hex (wire is referenced, never redefined). On freeze: add frame-level opaque vectors for 0x10/0x11/0x12/0x13/0x14/0x15/0x17/0x18/0x19/0x1A/0x41, then the payload-semantic vectors." +note "the shared corpus retains its frame-level vectors; current filesystem semantics are exercised by the portable oracle and native guards without claiming retrospective corpus promotion" # --- No-leak (cumulative clean-room gate) ------------------------------------ hdr "G1.S5.7 Clean-room no-leak still clean over the test tree" @@ -165,7 +145,7 @@ hdr "G1.S5.8 S5 behaviours on real hardware [HIL — ESP32-S3 harness: UART REP row "DEFERRED-HIL" "F-08: FILE_LIST returns entries rooted at fs_root; a missing directory returns ENOENT (FR-FS-1/15)" row "DEFERRED-HIL" "F-08: FILE_STAT returns {size,crc} matching a host-side zlib oracle; a missing path returns ENOENT (FR-FS-2)" row "DEFERRED-HIL" "F-08: FILE_GET streams a file byte-exact; FILE_GET_END whole-file CRC matches; a resume GET (offset>0) still reports the whole-file CRC over [0,total_size) (FR-FS-3)" -row "DEFERRED-HIL" "F-09: windowed PUT (W=4) over a lossy link advances on cumulative FILE_PUT_ACK{watermark}; a dropped/out-of-order chunk is re-ACKed at the watermark and retransmitted (Go-Back-N) (FR-FS-4/5, NFR-PERF-2)" +row "DEFERRED-HIL" "F-09: windowed PUT (advertised W=8) over a lossy link advances on cumulative FILE_PUT_ACK{watermark}; a dropped/out-of-order chunk is re-ACKed at the watermark and retransmitted (Go-Back-N) (FR-FS-4/5, NFR-PERF-2)" row "DEFERRED-HIL" "F-09: FILE_PUT_END reports OK ONLY after a whole-file CRC match; a deliberately-corrupted upload returns ECRC and the PRE-EXISTING dest file is byte-intact (temp deleted) (FR-FS-6/14)" row "DEFERRED-HIL" "F-09: temp-write-then-rename atomicity — the dest appears only after fsync + a CRC-gated os.rename; LittleFS rename is atomic across a mid-rename power cut (FR-FS-9)" row "DEFERRED-HIL" "F-09: a 2nd *_BEGIN while a transfer is active returns EBUSY (single-active-transfer); the host task stays responsive — other commands still answer during a GET/PUT (FR-BLE-11)" @@ -185,5 +165,5 @@ if [ "$S5_FAIL" -ne 0 ]; then printf 'G1 S5 slice: %d host criteria FAILING. G1 NOT advanced.\n' "$S5_FAIL" exit 1 fi -printf 'G1 S5 slice: host-runnable checks PASS; S5 payload-semantic conformance is DoR-BLOCKED (protocol.md §5 + specs.md FR-FS mirror + specs.md jail not yet frozen) and the behaviours are DEFERRED-HIL / native coverage DEFERRED-NATIVE (F-20). G1 remains OPEN through S6.\n' +printf 'G1 S5 slice: host-runnable checks PASS; physical behaviours remain DEFERRED-HIL and Unity-on-native coverage remains DEFERRED-NATIVE (F-20). G1 remains OPEN through S6.\n' exit 0 diff --git a/tests/firmware_tests/gates/g1_s6_check.sh b/tests/firmware_tests/gates/g1_s6_check.sh index 34ba7adb..dfe6104a 100755 --- a/tests/firmware_tests/gates/g1_s6_check.sh +++ b/tests/firmware_tests/gates/g1_s6_check.sh @@ -13,32 +13,18 @@ # CUMULATIVE RULE (02_milestones.md / PRD §1B.7): a later gate is never green # while an earlier one regressed, and G1 cannot be SIGNED while any earlier S2-S5 # slice is red. This script FIRST re-runs G0 + the G1 S2 slice and REFUSES to -# sign any S6 item if either regressed; the G1 S3/S4/S5 slices are reported -# INFORMATIONALLY (their native pble_* modules are in flight — an in-flight -# slice's exit code cannot distinguish "regressed" from "not-yet-green", the same -# choice g1_s3/s4/s5_check made). Because S6 CLOSES G1, the sign-off note is +# sign any S6 item if any implemented S2–S5 host slice regressed. Because S6 +# CLOSES G1, the sign-off note is # explicit that G1 stays OPEN until S2-S6 are ALL green on a real-hardware HIL # demo. # -# SDD DoR (spec-freeze precondition). Three POSTURE/SEMANTICS freezes gate S6 and -# are still DRAFT in the working tree (2026-07-01): -# F-10: protocol.md §5 'Resume on reconnect' reads 'full behaviour lands later' -# (the resume_offset FIELD is frozen; the BEHAVIOUR is not) + specs.md -# FR-FS-7 semantics stamp. -# F-12: OI-5 OPEN — protocol.md §7 auto_run caps position + §4 SET_AUTORUN -# opcode number (there is literally NO opcode to encode) + specs.md §4.7 -# FR-BOOT/FR-MODE/NFR-SAFE-3 freeze. -# F-18: protocol.md §10 Security (the LAST DRAFT wire section) + specs.md §9 -# SEC-* freeze. -# No S6 payload/behaviour [red] may be authored until each lands in a [docs] -# commit; the semantic obligations wait, byte-less, in host/conformance/ -# s6_pending.json (fabricating draft wire is forbidden — wire is referenced, -# never redefined). F-11 is EXEMPT from this block: its wire (§5 windowed upload) -# is FROZEN, so the bench is authored NOW. +# The S6 specification prerequisites are now frozen: reconnect resume behavior, +# SET_AUTORUN/auto_run, and the §10 security posture. Their former byte-less +# planning ledger is retained only in the conformance archive. # # A milestone gate is green ONLY on a real-hardware HIL demo. This script verifies -# the HOST-runnable S6 slice and reports HIL items as DEFERRED-HIL, doc-freeze -# preconditions as DEFERRED-DOCS, and native-C unit coverage as DEFERRED-NATIVE +# the HOST-runnable S6 slice and reports HIL items as DEFERRED-HIL and native-C +# unit coverage as DEFERRED-NATIVE # (Unity, arrives F-20). DEFERRED does not fail. # # Exit non-zero if any cumulative earlier signed slice regressed, OR if a host- @@ -78,7 +64,7 @@ for s in S3 S4 S5; do if bash "$FT/gates/g1_${lc}_check.sh" >/dev/null 2>&1; then note "G1 ${s} slice host-runnable checks currently GREEN" else - note "G1 ${s} slice not yet fully green (native pble_* modules in flight) — tracked by gates/g1_${lc}_check.sh; NOT a signed-gate regression. Re-verify green before G1 sign-off (cumulative rule)." + crow FAIL "G1 ${s} slice REGRESSED — current host criteria must pass before S6 can be signed" fi done @@ -87,36 +73,31 @@ hdr "G1.S6.1 SDD precondition — the three S6 [docs] freezes must land before S PROTO="$REPO_ROOT/docs/specifications/protocol.md" SPECS="$REPO_ROOT/docs/specifications/firmware/specs.md" -# F-10: §5 resume BEHAVIOUR. The resume_offset FIELD is §5-frozen already; the -# BEHAVIOUR paragraph still reads 'full behaviour lands later' / 'lands later'. -# Freeze = that DRAFT clause is ABSENT from §5. +# F-10: the historical forward-looking marker must remain absent. if grep -qiE 'full behaviour lands later|behaviour lands later|resume fills it later|lands F-10|lands later' "$PROTO" 2>/dev/null; then - row DEFERRED-DOCS "F-10: protocol.md §5 'Resume on reconnect' BEHAVIOUR FROZEN + specs.md FR-FS-7 semantics stamp (still DRAFT — 'lands later'; project-architect [docs] freeze, firmware-architect mirrors)" + row FAIL "F-10: obsolete pre-freeze resume marker reappeared" else row PASS "F-10: protocol.md §5 resume behaviour FROZEN (no 'lands later' clause)" fi -# F-18: §10 Security is the last DRAFT wire section. Freeze ledger line reads -# '| §10 Security | DRAFT |'. Freeze = §10 no longer marked DRAFT in the ledger. +# F-18: the frozen §10 ledger row must not regress. if grep -qiE '§10 *Security *\| *DRAFT|only §10 \(Security\) DRAFT|§10 \(Security\) remains DRAFT|Security[^|]*\| *DRAFT' "$PROTO" 2>/dev/null; then - row DEFERRED-DOCS "F-18: protocol.md §10 Security FROZEN + specs.md §9 SEC-* stamp (still DRAFT — §10 is the last DRAFT wire section; project-architect stamps §10, firmware-architect mirrors §9)" + row FAIL "F-18: protocol.md §10 Security regressed to a pre-freeze marker" else row PASS "F-18: protocol.md §10 Security FROZEN (ledger no longer marks it DRAFT)" fi -# F-12: OI-5. Freeze = OI-5 closed (protocol owner adds the auto_run cap position -# + SET_AUTORUN opcode number). Anchors: OI-5 marked CLOSED, OR a SET_AUTORUN -# opcode appears in protocol.md §4. Absent today -> DEFERRED. +# F-12: SET_AUTORUN and the closed OI-5 marker are retained frozen anchors. if grep -qiE 'SET_AUTORUN' "$PROTO" 2>/dev/null && grep -qiE 'OI-5.*(CLOSED|✅)' "$SPECS" 2>/dev/null; then row PASS "F-12: OI-5 CLOSED — protocol.md §4 SET_AUTORUN opcode + §7 auto_run cap position frozen; specs.md §4.7 FR-BOOT stamp" else - row DEFERRED-DOCS "F-12: OI-5 auto_run caps position + §4 SET_AUTORUN opcode number (protocol owner, additive §9) + specs.md §4.7 FR-BOOT/FR-MODE/NFR-SAFE-3 stamp (OPEN — HARD F-12 DoR blocker: no opcode number to encode yet)" + row FAIL "F-12: frozen OI-5 SET_AUTORUN/auto_run anchors are missing" fi -note "while the rows above are DEFERRED-DOCS, the S6 semantic obligations stay byte-less in host/conformance/s6_pending.json (_blocked_on ledger). F-11 is EXEMPT — its §5 windowed-upload wire is frozen and the bench is authored now." +note "pre-freeze planning obligations are archived byte-for-byte and are not active requirements" # --- F-10 upload resume (pble_fs — storage-engineer) ------------------------- hdr "G1.S6.2 F-10 upload resume [pble_fs_resume_prefix / pble_fs_on_disconnect — storage-engineer]" -row DEFERRED-DOCS "FILE_PUT_BEGIN on a verified partial temp prefix returns resume_offset>0 (re-CRC prefix, re-seed whole-file CRC, watermark=len); resume after a drop completes byte-identical, no corruption/dup; bad/foreign prefix -> ECRC keeps old file; temp>total -> truncate to 0 (FR-FS-7, NFR-REL-2/3, FR-FS-14) — s6_pending.json:put_begin_resume_offset_verified_prefix,resume_completes_after_drop_no_corruption_no_dup,resume_bad_or_foreign_prefix_ecrc_keeps_old_file" +row "$(run_test test_pyble_fs.sh)" "portable semantic oracle: verified-prefix resume offset, tail completion, foreign-prefix refusal, and old-file preservation (FR-FS-7/14, NFR-REL-2/3)" # --- F-11 multi-file reliability bench (HIL) — firmware-test-author ----------- hdr "G1.S6.3 F-11 multi-file reliability bench (HIL artifact — firmware-test-author, NO firmware [green])" @@ -130,11 +111,11 @@ row DEFERRED-HIL "F-11 EXECUTION on ESP32-S3 (bleak central): N files upload wit # --- F-12 cold-boot safety + opt-in auto-run (pble_boot — runtime-engineer) --- hdr "G1.S6.4 F-12 cold-boot safety + opt-in auto-run [pble_boot — runtime-engineer]" -row DEFERRED-DOCS "cold boot advertises-and-waits, NO auto-run by default (RUN_STATE idle regardless of workspace); auto-run OPT-IN via SET_AUTORUN + auto_run cap, NVS-persisted; a broken/looping /main.py still advertises (worker-only, STOP works); a control-plane fault fails safe to advertising (FR-BOOT-1..6, FR-MODE-1/4, NFR-SAFE-3) — s6_pending.json:cold_boot_advertise_and_wait_no_autorun_default,autorun_optin_flag_persisted_and_surfaced,broken_or_looping_main_still_advertises_failsafe" +row "$(run_test test_pyble_device_config_rp2.sh)" "portable config oracle: autorun defaults off, SET_AUTORUN is validated and persisted, and caps state follows durable configuration (FR-BOOT-1..6, FR-MODE-1/4)" # --- F-18 pairing baseline + single-writer (pble_ble + pble_lock) ------------- hdr "G1.S6.5 F-18 BLE pairing baseline + single-writer [pble_ble_sm_config / pble_lock — ble-transport + runtime-engineer]" -row DEFERRED-DOCS "pairing/encryption ENABLED+USED when the central initiates but NOT access-gating (no per-char ENC flag; bleak/CoreBluetooth still connects); single active writer (2nd same-class RUN/PUT -> EBUSY; W_RUN+W_XFER independent; token frees on disconnect); no MAC/label gating; no telemetry; no PII in the advertisement (SEC-1/2/3/5/7/8/10/11, protocol.md §10) — s6_pending.json:pairing_encryption_active_but_not_access_gating,single_active_writer_serialized_run_and_file_ops,no_mac_gating_no_telemetry_no_pii_adv" +row "$(run_test test_v061_native_hardening.sh)" "native structural/compiled guards retain persistence and isolation hardening; physical pairing, writer concurrency, and privacy posture remain HIL below (SEC-1/2/3/5/7/8/10/11)" # --- Native-C unit coverage (Unity-on-native, arrives F-20) ------------------- hdr "G1.S6.6 Native-C unit coverage — pble_fs/pble_boot/pble_lock (Unity, arrives with F-20)" @@ -143,14 +124,14 @@ row "DEFERRED-NATIVE" "pble_fs_resume_prefix re-CRC/watermark; pble_boot autorun # --- Shared cross-language corpus (firmware <-> Dart pble) -------------------- hdr "G1.S6.7 Shared PBLE/1 corpus — frozen frame-level vectors still verify" row "$(run_test test_pyble_proto.sh)" "corpus.json still byte-verifies (§3/§4/§8 frozen frame codec + CRC + fragmentation)" -note "the one promotable S6 frame-level vector (FILE_PUT_BEGIN RSP with resume_offset!=0) stays in s6_pending.json until the §5-resume [docs] stamp lands — promote it with the S5 §5 frame-level FS batch (see s5_pending.json). No SET_AUTORUN vector exists until OI-5 numbers the opcode." +note "the shared corpus remains a frame-codec fixture; current resume/autorun semantics are covered by named suites without claiming retrospective promotion of every planning vector" # --- No-leak (cumulative clean-room gate) ------------------------------------ -hdr "G1.S6.8 Clean-room no-leak still clean over the test tree (incl. new hil/ + s6_pending.json)" +hdr "G1.S6.8 Clean-room no-leak still clean over the test tree" if grep -rniE "$(forbidden_regex)" "$FT" --include='*.py' --include='*.sh' --include='*.json' >/dev/null 2>&1; then row FAIL "no-leak: forbidden token found in tests/firmware_tests" else - row PASS "no-leak: tests/firmware_tests (incl. hil/f11_reliability_bench.py, _pble_wire.py, _pble_central.py, s6_pending.json) free of forbidden proprietary tokens" + row PASS "no-leak: tests/firmware_tests (including the HIL bench and archived ledgers) free of forbidden proprietary tokens" fi # --- S6 HIL demo items (real esp32 / -s3 / -c3) — the truth for the gate ------ @@ -174,5 +155,5 @@ if [ "$S6_FAIL" -ne 0 ]; then printf 'G1 S6 slice: %d host criteria FAILING. G1 NOT advanced.\n' "$S6_FAIL" exit 1 fi -printf 'G1 S6 slice: host-runnable checks PASS (bench wire matches the shared corpus; corpus intact; no-leak clean). F-10/F-12/F-18 payload+behaviour are DoR-BLOCKED on three [docs] freezes (§5 resume behaviour; §10 Security / §9 SEC; OI-5 auto_run+SET_AUTORUN / §4.7 FR-BOOT) and are DEFERRED-HIL; native coverage is DEFERRED-NATIVE (F-20). F-11 is authored (HIL-execution-ready after F-10 Done). S6 CLOSES G1 — G1 is green ONLY when S2-S6 all pass a real-hardware HIL demo.\n' +printf 'G1 S6 slice: host-runnable checks PASS (bench wire matches the shared corpus; corpus intact; no-leak clean). Physical F-10/F-12/F-18 behavior remains DEFERRED-HIL; native Unity coverage is DEFERRED-NATIVE (F-20). S6 CLOSES G1 — G1 is green ONLY when S2-S6 all pass a real-hardware HIL demo.\n' exit 0 diff --git a/tests/firmware_tests/hil/README.md b/tests/firmware_tests/hil/README.md index 3baf439b..4e191add 100644 --- a/tests/firmware_tests/hil/README.md +++ b/tests/firmware_tests/hil/README.md @@ -8,6 +8,14 @@ firmware-test-author**; none of this ships in the firmware image. A milestone gate is green **only on a real-hardware HIL demo**, not on merged code (PRD §1B.7). These drivers are that demo, made repeatable. +Every valid outbound `CMD`, including no-response `FILE_PUT_DATA`, uses a +request ID in 1–255. Transfer data and controls share a wrapping allocator; +each Go-Back-N resend obtains another valid ID without waiting for a data +`RSP`. ID zero is reserved for `EVT` and intentional malformed-input tests. +Host regression coverage must exercise more than 255 submissions and feed +captured frames through the unchanged native wire-admission decision. These +tool checks do not replace actual candidate/board HIL evidence. + ## Files | File | Role | @@ -20,6 +28,7 @@ code (PRD §1B.7). These drivers are that demo, made repeatable. | `file_roundtrip_bench.py` | Upload/download regression bench for the reported 11.9 KiB stall: consumes HELLO caps, supports an exact canonical `--expect-chip`, and now requires contiguous unique GET offsets plus exact bytes/size/CRC. | | `target_smoke.py` | Target-neutral service/HELLO/DEVICE_INFO/INFO identity smoke. It requires an explicit expected chip, defaults the expected agent to `versions.lock`, and excludes BLE address, device ID, and label from its result line. | | `target_run_stop.py` | Target-neutral busy-loop and print-flood RUN/STOP lifecycle bench. It proves one matching `RSP{OK}` before one idle event in less than 500 ms, then runs a bounded same-link console nonce before file-mode cleanup. | +| `v061_hardening_bench.py` | Exact-profile v0.6.1 physical hardening orchestrator. It executes the seven frozen scenarios in order, including exactly 50 sequential RUNs, validates both separately acquired sacrificial workspace receipts, and writes a candidate-bound canonical private result plus redacted raw log without replacement. | ## Prerequisites (HIL runner only) @@ -28,13 +37,133 @@ python3 -m pip install bleak pyserial ``` Plus a board flashed with the exact firmware candidate under test. v0.6.0 is -one atomic five-profile matrix, in this order: `esp32-4mb`, +the qualified baseline; v0.6.1 repeats one fresh atomic five-profile matrix in +this same order: `esp32-4mb`, `esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, and `rpi-pico2-w`. Run them sequentially when USB capacity is limited; they do not need to be connected simultaneously. A missing profile blocks the whole release. Neither a serial-port name nor a chip family alone proves the required board, flash, PSRAM, or provisioning profile. +## v0.6.1 hardening qualification + +Each exact profile requires one new private result from +`v061_hardening_bench.py`. The live portion proves, in order, +`transport-session`, `fragment-hardening`, `run-isolation`, +`resource-stability`, `stdin-isolation`, `configuration-durability`, and +`filesystem-hardening`. Resource stability is exactly 50 sequential runs. +The transport/session scenario first proves ordinary disconnect/reconnect +negotiation isolation. It then writes a bounded sentinel into volatile VM +state, completes the acknowledged `SOFT_REBOOT` path, reconnects after the +board advertises again, and requires a pre-HELLO `DEVICE_INFO` command to +return `EBADREQ`. Only a new HELLO may reopen command admission, after which a +bounded RUN must prove that the volatile sentinel is absent. This binds the +negotiation-reset observation to a proven VM rotation rather than treating a +BLE reconnect alone as VM-reset evidence. + +The stdin scenario covers idle, terminal, overflow/STOP, disconnect, and VM +reset independently. For the VM-reset boundary it starts an active delayed +input RUN, queues a stale complete line, accepts `SOFT_REBOOT`, reconnects and +negotiates with the new VM, then starts a successor input RUN. The successor +must remain blocked until a fresh post-reset line is sent and must never echo +the queued predecessor line. Host tests can prove this orchestration, but only +executing it on each exact board/candidate supplies physical HIL evidence. + +The filesystem scenario repeats a deterministic 16 KiB GET for each +active-download admission probe. The central records completion of the whole +outbound command write and fails unless that cut precedes `FILE_GET_END`. +Valid `FILE_DELETE`, `MKDIR`, and `FILE_RENAME` commands must then return +`EBUSY` without changing their bench-owned source/destination paths. A +representative malformed mutation must retain `EBADREQ`, a jailed mutation +must retain `EACCES`, and `FILE_LIST` plus `FILE_STAT` must remain available. +Every probe still verifies the complete GET byte stream, offsets, length, and +whole-file CRC. Separate GETs are intentional: the native response pool has +two slots, so accumulating several response-bearing commands behind one GET +would test pool exhaustion instead of transfer serialization. +Configuration durability includes label and autorun persistence/restoration +and preserves an existing Identify configuration across reboot when the +profile advertises Identify. Because PBLE/1 caps do not expose the persisted +active level, the target-neutral bench invokes Identify before and after reboot +but does not rewrite an owner's LED configuration. + +Workspace first-boot behavior occurs before BLE service startup and is +therefore acquired separately on sacrificial media. Before the live run, +retain one candidate-bound canonical receipt/raw log for erased-media LFS2 +creation and one for nonblank incompatible-media refusal. Do not erase or +modify a user's working board to obtain either result. `NOT-RUN`, reused +v0.6.0 evidence, a receipt from another profile, or a configuration-corruption +test cannot qualify v0.6.1. + +The measured-boot amendment of 2026-09-06 requires the actual retained +`pyble_workspace.read_boot_observation(challenge)` response, complete private +pre/post workspace readbacks, and a fresh bounded advertisement watch. The +four-line canonical summary is derived from that acquisition; it cannot be +entered by an operator or inferred from expected source behavior. Success can +be queried through a bounded existing PBLE/1 RUN (including Pico); failure is +queried through USB REPL with no agent advertisement. Both paths only read the +sealed original boot observation and must never rerun the mount helper. +The private receipt and each later hardening/finalization step bind and reopen +all derived acquisition siblings. Detailed fields and fail-closed predicates +are frozen in firmware specs §4.7 and §5.3.4. Changes to the observer require +fresh five-profile build, resource/performance, and physical qualification. + +The host-generated native-USB refusal query paces the same one JSON line in +16-character stdout writes, separated by 50 ms, with a 1,024-byte ASCII ceiling +and bounded pre/post drain intervals. This avoids one-shot FIFO pressure while +leaving DTR/RTS and the original VM boot unchanged. It is not a new receipt +format or a guarantee of delivery: missing bytes, delimiters, nonce, or fields +still fail the existing parser and acquisition. Keep failed raw captures. + +Use `v061_workspace_acquire.py` for the physical acquisition, with a clean +qualification checkout, protected final candidate, reviewed mode-`0600` +private binding JSON, and a new receipt filename in a private directory: + +```sh +python tests/firmware_tests/hil/v061_workspace_acquire.py \ + --candidate-dir /absolute/private/candidate \ + --profile esp32-4mb --kind erased-media-first-boot \ + --binding /absolute/private/esp32-4mb-binding.json \ + --output /absolute/private/esp32-4mb/erased.json \ + --allow-disposable-erase +``` + +This command **erases the selected board**, installs the exact candidate, +and acquires actual measurements; retain an independently verified owner +backup and explicit disposable-media authorization first. Repeat separately +with `--kind nonblank-media-refusal` and a new `nonblank.json` output. The +binding includes exact application/loader USB endpoints and topology, physical +UID/MAC, chip/flash geometry, and reviewed transfer settings. Never guess a +binding from a short advertising suffix. The maintained schema rejects other +fields; private identities must not enter public documentation or Git. + +The macOS adapter uses Bleak `3.0.2`, pyserial `3.5`, esptool `4.12.0` for ESP, +and the exact repository-pinned picotool for Pico. Use a dedicated local +environment and retain its dependency versions; do not mutate the compiler +environment. The collector publishes no passing receipt on interruption or +partial acquisition and never automatically re-erases after failure. It +finishes post-readback in loader mode; returning to normal runtime or another +clean install is a separate, explicitly sequenced qualification operation. +Keep both receipts and all their siblings beside the later hardening result. + +The live runner receives the protected candidate directory, exact profile, +private BLE address, both receipt paths, and new raw-log/output paths. It emits +no passing result unless all seven live scenarios and both pre-service +receipts pass against the same v0.6.1 candidate. The output and log are +exclusive mode-`0600` files and must remain outside Git. The release workflow +passes the result to `create-hil-completion`; the operator input never contains +the derived hardening check or summary. Finalization reopens all five private +results, so copying a result line or editing HIL Markdown is not evidence. + +The runner validates the protected candidate, both receipts and raw siblings, +and the complete committed qualification-code closure before it connects. It +then requires `/v061_hil` and `/main.py` to be absent before making any board +change. Use a controlled qualification board: a collision is a hard refusal, +not permission to delete an owner's files. The invocation also requires the +reviewed manufacturer, model, and module marking for the selected physical +profile. That operator attestation is the exact-board authority because +PBLE/1 intentionally reports chip identity, not a carrier-board or provisioning +profile; the private values never enter result evidence. + For controlled reset samples, select an explicit USB serial adapter for the same board. The orchestrator uses the common ESP development-board wiring: **RTS asserts EN/reset low**, while **DTR remains deasserted** so GPIO0 is not @@ -300,9 +429,9 @@ python3 ../../../firmware/scripts/release_bundle.py \ <private>/esp32-c3-4mb-private-result.json ``` -The workflow-contract host test remains deliberately RED until these two safe -writers land. Do not work around it by hand-authoring a private result, -`profile_gate_summary`, candidate digest, or artifact digest. +These safe writers are the only supported path. Do not work around them by +hand-authoring a private result, `profile_gate_summary`, candidate digest, or +artifact digest. After exactly five fragments exist, create the completed report without editing Markdown: diff --git a/tests/firmware_tests/hil/_pble_bench.py b/tests/firmware_tests/hil/_pble_bench.py index 6c0ec471..80f123a5 100644 --- a/tests/firmware_tests/hil/_pble_bench.py +++ b/tests/firmware_tests/hil/_pble_bench.py @@ -1957,7 +1957,7 @@ async def put_file( accounting.note_send(next_offset, len(piece)) await central.send_cmd_no_rsp( wire.OP_FILE_PUT_DATA, - 0, + next_id(), _u32(next_offset) + piece, ) next_offset += len(piece) diff --git a/tests/firmware_tests/hil/_pble_central.py b/tests/firmware_tests/hil/_pble_central.py index 6282e564..0f90fe50 100644 --- a/tests/firmware_tests/hil/_pble_central.py +++ b/tests/firmware_tests/hil/_pble_central.py @@ -386,23 +386,40 @@ async def send_cmd_no_rsp(self, opcode, id_, payload=b""): response=False, ) - async def send_cmd(self, opcode, id_, payload=b"", timeout=10.0): - """Send a CMD and await the matching RSP (same opcode and ID).""" + async def send_cmd( + self, + opcode, + id_, + payload=b"", + timeout=10.0, + on_written=None, + ): + """Send a CMD and await the matching RSP (same opcode and ID). + + ``on_written`` is an optional synchronous receipt hook for physical + benches that must prove every command fragment was accepted by the BLE + backend before an independently observed event boundary. It is never + called after a partial or failed write. + """ loop = asyncio.get_running_loop() started_at = loop.time() deadline = started_at + timeout self._discard_responses_for_id(id_) + write_completed = False try: await self._write( wire.encode(wire.CMD, opcode, id_, payload), response=True, deadline=deadline, ) + write_completed = True except _CommandDeadlineExpired: # An exact response received while the final acknowledged write # was pending remains authoritative; _await_rsp validates both # sides of its request-start/absolute-deadline arrival window. pass + if write_completed and on_written is not None: + on_written() return await self._await_rsp( opcode, id_, diff --git a/tests/firmware_tests/hil/_pble_wire.py b/tests/firmware_tests/hil/_pble_wire.py index 46a84a1c..bdbbf886 100644 --- a/tests/firmware_tests/hil/_pble_wire.py +++ b/tests/firmware_tests/hil/_pble_wire.py @@ -43,6 +43,7 @@ OP_RUN = 0x20 OP_STOP = 0x21 OP_SOFT_REBOOT = 0x22 +OP_SET_AUTORUN = 0x23 OP_CONSOLE_DATA = 0x30 OP_CONSOLE_INPUT = 0x31 OP_RUN_STATE = 0x40 diff --git a/tests/firmware_tests/hil/_v061_workspace_hardware.py b/tests/firmware_tests/hil/_v061_workspace_hardware.py new file mode 100644 index 00000000..48ee673e --- /dev/null +++ b/tests/firmware_tests/hil/_v061_workspace_hardware.py @@ -0,0 +1,732 @@ +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Exact-target, disposable-board workspace acquisition hardware. + +This module is not a general installer. It has no target discovery fallback, +shell passthrough, write retry, or implicit runtime boot on failure/close. +The collector owns source binding, chronology, and publication. All temporary +hardware inputs and transcripts remain in an ignored private directory. +""" +from __future__ import annotations + +import asyncio +import copy +import hashlib +import importlib.metadata +import importlib.util +import json +import os +from pathlib import Path +import re +import stat +import struct +import subprocess +import tempfile +import threading +import time + + +_ESP = { + "esp32-4mb": ("ESP32", 0, 0x400000, 0x1000, "esp32"), + "esp32-c3-4mb": ("ESP32-C3", 5, 0x400000, 0, "esp32c3"), + "esp32-s3-n16r8": ("ESP32-S3", 9, 0x1000000, 0, "esp32s3"), + "waveshare-esp32-s3-lcd-147b": ("ESP32-S3", 9, 0x1000000, 0, "esp32s3"), +} +_MEDIA = { + "esp32-4mb": (0x200000, 0x200000), + "esp32-c3-4mb": (0x200000, 0x200000), + "esp32-s3-n16r8": (0x210000, 0xDF0000), + "waveshare-esp32-s3-lcd-147b": (0x210000, 0xDF0000), + "rpi-pico2-w": (0x180000, 0x280000), +} +_KINDS = ("erased-media-first-boot", "nonblank-media-refusal") +_USB_KEYS = {"port", "vid", "pid", "serial_number", "location", "product"} + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def _integer(value, low, high): + return type(value) is int and low <= value <= high + + +def _text(value, maximum=128): + return (type(value) is str and 0 < len(value) <= maximum + and all(32 <= ord(char) < 127 for char in value)) + + +def _pico_module(): + path = Path(__file__).with_name("_v061_workspace_pico.py") + spec = importlib.util.spec_from_file_location("pyble_workspace_pico_adapter", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def validate_binding(value): + """Validate explicit private facts without opening any hardware.""" + _require(type(value) is dict and set(value) == { + "schema_version", "profile_id", "device_id", "ble_address", + "application_usb", "loader_usb", "flash", "transfer"}, "binding keys changed") + _require(type(value["schema_version"]) is int and value["schema_version"] == 1, + "binding schema changed") + profile = value["profile_id"] + _require(type(profile) is str and profile in _MEDIA, "unsupported physical profile") + _require(_text(value["device_id"]) and _text(value["ble_address"]), "invalid board identity") + for key in ("application_usb", "loader_usb"): + usb = value[key] + _require(type(usb) is dict and set(usb) == _USB_KEYS, "USB binding keys changed") + _require(_integer(usb["vid"], 1, 65535) and _integer(usb["pid"], 1, 65535), + "invalid USB VID/PID") + _require(usb["serial_number"] is None or _text(usb["serial_number"]), "invalid USB serial") + _require(_text(usb["product"]), "missing USB product") + if profile == "rpi-pico2-w" and key == "loader_usb": + _require(usb["port"] is None and _integer(usb["location"], 1, 0xffffffff), + "Pico loader requires an exact USB location, not a serial endpoint") + else: + _require(type(usb["port"]) is str + and re.fullmatch(r"/dev/cu\.[A-Za-z0-9_-]+", usb["port"]) is not None, + "unsafe serial endpoint") + _require(_text(usb["location"]), "missing exact USB topology") + flash = value["flash"] + transfer = value["transfer"] + if profile == "rpi-pico2-w": + _pico_module().validate_pico_binding_details(value) + else: + _require(type(flash) is dict and set(flash) == { + "chip", "chip_id", "revision", "base_mac", "jedec_id", "size_bytes"}, + "ESP flash identity keys changed") + chip, chip_id, size, _offset, _tool_chip = _ESP[profile] + _require(flash["chip"] == chip and type(flash["chip_id"]) is int + and flash["chip_id"] == chip_id and type(flash["size_bytes"]) is int + and flash["size_bytes"] == size, "wrong profile family or flash geometry") + _require(_integer(flash["revision"], 0, 999) + and _integer(flash["jedec_id"], 1, 0xffffff), "invalid ROM revision or JEDEC ID") + _require(type(flash["base_mac"]) is str + and re.fullmatch(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", flash["base_mac"]) + and value["device_id"] == flash["base_mac"], "wrong exact physical MAC") + # The capacity byte is not replaced by the configured partition size. + _require(1 << ((flash["jedec_id"] >> 16) & 255) == size, + "JEDEC capacity disagrees with admitted flash size") + _require(type(transfer) is dict and set(transfer) == { + "initial_baud", "baud", "ram_block_bytes", "flash_block_bytes"}, + "transport keys changed") + _require(type(transfer["initial_baud"]) is int and transfer["initial_baud"] == 115200 + and type(transfer["baud"]) is int and transfer["baud"] in (115200, 460800), + "unreviewed serial baud") + for field in ("ram_block_bytes", "flash_block_bytes"): + _require(type(transfer[field]) is int and transfer[field] in (64, 1024, 4096), + "unreviewed serial block geometry") + if profile != "waveshare-esp32-s3-lcd-147b": + _require(value["loader_usb"] == value["application_usb"], + "serial bridge identity cannot change across boot") + return copy.deepcopy(value) + + +def nonblank_prefix(block_size): + _require(type(block_size) is int and block_size == 4096, "unsupported workspace block size") + return b"\0" * (2 * block_size) + + +def reconstruct_uf2_readback(image, read): + from _v061_workspace_pico import reconstruct_uf2_readback as reconstruct + return reconstruct(image, read) + + +def _stable_bytes(path, maximum=32 * 1024 * 1024): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, "rb") as stream: + before = os.fstat(stream.fileno()) + _require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 + and 0 <= before.st_size <= maximum, "unsafe hardware input") + raw = stream.read(maximum + 1) + after = os.fstat(stream.fileno()) + current = os.stat(path, follow_symlinks=False) + identity = lambda info: (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns) + _require(identity(before) == identity(after) == identity(current) + and len(raw) == before.st_size, "hardware input changed during read") + return raw + + +def _save(path, raw): + with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600), "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + _require(_stable_bytes(path) == raw, "retained hardware input changed") + + +def _ports(): + from serial.tools import list_ports + return [{"port": item.device, "vid": item.vid, "pid": item.pid, + "serial_number": item.serial_number, "location": item.location, + "product": item.product} for item in list_ports.comports()] + + +def _usb_exact(expected, *, idle=False, absent_ok=False, alternate=None): + ports = _ports() + selected = [item for item in ports if item["port"] == expected["port"]] + if absent_ok and (not selected or (alternate is not None and selected == [alternate])): + return False + _require(selected == [expected], "exact USB endpoint changed") + if idle: + aliases = [item["port"] for item in ports + if all(item[key] == expected[key] for key in _USB_KEYS - {"port"})] + endpoints = [] + for alias in aliases: + endpoints.extend((alias, alias.replace("/dev/cu.", "/dev/tty."))) + result = subprocess.run(["/usr/sbin/lsof", *endpoints], capture_output=True, timeout=10) + _require(result.returncode == 1 and not result.stdout, "USB endpoint or duplicate alias is occupied") + return True + + +class _EspAdapter: + """One retained loader session per phase, with no automatic write retry.""" + + def __init__(self, binding, work_dir, validate_candidate): + self.binding = binding + self.work_dir = work_dir + self.validate_candidate = validate_candidate + self.esp = None + self.cancelled = threading.Event() + self.erased = False + self.closed = False + self.sequence = 0 + + def _check(self): + _require(not self.cancelled.is_set() and not self.closed, "hardware operation was cancelled") + _usb_exact(self.binding["loader_usb"]) + + def _record(self, name, **facts): + self.sequence += 1 + _save(self.work_dir / ("esp-%03d-%s.json" % (self.sequence, name)), + (json.dumps({"event": name, "monotonic_ns": time.monotonic_ns(), **facts}, + sort_keys=True) + "\n").encode()) + + async def _call(self, function, *args): + task = asyncio.create_task(asyncio.to_thread(function, *args)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + self.cancelled.set() + if self.esp is not None: + self.esp._port.close() + # Closing the serial descriptor interrupts reads; never leave a + # detached worker that could continue to the next destructive step. + try: + await asyncio.wait_for(asyncio.shield(task), 15) + except (Exception, asyncio.CancelledError): + pass + raise + + async def loader_present(self): + return await self._call(_usb_exact, self.binding["loader_usb"]) + + def _connect(self): + from esptool.config import load_config_file + from esptool.targets import CHIP_DEFS + _require(importlib.metadata.version("esptool") == "4.12.0" + and importlib.metadata.version("pyserial") == "3.5", "unreviewed serial tool versions") + _require(not any(key.startswith("ESPTOOL_") for key in os.environ) + and load_config_file()[1] is None, "custom esptool configuration is not admitted") + self._check() + _usb_exact(self.binding["loader_usb"], idle=True) + transfer = self.binding["transfer"] + mode = "no_reset" if self.binding["profile_id"] == "waveshare-esp32-s3-lcd-147b" else "default_reset" + # Bind the expected ROM class and retain its open handle before the + # one connection attempt. Automatic chip detection has an internal + # reconnect fallback even when its connect_attempts argument is one. + tool_chip = _ESP[self.binding["profile_id"]][4] + esp = CHIP_DEFS[tool_chip](self.binding["loader_usb"]["port"], transfer["initial_baud"]) + self.esp = esp + esp.connect(mode, attempts=1, detecting=True) + self._check() + _require(not esp.IS_STUB and not esp.sync_stub_detected, "unexpected pre-existing stub") + expected = self.binding["flash"] + if expected["chip_id"] == 0: + # This legacy ROM does not support GET_SECURITY_INFO. Its actual + # magic read, followed by eFuse security checks below, is required. + magic = esp.read_reg(esp.CHIP_DETECT_MAGIC_REG_ADDR) + _require(magic == 0x00F01D83 and esp.IMAGE_CHIP_ID == 0, + "wrong classic ROM magic") + rom_detection = {"magic": magic} + else: + chip_id = esp.get_chip_id() + _require(type(chip_id) is int and chip_id == expected["chip_id"], "wrong actual ROM chip ID") + info = esp.get_security_info() + esp.secure_download_mode = info["parsed_flags"]["SECURE_DOWNLOAD_ENABLE"] + rom_detection = {"chip_id": chip_id} + actual = {"chip": esp.CHIP_NAME, "chip_id": esp.IMAGE_CHIP_ID, + "revision": esp.get_chip_revision(), "base_mac": bytes(esp.read_mac()).hex(":")} + _require(all(actual[key] == expected[key] for key in actual), "wrong ROM chip identity") + _require(not esp.secure_download_mode and not esp.get_secure_boot_enabled() + and not esp.get_flash_encryption_enabled(), "secure target is not disposable-test eligible") + esp._post_connect() + esp.flash_spi_attach(0) + flash_id = esp.flash_id() + _require(flash_id == expected["jedec_id"] + and 1 << ((flash_id >> 16) & 255) == expected["size_bytes"], "physical flash geometry changed") + self._record("rom-identity", **actual, jedec_id=flash_id, + flash_bytes=expected["size_bytes"], security_modes_disabled=True, + actual_rom_detection=rom_detection) + esp.ESP_RAM_BLOCK = transfer["ram_block_bytes"] + self.esp = esp.run_stub() + _require(self.esp.IS_STUB, "RAM stub did not start") + self.esp.flash_set_parameters(expected["size_bytes"]) + self.esp.change_baud(transfer["baud"]) + self.esp.FLASH_WRITE_SIZE = transfer["flash_block_bytes"] + self.esp.WRITE_FLASH_ATTEMPTS = 1 + self._check() + + async def connect(self): + _require(self.esp is None, "loader session already open") + await self._call(self._connect) + + def _erase(self): + self._check() + self.validate_candidate() + self._record("erase-start") + self.esp.erase_flash() + self._check() + size = self.binding["flash"]["size_bytes"] + digest = self.esp.flash_md5sum(0, size).lower() + _require(digest == hashlib.md5(b"\xff" * size).hexdigest(), "complete flash erase was not verified") + self.erased = True + self._record("erase-verified", size_bytes=size, device_md5=digest) + + async def erase(self): + await self._call(self._erase) + + def _write(self, offset, raw, name): + import esptool + self._check() + _require(self.erased, "write requires a verified complete erase in this retained session") + self.validate_candidate() + path = self.work_dir / name + _save(path, raw) + chip = _ESP[self.binding["profile_id"]][4] + argv = ["--chip", chip, "--port", self.binding["loader_usb"]["port"], + "--baud", str(self.binding["transfer"]["baud"]), + "--before", "no_reset_no_sync", "--after", "no_reset_stub", "--no-stub", + "--connect-attempts", "1", "write_flash", "--flash_mode", "keep", + "--flash_freq", "keep", "--flash_size", "keep", "--compress", hex(offset), str(path)] + self._record("write-start", offset=offset, size_bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest(), argv=argv) + port = self.esp._port + self.validate_candidate() + self._check() + _require(_stable_bytes(path) == raw, "private install input changed immediately before write") + esptool.main(argv, esp=self.esp) + _require(self.esp._port is port and port.is_open, "write reconnected or closed the loader") + self._check() + _require(self.esp.flash_md5sum(offset, len(raw)).lower() == hashlib.md5(raw).hexdigest(), + "written bytes failed device MD5 verification") + self._record("write-verified", offset=offset, size_bytes=len(raw)) + + async def write_image(self, raw): + await self._call(self._write, _ESP[self.binding["profile_id"]][3], raw, "firmware-input.bin") + + async def write_prefix(self, offset, raw): + _require(offset == _MEDIA[self.binding["profile_id"]][0] + and raw == nonblank_prefix(4096), "unsafe nonblank precondition write") + await self._call(self._write, offset, raw, "incompatible-prefix.bin") + + def _read(self, offset, size): + self._check() + _require(_integer(offset, 0, self.binding["flash"]["size_bytes"]) + and _integer(size, 1, self.binding["flash"]["size_bytes"] - offset), "flash read outside physical bounds") + before = self.esp.flash_md5sum(offset, size).lower() + last = [time.monotonic()] + def progress(done, total): + _require(not self.cancelled.is_set(), "physical flash read cancelled") + if time.monotonic() - last[0] >= 20 or done == total: + self._check() + print("Workspace physical flash read: %d/%d bytes" % (done, total), flush=True) + last[0] = time.monotonic() + raw = self.esp.read_flash(offset, size, progress) + self._check() + after = self.esp.flash_md5sum(offset, size).lower() + _require(type(raw) is bytes and len(raw) == size + and hashlib.md5(raw).hexdigest() == before == after, + "actual flash read was incomplete or changed during acquisition") + self._record("read-verified", offset=offset, size_bytes=size, + sha256=hashlib.sha256(raw).hexdigest(), device_md5=after) + return raw + + async def read_flash(self, offset, size): + return await self._call(self._read, offset, size) + + async def read_install(self, raw): + return await self.read_flash(_ESP[self.binding["profile_id"]][3], len(raw)) + + def _boot(self): + self._check() + self._record("first-runtime-reset-start") + if self.binding["profile_id"] != "waveshare-esp32-s3-lcd-147b": + self.esp.change_baud(115200) + self.esp.hard_reset() + # One reset only. The bridge remains open with both controls released + # while capturing this very first boot; native USB may disappear. + if self.binding["profile_id"] == "waveshare-esp32-s3-lcd-147b": + self.esp._port.close() + self.esp = None + return b"" + port = self.esp._port + port.dtr = False + port.rts = False + port.baudrate = 115200 + port.timeout = 0.2 + raw = bytearray() + until = time.monotonic() + 6 + while time.monotonic() < until: + raw.extend(port.read(4096)) + _require(len(raw) <= 128 * 1024, "boot transcript exceeded its bound") + port.close() + self.esp = None + return bytes(raw) + + async def boot(self): + return await self._call(self._boot) + + async def close(self): + if self.esp is not None: + self.esp._port.close() + self.esp = None + self.closed = True + + +class WorkspaceHardware: + """Strict prepare → pre-read → boot → observe → post-read lifecycle.""" + + def __init__(self, private_binding, *, repo_root, candidate_dir): + self.binding = validate_binding(private_binding) + self.repo_root = Path(repo_root) + self.candidate_dir = Path(candidate_dir) + self.work_dir = None + self.adapter = None + self.candidate = None + self.image = None + self.kind = None + self.phase = "new" + self._serial = None + self._serial_cancelled = threading.Event() + + def _candidate_bytes(self): + # Reopen the release AND image with the same maintained candidate + # validation immediately before every erase/write. Hash equality alone + # does not excuse a replacement inode or changed release descriptor. + from v061_workspace_acquire import gate + current = gate._candidate_snapshot(self.candidate_dir, self.binding["profile_id"]) + _require(all(current[key] == self.candidate[key] for key in current), "candidate snapshot changed") + raw = _stable_bytes(current["artifact"]) + _require(hashlib.sha256(raw).hexdigest() == current["install_sha256"], "candidate bytes changed") + if self.image is not None: + _require(raw == self.image, "candidate changed after preparation began") + if self.binding["profile_id"] in _ESP: + release = json.loads(_stable_bytes(self.candidate_dir / "release.json")) + matches = [row for row in release["profiles"] if row["id"] == self.binding["profile_id"]] + _require(len(matches) == 1 and type(matches[0]["install"].get("offset")) is int + and matches[0]["install"]["offset"] == _ESP[self.binding["profile_id"]][3], + "candidate install offset changed") + return raw + + def _validate_esp_image(self, raw): + profile = self.binding["profile_id"] + _chip, chip_id, _size, base, _tool = _ESP[profile] + start, _length = _MEDIA[profile] + _require(0x10000 < len(raw) < start - base and raw[0] == 0xe9 + and int.from_bytes(raw[12:14], "little") == chip_id, "candidate image family or footprint changed") + table = raw[0x8000 - base:0x8c00 - base] + entries = [] + for offset in range(0, len(table), 32): + entry = table[offset:offset + 32] + if entry[:2] == b"\xaa\x50": + _magic, kind, subtype, address, size, label, flags = struct.unpack("<HBBII16sI", entry) + if label.rstrip(b"\0") == b"vfs": + entries.append((kind, subtype, address, size, flags)) + _require(entries == [(1, 0x81, *_MEDIA[profile], 0)], "candidate workspace partition geometry changed") + + def _new_work_dir(self): + from v061_workspace_acquire import gate + local = self.repo_root / "local" + local.mkdir(mode=0o700, exist_ok=True) + chain = gate._open_directory_chain(local, label="private hardware workspace") + try: + self.work_dir = Path(tempfile.mkdtemp(prefix="workspace-hardware-", dir=local)) + gate._verify_directory_chain(chain, "private hardware workspace") + finally: + gate._close_directory_chain(chain) + _save(self.work_dir / "binding.json", (json.dumps(self.binding, sort_keys=True) + "\n").encode()) + print("Retained private hardware evidence: %s" % self.work_dir, flush=True) + + async def _native_loader_entry(self, *, refusal=False): + if self.binding["profile_id"] == "rpi-pico2-w": + if await self.adapter.loader_present(): + return + elif await asyncio.to_thread(_usb_exact, self.binding["loader_usb"], absent_ok=True, + alternate=self.binding["application_usb"]): + return + _usb_exact(self.binding["application_usb"], idle=True) + if refusal: + await self._usb_command("import machine,time\ntime.sleep_ms(350)\nmachine.bootloader()", bootloader=True) + else: + await self._ble_command("import machine,time\ntime.sleep_ms(350)\nmachine.bootloader()", bootloader=True) + until = time.monotonic() + 15 + while time.monotonic() < until: + if self.binding["profile_id"] == "rpi-pico2-w": + if await self.adapter.loader_present(): + return + elif await asyncio.to_thread(_usb_exact, self.binding["loader_usb"], absent_ok=True, + alternate=self.binding["application_usb"]): + return + await asyncio.sleep(0.2) + raise TimeoutError("exact native-USB loader did not appear") + + async def prepare(self, candidate_snapshot, profile_id, kind): + _require(self.phase == "new" and profile_id == self.binding["profile_id"] + and kind in _KINDS, "invalid or repeated hardware preparation") + self.phase = "preparing" + self.candidate = dict(candidate_snapshot) + self.kind = kind + self.image = self._candidate_bytes() + if profile_id in _ESP: + self._validate_esp_image(self.image) + else: + # Validate every UF2 record before any hardware access. + from _pble_bench import _reconstruct_rp2350_uf2 + _require(len(_reconstruct_rp2350_uf2(self.image)) <= _MEDIA[profile_id][0], + "candidate UF2 overlaps workspace") + self._new_work_dir() + if profile_id == "rpi-pico2-w": + from _v061_workspace_pico import PicoAdapter + self.adapter = PicoAdapter(self.binding, repo_root=self.repo_root, work_dir=self.work_dir) + else: + self.adapter = _EspAdapter(self.binding, self.work_dir, self._candidate_bytes) + if profile_id in ("rpi-pico2-w", "waveshare-esp32-s3-lcd-147b"): + await self._native_loader_entry() + await self.adapter.connect() + self._candidate_bytes() + await self.adapter.erase() + self._candidate_bytes() + await self.adapter.write_image(self.image) + actual = await self.adapter.read_install(self.image) + _require(type(actual) is bytes and actual == self.image, "physical installed candidate differs") + if kind == _KINDS[1]: + self._candidate_bytes() + await self.adapter.write_prefix(_MEDIA[profile_id][0], nonblank_prefix(4096)) + self.phase = "prepared" + return {key: self.binding[key] for key in ("device_id", "ble_address")}, actual + + async def read_media(self, offset, size): + _require((offset, size) == _MEDIA[self.binding["profile_id"]], "wrong full workspace read geometry") + _require(self.phase in ("prepared", "observed"), "workspace read is out of order") + post = self.phase == "observed" + self.phase = "post-reading" if post else "pre-reading" + if post: + if self.binding["profile_id"] in ("rpi-pico2-w", "waveshare-esp32-s3-lcd-147b"): + await self._native_loader_entry(refusal=self.kind == _KINDS[1]) + await self.adapter.connect() + raw = await self.adapter.read_flash(offset, size) + _require(type(raw) is bytes and len(raw) == size, "incomplete workspace physical read") + self.phase = "post-read" if post else "pre-read" + return raw + + async def boot(self): + _require(self.phase == "pre-read", "first boot requires a complete prepared pre-read") + self.phase = "booting" + raw = await self.adapter.boot() + raw = b"" if raw is None else raw + _require(type(raw) is bytes, "boot capture is not raw bytes") + _save(self.work_dir / "first-boot.bin", raw) + self.phase = "booted" + return raw + + def _identity_source(self): + return "import machine\nprint('\\nPYBLE_PHYSICAL_UID:'+machine.unique_id().hex())" + + def _check_identity(self, raw): + lines = [line[len(b"PYBLE_PHYSICAL_UID:"):] for line in raw.splitlines() + if line.startswith(b"PYBLE_PHYSICAL_UID:")] + expected = self.binding["device_id"].replace(":", "").encode("ascii") + _require(lines == [expected], "runtime connection is not the bound physical board") + + async def _ble_command(self, source, *, bootloader=False): + from _pble_central import PbleCentral, rsp_status + import _pble_wire as wire + from target_smoke import parse_caps, validate_caps + _require(importlib.metadata.version("bleak") == "3.0.2", "unreviewed BLE tool version") + _usb_exact(self.binding["application_usb"]) + central = await PbleCentral.connect(self.binding["ble_address"], timeout=25) + failure = None + async def run(code, command_id): + cursor = len(central.events) + response = await central.send_cmd(wire.OP_RUN, command_id, b"\x01" + code.encode()) + _require(rsp_status(response) == wire.ST_OK, "read-only RUN was refused") + until = time.monotonic() + 20 + while time.monotonic() < until: + events = central.events[cursor:] + states = [frame.payload for frame in events if frame.opcode == wire.OP_RUN_STATE] + _require(b"\x03" not in states, "read-only RUN failed") + if b"\x02" in states: + _require(states == [b"\x01", b"\x02"], "unexpected RUN state sequence") + stdout = b"".join(frame.payload[1:] for frame in events + if frame.opcode == wire.OP_CONSOLE_DATA and frame.payload[:1] == b"\0") + stderr = b"".join(frame.payload[1:] for frame in events + if frame.opcode == wire.OP_CONSOLE_DATA and frame.payload[:1] == b"\1") + _require(not stderr and len(stdout) <= 128 * 1024, "RUN stderr or oversized transcript") + return stdout + await asyncio.sleep(0.025) + raise TimeoutError("read-only RUN did not complete") + try: + response = await central.send_cmd(wire.OP_HELLO, 1, + b"proto_versions=1\napp_name=workspace-acquisition\napp_version=1") + _require(rsp_status(response) == wire.ST_OK, "HELLO refused") + caps = parse_caps(response.payload[1:].decode("utf-8", errors="strict")) + profile = self.binding["profile_id"] + target = {"esp32-4mb": "esp32", "esp32-c3-4mb": "esp32-c3", + "esp32-s3-n16r8": "esp32-s3", + "waveshare-esp32-s3-lcd-147b": "esp32-s3"}.get(profile, profile) + validate_caps(caps, target, "0.6.1") + _require(caps["auto_run"] == "0", "runtime autorun is not disabled") + central.confirm_caps_mtu(int(caps["mtu"])) + identity = await run(self._identity_source(), 2) + self._check_identity(identity) + if bootloader: + response = await central.send_cmd(wire.OP_RUN, 3, b"\x01" + source.encode()) + _require(rsp_status(response) == wire.ST_OK, "loader-entry RUN refused") + until = time.monotonic() + 10 + while time.monotonic() < until and central.is_connected: + await asyncio.sleep(0.05) + _require(not central.is_connected, "loader-entry RUN did not disconnect") + return identity + return identity + await run(source, 3) + except BaseException as exc: + failure = exc + raise + finally: + try: + await asyncio.wait_for(central.disconnect(), timeout=5) + _require(not central.is_connected, "BLE teardown left the link connected") + except BaseException as cleanup_error: + if isinstance(failure, asyncio.CancelledError): + raise failure from cleanup_error + raise RuntimeError("BLE teardown did not prove link closure within its bound") from cleanup_error + + def _usb_command_sync(self, source, bootloader): + import serial + _require(importlib.metadata.version("pyserial") == "3.5", "unreviewed REPL tool version") + _usb_exact(self.binding["application_usb"], idle=True) + port = serial.Serial(port=None, baudrate=115200, timeout=0.2, write_timeout=2) + port.dtr = False + port.rts = False + port.port = self.binding["application_usb"]["port"] + self._serial = port + transcript = bytearray() + def until(marker, seconds=12): + output = bytearray() + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + _require(not self._serial_cancelled.is_set(), "REPL operation cancelled") + data = port.read(1) + output.extend(data) + transcript.extend(data) + _require(len(transcript) <= 128 * 1024, "REPL transcript exceeded its bound") + if output.endswith(marker): + return bytes(output) + raise TimeoutError("raw REPL response did not complete") + def execute(code, loader=False): + port.write(code.encode("utf-8")) + port.write(b"\x04") # execute in raw REPL; not a soft reset + acknowledgement = until(b"OK") + _require(acknowledgement == b"OK", "raw REPL did not acknowledge code exactly") + if loader: + return b"" + stdout = until(b"\x04")[:-1] + stderr = until(b"\x04")[:-1] + _require(not stderr, "raw REPL command emitted stderr") + until(b">") + return stdout + try: + port.open() + # Re-enter from either friendly OR a previous raw REPL without + # Ctrl-D in friendly mode (which would create a second VM boot). + port.write(b"\x03\x03\x02\x01") + until(b"raw REPL; CTRL-B to exit\r\n>") + identity = execute(self._identity_source()) + self._check_identity(identity) + execute(source, bootloader) + return bytes(transcript) + finally: + port.close() + self._serial = None + label = "usb-loader" if bootloader else "usb-observation" + _save(self.work_dir / (label + ".bin"), bytes(transcript)) + + async def _usb_command(self, source, *, bootloader=False): + task = asyncio.create_task(asyncio.to_thread(self._usb_command_sync, source, bootloader)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + self._serial_cancelled.set() + if self._serial is not None: + self._serial.close() + try: + await asyncio.wait_for(asyncio.shield(task), 5) + except (Exception, asyncio.CancelledError): + pass + raise + + async def observe(self, challenge, transport): + _require(self.phase == "booted" and type(challenge) is str + and re.fullmatch("[0-9a-f]{32}", challenge), "invalid or repeated boot observation") + expected = "pble-run" if self.kind == _KINDS[0] else "usb-repl" + _require(transport == expected, "observation transport changed") + self.phase = "observing" + # Both values are measured on the board, in insertion order; the host + # neither fabricates JSON nor estimates successful mount/write counts. + probe = "list(os.statvfs('/'))" if transport == "pble-run" else "None" + source = ("import os,json,pyble_workspace\n" + "print('\\nPYBLE_WORKSPACE_OBSERVATION:'+json.dumps({" + "'observation':pyble_workspace.read_boot_observation('%s')," + "'workspace_probe':%s}))" % (challenge, probe)) + if transport == "usb-repl" and self.binding["profile_id"] in ( + "waveshare-esp32-s3-lcd-147b", "rpi-pico2-w"): + # Native CDC may overwrite a full TX FIFO with DTR deasserted. + # Pace the SAME device-generated line, not a new receipt format. + # Requested delays total at most 3400 ms, below the unchanged + # absolute 12-second stdout deadline. A write return is not proof + # of delivery; raw-REPL framing and the final parser still decide. + source = ("import os,json,pyble_workspace,sys,time\n" + "_pyble_line='\\nPYBLE_WORKSPACE_OBSERVATION:'+json.dumps({" + "'observation':pyble_workspace.read_boot_observation('%s')," + "'workspace_probe':None})+'\\n'\n" + "if len(_pyble_line)>1024 or any(ord(c)>127 for c in _pyble_line):\n" + " raise ValueError('workspace observation exceeds ASCII output bound')\n" + "time.sleep_ms(100)\n" + "for _pyble_offset in range(0,len(_pyble_line),16):\n" + " sys.stdout.write(_pyble_line[_pyble_offset:_pyble_offset+16])\n" + " time.sleep_ms(50)\n" + "time.sleep_ms(100)" % challenge) + until = time.monotonic() + 15 + while not _usb_exact(self.binding["application_usb"], absent_ok=True, + alternate=self.binding["loader_usb"]): + _require(time.monotonic() < until, "exact runtime USB did not reappear") + await asyncio.sleep(0.2) + if transport == "pble-run": + raw = await self._ble_command(source) + else: + # Native USB can take a bounded time to re-enumerate; opening it + # with deasserted controls does not request another boot. + raw = await self._usb_command(source) + _save(self.work_dir / "observation.bin", raw) + self.phase = "observed" + return raw + + async def close(self): + if self._serial is not None: + self._serial.close() + self._serial = None + if self.adapter is not None: + await self.adapter.close() + self.phase = "closed" diff --git a/tests/firmware_tests/hil/_v061_workspace_pico.py b/tests/firmware_tests/hil/_v061_workspace_pico.py new file mode 100644 index 00000000..a18e7082 --- /dev/null +++ b/tests/firmware_tests/hil/_v061_workspace_pico.py @@ -0,0 +1,418 @@ +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Exact RP2350 BOOTSEL adapter for physical workspace acquisition. + +This module never enters a running board's bootloader, connects BLE, or opens +the USB REPL. The shared hardware owner performs those separately. Offsets at +this interface are physical-flash-relative; only picotool arguments use XIP +addresses. Closing the adapter never boots or modifies the board. +""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import stat +import time +import tomllib + +FLASH_BYTES = 0x400000 +WORKSPACE_OFFSET = 0x180000 +XIP_BASE = 0x10000000 +SECTOR_BYTES = 4096 +_USB_KEYS = {"port", "vid", "pid", "serial_number", "location", "product"} + + +def _require(condition, message): + if not condition: + raise ValueError(message) + + +def validate_pico_binding_details(value): + """Reject identity/geometry substitutions without hardware access.""" + _require(type(value) is dict and value.get("profile_id") == "rpi-pico2-w", + "Pico binding profile is invalid") + _require(type(value.get("schema_version")) is int and value["schema_version"] == 1, + "Pico binding schema is invalid") + uid = value.get("device_id") + _require(type(uid) is str and re.fullmatch(r"[0-9a-f]{16}", uid), "Pico UID is invalid") + _require(type(value.get("ble_address")) is str and 0 < len(value["ble_address"]) <= 128, + "Pico BLE binding is invalid") + flash = value.get("flash") + _require(type(flash) is dict and set(flash) == { + "chip", "revision", "package", "unique_id", "family_id", "size_bytes", "secure_boot"}, + "Pico flash binding fields are invalid") + _require(flash["chip"] == "RP2350" and flash["revision"] == "A2" + and flash["package"] == "QFN60" and flash["unique_id"] == uid, + "Pico chip identity is invalid") + _require(type(flash["size_bytes"]) is int and flash["size_bytes"] == FLASH_BYTES + and type(flash["family_id"]) is int and flash["family_id"] == 0xE48BFF59 + and flash["secure_boot"] is False, "Pico geometry or security is invalid") + application, loader = value.get("application_usb"), value.get("loader_usb") + for usb in (application, loader): + _require(type(usb) is dict and set(usb) == _USB_KEYS, "Pico USB binding fields are invalid") + _require(type(usb["vid"]) is int and usb["vid"] == 0x2E8A + and type(usb["pid"]) is int, "Pico USB vendor/product is invalid") + _require(loader["pid"] == 15 and loader["port"] is None + and loader["serial_number"] == uid.upper() and loader["product"] == "RP2350 Boot" + and type(loader["location"]) is int and 0 < loader["location"] <= 0xFFFFFFFF, + "Pico BOOTSEL identity is invalid") + _require(application["pid"] == 5 and application["serial_number"] == uid + and type(application["port"]) is str + and re.fullmatch(r"/dev/cu\.[A-Za-z0-9_.-]+", application["port"]) + and type(application["location"]) is str and 0 < len(application["location"]) <= 128 + and type(application["product"]) is str and 0 < len(application["product"]) <= 128, + "Pico application USB identity is invalid") + _require(value.get("transfer") == {"kind": "picotool"}, "Pico transfer policy is invalid") + return copy.deepcopy(value) + + +def pico_loader_present(raw, binding): + """Interpret real macOS ioreg -a -l output, including dictionary roots.""" + cfg = validate_pico_binding_details(binding)["loader_usb"] + _require(type(raw) is bytes and 0 < len(raw) <= 2 * 1024 * 1024, "ioreg response is invalid") + try: + root = plistlib.loads(raw) + except Exception as exc: + raise ValueError("ioreg response is not a plist") from exc + pending = [(root, 0)] + records = [] + count = 0 + while pending: + node, depth = pending.pop() + count += 1 + _require(depth <= 64 and count <= 16384, "ioreg tree exceeds its bound") + if type(node) is list: + pending.extend((item, depth + 1) for item in node) + continue + _require(type(node) is dict, "ioreg tree node is invalid") + if node.get("idVendor") == 0x2E8A and node.get("idProduct") == 15: + serial = node.get("USB Serial Number") + if type(serial) is str and serial.upper() == cfg["serial_number"]: + records.append({"port": None, "vid": node.get("idVendor"), + "pid": node.get("idProduct"), "serial_number": serial, + "location": node.get("locationID"), "product": node.get("USB Product Name")}) + children = node.get("IORegistryEntryChildren", []) + _require(type(children) is list, "ioreg child inventory is invalid") + pending.extend((item, depth + 1) for item in children) + if not records: + return False + _require(len(records) == 1 and records[0] == cfg, "Pico BOOTSEL identity changed or is ambiguous") + return True + + +def parse_pico_device_info(raw, binding): + """Parse only actual Device Information, not candidate program metadata.""" + cfg = validate_pico_binding_details(binding)["flash"] + _require(type(raw) is bytes and len(raw) <= 65536, "picotool device response is invalid") + try: + lines = raw.decode("utf-8", errors="strict").splitlines() + except UnicodeError as exc: + raise ValueError("picotool device response is not UTF-8") from exc + starts = [index for index, line in enumerate(lines) if line == "Device Information"] + _require(len(starts) == 1, "picotool device section is missing or ambiguous") + fields = {} + for line in lines[starts[0] + 1:]: + if line and not line[0].isspace(): + break + if ":" not in line: + continue + key, value = (part.strip() for part in line.split(":", 1)) + _require(key not in fields, "duplicate picotool device fact") + fields[key] = value + expected = {"type": cfg["chip"], "revision": cfg["revision"], "package": cfg["package"], + "chipid": "0x" + cfg["unique_id"], "flash size": "4096K", "secure boot": "0"} + _require(all(fields.get(key) == value for key, value in expected.items()), + "Pico chip, UID, revision, capacity or security changed") + return {key: cfg[key] for key in ("chip", "revision", "package", "unique_id", "size_bytes", "secure_boot")} + + +def pico_read_argv(offset, size, path): + _require(type(offset) is int and type(size) is int and 0 <= offset < FLASH_BYTES + and 0 < size <= FLASH_BYTES - offset, "Pico physical read range is invalid") + return ["save", "-r", hex(XIP_BASE + offset), hex(XIP_BASE + offset + size), + "-v", os.fspath(path), "-t", "bin"] + + +def _payload(image): + # Binding-only finalizers load this module without the HIL import path. + # Runtime UF2 operations run in the source-bound HIL execution closure. + from _pble_bench import BenchError, _reconstruct_rp2350_uf2 + + _require(type(image) is bytes and 0 < len(image) <= (WORKSPACE_OFFSET // 256 + 1) * 512, + "Pico candidate UF2 bytes are invalid") + try: + payload = _reconstruct_rp2350_uf2(image) + except (BenchError, ValueError) as exc: + raise ValueError("Pico candidate UF2 structure is invalid") from exc + _require(0 < len(payload) <= WORKSPACE_OFFSET, "Pico UF2 overlaps the workspace") + return payload + + +def pico_programmed_footprint(image): + payload = _payload(image) + # Pinned picotool 2.3.0 main.cpp:5299-5308 rounds each flash load to 4096 bytes, + # inserting zeros, not FF, after the last partial program sector. + size = (len(payload) + SECTOR_BYTES - 1) // SECTOR_BYTES * SECTOR_BYTES + return payload + b"\0" * (size - len(payload)) + + +def pico_prefix_argv(offset, data, path): + _require(type(offset) is int and offset == WORKSPACE_OFFSET + and type(data) is bytes and data == b"\0" * (2 * SECTOR_BYTES), + "Pico incompatible-media write must be exactly the first two workspace blocks") + return ["load", "-v", os.fspath(path), "-t", "bin", "-o", hex(XIP_BASE + offset)] + + +def reconstruct_uf2_readback(image, read): + """Retain candidate container metadata; replace every loadable data byte.""" + payload = _payload(image) # Complete structural validation precedes all reads. + result = bytearray(image) + for index in range(len(payload) // 256): + actual = read(XIP_BASE + index * 256, 256) + _require(type(actual) is bytes and len(actual) == 256, "Pico payload read is incomplete") + start = (index + 1) * 512 + 32 # Validated first record is the ignored extension. + result[start:start + 256] = actual + return bytes(result) + + +def _regular_bytes(path, maximum): + path = Path(path) + _require(path.is_absolute(), "Pico input path must be absolute") + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + try: + first = os.fstat(descriptor) + _require(stat.S_ISREG(first.st_mode) and first.st_nlink == 1 and first.st_size <= maximum, + "Pico input is not a bounded single-link regular file") + raw = bytearray() + while len(raw) < first.st_size: + chunk = os.read(descriptor, min(1024 * 1024, first.st_size - len(raw))) + _require(chunk, "Pico input read was short") + raw.extend(chunk) + final = os.fstat(descriptor) + visible = path.lstat() + identity = lambda info: (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, + info.st_ctime_ns, info.st_mode, info.st_nlink) + _require(identity(first) == identity(final) == identity(visible), "Pico input changed while read") + return bytes(raw) + finally: + os.close(descriptor) + + +class PicoAdapter: + """One acquisition's exact loader, with retained private command artifacts.""" + + def __init__(self, binding, *, repo_root, work_dir): + self.binding = validate_pico_binding_details(binding) + self.repo_root = Path(repo_root) + self.work_dir = Path(work_dir) + _require(self.repo_root == Path(__file__).resolve().parents[3], "Pico adapter checkout changed") + _require(self.work_dir.is_absolute() and not self.work_dir.is_symlink(), "Pico work directory is invalid") + info = self.work_dir.stat() + _require(stat.S_ISDIR(info.st_mode) and info.st_mode & 0o7777 == 0o700, + "Pico work directory must be private mode0700") + self._directory_identity = (info.st_dev, info.st_ino) + self._directory_fd = os.open(self.work_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + self._counter = 0 + self._connected = self._erased = self._image_written = self._prefix_written = False + self._closed = self._failed = self._booted = False + self._process = None + self._lock_raw = None + self._image = None + self._tool = None + + def _directory_guard(self): + _require(not self._closed, "Pico adapter is closed") + info, visible = os.fstat(self._directory_fd), self.work_dir.lstat() + _require(stat.S_ISDIR(visible.st_mode) and visible.st_mode & 0o7777 == 0o700 + and (info.st_dev, info.st_ino) == (visible.st_dev, visible.st_ino) + == self._directory_identity, "Pico work directory changed") + + def _new_file(self, suffix, raw=b""): + self._directory_guard() + self._counter += 1 + name = "pico-%04d-%s" % (self._counter, suffix) + descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=self._directory_fd) + try: + offset = 0 + while offset < len(raw): + count = os.write(descriptor, raw[offset:]) + _require(count > 0, "Pico private file write was short") + offset += count + os.fsync(descriptor) + finally: + os.close(descriptor) + return self.work_dir / name + + def _tools_guard(self): + _require(not any(key.startswith(("DYLD_", "PICOTOOL_")) for key in os.environ), + "Pico tool environment override is not allowed") + raw = _regular_bytes(self.repo_root / "firmware/versions.lock", 65536) + if self._lock_raw is None: + self._lock_raw = raw + _require(raw == self._lock_raw, "Pico tool lock changed") + pin = tomllib.loads(raw.decode("utf-8"))["picotool"] + _require(pin["version"] == "2.3.0" and pin["executable_path"] == "picotool/picotool" + and pin["bundled_libusb_path"] == "picotool/libusb-1.0.0.dylib", + "Pico tool layout or version changed") + base = self.repo_root / "firmware/.picotool" + for path_key, sha_key in (("executable_path", "executable_sha256"), + ("bundled_libusb_path", "bundled_libusb_sha256")): + path = base / pin[path_key] + _require(path.resolve() == path, "Pico tool path contains a symlink") + _require(hashlib.sha256(_regular_bytes(path, 64 * 1024 * 1024)).hexdigest() == pin[sha_key], + "Pinned Pico tool bytes changed") + self._tool = base / pin["executable_path"] + return pin + + async def _child(self, command, timeout): + """Bounded child; partial output survives timeout/cancellation and close.""" + self._directory_guard() + _require(self._process is None, "Pico command is already active") + self._new_file("command.json", (json.dumps({"argv": command, "monotonic_ns": time.monotonic_ns()}, + sort_keys=True) + "\n").encode()) + output = self._new_file("output.log") + descriptor = os.open(output.name, os.O_WRONLY | os.O_NOFOLLOW, dir_fd=self._directory_fd) + process = None + code = None + try: + process = await asyncio.create_subprocess_exec(*command, stdout=descriptor, + stderr=asyncio.subprocess.STDOUT) + self._process = process + await asyncio.wait_for(process.wait(), timeout=timeout) + code = process.returncode + finally: + if process is not None and process.returncode is None: + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=5) + except asyncio.TimeoutError: + process.kill() + await process.wait() + self._process = None + os.fsync(descriptor) + os.close(descriptor) + self._new_file("command-end.json", (json.dumps({"returncode": code, + "monotonic_ns": time.monotonic_ns()}, sort_keys=True) + "\n").encode()) + raw = _regular_bytes(output, 2 * 1024 * 1024) + _require(code == 0, "Pico command failed; retained private output: " + output.name) + return raw + + async def loader_present(self): + _require(not self._failed and not self._closed, "Pico adapter is not usable") + raw = await self._child(["/usr/sbin/ioreg", "-a", "-l", "-p", "IOUSB"], 10) + return pico_loader_present(raw, self.binding) + + async def _run(self, args, *, input_guard=None): + try: + self._directory_guard() + _require(not self._failed, "Pico adapter has a prior failure") + self._tools_guard() + _require(await self.loader_present(), "Exact Pico BOOTSEL target is absent") + if input_guard is not None: + path, expected = input_guard + _require(_regular_bytes(path, 4 * 1024 * 1024) == expected, "Pico staged input changed") + usb = self.binding["loader_usb"] + command = [str(self._tool), *args, "--ser", usb["serial_number"], + "--vid", hex(usb["vid"]), "--pid", hex(usb["pid"])] + raw = await self._child(command, 180) + self._tools_guard() + if input_guard is not None: + _require(_regular_bytes(path, 4 * 1024 * 1024) == expected, "Pico staged input changed during command") + return raw + except BaseException: + self._failed = True + raise + + async def connect(self): + _require(not self._closed and not self._failed, "Pico adapter is not usable") + self._connected = False + # _run pins executable/dependency bytes. Version is also inspected, + # once, without any device-selection or reset flags. + if self._tool is None: + pin = self._tools_guard() + version = await self._child([str(self._tool), "version"], 10) + _require(version.decode("utf-8").strip() == pin["version_line"], "Pico tool version changed") + self.device_facts = parse_pico_device_info(await self._run(["info", "-a"]), self.binding) + self._connected = True + + def _ready(self): + _require(self._connected and not self._failed and not self._closed, "Pico loader is not connected") + + async def erase(self): + self._ready() + _require(not self._erased and not self._image_written and not self._booted, "Pico erase is one-shot") + await self._run(["erase", "-a"]) + raw = await self.read_flash(0, FLASH_BYTES) + if raw != b"\xff" * FLASH_BYTES: + self._failed = True + raise ValueError("Pico whole-chip erase is not independently verified") + self._erased = True + + async def write_image(self, candidate_uf2): + self._ready() + _require(self._erased and not self._image_written and not self._booted, + "Pico image needs a verified one-shot full erase") + footprint = pico_programmed_footprint(candidate_uf2) + path = self._new_file("candidate.uf2", candidate_uf2) + await self._run(["load", "-v", str(path)], input_guard=(path, candidate_uf2)) + if await self.read_flash(0, len(footprint)) != footprint: + self._failed = True + raise ValueError("Pico actual program and pinned zero-sector padding differ") + self._image = candidate_uf2 + self._image_written = True + + async def write_prefix(self, offset, data): + self._ready() + _require(self._image_written and not self._prefix_written and not self._booted, + "Pico incompatible prefix is one-shot before first boot") + pico_prefix_argv(offset, data, "guard-only") + path = self._new_file("incompatible.bin", data) + await self._run(pico_prefix_argv(offset, data, path), input_guard=(path, data)) + if await self.read_flash(offset, len(data)) != data: + self._failed = True + raise ValueError("Pico incompatible prefix readback differs") + self._prefix_written = True + + async def read_flash(self, offset, size): + self._ready() + pico_read_argv(offset, size, "guard-only") + path = self._new_file("readback.bin") + identity = path.stat() + await self._run(pico_read_argv(offset, size, path)) + raw = _regular_bytes(path, FLASH_BYTES) + final = path.stat() + if len(raw) != size or (identity.st_dev, identity.st_ino) != (final.st_dev, final.st_ino): + self._failed = True + raise ValueError("Pico physical readback is incomplete or was replaced") + return raw + + async def read_install(self, candidate_uf2): + self._ready() + _require(self._image_written and candidate_uf2 == self._image, "Pico candidate changed after load") + payload = _payload(candidate_uf2) + actual = await self.read_flash(0, len(payload)) + return reconstruct_uf2_readback(candidate_uf2, + lambda address, size: actual[address - XIP_BASE:address - XIP_BASE + size]) + + async def boot(self): + self._ready() + _require(self._image_written and not self._booted, "Pico boot is one-shot after image verification") + await self._run(["reboot", "-a"]) + self._connected = False + self._booted = True + + async def close(self): + if self._closed: + return + _require(self._process is None, "Pico command must finish or cancel before close") + os.close(self._directory_fd) + self._closed = True + self._connected = False diff --git a/tests/firmware_tests/hil/f11_reliability_bench.py b/tests/firmware_tests/hil/f11_reliability_bench.py index 7bd605e7..bf66d729 100644 --- a/tests/firmware_tests/hil/f11_reliability_bench.py +++ b/tests/firmware_tests/hil/f11_reliability_bench.py @@ -198,7 +198,7 @@ async def _upload_one(c, path, data, window, chunk, next_id, piece = data[next_off:next_off + chunk] await c.send_cmd_no_rsp( wire.OP_FILE_PUT_DATA, - 0, # DATA is no-RSP; id irrelevant + next_id(), # No RSP, but every CMD still needs a nonzero ID. next_off.to_bytes(4, "little") + piece) next_off += len(piece) diff --git a/tests/firmware_tests/hil/file_roundtrip_bench.py b/tests/firmware_tests/hil/file_roundtrip_bench.py index 06e74017..4a145ca9 100644 --- a/tests/firmware_tests/hil/file_roundtrip_bench.py +++ b/tests/firmware_tests/hil/file_roundtrip_bench.py @@ -31,7 +31,7 @@ import _pble_wire as wire from _pble_central import PbleCentral, rsp_status, status_name -from _pble_bench import DownloadVerifier +from _pble_bench import CommandIds, DownloadVerifier DEST = "/bench_roundtrip.bin" GET_STALL_S = 10.0 # bench-side inactivity ceiling per chunk (diagnostic bound) @@ -53,7 +53,13 @@ def _path_field(path): async def _hello(c, id_): - rsp = await c.send_cmd(wire.OP_HELLO, id_, b"app=bench\nversion=0\n") + rsp = await c.send_cmd( + wire.OP_HELLO, + id_, + b"proto_versions=1\n" + b"app_name=bench\n" + b"app_version=0", + ) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("HELLO refused: %s" % status_name(rsp_status(rsp))) caps = {} @@ -64,7 +70,7 @@ async def _hello(c, id_): return caps -async def _put(c, data, chunk, window): +async def _put(c, data, chunk, window, next_id): """Windowed FILE_PUT sender honoring the ADVERTISED caps window (FR-FS-4). Keeps up to `window` unacknowledged chunks in flight past the cumulative @@ -78,7 +84,7 @@ async def _put(c, data, chunk, window): reporting ack/want on a true stall (the dropped-chunk diagnostic).""" t0 = time.monotonic() rsp = await c.send_cmd( - wire.OP_FILE_PUT_BEGIN, 2, + wire.OP_FILE_PUT_BEGIN, next_id(), _u32(len(data)) + _u32(zlib.crc32(data)) + _path_field(DEST)) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("PUT_BEGIN: %s" % status_name(rsp_status(rsp))) @@ -95,7 +101,7 @@ async def _put(c, data, chunk, window): # never more than `window` unacknowledged chunks in flight (AC-1). while next_off < total and (next_off - c.last_ack) < win_bytes: piece = data[next_off:next_off + chunk] - await c.send_cmd_no_rsp(wire.OP_FILE_PUT_DATA, 0, _u32(next_off) + piece) + await c.send_cmd_no_rsp(wire.OP_FILE_PUT_DATA, next_id(), _u32(next_off) + piece) next_off += len(piece) now = time.monotonic() if c.last_ack > seen_ack: @@ -111,20 +117,20 @@ async def _put(c, data, chunk, window): next_off = c.last_ack last_rewind = now await asyncio.sleep(0.002) - rsp = await c.send_cmd(wire.OP_FILE_PUT_END, 3, _u32(zlib.crc32(data))) + rsp = await c.send_cmd(wire.OP_FILE_PUT_END, next_id(), _u32(zlib.crc32(data))) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("PUT_END: %s" % status_name(rsp_status(rsp))) return time.monotonic() - t0 -async def _get(c, expected): +async def _get(c, expected, next_id): """GET_BEGIN then drain GET_DATA events until GET_END. Deadline-bounded.""" expected = bytes(expected) expect_len = len(expected) verifier = DownloadVerifier(expected) c.events.clear() t0 = time.monotonic() - rsp = await c.send_cmd(wire.OP_FILE_GET_BEGIN, 4, _u32(0) + _path_field(DEST)) + rsp = await c.send_cmd(wire.OP_FILE_GET_BEGIN, next_id(), _u32(0) + _path_field(DEST)) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("GET_BEGIN: %s" % status_name(rsp_status(rsp))) total = int.from_bytes(rsp.payload[1:5], "little") @@ -172,7 +178,8 @@ async def run(args): c = await PbleCentral.connect(args.address) try: - caps = await _hello(c, 1) + ids = CommandIds() + caps = await _hello(c, ids.next()) chip = caps.get("chip", "?") caps_mtu = int(caps.get("mtu", "0") or 0) if caps_mtu: @@ -189,17 +196,17 @@ async def run(args): raise SystemExit("wrong board: chip=%s (expected %s)" % (chip, args.expect_chip)) data = os.urandom(args.size) - up_s = await _put(c, data, chunk, window) + up_s = await _put(c, data, chunk, window, ids.next) print("PUT %6d B in %5.2f s (%6.0f B/s)" % (len(data), up_s, len(data) / up_s)) - got, end_crc, dn_s = await _get(c, data) + got, end_crc, dn_s = await _get(c, data, ids.next) print("GET %6d B in %5.2f s (%6.0f B/s)" % (len(got), dn_s, len(got) / dn_s)) ok = got == data and end_crc == zlib.crc32(data) print("END crc=0x%08x match=%s bytes-equal=%s" % (end_crc, end_crc == zlib.crc32(data), got == data)) # Cleanup: remove the bench artifact from the board. - await c.send_cmd(wire.OP_FILE_DELETE, 5, _path_field(DEST)) + await c.send_cmd(wire.OP_FILE_DELETE, ids.next(), _path_field(DEST)) # Record the negotiated ATT MTU on the final line: it is the link fact the # OI-1 throughput numbers are read against (chunk = mtu - PBLE_CHUNK_OVERHEAD), # so the owner's measurement log captures the MTU the run actually saw. diff --git a/tests/firmware_tests/hil/rp2_run_stop.py b/tests/firmware_tests/hil/rp2_run_stop.py index 66d9d4b2..3824a0a3 100644 --- a/tests/firmware_tests/hil/rp2_run_stop.py +++ b/tests/firmware_tests/hil/rp2_run_stop.py @@ -30,6 +30,7 @@ import _pble_wire as wire from _pble_central import PbleCentral, rsp_status, status_name +from _pble_bench import CommandIds STOP_DEADLINE_S = 0.5 # H6: STOP must land inside this bound STATE_WAIT_S = 5.0 # bench-side ceiling for an expected RUN_STATE @@ -140,23 +141,23 @@ async def run_and_stop(c, central, log, id_, source, hold_s, desc): late_done is None and late_err is None) -async def put_small_file(central, path, data, first_id): +async def put_small_file(central, path, data, next_id): """Minimal single-chunk windowed PUT for a bench fixture file.""" rsp = await central.send_cmd( - wire.OP_FILE_PUT_BEGIN, first_id, + wire.OP_FILE_PUT_BEGIN, next_id(), len(data).to_bytes(4, "little") + (zlib.crc32(data) & 0xFFFFFFFF).to_bytes(4, "little") + len(path.encode()).to_bytes(2, "little") + path.encode()) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("PUT_BEGIN %s: %s" % (path, status_name(rsp_status(rsp)))) central.last_ack = 0 - await central.send_cmd_no_rsp(wire.OP_FILE_PUT_DATA, 0, (0).to_bytes(4, "little") + data) + await central.send_cmd_no_rsp(wire.OP_FILE_PUT_DATA, next_id(), (0).to_bytes(4, "little") + data) deadline = time.monotonic() + DONE_WAIT_S while central.last_ack < len(data): if time.monotonic() > deadline: raise SystemExit("PUT %s stalled: ack=%d want=%d" % (path, central.last_ack, len(data))) await asyncio.sleep(0.01) - rsp = await central.send_cmd(wire.OP_FILE_PUT_END, first_id + 1, + rsp = await central.send_cmd(wire.OP_FILE_PUT_END, next_id(), (zlib.crc32(data) & 0xFFFFFFFF).to_bytes(4, "little")) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("PUT_END %s: %s" % (path, status_name(rsp_status(rsp)))) @@ -173,7 +174,13 @@ async def run(args): log = install_tap(central) try: # HELLO first (§7); adopt the negotiated MTU for fragmentation. - rsp = await central.send_cmd(wire.OP_HELLO, 1, b"app=rp2-run-stop\nversion=0\n") + rsp = await central.send_cmd( + wire.OP_HELLO, + 1, + b"proto_versions=1\n" + b"app_name=rp2-run-stop\n" + b"app_version=0", + ) if rsp_status(rsp) != wire.ST_OK: raise SystemExit("HELLO refused: %s" % status_name(rsp_status(rsp))) caps = {} @@ -210,14 +217,16 @@ async def run(args): "got %d RUN_STATE event(s)" % len(stray)) # D) file-mode RUN of /main.py: same lifecycle, finite script -> done. - await put_small_file(central, MAIN_PATH, MAIN_SOURCE, 40) + file_ids = CommandIds() + await put_small_file(central, MAIN_PATH, MAIN_SOURCE, file_ids.next) cursor = len(log) - rsp = await central.send_cmd(wire.OP_RUN, 42, bytes((0,)) + MAIN_PATH.encode()) + file_run_id = file_ids.next() + rsp = await central.send_cmd(wire.OP_RUN, file_run_id, bytes((0,)) + MAIN_PATH.encode()) c.check("file-mode: RUN %s RSP status is OK" % MAIN_PATH, rsp_status(rsp) == wire.ST_OK, status_name(rsp_status(rsp))) run_i = await wait_run_state(log, cursor, ST_RUNNING, STATE_WAIT_S) if c.check("file-mode: RUN_STATE(running) arrives", run_i is not None): - rsp_i = find_rsp(log, cursor, wire.OP_RUN, 42) + rsp_i = find_rsp(log, cursor, wire.OP_RUN, file_run_id) c.check("file-mode: RSP precedes RUN_STATE(running) on the wire", rsp_i is not None and rsp_i < run_i, "rsp_index=%s running_index=%s" % (rsp_i, run_i)) @@ -225,7 +234,7 @@ async def run(args): c.check("file-mode: finite %s terminates as done" % MAIN_PATH, done_i is not None) # Cleanup the bench fixture so autorun cannot pick it up later. - await central.send_cmd(wire.OP_FILE_DELETE, 43, + await central.send_cmd(wire.OP_FILE_DELETE, file_ids.next(), len(MAIN_PATH.encode()).to_bytes(2, "little") + MAIN_PATH.encode()) diff --git a/tests/firmware_tests/hil/rp2_smoke.py b/tests/firmware_tests/hil/rp2_smoke.py index 81335a5f..05936b87 100644 --- a/tests/firmware_tests/hil/rp2_smoke.py +++ b/tests/firmware_tests/hil/rp2_smoke.py @@ -83,7 +83,13 @@ async def run(args): central = await PbleCentral.connect(args.address) try: # HELLO is the first exchange (§7). RSP = [status:u8] + caps text. - rsp = await central.send_cmd(wire.OP_HELLO, 1, b"app=rp2-smoke\nversion=0\n") + rsp = await central.send_cmd( + wire.OP_HELLO, + 1, + b"proto_versions=1\n" + b"app_name=rp2-smoke\n" + b"app_version=0", + ) c.check("HELLO RSP status is OK ([status]-prefixed)", rsp_status(rsp) == wire.ST_OK, status_name(rsp_status(rsp))) caps = parse_caps(rsp.payload[1:].decode(errors="replace")) diff --git a/tests/firmware_tests/hil/target_run_stop.py b/tests/firmware_tests/hil/target_run_stop.py index 87d40d5e..e04ac012 100644 --- a/tests/firmware_tests/hil/target_run_stop.py +++ b/tests/firmware_tests/hil/target_run_stop.py @@ -15,6 +15,7 @@ import _pble_wire as wire from _pble_central import PbleCentral, rsp_status, status_name +from _pble_bench import CommandIds import target_smoke @@ -346,12 +347,12 @@ async def run_and_stop(checks, central, log, request_id, source, description): ) -async def put_small_file(central, path, data, first_id): +async def put_small_file(central, path, data, next_id): encoded_path = path.encode("utf-8") checksum = zlib.crc32(data) & 0xFFFFFFFF response = await central.send_cmd( wire.OP_FILE_PUT_BEGIN, - first_id, + next_id(), len(data).to_bytes(4, "little") + checksum.to_bytes(4, "little") + len(encoded_path).to_bytes(2, "little") @@ -364,7 +365,7 @@ async def put_small_file(central, path, data, first_id): ) central.last_ack = 0 await central.send_cmd_no_rsp( - wire.OP_FILE_PUT_DATA, 0, (0).to_bytes(4, "little") + data + wire.OP_FILE_PUT_DATA, next_id(), (0).to_bytes(4, "little") + data ) deadline = time.monotonic() + DONE_WAIT_S while central.last_ack < len(data): @@ -372,7 +373,7 @@ async def put_small_file(central, path, data, first_id): raise ValueError("fixture upload stalled") await asyncio.sleep(0.01) response = await central.send_cmd( - wire.OP_FILE_PUT_END, first_id + 1, checksum.to_bytes(4, "little") + wire.OP_FILE_PUT_END, next_id(), checksum.to_bytes(4, "little") ) if rsp_status(response) != wire.ST_OK: raise ValueError("fixture PUT_END failed") @@ -396,7 +397,11 @@ async def run(args): caps = {} try: hello = await central.send_cmd( - wire.OP_HELLO, 1, b"app=target-run-stop\nversion=0\n" + wire.OP_HELLO, + 1, + b"proto_versions=1\n" + b"app_name=target-run-stop\n" + b"app_version=0", ) if rsp_status(hello) != wire.ST_OK: raise ValueError("HELLO was refused") @@ -435,10 +440,11 @@ async def run(args): ] checks.check("idle STOP emits no RUN_STATE", not stray) - await put_small_file(central, FIXTURE_PATH, FIXTURE_SOURCE, 40) + file_ids = CommandIds() + await put_small_file(central, FIXTURE_PATH, FIXTURE_SOURCE, file_ids.next) cursor = len(log) response = await central.send_cmd( - wire.OP_RUN, 42, bytes((0,)) + FIXTURE_PATH.encode("utf-8") + wire.OP_RUN, file_ids.next(), bytes((0,)) + FIXTURE_PATH.encode("utf-8") ) checks.check( "file-mode RUN RSP is OK", @@ -457,7 +463,7 @@ async def run(args): encoded_path = FIXTURE_PATH.encode("utf-8") await central.send_cmd( wire.OP_FILE_DELETE, - 43, + file_ids.next(), len(encoded_path).to_bytes(2, "little") + encoded_path, ) diff --git a/tests/firmware_tests/hil/target_smoke.py b/tests/firmware_tests/hil/target_smoke.py index 86ef8f1f..b35b76ae 100644 --- a/tests/firmware_tests/hil/target_smoke.py +++ b/tests/firmware_tests/hil/target_smoke.py @@ -136,7 +136,11 @@ async def run(args): central = await PbleCentral.connect(args.address) try: hello = await central.send_cmd( - wire.OP_HELLO, 1, b"app=target-smoke\nversion=0\n" + wire.OP_HELLO, + 1, + b"proto_versions=1\n" + b"app_name=target-smoke\n" + b"app_version=0", ) if rsp_status(hello) != wire.ST_OK: raise ValueError("HELLO status is %s" % status_name(rsp_status(hello))) diff --git a/tests/firmware_tests/hil/v061_hardening_bench.py b/tests/firmware_tests/hil/v061_hardening_bench.py new file mode 100644 index 00000000..02fa6e55 --- /dev/null +++ b/tests/firmware_tests/hil/v061_hardening_bench.py @@ -0,0 +1,2135 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Target-neutral physical HIL bench for firmware-v0.6.1 hardening. + +The tool exercises only PBLE/1 behavior amended for v0.6.1. It deliberately +does not claim that host-side checks, a different profile, or a different board +can fill a physical evidence row. BLE addresses, device IDs, labels, console +payloads, and exception details never enter the bounded machine evidence. + +The two pre-service workspace observations are supplied as separately captured +candidate-bound receipts. Only after those receipts and all seven live +scenarios pass does this runner publish one exclusive private result. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +from pathlib import Path +import re +import sys +import time + +import _pble_bench as pble_bench +from _pble_central import PbleCentral, RX_UUID, rsp_status, status_name +import _pble_wire as wire +import target_smoke + + +ROOT = Path(__file__).resolve().parents[3] +POLICY_PATH = ROOT / "firmware" / "qualification" / "oi1-gates.json" +QUALIFICATION_DIR = ROOT / "firmware" / "qualification" +if str(QUALIFICATION_DIR) not in sys.path: + sys.path.insert(0, str(QUALIFICATION_DIR)) +import v061_hardening_release_gate as hardening_gate # noqa: E402 + +PROFILE_ORDER = tuple(pble_bench.PROFILE_ORDER) +PROFILE_CHIPS = dict(pble_bench.PROFILE_CHIPS) +PROFILE_BOARD_IDENTITIES = { + "esp32-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32 development board", + "module_marking": "ESP32-D0WD revision v1.0 (esptool)", + }, + "esp32-s3-n16r8": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-S3 development board", + "module_marking": "ESP32-S3 QFN56 revision v0.1 (esptool)", + }, + "waveshare-esp32-s3-lcd-147b": { + "board_manufacturer": "Waveshare", + "board_model": "ESP32-S3-LCD-1.47B", + "module_marking": "ESP32-S3R8", + }, + "esp32-c3-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-C3 development board", + "module_marking": "ESP32-C3 QFN32 revision v0.4 (esptool)", + }, + "rpi-pico2-w": { + "board_manufacturer": "Raspberry Pi Ltd", + "board_model": "Raspberry Pi Pico 2 W", + "module_marking": "RP2350 + CYW43439", + }, +} +SCENARIO_ORDER = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) + +HELLO_PAYLOAD = ( + b"proto_versions=1\n" + b"app_name=v061-hardening\n" + b"app_version=0" +) +UNSUPPORTED_HELLO_PAYLOAD = ( + b"proto_versions=2\n" + b"app_name=v061-hardening\n" + b"app_version=0" +) + +REASSEMBLY_DEADLINE_S = 5.0 +MALFORMED_BUDGET = 8 +QUIET_S = 0.35 +EVENT_TIMEOUT_S = 12.0 +CONNECT_TIMEOUT_S = 25.0 +RECONNECT_RETRY_S = 35.0 +SEQUENTIAL_RUNS = 50 +CAPACITY_RESERVE = 65536 +ACTIVE_GET_BYTES = 16384 +STDIN_OVERFLOW_BYTES = 400 +STDIN_RESET_WRITE_TIMEOUT_S = 2.0 +STDIN_RESET_RESPONSE_TIMEOUT_S = 2.0 +LABEL_24 = "ทดสอบไทย" + +ST_IDLE = 0 +ST_RUNNING = 1 +ST_DONE = 2 +ST_ERROR = 3 + +WORK_DIR = "/v061_hil" +SOURCE_FILE_ONE = WORK_DIR + "/fresh_one.py" +SOURCE_FILE_TWO = WORK_DIR + "/fresh_two.py" +FS_SENTINEL_PATH = WORK_DIR + "/old_target.bin" +FS_RENAME_PATH = WORK_DIR + "/renamed.bin" +FS_BLOCKED_DIR = WORK_DIR + "/blocked_dir" +FS_CAPACITY_PATH = WORK_DIR + "/capacity.bin" +FS_ACTIVE_GET_PATH = WORK_DIR + "/active_get.bin" +FS_GET_DELETE_PATH = WORK_DIR + "/get_delete.bin" +FS_GET_MKDIR_PATH = WORK_DIR + "/get_mkdir" +FS_GET_RENAME_SOURCE_PATH = WORK_DIR + "/get_rename_source.bin" +FS_GET_RENAME_DEST_PATH = WORK_DIR + "/get_rename_dest.bin" + +SOURCE_SET_MARKER = b"__PYBLE_V061_SOURCE_SET__" +SOURCE_FRESH_MARKER = b"__PYBLE_V061_fresh-source__=1" +FILE_SET_MARKER = b"__PYBLE_V061_FILE_SET__" +FILE_FRESH_MARKER = b"__PYBLE_V061_fresh-file__=1" +STATVFS_MARKER = b"__PYBLE_V061_STATVFS__=" +VM_ROTATION_SENTINEL = "__pyble_v061_vm_rotation__" +VM_ROTATION_SET_MARKER = b"__PYBLE_V061_VM_SENTINEL_SET__" +VM_ROTATION_FRESH_MARKER = b"__PYBLE_V061_VM_SENTINEL_FRESH__=1" + + +class BenchFailure(RuntimeError): + """A deterministic, operator-safe physical-check failure.""" + + +class LiveState: + def __init__(self, args): + self.args = args + self.ids = pble_bench.CommandIds() + self.central = None + self.caps = {} + + +def _require(condition, message): + if not condition: + raise BenchFailure(message) + + +def _status_is(response, expected, operation): + actual = rsp_status(response) + if actual != expected: + raise BenchFailure( + "%s returned %s, expected %s" + % (operation, status_name(actual), status_name(expected)) + ) + return response + + +def _caps_from_hello(response, args): + _status_is(response, wire.ST_OK, "HELLO") + try: + text = response.payload[1:].decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise BenchFailure("HELLO caps are not strict UTF-8") from exc + caps = target_smoke.parse_caps(text) + try: + target_smoke.validate_caps( + caps, + PROFILE_CHIPS[args.profile], + args.expect_agent, + ) + except ValueError as exc: + raise BenchFailure("HELLO caps failed the target contract") from exc + return caps + + +async def _connect(state): + deadline = time.monotonic() + RECONNECT_RETRY_S + while True: + try: + state.central = await PbleCentral.connect( + state.args.address, + timeout=CONNECT_TIMEOUT_S, + ) + return state.central + except Exception as exc: + if time.monotonic() >= deadline: + raise BenchFailure("board did not accept a clean BLE connection") from exc + await asyncio.sleep(0.25) + + +async def _disconnect(state): + central = state.central + state.central = None + if central is None: + return + try: + await central.disconnect() + except Exception: + # The adversarial budget and SOFT_REBOOT cases intentionally make the + # peer close first. A proven disconnected client needs no second fact. + if central.is_connected: + raise + + +async def _best_effort_stop(state): + """Prevent a failed interactive check from leaving user code running.""" + central = state.central + if central is None or not central.is_connected: + return + try: + response = await central.send_cmd( + wire.OP_STOP, + state.ids.next(), + timeout=2.0, + ) + if rsp_status(response) == wire.ST_OK: + await asyncio.sleep(0.05) + except Exception: + pass + + +async def _negotiate(state): + response = await state.central.send_cmd( + wire.OP_HELLO, + state.ids.next(), + HELLO_PAYLOAD, + ) + caps = _caps_from_hello(response, state.args) + try: + state.central.confirm_caps_mtu(int(caps["mtu"])) + except ValueError as exc: + raise BenchFailure("HELLO and backend MTU evidence disagree") from exc + state.caps = caps + return caps + + +async def _reconnect_and_negotiate(state): + await _disconnect(state) + await _connect(state) + return await _negotiate(state) + + +async def _wait_until(predicate, timeout_s, failure): + deadline = time.monotonic() + timeout_s + while True: + if predicate(): + return + if time.monotonic() >= deadline: + raise BenchFailure(failure) + await asyncio.sleep(0.02) + + +async def _wait_disconnected(central, timeout_s=EVENT_TIMEOUT_S): + await _wait_until( + lambda: not central.is_connected, + timeout_s, + "board did not terminate the exact malformed-input session", + ) + + +async def _wait_event(central, cursor, predicate, timeout_s, failure): + deadline = time.monotonic() + timeout_s + while True: + next_cursor, events = central.events_since(cursor) + for event in events: + if predicate(event): + return next_cursor, event + cursor = next_cursor + if time.monotonic() >= deadline: + raise BenchFailure(failure) + await asyncio.sleep(0.02) + + +def _events_after(central, cursor): + _next, events = central.events_since(cursor) + return list(events) + + +def _console_bytes(events, stream=0): + return b"".join( + event.payload[1:] + for event in events + if event.type == wire.EVT + and event.opcode == wire.OP_CONSOLE_DATA + and event.payload + and event.payload[0] == stream + ) + + +def _states(events): + return [ + event.payload[0] + for event in events + if event.type == wire.EVT + and event.opcode == wire.OP_RUN_STATE + and len(event.payload) == 1 + ] + + +async def _wait_program_terminal(central, cursor, timeout_s=EVENT_TIMEOUT_S): + deadline = time.monotonic() + timeout_s + while True: + events = _events_after(central, cursor) + terminal = [state for state in _states(events) if state in (ST_DONE, ST_ERROR)] + if terminal: + _require(len(terminal) == 1, "RUN emitted more than one terminal state") + _require(terminal[0] == ST_DONE, "bounded RUN ended in error") + return events + if time.monotonic() >= deadline: + raise BenchFailure("bounded RUN did not reach a terminal state") + await asyncio.sleep(0.02) + + +async def _run_program(state, mode, data, description): + central = state.central + cursor = central.event_cursor() + response = await central.send_cmd( + wire.OP_RUN, + state.ids.next(), + bytes((mode,)) + bytes(data), + ) + _status_is(response, wire.ST_OK, description + " RUN") + events = await _wait_program_terminal(central, cursor) + states = _states(events) + _require( + states == [ST_RUNNING, ST_DONE], + description + " RUN did not emit exactly running then done", + ) + _require(_console_bytes(events, 1) == b"", description + " emitted stderr") + return _console_bytes(events, 0) + + +async def _set_vm_rotation_sentinel(state): + """Put one bounded marker in volatile VM state before SOFT_REBOOT.""" + source = ( + "import sys\n" + "sys.path.append(%r)\n" + "print(%r)\n" + % (VM_ROTATION_SENTINEL, VM_ROTATION_SET_MARKER.decode("ascii")) + ).encode("ascii") + stdout = await _run_program(state, 1, source, "VM-rotation sentinel setup") + _require( + stdout.count(VM_ROTATION_SET_MARKER) == 1, + "VM-rotation sentinel setup marker was not exact", + ) + + +async def _assert_vm_rotation_sentinel_absent(state): + """Prove that the post-reboot RUN executes in a newly initialized VM.""" + source = ( + "import sys\n" + "print('__PYBLE_V061_VM_SENTINEL_FRESH__=%%d' %% " + "(%r not in sys.path))\n" + % VM_ROTATION_SENTINEL + ).encode("ascii") + stdout = await _run_program(state, 1, source, "post-reboot VM sentinel") + _require( + stdout.count(VM_ROTATION_FRESH_MARKER) == 1, + "SOFT_REBOOT did not prove a fresh VM epoch", + ) + + +async def _wait_console_marker(central, cursor, marker, timeout_s=EVENT_TIMEOUT_S): + deadline = time.monotonic() + timeout_s + while True: + events = _events_after(central, cursor) + if marker in _console_bytes(events, 0): + return events + if ST_ERROR in _states(events) or ST_DONE in _states(events): + raise BenchFailure("interactive RUN terminated before its prompt") + if time.monotonic() >= deadline: + raise BenchFailure("interactive RUN did not emit its prompt") + await asyncio.sleep(0.02) + + +async def _assert_run_still_active(central, cursor, boundary): + await asyncio.sleep(QUIET_S) + states = _states(_events_after(central, cursor)) + _require( + ST_DONE not in states and ST_ERROR not in states and ST_IDLE not in states, + "%s bytes crossed into the successor RUN" % boundary, + ) + + +def _input_source(case, delay_ms=0, block_until_reset=False): + _require(re.fullmatch(r"[a-z-]{1,32}", case) is not None, "invalid input case") + _require( + not (delay_ms and block_until_reset), + "stdin source cannot be delayed and reset-blocked", + ) + prompt = "__PYBLE_V061_STDIN_%s_PROMPT__" % case + echo = "__PYBLE_V061_STDIN_%s_ECHO__=" % case + delay = "" + if block_until_reset: + delay = "while True: pass\n" + elif delay_ms: + delay = ( + "import time\n" + "_v061_deadline=time.ticks_add(time.ticks_ms(),%d)\n" + "while time.ticks_diff(_v061_deadline,time.ticks_ms())>0: pass\n" + % int(delay_ms) + ) + source = ( + "print(%r)\n" + "%s" + "_v061_value=input()\n" + "print(%r+_v061_value)\n" + % (prompt, delay, echo) + ).encode("ascii") + return source, prompt.encode("ascii"), echo.encode("ascii") + + +async def _start_input_run(state, case, delay_ms=0, block_until_reset=False): + source, prompt, echo = _input_source(case, delay_ms, block_until_reset) + cursor = state.central.event_cursor() + response = await state.central.send_cmd( + wire.OP_RUN, + state.ids.next(), + b"\x01" + source, + ) + _status_is(response, wire.ST_OK, case + " stdin RUN") + await _wait_console_marker(state.central, cursor, prompt) + return cursor, echo + + +async def _finish_input_run(state, cursor, echo_prefix, value): + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, + state.ids.next(), + value + b"\n", + ) + events = await _wait_program_terminal(state.central, cursor) + stdout = _console_bytes(events, 0) + _require( + stdout.count(echo_prefix + value) == 1, + "stdin successor did not consume exactly its fresh line", + ) + _require(_console_bytes(events, 1) == b"", "stdin RUN emitted stderr") + return stdout + + +def _assert_stdin_reset_acceptance( + central, + cursor, + echo_prefix, + stale_value, +): + """Prove stale stdin remained unconsumed at accepted reboot RSP{OK}.""" + events = _events_after(central, cursor) + _require( + _states(events) == [ST_RUNNING], + "stdin predecessor was not exclusively running at reset acceptance", + ) + _require( + echo_prefix + stale_value not in _console_bytes(events, 0), + "stdin predecessor consumed stale input before reset acceptance", + ) + + +def _put_begin_payload(path, data_size, checksum): + return ( + int(data_size).to_bytes(4, "little") + + int(checksum).to_bytes(4, "little") + + pble_bench.path_payload(path) + ) + + +async def _begin_put(state, path, data_size, checksum, expected=wire.ST_OK): + response = await state.central.send_cmd( + wire.OP_FILE_PUT_BEGIN, + state.ids.next(), + _put_begin_payload(path, data_size, checksum), + ) + _status_is(response, expected, "FILE_PUT_BEGIN") + if expected != wire.ST_OK: + return None + _require(len(response.payload) == 5, "FILE_PUT_BEGIN omitted resume_offset") + return int.from_bytes(response.payload[1:5], "little") + + +async def _send_put_piece(state, scope, offset, piece, total, valid_offsets): + await state.central.send_cmd_no_rsp( + wire.OP_FILE_PUT_DATA, + state.ids.next(), + int(offset).to_bytes(4, "little") + bytes(piece), + ) + wanted = offset + len(piece) + deadline = time.monotonic() + EVENT_TIMEOUT_S + while True: + try: + watermark = scope.poll( + sent_limit=wanted, + total=total, + valid_offsets=valid_offsets, + ) + except ValueError as exc: + raise BenchFailure("FILE_PUT_ACK was outside the transmitted range") from exc + if watermark >= wanted: + return watermark + if time.monotonic() >= deadline: + raise BenchFailure("FILE_PUT_DATA did not reach its ACK watermark") + await asyncio.sleep(0.01) + + +async def _end_put(state, checksum, expected=wire.ST_OK): + response = await state.central.send_cmd( + wire.OP_FILE_PUT_END, + state.ids.next(), + int(checksum).to_bytes(4, "little"), + ) + _status_is(response, expected, "FILE_PUT_END") + return response + + +async def put_file(state, path, data): + """Upload and stat-verify one small physical fixture with nonzero CMD IDs.""" + data = bytes(data) + checksum = wire.crc32(data) + await pble_bench.remove_if_present(state.central, path, state.ids.next) + resume = await _begin_put(state, path, len(data), checksum) + _require(resume == 0, "fresh fixture unexpectedly resumed a scratch prefix") + scope = state.central.begin_ack_scope(0) + offset = 0 + chunk = int(state.caps["chunk"]) + valid_offsets = {0} + while offset < len(data): + piece = data[offset : offset + chunk] + valid_offsets.add(offset + len(piece)) + offset = await _send_put_piece( + state, + scope, + offset, + piece, + len(data), + valid_offsets, + ) + await _end_put(state, checksum) + response = await state.central.send_cmd( + wire.OP_FILE_STAT, + state.ids.next(), + pble_bench.path_payload(path), + ) + _status_is(response, wire.ST_OK, "FILE_STAT after fixture PUT") + _require(len(response.payload) == 9, "FILE_STAT payload has the wrong length") + _require( + int.from_bytes(response.payload[1:5], "little") == len(data) + and int.from_bytes(response.payload[5:9], "little") == checksum, + "FILE_STAT did not verify the fixture bytes", + ) + + +def parse_list_payload(payload): + payload = bytes(payload) + _require(len(payload) >= 4, "FILE_LIST payload is truncated") + _require(payload[0] == wire.ST_OK, "FILE_LIST status is not OK") + _require(payload[1] in (0, 1), "FILE_LIST more flag is invalid") + count = int.from_bytes(payload[2:4], "little") + offset = 4 + entries = [] + for _index in range(count): + _require(offset + 7 <= len(payload), "FILE_LIST entry header is truncated") + kind = payload[offset] + size = int.from_bytes(payload[offset + 1 : offset + 5], "little") + name_size = int.from_bytes(payload[offset + 5 : offset + 7], "little") + offset += 7 + _require(offset + name_size <= len(payload), "FILE_LIST name is truncated") + try: + name = payload[offset : offset + name_size].decode( + "utf-8", errors="strict" + ) + except UnicodeDecodeError as exc: + raise BenchFailure("FILE_LIST name is not strict UTF-8") from exc + _require(kind in (0, 1), "FILE_LIST entry kind is invalid") + entries.append((kind, size, name)) + offset += name_size + _require(offset == len(payload), "FILE_LIST has trailing bytes") + return payload[1], entries + + +def _profile_thresholds(profile): + try: + policy = json.loads(POLICY_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise BenchFailure("the committed OI-1 resource policy is unreadable") from exc + _require( + tuple(policy.get("profile_order", ())) == PROFILE_ORDER, + "the committed resource policy profile order has drifted", + ) + matches = [entry for entry in policy.get("profiles", ()) if entry.get("profile_id") == profile] + _require(len(matches) == 1, "the resource policy lacks the selected profile") + return dict(matches[0].get("thresholds", {})) + + +def resource_failures(snapshots, thresholds): + failures = [] + floor_map = { + "gc_free_bytes": "gc_free_min_bytes", + "idf_internal_free_bytes": "idf_internal_free_min_bytes", + "idf_internal_largest_block_bytes": "idf_internal_largest_block_min_bytes", + "idf_internal_minimum_free_bytes": "idf_internal_minimum_free_min_bytes", + } + for index, snapshot in enumerate(snapshots): + for observed_key, threshold_key in floor_map.items(): + if threshold_key not in thresholds: + continue + value = snapshot.get(observed_key) + floor = thresholds[threshold_key] + if not isinstance(value, int) or isinstance(value, bool) or value < floor: + failures.append( + "resource floor failed at sample %d for %s" % (index + 1, observed_key) + ) + gc_values = [snapshot.get("gc_free_bytes") for snapshot in snapshots] + if ( + len(gc_values) > 1 + and all(isinstance(value, int) and not isinstance(value, bool) for value in gc_values) + and all(later <= earlier for earlier, later in zip(gc_values, gc_values[1:])) + and any(later < earlier for earlier, later in zip(gc_values, gc_values[1:])) + ): + failures.append("gc_free_bytes has a monotonic downward leak signature") + return failures + + +def format_result(status, profile, caps, workspace): + return ( + "V061 HARDENING %s (profile=%s chip=%s agent=%s scenarios=%d workspace=%s)" + % ( + status, + profile, + caps.get("chip", "?"), + caps.get("agent", "?"), + len(SCENARIO_ORDER), + workspace, + ) + ) + + +def workspace_prerequisite(args): + return ( + "passed" + if getattr(args, "_workspace_receipts_validated", False) + else "pending" + ) + + +def preflight_result_inputs(args): + """Lease the candidate, receipts, and committed runner before BLE work.""" + try: + preflight = hardening_gate.preflight_result_inputs( + candidate_dir=Path(args.candidate_dir), + profile_id=args.profile, + workspace_erased_receipt=Path(args.workspace_erased_receipt), + workspace_nonblank_receipt=Path(args.workspace_nonblank_receipt), + qualification_repo_root=Path(args.qualification_repo_root), + ) + except hardening_gate.QualificationError as exc: + raise BenchFailure("candidate-bound input preflight failed") from exc + args._workspace_receipts_validated = True + return preflight + + +async def preflight_board_workspace(state): + """Refuse any namespace collision before the first board mutation.""" + for path, label in ( + (WORK_DIR, "v0.6.1 scratch root"), + ("/main.py", "owner main.py"), + ): + response = await state.central.send_cmd( + wire.OP_FILE_STAT, + state.ids.next(), + pble_bench.path_payload(path), + ) + _status_is(response, wire.ST_ENOENT, "%s preflight" % label) + _require( + response.payload == bytes((wire.ST_ENOENT,)), + "%s preflight returned malformed evidence" % label, + ) + + +async def run_transport_session(state): + """Prove HELLO-first, unsupported-offer refusal, and reconnect freshness.""" + await _connect(state) + response = await state.central.send_cmd( + wire.OP_DEVICE_INFO, + state.ids.next(), + ) + _status_is(response, wire.ST_EBADREQ, "pre-HELLO DEVICE_INFO") + + response = await state.central.send_cmd( + wire.OP_HELLO, + state.ids.next(), + UNSUPPORTED_HELLO_PAYLOAD, + ) + _status_is(response, wire.ST_EUNSUPPORTED, "unsupported HELLO offer") + await _negotiate(state) + + # A failed repeated HELLO must not revoke the already committed v1 + # negotiation for this exact session. + response = await state.central.send_cmd( + wire.OP_HELLO, + state.ids.next(), + UNSUPPORTED_HELLO_PAYLOAD, + ) + _status_is(response, wire.ST_EUNSUPPORTED, "repeated unsupported HELLO offer") + response = await state.central.send_cmd( + wire.OP_DEVICE_INFO, + state.ids.next(), + ) + _status_is(response, wire.ST_OK, "DEVICE_INFO after failed repeated HELLO") + + await _disconnect(state) + await _connect(state) + response = await state.central.send_cmd( + wire.OP_DEVICE_INFO, + state.ids.next(), + ) + _status_is(response, wire.ST_EBADREQ, "reconnected pre-HELLO DEVICE_INFO") + await _negotiate(state) + + # A BLE reconnect alone cannot prove the independent VM-epoch half of the + # session token. Seed volatile state, take the acknowledged reboot path, + # and require command admission to start closed before the new HELLO. + # This first workspace check is before the reboot so an owner main.py can + # never run through autorun. The orchestrator repeats the read-only check + # after this scenario and before every later board mutation. + await preflight_board_workspace(state) + await _set_vm_rotation_sentinel(state) + expected_name = ( + state.caps.get("label") or "PyBLE-%s" % state.caps["device_id"] + ) + await _soft_reboot_connect_unnegotiated(state, expected_name) + response = await state.central.send_cmd( + wire.OP_DEVICE_INFO, + state.ids.next(), + ) + _status_is( + response, + wire.ST_EBADREQ, + "post-VM-reset pre-HELLO DEVICE_INFO", + ) + await _negotiate(state) + await _assert_vm_rotation_sentinel_absent(state) + + +async def _write_raw_packet(central, packet): + await asyncio.wait_for( + central._client.write_gatt_char(RX_UUID, bytes(packet), response=True), + timeout=EVENT_TIMEOUT_S, + ) + + +async def _write_raw_frame(central, frame): + for packet in wire.fragment(frame, central.mtu): + await _write_raw_packet(central, packet) + + +async def _expect_raw_silence(central, frame, description): + before = central.rx_frames + await _write_raw_frame(central, frame) + await asyncio.sleep(QUIET_S) + _require(central.rx_frames == before, description + " was not reply-silent") + + +async def run_fragment_hardening(state): + """Exercise TYPE/ID rejection, ECRC, expiry, and the exact budget.""" + central = state.central + + # A valid inbound RSP has the wrong TYPE and a valid CMD with ID zero has + # invalid correlation. Both are silently discarded physical RX writes; + # _write_raw_packet performs the real backend write_gatt_char operation. + await _expect_raw_silence( + central, + wire.encode(wire.RSP, wire.OP_DEVICE_INFO, 71, b"\x00"), + "wrong-direction frame", + ) + await _expect_raw_silence( + central, + wire.encode(wire.CMD, wire.OP_DEVICE_INFO, 0, b""), + "zero-ID frame", + ) + + cursor = central.event_cursor() + bad_crc = bytearray(wire.encode(wire.CMD, wire.OP_DEVICE_INFO, 72, b"")) + bad_crc[-1] ^= 0x80 + await _write_raw_frame(central, bad_crc) + _cursor, error = await _wait_event( + central, + cursor, + lambda event: event.type == wire.EVT + and event.opcode == wire.OP_DEVICE_INFO + and event.id == 0 + and event.payload == bytes((wire.ST_ECRC,)), + EVENT_TIMEOUT_S, + "CRC corruption did not emit the frozen ECRC event", + ) + _require(error.payload == bytes((wire.ST_ECRC,)), "ECRC payload drifted") + + # Progress at t<5000 ms must not extend the FIRST fragment's absolute + # REASSEMBLY_DEADLINE_S. The expired LAST is one discarded run. + frame = wire.encode(wire.CMD, wire.OP_DEVICE_INFO, 73, b"") + before = central.rx_frames + started = time.monotonic() + await _write_raw_packet(central, bytes((0x80,)) + frame[:3]) + await asyncio.sleep(REASSEMBLY_DEADLINE_S - 0.75) + _require( + time.monotonic() - started < REASSEMBLY_DEADLINE_S, + "host scheduling missed the pre-deadline fragment observation", + ) + await _write_raw_packet(central, bytes((0x01,)) + frame[3:6]) + remaining = REASSEMBLY_DEADLINE_S + 0.10 - (time.monotonic() - started) + if remaining > 0: + await asyncio.sleep(remaining) + await _write_raw_packet(central, bytes((0x42,)) + frame[6:]) + await asyncio.sleep(QUIET_S) + _require(central.rx_frames == before, "expired fragment run produced a response") + response = await central.send_cmd(wire.OP_DEVICE_INFO, state.ids.next()) + _status_is(response, wire.ST_OK, "post-expiry DEVICE_INFO") + + # Use a clean session so the MALFORMED_BUDGET count is exact. Seven + # wrong-direction frames stay connected and silent; number eight closes. + await _reconnect_and_negotiate(state) + central = state.central + for index in range(MALFORMED_BUDGET - 1): + await _expect_raw_silence( + central, + wire.encode(wire.RSP, wire.OP_DEVICE_INFO, 80 + index, b"\x00"), + "pre-limit wrong-direction frame", + ) + _require(central.is_connected, "session closed before the eighth violation") + try: + await _write_raw_frame( + central, + wire.encode(wire.RSP, wire.OP_DEVICE_INFO, 87, b"\x00"), + ) + except Exception: + if central.is_connected: + raise + await _wait_disconnected(central) + state.central = None + + # Only a new connection clears the budget; its HELLO proves a clean + # successor rather than treating local disconnect state as recovery. + await _connect(state) + await _negotiate(state) + + +async def run_fresh_globals(state): + """Prove fresh globals independently for source and file RUN modes.""" + # _run_program submits the physical wire.OP_RUN for both modes below. + source_one = ( + b"_pyble_v061_source_global=1\n" + b"print('__PYBLE_V061_SOURCE_SET__')\n" + ) + source_two = ( + b"print('__PYBLE_V061_fresh-source__=%d'%" + b"('_pyble_v061_source_global' not in globals()))\n" + ) + stdout = await _run_program(state, 1, source_one, "fresh-source setup") + _require(stdout.count(SOURCE_SET_MARKER) == 1, "source setup marker was not exact") + stdout = await _run_program(state, 1, source_two, "fresh-source check") + _require(stdout.count(SOURCE_FRESH_MARKER) == 1, "source globals leaked across RUN") + + await pble_bench.ensure_directory(state.central, WORK_DIR, state.ids.next) + file_one = ( + b"_pyble_v061_file_global=1\n" + b"print('__PYBLE_V061_FILE_SET__')\n" + ) + file_two = ( + b"print('__PYBLE_V061_fresh-file__=%d'%" + b"('_pyble_v061_file_global' not in globals()))\n" + ) + try: + await put_file(state, SOURCE_FILE_ONE, file_one) + await put_file(state, SOURCE_FILE_TWO, file_two) + stdout = await _run_program( + state, 0, SOURCE_FILE_ONE.encode("utf-8"), "fresh-file setup" + ) + _require(stdout.count(FILE_SET_MARKER) == 1, "file setup marker was not exact") + stdout = await _run_program( + state, 0, SOURCE_FILE_TWO.encode("utf-8"), "fresh-file check" + ) + _require(stdout.count(FILE_FRESH_MARKER) == 1, "file globals leaked across RUN") + finally: + await pble_bench.remove_if_present( + state.central, SOURCE_FILE_ONE, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, SOURCE_FILE_TWO, state.ids.next + ) + + +async def run_resource_stability(state): + """Run exactly SEQUENTIAL_RUNS bounded probes against existing floors.""" + thresholds = _profile_thresholds(state.args.profile) + _require("gc_free_min_bytes" in thresholds, "resource policy lacks the GC floor") + snapshots = [] + profile_token = re.sub(r"[^0-9A-Za-z]", "", state.args.profile) + for index in range(SEQUENTIAL_RUNS): + nonce = "v061%s%02d" % (profile_token, index) + if state.args.profile == "rpi-pico2-w": + snapshot = await pble_bench.run_rp2_heap_probe( + state.central, + state.ids.next, + nonce=nonce, + ) + else: + snapshot = await pble_bench.run_heap_probe( + state.central, + state.ids.next, + nonce=nonce, + ) + snapshots.append(snapshot) + failures = resource_failures(snapshots, thresholds) + _require(not failures, failures[0] if failures else "resource stability failed") + + +async def run_stdin_isolation(state): + """Prove idle/terminal/STOP/disconnect/VM-reset stdin boundaries.""" + # idle: pre-RUN input must be discarded; only input after the prompt wins. + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, state.ids.next(), b"idle-stale\n" + ) + cursor, echo = await _start_input_run(state, "idle") + await _assert_run_still_active(state.central, cursor, "idle") + await _finish_input_run(state, cursor, echo, b"idle-fresh") + + # terminal: a second queued line must be cleared when the first line ends + # the program, so it cannot satisfy the successor's prompt. + cursor, echo = await _start_input_run(state, "terminal-one") + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, + state.ids.next(), + b"terminal-good\nterminal-stale\n", + ) + events = await _wait_program_terminal(state.central, cursor) + _require( + _console_bytes(events, 0).count(echo + b"terminal-good") == 1, + "terminal setup did not consume its first line", + ) + cursor, echo = await _start_input_run(state, "terminal-two") + await _assert_run_still_active(state.central, cursor, "terminal") + await _finish_input_run(state, cursor, echo, b"terminal-fresh") + + # overflow + STOP: exceed the 256-byte ring while the VM is busy, then STOP + # must clear both retained prefix and overflow residue before a successor. + cursor, _echo = await _start_input_run(state, "overflow", delay_ms=3000) + first = b"x" * (STDIN_OVERFLOW_BYTES // 2) + second = b"y" * (STDIN_OVERFLOW_BYTES - len(first)) + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, state.ids.next(), first + ) + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, state.ids.next(), second + ) + stop_cursor = state.central.event_cursor() + response = await state.central.send_cmd(wire.OP_STOP, state.ids.next()) + _status_is(response, wire.ST_OK, "overflow STOP") + await _wait_event( + state.central, + stop_cursor, + lambda event: event.type == wire.EVT + and event.opcode == wire.OP_RUN_STATE + and event.payload == bytes((ST_IDLE,)), + EVENT_TIMEOUT_S, + "accepted STOP did not emit idle", + ) + cursor, echo = await _start_input_run(state, "stop-successor") + await _assert_run_still_active(state.central, cursor, "STOP/overflow") + await _finish_input_run(state, cursor, echo, b"stop-fresh") + + # disconnect: the program deliberately cannot call input() until after the + # old link is gone. A newly negotiated client feeds that same live RUN. + cursor, _echo = await _start_input_run(state, "disconnect", delay_ms=5000) + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, state.ids.next(), b"disconnect-stale\n" + ) + await _disconnect(state) + await _connect(state) + await _negotiate(state) + successor_cursor = state.central.event_cursor() + await asyncio.sleep(QUIET_S) + await state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, state.ids.next(), b"disconnect-fresh\n" + ) + await _wait_console_marker( + state.central, + successor_cursor, + b"__PYBLE_V061_STDIN_disconnect_ECHO__=disconnect-fresh", + EVENT_TIMEOUT_S, + ) + await _wait_event( + state.central, + successor_cursor, + lambda event: event.type == wire.EVT + and event.opcode == wire.OP_RUN_STATE + and event.payload == bytes((ST_DONE,)), + EVENT_TIMEOUT_S, + "disconnect-cleared RUN did not finish", + ) + stdout = _console_bytes(_events_after(state.central, successor_cursor), 0) + _require(b"disconnect-stale" not in stdout, "disconnect retained stale stdin") + + # VM reset: queue a complete line while an active RUN is deliberately busy. + # An acknowledged SOFT_REBOOT must destroy that queue. The new-VM RUN must + # remain blocked until it receives a fresh post-reset line. + cursor, echo = await _start_input_run( + state, + "soft-reboot", + block_until_reset=True, + ) + stale_value = b"reboot-stale" + try: + await asyncio.wait_for( + state.central.send_cmd_no_rsp( + wire.OP_CONSOLE_INPUT, + state.ids.next(), + stale_value + b"\n", + ), + timeout=STDIN_RESET_WRITE_TIMEOUT_S, + ) + except asyncio.TimeoutError as exc: + raise BenchFailure("stale stdin write exceeded its reset proof bound") from exc + await _assert_run_still_active(state.central, cursor, "pre-reset stale stdin") + expected_name = ( + state.caps.get("label") or "PyBLE-%s" % state.caps["device_id"] + ) + await _soft_reboot_connect_unnegotiated( + state, + expected_name, + response_timeout_s=STDIN_RESET_RESPONSE_TIMEOUT_S, + on_accepted=lambda central: _assert_stdin_reset_acceptance( + central, + cursor, + echo, + stale_value, + ), + ) + await _negotiate(state) + cursor, echo = await _start_input_run(state, "reboot-successor") + await _assert_run_still_active(state.central, cursor, "SOFT_REBOOT/VM reset") + stdout = await _finish_input_run(state, cursor, echo, b"reboot-fresh") + _require(b"reboot-stale" not in stdout, "VM reset retained stale stdin") + + +async def _device_info(state): + response = await state.central.send_cmd( + wire.OP_DEVICE_INFO, + state.ids.next(), + ) + _status_is(response, wire.ST_OK, "DEVICE_INFO") + try: + caps = target_smoke.parse_caps( + response.payload[1:].decode("utf-8", errors="strict") + ) + target_smoke.validate_caps( + caps, + PROFILE_CHIPS[state.args.profile], + state.args.expect_agent, + ) + except (UnicodeDecodeError, ValueError) as exc: + raise BenchFailure("DEVICE_INFO caps failed validation") from exc + return caps + + +async def _set_label(state, payload, expected): + response = await state.central.send_cmd( + wire.OP_SET_LABEL, + state.ids.next(), + payload, + ) + return _status_is(response, expected, "SET_LABEL") + + +async def _wait_advertisement(state, expected_name): + deadline = time.monotonic() + RECONNECT_RETRY_S + while time.monotonic() < deadline: + for address, name in await PbleCentral.scan(timeout=2.0): + if address == state.args.address and name == expected_name: + return + await asyncio.sleep(0.10) + raise BenchFailure("expected persisted advertisement was not observed") + + +async def _soft_reboot_connect_unnegotiated( + state, + expected_name, + *, + response_timeout_s=10.0, + on_accepted=None, +): + """Take acknowledged SOFT_REBOOT to a connected, unnegotiated successor.""" + central = state.central + response = await central.send_cmd( + wire.OP_SOFT_REBOOT, + state.ids.next(), + timeout=response_timeout_s, + ) + _status_is(response, wire.ST_OK, "SOFT_REBOOT") + if on_accepted is not None: + on_accepted(central) + await _wait_disconnected(central) + state.central = None + await _wait_advertisement(state, expected_name) + await _connect(state) + return state.central + + +async def _soft_reboot_to_advertisement(state, expected_name): + await _soft_reboot_connect_unnegotiated(state, expected_name) + return await _negotiate(state) + + +async def run_label_durability(state): + """Prove label, autorun, and owner-configured Identify durability.""" + # _set_label and _soft_reboot_to_advertisement issue wire.OP_SET_LABEL and + # wire.OP_SOFT_REBOOT; keeping them transactional lets restoration share + # the same exact physical path as the assertion. + original_caps = await _device_info(state) + original_label = original_caps["label"] + original_name = original_label or "PyBLE-%s" % original_caps["device_id"] + _require( + original_caps.get("auto_run") in ("0", "1"), + "DEVICE_INFO auto_run is not a bit", + ) + _require( + original_caps.get("has_identify") in ("0", "1"), + "DEVICE_INFO has_identify is not a bit", + ) + original_auto_run = int(original_caps["auto_run"]) + original_identify_led = original_caps.get("identify_led") + has_identify = original_caps["has_identify"] == "1" + test_bytes = LABEL_24.encode("utf-8") + _require(len(test_bytes) == 24, "LABEL_24 is not exactly 24 UTF-8 bytes") + primary_error = None + try: + # Identify is an action, never a configuration operation. Preserve + # the owner's identify_led and prove the action on both sides of a + # soft reboot only when the capability says it exists. + if has_identify: + response = await state.central.send_cmd( + wire.OP_IDENTIFY, + state.ids.next(), + ) + _status_is(response, wire.ST_OK, "IDENTIFY before soft reboot") + + rejected = ( + (b"x" * 25, wire.ST_ERANGE), + (b"bad\x00label", wire.ST_EBADREQ), + (b"bad\nlabel", wire.ST_EBADREQ), + (b"bad\rlabel", wire.ST_EBADREQ), + (b"bad\x01label", wire.ST_EBADREQ), + (b"bad\x7flabel", wire.ST_EBADREQ), + (b"\xc2\x80", wire.ST_EBADREQ), + (b"\xff", wire.ST_EBADREQ), + ) + for payload, status in rejected: + await _set_label(state, payload, status) + caps = await _device_info(state) + _require(caps["label"] == original_label, "rejected label changed DEVICE_INFO") + + # A reboot plus service-filtered scan proves rejected bytes did not + # reach either persistence or the advertisement, not just live caps. + caps = await _soft_reboot_to_advertisement(state, original_name) + _require( + caps["label"] == original_label, + "rejected label changed persisted configuration", + ) + _require( + caps.get("identify_led") == original_identify_led, + "soft reboot changed owner identify_led configuration", + ) + if has_identify: + response = await state.central.send_cmd( + wire.OP_IDENTIFY, + state.ids.next(), + ) + _status_is(response, wire.ST_OK, "IDENTIFY after soft reboot") + + toggled_auto_run = 1 - original_auto_run + response = await state.central.send_cmd( + wire.OP_SET_AUTORUN, + state.ids.next(), + bytes((toggled_auto_run,)), + ) + _status_is(response, wire.ST_OK, "SET_AUTORUN durability toggle") + caps = await _device_info(state) + _require( + caps.get("auto_run") == str(toggled_auto_run), + "SET_AUTORUN did not update DEVICE_INFO", + ) + caps = await _soft_reboot_to_advertisement(state, original_name) + _require( + caps.get("auto_run") == str(toggled_auto_run), + "auto_run did not persist across soft reboot", + ) + _require( + caps.get("identify_led") == original_identify_led, + "autorun durability changed owner identify_led configuration", + ) + + await _set_label(state, test_bytes, wire.ST_OK) + caps = await _device_info(state) + _require(caps["label"] == LABEL_24, "accepted 24-byte label was not published") + caps = await _soft_reboot_to_advertisement(state, LABEL_24) + _require(caps["label"] == LABEL_24, "24-byte label did not persist across reboot") + except BaseException as exc: + primary_error = exc + finally: + try: + if state.central is None or not state.central.is_connected: + await _connect(state) + await _negotiate(state) + await _set_label( + state, + original_label.encode("utf-8"), + wire.ST_OK, + ) + response = await state.central.send_cmd( + wire.OP_SET_AUTORUN, + state.ids.next(), + bytes((original_auto_run,)), + ) + _status_is(response, wire.ST_OK, "SET_AUTORUN restore") + restored = await _soft_reboot_to_advertisement(state, original_name) + _require( + restored["label"] == original_label, + "original label was not restored after the durability check", + ) + _require( + restored.get("auto_run") == str(original_auto_run), + "original auto_run was not restored after the durability check", + ) + _require( + restored.get("identify_led") == original_identify_led, + "restoration changed owner identify_led configuration", + ) + except BaseException as cleanup_error: + if primary_error is None: + primary_error = cleanup_error + else: + primary_error = BenchFailure( + "configuration check failed and deterministic restoration also failed" + ) + if primary_error is not None: + raise primary_error + + +async def _list_directory(state, path): + response = await state.central.send_cmd( + wire.OP_FILE_LIST, + state.ids.next(), + pble_bench.path_payload(path), + ) + _status_is(response, wire.ST_OK, "FILE_LIST") + return parse_list_payload(response.payload) + + +async def _assert_scratch_hidden(state): + more, entries = await _list_directory(state, WORK_DIR) + _require(more == 0, "small fixture directory was unexpectedly truncated") + _require( + all(not name.endswith(".pbltmp") for _kind, _size, name in entries), + ".pbltmp scratch artifact escaped into FILE_LIST", + ) + + +async def _mutation_status(state, opcode, payload, description): + response = await state.central.send_cmd(opcode, state.ids.next(), payload) + _status_is(response, wire.ST_EBUSY, description) + + +async def _get_with_active_probe( + state, + path, + expected, + probe_opcode, + probe_payload, + expected_status, + description, +): + """Admit one command before GET_END and verify the complete download. + + Each caller starts a separate deterministic GET. The native response pool + has depth two, so batching several response-bearing probes behind one GET + would measure response-pool exhaustion instead of the filesystem admission + contract. + """ + expected = bytes(expected) + verifier = pble_bench.DownloadVerifier(expected) + central = state.central + cursor = central.event_cursor() + response = await central.send_cmd( + wire.OP_FILE_GET_BEGIN, + state.ids.next(), + (0).to_bytes(4, "little") + pble_bench.path_payload(path), + timeout=EVENT_TIMEOUT_S, + ) + _status_is(response, wire.ST_OK, description + " FILE_GET_BEGIN") + _require( + len(response.payload) == 5, + description + " FILE_GET_BEGIN payload has the wrong length", + ) + _require( + int.from_bytes(response.payload[1:5], "little") == len(expected), + description + " FILE_GET_BEGIN advertised the wrong total", + ) + + write_receipt = False + + def record_write_receipt(): + nonlocal write_receipt + ended = any( + event.type == wire.EVT and event.opcode == wire.OP_FILE_GET_END + for event in _events_after(central, cursor) + ) + _require( + not ended, + description + " GET ended before the probe command write completed", + ) + write_receipt = True + + probe_response = await central.send_cmd( + probe_opcode, + state.ids.next(), + bytes(probe_payload), + timeout=EVENT_TIMEOUT_S, + on_written=record_write_receipt, + ) + _require( + write_receipt, + description + " did not produce a complete command-write receipt", + ) + _status_is(probe_response, expected_status, description) + + deadline = time.monotonic() + EVENT_TIMEOUT_S + finished = False + while True: + cursor, events = central.events_since(cursor) + progressed = False + try: + for event in events: + if event.type != wire.EVT: + continue + if event.opcode == wire.OP_FILE_GET_DATA: + _require(not finished, description + " emitted data after GET_END") + _require( + len(event.payload) > 4, + description + " FILE_GET_DATA payload is truncated", + ) + offset = int.from_bytes(event.payload[:4], "little") + verifier.feed(offset, event.payload[4:]) + progressed = True + elif event.opcode == wire.OP_FILE_GET_END: + _require(not finished, description + " emitted duplicate GET_END") + _require( + len(event.payload) == 4, + description + " FILE_GET_END payload has the wrong length", + ) + verifier.finish(int.from_bytes(event.payload, "little")) + finished = True + progressed = True + except pble_bench.IntegrityFailure as exc: + raise BenchFailure(description + " GET integrity check failed") from exc + if finished: + return probe_response + if progressed: + deadline = time.monotonic() + EVENT_TIMEOUT_S + if time.monotonic() >= deadline: + raise BenchFailure( + "%s GET timed out after %d/%d verified bytes" + % (description, verifier.unique_bytes, len(expected)) + ) + await asyncio.sleep(0.002) + + +async def _statvfs_geometry(state): + source = ( + b"import os\n" + b"_v061_sv=os.statvfs('/')\n" + b"print('__PYBLE_V061_STATVFS__=%d,%d'%(_v061_sv[1],_v061_sv[4]))\n" + ) + stdout = await _run_program(state, 1, source, "capacity statvfs") + matches = re.findall(rb"__PYBLE_V061_STATVFS__=([0-9]+),([0-9]+)", stdout) + _require(len(matches) == 1, "statvfs probe did not emit exact geometry") + unit, available = (int(value) for value in matches[0]) + _require(unit > 0 and available >= 0, "statvfs geometry is invalid") + return unit, available + + +async def _cleanup_bench_put(state, path): + if state.central is None or not state.central.is_connected: + await _connect(state) + await _negotiate(state) + # Resolve any active transaction first. Failure is expected for an + # incomplete transaction and safely removes its scratch. + try: + await _end_put(state, 0, expected=wire.ST_ERANGE) + except Exception: + pass + # A zero-length begin makes any remaining nonempty regular scratch + # malformed, safely removes it, and gives this bench a deletable artifact. + try: + resume = await _begin_put(state, path, 0, 0) + if resume == 0: + await _end_put(state, 0) + except Exception: + pass + try: + await pble_bench.remove_if_present(state.central, path, state.ids.next) + except Exception: + pass + + +async def run_filesystem_hardening(state): + """Exercise active-transfer admission, scratch safety, and capacity.""" + # _assert_scratch_hidden performs wire.OP_FILE_LIST; every valid namespace + # mutation below must return the exact wire.ST_EBUSY status during PUT. + old_bytes = b"v061-old-target-sentinel" + replacement = b"v061-new-target-content-123456" + active_get_bytes = pble_bench.deterministic_payload( + state.args.profile, + 0x0610, + ACTIVE_GET_BYTES, + ) + get_delete_bytes = b"v061-active-get-delete-sentinel" + get_rename_bytes = b"v061-active-get-rename-sentinel" + await pble_bench.ensure_directory(state.central, WORK_DIR, state.ids.next) + try: + # A separate wire.OP_FILE_GET_BEGIN surrounds each 16 KiB probe. + # Complete BLE write receipt must precede GET_END, and the download + # remains subject to the same offset/byte/size/CRC checks as an + # ordinary GET. + await put_file(state, FS_ACTIVE_GET_PATH, active_get_bytes) + await put_file(state, FS_GET_DELETE_PATH, get_delete_bytes) + await put_file(state, FS_GET_RENAME_SOURCE_PATH, get_rename_bytes) + await pble_bench.remove_if_present( + state.central, FS_GET_MKDIR_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_GET_RENAME_DEST_PATH, state.ids.next + ) + + await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_DELETE, + pble_bench.path_payload(FS_GET_DELETE_PATH), + wire.ST_EBUSY, + "active-GET FILE_DELETE", + ) + await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_MKDIR, + pble_bench.path_payload(FS_GET_MKDIR_PATH), + wire.ST_EBUSY, + "active-GET MKDIR", + ) + await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_RENAME, + pble_bench.path_payload(FS_GET_RENAME_SOURCE_PATH) + + pble_bench.path_payload(FS_GET_RENAME_DEST_PATH), + wire.ST_EBUSY, + "active-GET FILE_RENAME", + ) + await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_DELETE, + b"\x02\x00x", + wire.ST_EBADREQ, + "active-GET malformed FILE_DELETE", + ) + await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_RENAME, + pble_bench.path_payload(FS_GET_RENAME_SOURCE_PATH) + + pble_bench.path_payload("../escape"), + wire.ST_EACCES, + "active-GET jailed FILE_RENAME", + ) + list_response = await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_LIST, + pble_bench.path_payload(WORK_DIR), + wire.ST_OK, + "active-GET FILE_LIST", + ) + more, active_entries = parse_list_payload(list_response.payload) + _require(more == 0, "active-GET FILE_LIST was unexpectedly truncated") + active_names = {name for _kind, _size, name in active_entries} + _require( + { + "active_get.bin", + "get_delete.bin", + "get_rename_source.bin", + }.issubset(active_names), + "active-GET FILE_LIST omitted a deterministic fixture", + ) + stat_response = await _get_with_active_probe( + state, + FS_ACTIVE_GET_PATH, + active_get_bytes, + wire.OP_FILE_STAT, + pble_bench.path_payload(FS_ACTIVE_GET_PATH), + wire.ST_OK, + "active-GET FILE_STAT", + ) + _require( + len(stat_response.payload) == 9, + "active-GET FILE_STAT payload has the wrong length", + ) + _require( + int.from_bytes(stat_response.payload[1:5], "little") + == ACTIVE_GET_BYTES + and int.from_bytes(stat_response.payload[5:9], "little") + == wire.crc32(active_get_bytes), + "active-GET FILE_STAT did not verify the fixture", + ) + + # The valid mutation probes above must not have changed the namespace. + await pble_bench.get_file( + state.central, + FS_GET_DELETE_PATH, + get_delete_bytes, + next_id=state.ids.next, + ) + await pble_bench.get_file( + state.central, + FS_GET_RENAME_SOURCE_PATH, + get_rename_bytes, + next_id=state.ids.next, + ) + response = await state.central.send_cmd( + wire.OP_FILE_STAT, + state.ids.next(), + pble_bench.path_payload(FS_GET_RENAME_DEST_PATH), + ) + _status_is(response, wire.ST_ENOENT, "active-GET rename destination stat") + _more, entries = await _list_directory(state, WORK_DIR) + _require( + all(name != "get_mkdir" for _kind, _size, name in entries), + "active-GET MKDIR mutated the filesystem", + ) + + await put_file(state, FS_SENTINEL_PATH, old_bytes) + + # An active PUT exposes neither its .pbltmp file nor a mutation window. + resume = await _begin_put( + state, + FS_SENTINEL_PATH, + len(replacement), + wire.crc32(replacement), + ) + _require(resume == 0, "active-PUT fixture unexpectedly resumed") + scope = state.central.begin_ack_scope(0) + piece = replacement[:8] + await _send_put_piece( + state, scope, 0, piece, len(replacement), {0, len(piece)} + ) + await _assert_scratch_hidden(state) + await _mutation_status( + state, + wire.OP_FILE_DELETE, + pble_bench.path_payload(FS_SENTINEL_PATH), + "active-PUT FILE_DELETE", + ) + await _mutation_status( + state, + wire.OP_FILE_RENAME, + pble_bench.path_payload(FS_SENTINEL_PATH) + + pble_bench.path_payload(FS_RENAME_PATH), + "active-PUT FILE_RENAME", + ) + await _mutation_status( + state, + wire.OP_MKDIR, + pble_bench.path_payload(FS_BLOCKED_DIR), + "active-PUT MKDIR", + ) + await _end_put(state, wire.crc32(replacement), expected=wire.ST_ERANGE) + await pble_bench.get_file( + state.central, + FS_SENTINEL_PATH, + old_bytes, + next_id=state.ids.next, + ) + response = await state.central.send_cmd( + wire.OP_FILE_STAT, + state.ids.next(), + pble_bench.path_payload(FS_RENAME_PATH), + ) + _status_is(response, wire.ST_ENOENT, "post-PUT renamed destination stat") + _more, entries = await _list_directory(state, WORK_DIR) + _require( + all(name != "blocked_dir" for _kind, _size, name in entries), + "active-PUT MKDIR mutated the filesystem", + ) + + # Leave a valid 16-byte prefix, reconnect, then declare an 8-byte file. + # The now-oversized scratch is malformed; recovery must restart at zero + # while preserving the old destination byte-for-byte. + resume = await _begin_put( + state, + FS_SENTINEL_PATH, + len(replacement), + wire.crc32(replacement), + ) + _require(resume == 0, "malformed-scratch fixture unexpectedly resumed") + scope = state.central.begin_ack_scope(0) + piece = replacement[:16] + await _send_put_piece( + state, scope, 0, piece, len(replacement), {0, len(piece)} + ) + await _disconnect(state) + await _connect(state) + await _negotiate(state) + await _assert_scratch_hidden(state) + + short_data = b"12345678" + resume = await _begin_put( + state, + FS_SENTINEL_PATH, + len(short_data), + wire.crc32(short_data), + ) + _require(resume == 0, "oversized malformed scratch was resumed") + await _end_put(state, wire.crc32(short_data), expected=wire.ST_ERANGE) + await pble_bench.get_file( + state.central, + FS_SENTINEL_PATH, + old_bytes, + next_id=state.ids.next, + ) + + # Capacity admission uses the live board's statvfs geometry but writes + # no large payload. Exactly free-reserve rounded to a whole unit is + # accepted; one byte beyond that allocation boundary is ENOSPC. + unit, available = await _statvfs_geometry(state) + free = available * unit + blocks = (free - CAPACITY_RESERVE) // unit + _require(blocks >= 1, "capacity boundary is not safely feasible") + exact_total = blocks * unit + _require(exact_total < (1 << 32), "capacity boundary exceeds PBLE/1 u32") + await pble_bench.remove_if_present( + state.central, FS_CAPACITY_PATH, state.ids.next + ) + resume = await _begin_put(state, FS_CAPACITY_PATH, exact_total, 0) + _require(resume == 0, "exact capacity boundary unexpectedly resumed") + await _end_put(state, 0, expected=wire.ST_ERANGE) + + unit, available = await _statvfs_geometry(state) + free = available * unit + blocks = (free - CAPACITY_RESERVE) // unit + _require(blocks >= 0, "capacity rejection boundary is not feasible") + rejected_total = blocks * unit + 1 + _require(rejected_total < (1 << 32), "capacity rejection exceeds PBLE/1 u32") + await _begin_put( + state, + FS_CAPACITY_PATH, + rejected_total, + 0, + expected=wire.ST_ENOSPC, + ) + await pble_bench.get_file( + state.central, + FS_SENTINEL_PATH, + old_bytes, + next_id=state.ids.next, + ) + finally: + await _cleanup_bench_put(state, FS_SENTINEL_PATH) + await _cleanup_bench_put(state, FS_CAPACITY_PATH) + await pble_bench.remove_if_present( + state.central, FS_ACTIVE_GET_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_GET_DELETE_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_GET_RENAME_SOURCE_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_GET_RENAME_DEST_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_GET_MKDIR_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_RENAME_PATH, state.ids.next + ) + await pble_bench.remove_if_present( + state.central, FS_BLOCKED_DIR, state.ids.next + ) + await pble_bench.remove_if_present(state.central, WORK_DIR, state.ids.next) + + +def _scenario_log_bytes(scenario_results): + rows = [] + for name in SCENARIO_ORDER: + row = scenario_results[name] + value = {"scenario": name, "status": row["status"]} + if name == "resource-stability": + value["sequential_runs"] = row["sequential_runs"] + rows.append(value) + try: + return b"".join( + ( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + for value in rows + ) + except (KeyError, TypeError, ValueError) as exc: + raise BenchFailure("scenario result log is not canonical") from exc + + +def _write_private_log(path, raw): + """Create one mode-0600 raw log without following or replacing a name.""" + try: + hardening_gate._write_exclusive(Path(path), raw) + except hardening_gate.QualificationError as exc: + raise BenchFailure("raw log output could not be created safely") from exc + + +def write_private_result( + candidate_dir, + profile_id, + scenario_results, + workspace_erased_receipt, + workspace_nonblank_receipt, + raw_log, + result, + qualification_repo_root, +): + """Write the bounded raw log, then publish one shared-gate result.""" + _write_private_log(raw_log, _scenario_log_bytes(scenario_results)) + try: + return hardening_gate.create_result( + candidate_dir=Path(candidate_dir), + profile_id=profile_id, + scenario_results=scenario_results, + workspace_erased_receipt=Path(workspace_erased_receipt), + workspace_nonblank_receipt=Path(workspace_nonblank_receipt), + raw_log=Path(raw_log), + output_path=Path(result), + qualification_repo_root=Path(qualification_repo_root), + ) + except hardening_gate.QualificationError as exc: + raise BenchFailure("candidate-bound result validation failed") from exc + + +def write_private_result_from_preflight( + preflight, + scenario_results, + raw_log, + result, +): + """Write the raw log and consume the exact pre-BLE evidence lease.""" + _write_private_log(raw_log, _scenario_log_bytes(scenario_results)) + try: + return hardening_gate.create_result_from_preflight( + preflight=preflight, + scenario_results=scenario_results, + raw_log=Path(raw_log), + output_path=Path(result), + ) + except hardening_gate.QualificationError as exc: + raise BenchFailure("candidate-bound result validation failed") from exc + + +async def _run_scenario(name, operation): + await operation() + print("V061 SCENARIO PASS (%s)" % name) + + +async def run(args): + workspace = "pending" + expected_caps = { + "chip": PROFILE_CHIPS[args.profile], + "agent": args.expect_agent, + } + state = LiveState(args) + scenario_results = {} + try: + # The host preflight leases args.workspace_erased_receipt and + # args.workspace_nonblank_receipt before any physical operation. + preflight = preflight_result_inputs(args) + workspace = workspace_prerequisite(args) + await _run_scenario( + "transport-session", lambda: run_transport_session(state) + ) + scenario_results["transport-session"] = {"status": "passed"} + await preflight_board_workspace(state) + await _run_scenario( + "fragment-hardening", lambda: run_fragment_hardening(state) + ) + scenario_results["fragment-hardening"] = {"status": "passed"} + await _run_scenario("run-isolation", lambda: run_fresh_globals(state)) + scenario_results["run-isolation"] = {"status": "passed"} + await _run_scenario( + "resource-stability", lambda: run_resource_stability(state) + ) + scenario_results["resource-stability"] = { + "status": "passed", + "sequential_runs": SEQUENTIAL_RUNS, + } + await _run_scenario("stdin-isolation", lambda: run_stdin_isolation(state)) + scenario_results["stdin-isolation"] = {"status": "passed"} + await _run_scenario( + "configuration-durability", lambda: run_label_durability(state) + ) + scenario_results["configuration-durability"] = {"status": "passed"} + await _run_scenario( + "filesystem-hardening", lambda: run_filesystem_hardening(state) + ) + scenario_results["filesystem-hardening"] = {"status": "passed"} + write_private_result_from_preflight( + preflight=preflight, + scenario_results=scenario_results, + raw_log=args.raw_log, + result=args.result, + ) + print(format_result("PASS", args.profile, state.caps, workspace)) + return 0 + except BenchFailure as exc: + print("V061 FAILURE (%s)" % str(exc)) + print(format_result("FAIL", args.profile, state.caps or expected_caps, workspace)) + return 1 + finally: + try: + await _best_effort_stop(state) + await _disconnect(state) + except Exception: + pass + + +def _parse_args(argv=None): + parser = argparse.ArgumentParser( + description="physical PBLE/1 firmware-v0.6.1 hardening HIL bench" + ) + parser.add_argument( + "--create-workspace-receipt", + action="store_true", + help="derive one pre-service receipt without opening BLE", + ) + parser.add_argument( + "--profile", + choices=PROFILE_ORDER, + help="exact physical release profile under test", + ) + parser.add_argument( + "--address", + help="private BLE address/UUID (never emitted in the result)", + ) + parser.add_argument( + "--expect-agent", + help="exact v0.6.1 agent version", + ) + parser.add_argument( + "--candidate-dir", + type=Path, + help="exact firmware-v0.6.1 release candidate directory", + ) + parser.add_argument( + "--workspace-erased-receipt", + type=Path, + help="private erased-media-first-boot receipt", + ) + parser.add_argument( + "--workspace-nonblank-receipt", + type=Path, + help="private nonblank-media-refusal receipt", + ) + parser.add_argument( + "--raw-log", + type=Path, + help="new private canonical JSONL output", + ) + parser.add_argument( + "--result", + type=Path, + help="new private candidate-bound result output", + ) + parser.add_argument("--board-manufacturer") + parser.add_argument("--board-model") + parser.add_argument("--module-marking") + parser.add_argument( + "--observation-kind", + choices=hardening_gate.WORKSPACE_PROVISIONING_ORDER, + ) + parser.add_argument("--raw-boot-log", type=Path) + parser.add_argument("--receipt-output", type=Path) + args = parser.parse_args(argv) + + def require_options(names): + missing = ["--" + name.replace("_", "-") for name in names if getattr(args, name) is None] + if missing: + parser.error("the following arguments are required: %s" % ", ".join(missing)) + + args.qualification_repo_root = ROOT + try: + if args.create_workspace_receipt: + require_options( + ( + "profile", + "candidate_dir", + "observation_kind", + "raw_boot_log", + "receipt_output", + ) + ) + forbidden = ( + "address", + "expect_agent", + "workspace_erased_receipt", + "workspace_nonblank_receipt", + "raw_log", + "result", + "board_manufacturer", + "board_model", + "module_marking", + ) + if any(getattr(args, name) is not None for name in forbidden): + parser.error( + "workspace-receipt mode accepts only its bounded receipt inputs" + ) + candidate = hardening_gate._absolute_lexical_path( + args.candidate_dir, "candidate" + ) + chain = hardening_gate._open_directory_chain( + candidate, label="candidate" + ) + hardening_gate._close_directory_chain(chain) + raw_boot_log = hardening_gate._absolute_lexical_path( + args.raw_boot_log, "workspace raw boot log" + ) + receipt_output = hardening_gate._absolute_lexical_path( + args.receipt_output, "workspace receipt output" + ) + if receipt_output.suffix != ".json": + raise hardening_gate.QualificationError( + "workspace receipt output must end in .json" + ) + if raw_boot_log != hardening_gate._workspace_raw_path(receipt_output): + raise hardening_gate.QualificationError( + "workspace raw boot log name must be derived from its receipt" + ) + hardening_gate._stable_regular_bytes( + raw_boot_log, + label="workspace raw boot log", + maximum=64 * 1024, + private=True, + ) + if os.path.lexists(receipt_output): + raise hardening_gate.QualificationError( + "workspace receipt output already exists" + ) + for path, label in ( + (raw_boot_log, "workspace raw boot log"), + (receipt_output, "workspace receipt output"), + ): + if hardening_gate._inside(path, candidate): + raise hardening_gate.QualificationError( + "%s must be outside the candidate" % label + ) + if hardening_gate._inside(path, ROOT): + raise hardening_gate.QualificationError( + "%s must be outside the qualification checkout" % label + ) + chain = hardening_gate._open_directory_chain( + receipt_output.parent, label="workspace receipt output parent" + ) + hardening_gate._close_directory_chain(chain) + return args + + require_options( + ( + "profile", + "address", + "candidate_dir", + "workspace_erased_receipt", + "workspace_nonblank_receipt", + "raw_log", + "result", + "board_manufacturer", + "board_model", + "module_marking", + ) + ) + if any( + getattr(args, name) is not None + for name in ("observation_kind", "raw_boot_log", "receipt_output") + ): + parser.error("workspace-receipt inputs are invalid in live mode") + if not args.address.strip(): + parser.error("--address must be non-empty") + if args.expect_agent is not None and args.expect_agent.strip() != "0.6.1": + parser.error("--expect-agent must be exactly 0.6.1") + args.address = args.address.strip() + args.expect_agent = "0.6.1" + expected_identity = PROFILE_BOARD_IDENTITIES[args.profile] + for field in ("board_manufacturer", "board_model", "module_marking"): + if getattr(args, field) != expected_identity[field]: + parser.error( + "--%s must match the reviewed %s identity" + % (field.replace("_", "-"), args.profile) + ) + candidate = hardening_gate._absolute_lexical_path( + args.candidate_dir, "candidate" + ) + chain = hardening_gate._open_directory_chain( + candidate, label="candidate" + ) + hardening_gate._close_directory_chain(chain) + receipts = ( + args.workspace_erased_receipt, + args.workspace_nonblank_receipt, + ) + normalized_receipts = [] + for receipt in receipts: + normalized = hardening_gate._absolute_lexical_path( + receipt, "workspace receipt" + ) + hardening_gate._stable_regular_bytes( + normalized, + label="workspace receipt", + maximum=2 * 1024 * 1024, + private=True, + ) + normalized_receipts.append(normalized) + if normalized_receipts[0] == normalized_receipts[1]: + raise hardening_gate.QualificationError( + "workspace receipts must be distinct" + ) + + raw_log = hardening_gate._absolute_lexical_path( + args.raw_log, "hardening raw log" + ) + result = hardening_gate._absolute_lexical_path( + args.result, "hardening result" + ) + if raw_log.suffix != ".jsonl" or result.suffix != ".json": + raise hardening_gate.QualificationError( + "hardening outputs must use .jsonl and .json" + ) + if raw_log == result: + raise hardening_gate.QualificationError( + "hardening raw log and result must differ" + ) + if os.path.lexists(raw_log) or os.path.lexists(result): + raise hardening_gate.QualificationError( + "hardening output already exists" + ) + if hardening_gate._inside(raw_log, candidate) or hardening_gate._inside( + result, candidate + ): + raise hardening_gate.QualificationError( + "hardening outputs must be outside the candidate" + ) + if hardening_gate._inside(raw_log, ROOT) or hardening_gate._inside( + result, ROOT + ): + raise hardening_gate.QualificationError( + "hardening outputs must be outside the qualification checkout" + ) + for parent in (raw_log.parent, result.parent): + chain = hardening_gate._open_directory_chain( + parent, label="hardening output parent" + ) + hardening_gate._close_directory_chain(chain) + except hardening_gate.QualificationError as exc: + parser.error(str(exc)) + return args + + +def main(argv=None): + args = _parse_args(argv) + if args.create_workspace_receipt: + try: + hardening_gate.create_workspace_receipt( + candidate_dir=Path(args.candidate_dir), + profile_id=args.profile, + observation_kind=args.observation_kind, + raw_boot_log=args.raw_boot_log, + output_path=args.receipt_output, + qualification_repo_root=ROOT, + ) + except hardening_gate.QualificationError: + print("V061 WORKSPACE RECEIPT ERROR (private details withheld)") + return 2 + print( + "V061 WORKSPACE RECEIPT PASS (profile=%s observation=%s)" + % (args.profile, args.observation_kind) + ) + return 0 + try: + return asyncio.run(run(args)) + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + caps = { + "chip": PROFILE_CHIPS[args.profile], + "agent": args.expect_agent, + } + print("V061 ERROR (HIL prerequisite or transport failed; private details withheld)") + print( + format_result( + "FAIL", + args.profile, + caps, + workspace_prerequisite(args), + ) + ) + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/firmware_tests/hil/v061_workspace_acquire.py b/tests/firmware_tests/hil/v061_workspace_acquire.py new file mode 100644 index 00000000..a2cd2228 --- /dev/null +++ b/tests/firmware_tests/hil/v061_workspace_acquire.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Acquire private physical workspace evidence; never import operator counts. + +The maintained hardware backend verifies an explicitly selected disposable +board. This module owns fresh challenges, live scanner chronology, retained +actual readbacks/responses, and exclusive candidate/source-bound publication. +Injected backends/scanners are host-test seams, not a CLI qualification mode. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import importlib.util +import os +from pathlib import Path +import secrets +import sys +import time + + +ROOT = Path(__file__).resolve().parents[3] +_SPEC = importlib.util.spec_from_file_location( + "pyble_workspace_acquisition_gate", + ROOT / "firmware/qualification/v061_hardening_release_gate.py", +) +gate = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(gate) + + +def _sha(raw): + return hashlib.sha256(raw).hexdigest() + + +class _PrivateStream: + """Exclusive descriptor-relative live JSONL with immutable close checks.""" + + def __init__(self, path): + self.path = gate._absolute_lexical_path(Path(path), "acquisition live log") + self.chain = gate._open_directory_chain(self.path.parent, label="acquisition live log parent") + self.descriptor = -1 + self.raw = bytearray() + try: + self.descriptor = os.open(self.path.name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, + dir_fd=self.chain[-1][0]) + os.fchmod(self.descriptor, 0o600) + self.identity = os.fstat(self.descriptor) + gate._require(self.identity.st_nlink == 1, "acquisition live log is linked") + except BaseException: + self.close(check=False) + raise + + def append(self, value): + raw = gate._canonical_json_line(value) + gate._require(len(self.raw) + len(raw) <= gate._MAX_LOG_BYTES, + "acquisition live log exceeded its bound") + position = 0 + while position < len(raw): + count = os.write(self.descriptor, raw[position:]) + gate._require(count > 0, "acquisition live log write was short") + position += count + self.raw.extend(raw) + os.fsync(self.descriptor) + gate._verify_directory_chain(self.chain, "acquisition live log parent") + + def close(self, check=True): + if self.descriptor >= 0: + try: + os.fsync(self.descriptor) + current = os.fstat(self.descriptor) + if check: + gate._require(current.st_dev == self.identity.st_dev + and current.st_ino == self.identity.st_ino + and current.st_nlink == 1 + and current.st_mode & 0o7777 == 0o600, + "acquisition live log descriptor changed") + gate._verify_directory_chain(self.chain, "acquisition live log parent") + finally: + os.close(self.descriptor) + self.descriptor = -1 + if self.chain: + gate._close_directory_chain(self.chain) + self.chain = [] + if check: + raw, identity = gate._stable_regular_bytes(self.path, + label="acquisition live log", maximum=gate._MAX_LOG_BYTES, private=True) + gate._require(raw == bytes(self.raw) and identity[0] == self.identity.st_dev + and identity[1] == self.identity.st_ino, + "acquisition live log was replaced") + + +def _scanner_factory(callback): + from bleak import BleakScanner + return BleakScanner(detection_callback=callback, + service_uuids=[gate.WORKSPACE_SERVICE_UUID]) + + +def _private_binding(value, profile_id): + # The hardware backend owns complete USB/ROM/geometry validation. Retain + # its entire explicit input here; compact observed identity must match it. + raw = gate.canonical_json_bytes(value) + gate._require(len(raw) <= 16 * 1024, "physical binding is oversized") + binding = gate._decode_json(raw, "physical binding", canonical=True) + gate._require(type(binding.get("schema_version")) is int + and binding["schema_version"] == 1 + and binding.get("profile_id") == profile_id, + "physical binding profile or schema changed") + for field in ("device_id", "ble_address"): + gate._require(type(binding.get(field)) is str and 0 < len(binding[field]) <= 128, + "physical binding %s is missing" % field) + return gate.validate_workspace_binding(binding) + + +async def acquire(*, candidate_dir, profile_id, observation_kind, binding, + output_path, qualification_repo_root, backend, + allow_disposable_erase=False, scanner_factory=None, + clock_ns=time.monotonic_ns, wait=asyncio.sleep): + """Run one actual destructive preparation/boot/readback observation. + + No precomputed response, snapshot, media bytes, counters, or event log can + be supplied through this interface. The only production backend is the + reviewed hardware adapter instantiated by main(). A failed run retains + partial raw evidence and never publishes a passing receipt. + """ + gate._require(allow_disposable_erase is True, + "workspace acquisition requires an explicitly disposable board") + gate._require(observation_kind in gate.WORKSPACE_PROVISIONING_ORDER, + "workspace observation kind is unsupported") + binding = _private_binding(binding, profile_id) + output = gate._absolute_lexical_path(Path(output_path), "workspace receipt output") + gate._require(output.suffix == ".json", "workspace receipt must end in .json") + candidate = gate._candidate_snapshot(Path(candidate_dir), profile_id) + candidate["profile_id"] = profile_id + qualification = gate._qualification_snapshot(Path(qualification_repo_root)) + gate._require(qualification["root"] == ROOT, "collector is outside qualification checkout") + paths = [output, gate._workspace_raw_path(output), + gate._workspace_sibling(output, "acquisition.json")] + paths.extend(gate._workspace_sibling(output, suffix) + for suffix, _field in gate.WORKSPACE_RAW_SIBLINGS) + for path in paths: + gate._require_private_evidence_outside(path, label="workspace acquisition", + candidate=candidate, qualification=qualification) + chain = gate._open_directory_chain(path.parent, label="workspace acquisition parent") + try: + try: + os.stat(path.name, dir_fd=chain[-1][0], follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise gate.QualificationError("workspace acquisition output already exists") + gate._verify_directory_chain(chain, "workspace acquisition parent") + finally: + gate._close_directory_chain(chain) + + challenge = secrets.token_hex(16) + media = dict(gate.WORKSPACE_MEDIA[profile_id]) + transport = "pble-run" if observation_kind == gate.WORKSPACE_PROVISIONING_ORDER[0] else "usb-repl" + measurement = _PrivateStream(gate._workspace_sibling(output, "measurement.jsonl")) + watch = None + scanner = None + scanner_started = False + backend_closed = False + capture_error = None + raw_outputs = {} + stamps = [] + + def event(name, **fields): + stamp = clock_ns() + gate._require(type(stamp) is int and (not stamps or stamp > stamps[-1]), + "acquisition clock did not advance") + stamps.append(stamp) + measurement.append({"event": name, **fields, "monotonic_ns": stamp}) + return stamp + + def save(suffix, raw): + gate._require(type(raw) is bytes, "hardware measurement is not raw bytes") + path = gate._workspace_sibling(output, suffix) + gate._require(len(raw) <= gate._workspace_evidence_maximum(path), + "hardware measurement exceeded its bound") + gate._write_exclusive(path, raw, post_write_check=lambda: None) + raw_outputs[suffix] = raw + return _sha(raw) + + def callback(device, advertisement): + nonlocal capture_error + if watch is None or device.address != binding["ble_address"]: + return + try: + watch.append({"event": "advertisement", "monotonic_ns": clock_ns(), + "address": device.address, "local_name": advertisement.local_name, + "service_uuids": list(advertisement.service_uuids)}) + except BaseException as exc: + # Scanner callbacks may run outside our await stack. A callback + # failure is a terminal acquisition error, never an empty watch. + capture_error = exc + + try: + event("acquisition-start", challenge=challenge, binding=binding) + observed_binding, install_readback = await asyncio.wait_for( + backend.prepare(candidate, profile_id, observation_kind), timeout=900) + gate._require(type(observed_binding) is dict + and tuple(observed_binding) == ("device_id", "ble_address") + and observed_binding == {key: binding[key] for key in ("device_id", "ble_address")}, + "hardware preparation returned a different physical board") + install_digest = save("install.bin", install_readback) + gate._require(install_digest == candidate["install_sha256"], + "actual install readback differs from candidate") + event("install-verified", device_id=observed_binding["device_id"], + install_readback_sha256=install_digest) + pre = await asyncio.wait_for(backend.read_media(media["offset"], media["size"]), timeout=900) + pre_digest = save("pre.bin", pre) + gate._require(len(pre) == media["size"], "workspace pre-read is incomplete") + if observation_kind == gate.WORKSPACE_PROVISIONING_ORDER[0]: + gate._require(pre == b"\xff" * len(pre), "workspace pre-read is not blank") + else: + first = 2 * media["block_size"] + gate._require(pre == b"\0" * len(pre) + or (pre[:first] == b"\0" * first and pre[first:] == b"\xff" * (len(pre)-first)), + "workspace pre-read is not deliberately incompatible") + event("media-read", phase="pre", offset=media["offset"], size=media["size"], sha256=pre_digest) + scanner = (scanner_factory or _scanner_factory)(callback) + await asyncio.wait_for(scanner.start(), timeout=30) + scanner_started = True + watch = _PrivateStream(gate._workspace_sibling(output, "advertisements.jsonl")) + watch.append({"event": "watch-start", "challenge": challenge, "monotonic_ns": clock_ns()}) + boot_stamp = event("boot-start", device_id=observed_binding["device_id"]) + boot_raw = await asyncio.wait_for(backend.boot(), timeout=90) + gate._require(type(boot_raw) is bytes, "boot transcript is not raw bytes") + event("boot-complete") + observed_raw = await asyncio.wait_for(backend.observe(challenge, transport), timeout=120) + gate._require(type(observed_raw) is bytes, "observation response is not raw bytes") + response = boot_raw + observed_raw + response_digest = save("response.bin", response) + reply = gate.parse_workspace_response(response) + observed = reply["observation"] + gate._require(type(observed) is dict, "firmware observation is not a mapping") + boot_id = observed.get("boot_id") + gate._validate_boot_observation({"challenge": challenge, "boot_id": boot_id}, reply, observation_kind) + event("observation-read", challenge=challenge, boot_id=boot_id, + transport=transport, response_sha256=response_digest) + await wait(max(0.001, (boot_stamp + 10_000_000_001 - clock_ns()) / 1_000_000_000)) + await asyncio.wait_for(scanner.stop(), timeout=30) + scanner_started = False + if capture_error is not None: + raise gate.QualificationError("live advertisement capture failed") from capture_error + watch.append({"event": "watch-end", "challenge": challenge, + "monotonic_ns": clock_ns(), "status": "complete"}) + watch.close() + raw_outputs["advertisements.jsonl"] = bytes(watch.raw) + post = await asyncio.wait_for(backend.read_media(media["offset"], media["size"]), timeout=900) + post_digest = save("post.bin", post) + event("media-read", phase="post", offset=media["offset"], size=media["size"], sha256=post_digest) + await asyncio.wait_for(backend.close(), timeout=30) + backend_closed = True + event("acquisition-end", status="complete") + measurement.close() + raw_outputs["measurement.jsonl"] = bytes(measurement.raw) + gate._same_candidate(Path(candidate_dir), profile_id, candidate) + gate._same_qualification(Path(qualification_repo_root), qualification) + value = { + "schema_version": 1, "measurement_contract": gate.WORKSPACE_ACQUISITION_CONTRACT, + "observation_kind": observation_kind, "profile_id": profile_id, + "target": gate.PROFILE_TARGETS[profile_id], "firmware_version": "0.6.1", + "source_commit": candidate["source_commit"], + "candidate_release_json_sha256": candidate["release_sha256"], + "install_sha256": candidate["install_sha256"], + "qualification_source_commit": qualification["commit"], + "qualification_executable_sha256": qualification["executable_sha256"], + "collector_sha256": _sha(Path(__file__).read_bytes()), + "challenge": challenge, "boot_id": boot_id, "media": media, + "transport": transport, "board_binding": observed_binding, + **{field: _sha(raw_outputs[suffix]) for suffix, field in gate.WORKSPACE_RAW_SIBLINGS}, + "status": "passed", + } + gate._write_exclusive(gate._workspace_sibling(output, "acquisition.json"), + gate.canonical_json_bytes(value), post_write_check=lambda: None) + gate._workspace_acquisition_snapshot(output, observation_kind, + gate._validation_from(candidate, qualification)) + summary = b"".join(gate._canonical_json_line(row) + for row in gate._expected_workspace_values(observation_kind)) + gate._write_exclusive(gate._workspace_raw_path(output), summary, post_write_check=lambda: None) + return gate.create_workspace_receipt(candidate_dir=Path(candidate_dir), profile_id=profile_id, + observation_kind=observation_kind, raw_boot_log=gate._workspace_raw_path(output), + output_path=output, qualification_repo_root=Path(qualification_repo_root)) + finally: + primary_error = sys.exc_info()[1] + cleanup_errors = [] + if scanner_started: + try: + await asyncio.wait_for(scanner.stop(), timeout=30) + except BaseException as exc: + cleanup_errors.append(exc) + finally: + scanner_started = False + for stream in (watch, measurement): + if stream is not None and stream.descriptor >= 0: + try: + stream.close() + except BaseException as exc: + cleanup_errors.append(exc) + if not backend_closed: + try: + await asyncio.wait_for(backend.close(), timeout=30) + except BaseException as exc: + cleanup_errors.append(exc) + if cleanup_errors: + if primary_error is not None: + primary_error.add_note("Workspace acquisition cleanup also failed; partial evidence retained.") + else: + raise gate.QualificationError("workspace acquisition cleanup failed") from cleanup_errors[0] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--candidate-dir", type=Path, required=True) + parser.add_argument("--profile", choices=tuple(gate.PROFILE_TARGETS), required=True) + parser.add_argument("--kind", choices=gate.WORKSPACE_PROVISIONING_ORDER, required=True) + parser.add_argument("--binding", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--allow-disposable-erase", action="store_true") + args = parser.parse_args() + raw, _ = gate._stable_regular_bytes(args.binding, label="private physical binding", + maximum=16 * 1024, private=True) + binding = gate._decode_json(raw, "private physical binding", canonical=True) + from _v061_workspace_hardware import WorkspaceHardware + backend = WorkspaceHardware(binding, repo_root=ROOT, candidate_dir=args.candidate_dir) + output = asyncio.run(acquire(candidate_dir=args.candidate_dir, profile_id=args.profile, + observation_kind=args.kind, binding=binding, output_path=args.output, + qualification_repo_root=ROOT, backend=backend, + allow_disposable_erase=args.allow_disposable_erase)) + print("Workspace acquisition receipt:", output) + + +if __name__ == "__main__": + main() diff --git a/tests/firmware_tests/host/conformance/README.md b/tests/firmware_tests/host/conformance/README.md new file mode 100644 index 00000000..f1398986 --- /dev/null +++ b/tests/firmware_tests/host/conformance/README.md @@ -0,0 +1,27 @@ +# Firmware conformance fixtures + +This directory contains the active host-consumed PBLE/1 fixtures. PBLE/1 +§2–§10 is frozen for v1.0; none of the files at this directory level is a +placeholder waiting for a protocol freeze. + +| Fixture | Current role | +| --- | --- | +| `corpus.json` | Legacy byte-exact frame, CRC, fragmentation, response, and event codec vectors shared by firmware and client tests. Its scope is intentionally frame-level for several older operations. | +| `v061_session_vectors.json` | v0.6.1 HELLO grammar, version negotiation, session, direction/ID, and validation-precedence semantics shared by portable and compiled-native tests. | +| `v061_payload_grammar_vectors.json` | Exact existing-opcode payload acceptance and rejection semantics used for native/portable parity. | +| `v061_label_vectors.json` | Strict UTF-8 label and control-codepoint cases shared by both agent implementations. | +| `test_hil_wire.py` | Proves the HIL client's frame codec continues to consume the active byte corpus exactly. | + +The S3–S6 byte-less planning ledgers created before the corresponding protocol +freezes are preserved unchanged under `archive/pre-freeze/`. They explain why +wire bytes were deliberately not invented at that time; they are historical +records, not current requirements and not evidence that a test remains blocked. +Their individual ideas were implemented or superseded through the frozen +specifications and named portable/native host suites. No retrospective claim is +made that every old idea was copied into `corpus.json`. + +Host success is not physical qualification. v0.6.1 still requires fresh +exact-byte HIL on all five profiles, including the iPadOS and Android rows, +before release admission or publication. + +<!-- SPDX-License-Identifier: MIT --> diff --git a/tests/firmware_tests/host/conformance/archive/pre-freeze/README.md b/tests/firmware_tests/host/conformance/archive/pre-freeze/README.md new file mode 100644 index 00000000..4fc7f8ad --- /dev/null +++ b/tests/firmware_tests/host/conformance/archive/pre-freeze/README.md @@ -0,0 +1,25 @@ +# Archived S3–S6 pre-freeze ledgers + +These four JSON files are immutable planning records from the initial public +source snapshot. They describe the protocol/specification state observed by +their authors on 2026-07-01, before the dependent PBLE/1 sections were frozen. +Words such as `PENDING`, `DRAFT`, and `blocked_on` are preserved as historical +facts; they do not describe the current source tree and must not be consumed as +an active conformance gate. + +The files were moved here without changing a byte. Their SHA-256 identities +are: + +| File | SHA-256 | +| --- | --- | +| `s3_pending.json` | `7d9f9089c390a5f02a8b14ef8cc8fad77189a2270ab7a2132fbcbceed5b4327a` | +| `s4_pending.json` | `89f3f6f6e57172041715317cfa7f6cb0ca07c24ed0ac212632ef64a305a60b43` | +| `s5_pending.json` | `05e7b01e404155d332fb91c217d33214a92bb70aa1b3c3d0a3943cdc6cbbf737` | +| `s6_pending.json` | `96c51e3dbd0c591fdc2b3b148770ec9b1845f65e2ca22d09132035c8fad6a212` | + +In particular, the S3/S5 references to a `max_file_size` capability are not a +current requirement. The frozen serialized caps never included that key, and +the v0.6.1 contract uses dynamic `FILE_PUT_BEGIN` storage admission instead. +See the parent [current fixture ledger](../../README.md) for active coverage. + +<!-- SPDX-License-Identifier: MIT --> diff --git a/tests/firmware_tests/host/conformance/s3_pending.json b/tests/firmware_tests/host/conformance/archive/pre-freeze/s3_pending.json similarity index 100% rename from tests/firmware_tests/host/conformance/s3_pending.json rename to tests/firmware_tests/host/conformance/archive/pre-freeze/s3_pending.json diff --git a/tests/firmware_tests/host/conformance/s4_pending.json b/tests/firmware_tests/host/conformance/archive/pre-freeze/s4_pending.json similarity index 100% rename from tests/firmware_tests/host/conformance/s4_pending.json rename to tests/firmware_tests/host/conformance/archive/pre-freeze/s4_pending.json diff --git a/tests/firmware_tests/host/conformance/s5_pending.json b/tests/firmware_tests/host/conformance/archive/pre-freeze/s5_pending.json similarity index 100% rename from tests/firmware_tests/host/conformance/s5_pending.json rename to tests/firmware_tests/host/conformance/archive/pre-freeze/s5_pending.json diff --git a/tests/firmware_tests/host/conformance/s6_pending.json b/tests/firmware_tests/host/conformance/archive/pre-freeze/s6_pending.json similarity index 100% rename from tests/firmware_tests/host/conformance/s6_pending.json rename to tests/firmware_tests/host/conformance/archive/pre-freeze/s6_pending.json diff --git a/tests/firmware_tests/host/conformance/corpus.json b/tests/firmware_tests/host/conformance/corpus.json index 02be4876..754688d2 100644 --- a/tests/firmware_tests/host/conformance/corpus.json +++ b/tests/firmware_tests/host/conformance/corpus.json @@ -22,7 +22,7 @@ {"name": "set_label_rsp_ok", "type": 2, "opcode": 80, "id": 5, "payload_hex": "00", "frame_hex": "010250050100000f20dc78", "crc32": 2027692047}, {"name": "set_label_rsp_erange", "type": 2, "opcode": 80, "id": 6, "payload_hex": "09", "frame_hex": "010250060100094537b513", "crc32": 330643269} ], - "_s3_note": "run_rsp_* (0x20) and set_label_rsp_* (0x50) are FRAME-LEVEL vectors only: opcode is §4-frozen, payload[0] is a §8-frozen status per the frozen handler ABI (RSP payload = [status][extra]). They are NOT per-opcode PAYLOAD-SEMANTIC vectors — the HELLO/caps (§7), RUN{mode,path} (§6), and SET_LABEL body encodings are still DRAFT and MUST NOT be added here until §6/§7/§9 freeze (see s3_pending.json).", + "_s3_note": "run_rsp_* (0x20) and set_label_rsp_* (0x50) remain FRAME-LEVEL vectors: opcode is §4-frozen and payload[0] is a §8-frozen status. The byte-less S3 pre-freeze planning ledger is preserved under archive/pre-freeze/. Current HELLO/session, payload-grammar, and label semantics use the dedicated v0.6.1 vector files and named host suites; this corpus does not claim retrospective promotion of every historical semantic idea or completion of physical HIL.", "crc_reject": [ {"name": "hello_cmd_empty_corrupt_crc", "opcode": 1, "frame_hex": "010101010000e4568001", "note": "last CRC byte flipped from FE to 01; decode MUST reject and the dispatch path MUST answer EVT ERROR(ECRC) referencing opcode 0x01 (FR-PROTO-3)"} ] diff --git a/tests/firmware_tests/host/conformance/v061_label_vectors.json b/tests/firmware_tests/host/conformance/v061_label_vectors.json new file mode 100644 index 00000000..78f23238 --- /dev/null +++ b/tests/firmware_tests/host/conformance/v061_label_vectors.json @@ -0,0 +1,38 @@ +{ + "_meta": { + "description": "Shared PBLE/1 v0.6.1 SET_LABEL byte vectors. Length is checked before strict UTF-8 and control-scalar validation.", + "scope": "Portable Python and dependency-free compiled native C consume every case.", + "spdx": "MIT", + "version": 1 + }, + "constants": { + "label_max_bytes": 24 + }, + "cases": [ + {"name": "empty_clears", "payload_hex": "", "expected_status": 0}, + {"name": "plain_ascii", "payload_hex": "5079424c45", "expected_status": 0}, + {"name": "ascii_at_24_byte_limit", "payload_hex": "414141414141414141414141414141414141414141414141", "expected_status": 0}, + {"name": "multibyte_at_24_byte_limit", "payload_hex": "e282ace282ace282ace282ace282ace282ace282ace282ac", "expected_status": 0}, + {"name": "space_boundary_u0020", "payload_hex": "20", "expected_status": 0}, + {"name": "printable_ascii_boundary_u007e", "payload_hex": "7e", "expected_status": 0}, + {"name": "non_control_u00a0", "payload_hex": "c2a0", "expected_status": 0}, + {"name": "valid_max_scalar", "payload_hex": "f48fbfbf", "expected_status": 0}, + {"name": "ascii_25_bytes", "payload_hex": "41414141414141414141414141414141414141414141414141", "expected_status": 9}, + {"name": "length_precedes_malformed_utf8", "payload_hex": "41414141414141414141414141414141414141414141414180", "expected_status": 9}, + {"name": "embedded_nul", "payload_hex": "410042", "expected_status": 1}, + {"name": "c0_boundary_u001f", "payload_hex": "1f", "expected_status": 1}, + {"name": "ascii_tab", "payload_hex": "09", "expected_status": 1}, + {"name": "ascii_lf", "payload_hex": "0a", "expected_status": 1}, + {"name": "ascii_cr", "payload_hex": "0d", "expected_status": 1}, + {"name": "del_u007f", "payload_hex": "7f", "expected_status": 1}, + {"name": "c1_boundary_u0080", "payload_hex": "c280", "expected_status": 1}, + {"name": "c1_nel_u0085", "payload_hex": "c285", "expected_status": 1}, + {"name": "c1_boundary_u009f", "payload_hex": "c29f", "expected_status": 1}, + {"name": "lone_continuation", "payload_hex": "80", "expected_status": 1}, + {"name": "overlong_two_byte_nul", "payload_hex": "c080", "expected_status": 1}, + {"name": "overlong_three_byte_nul", "payload_hex": "e08080", "expected_status": 1}, + {"name": "utf16_surrogate", "payload_hex": "eda080", "expected_status": 1}, + {"name": "above_unicode_max", "payload_hex": "f4908080", "expected_status": 1}, + {"name": "truncated_multibyte", "payload_hex": "e282", "expected_status": 1} + ] +} diff --git a/tests/firmware_tests/host/conformance/v061_payload_grammar_vectors.json b/tests/firmware_tests/host/conformance/v061_payload_grammar_vectors.json new file mode 100644 index 00000000..64b2ccfd --- /dev/null +++ b/tests/firmware_tests/host/conformance/v061_payload_grammar_vectors.json @@ -0,0 +1,125 @@ +{ + "schema": 1, + "description": "PBLE/1 v0.6.1 exact opcode-payload grammar shared by portable and native host tests.", + "structured": [ + { + "name": "HELLO", + "opcode": 1, + "valid_payloads_hex": [ + "70726f746f5f76657273696f6e733d310a6170705f6e616d653d5079424c450a6170705f76657273696f6e3d302e322e30" + ], + "invalid_payload_hex": "70726f746f5f76657273696f6e733d310a6170705f6e616d653d5079424c450a6170705f76657273696f6e3d302e322e3000" + }, + { + "name": "DEVICE_INFO", + "opcode": 2, + "valid_payloads_hex": [""], + "invalid_payload_hex": "ff" + }, + { + "name": "FILE_LIST", + "opcode": 16, + "valid_payloads_hex": ["01002f"], + "invalid_payload_hex": "01002fff" + }, + { + "name": "FILE_STAT", + "opcode": 17, + "valid_payloads_hex": ["01002f"], + "invalid_payload_hex": "01002fff" + }, + { + "name": "FILE_GET_BEGIN", + "opcode": 18, + "valid_payloads_hex": ["0000000001002f"], + "invalid_payload_hex": "0000000001002fff" + }, + { + "name": "FILE_PUT_BEGIN", + "opcode": 21, + "valid_payloads_hex": ["000000000000000001002f"], + "invalid_payload_hex": "000000000000000001002fff" + }, + { + "name": "FILE_PUT_END", + "opcode": 23, + "valid_payloads_hex": ["00000000"], + "invalid_payload_hex": "00000000ff" + }, + { + "name": "FILE_DELETE", + "opcode": 24, + "valid_payloads_hex": ["01002f"], + "invalid_payload_hex": "01002fff" + }, + { + "name": "MKDIR", + "opcode": 25, + "valid_payloads_hex": ["01002f"], + "invalid_payload_hex": "01002fff" + }, + { + "name": "FILE_RENAME", + "opcode": 26, + "valid_payloads_hex": ["01002f02002f78"], + "invalid_payload_hex": "01002f02002f78ff" + }, + { + "name": "STOP", + "opcode": 33, + "valid_payloads_hex": [""], + "invalid_payload_hex": "ff" + }, + { + "name": "SOFT_REBOOT", + "opcode": 34, + "valid_payloads_hex": [""], + "invalid_payload_hex": "ff" + }, + { + "name": "SET_AUTORUN", + "opcode": 35, + "valid_payloads_hex": ["01"], + "invalid_payload_hex": "0100" + }, + { + "name": "SET_IDENTIFY_LED", + "opcode": 81, + "valid_payloads_hex": ["", "0001"], + "invalid_payload_hex": "0001ff" + }, + { + "name": "IDENTIFY", + "opcode": 82, + "valid_payloads_hex": ["", "14"], + "invalid_payload_hex": "14ff" + } + ], + "terminal_bytes": [ + { + "name": "RUN file data", + "opcode": 32, + "payload_hex": "006d61696e2e70792b7461696c" + }, + { + "name": "RUN source data", + "opcode": 32, + "payload_hex": "017072696e74283129237461696c" + }, + { + "name": "FILE_PUT_DATA bytes", + "opcode": 22, + "payload_hex": "00000000aabbcc" + }, + { + "name": "CONSOLE_INPUT bytes", + "opcode": 49, + "payload_hex": "6162630078797a" + }, + { + "name": "SET_LABEL bytes", + "opcode": 80, + "payload_hex": "4c616220ceb121" + } + ] +} diff --git a/tests/firmware_tests/host/conformance/v061_session_vectors.json b/tests/firmware_tests/host/conformance/v061_session_vectors.json new file mode 100644 index 00000000..4a818412 --- /dev/null +++ b/tests/firmware_tests/host/conformance/v061_session_vectors.json @@ -0,0 +1,386 @@ +{ + "_meta": { + "description": "Shared PBLE/1 v0.6.1 semantic vectors for strict HELLO parsing, per-session negotiation, inbound direction/ID gates, and validation precedence. Payloads are exact bytes; status values are PBLE/1 section 8 bytes.", + "scope": "Portable Python and compiled native C consume the agent expectations; Dart consumes the canonical client HELLO bytes and frame fields.", + "spdx": "MIT", + "version": 1 + }, + "constants": { + "protocol_version": 1, + "hello_max_bytes": 192, + "hello_max_offers": 8, + "hello_key_max_bytes": 32, + "hello_value_max_bytes": 32, + "hello_app_field_max_bytes": 32, + "rx_reassembly_deadline_ms": 5000, + "session_violation_limit": 8 + }, + "hello_cases": [ + { + "name": "canonical_current_app", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 0, + "negotiates_v1": true, + "violation_delta": 0 + }, + { + "name": "offer_order_unknown_key_and_trailing_lf", + "payload_ascii": "proto_versions=2,1\napp_name=Third Party\napp_version=1.2.3\nfuture=x=y\n", + "expected_status": 0, + "negotiates_v1": true, + "violation_delta": 0 + }, + { + "name": "two_trailing_lf_are_not_canonical", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\n\n", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "well_formed_offer_without_v1", + "payload_ascii": "proto_versions=2\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 10, + "negotiates_v1": false, + "violation_delta": 0 + }, + { + "name": "version_decimal_leading_zero_is_not_canonical", + "payload_ascii": "proto_versions=01\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "version_decimal_exceeds_uint8", + "payload_ascii": "proto_versions=256\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "more_than_eight_version_offers", + "payload_ascii": "proto_versions=0,1,2,3,4,5,6,7,8\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "missing_proto_versions", + "payload_ascii": "app_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "duplicate_required_key", + "payload_ascii": "proto_versions=1\nproto_versions=1\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "duplicate_app_name", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_name=Other\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "duplicate_app_version", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\napp_version=0.2.1", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "empty_version_list_element", + "payload_ascii": "proto_versions=1,,2\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "empty_interior_line", + "payload_ascii": "proto_versions=1\n\napp_name=PyBLE\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "invalid_unknown_key_character", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\nbad-key=x", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "unknown_key_33_bytes", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\nkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk=x", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "carriage_return_is_not_hello_ascii", + "payload_hex": "70726f746f5f76657273696f6e733d310d0a6170705f6e616d653d5079424c450a6170705f76657273696f6e3d302e322e30", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "non_ascii_app_name", + "payload_hex": "70726f746f5f76657273696f6e733d310a6170705f6e616d653dff0a6170705f76657273696f6e3d302e322e30", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "app_name_33_bytes", + "payload_ascii": "proto_versions=1\napp_name=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\napp_version=0.2.0", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "empty_app_version", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "app_version_33_bytes", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=111111111111111111111111111111111", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "unknown_value_33_bytes", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\nfuture=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "unknown_value_nonprintable", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\nfuture=x\ty", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + }, + { + "name": "unknown_value_splits_at_first_equals", + "payload_ascii": "proto_versions=1\napp_name=PyBLE\napp_version=0.2.0\nfuture=x=y", + "expected_status": 0, + "negotiates_v1": true, + "violation_delta": 0 + }, + { + "name": "payload_193_bytes", + "payload_hex": "70726f746f5f76657273696f6e733d310a6170705f6e616d653d5079424c450a6170705f76657273696f6e3d302e322e300a6675747572655f613d41414141414141414141414141414141414141414141414141414141414141410a6675747572655f623d42424242424242424242424242424242424242424242424242424242424242420a6675747572655f633d43434343434343434343434343434343434343434343434343434343434343430a65787472613d4444444444444444444444", + "expected_status": 1, + "negotiates_v1": false, + "violation_delta": 1 + } + ], + "inbound_cases": [ + { + "name": "pre_hello_response_bearing_command", + "initial_state": "unnegotiated", + "frame": {"ver": 1, "type": 1, "opcode": 2, "id": 7, "payload_hex": ""}, + "guard": {"action": "rsp", "opcode": 2, "id": 7, "status": 1}, + "expected": {"wire": "rsp", "opcode": 2, "id": 7, "status": 1}, + "violation_delta": 1 + }, + { + "name": "pre_hello_no_response_command_has_no_effect", + "initial_state": "unnegotiated", + "frame": {"ver": 1, "type": 1, "opcode": 49, "id": 8, "payload_hex": "78"}, + "guard": {"action": "drop"}, + "expected": {"wire": "none", "effect": "console_input_empty"}, + "violation_delta": 1 + }, + { + "name": "post_hello_command_is_admitted", + "initial_state": "negotiated", + "frame": {"ver": 1, "type": 1, "opcode": 2, "id": 9, "payload_hex": ""}, + "guard": {"action": "dispatch", "opcode": 2, "id": 9}, + "expected": {"wire": "rsp", "opcode": 2, "id": 9, "status": 0}, + "violation_delta": 0 + }, + { + "name": "valid_crc_inbound_rsp_is_silent", + "initial_state": "negotiated", + "frame": {"ver": 1, "type": 2, "opcode": 2, "id": 10, "payload_hex": "00"}, + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "valid_crc_inbound_evt_is_silent", + "initial_state": "negotiated", + "frame": {"ver": 1, "type": 3, "opcode": 64, "id": 0, "payload_hex": "01"}, + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "valid_crc_command_id_zero_is_silent", + "initial_state": "negotiated", + "frame": {"ver": 1, "type": 1, "opcode": 2, "id": 0, "payload_hex": ""}, + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "bad_crc_command_precedes_session_gate", + "initial_state": "unnegotiated", + "frame": {"ver": 1, "type": 1, "opcode": 2, "id": 11, "payload_hex": "", "corrupt_crc": true}, + "guard": {"action": "evt", "opcode": 2, "id": 0, "status": 8}, + "expected": {"wire": "evt", "opcode": 2, "id": 0, "status": 8}, + "violation_delta": 1 + }, + { + "name": "bad_crc_precedes_wrong_direction_gate", + "initial_state": "unnegotiated", + "frame": {"ver": 1, "type": 2, "opcode": 2, "id": 12, "payload_hex": "00", "corrupt_crc": true}, + "guard": {"action": "evt", "opcode": 2, "id": 0, "status": 8}, + "expected": {"wire": "evt", "opcode": 2, "id": 0, "status": 8}, + "violation_delta": 1 + }, + { + "name": "valid_crc_unsupported_frame_version", + "initial_state": "negotiated", + "frame": {"ver": 2, "type": 1, "opcode": 2, "id": 13, "payload_hex": ""}, + "guard": {"action": "rsp", "opcode": 2, "id": 13, "status": 1}, + "expected": {"wire": "rsp", "opcode": 2, "id": 13, "status": 1}, + "violation_delta": 1 + }, + { + "name": "short_input_without_safe_correlation_is_silent", + "initial_state": "negotiated", + "raw_hex": "010101", + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "length_mismatch_with_safe_header_is_refused", + "initial_state": "negotiated", + "raw_hex": "010102090500f75c519f", + "guard": {"action": "rsp", "opcode": 2, "id": 9, "status": 1}, + "expected": {"wire": "rsp", "opcode": 2, "id": 9, "status": 1}, + "violation_delta": 1 + }, + { + "name": "length_mismatch_wrong_direction_is_silent", + "initial_state": "negotiated", + "raw_hex": "01020209050000000000", + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "length_mismatch_zero_id_is_silent", + "initial_state": "negotiated", + "raw_hex": "01010200050000000000", + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "length_mismatch_unsupported_version_is_silent", + "initial_state": "negotiated", + "raw_hex": "02010209050000000000", + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "unknown_opcode_after_hello_is_not_a_violation", + "initial_state": "negotiated", + "frame": {"ver": 1, "type": 1, "opcode": 126, "id": 14, "payload_hex": ""}, + "guard": {"action": "rsp", "opcode": 126, "id": 14, "status": 10}, + "expected": {"wire": "rsp", "opcode": 126, "id": 14, "status": 10}, + "violation_delta": 0 + }, + { + "name": "valid_crc_wrong_direction_precedes_unsupported_version", + "initial_state": "negotiated", + "frame": {"ver": 2, "type": 2, "opcode": 2, "id": 15, "payload_hex": "00"}, + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "valid_crc_zero_id_precedes_unsupported_version", + "initial_state": "negotiated", + "frame": {"ver": 2, "type": 1, "opcode": 2, "id": 0, "payload_hex": ""}, + "guard": {"action": "drop"}, + "expected": {"wire": "none"}, + "violation_delta": 1 + }, + { + "name": "pre_hello_unknown_opcode_hits_session_gate_first", + "initial_state": "unnegotiated", + "frame": {"ver": 1, "type": 1, "opcode": 126, "id": 16, "payload_hex": ""}, + "guard": {"action": "rsp", "opcode": 126, "id": 16, "status": 1}, + "expected": {"wire": "rsp", "opcode": 126, "id": 16, "status": 1}, + "violation_delta": 1 + } + ], + "session_sequences": [ + { + "name": "compatible_repeat_is_idempotent", + "steps": [ + {"action": "hello", "case": "canonical_current_app", "id": 1, "wire": "rsp", "status": 0}, + {"action": "hello", "case": "canonical_current_app", "id": 2, "wire": "rsp", "status": 0}, + {"action": "command", "opcode": 2, "id": 3, "wire": "rsp", "status": 0} + ] + }, + { + "name": "failed_repeat_preserves_negotiated_state", + "steps": [ + {"action": "hello", "case": "canonical_current_app", "id": 4, "wire": "rsp", "status": 0}, + {"action": "hello", "case": "well_formed_offer_without_v1", "id": 5, "wire": "rsp", "status": 10}, + {"action": "command", "opcode": 2, "id": 6, "wire": "rsp", "status": 0} + ] + }, + { + "name": "malformed_repeat_preserves_negotiated_state", + "steps": [ + {"action": "hello", "case": "canonical_current_app", "id": 11, "wire": "rsp", "status": 0}, + {"action": "hello", "case": "missing_proto_versions", "id": 12, "wire": "rsp", "status": 1}, + {"action": "command", "opcode": 2, "id": 13, "wire": "rsp", "status": 0} + ] + }, + { + "name": "disconnect_and_reconnect_clear_negotiation", + "steps": [ + {"action": "hello", "case": "canonical_current_app", "id": 7, "wire": "rsp", "status": 0}, + {"action": "reconnect"}, + {"action": "command", "opcode": 2, "id": 8, "wire": "rsp", "status": 1} + ] + }, + { + "name": "unpublished_hello_does_not_negotiate", + "steps": [ + {"action": "hello", "case": "canonical_current_app", "id": 9, "accept_send": false, "wire": "none"}, + {"action": "command", "opcode": 2, "id": 10, "wire": "rsp", "status": 1} + ] + } + ], + "violation_budget_case": { + "name": "eighth_pre_hello_command_terminates_once_without_reply", + "frame": {"ver": 1, "type": 1, "opcode": 2, "payload_hex": ""}, + "first_id": 100, + "responses_before_limit": 7, + "response_status_before_limit": 1, + "wire_at_limit": "none", + "termination_count_at_limit": 1, + "wire_after_limit": "none", + "termination_count_after_limit": 1 + } +} diff --git a/tests/firmware_tests/host/native/failed_session_termination_harness.c b/tests/firmware_tests/host/native/failed_session_termination_harness.c index 35d1dbef..e8a08225 100644 --- a/tests/firmware_tests/host/native/failed_session_termination_harness.c +++ b/tests/firmware_tests/host/native/failed_session_termination_harness.c @@ -640,6 +640,30 @@ static void test_open_generation_rotation(void) { assert(state.generation == UINT64_C(111)); } +static void test_preclose_failure_claims_terminal_restart(void) { + pble_term_state_t state; + pble_term_init(&state); + assert(pble_term_open(&state, UINT16_C(50), UINT64_C(120))); + + assert_effects( + pble_term_preclose_failed(&state, UINT16_C(51), UINT64_C(120)), + PBLE_TERM_EFFECT_NONE); + assert_effects( + pble_term_preclose_failed(&state, UINT16_C(50), UINT64_C(119)), + PBLE_TERM_EFFECT_NONE); + assert(state.phase == PBLE_TERM_PHASE_OPEN); + + assert_effects( + pble_term_preclose_failed(&state, UINT16_C(50), UINT64_C(120)), + PBLE_TERM_EFFECT_RESTART); + assert(state.phase == PBLE_TERM_PHASE_RESTARTING); + assert_effects( + pble_term_disconnect(&state, UINT16_C(50), UINT64_C(120)), + PBLE_TERM_EFFECT_NONE); + assert_effects(pble_term_reset(&state), PBLE_TERM_EFFECT_NONE); + assert(!pble_term_open(&state, UINT16_C(50), UINT64_C(121))); +} + int main(void) { test_begin_and_terminal_deadline(); test_exact_cleanup_and_timer_stop(); @@ -648,6 +672,7 @@ int main(void) { test_mock_adapter_cleanup_matrix(); test_mock_adapter_callback_and_stale_matrix(); test_open_generation_rotation(); + test_preclose_failure_claims_terminal_restart(); puts("failed-session termination reducer: all scenarios passed"); return 0; } diff --git a/tests/firmware_tests/host/native/v061_pble_wire_harness.c b/tests/firmware_tests/host/native/v061_pble_wire_harness.c new file mode 100644 index 00000000..4c878368 --- /dev/null +++ b/tests/firmware_tests/host/native/v061_pble_wire_harness.c @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. +// +// Dependency-free host harness for the v0.6.1 pble_wire contract. This is a +// real compiled consumer of the same production C unit linked into firmware; +// it deliberately includes no ESP-IDF, FreeRTOS, MicroPython, or test shim. + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "pble_wire.h" + +#define LINE_CAP 16384u +#define BYTES_CAP 8192u + +static int hex_nibble(char value) { + if (value >= '0' && value <= '9') { + return value - '0'; + } + if (value >= 'a' && value <= 'f') { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') { + return value - 'A' + 10; + } + return -1; +} + +static bool decode_hex(const char *text, uint8_t *out, size_t *out_len) { + if (strcmp(text, "-") == 0) { + *out_len = 0; + return true; + } + size_t chars = strlen(text); + if ((chars & 1u) != 0u || chars / 2u > BYTES_CAP) { + return false; + } + for (size_t i = 0; i < chars / 2u; ++i) { + int high = hex_nibble(text[2u * i]); + int low = hex_nibble(text[2u * i + 1u]); + if (high < 0 || low < 0) { + return false; + } + out[i] = (uint8_t)((high << 4) | low); + } + *out_len = chars / 2u; + return true; +} + +static const char *action_name(pble_wire_action_t action) { + switch (action) { + case PBLE_WIRE_DROP: + return "drop"; + case PBLE_WIRE_DISPATCH: + return "dispatch"; + case PBLE_WIRE_RSP: + return "rsp"; + case PBLE_WIRE_EVT: + return "evt"; + default: + return "invalid"; + } +} + +int main(void) { + char line[LINE_CAP]; + uint8_t bytes[BYTES_CAP]; + pble_wire_session_t session; + pble_wire_session_reset(&session); + + while (fgets(line, sizeof(line), stdin) != NULL) { + char *command = strtok(line, " \t\r\n"); + if (command == NULL) { + continue; + } + if (strcmp(command, "RESET") == 0) { + pble_wire_session_reset(&session); + printf("RESET %u\n", + pble_wire_session_negotiated(&session) ? 1u : 0u); + continue; + } + if (strcmp(command, "LABEL") == 0) { + char *hex = strtok(NULL, " \t\r\n"); + size_t len = 0; + if (hex == NULL || !decode_hex(hex, bytes, &len)) { + fputs("invalid LABEL input\n", stderr); + return 2; + } + printf("LABEL %u\n", (unsigned)pble_wire_label_status(bytes, len)); + continue; + } + if (strcmp(command, "FRAME") == 0) { + char *published_text = strtok(NULL, " \t\r\n"); + char *hex = strtok(NULL, " \t\r\n"); + size_t len = 0; + if (published_text == NULL || hex == NULL || + (strcmp(published_text, "0") != 0 && + strcmp(published_text, "1") != 0) || + !decode_hex(hex, bytes, &len)) { + fputs("invalid FRAME input\n", stderr); + return 2; + } + pble_wire_decision_t decision; + pble_wire_decide(&session, bytes, len, &decision); + if (published_text[0] == '1' && decision.commit_hello) { + pble_wire_session_commit_v1(&session); + } + printf("FRAME %s %u %u %u %u %u %u\n", + action_name(decision.action), + (unsigned)decision.opcode, + (unsigned)decision.id, + (unsigned)decision.status, + decision.violation ? 1u : 0u, + decision.commit_hello ? 1u : 0u, + pble_wire_session_negotiated(&session) ? 1u : 0u); + continue; + } + fputs("unknown harness command\n", stderr); + return 2; + } + return ferror(stdin) ? 3 : 0; +} diff --git a/tests/firmware_tests/host/test_agent_version_lock.py b/tests/firmware_tests/host/test_agent_version_lock.py index d2d36ae5..52ca6610 100644 --- a/tests/firmware_tests/host/test_agent_version_lock.py +++ b/tests/firmware_tests/host/test_agent_version_lock.py @@ -18,7 +18,7 @@ ROOT / "docs" / "validation" / "browser-flashing" / "v0.4.2-production.json" ) -CURRENT_VERSION = "0.6.0" +CURRENT_VERSION = "0.6.1" class AgentVersionLockTests(unittest.TestCase): diff --git a/tests/firmware_tests/host/test_build_rp2_retained_source.py b/tests/firmware_tests/host/test_build_rp2_retained_source.py index af6d91bc..34772826 100644 --- a/tests/firmware_tests/host/test_build_rp2_retained_source.py +++ b/tests/firmware_tests/host/test_build_rp2_retained_source.py @@ -5,6 +5,8 @@ from __future__ import annotations +import hashlib +import io import json import os from pathlib import Path @@ -16,6 +18,7 @@ import tempfile import textwrap import unittest +import zipfile ROOT = Path(__file__).resolve().parents[3] @@ -32,6 +35,72 @@ "lib/pico-sdk", "lib/tinyusb", ) +PICOTOOL_VERSION_LINE = ( + "picotool v2.3.0 (Darwin, AppleClang-15.0.0.15000309, Release)" +) +PICOTOOL_EXECUTABLE = ( + b"#!/bin/sh\n" + b"[ \"${1:-}\" = version ] || exit 64\n" + + f"printf '%s\\n' '{PICOTOOL_VERSION_LINE}'\n".encode("utf-8") +) +PICOTOOL_CONFIG = b'include("${CMAKE_CURRENT_LIST_DIR}/picotoolTargets.cmake")\n' +PICOTOOL_CONFIG_VERSION = b'set(PACKAGE_VERSION "2.3.0")\n' +PICOTOOL_TARGETS = b'include("${CMAKE_CURRENT_LIST_DIR}/picotoolTargets-release.cmake")\n' +PICOTOOL_TARGETS_RELEASE = ( + b'set_target_properties(picotool PROPERTIES IMPORTED_LOCATION_RELEASE ' + b'"${_IMPORT_PREFIX}/picotool/picotool")\n' +) +PICOTOOL_LIBUSB = b"synthetic pinned libusb bytes\n" + + +def _picotool_archive( + executable: bytes = PICOTOOL_EXECUTABLE, +) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + for name, value, mode in ( + (".keep", b"", stat.S_IFREG | 0o644), + ("picotool/", b"", stat.S_IFDIR | 0o755), + ("picotool/picotool", executable, stat.S_IFREG | 0o755), + ( + "picotool/picotoolConfig.cmake", + PICOTOOL_CONFIG, + stat.S_IFREG | 0o644, + ), + ( + "picotool/picotoolConfigVersion.cmake", + PICOTOOL_CONFIG_VERSION, + stat.S_IFREG | 0o644, + ), + ( + "picotool/picotoolTargets.cmake", + PICOTOOL_TARGETS, + stat.S_IFREG | 0o644, + ), + ( + "picotool/picotoolTargets-release.cmake", + PICOTOOL_TARGETS_RELEASE, + stat.S_IFREG | 0o644, + ), + ( + "picotool/libusb-1.0.0.dylib", + PICOTOOL_LIBUSB, + stat.S_IFREG | 0o444, + ), + ): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.date_time = (2020, 1, 1, 0, 0, 0) + info.external_attr = mode << 16 + archive.writestr(info, value) + return output.getvalue() + + +PICOTOOL_ARCHIVE = _picotool_archive() + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() def _run( @@ -77,7 +146,7 @@ def _git(path: Path, *arguments: str) -> str: def _write_executable(path: Path, source: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(textwrap.dedent(source).lstrip(), encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IXUSR) + path.chmod(0o755) def _commit_all(path: Path, message: str) -> str: @@ -166,8 +235,12 @@ def __init__(self) -> None: self.make_log = self.base / "make.jsonl" self.prepare_log = self.base / "prepare.log" self.toolchain = self.base / "arm-gnu" + self.picotool = self.base / "pinned-picotool" + self.picotool_executable = self.picotool / "picotool" / "picotool" + self.ambient_picotool_marker = self.base / "ambient-picotool-ran" self._make_canonical_checkout() + self._make_pinned_picotool() self._make_pyble_checkout() self._make_fake_tools() self._make_fake_artifacts() @@ -289,7 +362,7 @@ def _make_pyble_checkout(self) -> None: commit = "{commit}" [pyble] - agent_version = "0.6.0" + agent_version = "0.6.1" protocol_version = "PBLE/1" [targets_rp2] @@ -298,10 +371,38 @@ def _make_pyble_checkout(self) -> None: [arm_gnu_toolchain] release = "14.2.Rel1" gcc_version = "14.2.1 20241119" + + [picotool] + version = "2.3.0" + version_line = "{picotool_version_line}" + source_repo = "https://github.com/raspberrypi/picotool.git" + source_ref = "2.3.0" + source_commit = "6f6458d792b93685a11423b244a585eaa99eafcf" + distribution_repo = "https://github.com/raspberrypi/pico-sdk-tools.git" + distribution_ref = "v2.3.0-0" + distribution_commit = "ad9e4a8375253cf4886bf168ea1a8d2746aadf24" + url = "https://example.invalid/picotool-2.3.0-test-mac.zip" + archive_filename = "picotool-2.3.0-test-mac.zip" + archive_bytes = {picotool_archive_bytes} + archive_format = "zip" + sha256 = "{picotool_archive_sha256}" + cmake_dir = "picotool" + executable_path = "picotool/picotool" + executable_sha256 = "{picotool_executable_sha256}" + cmake_config_path = "picotool/picotoolConfig.cmake" + cmake_config_sha256 = "{picotool_config_sha256}" + bundled_libusb_path = "picotool/libusb-1.0.0.dylib" + bundled_libusb_sha256 = "{picotool_libusb_sha256}" """ ).format( origin=MICROPYTHON_ORIGIN, commit=self.micropython_commit, + picotool_version_line=PICOTOOL_VERSION_LINE, + picotool_archive_bytes=len(PICOTOOL_ARCHIVE), + picotool_archive_sha256=_sha256(PICOTOOL_ARCHIVE), + picotool_executable_sha256=_sha256(PICOTOOL_EXECUTABLE), + picotool_config_sha256=_sha256(PICOTOOL_CONFIG), + picotool_libusb_sha256=_sha256(PICOTOOL_LIBUSB), ).lstrip(), encoding="utf-8", ) @@ -313,6 +414,34 @@ def _make_pyble_checkout(self) -> None: self._configure_git(self.repo) self.pyble_commit = _commit_all(self.repo, "PyBLE fixture") + def _make_pinned_picotool(self) -> None: + _write_executable( + self.picotool_executable, + PICOTOOL_EXECUTABLE.decode("utf-8"), + ) + (self.picotool / "picotool" / "picotoolConfig.cmake").write_bytes( + PICOTOOL_CONFIG + ) + ( + self.picotool / "picotool" / "picotoolConfigVersion.cmake" + ).write_bytes(PICOTOOL_CONFIG_VERSION) + (self.picotool / "picotool" / "picotoolTargets.cmake").write_bytes( + PICOTOOL_TARGETS + ) + ( + self.picotool / "picotool" / "picotoolTargets-release.cmake" + ).write_bytes(PICOTOOL_TARGETS_RELEASE) + (self.picotool / "picotool" / "libusb-1.0.0.dylib").write_bytes( + PICOTOOL_LIBUSB + ) + (self.picotool / "picotool" / "libusb-1.0.0.dylib").chmod(0o444) + (self.picotool / ".keep").write_bytes(b"") + retained = self.picotool / ".pyble-dist" + retained.mkdir() + (retained / "picotool-2.3.0-test-mac.zip").write_bytes( + PICOTOOL_ARCHIVE + ) + def _make_fake_tools(self) -> None: _write_executable( self.toolchain / "bin" / "arm-none-eabi-gcc", @@ -331,10 +460,11 @@ def _make_fake_tools(self) -> None: ) _write_executable( self.fake_bin / "picotool", - """ + r""" #!/usr/bin/env bash [ "${1:-}" = version ] - echo 'picotool v2.3.0 (PyBLE fixture)' + : > "${PYBLE_AMBIENT_PICOTOOL_MARKER:?}" + echo 'picotool v9.9.9 (ambient PATH fixture)' """, ) _write_executable( @@ -368,6 +498,14 @@ def _make_fake_tools(self) -> None: "CXXFLAGS", "ASMFLAGS", "CPPFLAGS", "EXTRA_CPPFLAGS", "EXTRA_CFLAGS", "EXTRA_CXXFLAGS", "MAKEFLAGS", "MFLAGS", "GNUMAKEFLAGS", "MAKEOVERRIDES", "PYTHONDONTWRITEBYTECODE", + "CMAKE_ARGS", "picotool_DIR", + "FETCHCONTENT_FULLY_DISCONNECTED", + "FETCHCONTENT_SOURCE_DIR_PICOTOOL", + "PICOTOOL_FETCH_FROM_GIT_PATH", + "PICOTOOL_FORCE_FETCH_FROM_GIT", + "CMAKE_FIND_USE_PACKAGE_REGISTRY", + "CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY", + "CMAKE_PREFIX_PATH", ) record = { "cwd": os.getcwd(), @@ -443,6 +581,24 @@ def _make_fake_tools(self) -> None: "$PYBLE_ARM_TOOLCHAIN_DIR" printf 'CMAKE_ASM_COMPILER:FILEPATH=%s/bin/arm-none-eabi-gcc\n' \ "$PYBLE_ARM_TOOLCHAIN_DIR" + printf 'picotool_DIR:UNINITIALIZED=%s/picotool\n' \ + "$PYBLE_PICOTOOL_DIR" + case "${PYBLE_TEST_MUTATION:-}" in + fetchcontent-cache-uninitialized) + printf 'FETCHCONTENT_FULLY_DISCONNECTED:UNINITIALIZED=ON\n' + ;; + fetchcontent-cache-off) + printf 'FETCHCONTENT_FULLY_DISCONNECTED:BOOL=OFF\n' + ;; + fetchcontent-cache-missing) + ;; + *) + printf 'FETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON\n' + ;; + esac + printf 'PICOTOOL_FORCE_FETCH_FROM_GIT:UNINITIALIZED=OFF\n' + printf 'CMAKE_FIND_USE_PACKAGE_REGISTRY:UNINITIALIZED=OFF\n' + printf 'CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY:UNINITIALIZED=OFF\n' } > "$output/CMakeCache.txt" if [ "${PYBLE_TEST_MUTATION:-}" = "provenance-temp-write-fail" ]; then @@ -476,6 +632,10 @@ def execute( "PATH": "{}:{}".format(self.fake_bin, os.environ.get("PATH", "")), "PYBLE_BUILD_ROOT": str(self.build_root), "PYBLE_ARM_TOOLCHAIN_DIR": str(self.toolchain), + "PYBLE_PICOTOOL_DIR": str(self.picotool), + "PYBLE_AMBIENT_PICOTOOL_MARKER": str( + self.ambient_picotool_marker + ), "PYBLE_LOCK_FILE": str(self.firmware / "versions.lock"), "PYBLE_FAKE_ARTIFACTS": str(self.fake_artifacts), "PYBLE_MAKE_LOG": str(self.make_log), @@ -503,6 +663,14 @@ def execute( "MFLAGS": "-DHOSTILE_MFLAGS=1", "GNUMAKEFLAGS": "CFLAGS=-DHOSTILE_GNUMAKEFLAGS=1", "MAKEOVERRIDES": "CFLAGS CFLAGS_EXTRA", + "picotool_DIR": hostile + "/ambient-picotool-package", + "FETCHCONTENT_FULLY_DISCONNECTED": "OFF", + "FETCHCONTENT_SOURCE_DIR_PICOTOOL": hostile + "/fetched-picotool", + "PICOTOOL_FETCH_FROM_GIT_PATH": hostile + "/git-picotool", + "PICOTOOL_FORCE_FETCH_FROM_GIT": "ON", + "CMAKE_FIND_USE_PACKAGE_REGISTRY": "ON", + "CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY": "ON", + "CMAKE_PREFIX_PATH": hostile + "/homebrew-prefix", } return _run( [str(self.firmware / "scripts" / "build_rp2.sh"), TARGET], @@ -660,6 +828,10 @@ def test_success_is_offline_isolated_deterministic_and_canonical_immutable(self) for record in port_records: self.assertIn(str(fixture.retained.resolve()), " ".join(record["args"])) self.assertEqual(fixture.canonical_snapshot(), before) + self.assertFalse( + fixture.ambient_picotool_marker.exists(), + "the RP2 build invoked the ambient PATH picotool", + ) records = fixture.make_records() mpy_records = [ record @@ -690,6 +862,40 @@ def test_success_is_offline_isolated_deterministic_and_canonical_immutable(self) serialized = json.dumps(records, sort_keys=True) self.assertNotIn("HOSTILE", serialized) final = final_records[0] + self.assertFalse( + any( + str(argument).startswith("CMAKE_ARGS=") + for argument in final["args"] + ), + "a command-line CMAKE_ARGS assignment prevents the upstream " + "RP2 Makefile from appending its board and manifest settings", + ) + self.assertIn("CMAKE_ARGS", final["env"]) + build_configuration = " ".join( + [*final["args"], *final["env"].values()] + ) + self.assertIn( + "-Dpicotool_DIR={}".format( + fixture.picotool.resolve() / "picotool" + ), + build_configuration, + ) + self.assertIn( + "-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON", + build_configuration, + ) + self.assertIn( + "-DPICOTOOL_FORCE_FETCH_FROM_GIT=OFF", + build_configuration, + ) + self.assertIn( + "-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF", + build_configuration, + ) + self.assertIn( + "-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF", + build_configuration, + ) compile_configuration = " ".join( [*final["args"], *final["env"].values()] ) @@ -716,9 +922,120 @@ def test_success_is_offline_isolated_deterministic_and_canonical_immutable(self) ) with self.subTest(language=language): self.assertIn("-ffile-prefix-map=", language_configuration) + + provenance = json.loads( + (fixture.output / "pyble-build-provenance.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(provenance["picotool"], PICOTOOL_VERSION_LINE) finally: fixture.cleanup() + def test_mismatched_pinned_picotool_fails_without_ambient_fallback(self) -> None: + fixture = RP2BuildFixture() + try: + fixture.picotool_executable.write_bytes( + fixture.picotool_executable.read_bytes() + b"# changed\n" + ) + fixture.picotool_executable.chmod(0o755) + + completed = fixture.execute() + + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertFalse(fixture.ambient_picotool_marker.exists()) + self.assertEqual([], fixture.make_records()) + self.assertFalse(fixture.output.exists()) + self.assertFalse(fixture.retained.exists()) + finally: + fixture.cleanup() + + def test_every_retained_picotool_identity_is_checked_before_make(self) -> None: + def nonzero_version(fixture: RP2BuildFixture) -> None: + changed = PICOTOOL_EXECUTABLE + b"exit 1\n" + archive = _picotool_archive(changed) + fixture.picotool_executable.write_bytes(changed) + fixture.picotool_executable.chmod(0o755) + ( + fixture.picotool + / ".pyble-dist" + / "picotool-2.3.0-test-mac.zip" + ).write_bytes(archive) + lock = fixture.firmware / "versions.lock" + text = lock.read_text(encoding="utf-8") + text = text.replace( + "archive_bytes = %d" % len(PICOTOOL_ARCHIVE), + "archive_bytes = %d" % len(archive), + ) + text = text.replace( + _sha256(PICOTOOL_ARCHIVE), + _sha256(archive), + ) + text = text.replace( + _sha256(PICOTOOL_EXECUTABLE), + _sha256(changed), + ) + lock.write_text(text, encoding="utf-8") + _commit_all(fixture.repo, "nonzero picotool version fixture") + + def wrong_version(fixture: RP2BuildFixture) -> None: + changed = PICOTOOL_EXECUTABLE.replace(b"v2.3.0", b"v2.3.1") + fixture.picotool_executable.write_bytes(changed) + fixture.picotool_executable.chmod(0o755) + lock = fixture.firmware / "versions.lock" + lock.write_text( + lock.read_text(encoding="utf-8").replace( + _sha256(PICOTOOL_EXECUTABLE), + _sha256(changed), + ), + encoding="utf-8", + ) + _commit_all(fixture.repo, "wrong runtime version fixture") + + cases = { + "cmake-config": lambda fixture: ( + fixture.picotool / "picotool" / "picotoolConfig.cmake" + ).write_bytes(b"changed package config\n"), + "cmake-config-mode": lambda fixture: ( + fixture.picotool / "picotool" / "picotoolConfig.cmake" + ).chmod(0o666), + "bundled-libusb": lambda fixture: ( + ( + fixture.picotool + / "picotool" + / "libusb-1.0.0.dylib" + ).chmod(0o644), + ( + fixture.picotool + / "picotool" + / "libusb-1.0.0.dylib" + ).write_bytes(b"changed bundled libusb\n"), + ), + "retained-archive": lambda fixture: ( + fixture.picotool + / ".pyble-dist" + / "picotool-2.3.0-test-mac.zip" + ).write_bytes(b"changed retained archive\n"), + "missing-package-target": lambda fixture: ( + fixture.picotool / "picotool" / "picotoolTargets.cmake" + ).unlink(), + "runtime-version-line": wrong_version, + "runtime-version-nonzero": nonzero_version, + } + for label, mutate in cases.items(): + with self.subTest(identity=label): + fixture = RP2BuildFixture() + try: + mutate(fixture) + completed = fixture.execute() + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertFalse(fixture.ambient_picotool_marker.exists()) + self.assertEqual([], fixture.make_records()) + self.assertFalse(fixture.output.exists()) + self.assertFalse(fixture.retained.exists()) + finally: + fixture.cleanup() + def test_failed_build_atomically_removes_new_source_and_output(self) -> None: fixture = RP2BuildFixture() try: @@ -734,6 +1051,25 @@ def test_failed_build_atomically_removes_new_source_and_output(self) -> None: finally: fixture.cleanup() + def test_cache_requires_exact_boolean_fetchcontent_disconnect_binding(self) -> None: + for mutation in ( + "fetchcontent-cache-uninitialized", + "fetchcontent-cache-off", + "fetchcontent-cache-missing", + ): + with self.subTest(mutation=mutation): + fixture = RP2BuildFixture() + try: + completed = fixture.execute(mutation=mutation) + self.assertNotEqual(completed.returncode, 0, completed.stdout) + self.assertFalse( + (fixture.output / "pyble-build-provenance.json").exists() + ) + self.assertFalse(fixture.output.exists()) + self.assertFalse(fixture.retained.exists()) + finally: + fixture.cleanup() + def test_unsafe_audit_inputs_are_rejected_before_provenance(self) -> None: for mutation in ("symlink-link", "host-path-elf"): with self.subTest(mutation=mutation): diff --git a/tests/firmware_tests/host/test_esp_native_console_stdin.py b/tests/firmware_tests/host/test_esp_native_console_stdin.py index cd8492a3..65af8941 100644 --- a/tests/firmware_tests/host/test_esp_native_console_stdin.py +++ b/tests/firmware_tests/host/test_esp_native_console_stdin.py @@ -12,7 +12,12 @@ from __future__ import annotations from pathlib import Path +import os import re +import shutil +import subprocess +import tempfile +import textwrap import unittest @@ -115,5 +120,171 @@ def test_poll_readiness_uses_the_same_worker_owned_ring(self): self.assertIn("MP_STREAM_POLL_WR", ioctl) +class EspNativeConsoleStdinBehaviorTests(unittest.TestCase): + """Compile the production ring reducer and exercise every v0.6.1 cut.""" + + def test_compiled_ring_isolates_idle_runs_disconnect_and_vm_reset(self): + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone(compiler, "a host C compiler is required") + functions = "\n".join( + c_function(CONSOLE, name) + for name in ( + "on_worker", + "stdin_ring_clear_locked", + "pble_console_stdin_begin", + "pble_console_stdin_end", + "pble_console_stdin_clear", + "pble_console_input", + "pble_console_stdin_getchar", + "stdin_ring_readable", + "pble_console_vm_detach", + "pble_console_vm_reset", + ) + ) + harness = textwrap.dedent( + r''' + #include <stdbool.h> + #include <stddef.h> + #include <stdint.h> + #include <stdio.h> + #include <string.h> + + #define PBLE_STDIN_RING 256 + #define PBLE_CONSOLE_CHUNK 200 + #define PBLE_NO_RSP 0xfe + #define taskENTER_CRITICAL(mux) ((void)(mux)) + #define taskEXIT_CRITICAL(mux) ((void)(mux)) + + typedef int portMUX_TYPE; + typedef struct { + const uint8_t *payload; + uint16_t len; + } pble_frame_t; + typedef struct { uint16_t conn; } pble_session_token_t; + + static void *volatile g_worker; + static void *current_thread; + static portMUX_TYPE g_ring_mux; + static uint8_t g_ring[PBLE_STDIN_RING]; + static uint16_t g_ring_head; + static uint16_t g_ring_tail; + static uint16_t g_ring_count; + static bool g_stdin_active; + static int64_t g_console_next_notify_us; + static uint8_t g_stage[1 + PBLE_CONSOLE_CHUNK]; + + static void *mp_thread_get_state(void) { return current_thread; } + static void console_reset_notify_interval(void) { + g_console_next_notify_us = 0; + } + ''' + ) + main = textwrap.dedent( + r''' + #define CHECK(condition, code) do { if (!(condition)) return (code); } while (0) + + static uint8_t feed(const uint8_t *data, uint16_t len) { + pble_frame_t frame = {data, len}; + size_t response_len = 99; + uint8_t status = pble_console_input( + &frame, NULL, &response_len, NULL); + CHECK(response_len == 0, 90); + return status; + } + + int main(void) { + static uint8_t bytes[300]; + for (size_t i = 0; i < sizeof(bytes); ++i) { + bytes[i] = (uint8_t)i; + } + g_worker = (void *)1; + current_thread = (void *)1; + + CHECK(feed(bytes, 4) == PBLE_NO_RSP, 20); + CHECK(g_ring_count == 0 && pble_console_stdin_getchar() == -1, 21); + + pble_console_stdin_begin(); + CHECK(g_stdin_active && !stdin_ring_readable(), 22); + CHECK(feed(bytes, sizeof(bytes)) == PBLE_NO_RSP, 23); + CHECK(g_ring_count == PBLE_STDIN_RING && stdin_ring_readable(), 24); + for (size_t i = 0; i < PBLE_STDIN_RING; ++i) { + CHECK(pble_console_stdin_getchar() == (int)(uint8_t)i, 25); + } + CHECK(pble_console_stdin_getchar() == -1, 26); + + CHECK(feed(bytes, 3) == PBLE_NO_RSP, 27); + current_thread = (void *)2; + CHECK(pble_console_stdin_getchar() == -1 && g_ring_count == 3, 28); + current_thread = (void *)1; + CHECK(pble_console_stdin_getchar() == 0 && g_ring_count == 2, 29); + + pble_console_stdin_clear(); + CHECK(g_stdin_active && g_ring_count == 0, 30); + CHECK(feed(bytes + 7, 1) == PBLE_NO_RSP, 31); + CHECK(pble_console_stdin_getchar() == 7, 32); + + CHECK(feed(bytes, 4) == PBLE_NO_RSP, 33); + pble_console_stdin_end(); + CHECK(!g_stdin_active && g_ring_count == 0, 34); + CHECK(feed(bytes, 4) == PBLE_NO_RSP && g_ring_count == 0, 35); + pble_console_stdin_begin(); + CHECK(pble_console_stdin_getchar() == -1, 36); + + CHECK(feed(bytes, 4) == PBLE_NO_RSP, 37); + g_console_next_notify_us = 123; + memset(g_stage, 0xa5, sizeof(g_stage)); + pble_console_vm_reset(); + CHECK(!g_stdin_active && g_ring_count == 0 && g_worker == NULL, 38); + CHECK(g_console_next_notify_us == 0, 39); + for (size_t i = 0; i < sizeof(g_ring); ++i) CHECK(g_ring[i] == 0, 40); + for (size_t i = 0; i < sizeof(g_stage); ++i) CHECK(g_stage[i] == 0, 41); + return 0; + } + ''' + ) + with tempfile.TemporaryDirectory(prefix="pyble-v061-stdin-") as temp: + source = Path(temp) / "stdin_probe.c" + binary = Path(temp) / "stdin_probe" + source.write_text(harness + functions + main, encoding="utf-8") + built = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-Wno-unused-function", + str(source), + "-o", + str(binary), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + built.returncode, + 0, + "native stdin probe did not compile:\n{}{}".format( + built.stdout, built.stderr + ), + ) + executed = subprocess.run( + [str(binary)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + executed.returncode, + 0, + "native stdin lifecycle probe exited {}:\n{}{}".format( + executed.returncode, executed.stdout, executed.stderr + ), + ) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_evidence_writer_inode_lifetime.py b/tests/firmware_tests/host/test_evidence_writer_inode_lifetime.py new file mode 100644 index 00000000..637c02d9 --- /dev/null +++ b/tests/firmware_tests/host/test_evidence_writer_inode_lifetime.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Host-only regressions for exclusive evidence-file inode ownership. + +Descriptor-lifetime assertions are deterministic on both macOS and Linux; +replacement checks must not depend on how quickly a filesystem reuses inodes. +Synthetic files do not constitute physical qualification evidence. +""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[3] + + +def load_gate(name): + path = ROOT / "firmware" / "qualification" / (name + ".py") + spec = importlib.util.spec_from_file_location(name + "_inode_test", path) + if spec is None or spec.loader is None: + raise AssertionError("cannot load evidence writer") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +WRITERS = ( + (load_gate("v060_profile_release_gate"), "_write_exclusive_result", "_remove_created_result"), + (load_gate("v061_hardening_release_gate"), "_write_exclusive", "_remove_created"), +) + + +@contextmanager +def observe_writer_descriptor(gate): + """Observe the real descriptor without extending its lifetime for the test.""" + state = {"descriptor": None, "closed": False} + real_open, real_close = os.open, os.close + + def open_file(path, flags, *args, **kwargs): + descriptor = real_open(path, flags, *args, **kwargs) + if flags & os.O_CREAT: + if state["descriptor"] is not None: + raise AssertionError("writer created more than one output") + state["descriptor"] = descriptor + return descriptor + + def close_file(descriptor): + if descriptor == state["descriptor"]: + state["closed"] = True + return real_close(descriptor) + + with ExitStack() as stack: + stack.enter_context(mock.patch.object(gate.os, "open", side_effect=open_file)) + stack.enter_context(mock.patch.object(gate.os, "close", side_effect=close_file)) + yield state + + +class EvidenceWriterInodeLifetimeTests(unittest.TestCase): + def assert_writer_pinned(self, state): + self.assertIsNotNone(state["descriptor"]) + self.assertFalse( + state["closed"], + "original inode must remain pinned until publication or cleanup finishes", + ) + return os.fstat(state["descriptor"]) + + def test_writer_is_pinned_until_successful_publication(self): + for gate, writer_name, _cleanup_name in WRITERS: + with self.subTest(writer=writer_name), tempfile.TemporaryDirectory() as tmp: + output = Path(tmp).resolve() / "result.json" + raw = b"writer-owned evidence\n" + with observe_writer_descriptor(gate) as state: + getattr(gate, writer_name)( + output, + raw, + post_write_check=lambda: self.assert_writer_pinned(state), + ) + self.assertTrue(state["closed"], "successful writer must close its descriptor") + self.assertEqual(output.read_bytes(), raw) + + def test_writer_is_pinned_through_failed_publication_cleanup(self): + for gate, writer_name, cleanup_name in WRITERS: + with self.subTest(writer=writer_name), tempfile.TemporaryDirectory() as tmp: + output = Path(tmp).resolve() / "result.json" + original_cleanup = getattr(gate, cleanup_name) + + def fail_validation(): + raise gate.QualificationError("synthetic validation failure") + + with observe_writer_descriptor(gate) as state: + def cleanup(*args): + self.assert_writer_pinned(state) + return original_cleanup(*args) + + with mock.patch.object(gate, cleanup_name, side_effect=cleanup) as observed: + with self.assertRaises(gate.QualificationError): + getattr(gate, writer_name)( + output, + b"writer-owned evidence\n", + post_write_check=fail_validation, + ) + observed.assert_called_once() + self.assertTrue(state["closed"], "failed writer must close its descriptor") + self.assertFalse(output.exists()) + + def test_replacements_are_rejected_and_preserved_even_with_identical_bytes(self): + raw = b"writer-owned evidence\n" + for gate, writer_name, _cleanup_name in WRITERS: + for replacement in (raw, b"replacement-owned evidence\n"): + with self.subTest(writer=writer_name, identical=replacement == raw): + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp).resolve() / "result.json" + + def replace_output(): + output.unlink() + output.write_bytes(replacement) + output.chmod(0o600) + + with self.assertRaises(gate.QualificationError): + getattr(gate, writer_name)( + output, + raw, + post_write_check=replace_output, + ) + self.assertEqual(output.read_bytes(), replacement) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_failed_session_termination.py b/tests/firmware_tests/host/test_failed_session_termination.py index 45cd3253..453fb5f0 100644 --- a/tests/firmware_tests/host/test_failed_session_termination.py +++ b/tests/firmware_tests/host/test_failed_session_termination.py @@ -333,6 +333,63 @@ def test_gap_connect_opens_exact_token_before_exposure(self) -> None: "pble_ready_refresh", ) + def test_terminal_latch_closes_admission_before_bounded_tx_drain(self) -> None: + close = _code(_function(self.ble, "pble_ble_terminate_session")) + latch_at = close.find("pble_termination_claim_driver_locked") + tx_at = close.find("xSemaphoreTakeRecursive(pble_tx_mutex") + self.assertTrue( + 0 <= latch_at < tx_at, + "exact-session terminal admission must latch before waiting for " + "physical TX ownership", + ) + self.assertNotIn( + "portMAX_DELAY", + close, + "failed-session termination must spend only the residual of its " + "one absolute 2500 ms deadline on TX drain", + ) + self.assertIn("PBLE_TERM_WATCHDOG_US", close) + self.assertRegex( + close, + r"xSemaphoreTakeRecursive\s*\(\s*pble_tx_mutex\s*,\s*" + r"[A-Za-z_]\w*\s*\)", + ) + + record = _code(_function( + self.ble, "pble_ble_record_protocol_violation")) + violation_latch = record.find("pble_termination_latch_locked") + unlock = record.find( + "taskEXIT_CRITICAL(&pble_session_mux)", violation_latch) + terminate = record.find("pble_ble_terminate_session", unlock) + self.assertTrue( + 0 <= violation_latch < unlock < terminate, + "the eighth violation must make the session non-live in the same " + "counter cut, before the bounded termination driver runs", + ) + + for name in ( + "pble_notify_packet", + "pble_ble_session_snapshot", + "pble_ble_session_snapshot_current", + "pble_ble_session_live", + "pble_ble_record_protocol_violation", + "pble_rx_access", + ): + with self.subTest(admission_body=name): + body = _code(_function(self.ble, name)) + self.assertIn( + "pble_session_admits_locked", + body, + "{} bypasses the shared terminal-admission latch" + .format(name), + ) + closing = _code(_function(self.ble, "pble_ble_session_closing")) + self.assertIn( + "pble_termination_pending", + closing, + "VM reset must treat a latched pre-mutex termination as closing", + ) + def test_open_to_closing_and_watchdog_arm_are_effect_driven(self) -> None: close = _code(_function(self.ble, "pble_ble_terminate_session")) _assert_inside_session_critical(self, close, "pble_term_begin") @@ -386,8 +443,53 @@ def test_open_to_closing_and_watchdog_arm_are_effect_driven(self) -> None: self.assertIn("esp_restart", close) self.assertIn("ESP_OK", close) self.assertEqual(close.count("ble_gap_terminate("), 1) + + def test_preclose_timeout_claims_restarting_before_public_restart(self) -> None: + helper = _code( + _function(self.ble, "pble_termination_restart_if_current") + ) + self.assertIn("pble_termination_pending_matches_locked", helper) + self.assertIn("pble_term_preclose_failed", helper) + _assert_inside_session_critical( + self, helper, "pble_term_preclose_failed" + ) + self.assertIn("PBLE_TERM_EFFECT_RESTART", helper) + + close = _code(_function(self.ble, "pble_ble_terminate_session")) + self.assertGreaterEqual( + close.count("pble_termination_restart_if_current"), + 4, + "missing mutex, pre-wait deadline, wait-timeout, or post-wait " + "deadline path without an atomic RESTARTING claim", + ) + self.assertNotIn( + "pble_termination_request_current", + close, + "check-then-restart leaves a cleanup race before esp_restart", + ) self.assertNotRegex(close, r"\b(?:for|while)\s*\(") + def test_absolute_deadline_mutex_waits_never_round_past_deadline(self) -> None: + for name in ( + "pble_ble_terminate_session", + "pble_ble_vm_tx_lock", + ): + with self.subTest(name=name): + body = _code(_function(self.ble, name)) + self.assertIn( + "(uint32_t)(residual_us / INT64_C(1000))", + body, + "a sub-tick residual must become a nonblocking take", + ) + self.assertNotIn("residual_us + INT64_C(999)", body) + self.assertNotRegex( + body, + r"if\s*\(\s*residual_ticks\s*==\s*0\s*\)\s*" + r"\{\s*residual_ticks\s*=\s*1\s*;", + "rounding a positive sub-tick residual up to one whole " + "tick can wait beyond the one absolute deadline", + ) + def test_gap_result_classification_is_exact_and_non_retrying(self) -> None: close = _code(_function(self.ble, "pble_ble_terminate_session")) self.assertRegex( @@ -585,7 +687,12 @@ def test_disconnect_claims_exact_cleanup_before_timer_or_invalidation(self) -> N "pble_rsp_owner_release_if_idle", "pble_reset_reassembly", "pble_lock_on_disconnect", + ) + self.assertNotIn( "pble_fs_on_disconnect", + disconnect, + "pble_lock_on_disconnect is the single teardown authority and " + "already advances filesystem transfer generation exactly once", ) self.assertIn("ESP_OK", disconnect) self.assertIn("PBLE_TERM_EFFECT_RESTART", disconnect) @@ -619,7 +726,12 @@ def test_reset_claims_cleanup_before_timer_and_bound_work(self) -> None: "pble_rsp_owner_release_if_idle", "pble_reset_reassembly", "pble_lock_on_disconnect", + ) + self.assertNotIn( "pble_fs_on_disconnect", + reset, + "reset cleanup must not advance filesystem generation once through " + "pble_lock and again through a direct filesystem callback", ) self.assertIn("ESP_OK", reset) self.assertIn("PBLE_TERM_EFFECT_RESTART", reset) diff --git a/tests/firmware_tests/host/test_firmware_roadmap_ledger.py b/tests/firmware_tests/host/test_firmware_roadmap_ledger.py new file mode 100644 index 00000000..6e771e34 --- /dev/null +++ b/tests/firmware_tests/host/test_firmware_roadmap_ledger.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Currentness guard for the firmware roadmap and retired G1 ledgers.""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +CONFORMANCE = REPO_ROOT / "tests" / "firmware_tests" / "host" / "conformance" +GATES = REPO_ROOT / "tests" / "firmware_tests" / "gates" + +ARCHIVED_LEDGERS = { + "s3_pending.json": + "7d9f9089c390a5f02a8b14ef8cc8fad77189a2270ab7a2132fbcbceed5b4327a", + "s4_pending.json": + "89f3f6f6e57172041715317cfa7f6cb0ca07c24ed0ac212632ef64a305a60b43", + "s5_pending.json": + "05e7b01e404155d332fb91c217d33214a92bb70aa1b3c3d0a3943cdc6cbbf737", + "s6_pending.json": + "96c51e3dbd0c591fdc2b3b148770ec9b1845f65e2ca22d09132035c8fad6a212", +} + + +def _text(relative: str) -> str: + return (REPO_ROOT / relative).read_text(encoding="utf-8") + + +class FirmwareRoadmapLedgerTests(unittest.TestCase): + def test_public_roadmap_distinguishes_v061_publication_and_v060_history(self): + roadmap = _text("docs/ROADMAP.md") + normalized = " ".join(roadmap.split()) + self.assertIn("published firmware v0.6.1", normalized) + self.assertIn("owner confirmation", normalized) + self.assertIn("Incomplete automated evidence is preserved unchanged", normalized) + self.assertIn("qualified v0.6.0 release remains a separate historical baseline", normalized) + self.assertNotIn("current qualified firmware v0.6.0", normalized) + self.assertNotIn("exact v0.4.2 bytes", roadmap) + self.assertNotIn("before enabling the ESP32-C3 installer", roadmap) + + def test_detailed_roadmap_records_only_approved_split_checkbox(self): + roadmap = _text( + "docs/planning/firmware-v0.6.1-v0.7.0-roadmap.md") + checked = re.findall(r"^- \[x\] (.+)$", roadmap, re.MULTILINE) + self.assertEqual( + ["the v0.6.1 hardening / v0.7.0 feature-release split;"], + checked, + ) + self.assertIn("Resolved for v0.6.1", roadmap) + self.assertIn("official images standardize on LFS2", roadmap) + + def test_normative_contract_preserves_future_persistent_mode(self): + protocol = _text("docs/specifications/protocol.md") + specs = _text("docs/specifications/firmware/specs.md") + trace = re.compile( + r"fresh globals.*future.*persistent.*interactive|" + r"persistent.*interactive.*fresh globals", + re.IGNORECASE | re.DOTALL, + ) + for document in (protocol, specs): + self.assertRegex(document, trace) + + def test_lfs2_standardization_has_an_indexed_adr(self): + index = _text("docs/decisions/README.md") + self.assertRegex(index, r"\[0047\].*LFS2") + adr = ( + REPO_ROOT / "docs" / "decisions" / + "0047-standardize-official-workspaces-on-lfs2.md" + ) + self.assertTrue(adr.is_file()) + body = adr.read_text(encoding="utf-8") + self.assertIn("Status: **Accepted**", body) + self.assertIn("all five", body) + self.assertIn("explicit operator action", body) + + def test_pre_freeze_ledgers_are_archived_byte_for_byte(self): + archive = CONFORMANCE / "archive" / "pre-freeze" + if not (archive / "README.md").is_file(): + self.fail("pre-freeze ledger archive README is missing") + for name, expected_sha256 in ARCHIVED_LEDGERS.items(): + self.assertFalse((CONFORMANCE / name).exists(), name) + payload = (archive / name).read_bytes() + self.assertEqual(expected_sha256, hashlib.sha256(payload).hexdigest()) + + def test_active_corpus_and_g1_docs_do_not_claim_frozen_wire_is_draft(self): + corpus = (CONFORMANCE / "corpus.json").read_text(encoding="utf-8") + readme = _text("tests/firmware_tests/README.md") + current_path = CONFORMANCE / "README.md" + if not current_path.is_file(): + self.fail("current conformance ledger README is missing") + current = current_path.read_text(encoding="utf-8") + active = "\n".join((corpus, readme, current)) + self.assertNotIn("still DRAFT", active) + self.assertNotIn("DoR-BLOCKED", active) + for name in ARCHIVED_LEDGERS: + self.assertNotIn(f"conformance/{name}", active) + + for sprint in (3, 4, 5, 6): + gate = (GATES / f"g1_s{sprint}_check.sh").read_text( + encoding="utf-8") + self.assertNotIn("DEFERRED-DOCS", gate) + self.assertNotIn(f"s{sprint}_pending.json", gate) + self.assertIn("DEFERRED-HIL", gate) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_hil_put_command_ids_v061.py b/tests/firmware_tests/host/test_hil_put_command_ids_v061.py new file mode 100644 index 00000000..defdcb38 --- /dev/null +++ b/tests/firmware_tests/host/test_hil_put_command_ids_v061.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Actual host senders plus unchanged compiled native wire admission; no HIL. + +Synthetic ACKs test sender orchestration only. Every captured valid CMD is +also admitted by the production C decision, so a permissive fake cannot hide +zero-ID frames. The raw codec remains available for deliberate negative tests. +""" +import ast +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest +from unittest import mock + +HERE = Path(__file__).resolve().parent +HIL = HERE.parent / "hil" +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HIL)) +import test_v061_pble_wire_native as native_fixture +import _pble_bench as shared +import _pble_wire as wire +import f11_reliability_bench as reliability +import file_roundtrip_bench as roundtrip +import rp2_run_stop as rp2 +import target_run_stop as target + + +class Clock: + def __init__(self): + self.value = 1.0 + + def now(self): + return self.value + + def ns(self): + self.value += 0.001 + return int(self.value * 1_000_000_000) + + async def sleep(self, seconds): + self.value += max(0.05, seconds) + + +class StrictCentral: + def __init__(self, test, data, *, drop_first=False): + self.test, self.data = test, data + self.drop_first = drop_first + self.dropped = False + self.last_ack = 0 + self.calls, self.frames, self.offsets = [], [], [] + self.events = [] + self.chunk = 229 + + def capture(self, opcode, request_id, payload, response): + self.test.assertIs(type(request_id), int, "caller must allocate an integer CMD ID") + self.test.assertTrue(1 <= request_id <= 255, "valid CMD ID must be 1..255 before encoding") + raw = wire.encode(wire.CMD, opcode, request_id, payload) + self.test.assertEqual(raw[3], request_id) + self.calls.append((opcode, request_id, bytes(payload), response)) + self.frames.append(raw) + + async def send_cmd(self, opcode, request_id, payload=b"", **kwargs): + self.test.assertNotEqual(opcode, wire.OP_FILE_PUT_DATA, "DATA must never wait for a RSP") + self.capture(opcode, request_id, payload, True) + result = b"\x00" + if opcode == wire.OP_FILE_PUT_BEGIN: + self.test.assertEqual(int.from_bytes(payload[:4], "little"), len(self.data)) + self.test.assertEqual(int.from_bytes(payload[4:8], "little"), wire.crc32(self.data)) + self.last_ack = 0 + result += b"\0" * 4 + elif opcode == wire.OP_FILE_PUT_END: + self.test.assertEqual(payload, wire.crc32(self.data).to_bytes(4, "little")) + self.test.assertEqual(self.last_ack, len(self.data)) + elif opcode == wire.OP_FILE_STAT: + result += len(self.data).to_bytes(4, "little") + wire.crc32(self.data).to_bytes(4, "little") + elif opcode == wire.OP_FILE_GET_BEGIN: + result += len(self.data).to_bytes(4, "little") + self.events.extend([ + wire.Frame(wire.EVT, wire.OP_FILE_GET_DATA, 0, b"\0" * 4 + self.data), + wire.Frame(wire.EVT, wire.OP_FILE_GET_END, 0, wire.crc32(self.data).to_bytes(4, "little")), + ]) + return wire.Frame(wire.RSP, opcode, request_id, result) + + async def send_cmd_no_rsp(self, opcode, request_id, payload=b""): + self.test.assertEqual(opcode, wire.OP_FILE_PUT_DATA) + self.capture(opcode, request_id, payload, False) + offset = int.from_bytes(payload[:4], "little") + piece = payload[4:] + self.test.assertGreater(len(piece), 0) + self.test.assertLessEqual(len(piece), self.chunk) + self.test.assertEqual(piece, self.data[offset:offset + len(piece)]) + self.offsets.append(offset) + if self.drop_first and not self.dropped: + self.dropped = True + return + if offset == self.last_ack: + self.last_ack += len(piece) + + def begin_ack_scope(self, watermark): + self.test.assertEqual(watermark, 0) + return SimpleNamespace(poll=lambda **kwargs: self.last_ack) + + +class ValidTransferCommandIDs(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + cls.native = native_fixture.CompiledNativeWireTest() + cls.native.setUp() + cls.native.build_harness() + + @classmethod + def tearDownClass(cls): + cls.native.doCleanups() + + def assert_admitted(self, central): + ids = [call[1] for call in central.calls] + self.assertEqual(ids, [(index % 255) + 1 for index in range(len(ids))]) + hello = wire.encode(wire.CMD, wire.OP_HELLO, 255, native_fixture.CANONICAL_HELLO) + output = self.native.run_harness([ + "RESET", "FRAME 1 " + hello.hex(), + *["FRAME 1 " + raw.hex() for raw in central.frames], + ]) + self.assertEqual(len(output), len(central.frames) + 2) + for raw, decision in zip(central.frames, output[2:]): + self.assertEqual(decision[1], "dispatch", decision) + self.assertEqual(int(decision[3]), raw[3]) + self.assertEqual(decision[5], "0", "valid upload must not consume violation budget") + + def test_native_admission_rejects_zero_even_for_no_response_command(self): + hello = wire.encode(wire.CMD, wire.OP_HELLO, 1, native_fixture.CANONICAL_HELLO) + for request_id, action, violation in ((0, "drop", "1"), (1, "dispatch", "0"), (255, "dispatch", "0")): + with self.subTest(request_id=request_id): + raw = wire.encode(wire.CMD, wire.OP_FILE_PUT_DATA, request_id, b"\0" * 4 + b"x") + result = self.native.run_harness(["RESET", "FRAME 1 " + hello.hex(), "FRAME 1 " + raw.hex()])[-1] + self.assertEqual(result[1], action) + self.assertEqual(result[5], violation) + + def test_allocator_wraps_through_three_complete_cycles_without_zero(self): + ids = shared.CommandIds() + self.assertEqual([ids.next() for _ in range(770)], [(i % 255) + 1 for i in range(770)]) + + def test_valid_senders_have_no_literal_zero_cmd_ids(self): + for module in (shared, reliability, roundtrip, target, rp2): + with self.subTest(module=module.__name__): + tree = ast.parse(Path(module.__file__).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr in {"send_cmd", "send_cmd_no_rsp"} and len(node.args) > 1: + self.assertFalse(isinstance(node.args[1], ast.Constant) and node.args[1].value == 0, + "%s:%d sends a valid CMD with ID zero" % (module.__name__, node.lineno)) + + async def test_oi1_all_chunks_and_rewinds_share_wrapping_control_allocator(self): + data = bytes(range(229)) * 260 + b"tail" + for drop_first in (False, True): + with self.subTest(drop_first=drop_first): + central = StrictCentral(self, data, drop_first=drop_first) + ids, clock = shared.CommandIds(), Clock() + with mock.patch.object(shared, "time", SimpleNamespace(monotonic=clock.now)): + result = await shared.put_file( + central, "/host-only.bin", data, window=8, chunk=229, + next_id=ids.next, ack_timeout_s=0.1, operation_timeout_s=60, + clock_ns=clock.ns, sleep=clock.sleep) + self.assertEqual(result.unique_committed_bytes, len(data)) + self.assertEqual(bool(result.retransmitted_chunks), drop_first) + self.assertEqual(bool(result.rewinds), drop_first) + self.assertGreater(len(central.offsets), 255) + self.assert_admitted(central) + + async def test_f11_all_chunks_and_rewinds_share_wrapping_control_allocator(self): + data = bytes(range(229)) * 260 + b"tail" + for drop_first in (False, True): + with self.subTest(drop_first=drop_first): + central = StrictCentral(self, data, drop_first=drop_first) + ids, clock = shared.CommandIds(), Clock() + fake_asyncio = SimpleNamespace(get_event_loop=lambda: SimpleNamespace(time=clock.now), sleep=clock.sleep) + with mock.patch.object(reliability, "asyncio", fake_asyncio): + result = await reliability._upload_one(central, "/host-only.bin", data, 8, 229, ids.next, 0.1, 1) + self.assertTrue(result.ok) + self.assertTrue(result.stat_verified) + self.assertEqual(bool(result.retransmits), drop_first) + self.assertGreater(len(central.offsets), 255) + self.assert_admitted(central) + + async def test_roundtrip_put_get_and_rewinds_share_wrapping_allocator(self): + data = bytes(range(229)) * 260 + b"tail" + for drop_first in (False, True): + with self.subTest(drop_first=drop_first): + central = StrictCentral(self, data, drop_first=drop_first) + ids, clock = shared.CommandIds(), Clock() + with mock.patch.object(roundtrip, "time", SimpleNamespace(monotonic=clock.now)), \ + mock.patch.object(roundtrip, "asyncio", SimpleNamespace(sleep=clock.sleep)): + await roundtrip._put(central, data, 229, 8, ids.next) + got, checksum, _duration = await roundtrip._get(central, data, ids.next) + self.assertEqual(got, data) + self.assertEqual(checksum, wire.crc32(data)) + self.assertEqual(len(central.offsets) != len(set(central.offsets)), drop_first) + self.assertGreater(len(central.offsets), 255) + self.assert_admitted(central) + + async def test_small_file_helpers_share_caller_allocator_across_wrap(self): + data = b"print('host-only')\n" + for module in (target, rp2): + with self.subTest(module=module.__name__): + ids = shared.CommandIds() + central = StrictCentral(self, data) + for _ in range(90): + await module.put_small_file(central, "/host-only.py", data, ids.next) + self.assertEqual(len(central.frames), 270) + self.assertEqual(len(central.offsets), 90) + self.assert_admitted(central) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_install_picotool_retention.py b/tests/firmware_tests/host/test_install_picotool_retention.py new file mode 100644 index 00000000..6a490da2 --- /dev/null +++ b/tests/firmware_tests/host/test_install_picotool_retention.py @@ -0,0 +1,886 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED contract for the pinned, retained picotool macOS distribution.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import time +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[3] +INSTALLER = ROOT / "firmware/scripts/install_picotool.sh" +ARCHIVE_NAME = "picotool-2.3.0-test-mac.zip" +VERSION_LINE = ( + "picotool v2.3.0 " + "(Darwin, AppleClang-15.0.0.15000309, Release)" +) +EXECUTABLE_PATH = "picotool/picotool" +CONFIG_PATH = "picotool/picotoolConfig.cmake" +CONFIG_VERSION_PATH = "picotool/picotoolConfigVersion.cmake" +TARGETS_PATH = "picotool/picotoolTargets.cmake" +TARGETS_RELEASE_PATH = "picotool/picotoolTargets-release.cmake" +LIBUSB_PATH = "picotool/libusb-1.0.0.dylib" +EXECUTABLE = ( + b"#!/bin/sh\n" + b"[ \"${1:-}\" = version ] || exit 64\n" + + f"printf '%s\\n' '{VERSION_LINE}'\n".encode("utf-8") +) +CONFIG = b'include("${CMAKE_CURRENT_LIST_DIR}/picotoolTargets.cmake")\n' +CONFIG_VERSION = b"""set(PACKAGE_VERSION \"2.3.0\") +if(PACKAGE_FIND_VERSION VERSION_GREATER PACKAGE_VERSION) + set(PACKAGE_VERSION_COMPATIBLE FALSE) +else() + set(PACKAGE_VERSION_COMPATIBLE TRUE) + if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION) + set(PACKAGE_VERSION_EXACT TRUE) + endif() +endif() +""" +TARGETS = b"""if(NOT TARGET picotool) + add_executable(picotool IMPORTED) + get_filename_component(_IMPORT_PREFIX \"${CMAKE_CURRENT_LIST_DIR}/..\" ABSOLUTE) + include(\"${CMAKE_CURRENT_LIST_DIR}/picotoolTargets-release.cmake\") + unset(_IMPORT_PREFIX) +endif() +""" +TARGETS_RELEASE = b"""set_property(TARGET picotool APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE) +set_target_properties(picotool PROPERTIES + IMPORTED_LOCATION_RELEASE \"${_IMPORT_PREFIX}/picotool/picotool\" +) +""" +LIBUSB = b"synthetic pinned libusb bytes\n" + + +def digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _zip_member( + archive: zipfile.ZipFile, + name: str, + value: bytes, + mode: int, +) -> None: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.date_time = (2020, 1, 1, 0, 0, 0) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = (mode & 0xFFFF) << 16 + archive.writestr(info, value) + + +def write_archive( + path: Path, + *, + executable: bytes = EXECUTABLE, + executable_mode: int = stat.S_IFREG | 0o755, + include_executable: bool = True, + include_config: bool = True, + include_libusb: bool = True, + config: bytes = CONFIG, + libusb: bytes = LIBUSB, + extra_members: tuple[tuple[str, bytes, int], ...] = (), +) -> None: + with zipfile.ZipFile(path, "w") as archive: + _zip_member(archive, ".keep", b"", stat.S_IFREG | 0o644) + _zip_member(archive, "picotool/", b"", stat.S_IFDIR | 0o755) + if include_executable: + _zip_member( + archive, + EXECUTABLE_PATH, + executable, + executable_mode, + ) + if include_config: + for name, value in ( + (CONFIG_PATH, config), + (CONFIG_VERSION_PATH, CONFIG_VERSION), + (TARGETS_PATH, TARGETS), + (TARGETS_RELEASE_PATH, TARGETS_RELEASE), + ): + _zip_member( + archive, + name, + value, + stat.S_IFREG | 0o644, + ) + if include_libusb: + _zip_member( + archive, + LIBUSB_PATH, + libusb, + stat.S_IFREG | 0o644, + ) + for name, value, mode in extra_members: + _zip_member(archive, name, value, mode) + + +def mark_first_member_encrypted(path: Path) -> None: + """Set ZIP encryption flags without changing the synthetic member bytes.""" + value = bytearray(path.read_bytes()) + local = value.find(b"PK\x03\x04") + central = value.find(b"PK\x01\x02") + if local < 0 or central < 0: + raise AssertionError("synthetic ZIP headers are missing") + value[local + 6] |= 0x01 + value[central + 8] |= 0x01 + path.write_bytes(value) + + +@unittest.skipUnless( + INSTALLER.is_file(), + "[red] the pinned picotool installer is not implemented", +) +class PicotoolRetentionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-picotool-retention-red-" + ) + self.root = Path(self.temporary.name) + self.archive = self.root / ARCHIVE_NAME + write_archive(self.archive) + self.destination = self.root / "installed-picotool" + self.lock = self.root / "versions.lock" + self.write_lock() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def write_lock( + self, + *, + archive: Path | None = None, + executable: bytes = EXECUTABLE, + executable_sha256: str | None = None, + version_line: str = VERSION_LINE, + archive_filename: str = ARCHIVE_NAME, + url_filename: str = ARCHIVE_NAME, + url: str | None = None, + archive_bytes: int | None = None, + archive_sha256: str | None = None, + cmake_dir: str = "picotool", + executable_path: str = EXECUTABLE_PATH, + config_path: str = CONFIG_PATH, + config_sha256: str | None = None, + libusb_path: str = LIBUSB_PATH, + libusb_sha256: str | None = None, + ) -> None: + selected_archive = archive or self.archive + self.lock.write_text( + "# SPDX-License-Identifier: MIT\n" + "[picotool]\n" + 'version = "2.3.0"\n' + f'version_line = "{version_line}"\n' + 'source_repo = "https://github.com/raspberrypi/picotool.git"\n' + 'source_ref = "2.3.0"\n' + 'source_commit = "6f6458d792b93685a11423b244a585eaa99eafcf"\n' + 'distribution_repo = ' + '"https://github.com/raspberrypi/pico-sdk-tools.git"\n' + 'distribution_ref = "v2.3.0-0"\n' + 'distribution_commit = ' + '"ad9e4a8375253cf4886bf168ea1a8d2746aadf24"\n' + f'url = "{url or f"https://example.invalid/{url_filename}"}"\n' + f'archive_filename = "{archive_filename}"\n' + f"archive_bytes = " + f"{selected_archive.stat().st_size if archive_bytes is None else archive_bytes}\n" + 'archive_format = "zip"\n' + f'sha256 = "{archive_sha256 or digest(selected_archive.read_bytes())}"\n' + f'cmake_dir = "{cmake_dir}"\n' + f'executable_path = "{executable_path}"\n' + f'executable_sha256 = ' + f'"{executable_sha256 or digest(executable)}"\n' + f'cmake_config_path = "{config_path}"\n' + f'cmake_config_sha256 = "{config_sha256 or digest(CONFIG)}"\n' + f'bundled_libusb_path = "{libusb_path}"\n' + f'bundled_libusb_sha256 = "{libusb_sha256 or digest(LIBUSB)}"\n', + encoding="utf-8", + ) + + def run_installer( + self, + *, + archive: Path | None = None, + path_prefix: Path | None = None, + destination: Path | None = None, + process_umask: int | None = None, + environment_overrides: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + if not INSTALLER.is_file(): + return subprocess.CompletedProcess( + [str(INSTALLER)], + 127, + stdout="install_picotool.sh is missing", + ) + environment = dict(os.environ) + environment["PYBLE_LOCK_FILE"] = str(self.lock) + if archive is not None: + environment["PYBLE_PICOTOOL_ARCHIVE"] = str(archive) + else: + environment.pop("PYBLE_PICOTOOL_ARCHIVE", None) + if path_prefix is not None: + environment["PATH"] = f"{path_prefix}:{environment['PATH']}" + if environment_overrides is not None: + environment.update(environment_overrides) + return subprocess.run( + [str(INSTALLER), str(destination or self.destination)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + preexec_fn=( + (lambda: os.umask(process_umask)) + if process_umask is not None + else None + ), + ) + + def assert_no_staging_tree(self) -> None: + self.assertEqual( + [], + sorted( + path + for path in self.root.iterdir() + if ".incoming." in path.name + ), + "a failed installer left a sibling staging tree", + ) + + def test_install_retains_and_revalidates_the_exact_distribution(self) -> None: + first = self.run_installer(archive=self.archive) + self.assertEqual(first.returncode, 0, first.stdout) + retained = self.destination / ".pyble-dist" / ARCHIVE_NAME + self.assertEqual(retained.read_bytes(), self.archive.read_bytes()) + self.assertEqual( + (self.destination / EXECUTABLE_PATH).read_bytes(), EXECUTABLE + ) + self.assertEqual((self.destination / CONFIG_PATH).read_bytes(), CONFIG) + self.assertEqual( + (self.destination / CONFIG_VERSION_PATH).read_bytes(), + CONFIG_VERSION, + ) + self.assertEqual((self.destination / TARGETS_PATH).read_bytes(), TARGETS) + self.assertEqual( + (self.destination / TARGETS_RELEASE_PATH).read_bytes(), + TARGETS_RELEASE, + ) + self.assertEqual((self.destination / LIBUSB_PATH).read_bytes(), LIBUSB) + self.assertTrue(os.access(self.destination / EXECUTABLE_PATH, os.X_OK)) + + fake_bin = self.root / "fake-bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text("#!/bin/sh\nexit 91\n", encoding="utf-8") + fake_curl.chmod(0o755) + second = self.run_installer(path_prefix=fake_bin) + self.assertEqual(second.returncode, 0, second.stdout) + self.assertEqual(retained.read_bytes(), self.archive.read_bytes()) + + def test_restrictive_umask_cannot_change_retained_tree_modes(self) -> None: + result = self.run_installer( + archive=self.archive, + process_umask=0o077, + ) + + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual( + stat.S_IMODE((self.destination / ".pyble-dist").stat().st_mode), + 0o755, + ) + self.assertEqual( + stat.S_IMODE( + ( + self.destination / ".pyble-dist" / ARCHIVE_NAME + ).stat().st_mode + ), + 0o644, + ) + + @unittest.skipUnless(shutil.which("cmake"), "cmake is required for package smoke") + def test_installed_distribution_is_a_real_find_package_config(self) -> None: + installed = self.run_installer(archive=self.archive) + self.assertEqual(installed.returncode, 0, installed.stdout) + project = self.root / "cmake-smoke" + project.mkdir() + (project / "CMakeLists.txt").write_text( + "cmake_minimum_required(VERSION 3.20)\n" + "project(pyble_picotool_smoke NONE)\n" + "find_package(picotool 2.3.0 EXACT REQUIRED CONFIG " + "NO_DEFAULT_PATH PATHS \"${PICOTOOL_PACKAGE_DIR}\")\n" + "if(NOT TARGET picotool)\n" + " message(FATAL_ERROR \"picotool imported target is missing\")\n" + "endif()\n" + "get_target_property(_location picotool IMPORTED_LOCATION_RELEASE)\n" + "if(NOT _location STREQUAL \"${EXPECTED_PICOTOOL}\")\n" + " message(FATAL_ERROR \"unexpected picotool location: ${_location}\")\n" + "endif()\n", + encoding="utf-8", + ) + configured = subprocess.run( + [ + shutil.which("cmake"), + "-S", + str(project), + "-B", + str(project / "build"), + "-DPICOTOOL_PACKAGE_DIR=%s" + % (self.destination / "picotool"), + "-DEXPECTED_PICOTOOL=%s" + % (self.destination / EXECUTABLE_PATH), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual( + configured.returncode, + 0, + configured.stdout + configured.stderr, + ) + + def test_hash_mismatch_fails_without_admitting_any_install(self) -> None: + cases = { + "archive-size": {"archive_bytes": self.archive.stat().st_size + 1}, + "archive-sha": {"archive_sha256": "0" * 64}, + "executable-sha": {"executable_sha256": "0" * 64}, + "config-sha": {"config_sha256": "0" * 64}, + "libusb-sha": {"libusb_sha256": "0" * 64}, + } + for label, overrides in cases.items(): + with self.subTest(digest=label): + self.write_lock(**overrides) + destination = self.root / ("installed-" + label) + result = self.run_installer( + archive=self.archive, + destination=destination, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_same_length_archive_mutation_fails_the_archive_digest(self) -> None: + original = self.archive.read_bytes() + mutated = bytearray(original) + mutated[-1] ^= 1 + self.archive.write_bytes(mutated) + self.write_lock( + archive_bytes=len(original), + archive_sha256=digest(original), + ) + + result = self.run_installer(archive=self.archive) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(self.destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_download_stops_at_the_pinned_archive_size_bound(self) -> None: + download_root = self.root / "download-source" + download_root.mkdir() + oversized = download_root / ARCHIVE_NAME + oversized.write_bytes(self.archive.read_bytes() + b"unexpected trailing byte") + self.write_lock(url=oversized.as_uri()) + + result = self.run_installer() + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("exceeded the pinned archive_bytes bound", result.stdout) + self.assertFalse(self.destination.exists(), result.stdout) + self.assertEqual([], sorted(self.root.glob(".picotool-download.*"))) + self.assert_no_staging_tree() + + def test_component_digests_are_checked_after_archive_digest(self) -> None: + cases = { + "changed-config": {"config": b"# changed package config\n"}, + "changed-libusb": {"libusb": b"changed bundled libusb\n"}, + } + for label, archive_overrides in cases.items(): + with self.subTest(component=label): + archive = self.root / (label + ".zip") + write_archive(archive, **archive_overrides) + self.write_lock(archive=archive) + destination = self.root / ("installed-" + label) + result = self.run_installer( + archive=archive, + destination=destination, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_exact_version_line_is_required_before_admission(self) -> None: + wrong = self.root / "wrong-version.zip" + wrong_executable = EXECUTABLE.replace(b"v2.3.0", b"v2.3.1") + write_archive(wrong, executable=wrong_executable) + self.write_lock(archive=wrong, executable=wrong_executable) + + result = self.run_installer(archive=wrong) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(self.destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_unsafe_zip_topologies_fail_before_destination_admission(self) -> None: + cases = { + "escaping-member": (("../escape", b"escape", stat.S_IFREG | 0o644),), + "nested-escape": ( + ("picotool/../escape", b"escape", stat.S_IFREG | 0o644), + ), + "absolute-member": (("/absolute", b"escape", stat.S_IFREG | 0o644),), + "unexpected-root": (("other/tool", b"tool", stat.S_IFREG | 0o755),), + "duplicate-member": ( + (EXECUTABLE_PATH, EXECUTABLE, stat.S_IFREG | 0o755), + ), + "symlink": (("picotool/link", b"picotool", stat.S_IFLNK | 0o777),), + "special-file": (("picotool/fifo", b"", stat.S_IFIFO | 0o644),), + } + for label, members in cases.items(): + with self.subTest(topology=label): + archive = self.root / f"{label}.zip" + write_archive(archive, extra_members=members) + self.write_lock(archive=archive) + destination = self.root / f"installed-{label}" + result = self.run_installer( + archive=archive, + destination=destination, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_missing_nonexecutable_or_encrypted_required_members_are_rejected(self): + cases = {} + missing_executable = self.root / "missing-executable.zip" + write_archive(missing_executable, include_executable=False) + cases["missing-executable"] = missing_executable + missing_config = self.root / "missing-config.zip" + write_archive(missing_config, include_config=False) + cases["missing-config"] = missing_config + missing_libusb = self.root / "missing-libusb.zip" + write_archive(missing_libusb, include_libusb=False) + cases["missing-libusb"] = missing_libusb + nonexecutable = self.root / "nonexecutable.zip" + write_archive( + nonexecutable, + executable_mode=stat.S_IFREG | 0o644, + ) + cases["nonexecutable"] = nonexecutable + encrypted = self.root / "encrypted.zip" + write_archive(encrypted) + mark_first_member_encrypted(encrypted) + cases["encrypted"] = encrypted + + for label, archive in cases.items(): + with self.subTest(member=label): + self.write_lock(archive=archive) + destination = self.root / f"installed-{label}" + result = self.run_installer( + archive=archive, + destination=destination, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_preexisting_mismatched_destination_is_preserved_and_rejected(self) -> None: + self.destination.mkdir() + sentinel = self.destination / "operator-owned-sentinel" + sentinel.write_bytes(b"do not replace\n") + + result = self.run_installer(archive=self.archive) + + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(sentinel.read_bytes(), b"do not replace\n") + self.assertEqual([sentinel], list(self.destination.iterdir())) + self.assert_no_staging_tree() + + def test_destination_creation_race_is_atomic_no_replace(self) -> None: + """Create an empty contender after the last advisory existence check.""" + + injection = self.root / "race-python" + injection.mkdir() + contender_identity = self.root / "contender-identity" + (injection / "sitecustomize.py").write_text( + "import os\n" + "_real_lexists = os.path.lexists\n" + "_target = os.environ['PYBLE_TEST_RACE_DESTINATION']\n" + "_marker = os.environ['PYBLE_TEST_RACE_MARKER']\n" + "_calls = 0\n" + "def _race(path):\n" + " global _calls\n" + " result = _real_lexists(path)\n" + " if os.path.abspath(os.fspath(path)) == _target:\n" + " _calls += 1\n" + " if _calls == 2 and not result:\n" + " os.mkdir(_target, 0o700)\n" + " identity = os.lstat(_target)\n" + " with open(_marker, 'w', encoding='ascii') as output:\n" + " output.write(f'{identity.st_dev}:{identity.st_ino}\\n')\n" + " return result\n" + "os.path.lexists = _race\n", + encoding="utf-8", + ) + + result = self.run_installer( + archive=self.archive, + environment_overrides={ + "PYTHONPATH": str(injection), + "PYBLE_TEST_RACE_DESTINATION": str( + Path(os.path.realpath(self.destination.parent)) + / self.destination.name + ), + "PYBLE_TEST_RACE_MARKER": str(contender_identity), + }, + ) + + self.assertTrue(contender_identity.is_file(), result.stdout) + self.assertNotEqual(result.returncode, 0, result.stdout) + wanted_device, wanted_inode = ( + int(value) + for value in contender_identity.read_text(encoding="ascii").strip().split(":") + ) + observed = self.destination.lstat() + self.assertEqual( + (observed.st_dev, observed.st_ino), + (wanted_device, wanted_inode), + ) + self.assertTrue(self.destination.is_dir()) + self.assertEqual([], list(self.destination.iterdir())) + self.assert_no_staging_tree() + + def test_destination_parent_identity_swap_is_rejected_and_cleaned(self) -> None: + ready = self.root / "version-probe-ready" + release = self.root / "version-probe-release" + barrier_executable = ( + b"#!/bin/sh\n" + b"[ \"${1:-}\" = version ] || exit 64\n" + b": > \"${PYBLE_TEST_VERSION_READY:?}\"\n" + b"while [ ! -e \"${PYBLE_TEST_VERSION_RELEASE:?}\" ]; do\n" + b" sleep 0.01\n" + b"done\n" + + f"printf '%s\\n' '{VERSION_LINE}'\n".encode("utf-8") + ) + archive = self.root / "parent-race.zip" + write_archive(archive, executable=barrier_executable) + self.write_lock(archive=archive, executable=barrier_executable) + original_parent = self.root / "original-parent" + original_parent.mkdir() + destination = original_parent / "installed" + environment = dict(os.environ) + environment.update( + { + "PYBLE_LOCK_FILE": str(self.lock), + "PYBLE_PICOTOOL_ARCHIVE": str(archive), + "PYBLE_TEST_VERSION_READY": str(ready), + "PYBLE_TEST_VERSION_RELEASE": str(release), + } + ) + process = subprocess.Popen( + [str(INSTALLER), str(destination)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + displaced_parent = self.root / "displaced-parent" + try: + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None: + if time.monotonic() >= deadline: + self.fail("picotool version-probe barrier was not reached") + time.sleep(0.01) + self.assertIsNone(process.poll(), "installer exited before the race") + + original_parent.rename(displaced_parent) + original_parent.mkdir() + attacker_sentinel = original_parent / "attacker-sentinel" + attacker_sentinel.write_bytes(b"preserve this parent\n") + release.touch() + stdout, _ = process.communicate(timeout=10) + finally: + release.touch(exist_ok=True) + if process.poll() is None: + process.kill() + process.communicate() + + self.assertNotEqual(process.returncode, 0, stdout) + self.assertEqual(attacker_sentinel.read_bytes(), b"preserve this parent\n") + self.assertFalse(destination.exists()) + self.assertEqual( + [], + sorted(displaced_parent.glob(".installed.incoming.*")), + "a rejected parent swap leaked its staged install", + ) + self.assertFalse((displaced_parent / destination.name).exists()) + + def test_fresh_install_reopens_all_bytes_after_the_version_probe(self) -> None: + ready = self.root / "fresh-version-probe-ready" + release = self.root / "fresh-version-probe-release" + barrier_executable = ( + b"#!/bin/sh\n" + b"[ \"${1:-}\" = version ] || exit 64\n" + b": > \"${PYBLE_TEST_VERSION_READY:?}\"\n" + b"while [ ! -e \"${PYBLE_TEST_VERSION_RELEASE:?}\" ]; do\n" + b" sleep 0.01\n" + b"done\n" + + f"printf '%s\\n' '{VERSION_LINE}'\n".encode("utf-8") + ) + archive = self.root / "fresh-reopen-race.zip" + write_archive(archive, executable=barrier_executable) + self.write_lock(archive=archive, executable=barrier_executable) + environment = dict(os.environ) + environment.update( + { + "PYBLE_LOCK_FILE": str(self.lock), + "PYBLE_PICOTOOL_ARCHIVE": str(archive), + "PYBLE_TEST_VERSION_READY": str(ready), + "PYBLE_TEST_VERSION_RELEASE": str(release), + } + ) + process = subprocess.Popen( + [str(INSTALLER), str(self.destination)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout = "" + try: + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None: + if time.monotonic() >= deadline: + self.fail("fresh-install version-probe barrier was not reached") + time.sleep(0.01) + self.assertIsNone(process.poll(), "installer exited before the race") + + incoming = sorted(self.root.glob(".installed-picotool.incoming.*")) + self.assertEqual(len(incoming), 1, "staging tree was not observable") + (incoming[0] / CONFIG_PATH).write_bytes( + b"tampered after the first complete-tree validation\n" + ) + release.touch() + stdout, _ = process.communicate(timeout=10) + finally: + release.touch(exist_ok=True) + if process.poll() is None: + process.kill() + process.communicate() + + self.assertNotEqual(process.returncode, 0, stdout) + self.assertFalse( + self.destination.exists(), + "a staging-tree mutation was admitted as the pinned distribution", + ) + self.assert_no_staging_tree() + + def test_existing_install_reopens_all_bytes_after_the_version_probe(self) -> None: + ready = self.root / "existing-version-probe-ready" + release = self.root / "existing-version-probe-release" + barrier_executable = ( + b"#!/bin/sh\n" + b"[ \"${1:-}\" = version ] || exit 64\n" + b": > \"${PYBLE_TEST_VERSION_READY:?}\"\n" + b"while [ ! -e \"${PYBLE_TEST_VERSION_RELEASE:?}\" ]; do\n" + b" sleep 0.01\n" + b"done\n" + + f"printf '%s\\n' '{VERSION_LINE}'\n".encode("utf-8") + ) + archive = self.root / "existing-reopen-race.zip" + write_archive(archive, executable=barrier_executable) + self.write_lock(archive=archive, executable=barrier_executable) + environment_overrides = { + "PYBLE_TEST_VERSION_READY": str(ready), + "PYBLE_TEST_VERSION_RELEASE": str(release), + } + + release.touch() + installed = self.run_installer( + archive=archive, + environment_overrides=environment_overrides, + ) + self.assertEqual(installed.returncode, 0, installed.stdout) + ready.unlink() + release.unlink() + + environment = dict(os.environ) + environment.update( + { + "PYBLE_LOCK_FILE": str(self.lock), + "PYBLE_TEST_VERSION_READY": str(ready), + "PYBLE_TEST_VERSION_RELEASE": str(release), + } + ) + environment.pop("PYBLE_PICOTOOL_ARCHIVE", None) + process = subprocess.Popen( + [str(INSTALLER), str(self.destination)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout = "" + tampered = b"tampered after the first complete-tree revalidation\n" + try: + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None: + if time.monotonic() >= deadline: + self.fail("existing-install version-probe barrier was not reached") + time.sleep(0.01) + self.assertIsNone(process.poll(), "installer exited before the race") + + (self.destination / CONFIG_PATH).write_bytes(tampered) + release.touch() + stdout, _ = process.communicate(timeout=10) + finally: + release.touch(exist_ok=True) + if process.poll() is None: + process.kill() + process.communicate() + + self.assertNotEqual(process.returncode, 0, stdout) + self.assertEqual( + (self.destination / CONFIG_PATH).read_bytes(), + tampered, + "revalidation must preserve a mismatched existing destination", + ) + self.assert_no_staging_tree() + + def test_retained_tree_is_revalidated_byte_for_byte_without_extras(self) -> None: + cases = { + "mutated-targets": lambda root: ( + root / TARGETS_PATH + ).write_bytes(b"tampered targets\n"), + "mutated-libusb": lambda root: ( + root / LIBUSB_PATH + ).write_bytes(b"tampered libusb\n"), + "mutated-retained-archive": lambda root: ( + root / ".pyble-dist" / ARCHIVE_NAME + ).write_bytes(b"tampered retained archive\n"), + "changed-mode": lambda root: ( + root / CONFIG_VERSION_PATH + ).chmod(0o600), + "injected-extra": lambda root: ( + root / "picotool" / "unreviewed-extra" + ).write_bytes(b"not in the distribution\n"), + } + for label, mutate in cases.items(): + with self.subTest(tree=label): + destination = self.root / ("installed-tree-" + label) + first = self.run_installer( + archive=self.archive, + destination=destination, + ) + self.assertEqual(first.returncode, 0, first.stdout) + mutate(destination) + before = { + path.relative_to(destination).as_posix(): ( + path.read_bytes(), path.stat().st_mode & 0o777 + ) + for path in destination.rglob("*") + if path.is_file() + } + fake_bin = self.root / ("fake-bin-" + label) + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text("#!/bin/sh\nexit 93\n", encoding="utf-8") + fake_curl.chmod(0o755) + second = self.run_installer( + path_prefix=fake_bin, + destination=destination, + ) + self.assertNotEqual(second.returncode, 0, second.stdout) + after = { + path.relative_to(destination).as_posix(): ( + path.read_bytes(), path.stat().st_mode & 0o777 + ) + for path in destination.rglob("*") + if path.is_file() + } + self.assertEqual(after, before, "revalidation rewrote owner bytes") + self.assert_no_staging_tree() + + def test_lock_controlled_paths_and_url_basename_cannot_escape(self) -> None: + cases = { + "archive-parent": {"archive_filename": "../escape.zip"}, + "archive-absolute": {"archive_filename": "/tmp/escape.zip"}, + "url-mismatch": {"url_filename": "different.zip"}, + "cmake-parent": {"cmake_dir": "../picotool"}, + "executable-parent": {"executable_path": "picotool/../tool"}, + "config-absolute": {"config_path": "/tmp/config.cmake"}, + "libusb-parent": {"libusb_path": "../libusb.dylib"}, + } + for label, overrides in cases.items(): + with self.subTest(lock_path=label): + self.write_lock(**overrides) + destination = self.root / ("installed-lock-" + label) + result = self.run_installer( + archive=self.archive, + destination=destination, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(destination.exists(), result.stdout) + self.assert_no_staging_tree() + + def test_missing_retained_archive_is_not_an_idempotent_success(self) -> None: + first = self.run_installer(archive=self.archive) + self.assertEqual(first.returncode, 0, first.stdout) + retained = self.destination / ".pyble-dist" / ARCHIVE_NAME + retained.unlink() + + fake_bin = self.root / "fake-bin" + fake_bin.mkdir() + fake_curl = fake_bin / "curl" + fake_curl.write_text("#!/bin/sh\nexit 92\n", encoding="utf-8") + fake_curl.chmod(0o755) + result = self.run_installer(path_prefix=fake_bin) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(retained.exists()) + + def test_symlinked_input_archive_and_destination_are_rejected(self) -> None: + archive_link = self.root / "archive-link.zip" + archive_link.symlink_to(self.archive) + input_result = self.run_installer(archive=archive_link) + self.assertNotEqual(input_result.returncode, 0, input_result.stdout) + self.assertFalse(self.destination.exists()) + + external = self.root / "external-destination" + external.mkdir() + sentinel = external / "sentinel" + sentinel.write_bytes(b"outside\n") + destination_link = self.root / "destination-link" + destination_link.symlink_to(external, target_is_directory=True) + destination_result = self.run_installer( + archive=self.archive, + destination=destination_link, + ) + self.assertNotEqual(destination_result.returncode, 0, destination_result.stdout) + self.assertEqual(sentinel.read_bytes(), b"outside\n") + self.assertEqual([sentinel], list(external.iterdir())) + + external_parent = self.root / "external-parent" + external_parent.mkdir() + linked_parent = self.root / "linked-parent" + linked_parent.symlink_to(external_parent, target_is_directory=True) + ancestor_result = self.run_installer( + archive=self.archive, + destination=linked_parent / "installed", + ) + self.assertNotEqual(ancestor_result.returncode, 0, ancestor_result.stdout) + self.assertFalse((external_parent / "installed").exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_oi1_profile_bench.py b/tests/firmware_tests/host/test_oi1_profile_bench.py index 8dd2feb9..1cf5eaa6 100644 --- a/tests/firmware_tests/host/test_oi1_profile_bench.py +++ b/tests/firmware_tests/host/test_oi1_profile_bench.py @@ -10,6 +10,7 @@ import copy import errno import hashlib +import importlib.util import inspect import io import json @@ -33,6 +34,26 @@ import oi1_profile_bench as profile_bench # noqa: E402 +def load_release_module(): + spec = importlib.util.spec_from_file_location( + "pyble_release_profile_bench_contract", + REPO_ROOT / "firmware" / "scripts" / "release_bundle.py", + ) + if spec is None or spec.loader is None: + raise AssertionError("cannot construct release_bundle.py import spec") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +RELEASE = load_release_module() +CURRENT_POLICY_PATH = REPO_ROOT / "firmware" / "qualification" / "oi1-gates.json" + + +def load_current_policy(): + return RELEASE._load_qualification_policy(REPO_ROOT) + + PROFILE_ORDER = ( "esp32-4mb", "esp32-s3-n16r8", @@ -65,12 +86,6 @@ } WAVESHARE_PROFILE_ID = "waveshare-esp32-s3-lcd-147b" V5_FIXED_WAVESHARE_LARGEST_BLOCK_MIN_BYTES = 98304 -EXPECTED_LARGEST_BLOCK_FLOORS = { - "esp32-4mb": 55296, - "esp32-s3-n16r8": 102400, - WAVESHARE_PROFILE_ID: V5_FIXED_WAVESHARE_LARGEST_BLOCK_MIN_BYTES, - "esp32-c3-4mb": 57344, -} FIXED_PERFORMANCE_THRESHOLDS = { profile_id: { "reset_to_service_advertisement_max_ms": ( @@ -391,6 +406,88 @@ def _valid_observation(profile_id): } +class CurrentPolicyBindingTest(unittest.TestCase): + def setUp(self): + self.policy = json.loads(CURRENT_POLICY_PATH.read_bytes()) + self.baseline_path = REPO_ROOT / self.policy["baseline_evidence"]["path"] + self.baseline = json.loads(self.baseline_path.read_bytes()) + + @staticmethod + def canonical(value): + return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + + @contextlib.contextmanager + def changed_inputs(self, policy, baseline=None): + # Override only selected reads; real checkout ancestry and every other + # source/evidence check remain active. No repository file is written. + replacements = {CURRENT_POLICY_PATH: self.canonical(policy)} + if baseline is not None: + replacements[self.baseline_path] = self.canonical(baseline) + original_read = Path.read_bytes + + def read(path): + return replacements[path] if path in replacements else original_read(path) + + with mock.patch.object(Path, "read_bytes", autospec=True, side_effect=read): + yield + + def test_active_policy_uses_actual_checkout_era_and_full_validator(self): + with mock.patch.object( + RELEASE, + "_qualification_derivation_for_checkout", + wraps=RELEASE._qualification_derivation_for_checkout, + ) as selector: + policy, digest = load_current_policy() + self.assertEqual(policy, self.policy) + self.assertEqual(digest, hashlib.sha256(CURRENT_POLICY_PATH.read_bytes()).hexdigest()) + selector.assert_called_once_with( + REPO_ROOT, RELEASE._read_lock(REPO_ROOT)["pyble"]["agent_version"] + ) + + def test_every_profile_threshold_is_bound_to_actual_baseline(self): + for index, profile in enumerate(self.policy["profiles"]): + for key in profile["thresholds"]: + with self.subTest(profile=profile["profile_id"], threshold=key): + changed = copy.deepcopy(self.policy) + changed["profiles"][index]["thresholds"][key] += 1 + with self.changed_inputs(changed), self.assertRaisesRegex( + RELEASE.ReleaseError, "thresholds were not derived from baseline" + ): + load_current_policy() + + def test_changed_baseline_digest_is_rejected(self): + changed = copy.deepcopy(self.policy) + changed["baseline_evidence"]["sha256"] = "0" * 64 + with self.changed_inputs(changed), self.assertRaisesRegex( + RELEASE.ReleaseError, "baseline evidence digest changed" + ): + load_current_policy() + + def test_rehashed_baseline_source_filename_mismatch_is_rejected(self): + baseline = copy.deepcopy(self.baseline) + baseline["source_commit"] = "0" * 40 + changed = copy.deepcopy(self.policy) + changed["baseline_evidence"]["sha256"] = hashlib.sha256( + self.canonical(baseline) + ).hexdigest() + with self.changed_inputs(changed, baseline), self.assertRaisesRegex( + RELEASE.ReleaseError, "source commit disagrees with its filename" + ): + load_current_policy() + + def test_rehashed_incomplete_baseline_observations_are_rejected(self): + baseline = copy.deepcopy(self.baseline) + baseline["profiles"][0]["oi1_observation"][ + "reset_to_service_advertisement_ms" + ].pop() + changed = copy.deepcopy(self.policy) + changed["baseline_evidence"]["sha256"] = hashlib.sha256( + self.canonical(baseline) + ).hexdigest() + with self.changed_inputs(changed, baseline), self.assertRaises(RELEASE.ReleaseError): + load_current_policy() + + class FrozenConstantsTest(unittest.TestCase): def test_exact_v060_candidate_profiles_and_common_workload_are_frozen(self): self.assertEqual(tuple(bench.PROFILE_ORDER), CURRENT_PROFILE_ORDER) @@ -422,12 +519,13 @@ def test_exact_v060_candidate_profiles_and_common_workload_are_frozen(self): SECOND_REPLACEMENT_V5_DERIVATION, ) - def test_committed_v060_policy_uses_fixed_slos_and_retains_a8_baseline(self): + def test_current_policy_uses_fixed_slos_and_validated_baseline_resources(self): policy_path = ( REPO_ROOT / "firmware" / "qualification" / "oi1-gates.json" ) payload = policy_path.read_bytes() - policy = json.loads(payload.decode("utf-8")) + policy, policy_sha256 = load_current_policy() + self.assertEqual(policy_sha256, hashlib.sha256(payload).hexdigest()) self.assertEqual(policy["schema_version"], 3) self.assertEqual( @@ -446,19 +544,6 @@ def test_committed_v060_policy_uses_fixed_slos_and_retains_a8_baseline(self): PROFILE_TARGETS, ) self.assertNotIn("deferred_profiles", policy) - self.assertEqual( - policy["baseline_evidence"], - { - "path": ( - "docs/validation/firmware/oi1/" - "a8be631df46590166307aa41afaea30b39e29230.json" - ), - "sha256": ( - "8a7dbf328ba8d70f5161582b56d1566821f20b7a259ff29d" - "c7d6fe6bb75a6044" - ), - }, - ) self.assertEqual(policy["derivation"], SECOND_REPLACEMENT_V5_DERIVATION) self.assertEqual( { @@ -475,19 +560,14 @@ def test_committed_v060_policy_uses_fixed_slos_and_retains_a8_baseline(self): FIXED_PERFORMANCE_THRESHOLDS, ) self.assertEqual( - { - entry["profile_id"]: entry["thresholds"][ + next( + entry["thresholds"][ "idf_internal_largest_block_min_bytes" ] for entry in policy["profiles"] - if "idf_internal_largest_block_min_bytes" in entry["thresholds"] - }, - EXPECTED_LARGEST_BLOCK_FLOORS, - ) - self.assertEqual(len(payload), 5036) - self.assertEqual( - hashlib.sha256(payload).hexdigest(), - "c3167853df6c31d7364b58616701d45ea62f2374d18cacc89e51292624dca8db", + if entry["profile_id"] == WAVESHARE_PROFILE_ID + ), + V5_FIXED_WAVESHARE_LARGEST_BLOCK_MIN_BYTES, ) def test_retained_a8_baseline_bytes_and_samples_remain_immutable(self): diff --git a/tests/firmware_tests/host/test_pble_central_response_correlation.py b/tests/firmware_tests/host/test_pble_central_response_correlation.py index 2e664a43..5f4da90d 100644 --- a/tests/firmware_tests/host/test_pble_central_response_correlation.py +++ b/tests/firmware_tests/host/test_pble_central_response_correlation.py @@ -281,6 +281,65 @@ async def on_write(): self.assertIs(observed, exact) + async def test_on_written_runs_after_all_fragments_before_response_wait(self): + request_id = 111 + events = [] + central = None + exact = _rsp(wire.OP_FILE_DELETE, request_id, 0xF1) + + class FragmentedWrite: + is_connected = True + + async def write_gatt_char(self, _uuid, _packet, response): + self.assert_response = response + events.append("write") + if events.count("write") == 1: + _deliver(central, exact) + + central = central_module.PbleCentral(FragmentedWrite()) + original_await_rsp = central._await_rsp + + async def observed_await_rsp(*args, **kwargs): + events.append("await-response") + return await original_await_rsp(*args, **kwargs) + + central._await_rsp = observed_await_rsp + observed = await central.send_cmd( + wire.OP_FILE_DELETE, + request_id, + b"x" * 64, + timeout=0.1, + on_written=lambda: events.append("written"), + ) + + self.assertEqual(observed.type, exact.type) + self.assertEqual(observed.opcode, exact.opcode) + self.assertEqual(observed.id, exact.id) + self.assertEqual(observed.payload, exact.payload) + self.assertGreater(events.count("write"), 1) + self.assertEqual(events[-2:], ["written", "await-response"]) + + async def test_on_written_is_not_called_when_the_command_write_fails(self): + request_id = 112 + called = [] + + class FailedWrite: + is_connected = True + + async def write_gatt_char(self, _uuid, _packet, response): + self.assert_response = response + raise OSError("write failed") + + central = central_module.PbleCentral(FailedWrite()) + with self.assertRaisesRegex(OSError, "write failed"): + await central.send_cmd( + wire.OP_FILE_DELETE, + request_id, + timeout=0.1, + on_written=lambda: called.append(True), + ) + self.assertEqual(called, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/firmware_tests/host/test_pble_window_bump.py b/tests/firmware_tests/host/test_pble_window_bump.py index 32bbf950..ec1533b6 100644 --- a/tests/firmware_tests/host/test_pble_window_bump.py +++ b/tests/firmware_tests/host/test_pble_window_bump.py @@ -20,9 +20,10 @@ # honest host-observable check is a compile-time (static-source) assertion on # the two constants and their W+2 relation — exactly the "compile-time check in # the test harness" the task scopes when queue depth is not host-observable. -# The BYTE-EXACT caps/HELLO wire vector stays gated in conformance/s3_pending -# until §7 caps serialization is filled into the shared corpus; this file -# asserts only the FROZEN NUMERIC contract (W=8, queue W+2), never a draft byte. +# The legacy shared corpus did not retroactively gain a byte-exact caps vector; +# its pre-freeze planning ledger is archived. This file asserts the frozen +# numeric contract (W=8, queue W+2), while current caps semantics are covered by +# the dedicated host/session suites. # # HAND-OFFs for [green] (I never edit firmware/**): # - pble_info.c caps `window=` 4->8 .......... identity-engineer diff --git a/tests/firmware_tests/host/test_pico_mount_recovery_v061.py b/tests/firmware_tests/host/test_pico_mount_recovery_v061.py new file mode 100644 index 00000000..567de03d --- /dev/null +++ b/tests/firmware_tests/host/test_pico_mount_recovery_v061.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# [red] v0.6.1 non-destructive Pico workspace mount recovery. +# +# Expected host-testable production seam: +# +# pyble_boot.mount_lfs2(bdev, vfs_module, *, progsize=256) -> VfsLfs2 +# +# A healthy constructor is returned immediately. Only an OSError from that +# first constructor permits a read-only, complete-device erased-state scan. +# Exactly-all-0xff media may be formatted once and constructed once more. +# Nonblank or uncertain media is never formatted and the mount error escapes +# the helper; the overlay converts it into one bounded local USB recovery +# message and skips agent/autorun startup. +# +# The seam deliberately lives in the already-frozen pyble_boot module. The +# board overlay contains only integration plumbing; this avoids creating a new +# release source-set entry merely to make a destructive boot decision testable. + +import ast +import errno +import os +import sys +import types +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _support # noqa: E402 + + +BOOT = _support.RedReason("pyble_boot", owner="rp2-agent-engineer") +WORKSPACE = _support.RedReason("pyble_workspace", owner="v0.6.1-storage-engineer") + +BLOCK_COUNT_IOCTL = 4 +BLOCK_SIZE_IOCTL = 5 +REPO_ROOT = _support.REPO_ROOT +OVERLAY_BOOT = os.path.join( + REPO_ROOT, "firmware", "board_overlays", "rpi-pico2-w", "_boot.py") + + +class FakeBlockDevice: + """Small RP2-style block device with observable read/write effects.""" + + def __init__(self, blocks, read_error_at=None, ioctl_error=None, + count_override=None, size_override=None, read_error=None, + read_return=None, wrong_size_at=None, partial_at=None, + block_size=4096): + if any(len(block) > block_size for block in blocks): + raise ValueError("fixture block exceeds its declared geometry") + # Use an LFS2-compatible default geometry even when a test spells only + # the significant prefix of a block. This keeps ordinary recovery + # cases realistic while dedicated cases can select hostile geometry. + self.blocks = [ + bytes(block) + b"\xff" * (block_size - len(block)) + for block in blocks + ] + self.block_size = block_size + self.read_error_at = read_error_at + self.read_error = read_error + self.ioctl_error = ioctl_error + self.count_override = count_override + self.size_override = size_override + self.read_return = read_return + self.wrong_size_at = wrong_size_at + self.partial_at = partial_at + self.ioctl_calls = [] + self.read_calls = [] + self.read_buffers = [] + self.write_calls = [] + + def ioctl(self, command, argument): + self.ioctl_calls.append((command, argument)) + if self.ioctl_error is not None: + raise self.ioctl_error + if command == BLOCK_COUNT_IOCTL: + if self.count_override is not None: + return self.count_override + return len(self.blocks) + if command == BLOCK_SIZE_IOCTL: + if self.size_override is not None: + return self.size_override + return self.block_size if self.blocks else 0 + return None + + def readblocks(self, block_number, destination): + self.read_calls.append(block_number) + self.read_buffers.append(destination) + if block_number == self.read_error_at: + if self.read_error is not None: + raise self.read_error + raise OSError(errno.EIO, "injected read fault") + block = self.blocks[block_number] + if block_number == self.wrong_size_at: + destination[:] = block[:-1] # malicious Python bdev shrinks bytearray + elif block_number == self.partial_at: + # Write only a strict prefix. A safe scanner poisons its reused + # buffer before each read so stale all-FF bytes cannot make this + # incomplete read look like conclusively erased media. + prefix = max(1, len(block) // 2) + destination[:prefix] = block[:prefix] + else: + destination[:] = block + return self.read_return + + def writeblocks(self, block_number, source, *offset): + self.write_calls.append((block_number, bytes(source), offset)) + raise AssertionError( + "blank detection must be read-only; only VfsLfs2.mkfs owns writes") + + +def fake_vfs(constructor_effects, mkfs_effect=None): + """Return (module, state) for deterministic VfsLfs2 construction.""" + state = types.SimpleNamespace( + effects=list(constructor_effects), + constructor_calls=[], + mkfs_calls=[], + order=[]) + + class VfsLfs2: + def __new__(cls, bdev, *, progsize): + state.constructor_calls.append((bdev, progsize)) + state.order.append("construct") + if not state.effects: + raise AssertionError("unexpected extra VfsLfs2 construction") + effect = state.effects.pop(0) + if isinstance(effect, BaseException): + raise effect + return effect + + @classmethod + def mkfs(cls, bdev, *, progsize): + state.mkfs_calls.append((bdev, progsize)) + state.order.append("mkfs") + if mkfs_effect is not None: + raise mkfs_effect + + return types.SimpleNamespace(VfsLfs2=VfsLfs2), state + + +class MountLfs2RecoveryTest(unittest.TestCase): + def mount_fn(self, criterion): + return BOOT.attr(self, "mount_lfs2", criterion) + + def test_pico_reexports_the_single_shared_workspace_mount_decision(self): + shared = WORKSPACE.attr( + self, "mount_lfs2", "v0.6.1 shared LFS2 recovery authority") + self.assertIs( + self.mount_fn("v0.6.1 Pico uses shared LFS2 recovery authority"), + shared, + "Pico and ESP boot must not drift into different erased-media " + "format decisions", + ) + + def test_healthy_mount_does_not_inspect_or_format_storage(self): + mounted = object() + bdev = FakeBlockDevice([b"\xff" * 8, b"\xff" * 8]) + vfs_module, state = fake_vfs([mounted]) + + result = self.mount_fn( + "v0.6.1 healthy LFS2 construction has no recovery side effect")( + bdev, vfs_module, progsize=256) + + self.assertIs(result, mounted) + self.assertEqual(state.constructor_calls, [(bdev, 256)]) + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(bdev.ioctl_calls, []) + self.assertEqual(bdev.read_calls, []) + self.assertEqual(bdev.write_calls, []) + + def test_fully_erased_media_formats_exactly_once_then_reconstructs(self): + mount_error = OSError(errno.EIO, "unformatted") + mounted = object() + blocks = [b"\xff" * 7 for _ in range(4)] + bdev = FakeBlockDevice(blocks) + vfs_module, state = fake_vfs([mount_error, mounted]) + + result = self.mount_fn( + "v0.6.1 only conclusively blank media may be formatted")( + bdev, vfs_module, progsize=256) + + self.assertIs(result, mounted) + self.assertEqual(bdev.read_calls, [0, 1, 2, 3], + "blank recovery must inspect every reported block") + self.assertEqual( + len(set(id(buffer) for buffer in bdev.read_buffers)), 1, + "the complete flash scan must reuse one bounded block buffer") + self.assertEqual( + set(bdev.ioctl_calls), + {(BLOCK_COUNT_IOCTL, 0), (BLOCK_SIZE_IOCTL, 0)}) + self.assertEqual(state.mkfs_calls, [(bdev, 256)]) + self.assertEqual(state.constructor_calls, [(bdev, 256), (bdev, 256)]) + self.assertEqual(state.order, ["construct", "mkfs", "construct"]) + self.assertEqual(bdev.write_calls, [], + "the scanner itself must perform no block write") + + def test_one_nonblank_byte_rejects_format_after_a_full_scan(self): + mount_error = OSError(errno.EIO, "corrupt nonblank filesystem") + blocks = [b"\x00" + b"\xff" * 7, b"\xff" * 8, b"\xff" * 8] + bdev = FakeBlockDevice(blocks) + vfs_module, state = fake_vfs([mount_error]) + + with self.assertRaises(OSError) as raised: + self.mount_fn( + "v0.6.1 nonblank workspace is never auto-formatted")( + bdev, vfs_module, progsize=256) + + self.assertIs(raised.exception, mount_error, + "nonblank recovery must re-raise the original mount fault") + self.assertEqual( + bdev.read_calls, [0, 1, 2], + "the erased-state decision is a complete scan, not a prefix probe") + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_nonblank_last_block_is_also_detected(self): + mount_error = OSError(errno.EIO, "corrupt nonblank filesystem") + blocks = [b"\xff" * 8, b"\xff" * 8, b"\xff" * 7 + b"\x7f"] + bdev = FakeBlockDevice(blocks) + vfs_module, state = fake_vfs([mount_error]) + + with self.assertRaises(OSError): + self.mount_fn( + "v0.6.1 final flash block participates in blank decision")( + bdev, vfs_module, progsize=256) + + self.assertEqual(bdev.read_calls, [0, 1, 2]) + self.assertEqual(state.mkfs_calls, []) + + def test_scan_read_failure_is_uncertain_and_never_formats(self): + for read_error in ( + OSError(errno.EIO, "read fault"), MemoryError("scan allocation")): + with self.subTest(exception=type(read_error).__name__): + bdev = FakeBlockDevice( + [b"\xff" * 8, b"\xff" * 8, b"\xff" * 8], + read_error_at=1, read_error=read_error) + vfs_module, state = fake_vfs( + [OSError(errno.EIO, "mount failed")]) + + with self.assertRaises((OSError, MemoryError)): + self.mount_fn( + "v0.6.1 unreadable storage is not equivalent to blank storage")( + bdev, vfs_module, progsize=256) + + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_nonzero_readblocks_status_is_not_treated_as_blank(self): + bdev = FakeBlockDevice( + [b"\xff" * 8, b"\xff" * 8], read_return=1) + vfs_module, state = fake_vfs([OSError(errno.EIO, "mount failed")]) + + with self.assertRaises((OSError, ValueError)): + self.mount_fn( + "v0.6.1 nonzero readblocks result makes blank state uncertain")( + bdev, vfs_module, progsize=256) + + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_readblocks_may_not_shrink_the_reused_buffer(self): + bdev = FakeBlockDevice( + [b"\xff" * 8, b"\xff" * 8], wrong_size_at=1) + vfs_module, state = fake_vfs([OSError(errno.EIO, "mount failed")]) + + with self.assertRaises((OSError, ValueError)): + self.mount_fn( + "v0.6.1 wrong-sized read mutation is not conclusive blank media")( + bdev, vfs_module, progsize=256) + + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_short_second_block_write_cannot_inherit_prior_all_ff_tail(self): + bdev = FakeBlockDevice( + [b"\xff" * 8, b"\xff" * 8], partial_at=1) + vfs_module, state = fake_vfs([OSError(errno.EIO, "mount failed")]) + + with self.assertRaises((OSError, ValueError)): + self.mount_fn( + "v0.6.1 short read mutation cannot reuse stale erased bytes")( + bdev, vfs_module, progsize=256) + + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_geometry_failure_or_invalid_geometry_never_formats(self): + cases = ( + (dict(ioctl_error=OSError(errno.EIO, "ioctl")), "ioctl failure"), + (dict(count_override=0), "zero block count"), + (dict(count_override=1), "one block cannot hold a metadata pair"), + (dict(size_override=0), "zero block size"), + ) + for kwargs, label in cases: + with self.subTest(label=label): + bdev = FakeBlockDevice([b"\xff" * 8], **kwargs) + vfs_module, state = fake_vfs( + [OSError(errno.EIO, "mount failed")]) + with self.assertRaises((OSError, ValueError)): + self.mount_fn( + "v0.6.1 uncertain block geometry is fail-closed")( + bdev, vfs_module, progsize=256) + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.write_calls, []) + + def test_positive_but_lfs_incompatible_geometry_never_formats(self): + # MicroPython derives cache_size as + # min(block_size, 4 * max(readsize=32, progsize)). LittleFS requires + # cache % readsize == 0, cache % progsize == 0, block % cache == 0, + # and block >= 128. Positive integers alone are therefore insufficient + # authority for the destructive first-use format path. + cases = ( + (64, 32, "block below LittleFS minimum"), + (128, 256, "cache is not a multiple of progsize"), + (1280, 256, "block is not a multiple of derived cache"), + ) + for block_size, progsize, label in cases: + with self.subTest(label=label): + bdev = FakeBlockDevice( + [b"\xff", b"\xff"], block_size=block_size) + vfs_module, state = fake_vfs( + [OSError(errno.EIO, "mount failed"), object()]) + + with self.assertRaises(ValueError): + self.mount_fn( + "v0.6.1 LFS-incompatible geometry is fail-closed")( + bdev, vfs_module, progsize=progsize) + + self.assertEqual(state.mkfs_calls, []) + self.assertEqual(len(state.constructor_calls), 1) + self.assertEqual(bdev.read_calls, []) + self.assertEqual(bdev.write_calls, []) + + def test_unexpected_constructor_exception_propagates_without_scan(self): + for failure in (RuntimeError("bug"), MemoryError("allocation")): + with self.subTest(exception=type(failure).__name__): + bdev = FakeBlockDevice([b"\xff" * 8]) + vfs_module, state = fake_vfs([failure]) + with self.assertRaises(type(failure)) as raised: + self.mount_fn( + "v0.6.1 only OSError enters first-boot recovery")( + bdev, vfs_module, progsize=256) + self.assertIs(raised.exception, failure) + self.assertEqual(bdev.ioctl_calls, []) + self.assertEqual(bdev.read_calls, []) + self.assertEqual(state.mkfs_calls, []) + + def test_mkfs_failure_does_not_attempt_a_remount(self): + bdev = FakeBlockDevice([b"\xff" * 8, b"\xff" * 8]) + mkfs_error = OSError(errno.EIO, "mkfs failed") + vfs_module, state = fake_vfs( + [OSError(errno.EIO, "unformatted")], mkfs_effect=mkfs_error) + + with self.assertRaises(OSError) as raised: + self.mount_fn( + "v0.6.1 blank recovery has one destructive attempt")( + bdev, vfs_module, progsize=256) + + self.assertIs(raised.exception, mkfs_error) + self.assertEqual(len(state.mkfs_calls), 1) + self.assertEqual(len(state.constructor_calls), 1) + + def test_post_format_constructor_failure_never_formats_again(self): + first = OSError(errno.EIO, "unformatted") + second = OSError(errno.EIO, "still unmountable") + bdev = FakeBlockDevice([b"\xff" * 8, b"\xff" * 8]) + vfs_module, state = fake_vfs([first, second]) + + with self.assertRaises(OSError) as raised: + self.mount_fn( + "v0.6.1 post-format mount failure has no format loop")( + bdev, vfs_module, progsize=256) + + self.assertIs(raised.exception, second) + self.assertEqual(len(state.mkfs_calls), 1) + self.assertEqual(len(state.constructor_calls), 2) + self.assertEqual(state.order, ["construct", "mkfs", "construct"]) + + +class OverlayIntegrationTest(unittest.TestCase): + """The destructive decision belongs to the tested helper, not _boot.py.""" + + @classmethod + def setUpClass(cls): + with open(OVERLAY_BOOT, "r", encoding="utf-8") as source_file: + cls.source = source_file.read() + cls.tree = ast.parse(cls.source, filename=OVERLAY_BOOT) + + @staticmethod + def _call_name(call): + fn = call.func + if isinstance(fn, ast.Name): + return fn.id + if isinstance(fn, ast.Attribute): + if isinstance(fn.value, ast.Name): + return fn.value.id + "." + fn.attr + return fn.attr + return "" + + def call_names(self): + return [ + self._call_name(node) + for node in ast.walk(self.tree) + if isinstance(node, ast.Call) + ] + + def test_overlay_delegates_mount_recovery_once(self): + call_names = self.call_names() + helper_calls = [ + name for name in call_names + if name in ("mount_lfs2", "pyble_boot.mount_lfs2") + ] + + self.assertEqual( + len(helper_calls), 1, + "rpi-pico2-w/_boot.py must call the tested mount_lfs2 seam exactly once") + + def test_overlay_contains_no_direct_mkfs_authority(self): + call_names = self.call_names() + self.assertNotIn( + "vfs.VfsLfs2.mkfs", call_names, + "the board overlay must not retain an untested destructive fallback") + self.assertFalse( + any(name == "mkfs" or name.endswith(".mkfs") for name in call_names), + "all formatting authority belongs inside pyble_boot.mount_lfs2") + + def test_agent_start_is_textually_after_mount_and_vfs_attachment(self): + calls = [ + (node.lineno, self._call_name(node)) + for node in ast.walk(self.tree) + if isinstance(node, ast.Call) + ] + mount_helper_lines = [ + line for line, name in calls + if name in ("mount_lfs2", "pyble_boot.mount_lfs2") + ] + attach_lines = [line for line, name in calls if name == "vfs.mount"] + agent_lines = [line for line, name in calls if name == "pyble_agent.main"] + + self.assertEqual(len(mount_helper_lines), 1, + "overlay must have one mount_lfs2 call") + self.assertEqual(len(attach_lines), 1, + "overlay must attach the returned VFS exactly once") + self.assertEqual(len(agent_lines), 1, + "overlay must start the agent exactly once") + self.assertLess(mount_helper_lines[0], attach_lines[0]) + self.assertLess( + attach_lines[0], agent_lines[0], + "workspace attachment must resolve before BLE agent startup") + + def assert_one_bounded_recovery_print(self, printed): + self.assertEqual( + len(printed), 1, + "a failed workspace mount must print exactly one local recovery message") + args, kwargs = printed[0] + self.assertEqual(kwargs, {}, + "the recovery message must not depend on an output option") + self.assertEqual(len(args), 1, + "exception objects or variable diagnostics must not be printed") + self.assertIs(type(args[0]), str) + self.assertIn("recovery", args[0].lower(), + "the fixed USB message must tell the operator it is recovery") + + def test_mount_recovery_failure_prints_once_and_skips_attach_and_agent(self): + class MountFailure(OSError): + pass + + calls = [] + printed = [] + bdev = object() + + class DirectLfs2: + # Makes the legacy inline constructor path succeed, so this test's + # RED is specifically failure to delegate/handle the helper—not + # an incidental missing attribute in the fake VFS module. + def __new__(cls, _bdev, *, progsize): + calls.append(("direct-constructor", progsize)) + return object() + + @classmethod + def mkfs(cls, _bdev, *, progsize): + calls.append(("direct-mkfs", progsize)) + + def mount_lfs2(_bdev, _vfs_module, *, progsize, observe_boot=False): + self.assertIs(observe_boot, True) + calls.append(("recovery-helper", progsize)) + raise MountFailure(errno.EIO, "nonblank mount failed") + + fake_modules = { + "rp2": types.SimpleNamespace(Flash=lambda: bdev), + "vfs": types.SimpleNamespace( + VfsLfs2=DirectLfs2, + mount=lambda _fs, path: calls.append(("vfs-mount", path))), + "pyble_boot": types.SimpleNamespace(mount_lfs2=mount_lfs2), + "pyble_workspace": types.SimpleNamespace( + boot_attached=lambda: calls.append(("unexpected-attachment",)), + boot_recovery=lambda: print( + "PyBLE workspace recovery is required; reconnect by USB.")), + "pyble_agent": types.SimpleNamespace( + main=lambda: calls.append(("agent-main",))), + } + + with mock.patch.dict(sys.modules, fake_modules), mock.patch( + "builtins.print", + side_effect=lambda *args, **kwargs: printed.append((args, kwargs))): + try: + exec(compile(self.source, OVERLAY_BOOT, "exec"), + {"__name__": "_pyble_boot_v061_probe"}) + except Exception as exc: + self.fail( + "_boot.py must convert mount recovery failure into bounded " + "local USB recovery, not propagate {}: {}".format( + type(exc).__name__, exc)) + + self.assertEqual(calls, [("recovery-helper", 256)]) + self.assert_one_bounded_recovery_print(printed) + + def test_vfs_mount_oserror_does_not_scan_format_or_start_agent(self): + class MountFailure(OSError): + pass + + calls = [] + printed = [] + bdev = object() + constructed = object() + + class DirectLfs2: + def __new__(cls, _bdev, *, progsize): + calls.append(("direct-constructor", progsize)) + return object() + + @classmethod + def mkfs(cls, _bdev, *, progsize): + calls.append(("direct-mkfs", progsize)) + + def mount_lfs2(_bdev, _vfs_module, *, progsize, observe_boot=False): + self.assertIs(observe_boot, True) + calls.append(("recovery-helper", progsize)) + return constructed + + def attach(_fs, path): + self.assertIs(_fs, constructed) + calls.append(("vfs-mount", path)) + raise MountFailure(errno.EIO, "VFS attachment failed") + + fake_modules = { + "rp2": types.SimpleNamespace(Flash=lambda: bdev), + "vfs": types.SimpleNamespace(VfsLfs2=DirectLfs2, mount=attach), + "pyble_boot": types.SimpleNamespace(mount_lfs2=mount_lfs2), + "pyble_workspace": types.SimpleNamespace( + boot_attached=lambda: calls.append(("unexpected-attachment",)), + boot_recovery=lambda: print( + "PyBLE workspace recovery is required; reconnect by USB.")), + "pyble_agent": types.SimpleNamespace( + main=lambda: calls.append(("agent-main",))), + } + + with mock.patch.dict(sys.modules, fake_modules), mock.patch( + "builtins.print", + side_effect=lambda *args, **kwargs: printed.append((args, kwargs))): + try: + exec(compile(self.source, OVERLAY_BOOT, "exec"), + {"__name__": "_pyble_boot_v061_attach_probe"}) + except Exception as exc: + self.fail( + "vfs.mount OSError must enter bounded local recovery without " + "constructor rescan/format or propagation; got {}: {}".format( + type(exc).__name__, exc)) + + self.assertEqual( + calls, + [("recovery-helper", 256), ("vfs-mount", "/")], + "an attachment failure must not retry construction, scan, format, " + "or start the agent") + self.assert_one_bounded_recovery_print(printed) + + def test_unexpected_recovery_exceptions_use_one_fixed_local_message(self): + observed_messages = [] + failures = ( + MemoryError("scan buffer allocation failed"), + ValueError("invalid recovery geometry"), + RuntimeError("unexpected recovery fault"), + ) + + for failure in failures: + with self.subTest(exception=type(failure).__name__): + calls = [] + printed = [] + bdev = object() + + class DirectLfs2: + def __new__(cls, _bdev, *, progsize): + calls.append(("direct-constructor", progsize)) + return object() + + @classmethod + def mkfs(cls, _bdev, *, progsize): + calls.append(("direct-mkfs", progsize)) + + def mount_lfs2(_bdev, _vfs_module, *, progsize, observe_boot=False): + self.assertIs(observe_boot, True) + calls.append(("recovery-helper", progsize)) + raise failure + + fake_modules = { + "rp2": types.SimpleNamespace(Flash=lambda: bdev), + "vfs": types.SimpleNamespace( + VfsLfs2=DirectLfs2, + mount=lambda _fs, path: calls.append(("vfs-mount", path))), + "pyble_boot": types.SimpleNamespace(mount_lfs2=mount_lfs2), + "pyble_workspace": types.SimpleNamespace( + boot_attached=lambda: calls.append(("unexpected-attachment",)), + boot_recovery=lambda: print( + "PyBLE workspace recovery is required; reconnect by USB.")), + "pyble_agent": types.SimpleNamespace( + main=lambda: calls.append(("agent-main",))), + } + + with mock.patch.dict(sys.modules, fake_modules), mock.patch( + "builtins.print", + side_effect=lambda *args, **kwargs: + printed.append((args, kwargs))): + try: + exec(compile(self.source, OVERLAY_BOOT, "exec"), + {"__name__": "_pyble_boot_v061_unexpected_probe"}) + except Exception as exc: + self.fail( + "{} from mount_lfs2 must become bounded local " + "recovery, not propagate {}: {}".format( + type(failure).__name__, type(exc).__name__, exc)) + + self.assertEqual( + calls, [("recovery-helper", 256)], + "unexpected recovery failure must neither attach VFS nor " + "start agent/autorun") + self.assert_one_bounded_recovery_print(printed) + observed_messages.append(printed[0][0][0]) + + self.assertEqual( + len(set(observed_messages)), 1, + "allocation, validation, and unexpected recovery faults must emit " + "one fixed bounded message without exception-dependent text") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_prepare_python_materialization.py b/tests/firmware_tests/host/test_prepare_python_materialization.py index 20346add..e1d3a892 100644 --- a/tests/firmware_tests/host/test_prepare_python_materialization.py +++ b/tests/firmware_tests/host/test_prepare_python_materialization.py @@ -3,8 +3,8 @@ # Part of PyBLE (https://pyble.dev) — see /LICENSE. """RED contract tests for deterministic prepare.sh Python materialization. -The ESP manifests select four reviewed scaffolds. The RP2 manifest selects an -exact literal eleven-file package. Preparation must materialize those inputs, +The ESP manifests select five reviewed scaffolds. The RP2 manifest selects an +exact literal twelve-file package. Preparation must materialize those inputs, not recursively trust whatever ignored files happen to be beside them in a developer checkout. """ @@ -36,6 +36,7 @@ "_version.py", "pyble_ble.py", "pyble_proto.py", + "pyble_workspace.py", ) RP2_PYBLE_INVENTORY = ( @@ -50,6 +51,7 @@ "pyble_info.py", "pyble_proto.py", "pyble_runner.py", + "pyble_workspace.py", ) UNREVIEWED_PATHS = ( @@ -216,7 +218,7 @@ def materialized_bytes(package: Path) -> dict[str, bytes]: class PreparePythonMaterializationTests(unittest.TestCase): - def test_every_esp_target_copies_exact_reviewed_four_file_inventory(self): + def test_every_esp_target_copies_exact_reviewed_five_file_inventory(self): fixture = PreparePythonFixture() try: expected = fixture.expected_bytes(ESP_PYBLE_INVENTORY) @@ -227,13 +229,13 @@ def test_every_esp_target_copies_exact_reviewed_four_file_inventory(self): self.assertEqual( fixture.materialized_bytes(fixture.board_pyble(target)), expected, - "ESP preparation must materialize only the four files " + "ESP preparation must materialize only the five files " "selected by the reviewed target manifest", ) finally: fixture.cleanup() - def test_rp2_copies_exact_reviewed_literal_eleven_file_inventory(self): + def test_rp2_copies_exact_reviewed_literal_twelve_file_inventory(self): fixture = PreparePythonFixture() try: completed = fixture.prepare("rpi-pico2-w") @@ -242,7 +244,7 @@ def test_rp2_copies_exact_reviewed_literal_eleven_file_inventory(self): fixture.materialized_bytes(fixture.board_pyble("rpi-pico2-w")), fixture.expected_bytes(RP2_PYBLE_INVENTORY), "RP2 preparation must materialize exactly the manifest's " - "literal eleven-file frozen package", + "literal twelve-file frozen package", ) finally: fixture.cleanup() diff --git a/tests/firmware_tests/host/test_pyble_agent.py b/tests/firmware_tests/host/test_pyble_agent.py index 7c64ed4e..70dd7387 100644 --- a/tests/firmware_tests/host/test_pyble_agent.py +++ b/tests/firmware_tests/host/test_pyble_agent.py @@ -13,9 +13,9 @@ # jailed <dest>.pbltmp persists), §6 (STOP -> RSP{OK}, active STOP's response # precedes interrupt delivery, idle STOP emits no RUN_STATE); # ports/rpi-pico2-w.md P1 (has_identify=0 -> IDENTIFY EUNSUPPORTED), P2 -# (transfers during an active RUN -> EBUSY; *_BEGIN validation/reservation is -# answered inline at dispatch, only streaming/window pumping is supervisor -# work), P4 (SOFT_REBOOT: RSP{OK} first, then the injected reset callable from +# (transfers during an active RUN -> EBUSY; PUT validation and VFS work are +# drained from a bounded supervisor mailbox, never the BTstack callback), P4 +# (SOFT_REBOOT: RSP{OK} first, then the injected reset callable from # the supervisor after a bounded TX-flush delay). # # Env-reality: the BLE link is the FakeLink below, which mimics ONLY the frozen @@ -84,6 +84,11 @@ NO_RSP_OPCODES = {0x16, 0x31} # CMD-only: the handler suppresses the RSP (§4) UNIQUE_ID = b"\x12\x34\x56\x78\x9a\xbc\x9f\x3a" # -> device_id "9F3A" +HELLO_PAYLOAD = ( + b"proto_versions=1\n" + b"app_name=PyBLE\n" + b"app_version=0.2.0" +) class FakeLink: @@ -102,6 +107,7 @@ def __init__(self, order=None): self.omit_terminal = False self.session = 1 self.replace_session_before_terminal_retry = False + self.terminated_sessions = [] def on_message(self, cb): self.message_cb = cb @@ -115,6 +121,13 @@ def on_disconnect(self, cb): def session_token(self): return self.session + def terminate_session(self, expected_session=None): + current = self.session_token() + if expected_session is not None and expected_session != current: + return False + self.terminated_sessions.append(current) + return True + def send_message(self, msg, on_published=None, expected_session=None): msg = bytes(msg) frame = pyble_proto.decode(msg) @@ -234,6 +247,21 @@ def new_agent(self, criterion=None, order=None, reset=None, notify=None, if arm_reset is not None: kwargs["arm_reset"] = arm_reset agent = cls(link, self.root, **kwargs) + + # Legacy behavior tests below exercise already-negotiated commands. A + # strict v0.6.1 agent begins UNNEGOTIATED, so establish the real session + # precondition once and discard only this setup traffic. Tests dedicated + # to pre-HELLO/repeat/reset behavior use their own fresh harness. + self.assertIsNotNone(link.connect_cb, "Agent must bind on_connect") + link.connect_cb() + send(self, link, CMD_OPCODES["HELLO"], HELLO_PAYLOAD, id_=255) + hello = rsps(link, opcode=CMD_OPCODES["HELLO"], id_=255) + self.assertEqual(len(hello), 1, "legacy fixture HELLO must receive one RSP") + self.assertEqual(hello[0].payload[0], OK, + "legacy fixture canonical HELLO must negotiate v1") + link.sent[:] = [] + if order is not None: + order[:] = [] return agent, link @@ -270,7 +298,8 @@ def test_every_cmd_opcode_is_registered(self): with self.subTest(opcode=name): agent, link = self.new_agent( "F-25/§4 handler registered: {}".format(name)) - send(self, link, op, id_=7) + payload = HELLO_PAYLOAD if op == CMD_OPCODES["HELLO"] else b"" + send(self, link, op, payload, id_=7) pump(agent, 2, self.now) answers = rsps(link, opcode=op, id_=7) if op == CMD_OPCODES["IDENTIFY"]: @@ -295,6 +324,21 @@ def test_every_cmd_opcode_is_registered(self): "{} answered EUNSUPPORTED — no handler is registered " "for it".format(name)) + def test_set_identify_led_rejects_every_trailing_payload_byte(self): + agent, link = self.new_agent( + "P1 SET_IDENTIFY_LED exact portable payload grammar") + + send(self, link, CMD_OPCODES["SET_IDENTIFY_LED"], b"\x00\x01\xff", + id_=8) + + answers = rsps(link, CMD_OPCODES["SET_IDENTIFY_LED"], 8) + self.assertEqual(len(answers), 1) + self.assertEqual( + answers[0].payload[0], EBADREQ, + "SET_IDENTIFY_LED is exactly empty or two bytes; a valid prefix " + "cannot make trailing bytes disappear", + ) + class EmitTest(AgentTestBase): """emit() is the agent's EVT path: TYPE=EVT, ID=0, valid CRC (§3.1).""" @@ -348,6 +392,10 @@ def test_disconnect_frees_the_slot_but_keeps_pbltmp(self): with open(os.path.join(self.root, "g.txt"), "wb") as fh: fh.write(b"hello world") # 11 bytes, for the later GET agent, link = self.new_agent(crit) + # F-10 intentionally retains the orphan file object for a future PUT + # resume; close that test-owned object after assertions so CPython's + # ResourceWarning does not obscure RED/green protocol results. + self.addCleanup(agent._fs._close_put_file) # Open an upload and land one in-order window chunk. body = b"ABCDEFGH" @@ -375,6 +423,13 @@ def test_disconnect_frees_the_slot_but_keeps_pbltmp(self): "the jailed <dest>.pbltmp MUST survive the disconnect " "(it seeds the §5 resume_offset)") + # A reconnect is a fresh PBLE session and must re-negotiate before the + # single-transfer slot can be observed as free. + link.session += 1 + link.connect_cb() + send(self, link, CMD_OPCODES["HELLO"], HELLO_PAYLOAD, id_=4) + self.assertEqual(rsps(link, CMD_OPCODES["HELLO"], 4)[0].payload[0], OK) + # The single-transfer slot MUST be free again: a GET now succeeds. send(self, link, 0x12, p_get_begin(0, "/g.txt"), id_=5) pump(agent, 2, self.now) @@ -387,6 +442,145 @@ def test_disconnect_frees_the_slot_but_keeps_pbltmp(self): "GET_BEGIN OK carries [total_size:u32] (§5)") +class PutMailboxContextTest(AgentTestBase): + """P2: PUT VFS work runs only when the supervisor drains its mailbox.""" + + def test_put_begin_does_no_vfs_work_or_response_inside_ble_callback(self): + agent, link = self.new_agent("P2 FILE_PUT_BEGIN supervisor mailbox") + calls = [] + + def handle(payload): + calls.append(bytes(payload)) + return bytes((OK,)) + struct.pack("<I", 0) + + agent._fs.handle_put_begin = handle + send(self, link, 0x15, p_put_begin(4, 0, "/mailbox.bin"), id_=170) + + self.assertEqual(calls, [], "BTstack callback must return before VFS work") + self.assertEqual(rsps(link, 0x15, 170), []) + agent.poll() + self.assertEqual(len(calls), 1) + self.assertEqual(rsps(link, 0x15, 170)[0].payload[0], OK) + + def test_full_put_window_is_deferred_and_drained_in_wire_order(self): + agent, link = self.new_agent("P2 bounded FILE_PUT window mailbox") + depth = AGENT.attr( + self, "FS_MAILBOX_DEPTH", "P2 PUT mailbox depth = window + 2") + self.assertEqual(depth, 6) + data = b"ABCD" + send(self, link, 0x15, + p_put_begin(len(data), pyble_proto.crc32(data), "/window.bin"), + id_=171) + agent.poll() + self.assertEqual(rsps(link, 0x15, 171)[0].payload[0], OK) + link.sent[:] = [] + + for offset, value in enumerate(data): + send(self, link, 0x16, + struct.pack("<I", offset) + bytes((value,)), + id_=172 + offset) + self.assertEqual(evts(link, 0x41), [], + "PUT_DATA cannot ACK from synchronous BLE context") + pump(agent, 6, self.now) + self.assertEqual( + [frame.payload for frame in evts(link, 0x41)], + [struct.pack("<I", offset) for offset in (1, 2, 3, 4)], + ) + + send(self, link, 0x17, struct.pack("<I", pyble_proto.crc32(data)), + id_=176) + self.assertEqual(rsps(link, 0x17, 176), []) + agent.poll() + self.assertEqual(rsps(link, 0x17, 176)[0].payload[0], OK) + with open(os.path.join(self.root, "window.bin"), "rb") as stream: + self.assertEqual(stream.read(), data) + + def test_disconnect_cancels_queued_put_before_any_vfs_effect(self): + agent, link = self.new_agent("P2 disconnect cancels queued PUT") + dest = os.path.join(self.root, "cancelled.bin") + send(self, link, 0x15, p_put_begin(1, 0, "/cancelled.bin"), id_=177) + link.disconnect_cb() + agent.poll() + self.assertFalse(os.path.exists(dest)) + self.assertFalse(os.path.exists(dest + ".pbltmp")) + + def test_second_response_overflow_terminates_and_cancels_queued_work(self): + agent, link = self.new_agent( + "P2 response-bearing PUT overflow is never silently dropped") + calls = [] + + def handle(payload): + calls.append(bytes(payload)) + return bytes((OK,)) + struct.pack("<I", 0) + + agent._fs.handle_put_begin = handle + session = link.session_token() + payload = p_put_begin(1, pyble_proto.crc32(b"A"), "/overflow.bin") + + # Six commands fill the bounded worker mailbox. The seventh consumes + # its sole deferred EBUSY response slot; the eighth cannot receive a + # response while the connection remains live and therefore must close + # that exact session before any queued VFS work can execute. + for request_id in range(190, 198): + send(self, link, CMD_OPCODES["FILE_PUT_BEGIN"], payload, + id_=request_id) + + self.assertEqual(link.terminated_sessions, [session]) + pump(agent, 10, self.now) + self.assertEqual(calls, []) + self.assertEqual( + [frame for frame in decoded(link) if 190 <= frame.id < 198], + [], + "a terminal overflow cannot later publish from cancelled work", + ) + + def test_paused_stale_begin_cannot_republish_over_successor_session(self): + agent, link = self.new_agent( + "P2 PUT mailbox exact-session stale commit rejection") + self.addCleanup(agent._fs._close_put_file) + original_open = agent._fs._open + injected = [False] + opened_files = [] + self.addCleanup( + lambda: [stream.close() for stream in opened_files + if not stream.closed]) + + def reconnecting_open(path, mode): + opened = original_open(path, mode) + opened_files.append(opened) + if not injected[0] and path.endswith("predecessor.bin.pbltmp"): + injected[0] = True + link.disconnect_cb() + link.session += 1 + link.connect_cb() + send(self, link, CMD_OPCODES["HELLO"], HELLO_PAYLOAD, id_=181) + send(self, link, CMD_OPCODES["FILE_PUT_BEGIN"], + p_put_begin(1, pyble_proto.crc32(b"B"), + "/successor.bin"), + id_=182) + return opened + + agent._fs._open = reconnecting_open + send(self, link, CMD_OPCODES["FILE_PUT_BEGIN"], + p_put_begin(1, pyble_proto.crc32(b"A"), "/predecessor.bin"), + id_=180) + pump(agent, 3, self.now) + + self.assertTrue(injected[0]) + self.assertEqual( + rsps(link, CMD_OPCODES["FILE_PUT_BEGIN"], 180), [], + "session A cannot publish a response after its disconnect cut", + ) + successor = rsps(link, CMD_OPCODES["FILE_PUT_BEGIN"], 182) + self.assertEqual(len(successor), 1) + self.assertEqual(successor[0].payload[0], OK) + self.assertTrue( + agent._fs._put_dest.endswith("successor.bin"), + "a paused session-A BEGIN must not overwrite session B's active " + "transfer state after disconnect invalidation", + ) + + class TransfersDuringRunEbusyTest(AgentTestBase): """P2: while a RUN is active (reserved), FILE_GET_BEGIN / FILE_PUT_BEGIN are refused EBUSY inline (legal §8; concurrency returns with OI-P5).""" @@ -681,11 +875,11 @@ def fail_dispatch(msg): agent._dispatcher.on_message = fail_dispatch try: with self.assertRaisesRegex(RuntimeError, "dispatch failure"): - send(self, link, 0x01, id_=70) + send(self, link, 0x01, HELLO_PAYLOAD, id_=70) finally: agent._dispatcher.on_message = real_dispatch - send(self, link, 0x01, id_=71) + send(self, link, 0x01, HELLO_PAYLOAD, id_=71) self.assertEqual(notifies, [], "a later HELLO MUST NOT inherit stale interrupt intent") @@ -711,7 +905,7 @@ def fail_stop_rsp(type_, opcode, id_, payload=b""): pyble_proto.encode = real_encode agent._runner._executing = False - send(self, link, 0x01, id_=74) + send(self, link, 0x01, HELLO_PAYLOAD, id_=74) self.assertEqual(notifies, [], "encode failure MUST clear the STOP post-RSP intent") @@ -724,11 +918,11 @@ def test_response_send_exception_clears_stop_intent(self): agent._runner._executing = True real_send = link.send_message - def fail_stop_send(msg): + def fail_stop_send(msg, *args, **kwargs): frame = pyble_proto.decode(msg) if frame.type == RSP and frame.opcode == 0x21 and frame.id == 76: raise RuntimeError("deterministic send failure") - real_send(msg) + return real_send(msg, *args, **kwargs) link.send_message = fail_stop_send try: @@ -738,7 +932,7 @@ def fail_stop_send(msg): link.send_message = real_send agent._runner._executing = False - send(self, link, 0x01, id_=77) + send(self, link, 0x01, HELLO_PAYLOAD, id_=77) self.assertEqual(notifies, [], "send failure MUST clear the STOP post-RSP intent") @@ -784,11 +978,11 @@ def test_reserved_soft_send_failure_leaves_run_and_deadline_unchanged(self): send(self, link, 0x20, bytes((1,)) + b"x = 2", id_=92) real_send = link.send_message - def fail_soft_rsp(msg): + def fail_soft_rsp(msg, *args, **kwargs): frame = pyble_proto.decode(msg) if frame.type == RSP and frame.opcode == 0x22 and frame.id == 93: raise RuntimeError("deterministic SOFT send failure") - real_send(msg) + return real_send(msg, *args, **kwargs) link.send_message = fail_soft_rsp try: @@ -938,6 +1132,26 @@ def test_duplicate_is_ebusy_without_extending_original_deadline(self): self.assertEqual(self.resets, [1], "the original t=250 deadline remains authoritative") + def test_duplicate_after_reconnect_is_ebusy_without_renegotiation(self): + agent, link = self.new_agent( + "F-27/P4 closing survives disconnect and successor connect") + send(self, link, 0x22, id_=160) + self.assertEqual(rsps(link, 0x22, 160)[0].payload[0], OK) + original_deadline = agent._reboot_at + + link.disconnect_cb() + link.session += 1 + link.connect_cb() + send(self, link, 0x22, id_=161) + + self.assertEqual( + rsps(link, 0x22, 161)[0].payload[0], EBUSY, + "the global closing gate precedes the successor session's " + "HELLO-first dispatch gate", + ) + self.assertEqual(agent._reboot_at, original_deadline, + "a successor duplicate cannot move the first deadline") + def test_pending_reboot_rejects_run_and_skips_filesystem_pump(self): agent, link = self.new_agent( "F-27/P4 closing state rejects work") @@ -981,6 +1195,21 @@ def test_closing_gate_covers_every_non_soft_command_and_unknown(self): self.assertEqual(rsps(link, 0x7F, 150)[0].payload[0], EBUSY, "unknown valid commands are gated while closing too") + def test_closing_gate_keeps_zero_id_requests_silent(self): + agent, link = self.new_agent( + "F-27/P4 closing preserves protocol direction/id precedence") + send(self, link, 0x22, id_=157) + before = len(link.sent) + + link.message_cb(pyble_proto.encode(CMD, 0x20, 0, b"\x01x = 1")) + + self.assertEqual( + len(link.sent), before, + "a valid-CRC CMD with ID=0 stays silent while closing", + ) + self.assertIsNone(agent._runner._pending, + "the zero-ID RUN must never reach its handler") + def test_closing_gate_prevents_inline_persistent_and_console_mutation(self): keep = os.path.join(self.root, "keep.txt") with open(keep, "wb") as fh: diff --git a/tests/firmware_tests/host/test_pyble_console.py b/tests/firmware_tests/host/test_pyble_console.py index 1a1fa276..91c83a80 100644 --- a/tests/firmware_tests/host/test_pyble_console.py +++ b/tests/firmware_tests/host/test_pyble_console.py @@ -48,8 +48,12 @@ # # traceback path uses out(STDERR, ...)) # .readinto(buf) -> 1 | None # 1 byte into buf[0], or None if empty; # # armed STOP_CHAR preempts buffered stdin -# .feed_input(b) -> None # CONSOLE_INPUT: bounded ring; on -# # overflow the EXCESS TAIL is dropped +# .begin_input() # RUN admission: clear + admit stdin +# .end_input() # STOP/terminal/reset: clear + reject +# .clear_input() # disconnect: clear, retain admission +# .feed_input(b) -> None # CONSOLE_INPUT: idle-discard; while +# # admitted, bounded ring whose overflow +# # drops the EXCESS TAIL # .inject_stop() -> bool # atomically arm 0x03 + call notify once; # # False rolls the arm back on notify error # .set_stop_retry(retry) # install two-stage scheduled recovery @@ -129,6 +133,15 @@ def drain(testcase, con, limit=1000): testcase.fail("readinto never drained — ring not bounded?") +def begin_input(testcase, con): + """Enter the v0.6.1 per-run stdin boundary with a reasoned RED failure.""" + testcase.assertTrue( + hasattr(con, "begin_input"), + "v0.6.1 run-scoped stdin requires Console.begin_input()", + ) + con.begin_input() + + class ConstantsAndShapeTest(unittest.TestCase): """P8: the frozen tuning constants mirror pble_console.c, and the console is ONE io.IOBase object (the os.dupterm tee).""" @@ -591,6 +604,7 @@ def test_empty_returns_none_not_zero(self): def test_feed_then_read_one_byte_at_a_time(self): con, _, _ = make_console(self, self.CRIT) + begin_input(self, con) self.assertIsNone(con.feed_input(b"ab"), "CONSOLE_INPUT is fire-and-forget: feed_input " "returns None (no RSP frame, §6)") @@ -610,6 +624,7 @@ class RingOverflowTest(unittest.TestCase): def test_overflow_drops_the_new_bytes(self): con, _, _ = make_console(self, self.CRIT) + begin_input(self, con) con.feed_input(b"A" * 256) con.feed_input(b"Z") # full: dropped self.assertEqual(drain(self, con), b"A" * 256, @@ -617,6 +632,7 @@ def test_overflow_drops_the_new_bytes(self): def test_partial_overflow_keeps_the_head_of_the_feed(self): con, _, _ = make_console(self, self.CRIT) + begin_input(self, con) con.feed_input(b"A" * 250) con.feed_input(b"0123456789") # room for 6: keep head, drop tail self.assertEqual(drain(self, con), b"A" * 250 + b"012345", @@ -644,6 +660,7 @@ def test_arms_stop_char_and_notifies_once(self): def test_stop_preempts_buffered_stdin(self): con, _, notifies = make_console(self, self.CRIT) + begin_input(self, con) con.feed_input(b"xy") self.assertTrue(con.inject_stop()) self.assertEqual(drain(self, con), b"\x03xy", @@ -656,6 +673,7 @@ def queue_full(): raise RuntimeError("schedule queue full") con, _, _ = make_console(self, self.CRIT, notify=queue_full) + begin_input(self, con) con.feed_input(b"xy") self.assertFalse( diff --git a/tests/firmware_tests/host/test_pyble_device_config.py b/tests/firmware_tests/host/test_pyble_device_config.py index c1daafe6..712eb70d 100644 --- a/tests/firmware_tests/host/test_pyble_device_config.py +++ b/tests/firmware_tests/host/test_pyble_device_config.py @@ -8,29 +8,23 @@ # identity-engineer -> firmware/pyble/pyble_device_config.py (native twin # pble_device_config.c). # -# ===================================================================== -# DoR STATUS — BLOCKED (do NOT commit [red] before the freeze): -# protocol.md §7 (label/device_id carried in caps) is DRAFT, and the OI-6 -# label max-length freeze [docs] mirror (architect froze PBLE_LABEL_MAX=24 -# in the native contract) has not landed in protocol.md §4/§7 + specs.md -# FR-IDENT-1. Until then this suite asserts ONLY the FROZEN SEMANTICS via the -# scaffold API: the 24-byte bound (architect freeze), advertised-name -# derivation (§2 FROZEN, FR-BLE-5/12), empty-clears, over-length -> ERANGE -# (§8 FROZEN), and never-gates-access (SEC-11/CON-7). It NEVER hardcodes a -# DRAFT SET_LABEL body byte. NVS persist-across-reboot + scan-list visibility -# are HIL-only (see gates/g1_s3_check.sh). -# ===================================================================== +# Historical TDD provenance: the first [red] version deliberately avoided an +# unfrozen SET_LABEL body. Protocol §7 and the OI-6 label mirror are now frozen, +# so this is an active regression suite for the 24-byte bound, advertised-name +# derivation, empty clear, ERANGE, strict UTF-8, and display-only identity. +# Persistence across reboot and scan-list visibility remain HIL-only. # # FROZEN references: protocol.md §2 (advertising: label replaces name, MAC- -# suffix device_id), §4 (0x50 SET_LABEL), §8 (ERANGE 0x09); specs.md FR-BLE-5/12, +# suffix device_id), §4 (0x50 SET_LABEL), §7 (payload), §8 (ERANGE 0x09); specs.md FR-BLE-5/12, # FR-IDENT-1, SEC-10/11, CON-7; architect freeze OI-6 = 24 bytes UTF-8. # # --------------------------------------------------------------------------- # INTERFACE PINNED BY THIS [red] TEST (identity-engineer implements to it): # pyble_device_config.PBLE_LABEL_MAX : int == 24 # UTF-8 encoded bytes # pyble_device_config.label_status(utf8: bytes) -> int -# # PURE bound decision: OK(0x00) if len<=MAX (incl. empty=clear), -# # ERANGE(0x09) if over. The NVS store/clear is layered on top (HIL). +# # PURE validator: length is checked first (ERANGE if over); otherwise +# # malformed UTF-8 or C0/C1/DEL controls -> EBADREQ; empty=clear and a +# # valid control-free label -> OK. Persistence is layered on top. # pyble_device_config.adv_name(device_id: str, label: str) -> str # # SINGLE SOURCE: label if non-empty else "PyBLE-"+device_id # # (pyble_ble.advertised_name delegates here — F-22 refactor). @@ -50,8 +44,8 @@ class LabelBoundTest(unittest.TestCase): - """FR-IDENT-1 / SEC-10 / OI-6: bounded UTF-8 label; over-length -> ERANGE - and NOT stored; the bound is measured in ENCODED BYTES, not codepoints.""" + """FR-IDENT-1 / SEC-10 / OI-6: validated, bounded UTF-8 label; the + bound is measured in ENCODED BYTES, not codepoints.""" def test_frozen_bound_is_24_bytes(self): self.assertEqual(DC.attr(self, "PBLE_LABEL_MAX", "F-22/OI-6 label max = 24 bytes"), diff --git a/tests/firmware_tests/host/test_pyble_device_config_rp2.py b/tests/firmware_tests/host/test_pyble_device_config_rp2.py index cbb630f2..083b55e4 100644 --- a/tests/firmware_tests/host/test_pyble_device_config_rp2.py +++ b/tests/firmware_tests/host/test_pyble_device_config_rp2.py @@ -9,21 +9,21 @@ # pure scaffold; native twins pble_device_config.c + pble_boot.c). # # FROZEN references: -# ports/rpi-pico2-w.md P5 — /pyble_conf.json on the LFS2 vfs: -# {"label": str <=24 UTF-8 bytes, "autorun": 0|1}; survives reset. +# ports/rpi-pico2-w.md P5 — versioned, integrity-checked /pyble_conf.json on +# the LFS2 vfs; it carries label + autorun and survives reset. # ports/rpi-pico2-w.md P1 — auto_run served from the persisted flag; # advertised name label-else-default (FR-BLE-5 twin). -# protocol.md §4 — 0x23 SET_AUTORUN [enable:u8] (0=off default, 1=on, -# persisted); 0x50 SET_LABEL (empty clears; becomes the advertised name +# protocol.md §4 — 0x23 SET_AUTORUN is exactly one `[enable:u8]` byte whose +# value is exactly 0 (off/default) or 1 (on), persisted; 0x50 SET_LABEL +# (empty clears; becomes the advertised name # and DEVICE_INFO.label). §7 — label max 24 UTF-8 bytes else ERANGE; # caps carry `label` + `auto_run`. §8 — status codes. # C reference invariants transliterated: # pble_device_config.c pble_dc_set_label: over-length -> ERANGE, NOT # stored; empty clears to default; on OK (and only on OK) the advertised # name is re-derived and pushed (pble_ble_set_adv_name). -# pble_boot.c pble_boot_set_autorun_cmd: payload shorter than 1 byte -> -# EBADREQ; enable = payload[0] != 0; persisted as a 0/1 flag, default -# off when the key is absent. +# pble_boot.c pble_boot_set_autorun_cmd: payload length other than 1 or a +# value outside {0,1} -> EBADREQ; default off when the key is absent. # # The PURE scaffold surface (PBLE_LABEL_MAX / label_status / adv_name) stays # covered by the existing host/test_pyble_device_config.py — this suite does @@ -52,10 +52,10 @@ # # >24 encoded bytes -> ERANGE, nothing persisted, no adv push. # # OK: persists, updates .label, pushes adv_name(device_id, label). # .set_autorun(enable: int) -> int §8 status -# # persists 0/1 (any nonzero input normalizes to 1). +# # persists exact 0/1; every other value is EBADREQ and unchanged. # .handle_set_label(frame) -> bytes # Dispatcher handler contract: # .handle_set_autorun(frame) -> bytes # payload[0] = §8 status. -# # handle_set_autorun: len(payload) < 1 -> EBADREQ (pble_boot.c twin). +# # handle_set_autorun: len(payload) != 1 or value >1 -> EBADREQ. # --------------------------------------------------------------------------- import json @@ -102,10 +102,14 @@ def read_conf(self): with open(self.conf_path(), "r", encoding="utf-8") as fh: return json.load(fh) + def read_conf_bytes(self): + with open(self.conf_path(), "rb") as fh: + return fh.read() + class ConfFileContractTest(_ConfCase): - """P5: the persisted store is <root>/pyble_conf.json with the frozen - {"label": str, "autorun": 0|1} shape.""" + """P5: the persisted store is <root>/pyble_conf.json and carries the + label and normalized autorun fields within its versioned record.""" def test_conf_name_constant_is_frozen(self): self.assertEqual( @@ -134,8 +138,8 @@ def test_corrupt_conf_falls_back_to_defaults(self): self.assertEqual(cfg.label, "") self.assertEqual(cfg.auto_run, 0) - def test_persisted_json_shape_is_frozen(self): - cfg = self.make("P5 on-disk shape {label, autorun}") + def test_persisted_record_carries_label_and_autorun(self): + cfg = self.make("P5 on-disk record carries label + autorun") self.assertEqual(cfg.set_label(b"Bench 2"), OK) self.assertEqual(cfg.set_autorun(1), OK) data = self.read_conf() @@ -226,7 +230,7 @@ def test_no_seam_is_tolerated(self): class SetAutorunTest(_ConfCase): """F-25/P5 + §4 0x23: single flag, default OFF, persists; the C twin's - payload semantics (pble_boot.c) carried over exactly.""" + exact payload semantics (pble_boot.c) carried over.""" def test_autorun_defaults_off(self): cfg = self.make("§4 SET_AUTORUN default is OFF (0)") @@ -246,14 +250,14 @@ def test_disable_persists_back_to_zero(self): self.assertEqual(cfg.auto_run, 0) self.assertEqual(self.make("P5 autorun=0 survives reset").auto_run, 0) - def test_nonzero_enable_normalizes_to_one(self): - # pble_boot.c twin: enable = payload[0] != 0; the STORED value is the - # frozen P5 0|1 domain, never the raw byte. - cfg = self.make("P5 nonzero enable normalizes to 1 in the store") - self.assertEqual(cfg.set_autorun(5), OK) + def test_out_of_domain_enable_is_ebadreq_and_unchanged(self): + cfg = self.make("v0.6.1 SET_AUTORUN accepts exactly 0 or 1") + self.assertEqual(cfg.set_autorun(1), OK) + before = self.read_conf_bytes() + self.assertEqual(cfg.set_autorun(5), EBADREQ) self.assertEqual(cfg.auto_run, 1) - self.assertEqual(self.read_conf().get("autorun"), 1, - 'the persisted "autorun" domain is 0|1 (P5), not raw bytes') + self.assertEqual(self.read_conf_bytes(), before, + "an invalid autorun value must not rewrite persistence") def test_autorun_and_label_do_not_clobber_each_other(self): cfg = self.make("P5 one conf file, two keys, no clobber") @@ -293,14 +297,42 @@ def test_set_autorun_handler_enable_u8(self): self.assertEqual(bytes(rsp)[0], OK) self.assertEqual(cfg.auto_run, 1) + def test_set_autorun_handler_accepts_exact_zero(self): + cfg = self.make("§4 0x23 handler exact zero disables") + self.assertEqual(cfg.set_autorun(1), OK) + rsp = cfg.handle_set_autorun(cmd_frame(OP_SET_AUTORUN, b"\x00")) + self.assertEqual(bytes(rsp), bytes((OK,))) + self.assertEqual(cfg.auto_run, 0) + def test_set_autorun_handler_empty_payload_ebadreq(self): - # pble_boot.c: "§4 SET_AUTORUN payload = [enable:u8]; anything shorter - # is malformed." + # Exactly one enable byte is required. cfg = self.make("§4 0x23 empty payload -> [EBADREQ], flag untouched") rsp = cfg.handle_set_autorun(cmd_frame(OP_SET_AUTORUN, b"")) self.assertEqual(bytes(rsp)[0], EBADREQ) self.assertEqual(cfg.auto_run, 0, "a malformed CMD MUST NOT flip the flag") + def test_set_autorun_handler_rejects_trailing_bytes(self): + cfg = self.make("§4 0x23 payload length must be exactly one") + self.assertEqual(cfg.set_autorun(1), OK) + before = self.read_conf_bytes() + rsp = cfg.handle_set_autorun( + cmd_frame(OP_SET_AUTORUN, b"\x00\x01")) + self.assertEqual(bytes(rsp), bytes((EBADREQ,))) + self.assertEqual(cfg.auto_run, 1) + self.assertEqual(self.read_conf_bytes(), before) + + def test_set_autorun_handler_rejects_values_outside_boolean_domain(self): + cfg = self.make("§4 0x23 enable value must be exactly 0 or 1") + self.assertEqual(cfg.set_autorun(1), OK) + before = self.read_conf_bytes() + for value in (2, 0xFF): + with self.subTest(value=value): + rsp = cfg.handle_set_autorun( + cmd_frame(OP_SET_AUTORUN, bytes((value,)))) + self.assertEqual(bytes(rsp), bytes((EBADREQ,))) + self.assertEqual(cfg.auto_run, 1) + self.assertEqual(self.read_conf_bytes(), before) + class DeviceInfoPlumbingTest(_ConfCase): """P1/§7: DEVICE_INFO (and the caps payload) serves `label` and `auto_run` diff --git a/tests/firmware_tests/host/test_pyble_fs.py b/tests/firmware_tests/host/test_pyble_fs.py index 015bae03..495688d0 100644 --- a/tests/firmware_tests/host/test_pyble_fs.py +++ b/tests/firmware_tests/host/test_pyble_fs.py @@ -749,6 +749,50 @@ def test_mpy_pyc_artifacts_rejected(self): # Resume on reconnect (F-10) — temp kept, prefix re-CRC'd, watermark re-seeded # ============================================================================ class PutResumeTest(FsTestBase): + def test_disconnect_defers_file_close_out_of_synchronous_ble_context(self): + data = b"D" * 64 + opened = [] + + class TrackedFile: + def __init__(self, wrapped): + self.wrapped = wrapped + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + return self.wrapped.close() + + def __getattr__(self, name): + return getattr(self.wrapped, name) + + def open_fn(path, mode): + wrapped = open(path, mode) + if path.endswith(".pbltmp") and ("w" in mode or "a" in mode): + tracked = TrackedFile(wrapped) + opened.append(tracked) + return tracked + return wrapped + + s, _rec = self.svc( + "F-10 portable disconnect is RAM-only in BLE callback context", + open_fn=open_fn, + ) + self.begin_put(s, b"/deferred.bin", data) + s.handle_put_data(put_data_pl(0, data)) + self.assertEqual(len(opened), 1) + + s.on_disconnect() + self.assertEqual( + opened[0].close_calls, 0, + "the synchronous BLE disconnect callback must not enter VFS close", + ) + + rsp = self.begin_put(s, b"/deferred.bin", data) + self.assertEqual(opened[0].close_calls, 1, + "the next worker-context PUT_BEGIN owns deferred close") + self.assertEqual(rsp[0], OK) + self.assertEqual(u32(rsp, 1), len(data)) + def test_disconnect_keeps_temp_and_resets_ram_state(self): data = b"R" * 300 s, _rec = self.svc("F-25/F-10 disconnect keeps .pbltmp, resets RAM state") diff --git a/tests/firmware_tests/host/test_pyble_fs_v061.py b/tests/firmware_tests/host/test_pyble_fs_v061.py new file mode 100644 index 00000000..91728dcf --- /dev/null +++ b/tests/firmware_tests/host/test_pyble_fs_v061.py @@ -0,0 +1,1113 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# [red] v0.6.1 filesystem hardening tests. These tests intentionally pin the +# additive, wire-compatible behaviour before the portable and native agents +# implement it: +# +# * transfer scratch is never visible through FILE_LIST; +# * namespace mutations are rejected while a PUT or GET owns the transaction; +# * one DATA chunk may never cross the total declared by PUT_BEGIN; +# * PUT admission keeps a fixed 64 KiB filesystem safety reserve; and +# * malformed scratch is either normalised safely or rejected without touching +# the previous destination. +# +# The existing F-25 suite remains the baseline. This file imports its wire +# builders and tmpdir fixture so the RED increment is isolated from that large +# test file and can be reverted or reviewed independently. + +import errno +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from test_pyble_fs import ( # noqa: E402 + EACCES, + EBADREQ, + EBUSY, + ECRC, + EIO, + ENOSPC, + ERANGE, + FS, + OK, + OP_GET_DATA, + OP_GET_END, + OP_PUT_ACK, + FsTestBase, + crc32, + get_begin_pl, + path_pl, + p32, + put_begin_pl, + put_data_pl, + rename_pl, + u32, +) + + +SAFETY_RESERVE = 64 * 1024 +UINT64_MAX = (1 << 64) - 1 +_DEFAULT_STATVFS = object() + + +class V061FsTestBase(FsTestBase): + """Baseline fixture plus best-effort cleanup for intentionally RED PUTs.""" + + def svc(self, criterion, **kwargs): + service, events = super().svc(criterion, **kwargs) + + def close_open_test_file(): + # Several RED assertions intentionally stop before PUT_END. Avoid + # leaking a CPython buffered writer while preserving the production + # disconnect semantics tested by the baseline suite. + open_file = getattr(service, "_put_file", None) + if open_file is not None: + try: + open_file.close() + except Exception: + pass + + self.addCleanup(close_open_test_file) + return service, events + + +class ScratchVisibilityTest(V061FsTestBase): + """Reserved transport state is not part of the user's workspace view.""" + + def test_root_and_nested_lists_omit_scratch_files_and_directories(self): + self.put_file("/visible.py", b"print('visible')\n") + self.put_file("/visible.py.pbltmp", b"partial") + os.mkdir(self.hpath("/hidden-dir.pbltmp")) + os.mkdir(self.hpath("/lib")) + self.put_file("/lib/module.py", b"VALUE = 1\n") + self.put_file("/lib/module.py.pbltmp", b"partial") + + service, _events = self.svc( + "v0.6.1/FR-FS scratch entries are absent from every FILE_LIST") + + root_more, _root_count, root_entries = self.parse_list( + service.handle_list(path_pl(b"/"))) + nested_more, _nested_count, nested_entries = self.parse_list( + service.handle_list(path_pl(b"/lib"))) + + self.assertEqual(root_more, 0) + self.assertEqual( + set(name for _kind, _size, name in root_entries), + {"visible.py", "lib"}, + "a case-sensitive '.pbltmp' basename is internal transport state, " + "whether it names a file or a directory") + self.assertEqual(nested_more, 0) + self.assertEqual( + [name for _kind, _size, name in nested_entries], + ["module.py"], + "nested scratch must be hidden by the same listing rule") + + def test_hidden_entries_consume_no_budget_count_or_more_flag(self): + # Force a deterministic directory order. On the current implementation + # these phantom scratch names fill the 480-byte response before the one + # real visible file is reached. A correct implementation skips them + # before stat, response-budget, count, and `more` processing. + visible = "answer.py" + self.put_file("/" + visible, b"42\n") + hidden = [ + "scratch_%02d_%s.pbltmp" % (index, "x" * 26) + for index in range(16) + ] + module = FS.obj( + self, "v0.6.1 hidden scratch does not truncate visible listings") + service, _events = self.svc( + "v0.6.1 hidden scratch does not consume LIST response budget") + + with mock.patch.object(module.os, "listdir", + return_value=hidden + [visible]): + more, count, entries = self.parse_list( + service.handle_list(path_pl(b"/"))) + + self.assertEqual(more, 0, + "hidden-only overflow must not advertise truncation") + self.assertEqual(count, 1, + "hidden scratch must not increment FILE_LIST count") + self.assertEqual(entries, [(0, 3, visible)]) + + +class ActivePutMutationTest(V061FsTestBase): + """A PUT transaction owns namespace mutation until it resolves.""" + + def _begin_over_existing(self, dest="/target.py"): + self.put_file(dest, b"OLD DESTINATION") + service, events = self.svc( + "v0.6.1 active PUT serialises namespace mutations") + rsp = self.begin_put(service, dest.encode("utf-8"), b"NEW CONTENT") + self.assertEqual(rsp[0], OK) + return service, events + + def test_delete_active_destination_is_ebusy_and_has_no_effect(self): + service, _events = self._begin_over_existing() + rsp = service.handle_delete(path_pl(b"/target.py")) + self.assertEqual(rsp[0], EBUSY) + self.assertEqual(self.read_file("/target.py"), b"OLD DESTINATION") + + def test_mkdir_during_put_is_ebusy_even_when_path_is_missing(self): + service, _events = self._begin_over_existing() + rsp = service.handle_mkdir(path_pl(b"/unrelated")) + self.assertEqual( + rsp[0], EBUSY, + "busy arbitration must happen before probing a valid namespace path") + self.assertFalse(os.path.exists(self.hpath("/unrelated"))) + + def test_rename_active_parent_is_ebusy_and_has_no_effect(self): + self.put_file("/project/target.py", b"OLD DESTINATION") + service, _events = self.svc( + "v0.6.1 active PUT prevents its parent directory moving") + self.assertEqual( + self.begin_put(service, b"/project/target.py", b"NEW")[0], OK) + + rsp = service.handle_rename(rename_pl(b"/project", b"/moved")) + self.assertEqual(rsp[0], EBUSY) + self.assertTrue(os.path.isdir(self.hpath("/project"))) + self.assertFalse(os.path.exists(self.hpath("/moved"))) + self.assertEqual(self.read_file("/project/target.py"), b"OLD DESTINATION") + + def test_unrelated_rename_is_also_serialised_conservatively(self): + service, _events = self._begin_over_existing() + self.put_file("/other.txt", b"OTHER") + rsp = service.handle_rename(rename_pl(b"/other.txt", b"/renamed.txt")) + self.assertEqual(rsp[0], EBUSY) + self.assertEqual(self.read_file("/other.txt"), b"OTHER") + self.assertFalse(os.path.exists(self.hpath("/renamed.txt"))) + + def test_valid_missing_delete_is_ebusy_not_enoent(self): + service, _events = self._begin_over_existing() + self.assertEqual( + service.handle_delete(path_pl(b"/missing.txt"))[0], EBUSY, + "once a path is syntactically valid and jailed, PUT ownership wins " + "over namespace existence") + + def test_parse_and_jail_precede_active_put_busy_status(self): + service, _events = self._begin_over_existing() + self.put_file("/source.txt", b"source") + + self.assertEqual(service.handle_delete(b"\x01")[0], EBADREQ) + self.assertEqual(service.handle_delete(path_pl(b"../escape"))[0], EACCES) + self.assertEqual(service.handle_mkdir(path_pl(b"bad.pbltmp"))[0], EACCES) + self.assertEqual( + service.handle_rename( + rename_pl(b"/source.txt", b"../escape"))[0], + EACCES, + "FILE_RENAME must resolve both paths before applying EBUSY") + self.assertEqual(self.read_file("/source.txt"), b"source") + + +class ActiveGetMutationTest(V061FsTestBase): + """An admitted download conservatively owns every namespace mutation.""" + + def _begin_get(self): + self.put_file("/download.bin", b"download payload") + service, events = self.svc( + "v0.6.1 active GET serialises namespace mutations") + self.assertEqual( + service.handle_get_begin( + get_begin_pl(0, b"/download.bin"))[0], + OK, + ) + return service, events + + def test_all_valid_mutations_admitted_during_get_are_ebusy(self): + self.put_file("/source.txt", b"source") + service, _events = self._begin_get() + + self.assertEqual( + service.handle_delete(path_pl(b"/missing.txt"))[0], EBUSY, + "active GET ownership must precede a valid path's existence probe", + ) + self.assertEqual( + service.handle_mkdir(path_pl(b"/unrelated"))[0], EBUSY, + "even an unrelated MKDIR is conservatively serialized", + ) + self.assertEqual( + service.handle_rename( + rename_pl(b"/source.txt", b"/renamed.txt"))[0], + EBUSY, + "even an unrelated RENAME is conservatively serialized", + ) + self.assertFalse(os.path.exists(self.hpath("/unrelated"))) + self.assertEqual(self.read_file("/source.txt"), b"source") + self.assertFalse(os.path.exists(self.hpath("/renamed.txt"))) + + def test_parse_and_jail_still_precede_active_get_busy_status(self): + self.put_file("/source.txt", b"source") + service, _events = self._begin_get() + + self.assertEqual(service.handle_delete(b"\x01")[0], EBADREQ) + self.assertEqual( + service.handle_delete(path_pl(b"../escape"))[0], EACCES) + self.assertEqual( + service.handle_mkdir(path_pl(b"bad.pbltmp"))[0], EACCES) + self.assertEqual( + service.handle_rename( + rename_pl(b"/source.txt", b"../escape"))[0], + EACCES, + ) + + def test_read_only_list_and_stat_remain_available_during_get(self): + service, _events = self._begin_get() + + stat = service.handle_stat(path_pl(b"/download.bin")) + self.assertEqual(stat[0], OK) + self.assertEqual(u32(stat, 1), len(b"download payload")) + listing = service.handle_list(path_pl(b"/")) + self.assertEqual(listing[0], OK) + _more, _count, entries = self.parse_list(listing) + self.assertIn("download.bin", [name for _kind, _size, name in entries]) + + +class RenameMailboxBoundaryTest(V061FsTestBase): + def test_two_exact_128_byte_paths_fit_one_rename_request(self): + source = "/" + "s" * 127 + destination = "/" + "d" * 127 + self.put_file(source, b"payload") + service, _events = self.svc( + "v0.6.1 FILE_RENAME 128/128 path boundary") + + payload = rename_pl(source.encode("utf-8"), destination.encode("utf-8")) + self.assertEqual(len(payload), 260) + self.assertEqual(service.handle_rename(payload)[0], OK) + self.assertEqual(self.read_file(destination), b"payload") + + def test_one_129_byte_path_is_erange(self): + source = b"/" + b"s" * 127 + destination = b"/" + b"d" * 128 + service, _events = self.svc( + "v0.6.1 FILE_RENAME rejects a 129-byte path") + self.put_file(source.decode("utf-8"), b"payload") + + self.assertEqual( + service.handle_rename(rename_pl(source, destination))[0], ERANGE) + + +class DeclaredTotalBoundaryTest(V061FsTestBase): + def test_crossing_chunk_is_not_written_and_latches_erange(self): + self.put_file("/bounded.bin", b"OLD") + service, events = self.svc( + "v0.6.1 PUT_DATA may not cross PUT_BEGIN total_size") + expected = b"ABCD" + self.assertEqual( + service.handle_put_begin( + put_begin_pl(len(expected), crc32(expected), b"/bounded.bin"))[0], + OK) + + service.handle_put_data(put_data_pl(0, b"ABC")) + service.handle_put_data(put_data_pl(3, b"DE")) # crosses total by 1 + # A correct-looking retry must not clear a latched protocol-range fault. + service.handle_put_data(put_data_pl(3, b"D")) + + acknowledgements = [ + u32(payload) for opcode, payload in events if opcode == OP_PUT_ACK + ] + self.assertEqual( + acknowledgements, [3, 3, 3], + "the crossing chunk and every post-fault chunk must leave the " + "durable watermark unchanged") + self.assertEqual(service.handle_put_end(p32(crc32(expected)))[0], ERANGE) + self.assertEqual(self.read_file("/bounded.bin"), b"OLD") + self.assertFalse(os.path.exists(self.hpath("/bounded.bin.pbltmp"))) + + def test_chunk_ending_exactly_at_total_remains_valid(self): + service, events = self.svc( + "v0.6.1 declared-total check accepts the exact boundary") + data = b"ABCD" + self.assertEqual(self.begin_put(service, b"/exact.bin", data)[0], OK) + service.handle_put_data(put_data_pl(0, data)) + self.assertEqual( + [u32(payload) for opcode, payload in events if opcode == OP_PUT_ACK], + [4]) + self.assertEqual(service.handle_put_end(p32(crc32(data)))[0], OK) + self.assertEqual(self.read_file("/exact.bin"), data) + + +class _FailingCloseWriter: + """Write normally, then surface a deterministic durability-cut failure.""" + + def __init__(self, wrapped): + self._wrapped = wrapped + + def write(self, data): + return self._wrapped.write(data) + + def close(self): + self._wrapped.flush() + self._wrapped.close() + raise OSError(errno.EIO, "injected close failure") + + +class _NoneProgressWriter: + """Legal stream shape that reports no write progress as ``None``.""" + + def __init__(self, wrapped): + self._wrapped = wrapped + + def write(self, _data): + return None + + def close(self): + return self._wrapped.close() + + +class PutDurabilityCutTest(V061FsTestBase): + def test_none_write_progress_latches_eio_without_advancing_crc_or_ack(self): + old = b"OLD DESTINATION" + new = b"NEW CONTENT" + self.put_file("/stalled.bin", old) + scratch = self.hpath("/stalled.bin.pbltmp") + + def open_fn(path, mode): + wrapped = open(path, mode) + if path == scratch and mode == "wb": + return _NoneProgressWriter(wrapped) + return wrapped + + service, events = self.svc( + "v0.6.1 a stalled portable write cannot commit phantom bytes", + open_fn=open_fn) + self.assertEqual(self.begin_put(service, b"/stalled.bin", new)[0], OK) + service.handle_put_data(put_data_pl(0, new)) + + self.assertEqual( + [u32(payload) for opcode, payload in events + if opcode == OP_PUT_ACK], + [0], + "None progress must leave the durable watermark at zero", + ) + self.assertEqual(service.handle_put_end(p32(crc32(new)))[0], EIO) + self.assertEqual(self.read_file("/stalled.bin"), old) + self.assertFalse(os.path.exists(scratch)) + + def test_close_failure_never_replaces_the_old_destination(self): + old = b"OLD DESTINATION" + new = b"NEW CONTENT" + self.put_file("/durable.bin", old) + scratch = self.hpath("/durable.bin.pbltmp") + + def open_fn(path, mode): + wrapped = open(path, mode) + if path == scratch and mode == "wb": + return _FailingCloseWriter(wrapped) + return wrapped + + service, _events = self.svc( + "v0.6.1 failed PUT close cannot cross the atomic commit cut", + open_fn=open_fn) + self.assertEqual( + service.handle_put_begin( + put_begin_pl(len(new), crc32(new), b"/durable.bin"))[0], + OK, + ) + service.handle_put_data(put_data_pl(0, new)) + + self.assertEqual(service.handle_put_end(p32(crc32(new)))[0], EIO) + self.assertEqual(self.read_file("/durable.bin"), old) + self.assertFalse(os.path.exists(scratch)) + + def test_throwing_abort_remove_overrides_originating_status_with_eio(self): + old = b"OLD DESTINATION" + data = b"AB" + self.put_file("/throwing-abort.bin", old) + scratch = self.hpath("/throwing-abort.bin.pbltmp") + module = FS.obj(self, "v0.6.1 PUT abort cleanup status is authoritative") + service, _events = self.svc( + "v0.6.1 throwing PUT-abort removal is EIO") + self.assertEqual( + self.begin_put(service, b"/throwing-abort.bin", data)[0], OK) + service.handle_put_data(put_data_pl(0, data[:1])) + real_remove = module.os.remove + + def remove_fn(path): + if path == scratch: + raise OSError(errno.EIO, "injected abort removal failure") + return real_remove(path) + + with mock.patch.object(module.os, "remove", side_effect=remove_fn): + status = service.handle_put_end(p32(crc32(data)))[0] + + self.assertEqual(status, EIO, "cleanup failure overrides ERANGE") + self.assertEqual(self.read_file("/throwing-abort.bin"), old) + self.assertTrue(os.path.exists(scratch)) + + def test_noop_abort_remove_is_detected_by_absence_verification(self): + old = b"OLD DESTINATION" + data = b"NEW CONTENT" + self.put_file("/noop-abort.bin", old) + scratch = self.hpath("/noop-abort.bin.pbltmp") + module = FS.obj(self, "v0.6.1 PUT abort verifies scratch absence") + service, _events = self.svc( + "v0.6.1 lying PUT-abort removal is EIO") + self.assertEqual(self.begin_put(service, b"/noop-abort.bin", data)[0], OK) + service.handle_put_data(put_data_pl(0, data)) + real_remove = module.os.remove + + def remove_fn(path): + if path == scratch: + return None + return real_remove(path) + + with mock.patch.object(module.os, "remove", side_effect=remove_fn): + status = service.handle_put_end(p32(crc32(data) ^ 1))[0] + + self.assertEqual(status, EIO, "cleanup failure overrides ECRC") + self.assertNotEqual(status, ECRC) + self.assertEqual(self.read_file("/noop-abort.bin"), old) + self.assertTrue(os.path.exists(scratch)) + + +class StatvfsAdmissionTest(V061FsTestBase): + """PUT_BEGIN admission uses f_frsize/f_bavail and a 64 KiB reserve.""" + + @staticmethod + def _statvfs_tuple(unit, available, free=None): + # POSIX/MicroPython order: bsize, frsize, blocks, bfree, bavail, + # files, ffree, favail, flags, namemax. + if free is None: + free = available + return (unit, unit, 1_000_000, free, available, + 1_000_000, 1_000_000, 1_000_000, 0, 255) + + def _service_with_space(self, unit, available, free=None, effect=None, + stat_result=_DEFAULT_STATVFS): + calls = [] + + def statvfs_fn(path): + calls.append(path) + if effect is not None: + raise effect + if stat_result is not _DEFAULT_STATVFS: + return stat_result + return self._statvfs_tuple(unit, available, free=free) + + cls = FS.attr( + self, "FsService", + "v0.6.1 FsService statvfs admission seam") + events = [] + try: + service = cls( + self.root, + lambda opcode, payload: events.append((opcode, bytes(payload))), + statvfs_fn=statvfs_fn) + except TypeError as exc: + self.fail( + "[v0.6.1 filesystem admission] FsService must accept the " + "deterministic `statvfs_fn(path)` seam; current constructor " + "rejected it: {}".format(exc)) + open_file = getattr(service, "_put_file", None) + if open_file is not None: + self.addCleanup(open_file.close) + else: + def close_future_file(): + candidate = getattr(service, "_put_file", None) + if candidate is not None: + try: + candidate.close() + except Exception: + pass + + self.addCleanup(close_future_file) + return service, events, calls + + def test_exact_reserve_plus_rounded_payload_is_admitted(self): + unit = 4096 + total = unit + 1 # rounds to two allocation units + available = (SAFETY_RESERVE + 2 * unit) // unit + service, _events, calls = self._service_with_space(unit, available) + self.assertEqual(calls, [], + "free space is dynamic and must not be cached at construction") + + rsp = service.handle_put_begin( + put_begin_pl(total, crc32(b"x" * total), b"/exact-space.bin")) + self.assertEqual(rsp[0], OK) + self.assertEqual(u32(rsp, 1), 0) + self.assertEqual(calls, [self.root], + "admission must query the injected workspace root once") + # Abort the accepted empty transfer and remove its scratch. + self.assertEqual(service.handle_put_end(p32(0))[0], ERANGE) + + def test_one_allocation_unit_below_boundary_is_enospc_before_open(self): + unit = 4096 + total = unit + 1 + available = (SAFETY_RESERVE + unit) // unit + self.put_file("/keep.bin", b"OLD DESTINATION") + service, _events, _calls = self._service_with_space(unit, available) + + rsp = service.handle_put_begin( + put_begin_pl(total, crc32(b"x" * total), b"/keep.bin")) + self.assertEqual(rsp[0], ENOSPC) + self.assertEqual(self.read_file("/keep.bin"), b"OLD DESTINATION") + self.assertFalse( + os.path.exists(self.hpath("/keep.bin.pbltmp")), + "ENOSPC admission must happen before scratch creation/growth") + + def test_exact_64k_reserve_admits_zero_byte_upload(self): + service, _events, _calls = self._service_with_space( + unit=1, available=SAFETY_RESERVE) + rsp = service.handle_put_begin( + put_begin_pl(0, crc32(b""), b"/empty.bin")) + self.assertEqual(rsp[0], OK) + self.assertEqual(service.handle_put_end(p32(crc32(b"")))[0], OK) + self.assertEqual(self.read_file("/empty.bin"), b"") + + def test_one_byte_below_64k_reserve_rejects_even_zero_byte_upload(self): + service, _events, _calls = self._service_with_space( + unit=1, available=SAFETY_RESERVE - 1) + rsp = service.handle_put_begin( + put_begin_pl(0, crc32(b""), b"/empty.bin")) + self.assertEqual(rsp[0], ENOSPC) + self.assertFalse(os.path.exists(self.hpath("/empty.bin.pbltmp"))) + + def test_bavail_not_bfree_is_the_admission_authority(self): + unit = 4096 + # Plenty of privileged free blocks, but only the 16-block reserve is + # available to this filesystem user: a one-byte upload must be refused. + service, _events, _calls = self._service_with_space( + unit=unit, available=16, free=10_000) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/bavail.bin")) + self.assertEqual(rsp[0], ENOSPC) + + def test_resume_charges_only_bytes_after_verified_prefix(self): + unit = 4096 + prefix = b"P" * unit + total = 2 * unit + self.put_file("/resume.bin", b"OLD") + self.put_file("/resume.bin.pbltmp", prefix) + service, _events, _calls = self._service_with_space( + unit=unit, + available=(SAFETY_RESERVE + unit) // unit) + + rsp = service.handle_put_begin( + put_begin_pl(total, crc32(prefix + b"Q" * unit), b"/resume.bin")) + self.assertEqual(rsp[0], OK) + self.assertEqual(u32(rsp, 1), unit) + self.assertEqual(service.handle_put_end(p32(0))[0], ERANGE) + self.assertEqual(self.read_file("/resume.bin"), b"OLD") + + def test_statvfs_failure_is_eio_and_never_opens_scratch(self): + self.put_file("/keep.bin", b"OLD") + service, _events, _calls = self._service_with_space( + unit=4096, available=1000, + effect=OSError(errno.EIO, "statvfs fault")) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/keep.bin")) + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file("/keep.bin"), b"OLD") + self.assertFalse(os.path.exists(self.hpath("/keep.bin.pbltmp"))) + + def test_zero_fragment_size_is_invalid_geometry_but_zero_bavail_is_enospc(self): + zero_unit, _events, _calls = self._service_with_space( + unit=0, available=1000) + self.assertEqual( + zero_unit.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/zero-unit.bin"))[0], + EIO, + "f_frsize=0 cannot define an allocation or rounding unit") + self.assertFalse(os.path.exists(self.hpath("/zero-unit.bin.pbltmp"))) + + zero_available, _events, _calls = self._service_with_space( + unit=4096, available=0) + self.assertEqual( + zero_available.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/zero-free.bin"))[0], + ENOSPC, + "zero valid available blocks is capacity exhaustion, not bad geometry") + self.assertFalse(os.path.exists(self.hpath("/zero-free.bin.pbltmp"))) + + def test_negative_statvfs_fields_are_eio(self): + cases = ((-1, 1000, "negative f_frsize"), + (4096, -1, "negative f_bavail")) + for index, (unit, available, label) in enumerate(cases): + with self.subTest(field=label): + dest = "/negative-%d.bin" % index + service, _events, _calls = self._service_with_space( + unit=unit, available=available) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), dest.encode("utf-8"))) + self.assertEqual(rsp[0], EIO) + self.assertFalse(os.path.exists(self.hpath(dest + ".pbltmp"))) + + def test_non_integer_statvfs_fields_are_eio(self): + cases = (("4096", 1000, "text f_frsize"), + (4096.0, 1000, "float f_frsize"), + (4096, "1000", "text f_bavail"), + (4096, None, "None f_bavail")) + for index, (unit, available, label) in enumerate(cases): + with self.subTest(field=label): + dest = "/noninteger-%d.bin" % index + service, _events, _calls = self._service_with_space( + unit=unit, available=available) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), dest.encode("utf-8"))) + self.assertEqual(rsp[0], EIO) + self.assertFalse(os.path.exists(self.hpath(dest + ".pbltmp"))) + + def test_short_statvfs_result_is_eio(self): + service, _events, _calls = self._service_with_space( + unit=4096, available=1000, + stat_result=(4096, 4096, 1000, 1000)) # no f_bavail field 4 + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/short-statvfs.bin")) + self.assertEqual(rsp[0], EIO) + self.assertFalse(os.path.exists(self.hpath("/short-statvfs.bin.pbltmp"))) + + def test_available_byte_multiplication_overflow_is_eio(self): + service, _events, _calls = self._service_with_space( + unit=1 << 63, available=2) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/multiply-overflow.bin")) + self.assertEqual( + rsp[0], EIO, + "f_bavail * f_frsize beyond UINT64_MAX is invalid geometry, " + "not wrapped free space") + self.assertFalse( + os.path.exists(self.hpath("/multiply-overflow.bin.pbltmp"))) + + def test_rounding_intermediate_overflow_is_eio(self): + # Mathematically ceil(2 / UINT64_MAX) * UINT64_MAX is representable, + # but the common `(remaining + unit - 1)` expression overflows. The + # admission code must detect that checked-u64 intermediate rather than + # wrap it into a tiny requirement and accept the upload. + service, _events, _calls = self._service_with_space( + unit=UINT64_MAX, available=1) + rsp = service.handle_put_begin( + put_begin_pl(2, crc32(b"xx"), b"/round-overflow.bin")) + self.assertEqual(rsp[0], EIO) + self.assertFalse(os.path.exists(self.hpath("/round-overflow.bin.pbltmp"))) + + def test_reserve_addition_overflow_is_eio(self): + # `needed` itself is representable and free == needed. Adding the + # exact 64 KiB reserve is the checked operation that exceeds u64. + unit = UINT64_MAX - SAFETY_RESERVE + 1 + service, _events, _calls = self._service_with_space( + unit=unit, available=1) + rsp = service.handle_put_begin( + put_begin_pl(1, crc32(b"x"), b"/reserve-overflow.bin")) + self.assertEqual(rsp[0], EIO) + self.assertFalse(os.path.exists(self.hpath("/reserve-overflow.bin.pbltmp"))) + + +class _ShortReader: + """Read a strict prefix once, then report premature EOF.""" + + def __init__(self, wrapped, first_length): + self._wrapped = wrapped + self._first_length = first_length + self._read_once = False + + def read(self, _length=-1): + if self._read_once: + return b"" + self._read_once = True + return self._wrapped.read(self._first_length) + + def close(self): + return self._wrapped.close() + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + +class _ScriptedReader: + """Return adversarial binary-read results, then a stable EOF.""" + + def __init__(self, wrapped, results): + self._wrapped = wrapped + self._results = list(results) + self.read_calls = 0 + + def read(self, _length=-1): + self.read_calls += 1 + if self._results: + return self._results.pop(0) + return b"" + + def close(self): + return self._wrapped.close() + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + +class HostileReadBoundaryTest(V061FsTestBase): + def _scripted_service(self, path, results, *, chunk_size=4): + readers = [] + + def open_fn(candidate, mode): + wrapped = open(candidate, mode) + if candidate == path and mode == "rb": + reader = _ScriptedReader(wrapped, results) + readers.append(reader) + return reader + return wrapped + + service, events = self.svc( + "v0.6.1 every portable VFS read is validated before use", + open_fn=open_fn, + chunk_size=chunk_size, + ) + return service, events, readers + + def test_file_stat_rejects_non_bytes_and_overreported_chunks_as_eio(self): + path = self.put_file("/hostile-stat.bin", b"ABCDEFGH") + cases = ( + (None, "None"), + (bytearray(b"A"), "bytearray"), + (memoryview(b"A"), "memoryview"), + ("A", "str"), + (object(), "object"), + (b"X" * 257, "overreported bytes"), + ) + for result, label in cases: + with self.subTest(result=label): + service, _events, _readers = self._scripted_service( + path, (result, b"")) + try: + response = service.handle_stat(path_pl(b"/hostile-stat.bin")) + except Exception as exc: # pragma: no cover - RED diagnostic + self.fail("hostile stat read escaped as {!r}".format(exc)) + self.assertEqual( + response[0], + EIO, + "an invalid binary-read result must not be CRC'd or accepted", + ) + + def test_file_stat_rejects_eof_before_its_stat_extent(self): + path = self.put_file("/short-stat.bin", b"ABCDEFGH") + service, _events, _readers = self._scripted_service( + path, (b"ABC", b"")) + + response = service.handle_stat(path_pl(b"/short-stat.bin")) + + self.assertEqual( + response[0], EIO, + "FILE_STAT cannot pair an eight-byte size with a three-byte CRC", + ) + + def test_file_stat_crc_is_bounded_to_the_promised_extent(self): + path = self.put_file("/growing-stat.bin", b"ABCDEFGH") + service, _events, readers = self._scripted_service( + path, (b"ABCDEFGH", b"TAIL", b"")) + + response = service.handle_stat(path_pl(b"/growing-stat.bin")) + + self.assertEqual(response[0], OK) + self.assertEqual(u32(response, 1), 8) + self.assertEqual(u32(response, 5), crc32(b"ABCDEFGH")) + self.assertEqual( + readers[0].read_calls, 1, + "CRC scanning must stop exactly at the stat-advertised extent", + ) + + def test_get_rejects_invalid_or_overreported_chunk_without_exposure(self): + path = self.put_file("/hostile-get.bin", b"ABCDEFGH") + for result, label in ((object(), "object"), (b"ABCDE", "overreport")): + with self.subTest(result=label): + service, events, _readers = self._scripted_service( + path, (result, b""), chunk_size=4) + response = service.handle_get_begin( + get_begin_pl(0, b"/hostile-get.bin")) + self.assertEqual(response[0], OK) + try: + service.pump() + except Exception as exc: # pragma: no cover - RED diagnostic + self.fail("hostile GET read escaped as {!r}".format(exc)) + self.assertEqual( + self.evts(events, OP_GET_DATA), [], + "an invalid chunk must be rejected before bytes are exposed", + ) + self.assertEqual( + self.evts(events, OP_GET_END), [], + "a failed stream must never emit the completion marker", + ) + + def test_get_short_read_keeps_prior_data_but_omits_completion(self): + path = self.put_file("/short-get.bin", b"ABCDEFGH") + service, events, _readers = self._scripted_service( + path, (b"ABC", b""), chunk_size=4) + self.assertEqual( + service.handle_get_begin(get_begin_pl(0, b"/short-get.bin"))[0], + OK, + ) + + service.pump() + + data = self.evts(events, OP_GET_DATA) + self.assertEqual([payload[4:] for payload in data], [b"ABC"]) + self.assertEqual( + self.evts(events, OP_GET_END), [], + "premature EOF leaves an incomplete transfer for the existing " + "client timeout; it cannot bless a partial CRC", + ) + + def test_get_never_reads_or_exposes_growth_beyond_begin_total(self): + path = self.put_file("/growing-get.bin", b"ABCDEFGH") + service, events, readers = self._scripted_service( + path, (b"ABCD", b"EFGH", b"IJKL", b""), chunk_size=4) + response = service.handle_get_begin( + get_begin_pl(0, b"/growing-get.bin")) + self.assertEqual(u32(response, 1), 8) + + service.pump() + + data = self.evts(events, OP_GET_DATA) + self.assertEqual(b"".join(payload[4:] for payload in data), b"ABCDEFGH") + self.assertEqual(u32(self.evts(events, OP_GET_END)[0]), crc32(b"ABCDEFGH")) + self.assertEqual( + readers[0].read_calls, 2, + "GET must stop at its BEGIN-advertised total without probing growth", + ) + + +class _PostScanMutationReader: + """Mutate the scratch namespace after its declared prefix was read.""" + + def __init__(self, wrapped, original_length, mutation): + self._wrapped = wrapped + self._original_length = original_length + self._mutation = mutation + self._mutated = False + + def read(self, length=-1): + data = self._wrapped.read(length) + if (not self._mutated and data + and self._wrapped.tell() >= self._original_length): + self._mutated = True + self._mutation() + return data + + def close(self): + return self._wrapped.close() + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + +class MalformedScratchRecoveryTest(V061FsTestBase): + def test_non_regular_scratch_fails_closed_without_open_or_removal(self): + module = FS.obj(self, "v0.6.1 resume accepts exact regular files only") + for index, mode in enumerate((0xA000, 0x2000, 0x1000, 0x0000)): + with self.subTest(mode=hex(mode)): + path = "/special-{}.bin".format(index) + old = b"OLD DESTINATION" + scratch_bytes = b"X" + self.put_file(path, old) + scratch = self.put_file(path + ".pbltmp", scratch_bytes) + service, _events = self.svc( + "v0.6.1 special scratch type fails closed") + real_stat = module.os.stat + + def stat_fn(candidate): + result = real_stat(candidate) + if candidate != scratch: + return result + values = list(result) + values[0] = mode + return tuple(values) + + with mock.patch.object(module.os, "stat", side_effect=stat_fn): + rsp = self.begin_put( + service, path.encode("utf-8"), b"NEW") + + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file(path), old) + self.assertEqual( + self.read_file(path + ".pbltmp"), scratch_bytes) + + def test_empty_scratch_directory_is_removed_and_upload_restarts(self): + os.mkdir(self.hpath("/fresh.bin.pbltmp")) + service, _events = self.svc( + "v0.6.1 empty scratch directory is safely normalised") + data = b"fresh" + + rsp = self.begin_put(service, b"/fresh.bin", data) + self.assertEqual(rsp[0], OK) + self.assertEqual(u32(rsp, 1), 0) + self.assertTrue(os.path.isfile(self.hpath("/fresh.bin.pbltmp"))) + self.send_put(service, data) + self.assertEqual(service.handle_put_end(p32(crc32(data)))[0], OK) + self.assertEqual(self.read_file("/fresh.bin"), data) + + def test_nonempty_scratch_directory_fails_closed_without_recursion(self): + self.put_file("/keep.bin", b"OLD DESTINATION") + self.put_file("/keep.bin.pbltmp/unknown.txt", b"DO NOT DELETE") + service, _events = self.svc( + "v0.6.1 nonempty scratch directory is an internal EIO") + + rsp = self.begin_put(service, b"/keep.bin", b"NEW") + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file("/keep.bin"), b"OLD DESTINATION") + self.assertEqual( + self.read_file("/keep.bin.pbltmp/unknown.txt"), b"DO NOT DELETE") + self.assertEqual(service.handle_put_end(p32(0))[0], EBADREQ, + "a rejected BEGIN must not leave an active transfer") + + def test_short_read_never_becomes_a_nonzero_resume_offset(self): + scratch = self.put_file("/short.bin.pbltmp", b"ABCDEFGH") + + def open_fn(path, mode): + wrapped = open(path, mode) + if path == scratch and mode == "rb": + return _ShortReader(wrapped, 3) + return wrapped + + service, _events = self.svc( + "v0.6.1 resume offset equals bytes actually CRC-scanned", + open_fn=open_fn) + data = b"0123456789ABCDEF" + + rsp = self.begin_put(service, b"/short.bin", data) + self.assertEqual(rsp[0], OK) + self.assertEqual( + u32(rsp, 1), 0, + "a stat length of eight with only three readable bytes is malformed, " + "not an eight-byte verified prefix") + self.send_put(service, data) + self.assertEqual(service.handle_put_end(p32(crc32(data)))[0], OK) + self.assertEqual(self.read_file("/short.bin"), data) + + def test_failed_oversize_cleanup_is_eio_and_does_not_truncate(self): + old = b"OLD DESTINATION" + stale = b"X" * 64 + self.put_file("/keep.bin", old) + scratch = self.put_file("/keep.bin.pbltmp", stale) + module = FS.obj(self, "v0.6.1 malformed scratch cleanup is fail-closed") + service, _events = self.svc( + "v0.6.1 failed malformed-scratch removal returns EIO") + real_remove = module.os.remove + + def remove_fn(path): + if path == scratch: + raise OSError(errno.EIO, "injected scratch removal failure") + return real_remove(path) + + with mock.patch.object(module.os, "remove", side_effect=remove_fn): + rsp = self.begin_put(service, b"/keep.bin", b"small") + + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file("/keep.bin"), old) + self.assertEqual(self.read_file("/keep.bin.pbltmp"), stale, + "failed cleanup must not fall through to wb truncation") + self.assertEqual(service.handle_put_end(p32(0))[0], EBADREQ) + + def test_post_scan_length_change_is_not_accepted_as_a_resume_prefix(self): + original = b"ABCDEFGH" + scratch = self.put_file("/length-race.bin.pbltmp", original) + self.put_file("/length-race.bin", b"OLD") + + def grow_after_scan(): + with open(scratch, "ab") as destination: + destination.write(b"!") + + def open_fn(path, mode): + wrapped = open(path, mode) + if path == scratch and mode == "rb": + return _PostScanMutationReader( + wrapped, len(original), grow_after_scan) + return wrapped + + service, _events = self.svc( + "v0.6.1 resume re-stats scratch length after CRC scan", + open_fn=open_fn) + data = b"0123456789ABCDEF" + rsp = self.begin_put(service, b"/length-race.bin", data) + + self.assertEqual(rsp[0], OK) + self.assertEqual( + u32(rsp, 1), 0, + "a scratch length changed after scan is malformed and must restart") + self.assertEqual(self.read_file("/length-race.bin"), b"OLD") + self.send_put(service, data) + self.assertEqual(service.handle_put_end(p32(crc32(data)))[0], OK) + self.assertEqual(self.read_file("/length-race.bin"), data) + + def test_post_scan_type_change_is_cleaned_without_touching_destination(self): + original = b"ABCDEFGH" + scratch = self.put_file("/type-race.bin.pbltmp", original) + self.put_file("/type-race.bin", b"OLD") + + def replace_with_empty_directory(): + os.remove(scratch) + os.mkdir(scratch) + + def open_fn(path, mode): + wrapped = open(path, mode) + if path == scratch and mode == "rb": + return _PostScanMutationReader( + wrapped, len(original), replace_with_empty_directory) + return wrapped + + service, _events = self.svc( + "v0.6.1 resume re-stats scratch type after CRC scan", + open_fn=open_fn) + data = b"new payload" + rsp = self.begin_put(service, b"/type-race.bin", data) + + self.assertEqual(rsp[0], OK) + self.assertEqual(u32(rsp, 1), 0) + self.assertTrue(os.path.isfile(scratch), + "the changed empty directory must be normalised before wb") + self.assertEqual(self.read_file("/type-race.bin"), b"OLD") + self.send_put(service, data) + self.assertEqual(service.handle_put_end(p32(crc32(data)))[0], OK) + self.assertEqual(self.read_file("/type-race.bin"), data) + + def test_successful_remove_that_leaves_regular_scratch_fails_closed(self): + old = b"OLD DESTINATION" + stale = b"X" * 64 + self.put_file("/lying-remove.bin", old) + scratch = self.put_file("/lying-remove.bin.pbltmp", stale) + module = FS.obj( + self, "v0.6.1 scratch removal success is verified by absence") + service, _events = self.svc( + "v0.6.1 no-op remove cannot fall through to scratch truncation") + real_remove = module.os.remove + + def remove_fn(path): + if path == scratch: + return None # lying backend: reports success, leaves path + return real_remove(path) + + with mock.patch.object(module.os, "remove", side_effect=remove_fn): + rsp = self.begin_put(service, b"/lying-remove.bin", b"small") + + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file("/lying-remove.bin"), old) + self.assertEqual(self.read_file("/lying-remove.bin.pbltmp"), stale) + self.assertEqual(service.handle_put_end(p32(0))[0], EBADREQ) + + def test_successful_rmdir_that_leaves_scratch_directory_fails_closed(self): + self.put_file("/lying-rmdir.bin", b"OLD DESTINATION") + scratch = self.hpath("/lying-rmdir.bin.pbltmp") + os.mkdir(scratch) + module = FS.obj( + self, "v0.6.1 scratch rmdir success is verified by absence") + service, _events = self.svc( + "v0.6.1 no-op rmdir cannot fall through to scratch open") + real_rmdir = module.os.rmdir + + def rmdir_fn(path): + if path == scratch: + return None # lying backend: reports success, leaves path + return real_rmdir(path) + + with mock.patch.object(module.os, "rmdir", side_effect=rmdir_fn): + rsp = self.begin_put(service, b"/lying-rmdir.bin", b"new") + + self.assertEqual(rsp[0], EIO) + self.assertEqual(self.read_file("/lying-rmdir.bin"), b"OLD DESTINATION") + self.assertTrue(os.path.isdir(scratch)) + self.assertEqual(service.handle_put_end(p32(0))[0], EBADREQ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_pyble_fs_v061_native.py b/tests/firmware_tests/host/test_pyble_fs_v061_native.py new file mode 100644 index 00000000..689b3657 --- /dev/null +++ b/tests/firmware_tests/host/test_pyble_fs_v061_native.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# [red] Native-C parity guard for the v0.6.1 filesystem hardening increment. +# +# The portable suite provides executable semantic coverage over a real host +# filesystem. pble_fs.c is coupled to the MicroPython VM, MP streams, NLR, +# FreeRTOS, and the response-ticket lifecycle, so pretending to execute it with +# a large fake runtime would test the fake. These focused structural checks +# instead ensure the native path contains the same gates, in the safe order, +# while existing ESP build/HIL gates provide compilation and physical proof. + +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +SOURCE_PATH = os.path.join( + REPO_ROOT, "firmware", "user_c_modules", "pyble", "pble_fs.c") +HEADER_PATH = os.path.join( + REPO_ROOT, "firmware", "user_c_modules", "pyble", "pble_fs.h") + + +def _matching_brace(source, opening): + """Return the matching `}` while ignoring comments and C literals.""" + depth = 0 + index = opening + state = "code" + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "code": + if char == "/" and nxt == "*": + state = "block-comment" + index += 2 + continue + if char == "/" and nxt == "/": + state = "line-comment" + index += 2 + continue + if char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + elif state == "block-comment": + if char == "*" and nxt == "/": + state = "code" + index += 2 + continue + elif state == "line-comment": + if char == "\n": + state = "code" + elif state in ("string", "char"): + if char == "\\": + index += 2 + continue + if (state == "string" and char == '"') or ( + state == "char" and char == "'"): + state = "code" + index += 1 + raise AssertionError("unbalanced C function body at byte {}".format(opening)) + + +def _function_span(source, name): + # The leading return-type match is line-anchored so calls cannot masquerade + # as definitions. pble_fs.c uses ordinary C definitions without attributes. + pattern = re.compile( + r"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_][A-Za-z0-9_ \t*]*" + + re.escape(name) + + r"[ \t]*\([^;{}]*\)[ \t]*\{") + match = pattern.search(source) + if match is None: + raise AssertionError("native function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + closing = _matching_brace(source, opening) + return match.start(), closing + 1, source[opening + 1:closing] + + +def _function_body(source, name): + return _function_span(source, name)[2] + + +def _defined_functions(source): + names = set(re.findall( + r"(?m)^[ \t]*(?:[A-Za-z_][A-Za-z0-9_]*[ \t*]+)+?" + r"([A-Za-z_][A-Za-z0-9_]*)[ \t]*\([^;{}]*\)[ \t]*\{", + source)) + return dict((name, _function_body(source, name)) for name in names) + + +class NativeFsV061ContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + with open(SOURCE_PATH, "r", encoding="utf-8") as source_file: + cls.source = source_file.read() + with open(HEADER_PATH, "r", encoding="utf-8") as header_file: + cls.header = header_file.read() + + def test_list_filters_scratch_before_budget_stat_and_serialisation(self): + body = _function_body(self.source, "fs_do_list") + name_at = body.find("mp_obj_str_get_data") + suffix_at = body.find("fs_has_tmp_suffix") + budget_at = body.find("PBLE_RSP_MAX") + stat_at = body.find("fs_stat_path") + serialise_at = body.find("memcpy(g_scratch") + + self.assertGreaterEqual( + suffix_at, 0, + "native FILE_LIST must invoke its existing .pbltmp suffix helper") + self.assertTrue( + name_at < suffix_at < budget_at, + "scratch filtering must occur after decoding the entry name but " + "before response-budget/more processing") + self.assertTrue( + suffix_at < stat_at and suffix_at < serialise_at, + "a hidden entry must consume neither VFS stat work nor response bytes") + self.assertIn( + "continue", body[suffix_at:budget_at], + "the scratch predicate must skip the directory entry, not merely inspect it") + + def test_mailbox_payload_holds_two_maximum_rename_paths(self): + capacity = re.search( + r"#\s*define\s+PBLE_FS_ITEM_PAYLOAD\s+(\d+)\b", self.source) + self.assertIsNotNone(capacity) + self.assertGreaterEqual( + int(capacity.group(1)), 2 + 128 + 2 + 128, + "the native mailbox cannot reject a protocol-valid 128/128 " + "FILE_RENAME before either path reaches the jail validator", + ) + + def test_each_mutator_gates_every_active_transfer_after_path_resolution(self): + cases = ( + ("fs_do_delete", 1, "fs_stat_path"), + ("fs_do_mkdir", 1, "mp_vfs_mkdir"), + ("fs_do_rename", 2, "fs_stat_path"), + ) + for function, resolve_count, first_effect in cases: + with self.subTest(function=function): + body = _function_body(self.source, function) + resolve_positions = [ + match.start() + for match in re.finditer(r"\bpble_fs_resolve\s*\(", body) + ] + self.assertEqual( + len(resolve_positions), resolve_count, + "the existing jail chokepoint count must remain explicit") + busy_at = body.find("fs_put_active_current(it)") + effect_at = body.find(first_effect) + self.assertGreaterEqual( + busy_at, 0, + "{} must consult generation-qualified active PUT ownership" + .format(function)) + self.assertTrue( + resolve_positions[-1] < busy_at < effect_at, + "{} must apply jail precedence, then EBUSY, before the first " + "namespace probe/effect".format(function)) + gate = body[busy_at:effect_at] + self.assertIn("PBLE_EBUSY", gate) + self.assertIn("return", gate) + + get_busy_at = body.find("get_active_at_enqueue") + self.assertTrue( + resolve_positions[-1] < get_busy_at < effect_at, + "{} must consume the admission-time active-GET snapshot " + "after jail resolution and before VFS".format(function), + ) + + def test_native_mailbox_snapshots_accepted_get_activity(self): + item = re.search( + r"typedef\s+struct\s*\{(?P<body>[^{}]*)\}\s*pble_fs_req_t\s*;", + self.source, + flags=re.DOTALL, + ) + self.assertIsNotNone(item) + self.assertRegex( + item.group("body"), + r"\bbool\s+get_active_at_enqueue\s*;", + "each queued command must retain whether a GET owned its " + "admission instant", + ) + + enqueue = _function_body(self.source, "fs_enqueue") + self.assertIn("get_active_at_enqueue", enqueue) + self.assertIn("g_get_active", enqueue) + snapshot_at = enqueue.find("get_active_at_enqueue") + queue_at = enqueue.find("xQueueSend") + self.assertTrue(0 <= snapshot_at < queue_at) + + get_body = _function_body(self.source, "fs_do_get") + self.assertIn("g_get_active", get_body) + self.assertRegex( + get_body, + r"g_get_active\s*=\s*true", + "GET must become active before its successful response commits", + ) + self.assertRegex( + get_body, + r"g_get_active\s*=\s*false", + "every completed or aborted stream must release active GET", + ) + + def test_put_data_checks_crossing_with_non_overflowing_math_before_write(self): + body = _function_body(self.source, "fs_do_put_data") + write_at = body.find("sp->write") + latch_at = body.find("g_put_latched = PBLE_ERANGE") + self.assertGreaterEqual( + latch_at, 0, + "a chunk crossing total_size must latch PBLE_ERANGE") + self.assertLess(latch_at, write_at, + "range rejection must occur before the first MP stream write") + prewrite = body[:write_at] + self.assertRegex( + prewrite, + r"(?:offset|g_put_watermark)\s*>\s*g_put_total", + "check the contiguous offset before subtracting it from the declared total") + self.assertRegex( + prewrite, + r"n\s*>\s*\(?\s*g_put_total\s*-\s*" + r"(?:offset|g_put_watermark)\s*\)?", + "use subtraction-form bounds checking; offset+n may wrap uint32_t") + self.assertIn("fs_put_ack", body[latch_at:write_at]) + self.assertIn("return", body[latch_at:write_at]) + + def test_put_data_rejects_zero_or_impossible_stream_progress(self): + body = _function_body(self.source, "fs_do_put_data") + write_at = body.find("sp->write") + progress_at = body.find("done +=", write_at) + self.assertGreaterEqual(write_at, 0) + self.assertGreater(progress_at, write_at) + guard = body[write_at:progress_at] + self.assertRegex( + guard, + r"\bw\s*==\s*0", + "a zero-byte successful write must not spin the filesystem worker", + ) + self.assertRegex( + guard, + r"\bw\s*>\s*\(?\s*n\s*-\s*done\s*\)?", + "an impossible over-report must not advance beyond the input chunk", + ) + self.assertIn("PBLE_EIO", guard) + self.assertIn("break", guard) + + def test_every_native_read_rejects_backend_overreport_before_buffer_use(self): + cases = ( + ("fs_crc_file", "PBLE_FS_SCRATCH"), + ("fs_crc_prefix", "want"), + ("fs_do_get", "chunk"), + ) + for function, requested in cases: + with self.subTest(function=function): + body = _function_body(self.source, function) + read_at = body.find("sp->read") + crc_at = body.find("crc32_update", read_at) + self.assertTrue(0 <= read_at < crc_at) + guard = body[read_at:crc_at] + self.assertRegex( + guard, + r"\bn\s*>\s*\(?\s*{}\s*\)?".format( + re.escape(requested)), + "{} trusts a stream count larger than its {}-byte " + "destination and may read beyond g_scratch".format( + function, requested), + ) + self.assertRegex(guard, r"\b(?:MP|PBLE)_EIO\b") + + def test_stat_crc_consumes_exactly_the_advertised_extent(self): + start, _end, crc_body = _function_span(self.source, "fs_crc_file") + signature = self.source[start:self.source.find("{", start)] + self.assertRegex( + signature, + r"fs_crc_file\s*\([^)]*uint32_t\s+(?:expected|extent|size)", + "FILE_STAT must pass its stat-advertised size into the CRC scan", + ) + self.assertRegex( + crc_body, + r"uint32_t\s+remaining\s*=\s*(?:expected|extent|size)\s*;", + ) + self.assertRegex(crc_body, r"while\s*\(\s*remaining\s*>\s*0\s*\)") + self.assertRegex( + crc_body, + r"sp->read\s*\([^;]*,\s*want\s*,", + "the last CRC read must be bounded by the promised remainder", + ) + eof = re.search( + r"if\s*\(\s*n\s*==\s*0\s*\)\s*\{(?P<body>[^{}]*)\}", + crc_body, + flags=re.DOTALL, + ) + self.assertIsNotNone(eof) + self.assertNotIn("break", eof.group("body")) + self.assertRegex(eof.group("body"), r"(?:MP|PBLE)_EIO") + stat_body = _function_body(self.source, "fs_do_stat") + self.assertRegex( + stat_body, + r"fs_crc_file\s*\(\s*it\s*,\s*path\s*,\s*size\s*,\s*&crc\s*\)", + ) + + def test_get_short_read_cannot_emit_a_completion_event(self): + body = _function_body(self.source, "fs_do_get") + self.assertRegex(body, r"uint32_t\s+remaining\s*=\s*total\s*;") + self.assertRegex(body, r"while\s*\(\s*remaining\s*>\s*0\s*\)") + read_at = body.find("sp->read") + self.assertGreaterEqual(read_at, 0) + self.assertRegex( + body[read_at:body.find("crc32_update", read_at)], + r"\bn\s*>\s*want\b", + "GET must compare a backend count with the exact final read size", + ) + self.assertRegex( + body[read_at:body.find("crc32_update", read_at)], + r"\bn\s*==\s*0\b[\s\S]*(?:MP|PBLE)_EIO", + "zero before the promised total must take the read-failure path", + ) + self.assertIn( + "stream_complete", + body, + "GET_END needs an explicit exact-extent completion predicate", + ) + end_at = body.find("PBLE_OP_FILE_GET_END") + self.assertGreater(end_at, 0) + completion_gate = body[max(0, end_at - 500):end_at] + self.assertIn( + "stream_complete", completion_gate, + "a catch path or early EOF must not publish FILE_GET_END", + ) + + def test_put_end_requires_a_successful_close_before_rename(self): + functions = _defined_functions(self.source) + close = functions["fs_put_close"] + end = functions["fs_do_put_end"] + signature_start, _end, _body = _function_span( + self.source, "fs_put_close") + signature = self.source[ + signature_start:self.source.find("{", signature_start)] + self.assertRegex(signature, r"\buint8_t\s+fs_put_close\s*\(") + self.assertIn("PBLE_EIO", close) + close_at = end.find("fs_put_close(") + rename_at = end.find("mp_vfs_rename(") + self.assertTrue(0 <= close_at < rename_at) + gate = end[close_at:rename_at] + self.assertIn("PBLE_OK", gate) + self.assertIn("return", gate) + self.assertIn("fs_put_abort", gate) + + def test_put_abort_returns_checked_cleanup_and_verifies_absence(self): + start, _end, abort = _function_span(self.source, "fs_put_abort") + signature = self.source[start:self.source.find("{", start)] + self.assertRegex(signature, r"\buint8_t\s+fs_put_abort\s*\(") + self.assertIn("fs_put_close", abort) + self.assertIn("mp_vfs_remove", abort) + self.assertGreaterEqual( + abort.find("fs_stat_path", abort.find("mp_vfs_remove")), 0, + "a nominally successful abort remove must be followed by stat", + ) + self.assertIn("PBLE_EIO", abort) + + end = _function_body(self.source, "fs_do_put_end") + self.assertRegex( + end, + r"(?:return|=)[^;\n]*fs_put_abort\s*\(", + "PUT_END must consume abort status instead of discarding it", + ) + + def test_resume_requires_exact_regular_file_mode(self): + start, end, _body = _function_span(self.source, "fs_mode_is_regular") + helper = self.source[start:end] + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone(compiler, "a host C compiler is required") + probe = ( + "#include <stdbool.h>\n" + "typedef long mp_int_t;\n" + "#define MP_S_IFREG 0x8000\n" + + helper + + "\nint main(void) {\n" + " if (!fs_mode_is_regular(0x8000)) return 20;\n" + " if (!fs_mode_is_regular(0x81a4)) return 21;\n" + " if (fs_mode_is_regular(0x4000)) return 22;\n" + " if (fs_mode_is_regular(0xa000)) return 23;\n" + " if (fs_mode_is_regular(0x2000)) return 24;\n" + " if (fs_mode_is_regular(0x0000)) return 25;\n" + " return 0;\n}\n" + ) + with tempfile.TemporaryDirectory(prefix="pyble-v061-mode-") as temp: + source = os.path.join(temp, "mode_probe.c") + binary = os.path.join(temp, "mode_probe") + with open(source, "w", encoding="utf-8") as stream: + stream.write(probe) + built = subprocess.run( + [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", + source, "-o", binary], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False, + ) + self.assertEqual( + built.returncode, 0, + "native mode probe did not compile:\n{}{}".format( + built.stdout, built.stderr), + ) + executed = subprocess.run( + [binary], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False, + ) + self.assertEqual( + executed.returncode, 0, + "native regular-file predicate accepted a non-regular mode; " + "probe exit {}".format(executed.returncode), + ) + + stat = _function_body(self.source, "fs_stat_path") + resume = _function_body(self.source, "pble_fs_resume_prefix") + self.assertIn("fs_mode_is_regular", stat) + self.assertRegex(resume, r"!\s*[A-Za-z_]*isreg") + + def test_put_begin_calls_checked_statvfs_admission_before_open(self): + functions = _defined_functions(self.source) + providers = [ + name for name, body in functions.items() + if "mp_vfs_statvfs" in body + ] + self.assertEqual( + len(providers), 1, + "native filesystem must have one auditable mp_vfs_statvfs admission helper") + provider_name = providers[0] + provider = functions[provider_name] + begin = functions["fs_do_put_begin"] + + if provider_name == "fs_do_put_begin": + admission_at = begin.find("mp_vfs_statvfs") + else: + admission_at = begin.find(provider_name + "(") + self.assertGreaterEqual( + admission_at, 0, + "FILE_PUT_BEGIN must call the statvfs admission helper") + resume_at = begin.find("pble_fs_resume_prefix") + open_at = begin.find("fs_open") + self.assertTrue( + resume_at < admission_at < open_at, + "validate/normalise scratch, determine resume, then admit remaining " + "allocation before opening or growing scratch") + + reserve_names = set(re.findall( + r"\b[A-Za-z_][A-Za-z0-9_]*reserve[A-Za-z0-9_]*\b", + self.source, flags=re.IGNORECASE)) + reserve_names = set( + name for name in reserve_names + if name.lower() != "reserve" and "_" in name) + used_reserves = [name for name in reserve_names if name in provider] + self.assertTrue( + used_reserves, + "the statvfs helper must apply a named safety-reserve constant") + definitions = self.source + "\n" + self.header + definition_lines = [ + line for line in definitions.splitlines() + if any(name in line for name in used_reserves) + ] + compact = "".join(definition_lines).replace(" ", "").lower() + self.assertTrue( + "65536" in compact or "64u*1024u" in compact + or "64*1024" in compact, + "native admission reserve must be exactly 65,536 bytes") + self.assertIn("uint64_t", provider, + "free-space and rounded-needed arithmetic must not wrap u32") + self.assertIn("PBLE_ENOSPC", provider) + self.assertIn("PBLE_EIO", provider) + self.assertRegex(provider, r"\[\s*1\s*\]", + "statvfs f_frsize is tuple field 1") + self.assertRegex(provider, r"\[\s*4\s*\]", + "statvfs f_bavail is tuple field 4") + + def test_resume_helper_returns_explicit_status_and_cleans_malformed_state(self): + start, _end, body = _function_span(self.source, "pble_fs_resume_prefix") + signature = self.source[start:self.source.find("{", start)] + self.assertRegex( + signature, + r"\buint8_t\s+pble_fs_resume_prefix\s*\(", + "resume helper must return an explicit PBLE status, not overload " + "resume_offset zero as both fresh and failed") + functions = _defined_functions(self.source) + cleanup = body + if "mp_vfs_remove" not in cleanup or "mp_vfs_rmdir" not in cleanup: + cleanup_helpers = [ + (name, candidate) + for name, candidate in functions.items() + if "mp_vfs_remove" in candidate and "mp_vfs_rmdir" in candidate + and name + "(" in body + ] + self.assertEqual( + len(cleanup_helpers), 1, + "resume must directly clean malformed scratch or call one " + "auditable non-recursive cleanup helper") + cleanup = cleanup_helpers[0][1] + self.assertIn("mp_vfs_remove", cleanup, + "malformed regular scratch must be removed explicitly") + self.assertIn("mp_vfs_rmdir", cleanup, + "an empty malformed scratch directory must be removed safely") + self.assertTrue( + "PBLE_EIO" in body or "PBLE_EIO" in cleanup, + "uncleanable/type-invalid scratch must fail closed") + self.assertGreaterEqual( + len(re.findall(r"\bfs_stat_path\s*\(", body)), 2, + "resume must re-stat after scanning to reject a changed type/length") + + begin = functions["fs_do_put_begin"] + resume_at = begin.find("pble_fs_resume_prefix") + first_open_at = begin.find("fs_open") + status_window = begin[resume_at:first_open_at] + self.assertIn("PBLE_OK", status_window) + self.assertIn( + "return", status_window, + "FILE_PUT_BEGIN must stop before opening scratch when resume " + "validation/normalisation returns a failure status") + + crc_body = _function_body(self.source, "fs_crc_prefix") + eof = re.search( + r"if\s*\(\s*n\s*==\s*0\s*\)\s*\{([^{}]*)\}", crc_body, + flags=re.DOTALL) + self.assertIsNotNone(eof, + "prefix scanner must handle premature EOF explicitly") + self.assertNotIn( + "break", eof.group(1), + "premature EOF before the stat length cannot produce a successful CRC") + self.assertTrue( + "PBLE_EIO" in eof.group(1) or "return" in eof.group(1), + "premature EOF must surface as malformed/error to the resume helper") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_pyble_proto.py b/tests/firmware_tests/host/test_pyble_proto.py index 65e3a49a..7b58f2e9 100755 --- a/tests/firmware_tests/host/test_pyble_proto.py +++ b/tests/firmware_tests/host/test_pyble_proto.py @@ -34,7 +34,9 @@ # handler(frame) -> bytes # returns the RSP PAYLOAD (payload[0]=status) # unknown opcode -> RSP payload[0]=EUNSUPPORTED, echoes id+opcode # CRC failure -> EVT id=0, opcode=offending, payload[0]=ECRC -# malformed frame -> RSP payload[0]=EBADREQ +# malformed frame -> RSP EBADREQ only with a complete, safely +# correlatable v1/CMD/nonzero-ID header; else silent +# valid-CRC non-CMD / CMD id=0 -> silent, never dispatched # --------------------------------------------------------------------------- import os @@ -235,16 +237,16 @@ def test_crc_failed_frame_is_not_dispatched(self): d.on_message(_support.hx(_support.crc_reject_frames()[0]["frame_hex"])) self.assertEqual(called["n"], 0, "a CRC-failed frame must be dropped, not dispatched") - def test_malformed_short_frame_answers_ebadreq(self): - disp_cls = PROTO.attr(self, "Dispatcher", "F-02/FR-PROTO-8 malformed -> EBADREQ") - decode = PROTO.attr(self, "decode", "F-02/FR-PROTO-8 decode") + def test_malformed_short_frame_without_safe_correlation_is_silent(self): + disp_cls = PROTO.attr( + self, "Dispatcher", + "v0.6.1 FR-PROTO-8 malformed without safe correlation -> silent") d = disp_cls() out = d.on_message(b"\x01\x01\x01") # far too short for a §3.1 header - self.assertIsNotNone(out, "a malformed frame must be answered") - rsp = decode(bytes(out)) - self.assertEqual(rsp.type, RSP) - self.assertEqual(rsp.payload[0], FROZEN_STATUS["EBADREQ"], - "structurally-invalid input must answer EBADREQ (0x01)") + self.assertIsNone( + out, + "fewer than six header bytes cannot safely identify VER/type/op/id; " + "the board must not synthesize an RSP with ID 0") def test_malformed_len_mismatch_answers_ebadreq(self): disp_cls = PROTO.attr(self, "Dispatcher", "F-02/FR-PROTO-8 LEN mismatch -> EBADREQ") @@ -258,6 +260,47 @@ def test_malformed_len_mismatch_answers_ebadreq(self): self.assertEqual(rsp.payload[0], FROZEN_STATUS["EBADREQ"], "a LEN that overruns the buffer is malformed -> EBADREQ") + def test_valid_crc_non_cmd_frames_are_silent_and_not_dispatched(self): + disp_cls = PROTO.attr( + self, "Dispatcher", "v0.6.1 TYPE direction gate before dispatch") + d = disp_cls() + called = {"n": 0} + d.register( + FROZEN_OPCODES["DEVICE_INFO"], + lambda frame: called.__setitem__("n", called["n"] + 1) or b"\x00") + for type_, id_ in ((RSP, 7), (EVT, 0), (0x7F, 8)): + with self.subTest(type=type_): + out = d.on_message(oracle_frame( + type_, FROZEN_OPCODES["DEVICE_INFO"], id_, b"")) + self.assertIsNone(out, "board RX accepts TYPE=CMD only") + self.assertEqual(0, called["n"]) + + def test_valid_crc_cmd_id_zero_is_silent_and_not_dispatched(self): + disp_cls = PROTO.attr( + self, "Dispatcher", "v0.6.1 nonzero request-ID gate") + d = disp_cls() + called = {"n": 0} + d.register( + FROZEN_OPCODES["DEVICE_INFO"], + lambda frame: called.__setitem__("n", called["n"] + 1) or b"\x00") + out = d.on_message(oracle_frame( + CMD, FROZEN_OPCODES["DEVICE_INFO"], 0, b"")) + self.assertIsNone(out, "CMD ID 0 is invalid and response-loop unsafe") + self.assertEqual(0, called["n"]) + + def test_crc_failure_precedes_wrong_direction_gate(self): + disp_cls = PROTO.attr( + self, "Dispatcher", "v0.6.1 structure -> CRC -> TYPE precedence") + decode = PROTO.attr(self, "decode", "v0.6.1 ECRC EVT decode") + msg = oracle_frame(RSP, FROZEN_OPCODES["DEVICE_INFO"], 7, b"\x00") + msg = msg[:-1] + bytes((msg[-1] ^ 0x01,)) + out = disp_cls().on_message(msg) + self.assertIsNotNone(out) + evt = decode(bytes(out)) + self.assertEqual((EVT, FROZEN_OPCODES["DEVICE_INFO"], 0), + (evt.type, evt.opcode, evt.id)) + self.assertEqual(bytes((FROZEN_STATUS["ECRC"],)), bytes(evt.payload)) + class FragmentationTest(unittest.TestCase): """FR-BLE-8: payload = MTU-3 (ATT) minus 1-byte frag header. diff --git a/tests/firmware_tests/host/test_pyble_proto_version_guard.py b/tests/firmware_tests/host/test_pyble_proto_version_guard.py index 4358a70e..9440700f 100644 --- a/tests/firmware_tests/host/test_pyble_proto_version_guard.py +++ b/tests/firmware_tests/host/test_pyble_proto_version_guard.py @@ -8,18 +8,10 @@ # firmware/pyble/pyble_proto.py (native twin pble_proto.c). The HELLO-side of # F-16 (version negotiation, FR-INFO-5) lives in test_pyble_info.py. # -# ===================================================================== -# DoR STATUS — BLOCKED on §9 freeze (accept-only-VER-0x01 / refusal policy). -# protocol.md §9 (Versioning policy) is DRAFT (freeze ledger, 2026-07-01). -# The VER-byte itself (§3.1) and EUNSUPPORTED (§8) are FROZEN, so the -# MECHANISM is testable; the REFUSAL POLICY wording is §9-DRAFT. Commit -# [red] only after §9 freezes + specs.md FR-PROTO-7 mirror lands. -# ===================================================================== -# -# FROZEN references: §3.1 VER=0x01, §4 opcode set, §8 status set (EUNSUPPORTED -# 0x0A, EBADREQ 0x01), architect contract dispatch guard note. This file NEVER -# redefines the wire; the corrupt/other-version frames are built with a CORRECT -# CRC (zlib oracle) so the failure is the VER guard, not a CRC reject. +# FROZEN references: §3.1 VER=0x01, §3.2 validation precedence, §4 opcode set, +# §8 status set, and §9 refusal policy (amended 2026-09-02). This file NEVER +# redefines the wire; other-version frames use the zlib CRC oracle so each test +# can distinguish the CRC gate from direction and version refusal. # # INTERFACE PINNED (protocol-engineer implements to it, already pinned by # test_pyble_proto.py): pyble_proto.decode / .Dispatcher().on_message. @@ -66,10 +58,12 @@ def test_ver2_frame_is_refused_not_silently_accepted(self): decode = PROTO.attr(self, "decode", "F-16/FR-PROTO-7 decode") d = disp_cls() out = d.on_message(oracle_frame(0x02, CMD, HELLO, 1, b"")) - # Guard note: reject -> EVT ERROR(EBADREQ). Assert it is NOT a normal RSP - # OK and DOES carry an error status, so the client is not mis-spoken to. + # A safely correlatable, direction-valid VER2 CMD is refused by a v1 RSP + # that echoes its opcode/ID; it is never accepted or answered OK. self.assertIsNotNone(out, "a VER!=0x01 frame MUST be answered, not ignored silently") ans = decode(bytes(out)) + self.assertEqual(RSP, ans.type) + self.assertEqual((HELLO, 1), (ans.opcode, ans.id)) self.assertNotEqual(ans.payload[0], 0x00, "a VER!=0x01 frame MUST NOT be answered OK") self.assertEqual(ans.payload[0], EBADREQ, @@ -84,6 +78,29 @@ def test_ver1_frame_still_dispatches(self): d.on_message(oracle_frame(VER, CMD, HELLO, 1, b"")) self.assertEqual(seen["n"], 1, "a valid VER=0x01 CMD MUST still dispatch") + def test_crc_failure_precedes_version_refusal(self): + disp_cls = PROTO.attr( + self, "Dispatcher", "v0.6.1 structure -> CRC -> VER precedence") + decode = PROTO.attr(self, "decode", "v0.6.1 bad VER/bad CRC response") + msg = oracle_frame(0x02, CMD, HELLO, 2, b"") + msg = msg[:-1] + bytes((msg[-1] ^ 0x01,)) + out = disp_cls().on_message(msg) + self.assertIsNotNone(out) + answer = decode(bytes(out)) + self.assertEqual((EVT, HELLO, 0), + (answer.type, answer.opcode, answer.id)) + self.assertEqual(b"\x08", bytes(answer.payload), + "bad CRC is ECRC even when VER is also unsupported") + + def test_direction_gate_precedes_version_refusal(self): + disp_cls = PROTO.attr( + self, "Dispatcher", "v0.6.1 CRC -> direction -> VER precedence") + out = disp_cls().on_message(oracle_frame(0x02, RSP, HELLO, 3, b"\x00")) + self.assertIsNone( + out, + "a valid-CRC inbound RSP is silent; unsupported VER cannot turn it " + "into a response loop") + class UnknownOpcodeEunsupportedTest(unittest.TestCase): """FR-PROTO-9: a well-formed request for an unsupported opcode MUST be diff --git a/tests/firmware_tests/host/test_pyble_runner.py b/tests/firmware_tests/host/test_pyble_runner.py index 598eac45..39efa4f5 100644 --- a/tests/firmware_tests/host/test_pyble_runner.py +++ b/tests/firmware_tests/host/test_pyble_runner.py @@ -7,18 +7,11 @@ # module owner (HAND-OFF for [green]): runtime-engineer -> # firmware/pyble/pyble_runner.py (native twin pble_runner.c). # -# ===================================================================== -# DoR STATUS — BLOCKED (do NOT commit [red] before the freeze): -# protocol.md §6 (Run / Stop / Console) is DRAFT (freeze ledger, 2026-07-01). -# Per the architect DoR, F-04 is ready:false until §6 freezes (RUN{mode,path} -# shape, RSP-then-RUN_STATE(running) sequence, running->done/error, EBUSY) and -# is mirrored into specs.md FR-RUN. Until then this suite asserts ONLY the -# FROZEN SEMANTICS via a PURE run-state-machine seam (the F-04 refactor -# extracts one for reuse by RUN source + STOP) — it NEVER hardcodes a DRAFT -# RUN payload byte, and it NEVER spawns a task. The actual separate-task -# spawn, link-stays-responsive, and HIL run-file are HIL-only (see -# gates/g1_s3_check.sh). -# ===================================================================== +# Historical TDD provenance: the first [red] version used only a pure +# run-state-machine seam while protocol §6 was awaiting its freeze. Section §6 +# and the FR-RUN mirror are now frozen, so this is an active regression suite +# for RUN lifecycle, response/state order, and EBUSY. Separate-task execution, +# link responsiveness, and physical RUN remain HIL-only. # # FROZEN references: protocol.md §4 (0x20 RUN, 0x40 RUN_STATE), §8 (EBUSY 0x07); # specs.md FR-RUN-1/3/4/7/9, NFR-SAFE-1/2, NFR-REL-1; architect contract diff --git a/tests/firmware_tests/host/test_pyble_runner_rp2.py b/tests/firmware_tests/host/test_pyble_runner_rp2.py index ae62edb9..2d5f1c64 100644 --- a/tests/firmware_tests/host/test_pyble_runner_rp2.py +++ b/tests/firmware_tests/host/test_pyble_runner_rp2.py @@ -49,6 +49,7 @@ import os import sys +import tempfile import unittest sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -460,6 +461,47 @@ def test_run_allowed_again_after_terminal(self): "after a terminal state the runner MUST accept a new RUN") +class FreshGlobalsExecutorTest(unittest.TestCase): + """v0.6.1 ordinary RUNs receive a new ``__main__`` namespace.""" + + def test_sequential_source_and_file_runs_do_not_share_globals(self): + make_exec_fn = RUNNER.attr( + self, + "make_exec_fn", + "v0.6.1 portable RUN fresh-globals executor", + ) + execute = make_exec_fn() + + execute( + RUNNER.attr(self, "MODE_SOURCE", "v0.6.1 source RUN mode"), + b"sentinel_from_run_one = 1\n" + b"assert __name__ == '__main__'\n", + ) + execute( + RUNNER.attr(self, "MODE_SOURCE", "v0.6.1 source RUN mode"), + b"assert 'sentinel_from_run_one' not in globals()\n" + b"assert __name__ == '__main__'\n", + ) + + with tempfile.TemporaryDirectory(prefix="pyble-v061-runner-") as root: + path = os.path.join(root, "fresh_file_run.py") + with open(path, "wb") as script: + script.write( + b"assert 'sentinel_from_run_one' not in globals()\n" + b"assert __name__ == '__main__'\n" + b"file_only_sentinel = 1\n" + ) + execute( + RUNNER.attr(self, "MODE_FILE", "v0.6.1 file RUN mode"), + path.encode("utf-8"), + ) + + execute( + RUNNER.attr(self, "MODE_SOURCE", "v0.6.1 source RUN mode"), + b"assert 'file_only_sentinel' not in globals()\n", + ) + + class StaleStopClearedTest(unittest.TestCase): """P3/§6: STOP is idempotent (always OK, no emission while idle) and a stale stop intent from an idle STOP MUST NOT bleed into the next run.""" diff --git a/tests/firmware_tests/host/test_release_bundle.py b/tests/firmware_tests/host/test_release_bundle.py index 869e4e83..bbbd49f2 100644 --- a/tests/firmware_tests/host/test_release_bundle.py +++ b/tests/firmware_tests/host/test_release_bundle.py @@ -3455,5 +3455,93 @@ def test_missing_license_or_unrecognized_incompatible_spdx_fails(self): ) +@unittest.skipUnless(HAVE_RELEASE, RELEASE_LOAD_ERROR) +class V061CandidateHardeningRecordRedTests(unittest.TestCase): + """Version-route the v0.6.1 record extension without rewriting v0.6.0.""" + + V060_GOLDEN_SHA256 = ( + "5771a10be2f1c3dff05d87e3c6cab73667bdc8ade35cc486434a3e7537891066" + ) + + def candidate_report(self, firmware_version: str) -> str: + profile_records = [] + for index, profile_id in enumerate(V060_RELEASE_PROFILE_ORDER): + spec = RELEASE.PROFILE_SPECS[profile_id] + record = { + "id": profile_id, + "install": {"sha256": str(index + 1) * 64}, + } + if spec["port"] != "rp2": + record["manifest"] = { + "sha256": chr(ord("a") + index) * 64, + } + profile_records.append(record) + policy = { + "schema_version": 3, + "profile_order": list(V060_RELEASE_PROFILE_ORDER), + "profiles": [ + {"profile_id": profile_id, "fixture": index} + for index, profile_id in enumerate(V060_RELEASE_PROFILE_ORDER) + ], + } + with mock.patch.object( + RELEASE, + "_qualification_build_measurement", + side_effect=lambda _bundle, profile_id: { + "profile_index": list(V060_RELEASE_PROFILE_ORDER).index(profile_id) + }, + ): + return RELEASE._candidate_hil_report( + firmware_version, + {"pyble": {"commit": "1" * 40}}, + profile_records, + policy, + "f" * 64, + Path("/unused-v061-candidate-fixture"), + ) + + def test_v061_candidate_adds_exact_pending_hardening_fields(self): + rendered = self.candidate_report("0.6.1") + marker = RELEASE.HIL_MARKER_RE.search(rendered) + self.assertIsNotNone(marker) + payload = json.loads(marker.group(2)) + self.assertEqual(payload["schema_version"], 5) + self.assertEqual( + [record["profile_id"] for record in payload["records"]], + list(V060_RELEASE_PROFILE_ORDER), + ) + for record in payload["records"]: + with self.subTest(profile=record["profile_id"]): + self.assertIn("v061_hardening", record) + self.assertIsNone(record["v061_hardening"]) + self.assertEqual(record["checks"]["v061_hardening"], "pending") + self.assertEqual( + tuple(record["checks"]), + ( + "provisioning_install", + "provisioning_recovery", + "advertising_info_hello", + "pble_workflow", + "safe_boot_reconnect", + "filesystem_resume_reliability", + "footprint_reliability", + "v061_hardening", + ), + ) + + def test_v060_candidate_rendering_remains_byte_identical(self): + rendered = self.candidate_report("0.6.0").encode("utf-8") + self.assertEqual( + hashlib.sha256(rendered).hexdigest(), + self.V060_GOLDEN_SHA256, + ) + marker = RELEASE.HIL_MARKER_RE.search(rendered.decode("utf-8")) + self.assertIsNotNone(marker) + payload = json.loads(marker.group(2)) + for record in payload["records"]: + self.assertNotIn("v061_hardening", record) + self.assertNotIn("v061_hardening", record["checks"]) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_release_evidence_assembly.py b/tests/firmware_tests/host/test_release_evidence_assembly.py index f4baa731..6d4e5e60 100644 --- a/tests/firmware_tests/host/test_release_evidence_assembly.py +++ b/tests/firmware_tests/host/test_release_evidence_assembly.py @@ -24,6 +24,7 @@ import test_release_bundle as bundle_fixture import test_release_finalization as finalization_fixture +import test_v061_hardening_release_gate as v061_fixture RELEASE = bundle_fixture.RELEASE @@ -96,6 +97,131 @@ def write_json(path: Path, value: object) -> None: path.write_bytes(canonical_json_bytes(value)) +def v061_public_summary(index: int) -> dict: + return { + "measurement_contract": "v061-hardening-seven-scenario-v1", + "scenario_order": list(v061_fixture.SCENARIO_ORDER), + "scenarios": { + name: "passed" for name in v061_fixture.SCENARIO_ORDER + }, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in v061_fixture.WORKSPACE_ORDER + }, + "private_result_sha256": "%064x" % (index + 1), + } + + +def v061_promotion_payloads() -> tuple[dict, dict]: + historical_checks = ( + "provisioning_install", + "provisioning_recovery", + "advertising_info_hello", + "pble_workflow", + "safe_boot_reconnect", + "filesystem_resume_reliability", + "footprint_reliability", + ) + records = [] + for index, profile_id in enumerate(v061_fixture.PROFILE_TARGETS): + target = v061_fixture.PROFILE_TARGETS[profile_id] + record = { + "profile_id": profile_id, + "target": target, + "resource_kind": "rp2" if profile_id == "rpi-pico2-w" else "esp-idf", + "provisioning_kind": ( + "verified-uf2-bootsel" + if profile_id == "rpi-pico2-w" + else "esp-web-serial" + ), + "status": "pending", + "board_manufacturer": "", + "board_model": "", + "module_marking": "", + "device_flash_capacity_bytes": 0, + "device_psram_capacity_bytes": 0, + "firmware_version": "0.6.1", + "tag": "firmware-v0.6.1", + "source_commit": "1" * 40, + "install_sha256": "%064x" % (index + 10), + "tested_at": "", + "operator": "", + "maintainer_signoff": "", + "desktop_os": "", + "chromium_version": "", + "ble_backend": "", + "ble_adapter": "", + "python_version": "", + "oi1_policy": {"profile_id": profile_id}, + "oi1_build": {"fixture": index}, + "checks": { + **{name: "pending" for name in historical_checks}, + "v061_hardening": "pending", + }, + "app_hil": {"ipad": None, "android": None}, + "profile_gate_summary": None, + "oi1_observation": None, + "redacted_console_log": "", + "v061_hardening": None, + } + if profile_id != "rpi-pico2-w": + record["manifest_sha256"] = "%064x" % (index + 20) + records.append(record) + candidate = { + "schema_version": 5, + "candidate_release_json_sha256": "", + "qualification_policy_sha256": "2" * 64, + "qualification_policy": {"schema_version": 3}, + "records": records, + "waveshare_lcd147b_qualification": None, + "esp32_c3_qualification": None, + "rpi_pico2_w_qualification": None, + } + completed = copy.deepcopy(candidate) + completed["candidate_release_json_sha256"] = "3" * 64 + for index, record in enumerate(completed["records"]): + profile_id = record["profile_id"] + record["status"] = "passed" + record["board_manufacturer"] = "Fixture Boards" + record["board_model"] = "Fixture " + profile_id + record["module_marking"] = profile_id + spec = RELEASE.PROFILE_SPECS[profile_id] + record["device_flash_capacity_bytes"] = spec["flash_size_bytes"] + record["device_psram_capacity_bytes"] = spec["psram"]["size_bytes"] + record["tested_at"] = "2026-09-02T12:00:00Z" + record["operator"] = "Fixture Operator" + record["maintainer_signoff"] = "Fixture Maintainer" + record["desktop_os"] = "FixtureOS 1" + record["chromium_version"] = "Chrome 151" + record["ble_backend"] = "Fixture BLE 1" + record["ble_adapter"] = "Fixture Adapter 1" + record["python_version"] = "3.13.5" + record["checks"] = { + name: "passed" for name in record["checks"] + } + record["app_hil"] = { + platform: { + "app_version": "0.2.0", + "app_build": "6", + "os_major": "12" if platform == "android" else "26", + "status": "passed", + } + for platform in ("ipad", "android") + } + record["oi1_observation"] = {"fixture": profile_id} + record["redacted_console_log"] = "private values removed" + record["v061_hardening"] = v061_public_summary(index) + if profile_id == "esp32-c3-4mb": + record["profile_gate_summary"] = { + "C3-G%d" % gate: "passed" for gate in range(7) + } + elif profile_id == "rpi-pico2-w": + record["profile_gate_summary"] = { + "GP%d" % gate: "passed" for gate in range(3) + } + return candidate, completed + + class BaselineAssemblyFixture: def __init__(self) -> None: self.release_fixture = bundle_fixture.ReleaseFixture() @@ -195,6 +321,36 @@ def completion_fragment(record: dict) -> dict: return fragment +def v5_operator_input(profile_id: str = "esp32-4mb") -> dict: + spec = RELEASE.PROFILE_SPECS[profile_id] + return { + "board_manufacturer": "Fixture Boards", + "board_model": "Fixture ESP32", + "module_marking": "fixture-module", + "device_flash_capacity_bytes": spec["flash_size_bytes"], + "device_psram_capacity_bytes": spec["psram"]["size_bytes"], + "tested_at": "2026-09-02T12:00:00Z", + "operator": "Fixture Operator", + "maintainer_signoff": "Fixture Maintainer", + "desktop_os": "FixtureOS 1", + "chromium_version": "Chrome 151", + "ble_backend": "Fixture BLE 1", + "ble_adapter": "Fixture Adapter 1", + "python_version": "3.13.5", + "redacted_console_log": "private values removed", + "checks": {name: "passed" for name in OPERATOR_CHECKS}, + "app_hil": { + platform: { + "app_version": "0.2.0", + "app_build": "6", + "os_major": "12" if platform == "android" else "26", + "status": "passed", + } + for platform in ("ipad", "android") + }, + } + + class ReleaseEvidenceAssemblySeamTests(unittest.TestCase): def test_exact_production_apis_exist(self) -> None: self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) @@ -273,6 +429,171 @@ def test_exact_cli_commands_exist_without_manual_identity_fields(self) -> None: self.assertNotIn(token, completed.stdout) +class V061CompletionAndAssemblyRedTests(unittest.TestCase): + def test_completion_validator_is_version_routed_and_derives_hardening(self): + validator = getattr(RELEASE, "_validate_v5_completion_fragment", None) + self.assertTrue(callable(validator)) + self.assertEqual( + set(inspect.signature(validator).parameters), + { + "value", + "profile_id", + "firmware_version", + "observation", + "gate_summary", + "v061_hardening_summary", + }, + ) + + completion = getattr(RELEASE, "create_hil_completion_fragment", None) + self.assertTrue(callable(completion)) + self.assertIn( + "v061_hardening_result", + inspect.signature(completion).parameters, + ) + + observation = {"fixture": "candidate-bound-observation"} + historical = { + "profile_id": "esp32-4mb", + **v5_operator_input(), + "profile_gate_summary": None, + "oi1_observation": observation, + } + self.assertEqual( + validator( + historical, + profile_id="esp32-4mb", + firmware_version="0.6.0", + observation=observation, + gate_summary=None, + v061_hardening_summary=None, + ), + historical, + ) + invalid_historical = copy.deepcopy(historical) + invalid_historical["checks"]["v061_hardening"] = "passed" + invalid_historical["v061_hardening"] = v061_public_summary(0) + with self.assertRaises(RELEASE.ReleaseError): + validator( + invalid_historical, + profile_id="esp32-4mb", + firmware_version="0.6.0", + observation=observation, + gate_summary=None, + v061_hardening_summary=None, + ) + + summary = v061_public_summary(0) + current = copy.deepcopy(historical) + current["checks"]["v061_hardening"] = "passed" + current["v061_hardening"] = copy.deepcopy(summary) + self.assertEqual( + validator( + current, + profile_id="esp32-4mb", + firmware_version="0.6.1", + observation=observation, + gate_summary=None, + v061_hardening_summary=summary, + ), + current, + ) + for mutation in ("missing-summary", "pending-check", "wrong-digest"): + with self.subTest(mutation=mutation): + changed = copy.deepcopy(current) + if mutation == "missing-summary": + changed.pop("v061_hardening") + elif mutation == "pending-check": + changed["checks"]["v061_hardening"] = "pending" + else: + changed["v061_hardening"]["private_result_sha256"] = "9" * 64 + with self.assertRaises(RELEASE.ReleaseError): + validator( + changed, + profile_id="esp32-4mb", + firmware_version="0.6.1", + observation=observation, + gate_summary=None, + v061_hardening_summary=summary, + ) + + def test_operator_input_cannot_author_hardening_check_or_summary(self): + profile_id = "esp32-4mb" + value = v5_operator_input(profile_id) + self.assertEqual( + RELEASE._validate_v5_operator_input(value, profile_id), + value, + ) + for field in ("v061_hardening", "private_result_sha256"): + with self.subTest(field=field): + authored = copy.deepcopy(value) + authored[field] = v061_public_summary(0) + with self.assertRaises(RELEASE.ReleaseError): + RELEASE._validate_v5_operator_input(authored, profile_id) + authored_check = copy.deepcopy(value) + authored_check["checks"]["v061_hardening"] = "passed" + with self.assertRaises(RELEASE.ReleaseError): + RELEASE._validate_v5_operator_input(authored_check, profile_id) + + def test_assembly_envelope_requires_five_distinct_exact_summaries(self): + candidate, completed = v061_promotion_payloads() + RELEASE._validate_hil_promotion_envelope(candidate, completed) + + mutations = { + "missing": lambda payload: payload["records"][0].__setitem__( + "v061_hardening", None + ), + "pending-check": lambda payload: payload["records"][0][ + "checks" + ].__setitem__("v061_hardening", "pending"), + "duplicate-private-result": lambda payload: payload["records"][1][ + "v061_hardening" + ].__setitem__( + "private_result_sha256", + payload["records"][0]["v061_hardening"][ + "private_result_sha256" + ], + ), + "runs": lambda payload: payload["records"][0][ + "v061_hardening" + ].__setitem__("sequential_runs", 49), + "scenario-order": lambda payload: payload["records"][0][ + "v061_hardening" + ]["scenario_order"].reverse(), + "workspace-not-run": lambda payload: payload["records"][0][ + "v061_hardening" + ]["workspace_provisioning"].__setitem__( + v061_fixture.WORKSPACE_ORDER[0], "NOT-RUN" + ), + "extra": lambda payload: payload["records"][0][ + "v061_hardening" + ].__setitem__("raw_log_sha256", "9" * 64), + } + for name, mutate in mutations.items(): + with self.subTest(case=name): + invalid = copy.deepcopy(completed) + mutate(invalid) + with self.assertRaises(RELEASE.ReleaseError): + RELEASE._validate_hil_promotion_envelope(candidate, invalid) + + def test_v060_envelope_rejects_the_v061_extension(self): + candidate, completed = v061_promotion_payloads() + for payload in (candidate, completed): + for record in payload["records"]: + record["firmware_version"] = "0.6.0" + record["tag"] = "firmware-v0.6.0" + record.pop("v061_hardening") + record["checks"].pop("v061_hardening") + RELEASE._validate_hil_promotion_envelope(candidate, completed) + + candidate["records"][0]["v061_hardening"] = None + candidate["records"][0]["checks"]["v061_hardening"] = "pending" + completed["records"][0]["v061_hardening"] = v061_public_summary(0) + completed["records"][0]["checks"]["v061_hardening"] = "passed" + with self.assertRaises(RELEASE.ReleaseError): + RELEASE._validate_hil_promotion_envelope(candidate, completed) + + @unittest.skipUnless( HAVE_BASELINE_ASSEMBLER, "[red] assemble_oi1_baseline is not implemented", diff --git a/tests/firmware_tests/host/test_release_finalization.py b/tests/firmware_tests/host/test_release_finalization.py index ac394d40..cae1014a 100644 --- a/tests/firmware_tests/host/test_release_finalization.py +++ b/tests/firmware_tests/host/test_release_finalization.py @@ -23,6 +23,7 @@ # waveshare_lcd147b_qualification_result: Path, # esp32_c3_qualification_result: Path, # rpi_pico2_w_qualification_result: Path, +# v061_hardening_result_paths: list[Path] | None, # ) -> Path # # firmware/scripts/release_bundle.py finalize-public @@ -55,6 +56,8 @@ import unittest from unittest import mock +import test_v061_hardening_release_gate as v061_fixture + REPO_ROOT = Path(__file__).resolve().parents[3] RELEASE_SCRIPT = REPO_ROOT / "firmware" / "scripts" / "release_bundle.py" @@ -130,6 +133,11 @@ def load_module(name: str, path: Path): HAVE_FINALIZER = HAVE_RELEASE and callable( getattr(RELEASE, "finalize_public_bundle", None) ) +HAVE_V061_RESULT_SET = ( + HAVE_RELEASE + and v061_fixture.HAVE_GATE + and callable(getattr(RELEASE, "_validate_v061_hardening_result_set", None)) +) def sha256_path(path: Path) -> str: @@ -556,6 +564,15 @@ def write_v060_private_result( async def combined_result_for_firmware(firmware: Path) -> dict: """Generate private finalization evidence through the real HIL runner.""" + # This fixture exercises immutable v0.6.0 finalization semantics even when + # the source checkout has advanced. Keep its synthetic live observations + # bound to that historical candidate instead of inheriting the current + # versions.lock through the imported HIL modules. + historical_version = "0.6.0" + historical_caps = COMBINED_TEST.CAPS.replace( + ("agent=%s\n" % COMBINED_TEST.SELECTED_AGENT_VERSION).encode("ascii"), + ("agent=%s\n" % historical_version).encode("ascii"), + ) payload = firmware.read_bytes() size_bytes = len(payload) spans = [ @@ -575,16 +592,22 @@ async def combined_result_for_firmware(firmware: Path) -> dict: COMBINED_TEST.CombinedFakeCentral( "candidate/setup-disabled", live_candidate_sha256=attestation["sha256"], + hello_payload=historical_caps, ), - COMBINED_TEST.CombinedFakeCentral("setup-disabled/setup-enabled"), COMBINED_TEST.CombinedFakeCentral( - "setup-enabled/exercise/cycle-1-arm" + "setup-disabled/setup-enabled", hello_payload=historical_caps), + COMBINED_TEST.CombinedFakeCentral( + "setup-enabled/exercise/cycle-1-arm", + hello_payload=historical_caps, ), COMBINED_TEST.CombinedFakeCentral( - "cycle-1/final-disable/cycle-2-arm" + "cycle-1/final-disable/cycle-2-arm", + hello_payload=historical_caps, ), - COMBINED_TEST.CombinedFakeCentral("cycle-2/cycle-3-arm"), - COMBINED_TEST.CombinedFakeCentral("cycle-3/final-proof"), + COMBINED_TEST.CombinedFakeCentral( + "cycle-2/cycle-3-arm", hello_payload=historical_caps), + COMBINED_TEST.CombinedFakeCentral( + "cycle-3/final-proof", hello_payload=historical_caps), ] connector = COMBINED_TEST.CombinedFakeConnector(connections) @@ -595,20 +618,33 @@ async def confirm_tft(_pattern): connector.last.visual_confirmed = True return True - return await COMBINED_TEST.bench.run_combined_qualification( - connector, - "private-input-only", - COMBINED_TEST.preflight(), - hashlib.sha256(payload).hexdigest(), - size_bytes, - attestation, - timeout_s=2.0, - poll_interval_s=0, - production_app_probe=COMBINED_TEST.production_app_evidence, - confirm_splash=confirm_splash, - confirm_tft=confirm_tft, - session_id="34" * 16, - ) + with ( + mock.patch.object(COMBINED_TEST, "CAPS", historical_caps), + mock.patch.object( + COMBINED_TEST.bench, + "EXPECTED_FIRMWARE_VERSION", + historical_version, + ), + mock.patch.object( + COMBINED_TEST.tft_bench, + "EXPECTED_AGENT_VERSION", + historical_version, + ), + ): + return await COMBINED_TEST.bench.run_combined_qualification( + connector, + "private-input-only", + COMBINED_TEST.preflight(), + hashlib.sha256(payload).hexdigest(), + size_bytes, + attestation, + timeout_s=2.0, + poll_interval_s=0, + production_app_probe=COMBINED_TEST.production_app_evidence, + confirm_splash=confirm_splash, + confirm_tft=confirm_tft, + session_id="34" * 16, + ) def refresh_candidate_hashes(candidate: Path) -> None: @@ -984,6 +1020,7 @@ def test_finalize_public_api_exists_with_exact_keyword_contract(self): "waveshare_lcd147b_qualification_result", "esp32_c3_qualification_result", "rpi_pico2_w_qualification_result", + "v061_hardening_result_paths", }, ) @@ -996,6 +1033,136 @@ def test_finalize_public_cli_command_exists(self): ) self.assertEqual(completed.returncode, 0, completed.stderr) self.assertIn("finalize-public", completed.stdout) + command_help = subprocess.run( + [ + sys.executable, + os.fspath(RELEASE_SCRIPT), + "finalize-public", + "--help", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(command_help.returncode, 0, command_help.stderr) + self.assertIn("--v061-hardening-result", command_help.stdout) + + def test_v061_finalization_reopens_exactly_five_private_results_twice(self): + helper = getattr(RELEASE, "_validate_v061_hardening_result_set", None) + self.assertTrue( + callable(helper), + "[red] finalization needs one exact five-result hardening authority", + ) + if callable(helper): + self.assertEqual( + set(inspect.signature(helper).parameters), + { + "result_paths", + "candidate_dir", + "completed_hil", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + }, + ) + + source = inspect.getsource(RELEASE.finalize_public_bundle) + self.assertIn("v061_hardening_result_paths", source) + self.assertIn("_validate_v061_hardening_result_set", source) + + +@unittest.skipUnless( + HAVE_V061_RESULT_SET, + "[red] v0.6.1 five-result finalization gate is not implemented", +) +class V061HardeningResultSetRedTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-finalization-results-" + ) + self.root = Path(self.temporary.name) + self.candidate = self.root / "candidate" + self.candidate.mkdir() + self.paths = [] + self.completed = {"records": []} + for index, (profile_id, _target) in enumerate( + v061_fixture.PROFILE_TARGETS.items() + ): + artifact_name = ( + "firmware.uf2" if profile_id == PICO_PROFILE_ID else "firmware.bin" + ) + artifact = self.candidate / profile_id / artifact_name + artifact.parent.mkdir() + artifact.write_bytes(("candidate-%s" % profile_id).encode("ascii")) + value = v061_fixture.valid_result(profile_id=profile_id) + value["install_sha256"] = sha256_path(artifact) + path = self.root / ("%02d-%s.json" % (index, profile_id)) + v061_fixture.attach_result_workspace(path, value, artifact.read_bytes()) + path.write_bytes(v061_fixture.canonical_json_bytes(value)) + path.chmod(0o600) + self.paths.append(path) + self.completed["records"].append( + { + "profile_id": profile_id, + "v061_hardening": { + "measurement_contract": ( + "v061-hardening-seven-scenario-v1" + ), + "scenario_order": list(v061_fixture.SCENARIO_ORDER), + "scenarios": { + name: "passed" + for name in v061_fixture.SCENARIO_ORDER + }, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" + for name in v061_fixture.WORKSPACE_ORDER + }, + "private_result_sha256": sha256_path(path), + }, + } + ) + + def tearDown(self): + self.temporary.cleanup() + + def validate(self, paths=None): + return RELEASE._validate_v061_hardening_result_set( + result_paths=self.paths if paths is None else paths, + candidate_dir=self.candidate, + completed_hil=self.completed, + firmware_version="0.6.1", + source_commit="1" * 40, + candidate_release_json_sha256="2" * 64, + qualification_source_commit="4" * 40, + qualification_executable_sha256="5" * 64, + ) + + def test_exact_profile_order_is_accepted(self): + self.assertIsNotNone(self.validate()) + + def test_missing_extra_duplicate_or_swapped_result_is_rejected(self): + cases = { + "missing": self.paths[:-1], + "extra": [*self.paths, self.paths[0]], + "duplicate": [self.paths[0], self.paths[0], *self.paths[2:]], + "swapped": [self.paths[1], self.paths[0], *self.paths[2:]], + "reordered": list(reversed(self.paths)), + } + for name, paths in cases.items(): + with self.subTest(case=name), self.assertRaises(RELEASE.ReleaseError): + self.validate(paths) + + def test_post_validation_canonical_mutation_no_longer_matches_report(self): + self.validate() + value = json.loads(self.paths[0].read_text(encoding="utf-8")) + value["raw_log_sha256"] = "9" * 64 + self.paths[0].write_bytes(v061_fixture.canonical_json_bytes(value)) + self.paths[0].chmod(0o600) + with self.assertRaises(RELEASE.ReleaseError): + self.validate() @unittest.skipUnless( diff --git a/tests/firmware_tests/host/test_release_frozen_source_paths.py b/tests/firmware_tests/host/test_release_frozen_source_paths.py new file mode 100644 index 00000000..82532652 --- /dev/null +++ b/tests/firmware_tests/host/test_release_frozen_source_paths.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# [red] Frozen destinations and generated BOARD_DIR source paths are distinct. + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import unittest + + +FIXTURE_PATH = Path(__file__).with_name( + "test_release_license_policy_v2_integration.py" +) +SPEC = importlib.util.spec_from_file_location( + "pyble_frozen_source_path_fixtures", FIXTURE_PATH +) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("cannot load frozen reconstruction fixtures") +FIXTURES = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIXTURES) +RELEASE = FIXTURES.RELEASE +BASE = FIXTURES.BASE + + +class CanonicalPybleFixture(FIXTURES.ObservationV2Fixture): + """Real retained board layout with deterministic synthetic compiler tools. + + The observer, manifest resolver, descriptor snapshot, byte comparisons and + reconstruction subprocesses are production code, not patched test models. + Only the compiler inputs/tools are the existing explicitly synthetic ones. + """ + + def _install_frozen_proof_fixture(self): + super()._install_frozen_proof_fixture() + canonical = self.firmware / "pyble" + canonical.mkdir() + (canonical / "pyble_workspace.py").write_text( + "# SPDX-License-Identifier: MIT\nWORKSPACE = 'canonical'\n", + encoding="utf-8", + ) + (canonical / "pyble_agent.py").write_text( + "# SPDX-License-Identifier: MIT\n", encoding="utf-8" + ) + # Deliberately unlike the generated lock-derived copy: comparing this + # placeholder instead of the lock output must not become acceptable. + (canonical / "_version.py").write_text( + "# SPDX-License-Identifier: MIT\nAGENT_VERSION = 'placeholder'\n", + encoding="utf-8", + ) + generator = ( + self.firmware / "upstream/micropython/tools/makemanifest.py" + ) + text = generator.read_text(encoding="utf-8") + marker = 'manifest_bytes = b"".join(\n' + if text.count(marker) != 1: + raise AssertionError("synthetic generator insertion point changed") + generator.write_text( + text.replace( + marker, + 'sources += (\n' + ' ("pyble_workspace.py", board / "pyble/pyble_workspace.py"),\n' + ' ("_version.py", board / "pyble/_version.py"),\n' + ')\n' + marker, + ), + encoding="utf-8", + ) + for _profile_id, target, _idf_target in FIXTURES.PROFILE_TARGETS: + overlay = self.firmware / "board_overlays" / target + # This selects firmware/pyble through the production resolver's + # canonical fallback, not a same-named overlay source. + (overlay / "pyble/pyble_agent.py").unlink() + manifest = overlay / "manifest.py" + manifest.write_text( + manifest.read_text(encoding="utf-8") + + 'module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble", opt=3)\n' + + 'module("_version.py", base_path="$(BOARD_DIR)/pyble", opt=3)\n', + encoding="utf-8", + ) + board = ( + self.firmware + / "upstream/micropython/ports/esp32/boards" + / RELEASE.FROZEN_TARGET_SETTINGS[target]["board"] + ) + (board / "manifest.py").write_bytes(manifest.read_bytes()) + (board / "pyble/pyble_workspace.py").write_bytes( + (canonical / "pyble_workspace.py").read_bytes() + ) + (board / "pyble/_version.py").write_bytes( + b"# SPDX-License-Identifier: MIT\n" + b"# GENERATED by firmware/scripts/build.sh from " + b"firmware/versions.lock [pyble] \xe2\x80\x94 do not edit.\n" + b'AGENT_VERSION = "0.5.0"\n' + b'PROTOCOL_VERSION = "PBLE/1"\n' + ) + self.regenerate_frozen_payload(target) + + +class FrozenSourcePathTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixture = CanonicalPybleFixture() + + @classmethod + def tearDownClass(cls): + cls.fixture.close() + + def setUp(self): + self.target = "esp32" + self.manifest = ( + self.fixture.firmware / "board_overlays/esp32/manifest.py" + ) + self.board = ( + self.fixture.build_root + / ".sources/esp32/micropython/ports/esp32/boards" + / RELEASE.FROZEN_TARGET_SETTINGS[self.target]["board"] + ) + self.workspace = self.board / "pyble/pyble_workspace.py" + self.canonical = self.fixture.firmware / "pyble/pyble_workspace.py" + + def observe(self): + # Same production observer called by the all-profile license collector; + # pass the independently known synthetic compiler hash to avoid an + # unrelated repeated compiler rebuild, not a frozen-proof mock. + compiler = ( + self.fixture.firmware + / "upstream/micropython/mpy-cross/build/mpy-cross" + ) + return RELEASE._audit_manifest_evidence_record( + self.manifest, + self.fixture.build_root / self.target / "frozen_content.c", + repo_root=self.fixture.repo, + target=self.target, + trusted_mpy_cross_sha256=BASE.sha256_path(compiler), + ) + + def test_flat_workspace_and_prefixed_package_reach_full_observer(self): + context = RELEASE._audit_observe_policy_v2_context( + self.fixture.policy, + repo_root=self.fixture.repo, + build_root=self.fixture.build_root, + ) + self.assertEqual(len(context["manifest_evidence"]), 4) + for record in context["manifest_evidence"]: + selected = { + item["destination"]: item for item in record["selections"] + } + workspace = selected["pyble_workspace.py"] + self.assertEqual( + workspace["source_path"], "firmware/pyble/pyble_workspace.py" + ) + self.assertEqual(workspace["sha256"], BASE.sha256_path(self.canonical)) + self.assertEqual(workspace["optimization"], 3) + self.assertEqual( + selected["pyble/pyble_agent.py"]["source_path"], + "firmware/pyble/pyble_agent.py", + ) + frozen = {item["destination"] for item in record["frozen_mpy"]} + self.assertIn("pyble_workspace.mpy", frozen) + self.assertIn("pyble/pyble_agent.mpy", frozen) + self.assertNotIn("pyble/pyble_workspace.mpy", frozen) + self.assertNotIn("pyble/pyble/pyble_agent.mpy", frozen) + self.assertFalse((self.board / "pyble_workspace.py").exists()) + self.assertFalse((self.board / "pyble/pyble").exists()) + + def test_missing_nested_workspace_is_rejected(self): + with BASE.removed_file(self.workspace): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*missing"): + self.observe() + + def test_matching_flat_decoy_cannot_replace_missing_nested_source(self): + decoy = self.board / "pyble_workspace.py" + decoy.write_bytes(self.canonical.read_bytes()) + try: + with BASE.removed_file(self.workspace): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*missing"): + self.observe() + finally: + decoy.unlink() + + def test_matching_flat_decoy_cannot_hide_stale_nested_source(self): + decoy = self.board / "pyble_workspace.py" + decoy.write_bytes(self.canonical.read_bytes()) + try: + with BASE.patched_bytes(self.workspace, b"# stale nested bytes\n"): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): + self.observe() + finally: + decoy.unlink() + + def test_nested_workspace_file_symlink_is_rejected(self): + with BASE.removed_file(self.workspace): + self.workspace.symlink_to(self.canonical) + try: + with self.assertRaisesRegex(RELEASE.ReleaseError, "symlink"): + self.observe() + finally: + self.workspace.unlink() + + def test_nested_pyble_directory_symlink_is_rejected(self): + nested = self.board / "pyble" + backup = self.board.parent / "pyble-path-test-backup" + nested.rename(backup) + try: + nested.symlink_to(backup, target_is_directory=True) + with self.assertRaisesRegex(RELEASE.ReleaseError, "symlink"): + self.observe() + finally: + nested.unlink() + backup.rename(nested) + + def test_changed_canonical_workspace_is_rejected(self): + with BASE.patched_bytes(self.canonical, b"# changed canonical bytes\n"): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): + self.observe() + + def test_already_prefixed_package_copy_is_still_byte_checked(self): + with BASE.patched_bytes( + self.board / "pyble/pyble_agent.py", b"# stale package\n" + ): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): + self.observe() + + def test_lock_version_exception_does_not_compare_placeholder(self): + version = self.board / "pyble/_version.py" + placeholder = self.fixture.firmware / "pyble/_version.py" + self.assertNotEqual(version.read_bytes(), placeholder.read_bytes()) + self.observe() + with BASE.patched_bytes(version, placeholder.read_bytes()): + with self.assertRaisesRegex(RELEASE.ReleaseError, "lock-derived.*stale"): + self.observe() + + def test_overlay_copy_keeps_its_existing_byte_check(self): + with BASE.patched_bytes(self.board / "_boot.py", b"# stale boot\n"): + with self.assertRaisesRegex(RELEASE.ReleaseError, "source.*stale"): + self.observe() + + def test_duplicate_destination_remains_rejected(self): + original = self.manifest.read_text(encoding="utf-8") + with BASE.patched_text( + self.manifest, + original + + 'module("pyble_workspace.py", base_path="$(BOARD_DIR)/pyble")\n', + ): + with self.assertRaisesRegex(RELEASE.ReleaseError, "duplicates destination"): + self.observe() + + def test_historical_overlay_package_layout_still_reconstructs(self): + historical = FIXTURES.ObservationV2Fixture() + try: + context = RELEASE._audit_observe_policy_v2_context( + historical.policy, + repo_root=historical.repo, + build_root=historical.build_root, + ) + for record in context["manifest_evidence"]: + selected = { + item["destination"]: item for item in record["selections"] + } + self.assertNotIn("pyble_workspace.py", selected) + self.assertEqual( + selected["pyble/pyble_agent.py"]["source_path"], + "firmware/board_overlays/%s/pyble/pyble_agent.py" + % record["target"], + ) + finally: + historical.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_release_hardening.py b/tests/firmware_tests/host/test_release_hardening.py index aa944feb..6691b6db 100644 --- a/tests/firmware_tests/host/test_release_hardening.py +++ b/tests/firmware_tests/host/test_release_hardening.py @@ -532,7 +532,13 @@ def __init__(self): ) pyble = self.firmware / "pyble" pyble.mkdir() - for name in ("__init__.py", "_version.py", "pyble_ble.py", "pyble_proto.py"): + for name in ( + "__init__.py", + "_version.py", + "pyble_ble.py", + "pyble_proto.py", + "pyble_workspace.py", + ): (pyble / name).write_text( "# synthetic reviewed ESP input\n", encoding="utf-8" ) diff --git a/tests/firmware_tests/host/test_release_license_audit.py b/tests/firmware_tests/host/test_release_license_audit.py index 4a477135..128d536a 100644 --- a/tests/firmware_tests/host/test_release_license_audit.py +++ b/tests/firmware_tests/host/test_release_license_audit.py @@ -4872,7 +4872,6 @@ class RealFormatLicenseAuditRegressionTests(unittest.TestCase): def test_pinned_literal_manifests_resolve_exact_frozen_destinations(self): expected = { "flashbdev.py", - "inisetup.py", "asyncio/__init__.py", "asyncio/core.py", "asyncio/event.py", @@ -4886,6 +4885,7 @@ def test_pinned_literal_manifests_resolve_exact_frozen_destinations(self): "pyble/__init__.py", "pyble/pyble_ble.py", "pyble/pyble_proto.py", + "pyble_workspace.py", } with tempfile.TemporaryDirectory(prefix="pyble-real-manifest-red-") as raw: frozen = Path(raw) / "frozen_content.c" diff --git a/tests/firmware_tests/host/test_release_license_preflight_regressions.py b/tests/firmware_tests/host/test_release_license_preflight_regressions.py index 33d1978e..4fa20d59 100644 --- a/tests/firmware_tests/host/test_release_license_preflight_regressions.py +++ b/tests/firmware_tests/host/test_release_license_preflight_regressions.py @@ -37,6 +37,7 @@ def load_release_module(): RELEASE, RELEASE_LOAD_ERROR = load_release_module() EXPECTED_PYBLE_C_SOURCES = ( + "pble_wire.c", "pble_proto.c", "pble_ble.c", "pble_info.c", @@ -116,7 +117,7 @@ def test_exact_compiled_pyble_source_inventory_includes_lifecycle_files( self.assertEqual( RELEASE._AUDIT_PYBLE_C_SOURCES, EXPECTED_PYBLE_C_SOURCES, - "the audit must freeze the same eleven first-party C sources as " + "the audit must freeze the same twelve first-party C sources as " "micropython.cmake", ) source_root = REPO_ROOT / "firmware/user_c_modules/pyble" diff --git a/tests/firmware_tests/host/test_release_v060_audit_closure.py b/tests/firmware_tests/host/test_release_v060_audit_closure.py index 52da8183..90c6b01c 100644 --- a/tests/firmware_tests/host/test_release_v060_audit_closure.py +++ b/tests/firmware_tests/host/test_release_v060_audit_closure.py @@ -166,7 +166,9 @@ def test_rebound_unselected_text_and_control_files_do_not_replay_old_receipt( mock.patch.object( RELEASE, "_audit_load_tool_lock", - side_effect=lambda _root: self.fixture.tool_lock(), + side_effect=lambda _root, firmware_version=None: ( + self.fixture.tool_lock() + ), ), mock.patch.object( RELEASE, diff --git a/tests/firmware_tests/host/test_release_v060_contract.py b/tests/firmware_tests/host/test_release_v060_contract.py index aca6c606..e972f52d 100644 --- a/tests/firmware_tests/host/test_release_v060_contract.py +++ b/tests/firmware_tests/host/test_release_v060_contract.py @@ -71,6 +71,9 @@ V060_FIRST_REPLACEMENT_SOURCE_BOUNDARY = ( "7d853289815751c7381c9fd0b9a9a4409bdb6879" ) +V060_INITIAL_SECOND_REPLACEMENT_POLICY_SOURCE = ( + "0c7230d6708797c241160ba71fbd37e6b22f180a" +) QUALIFICATION_DERIVATION_V5 = { "application_image": "exact-byte-identical-two-root-v1", "application_headroom": "factory-minus-application-v1", @@ -540,7 +543,7 @@ def test_unknown_or_unrelated_source_ancestry_fails_closed(self): firmware_version="0.6.0", ) - def test_active_policy_is_exact_fixed_slo_delta_of_superseded_policy(self): + def test_retained_replacement_policies_are_exact_fixed_slo_deltas(self): historical_raw = subprocess.run( [ "git", @@ -562,15 +565,26 @@ def test_active_policy_is_exact_fixed_slo_delta_of_superseded_policy(self): historical = json.loads(historical_raw.decode("utf-8")) self.assertEqual(historical["derivation"], QUALIFICATION_DERIVATION_V3) - active_path = ( - REPO_ROOT / "firmware" / "qualification" / "oi1-gates.json" + second_replacement_raw = subprocess.run( + [ + "git", "-C", str(REPO_ROOT), "show", + "%s:firmware/qualification/oi1-gates.json" + % V060_INITIAL_SECOND_REPLACEMENT_POLICY_SOURCE, + ], + check=True, + capture_output=True, + ).stdout + self.assertEqual(len(second_replacement_raw), 5036) + self.assertEqual( + hashlib.sha256(second_replacement_raw).hexdigest(), + "c3167853df6c31d7364b58616701d45ea62f2374d18cacc89e51292624dca8db", + "the initial second-replacement policy identity must stay auditable", ) - active_raw = active_path.read_bytes() - active = json.loads(active_raw.decode("utf-8")) + second_replacement = json.loads(second_replacement_raw.decode("utf-8")) self.assertEqual( ( json.dumps( - active, + second_replacement, indent=2, sort_keys=True, ensure_ascii=False, @@ -578,10 +592,10 @@ def test_active_policy_is_exact_fixed_slo_delta_of_superseded_policy(self): ) + "\n" ).encode("utf-8"), - active_raw, - "the active policy must stay canonical sorted-key JSON", + second_replacement_raw, + "the retained second-replacement policy must stay canonical sorted-key JSON", ) - self.assertEqual(active["derivation"], QUALIFICATION_DERIVATION_V5) + self.assertEqual(second_replacement["derivation"], QUALIFICATION_DERIVATION_V5) first_replacement_raw = subprocess.run( [ @@ -629,12 +643,12 @@ def test_active_policy_is_exact_fixed_slo_delta_of_superseded_policy(self): "binding must stay byte-identical to the superseded policy", ) - expected_active = copy.deepcopy(first_replacement) - expected_active["derivation"] = copy.deepcopy( + expected_second_replacement = copy.deepcopy(first_replacement) + expected_second_replacement["derivation"] = copy.deepcopy( QUALIFICATION_DERIVATION_V5 ) second_changed_numeric_fields = 0 - for entry in expected_active["profiles"]: + for entry in expected_second_replacement["profiles"]: if entry["profile_id"] == "waveshare-esp32-s3-lcd-147b": self.assertEqual( entry["thresholds"][ @@ -653,12 +667,41 @@ def test_active_policy_is_exact_fixed_slo_delta_of_superseded_policy(self): "the heap-floor identifier is a separate string field", ) self.assertEqual( - active, - expected_active, + second_replacement, + expected_second_replacement, "every other threshold, schema field, and baseline binding " "must stay byte-identical to the first-replacement policy", ) + def test_current_policy_keeps_fixed_slos_with_validated_measured_resources(self): + policy, digest = RELEASE._load_qualification_policy(REPO_ROOT) + payload = (REPO_ROOT / "firmware/qualification/oi1-gates.json").read_bytes() + self.assertEqual(digest, hashlib.sha256(payload).hexdigest()) + self.assertEqual( + payload, + (json.dumps(policy, indent=2, sort_keys=True) + "\n").encode("utf-8"), + ) + self.assertEqual(policy["derivation"], QUALIFICATION_DERIVATION_V5) + self.assertEqual(policy["profile_order"], list(V060_PROFILE_ORDER)) + self.assertEqual( + { + entry["profile_id"]: { + key: entry["thresholds"][key] + for key in FIXED_PERFORMANCE_THRESHOLDS[entry["profile_id"]] + } + for entry in policy["profiles"] + }, + FIXED_PERFORMANCE_THRESHOLDS, + ) + self.assertEqual( + next( + entry["thresholds"]["idf_internal_largest_block_min_bytes"] + for entry in policy["profiles"] + if entry["profile_id"] == "waveshare-esp32-s3-lcd-147b" + ), + V5_FIXED_WAVESHARE_LARGEST_BLOCK_MIN_BYTES, + ) + class V060ReproducibilityRoutingContractTests(unittest.TestCase): @staticmethod diff --git a/tests/firmware_tests/host/test_release_v060_license_generation.py b/tests/firmware_tests/host/test_release_v060_license_generation.py index ba370047..1e42e950 100644 --- a/tests/firmware_tests/host/test_release_v060_license_generation.py +++ b/tests/firmware_tests/host/test_release_v060_license_generation.py @@ -416,6 +416,25 @@ def test_entrypoint_generates_the_exact_canonical_schema2_inventory(self) -> Non ) self.assertEqual(verified, inventory) + def test_v061_generation_rejects_a_v060_lock_without_picotool_evidence(self) -> None: + lock_path = self.fixture.repo / "firmware" / "versions.lock" + lock_text = lock_path.read_text(encoding="utf-8", errors="strict") + self.assertEqual(lock_text.count('agent_version = "0.6.0"'), 1) + lock_path.write_text( + lock_text.replace( + 'agent_version = "0.6.0"', + 'agent_version = "0.6.1"', + ), + encoding="utf-8", + ) + + with self.assertRaises(RELEASE.ReleaseError): + self.fixture.audit(verifier_side_effect=self.semantic_replay) + self.assertFalse( + self.fixture.evidence.exists(), + "a v0.6.1 audit without the pinned picotool disposition published evidence", + ) + def test_exact_esp_review_bytes_survive_cross_process_semantic_replay(self) -> None: self.fixture.audit(verifier_side_effect=self.semantic_replay) expected_path = self.fixture.root / "expected-esp-review-sha256.json" @@ -448,6 +467,7 @@ def verify_exact_esp_bytes(**kwargs): evidence_dir=Path(evidence), build_root=Path(build), repo_root=Path(repo), + firmware_version="0.6.0", ) ''' completed = subprocess.run( diff --git a/tests/firmware_tests/host/test_release_v060_private_gates.py b/tests/firmware_tests/host/test_release_v060_private_gates.py index 5f5cb0ed..37dfd43d 100644 --- a/tests/firmware_tests/host/test_release_v060_private_gates.py +++ b/tests/firmware_tests/host/test_release_v060_private_gates.py @@ -660,19 +660,58 @@ def test_public_summary_revalidation_is_exact_and_private_free(self) -> None: @unittest.skipUnless(RELEASE is not None, RELEASE_ERROR) class V5SummaryBindingTests(unittest.TestCase): - def payload(self) -> dict[str, object]: + def hardening_summary(self, index: int) -> dict[str, object]: + scenarios = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", + ) + workspace = ( + "erased-media-first-boot", + "nonblank-media-refusal", + ) + return { + "measurement_contract": "v061-hardening-seven-scenario-v1", + "scenario_order": list(scenarios), + "scenarios": {name: "passed" for name in scenarios}, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in workspace + }, + "private_result_sha256": "%064x" % (index + 1), + } + + def payload(self, *, firmware_version: str = VERSION) -> dict[str, object]: + records = [ + { + "profile_id": profile_id, + "profile_gate_summary": None, + } + for profile_id in PROFILE_ORDER + ] + if firmware_version == "0.6.1": + for index, record in enumerate(records): + record["checks"] = { + "provisioning_install": "passed", + "provisioning_recovery": "passed", + "advertising_info_hello": "passed", + "pble_workflow": "passed", + "safe_boot_reconnect": "passed", + "filesystem_resume_reliability": "passed", + "footprint_reliability": "passed", + "v061_hardening": "passed", + } + record["v061_hardening"] = self.hardening_summary(index) return { "schema_version": 5, "candidate_release_json_sha256": RELEASE_DIGEST, "qualification_policy_sha256": "1" * 64, "qualification_policy": {"fixture": True}, - "records": [ - { - "profile_id": profile_id, - "profile_gate_summary": None, - } - for profile_id in PROFILE_ORDER - ], + "records": records, "waveshare_lcd147b_qualification": None, "esp32_c3_qualification": None, "rpi_pico2_w_qualification": None, @@ -724,6 +763,44 @@ def test_binder_cross_checks_profile_rows_and_sets_top_summaries_atomically(self for profile_id in PROFILE_ORDER[:3]: self.assertIsNone(by_id[profile_id]["profile_gate_summary"]) + def test_binder_accepts_the_exact_v061_v5_source_version(self) -> None: + payload = self.payload(firmware_version="0.6.1") + waveshare, c3, pico = self.summaries() + by_id = {record["profile_id"]: record for record in payload["records"]} + by_id[C3_PROFILE]["profile_gate_summary"] = copy.deepcopy(c3["gates"]) + by_id[PICO_PROFILE]["profile_gate_summary"] = copy.deepcopy(pico["gates"]) + + bound = self.binder()( + payload, + waveshare_lcd147b_summary=waveshare, + esp32_c3_summary=c3, + rpi_pico2_w_summary=pico, + firmware_version="0.6.1", + ) + + self.assertEqual(bound["waveshare_lcd147b_qualification"], waveshare) + self.assertEqual(bound["esp32_c3_qualification"], c3) + self.assertEqual(bound["rpi_pico2_w_qualification"], pico) + + legacy = self.payload() + legacy_by_id = { + record["profile_id"]: record for record in legacy["records"] + } + legacy_by_id[C3_PROFILE]["profile_gate_summary"] = copy.deepcopy( + c3["gates"] + ) + legacy_by_id[PICO_PROFILE]["profile_gate_summary"] = copy.deepcopy( + pico["gates"] + ) + with self.assertRaises(RELEASE.ReleaseError): + self.binder()( + legacy, + waveshare_lcd147b_summary=waveshare, + esp32_c3_summary=c3, + rpi_pico2_w_summary=pico, + firmware_version="0.6.1", + ) + def test_binder_rejects_partial_changed_or_wrong_phase_inputs(self) -> None: binder = self.binder() waveshare, c3, pico = self.summaries() @@ -758,7 +835,10 @@ def test_binder_rejects_partial_changed_or_wrong_phase_inputs(self) -> None: wrong_schema = self.payload() wrong_schema["schema_version"] = 4 cases["wrong-schema"] = (wrong_schema, waveshare, c3, pico, VERSION) - cases["wrong-version"] = (self.payload(), waveshare, c3, pico, "0.6.1") + cases["pre-v5-version"] = (self.payload(), waveshare, c3, pico, "0.5.1") + cases["unapproved-future-version"] = ( + self.payload(), waveshare, c3, pico, "0.7.0" + ) missing_record = self.payload() missing_record["records"] = missing_record["records"][:-1] cases["missing-record"] = (missing_record, waveshare, c3, pico, VERSION) diff --git a/tests/firmware_tests/host/test_release_v060_promotion_lifecycle.py b/tests/firmware_tests/host/test_release_v060_promotion_lifecycle.py index 5904242e..8b227c71 100644 --- a/tests/firmware_tests/host/test_release_v060_promotion_lifecycle.py +++ b/tests/firmware_tests/host/test_release_v060_promotion_lifecycle.py @@ -72,7 +72,7 @@ def build_measurement(profile_id: str) -> dict[str, int]: } -def pending_v5_payload() -> dict[str, object]: +def pending_v5_payload(version: str = "0.6.0") -> dict[str, object]: policy = lifecycle_fixture.schema3_policy() policy_by_id = {item["profile_id"]: item for item in policy["profiles"]} profiles = { @@ -96,8 +96,8 @@ def pending_v5_payload() -> dict[str, object]: "module_marking": "", "device_flash_capacity_bytes": 0, "device_psram_capacity_bytes": 0, - "firmware_version": "0.6.0", - "tag": "firmware-v0.6.0", + "firmware_version": version, + "tag": "firmware-v%s" % version, "source_commit": "1" * 40, "install_sha256": profile["install"]["sha256"], "tested_at": "", @@ -118,6 +118,9 @@ def pending_v5_payload() -> dict[str, object]: } if not is_rp2: record["manifest_sha256"] = profile["manifest"]["sha256"] + if version == "0.6.1": + record["checks"]["v061_hardening"] = "pending" + record["v061_hardening"] = None records.append(record) return { "schema_version": 5, @@ -254,7 +257,7 @@ def provision_qualification_root(root: Path) -> None: """Provision a hermetic qualification root the completion writer accepts. The writer reads firmware/versions.lock, the committed OI-1 policy, and - the retained a8be631d baseline under the qualification root + the exact retained baseline bound by that policy under the qualification root (release_bundle._load_qualification_policy), and proves the candidate's source-era ancestry with git against that same root. Copy the REAL files so the fixture exercises the production parse and threshold verification, @@ -273,13 +276,10 @@ def provision_qualification_root(root: Path) -> None: (gates_dir / "oi1-gates.json").write_bytes( (repo / "firmware" / "qualification" / "oi1-gates.json").read_bytes() ) - baseline_rel = Path("docs") / "validation" / "firmware" / "oi1" - baseline_dir = root / baseline_rel - baseline_dir.mkdir(parents=True) - baseline_name = "a8be631df46590166307aa41afaea30b39e29230.json" - (baseline_dir / baseline_name).write_bytes( - (repo / baseline_rel / baseline_name).read_bytes() - ) + policy, _ = RELEASE._load_qualification_policy(repo) + baseline_rel = Path(policy["baseline_evidence"]["path"]) + (root / baseline_rel).parent.mkdir(parents=True) + (root / baseline_rel).write_bytes((repo / baseline_rel).read_bytes()) (root / ".git").write_text( "gitdir: %s\n" % _repo_git("rev-parse", "--absolute-git-dir"), encoding="utf-8", @@ -288,6 +288,23 @@ def provision_qualification_root(root: Path) -> None: @unittest.skipUnless(HAVE_RELEASE, RELEASE_LOAD_ERROR) class V5CompletionAndPromotionContractTests(unittest.TestCase): + def test_qualification_fixture_preserves_committed_policy_baseline_closure(self) -> None: + """ADR-0038/0039: retain actual policy inputs, not an old active path.""" + + repo = Path(_support.REPO_ROOT) + expected_policy, expected_digest = RELEASE._load_qualification_policy(repo) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + provision_qualification_root(root) + actual_policy, actual_digest = RELEASE._load_qualification_policy(root) + self.assertEqual(actual_policy, expected_policy) + self.assertEqual(actual_digest, expected_digest) + baseline_rel = Path(expected_policy["baseline_evidence"]["path"]) + self.assertEqual( + (root / baseline_rel).read_bytes(), + (repo / baseline_rel).read_bytes(), + ) + def test_completion_writer_derives_profile_and_gate_fields(self) -> None: pending = pending_v5_payload() release = { @@ -370,6 +387,50 @@ def test_completion_writer_derives_profile_and_gate_fields(self) -> None: ) self.assertEqual(payload, json.loads(output.read_text(encoding="utf-8"))) + def test_completion_writer_requires_v061_hardening_without_v060_substitution( + self, + ) -> None: + pending = pending_v5_payload("0.6.1") + release = { + "identity": {"version": "0.6.1", "tag": "firmware-v0.6.1"}, + "provenance": {"pyble": {"commit": real_source_commit()}}, + "profiles": lifecycle_fixture.pending_profiles(), + } + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + candidate = root / "candidate" + candidate.mkdir() + (candidate / "release.json").write_bytes(b"{}\n") + (candidate / "HIL_REPORT.md").write_text( + hil_report(pending), encoding="utf-8" + ) + provision_qualification_root(root) + profile_id = "esp32-4mb" + operator_path = root / "operator.json" + observation_path = root / "observation.json" + output = root / "completion.json" + write_json(operator_path, operator_input(profile_id)) + write_json(observation_path, {"fixture": profile_id}) + + with mock.patch.object( + RELEASE, "validate_bundle", return_value=release + ), mock.patch.object( + RELEASE, + "_validate_qualification_observation", + side_effect=lambda value, *_args, **_kwargs: value, + ), self.assertRaises(RELEASE.ReleaseError): + RELEASE.create_hil_completion_fragment( + candidate_dir=candidate, + profile_id=profile_id, + operator_input_path=operator_path, + oi1_observation_path=observation_path, + output_path=output, + qualification_repo_root=root, + profile_qualification_result=None, + ) + + self.assertFalse(output.exists()) + def test_completion_writer_rejects_operator_gate_fields_or_wrong_private_phase( self, ) -> None: diff --git a/tests/firmware_tests/host/test_rp2_arm_runtime_closure.py b/tests/firmware_tests/host/test_rp2_arm_runtime_closure.py index 6f395fbb..98e56c6c 100644 --- a/tests/firmware_tests/host/test_rp2_arm_runtime_closure.py +++ b/tests/firmware_tests/host/test_rp2_arm_runtime_closure.py @@ -789,5 +789,144 @@ def test_real_observer_binds_builder_scrub_and_checks_every_recipe(self) -> None ) +class Rp2BuildEnvironmentParserTests(unittest.TestCase): + """Parse real driver bytes; never execute the shell or a compiler.""" + + MARKER = ( + "# Never allow ambient compiler/make flags to influence the pinned build.\n" + "unset \\\n" + ) + END = '\n\nmake -C "$RETAINED_UPSTREAM/mpy-cross"' + SETUP = "# Bind Pico SDK's CMake configure to the verified package" + PACKAGE_VARIABLES = ( + "CMAKE_ARGS", "picotool_DIR", "FETCHCONTENT_FULLY_DISCONNECTED", + "FETCHCONTENT_SOURCE_DIR_PICOTOOL", "PICOTOOL_FETCH_FROM_GIT_PATH", + "PICOTOOL_FORCE_FETCH_FROM_GIT", "CMAKE_FIND_USE_PACKAGE_REGISTRY", + "CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY", "CMAKE_PREFIX_PATH", + ) + + def setUp(self) -> None: + self.driver = BUILD_RP2.read_text(encoding="utf-8") + + def parse(self, source: str, version: str = "0.6.1") -> list[str]: + parser = getattr(RELEASE, "_audit_rp2_build_environment", None) + self.assertTrue(callable(parser), "missing real RP2 environment parser") + return parser(source, firmware_version=version) + + def test_checked_in_driver_accepts_one_unset_and_exact_offline_setup(self) -> None: + variables = self.parse(self.driver) + self.assertIn("picotool_DIR", variables) + self.assertEqual(len(variables), len(set(variables))) + for variable in (*RELEASE._RP2_GCC_ENVIRONMENT_OVERRIDES, *self.PACKAGE_VARIABLES): + with self.subTest(variable=variable): + self.assertIn(variable, variables) + + def test_actual_observer_reaches_cache_after_current_driver_parse(self) -> None: + class CacheReached(Exception): + pass + + original_read = RELEASE._read_regular_file_bytes + + def read(path, label): + if label == "RP2 CMake cache": + raise CacheReached + return original_read(path, label) + + with tempfile.TemporaryDirectory() as temporary, mock.patch.object( + RELEASE, "_read_regular_file_bytes", side_effect=read + ), self.assertRaises(CacheReached): + target = Path(temporary) / "unopened-target" + RELEASE._audit_observe_rp2_arm_runtime_closure( + repo_root=ROOT, target_root=target, cache=target / "CMakeCache.txt", + mappings={}, link_path=target / "link.txt", map_loads=[], + direct_contributors=set(), archive_members=[], compiler_dependency_closure=[], + ) + + def test_missing_or_duplicate_boundaries_are_rejected(self) -> None: + for marker in (self.MARKER, self.END): + for changed in (self.driver.replace(marker, "", 1), + self.driver.replace(marker, marker + marker, 1)): + with self.subTest(marker=marker, changed=changed), self.assertRaises(RELEASE.ReleaseError): + self.parse(changed) + + def test_every_gcc_and_package_override_must_be_scrubbed(self) -> None: + for variable in (*RELEASE._RP2_GCC_ENVIRONMENT_OVERRIDES, *self.PACKAGE_VARIABLES): + line = " " + variable + ("\n" if variable == "CMAKE_PREFIX_PATH" else " \\\n") + self.assertEqual(self.driver.count(line), 1 if variable not in { + "CMAKE_ARGS", "picotool_DIR", "FETCHCONTENT_FULLY_DISCONNECTED", + "PICOTOOL_FORCE_FETCH_FROM_GIT", "CMAKE_FIND_USE_PACKAGE_REGISTRY" + } else 2) + with self.subTest(variable=variable), self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver.replace(line, "", 1)) + + def test_duplicate_scrub_name_is_rejected_even_if_coverage_is_complete(self) -> None: + line = " GCC_EXEC_PREFIX \\\n" + with self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver.replace(line, line + line, 1)) + + def test_shell_identifier_case_is_preserved_without_shell_syntax(self) -> None: + safe = self.driver.replace(self.MARKER, self.MARKER + " _safe_Mixed9 \\\n", 1) + self.assertIn("_safe_Mixed9", self.parse(safe)) + for variable in ("9BAD", "BAD-NAME", "A=1", "$PATH", "A;echo", "$(id)", ""): + with self.subTest(variable=variable), self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver.replace(" CFLAGS_EXTRA \\\n", " " + variable + " \\\n", 1)) + + def test_continuations_indentation_and_terminal_line_are_exact(self) -> None: + cases = ( + self.driver.replace(" CFLAGS_EXTRA \\\n", " CFLAGS_EXTRA\n", 1), + self.driver.replace(" CFLAGS_EXTRA \\\n", " CFLAGS_EXTRA \\\n", 1), + self.driver.replace(" CFLAGS_EXTRA \\\n", "\tCFLAGS_EXTRA \\\n", 1), + self.driver.replace(" CMAKE_PREFIX_PATH\n", " CMAKE_PREFIX_PATH \\\n", 1), + self.driver.replace(" CFLAGS_EXTRA \\\n", " CFLAGS_EXTRA \\ \n", 1), + ) + for changed in cases: + with self.subTest(changed=changed), self.assertRaises(RELEASE.ReleaseError): + self.parse(changed) + + def test_unknown_intervening_commands_and_reassignments_are_rejected(self) -> None: + for command in ('GCC_EXEC_PREFIX=/tmp/other', 'export CFLAGS=-fplugin=bad', + 'echo injected', 'unset CMAKE_ARGS', ': "$(id)"'): + for changed in ( + self.driver.replace(self.SETUP, command + "\n" + self.SETUP, 1), + self.driver.replace(self.END, "\n" + command + self.END, 1), + ): + with self.subTest(command=command), self.assertRaises(RELEASE.ReleaseError): + self.parse(changed) + + def test_offline_values_and_package_binding_are_exact(self) -> None: + for old, new in ( + ('-Dpicotool_DIR=$PICOTOOL_PACKAGE_DIR', '-Dpicotool_DIR=/tmp/other'), + ('-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON', '-DFETCHCONTENT_FULLY_DISCONNECTED=ON'), + ('-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON', '-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=OFF'), + ('-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF', '-DCMAKE_FIND_USE_PACKAGE_REGISTRY=ON'), + ('-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF', '-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=ON'), + ('-DPICOTOOL_FORCE_FETCH_FROM_GIT=OFF', '-DPICOTOOL_FORCE_FETCH_FROM_GIT=ON'), + ('picotool_DIR="$PICOTOOL_PACKAGE_DIR"', 'picotool_DIR="$(echo bad)"'), + ('FETCHCONTENT_FULLY_DISCONNECTED=ON', 'FETCHCONTENT_FULLY_DISCONNECTED=OFF'), + ): + self.assertIn(old, self.driver) + with self.subTest(old=old), self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver.replace(old, new, 1)) + + def test_offline_export_is_not_optional_or_extensible(self) -> None: + start = self.driver.index("\nexport \\\n", self.driver.index(self.SETUP)) + end = self.driver.index(self.END, start) + export = self.driver[start:end] + for changed_export in ("", export.replace(" picotool_DIR \\\n", "", 1), + export.replace("export \\\n", "export \\\n GCC_EXEC_PREFIX \\\n", 1)): + with self.subTest(export=changed_export), self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver[:start] + changed_export + self.driver[end:]) + + def test_historical_scrub_only_form_is_version_selected(self) -> None: + start = self.driver.index("\n\n" + self.SETUP) + end = self.driver.index(self.END, start) + historical = self.driver[:start] + self.driver[end:] + self.assertIn("GCC_EXEC_PREFIX", self.parse(historical, "0.6.0")) + with self.assertRaises(RELEASE.ReleaseError): + self.parse(historical, "0.6.1") + with self.assertRaises(RELEASE.ReleaseError): + self.parse(self.driver, "0.6.0") + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_rp2_picotool_pin.py b/tests/firmware_tests/host/test_rp2_picotool_pin.py new file mode 100644 index 00000000..2b3be307 --- /dev/null +++ b/tests/firmware_tests/host/test_rp2_picotool_pin.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED structural contract for deterministic RP2 picotool selection.""" + +from __future__ import annotations + +import importlib.util +import inspect +from pathlib import Path +import re +import sys +import tomllib +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[3] +LOCK = ROOT / "firmware/versions.lock" +BUILD = ROOT / "firmware/scripts/build_rp2.sh" +INSTALLER = ROOT / "firmware/scripts/install_picotool.sh" +GITIGNORE = ROOT / ".gitignore" +RELEASE_SCRIPT = ROOT / "firmware/scripts/release_bundle.py" +VERSION_LINE = ( + "picotool v2.3.0 " + "(Darwin, AppleClang-15.0.0.15000309, Release)" +) + + +def load_release_module(): + spec = importlib.util.spec_from_file_location( + "pyble_v061_picotool_release_bundle", + RELEASE_SCRIPT, + ) + if spec is None or spec.loader is None: + raise AssertionError("cannot load release_bundle.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +RELEASE = load_release_module() + + +def rp2_provenance(picotool: str) -> dict: + return { + "schema_version": 1, + "target": "rpi-pico2-w", + "port": "rp2", + "board": "PYBLE_RPI_PICO2_W", + "source_date_epoch": 1_786_506_287, + "pyble": {"commit": "1" * 40, "clean": True}, + "micropython": {"commit": "2" * 40}, + "arm_gnu_toolchain": { + "release": "14.2.Rel1", + "gcc": ( + "arm-none-eabi-gcc (Arm GNU Toolchain 14.2.Rel1) " + "14.2.1 20241119" + ), + }, + "picotool": picotool, + "firmware_bin_bytes": 17, + } + + +def source_lock(version: str) -> dict: + value = { + "pyble": {"agent_version": version}, + "micropython": {"commit": "2" * 40}, + "arm_gnu_toolchain": { + "release": "14.2.Rel1", + "gcc_version": "14.2.1 20241119", + }, + } + if version == "0.6.1": + value["picotool"] = {"version_line": VERSION_LINE} + return value + + +class PicotoolPinContractTests(unittest.TestCase): + def test_versions_lock_binds_the_reviewed_official_distribution(self) -> None: + lock = tomllib.loads(LOCK.read_text(encoding="utf-8")) + self.assertIn("picotool", lock) + self.assertEqual( + lock["picotool"], + { + "version": "2.3.0", + "version_line": VERSION_LINE, + "source_repo": "https://github.com/raspberrypi/picotool.git", + "source_ref": "2.3.0", + "source_commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "distribution_repo": ( + "https://github.com/raspberrypi/pico-sdk-tools.git" + ), + "distribution_ref": "v2.3.0-0", + "distribution_commit": ( + "ad9e4a8375253cf4886bf168ea1a8d2746aadf24" + ), + "url": ( + "https://github.com/raspberrypi/pico-sdk-tools/releases/" + "download/v2.3.0-0/picotool-2.3.0-mac.zip" + ), + "archive_filename": "picotool-2.3.0-mac.zip", + "archive_bytes": 1_980_457, + "archive_format": "zip", + "sha256": ( + "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e" + ), + "cmake_dir": "picotool", + "executable_path": "picotool/picotool", + "executable_sha256": ( + "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" + ), + "cmake_config_path": "picotool/picotoolConfig.cmake", + "cmake_config_sha256": ( + "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" + ), + "bundled_libusb_path": "picotool/libusb-1.0.0.dylib", + "bundled_libusb_sha256": ( + "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" + ), + }, + ) + + def test_installer_and_default_install_root_are_declared(self) -> None: + self.assertTrue(INSTALLER.is_file(), "install_picotool.sh is required") + self.assertTrue(INSTALLER.stat().st_mode & 0o100) + source = INSTALLER.read_text(encoding="utf-8") + self.assertIn("PYBLE_PICOTOOL_ARCHIVE", source) + self.assertIn("PYBLE_LOCK_FILE", source) + self.assertIn("$FW/.picotool", source) + self.assertIn("[picotool]", source) + + def test_build_selects_only_the_verified_explicit_package(self) -> None: + source = BUILD.read_text(encoding="utf-8") + logical = re.sub(r"\\\n[ \t]*", " ", source) + + self.assertIn("PYBLE_PICOTOOL_DIR", source) + self.assertIn("$FW/.picotool", source) + self.assertIn("executable_path", source) + self.assertRegex( + logical, + r'"\$PICOTOOL(?:_EXECUTABLE)?"\s+version', + ) + self.assertNotRegex(logical, r"(?<![/$\w])picotool\s+version") + self.assertNotRegex(logical, r"\b(?:brew|which)\s+picotool\b") + self.assertNotRegex(logical, r"command\s+-v\s+picotool\b") + + self.assertIn("-Dpicotool_DIR=", source) + self.assertIn( + "-DFETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON", + source, + ) + self.assertNotIn("-DFETCHCONTENT_FULLY_DISCONNECTED=ON", source) + self.assertIn( + '"FETCHCONTENT_FULLY_DISCONNECTED:BOOL=ON"', + source, + ) + self.assertNotIn( + '"FETCHCONTENT_FULLY_DISCONNECTED:UNINITIALIZED=ON"', + source, + ) + self.assertIn("-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF", source) + self.assertIn("-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF", source) + self.assertIn("FETCHCONTENT_SOURCE_DIR_PICOTOOL", source) + self.assertIn("PICOTOOL_FETCH_FROM_GIT_PATH", source) + self.assertIn("PICOTOOL_FORCE_FETCH_FROM_GIT", source) + self.assertIn("-DPICOTOOL_FORCE_FETCH_FROM_GIT=OFF", source) + + self.assertIn("version_line", source) + + def test_only_the_exact_default_tree_is_gitignored(self) -> None: + lines = { + line.strip() + for line in GITIGNORE.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + self.assertIn("firmware/.picotool/", lines) + self.assertNotIn(".picotool/", lines) + self.assertNotIn("**/.picotool/", lines) + self.assertNotIn("firmware/.picotool*", lines) + self.assertNotIn("firmware/.picotool-cache/", lines) + + def test_release_provenance_requires_the_exact_line_only_for_v061(self) -> None: + self.assertIn( + "firmware_version", + inspect.signature(RELEASE._validate_rp2_build_provenance).parameters, + "picotool strictness must be routed from the selected candidate version", + ) + + def git_output(_root, _label, *arguments): + if arguments == ("rev-parse", "HEAD"): + return "1" * 40 + if arguments == ( + "show", + "-s", + "--format=%ct", + "1" * 40, + ): + return "1786506287" + raise AssertionError("unexpected git query: %r" % (arguments,)) + + def validate(version: str, picotool: str, *, lock_value=None): + with ( + mock.patch.object( + RELEASE, + "_read_lock", + return_value=( + source_lock(version) + if lock_value is None + else lock_value + ), + ), + mock.patch.object( + RELEASE, + "_git_output", + side_effect=git_output, + ), + mock.patch.object(RELEASE, "_require_checkout_clean"), + ): + return RELEASE._validate_rp2_build_provenance( + rp2_provenance(picotool), + "rpi-pico2-w", + firmware_bin_bytes=17, + repo_root=ROOT, + firmware_version=version, + ) + + self.assertEqual(validate("0.6.1", VERSION_LINE)["picotool"], VERSION_LINE) + for merely_version_shaped in ( + "picotool v2.3.0 (Fixture, Release)", + "picotool v2.3.1 (Darwin, Release)", + VERSION_LINE + " ", + " " + VERSION_LINE, + VERSION_LINE + "\n", + VERSION_LINE.replace("picotool", "Picotool"), + VERSION_LINE.replace("Release", "release"), + "", + ): + with self.subTest(value=merely_version_shaped), self.assertRaises( + RELEASE.ReleaseError + ): + validate("0.6.1", merely_version_shaped) + + for malformed_lock in ( + { + key: value + for key, value in source_lock("0.6.1").items() + if key != "picotool" + }, + { + **source_lock("0.6.1"), + "picotool": {}, + }, + ): + with self.subTest(lock=malformed_lock), self.assertRaises( + RELEASE.ReleaseError + ): + validate("0.6.1", VERSION_LINE, lock_value=malformed_lock) + + historical = "picotool v2.3.0 (Fixture, Release)" + self.assertEqual( + validate( + "0.6.0", + historical, + lock_value=source_lock("0.6.1"), + )["picotool"], + historical, + "the validator checkout's v0.6.1 lock must not reinterpret " + "historical v0.6.0 evidence", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_run_response_admission.py b/tests/firmware_tests/host/test_run_response_admission.py index 72caafe2..02da871d 100644 --- a/tests/firmware_tests/host/test_run_response_admission.py +++ b/tests/firmware_tests/host/test_run_response_admission.py @@ -368,6 +368,44 @@ def test_failed_submit_restores_exact_prior_state_and_never_wakes(self): self.assertIn("taskEXIT_CRITICAL(&g_mux)", failure_body) self.assertIn("return PBLE_NO_RSP;", failure_body) self.assertNotIn("xSemaphoreGive", failure_body) + self.assertNotIn( + "pble_console_stdin_begin", failure_body, + "a failed RUN response must preserve the prior stdin bytes/lease", + ) + self.assertGreater( + body.find("pble_console_stdin_begin()"), + failure.end(), + "stdin admission/clear belongs strictly after successful TX", + ) + + def test_failed_stop_submit_cannot_end_or_clear_live_stdin(self): + stop = code_only(c_function(RUNNER, "pble_runner_stop")) + failure = re.search( + r"if\s*\(\s*tx_rc\s*!=\s*PBLE_TX_OK\s*\)\s*" + r"\{(?P<body>.*?)\n\s*\}", + stop, + flags=re.DOTALL, + ) + self.assertIsNotNone(failure) + self.assertIn("runner_control_attempt_resolve(false)", failure.group("body")) + self.assertNotIn("pble_console_stdin_end", failure.group("body")) + + resolve = code_only( + c_function(RUNNER, "runner_control_attempt_resolve") + ) + accepted = re.search( + r"if\s*\(\s*accepted\s*!=\s*false\s*\)\s*" + r"\{(?P<body>.*?)\}", + resolve, + flags=re.DOTALL, + ) + self.assertIsNotNone(accepted) + self.assertIn("pble_console_stdin_end()", accepted.group("body")) + self.assertEqual( + resolve.count("pble_console_stdin_end()"), + 1, + "only accepted control publication may close stdin", + ) def test_handoff_is_initialized_and_the_binary_give_is_an_invariant(self): body = code_only(c_function(RUNNER, "pble_runner_run")) diff --git a/tests/firmware_tests/host/test_runner_prestart_cancellation.py b/tests/firmware_tests/host/test_runner_prestart_cancellation.py index 0fdf4520..8784cce0 100644 --- a/tests/firmware_tests/host/test_runner_prestart_cancellation.py +++ b/tests/firmware_tests/host/test_runner_prestart_cancellation.py @@ -6,7 +6,12 @@ from __future__ import annotations +import os import re +import shutil +import subprocess +import tempfile +import textwrap import unittest from pathlib import Path @@ -402,6 +407,121 @@ def test_worker_pickup_never_erases_post_reservation_control(self): guarded = braced_statement(worker[pickup:], guard.start()) self.assertIn("runner_exec(", guarded) + def test_cancelled_prestart_run_still_emits_terminal_idle(self): + worker = code_only(c_function(RUNNER, "pble_runner_worker")) + self.assertRegex( + worker, + r"int\s+term\s*=\s*runner_terminal_transition\s*\([^;]+\);" + r"\s*runner_emit_state\s*\(\s*term\s*\)\s*;", + "STOP/SOFT before pickup emits IDLE without a preceding RUNNING", + ) + + def test_predecessor_terminal_clear_cannot_erase_successor_stdin(self): + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone(compiler, "a host C compiler is required") + transition = c_function(RUNNER, "runner_terminal_transition") + harness = textwrap.dedent( + r''' + #include <stdbool.h> + #include <stddef.h> + #include <stdint.h> + + #define PBLE_RUN_RUNNING 1 + #define PBLE_RUN_DONE 2 + #define PBLE_RUN_ERROR 3 + #define MP_OBJ_NULL 0 + #define portMAX_DELAY 0xffffffffu + #define pdTRUE 1 + #define configASSERT(value) do { if (!(value)) __builtin_trap(); } while (0) + #define MP_THREAD_GIL_EXIT() do { } while (0) + #define MP_THREAD_GIL_ENTER() do { } while (0) + + typedef int portMUX_TYPE; + typedef int SemaphoreHandle_t; + typedef struct { int state; } pble_rsm_t; + typedef struct { uintptr_t mp_pending_exception; } mp_state_thread_t; + + static portMUX_TYPE g_mux; + static SemaphoreHandle_t g_control_resolution_sem_storage = 1; + static SemaphoreHandle_t *g_control_resolution_sem = + &g_control_resolution_sem_storage; + static bool g_control_unresolved; + static bool g_stop_requested; + static pble_rsm_t g_rsm = {PBLE_RUN_RUNNING}; + static mp_state_thread_t *g_worker_state; + static bool stdin_active = true; + static bool successor_reserved; + static bool runner_locked; + + static void pble_console_stdin_begin(void) { stdin_active = true; } + static void pble_console_stdin_end(void) { stdin_active = false; } + static int pble_rsm_on_stopped(pble_rsm_t *rsm) { + rsm->state = 0; + return 0; + } + static int pble_rsm_on_finished(pble_rsm_t *rsm, bool ok) { + rsm->state = ok ? PBLE_RUN_DONE : PBLE_RUN_ERROR; + return rsm->state; + } + static int xSemaphoreTake(SemaphoreHandle_t *sem, uint32_t wait) { + (void)sem; + (void)wait; + return pdTRUE; + } + static void enter_runner(void) { runner_locked = true; } + static void exit_runner(void) { + runner_locked = false; + // Deterministically schedule the host task at the first point + // where the predecessor is no longer RUNNING and g_mux is free. + if (!successor_reserved && g_rsm.state != PBLE_RUN_RUNNING) { + successor_reserved = true; + g_rsm.state = PBLE_RUN_RUNNING; + pble_console_stdin_begin(); + } + } + #define taskENTER_CRITICAL(mux) do { (void)(mux); enter_runner(); } while (0) + #define taskEXIT_CRITICAL(mux) do { (void)(mux); exit_runner(); } while (0) + ''') + main = textwrap.dedent( + r''' + int main(void) { + int term = runner_terminal_transition(true); + if (term != PBLE_RUN_DONE) return 20; + if (!successor_reserved) return 21; + if (g_rsm.state != PBLE_RUN_RUNNING) return 22; + if (!stdin_active) return 23; + if (runner_locked) return 24; + return 0; + } + ''') + with tempfile.TemporaryDirectory(prefix="pyble-v061-run-stdin-") as temp: + source = Path(temp) / "runner_stdin_probe.c" + binary = Path(temp) / "runner_stdin_probe" + source.write_text( + harness + "\n" + transition + "\n" + main, + encoding="utf-8", + ) + built = subprocess.run( + [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", + str(source), "-o", str(binary)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False, + ) + self.assertEqual( + built.returncode, 0, + "native terminal/stdin probe did not compile:\n{}{}".format( + built.stdout, built.stderr), + ) + executed = subprocess.run( + [str(binary)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False, + ) + self.assertEqual( + executed.returncode, 0, + "predecessor terminal cleanup erased successor stdin; " + "probe exit {}".format(executed.returncode), + ) + def test_control_attempt_resolution_bridges_tx_without_lock_inversion(self): gate = self.control_gate_name() begin = code_only(c_function(RUNNER, "runner_control_attempt_begin")) diff --git a/tests/firmware_tests/host/test_target_hil_benches.py b/tests/firmware_tests/host/test_target_hil_benches.py index 66acaeac..9668b570 100644 --- a/tests/firmware_tests/host/test_target_hil_benches.py +++ b/tests/firmware_tests/host/test_target_hil_benches.py @@ -23,7 +23,7 @@ import _pble_wire as wire # noqa: E402 -CURRENT_VERSION = "0.6.0" +CURRENT_VERSION = "0.6.1" CURRENT_TARGET_MATRIX = { "esp32": ("esp32", "esp32"), "esp32-s3": ("esp32s3", "esp32-s3"), diff --git a/tests/firmware_tests/host/test_target_runtime_contract.py b/tests/firmware_tests/host/test_target_runtime_contract.py index 18ef69d4..296df626 100644 --- a/tests/firmware_tests/host/test_target_runtime_contract.py +++ b/tests/firmware_tests/host/test_target_runtime_contract.py @@ -31,6 +31,7 @@ import tomllib import types import unittest +from unittest import mock HOST_DIR = Path(__file__).resolve().parent @@ -50,11 +51,45 @@ "esp32-c3": "PYBLE_ESP32_C3", } EXACT_BOARD_TARGET = "waveshare-esp32-s3-lcd-147b" +C3_RESOURCE_THRESHOLD_KEYS = { + "application_headroom_min_bytes", + "application_image_max_bytes", + "gc_free_min_bytes", + "idf_internal_free_min_bytes", + "idf_internal_largest_block_min_bytes", + "idf_internal_minimum_free_min_bytes", +} +C3_FIXED_PERFORMANCE_THRESHOLDS = { + "get_verified_goodput_min_bytes_per_second": 6600, + "put_committed_goodput_min_bytes_per_second": 6600, + "reset_to_service_advertisement_max_ms": 3000, +} + + +def _load_release_module(): + spec = importlib.util.spec_from_file_location( + "pyble_release_target_runtime_contract", + FIRMWARE_DIR / "scripts" / "release_bundle.py", + ) + if spec is None or spec.loader is None: + raise AssertionError("cannot construct release_bundle.py import spec") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +RELEASE = _load_release_module() + + +def _current_c3_profile(): + policy, _digest = RELEASE._load_qualification_policy(REPO_ROOT) + return next( + item for item in policy["profiles"] if item["profile_id"] == "esp32-c3-4mb" + ) EXPECTED_FROZEN_PATHS = Counter( { "flashbdev.py": 1, - "inisetup.py": 1, "asyncio/__init__.py": 1, "asyncio/core.py": 1, "asyncio/event.py": 1, @@ -68,12 +103,12 @@ "pyble/__init__.py": 1, "pyble/pyble_ble.py": 1, "pyble/pyble_proto.py": 1, + "pyble_workspace.py": 1, } ) EXPECTED_FROZEN_SYMBOLS = { "flashbdev", - "inisetup", "asyncio___init__", "asyncio_core", "asyncio_event", @@ -87,6 +122,7 @@ "pyble___init__", "pyble_pyble_ble", "pyble_pyble_proto", + "pyble_workspace", } @@ -184,6 +220,13 @@ def _node_contains_line(node: ast.AST, line: int) -> bool: return node.lineno <= line <= getattr(node, "end_lineno", node.lineno) +def _observed_mount_fixture(expected_bdev, bdev, options): + """Keep lifecycle fakes strict about the official measured-boot call.""" + if bdev is not expected_bdev or options != {"progsize": 256, "observe_boot": True}: + raise AssertionError("boot must mount the exact workspace with observation enabled") + return object() + + def _boot_failure_trace(failure: str) -> list[str]: """Execute frozen boot with host fakes and inject one splash failure. @@ -227,15 +270,19 @@ def maybe_show(wait_fn): return False console_stream = object() + workspace_bdev = object() modules = { "gc": types.SimpleNamespace(collect=lambda: trace.append("gc")), "sys": types.SimpleNamespace(path=["", ".frozen", "/lib"]), "vfs": types.SimpleNamespace( - mount=lambda *_args: trace.append("vfs.mount") + mount=lambda *_args: None ), - "flashbdev": types.SimpleNamespace(bdev=None), - "inisetup": types.SimpleNamespace( - setup=lambda: trace.append("inisetup.setup") + "flashbdev": types.SimpleNamespace(bdev=workspace_bdev), + "pyble_workspace": types.SimpleNamespace( + mount_lfs2=lambda bdev, _vfs, **_kwargs: + _observed_mount_fixture(workspace_bdev, bdev, _kwargs), + boot_attached=lambda: None, + boot_recovery=lambda: trace.append("unexpected.workspace.recovery"), ), "pble_ble": types.SimpleNamespace( init_agent=lambda: trace.append("agent.init"), @@ -367,15 +414,19 @@ def frozen_guard(wait_fn): _maybe_show_boot_splash=frozen_guard ) console_stream = object() + workspace_bdev = object() modules = { "gc": types.SimpleNamespace(collect=lambda: trace.append(("gc",))), "sys": sys_module, "vfs": types.SimpleNamespace( mount=lambda *_args: trace.append(("vfs.mount",)) ), - "flashbdev": types.SimpleNamespace(bdev=None), - "inisetup": types.SimpleNamespace( - setup=lambda: trace.append(("inisetup.setup",)) + "flashbdev": types.SimpleNamespace(bdev=workspace_bdev), + "pyble_workspace": types.SimpleNamespace( + mount_lfs2=lambda bdev, _vfs, **_kwargs: + _observed_mount_fixture(workspace_bdev, bdev, _kwargs), + boot_attached=lambda: None, + boot_recovery=lambda: trace.append(("unexpected.workspace.recovery",)), ), "pble_ble": types.SimpleNamespace( init_agent=lambda: trace.append(("agent.init",)), @@ -466,6 +517,57 @@ def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0): } +def _boot_workspace_failure_trace(target: str) -> list[tuple]: + """Execute one ESP boot with a shared-authority mount failure.""" + + trace: list[tuple] = [] + workspace_bdev = object() + + def mount_lfs2(bdev, _vfs, **kwargs): + _observed_mount_fixture(workspace_bdev, bdev, kwargs) + trace.append(("workspace.mount", bdev, kwargs.get("progsize"))) + raise OSError("synthetic nonblank workspace") + + modules = { + "gc": types.SimpleNamespace(collect=lambda: trace.append(("gc",))), + "vfs": types.SimpleNamespace( + mount=lambda *_args: trace.append(("vfs.mount",)) + ), + "flashbdev": types.SimpleNamespace(bdev=workspace_bdev), + "pyble_workspace": types.SimpleNamespace( + mount_lfs2=mount_lfs2, + boot_attached=lambda: trace.append(("unexpected.workspace.attachment",)), + boot_recovery=lambda: trace.append(( + "recovery.print", + ("PyBLE workspace recovery is required; reconnect by USB.",), + {}, + )), + ), + "pble_ble": types.SimpleNamespace( + init_agent=lambda: trace.append(("agent.init",)) + ), + } + + def fake_import(name, globals_=None, locals_=None, fromlist=(), level=0): + del globals_, locals_, fromlist, level + if name not in modules: + raise AssertionError("unexpected failed-workspace import: {}".format(name)) + return modules[name] + + exec_builtins = dict(vars(builtins)) + exec_builtins["__import__"] = fake_import + exec_builtins["print"] = lambda *args, **kwargs: trace.append( + ("recovery.print", args, kwargs) + ) + source_path = OVERLAYS / target / "_boot.py" + exec( + compile(source_path.read_bytes(), str(source_path), "exec"), + {"__builtins__": exec_builtins}, + {}, + ) + return trace + + def _sdkconfig_values(path: Path) -> dict[str, str]: values: dict[str, str] = {} for line in path.read_text(encoding="utf-8").splitlines(): @@ -528,6 +630,58 @@ def _elf32_sections(path: Path) -> dict[str, bytes]: class EffectiveManifestContractTests(unittest.TestCase): + def test_every_esp_boot_uses_shared_erased_only_lfs2_mount_authority(self): + for target in TARGETS: + with self.subTest(target=target): + boot = OVERLAYS / target / "_boot.py" + tree = ast.parse(boot.read_bytes(), filename=str(boot)) + calls = [ + (_dotted_name(node.func), node) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + ] + lfs_calls = [node for name, node in calls + if name == "pyble_workspace.mount_lfs2"] + mount_calls = [node for name, node in calls + if name == "vfs.mount"] + self.assertEqual( + len(lfs_calls), 1, + "{} must delegate to the full-device erased-media " + "authority".format(target), + ) + self.assertEqual(len(mount_calls), 1) + self.assertTrue(mount_calls[0].args) + self.assertIsInstance( + mount_calls[0].args[0], ast.Call, + "the mounted workspace must be the explicit LFS2 object", + ) + self.assertEqual( + _dotted_name(mount_calls[0].args[0].func), + "pyble_workspace.mount_lfs2", + ) + self.assertFalse( + any(name == "inisetup.setup" for name, _node in calls), + "{} may not delegate a destructive decision to upstream's " + "block-0-only probe".format(target), + ) + + def test_workspace_mount_failure_skips_every_esp_agent_lifecycle(self): + for target in TARGETS: + with self.subTest(target=target): + trace = _boot_workspace_failure_trace(target) + self.assertEqual(trace[0][0], "workspace.mount") + self.assertIsNotNone(trace[0][1]) + self.assertEqual(trace[0][2], 256) + recovery = [event for event in trace + if event[0] == "recovery.print"] + self.assertEqual(len(recovery), 1) + self.assertEqual(recovery[0][2], {}) + self.assertEqual(len(recovery[0][1]), 1) + self.assertIs(type(recovery[0][1][0]), str) + self.assertIn("recovery", recovery[0][1][0].lower()) + self.assertFalse(any(event[0] == "vfs.mount" for event in trace)) + self.assertFalse(any(event[0] == "agent.init" for event in trace)) + def test_all_targets_resolve_the_exact_lean_runtime_allowlist(self): for target, board_name in TARGETS.items(): with self.subTest(target=target), _resolved_manifest( @@ -622,7 +776,7 @@ def test_every_effective_boot_is_board_owned_and_has_full_lifecycle(self): for required in ( "vfs.mount", - "inisetup.setup", + "pyble_workspace.mount_lfs2", "pble_ble.init_agent", "os.dupterm", "pble_boot.maybe_autorun", @@ -717,7 +871,7 @@ def test_every_effective_boot_is_board_owned_and_has_full_lifecycle(self): by_name["pble_ble.init_agent"][0].lineno, ) self.assertLess( - by_name["inisetup.setup"][0].lineno, + by_name["pyble_workspace.mount_lfs2"][0].lineno, by_name["pble_ble.init_agent"][0].lineno, ) @@ -902,8 +1056,8 @@ def test_frozen_package_version_is_generated_from_versions_lock(self): expected = tomllib.load(handle)["pyble"]["agent_version"] self.assertEqual( expected, - "0.6.0", - "the combined Pico 2 W source must advance the agent minor version", + "0.6.1", + "the v0.6.1 hardening source must advance the patch version", ) package_source = (FIRMWARE_DIR / "pyble" / "__init__.py").read_text( encoding="utf-8" @@ -1116,34 +1270,59 @@ def test_c3_disables_phy_calibration_storage_without_relaxing_oi1(self): "C3 must run full PHY calibration instead of growing PHY NVS state", ) - policy = json.loads( - (FIRMWARE_DIR / "qualification" / "oi1-gates.json").read_text( - encoding="utf-8" - ) - ) - profile = next( - item - for item in policy["profiles"] - if item["profile_id"] == "esp32-c3-4mb" - ) + profile = _current_c3_profile() + self.assertEqual(profile["profile_id"], "esp32-c3-4mb") + self.assertEqual(profile["target"], "esp32-c3") + self.assertEqual(profile["resource_kind"], "esp-idf") thresholds = profile["thresholds"] self.assertEqual( - thresholds, - { - "application_headroom_min_bytes": 352880, - "application_image_max_bytes": 1678736, - "gc_free_min_bytes": 117760, - "get_verified_goodput_min_bytes_per_second": 6600, - "idf_internal_free_min_bytes": 66560, - "idf_internal_largest_block_min_bytes": 57344, - "idf_internal_minimum_free_min_bytes": 63488, - "put_committed_goodput_min_bytes_per_second": 6600, - "reset_to_service_advertisement_max_ms": 3000, - }, - "the PHY policy must keep the exact C3 static/heap gates and " + set(thresholds), + C3_RESOURCE_THRESHOLD_KEYS | set(C3_FIXED_PERFORMANCE_THRESHOLDS), + ) + self.assertEqual( + {key: thresholds[key] for key in C3_FIXED_PERFORMANCE_THRESHOLDS}, + C3_FIXED_PERFORMANCE_THRESHOLDS, + "the PHY policy must validate the current C3 static/heap gates and " "carry the fixed ADR-0037 product SLOs", ) + def test_c3_phy_storage_cannot_be_reenabled(self): + with mock.patch( + __name__ + "._sdkconfig_values", + return_value={"CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE": "y"}, + ), self.assertRaisesRegex(AssertionError, "C3 must run full PHY calibration"): + self.test_c3_disables_phy_calibration_storage_without_relaxing_oi1() + + def test_c3_policy_loader_failure_is_not_ignored(self): + with mock.patch.object( + RELEASE, "_load_qualification_policy", + side_effect=RELEASE.ReleaseError("injected policy validation failure"), + ), self.assertRaisesRegex(RELEASE.ReleaseError, "injected policy validation failure"): + _current_c3_profile() + + def test_c3_resource_threshold_mutations_are_rejected(self): + policy_path = FIRMWARE_DIR / "qualification" / "oi1-gates.json" + original_read = Path.read_bytes + for key in sorted(C3_RESOURCE_THRESHOLD_KEYS): + with self.subTest(threshold=key): + changed = json.loads(policy_path.read_bytes()) + profile = next( + item for item in changed["profiles"] + if item["profile_id"] == "esp32-c3-4mb" + ) + profile["thresholds"][key] += 1 + payload = (json.dumps(changed, indent=2, sort_keys=True) + "\n").encode() + + def read(path): + return payload if path == policy_path else original_read(path) + + with mock.patch.object( + Path, "read_bytes", autospec=True, side_effect=read + ), self.assertRaisesRegex( + RELEASE.ReleaseError, "thresholds were not derived from baseline esp32-c3-4mb" + ): + _current_c3_profile() + class GeneratedRuntimeContractTests(unittest.TestCase): @classmethod diff --git a/tests/firmware_tests/host/test_v060_physical_fact_lineage.py b/tests/firmware_tests/host/test_v060_physical_fact_lineage.py index c3494617..ee1afc7c 100644 --- a/tests/firmware_tests/host/test_v060_physical_fact_lineage.py +++ b/tests/firmware_tests/host/test_v060_physical_fact_lineage.py @@ -248,6 +248,38 @@ def test_fresh_automatic_log_reconstructs_every_nonphysical_field(self) -> None: candidate_identity=None, ) + def test_common_validator_keeps_lineage_exactly_v060_only(self) -> None: + replay = getattr( + RELEASE, "_reconstruct_lineage_automatic_observation", None + ) + self.assertTrue(callable(replay), "strict automatic replay is missing") + observation = replay( + automatic_raw(), + profile_id=PROFILE, + expected_build=BUILD, + candidate_identity=None, + ) + observation["physical_power_cycle_advertising"] = "passed" + observation["physical_fact_lineage"] = lineage_summary() + observation["physical_fact_lineage"][ + "candidate_automatic_raw_log_sha256" + ] = observation["raw_log_sha256"] + + validated = RELEASE._validate_qualification_observation( + copy.deepcopy(observation), + None, + PROFILE, + firmware_version="0.6.0", + ) + self.assertEqual(validated, observation) + with self.assertRaises(RELEASE.ReleaseError): + RELEASE._validate_qualification_observation( + copy.deepcopy(observation), + None, + PROFILE, + firmware_version="0.6.1", + ) + def test_summary_reuses_exactly_one_fact_and_rejects_every_binding_mutation(self) -> None: validate = getattr(RELEASE, "_validate_physical_fact_lineage_summary", None) self.assertTrue(callable(validate), "lineage summary validator is missing") diff --git a/tests/firmware_tests/host/test_v060_qualification_workflow.py b/tests/firmware_tests/host/test_v060_qualification_workflow.py index 7b6d00f1..89ffb7dc 100644 --- a/tests/firmware_tests/host/test_v060_qualification_workflow.py +++ b/tests/firmware_tests/host/test_v060_qualification_workflow.py @@ -181,7 +181,10 @@ def write_c3_post_oi_nvs_receipt( } -def candidate_release(artifact: bytes) -> dict[str, object]: +def candidate_release( + artifact: bytes, + version: str = "0.6.0", +) -> dict[str, object]: esp_specs = { "esp32-4mb": ( "esp32", @@ -303,9 +306,9 @@ def candidate_release(artifact: bytes) -> dict[str, object]: return { "schema_version": 4, "identity": { - "version": "0.6.0", - "tag": "firmware-v0.6.0", - "agent_version": "0.6.0", + "version": version, + "tag": "firmware-v%s" % version, + "agent_version": version, "protocol_version": "PBLE/1", "built_at": "2026-08-12T00:00:00Z", }, @@ -474,6 +477,389 @@ def test_pico_cli_has_no_operator_selectable_pacing_value(self): ) +class C3PostOiNvsReceiptWriterContractTests(unittest.TestCase): + """RED contract for the sole mechanical C3 receipt-authoring path.""" + + API_PARAMETERS = ( + "candidate_dir", + "observation_path", + "oi1_raw_log_path", + "post_oi_nvs_slice_path", + "post_oi_nvs_acquisition_log_path", + "output_path", + ) + + def writer(self): + writer = getattr(GATE, "create_post_oi_nvs_receipt_file", None) + if not callable(writer): + self.skipTest("RED: create-post-oi-nvs-receipt has not landed") + return writer + + def make_inputs(self, root: Path) -> dict[str, object]: + candidate = root / "candidate" + artifact_path = candidate / "esp32-c3-4mb" / "firmware.bin" + artifact_path.parent.mkdir(parents=True) + artifact = b"candidate v0.6.1 C3 receipt firmware\0" + artifact_path.write_bytes(artifact) + release_raw = canonical_json_bytes( + candidate_release(artifact, version="0.6.1") + ) + release_path = candidate / "release.json" + release_path.write_bytes(release_raw) + + private = root / "private" + raw_log_path = private / "c3-oi1-verify.jsonl" + raw_log = ( + b'{"event":"measurement_start","profile_id":"esp32-c3-4mb",' + b'"sequence":1}\n' + ) + observation = { + "raw_log_sha256": hashlib.sha256(raw_log).hexdigest(), + "reset_to_service_advertisement_ms": [ + 1000 + index for index in range(10) + ], + } + observation_path = private / "c3-oi1-observation.json" + slice_path = private / "c3-post-oi-nvs-slice.bin" + acquisition_log_path = private / "c3-post-oi-nvs-acquisition.log" + for path, raw in ( + (observation_path, canonical_json_bytes(observation)), + (raw_log_path, raw_log), + (slice_path, C3_NVS_SLICE), + (acquisition_log_path, C3_NVS_ACQUISITION_LOG), + ): + _write_private_file(path, raw) + + summary = { + "schema_version": 1, + "profile_id": "esp32-c3-4mb", + "candidate_release_json_sha256": hashlib.sha256( + release_raw + ).hexdigest(), + "candidate_firmware_sha256": hashlib.sha256(artifact).hexdigest(), + "oi1_raw_sha256": hashlib.sha256(raw_log).hexdigest(), + "reset_samples": 10, + "partition_offset": 0x9000, + "partition_size": 0x6000, + "nvs_slice_sha256": C3_NVS_SLICE_SHA256, + "nvs_slice_size_bytes": len(C3_NVS_SLICE), + "acquisition_log_sha256": hashlib.sha256( + C3_NVS_ACQUISITION_LOG + ).hexdigest(), + "acquisition_log_size_bytes": len(C3_NVS_ACQUISITION_LOG), + "integrity": "passed", + "written_namespaces": [], + } + return { + "candidate_dir": candidate, + "artifact_path": artifact_path, + "artifact": artifact, + "release_path": release_path, + "release_raw": release_raw, + "observation": observation, + "observation_path": observation_path, + "raw_log": raw_log, + "oi1_raw_log_path": raw_log_path, + "post_oi_nvs_slice_path": slice_path, + "post_oi_nvs_acquisition_log_path": acquisition_log_path, + "output_path": private / "c3-post-oi-nvs-receipt.json", + "expected_receipt": {"summary": summary, "inventory": []}, + } + + def create(self, evidence: dict[str, object], **overrides): + arguments = { + key: evidence[key] + for key in self.API_PARAMETERS + } + arguments.update(overrides) + return self.writer()(**arguments) + + @staticmethod + def rewrite_observation( + evidence: dict[str, object], + *, + reset_count: int = 10, + raw_log_sha256: str | None = None, + canonical: bool = True, + ) -> None: + observation = { + "raw_log_sha256": ( + raw_log_sha256 + if raw_log_sha256 is not None + else hashlib.sha256(evidence["raw_log"]).hexdigest() + ), + "reset_to_service_advertisement_ms": [1000] * reset_count, + } + raw = ( + canonical_json_bytes(observation) + if canonical + else (json.dumps(observation) + "\n").encode("utf-8") + ) + _write_private_file(evidence["observation_path"], raw) + + def test_candidate_bound_receipt_writer_api_is_exact(self): + writer = getattr(GATE, "create_post_oi_nvs_receipt_file", None) + self.assertTrue( + callable(writer), + "the C3 receipt must have one mechanical candidate-bound writer", + ) + if not callable(writer): + return + self.assertEqual( + tuple(inspect.signature(writer).parameters), + self.API_PARAMETERS, + ) + self.assertTrue( + all( + parameter.kind is inspect.Parameter.KEYWORD_ONLY + for parameter in inspect.signature(writer).parameters.values() + ) + ) + self.assertIn("create_post_oi_nvs_receipt_file", GATE.__all__) + + def test_cli_exposes_six_positional_inputs_and_no_authored_claims(self): + top = subprocess.run( + [sys.executable, str(GATE_PATH), "--help"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(top.returncode, 0, top.stderr) + self.assertIn("create-post-oi-nvs-receipt", top.stdout) + command = subprocess.run( + [ + sys.executable, + str(GATE_PATH), + "create-post-oi-nvs-receipt", + "--help", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(command.returncode, 0, command.stderr) + usage = command.stdout.split("\n\n", 1)[0] + positions = [usage.index(positional) for positional in self.API_PARAMETERS] + self.assertEqual(positions, sorted(positions)) + for positional in self.API_PARAMETERS: + self.assertIn(positional, command.stdout) + for forbidden in ( + "--candidate-release-json-sha256", + "--candidate-firmware-sha256", + "--oi1-raw-sha256", + "--reset-samples", + "--partition-offset", + "--partition-size", + "--integrity", + "--written-namespace", + ): + self.assertNotIn(forbidden, command.stdout) + + def test_writer_derives_canonical_receipt_and_chains_to_result_validation(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-c3-receipt-") as tmp: + root = Path(tmp).resolve() + evidence = self.make_inputs(root) + input_paths = ( + evidence["release_path"], + evidence["artifact_path"], + evidence["observation_path"], + evidence["oi1_raw_log_path"], + evidence["post_oi_nvs_slice_path"], + evidence["post_oi_nvs_acquisition_log_path"], + ) + before = {path: path.read_bytes() for path in input_paths} + sync_modes: list[int] = [] + real_fsync = GATE.os.fsync + + def record_fsync(descriptor): + sync_modes.append(GATE.os.fstat(descriptor).st_mode) + return real_fsync(descriptor) + + with mock.patch.object(GATE.os, "fsync", side_effect=record_fsync): + created = self.create(evidence) + + output = evidence["output_path"] + expected_raw = canonical_json_bytes(evidence["expected_receipt"]) + self.assertEqual(len(expected_raw), 801) + self.assertEqual(Path(created), output) + self.assertEqual(output.read_bytes(), expected_raw) + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600) + self.assertEqual(output.stat().st_nlink, 1) + self.assertTrue(any(stat.S_ISREG(mode) for mode in sync_modes)) + self.assertTrue(any(stat.S_ISDIR(mode) for mode in sync_modes)) + self.assertEqual( + {path: path.read_bytes() for path in input_paths}, + before, + "receipt creation must not rewrite candidate or private inputs", + ) + + result_path = output.with_name("c3-result.json") + GATE.create_result_file( + candidate_dir=evidence["candidate_dir"], + profile_id="esp32-c3-4mb", + passed_gates=["C3-G%d" % index for index in range(7)], + output_path=result_path, + post_oi_nvs_receipt_path=output, + post_oi_nvs_slice_path=evidence["post_oi_nvs_slice_path"], + post_oi_nvs_acquisition_log_path=evidence[ + "post_oi_nvs_acquisition_log_path" + ], + ) + summary = GATE.validate_c3_result_for_observation( + result_path, + artifact_path=evidence["artifact_path"], + expected_version="0.6.1", + candidate_release_json_sha256=hashlib.sha256( + evidence["release_raw"] + ).hexdigest(), + observation=evidence["observation"], + post_oi_nvs_receipt_path=output, + post_oi_nvs_slice_path=evidence["post_oi_nvs_slice_path"], + post_oi_nvs_acquisition_log_path=evidence[ + "post_oi_nvs_acquisition_log_path" + ], + ) + self.assertEqual( + summary["candidate_release_json_sha256"], + hashlib.sha256(evidence["release_raw"]).hexdigest(), + ) + + def test_cli_creates_the_same_candidate_bound_canonical_receipt(self): + self.writer() + with tempfile.TemporaryDirectory(prefix="pyble-v061-c3-receipt-cli-") as tmp: + evidence = self.make_inputs(Path(tmp).resolve()) + output = evidence["output_path"] + completed = subprocess.run( + [ + sys.executable, + str(GATE_PATH), + "create-post-oi-nvs-receipt", + str(evidence["candidate_dir"]), + str(evidence["observation_path"]), + str(evidence["oi1_raw_log_path"]), + str(evidence["post_oi_nvs_slice_path"]), + str(evidence["post_oi_nvs_acquisition_log_path"]), + str(output), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout.strip(), str(output)) + self.assertEqual( + output.read_bytes(), + canonical_json_bytes(evidence["expected_receipt"]), + ) + + def test_preexisting_receipt_is_never_replaced(self): + with tempfile.TemporaryDirectory( + prefix="pyble-v061-c3-receipt-exists-" + ) as tmp: + evidence = self.make_inputs(Path(tmp).resolve()) + output = evidence["output_path"] + sentinel = b"pre-existing private receipt\n" + _write_private_file(output, sentinel) + with self.assertRaises(GATE.QualificationError): + self.create(evidence) + self.assertEqual(output.read_bytes(), sentinel) + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600) + + def test_unsafe_or_mismatched_inputs_publish_no_receipt(self): + self.writer() + + def chmod(evidence, key): + evidence[key].chmod(0o644) + + def hardlink(evidence, key): + GATE.os.link( + evidence[key], + evidence[key].with_name(evidence[key].name + ".second-link"), + ) + + def symlink(evidence, key): + path = evidence[key] + target = path.with_name(path.name + ".real") + path.rename(target) + path.symlink_to(target) + + cases = { + "observation-mode": lambda item: chmod(item, "observation_path"), + "raw-log-mode": lambda item: chmod(item, "oi1_raw_log_path"), + "slice-mode": lambda item: chmod(item, "post_oi_nvs_slice_path"), + "acquisition-mode": lambda item: chmod( + item, "post_oi_nvs_acquisition_log_path" + ), + "raw-log-hardlink": lambda item: hardlink(item, "oi1_raw_log_path"), + "slice-symlink": lambda item: symlink(item, "post_oi_nvs_slice_path"), + "raw-log-mismatch": lambda item: _write_private_file( + item["oi1_raw_log_path"], item["raw_log"] + b"changed\n" + ), + "nine-reset-samples": lambda item: self.rewrite_observation( + item, reset_count=9 + ), + "noncanonical-observation": lambda item: self.rewrite_observation( + item, canonical=False + ), + "non-erased-slice": lambda item: _write_private_file( + item["post_oi_nvs_slice_path"], + b"\x00" + C3_NVS_SLICE[1:], + ), + "truncated-slice": lambda item: _write_private_file( + item["post_oi_nvs_slice_path"], C3_NVS_SLICE[:-1] + ), + "one-line-acquisition-marker": lambda item: _write_private_file( + item["post_oi_nvs_acquisition_log_path"], + b"c3-post-oi-nvs-acquisition-v1\n", + ), + "changed-acquisition-marker": lambda item: _write_private_file( + item["post_oi_nvs_acquisition_log_path"], + C3_NVS_ACQUISITION_LOG + b"unexpected-third-line\n", + ), + } + for name, mutate in cases.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory( + prefix="pyble-v061-c3-receipt-reject-" + ) as tmp: + evidence = self.make_inputs(Path(tmp).resolve()) + mutate(evidence) + output = evidence["output_path"] + with self.assertRaises(GATE.QualificationError): + self.create(evidence) + self.assertFalse(output.exists()) + + def test_slice_change_after_output_fsync_rolls_back_receipt(self): + with tempfile.TemporaryDirectory( + prefix="pyble-v061-c3-receipt-race-" + ) as tmp: + evidence = self.make_inputs(Path(tmp).resolve()) + output = evidence["output_path"] + slice_path = evidence["post_oi_nvs_slice_path"] + real_fsync = GATE.os.fsync + changed = False + + def change_slice_after_output_sync(descriptor): + nonlocal changed + mode = GATE.os.fstat(descriptor).st_mode + result = real_fsync(descriptor) + if stat.S_ISREG(mode) and not changed: + changed = True + _write_private_file( + slice_path, + b"\x00" + C3_NVS_SLICE[1:], + ) + return result + + with mock.patch.object( + GATE.os, + "fsync", + side_effect=change_slice_after_output_sync, + ), self.assertRaises(GATE.QualificationError): + self.create(evidence) + self.assertTrue(changed, "the fixture must inject the slice race") + self.assertFalse(output.exists()) + + class PrivateGateResultWriterContractTests(unittest.TestCase): def test_candidate_bound_no_replace_writer_api_exists(self): writer = getattr(GATE, "create_result_file", None) @@ -651,6 +1037,48 @@ def create(output_path, **overrides): ) self.assertFalse(phy_output.exists()) + def test_writer_accepts_v061_and_derives_its_exact_result_version(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-workflow-gate-") as tmp: + root = Path(tmp).resolve() + candidate = root / "candidate" + (candidate / "esp32-c3-4mb").mkdir(parents=True) + artifact = b"candidate v0.6.1 C3 firmware\0" + (candidate / "esp32-c3-4mb" / "firmware.bin").write_bytes(artifact) + release_raw = canonical_json_bytes( + candidate_release(artifact, version="0.6.1") + ) + (candidate / "release.json").write_bytes(release_raw) + output = root / "private" / "c3-result.json" + receipt = root / "private" / "c3-post-oi-nvs.json" + evidence = write_c3_post_oi_nvs_receipt( + receipt, + release_raw=release_raw, + artifact=artifact, + ) + + created = GATE.create_result_file( + candidate_dir=candidate, + profile_id="esp32-c3-4mb", + passed_gates=["C3-G%d" % index for index in range(7)], + output_path=output, + post_oi_nvs_receipt_path=receipt, + post_oi_nvs_slice_path=evidence["slice_path"], + post_oi_nvs_acquisition_log_path=evidence["log_path"], + ) + + self.assertEqual(Path(created), output) + result = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(result["firmware_version"], "0.6.1") + + future = candidate_release(artifact, version="0.7.0") + with self.assertRaises(GATE.QualificationError): + GATE._validate_candidate_release( + future, + profile_id="esp32-c3-4mb", + artifact_size=len(artifact), + artifact_sha256=hashlib.sha256(artifact).hexdigest(), + ) + def test_writer_reopens_slice_bytes_and_never_mints_from_diagnostics(self): """Handoff robust-design points 2, 4, and 5 at result creation. @@ -1027,6 +1455,7 @@ def test_candidate_bound_completion_writer_api_exists(self): "output_path", "qualification_repo_root", "profile_qualification_result", + "v061_hardening_result", }, ) @@ -1053,6 +1482,7 @@ def test_release_cli_exposes_completion_writer_without_gate_map_argument(self): ) self.assertEqual(command_help.returncode, 0, command_help.stderr) self.assertIn("--profile-qualification-result", command_help.stdout) + self.assertIn("--v061-hardening-result", command_help.stdout) self.assertNotIn("--profile-gate-summary", command_help.stdout) self.assertNotIn("--footprint-reliability", command_help.stdout) diff --git a/tests/firmware_tests/host/test_v061_config_hardening.py b/tests/firmware_tests/host/test_v061_config_hardening.py new file mode 100644 index 00000000..1511134e --- /dev/null +++ b/tests/firmware_tests/host/test_v061_config_hardening.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""[red] v0.6.1 label validation and Pico configuration durability. + +The tests use real temporary files and inject failures only at the filesystem +operation named by each case. They deliberately do not mock a successful +transaction: a passing implementation must write a loadable, independently +verified record and preserve the prior record at every pre-rename failure cut. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import sys +import tempfile +import unittest +from unittest import mock + + +HOST_DIR = os.path.dirname(os.path.abspath(__file__)) +if HOST_DIR not in sys.path: + sys.path.insert(0, HOST_DIR) + +import _support # noqa: E402 +import pyble_fs # noqa: E402 +import pyble_proto # noqa: E402 + + +DC = _support.RedReason("pyble_device_config", owner="v0.6.1-config-engineer") + +OK = 0x00 +EBADREQ = 0x01 +EIO = 0x05 +ERANGE = 0x09 +VERSION = 1 +CONF_NAME = "pyble_conf.json" +CONF_TMP_NAME = ".pyble_conf.json.pbltmp" +CONFIG_FAULT_NONE = 0 +CONFIG_FAULT_CORRUPT = 1 + + +def canonical_crc(label, autorun): + encoded = label.encode("utf-8") + canonical = b"PBLECFG" + bytes((VERSION, autorun, len(encoded))) + encoded + return pyble_proto.crc32(canonical) & 0xFFFFFFFF + + +def record(label="", autorun=0, **updates): + value = { + "version": VERSION, + "label": label, + "autorun": autorun, + "crc32": canonical_crc(label, autorun), + } + value.update(updates) + return value + + +class ConfigCase(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp(prefix="pyble_v061_config_") + self.addCleanup(shutil.rmtree, self.root, True) + self.adv = [] + + @property + def module(self): + return DC.obj(self, "v0.6.1 portable configuration module") + + @property + def primary(self): + return os.path.join(self.root, CONF_NAME) + + @property + def temporary(self): + return os.path.join(self.root, CONF_TMP_NAME) + + def make(self): + cls = DC.attr(self, "DeviceConfig", "v0.6.1 durable DeviceConfig") + return cls(self.root, "9F3A", set_adv_name=self.adv.append) + + def write_json(self, path, value): + with open(path, "w", encoding="utf-8") as stream: + json.dump(value, stream, separators=(",", ":")) + + def read_json(self, path=None): + with open(path or self.primary, "r", encoding="utf-8") as stream: + return json.load(stream) + + def assert_fault(self, config, expected): + self.assertTrue( + hasattr(config, "config_fault"), + "v0.6.1 requires a bounded in-RAM DeviceConfig.config_fault marker", + ) + self.assertEqual(config.config_fault, expected) + + def seed(self, label="old", autorun=1): + config = self.make() + self.assertEqual(config.set_label(label.encode("utf-8")), OK) + self.assertEqual(config.set_autorun(autorun), OK) + self.adv[:] = [] + return config, Path(self.primary).read_bytes() + + +class StrictLabelValidationTests(ConfigCase): + def label_status(self): + return DC.attr(self, "label_status", "v0.6.1 strict UTF-8 label validator") + + def test_valid_multibyte_label_at_24_byte_limit(self): + encoded = "€".encode("utf-8") * 8 + self.assertEqual(len(encoded), 24) + self.assertEqual(self.label_status()(encoded), OK) + + def test_malformed_utf8_is_ebadreq(self): + malformed = ( + b"\x80", # lone continuation + b"\xc0\x80", # overlong NUL + b"\xe0\x80\x80", # overlong three-byte form + b"\xed\xa0\x80", # UTF-16 surrogate U+D800 + b"\xf4\x90\x80\x80", # above U+10FFFF + b"\xe2\x82", # truncated sequence + ) + for value in malformed: + with self.subTest(value=value.hex()): + self.assertEqual( + self.label_status()(value), + EBADREQ, + "malformed UTF-8 must be rejected before persistence", + ) + + def test_c0_c1_and_del_controls_are_ebadreq(self): + controls = tuple(bytes((value,)) for value in (0, 1, 9, 10, 13, 27, 31, 127)) + controls += ("\u0080".encode("utf-8"), "\u0085".encode("utf-8"), + "\u009f".encode("utf-8")) + for value in controls: + with self.subTest(value=value.hex()): + self.assertEqual( + self.label_status()(value), + EBADREQ, + "control scalar must not enter LF-delimited capabilities", + ) + + def test_length_precedes_content_validation(self): + value = b"x" * 24 + b"\x80" + self.assertEqual(self.label_status()(value), ERANGE) + + def test_rejected_newline_keeps_ram_file_and_advertisement(self): + config, before = self.seed() + self.assertEqual(config.set_label(b"x\nauto_run=1"), EBADREQ) + self.assertEqual(config.label, "old") + self.assertEqual(Path(self.primary).read_bytes(), before) + self.assertEqual(self.adv, []) + + +class PortableStrictUtf8BoundaryTests(ConfigCase): + """Pin a decoder-independent strict-scalar UTF-8 authority on RP2.""" + + def strict_decode(self): + helper = getattr(pyble_proto, "strict_utf8_decode", None) + self.assertIsNotNone( + helper, + "portable wire text must use one explicit strict UTF-8 decoder; " + "the pinned MicroPython bytes.decode accepts invalid scalars", + ) + return helper + + def test_shared_decoder_accepts_only_shortest_form_unicode_scalars(self): + valid = ( + (b"ASCII", "ASCII"), + (b"\xc2\x80", "\u0080"), + (b"\xe2\x82\xac", "\u20ac"), + (b"\xf4\x8f\xbf\xbf", "\U0010ffff"), + ) + invalid = ( + b"\x80", b"\xc0\x80", b"\xc1\xbf", b"\xe0\x80\x80", + b"\xed\xa0\x80", b"\xf0\x80\x80\x80", + b"\xf4\x90\x80\x80", b"\xf5\x80\x80\x80", + b"\xe2\x82", b"\xf0\x9f\x92", b"\xe2(\xa1", + ) + decode = self.strict_decode() + for encoded, expected in valid: + with self.subTest(valid=encoded.hex()): + self.assertEqual(decode(encoded), expected) + for encoded in invalid: + with self.subTest(invalid=encoded.hex()): + self.assertIsNone(decode(encoded)) + + def test_path_and_label_share_the_explicit_decoder(self): + with mock.patch.object( + pyble_proto, + "strict_utf8_decode", + return_value=None, + create=True, + ) as strict: + self.assertEqual( + DC.attr(self, "label_status", "strict label UTF-8")(b"safe"), + EBADREQ, + ) + self.assertEqual(pyble_fs.resolve(b"/safe"), (EBADREQ, None)) + self.assertEqual( + strict.call_count, + 2, + "both portable wire-text chokepoints must delegate to the one " + "decoder-independent scalar validator", + ) + + +class VersionedRecordTests(ConfigCase): + def test_v1_constants_and_exact_record_are_persisted(self): + self.assertEqual( + DC.attr(self, "CONFIG_VERSION", "v0.6.1 config schema version"), VERSION) + self.assertEqual( + DC.attr(self, "CONF_TMP_NAME", "v0.6.1 reserved config temp"), + CONF_TMP_NAME, + ) + config = self.make() + self.assertEqual(config.set_label("€".encode("utf-8")), OK) + self.assertEqual(config.set_autorun(1), OK) + persisted = self.read_json() + self.assertEqual( + set(persisted), {"version", "label", "autorun", "crc32"}, + "v1 has one unambiguous, integrity-covered schema", + ) + self.assertEqual(persisted["version"], VERSION) + self.assertEqual(persisted["label"], "€") + self.assertEqual(persisted["autorun"], 1) + self.assertIs(type(persisted["crc32"]), int) + self.assertEqual(persisted["crc32"], canonical_crc("€", 1)) + + def test_missing_primary_is_clean_first_boot(self): + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_NONE) + + def test_crc_mismatch_fails_closed_without_rewriting_evidence(self): + corrupt = record("unsafe", 1) + corrupt["crc32"] ^= 1 + self.write_json(self.primary, corrupt) + before = Path(self.primary).read_bytes() + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assertEqual(Path(self.primary).read_bytes(), before) + + def test_unknown_version_wrong_types_extra_keys_and_controls_fail_closed(self): + cases = ( + record("unsafe", 1, version=2), + {"version": 1, "label": 7, "autorun": 1, "crc32": 0}, + record("unsafe", 1, extension=True), + record("line\nbreak", 1), + ) + for index, value in enumerate(cases): + with self.subTest(index=index): + self.write_json(self.primary, value) + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + + def test_v1_autorun_requires_an_exact_integer_zero_or_one(self): + for value in (-1, 2, True, False, 1.0, "1", None): + with self.subTest(value=repr(value)): + invalid = { + "version": 1, + "label": "unsafe", + "autorun": value, + "crc32": 0, + } + self.write_json(self.primary, invalid) + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + + def test_oversize_record_is_rejected_before_unbounded_json_loading(self): + with open(self.primary, "wb") as stream: + stream.write(b" " * 257 + b"{}") + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + + def test_bounded_decoder_failure_selects_defaults_and_fault_marker(self): + self.write_json(self.primary, record("would-run", 1)) + cls = DC.attr(self, "DeviceConfig", "v0.6.1 guarded record decoder") + + with mock.patch.object( + cls, "_decode_record", + side_effect=MemoryError("injected bounded decode failure")): + config = cls(self.root, "9F3A", set_adv_name=self.adv.append) + + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + + def test_legacy_record_loads_and_migrates_only_on_next_successful_set(self): + legacy = {"label": "legacy", "autorun": 1} + self.write_json(self.primary, legacy) + before = Path(self.primary).read_bytes() + config = self.make() + self.assertEqual((config.label, config.auto_run), ("legacy", 1)) + self.assert_fault(config, CONFIG_FAULT_NONE) + self.assertEqual(Path(self.primary).read_bytes(), before, + "boot must not perform a migration write") + self.assertEqual(config.set_autorun(0), OK) + migrated = self.read_json() + self.assertEqual(set(migrated), {"version", "label", "autorun", "crc32"}) + self.assertEqual(migrated["crc32"], canonical_crc("legacy", 0)) + + def test_legacy_record_requires_exactly_two_valid_keys(self): + invalid_legacy = ( + {"label": "legacy", "autorun": 1, "extra": 0}, + {"label": "legacy"}, + {"autorun": 1}, + {"label": 7, "autorun": 1}, + {"label": "line\nbreak", "autorun": 1}, + {"label": "legacy", "autorun": -1}, + {"label": "legacy", "autorun": 2}, + {"label": "legacy", "autorun": True}, + {"label": "legacy", "autorun": False}, + {"label": "legacy", "autorun": 1.0}, + {"label": "legacy", "autorun": "1"}, + {"label": "legacy", "autorun": None}, + ) + for index, value in enumerate(invalid_legacy): + with self.subTest(index=index, value=value): + self.write_json(self.primary, value) + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + + def test_orphan_temp_is_never_promoted(self): + self.write_json(self.temporary, record("uncommitted", 1)) + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_NONE) + self.assertFalse(os.path.exists(self.primary)) + + def test_valid_primary_wins_over_an_orphan_temp(self): + self.write_json(self.primary, record("committed", 0)) + self.write_json(self.temporary, record("uncommitted", 1)) + config = self.make() + self.assertEqual((config.label, config.auto_run), ("committed", 0)) + self.assertFalse( + os.path.exists(self.temporary), + "a valid primary triggers best-effort stale-temp cleanup", + ) + self.assert_fault(config, CONFIG_FAULT_NONE) + + def test_stale_temp_cleanup_failure_does_not_hide_a_valid_primary(self): + self.write_json(self.primary, record("committed", 1)) + self.write_json(self.temporary, record("stale", 0)) + with mock.patch.object( + os, "remove", side_effect=OSError("synthetic stale-temp cleanup")) \ + as remove: + config = self.make() + remove.assert_called_once_with(self.temporary) + self.assertEqual((config.label, config.auto_run), ("committed", 1)) + self.assert_fault(config, CONFIG_FAULT_NONE) + self.assertTrue(os.path.exists(self.temporary), + "best-effort cleanup failure may leave the stale temp") + + def test_corrupt_primary_never_promotes_a_valid_temp(self): + corrupt = record("corrupt-primary", 1) + corrupt["crc32"] ^= 1 + self.write_json(self.primary, corrupt) + self.write_json(self.temporary, record("tempting-temp", 1)) + primary_before = Path(self.primary).read_bytes() + temp_before = Path(self.temporary).read_bytes() + config = self.make() + self.assertEqual((config.label, config.auto_run), ("", 0)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assertEqual( + Path(self.primary).read_bytes(), primary_before, + "boot must preserve corrupt-primary evidence instead of promoting temp", + ) + self.assertNotEqual(Path(self.primary).read_bytes(), temp_before) + + +class AtomicFailureTests(ConfigCase): + class _DelegatingFile: + def __init__(self, wrapped): + self._wrapped = wrapped + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return self._wrapped.__exit__(exc_type, exc, traceback) + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + class _FlushFailure: + def __init__(self, wrapped): + self._wrapped = wrapped + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + self._wrapped.close() + return False + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def write(self, value): + return self._wrapped.write(value) + + def flush(self): + raise OSError("synthetic config flush failure") + + class _ShortWrite(_DelegatingFile): + def write(self, value): + count = max(0, len(value) - 1) + self._wrapped.write(value[:count]) + return count + + class _CloseFailure(_DelegatingFile): + def close(self): + if self._wrapped.closed: + return None + self._wrapped.close() + raise OSError("synthetic config close failure") + + def __exit__(self, exc_type, exc, traceback): + if not self._wrapped.closed: + self.close() + return False + + def assert_old_survives(self, before): + self.assertEqual(Path(self.primary).read_bytes(), before) + loaded = self.make() + self.assertEqual((loaded.label, loaded.auto_run), ("old", 1)) + + def test_temp_flush_failure_returns_eio_and_keeps_old_commit(self): + config, before = self.seed() + real_open = open + + def failing_open(path, mode="r", *args, **kwargs): + opened = real_open(path, mode, *args, **kwargs) + if os.fspath(path) == self.temporary and "w" in mode: + return self._FlushFailure(opened) + return opened + + with mock.patch("builtins.open", side_effect=failing_open): + status = config.set_label(b"new") + self.assertEqual(status, EIO) + self.assertEqual((config.label, config.auto_run), ("old", 1)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assertEqual(self.adv, []) + self.assert_old_survives(before) + + def test_short_temp_write_returns_eio_and_keeps_old_commit(self): + config, before = self.seed() + real_open = open + + def short_open(path, mode="r", *args, **kwargs): + opened = real_open(path, mode, *args, **kwargs) + if os.fspath(path) == self.temporary and "w" in mode: + return self._ShortWrite(opened) + return opened + + with mock.patch("builtins.open", side_effect=short_open): + status = config.set_label(b"new") + self.assertEqual(status, EIO) + self.assertEqual((config.label, config.auto_run), ("old", 1)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assertEqual(self.adv, []) + self.assert_old_survives(before) + + def test_temp_close_failure_returns_eio_and_keeps_old_commit(self): + config, before = self.seed() + real_open = open + + def close_failing_open(path, mode="r", *args, **kwargs): + opened = real_open(path, mode, *args, **kwargs) + if os.fspath(path) == self.temporary and "w" in mode: + return self._CloseFailure(opened) + return opened + + with mock.patch("builtins.open", side_effect=close_failing_open): + status = config.set_autorun(0) + self.assertEqual(status, EIO) + self.assertEqual((config.label, config.auto_run), ("old", 1)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assert_old_survives(before) + + def test_sync_failure_returns_eio_and_keeps_old_commit(self): + config, before = self.seed() + with mock.patch.object( + os, "sync", side_effect=OSError("synthetic config sync failure"), + create=True): + status = config.set_autorun(0) + self.assertEqual(status, EIO) + self.assertEqual((config.label, config.auto_run), ("old", 1)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assert_old_survives(before) + + def test_rename_failure_returns_eio_and_keeps_old_commit(self): + config, before = self.seed() + with mock.patch.object( + os, "rename", side_effect=OSError("synthetic config rename failure")): + status = config.set_label(b"new") + self.assertEqual(status, EIO) + self.assertEqual((config.label, config.auto_run), ("old", 1)) + self.assert_fault(config, CONFIG_FAULT_CORRUPT) + self.assertEqual(self.adv, []) + self.assert_old_survives(before) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_hardening_release_gate.py b/tests/firmware_tests/host/test_v061_hardening_release_gate.py new file mode 100644 index 00000000..b0d73981 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_hardening_release_gate.py @@ -0,0 +1,1200 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED contract for candidate-bound firmware-v0.6.1 hardening evidence. + +The production gate is deliberately a pure, standard-library authority shared +by the physical runner, completion writer, and finalizer. These fixtures are +synthetic and are never physical HIL evidence. +""" + +from __future__ import annotations + +import copy +import concurrent.futures +import hashlib +import importlib.util +import inspect +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +HERE = Path(__file__).resolve().parent +GATE_PATH = ( + ROOT / "firmware" / "qualification" / "v061_hardening_release_gate.py" +) +BENCH_PATH = ROOT / "tests/firmware_tests/hil/v061_hardening_bench.py" +sys.path.insert(0, str(HERE)) +import test_v060_qualification_workflow as v060_fixture # noqa: E402 + +PROFILE_TARGETS = { + "esp32-4mb": "esp32", + "esp32-s3-n16r8": "esp32-s3", + "waveshare-esp32-s3-lcd-147b": "waveshare-esp32-s3-lcd-147b", + "esp32-c3-4mb": "esp32-c3", + "rpi-pico2-w": "rpi-pico2-w", +} +SCENARIO_ORDER = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) +WORKSPACE_ORDER = ( + "erased-media-first-boot", + "nonblank-media-refusal", +) +RESULT_KEYS = ( + "schema_version", + "measurement_contract", + "profile_id", + "target", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "install_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + "scenario_order", + "scenarios", + "workspace_provisioning", + "raw_log_sha256", + "status", +) +SUMMARY_KEYS = ( + "measurement_contract", + "scenario_order", + "scenarios", + "sequential_runs", + "workspace_provisioning", + "private_result_sha256", +) +RECEIPT_KEYS = ( + "schema_version", + "measurement_contract", + "observation_kind", + "profile_id", + "target", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "install_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + "acquisition_sha256", + "raw_log_sha256", + "status", +) + + +def canonical_json_bytes(value: object) -> bytes: + return ( + json.dumps( + value, + indent=2, + sort_keys=False, + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + + +def load_gate(): + if not GATE_PATH.is_file(): + return None, "missing production hardening gate: %s" % GATE_PATH + spec = importlib.util.spec_from_file_location( + "pyble_v061_hardening_release_gate", + GATE_PATH, + ) + if spec is None or spec.loader is None: + return None, "cannot load production hardening gate" + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except Exception as exc: # pragma: no cover - reported by the seam test. + return None, "cannot import production hardening gate: %s" % exc + return module, "" + + +GATE, GATE_LOAD_ERROR = load_gate() +HAVE_GATE = GATE is not None + + +def valid_receipt( + observation_kind: str, + *, + profile_id: str = "esp32-4mb", +) -> dict: + return { + "schema_version": 1, + "measurement_contract": "v061-workspace-provisioning-receipt-v1", + "observation_kind": observation_kind, + "profile_id": profile_id, + "target": PROFILE_TARGETS[profile_id], + "firmware_version": "0.6.1", + "source_commit": "1" * 40, + "candidate_release_json_sha256": "2" * 64, + "install_sha256": "3" * 64, + "qualification_source_commit": "4" * 40, + "qualification_executable_sha256": "5" * 64, + "acquisition_sha256": "a" * 64 if observation_kind == WORKSPACE_ORDER[0] else "b" * 64, + "raw_log_sha256": ( + "6" * 64 + if observation_kind == WORKSPACE_ORDER[0] + else "7" * 64 + ), + "status": "passed", + } + + +def valid_result(*, profile_id: str = "esp32-4mb") -> dict: + erased = canonical_json_bytes( + valid_receipt(WORKSPACE_ORDER[0], profile_id=profile_id) + ) + nonblank = canonical_json_bytes( + valid_receipt(WORKSPACE_ORDER[1], profile_id=profile_id) + ) + scenarios = {} + for name in SCENARIO_ORDER: + scenarios[name] = ( + {"status": "passed", "sequential_runs": 50} + if name == "resource-stability" + else {"status": "passed"} + ) + return { + "schema_version": 1, + "measurement_contract": "v061-hardening-seven-scenario-v1", + "profile_id": profile_id, + "target": PROFILE_TARGETS[profile_id], + "firmware_version": "0.6.1", + "source_commit": "1" * 40, + "candidate_release_json_sha256": "2" * 64, + "install_sha256": "3" * 64, + "qualification_source_commit": "4" * 40, + "qualification_executable_sha256": "5" * 64, + "scenario_order": list(SCENARIO_ORDER), + "scenarios": scenarios, + "workspace_provisioning": { + WORKSPACE_ORDER[0]: { + "status": "passed", + "receipt_file": "erased.json", + "receipt_sha256": hashlib.sha256(erased).hexdigest(), + "acquisition_sha256": "a" * 64, + "raw_log_sha256": "6" * 64, + }, + WORKSPACE_ORDER[1]: { + "status": "passed", + "receipt_file": "nonblank.json", + "receipt_sha256": hashlib.sha256(nonblank).hexdigest(), + "acquisition_sha256": "b" * 64, + "raw_log_sha256": "7" * 64, + }, + }, + "raw_log_sha256": "8" * 64, + "status": "passed", + } + + +def validation_kwargs(profile_id: str = "esp32-4mb") -> dict: + return { + "expected_profile_id": profile_id, + "expected_target": PROFILE_TARGETS[profile_id], + "expected_version": "0.6.1", + "expected_source_commit": "1" * 40, + "candidate_release_json_sha256": "2" * 64, + "expected_install_sha256": "3" * 64, + "expected_qualification_source_commit": "4" * 40, + "expected_qualification_executable_sha256": "5" * 64, + } + + +def private_write(path: Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + path.chmod(0o600) + + +def scenario_results() -> dict: + return { + name: ( + {"status": "passed", "sequential_runs": 50} + if name == "resource-stability" + else {"status": "passed"} + ) + for name in SCENARIO_ORDER + } + + +def canonical_json_lines(values: list[dict]) -> bytes: + return b"".join( + ( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + for value in values + ) + + +def workspace_raw_log(observation_kind: str) -> bytes: + if observation_kind == WORKSPACE_ORDER[0]: + values = [ + {"event": "media-precondition", "state": "erased"}, + {"count": 1, "event": "lfs2-format"}, + {"count": 1, "event": "lfs2-remount"}, + {"event": "service-advertisement", "status": "observed"}, + ] + elif observation_kind == WORKSPACE_ORDER[1]: + values = [ + { + "event": "media-precondition", + "state": "nonblank-incompatible", + }, + {"count": 0, "event": "media-write"}, + {"count": 1, "event": "recovery-message"}, + {"event": "service-advertisement", "status": "absent"}, + ] + else: + raise AssertionError("unknown workspace observation kind") + return canonical_json_lines(values) + + +def write_acquisition_fixture(path, observation_kind, receipt, artifact_bytes): + """Synthetic physical-byte fixture; it is never release/HIL evidence.""" + import test_v061_workspace_acquisition as acquisition + validation = { + "expected_profile_id": receipt["profile_id"], + "expected_target": receipt["target"], + "expected_version": receipt["firmware_version"], + "expected_source_commit": receipt["source_commit"], + "candidate_release_json_sha256": receipt["candidate_release_json_sha256"], + "expected_install_sha256": receipt["install_sha256"], + "expected_qualification_source_commit": receipt["qualification_source_commit"], + "expected_qualification_executable_sha256": receipt["qualification_executable_sha256"], + } + _, _, _, value = acquisition.acquisition_fixture( + path.parent, observation_kind, receipt_name=path.name, + profile_id=receipt["profile_id"], validation=validation, install=artifact_bytes) + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def attach_result_workspace(path, value, artifact_bytes): + """Attach synthetic measured-byte siblings to a private-result fixture.""" + for kind, suffix in zip(WORKSPACE_ORDER, ("erased", "nonblank")): + receipt_path = path.with_name(path.stem + "-" + suffix + ".json") + receipt = valid_receipt(kind, profile_id=value["profile_id"]) + for field in ("source_commit", "candidate_release_json_sha256", "install_sha256", + "qualification_source_commit", "qualification_executable_sha256"): + receipt[field] = value[field] + raw = workspace_raw_log(kind) + private_write(receipt_path.with_name(receipt_path.stem + "-raw.jsonl"), raw) + receipt["raw_log_sha256"] = hashlib.sha256(raw).hexdigest() + receipt["acquisition_sha256"] = write_acquisition_fixture( + receipt_path, kind, receipt, artifact_bytes) + receipt_raw = canonical_json_bytes(receipt) + private_write(receipt_path, receipt_raw) + value["workspace_provisioning"][kind] = { + "status": "passed", "receipt_file": receipt_path.name, + "receipt_sha256": hashlib.sha256(receipt_raw).hexdigest(), + "acquisition_sha256": receipt["acquisition_sha256"], + "raw_log_sha256": receipt["raw_log_sha256"], + } + + +def writer_fixture(root: Path, profile_id: str) -> dict[str, object]: + candidate = root / "candidate" + filename = "firmware.uf2" if profile_id == "rpi-pico2-w" else "firmware.bin" + artifact = candidate / profile_id / filename + artifact_bytes = ("synthetic %s v0.6.1 firmware" % profile_id).encode("ascii") + private_write(artifact, artifact_bytes) + release = v060_fixture.candidate_release( + artifact_bytes, + version="0.6.1", + ) + profile = next(item for item in release["profiles"] if item["id"] == profile_id) + profile["install"]["path"] = "%s/%s" % (profile_id, filename) + profile["install"]["size"] = len(artifact_bytes) + profile["install"]["sha256"] = hashlib.sha256(artifact_bytes).hexdigest() + release_path = candidate / "release.json" + release_path.write_bytes(v060_fixture.canonical_json_bytes(release)) + candidate_digest = hashlib.sha256(release_path.read_bytes()).hexdigest() + install_digest = hashlib.sha256(artifact_bytes).hexdigest() + qualification_source_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + qualification_executable_sha256 = hashlib.sha256( + BENCH_PATH.read_bytes() + ).hexdigest() + receipts = [] + for observation_kind, stem in zip(WORKSPACE_ORDER, ("erased", "nonblank")): + path = root / (stem + ".json") + boot_log = root / (stem + "-raw.jsonl") + private_write(boot_log, workspace_raw_log(observation_kind)) + receipt = valid_receipt(observation_kind, profile_id=profile_id) + receipt["candidate_release_json_sha256"] = candidate_digest + receipt["install_sha256"] = install_digest + receipt["qualification_source_commit"] = qualification_source_commit + receipt["qualification_executable_sha256"] = ( + qualification_executable_sha256 + ) + receipt["raw_log_sha256"] = hashlib.sha256( + boot_log.read_bytes() + ).hexdigest() + receipt["acquisition_sha256"] = write_acquisition_fixture( + path, observation_kind, receipt, artifact_bytes) + private_write(path, canonical_json_bytes(receipt)) + receipts.append(path) + raw_log = root / "hardening.jsonl" + private_write( + raw_log, + canonical_json_lines( + [ + ( + { + "scenario": name, + "sequential_runs": 50, + "status": "passed", + } + if name == "resource-stability" + else {"scenario": name, "status": "passed"} + ) + for name in SCENARIO_ORDER + ] + ), + ) + return { + "candidate": candidate, + "artifact": artifact, + "release_path": release_path, + "candidate_digest": candidate_digest, + "install_digest": install_digest, + "qualification_source_commit": qualification_source_commit, + "qualification_executable_sha256": qualification_executable_sha256, + "erased": receipts[0], + "nonblank": receipts[1], + "raw_log": raw_log, + "output": root / "hardening-result.json", + } + + +class V061HardeningGateSeamTests(unittest.TestCase): + def test_shared_gate_and_exact_public_functions_exist(self): + self.assertIsNotNone( + GATE, + "[red] %s" % GATE_LOAD_ERROR, + ) + if GATE is None: + return + expected = { + "canonical_json_bytes": {"value"}, + "validate_workspace_receipt_payload": { + "value", + "observation_kind", + *validation_kwargs(), + }, + "validate_workspace_receipt_file": { + "path", + "observation_kind", + *validation_kwargs(), + }, + "validate_result_payload": {"value", *validation_kwargs()}, + "validate_result_file": { + "path", + "artifact_path", + "expected_profile_id", + "expected_target", + "expected_version", + "expected_source_commit", + "candidate_release_json_sha256", + "expected_qualification_source_commit", + "expected_qualification_executable_sha256", + }, + "create_result": { + "candidate_dir", + "profile_id", + "scenario_results", + "workspace_erased_receipt", + "workspace_nonblank_receipt", + "raw_log", + "output_path", + "qualification_repo_root", + }, + "create_workspace_receipt": { + "candidate_dir", + "profile_id", + "observation_kind", + "raw_boot_log", + "output_path", + "qualification_repo_root", + }, + } + for name, parameters in expected.items(): + with self.subTest(function=name): + function = getattr(GATE, name, None) + self.assertTrue(callable(function), "missing %s" % name) + if callable(function): + self.assertEqual( + set(inspect.signature(function).parameters), + set(parameters), + ) + + def test_frozen_constant_inventory_is_exact(self): + self.assertIsNotNone(GATE, GATE_LOAD_ERROR) + if GATE is None: + return + self.assertEqual(GATE.PROFILE_TARGETS, PROFILE_TARGETS) + self.assertEqual(GATE.SCENARIO_ORDER, SCENARIO_ORDER) + self.assertEqual(GATE.WORKSPACE_PROVISIONING_ORDER, WORKSPACE_ORDER) + self.assertEqual(GATE.RESULT_KEYS, RESULT_KEYS) + self.assertEqual(GATE.PUBLIC_SUMMARY_KEYS, SUMMARY_KEYS) + self.assertEqual(GATE.WORKSPACE_RECEIPT_KEYS, RECEIPT_KEYS) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is not implemented") +class V061HardeningPayloadValidationTests(unittest.TestCase): + def validate(self, value: dict, *, profile_id: str = "esp32-4mb") -> dict: + return GATE.validate_result_payload( + value, + **validation_kwargs(profile_id), + ) + + def test_exact_result_derives_only_the_privacy_safe_summary(self): + value = valid_result() + summary = self.validate(value) + self.assertEqual(tuple(value), RESULT_KEYS) + self.assertEqual(tuple(value["scenarios"]), SCENARIO_ORDER) + self.assertEqual( + tuple(value["workspace_provisioning"]), + WORKSPACE_ORDER, + ) + self.assertEqual(tuple(summary), SUMMARY_KEYS) + self.assertEqual( + summary, + { + "measurement_contract": "v061-hardening-seven-scenario-v1", + "scenario_order": list(SCENARIO_ORDER), + "scenarios": {name: "passed" for name in SCENARIO_ORDER}, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in WORKSPACE_ORDER + }, + "private_result_sha256": hashlib.sha256( + canonical_json_bytes(value) + ).hexdigest(), + }, + ) + encoded = canonical_json_bytes(summary) + for private in ( + value["source_commit"], + value["install_sha256"], + value["qualification_source_commit"], + value["qualification_executable_sha256"], + value["raw_log_sha256"], + "device_id", + "address", + "label", + "console", + "exception", + ): + self.assertNotIn(private.encode("ascii"), encoded) + + def test_missing_extra_reordered_or_nonpassing_scenario_is_rejected(self): + mutations = { + "missing": lambda value: value["scenarios"].pop(SCENARIO_ORDER[0]), + "extra": lambda value: value["scenarios"].__setitem__( + "future-scenario", {"status": "passed"} + ), + "reordered-list": lambda value: value["scenario_order"].reverse(), + "reordered-map": lambda value: value.__setitem__( + "scenarios", + dict(reversed(tuple(value["scenarios"].items()))), + ), + "failed": lambda value: value["scenarios"][SCENARIO_ORDER[0]].__setitem__( + "status", "failed" + ), + "scenario-extra": lambda value: value["scenarios"][ + SCENARIO_ORDER[0] + ].__setitem__("detail", "private"), + "runs-49": lambda value: value["scenarios"][ + "resource-stability" + ].__setitem__("sequential_runs", 49), + "runs-bool": lambda value: value["scenarios"][ + "resource-stability" + ].__setitem__("sequential_runs", True), + } + for name, mutate in mutations.items(): + with self.subTest(case=name): + value = valid_result() + mutate(value) + with self.assertRaises(GATE.QualificationError): + self.validate(value) + + def test_workspace_not_run_missing_shared_or_reordered_receipts_are_rejected(self): + mutations = { + "not-run": lambda value: value["workspace_provisioning"][ + WORKSPACE_ORDER[0] + ].__setitem__("status", "NOT-RUN"), + "missing": lambda value: value["workspace_provisioning"].pop( + WORKSPACE_ORDER[1] + ), + "extra": lambda value: value["workspace_provisioning"].__setitem__( + "configuration-corruption-proxy", + { + "status": "passed", + "receipt_sha256": "9" * 64, + "raw_log_sha256": "a" * 64, + }, + ), + "reordered": lambda value: value.__setitem__( + "workspace_provisioning", + dict(reversed(tuple(value["workspace_provisioning"].items()))), + ), + "shared-receipt": lambda value: value["workspace_provisioning"][ + WORKSPACE_ORDER[1] + ].__setitem__( + "receipt_sha256", + value["workspace_provisioning"][WORKSPACE_ORDER[0]][ + "receipt_sha256" + ], + ), + "shared-log": lambda value: value["workspace_provisioning"][ + WORKSPACE_ORDER[1] + ].__setitem__( + "raw_log_sha256", + value["workspace_provisioning"][WORKSPACE_ORDER[0]][ + "raw_log_sha256" + ], + ), + } + for name, mutate in mutations.items(): + with self.subTest(case=name): + value = valid_result() + mutate(value) + with self.assertRaises(GATE.QualificationError): + self.validate(value) + + def test_every_candidate_and_qualification_identity_is_enforced(self): + mutations = { + "profile": ("profile_id", "esp32-s3-n16r8"), + "target": ("target", "esp32-s3"), + "version-old": ("firmware_version", "0.6.0"), + "version-new": ("firmware_version", "0.6.1+local"), + "source": ("source_commit", "9" * 40), + "candidate": ("candidate_release_json_sha256", "9" * 64), + "install": ("install_sha256", "9" * 64), + "qualification-source": ("qualification_source_commit", "9" * 40), + "qualification-executable": ( + "qualification_executable_sha256", + "9" * 64, + ), + "raw-log-uppercase": ("raw_log_sha256", "A" * 64), + "status": ("status", "pending"), + } + for name, (field, replacement) in mutations.items(): + with self.subTest(case=name): + value = valid_result() + value[field] = replacement + with self.assertRaises(GATE.QualificationError): + self.validate(value) + + for name, mutation in ( + ("missing", lambda value: value.pop("raw_log_sha256")), + ("extra", lambda value: value.__setitem__("private_address", "secret")), + ("bool-schema", lambda value: value.__setitem__("schema_version", True)), + ): + with self.subTest(case=name): + value = valid_result() + mutation(value) + with self.assertRaises(GATE.QualificationError): + self.validate(value) + + def test_workspace_receipts_are_exact_and_candidate_bound(self): + for observation_kind in WORKSPACE_ORDER: + receipt = valid_receipt(observation_kind) + with self.subTest(kind=observation_kind): + self.assertEqual(tuple(receipt), RECEIPT_KEYS) + self.assertEqual( + GATE.validate_workspace_receipt_payload( + receipt, + observation_kind=observation_kind, + **validation_kwargs(), + ), + { + "status": "passed", + "receipt_sha256": hashlib.sha256( + canonical_json_bytes(receipt) + ).hexdigest(), + "acquisition_sha256": receipt["acquisition_sha256"], + "raw_log_sha256": receipt["raw_log_sha256"], + }, + ) + + identity_fields = ( + "profile_id", + "target", + "firmware_version", + "source_commit", + "candidate_release_json_sha256", + "install_sha256", + "qualification_source_commit", + "qualification_executable_sha256", + ) + for field in identity_fields: + with self.subTest(field=field): + receipt = valid_receipt(WORKSPACE_ORDER[0]) + receipt[field] = "9" * len(receipt[field]) + with self.assertRaises(GATE.QualificationError): + GATE.validate_workspace_receipt_payload( + receipt, + observation_kind=WORKSPACE_ORDER[0], + **validation_kwargs(), + ) + + swapped = valid_receipt(WORKSPACE_ORDER[1]) + with self.assertRaises(GATE.QualificationError): + GATE.validate_workspace_receipt_payload( + swapped, + observation_kind=WORKSPACE_ORDER[0], + **validation_kwargs(), + ) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is not implemented") +class V061WorkspaceReceiptWriterTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-workspace-receipt-" + ) + self.root = Path(self.temporary.name) + self.candidate = self.root / "candidate" + self.profile_id = "esp32-c3-4mb" + self.artifact = self.candidate / self.profile_id / "firmware.bin" + private_write(self.artifact, b"synthetic v0.6.1 candidate firmware") + release = v060_fixture.candidate_release( + self.artifact.read_bytes(), + version="0.6.1", + ) + self.release_path = self.candidate / "release.json" + self.release_path.write_bytes(v060_fixture.canonical_json_bytes(release)) + + def tearDown(self): + self.temporary.cleanup() + + def paths(self, observation_kind: str) -> tuple[Path, Path]: + stem = "erased" if observation_kind == WORKSPACE_ORDER[0] else "nonblank" + output = self.root / (stem + ".json") + return output.with_name(output.stem + "-raw.jsonl"), output + + def create(self, observation_kind: str, *, raw_path=None, output_path=None): + expected_raw, expected_output = self.paths(observation_kind) + selected_raw = expected_raw if raw_path is None else Path(raw_path) + selected_output = expected_output if output_path is None else Path(output_path) + if not selected_raw.exists() and not selected_raw.is_symlink(): + private_write(selected_raw, workspace_raw_log(observation_kind)) + acquisition = selected_output.with_name(selected_output.stem + "-acquisition.json") + if selected_output.parent == self.root and not acquisition.exists(): + receipt = valid_receipt(observation_kind, profile_id=self.profile_id) + receipt["candidate_release_json_sha256"] = hashlib.sha256(self.release_path.read_bytes()).hexdigest() + receipt["install_sha256"] = hashlib.sha256(self.artifact.read_bytes()).hexdigest() + receipt["qualification_source_commit"] = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=ROOT, check=True, + capture_output=True, text=True).stdout.strip() + receipt["qualification_executable_sha256"] = hashlib.sha256(BENCH_PATH.read_bytes()).hexdigest() + write_acquisition_fixture(selected_output, observation_kind, receipt, self.artifact.read_bytes()) + return GATE.create_workspace_receipt( + candidate_dir=self.candidate, + profile_id=self.profile_id, + observation_kind=observation_kind, + raw_boot_log=selected_raw, + output_path=selected_output, + qualification_repo_root=ROOT, + ) + + def test_writer_derives_both_exact_candidate_bound_receipts(self): + qualification_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + for observation_kind in WORKSPACE_ORDER: + with self.subTest(kind=observation_kind): + raw_path, output = self.paths(observation_kind) + self.assertEqual(self.create(observation_kind), output) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + value = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(tuple(value), RECEIPT_KEYS) + self.assertEqual(value["observation_kind"], observation_kind) + self.assertEqual(value["profile_id"], self.profile_id) + self.assertEqual(value["target"], PROFILE_TARGETS[self.profile_id]) + self.assertEqual(value["firmware_version"], "0.6.1") + self.assertEqual(value["source_commit"], "1" * 40) + self.assertEqual( + value["candidate_release_json_sha256"], + hashlib.sha256(self.release_path.read_bytes()).hexdigest(), + ) + self.assertEqual( + value["install_sha256"], + hashlib.sha256(self.artifact.read_bytes()).hexdigest(), + ) + self.assertEqual( + value["qualification_source_commit"], + qualification_commit, + ) + self.assertEqual( + value["qualification_executable_sha256"], + hashlib.sha256(BENCH_PATH.read_bytes()).hexdigest(), + ) + self.assertEqual( + value["raw_log_sha256"], + hashlib.sha256(raw_path.read_bytes()).hexdigest(), + ) + self.assertEqual(output.read_bytes(), canonical_json_bytes(value)) + + def test_writer_rejects_invalid_logs_links_paths_and_existing_output(self): + invalid_values = { + "empty": b"", + "reordered": canonical_json_lines( + list( + reversed( + [ + {"event": "media-precondition", "state": "erased"}, + {"count": 1, "event": "lfs2-format"}, + {"count": 1, "event": "lfs2-remount"}, + { + "event": "service-advertisement", + "status": "observed", + }, + ] + ) + ) + ), + "two-formats": canonical_json_lines( + [ + {"event": "media-precondition", "state": "erased"}, + {"count": 2, "event": "lfs2-format"}, + {"count": 1, "event": "lfs2-remount"}, + {"event": "service-advertisement", "status": "observed"}, + ] + ), + "free-text": canonical_json_lines( + [{"detail": "operator assertion", "status": "passed"}] + ), + } + for label, raw in invalid_values.items(): + with self.subTest(log=label): + raw_path = self.root / (label + "-raw.jsonl") + output = self.root / (label + ".json") + private_write(raw_path, raw) + with self.assertRaises(GATE.QualificationError): + self.create( + WORKSPACE_ORDER[0], + raw_path=raw_path, + output_path=output, + ) + self.assertFalse(output.exists()) + + raw_path, output = self.paths(WORKSPACE_ORDER[0]) + private_write(raw_path, workspace_raw_log(WORKSPACE_ORDER[0])) + output.write_bytes(b"owner data\n") + output.chmod(0o600) + with self.assertRaises(GATE.QualificationError): + self.create(WORKSPACE_ORDER[0]) + self.assertEqual(output.read_bytes(), b"owner data\n") + + output.unlink() + raw_path.chmod(0o644) + with self.assertRaises(GATE.QualificationError): + self.create(WORKSPACE_ORDER[0]) + self.assertFalse(output.exists()) + raw_path.chmod(0o600) + + linked = self.root / "raw-second-link.jsonl" + os.link(raw_path, linked) + try: + with self.assertRaises(GATE.QualificationError): + self.create(WORKSPACE_ORDER[0]) + self.assertFalse(output.exists()) + finally: + linked.unlink() + + inside = self.candidate / "workspace.json" + with self.assertRaises(GATE.QualificationError): + self.create(WORKSPACE_ORDER[0], output_path=inside) + self.assertFalse(inside.exists()) + + wrong_raw = self.root / "not-the-derived-name.jsonl" + private_write(wrong_raw, workspace_raw_log(WORKSPACE_ORDER[0])) + with self.assertRaises(GATE.QualificationError): + self.create(WORKSPACE_ORDER[0], raw_path=wrong_raw) + self.assertFalse(output.exists()) + + def test_result_file_must_be_canonical_regular_private_and_unlinked(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-gate-") as tmp: + root = Path(tmp) + artifact = root / "firmware.bin" + artifact.write_bytes(b"candidate firmware") + value = valid_result() + value["install_sha256"] = hashlib.sha256( + artifact.read_bytes() + ).hexdigest() + path = root / "result.json" + attach_result_workspace(path, value, artifact.read_bytes()) + path.write_bytes(canonical_json_bytes(value)) + path.chmod(0o600) + kwargs = validation_kwargs() + kwargs.pop("expected_install_sha256") + self.assertEqual( + GATE.validate_result_file( + path, + artifact_path=artifact, + **kwargs, + )["private_result_sha256"], + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + + for case, prepare in ( + ("mode", lambda: path.chmod(0o644)), + ( + "noncanonical", + lambda: path.write_text(json.dumps(value) + "\n", encoding="utf-8"), + ), + ): + path.write_bytes(canonical_json_bytes(value)) + path.chmod(0o600) + prepare() + with self.subTest(case=case), self.assertRaises( + GATE.QualificationError + ): + GATE.validate_result_file( + path, + artifact_path=artifact, + **kwargs, + ) + + path.write_bytes(canonical_json_bytes(value)) + path.chmod(0o600) + linked = root / "linked.json" + os.link(path, linked) + with self.assertRaises(GATE.QualificationError): + GATE.validate_result_file( + path, + artifact_path=artifact, + **kwargs, + ) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is not implemented") +class V061HardeningResultWriterTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="pyble-v061-writer-") + self.root = Path(self.temporary.name) + self.profile_id = "esp32-c3-4mb" + fixture = writer_fixture(self.root, self.profile_id) + for name, value in fixture.items(): + setattr(self, name, value) + + def tearDown(self): + self.temporary.cleanup() + + def create(self, *, output: Path | None = None): + return GATE.create_result( + candidate_dir=self.candidate, + profile_id=self.profile_id, + scenario_results=scenario_results(), + workspace_erased_receipt=self.erased, + workspace_nonblank_receipt=self.nonblank, + raw_log=self.raw_log, + output_path=self.output if output is None else output, + qualification_repo_root=ROOT, + ) + + def test_writer_derives_every_identity_and_publishes_one_private_result(self): + self.assertEqual(self.create(), self.output) + self.assertEqual(self.output.stat().st_mode & 0o777, 0o600) + value = json.loads(self.output.read_text(encoding="utf-8")) + self.assertEqual(tuple(value), RESULT_KEYS) + self.assertEqual(value["source_commit"], "1" * 40) + self.assertEqual( + value["candidate_release_json_sha256"], + self.candidate_digest, + ) + self.assertEqual(value["install_sha256"], self.install_digest) + self.assertEqual( + value["qualification_source_commit"], + self.qualification_source_commit, + ) + self.assertEqual( + value["qualification_executable_sha256"], + self.qualification_executable_sha256, + ) + self.assertEqual( + value["raw_log_sha256"], + hashlib.sha256(self.raw_log.read_bytes()).hexdigest(), + ) + self.assertEqual( + self.output.read_bytes(), + canonical_json_bytes(value), + ) + + def test_writer_admits_each_exact_release_profile_and_install_shape(self): + for profile_id in PROFILE_TARGETS: + with self.subTest(profile_id=profile_id): + case_root = self.root / ("profile-" + profile_id) + fixture = writer_fixture(case_root, profile_id) + output = GATE.create_result( + candidate_dir=fixture["candidate"], + profile_id=profile_id, + scenario_results=scenario_results(), + workspace_erased_receipt=fixture["erased"], + workspace_nonblank_receipt=fixture["nonblank"], + raw_log=fixture["raw_log"], + output_path=fixture["output"], + qualification_repo_root=ROOT, + ) + value = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(value["profile_id"], profile_id) + self.assertEqual(value["target"], PROFILE_TARGETS[profile_id]) + self.assertEqual( + value["install_sha256"], + fixture["install_digest"], + ) + expected_name = ( + "firmware.uf2" + if profile_id == "rpi-pico2-w" + else "firmware.bin" + ) + self.assertEqual(Path(fixture["artifact"]).name, expected_name) + + def test_existing_or_candidate_internal_output_is_never_replaced(self): + private_write(self.output, b"operator-owned bytes\n") + with self.assertRaises(GATE.QualificationError): + self.create() + self.assertEqual(self.output.read_bytes(), b"operator-owned bytes\n") + + self.output.unlink() + inside = self.candidate / "forbidden-result.json" + with self.assertRaises(GATE.QualificationError): + self.create(output=inside) + self.assertFalse(inside.exists()) + + def test_nonprivate_linked_or_noncanonical_inputs_mint_no_result(self): + cases = {} + self.raw_log.chmod(0o644) + cases["public-raw-log"] = lambda: self.raw_log.chmod(0o600) + + for name, restore in cases.items(): + with self.subTest(case=name), self.assertRaises( + GATE.QualificationError + ): + self.create() + self.assertFalse(self.output.exists()) + restore() + + linked = self.root / "linked-erased.json" + os.link(self.erased, linked) + try: + with self.assertRaises(GATE.QualificationError): + self.create() + self.assertFalse(self.output.exists()) + finally: + linked.unlink() + + original = self.raw_log.read_bytes() + invalid_logs = { + "empty": b"", + "whitespace": b'{"status": "passed"}\n', + "missing-final-lf": b'{"status":"passed"}', + "blank-line": b'{"status":"passed"}\n\n', + "non-object": b'[]\n', + "non-finite": b'{"measurement":NaN}\n', + "invalid-utf8": b'{"status":"\xff"}\n', + "private-field": canonical_json_lines( + [{"address": "private-address", "status": "passed"}] + ), + "device-id": canonical_json_lines( + [{"device_id": "private-id", "status": "passed"}] + ), + "label": canonical_json_lines( + [{"label": "private-label", "status": "passed"}] + ), + "source": canonical_json_lines( + [{"source": "print('secret')", "status": "passed"}] + ), + "file-content": canonical_json_lines( + [{"file_content": "secret bytes", "status": "passed"}] + ), + "console": canonical_json_lines( + [{"console": "private output", "status": "passed"}] + ), + "exception": canonical_json_lines( + [{"exception": "private traceback", "status": "passed"}] + ), + "unbounded-free-text": canonical_json_lines( + [{"detail": "operator supplied text", "status": "passed"}] + ), + "private-scenario-value": canonical_json_lines( + [{"scenario": "private-label", "status": "passed"}] + ), + "unbounded-number": canonical_json_lines( + [ + { + "scenario": "resource-stability", + "sequential_runs": 10**100, + "status": "passed", + } + ] + ), + } + for name, raw in invalid_logs.items(): + with self.subTest(raw_log=name): + self.raw_log.write_bytes(raw) + self.raw_log.chmod(0o600) + with self.assertRaises(GATE.QualificationError): + self.create() + self.assertFalse(self.output.exists()) + self.raw_log.write_bytes(original) + self.raw_log.chmod(0o600) + + self.erased.write_text( + json.dumps(json.loads(self.erased.read_text(encoding="utf-8"))) + + "\n", + encoding="utf-8", + ) + self.erased.chmod(0o600) + with self.assertRaises(GATE.QualificationError): + self.create() + self.assertFalse(self.output.exists()) + + def test_link_special_and_symlink_parent_paths_mint_no_result(self): + cases = ( + "raw-symlink", + "raw-broken-symlink", + "raw-hardlink", + "raw-directory", + "receipt-symlink", + "receipt-broken-symlink", + "receipt-public-mode", + "receipt-hardlink", + "output-symlink", + "output-broken-symlink", + "output-directory", + "output-symlink-parent", + ) + for label in cases: + with self.subTest(path_case=label): + case_root = self.root / ("unsafe-" + label) + fixture = writer_fixture(case_root, self.profile_id) + raw_log = Path(fixture["raw_log"]) + erased = Path(fixture["erased"]) + output = Path(fixture["output"]) + external_sentinel = None + if label == "raw-symlink": + target = raw_log.with_name("real-hardening.jsonl") + raw_log.rename(target) + raw_log.symlink_to(target) + elif label == "raw-broken-symlink": + raw_log.unlink() + raw_log.symlink_to(raw_log.with_name("missing.jsonl")) + elif label == "raw-hardlink": + os.link(raw_log, raw_log.with_name("raw-second-link.jsonl")) + elif label == "raw-directory": + raw_log.unlink() + raw_log.mkdir() + elif label == "receipt-symlink": + target = erased.with_name("real-erased.json") + erased.rename(target) + erased.symlink_to(target) + elif label == "receipt-broken-symlink": + erased.unlink() + erased.symlink_to(erased.with_name("missing.json")) + elif label == "receipt-public-mode": + erased.chmod(0o644) + elif label == "receipt-hardlink": + os.link(erased, erased.with_name("receipt-second-link.json")) + elif label == "output-symlink": + external_sentinel = output.with_name("external-owner-data") + external_sentinel.write_bytes(b"owner bytes\n") + output.symlink_to(external_sentinel) + elif label == "output-broken-symlink": + output.symlink_to(output.with_name("missing-output.json")) + elif label == "output-directory": + output.mkdir() + elif label == "output-symlink-parent": + external_parent = case_root / "external-parent" + external_parent.mkdir() + linked_parent = case_root / "linked-parent" + linked_parent.symlink_to(external_parent, target_is_directory=True) + output = linked_parent / "hardening-result.json" + + with self.assertRaises(GATE.QualificationError): + GATE.create_result( + candidate_dir=fixture["candidate"], + profile_id=self.profile_id, + scenario_results=scenario_results(), + workspace_erased_receipt=erased, + workspace_nonblank_receipt=fixture["nonblank"], + raw_log=raw_log, + output_path=output, + qualification_repo_root=ROOT, + ) + if external_sentinel is not None: + self.assertEqual( + external_sentinel.read_bytes(), + b"owner bytes\n", + ) + if label == "output-symlink-parent": + self.assertFalse( + (case_root / "external-parent" / output.name).exists() + ) + + def test_concurrent_result_creators_have_exactly_one_winner(self): + case_root = self.root / "concurrent-output" + fixture = writer_fixture(case_root, self.profile_id) + + def attempt(): + try: + return GATE.create_result( + candidate_dir=fixture["candidate"], + profile_id=self.profile_id, + scenario_results=scenario_results(), + workspace_erased_receipt=fixture["erased"], + workspace_nonblank_receipt=fixture["nonblank"], + raw_log=fixture["raw_log"], + output_path=fixture["output"], + qualification_repo_root=ROOT, + ) + except GATE.QualificationError: + return None + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(lambda _: attempt(), range(2))) + self.assertEqual(sum(value is not None for value in outcomes), 1) + output = Path(fixture["output"]) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + value = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(output.read_bytes(), canonical_json_bytes(value)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_hil_bench_contract.py b/tests/firmware_tests/host/test_v061_hil_bench_contract.py new file mode 100644 index 00000000..f54e0970 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_hil_bench_contract.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Contract for the target-neutral firmware-v0.6.1 hardening HIL bench. + +These tests inspect deterministic host-side seams only. They do not simulate a +board and cannot be cited as physical HIL evidence. +""" + +from __future__ import annotations + +import importlib.util +import inspect +from pathlib import Path +import sys +import tempfile +import unittest + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +HIL = HERE.parent / "hil" +BENCH_PATH = HIL / "v061_hardening_bench.py" +sys.path.insert(0, str(HIL)) + + +PROFILE_CHIPS = { + "esp32-4mb": "esp32", + "esp32-s3-n16r8": "esp32-s3", + "waveshare-esp32-s3-lcd-147b": "esp32-s3", + "esp32-c3-4mb": "esp32-c3", + "rpi-pico2-w": "rpi-pico2-w", +} + +PROFILE_BOARD_IDENTITIES = { + "esp32-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32 development board", + "module_marking": "ESP32-D0WD revision v1.0 (esptool)", + }, + "esp32-s3-n16r8": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-S3 development board", + "module_marking": "ESP32-S3 QFN56 revision v0.1 (esptool)", + }, + "waveshare-esp32-s3-lcd-147b": { + "board_manufacturer": "Waveshare", + "board_model": "ESP32-S3-LCD-1.47B", + "module_marking": "ESP32-S3R8", + }, + "esp32-c3-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-C3 development board", + "module_marking": "ESP32-C3 QFN32 revision v0.4 (esptool)", + }, + "rpi-pico2-w": { + "board_manufacturer": "Raspberry Pi Ltd", + "board_model": "Raspberry Pi Pico 2 W", + "module_marking": "RP2350 + CYW43439", + }, +} + +SCENARIOS = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) + +EVIDENCE_OPTIONS = ( + "--candidate-dir", + "--workspace-erased-receipt", + "--workspace-nonblank-receipt", + "--raw-log", + "--result", +) + + +def evidence_argv(root="/private/v061", profile="esp32-4mb"): + identity = PROFILE_BOARD_IDENTITIES[profile] + return [ + "--candidate-dir", + root + "/candidate", + "--workspace-erased-receipt", + root + "/erased-media.json", + "--workspace-nonblank-receipt", + root + "/nonblank-media.json", + "--raw-log", + root + "/hardening.jsonl", + "--result", + root + "/hardening-result.json", + "--board-manufacturer", + identity["board_manufacturer"], + "--board-model", + identity["board_model"], + "--module-marking", + identity["module_marking"], + ] + + +def load_bench(): + if not BENCH_PATH.is_file(): + raise AssertionError("missing v0.6.1 HIL bench: %s" % BENCH_PATH.name) + spec = importlib.util.spec_from_file_location("v061_hardening_bench", BENCH_PATH) + if spec is None or spec.loader is None: + raise AssertionError("cannot load %s" % BENCH_PATH.name) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class V061HilBenchContractTests(unittest.TestCase): + def test_cli_requires_exact_profile_address_and_candidate_bound_outputs(self): + bench = load_bench() + self.assertEqual(bench.PROFILE_CHIPS, PROFILE_CHIPS) + self.assertEqual(bench.PROFILE_ORDER, tuple(PROFILE_CHIPS)) + + for argv in ( + [], + ["--address", "private-address"], + ["--profile", "esp32-4mb"], + [ + "--profile", + "esp32", + "--address", + "private-address", + *evidence_argv(), + ], + ): + with self.subTest(argv=argv), self.assertRaises(SystemExit): + bench._parse_args(argv) + + with tempfile.TemporaryDirectory(prefix="pyble-v061-cli-valid-") as tmp: + root = Path(tmp) + (root / "candidate").mkdir() + for name in ("erased-media.json", "nonblank-media.json"): + receipt = root / name + receipt.write_text("{}\n", encoding="utf-8") + receipt.chmod(0o600) + complete = [ + "--profile", + "esp32-4mb", + "--address", + "private-address", + *evidence_argv(str(root)), + ] + for option in EVIDENCE_OPTIONS: + index = complete.index(option) + incomplete = complete[:index] + complete[index + 2 :] + with self.subTest(missing=option), self.assertRaises(SystemExit): + bench._parse_args(incomplete) + + try: + args = bench._parse_args(complete) + except SystemExit as exc: + self.fail( + "[red] complete candidate-bound v0.6.1 CLI was rejected: %s" + % exc + ) + self.assertEqual(args.profile, "esp32-4mb") + self.assertEqual(args.expect_agent, "0.6.1") + self.assertEqual(args.address, "private-address") + self.assertEqual(args.candidate_dir, root / "candidate") + self.assertEqual( + args.workspace_erased_receipt, + root / "erased-media.json", + ) + self.assertEqual( + args.workspace_nonblank_receipt, + root / "nonblank-media.json", + ) + self.assertEqual(args.raw_log, root / "hardening.jsonl") + self.assertEqual(args.result, root / "hardening-result.json") + for substituted_version in ("0.6.0", "0.6.2", "1.0.0"): + with self.subTest(expect_agent=substituted_version): + with self.assertRaises(SystemExit): + bench._parse_args( + [ + *complete, + "--expect-agent", + substituted_version, + ] + ) + + def test_workspace_not_run_and_legacy_acknowledgement_flags_are_ineligible(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-cli-legacy-") as tmp: + root = Path(tmp) + (root / "candidate").mkdir() + for name in ("erased-media.json", "nonblank-media.json"): + receipt = root / name + receipt.write_text("{}\n", encoding="utf-8") + receipt.chmod(0o600) + base = [ + "--profile", + "esp32-4mb", + "--address", + "private-address", + *evidence_argv(str(root)), + ] + try: + bench._parse_args(base) + except SystemExit as exc: + self.fail("[red] safe v0.6.1 base CLI was rejected: %s" % exc) + for legacy in ( + "--require-workspace-provisioning", + "--sacrificial-media", + ): + with self.subTest(option=legacy), self.assertRaises(SystemExit): + bench._parse_args([*base, legacy]) + + source = BENCH_PATH.read_text(encoding="utf-8") + self.assertNotIn("NOT-RUN(nondestructive)", source) + self.assertNotIn( + "requires-out-of-band-blank-and-nonblank-boot-observations", + source, + ) + + def test_scenario_inventory_is_v061_only_and_is_executed(self): + bench = load_bench() + self.assertEqual(bench.SCENARIO_ORDER, SCENARIOS) + live_run = inspect.getsource(bench.run) + required = { + "transport-session": "run_transport_session", + "fragment-hardening": "run_fragment_hardening", + "run-isolation": "run_fresh_globals", + "resource-stability": "run_resource_stability", + "stdin-isolation": "run_stdin_isolation", + "configuration-durability": "run_label_durability", + "filesystem-hardening": "run_filesystem_hardening", + } + for scenario, function_name in required.items(): + with self.subTest(scenario=scenario): + self.assertIn(function_name, live_run) + self.assertTrue(callable(getattr(bench, function_name))) + + source = BENCH_PATH.read_text(encoding="utf-8") + for helper in ("_pble_wire", "_pble_central", "target_smoke", "_pble_bench"): + self.assertIn(helper, source) + for forbidden_v070_name in ( + "FILE_LIST_V2", + "TRANSFER_ABORT", + "RUN_STATUS", + "CONSOLE_INPUT_V2", + "FORCE_RESET", + ): + self.assertNotIn(forbidden_v070_name, source) + + def test_wire_adversarial_contract_uses_real_central_writes(self): + bench = load_bench() + transport = inspect.getsource(bench.run_transport_session) + fragment = inspect.getsource(bench.run_fragment_hardening) + for token in ( + "OP_DEVICE_INFO", + "OP_HELLO", + "ST_EBADREQ", + "ST_EUNSUPPORTED", + "disconnect", + "_set_vm_rotation_sentinel", + "_soft_reboot_connect_unnegotiated", + "_assert_vm_rotation_sentinel_absent", + ): + self.assertIn(token, transport) + reboot = transport.find("_soft_reboot_connect_unnegotiated") + workspace = transport.find("preflight_board_workspace") + sentinel = transport.find("_set_vm_rotation_sentinel") + pre_hello = transport.find('"post-VM-reset pre-HELLO DEVICE_INFO"') + renegotiate = transport.find("_negotiate(state)", pre_hello) + freshness = transport.find("_assert_vm_rotation_sentinel_absent") + self.assertTrue( + -1 + < workspace + < sentinel + < reboot + < pre_hello + < renegotiate + < freshness, + "transport-session must prove VM rotation, reject a pre-HELLO " + "command, negotiate, then prove volatile state was cleared", + ) + for token in ( + "wire.encode", + "TYPE", + "ID", + "ECRC", + "REASSEMBLY_DEADLINE_S", + "MALFORMED_BUDGET", + "write_gatt_char", + "is_connected", + ): + self.assertIn(token, fragment) + + def test_run_stdin_label_and_filesystem_scenarios_are_physical_operations(self): + bench = load_bench() + expectations = { + bench.run_fresh_globals: ( + "OP_RUN", + "put_file", + "fresh-source", + "fresh-file", + ), + bench.run_resource_stability: ( + "SEQUENTIAL_RUNS", + "run_heap_probe", + "gc_free_min_bytes", + ), + bench.run_stdin_isolation: ( + "OP_CONSOLE_INPUT", + "OP_STOP", + "idle", + "terminal", + "disconnect", + "overflow", + "soft-reboot", + "reboot-stale", + "reboot-fresh", + "_soft_reboot_connect_unnegotiated", + ), + bench.run_label_durability: ( + "OP_SET_LABEL", + "LABEL_24", + "OP_SET_AUTORUN", + "auto_run", + "OP_IDENTIFY", + "has_identify", + "identify_led", + "OP_SOFT_REBOOT", + "advertisement", + "restore", + ), + bench.run_filesystem_hardening: ( + ".pbltmp", + "OP_FILE_LIST", + "OP_FILE_STAT", + "OP_FILE_GET_BEGIN", + "OP_FILE_DELETE", + "OP_FILE_RENAME", + "OP_MKDIR", + "ST_EBUSY", + "ST_EBADREQ", + "ST_EACCES", + "ST_ENOSPC", + "CAPACITY_RESERVE", + "_get_with_active_probe", + ), + } + for function, tokens in expectations.items(): + source = inspect.getsource(function) + for token in tokens: + with self.subTest(function=function.__name__, token=token): + self.assertIn(token, source) + reboot_helper = inspect.getsource( + bench._soft_reboot_connect_unnegotiated + ) + for token in ("OP_SOFT_REBOOT", "ST_OK", "_wait_disconnected"): + self.assertIn(token, reboot_helper) + + stdin = inspect.getsource(bench.run_stdin_isolation) + positions = [ + stdin.find('"soft-reboot"'), + stdin.find("block_until_reset=True"), + stdin.find("reboot-stale"), + stdin.find("asyncio.wait_for"), + stdin.find("_assert_run_still_active", stdin.find("reboot-stale")), + stdin.find("_soft_reboot_connect_unnegotiated"), + stdin.find("on_accepted"), + stdin.find("_negotiate(state)", stdin.find("reboot-stale")), + stdin.find('"reboot-successor"'), + stdin.find( + "_assert_run_still_active", + stdin.find('"reboot-successor"'), + ), + stdin.find("reboot-fresh"), + ] + self.assertTrue( + all(position >= 0 for position in positions) + and positions == sorted(positions), + "stdin VM-reset case must use a non-expiring predecessor, bound the " + "stale write, prove it active at reboot acceptance, negotiate, then " + "require fresh successor input", + ) + self.assertNotIn( + "OP_SET_IDENTIFY_LED", + inspect.getsource(bench.run_label_durability), + "the target-neutral runner must preserve owner Identify configuration", + ) + + self.assertEqual(len(bench.LABEL_24.encode("utf-8")), 24) + self.assertEqual(bench.SEQUENTIAL_RUNS, 50) + self.assertEqual(bench.CAPACITY_RESERVE, 65536) + self.assertEqual(bench.ACTIVE_GET_BYTES, 16384) + active_get = inspect.getsource(bench._get_with_active_probe) + for token in ( + "DownloadVerifier", + "OP_FILE_GET_BEGIN", + "OP_FILE_GET_DATA", + "OP_FILE_GET_END", + "on_written", + "GET ended before", + ): + self.assertIn(token, active_get) + + def test_resource_evaluation_and_redacted_console_result_are_deterministic(self): + bench = load_bench() + thresholds = {"gc_free_min_bytes": 4096} + healthy = [ + {"gc_free_bytes": 8192}, + {"gc_free_bytes": 7168}, + {"gc_free_bytes": 7680}, + ] + leaking = [ + {"gc_free_bytes": 8192}, + {"gc_free_bytes": 7168}, + {"gc_free_bytes": 6144}, + ] + below = healthy + [{"gc_free_bytes": 4095}] + self.assertEqual(bench.resource_failures(healthy, thresholds), []) + self.assertIn( + "monotonic", + " ".join(bench.resource_failures(leaking, thresholds)), + ) + self.assertIn("floor", " ".join(bench.resource_failures(below, thresholds))) + + caps = { + "chip": "esp32", + "agent": "0.6.1", + "device_id": "SECRET", + "label": "private-label", + } + result = bench.format_result( + "PASS", + "esp32-4mb", + caps, + "passed", + ) + self.assertEqual( + result, + "V061 HARDENING PASS (profile=esp32-4mb chip=esp32 " + "agent=0.6.1 scenarios=7 workspace=passed)", + ) + for private in ("private-address", "SECRET", "private-label"): + self.assertNotIn(private, result) + + def test_runner_uses_the_shared_gate_and_publishes_only_after_all_scenarios(self): + bench = load_bench() + writer = getattr(bench, "write_private_result", None) + self.assertTrue( + callable(writer), + "[red] v0.6.1 HIL needs a canonical exclusive private-result writer", + ) + self.assertEqual( + set(inspect.signature(writer).parameters), + { + "candidate_dir", + "profile_id", + "scenario_results", + "workspace_erased_receipt", + "workspace_nonblank_receipt", + "raw_log", + "result", + "qualification_repo_root", + }, + ) + token_writer = getattr( + bench, + "write_private_result_from_preflight", + None, + ) + self.assertTrue( + callable(token_writer), + "[red] long-running HIL must publish from its original preflight token", + ) + if callable(token_writer): + self.assertEqual( + set(inspect.signature(token_writer).parameters), + {"preflight", "scenario_results", "raw_log", "result"}, + ) + + source = inspect.getsource(bench.run) + positions = [source.find('"%s"' % name) for name in SCENARIOS] + self.assertTrue(all(position >= 0 for position in positions)) + self.assertEqual(positions, sorted(positions)) + preflight_position = source.find("preflight_result_inputs(") + self.assertGreaterEqual(preflight_position, 0) + self.assertLess(preflight_position, positions[0]) + board_preflight_position = source.find("preflight_board_workspace(") + self.assertGreater(board_preflight_position, positions[0]) + self.assertLess(board_preflight_position, positions[1]) + writer_position = source.find("write_private_result_from_preflight(") + self.assertGreater(writer_position, positions[-1]) + self.assertIn("workspace_erased_receipt", source) + self.assertIn("workspace_nonblank_receipt", source) + self.assertIn("raw_log", source) + + def test_result_and_raw_log_paths_are_new_and_outside_candidate(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-hil-cli-") as tmp: + root = Path(tmp) + candidate = root / "candidate" + candidate.mkdir() + for name in ("erased-media.json", "nonblank-media.json"): + receipt = root / name + receipt.write_text("{}\n", encoding="utf-8") + receipt.chmod(0o600) + valid = [ + "--profile", + "esp32-4mb", + "--address", + "private-address", + *evidence_argv(str(root)), + ] + (root / "hardening-result.json").write_text( + "owner data\n", encoding="utf-8" + ) + with self.assertRaises(SystemExit): + bench._parse_args(valid) + + (root / "hardening-result.json").unlink() + (root / "hardening.jsonl").write_text( + "owner data\n", encoding="utf-8" + ) + with self.assertRaises(SystemExit): + bench._parse_args(valid) + + (root / "hardening.jsonl").unlink() + try: + accepted = bench._parse_args(valid) + except SystemExit as exc: + self.fail( + "[red] safe output paths were rejected before path cases: %s" + % exc + ) + self.assertEqual(accepted.result, root / "hardening-result.json") + self.assertEqual(accepted.raw_log, root / "hardening.jsonl") + + inside = list(valid) + inside[inside.index("--result") + 1] = str(candidate / "result.json") + with self.assertRaises(SystemExit): + bench._parse_args(inside) + + inside = list(valid) + inside[inside.index("--raw-log") + 1] = str(candidate / "raw.jsonl") + with self.assertRaises(SystemExit): + bench._parse_args(inside) + + same = list(valid) + same[same.index("--result") + 1] = same[ + same.index("--raw-log") + 1 + ] + with self.assertRaises(SystemExit): + bench._parse_args(same) + + symlink_parent_target = root / "external-output-parent" + symlink_parent_target.mkdir() + symlink_parent = root / "linked-output-parent" + symlink_parent.symlink_to( + symlink_parent_target, + target_is_directory=True, + ) + linked = list(valid) + linked[linked.index("--result") + 1] = str( + symlink_parent / "result.json" + ) + with self.assertRaises(SystemExit): + bench._parse_args(linked) + self.assertFalse((symlink_parent_target / "result.json").exists()) + + broken = root / "broken-result.json" + broken.symlink_to(root / "missing-target.json") + broken_output = list(valid) + broken_output[broken_output.index("--result") + 1] = str(broken) + with self.assertRaises(SystemExit): + bench._parse_args(broken_output) + + candidate_target = root / "candidate-target" + candidate_target.mkdir() + candidate_link = root / "candidate-link" + candidate_link.symlink_to(candidate_target, target_is_directory=True) + linked_candidate = list(valid) + linked_candidate[linked_candidate.index("--candidate-dir") + 1] = str( + candidate_link + ) + with self.assertRaises(SystemExit): + bench._parse_args(linked_candidate) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_hil_orchestration_behavior.py b/tests/firmware_tests/host/test_v061_hil_orchestration_behavior.py new file mode 100644 index 00000000..b52b4442 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_hil_orchestration_behavior.py @@ -0,0 +1,716 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Behavioral RED for v0.6.1 hardening-HIL orchestration. + +These tests replace the physical operations with deterministic async probes. +They prove orchestration and configuration-opcode behavior only; they are not +physical HIL evidence. +""" + +from __future__ import annotations + +import asyncio +from contextlib import ExitStack +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import sys +import tempfile +import unittest +from unittest import mock + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +HIL = HERE.parent / "hil" +BENCH_PATH = HIL / "v061_hardening_bench.py" +sys.path.insert(0, str(HIL)) + + +SCENARIOS = ( + "transport-session", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", +) + +SCENARIO_FUNCTIONS = ( + "run_transport_session", + "run_fragment_hardening", + "run_fresh_globals", + "run_resource_stability", + "run_stdin_isolation", + "run_label_durability", + "run_filesystem_hardening", +) + + +def load_bench(): + if not BENCH_PATH.is_file(): + raise AssertionError("missing v0.6.1 HIL bench: %s" % BENCH_PATH.name) + spec = importlib.util.spec_from_file_location( + "v061_hardening_bench_behavioral_probe", + BENCH_PATH, + ) + if spec is None or spec.loader is None: + raise AssertionError("cannot load %s" % BENCH_PATH.name) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +async def async_noop(*_args, **_kwargs): + return None + + +class V061HilScenarioOrchestrationTests(unittest.TestCase): + def exercise_run(self, bench, *, stop_at=None, failure_factory=None): + observed = [] + publications = [] + + with tempfile.TemporaryDirectory( + prefix="pyble-v061-orchestration-" + ) as temporary: + root = Path(temporary) + candidate = root / "candidate" + candidate.mkdir() + erased = root / "erased-media.json" + nonblank = root / "nonblank-media.json" + for receipt in (erased, nonblank): + receipt.write_text("{}\n", encoding="utf-8") + receipt.chmod(0o600) + args = SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + candidate_dir=candidate, + workspace_erased_receipt=erased, + workspace_nonblank_receipt=nonblank, + raw_log=root / "hardening.jsonl", + result=root / "hardening-result.json", + qualification_repo_root=ROOT, + board_manufacturer="Espressif Systems", + board_model="Electronically identified ESP32 development board", + module_marking="ESP32-D0WD revision v1.0 (esptool)", + # Legacy attributes keep this behavioral seam runnable against + # the predecessor bench while the candidate-bound CLI is RED. + require_workspace_provisioning=False, + sacrificial_media=False, + ) + + def operation(name): + async def probe(_state): + observed.append(name) + if name == stop_at: + if failure_factory is None: + raise AssertionError("missing failure factory") + raise failure_factory() + + return probe + + def publish(*positional, **keywords): + publications.append((positional, keywords)) + output = Path( + keywords.get("result") + or keywords.get("output_path") + or args.result + ) + if output.exists(): + raise AssertionError("result existed before publication") + output.write_bytes(b"behavioral publication probe\n") + output.chmod(0o600) + return output + + preflight_token = object() + + def token_publish( + preflight, + scenario_results, + raw_log, + result, + ): + if preflight is not preflight_token: + raise AssertionError("publication did not receive preflight token") + return publish( + scenario_results=scenario_results, + raw_log=raw_log, + result=result, + ) + + with ExitStack() as stack: + for scenario, function_name in zip( + SCENARIOS, + SCENARIO_FUNCTIONS, + ): + stack.enter_context( + mock.patch.object( + bench, + function_name, + new=operation(scenario), + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "workspace_prerequisite", + return_value="passed", + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "preflight_result_inputs", + return_value=preflight_token, + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "preflight_board_workspace", + new=async_noop, + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "write_private_result", + new=publish, + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "write_private_result_from_preflight", + new=token_publish, + create=True, + ) + ) + stack.enter_context( + mock.patch.object(bench, "_best_effort_stop", new=async_noop) + ) + stack.enter_context( + mock.patch.object(bench, "_disconnect", new=async_noop) + ) + stack.enter_context(mock.patch("builtins.print")) + try: + outcome = asyncio.run(bench.run(args)) + except BaseException as exc: # includes the interruption probe. + outcome = exc + + result_exists = args.result.exists() + + return outcome, observed, publications, result_exists + + def test_exact_seven_scenarios_run_once_in_order_before_one_publication(self): + bench = load_bench() + self.assertEqual(tuple(bench.SCENARIO_ORDER), SCENARIOS) + + outcome, observed, publications, result_exists = self.exercise_run(bench) + + self.assertEqual(outcome, 0) + self.assertEqual(observed, list(SCENARIOS)) + self.assertEqual( + len(publications), + 1, + "all seven passes must publish exactly one private result", + ) + self.assertTrue(result_exists) + + def test_every_failure_cut_stops_without_retry_or_result_publication(self): + failure_kinds = ( + ("bench-failure", lambda bench: lambda: bench.BenchFailure("failed")), + ("exception", lambda _bench: lambda: RuntimeError("failed")), + ("interrupt", lambda _bench: lambda: KeyboardInterrupt()), + ) + for stop_index, stop_at in enumerate(SCENARIOS): + for failure_name, make_factory in failure_kinds: + with self.subTest(stop_at=stop_at, failure=failure_name): + bench = load_bench() + outcome, observed, publications, result_exists = self.exercise_run( + bench, + stop_at=stop_at, + failure_factory=make_factory(bench), + ) + self.assertEqual( + observed, + list(SCENARIOS[: stop_index + 1]), + "a failed scenario was retried or a later scenario ran", + ) + self.assertTrue( + isinstance(outcome, BaseException) + or (type(outcome) is int and outcome != 0), + "a failed/interrupted orchestration reported success", + ) + self.assertEqual(publications, []) + self.assertFalse(result_exists) + + +class FakeRebootCentral: + def __init__(self, bench, events): + self.bench = bench + self.events = events + self.is_connected = True + + async def send_cmd(self, opcode, command_id, payload=b"", **_kwargs): + self.events.append(("command", opcode, command_id, bytes(payload))) + return self.bench.wire.Frame( + self.bench.wire.RSP, + opcode, + command_id, + bytes((self.bench.wire.ST_OK,)), + ) + + +class FakeResetCutCentral: + def __init__(self, frames): + self.frames = list(frames) + + def events_since(self, cursor): + return len(self.frames), self.frames[cursor:] + + +class V061VmResetOrchestrationTests(unittest.TestCase): + def test_vm_rotation_probes_use_bounded_executable_source_and_exact_markers(self): + bench = load_bench() + captured = [] + state = bench.LiveState( + SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + ) + ) + + async def run_program(_state, mode, source, description): + source = bytes(source) + compile(source, "<v061-vm-probe>", "exec") + captured.append((mode, source, description)) + if "setup" in description: + return bench.VM_ROTATION_SET_MARKER + return bench.VM_ROTATION_FRESH_MARKER + + with mock.patch.object(bench, "_run_program", new=run_program): + asyncio.run(bench._set_vm_rotation_sentinel(state)) + asyncio.run(bench._assert_vm_rotation_sentinel_absent(state)) + + self.assertEqual([row[0] for row in captured], [1, 1]) + self.assertTrue(all(len(row[1]) <= 256 for row in captured)) + self.assertTrue( + all( + bench.VM_ROTATION_SENTINEL.encode("ascii") in row[1] + for row in captured + ) + ) + self.assertIn(bench.VM_ROTATION_SET_MARKER, captured[0][1]) + self.assertIn(b"not in sys.path", captured[1][1]) + + def test_soft_reboot_helper_reconnects_without_implicitly_negotiating(self): + bench = load_bench() + helper = getattr(bench, "_soft_reboot_connect_unnegotiated", None) + self.assertTrue( + callable(helper), + "[red] the HIL runner needs a reusable acknowledged reboot seam " + "that deliberately leaves the successor session unnegotiated", + ) + if not callable(helper): + return + + events = [] + args = SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + ) + state = bench.LiveState(args) + old_central = FakeRebootCentral(bench, events) + new_central = FakeRebootCentral(bench, events) + state.central = old_central + + async def wait_disconnected(central, timeout_s=None): + self.assertIs(central, old_central) + events.append(("disconnected", timeout_s)) + central.is_connected = False + + async def wait_advertisement(received_state, name): + self.assertIs(received_state, state) + events.append(("advertisement", name)) + + async def connect(received_state): + self.assertIs(received_state, state) + self.assertIsNone(state.central) + events.append(("connect", None)) + state.central = new_central + return new_central + + async def forbidden_negotiate(_state): + raise AssertionError("reboot helper negotiated before the scenario probe") + + with ( + mock.patch.object(bench, "_wait_disconnected", new=wait_disconnected), + mock.patch.object(bench, "_wait_advertisement", new=wait_advertisement), + mock.patch.object(bench, "_connect", new=connect), + mock.patch.object(bench, "_negotiate", new=forbidden_negotiate), + ): + returned = asyncio.run(helper(state, "PyBLE-1234")) + + self.assertIs(returned, new_central) + self.assertIs(state.central, new_central) + self.assertEqual(events[0][0:2], ("command", bench.wire.OP_SOFT_REBOOT)) + self.assertEqual( + [event[0] for event in events], + ["command", "disconnected", "advertisement", "connect"], + ) + + def test_soft_reboot_acceptance_guard_runs_before_disconnect(self): + bench = load_bench() + events = [] + args = SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + ) + state = bench.LiveState(args) + old_central = FakeRebootCentral(bench, events) + new_central = FakeRebootCentral(bench, events) + state.central = old_central + + def accepted(central): + self.assertIs(central, old_central) + events.append(("accepted", None)) + + async def wait_disconnected(central, timeout_s=None): + self.assertIs(central, old_central) + events.append(("disconnected", timeout_s)) + central.is_connected = False + + async def wait_advertisement(_state, name): + events.append(("advertisement", name)) + + async def connect(_state): + events.append(("connect", None)) + state.central = new_central + return new_central + + with ( + mock.patch.object(bench, "_wait_disconnected", new=wait_disconnected), + mock.patch.object(bench, "_wait_advertisement", new=wait_advertisement), + mock.patch.object(bench, "_connect", new=connect), + ): + returned = asyncio.run( + bench._soft_reboot_connect_unnegotiated( + state, + "PyBLE-1234", + response_timeout_s=2.0, + on_accepted=accepted, + ) + ) + + self.assertIs(returned, new_central) + self.assertEqual( + [event[0] for event in events], + ["command", "accepted", "disconnected", "advertisement", "connect"], + ) + + def test_stdin_reset_cut_rejects_consumed_or_terminal_predecessor(self): + bench = load_bench() + running = bench.wire.Frame( + bench.wire.EVT, + bench.wire.OP_RUN_STATE, + 0, + bytes((bench.ST_RUNNING,)), + ) + stale_echo = bench.wire.Frame( + bench.wire.EVT, + bench.wire.OP_CONSOLE_DATA, + 0, + b"\x00__PYBLE_V061_STDIN_soft-reboot_ECHO__=reboot-stale\n", + ) + done = bench.wire.Frame( + bench.wire.EVT, + bench.wire.OP_RUN_STATE, + 0, + bytes((bench.ST_DONE,)), + ) + guard = getattr(bench, "_assert_stdin_reset_acceptance", None) + self.assertTrue( + callable(guard), + "[red] the VM-reset HIL case needs an acceptance-cut guard", + ) + if not callable(guard): + return + + guard( + FakeResetCutCentral([running]), + 0, + b"__PYBLE_V061_STDIN_soft-reboot_ECHO__=", + b"reboot-stale", + ) + for frames in ([running, stale_echo], [running, done]): + with self.subTest(frames=frames), self.assertRaises(bench.BenchFailure): + guard( + FakeResetCutCentral(frames), + 0, + b"__PYBLE_V061_STDIN_soft-reboot_ECHO__=", + b"reboot-stale", + ) + + +class FakeActiveGetCentral: + def __init__(self, bench, expected, *, end_before_write_receipt=False): + self.bench = bench + self.expected = bytes(expected) + self.end_before_write_receipt = end_before_write_receipt + self.events = [] + self.commands = [] + self.write_receipts = 0 + + def event_cursor(self): + return len(self.events) + + def events_since(self, cursor): + return len(self.events), self.events[cursor:] + + def _append_download(self): + midpoint = len(self.expected) // 2 + for offset, data in ( + (0, self.expected[:midpoint]), + (midpoint, self.expected[midpoint:]), + ): + self.events.append( + self.bench.wire.Frame( + self.bench.wire.EVT, + self.bench.wire.OP_FILE_GET_DATA, + 0, + offset.to_bytes(4, "little") + data, + ) + ) + self.events.append( + self.bench.wire.Frame( + self.bench.wire.EVT, + self.bench.wire.OP_FILE_GET_END, + 0, + self.bench.wire.crc32(self.expected).to_bytes(4, "little"), + ) + ) + + async def send_cmd( + self, + opcode, + command_id, + payload=b"", + on_written=None, + **_kwargs, + ): + self.commands.append((opcode, command_id, bytes(payload))) + if opcode == self.bench.wire.OP_FILE_GET_BEGIN: + return self.bench.wire.Frame( + self.bench.wire.RSP, + opcode, + command_id, + bytes((self.bench.wire.ST_OK,)) + + len(self.expected).to_bytes(4, "little"), + ) + if self.end_before_write_receipt: + self._append_download() + if on_written is not None: + on_written() + self.write_receipts += 1 + if not self.end_before_write_receipt: + self._append_download() + return self.bench.wire.Frame( + self.bench.wire.RSP, + opcode, + command_id, + bytes((self.bench.wire.ST_EBUSY,)), + ) + + +class V061ActiveGetOrchestrationTests(unittest.TestCase): + def test_active_get_probe_binds_write_before_end_and_verifies_all_bytes(self): + bench = load_bench() + helper = getattr(bench, "_get_with_active_probe", None) + self.assertTrue( + callable(helper), + "[red] filesystem HIL needs an active-GET admission probe", + ) + if not callable(helper): + return + + expected = b"0123456789abcdef" + state = bench.LiveState( + SimpleNamespace(profile="esp32-4mb", expect_agent="0.6.1") + ) + state.central = FakeActiveGetCentral(bench, expected) + asyncio.run( + helper( + state, + "/v061_hil/active_get.bin", + expected, + bench.wire.OP_FILE_DELETE, + bench.pble_bench.path_payload("/v061_hil/delete.bin"), + bench.wire.ST_EBUSY, + "active-GET DELETE", + ) + ) + self.assertEqual(state.central.write_receipts, 1) + self.assertEqual( + [command[0] for command in state.central.commands], + [bench.wire.OP_FILE_GET_BEGIN, bench.wire.OP_FILE_DELETE], + ) + + def test_active_get_probe_rejects_a_command_written_after_get_end(self): + bench = load_bench() + helper = getattr(bench, "_get_with_active_probe", None) + self.assertTrue(callable(helper)) + if not callable(helper): + return + + expected = b"0123456789abcdef" + state = bench.LiveState( + SimpleNamespace(profile="esp32-4mb", expect_agent="0.6.1") + ) + state.central = FakeActiveGetCentral( + bench, + expected, + end_before_write_receipt=True, + ) + with self.assertRaises(bench.BenchFailure): + asyncio.run( + helper( + state, + "/v061_hil/active_get.bin", + expected, + bench.wire.OP_FILE_DELETE, + bench.pble_bench.path_payload("/v061_hil/delete.bin"), + bench.wire.ST_EBUSY, + "late active-GET DELETE", + ) + ) + + +class FakeConfigurationCentral: + def __init__(self, bench, configuration, events): + self.bench = bench + self.configuration = configuration + self.events = events + self.is_connected = True + + async def send_cmd(self, opcode, command_id, payload=b"", **_kwargs): + self.events.append(("opcode", opcode)) + if opcode == self.bench.wire.OP_SET_AUTORUN and payload: + self.configuration["auto_run"] = str(payload[0]) + if opcode == self.bench.wire.OP_SET_IDENTIFY_LED and payload: + self.configuration["identify_led"] = str(payload[0]) + return self.bench.wire.Frame( + self.bench.wire.RSP, + opcode, + command_id, + bytes((self.bench.wire.ST_OK,)), + ) + + +class V061IdentifyDurabilityBehaviorTests(unittest.TestCase): + def exercise_identify_profile(self, has_identify): + bench = load_bench() + events = [] + configuration = { + "label": "owner-label", + "auto_run": "1", + "has_identify": "1" if has_identify else "0", + "identify_led": "7" if has_identify else "255", + } + args = SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + ) + state = bench.LiveState(args) + state.central = FakeConfigurationCentral(bench, configuration, events) + + def caps(): + return { + "chip": "esp32", + "agent": "0.6.1", + "device_id": "PRIVATE", + **configuration, + } + + async def device_info(_state): + return dict(caps()) + + async def set_label(_state, payload, expected): + if expected == bench.wire.ST_OK: + configuration["label"] = payload.decode("utf-8", errors="strict") + return bench.wire.Frame( + bench.wire.RSP, + bench.wire.OP_SET_LABEL, + 1, + bytes((expected,)), + ) + + async def reboot(_state, _expected_name): + events.append(("reboot", None)) + return dict(caps()) + + with ( + mock.patch.object(bench, "_device_info", new=device_info), + mock.patch.object(bench, "_set_label", new=set_label), + mock.patch.object( + bench, + "_soft_reboot_to_advertisement", + new=reboot, + ), + ): + asyncio.run(bench.run_label_durability(state)) + + opcodes = [value for kind, value in events if kind == "opcode"] + identify_positions = [ + index + for index, event in enumerate(events) + if event == ("opcode", bench.wire.OP_IDENTIFY) + ] + reboot_positions = [ + index + for index, event in enumerate(events) + if event[0] == "reboot" + ] + return bench, configuration, opcodes, identify_positions, reboot_positions + + def test_identify_capable_profile_identifies_once_before_and_after_reboot(self): + bench, configuration, opcodes, identifies, reboots = ( + self.exercise_identify_profile(True) + ) + + self.assertNotIn(bench.wire.OP_SET_IDENTIFY_LED, opcodes) + self.assertEqual(len(identifies), 2) + self.assertTrue( + any(identifies[0] < reboot < identifies[1] for reboot in reboots), + "Identify was not proven on both sides of a reboot", + ) + self.assertEqual(configuration["identify_led"], "7") + self.assertEqual(configuration["label"], "owner-label") + self.assertEqual(configuration["auto_run"], "1") + + def test_non_identify_profile_invents_no_identify_and_never_reconfigures_led(self): + bench, configuration, opcodes, identifies, _reboots = ( + self.exercise_identify_profile(False) + ) + + self.assertNotIn(bench.wire.OP_SET_IDENTIFY_LED, opcodes) + self.assertNotIn(bench.wire.OP_IDENTIFY, opcodes) + self.assertEqual(identifies, []) + self.assertEqual(configuration["identify_led"], "255") + self.assertEqual(configuration["label"], "owner-label") + self.assertEqual(configuration["auto_run"], "1") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_hil_preflight_hardening.py b/tests/firmware_tests/host/test_v061_hil_preflight_hardening.py new file mode 100644 index 00000000..b7b359cd --- /dev/null +++ b/tests/firmware_tests/host/test_v061_hil_preflight_hardening.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Focused REDs for v0.6.1 HIL preflight and publication hardening. + +These tests exercise host-only seams with synthetic inputs. They are not +physical HIL evidence and cannot qualify a firmware release. +""" + +from __future__ import annotations + +import asyncio +from contextlib import ExitStack, redirect_stderr +import importlib.util +import inspect +from io import StringIO +import json +import os +from pathlib import Path +import shutil +from types import SimpleNamespace +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[3] +HERE = Path(__file__).resolve().parent +HIL = HERE.parent / "hil" +BENCH_PATH = HIL / "v061_hardening_bench.py" +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HIL)) + +import test_v061_hardening_release_gate as gate_fixture # noqa: E402 + + +GATE = gate_fixture.GATE +HAVE_GATE = gate_fixture.HAVE_GATE + +QUALIFICATION_SOURCE_PATHS = ( + "firmware/qualification/v061_hardening_release_gate.py", + "tests/firmware_tests/hil/v061_workspace_acquire.py", + "tests/firmware_tests/hil/_v061_workspace_hardware.py", + "tests/firmware_tests/hil/_v061_workspace_pico.py", + "tests/firmware_tests/hil/v061_hardening_bench.py", + "tests/firmware_tests/hil/_pble_bench.py", + "tests/firmware_tests/hil/_pble_central.py", + "tests/firmware_tests/hil/_pble_wire.py", + "tests/firmware_tests/hil/target_smoke.py", + "firmware/qualification/oi1-gates.json", + "firmware/versions.lock", +) + +PROFILE_BOARD_IDENTITIES = { + "esp32-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32 development board", + "module_marking": "ESP32-D0WD revision v1.0 (esptool)", + }, + "esp32-s3-n16r8": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-S3 development board", + "module_marking": "ESP32-S3 QFN56 revision v0.1 (esptool)", + }, + "waveshare-esp32-s3-lcd-147b": { + "board_manufacturer": "Waveshare", + "board_model": "ESP32-S3-LCD-1.47B", + "module_marking": "ESP32-S3R8", + }, + "esp32-c3-4mb": { + "board_manufacturer": "Espressif Systems", + "board_model": "Electronically identified ESP32-C3 development board", + "module_marking": "ESP32-C3 QFN32 revision v0.4 (esptool)", + }, + "rpi-pico2-w": { + "board_manufacturer": "Raspberry Pi Ltd", + "board_model": "Raspberry Pi Pico 2 W", + "module_marking": "RP2350 + CYW43439", + }, +} + + +def load_bench(): + spec = importlib.util.spec_from_file_location( + "pyble_v061_hardening_preflight_probe", + BENCH_PATH, + ) + if spec is None or spec.loader is None: + raise AssertionError("cannot load v0.6.1 hardening bench") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def private_write(path: Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + path.chmod(0o600) + + +def identity_argv(profile_id: str) -> list[str]: + identity = PROFILE_BOARD_IDENTITIES[profile_id] + return [ + "--board-manufacturer", + identity["board_manufacturer"], + "--board-model", + identity["board_model"], + "--module-marking", + identity["module_marking"], + ] + + +def live_argv(root: Path, profile_id: str = "esp32-4mb") -> list[str]: + candidate = root / "candidate" + candidate.mkdir(parents=True, exist_ok=True) + for name in ("erased.json", "nonblank.json"): + receipt = root / name + if not receipt.exists(): + private_write(receipt, b"{}\n") + return [ + "--profile", + profile_id, + "--address", + "private-address", + "--candidate-dir", + os.fspath(candidate), + "--workspace-erased-receipt", + os.fspath(root / "erased.json"), + "--workspace-nonblank-receipt", + os.fspath(root / "nonblank.json"), + "--raw-log", + os.fspath(root / "hardening.jsonl"), + "--result", + os.fspath(root / "hardening-result.json"), + *identity_argv(profile_id), + ] + + +def make_clean_qualification_checkout(root: Path) -> Path: + """Create a clean synthetic checkout containing the exact source closure.""" + + checkout = root / "qualification-checkout" + checkout.mkdir() + for relative in QUALIFICATION_SOURCE_PATHS: + source = ROOT / relative + destination = checkout / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + commands = ( + ("git", "init", "-q"), + ("git", "config", "user.name", "PyBLE test"), + ("git", "config", "user.email", "tests@pyble.invalid"), + ("git", "add", *QUALIFICATION_SOURCE_PATHS), + ("git", "commit", "-q", "-m", "synthetic qualification checkout"), + ) + for command in commands: + subprocess.run(command, cwd=checkout, check=True, capture_output=True) + return checkout + + +def qualification_identity(checkout: Path) -> tuple[str, str]: + commit = subprocess.run( + ("git", "rev-parse", "HEAD"), + cwd=checkout, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + digest = gate_fixture.hashlib.sha256( + (checkout / "tests/firmware_tests/hil/v061_hardening_bench.py").read_bytes() + ).hexdigest() + return commit, digest + + +def rewrite_receipt_qualification(fixture: dict[str, object], checkout: Path) -> None: + commit, executable_digest = qualification_identity(checkout) + for key in ("erased", "nonblank"): + path = Path(fixture[key]) + value = json.loads(path.read_text(encoding="utf-8")) + value["qualification_source_commit"] = commit + value["qualification_executable_sha256"] = executable_digest + acquisition_path = path.with_name(path.stem + "-acquisition.json") + acquisition = json.loads(acquisition_path.read_text()) + acquisition["qualification_source_commit"] = commit + acquisition["qualification_executable_sha256"] = executable_digest + acquisition_raw = gate_fixture.canonical_json_bytes(acquisition) + private_write(acquisition_path, acquisition_raw) + value["acquisition_sha256"] = gate_fixture.hashlib.sha256(acquisition_raw).hexdigest() + private_write(path, gate_fixture.canonical_json_bytes(value)) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is unavailable") +class V061ExclusivePublicationFinalReopenTests(unittest.TestCase): + def test_visible_replacement_during_callback_is_rejected_and_preserved(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-final-reopen-") as tmp: + output = Path(tmp) / "result.json" + attacker = b"attacker-owned replacement\n" + + def replace_visible_name() -> None: + output.unlink() + private_write(output, attacker) + + with self.assertRaises(GATE.QualificationError): + GATE._write_exclusive( + output, + b"writer-owned evidence\n", + post_write_check=replace_visible_name, + ) + self.assertEqual( + output.read_bytes(), + attacker, + "failure cleanup must not unlink an attacker's replacement inode", + ) + + def test_hardlink_added_during_callback_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-final-link-") as tmp: + root = Path(tmp) + output = root / "result.json" + second_link = root / "attacker-link.json" + raw = b"writer-owned evidence\n" + + def add_link() -> None: + os.link(output, second_link) + + with self.assertRaises(GATE.QualificationError): + GATE._write_exclusive( + output, + raw, + post_write_check=add_link, + ) + self.assertFalse( + output.exists(), + "the writer-owned output name must be removed after link attack", + ) + self.assertEqual(second_link.read_bytes(), raw) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is unavailable") +class V061QualificationCheckoutClosureTests(unittest.TestCase): + def test_every_authored_runtime_dependency_must_match_head(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-source-closure-") as tmp: + checkout = make_clean_qualification_checkout(Path(tmp)) + baseline = GATE._qualification_snapshot(checkout) + self.assertEqual(baseline["commit"], qualification_identity(checkout)[0]) + + for relative in QUALIFICATION_SOURCE_PATHS: + with self.subTest(relative=relative): + path = checkout / relative + original = path.read_bytes() + path.write_bytes(original + b"\n# uncommitted qualification mutation\n") + try: + with self.assertRaises(GATE.QualificationError): + GATE._qualification_snapshot(checkout) + finally: + path.write_bytes(original) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is unavailable") +class V061ResultInputLeaseSeamTests(unittest.TestCase): + def test_gate_exposes_token_bound_preflight_and_publication_helpers(self): + preflight = getattr(GATE, "preflight_result_inputs", None) + publish = getattr(GATE, "create_result_from_preflight", None) + self.assertTrue( + callable(preflight), + "[red] live HIL cannot validate all long-lived inputs before BLE", + ) + self.assertTrue( + callable(publish), + "[red] final publication cannot consume the exact preflight snapshot", + ) + if callable(preflight): + self.assertEqual( + set(inspect.signature(preflight).parameters), + { + "candidate_dir", + "profile_id", + "workspace_erased_receipt", + "workspace_nonblank_receipt", + "qualification_repo_root", + }, + ) + if callable(publish): + self.assertEqual( + set(inspect.signature(publish).parameters), + {"preflight", "scenario_results", "raw_log", "output_path"}, + ) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is unavailable") +class V061QualificationOutputBoundaryTests(unittest.TestCase): + def test_workspace_receipt_and_raw_log_cannot_live_in_qualification_checkout(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-output-boundary-") as tmp: + root = Path(tmp) + checkout = make_clean_qualification_checkout(root) + fixture = gate_fixture.writer_fixture(root / "inputs", "esp32-c3-4mb") + evidence = checkout / "private-evidence" + output = evidence / "erased.json" + raw = evidence / "erased-raw.jsonl" + private_write( + raw, + gate_fixture.workspace_raw_log( + gate_fixture.WORKSPACE_ORDER[0] + ), + ) + + with self.assertRaises(GATE.QualificationError): + GATE.create_workspace_receipt( + candidate_dir=fixture["candidate"], + profile_id="esp32-c3-4mb", + observation_kind=gate_fixture.WORKSPACE_ORDER[0], + raw_boot_log=raw, + output_path=output, + qualification_repo_root=checkout, + ) + self.assertFalse(output.exists()) + + def test_hardening_result_cannot_live_in_qualification_checkout(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-result-boundary-") as tmp: + root = Path(tmp) + checkout = make_clean_qualification_checkout(root) + fixture = gate_fixture.writer_fixture(root / "inputs", "esp32-c3-4mb") + rewrite_receipt_qualification(fixture, checkout) + output = checkout / "private-evidence" / "hardening-result.json" + output.parent.mkdir() + + with self.assertRaises(GATE.QualificationError): + GATE.create_result( + candidate_dir=fixture["candidate"], + profile_id="esp32-c3-4mb", + scenario_results=gate_fixture.scenario_results(), + workspace_erased_receipt=fixture["erased"], + workspace_nonblank_receipt=fixture["nonblank"], + raw_log=fixture["raw_log"], + output_path=output, + qualification_repo_root=checkout, + ) + self.assertFalse(output.exists()) + + def test_hardening_raw_log_cannot_live_in_qualification_checkout(self): + with tempfile.TemporaryDirectory(prefix="pyble-v061-log-boundary-") as tmp: + root = Path(tmp) + checkout = make_clean_qualification_checkout(root) + fixture = gate_fixture.writer_fixture(root / "inputs", "esp32-c3-4mb") + rewrite_receipt_qualification(fixture, checkout) + raw = checkout / "private-evidence" / "hardening.jsonl" + private_write(raw, Path(fixture["raw_log"]).read_bytes()) + + with self.assertRaises(GATE.QualificationError): + GATE.create_result( + candidate_dir=fixture["candidate"], + profile_id="esp32-c3-4mb", + scenario_results=gate_fixture.scenario_results(), + workspace_erased_receipt=fixture["erased"], + workspace_nonblank_receipt=fixture["nonblank"], + raw_log=raw, + output_path=fixture["output"], + qualification_repo_root=checkout, + ) + self.assertFalse(Path(fixture["output"]).exists()) + + +class V061ExactProfileAttributionTests(unittest.TestCase): + def test_live_cli_requires_the_frozen_oi_board_identity_for_each_profile(self): + bench = load_bench() + self.assertEqual( + getattr(bench, "PROFILE_BOARD_IDENTITIES", None), + PROFILE_BOARD_IDENTITIES, + "[red] hardening HIL has no exact-profile operator attestation map", + ) + + with tempfile.TemporaryDirectory(prefix="pyble-v061-profile-id-") as tmp: + root = Path(tmp) + for profile_id, expected in PROFILE_BOARD_IDENTITIES.items(): + with self.subTest(profile_id=profile_id): + profile_root = root / profile_id + argv = live_argv(profile_root, profile_id) + try: + args = bench._parse_args(argv) + except SystemExit as exc: + self.fail( + "[red] exact OI identity was rejected for %s: %s" + % (profile_id, exc) + ) + self.assertEqual(args.board_manufacturer, expected["board_manufacturer"]) + self.assertEqual(args.board_model, expected["board_model"]) + self.assertEqual(args.module_marking, expected["module_marking"]) + + for option in ( + "--board-manufacturer", + "--board-model", + "--module-marking", + ): + index = argv.index(option) + missing = argv[:index] + argv[index + 2 :] + with self.subTest(profile_id=profile_id, missing=option): + with self.assertRaises(SystemExit): + bench._parse_args(missing) + + wrong = list(argv) + wrong[index + 1] = "operator-selected different hardware" + with self.subTest(profile_id=profile_id, wrong=option): + with self.assertRaises(SystemExit): + bench._parse_args(wrong) + + def test_live_outputs_are_rejected_inside_the_qualification_checkout(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-live-inputs-") as tmp: + root = Path(tmp) + base = live_argv(root) + with tempfile.TemporaryDirectory( + dir=ROOT, + prefix=".pyble-v061-live-output-red-", + ) as checkout_tmp: + checkout_output = Path(checkout_tmp) + for option, name in ( + ("--raw-log", "hardening.jsonl"), + ("--result", "hardening-result.json"), + ): + argv = list(base) + argv[argv.index(option) + 1] = os.fspath(checkout_output / name) + stderr = StringIO() + with self.subTest(option=option), redirect_stderr(stderr): + with self.assertRaises(SystemExit): + bench._parse_args(argv) + self.assertIn("qualification", stderr.getvalue().lower()) + + +class V061WorkspaceReceiptCliTests(unittest.TestCase): + def receipt_argv( + self, + root: Path, + *, + output: Path | None = None, + raw: Path | None = None, + ) -> tuple[list[str], dict[str, object]]: + profile_id = "esp32-c3-4mb" + fixture = gate_fixture.writer_fixture(root / "inputs", profile_id) + selected_output = output or (root / "new-erased-receipt.json") + selected_raw = raw or selected_output.with_name( + selected_output.stem + "-raw.jsonl" + ) + if not selected_raw.exists(): + private_write( + selected_raw, + gate_fixture.workspace_raw_log(gate_fixture.WORKSPACE_ORDER[0]), + ) + argv = [ + "--create-workspace-receipt", + "--profile", + profile_id, + "--candidate-dir", + os.fspath(fixture["candidate"]), + "--observation-kind", + gate_fixture.WORKSPACE_ORDER[0], + "--raw-boot-log", + os.fspath(selected_raw), + "--receipt-output", + os.fspath(selected_output), + ] + return argv, fixture + + def test_same_executable_dispatches_the_separate_receipt_creation_mode(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-receipt-cli-") as tmp: + root = Path(tmp) + argv, fixture = self.receipt_argv(root) + try: + args = bench._parse_args(argv) + except SystemExit as exc: + self.fail("[red] workspace-receipt CLI mode is missing: %s" % exc) + self.assertTrue(args.create_workspace_receipt) + self.assertEqual(args.profile, "esp32-c3-4mb") + self.assertEqual( + args.observation_kind, + gate_fixture.WORKSPACE_ORDER[0], + ) + self.assertEqual(args.raw_boot_log, root / "new-erased-receipt-raw.jsonl") + self.assertEqual(args.receipt_output, root / "new-erased-receipt.json") + self.assertFalse(hasattr(args, "address") and args.address) + + with mock.patch.object( + bench.hardening_gate, + "create_workspace_receipt", + return_value=args.receipt_output, + ) as create, mock.patch.object( + bench.asyncio, + "run", + side_effect=AssertionError("receipt mode attempted a BLE run"), + ): + self.assertEqual(bench.main(argv), 0) + create.assert_called_once_with( + candidate_dir=Path(fixture["candidate"]), + profile_id="esp32-c3-4mb", + observation_kind=gate_fixture.WORKSPACE_ORDER[0], + raw_boot_log=args.raw_boot_log, + output_path=args.receipt_output, + qualification_repo_root=ROOT, + ) + + def test_receipt_mode_requires_only_its_exact_bounded_inputs(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-receipt-required-") as tmp: + argv, _fixture = self.receipt_argv(Path(tmp)) + # A generic live-mode parse failure must not make the individual + # receipt-mode required-input assertions pass vacuously. + try: + bench._parse_args(argv) + except SystemExit as exc: + self.fail("[red] receipt-mode base CLI is unavailable: %s" % exc) + required = ( + "--profile", + "--candidate-dir", + "--observation-kind", + "--raw-boot-log", + "--receipt-output", + ) + for option in required: + index = argv.index(option) + missing = argv[:index] + argv[index + 2 :] + with self.subTest(missing=option), self.assertRaises(SystemExit): + bench._parse_args(missing) + with self.assertRaises(SystemExit): + bench._parse_args([*argv, "--address", "private-address"]) + + def test_receipt_and_raw_outputs_are_rejected_inside_qualification_checkout(self): + bench = load_bench() + with tempfile.TemporaryDirectory(prefix="pyble-v061-receipt-inputs-") as tmp: + root = Path(tmp) + with tempfile.TemporaryDirectory( + dir=ROOT, + prefix=".pyble-v061-receipt-output-red-", + ) as checkout_tmp: + output = Path(checkout_tmp) / "erased.json" + raw = Path(checkout_tmp) / "erased-raw.jsonl" + argv, _fixture = self.receipt_argv(root, output=output, raw=raw) + stderr = StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit): + bench._parse_args(argv) + self.assertIn("qualification", stderr.getvalue().lower()) + + +class FakeWorkspaceCentral: + def __init__(self, bench, statuses): + self.bench = bench + self.statuses = dict(statuses) + self.requests = [] + self.is_connected = True + + async def send_cmd(self, opcode, command_id, payload=b"", **_kwargs): + if opcode != self.bench.wire.OP_FILE_STAT: + raise AssertionError("workspace preflight issued a mutating opcode") + size = int.from_bytes(payload[:2], "little") + path = payload[2 : 2 + size].decode("utf-8", errors="strict") + if 2 + size != len(payload): + raise AssertionError("workspace preflight path was malformed") + self.requests.append((opcode, path)) + status = self.statuses.get(path, self.bench.wire.ST_ENOENT) + return self.bench.wire.Frame( + self.bench.wire.RSP, + opcode, + command_id, + bytes((status,)), + ) + + +class V061BoardWorkspacePreflightTests(unittest.TestCase): + def test_only_absent_scratch_and_main_are_admitted_without_mutation(self): + bench = load_bench() + preflight = getattr(bench, "preflight_board_workspace", None) + self.assertTrue( + callable(preflight), + "[red] HIL has no no-delete board workspace preflight", + ) + if not callable(preflight): + return + + state = bench.LiveState( + SimpleNamespace(profile="esp32-4mb", expect_agent="0.6.1") + ) + state.caps = {"chip": "esp32", "agent": "0.6.1"} + state.central = FakeWorkspaceCentral(bench, {}) + asyncio.run(preflight(state)) + self.assertEqual( + state.central.requests, + [ + (bench.wire.OP_FILE_STAT, "/v061_hil"), + (bench.wire.OP_FILE_STAT, "/main.py"), + ], + ) + + for present in ("/v061_hil", "/main.py"): + with self.subTest(present=present): + state = bench.LiveState( + SimpleNamespace(profile="esp32-4mb", expect_agent="0.6.1") + ) + state.caps = {"chip": "esp32", "agent": "0.6.1"} + state.central = FakeWorkspaceCentral( + bench, + {present: bench.wire.ST_OK}, + ) + with self.assertRaises(bench.BenchFailure): + asyncio.run(preflight(state)) + self.assertTrue( + all( + opcode == bench.wire.OP_FILE_STAT + for opcode, _path in state.central.requests + ) + ) + + +class V061LivePreflightOrchestrationTests(unittest.TestCase): + def make_args(self, root: Path) -> SimpleNamespace: + candidate = root / "candidate" + candidate.mkdir() + erased = root / "erased.json" + nonblank = root / "nonblank.json" + for receipt in (erased, nonblank): + private_write(receipt, b"{}\n") + identity = PROFILE_BOARD_IDENTITIES["esp32-4mb"] + return SimpleNamespace( + profile="esp32-4mb", + address="private-address", + expect_agent="0.6.1", + candidate_dir=candidate, + workspace_erased_receipt=erased, + workspace_nonblank_receipt=nonblank, + raw_log=root / "hardening.jsonl", + result=root / "hardening-result.json", + qualification_repo_root=ROOT, + board_manufacturer=identity["board_manufacturer"], + board_model=identity["board_model"], + module_marking=identity["module_marking"], + ) + + def test_invalid_candidate_and_receipts_stop_before_any_live_scenario(self): + bench = load_bench() + observed = [] + + async def scenario(_state): + observed.append("live-scenario") + + with tempfile.TemporaryDirectory(prefix="pyble-v061-early-preflight-") as tmp: + args = self.make_args(Path(tmp)) + with ExitStack() as stack: + for name in ( + "run_transport_session", + "run_fragment_hardening", + "run_fresh_globals", + "run_resource_stability", + "run_stdin_isolation", + "run_label_durability", + "run_filesystem_hardening", + ): + stack.enter_context(mock.patch.object(bench, name, new=scenario)) + stack.enter_context( + mock.patch.object(bench, "_best_effort_stop", new=mock.AsyncMock()) + ) + stack.enter_context( + mock.patch.object(bench, "_disconnect", new=mock.AsyncMock()) + ) + stack.enter_context(mock.patch("builtins.print")) + outcome = asyncio.run(bench.run(args)) + + self.assertNotEqual(outcome, 0) + self.assertEqual( + observed, + [], + "candidate and both receipts must be fully validated before BLE work", + ) + self.assertFalse(args.raw_log.exists() or args.result.exists()) + + def test_same_opaque_snapshot_flows_from_preflight_to_publication(self): + bench = load_bench() + token = object() + events = [] + + def host_preflight(args): + events.append("host-preflight") + return token + + async def board_preflight(_state): + events.append("board-preflight") + + def operation(name): + async def probe(_state): + events.append(name) + + return probe + + def publish(preflight, scenario_results, raw_log, result): + self.assertIs(preflight, token) + self.assertEqual(tuple(scenario_results), gate_fixture.SCENARIO_ORDER) + self.assertEqual(Path(raw_log).suffix, ".jsonl") + self.assertEqual(Path(result).suffix, ".json") + events.append("token-publication") + private_write(Path(result), b"token publication probe\n") + return Path(result) + + def legacy_publish(*_args, **_kwargs): + raise AssertionError("run bypassed token-bound publication") + + with tempfile.TemporaryDirectory(prefix="pyble-v061-token-flow-") as tmp: + args = self.make_args(Path(tmp)) + with ExitStack() as stack: + stack.enter_context( + mock.patch.object( + bench, + "preflight_result_inputs", + new=host_preflight, + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "preflight_board_workspace", + new=board_preflight, + create=True, + ) + ) + for name, function_name in zip( + gate_fixture.SCENARIO_ORDER, + ( + "run_transport_session", + "run_fragment_hardening", + "run_fresh_globals", + "run_resource_stability", + "run_stdin_isolation", + "run_label_durability", + "run_filesystem_hardening", + ), + ): + stack.enter_context( + mock.patch.object(bench, function_name, new=operation(name)) + ) + stack.enter_context( + mock.patch.object( + bench, + "write_private_result_from_preflight", + new=publish, + create=True, + ) + ) + stack.enter_context( + mock.patch.object( + bench, + "write_private_result", + new=legacy_publish, + ) + ) + stack.enter_context( + mock.patch.object(bench, "_best_effort_stop", new=mock.AsyncMock()) + ) + stack.enter_context( + mock.patch.object(bench, "_disconnect", new=mock.AsyncMock()) + ) + stack.enter_context(mock.patch("builtins.print")) + outcome = asyncio.run(bench.run(args)) + + self.assertEqual(outcome, 0) + self.assertEqual( + events, + [ + "host-preflight", + "transport-session", + "board-preflight", + "fragment-hardening", + "run-isolation", + "resource-stability", + "stdin-isolation", + "configuration-durability", + "filesystem-hardening", + "token-publication", + ], + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_autorun_persistence.py b/tests/firmware_tests/host/test_v061_native_autorun_persistence.py new file mode 100644 index 00000000..4a0121bc --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_autorun_persistence.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""[red] compiled ESP autorun persistence/cache contract for v0.6.1. + +The production ``pble_boot`` core is compiled against a deterministic fake NVS +backend. This keeps the regression at the native state boundary: the first +autorun observation loads persisted state, later observations use that cached +state, and only a successful set+commit transaction may publish a new runtime +value. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +BOOT = NATIVE / "pble_boot.c" + + +class NativeAutorunPersistenceTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which(os.environ.get("CC", "cc")) + if compiler is None: + raise unittest.SkipTest("a host C compiler is required") + + source = BOOT.read_text(encoding="utf-8", errors="strict") + marker = "// Thin MicroPython surface (boot wiring)" + if marker not in source: + raise AssertionError("pble_boot native-core boundary is missing") + # Compile the production persistence and autorun core verbatim. The + # MicroPython module-object declarations below this marker need the + # complete target ABI but do not participate in this state contract. + core = source.partition(marker)[0] + + cls._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-native-autorun-" + ) + temp = Path(cls._temporary.name) + (temp / "py").mkdir() + (temp / "nvs.h").write_text(NVS_STUB, encoding="utf-8") + (temp / "py" / "runtime.h").write_text( + "#ifndef MP_RUNTIME_H\n#define MP_RUNTIME_H\n#endif\n", + encoding="utf-8", + ) + (temp / "py" / "obj.h").write_text( + "#ifndef MP_OBJ_H\n#define MP_OBJ_H\n#endif\n", + encoding="utf-8", + ) + (temp / "py" / "builtin.h").write_text( + BUILTIN_STUB, encoding="utf-8" + ) + + harness = temp / "native_autorun_harness.c" + harness.write_text(core + HARNESS, encoding="utf-8") + cls._executable = temp / "native_autorun_harness" + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(temp), + "-I", + str(NATIVE), + str(harness), + "-o", + str(cls._executable), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if compiled.returncode != 0: + raise AssertionError( + "native autorun harness did not compile:\n" + + compiled.stdout + + compiled.stderr + ) + + @classmethod + def tearDownClass(cls): + cls._temporary.cleanup() + + def run_scenario(self, scenario): + completed = subprocess.run( + [str(self._executable), scenario], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native autorun scenario {!r} failed:\n{}{}".format( + scenario, completed.stdout, completed.stderr + ), + ) + + def test_startup_load_is_cached_after_one_nvs_read(self): + self.run_scenario("cache") + + def test_successful_commit_publishes_the_new_cached_value(self): + self.run_scenario("success") + + def test_set_failure_keeps_the_prior_runtime_value(self): + self.run_scenario("set-failure") + + def test_commit_failure_keeps_the_prior_runtime_value(self): + self.run_scenario("commit-failure") + + +NVS_STUB = r""" +#ifndef NVS_H +#define NVS_H +#include <stdint.h> +typedef int esp_err_t; +typedef unsigned nvs_handle_t; +#define ESP_OK 0 +#define ESP_FAIL (-1) +#define ESP_ERR_NVS_NOT_FOUND 0x1102 +#define NVS_READONLY 1 +#define NVS_READWRITE 2 +esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle); +esp_err_t nvs_get_u8(nvs_handle_t handle, const char *key, uint8_t *value); +esp_err_t nvs_set_u8(nvs_handle_t handle, const char *key, uint8_t value); +esp_err_t nvs_commit(nvs_handle_t handle); +void nvs_close(nvs_handle_t handle); +#endif +""" + + +BUILTIN_STUB = r""" +#ifndef MP_BUILTIN_H +#define MP_BUILTIN_H +#define MP_IMPORT_STAT_FILE 1 +int mp_import_stat(const char *path); +#endif +""" + + +HARNESS = r""" + +#include <stdio.h> +#include <string.h> + +static uint8_t durable_autorun = 1u; +static uint8_t staged_autorun; +static bool staged_valid; +static esp_err_t read_open_result = ESP_OK; +static esp_err_t write_open_result = ESP_OK; +static esp_err_t get_result = ESP_OK; +static esp_err_t set_result = ESP_OK; +static esp_err_t commit_result = ESP_OK; +static unsigned read_open_calls; +static unsigned write_open_calls; +static unsigned get_calls; +static unsigned set_calls; +static unsigned commit_calls; +static unsigned register_calls; + +#define CHECK(condition, message) do { \ + if (!(condition)) { \ + fprintf(stderr, "%s\n", (message)); \ + return 1; \ + } \ +} while (0) + +esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle) { + if (strcmp(name, "pyble") != 0) { + return ESP_FAIL; + } + *handle = 1u; + if (mode == NVS_READONLY) { + read_open_calls++; + return read_open_result; + } + write_open_calls++; + return write_open_result; +} + +esp_err_t nvs_get_u8(nvs_handle_t handle, const char *key, uint8_t *value) { + (void)handle; + if (strcmp(key, "autorun") != 0) { + return ESP_FAIL; + } + get_calls++; + if (get_result == ESP_OK) { + *value = durable_autorun; + } + return get_result; +} + +esp_err_t nvs_set_u8(nvs_handle_t handle, const char *key, uint8_t value) { + (void)handle; + if (strcmp(key, "autorun") != 0) { + return ESP_FAIL; + } + set_calls++; + if (set_result == ESP_OK) { + staged_autorun = value; + staged_valid = true; + } + return set_result; +} + +esp_err_t nvs_commit(nvs_handle_t handle) { + (void)handle; + commit_calls++; + if (commit_result == ESP_OK && staged_valid) { + durable_autorun = staged_autorun; + staged_valid = false; + } + return commit_result; +} + +void nvs_close(nvs_handle_t handle) { + (void)handle; + staged_valid = false; +} + +void pble_proto_register(uint8_t opcode, pble_handler_t handler) { + if (opcode == PBLE_OP_SET_AUTORUN && handler == pble_boot_set_autorun_cmd) { + register_calls++; + } +} + +int mp_import_stat(const char *path) { + (void)path; + return 0; +} + +uint8_t pble_runner_run_file(const char *path) { + (void)path; + return PBLE_OK; +} + +static int initialize_enabled(void) { + CHECK(pble_boot_autorun_enabled(), + "initial persisted autorun=1 was not loaded"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "initial autorun observation must read NVS exactly once"); + return 0; +} + +static int scenario_cache(void) { + CHECK(initialize_enabled() == 0, "initial cache load failed"); + durable_autorun = 0u; + get_result = ESP_FAIL; + CHECK(pble_boot_autorun_enabled(), + "a later NVS change/failure replaced the cached runtime value"); + CHECK(pble_boot_autorun_enabled(), + "repeated capability reads did not retain the cached value"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "runtime autorun queries reopened NVS after initialization"); + return 0; +} + +static int scenario_success(void) { + CHECK(initialize_enabled() == 0, "initial cache load failed"); + CHECK(pble_boot_set_autorun(false) == PBLE_OK, + "successful disable did not return PBLE_OK"); + CHECK(durable_autorun == 0u, + "successful disable was not durably committed"); + CHECK(!pble_boot_autorun_enabled(), + "successful disable did not publish the cached runtime value"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "successful set forced a second NVS read instead of publishing RAM"); + CHECK(write_open_calls == 1u && set_calls == 1u && commit_calls == 1u, + "successful set did not perform exactly one write transaction"); + + CHECK(pble_boot_set_autorun(true) == PBLE_OK, + "successful enable did not return PBLE_OK"); + CHECK(durable_autorun == 1u && pble_boot_autorun_enabled(), + "successful enable did not update durable and cached state"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "successful enable forced a second NVS read"); + CHECK(pble_boot_config_fault() == 0u, + "successful persistence did not clear the bounded fault marker"); + return 0; +} + +static int scenario_set_failure(void) { + CHECK(initialize_enabled() == 0, "initial cache load failed"); + set_result = ESP_FAIL; + CHECK(pble_boot_set_autorun(false) == PBLE_EIO, + "failed nvs_set_u8 did not return PBLE_EIO"); + CHECK(durable_autorun == 1u && commit_calls == 0u, + "failed nvs_set_u8 changed persistence or attempted commit"); + get_result = ESP_FAIL; + CHECK(pble_boot_autorun_enabled(), + "failed nvs_set_u8 changed the prior runtime autorun value"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "set failure made runtime behavior depend on a later NVS read"); + CHECK(pble_boot_config_fault() != 0u, + "set failure did not latch the bounded fault marker"); + return 0; +} + +static int scenario_commit_failure(void) { + CHECK(initialize_enabled() == 0, "initial cache load failed"); + commit_result = ESP_FAIL; + CHECK(pble_boot_set_autorun(false) == PBLE_EIO, + "failed nvs_commit did not return PBLE_EIO"); + CHECK(durable_autorun == 1u && set_calls == 1u && commit_calls == 1u, + "failed commit changed durable state or skipped the transaction"); + get_result = ESP_FAIL; + CHECK(pble_boot_autorun_enabled(), + "failed nvs_commit changed the prior runtime autorun value"); + CHECK(read_open_calls == 1u && get_calls == 1u, + "commit failure made runtime behavior depend on a later NVS read"); + CHECK(pble_boot_config_fault() != 0u, + "commit failure did not latch the bounded fault marker"); + return 0; +} + +int main(int argc, char **argv) { + if (argc != 2) { + fputs("expected exactly one scenario\n", stderr); + return 2; + } + pble_boot_register(); + CHECK(register_calls == 1u, "SET_AUTORUN handler was not registered"); + if (strcmp(argv[1], "cache") == 0) { + return scenario_cache(); + } + if (strcmp(argv[1], "success") == 0) { + return scenario_success(); + } + if (strcmp(argv[1], "set-failure") == 0) { + return scenario_set_failure(); + } + if (strcmp(argv[1], "commit-failure") == 0) { + return scenario_commit_failure(); + } + fputs("unknown scenario\n", stderr); + return 2; +} +""" + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_closing_precedence.py b/tests/firmware_tests/host/test_v061_native_closing_precedence.py new file mode 100644 index 00000000..4dcc973c --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_closing_precedence.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""RED native contract for global SOFT_REBOOT closing precedence.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +PROTO = NATIVE / "pble_proto.c" +WIRE = NATIVE / "pble_wire.c" + + +class NativeClosingPrecedenceTest(unittest.TestCase): + """Exercise production dispatch on an unnegotiated successor session.""" + + def test_successor_commands_observe_global_closing_before_wire_session(self): + compiler = shutil.which("cc") + self.assertIsNotNone(compiler, "a host C compiler is required") + + proto_source = PROTO.read_text(encoding="utf-8", errors="strict") + marker = "// --- MicroPython module shim" + self.assertIn(marker, proto_source) + # Compile the production protocol core verbatim. The MicroPython-only + # module registration below this marker is unrelated to dispatch and + # would otherwise require the complete target object ABI on the host. + proto_core = proto_source.partition(marker)[0] + + with tempfile.TemporaryDirectory( + prefix="pyble-v061-native-closing-" + ) as temporary: + temp = Path(temporary) + (temp / "py").mkdir() + (temp / "freertos").mkdir() + (temp / "py" / "runtime.h").write_text( + RUNTIME_STUB, encoding="utf-8" + ) + (temp / "py" / "obj.h").write_text( + "#ifndef MP_OBJ_H\n#define MP_OBJ_H\n#endif\n", + encoding="utf-8", + ) + (temp / "freertos" / "FreeRTOS.h").write_text( + FREERTOS_STUB, encoding="utf-8" + ) + (temp / "freertos" / "task.h").write_text( + TASK_STUB, encoding="utf-8" + ) + (temp / "freertos" / "semphr.h").write_text( + SEMPHR_STUB, encoding="utf-8" + ) + + harness = temp / "native_closing_harness.c" + harness.write_text(proto_core + HARNESS, encoding="utf-8") + executable = temp / "native_closing_harness" + linker_gc = ( + "-Wl,-dead_strip" + if sys.platform == "darwin" + else "-Wl,--gc-sections" + ) + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-ffunction-sections", + "-fdata-sections", + "-I", + str(temp), + "-I", + str(NATIVE), + str(harness), + str(WIRE), + linker_gc, + "-o", + str(executable), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + compiled.returncode, + 0, + "native closing harness did not compile:\n" + compiled.stderr, + ) + + completed = subprocess.run( + [str(executable)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native dispatch violated closing precedence:\n" + + completed.stderr, + ) + + +RUNTIME_STUB = r""" +#ifndef MP_RUNTIME_H +#define MP_RUNTIME_H +typedef struct { int unused; } mp_obj_type_t; +extern const mp_obj_type_t mp_type_RuntimeError; +#define MP_ERROR_TEXT(text) (text) +void mp_raise_msg(const mp_obj_type_t *type, const char *message); +#endif +""" + + +FREERTOS_STUB = r""" +#ifndef FREERTOS_H +#define FREERTOS_H +#include <stdint.h> +typedef uint32_t TickType_t; +typedef struct { unsigned count; } StaticSemaphore_t; +typedef StaticSemaphore_t *SemaphoreHandle_t; +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define portMAX_DELAY UINT32_MAX +#define pdTRUE 1 +#define pdFALSE 0 +#define pdMS_TO_TICKS(milliseconds) ((TickType_t)(milliseconds)) +#define taskENTER_CRITICAL(mux) ((void)(mux)) +#define taskEXIT_CRITICAL(mux) ((void)(mux)) +#endif +""" + + +TASK_STUB = r""" +#ifndef FREERTOS_TASK_H +#define FREERTOS_TASK_H +#include "FreeRTOS.h" +TickType_t xTaskGetTickCount(void); +#endif +""" + + +SEMPHR_STUB = r""" +#ifndef FREERTOS_SEMPHR_H +#define FREERTOS_SEMPHR_H +#include "FreeRTOS.h" +SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *storage); +int xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout); +int xSemaphoreGive(SemaphoreHandle_t semaphore); +#endif +""" + + +HARNESS = r""" + +#include <stdio.h> +#include <stdlib.h> + +const mp_obj_type_t mp_type_RuntimeError = {0}; + +static unsigned violation_debits; +static unsigned handler_effects; +static unsigned notify_effects; +static uint64_t harness_epoch = 7u; + +void mp_raise_msg(const mp_obj_type_t *type, const char *message) { + (void)type; + fprintf(stderr, "unexpected MicroPython raise: %s\n", message); + abort(); +} + +SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *storage) { + storage->count = 0u; + return storage; +} + +int xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout) { + (void)timeout; + if (semaphore->count == 0u) { + return pdFALSE; + } + semaphore->count--; + return pdTRUE; +} + +int xSemaphoreGive(SemaphoreHandle_t semaphore) { + semaphore->count++; + return pdTRUE; +} + +TickType_t xTaskGetTickCount(void) { + return 1u; +} + +bool pble_ble_session_live(const pble_session_token_t *session) { + return session != NULL && session->vm_epoch == harness_epoch; +} + +bool pble_ble_record_protocol_violation( + const pble_session_token_t *session) { + (void)session; + violation_debits++; + return true; +} + +void pble_ble_terminate_session(const pble_session_token_t *session) { + (void)session; +} + +void pble_ble_rsp_kick(void) { +} + +int pble_ble_notify(const uint8_t *message, size_t length, + const pble_session_token_t *session) { + (void)message; + (void)length; + (void)session; + notify_effects++; + return PBLE_TX_OK; +} + +uint64_t pble_vm_epoch_current(void) { + return harness_epoch; +} + +bool pble_vm_epoch_valid(uint64_t epoch) { + return epoch == harness_epoch; +} + +bool pble_vm_reboot_command_admitted(bool is_soft_reboot) { + // Model the committed 250 ms grace: the legacy provisional gate admits + // SOFT so its handler can report the duplicate, but rejects other work. + // A global closing-precedence check calls this with false before applying + // the fresh successor's HELLO-first wire-session gate. + return is_soft_reboot; +} + +static uint8_t side_effect_handler( + const pble_frame_t *request, uint8_t *response, size_t *response_length, + const pble_session_token_t *session) { + (void)request; + (void)response; + (void)session; + handler_effects++; + *response_length = 0u; + return PBLE_OK; +} + +static void dispatch_empty_command(uint8_t opcode, uint8_t id, + const pble_session_token_t *session) { + uint8_t frame[16]; + int length = pble_proto_encode( + PBLE_TYPE_CMD, opcode, id, NULL, 0u, frame, sizeof(frame)); + if (length <= 0) { + fprintf(stderr, "could not encode opcode 0x%02x\n", opcode); + abort(); + } + pble_proto_dispatch_admitted(frame, (size_t)length, session); +} + +static void dispatch_empty_frame(uint8_t type, uint8_t opcode, uint8_t id, + const pble_session_token_t *session) { + uint8_t frame[16]; + int length = pble_proto_encode( + type, opcode, id, NULL, 0u, frame, sizeof(frame)); + if (length <= 0) { + fprintf(stderr, "could not encode type 0x%02x\n", type); + abort(); + } + pble_proto_dispatch_admitted(frame, (size_t)length, session); +} + +static int take_status(uint8_t opcode, uint8_t id) { + pble_rsp_tx_t tx; + if (!pble_rsp_tx_peek(&tx)) { + return -1; + } + int status = -2; + if (tx.frame_len >= PBLE_HDR_LEN + 1u + PBLE_CRC_LEN && + tx.frame[0] == PBLE_PROTO_VERSION && + tx.frame[1] == PBLE_TYPE_RSP && tx.frame[2] == opcode && + tx.frame[3] == id && tx.frame[4] == 1u && tx.frame[5] == 0u) { + status = tx.frame[PBLE_HDR_LEN]; + } + pble_rsp_tx_result(&tx, tx.stream_generation, tx.offset, tx.index, + tx.frame_len, PBLE_TX_OK); + return status; +} + +static void expect_equal(const char *label, unsigned actual, unsigned expected, + unsigned *failures) { + if (actual != expected) { + fprintf(stderr, "%s: got %u, expected %u\n", label, actual, expected); + (*failures)++; + } +} + +int main(void) { + const pble_session_token_t successor = { + .conn = 0x0044u, + .generation = 2u, + .vm_epoch = 7u, + }; + unsigned failures = 0u; + unsigned before; + + pble_proto_register(PBLE_OP_DEVICE_INFO, side_effect_handler); + pble_proto_register_special(PBLE_OP_SOFT_REBOOT, side_effect_handler); + pble_proto_register_no_response( + PBLE_OP_CONSOLE_INPUT, side_effect_handler); + + // Structural and direction faults retain their ordinary drop/refusal + // classification during accepted-SOFT closing, but closing has precedence + // over the session violation budget. + before = violation_debits; + const uint8_t short_message[] = {PBLE_PROTO_VERSION, PBLE_TYPE_CMD, 0x02u}; + pble_proto_dispatch_admitted( + short_message, sizeof(short_message), &successor); + dispatch_empty_frame(PBLE_TYPE_RSP, PBLE_OP_DEVICE_INFO, 0x30u, + &successor); + expect_equal("closing malformed/direction violation debit", + violation_debits, before, &failures); + + before = violation_debits; + dispatch_empty_command(PBLE_OP_DEVICE_INFO, 0x31u, &successor); + expect_equal("response-bearing status", + (unsigned)take_status(PBLE_OP_DEVICE_INFO, 0x31u), + PBLE_EBUSY, &failures); + expect_equal("response-bearing violation debit", violation_debits, before, + &failures); + + before = violation_debits; + dispatch_empty_command(PBLE_OP_SOFT_REBOOT, 0x32u, &successor); + expect_equal("successor duplicate SOFT status", + (unsigned)take_status(PBLE_OP_SOFT_REBOOT, 0x32u), + PBLE_EBUSY, &failures); + expect_equal("successor duplicate SOFT violation debit", violation_debits, + before, &failures); + + before = violation_debits; + dispatch_empty_command(PBLE_OP_CONSOLE_INPUT, 0x33u, &successor); + expect_equal("CONSOLE_INPUT violation debit", violation_debits, before, + &failures); + expect_equal("CONSOLE_INPUT response", pble_rsp_has_pending(), false, + &failures); + + expect_equal("handler side effects", handler_effects, 0u, &failures); + expect_equal("direct notify side effects", notify_effects, 0u, &failures); + return failures == 0u ? EXIT_SUCCESS : EXIT_FAILURE; +} +""" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_native_device_config_persistence.py b/tests/firmware_tests/host/test_v061_native_device_config_persistence.py new file mode 100644 index 00000000..9bc9a6ad --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_device_config_persistence.py @@ -0,0 +1,896 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Compiled ESP device-configuration persistence contract for v0.6.1. + +The production ``pble_device_config.c`` and ``pble_wire.c`` translation units +are compiled unchanged against deterministic host shims for NVS, advertising, +GPIO, timers, and VM lifecycle. Each scenario is a fresh process so the +production file's boot-lifetime static state is exercised exactly once. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +DEVICE_CONFIG = NATIVE / "pble_device_config.c" +WIRE = NATIVE / "pble_wire.c" + + +class NativeDeviceConfigPersistenceTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which(os.environ.get("CC", "cc")) + if compiler is None: + raise unittest.SkipTest("a host C compiler is required") + + cls._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-native-device-config-" + ) + temp = Path(cls._temporary.name) + for relative, source in STUB_HEADERS.items(): + target = temp / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + + harness = temp / "native_device_config_harness.c" + harness.write_text(HARNESS, encoding="utf-8") + cls._executable = temp / "native_device_config_harness" + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(temp), + "-I", + str(NATIVE), + str(DEVICE_CONFIG), + str(WIRE), + str(harness), + "-o", + str(cls._executable), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if compiled.returncode != 0: + raise AssertionError( + "native device-config harness did not compile:\n" + + compiled.stdout + + compiled.stderr + ) + + @classmethod + def tearDownClass(cls): + cls._temporary.cleanup() + + def run_scenario(self, scenario): + completed = subprocess.run( + [str(self._executable), scenario], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native device-config scenario {!r} failed:\n{}{}".format( + scenario, completed.stdout, completed.stderr + ), + ) + + def test_label_set_erase_and_commit_failures_retain_runtime_surfaces(self): + for scenario in ( + "label-set-failure", + "label-erase-failure", + "label-commit-failure", + "label-clear-commit-failure", + ): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_identify_set_and_commit_failures_retain_caps_and_gpio(self): + for scenario in ( + "identify-set-failure", + "identify-commit-failure", + "identify-clear-set-failure", + "identify-clear-commit-failure", + ): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_invalid_authoritative_blob_never_falls_back_to_legacy(self): + for scenario in ( + "authoritative-bad-version", + "authoritative-bad-length", + "authoritative-bad-disabled-shape", + "authoritative-bad-active-level", + "authoritative-bad-gpio", + "authoritative-probe-failure", + "authoritative-read-failure", + ): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_missing_authoritative_blob_accepts_only_wholly_valid_legacy_pair(self): + for scenario in ( + "legacy-valid", + "legacy-both-missing", + "legacy-missing-gpio", + "legacy-missing-active-level", + "legacy-bad-active-level", + "legacy-bad-gpio", + "legacy-gpio-read-failure", + "legacy-active-read-failure", + ): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_successful_repair_clears_only_its_configuration_domain(self): + for scenario in ("repair-label-only", "repair-identify-only"): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + +STUB_HEADERS = { + "esp_mac.h": r""" +#ifndef ESP_MAC_H +#define ESP_MAC_H +#include <stdint.h> +#ifndef ESP_ERR_T_DEFINED +#define ESP_ERR_T_DEFINED +typedef int esp_err_t; +#endif +#define ESP_MAC_BT 1 +esp_err_t esp_read_mac(uint8_t *mac, int type); +#endif +""", + "nvs.h": r""" +#ifndef NVS_H +#define NVS_H +#include <stddef.h> +#include <stdint.h> +#ifndef ESP_ERR_T_DEFINED +#define ESP_ERR_T_DEFINED +typedef int esp_err_t; +#endif +typedef unsigned nvs_handle_t; +#define ESP_OK 0 +#define ESP_FAIL (-1) +#define ESP_ERR_NVS_NOT_FOUND 0x1102 +#define ESP_ERR_INVALID_STATE 0x103 +#define NVS_READONLY 1 +#define NVS_READWRITE 2 +esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle); +esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *value, + size_t *length); +esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *value, + size_t *length); +esp_err_t nvs_get_u8(nvs_handle_t handle, const char *key, uint8_t *value); +esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value); +esp_err_t nvs_erase_key(nvs_handle_t handle, const char *key); +esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, + const void *value, size_t length); +esp_err_t nvs_commit(nvs_handle_t handle); +void nvs_close(nvs_handle_t handle); +#endif +""", + "esp_timer.h": r""" +#ifndef ESP_TIMER_H +#define ESP_TIMER_H +#include <stdint.h> +#include "nvs.h" +typedef void *esp_timer_handle_t; +typedef struct { + void (*callback)(void *); + const char *name; +} esp_timer_create_args_t; +esp_err_t esp_timer_create(const esp_timer_create_args_t *args, + esp_timer_handle_t *timer); +esp_err_t esp_timer_stop(esp_timer_handle_t timer); +esp_err_t esp_timer_start_once(esp_timer_handle_t timer, uint64_t timeout_us); +esp_err_t esp_timer_start_periodic(esp_timer_handle_t timer, + uint64_t period_us); +int64_t esp_timer_get_time(void); +#endif +""", + "esp_system.h": r""" +#ifndef ESP_SYSTEM_H +#define ESP_SYSTEM_H +void esp_restart(void) __attribute__((noreturn)); +#endif +""", + "driver/gpio.h": r""" +#ifndef DRIVER_GPIO_H +#define DRIVER_GPIO_H +#include "nvs.h" +#define GPIO_MODE_OUTPUT 1 +esp_err_t gpio_reset_pin(int gpio); +esp_err_t gpio_set_direction(int gpio, int mode); +esp_err_t gpio_set_level(int gpio, int level); +#endif +""", + "soc/gpio_num.h": r""" +#ifndef SOC_GPIO_NUM_H +#define SOC_GPIO_NUM_H +#define GPIO_NUM_MAX 16 +#define GPIO_IS_VALID_OUTPUT_GPIO(gpio) ((gpio) >= 0 && (gpio) < 10) +#endif +""", + "freertos/FreeRTOS.h": r""" +#ifndef FREERTOS_FREERTOS_H +#define FREERTOS_FREERTOS_H +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define taskENTER_CRITICAL(mux) ((void)(mux)) +#define taskEXIT_CRITICAL(mux) ((void)(mux)) +#endif +""", + "freertos/task.h": r""" +#ifndef FREERTOS_TASK_H +#define FREERTOS_TASK_H +#endif +""", +} + + +HARNESS = r""" +#include <stdbool.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "pble_device_config.h" +#include "esp_mac.h" +#include "nvs.h" +#include "esp_timer.h" +#include "driver/gpio.h" +#include "pble_vm_lifecycle.h" + +#define FAULT_LABEL 1u +#define FAULT_IDENTIFY 2u +#define VALID_OLD_GPIO 5u +#define VALID_NEW_GPIO 6u + +typedef struct { + bool label_present; + char label[64]; + bool id_cfg_present; + uint8_t id_cfg[16]; + size_t id_cfg_len; + bool legacy_gpio_present; + uint8_t legacy_gpio; + bool legacy_active_present; + uint8_t legacy_active; +} fake_store_t; + +static fake_store_t durable; +static fake_store_t staged; +static bool staged_open; + +static esp_err_t open_read_rc; +static esp_err_t open_write_rc; +static esp_err_t get_str_rc; +static esp_err_t get_blob_probe_rc; +static esp_err_t get_blob_read_rc; +static esp_err_t get_gpio_rc; +static esp_err_t get_active_rc; +static esp_err_t set_str_rc; +static esp_err_t erase_rc; +static esp_err_t set_blob_rc; +static esp_err_t commit_rc; + +static unsigned adv_calls; +static char last_adv[64]; +static unsigned gpio_reset_calls; +static unsigned gpio_direction_calls; +static unsigned gpio_level_calls; +static int last_gpio = -1; +static int last_gpio_level = -1; + +#define CHECK(condition, message) do { \ + if (!(condition)) { \ + fprintf(stderr, "%s\n", (message)); \ + return 1; \ + } \ +} while (0) + +static void backend_defaults(void) { + memset(&durable, 0, sizeof(durable)); + memset(&staged, 0, sizeof(staged)); + staged_open = false; + open_read_rc = ESP_OK; + open_write_rc = ESP_OK; + get_str_rc = ESP_OK; + get_blob_probe_rc = ESP_OK; + get_blob_read_rc = ESP_OK; + get_gpio_rc = ESP_OK; + get_active_rc = ESP_OK; + set_str_rc = ESP_OK; + erase_rc = ESP_OK; + set_blob_rc = ESP_OK; + commit_rc = ESP_OK; + adv_calls = 0; + memset(last_adv, 0, sizeof(last_adv)); + gpio_reset_calls = 0; + gpio_direction_calls = 0; + gpio_level_calls = 0; + last_gpio = -1; + last_gpio_level = -1; +} + +static void seed_label(const char *label) { + durable.label_present = true; + snprintf(durable.label, sizeof(durable.label), "%s", label); +} + +static void seed_valid_id(uint8_t gpio, uint8_t active) { + durable.id_cfg_present = true; + durable.id_cfg_len = 4u; + durable.id_cfg[0] = 1u; + durable.id_cfg[1] = 1u; + durable.id_cfg[2] = gpio; + durable.id_cfg[3] = active; +} + +static void seed_valid_legacy(uint8_t gpio, uint8_t active) { + durable.id_cfg_present = false; + durable.legacy_gpio_present = true; + durable.legacy_gpio = gpio; + durable.legacy_active_present = true; + durable.legacy_active = active; +} + +static void clear_observations(void) { + adv_calls = 0; + memset(last_adv, 0, sizeof(last_adv)); + gpio_reset_calls = 0; + gpio_direction_calls = 0; + gpio_level_calls = 0; + last_gpio = -1; + last_gpio_level = -1; +} + +static bool label_equals(const char *expected) { + char actual[64]; + size_t length = pble_dc_label(actual, sizeof(actual)); + return length == strlen(expected) && strcmp(actual, expected) == 0; +} + +static bool identify_equals(uint8_t expected_gpio, uint8_t expected_active) { + uint8_t gpio = 0xffu; + uint8_t active = 0xffu; + return pble_dc_identify_led(&gpio, &active) && + gpio == expected_gpio && active == expected_active; +} + +static bool no_gpio_effect(void) { + return gpio_reset_calls == 0u && gpio_direction_calls == 0u && + gpio_level_calls == 0u; +} + +esp_err_t esp_read_mac(uint8_t *mac, int type) { + if (mac == NULL || type != ESP_MAC_BT) { + return ESP_FAIL; + } + const uint8_t fixed[6] = {0x02u, 0, 0, 0, 0x56u, 0x46u}; + memcpy(mac, fixed, sizeof(fixed)); + return ESP_OK; +} + +esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle) { + if (strcmp(name, "pyble") != 0 || handle == NULL) { + return ESP_FAIL; + } + if (mode == NVS_READONLY) { + *handle = 1u; + return open_read_rc; + } + if (mode == NVS_READWRITE) { + *handle = 2u; + if (open_write_rc == ESP_OK) { + staged = durable; + staged_open = true; + } + return open_write_rc; + } + return ESP_FAIL; +} + +esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *value, + size_t *length) { + if (handle != 1u || strcmp(key, "label") != 0 || length == NULL) { + return ESP_FAIL; + } + if (get_str_rc != ESP_OK) { + return get_str_rc; + } + if (!durable.label_present) { + return ESP_ERR_NVS_NOT_FOUND; + } + size_t needed = strlen(durable.label) + 1u; + if (value == NULL || *length < needed) { + *length = needed; + return ESP_FAIL; + } + memcpy(value, durable.label, needed); + *length = needed; + return ESP_OK; +} + +esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *value, + size_t *length) { + if (handle != 1u || strcmp(key, "id_cfg") != 0 || length == NULL) { + return ESP_FAIL; + } + if (!durable.id_cfg_present) { + return ESP_ERR_NVS_NOT_FOUND; + } + if (value == NULL) { + if (get_blob_probe_rc != ESP_OK) { + return get_blob_probe_rc; + } + *length = durable.id_cfg_len; + return ESP_OK; + } + if (get_blob_read_rc != ESP_OK) { + return get_blob_read_rc; + } + if (*length < durable.id_cfg_len) { + *length = durable.id_cfg_len; + return ESP_FAIL; + } + memcpy(value, durable.id_cfg, durable.id_cfg_len); + *length = durable.id_cfg_len; + return ESP_OK; +} + +esp_err_t nvs_get_u8(nvs_handle_t handle, const char *key, uint8_t *value) { + if (handle != 1u || value == NULL) { + return ESP_FAIL; + } + if (strcmp(key, "id_gpio") == 0) { + if (get_gpio_rc != ESP_OK) { + return get_gpio_rc; + } + if (!durable.legacy_gpio_present) { + return ESP_ERR_NVS_NOT_FOUND; + } + *value = durable.legacy_gpio; + return ESP_OK; + } + if (strcmp(key, "id_al") == 0) { + if (get_active_rc != ESP_OK) { + return get_active_rc; + } + if (!durable.legacy_active_present) { + return ESP_ERR_NVS_NOT_FOUND; + } + *value = durable.legacy_active; + return ESP_OK; + } + return ESP_FAIL; +} + +esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value) { + if (handle != 2u || !staged_open || strcmp(key, "label") != 0 || + value == NULL) { + return ESP_FAIL; + } + if (set_str_rc != ESP_OK) { + return set_str_rc; + } + staged.label_present = true; + snprintf(staged.label, sizeof(staged.label), "%s", value); + return ESP_OK; +} + +esp_err_t nvs_erase_key(nvs_handle_t handle, const char *key) { + if (handle != 2u || !staged_open || strcmp(key, "label") != 0) { + return ESP_FAIL; + } + if (erase_rc != ESP_OK) { + return erase_rc; + } + staged.label_present = false; + staged.label[0] = '\0'; + return ESP_OK; +} + +esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, + const void *value, size_t length) { + if (handle != 2u || !staged_open || strcmp(key, "id_cfg") != 0 || + value == NULL || length > sizeof(staged.id_cfg)) { + return ESP_FAIL; + } + if (set_blob_rc != ESP_OK) { + return set_blob_rc; + } + staged.id_cfg_present = true; + staged.id_cfg_len = length; + memcpy(staged.id_cfg, value, length); + return ESP_OK; +} + +esp_err_t nvs_commit(nvs_handle_t handle) { + if (handle != 2u || !staged_open) { + return ESP_FAIL; + } + if (commit_rc != ESP_OK) { + return commit_rc; + } + durable = staged; + return ESP_OK; +} + +void nvs_close(nvs_handle_t handle) { + if (handle == 2u) { + staged_open = false; + } +} + +void pble_ble_set_adv_name(const char *name) { + adv_calls++; + snprintf(last_adv, sizeof(last_adv), "%s", name == NULL ? "" : name); +} + +esp_err_t gpio_reset_pin(int gpio) { + gpio_reset_calls++; + last_gpio = gpio; + return ESP_OK; +} + +esp_err_t gpio_set_direction(int gpio, int mode) { + if (mode != GPIO_MODE_OUTPUT) { + return ESP_FAIL; + } + gpio_direction_calls++; + last_gpio = gpio; + return ESP_OK; +} + +esp_err_t gpio_set_level(int gpio, int level) { + gpio_level_calls++; + last_gpio = gpio; + last_gpio_level = level; + return ESP_OK; +} + +esp_err_t esp_timer_create(const esp_timer_create_args_t *args, + esp_timer_handle_t *timer) { + if (args == NULL || args->callback == NULL || timer == NULL) { + return ESP_FAIL; + } + *timer = (void *)1; + return ESP_OK; +} + +esp_err_t esp_timer_stop(esp_timer_handle_t timer) { + (void)timer; + return ESP_ERR_INVALID_STATE; +} + +esp_err_t esp_timer_start_once(esp_timer_handle_t timer, uint64_t timeout_us) { + (void)timer; + (void)timeout_us; + return ESP_OK; +} + +esp_err_t esp_timer_start_periodic(esp_timer_handle_t timer, + uint64_t period_us) { + (void)timer; + (void)period_us; + return ESP_OK; +} + +int64_t esp_timer_get_time(void) { + return 1; +} + +void esp_restart(void) { + abort(); +} + +bool pble_vm_callback_enter(uint64_t epoch, pble_vm_activity_t *activity) { + if (activity != NULL) { + activity->epoch = epoch; + activity->active = true; + } + return epoch != 0; +} + +void pble_vm_callback_leave(pble_vm_activity_t *activity) { + if (activity != NULL) { + activity->active = false; + } +} + +void pble_proto_register(uint8_t opcode, pble_handler_t handler) { + (void)opcode; + (void)handler; +} + +static int initialize_old_state(void) { + seed_label("old"); + seed_valid_id(VALID_OLD_GPIO, 1u); + pble_dc_init(); + CHECK(label_equals("old"), "initial label did not load"); + CHECK(identify_equals(VALID_OLD_GPIO, 1u), + "initial Identify configuration did not load"); + CHECK(pble_dc_config_fault() == 0u, + "valid initial state unexpectedly set a fault marker"); + clear_observations(); + return 0; +} + +static int check_label_failure(bool clear, esp_err_t write_rc, + esp_err_t transaction_commit_rc) { + CHECK(initialize_old_state() == 0, "initial state failed"); + if (clear) { + erase_rc = write_rc; + } else { + set_str_rc = write_rc; + } + commit_rc = transaction_commit_rc; + const uint8_t replacement[] = "new"; + uint8_t status = pble_dc_set_label( + clear ? NULL : replacement, + clear ? 0u : sizeof(replacement) - 1u); + CHECK(status == PBLE_EIO, "failed label transaction was not EIO"); + CHECK(label_equals("old"), "failed label transaction changed RAM/caps"); + CHECK(durable.label_present && strcmp(durable.label, "old") == 0, + "failed label transaction changed durable state"); + CHECK(adv_calls == 0u && last_adv[0] == '\0', + "failed label transaction changed advertisement"); + CHECK(identify_equals(VALID_OLD_GPIO, 1u), + "label failure changed Identify caps"); + CHECK(no_gpio_effect(), "label failure changed GPIO state"); + CHECK(pble_dc_config_fault() == FAULT_LABEL, + "label failure changed the wrong fault domain"); + return 0; +} + +static int check_identify_failure(bool clear, esp_err_t write_rc, + esp_err_t transaction_commit_rc) { + CHECK(initialize_old_state() == 0, "initial state failed"); + set_blob_rc = write_rc; + commit_rc = transaction_commit_rc; + const uint8_t replacement[2] = {VALID_NEW_GPIO, 0u}; + CHECK(pble_dc_set_identify_led(clear ? NULL : replacement, + clear ? 0u : sizeof(replacement)) == PBLE_EIO, + "failed Identify transaction was not EIO"); + CHECK(identify_equals(VALID_OLD_GPIO, 1u), + "failed Identify transaction changed RAM/caps"); + CHECK(durable.id_cfg_present && durable.id_cfg_len == 4u && + durable.id_cfg[1] == 1u && durable.id_cfg[2] == VALID_OLD_GPIO && + durable.id_cfg[3] == 1u, + "failed Identify transaction changed durable state"); + CHECK(label_equals("old"), "Identify failure changed label caps"); + CHECK(adv_calls == 0u, "Identify failure changed advertisement"); + CHECK(no_gpio_effect(), "Identify failure changed GPIO state"); + CHECK(pble_dc_config_fault() == FAULT_IDENTIFY, + "Identify failure changed the wrong fault domain"); + return 0; +} + +static int check_authoritative_invalid(const char *scenario) { + seed_valid_legacy(VALID_OLD_GPIO, 1u); + durable.id_cfg_present = true; + durable.id_cfg_len = 4u; + durable.id_cfg[0] = 1u; + durable.id_cfg[1] = 1u; + durable.id_cfg[2] = VALID_NEW_GPIO; + durable.id_cfg[3] = 0u; + if (strcmp(scenario, "authoritative-bad-version") == 0) { + durable.id_cfg[0] = 2u; + } else if (strcmp(scenario, "authoritative-bad-length") == 0) { + durable.id_cfg_len = 3u; + } else if (strcmp(scenario, "authoritative-bad-disabled-shape") == 0) { + durable.id_cfg[1] = 0u; + durable.id_cfg[2] = 1u; + } else if (strcmp(scenario, "authoritative-bad-active-level") == 0) { + durable.id_cfg[3] = 2u; + } else if (strcmp(scenario, "authoritative-bad-gpio") == 0) { + durable.id_cfg[2] = 12u; + } else if (strcmp(scenario, "authoritative-probe-failure") == 0) { + get_blob_probe_rc = ESP_FAIL; + } else if (strcmp(scenario, "authoritative-read-failure") == 0) { + get_blob_read_rc = ESP_FAIL; + } else { + return 90; + } + pble_dc_init(); + CHECK(!pble_dc_identify_led(NULL, NULL), + "invalid authoritative id_cfg fell back to legacy keys"); + CHECK((pble_dc_config_fault() & FAULT_IDENTIFY) != 0u, + "invalid authoritative id_cfg did not mark Identify fault"); + CHECK(no_gpio_effect(), + "invalid authoritative id_cfg configured or drove legacy GPIO"); + return 0; +} + +static int check_legacy(const char *scenario) { + durable.id_cfg_present = false; + if (strcmp(scenario, "legacy-valid") == 0) { + seed_valid_legacy(VALID_OLD_GPIO, 1u); + } else if (strcmp(scenario, "legacy-both-missing") == 0) { + /* Clean first boot: neither legacy key exists. */ + } else if (strcmp(scenario, "legacy-missing-gpio") == 0) { + durable.legacy_active_present = true; + durable.legacy_active = 1u; + } else if (strcmp(scenario, "legacy-missing-active-level") == 0) { + durable.legacy_gpio_present = true; + durable.legacy_gpio = VALID_OLD_GPIO; + } else if (strcmp(scenario, "legacy-bad-active-level") == 0) { + seed_valid_legacy(VALID_OLD_GPIO, 2u); + } else if (strcmp(scenario, "legacy-bad-gpio") == 0) { + seed_valid_legacy(12u, 1u); + } else if (strcmp(scenario, "legacy-gpio-read-failure") == 0) { + seed_valid_legacy(VALID_OLD_GPIO, 1u); + get_gpio_rc = ESP_FAIL; + } else if (strcmp(scenario, "legacy-active-read-failure") == 0) { + seed_valid_legacy(VALID_OLD_GPIO, 1u); + get_active_rc = ESP_FAIL; + } else { + return 91; + } + pble_dc_init(); + if (strcmp(scenario, "legacy-valid") == 0) { + CHECK(identify_equals(VALID_OLD_GPIO, 1u), + "wholly valid legacy pair was not accepted"); + CHECK((pble_dc_config_fault() & FAULT_IDENTIFY) == 0u, + "wholly valid legacy pair set an Identify fault"); + CHECK(gpio_reset_calls == 1u && gpio_direction_calls == 1u && + gpio_level_calls == 1u && last_gpio == VALID_OLD_GPIO, + "wholly valid legacy pair did not configure its GPIO once"); + } else if (strcmp(scenario, "legacy-both-missing") == 0) { + CHECK(!pble_dc_identify_led(NULL, NULL), + "missing legacy keys unexpectedly configured Identify"); + CHECK((pble_dc_config_fault() & FAULT_IDENTIFY) == 0u, + "clean first boot was classified as corruption"); + CHECK(no_gpio_effect(), "missing legacy keys changed GPIO state"); + } else { + CHECK(!pble_dc_identify_led(NULL, NULL), + "partial or invalid legacy keys configured Identify"); + CHECK((pble_dc_config_fault() & FAULT_IDENTIFY) != 0u, + "partial or invalid legacy keys did not mark Identify fault"); + CHECK(no_gpio_effect(), "partial or invalid legacy keys changed GPIO"); + } + return 0; +} + +static int repair_label_only(void) { + seed_label("old"); + durable.id_cfg_present = true; + durable.id_cfg_len = 4u; + durable.id_cfg[0] = 2u; /* authoritative Identify corruption */ + pble_dc_init(); + CHECK(pble_dc_config_fault() == FAULT_IDENTIFY, + "initial Identify fault was not isolated"); + clear_observations(); + + set_str_rc = ESP_FAIL; + CHECK(pble_dc_set_label((const uint8_t *)"new", 3u) == PBLE_EIO, + "synthetic label failure was not EIO"); + CHECK(pble_dc_config_fault() == (FAULT_LABEL | FAULT_IDENTIFY), + "synthetic label failure did not latch both domains"); + CHECK(label_equals("old") && adv_calls == 0u, + "failed label repair changed runtime state"); + + set_str_rc = ESP_OK; + CHECK(pble_dc_set_label((const uint8_t *)"new", 3u) == PBLE_OK, + "successful label repair failed"); + CHECK(label_equals("new") && durable.label_present && + strcmp(durable.label, "new") == 0, + "successful label repair did not publish committed value"); + CHECK(adv_calls == 1u && strcmp(last_adv, "new") == 0, + "successful label repair did not update advertisement exactly once"); + CHECK(pble_dc_config_fault() == FAULT_IDENTIFY, + "label repair cleared another configuration domain"); + CHECK(!pble_dc_identify_led(NULL, NULL) && no_gpio_effect(), + "label repair changed failed-closed Identify state"); + return 0; +} + +static int repair_identify_only(void) { + seed_label("unsafe\nlabel"); /* strict label load failure */ + seed_valid_id(VALID_OLD_GPIO, 1u); + pble_dc_init(); + CHECK(pble_dc_config_fault() == FAULT_LABEL, + "initial label fault was not isolated"); + CHECK(identify_equals(VALID_OLD_GPIO, 1u), + "initial valid Identify state did not load"); + clear_observations(); + + const uint8_t replacement[2] = {VALID_NEW_GPIO, 0u}; + set_blob_rc = ESP_FAIL; + CHECK(pble_dc_set_identify_led(replacement, sizeof(replacement)) == PBLE_EIO, + "synthetic Identify failure was not EIO"); + CHECK(pble_dc_config_fault() == (FAULT_LABEL | FAULT_IDENTIFY), + "synthetic Identify failure did not latch both domains"); + CHECK(identify_equals(VALID_OLD_GPIO, 1u) && no_gpio_effect(), + "failed Identify repair changed runtime/GPIO state"); + + set_blob_rc = ESP_OK; + CHECK(pble_dc_set_identify_led(replacement, sizeof(replacement)) == PBLE_OK, + "successful Identify repair failed"); + CHECK(identify_equals(VALID_NEW_GPIO, 0u), + "successful Identify repair did not publish committed caps"); + CHECK(durable.id_cfg_present && durable.id_cfg[2] == VALID_NEW_GPIO && + durable.id_cfg[3] == 0u, + "successful Identify repair did not update durable state"); + CHECK(gpio_reset_calls == 1u && gpio_direction_calls == 1u && + gpio_level_calls >= 2u && last_gpio == VALID_NEW_GPIO, + "successful Identify repair did not apply GPIO after commit"); + CHECK(pble_dc_config_fault() == FAULT_LABEL, + "Identify repair cleared another configuration domain"); + CHECK(label_equals("") && adv_calls == 0u, + "Identify repair changed failed-closed label/advertisement state"); + return 0; +} + +int main(int argc, char **argv) { + if (argc != 2) { + return 64; + } + backend_defaults(); + const char *scenario = argv[1]; + if (strcmp(scenario, "label-set-failure") == 0) { + return check_label_failure(false, ESP_FAIL, ESP_OK); + } + if (strcmp(scenario, "label-erase-failure") == 0) { + return check_label_failure(true, ESP_FAIL, ESP_OK); + } + if (strcmp(scenario, "label-commit-failure") == 0) { + return check_label_failure(false, ESP_OK, ESP_FAIL); + } + if (strcmp(scenario, "label-clear-commit-failure") == 0) { + return check_label_failure(true, ESP_OK, ESP_FAIL); + } + if (strcmp(scenario, "identify-set-failure") == 0) { + return check_identify_failure(false, ESP_FAIL, ESP_OK); + } + if (strcmp(scenario, "identify-commit-failure") == 0) { + return check_identify_failure(false, ESP_OK, ESP_FAIL); + } + if (strcmp(scenario, "identify-clear-set-failure") == 0) { + return check_identify_failure(true, ESP_FAIL, ESP_OK); + } + if (strcmp(scenario, "identify-clear-commit-failure") == 0) { + return check_identify_failure(true, ESP_OK, ESP_FAIL); + } + if (strncmp(scenario, "authoritative-", 14u) == 0) { + return check_authoritative_invalid(scenario); + } + if (strncmp(scenario, "legacy-", 7u) == 0) { + return check_legacy(scenario); + } + if (strcmp(scenario, "repair-label-only") == 0) { + return repair_label_only(); + } + if (strcmp(scenario, "repair-identify-only") == 0) { + return repair_identify_only(); + } + return 65; +} +""" + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_fs_behavior.py b/tests/firmware_tests/host/test_v061_native_fs_behavior.py new file mode 100644 index 00000000..2c154412 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_fs_behavior.py @@ -0,0 +1,1406 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Execute v0.6.1 native filesystem reducers against a fake VFS. + +This test compiles the named function bodies directly from production +``pble_fs.c``. The surrounding MicroPython/VFS and PBLE session surfaces are +small deterministic seams: assertions therefore exercise the C control flow +that ships, rather than a Python model of that control flow. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +FS_SOURCE = NATIVE / "pble_fs.c" + + +def _matching_brace(source: str, opening: int) -> int: + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line-comment": + if char == "\n": + state = "code" + elif state == "block-comment": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line-comment" + index += 1 + elif char == "/" and nxt == "*": + state = "block-comment" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + index += 1 + raise AssertionError("unbalanced C function body") + + +def _function(source: str, name: str) -> str: + match = re.search( + r"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_]" + r"[A-Za-z0-9_ \t*]*\b" + + re.escape(name) + + r"[ \t]*\([^;{}]*\)[ \t]*\{", + source, + ) + if match is None: + raise AssertionError("native function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + closing = _matching_brace(source, opening) + return source[match.start(): closing + 1] + + +PRODUCTION_FUNCTIONS = ( + "le32", + "rd32", + "rd16", + "crc32_update", + "pble_fs_errno_to_status", + "fs_exc_to_status", + "fs_reserved_prefix", + "fs_has_tmp_suffix", + "fs_utf8_valid", + "pble_fs_resolve", + "fs_is_forbidden_artifact", + "fs_str", + "fs_close_local", + "fs_open", + "fs_chunk", + "fs_mode_is_regular", + "fs_stat_path", + "fs_crc_file", + "fs_crc_prefix", + "fs_do_list", + "fs_do_get", + "pble_fs_transfer_valid", + "pble_fs_item_valid", + "pble_fs_ticket_valid", + "fs_put_close", + "fs_put_reset_locked", + "fs_put_owned", + "fs_put_active_current", + "fs_put_reset_owner", + "fs_put_close_local", + "fs_put_reconcile_stale", + "pble_fs_on_disconnect", + "fs_remove_malformed_scratch", + "fs_put_space_status", + "pble_fs_resume_prefix", + "fs_put_abort", + "fs_put_publish", + "fs_do_put_begin", + "fs_do_put_end", + "fs_do_delete", + "fs_do_mkdir", + "fs_do_rename", +) + + +class NativeFsBehaviorTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + compiler = shutil.which(os.environ.get("CC", "cc")) + if compiler is None: + raise unittest.SkipTest("a host C compiler is required") + + source = FS_SOURCE.read_text(encoding="utf-8", errors="strict") + production = "\n\n".join( + _function(source, name) for name in PRODUCTION_FUNCTIONS + ) + cls._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-native-fs-behavior-" + ) + temp = Path(cls._temporary.name) + harness = temp / "native_fs_behavior.c" + harness.write_text( + PRELUDE + "\n\n" + production + "\n\n" + SCENARIOS, + encoding="utf-8", + ) + cls._executable = temp / "native_fs_behavior" + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + str(harness), + "-o", + str(cls._executable), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if compiled.returncode != 0: + raise AssertionError( + "native filesystem behavior harness did not compile:\n" + + compiled.stdout + + compiled.stderr + ) + + @classmethod + def tearDownClass(cls) -> None: + cls._temporary.cleanup() + + def run_scenario(self, scenario: str) -> None: + completed = subprocess.run( + [str(self._executable), scenario], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native filesystem scenario {!r} failed:\n{}{}".format( + scenario, completed.stdout, completed.stderr + ), + ) + + def test_scratch_entries_are_hidden_before_budget_and_stat(self) -> None: + self.run_scenario("listing") + + def test_capacity_uses_exact_reserve_and_checked_geometry(self) -> None: + self.run_scenario("capacity") + + def test_malformed_resume_reads_preserve_the_old_destination(self) -> None: + self.run_scenario("malformed-resume") + + def test_active_transfers_block_mutation_only_after_jail_resolution(self) -> None: + self.run_scenario("mutation-gate") + + def test_stale_identity_cannot_publish_or_commit(self) -> None: + self.run_scenario("identity") + + def test_crc_file_closes_after_post_open_invalidation(self) -> None: + self.run_scenario("crc-file-open-close") + + def test_crc_file_closes_after_post_read_invalidation(self) -> None: + self.run_scenario("crc-file-read-close") + + def test_crc_prefix_closes_after_post_open_invalidation(self) -> None: + self.run_scenario("crc-prefix-open-close") + + def test_crc_prefix_closes_after_post_read_invalidation(self) -> None: + self.run_scenario("crc-prefix-read-close") + + def test_get_closes_after_post_open_invalidation(self) -> None: + self.run_scenario("get-open-close") + + def test_get_closes_after_post_read_invalidation(self) -> None: + self.run_scenario("get-read-close") + + def test_put_begin_closes_local_handle_across_invalidation(self) -> None: + self.run_scenario("put-begin-open-close") + + +PRELUDE = r""" +#define _POSIX_C_SOURCE 200809L +#include <stdbool.h> +#include <stdint.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <setjmp.h> +#include <limits.h> + +#define PBLE_OK 0x00u +#define PBLE_EBADREQ 0x01u +#define PBLE_ENOENT 0x02u +#define PBLE_EACCES 0x03u +#define PBLE_ENOSPC 0x04u +#define PBLE_EIO 0x05u +#define PBLE_ENOMEM 0x06u +#define PBLE_EBUSY 0x07u +#define PBLE_ECRC 0x08u +#define PBLE_ERANGE 0x09u +#define PBLE_EINTERNAL 0xffu +#define PBLE_NO_RSP 0xfeu +#define PBLE_RSP_MAX 480u +#define PBLE_OP_FILE_GET_BEGIN 0x12u +#define PBLE_OP_FILE_GET_DATA 0x13u +#define PBLE_OP_FILE_GET_END 0x14u +#define PBLE_TX_OK 0 + +#define PBLE_FS_ROOT "/" +#define PBLE_FS_PATH_MAX 128u +#define PBLE_FS_PATH_BUF 160u +#define PBLE_FS_TMP_SUFFIX ".pbltmp" +#define PBLE_FS_SAFETY_RESERVE 65536u +#define PBLE_FS_ITEM_PAYLOAD 260u +#define PBLE_FS_SCRATCH 512u +#define PBLE_FS_ENOTEMPTY 39 +#define PBLE_CHUNK_OVERHEAD 18u + +#define MP_ENOENT 2 +#define MP_EIO 5 +#define MP_ENOMEM 12 +#define MP_EACCES 13 +#define MP_EEXIST 17 +#define MP_ENOTDIR 20 +#define MP_EISDIR 21 +#define MP_EINVAL 22 +#define MP_ENOSPC 28 +#define MP_EROFS 30 +#define MP_ERANGE 34 +#define MP_EPERM 1 +#define MP_S_IFDIR 0x4000 +#define MP_S_IFREG 0x8000 +#define MP_STREAM_ERROR ((mp_uint_t)-1) +#define MP_THREAD_GIL_EXIT() do { } while (0) +#define MP_THREAD_GIL_ENTER() do { } while (0) + +typedef long long mp_int_t; +typedef unsigned long long mp_uint_t; +typedef struct fake_obj fake_obj_t; +typedef fake_obj_t *mp_obj_t; +typedef struct { int unused; } mp_map_t; +typedef struct { int unused; } mp_obj_iter_buf_t; +typedef struct { + mp_uint_t (*read)(mp_obj_t, void *, mp_uint_t, int *); + mp_uint_t (*write)(mp_obj_t, const void *, mp_uint_t, int *); +} mp_stream_p_t; + +typedef struct { + uint16_t conn; + uint32_t incarnation; +} pble_session_token_t; + +typedef struct { + uint8_t slot; + uint32_t incarnation; + pble_session_token_t session; + uint64_t vm_epoch; +} pble_rsp_ticket_t; + +typedef struct { + uint8_t opcode; + uint8_t id; + pble_session_token_t session; + uint64_t vm_epoch; + uint64_t transfer_generation; + bool get_active_at_enqueue; + pble_rsp_ticket_t ticket; + uint16_t len; + uint8_t payload[PBLE_FS_ITEM_PAYLOAD]; +} pble_fs_req_t; + +typedef bool (*pble_fs_validator_t)(const pble_fs_req_t *it); + +typedef struct nlr_buf { + jmp_buf jump; + void *ret_val; + struct nlr_buf *previous; +} nlr_buf_t; + +static nlr_buf_t *g_nlr_top; +#define nlr_push(buffer) \ + (((buffer)->previous = g_nlr_top), (g_nlr_top = (buffer)), \ + setjmp((buffer)->jump)) +#define nlr_pop() do { \ + g_nlr_top = g_nlr_top->previous; \ +} while (0) + +enum fake_kind { + FAKE_INT, + FAKE_STRING, + FAKE_ARRAY, + FAKE_ITERATOR, + FAKE_FILE, + FAKE_EXCEPTION, +}; + +struct fake_obj { + enum fake_kind kind; + mp_int_t integer; + const char *string; + size_t length; + mp_obj_t elements[10]; + size_t count; + size_t cursor; +}; + +#define MP_OBJ_NULL ((mp_obj_t)0) +#define MP_OBJ_STOP_ITERATION ((mp_obj_t)(uintptr_t)1u) +#define MP_OBJ_FROM_PTR(pointer) ((mp_obj_t)(pointer)) + +static fake_obj_t g_objects[512]; +static size_t g_object_count; +static fake_obj_t g_exception; +static fake_obj_t g_exception_value; +static fake_obj_t g_file_object; +static mp_obj_t g_listing_items[8]; +static size_t g_listing_count; + +static bool g_session_enabled = true; +static bool g_vm_enabled = true; +static bool g_ticket_enabled = true; +static pble_session_token_t g_live_session = {7u, 11u}; +static uint64_t g_live_vm_epoch = 13u; + +enum invalidation_domain { + INVALIDATE_NONE, + INVALIDATE_SESSION, + INVALIDATE_VM, + INVALIDATE_TRANSFER, +}; +static enum invalidation_domain g_invalidation_domain; +static bool g_invalidate_after_open; +static bool g_invalidate_after_read; +static bool g_open_cut_via_ticket; +static bool g_open_cut_armed; +static unsigned g_item_checks_after_open; +static unsigned g_ticket_checks_after_open; + +static bool g_scratch_exists; +static bool g_scratch_is_directory; +static uint32_t g_scratch_stat_size; +static uint8_t g_scratch_bytes[32]; +static size_t g_scratch_actual_size; +static int g_read_behavior; +static bool g_remove_failure; +static char g_old_destination[32]; + +static mp_int_t g_statvfs_unit = 4096; +static mp_int_t g_statvfs_available = 16; +static size_t g_statvfs_count = 5u; +static bool g_statvfs_unit_is_int = true; +static bool g_statvfs_available_is_int = true; + +static unsigned g_stat_calls; +static unsigned g_visible_stat_calls; +static unsigned g_hidden_stat_calls; +static unsigned g_open_calls; +static unsigned g_close_calls; +static unsigned g_remove_calls; +static unsigned g_rmdir_calls; +static unsigned g_mkdir_calls; +static unsigned g_rename_calls; + +static uint8_t g_scratch[PBLE_FS_SCRATCH]; +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define taskENTER_CRITICAL(mux) do { (void)(mux); } while (0) +#define taskEXIT_CRITICAL(mux) do { (void)(mux); } while (0) +static portMUX_TYPE g_fs_transfer_mux = portMUX_INITIALIZER_UNLOCKED; +static uint64_t g_fs_transfer_generation = 1u; +static bool g_fs_transfer_exhausted; +static bool g_get_active; +static uint64_t g_get_generation; +static bool g_put_active; +static char g_put_temp[PBLE_FS_PATH_BUF]; +static char g_put_dest[PBLE_FS_PATH_BUF]; +static uint32_t g_put_total; +static uint32_t g_put_crc_target; +static uint32_t g_put_watermark; +static uint32_t g_put_crc_running; +static uint8_t g_put_latched; +static uint64_t g_put_generation; +static mp_obj_t g_root_pble_fs_put_file; +#define MP_STATE_VM(member) g_root_##member + +static bool pble_fs_transfer_valid(const pble_fs_req_t *it); +static bool pble_fs_item_valid(const pble_fs_req_t *it); +static bool pble_fs_ticket_valid(const pble_fs_req_t *it); +static uint8_t fs_stat_path(const pble_fs_req_t *it, const char *path, + uint32_t *size, bool *isdir); +static bool fs_put_owned(const pble_fs_req_t *it); +static bool fs_put_active_current(const pble_fs_req_t *it); + +static void invalidate_identity(void) { + switch (g_invalidation_domain) { + case INVALIDATE_SESSION: + g_session_enabled = false; + break; + case INVALIDATE_VM: + g_vm_enabled = false; + break; + case INVALIDATE_TRANSFER: + g_fs_transfer_generation++; + break; + default: + break; + } +} + +static void fake_die(const char *message) { + fprintf(stderr, "fake runtime error: %s\n", message); + exit(90); +} + +static fake_obj_t *fake_object(enum fake_kind kind) { + if (g_object_count >= sizeof(g_objects) / sizeof(g_objects[0])) { + fake_die("object pool exhausted"); + } + fake_obj_t *object = &g_objects[g_object_count++]; + memset(object, 0, sizeof(*object)); + object->kind = kind; + return object; +} + +static mp_obj_t fake_int(mp_int_t value) { + fake_obj_t *object = fake_object(FAKE_INT); + object->integer = value; + return object; +} + +static mp_obj_t fake_string(const char *value, size_t length) { + fake_obj_t *object = fake_object(FAKE_STRING); + object->string = value; + object->length = length; + return object; +} + +static mp_obj_t fake_array(mp_obj_t *items, size_t count) { + if (count > 10u) { + fake_die("array too large"); + } + fake_obj_t *object = fake_object(FAKE_ARRAY); + memcpy(object->elements, items, count * sizeof(items[0])); + object->count = count; + return object; +} + +static _Noreturn void mp_raise_OSError(int error) { + if (g_nlr_top == NULL) { + fake_die("OSError outside nlr"); + } + g_exception.kind = FAKE_EXCEPTION; + g_exception_value.kind = FAKE_INT; + g_exception_value.integer = error; + nlr_buf_t *target = g_nlr_top; + target->ret_val = &g_exception; + g_nlr_top = target->previous; + longjmp(target->jump, 1); +} + +static bool mp_obj_is_exception_instance(mp_obj_t object) { + return object != MP_OBJ_NULL && object->kind == FAKE_EXCEPTION; +} + +static mp_obj_t mp_obj_exception_get_value(mp_obj_t object) { + (void)object; + return &g_exception_value; +} + +static bool mp_obj_get_int_maybe(mp_obj_t object, mp_int_t *value) { + if (object == MP_OBJ_NULL || object->kind != FAKE_INT) { + return false; + } + *value = object->integer; + return true; +} + +static bool mp_obj_is_int(mp_obj_t object) { + return object != MP_OBJ_NULL && object->kind == FAKE_INT; +} + +static mp_int_t mp_obj_get_int(mp_obj_t object) { + mp_int_t value; + if (!mp_obj_get_int_maybe(object, &value)) { + fake_die("integer requested from non-integer"); + } + return value; +} + +static mp_obj_t mp_obj_new_str(const char *value, size_t length) { + return fake_string(value, length); +} + +static void mp_map_init(mp_map_t *map, size_t count) { + (void)map; + (void)count; +} + +static const char *mp_obj_str_get_data(mp_obj_t object, size_t *length) { + if (object == MP_OBJ_NULL || object->kind != FAKE_STRING) { + fake_die("string requested from non-string"); + } + *length = object->length; + return object->string; +} + +static void mp_obj_get_array(mp_obj_t object, size_t *count, + mp_obj_t **fields) { + if (object == MP_OBJ_NULL || object->kind != FAKE_ARRAY) { + fake_die("array requested from non-array"); + } + *count = object->count; + *fields = object->elements; +} + +static mp_obj_t mp_getiter(mp_obj_t iterable, mp_obj_iter_buf_t *buffer) { + (void)buffer; + return iterable; +} + +static mp_obj_t mp_iternext(mp_obj_t iterable) { + if (iterable == MP_OBJ_NULL || iterable->kind != FAKE_ITERATOR) { + fake_die("iteration requested from non-iterator"); + } + if (iterable->cursor >= iterable->count) { + return MP_OBJ_STOP_ITERATION; + } + return iterable->elements[iterable->cursor++]; +} + +static bool pble_ble_session_live(const pble_session_token_t *session) { + if (g_invalidate_after_open && !g_open_cut_via_ticket && + g_open_cut_armed) { + g_item_checks_after_open++; + if (g_item_checks_after_open == 2u) { + invalidate_identity(); + } + } + return g_session_enabled && session != NULL && + session->conn == g_live_session.conn && + session->incarnation == g_live_session.incarnation; +} + +static bool pble_vm_epoch_valid(uint64_t epoch) { + return g_vm_enabled && epoch == g_live_vm_epoch; +} + +static bool pble_rsp_ticket_valid(const pble_rsp_ticket_t *ticket) { + if (g_invalidate_after_open && g_open_cut_via_ticket && + g_open_cut_armed) { + g_ticket_checks_after_open++; + if (g_ticket_checks_after_open == 2u) { + invalidate_identity(); + } + } + return g_ticket_enabled && ticket != NULL && + ticket->incarnation == 17u && + pble_ble_session_live(&ticket->session) && + pble_vm_epoch_valid(ticket->vm_epoch); +} + +static mp_obj_t fake_stat(mp_int_t mode, uint32_t size) { + mp_obj_t fields[7] = { + fake_int(mode), fake_int(0), fake_int(0), fake_int(0), + fake_int(0), fake_int(0), fake_int((mp_int_t)size), + }; + return fake_array(fields, 7u); +} + +static const char *fake_path(mp_obj_t object) { + size_t length; + const char *path = mp_obj_str_get_data(object, &length); + (void)length; + return path; +} + +static mp_obj_t mp_vfs_stat(mp_obj_t path_object) { + const char *path = fake_path(path_object); + g_stat_calls++; + if (strstr(path, ".pbltmp") != NULL) { + g_hidden_stat_calls++; + } + if (strcmp(path, "/main.py") == 0) { + g_visible_stat_calls++; + return fake_stat(MP_S_IFREG, 7u); + } + if (strcmp(path, "/docs") == 0) { + g_visible_stat_calls++; + return fake_stat(MP_S_IFDIR, 0u); + } + if (strcmp(path, "/old.py") == 0) { + return fake_stat(MP_S_IFREG, (uint32_t)strlen(g_old_destination)); + } + if (strcmp(path, "/old.py.pbltmp") == 0 && g_scratch_exists) { + return fake_stat( + g_scratch_is_directory ? MP_S_IFDIR : MP_S_IFREG, + g_scratch_stat_size + ); + } + mp_raise_OSError(MP_ENOENT); +} + +static mp_obj_t mp_vfs_ilistdir(size_t count, mp_obj_t *arguments) { + (void)count; + (void)arguments; + fake_obj_t *iterator = fake_object(FAKE_ITERATOR); + if (g_listing_count > 10u) { + fake_die("too many listing entries"); + } + memcpy( + iterator->elements, + g_listing_items, + g_listing_count * sizeof(g_listing_items[0]) + ); + iterator->count = g_listing_count; + return iterator; +} + +static mp_obj_t mp_vfs_statvfs(mp_obj_t path_object) { + (void)path_object; + mp_obj_t fields[5] = { + fake_int(0), + g_statvfs_unit_is_int + ? fake_int(g_statvfs_unit) + : fake_string("invalid", 7u), + fake_int(0), + fake_int(0), + g_statvfs_available_is_int + ? fake_int(g_statvfs_available) + : fake_string("invalid", 7u), + }; + return fake_array(fields, g_statvfs_count); +} + +static mp_uint_t fake_stream_read(mp_obj_t file, void *buffer, + mp_uint_t requested, int *error) { + (void)error; + if (file != &g_file_object) { + fake_die("read from unknown file"); + } + if (g_read_behavior == 2) { + g_read_behavior = 3; + return requested + 1u; + } + size_t remaining = g_scratch_actual_size > file->cursor + ? g_scratch_actual_size - file->cursor + : 0u; + size_t amount = remaining < requested ? remaining : (size_t)requested; + memcpy(buffer, g_scratch_bytes + file->cursor, amount); + file->cursor += amount; + if (g_invalidate_after_read) { + g_invalidate_after_read = false; + invalidate_identity(); + } + return (mp_uint_t)amount; +} + +static mp_uint_t fake_stream_write(mp_obj_t file, const void *buffer, + mp_uint_t requested, int *error) { + (void)file; + (void)buffer; + (void)error; + return requested; +} + +static const mp_stream_p_t g_stream = { + .read = fake_stream_read, + .write = fake_stream_write, +}; + +static mp_obj_t mp_vfs_open(size_t count, mp_obj_t *arguments, mp_map_t *kw) { + (void)kw; + const char *path = fake_path(arguments[0]); + if (count != 2u || + (strcmp(path, "/old.py.pbltmp") != 0 && + strcmp(path, "/old.py") != 0)) { + fake_die("unexpected open"); + } + g_open_calls++; + memset(&g_file_object, 0, sizeof(g_file_object)); + g_file_object.kind = FAKE_FILE; + if (g_invalidate_after_open) { + g_open_cut_armed = true; + g_item_checks_after_open = 0u; + g_ticket_checks_after_open = 0u; + } + return &g_file_object; +} + +static const mp_stream_p_t *mp_get_stream(mp_obj_t file) { + if (file != &g_file_object || file->kind != FAKE_FILE) { + fake_die("stream requested from non-file"); + } + return &g_stream; +} + +static void mp_stream_close(mp_obj_t file) { + if (file != &g_file_object) { + fake_die("close of unknown file"); + } + g_close_calls++; +} + +static void mp_vfs_remove(mp_obj_t path_object) { + const char *path = fake_path(path_object); + g_remove_calls++; + if (strcmp(path, "/old.py.pbltmp") == 0) { + if (g_remove_failure) { + mp_raise_OSError(MP_EIO); + } + g_scratch_exists = false; + return; + } + if (strcmp(path, "/old.py") == 0) { + g_old_destination[0] = '\0'; + return; + } +} + +static void mp_vfs_rmdir(mp_obj_t path_object) { + const char *path = fake_path(path_object); + g_rmdir_calls++; + if (strcmp(path, "/old.py.pbltmp") == 0) { + if (g_remove_failure) { + mp_raise_OSError(MP_EIO); + } + g_scratch_exists = false; + } +} + +static void mp_vfs_mkdir(mp_obj_t path_object) { + (void)path_object; + g_mkdir_calls++; +} + +static void mp_vfs_rename(mp_obj_t source_object, mp_obj_t dest_object) { + const char *source = fake_path(source_object); + const char *dest = fake_path(dest_object); + g_rename_calls++; + if (strcmp(source, "/old.py.pbltmp") == 0 && + strcmp(dest, "/old.py") == 0) { + memcpy(g_old_destination, g_scratch_bytes, g_scratch_actual_size); + g_old_destination[g_scratch_actual_size] = '\0'; + g_scratch_exists = false; + } +} + +static uint16_t pble_ble_mtu(void) { + return 247u; +} + +static bool pble_rsp_expect_completion(const pble_rsp_ticket_t *ticket) { + return pble_rsp_ticket_valid(ticket); +} + +static bool pble_rsp_publish(const pble_rsp_ticket_t *ticket, uint8_t opcode, + uint8_t id, const uint8_t *payload, size_t len) { + (void)opcode; + (void)id; + (void)payload; + (void)len; + return pble_rsp_ticket_valid(ticket); +} + +static void pble_rsp_cancel_ticket(const pble_rsp_ticket_t *ticket) { + (void)ticket; +} + +static bool pble_rsp_wait(const pble_rsp_ticket_t *ticket) { + (void)ticket; + return true; +} + +static int fs_emit_paced(const pble_fs_req_t *it, uint8_t opcode, + const uint8_t *payload, size_t len) { + (void)it; + (void)opcode; + (void)payload; + (void)len; + return PBLE_TX_OK; +} +""" + + +SCENARIOS = r""" +#define CHECK(condition, message) do { \ + if (!(condition)) { \ + fprintf(stderr, "%s\n", (message)); \ + return 1; \ + } \ +} while (0) + +static void reset_world(void) { + memset(g_objects, 0, sizeof(g_objects)); + g_object_count = 0u; + g_nlr_top = NULL; + g_listing_count = 0u; + g_session_enabled = true; + g_vm_enabled = true; + g_ticket_enabled = true; + g_invalidation_domain = INVALIDATE_NONE; + g_invalidate_after_open = false; + g_invalidate_after_read = false; + g_open_cut_via_ticket = false; + g_open_cut_armed = false; + g_item_checks_after_open = 0u; + g_ticket_checks_after_open = 0u; + g_live_session.conn = 7u; + g_live_session.incarnation = 11u; + g_live_vm_epoch = 13u; + g_scratch_exists = false; + g_scratch_is_directory = false; + g_scratch_stat_size = 0u; + memset(g_scratch_bytes, 0, sizeof(g_scratch_bytes)); + g_scratch_actual_size = 0u; + g_read_behavior = 0; + g_remove_failure = false; + strcpy(g_old_destination, "OLD"); + g_statvfs_unit = 4096; + g_statvfs_available = 16; + g_statvfs_count = 5u; + g_statvfs_unit_is_int = true; + g_statvfs_available_is_int = true; + g_stat_calls = 0u; + g_visible_stat_calls = 0u; + g_hidden_stat_calls = 0u; + g_open_calls = 0u; + g_close_calls = 0u; + g_remove_calls = 0u; + g_rmdir_calls = 0u; + g_mkdir_calls = 0u; + g_rename_calls = 0u; + memset(g_scratch, 0, sizeof(g_scratch)); + g_fs_transfer_mux = portMUX_INITIALIZER_UNLOCKED; + g_fs_transfer_generation = 19u; + g_fs_transfer_exhausted = false; + g_get_active = false; + g_get_generation = 0u; + g_put_active = false; + memset(g_put_temp, 0, sizeof(g_put_temp)); + memset(g_put_dest, 0, sizeof(g_put_dest)); + g_put_total = 0u; + g_put_crc_target = 0u; + g_put_watermark = 0u; + g_put_crc_running = 0xffffffffu; + g_put_latched = 0u; + g_put_generation = 0u; + g_root_pble_fs_put_file = MP_OBJ_NULL; +} + +static pble_fs_req_t current_request(void) { + pble_fs_req_t request; + memset(&request, 0, sizeof(request)); + request.id = 3u; + request.session = g_live_session; + request.vm_epoch = g_live_vm_epoch; + request.transfer_generation = g_fs_transfer_generation; + request.ticket.slot = 1u; + request.ticket.incarnation = 17u; + request.ticket.session = g_live_session; + request.ticket.vm_epoch = g_live_vm_epoch; + return request; +} + +static void set_path_request(pble_fs_req_t *request, const char *path) { + size_t length = strlen(path); + request->payload[0] = (uint8_t)(length & 0xffu); + request->payload[1] = (uint8_t)(length >> 8); + memcpy(request->payload + 2u, path, length); + request->len = (uint16_t)(length + 2u); +} + +static void set_rename_request(pble_fs_req_t *request, + const char *source, const char *dest) { + size_t source_length = strlen(source); + size_t dest_length = strlen(dest); + request->payload[0] = (uint8_t)(source_length & 0xffu); + request->payload[1] = (uint8_t)(source_length >> 8); + memcpy(request->payload + 2u, source, source_length); + size_t dest_header = 2u + source_length; + request->payload[dest_header] = (uint8_t)(dest_length & 0xffu); + request->payload[dest_header + 1u] = (uint8_t)(dest_length >> 8); + memcpy(request->payload + dest_header + 2u, dest, dest_length); + request->len = (uint16_t)(dest_header + 2u + dest_length); +} + +static mp_obj_t listing_entry(const char *name, size_t length, mp_int_t type) { + mp_obj_t fields[2] = { + fake_string(name, length), + fake_int(type), + }; + return fake_array(fields, 2u); +} + +static int scenario_listing(void) { + reset_world(); + static char oversized_hidden[501]; + memset(oversized_hidden, 'x', sizeof(oversized_hidden)); + memcpy( + oversized_hidden + sizeof(oversized_hidden) - 8u, + ".pbltmp", + 8u + ); + oversized_hidden[sizeof(oversized_hidden) - 1u] = '\0'; + static const char hidden_directory[] = "cache.pbltmp"; + static const char visible_file[] = "main.py"; + static const char visible_directory[] = "docs"; + + g_listing_items[0] = listing_entry( + oversized_hidden, strlen(oversized_hidden), MP_S_IFREG); + g_listing_items[1] = listing_entry( + hidden_directory, strlen(hidden_directory), MP_S_IFDIR); + g_listing_items[2] = listing_entry( + visible_file, strlen(visible_file), MP_S_IFREG); + g_listing_items[3] = listing_entry( + visible_directory, strlen(visible_directory), MP_S_IFDIR); + g_listing_count = 4u; + + pble_fs_req_t request = current_request(); + set_path_request(&request, "/"); + size_t extra = 0u; + uint8_t status = fs_do_list(&request, &extra); + CHECK(status == PBLE_OK, "LIST did not succeed"); + CHECK(g_scratch[1] == 0u, + "oversized hidden scratch entry consumed the response budget"); + CHECK(g_scratch[2] == 2u && g_scratch[3] == 0u, + "LIST did not expose exactly the two visible entries"); + CHECK(g_hidden_stat_calls == 0u, + "hidden scratch entry reached the per-entry stat seam"); + CHECK(g_visible_stat_calls == 1u, + "the one visible file did not receive exactly one stat"); + CHECK(extra > 3u, "LIST emitted no visible entry payload"); + return 0; +} + +static int expect_space(uint32_t remaining, uint8_t expected, + const char *message) { + pble_fs_req_t request = current_request(); + uint8_t actual = fs_put_space_status(&request, remaining); + if (actual != expected) { + fprintf(stderr, "%s: expected %u, got %u\n", + message, (unsigned)expected, (unsigned)actual); + return 1; + } + return 0; +} + +static int scenario_capacity(void) { + reset_world(); + CHECK(expect_space(0u, PBLE_OK, "exact reserve") == 0, + "exact reserve boundary failed"); + g_statvfs_available = 15; + CHECK(expect_space(0u, PBLE_ENOSPC, "one block below reserve") == 0, + "below-reserve boundary failed"); + g_statvfs_available = 17; + CHECK(expect_space(1u, PBLE_OK, "one-byte rounded admission") == 0, + "rounded exact boundary failed"); + g_statvfs_available = 16; + CHECK(expect_space(1u, PBLE_ENOSPC, "rounded admission shortfall") == 0, + "rounded shortfall failed"); + + g_statvfs_count = 4u; + CHECK(expect_space(0u, PBLE_EIO, "short statvfs tuple") == 0, + "short statvfs tuple was trusted"); + g_statvfs_count = 5u; + g_statvfs_unit_is_int = false; + CHECK(expect_space(0u, PBLE_EIO, "non-integer unit") == 0, + "non-integer unit was trusted"); + g_statvfs_unit_is_int = true; + g_statvfs_available_is_int = false; + CHECK(expect_space(0u, PBLE_EIO, "non-integer availability") == 0, + "non-integer availability was trusted"); + g_statvfs_available_is_int = true; + g_statvfs_unit = 0; + CHECK(expect_space(0u, PBLE_EIO, "zero unit") == 0, + "zero allocation unit was trusted"); + g_statvfs_unit = 4096; + g_statvfs_available = -1; + CHECK(expect_space(0u, PBLE_EIO, "negative availability") == 0, + "negative availability was trusted"); + g_statvfs_unit = LLONG_MAX; + g_statvfs_available = 3; + CHECK(expect_space(0u, PBLE_EIO, "geometry multiplication overflow") == 0, + "overflowing geometry was trusted"); + return 0; +} + +static int run_bad_resume(int behavior, bool cleanup_failure, + uint8_t expected_status) { + reset_world(); + g_scratch_exists = true; + g_scratch_stat_size = 4u; + memcpy(g_scratch_bytes, "AB", 2u); + g_scratch_actual_size = 2u; + g_read_behavior = behavior; + g_remove_failure = cleanup_failure; + pble_fs_req_t request = current_request(); + uint32_t resume = 99u; + uint32_t running = 0u; + uint8_t status = pble_fs_resume_prefix( + &request, "/old.py", 4u, &resume, &running); + CHECK(status == expected_status, "malformed resume returned wrong status"); + CHECK(strcmp(g_old_destination, "OLD") == 0, + "malformed scratch changed the old destination"); + CHECK(g_rename_calls == 0u, + "malformed scratch reached the publication rename"); + CHECK(g_open_calls == 1u && g_close_calls == 1u, + "malformed scratch scan did not close its exact file"); + return 0; +} + +static int scenario_malformed_resume(void) { + CHECK(run_bad_resume(1, false, PBLE_OK) == 0, + "short-read cleanup scenario failed"); + CHECK(!g_scratch_exists && g_remove_calls == 1u, + "short-read scratch was not removed exactly once"); + + CHECK(run_bad_resume(2, false, PBLE_OK) == 0, + "overreported-read cleanup scenario failed"); + CHECK(!g_scratch_exists && g_remove_calls == 1u, + "overreported-read scratch was not removed exactly once"); + + CHECK(run_bad_resume(1, true, PBLE_EIO) == 0, + "cleanup-failure scenario failed"); + CHECK(g_scratch_exists && g_remove_calls == 1u, + "failed cleanup did not preserve the scratch evidence"); + CHECK(strcmp(g_old_destination, "OLD") == 0, + "cleanup failure changed the old destination"); + return 0; +} + +static void arm_active_put(void) { + g_put_active = true; + g_put_generation = g_fs_transfer_generation; + strcpy(g_put_temp, "/old.py.pbltmp"); + strcpy(g_put_dest, "/old.py"); +} + +static int scenario_mutation_gate(void) { + reset_world(); + arm_active_put(); + pble_fs_req_t request = current_request(); + size_t extra = 123u; + + set_path_request(&request, "../escape"); + CHECK(fs_do_delete(&request, &extra) == PBLE_EACCES, + "DELETE applied busy precedence before jail rejection"); + CHECK(g_stat_calls == 0u && g_remove_calls == 0u, + "rejected DELETE reached VFS"); + set_path_request(&request, "/user.py"); + CHECK(fs_do_delete(&request, &extra) == PBLE_EBUSY, + "valid DELETE was not blocked by active PUT"); + CHECK(g_stat_calls == 0u && g_remove_calls == 0u, + "busy DELETE reached VFS"); + + set_path_request(&request, "../escape"); + CHECK(fs_do_mkdir(&request, &extra) == PBLE_EACCES, + "MKDIR applied busy precedence before jail rejection"); + CHECK(g_mkdir_calls == 0u, "rejected MKDIR reached VFS"); + set_path_request(&request, "/new-dir"); + CHECK(fs_do_mkdir(&request, &extra) == PBLE_EBUSY, + "valid MKDIR was not blocked by active PUT"); + CHECK(g_mkdir_calls == 0u, "busy MKDIR reached VFS"); + + set_rename_request(&request, "/source.py", "../escape"); + CHECK(fs_do_rename(&request, &extra) == PBLE_EACCES, + "RENAME applied busy precedence before destination jail rejection"); + CHECK(g_stat_calls == 0u && g_rename_calls == 0u, + "rejected RENAME reached VFS"); + set_rename_request(&request, "/source.py", "/dest.py"); + CHECK(fs_do_rename(&request, &extra) == PBLE_EBUSY, + "valid RENAME was not blocked by active PUT"); + CHECK(g_stat_calls == 0u && g_rename_calls == 0u, + "busy RENAME reached VFS"); + + g_put_active = false; + request.get_active_at_enqueue = true; + set_path_request(&request, "/missing.py"); + CHECK(fs_do_delete(&request, &extra) == PBLE_EBUSY, + "valid DELETE admitted during GET was not blocked"); + CHECK(g_stat_calls == 0u && g_remove_calls == 0u, + "GET-busy DELETE reached VFS"); + set_path_request(&request, "/new-dir"); + CHECK(fs_do_mkdir(&request, &extra) == PBLE_EBUSY, + "valid MKDIR admitted during GET was not blocked"); + CHECK(g_mkdir_calls == 0u, "GET-busy MKDIR reached VFS"); + set_rename_request(&request, "/source.py", "/dest.py"); + CHECK(fs_do_rename(&request, &extra) == PBLE_EBUSY, + "valid RENAME admitted during GET was not blocked"); + CHECK(g_stat_calls == 0u && g_rename_calls == 0u, + "GET-busy RENAME reached VFS"); + + set_path_request(&request, "../escape"); + CHECK(fs_do_delete(&request, &extra) == PBLE_EACCES, + "GET-busy DELETE applied EBUSY before jail rejection"); + set_rename_request(&request, "/source.py", "../escape"); + CHECK(fs_do_rename(&request, &extra) == PBLE_EACCES, + "GET-busy RENAME applied EBUSY before destination jail rejection"); + return 0; +} + +static bool try_publish(pble_fs_req_t *request) { + return fs_put_publish( + request, + &g_file_object, + "/old.py.pbltmp", + "/old.py", + 0u, + 0u, + 0u, + 0xffffffffu + ); +} + +static uint8_t try_end(pble_fs_req_t *request) { + request->len = 4u; + memset(request->payload, 0, 4u); + size_t extra = 99u; + return fs_do_put_end(request, &extra); +} + +static int scenario_identity(void) { + reset_world(); + pble_fs_req_t request = current_request(); + pble_fs_req_t stale = request; + stale.session.incarnation--; + CHECK(!try_publish(&stale) && !g_put_active, + "stale connection published PUT ownership"); + + stale = request; + stale.vm_epoch--; + CHECK(!try_publish(&stale) && !g_put_active, + "stale VM epoch published PUT ownership"); + + stale = request; + stale.transfer_generation--; + CHECK(!try_publish(&stale) && !g_put_active, + "stale transfer generation published PUT ownership"); + + CHECK(try_publish(&request) && g_put_active, + "current identity could not publish PUT ownership"); + uint64_t published_generation = g_put_generation; + pble_fs_on_disconnect(); + CHECK(g_fs_transfer_generation == published_generation + 1u, + "disconnect did not invalidate the filesystem generation"); + CHECK(try_end(&request) == PBLE_NO_RSP, + "disconnected owner produced a terminal response"); + CHECK(g_rename_calls == 0u && strcmp(g_old_destination, "OLD") == 0, + "disconnected owner committed its stale scratch"); + + reset_world(); + arm_active_put(); + request = current_request(); + request.session.incarnation--; + CHECK(try_end(&request) == PBLE_NO_RSP, + "stale connection committed an active transfer"); + CHECK(g_rename_calls == 0u && strcmp(g_old_destination, "OLD") == 0, + "stale connection reached publication rename"); + + reset_world(); + arm_active_put(); + request = current_request(); + request.vm_epoch--; + CHECK(try_end(&request) == PBLE_NO_RSP, + "stale VM epoch committed an active transfer"); + CHECK(g_rename_calls == 0u && strcmp(g_old_destination, "OLD") == 0, + "stale VM epoch reached publication rename"); + + reset_world(); + arm_active_put(); + request = current_request(); + request.transfer_generation--; + CHECK(try_end(&request) == PBLE_NO_RSP, + "stale generation committed an active transfer"); + CHECK(g_rename_calls == 0u && strcmp(g_old_destination, "OLD") == 0, + "stale generation reached publication rename"); + return 0; +} + +static void prepare_file_bytes(const char *bytes) { + size_t length = strlen(bytes); + if (length > sizeof(g_scratch_bytes)) { + fake_die("test file exceeds fake storage"); + } + memcpy(g_scratch_bytes, bytes, length); + g_scratch_actual_size = length; +} + +static void arm_post_open_cut(enum invalidation_domain domain, + bool via_ticket) { + g_invalidation_domain = domain; + g_invalidate_after_open = true; + g_open_cut_via_ticket = via_ticket; +} + +static void arm_post_read_cut(enum invalidation_domain domain) { + g_invalidation_domain = domain; + g_invalidate_after_read = true; +} + +static int check_local_close(uint8_t status, const char *context) { + if (status != PBLE_NO_RSP) { + fprintf(stderr, "%s returned %u instead of silent cancellation\n", + context, (unsigned)status); + return 1; + } + if (g_open_calls != 1u || g_close_calls != 1u) { + fprintf(stderr, + "%s opened %u local object(s) but closed %u (expected 1/1)\n", + context, g_open_calls, g_close_calls); + return 1; + } + return 0; +} + +static int scenario_crc_file_close(bool after_open) { + reset_world(); + prepare_file_bytes("OLD"); + if (after_open) { + arm_post_open_cut(INVALIDATE_SESSION, true); + } else { + arm_post_read_cut(INVALIDATE_VM); + } + pble_fs_req_t request = current_request(); + uint32_t crc = 0u; + uint8_t status = fs_crc_file(&request, "/old.py", 3u, &crc); + return check_local_close( + status, after_open ? "fs_crc_file post-open" + : "fs_crc_file post-read"); +} + +static int scenario_crc_prefix_close(bool after_open) { + reset_world(); + prepare_file_bytes("OLD"); + if (after_open) { + arm_post_open_cut(INVALIDATE_TRANSFER, true); + } else { + arm_post_read_cut(INVALIDATE_SESSION); + } + pble_fs_req_t request = current_request(); + uint32_t running = 0u; + uint8_t status = fs_crc_prefix( + &request, "/old.py.pbltmp", 3u, &running); + return check_local_close( + status, after_open ? "fs_crc_prefix post-open" + : "fs_crc_prefix post-read"); +} + +static void set_get_request(pble_fs_req_t *request, const char *path) { + size_t length = strlen(path); + memset(request->payload, 0, 4u); + request->payload[4] = (uint8_t)(length & 0xffu); + request->payload[5] = (uint8_t)(length >> 8); + memcpy(request->payload + 6u, path, length); + request->len = (uint16_t)(6u + length); +} + +static int scenario_get_close(bool after_open) { + reset_world(); + prepare_file_bytes("OLD"); + if (after_open) { + arm_post_open_cut(INVALIDATE_VM, false); + } else { + arm_post_read_cut(INVALIDATE_TRANSFER); + } + pble_fs_req_t request = current_request(); + set_get_request(&request, "/old.py"); + uint8_t status = fs_do_get(&request); + int result = check_local_close( + status, after_open ? "fs_do_get post-open" + : "fs_do_get post-read"); + if (result != 0) { + return result; + } + CHECK(!g_get_active && g_get_generation == 0u, + "cancelled GET retained active-download ownership"); + return 0; +} + +static void set_put_begin_request(pble_fs_req_t *request, const char *path) { + size_t length = strlen(path); + memset(request->payload, 0, 8u); // total=0, whole-file CRC=0 + request->payload[8] = (uint8_t)(length & 0xffu); + request->payload[9] = (uint8_t)(length >> 8); + memcpy(request->payload + 10u, path, length); + request->len = (uint16_t)(10u + length); +} + +static int scenario_put_begin_close(void) { + reset_world(); + arm_post_open_cut(INVALIDATE_SESSION, true); + pble_fs_req_t request = current_request(); + set_put_begin_request(&request, "/old.py"); + size_t extra = 99u; + uint8_t status = fs_do_put_begin(&request, &extra); + CHECK(!g_put_active && g_root_pble_fs_put_file == MP_OBJ_NULL, + "cancelled PUT_BEGIN published an active/rooted file"); + CHECK(strcmp(g_old_destination, "OLD") == 0, + "cancelled PUT_BEGIN changed the old destination"); + return check_local_close(status, "fs_do_put_begin post-open"); +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "expected one scenario\n"); + return 2; + } + if (strcmp(argv[1], "listing") == 0) { + return scenario_listing(); + } + if (strcmp(argv[1], "capacity") == 0) { + return scenario_capacity(); + } + if (strcmp(argv[1], "malformed-resume") == 0) { + return scenario_malformed_resume(); + } + if (strcmp(argv[1], "mutation-gate") == 0) { + return scenario_mutation_gate(); + } + if (strcmp(argv[1], "identity") == 0) { + return scenario_identity(); + } + if (strcmp(argv[1], "crc-file-open-close") == 0) { + return scenario_crc_file_close(true); + } + if (strcmp(argv[1], "crc-file-read-close") == 0) { + return scenario_crc_file_close(false); + } + if (strcmp(argv[1], "crc-prefix-open-close") == 0) { + return scenario_crc_prefix_close(true); + } + if (strcmp(argv[1], "crc-prefix-read-close") == 0) { + return scenario_crc_prefix_close(false); + } + if (strcmp(argv[1], "get-open-close") == 0) { + return scenario_get_close(true); + } + if (strcmp(argv[1], "get-read-close") == 0) { + return scenario_get_close(false); + } + if (strcmp(argv[1], "put-begin-open-close") == 0) { + return scenario_put_begin_close(); + } + fprintf(stderr, "unknown scenario: %s\n", argv[1]); + return 2; +} +""" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_native_fs_session_race.py b/tests/firmware_tests/host/test_v061_native_fs_session_race.py new file mode 100644 index 00000000..91757b01 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_fs_session_race.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""RED contract for native PUT ownership across disconnect/reconnect.""" + +from __future__ import annotations + +import re +import unittest +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +FS_PATH = ROOT / "firmware" / "user_c_modules" / "pyble" / "pble_fs.c" + + +def _matching_brace(source: str, opening: int) -> int: + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line-comment": + if char == "\n": + state = "code" + elif state == "block-comment": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line-comment" + index += 1 + elif char == "/" and nxt == "*": + state = "block-comment" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + index += 1 + raise AssertionError("unbalanced C braces") + + +def _function(source: str, name: str) -> str: + match = re.search( + r"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_]" + r"[A-Za-z0-9_ \t*]*\b" + + re.escape(name) + + r"[ \t]*\([^;{}]*\)[ \t]*\{", + source, + ) + if match is None: + raise AssertionError("native function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + closing = _matching_brace(source, opening) + return source[match.start() : closing + 1] + + +def _functions(source: str) -> dict[str, str]: + names = re.findall( + r"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_]" + r"[A-Za-z0-9_ \t*]*\b([A-Za-z_]\w*)" + r"[ \t]*\([^;{}]*\)[ \t]*\{", + source, + ) + return {name: _function(source, name) for name in names} + + +def _called_helpers(body: str, functions: dict[str, str]) -> list[str]: + return [ + candidate + for name, candidate in functions.items() + if re.search(r"\b{}\s*\(".format(re.escape(name)), body) + ] + + +@dataclass(frozen=True) +class _QueuedPut: + transfer_generation: int + + +class _PutGenerationOracle: + """Minimal interleaving model for the required synchronized cut.""" + + def __init__(self) -> None: + self.transfer_generation = 1 + self.active_owner: int | None = None + + def enqueue(self) -> _QueuedPut: + return _QueuedPut(self.transfer_generation) + + def disconnect(self) -> None: + # The host callback invalidates ownership; it never edits worker state. + self.transfer_generation += 1 + + def commit_begin(self, request: _QueuedPut) -> bool: + # This comparison and publication are one indivisible cut. + if request.transfer_generation != self.transfer_generation: + return False + self.active_owner = request.transfer_generation + return True + + def begin_successor(self, request: _QueuedPut) -> str: + # Worker-side reconciliation makes an old committed owner non-busy. + if self.active_owner != self.transfer_generation: + self.active_owner = None + if self.active_owner is not None: + return "EBUSY" + return "OK" if self.commit_begin(request) else "DROP" + + def owns(self, request: _QueuedPut) -> bool: + return ( + request.transfer_generation == self.transfer_generation + and self.active_owner == request.transfer_generation + ) + + +class NativePutSessionRaceTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.source = FS_PATH.read_text(encoding="utf-8", errors="strict") + cls.functions = _functions(cls.source) + + def test_interleaving_oracle_rejects_both_stale_commit_orders(self): + # Cut 1: disconnect wins immediately before A's final publication. + before_commit = _PutGenerationOracle() + request_a = before_commit.enqueue() + before_commit.disconnect() + self.assertFalse(before_commit.commit_begin(request_a)) + request_b = before_commit.enqueue() + self.assertEqual(before_commit.begin_successor(request_b), "OK") + self.assertFalse(before_commit.owns(request_a)) + self.assertTrue(before_commit.owns(request_b)) + + # Cut 2: A publishes first, then disconnect invalidates its owner. B + # reconciles that stale worker state instead of observing false EBUSY. + after_commit = _PutGenerationOracle() + request_a = after_commit.enqueue() + self.assertTrue(after_commit.commit_begin(request_a)) + after_commit.disconnect() + request_b = after_commit.enqueue() + self.assertEqual(after_commit.begin_successor(request_b), "OK") + self.assertFalse(after_commit.owns(request_a)) + self.assertTrue(after_commit.owns(request_b)) + + def test_disconnect_invalidates_generation_without_writing_put_state(self): + disconnect = self.functions["pble_fs_on_disconnect"] + self.assertNotRegex( + disconnect, + r"\bg_put_|\bfs_put_reset\s*\(", + "the NimBLE host callback must not race the worker by writing " + "worker-owned PUT state", + ) + self.assertIn("g_fs_transfer_mux", disconnect) + self.assertRegex(disconnect, r"\btaskENTER_CRITICAL\s*\(") + self.assertRegex(disconnect, r"\btaskEXIT_CRITICAL\s*\(") + self.assertRegex( + disconnect, + r"\bg_fs_transfer_generation\s*(?:\+\+|\+=\s*1\b|=)", + "disconnect must invalidate queued/in-flight work with a bounded " + "FS-local generation", + ) + + def test_queue_snapshots_generation_under_the_disconnect_mux(self): + request_type = re.search( + r"typedef\s+struct\s*\{(?P<body>.*?)\}\s*pble_fs_req_t\s*;", + self.source, + flags=re.DOTALL, + ) + self.assertIsNotNone(request_type) + self.assertRegex( + request_type.group("body"), + r"\buint64_t\s+transfer_generation\s*;", + "each queued request needs an FS-local disconnect generation", + ) + self.assertIsNotNone( + re.search( + r"\bstatic\s+portMUX_TYPE\s+g_fs_transfer_mux\b", + self.source, + ), + "native FS needs one host/worker transfer-generation critical domain", + ) + self.assertIsNotNone( + re.search( + r"\bstatic\s+uint64_t\s+g_fs_transfer_generation\b", + self.source, + ), + "native FS needs a bounded local generation invalidated on disconnect", + ) + + enqueue = self.functions["fs_enqueue"] + assignment = re.search( + r"g_enq\.transfer_generation\s*=\s*" + r"g_fs_transfer_generation\s*;", + enqueue, + ) + self.assertIsNotNone( + assignment, + "enqueue must snapshot the synchronized transfer generation", + ) + enter = enqueue.rfind("taskENTER_CRITICAL", 0, assignment.start()) + leave = enqueue.find("taskEXIT_CRITICAL", assignment.end()) + self.assertTrue( + 0 <= enter < assignment.start() < leave, + "the queue snapshot must use the same critical domain as disconnect", + ) + self.assertIn( + "g_fs_transfer_mux", enqueue[enter:leave], + "the queue snapshot used a different synchronization domain", + ) + + def test_put_begin_publication_is_one_generation_checked_atomic_cut(self): + publication_functions = [ + (name, body) + for name, body in self.functions.items() + if re.search(r"\bg_put_active\s*=\s*true\s*;", body) + ] + self.assertEqual( + len(publication_functions), + 1, + "PUT_BEGIN must have one auditable ownership-publication seam", + ) + name, publication = publication_functions[0] + active = publication.find("g_put_active") + enter = publication.rfind("taskENTER_CRITICAL", 0, active) + leave = publication.find("taskEXIT_CRITICAL", active) + self.assertTrue( + 0 <= enter < active < leave, + "{} must publish active PUT ownership inside the transfer-generation " + "critical cut".format(name), + ) + atomic_cut = publication[enter:leave] + self.assertIn("g_fs_transfer_mux", atomic_cut) + self.assertRegex( + atomic_cut, + r"it->transfer_generation\s*==\s*g_fs_transfer_generation|" + r"g_fs_transfer_generation\s*==\s*it->transfer_generation", + "the final publication must revalidate the queued generation", + ) + self.assertRegex( + atomic_cut, + r"\bg_put_generation\s*=\s*(?:it->transfer_generation|" + r"g_fs_transfer_generation)\s*;", + "active PUT state must retain its exact owning generation", + ) + for field in ( + "g_put_active", + "g_put_total", + "g_put_crc_target", + "g_put_latched", + ): + with self.subTest(publication_field=field): + self.assertRegex(atomic_cut, r"\b{}\s*=".format(field)) + + def test_successor_reconciles_owner_and_data_end_require_exact_owner(self): + self.assertIsNotNone( + re.search( + r"\bstatic\s+uint64_t\s+g_put_generation\b", + self.source, + ), + "the worker must retain which transfer generation owns g_put_*", + ) + + begin = self.functions["fs_do_put_begin"] + busy = begin.find("PBLE_EBUSY") + self.assertGreaterEqual(busy, 0) + pre_busy = begin[:busy] + expanded_pre_busy = pre_busy + "\n" + "\n".join( + _called_helpers(pre_busy, self.functions) + ) + for token in ( + "g_put_generation", + "transfer_generation", + "fs_put_reset", + ): + with self.subTest(successor_reconciliation=token): + self.assertIn( + token, + expanded_pre_busy, + "successor PUT_BEGIN must reconcile stale ownership before " + "the single-transfer EBUSY gate", + ) + + for function, first_effect in ( + ("fs_do_put_data", "sp->write"), + ("fs_do_put_end", "fs_put_close"), + ): + body = self.functions[function] + effect = body.find(first_effect) + self.assertGreaterEqual(effect, 0) + pre_effect = body[:effect] + expanded = pre_effect + "\n" + "\n".join( + _called_helpers(pre_effect, self.functions) + ) + with self.subTest(owner_gate=function): + self.assertIn("g_put_generation", expanded) + self.assertIn("transfer_generation", expanded) + self.assertRegex( + expanded, + r"PBLE_NO_RSP|\breturn\b", + "stale DATA/END must stop before any active-transfer effect", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_hardening.py b/tests/firmware_tests/host/test_v061_native_hardening.py new file mode 100644 index 00000000..31b73548 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_hardening.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""[red] native ESP v0.6.1 execution and persistence hardening. + +The runner test compiles the production ``runner_exec`` body against a small +host model of MicroPython's globals API. It therefore proves fresh dictionaries +and restoration behavior rather than accepting a comment or helper name alone. +The NVS tests are structural because ESP-IDF NVS is not faked as storage truth; +they pin the checked commit cuts and the single authoritative Identify record. +""" + +from __future__ import annotations + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +RUNNER = (NATIVE / "pble_runner.c").read_text(encoding="utf-8") +DEVICE_CONFIG = (NATIVE / "pble_device_config.c").read_text(encoding="utf-8") +DEVICE_CONFIG_H = (NATIVE / "pble_device_config.h").read_text(encoding="utf-8") +BOOT = (NATIVE / "pble_boot.c").read_text(encoding="utf-8") +BOOT_H = (NATIVE / "pble_boot.h").read_text(encoding="utf-8") +BLE = (NATIVE / "pble_ble.c").read_text(encoding="utf-8") + + +def matching_brace(source, opening): + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line": + if char == "\n": + state = "code" + elif state == "block": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line" + index += 1 + elif char == "/" and nxt == "*": + state = "block" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + index += 1 + raise AssertionError("unterminated C block") + + +def c_function(source, name): + match = re.search( + rf"(?m)^[^\n;{{}}]*\b{re.escape(name)}\s*\([^;{{}}]*\)\s*\{{", + source, + ) + if match is None: + raise AssertionError("missing function {}".format(name)) + opening = source.find("{", match.start()) + return source[match.start():matching_brace(source, opening) + 1] + + +def code_only(source): + return re.sub(r"/\*.*?\*/|//[^\n]*", "", source, flags=re.DOTALL) + + +def assert_checked_commit(testcase, body, criterion): + body = code_only(body) + testcase.assertIn("nvs_commit(", body, criterion + ": persistence must commit") + testcase.assertNotRegex( + body, + r"\(void\)\s*nvs_commit\s*\(", + criterion + ": nvs_commit result must never be discarded", + ) + checked = re.search( + r"(?:if\s*\([^)]*nvs_commit\s*\([^)]*\)[^)]*(?:==|!=)[^)]*ESP_OK|" + r"(?:esp_err_t|int)\s+([A-Za-z_]\w*)\s*=\s*nvs_commit\s*\([^)]*\))", + body, + ) + testcase.assertIsNotNone(checked, criterion + ": nvs_commit must be checked") + if checked.group(1): + tail = body[checked.end():] + testcase.assertRegex( + tail, + r"\b{}\b\s*(?:==|!=)\s*ESP_OK".format(re.escape(checked.group(1))), + criterion + ": captured commit result must control success", + ) + + +class NativeFreshGlobalsTests(unittest.TestCase): + def test_runner_explicitly_swaps_both_globals_and_locals(self): + body = code_only(c_function(RUNNER, "runner_exec")) + for call in ( + "mp_globals_get(", + "mp_locals_get(", + "mp_obj_new_dict(", + "mp_globals_set(", + "mp_locals_set(", + ): + self.assertIn(call, body, "fresh native RUN is missing {}".format(call)) + self.assertIn("MP_QSTR___name__", body) + self.assertIn("MP_QSTR___main__", body) + self.assertGreaterEqual( + body.count("mp_globals_set("), 2, + "native RUN must install fresh globals and restore the predecessor", + ) + self.assertGreaterEqual( + body.count("mp_locals_set("), 2, + "native RUN must install fresh locals and restore the predecessor", + ) + + def test_compiled_runner_exec_does_not_leak_names_and_restores_context(self): + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone(compiler, "a host C compiler is required for runner_exec") + runner_exec = c_function(RUNNER, "runner_exec") + harness = textwrap.dedent( + r''' + #include <stdbool.h> + #include <stddef.h> + #include <stdint.h> + #include <stdlib.h> + #include <setjmp.h> + + typedef int qstr; + typedef uintptr_t mp_obj_t; + typedef struct { int marker; int name_is_main; } mp_obj_dict_t; + typedef struct { int unused; } mp_lexer_t; + typedef struct { int unused; } mp_parse_tree_t; + typedef struct { jmp_buf jump; void *ret_val; } nlr_buf_t; + typedef int mp_print_t; + + #define PBLE_RUN_MODE_FILE 0 + #define MP_PARSE_FILE_INPUT 0 + #define MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS 0 + #define MP_QSTR__lt_stdin_gt_ 10 + #define MP_QSTR___name__ 11 + #define MP_QSTR___main__ 12 + #define MP_OBJ_NULL ((mp_obj_t)0) + #define MP_OBJ_FROM_PTR(p) ((mp_obj_t)(uintptr_t)(p)) + #define MP_OBJ_TO_PTR(o) ((void *)(uintptr_t)(o)) + #define MP_OBJ_NEW_QSTR(q) ((mp_obj_t)(q)) + + static mp_obj_dict_t main_dict; + static mp_obj_dict_t pool[8]; + static size_t pool_used; + static mp_obj_dict_t *current_globals = &main_dict; + static mp_obj_dict_t *current_locals = &main_dict; + static int phase; + static int leaked; + static mp_lexer_t lexer; + static const mp_print_t pble_console_stderr_print = 0; + static nlr_buf_t *active_nlr; + + #define nlr_push(nlr) (active_nlr = (nlr), setjmp((nlr)->jump)) + #define nlr_pop() do { active_nlr = NULL; } while (0) + static qstr qstr_from_str(const char *s) { (void)s; return 1; } + static mp_lexer_t *mp_lexer_new_from_file(qstr q) { (void)q; return &lexer; } + static mp_lexer_t *mp_lexer_new_from_str_len(qstr q, const char *s, + size_t n, int free_len) { + (void)q; (void)s; (void)n; (void)free_len; return &lexer; + } + static mp_parse_tree_t mp_parse(mp_lexer_t *lex, int mode) { + mp_parse_tree_t tree = {0}; (void)lex; (void)mode; return tree; + } + static mp_obj_t mp_compile(mp_parse_tree_t *tree, qstr q, bool repl) { + (void)tree; (void)q; (void)repl; return 1; + } + static void mp_call_function_0(mp_obj_t fn) { + (void)fn; + if (phase == 1) current_globals->marker = 1; + if (phase == 2) leaked = current_globals->marker; + if (phase == 3) { + active_nlr->ret_val = (void *)(uintptr_t)99; + longjmp(active_nlr->jump, 1); + } + } + static void mp_handle_pending(int mode) { (void)mode; } + static bool pble_runner_stop_requested(void) { return false; } + static void mp_obj_print_exception(const mp_print_t *print, mp_obj_t exc) { + (void)print; (void)exc; + } + static mp_obj_dict_t *mp_globals_get(void) { return current_globals; } + static mp_obj_dict_t *mp_locals_get(void) { return current_locals; } + static void mp_globals_set(mp_obj_dict_t *dict) { current_globals = dict; } + static void mp_locals_set(mp_obj_dict_t *dict) { current_locals = dict; } + static mp_obj_t mp_obj_new_dict(size_t count) { + (void)count; + if (pool_used >= sizeof(pool) / sizeof(pool[0])) abort(); + pool[pool_used].marker = 0; + pool[pool_used].name_is_main = 0; + return MP_OBJ_FROM_PTR(&pool[pool_used++]); + } + static void mp_obj_dict_store(mp_obj_t dict, mp_obj_t key, mp_obj_t value) { + mp_obj_dict_t *target = (mp_obj_dict_t *)MP_OBJ_TO_PTR(dict); + if (key == MP_QSTR___name__ && value == MP_QSTR___main__) { + target->name_is_main = 1; + } + } + static void mp_store_global(qstr key, mp_obj_t value) { + mp_obj_dict_store(MP_OBJ_FROM_PTR(current_globals), key, value); + } + static void mp_store_name(qstr key, mp_obj_t value) { + mp_obj_dict_store(MP_OBJ_FROM_PTR(current_locals), key, value); + } + ''' + ) + main = textwrap.dedent( + r''' + int main(void) { + phase = 1; + if (!runner_exec(1, "first", 5)) return 20; + if (current_globals != &main_dict || current_locals != &main_dict) return 21; + phase = 2; + if (!runner_exec(1, "second", 6)) return 22; + if (current_globals != &main_dict || current_locals != &main_dict) return 23; + phase = 3; + if (runner_exec(1, "raises", 6)) return 27; + if (current_globals != &main_dict || current_locals != &main_dict) return 28; + if (leaked) return 24; + if (pool_used != 3) return 25; + if (!pool[0].name_is_main || !pool[1].name_is_main || + !pool[2].name_is_main) return 26; + return 0; + } + ''' + ) + with tempfile.TemporaryDirectory(prefix="pyble-v061-runner-") as temp: + source = Path(temp) / "runner_probe.c" + binary = Path(temp) / "runner_probe" + source.write_text(harness + "\n" + runner_exec + "\n" + main, + encoding="utf-8") + built = subprocess.run( + [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", + str(source), "-o", str(binary)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + built.returncode, 0, + "runner_exec host probe did not compile:\n{}{}".format( + built.stdout, built.stderr), + ) + executed = subprocess.run( + [str(binary)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False) + self.assertEqual( + executed.returncode, 0, + "runner_exec leaked/restored incorrectly; probe exit {}".format( + executed.returncode), + ) + + +class NativeReassemblyTests(unittest.TestCase): + def test_soft_closing_precedes_native_fragment_violation_accounting(self): + body = code_only(c_function(BLE, "pble_ble_record_protocol_violation")) + closing_at = body.find("pble_vm_reboot_command_admitted(false)") + increment_at = body.find("pble_protocol_violation_count++") + self.assertTrue( + 0 <= closing_at < increment_at, + "the shared native violation seam must check accepted-SOFT global " + "closing before fragment or complete-frame accounting", + ) + self.assertRegex( + body[:increment_at], + r"if\s*\(\s*!\s*pble_vm_reboot_command_admitted\s*\(\s*false\s*\)" + r"\s*\)\s*\{\s*return\s+true\s*;", + "closing faults remain classifiable for their normal error/drop " + "semantics, but cannot consume the bounded session budget", + ) + + def test_rx_capacity_carries_the_largest_supported_run_source(self): + match = re.search(r"#\s*define\s+PBLE_MSG_MAX\s+(\d+)\b", BLE) + self.assertIsNotNone(match, "native reassembly needs a fixed static cap") + # RUN payload is [mode:u8][source <= 2048], inside the six-byte PBLE + # header and four-byte CRC. A smaller transport cap makes a valid + # handler-level maximum unreachable on every ESP profile. + self.assertGreaterEqual( + int(match.group(1)), 6 + 1 + 2048 + 4, + "native RX cannot carry the documented maximum RUN source frame", + ) + + def test_compiled_rx_discards_empty_cut_and_correlates_split_header(self): + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone(compiler, "a host C compiler is required for RX") + limit_match = re.search(r"#\s*define\s+PBLE_MSG_MAX\s+(\d+)\b", BLE) + self.assertIsNotNone(limit_match) + limit = int(limit_match.group(1)) + reset = c_function(BLE, "pble_reset_reassembly") + discard = c_function(BLE, "pble_discard_reassembly_tail") + ingest = c_function(BLE, "pble_rx_ingest") + harness = textwrap.dedent( + r''' + #include <stdbool.h> + #include <stddef.h> + #include <stdint.h> + #include <string.h> + + #define PBLE_MSG_MAX __LIMIT__ + #define PBLE_FRAG_FIRST 0x80 + #define PBLE_FRAG_LAST 0x40 + #define PBLE_FRAG_IDX_MASK 0x3f + #define PBLE_FRAG_IDX_MOD 64 + #define PBLE_RX_REASSEMBLY_DEADLINE_US 5000000LL + #define PBLE_PROTO_VERSION 1 + #define PBLE_TYPE_CMD 1 + #define PBLE_ERANGE 9 + + typedef struct { uint16_t conn; } pble_session_token_t; + static uint8_t pble_rx_buf[PBLE_MSG_MAX]; + static size_t pble_rx_len; + static bool pble_rx_active; + static uint8_t pble_rx_next_index; + static int64_t pble_rx_started_us; + static bool pble_rx_discard_tail; + static bool pble_rx_hdr_valid; + static uint8_t pble_rx_hdr_ver; + static uint8_t pble_rx_hdr_type; + static uint8_t pble_rx_hdr_op; + static uint8_t pble_rx_hdr_id; + static int dispatch_calls; + static int violation_calls; + static int refuse_calls; + static uint8_t refused_op; + static uint8_t refused_id; + static uint8_t refused_status; + + static int64_t esp_timer_get_time(void) { return 100; } + static bool pble_ble_record_protocol_violation( + const pble_session_token_t *session) { + (void)session; + violation_calls++; + return true; + } + static void pble_proto_dispatch(const uint8_t *msg, size_t len, + uint16_t conn) { + (void)msg; (void)len; (void)conn; + dispatch_calls++; + } + static void pble_proto_refuse(uint8_t op, uint8_t id, + uint8_t status, + const pble_session_token_t *session) { + (void)session; + refuse_calls++; + refused_op = op; + refused_id = id; + refused_status = status; + } + ''' + ).replace("__LIMIT__", str(limit)) + main = textwrap.dedent( + r''' + static int empty_write_cut(const pble_session_token_t *session) { + uint8_t first[] = {PBLE_FRAG_FIRST, 'A'}; + uint8_t stale_last[] = {PBLE_FRAG_LAST | 1, 'B'}; + pble_reset_reassembly(); + dispatch_calls = violation_calls = 0; + pble_rx_ingest(first, sizeof(first), session); + pble_rx_ingest(NULL, 0, session); + pble_rx_ingest(stale_last, sizeof(stale_last), session); + return dispatch_calls == 0 && violation_calls == 1; + } + + static int split_header_oversize(const pble_session_token_t *session) { + uint8_t message[PBLE_MSG_MAX + 1]; + uint8_t packet[98]; + memset(message, 'X', sizeof(message)); + message[0] = PBLE_PROTO_VERSION; + message[1] = PBLE_TYPE_CMD; + message[2] = 0x20; + message[3] = 0x2a; + message[4] = 0; + message[5] = 0; + pble_reset_reassembly(); + refuse_calls = violation_calls = 0; + size_t offset = 0; + uint8_t index = 0; + while (offset < sizeof(message)) { + size_t chunk = index == 0 ? 1 : sizeof(packet) - 1; + if (chunk > sizeof(message) - offset) { + chunk = sizeof(message) - offset; + } + packet[0] = (index == 0 ? PBLE_FRAG_FIRST : 0) | + (index & PBLE_FRAG_IDX_MASK); + memcpy(packet + 1, message + offset, chunk); + pble_rx_ingest(packet, chunk + 1, session); + offset += chunk; + index = (uint8_t)((index + 1) % PBLE_FRAG_IDX_MOD); + } + return refuse_calls == 1 && violation_calls == 1 && + refused_op == 0x20 && refused_id == 0x2a && + refused_status == PBLE_ERANGE; + } + + int main(void) { + pble_session_token_t session = {7}; + if (!empty_write_cut(&session)) return 20; + if (!split_header_oversize(&session)) return 21; + return 0; + } + ''' + ) + with tempfile.TemporaryDirectory(prefix="pyble-v061-rx-") as temp: + source = Path(temp) / "rx_probe.c" + binary = Path(temp) / "rx_probe" + source.write_text( + harness + "\n" + reset + "\n" + discard + "\n" + ingest + + "\n" + main, + encoding="utf-8", + ) + built = subprocess.run( + [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-variable", str(source), "-o", str(binary)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + built.returncode, 0, + "native RX host probe did not compile:\n{}{}".format( + built.stdout, built.stderr), + ) + executed = subprocess.run( + [str(binary)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, check=False) + self.assertEqual( + executed.returncode, 0, + "native RX mishandled empty/split-header input; probe exit {}" + .format(executed.returncode), + ) + + +class NativeLabelAndNvsTests(unittest.TestCase): + def test_bounded_fault_markers_have_retained_internal_read_seams(self): + self.assertRegex( + DEVICE_CONFIG_H, + r"\buint8_t\s+pble_dc_config_fault\s*\(\s*void\s*\)\s*;", + "the native identity marker must survive optimization and remain " + "available to the separately specified diagnostic surface", + ) + self.assertRegex( + BOOT_H, + r"\buint8_t\s+pble_boot_config_fault\s*\(\s*void\s*\)\s*;", + "the native autorun marker needs the same internal read seam", + ) + self.assertRegex( + code_only(c_function(DEVICE_CONFIG, "pble_dc_config_fault")), + r"return\s+dc_config_fault\s*;", + ) + self.assertRegex( + code_only(c_function(BOOT, "pble_boot_config_fault")), + r"return\s+boot_config_fault\s*;", + ) + + def test_autorun_fault_latches_until_a_successful_repair_commit(self): + reader = code_only(c_function(BOOT, "pble_boot_autorun_enabled")) + self.assertNotRegex( + reader, + r"boot_config_fault\s*=\s*BOOT_CONFIG_OK", + "an ordinary missing/valid read is observation, not a repair cut", + ) + setter = code_only(c_function(BOOT, "pble_boot_set_autorun")) + open_failure = re.search( + r"if\s*\([^{}]*nvs_open[^{}]*\)\s*\{(?P<body>[^{}]*)\}", + setter, + flags=re.DOTALL, + ) + self.assertIsNotNone(open_failure) + self.assertIn("BOOT_CONFIG_CORRUPT", open_failure.group("body")) + self.assertRegex( + setter, + r"boot_config_fault\s*=\s*st\s*==\s*PBLE_OK\s*\?\s*" + r"BOOT_CONFIG_OK\s*:\s*BOOT_CONFIG_CORRUPT", + "only the SET_AUTORUN transaction resolves the latched marker", + ) + + def test_identity_persistence_failures_latch_the_field_fault_bits(self): + for function, fault in ( + ("pble_dc_set_label", "DC_CONFIG_FAULT_LABEL"), + ("pble_dc_set_identify_led", "DC_CONFIG_FAULT_IDENTIFY"), + ): + with self.subTest(function=function): + setter = code_only(c_function(DEVICE_CONFIG, function)) + open_failure = re.search( + r"if\s*\([^{}]*nvs_open[^{}]*\)\s*\{(?P<body>[^{}]*)\}", + setter, + flags=re.DOTALL, + ) + self.assertIsNotNone(open_failure) + self.assertIn( + fault, open_failure.group("body"), + "NVS-open failure must be visible to future diagnostics", + ) + self.assertRegex( + setter, + (r"if\s*\(\s*st\s*!=\s*PBLE_OK\s*\)\s*\{[^{}]*" + r"dc_config_fault\s*\|=\s*" + re.escape(fault)), + "set/erase/commit failure must latch its field marker", + ) + + def test_missing_nvs_namespace_is_clean_first_boot(self): + autorun = code_only(c_function(BOOT, "pble_boot_autorun_enabled")) + missing = re.search( + r"if\s*\(\s*open_rc\s*==\s*ESP_ERR_NVS_NOT_FOUND\s*\)\s*" + r"\{(?P<body>[^{}]*)\}", + autorun, + flags=re.DOTALL, + ) + self.assertIsNotNone(missing) + self.assertNotIn( + "BOOT_CONFIG_CORRUPT", missing.group("body"), + "an erased device without the NVS namespace is not a new fault", + ) + init = code_only(c_function(DEVICE_CONFIG, "pble_dc_init")) + self.assertRegex( + init, + r"else\s+if\s*\(\s*open_rc\s*!=\s*ESP_ERR_NVS_NOT_FOUND\s*\)" + r"[\s\S]*dc_config_fault", + "missing device-config namespace must not set a corruption fault", + ) + + def test_set_and_load_share_the_strict_label_validator(self): + self.assertIsNotNone( + re.search(r"\bpble_dc_label_status\s*\(", DEVICE_CONFIG), + "native strict UTF-8 validator must have one directly testable seam", + ) + set_label = code_only(c_function(DEVICE_CONFIG, "pble_dc_set_label")) + init = code_only(c_function(DEVICE_CONFIG, "pble_dc_init")) + self.assertIn("pble_dc_label_status(", set_label) + self.assertIn("pble_dc_label_status(", init, + "persisted NVS labels require the same validation as BLE input") + + def test_label_commit_is_checked_before_ram_and_advertisement_change(self): + body = code_only(c_function(DEVICE_CONFIG, "pble_dc_set_label")) + assert_checked_commit(self, body, "SET_LABEL") + commit = body.find("nvs_commit(") + mutations = [position for position in ( + body.find("dc_label["), body.find("memcpy(dc_label")) if position >= 0] + self.assertTrue(mutations, "SET_LABEL must publish its committed label to RAM") + self.assertLess(commit, min(mutations), "RAM label changed before NVS commit") + self.assertGreater(body.find("pble_ble_set_adv_name", commit), commit) + + def test_identify_uses_one_authoritative_versioned_blob(self): + for pattern, message in ( + (r'#\s*define\s+DC_KEY_IDCFG\s+"id_cfg"', + "Identify needs one authoritative id_cfg NVS key"), + (r'#\s*define\s+DC_IDCFG_VERSION\s+1\b', + "Identify blob schema version must be 1"), + (r'#\s*define\s+DC_IDCFG_LEN\s+4\b', + "Identify blob must be exactly four bytes"), + ): + self.assertIsNotNone(re.search(pattern, DEVICE_CONFIG), message) + init = code_only(c_function(DEVICE_CONFIG, "pble_dc_init")) + setter = code_only(c_function(DEVICE_CONFIG, "pble_dc_set_identify_led")) + self.assertIn("nvs_get_blob(", init) + self.assertLess(init.find("nvs_get_blob("), init.find("nvs_get_u8("), + "authoritative id_cfg must be examined before legacy fallback") + self.assertRegex( + init, + r"ESP_ERR_NVS_NOT_FOUND[\s\S]*nvs_get_u8\s*\(", + "legacy keys may be consulted only when id_cfg is absent", + ) + self.assertIn("nvs_set_blob(", setter) + self.assertNotRegex(setter, r"nvs_set_u8\s*\([^,]+,\s*DC_KEY_ID") + self.assertNotRegex(setter, r"nvs_erase_key\s*\([^,]+,\s*DC_KEY_ID") + assert_checked_commit(self, setter, "SET_IDENTIFY_LED") + commit = setter.find("nvs_commit(") + publish = setter.find("dc_id_configured", commit) + self.assertGreater(publish, commit, "Identify RAM/GPIO changed before commit") + + def test_autorun_commit_is_checked_and_only_exact_one_enables(self): + enabled = code_only(c_function(BOOT, "pble_boot_autorun_enabled")) + self.assertRegex(enabled, r"return\s+v\s*==\s*1\s*;") + self.assertNotRegex(enabled, r"return\s+v\s*!=\s*0\s*;") + setter = code_only(c_function(BOOT, "pble_boot_set_autorun")) + assert_checked_commit(self, setter, "SET_AUTORUN") + + def test_autorun_wire_payload_is_exactly_one_boolean_byte(self): + handler = code_only(c_function(BOOT, "pble_boot_set_autorun_cmd")) + self.assertRegex( + handler, + r"req->len\s*!=\s*1", + "SET_AUTORUN must reject both short and trailing payload bytes", + ) + self.assertRegex( + handler, + r"req->payload\s*\[\s*0\s*\]\s*>\s*1", + "SET_AUTORUN accepts only exact wire values 0 and 1", + ) + self.assertNotRegex(handler, r"req->payload\s*\[\s*0\s*\]\s*!=\s*0") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_reassembly_behavior.py b/tests/firmware_tests/host/test_v061_native_reassembly_behavior.py new file mode 100644 index 00000000..ca898a53 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_reassembly_behavior.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Compiled behavior contract for the native ESP FR-BLE-10 reducer. + +The probe below compiles the production reassembly, exact-session admission, +termination-latch, and violation-counter functions from ``pble_ble.c``. Only +hardware boundaries (clock, critical section, dispatcher/refusal sink, and the +physical termination driver) are deterministic stubs. The pure production +termination reducer is linked alongside the probe. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +BLE_PATH = NATIVE / "pble_ble.c" +TERMINATION_PATH = NATIVE / "pble_termination.c" +BLE = BLE_PATH.read_text(encoding="utf-8") + + +def _matching_brace(source: str, opening: int) -> int: + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + following = source[index + 1] if index + 1 < len(source) else "" + if state == "line": + if char == "\n": + state = "code" + elif state == "block": + if char == "*" and following == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and following == "/": + state = "line" + index += 1 + elif char == "/" and following == "*": + state = "block" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + index += 1 + raise AssertionError("unterminated C block") + + +def _c_function(source: str, name: str) -> str: + match = re.search( + rf"(?m)^[^\n;{{}}]*\b{re.escape(name)}\s*\([^;{{}}]*\)\s*\{{", + source, + ) + if match is None: + raise AssertionError("missing function {}".format(name)) + opening = source.find("{", match.start()) + return source[match.start() : _matching_brace(source, opening) + 1] + + +def _macro_value(source: str, name: str) -> str: + match = re.search( + rf"(?m)^\s*#\s*define\s+{re.escape(name)}\s+([^\s/]+)", source + ) + if match is None: + raise AssertionError("missing macro {}".format(name)) + return match.group(1) + + +class NativeReassemblyBehaviorTests(unittest.TestCase): + """Execute the native reducer at the FR-BLE-10 boundary cuts.""" + + @classmethod + def setUpClass(cls) -> None: + compiler = shutil.which(os.environ.get("CC", "cc")) + if compiler is None: + raise unittest.SkipTest("a host C compiler is required for native RX") + + production_functions = "\n\n".join( + _c_function(BLE, name) + for name in ( + "pble_reset_reassembly", + "pble_discard_reassembly_tail", + "pble_session_matches_locked", + "pble_session_token_equal", + "pble_termination_pending_matches_locked", + "pble_session_admits_locked", + "pble_termination_clear_locked", + "pble_termination_latch_locked", + "pble_rx_ingest", + "pble_ble_record_protocol_violation", + ) + ) + preamble = textwrap.dedent( + r''' + #include <stdbool.h> + #include <stddef.h> + #include <stdint.h> + #include <limits.h> + #include <stdio.h> + #include <string.h> + + #include "pble_termination.h" + + #define PBLE_MSG_MAX __MESSAGE_LIMIT__ + #define PBLE_RX_REASSEMBLY_DEADLINE_US __DEADLINE_US__ + #define PBLE_SESSION_VIOLATION_LIMIT __VIOLATION_LIMIT__ + #define PBLE_FRAG_FIRST 0x80 + #define PBLE_FRAG_LAST 0x40 + #define PBLE_FRAG_IDX_MASK 0x3f + #define PBLE_FRAG_IDX_MOD 64 + #define PBLE_PROTO_VERSION 0x01 + #define PBLE_TYPE_CMD 0x01 + #define PBLE_ERANGE 0x09 + #define BLE_HS_CONN_HANDLE_NONE UINT16_MAX + + typedef int portMUX_TYPE; + #define taskENTER_CRITICAL(mux) do { (void)(mux); } while (0) + #define taskEXIT_CRITICAL(mux) do { (void)(mux); } while (0) + + static uint8_t pble_rx_buf[PBLE_MSG_MAX]; + static size_t pble_rx_len; + static bool pble_rx_active; + static uint8_t pble_rx_next_index; + static int64_t pble_rx_started_us; + static bool pble_rx_discard_tail; + + static uint16_t pble_conn_handle = BLE_HS_CONN_HANDLE_NONE; + static uint64_t pble_conn_generation; + static uint64_t pble_session_vm_epoch; + static uint8_t pble_protocol_violation_count; + static bool pble_termination_pending; + static bool pble_termination_driver_claimed; + static pble_session_token_t pble_termination_pending_session; + static int64_t pble_termination_begin_us; + static int64_t pble_termination_deadline_us; + static portMUX_TYPE pble_session_mux; + static pble_term_state_t pble_term_state; + + static int64_t fake_now_us; + static int dispatch_calls; + static size_t dispatched_len; + static uint8_t dispatched[32]; + static int refuse_calls; + static int termination_calls; + static pble_session_token_t terminated_session; + + static int64_t esp_timer_get_time(void) { return fake_now_us; } + static bool pble_vm_reboot_command_admitted(bool begin) { + (void)begin; + return true; + } + static void pble_proto_dispatch(const uint8_t *msg, size_t len, + uint16_t conn) { + (void)conn; + dispatch_calls++; + dispatched_len = len < sizeof(dispatched) ? len : sizeof(dispatched); + memcpy(dispatched, msg, dispatched_len); + } + static void pble_proto_refuse(uint8_t op, uint8_t id, + uint8_t status, + const pble_session_token_t *session) { + (void)op; + (void)id; + (void)status; + (void)session; + refuse_calls++; + } + + bool pble_ble_record_protocol_violation( + const pble_session_token_t *session); + void pble_ble_terminate_session( + const pble_session_token_t *session); + ''' + ) + for marker, value in ( + ("__MESSAGE_LIMIT__", _macro_value(BLE, "PBLE_MSG_MAX")), + ( + "__DEADLINE_US__", + _macro_value(BLE, "PBLE_RX_REASSEMBLY_DEADLINE_US"), + ), + ( + "__VIOLATION_LIMIT__", + _macro_value(BLE, "PBLE_SESSION_VIOLATION_LIMIT"), + ), + ): + preamble = preamble.replace(marker, value) + + scenarios = textwrap.dedent( + r''' + void pble_ble_terminate_session( + const pble_session_token_t *session) { + termination_calls++; + terminated_session = *session; + } + + static bool token_equal(pble_session_token_t left, + pble_session_token_t right) { + return left.conn == right.conn && + left.generation == right.generation && + left.vm_epoch == right.vm_epoch; + } + + static bool model_open(uint16_t conn, uint64_t generation, + uint64_t vm_epoch) { + pble_term_init(&pble_term_state); + if (!pble_term_open(&pble_term_state, conn, generation)) { + return false; + } + pble_conn_handle = conn; + pble_conn_generation = generation; + pble_session_vm_epoch = vm_epoch; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); + pble_reset_reassembly(); + dispatch_calls = 0; + dispatched_len = 0; + refuse_calls = 0; + termination_calls = 0; + memset(&terminated_session, 0, sizeof(terminated_session)); + return true; + } + + static bool model_vm_rotation(uint64_t generation, + uint64_t vm_epoch) { + uint64_t old_generation = pble_conn_generation; + if (!pble_term_rotate_open(&pble_term_state, pble_conn_handle, + old_generation, generation)) { + return false; + } + pble_conn_generation = generation; + pble_session_vm_epoch = vm_epoch; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); + pble_reset_reassembly(); + return true; + } + + static bool model_disconnect_reconnect(uint16_t conn, + uint64_t generation, + uint64_t vm_epoch) { + uint16_t old_conn = pble_conn_handle; + uint64_t old_generation = pble_conn_generation; + if (pble_term_disconnect(&pble_term_state, old_conn, + old_generation) != + PBLE_TERM_EFFECT_INVALIDATE) { + return false; + } + (void)pble_term_cleanup_complete( + &pble_term_state, old_conn, old_generation); + if (pble_term_state.phase != PBLE_TERM_PHASE_CLOSED) { + return false; + } + pble_conn_handle = BLE_HS_CONN_HANDLE_NONE; + pble_conn_generation = 0; + pble_session_vm_epoch = 0; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); + pble_reset_reassembly(); + if (!pble_term_open(&pble_term_state, conn, generation)) { + return false; + } + pble_conn_handle = conn; + pble_conn_generation = generation; + pble_session_vm_epoch = vm_epoch; + pble_protocol_violation_count = 0; + pble_termination_clear_locked(); + return true; + } + + static int scenario_deadline_boundary(void) { + pble_session_token_t session = {7, 11, 21}; + uint8_t first[] = {PBLE_FRAG_FIRST, 'A'}; + uint8_t last[] = {PBLE_FRAG_LAST | 1, 'B'}; + if (!model_open(session.conn, session.generation, + session.vm_epoch)) return 10; + + fake_now_us = 100000; + pble_rx_ingest(first, sizeof(first), &session); + fake_now_us = 5099000; /* elapsed 4,999 ms */ + pble_rx_ingest(last, sizeof(last), &session); + if (dispatch_calls != 1 || dispatched_len != 2 || + memcmp(dispatched, "AB", 2) != 0 || + pble_protocol_violation_count != 0) return 11; + + fake_now_us = 10000000; + pble_rx_ingest(first, sizeof(first), &session); + fake_now_us = 15000000; /* elapsed exactly 5,000 ms */ + pble_rx_ingest(last, sizeof(last), &session); + if (dispatch_calls != 1 || + pble_protocol_violation_count != 1 || + !pble_rx_discard_tail) return 12; + return 0; + } + + static int scenario_non_extending(void) { + pble_session_token_t session = {7, 11, 21}; + uint8_t first[] = {PBLE_FRAG_FIRST, 'A'}; + uint8_t progress[] = {1, 'B'}; + uint8_t last[] = {PBLE_FRAG_LAST | 2, 'C'}; + if (!model_open(session.conn, session.generation, + session.vm_epoch)) return 20; + fake_now_us = 100000; + pble_rx_ingest(first, sizeof(first), &session); + fake_now_us = 4100000; + pble_rx_ingest(progress, sizeof(progress), &session); + if (pble_rx_started_us != 100000 || + pble_rx_next_index != 2) return 21; + fake_now_us = 5100000; + pble_rx_ingest(last, sizeof(last), &session); + if (dispatch_calls != 0 || + pble_protocol_violation_count != 1 || + !pble_rx_discard_tail) return 22; + return 0; + } + + static int scenario_discard_once(void) { + pble_session_token_t session = {7, 11, 21}; + uint8_t first[] = {PBLE_FRAG_FIRST, 'A'}; + uint8_t gap[] = {PBLE_FRAG_LAST | 2, 'B'}; + uint8_t tail[] = {PBLE_FRAG_LAST | 3, 'C'}; + if (!model_open(session.conn, session.generation, + session.vm_epoch)) return 30; + fake_now_us = 100; + pble_rx_ingest(first, sizeof(first), &session); + pble_rx_ingest(gap, sizeof(gap), &session); + for (int index = 0; index < 32; index++) { + tail[0] = (uint8_t)((index + 3) & PBLE_FRAG_IDX_MASK); + if (index == 31) tail[0] |= PBLE_FRAG_LAST; + pble_rx_ingest(tail, sizeof(tail), &session); + } + if (dispatch_calls != 0 || + pble_protocol_violation_count != 1 || + !pble_rx_discard_tail) return 31; + return 0; + } + + static int scenario_first_replacement(void) { + pble_session_token_t session = {7, 11, 21}; + uint8_t old_first[] = {PBLE_FRAG_FIRST, 'o', 'l', 'd'}; + uint8_t replacement[] = { + PBLE_FRAG_FIRST | PBLE_FRAG_LAST, 'n', 'e', 'w' + }; + uint8_t gap[] = {2, 'x'}; + if (!model_open(session.conn, session.generation, + session.vm_epoch)) return 40; + fake_now_us = 100; + pble_rx_ingest(old_first, sizeof(old_first), &session); + pble_rx_ingest(replacement, sizeof(replacement), &session); + if (dispatch_calls != 1 || dispatched_len != 3 || + memcmp(dispatched, "new", 3) != 0 || + pble_protocol_violation_count != 0) return 41; + + pble_rx_ingest(old_first, sizeof(old_first), &session); + pble_rx_ingest(gap, sizeof(gap), &session); + if (!pble_rx_discard_tail || + pble_protocol_violation_count != 1) return 42; + pble_rx_ingest(replacement, sizeof(replacement), &session); + if (dispatch_calls != 2 || dispatched_len != 3 || + memcmp(dispatched, "new", 3) != 0 || + pble_rx_discard_tail || + pble_protocol_violation_count != 1) return 43; + return 0; + } + + static int scenario_terminal_budget(void) { + pble_session_token_t session = {7, 11, 21}; + if (!model_open(session.conn, session.generation, + session.vm_epoch)) return 50; + for (int index = 0; index < 7; index++) { + if (!pble_ble_record_protocol_violation(&session)) return 51; + } + + memset(pble_rx_buf, 'x', sizeof(pble_rx_buf)); + pble_rx_buf[0] = PBLE_PROTO_VERSION; + pble_rx_buf[1] = PBLE_TYPE_CMD; + pble_rx_buf[2] = 0x20; + pble_rx_buf[3] = 0x2a; + pble_rx_buf[4] = 0; + pble_rx_buf[5] = 0; + pble_rx_len = sizeof(pble_rx_buf); + pble_rx_active = true; + pble_rx_next_index = 1; + pble_rx_started_us = fake_now_us; + pble_rx_discard_tail = false; + uint8_t overflow[] = {1, 'z'}; + pble_rx_ingest(overflow, sizeof(overflow), &session); + + if (pble_protocol_violation_count != 8 || refuse_calls != 0 || + termination_calls != 1 || + !token_equal(terminated_session, session) || + pble_session_admits_locked(&session)) return 52; + if (pble_ble_record_protocol_violation(&session) || + termination_calls != 1 || + pble_protocol_violation_count != 8) return 53; + return 0; + } + + static int scenario_reset_budget(void) { + pble_session_token_t original = {7, 11, 21}; + if (!model_open(original.conn, original.generation, + original.vm_epoch)) return 60; + for (int index = 0; index < 7; index++) { + if (!pble_ble_record_protocol_violation(&original)) return 61; + } + + if (!model_vm_rotation(12, 22)) return 62; + pble_session_token_t rotated = {7, 12, 22}; + if (pble_ble_record_protocol_violation(&original) || + pble_protocol_violation_count != 0 || + !pble_session_admits_locked(&rotated)) return 63; + for (int index = 0; index < 7; index++) { + if (!pble_ble_record_protocol_violation(&rotated)) return 64; + } + if (pble_protocol_violation_count != 7 || + termination_calls != 0) return 65; + + if (!model_disconnect_reconnect(8, 13, 22)) return 66; + pble_session_token_t reconnected = {8, 13, 22}; + if (pble_ble_record_protocol_violation(&rotated) || + pble_protocol_violation_count != 0 || + !pble_session_admits_locked(&reconnected)) return 67; + for (int index = 0; index < 7; index++) { + if (!pble_ble_record_protocol_violation(&reconnected)) return 68; + } + if (pble_protocol_violation_count != 7 || + termination_calls != 0) return 69; + return 0; + } + + int main(int argc, char **argv) { + if (argc != 2) return 2; + if (strcmp(argv[1], "deadline-boundary") == 0) + return scenario_deadline_boundary(); + if (strcmp(argv[1], "non-extending") == 0) + return scenario_non_extending(); + if (strcmp(argv[1], "discard-once") == 0) + return scenario_discard_once(); + if (strcmp(argv[1], "first-replacement") == 0) + return scenario_first_replacement(); + if (strcmp(argv[1], "terminal-budget") == 0) + return scenario_terminal_budget(); + if (strcmp(argv[1], "reset-budget") == 0) + return scenario_reset_budget(); + return 3; + } + ''' + ) + + cls._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-native-rx-behavior-" + ) + temporary = Path(cls._temporary.name) + source = temporary / "native_rx_behavior_probe.c" + cls._binary = temporary / "native_rx_behavior_probe" + source.write_text( + preamble + "\n" + production_functions + "\n" + scenarios, + encoding="utf-8", + ) + built = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-Wno-unused-function", + "-I", + str(NATIVE), + str(source), + str(TERMINATION_PATH), + "-o", + str(cls._binary), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if built.returncode != 0: + cls._temporary.cleanup() + raise AssertionError( + "native FR-BLE-10 probe did not compile:\n{}{}".format( + built.stdout, built.stderr + ) + ) + + @classmethod + def tearDownClass(cls) -> None: + temporary = getattr(cls, "_temporary", None) + if temporary is not None: + temporary.cleanup() + + def _assert_scenario(self, scenario: str) -> None: + executed = subprocess.run( + [str(self._binary), scenario], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + executed.returncode, + 0, + "native FR-BLE-10 scenario {!r} failed at probe cut {}:\n{}{}".format( + scenario, executed.returncode, executed.stdout, executed.stderr + ), + ) + + def test_4999ms_is_accepted_but_5000ms_is_expired(self) -> None: + self._assert_scenario("deadline-boundary") + + def test_fragment_progress_never_extends_the_first_deadline(self) -> None: + self._assert_scenario("non-extending") + + def test_discarded_run_tails_debit_the_budget_once(self) -> None: + self._assert_scenario("discard-once") + + def test_first_replaces_incomplete_and_discarded_runs(self) -> None: + self._assert_scenario("first-replacement") + + def test_eighth_violation_suppresses_reply_and_terminates_once(self) -> None: + self._assert_scenario("terminal-budget") + + def test_vm_rotation_and_reconnect_restore_only_the_new_budget(self) -> None: + self._assert_scenario("reset-budget") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_native_registration_idempotence.py b/tests/firmware_tests/host/test_v061_native_registration_idempotence.py new file mode 100644 index 00000000..0648e1c7 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_registration_idempotence.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Compile the native same-VM registration paths against retained state. + +The v0.6.1 lifecycle contract permits repeating ``pble_ble.init_agent()`` in +one VM. Its registration calls must therefore preserve live runtime state. +This harness extracts the unchanged production C function bodies; the fake +RTOS/protocol seams only make the already-created objects deterministic. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" + + +def _matching_brace(source: str, opening: int) -> int: + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line-comment": + if char == "\n": + state = "code" + elif state == "block-comment": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line-comment" + index += 1 + elif char == "/" and nxt == "*": + state = "block-comment" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return index + index += 1 + raise AssertionError("unbalanced C function body") + + +def _function(source: str, name: str) -> str: + match = re.search( + r"(?m)^[ \t]*(?:static[ \t]+)?[A-Za-z_]" + r"[A-Za-z0-9_ \t*]*\b" + + re.escape(name) + + r"[ \t]*\([^;{}]*\)[ \t]*\{", + source, + ) + if match is None: + raise AssertionError("native function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + closing = _matching_brace(source, opening) + return source[match.start(): closing + 1] + + +class NativeRegistrationIdempotenceTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + compiler = shutil.which(os.environ.get("CC", "cc")) + if compiler is None: + raise unittest.SkipTest("a host C compiler is required") + + fs_source = (NATIVE / "pble_fs.c").read_text(encoding="utf-8") + lock_source = (NATIVE / "pble_lock.c").read_text(encoding="utf-8") + production = "\n\n".join( + [ + _function(fs_source, "fs_put_reset_locked"), + _function(fs_source, "fs_put_reset"), + _function(fs_source, "pble_fs_register"), + _function(lock_source, "lock_slot_for"), + _function(lock_source, "pble_lock_acquire"), + _function(lock_source, "pble_lock_register"), + ] + ) + + cls._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-native-register-" + ) + temp = Path(cls._temporary.name) + harness = temp / "native_registration_idempotence.c" + harness.write_text( + PRELUDE + "\n\n" + production + "\n\n" + SCENARIOS, + encoding="utf-8", + ) + cls.binary = temp / "native_registration_idempotence" + built = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + str(harness), + "-o", + str(cls.binary), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if built.returncode != 0: + raise AssertionError( + "native registration harness did not compile:\n{}{}".format( + built.stdout, built.stderr + ) + ) + + @classmethod + def tearDownClass(cls) -> None: + if hasattr(cls, "_temporary"): + cls._temporary.cleanup() + + def run_scenario(self, scenario: str) -> None: + completed = subprocess.run( + [str(self.binary), scenario], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native registration scenario {!r} failed:\n{}{}".format( + scenario, completed.stdout, completed.stderr + ), + ) + + def test_repeated_fs_registration_preserves_active_rooted_put(self) -> None: + self.run_scenario("filesystem") + + def test_repeated_lock_registration_preserves_writer_owners(self) -> None: + self.run_scenario("writer-locks") + + +PRELUDE = r""" +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#define PBLE_OK 0x00u +#define PBLE_EBADREQ 0x01u +#define PBLE_EBUSY 0x07u + +#define PBLE_OP_FILE_LIST 0x10u +#define PBLE_OP_FILE_STAT 0x11u +#define PBLE_OP_FILE_GET_BEGIN 0x12u +#define PBLE_OP_FILE_PUT_BEGIN 0x15u +#define PBLE_OP_FILE_PUT_DATA 0x16u +#define PBLE_OP_FILE_PUT_END 0x17u +#define PBLE_OP_FILE_DELETE 0x18u +#define PBLE_OP_MKDIR 0x19u +#define PBLE_OP_FILE_RENAME 0x1au +#define PBLE_FS_QDEPTH 10u +#define PBLE_FS_PATH_BUF 160u + +#define pdTRUE 1 +#define portMAX_DELAY UINT32_MAX +typedef int BaseType_t; +typedef void *QueueHandle_t; +typedef void *SemaphoreHandle_t; +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define taskENTER_CRITICAL(mux) ((void)(mux)) +#define taskEXIT_CRITICAL(mux) ((void)(mux)) + +static int g_queue_object; +static int g_gate_object; +static int g_work_object; +static QueueHandle_t xQueueCreate(unsigned depth, size_t size) { + (void)depth; + (void)size; + return &g_queue_object; +} +static SemaphoreHandle_t xSemaphoreCreateMutex(void) { + return &g_gate_object; +} +static SemaphoreHandle_t xSemaphoreCreateCounting(unsigned maximum, + unsigned initial) { + (void)maximum; + (void)initial; + return &g_work_object; +} +static BaseType_t xSemaphoreTake(SemaphoreHandle_t semaphore, + uint32_t timeout) { + (void)semaphore; + (void)timeout; + return pdTRUE; +} +static BaseType_t xSemaphoreGive(SemaphoreHandle_t semaphore) { + (void)semaphore; + return pdTRUE; +} + +typedef uintptr_t mp_obj_t; +#define MP_OBJ_NULL ((mp_obj_t)0) +static mp_obj_t g_root_pble_fs_put_file; +#define MP_STATE_VM(name) g_root_##name +#define MP_ERROR_TEXT(text) (text) +static int mp_type_RuntimeError; +static void mp_raise_msg(const int *type, const char *message) { + (void)type; + fprintf(stderr, "unexpected MicroPython raise: %s\n", message); + abort(); +} + +typedef struct { uint8_t unused; } pble_frame_t; +typedef struct { uint8_t unused; } pble_session_token_t; +typedef struct { uint8_t unused; } pble_rsp_ticket_t; +typedef struct { uint8_t unused; } pble_fs_req_t; +typedef uint8_t (*pble_deferred_handler_t)( + const pble_frame_t *, uint8_t *, size_t *, + const pble_session_token_t *, const pble_rsp_ticket_t *); +typedef uint8_t (*pble_handler_t)( + const pble_frame_t *, uint8_t *, size_t *, + const pble_session_token_t *); + +#define DEFINE_DEFERRED(name) \ + static uint8_t name(const pble_frame_t *frame, uint8_t *response, \ + size_t *length, const pble_session_token_t *session, \ + const pble_rsp_ticket_t *ticket) { \ + (void)frame; (void)response; (void)length; (void)session; \ + (void)ticket; return PBLE_OK; \ + } +#define DEFINE_HANDLER(name) \ + static uint8_t name(const pble_frame_t *frame, uint8_t *response, \ + size_t *length, const pble_session_token_t *session) { \ + (void)frame; (void)response; (void)length; (void)session; \ + return PBLE_OK; \ + } +DEFINE_DEFERRED(pble_fs_list) +DEFINE_DEFERRED(pble_fs_stat) +DEFINE_DEFERRED(pble_fs_get_begin) +DEFINE_DEFERRED(pble_fs_put_begin) +DEFINE_HANDLER(pble_fs_put_data) +DEFINE_DEFERRED(pble_fs_put_end) +DEFINE_DEFERRED(pble_fs_delete) +DEFINE_DEFERRED(pble_fs_mkdir) +DEFINE_DEFERRED(pble_fs_rename) + +static void pble_proto_register_deferred(uint8_t opcode, + pble_deferred_handler_t handler) { + (void)opcode; + (void)handler; +} +static void pble_proto_register_no_response(uint8_t opcode, + pble_handler_t handler) { + (void)opcode; + (void)handler; +} + +static QueueHandle_t g_fs_q; +static SemaphoreHandle_t g_fs_gate; +static SemaphoreHandle_t g_fs_work; +static bool g_fs_admission_open; +static bool g_fs_registered; +static uint64_t g_fs_registration_epoch; +static portMUX_TYPE g_fs_transfer_mux = portMUX_INITIALIZER_UNLOCKED; +static bool g_put_active; +static char g_put_temp[PBLE_FS_PATH_BUF]; +static char g_put_dest[PBLE_FS_PATH_BUF]; +static uint32_t g_put_total; +static uint32_t g_put_crc_target; +static uint32_t g_put_watermark; +static uint32_t g_put_crc_running; +static uint8_t g_put_latched; +static uint64_t g_put_generation; + +typedef enum { + PBLE_W_NONE = 0, + PBLE_W_RUN = 1, + PBLE_W_XFER = 2, +} pble_writer_t; +typedef struct { + bool held; + uint16_t conn; +} lock_slot_t; +static lock_slot_t g_run_slot; +static lock_slot_t g_xfer_slot; +static portMUX_TYPE g_lock_mux = portMUX_INITIALIZER_UNLOCKED; +static bool g_lock_registered; +static uint64_t g_lock_registration_epoch; +static uint64_t g_vm_epoch = UINT64_C(7); +static uint64_t pble_vm_epoch_current(void) { + return g_vm_epoch; +} +""" + + +SCENARIOS = r""" +static int scenario_filesystem(void) { + pble_fs_register(); + + g_put_active = true; + g_put_generation = UINT64_C(17); + strcpy(g_put_temp, "/demo.py.pbltmp"); + strcpy(g_put_dest, "/demo.py"); + g_put_total = 100u; + g_put_crc_target = 0x12345678u; + g_put_watermark = 40u; + g_put_crc_running = 0x87654321u; + g_put_latched = PBLE_EBUSY; + g_root_pble_fs_put_file = (mp_obj_t)(uintptr_t)0x1234u; + + pble_fs_register(); + + if (!g_put_active || g_put_generation != UINT64_C(17) || + strcmp(g_put_temp, "/demo.py.pbltmp") != 0 || + strcmp(g_put_dest, "/demo.py") != 0 || g_put_total != 100u || + g_put_crc_target != 0x12345678u || g_put_watermark != 40u || + g_put_crc_running != 0x87654321u || g_put_latched != PBLE_EBUSY || + g_root_pble_fs_put_file != (mp_obj_t)(uintptr_t)0x1234u) { + fprintf(stderr, + "repeated pble_fs_register discarded active PUT state " + "without closing/preserving its rooted file\n"); + return 1; + } + + g_vm_epoch++; + g_root_pble_fs_put_file = MP_OBJ_NULL; /* lifecycle reset owns the root */ + pble_fs_register(); + if (g_put_active || g_put_generation != 0u || g_put_temp[0] != '\0' || + g_put_dest[0] != '\0' || g_put_total != 0u || + g_put_crc_target != 0u || g_put_watermark != 0u || + g_put_crc_running != 0xffffffffu || g_put_latched != 0u) { + fprintf(stderr, "new-VM pble_fs_register did not reset PUT state\n"); + return 2; + } + return 0; +} + +static int scenario_writer_locks(void) { + pble_lock_register(); + if (pble_lock_acquire(PBLE_W_RUN, 41u) != PBLE_OK || + pble_lock_acquire(PBLE_W_XFER, 41u) != PBLE_OK) { + fprintf(stderr, "could not establish writer ownership\n"); + return 2; + } + + pble_lock_register(); + + if (!g_run_slot.held || g_run_slot.conn != 41u || + !g_xfer_slot.held || g_xfer_slot.conn != 41u || + pble_lock_acquire(PBLE_W_RUN, 99u) != PBLE_EBUSY || + pble_lock_acquire(PBLE_W_XFER, 99u) != PBLE_EBUSY) { + fprintf(stderr, + "repeated pble_lock_register released live writer owners\n"); + return 1; + } + + g_vm_epoch++; + pble_lock_register(); + if (g_run_slot.held || g_xfer_slot.held || + pble_lock_acquire(PBLE_W_RUN, 99u) != PBLE_OK || + pble_lock_acquire(PBLE_W_XFER, 99u) != PBLE_OK) { + fprintf(stderr, "new-VM pble_lock_register did not reset owners\n"); + return 3; + } + return 0; +} + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "expected one scenario\n"); + return 2; + } + if (strcmp(argv[1], "filesystem") == 0) { + return scenario_filesystem(); + } + if (strcmp(argv[1], "writer-locks") == 0) { + return scenario_writer_locks(); + } + fprintf(stderr, "unknown scenario: %s\n", argv[1]); + return 2; +} +""" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_native_run_source_boundary.py b/tests/firmware_tests/host/test_v061_native_run_source_boundary.py new file mode 100644 index 00000000..711059e3 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_run_source_boundary.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""Compiled native integration guard for the maximum RUN-source boundary.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +BLE_PATH = NATIVE / "pble_ble.c" +PROTO_PATH = NATIVE / "pble_proto.c" +RUNNER_PATH = NATIVE / "pble_runner.c" +WIRE_PATH = NATIVE / "pble_wire.c" + + +def _function(source: str, name: str) -> str: + match = re.search( + r"(?m)^[^\n;{}]*\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", + source, + ) + if match is None: + raise AssertionError("production function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line": + if char == "\n": + state = "code" + elif state == "block": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line" + index += 1 + elif char == "/" and nxt == "*": + state = "block" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[match.start() : index + 1] + index += 1 + raise AssertionError("unbalanced production function `{}`".format(name)) + + +def _integer_define(source: str, name: str) -> int: + match = re.search( + r"(?m)^\s*#\s*define\s+{}\s+(\d+)\b".format(re.escape(name)), + source, + ) + if match is None: + raise AssertionError("production define `{}` is missing".format(name)) + return int(match.group(1)) + + +class NativeRunSourceBoundaryTest(unittest.TestCase): + def test_full_native_path_reaches_runner_at_2048_and_2049_bytes(self): + compiler = shutil.which("cc") + self.assertIsNotNone(compiler, "a host C compiler is required") + + ble = BLE_PATH.read_text(encoding="utf-8", errors="strict") + proto = PROTO_PATH.read_text(encoding="utf-8", errors="strict") + runner = RUNNER_PATH.read_text(encoding="utf-8", errors="strict") + message_max = _integer_define(ble, "PBLE_MSG_MAX") + runner_max = _integer_define(runner, "PBLE_RUN_BUF_MAX") + self.assertEqual(runner_max, 2048) + + marker = "// --- MicroPython module shim" + self.assertIn(marker, proto) + # This is the exact production protocol core. Only its MicroPython + # module-registration tail is omitted because it is unrelated to RX. + protocol_core = proto.partition(marker)[0] + production_functions = "\n\n".join( + ( + _function(ble, "pble_reset_reassembly"), + _function(ble, "pble_discard_reassembly_tail"), + _function(ble, "pble_rx_ingest"), + _function(runner, "pble_rsm_init"), + _function(runner, "pble_rsm_on_run"), + _function(runner, "pble_runner_run"), + ) + ) + + with tempfile.TemporaryDirectory( + prefix="pyble-v061-native-run-boundary-" + ) as temporary: + temp = Path(temporary) + (temp / "py").mkdir() + (temp / "freertos").mkdir() + (temp / "py" / "runtime.h").write_text( + RUNTIME_STUB, encoding="utf-8" + ) + (temp / "py" / "obj.h").write_text( + "#ifndef MP_OBJ_H\n#define MP_OBJ_H\n#endif\n", + encoding="utf-8", + ) + (temp / "py" / "mpstate.h").write_text( + MPSTATE_STUB, encoding="utf-8" + ) + (temp / "freertos" / "FreeRTOS.h").write_text( + FREERTOS_STUB, encoding="utf-8" + ) + (temp / "freertos" / "task.h").write_text( + TASK_STUB, encoding="utf-8" + ) + (temp / "freertos" / "semphr.h").write_text( + SEMPHR_STUB, encoding="utf-8" + ) + + harness = temp / "native_run_boundary.c" + harness.write_text( + protocol_core + + HARNESS_PREAMBLE.replace("__MESSAGE_MAX__", str(message_max)) + .replace("__RUNNER_MAX__", str(runner_max)) + + production_functions + + HARNESS_MAIN, + encoding="utf-8", + ) + executable = temp / "native_run_boundary" + linker_gc = ( + "-Wl,-dead_strip" + if sys.platform == "darwin" + else "-Wl,--gc-sections" + ) + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-ffunction-sections", + "-fdata-sections", + "-I", + str(temp), + "-I", + str(NATIVE), + str(harness), + str(WIRE_PATH), + linker_gc, + "-o", + str(executable), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + compiled.returncode, + 0, + "native RUN-boundary harness did not compile:\n" + + compiled.stderr, + ) + + completed = subprocess.run( + [str(executable)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native RUN-boundary integration failed:\n" + completed.stderr, + ) + + +RUNTIME_STUB = r""" +#ifndef MP_RUNTIME_H +#define MP_RUNTIME_H +#include <stddef.h> +typedef void *mp_obj_t; +typedef struct { int unused; } mp_obj_type_t; +extern const mp_obj_type_t mp_type_RuntimeError; +#define MP_OBJ_NULL ((mp_obj_t)0) +#define MP_ERROR_TEXT(text) (text) +void mp_raise_msg(const mp_obj_type_t *type, const char *message); +#endif +""" + + +MPSTATE_STUB = r""" +#ifndef MP_STATE_H +#define MP_STATE_H +#include "runtime.h" +typedef struct { + mp_obj_t mp_pending_exception; +} mp_state_thread_t; +#endif +""" + + +FREERTOS_STUB = r""" +#ifndef FREERTOS_H +#define FREERTOS_H +#include <assert.h> +#include <stdint.h> +typedef uint32_t TickType_t; +typedef int BaseType_t; +typedef struct { unsigned count; } StaticSemaphore_t; +typedef StaticSemaphore_t *SemaphoreHandle_t; +typedef int portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define portMAX_DELAY UINT32_MAX +#define pdTRUE 1 +#define pdFALSE 0 +#define pdMS_TO_TICKS(milliseconds) ((TickType_t)(milliseconds)) +#define taskENTER_CRITICAL(mux) ((void)(mux)) +#define taskEXIT_CRITICAL(mux) ((void)(mux)) +#define configASSERT(condition) assert(condition) +#endif +""" + + +TASK_STUB = r""" +#ifndef FREERTOS_TASK_H +#define FREERTOS_TASK_H +#include "FreeRTOS.h" +TickType_t xTaskGetTickCount(void); +#endif +""" + + +SEMPHR_STUB = r""" +#ifndef FREERTOS_SEMPHR_H +#define FREERTOS_SEMPHR_H +#include "FreeRTOS.h" +SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *storage); +int xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout); +BaseType_t xSemaphoreGive(SemaphoreHandle_t semaphore); +#endif +""" + + +HARNESS_PREAMBLE = r""" + +#include <stdio.h> +#include <stdlib.h> +#include "py/mpstate.h" +#include "pble_runner.h" + +#define PBLE_MSG_MAX __MESSAGE_MAX__ +#define PBLE_RX_REASSEMBLY_DEADLINE_US 5000000LL +#define PBLE_FRAG_FIRST 0x80u +#define PBLE_FRAG_LAST 0x40u +#define PBLE_FRAG_IDX_MASK 0x3fu +#define PBLE_FRAG_IDX_MOD 64u +#define PBLE_RUN_PATH_MAX 128u +#define PBLE_RUN_BUF_MAX __RUNNER_MAX__ + +static uint8_t pble_rx_buf[PBLE_MSG_MAX]; +static size_t pble_rx_len; +static bool pble_rx_active; +static uint8_t pble_rx_next_index; +static int64_t pble_rx_started_us; +static bool pble_rx_discard_tail; + +static pble_rsm_t g_rsm; +static portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED; +static StaticSemaphore_t run_sem_storage; +static SemaphoreHandle_t g_run_sem = &run_sem_storage; +static mp_state_thread_t *volatile g_worker_state; +static volatile bool g_stop_requested; +static uint8_t g_run_mode; +static size_t g_run_len; +static char g_run_buf[PBLE_RUN_BUF_MAX]; + +static pble_session_token_t live_session = { + .conn = 0x0042u, + .generation = 3u, + .vm_epoch = 9u, +}; +static unsigned protocol_dispatches; +static unsigned runner_calls; +static uint16_t runner_lengths[2]; +static unsigned violation_debits; +static unsigned specialized_responses; +static uint8_t specialized_status; +static unsigned stdin_begins; + +int64_t esp_timer_get_time(void); +void pble_console_stdin_begin(void); +""" + + +HARNESS_MAIN = r""" + +const mp_obj_type_t mp_type_RuntimeError = {0}; + +void mp_raise_msg(const mp_obj_type_t *type, const char *message) { + (void)type; + fprintf(stderr, "unexpected MicroPython raise: %s\n", message); + abort(); +} + +SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *storage) { + storage->count = 0u; + return storage; +} + +int xSemaphoreTake(SemaphoreHandle_t semaphore, TickType_t timeout) { + (void)timeout; + if (semaphore->count == 0u) { + return pdFALSE; + } + semaphore->count--; + return pdTRUE; +} + +BaseType_t xSemaphoreGive(SemaphoreHandle_t semaphore) { + semaphore->count++; + return pdTRUE; +} + +TickType_t xTaskGetTickCount(void) { + return 1u; +} + +int64_t esp_timer_get_time(void) { + return 100; +} + +bool pble_ble_session_snapshot(uint16_t expected_conn, + pble_session_token_t *session) { + if (session == NULL || expected_conn != live_session.conn) { + return false; + } + *session = live_session; + protocol_dispatches++; + return true; +} + +bool pble_ble_session_live(const pble_session_token_t *session) { + return session != NULL && session->conn == live_session.conn && + session->generation == live_session.generation && + session->vm_epoch == live_session.vm_epoch; +} + +bool pble_ble_record_protocol_violation( + const pble_session_token_t *session) { + (void)session; + violation_debits++; + return true; +} + +void pble_ble_terminate_session(const pble_session_token_t *session) { + (void)session; +} + +void pble_ble_rsp_kick(void) { +} + +int pble_ble_notify(const uint8_t *message, size_t length, + const pble_session_token_t *session) { + (void)message; + (void)length; + (void)session; + return PBLE_TX_OK; +} + +int pble_ble_notify_control_try_for_conn( + const uint8_t *message, size_t length, + const pble_session_token_t *session) { + if (!pble_ble_session_live(session) || length != 11u || + message[0] != PBLE_PROTO_VERSION || message[1] != PBLE_TYPE_RSP || + message[2] != PBLE_OP_RUN || message[4] != 1u || message[5] != 0u) { + return PBLE_TX_NO_CONN; + } + specialized_responses++; + specialized_status = message[6]; + return PBLE_TX_OK; +} + +uint64_t pble_vm_epoch_current(void) { + return live_session.vm_epoch; +} + +bool pble_vm_epoch_valid(uint64_t epoch) { + return epoch == live_session.vm_epoch; +} + +bool pble_vm_dispatch_enter(uint64_t epoch, pble_vm_activity_t *activity) { + activity->epoch = epoch; + activity->active = epoch == live_session.vm_epoch; + return activity->active; +} + +void pble_vm_dispatch_leave(pble_vm_activity_t *activity) { + activity->active = false; +} + +bool pble_vm_reboot_command_admitted(bool is_soft_reboot) { + (void)is_soft_reboot; + return true; +} + +void pble_console_stdin_begin(void) { + stdin_begins++; +} + +static uint8_t observed_runner( + const pble_frame_t *request, uint8_t *response, size_t *response_length, + const pble_session_token_t *session) { + if (runner_calls < 2u) { + runner_lengths[runner_calls] = request->len; + } + runner_calls++; + return pble_runner_run(request, response, response_length, session); +} + +static int take_generic_status(uint8_t id) { + pble_rsp_tx_t tx; + if (!pble_rsp_tx_peek(&tx)) { + return -1; + } + int status = -2; + if (tx.frame_len == 11u && tx.frame[0] == PBLE_PROTO_VERSION && + tx.frame[1] == PBLE_TYPE_RSP && tx.frame[2] == PBLE_OP_RUN && + tx.frame[3] == id && tx.frame[4] == 1u && tx.frame[5] == 0u) { + status = tx.frame[6]; + } + pble_rsp_tx_result(&tx, tx.stream_generation, tx.offset, tx.index, + tx.frame_len, PBLE_TX_OK); + return status; +} + +static bool send_source(size_t source_length, uint8_t id) { + uint8_t payload[1u + 2049u]; + uint8_t frame[PBLE_MSG_MAX]; + uint8_t fragment[32]; + payload[0] = PBLE_RUN_MODE_SOURCE; + memset(payload + 1, 'x', source_length); + int encoded = pble_proto_encode(PBLE_TYPE_CMD, PBLE_OP_RUN, id, payload, + source_length + 1u, frame, sizeof(frame)); + if (encoded <= 0) { + return false; + } + + size_t offset = 0u; + uint8_t index = 0u; + while (offset < (size_t)encoded) { + size_t chunk = (size_t)encoded - offset; + if (chunk > sizeof(fragment) - 1u) { + chunk = sizeof(fragment) - 1u; + } + uint8_t header = index & PBLE_FRAG_IDX_MASK; + if (offset == 0u) { + header |= PBLE_FRAG_FIRST; + } + if (offset + chunk == (size_t)encoded) { + header |= PBLE_FRAG_LAST; + } + fragment[0] = header; + memcpy(fragment + 1, frame + offset, chunk); + pble_rx_ingest(fragment, chunk + 1u, &live_session); + offset += chunk; + index = (uint8_t)((index + 1u) % PBLE_FRAG_IDX_MOD); + } + return true; +} + +static void expect_equal(const char *label, unsigned actual, unsigned expected, + unsigned *failures) { + if (actual != expected) { + fprintf(stderr, "%s: got %u, expected %u\n", label, actual, expected); + (*failures)++; + } +} + +int main(void) { + unsigned failures = 0u; + pble_rsm_init(&g_rsm); + pble_proto_register_special(PBLE_OP_RUN, observed_runner); + + // Establish the exact successor as already HELLO-negotiated. This test + // begins at RX fragmentation, not at the unrelated HELLO exchange. + s_wire_session_token = live_session; + s_wire_session_bound = true; + pble_wire_session_reset(&s_wire_session); + pble_wire_session_commit_v1(&s_wire_session); + + if (!send_source(2048u, 0x61u)) { + fprintf(stderr, "2048-byte RUN frame did not encode\n"); + return EXIT_FAILURE; + } + expect_equal("2048 dispatches", protocol_dispatches, 1u, &failures); + expect_equal("2048 runner calls", runner_calls, 1u, &failures); + expect_equal("2048 runner payload length", runner_lengths[0], 2049u, + &failures); + expect_equal("2048 specialized responses", specialized_responses, 1u, + &failures); + expect_equal("2048 status", specialized_status, PBLE_OK, &failures); + expect_equal("2048 captured source length", (unsigned)g_run_len, 2048u, + &failures); + expect_equal("2048 stdin begins", stdin_begins, 1u, &failures); + expect_equal("2048 transport violations", violation_debits, 0u, &failures); + expect_equal("2048 generic responses", pble_rsp_has_pending(), false, + &failures); + + if (!send_source(2049u, 0x62u)) { + fprintf(stderr, "2049-byte RUN frame did not encode\n"); + return EXIT_FAILURE; + } + expect_equal("2049 dispatches", protocol_dispatches, 2u, &failures); + expect_equal("2049 runner calls", runner_calls, 2u, &failures); + expect_equal("2049 runner payload length", runner_lengths[1], 2050u, + &failures); + expect_equal("2049 generic status", + (unsigned)take_generic_status(0x62u), PBLE_ERANGE, &failures); + expect_equal("2049 specialized response count", specialized_responses, 1u, + &failures); + expect_equal("2049 did not replace accepted source", + (unsigned)g_run_len, 2048u, &failures); + expect_equal("2049 stdin begins", stdin_begins, 1u, &failures); + expect_equal("2049 transport violations", violation_debits, 0u, &failures); + return failures == 0u ? EXIT_SUCCESS : EXIT_FAILURE; +} +""" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_native_utf8_paths.py b/tests/firmware_tests/host/test_v061_native_utf8_paths.py new file mode 100644 index 00000000..30349881 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_native_utf8_paths.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""RED compiled contract for strict UTF-8 at the native FS jail.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +FS_PATH = NATIVE / "pble_fs.c" + + +def _function_span(source: str, name: str) -> tuple[int, int]: + import re + + match = re.search( + r"(?m)^[^\n;{}]*\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", + source, + ) + if match is None: + raise AssertionError("production function `{}` is missing".format(name)) + opening = source.find("{", match.start(), match.end()) + depth = 0 + state = "code" + index = opening + while index < len(source): + char = source[index] + nxt = source[index + 1] if index + 1 < len(source) else "" + if state == "line": + if char == "\n": + state = "code" + elif state == "block": + if char == "*" and nxt == "/": + state = "code" + index += 1 + elif state == "string": + if char == "\\": + index += 1 + elif char == '"': + state = "code" + elif state == "char": + if char == "\\": + index += 1 + elif char == "'": + state = "code" + elif char == "/" and nxt == "/": + state = "line" + index += 1 + elif char == "/" and nxt == "*": + state = "block" + index += 1 + elif char == '"': + state = "string" + elif char == "'": + state = "char" + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return match.start(), index + 1 + index += 1 + raise AssertionError("unbalanced production function `{}`".format(name)) + + +def _function(source: str, name: str) -> str: + start, end = _function_span(source, name) + return source[start:end] + + +class NativeUtf8PathTest(unittest.TestCase): + def test_malformed_utf8_is_ebadreq_before_any_vfs_effect(self): + compiler = shutil.which("cc") + self.assertIsNotNone(compiler, "a host C compiler is required") + fs = FS_PATH.read_text(encoding="utf-8", errors="strict") + + # Keep the production jail helper cluster contiguous so a strict UTF-8 + # helper added beside the existing reserved-name/suffix helpers is also + # compiled by this regression without creating a host-only twin. + helper_start, _ = _function_span(fs, "fs_reserved_prefix") + _, resolver_end = _function_span(fs, "pble_fs_resolve") + jail_cluster = fs[helper_start:resolver_end] + native_functions = "\n\n".join( + ( + jail_cluster, + _function(fs, "rd16"), + _function(fs, "fs_do_mkdir"), + ) + ) + + with tempfile.TemporaryDirectory( + prefix="pyble-v061-native-utf8-path-" + ) as temporary: + temp = Path(temporary) + source = temp / "native_utf8_path.c" + binary = temp / "native_utf8_path" + source.write_text( + HARNESS_PREAMBLE + native_functions + HARNESS_MAIN, + encoding="utf-8", + ) + compiled = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-D_POSIX_C_SOURCE=200809L", + "-I", + str(NATIVE), + str(source), + "-o", + str(binary), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + compiled.returncode, + 0, + "native UTF-8 path harness did not compile:\n" + + compiled.stderr, + ) + + completed = subprocess.run( + [str(binary)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + completed.returncode, + 0, + "native path jail accepted malformed UTF-8:\n" + + completed.stderr, + ) + + +HARNESS_PREAMBLE = r""" +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "pble_proto.h" + +#define PBLE_FS_PATH_MAX 128u +#define PBLE_FS_PATH_BUF 160u +#define PBLE_FS_ITEM_PAYLOAD 248u +#define PBLE_FS_TMP_SUFFIX ".pbltmp" + +typedef void *mp_obj_t; +typedef struct { void *ret_val; } nlr_buf_t; +typedef struct { + uint16_t len; + bool get_active_at_enqueue; + uint8_t payload[PBLE_FS_ITEM_PAYLOAD]; +} pble_fs_req_t; + +#define MP_OBJ_FROM_PTR(pointer) ((mp_obj_t)(pointer)) + +static bool g_put_active; +static unsigned vfs_effects; + +static int nlr_push(nlr_buf_t *nlr) { + nlr->ret_val = NULL; + return 0; +} + +static void nlr_pop(void) { +} + +static bool pble_fs_ticket_valid(const pble_fs_req_t *request) { + return request != NULL; +} + +static bool fs_put_active_current(const pble_fs_req_t *request) { + (void)request; + return g_put_active; +} + +static mp_obj_t fs_str(const char *path) { + return (mp_obj_t)path; +} + +static void mp_vfs_mkdir(mp_obj_t path) { + (void)path; + vfs_effects++; +} + +static uint8_t fs_exc_to_status(mp_obj_t exception) { + (void)exception; + return PBLE_EIO; +} + +static uint8_t fs_stat_path(const pble_fs_req_t *request, const char *path, + uint32_t *size, bool *is_directory) { + (void)request; + (void)path; + *size = 0u; + *is_directory = false; + return PBLE_ENOENT; +} +""" + + +HARNESS_MAIN = r""" + +typedef struct { + const char *name; + const uint8_t *path; + size_t length; +} malformed_case_t; + +static uint8_t mkdir_path(const uint8_t *path, size_t length) { + pble_fs_req_t request = {0}; + size_t extra = 99u; + request.len = (uint16_t)(length + 2u); + request.payload[0] = (uint8_t)(length & 0xffu); + request.payload[1] = (uint8_t)(length >> 8); + memcpy(request.payload + 2, path, length); + return fs_do_mkdir(&request, &extra); +} + +int main(void) { + static const uint8_t overlong[] = {'/', 'a', 0xc0u, 0xafu}; + static const uint8_t continuation[] = {'/', 'a', 0x80u}; + static const uint8_t surrogate[] = {'/', 'a', 0xedu, 0xa0u, 0x80u}; + static const uint8_t above_unicode[] = { + '/', 'a', 0xf4u, 0x90u, 0x80u, 0x80u, + }; + static const malformed_case_t cases[] = { + {"overlong encoding", overlong, sizeof(overlong)}, + {"lone continuation", continuation, sizeof(continuation)}, + {"surrogate U+D800", surrogate, sizeof(surrogate)}, + {"> U+10FFFF", above_unicode, sizeof(above_unicode)}, + }; + unsigned failures = 0u; + + for (size_t i = 0u; i < sizeof(cases) / sizeof(cases[0]); ++i) { + char resolved[PBLE_FS_PATH_BUF]; + vfs_effects = 0u; + uint8_t direct = pble_fs_resolve( + (const char *)cases[i].path, cases[i].length, + resolved, sizeof(resolved)); + if (direct != PBLE_EBADREQ) { + fprintf(stderr, "%s resolver status: got %u, expected %u\n", + cases[i].name, direct, PBLE_EBADREQ); + failures++; + } + + uint8_t command = mkdir_path(cases[i].path, cases[i].length); + if (command != PBLE_EBADREQ) { + fprintf(stderr, "%s MKDIR status: got %u, expected %u\n", + cases[i].name, command, PBLE_EBADREQ); + failures++; + } + if (vfs_effects != 0u) { + fprintf(stderr, "%s leaked %u VFS effect(s)\n", + cases[i].name, vfs_effects); + failures++; + } + } + return failures == 0u ? EXIT_SUCCESS : EXIT_FAILURE; +} +""" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_payload_grammar.py b/tests/firmware_tests/host/test_v061_payload_grammar.py new file mode 100644 index 00000000..1321ff4c --- /dev/null +++ b/tests/firmware_tests/host/test_v061_payload_grammar.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""RED cross-runtime contract for exact PBLE/1 command payload grammar. + +The JSON corpus is deliberately shared by the portable dispatcher and the +dependency-free native wire admission unit. A malformed structured payload +must be rejected before a registered handler can observe it. Commands whose +last field is explicitly "remaining bytes" are the counterexample: every byte +in that field remains valid payload and must reach the handler unchanged. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +HOST = Path(__file__).resolve().parent +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +CORPUS_PATH = HOST / "conformance" / "v061_payload_grammar_vectors.json" + +if str(HOST) not in sys.path: + sys.path.insert(0, str(HOST)) +import _support # noqa: E402 + +_support._inject_micropython_guards() +import pyble_proto as proto # noqa: E402 + + +CMD = 0x01 +RSP = 0x02 +OK = 0x00 +EBADREQ = 0x01 +NO_RESPONSE = frozenset((0x16, 0x31)) + + +def load_vectors() -> dict: + with CORPUS_PATH.open("r", encoding="utf-8") as stream: + vectors = json.load(stream) + if vectors.get("schema") != 1: + raise AssertionError("unsupported payload-grammar corpus schema") + return vectors + + +VECTORS = load_vectors() +HELLO_PAYLOAD = bytes.fromhex(VECTORS["structured"][0]["valid_payloads_hex"][0]) + + +class FakeLink: + """Small production-dispatcher link seam with observable violation debit.""" + + def __init__(self) -> None: + self.token = 1 + self.violations = 0 + + def session_token(self) -> int: + return self.token + + def record_protocol_violation(self, session: int) -> bool: + if session == self.token: + self.violations += 1 + return True + + +def fresh_portable_dispatcher(): + link = FakeLink() + dispatcher = proto.Dispatcher(link) + effects: list[tuple[int, bytes]] = [] + + def handler(frame): + effects.append((frame.opcode, bytes(frame.payload))) + if frame.opcode in NO_RESPONSE: + return None + return bytes((OK,)) + + for row in VECTORS["structured"] + VECTORS["terminal_bytes"]: + dispatcher.register(row["opcode"], handler) + return dispatcher, link, effects + + +def negotiate(dispatcher) -> None: + response = dispatcher.on_message( + proto.encode(CMD, proto.OPCODES["HELLO"], 0x7E, HELLO_PAYLOAD) + ) + if response is None: + raise AssertionError("test precondition: canonical HELLO produced no response") + receipt = dispatcher.take_publish_callback() + if receipt is None: + raise AssertionError("test precondition: canonical HELLO armed no receipt") + receipt() + + +class PortablePayloadGrammarTest(unittest.TestCase): + def test_canonical_structured_payloads_reach_handler_unchanged(self): + failures = [] + request_id = 1 + for row in VECTORS["structured"]: + for payload_hex in row["valid_payloads_hex"]: + dispatcher, _link, effects = fresh_portable_dispatcher() + if row["opcode"] != proto.OPCODES["HELLO"]: + negotiate(dispatcher) + effects.clear() + payload = bytes.fromhex(payload_hex) + response = dispatcher.on_message( + proto.encode(CMD, row["opcode"], request_id, payload) + ) + expected_effect = [(row["opcode"], payload)] + if effects != expected_effect: + failures.append( + "{}: handler effects {!r}, expected {!r}".format( + row["name"], effects, expected_effect + ) + ) + if response is None: + failures.append("{}: canonical request produced no RSP".format(row["name"])) + else: + decoded = proto.decode(response) + if (decoded.type, decoded.opcode, decoded.id) != ( + RSP, + row["opcode"], + request_id, + ) or not decoded.payload or decoded.payload[0] != OK: + failures.append("{}: canonical request did not return OK".format(row["name"])) + request_id = request_id % 0xFF + 1 + self.assertEqual([], failures, "\n" + "\n".join(failures)) + + def test_trailing_bytes_are_ebadreq_before_any_handler_side_effect(self): + failures = [] + request_id = 0x40 + for row in VECTORS["structured"]: + dispatcher, link, effects = fresh_portable_dispatcher() + if row["opcode"] != proto.OPCODES["HELLO"]: + negotiate(dispatcher) + effects.clear() + link.violations = 0 + payload = bytes.fromhex(row["invalid_payload_hex"]) + response = dispatcher.on_message( + proto.encode(CMD, row["opcode"], request_id, payload) + ) + status = None + if response is not None: + decoded = proto.decode(response) + if decoded.type == RSP and decoded.payload: + status = decoded.payload[0] + if status != EBADREQ or effects: + failures.append( + "{}: status={!r}, handler_effects={!r}, violation_debits={}".format( + row["name"], status, effects, link.violations + ) + ) + request_id += 1 + self.assertEqual( + [], + failures, + "structured payload trailing bytes crossed the portable admission cut:\n" + + "\n".join(failures), + ) + + def test_terminal_byte_fields_reach_handler_whole_and_unchanged(self): + failures = [] + request_id = 0x70 + for row in VECTORS["terminal_bytes"]: + dispatcher, _link, effects = fresh_portable_dispatcher() + negotiate(dispatcher) + effects.clear() + payload = bytes.fromhex(row["payload_hex"]) + response = dispatcher.on_message( + proto.encode(CMD, row["opcode"], request_id, payload) + ) + if effects != [(row["opcode"], payload)]: + failures.append("{}: handler did not receive the complete payload".format(row["name"])) + if row["opcode"] in NO_RESPONSE: + if response is not None: + failures.append("{}: CMD-only form unexpectedly returned a RSP".format(row["name"])) + elif response is None or proto.decode(response).payload[:1] != bytes((OK,)): + failures.append("{}: response-bearing form did not return OK".format(row["name"])) + request_id += 1 + self.assertEqual([], failures, "\n" + "\n".join(failures)) + + +def c_array(name: str, payload: bytes) -> tuple[str, str, str]: + if not payload: + return "", "NULL", "0u" + body = ", ".join("0x{:02x}u".format(value) for value in payload) + return ( + "static const uint8_t {}[] = {{{}}};\n".format(name, body), + name, + "sizeof({})".format(name), + ) + + +def native_harness_source() -> str: + declarations = [] + calls = [] + index = 0 + + for row in VECTORS["structured"]: + for payload_hex in row["valid_payloads_hex"]: + declaration, pointer, length = c_array( + "payload_{}".format(index), bytes.fromhex(payload_hex) + ) + declarations.append(declaration) + calls.append( + "failures += check_valid(\"{} canonical\", 0x{:02x}u, {}, {});".format( + row["name"], row["opcode"], pointer, length + ) + ) + index += 1 + declaration, pointer, length = c_array( + "payload_{}".format(index), bytes.fromhex(row["invalid_payload_hex"]) + ) + declarations.append(declaration) + calls.append( + "failures += check_invalid(\"{} trailing\", 0x{:02x}u, {}, {});".format( + row["name"], row["opcode"], pointer, length + ) + ) + index += 1 + + for row in VECTORS["terminal_bytes"]: + declaration, pointer, length = c_array( + "payload_{}".format(index), bytes.fromhex(row["payload_hex"]) + ) + declarations.append(declaration) + calls.append( + "failures += check_valid(\"{}\", 0x{:02x}u, {}, {});".format( + row["name"], row["opcode"], pointer, length + ) + ) + index += 1 + + return textwrap.dedent( + r""" + #include <stdint.h> + #include <stdio.h> + #include <string.h> + + #include "pble_wire.c" + + #define FRAME_CAP 512u + + __DECLARATIONS__ + + static size_t encode_cmd(uint8_t opcode, const uint8_t *payload, + size_t payload_len, uint8_t *out) { + size_t crc_at = 6u + payload_len; + out[0] = PBLE_WIRE_VERSION; + out[1] = PBLE_WIRE_TYPE_CMD; + out[2] = opcode; + out[3] = 0x35u; + out[4] = (uint8_t)(payload_len & 0xffu); + out[5] = (uint8_t)((payload_len >> 8) & 0xffu); + if (payload_len != 0u) { + memcpy(out + 6u, payload, payload_len); + } + uint32_t crc = pble_wire_crc32(out, crc_at); + out[crc_at] = (uint8_t)(crc & 0xffu); + out[crc_at + 1u] = (uint8_t)((crc >> 8) & 0xffu); + out[crc_at + 2u] = (uint8_t)((crc >> 16) & 0xffu); + out[crc_at + 3u] = (uint8_t)((crc >> 24) & 0xffu); + return crc_at + 4u; + } + + static int reaches_handler(const pble_wire_decision_t *decision) { + return decision->action == PBLE_WIRE_DISPATCH || + decision->commit_hello; + } + + static void decide(uint8_t opcode, const uint8_t *payload, + size_t payload_len, pble_wire_decision_t *decision, + uint8_t *before, uint8_t *after, size_t *frame_len) { + pble_wire_session_t session; + pble_wire_session_reset(&session); + pble_wire_session_commit_v1(&session); + *frame_len = encode_cmd(opcode, payload, payload_len, before); + memcpy(after, before, *frame_len); + pble_wire_decide(&session, after, *frame_len, decision); + } + + static int check_valid(const char *name, uint8_t opcode, + const uint8_t *payload, size_t payload_len) { + uint8_t before[FRAME_CAP]; + uint8_t after[FRAME_CAP]; + size_t frame_len = 0u; + pble_wire_decision_t decision; + decide(opcode, payload, payload_len, &decision, + before, after, &frame_len); + if (!reaches_handler(&decision) || + memcmp(before, after, frame_len) != 0) { + fprintf(stderr, + "%s: canonical/terminal bytes did not reach handler " + "unchanged (action=%d status=%u commit=%d)\n", + name, (int)decision.action, (unsigned)decision.status, + decision.commit_hello ? 1 : 0); + return 1; + } + return 0; + } + + static int check_invalid(const char *name, uint8_t opcode, + const uint8_t *payload, size_t payload_len) { + uint8_t before[FRAME_CAP]; + uint8_t after[FRAME_CAP]; + size_t frame_len = 0u; + pble_wire_decision_t decision; + decide(opcode, payload, payload_len, &decision, + before, after, &frame_len); + int handler_effects = reaches_handler(&decision) ? 1 : 0; + if (decision.action != PBLE_WIRE_RSP || + decision.status != PBLE_WIRE_EBADREQ || handler_effects != 0) { + fprintf(stderr, + "%s: action=%d status=%u handler_effects=%d\n", + name, (int)decision.action, (unsigned)decision.status, + handler_effects); + return 1; + } + return 0; + } + + int main(void) { + int failures = 0; + __CALLS__ + return failures == 0 ? 0 : 1; + } + """ + ).replace("__DECLARATIONS__", "".join(declarations)).replace( + "__CALLS__", "\n ".join(calls) + ) + + +@unittest.skipUnless(shutil.which("cc"), "a host C compiler is required") +class NativePayloadGrammarTest(unittest.TestCase): + def test_production_wire_admission_uses_the_shared_exact_grammar(self): + with tempfile.TemporaryDirectory(prefix="pyble-payload-grammar-") as temp_dir: + source_path = Path(temp_dir) / "payload_grammar.c" + binary_path = Path(temp_dir) / "payload_grammar" + source_path.write_text(native_harness_source(), encoding="utf-8") + compile_result = subprocess.run( + [ + shutil.which("cc"), + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + os.fspath(NATIVE), + os.fspath(source_path), + "-o", + os.fspath(binary_path), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual( + 0, + compile_result.returncode, + "native payload-grammar harness did not compile:\n" + + compile_result.stdout + + compile_result.stderr, + ) + run_result = subprocess.run( + [os.fspath(binary_path)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual( + 0, + run_result.returncode, + "production pble_wire payload admission diverged from the shared corpus:\n" + + run_result.stdout + + run_result.stderr, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_pble_wire_native.py b/tests/firmware_tests/host/test_v061_pble_wire_native.py new file mode 100644 index 00000000..2be2b81e --- /dev/null +++ b/tests/firmware_tests/host/test_v061_pble_wire_native.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED parity contract for dependency-free native ``pble_wire``. + +The production C unit is compiled directly with a small standard-C harness; +there is no Python reimplementation standing in for native behavior. Exact +HELLO/frame bytes come from the same corpus as the portable Agent and Dart +client. Exact label bytes are consumed by both portable Python and native C. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zlib + + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +NATIVE = ROOT / "firmware" / "user_c_modules" / "pyble" +HARNESS = HERE / "native" / "v061_pble_wire_harness.c" +SESSION_CORPUS = HERE / "conformance" / "v061_session_vectors.json" +LABEL_CORPUS = HERE / "conformance" / "v061_label_vectors.json" + +sys.path.insert(0, str(HERE)) +import _support # noqa: E402 + +DC = _support.RedReason("pyble_device_config", owner="v0.6.1-config-engineer") + +CMD = 0x01 +HELLO = 0x01 +CANONICAL_HELLO = ( + b"proto_versions=1\n" + b"app_name=PyBLE\n" + b"app_version=0.2.0" +) + + +def load_json(path): + with path.open("r", encoding="utf-8") as stream: + return json.load(stream) + + +def case_payload(case): + if "payload_ascii" in case: + return case["payload_ascii"].encode("ascii") + return bytes.fromhex(case["payload_hex"]) + + +def frame(ver, type_, opcode, id_, payload=b""): + payload = bytes(payload) + head = bytes((ver, type_, opcode, id_)) + len(payload).to_bytes(2, "little") + body = head + payload + return body + (zlib.crc32(body) & 0xFFFFFFFF).to_bytes(4, "little") + + +def vector_frame(case): + if "raw_hex" in case: + return bytes.fromhex(case["raw_hex"]) + spec = case["frame"] + value = frame( + spec["ver"], spec["type"], spec["opcode"], spec["id"], + bytes.fromhex(spec.get("payload_hex", "")), + ) + if spec.get("corrupt_crc"): + value = value[:-1] + bytes((value[-1] ^ 1,)) + return value + + +class PortableLabelCorpusTest(unittest.TestCase): + def test_portable_validator_consumes_every_shared_byte_vector(self): + corpus = load_json(LABEL_CORPUS) + self.assertEqual(24, corpus["constants"]["label_max_bytes"]) + validator = DC.attr( + self, "label_status", "v0.6.1 shared strict label validator") + for case in corpus["cases"]: + with self.subTest(case=case["name"]): + self.assertEqual( + case["expected_status"], + validator(bytes.fromhex(case["payload_hex"])), + ) + + +class CompiledNativeWireTest(unittest.TestCase): + maxDiff = None + + def setUp(self): + self.sessions = load_json(SESSION_CORPUS) + self.labels = load_json(LABEL_CORPUS) + self.temp = tempfile.TemporaryDirectory(prefix="pyble-v061-wire-") + self.addCleanup(self.temp.cleanup) + self._binary = None + + def build_harness(self): + if self._binary is not None: + return self._binary + source = NATIVE / "pble_wire.c" + header = NATIVE / "pble_wire.h" + self.assertTrue( + source.is_file() and header.is_file(), + "D11 requires dependency-free production pble_wire.c/.h shared " + "by firmware and the compiled host harness", + ) + compiler = shutil.which(os.environ.get("CC", "cc")) + self.assertIsNotNone( + compiler, "a host C compiler is required for pble_wire parity") + binary = Path(self.temp.name) / "v061_pble_wire_harness" + built = subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-I", + str(NATIVE), + str(HARNESS), + str(source), + "-o", + str(binary), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + 0, built.returncode, + "pble_wire must compile as standard C without ESP-IDF, FreeRTOS, " + "MicroPython, or test-only dependencies:\n{}".format(built.stderr), + ) + self._binary = binary + return binary + + def run_harness(self, commands): + binary = self.build_harness() + result = subprocess.run( + [str(binary)], + input="\n".join(commands) + "\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + self.assertEqual( + 0, result.returncode, + "compiled pble_wire harness failed:\n{}".format(result.stderr), + ) + return [line.split() for line in result.stdout.splitlines()] + + def assert_frame_line(self, parts, expected, violation, negotiated, + commit=False): + self.assertEqual("FRAME", parts[0]) + self.assertEqual(expected["action"], parts[1]) + self.assertEqual(8, len(parts)) + values = tuple(int(value) for value in parts[2:]) + opcode, id_, status, got_violation, got_commit, got_negotiated = values + if "opcode" in expected: + self.assertEqual(expected["opcode"], opcode) + if "id" in expected: + self.assertEqual(expected["id"], id_) + if "status" in expected: + self.assertEqual(expected["status"], status) + self.assertEqual(int(violation), got_violation) + self.assertEqual(int(commit), got_commit) + self.assertEqual(int(negotiated), got_negotiated) + + def test_dependency_free_native_label_corpus_matches_portable_contract(self): + commands = [ + "LABEL {}".format(case["payload_hex"] or "-") + for case in self.labels["cases"] + ] + output = self.run_harness(commands) + self.assertEqual(len(commands), len(output)) + for case, parts in zip(self.labels["cases"], output): + with self.subTest(case=case["name"]): + self.assertEqual( + ["LABEL", str(case["expected_status"])], parts) + + def test_native_hello_and_guard_use_the_shared_session_corpus(self): + self.build_harness() + for request_id, case in enumerate( + self.sessions["hello_cases"], start=1): + with self.subTest(hello=case["name"]): + request = frame( + 1, CMD, HELLO, request_id, case_payload(case)) + output = self.run_harness([ + "RESET", + "FRAME 1 {}".format(request.hex()), + ]) + self.assertEqual(["RESET", "0"], output[0]) + self.assert_frame_line( + output[1], + { + "action": "rsp", + "opcode": HELLO, + "id": request_id, + "status": case["expected_status"], + }, + bool(case["violation_delta"]), + case["negotiates_v1"], + commit=case["negotiates_v1"], + ) + + for case in self.sessions["inbound_cases"]: + with self.subTest(frame=case["name"]): + commands = ["RESET"] + if case["initial_state"] == "negotiated": + hello = frame(1, CMD, HELLO, 250, CANONICAL_HELLO) + commands.append("FRAME 1 {}".format(hello.hex())) + commands.append("FRAME 1 {}".format(vector_frame(case).hex())) + output = self.run_harness(commands) + target = output[-1] + self.assert_frame_line( + target, + case["guard"], + bool(case["violation_delta"]), + case["initial_state"] == "negotiated", + ) + + def test_native_repeat_failure_publication_and_reset_semantics(self): + self.build_harness() + hello_by_name = { + case["name"]: case for case in self.sessions["hello_cases"] + } + expected_final_state = { + "compatible_repeat_is_idempotent": True, + "failed_repeat_preserves_negotiated_state": True, + "malformed_repeat_preserves_negotiated_state": True, + "disconnect_and_reconnect_clear_negotiation": False, + "unpublished_hello_does_not_negotiate": False, + } + for sequence in self.sessions["session_sequences"]: + commands = ["RESET"] + for step in sequence["steps"]: + if step["action"] == "reconnect": + commands.append("RESET") + continue + if step["action"] == "hello": + payload = case_payload(hello_by_name[step["case"]]) + request = frame(1, CMD, HELLO, step["id"], payload) + else: + request = frame(1, CMD, step["opcode"], step["id"]) + published = 1 if step.get("accept_send", True) else 0 + commands.append( + "FRAME {} {}".format(published, request.hex())) + + with self.subTest(sequence=sequence["name"]): + output = self.run_harness(commands) + self.assertEqual( + int(expected_final_state[sequence["name"]]), + int(output[-1][-1]), + "repeat failure or reset changed the wrong session state", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_picotool_license_admission.py b/tests/firmware_tests/host/test_v061_picotool_license_admission.py new file mode 100644 index 00000000..6f6438cb --- /dev/null +++ b/tests/firmware_tests/host/test_v061_picotool_license_admission.py @@ -0,0 +1,888 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Admission contract for the v0.6.1 picotool build-tool evidence. + +Temporary repositories use exact copies of the checked-in reviewed policy and +its complete referenced evidence closure. Archive/install-tree observations +remain synthetic and do not represent physical release qualification. +""" + +from __future__ import annotations + +import copy +import hashlib +import inspect +import json +import os +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest import mock + +import test_release_v060_license_generation as generation_fixture + + +RELEASE = generation_fixture.RELEASE +RELEASE_LOAD_ERROR = generation_fixture.RELEASE_LOAD_ERROR +REPO_ROOT = Path(__file__).resolve().parents[3] +V060_ROLES = tuple(generation_fixture.RP2_ROLES) +V061_ROLES = (*V060_ROLES, "build-tools") +VERSION_LINE = ( + "picotool v2.3.0 " + "(Darwin, AppleClang-15.0.0.15000309, Release)" +) +POLICY_PATH = "firmware/licenses/rp2-build-tools-license-policy.json" +ATTRIBUTION_PATH = ( + "firmware/licenses/evidence/rp2/picotool/2.3.0/" + "distribution-attribution-v1.json" +) +EMPTY_SHA256 = hashlib.sha256(b"").hexdigest() + +PICOTOOL_LOCK = { + "version": "2.3.0", + "version_line": VERSION_LINE, + "source_repo": "https://github.com/raspberrypi/picotool.git", + "source_ref": "2.3.0", + "source_commit": "6f6458d792b93685a11423b244a585eaa99eafcf", + "distribution_repo": "https://github.com/raspberrypi/pico-sdk-tools.git", + "distribution_ref": "v2.3.0-0", + "distribution_commit": "ad9e4a8375253cf4886bf168ea1a8d2746aadf24", + "url": ( + "https://github.com/raspberrypi/pico-sdk-tools/releases/download/" + "v2.3.0-0/picotool-2.3.0-mac.zip" + ), + "archive_filename": "picotool-2.3.0-mac.zip", + "archive_bytes": 1_980_457, + "archive_format": "zip", + "sha256": "085ea99ccc2d64309e967a72e307fb113838713a46ba45bf7e156e519cca7d8e", + "cmake_dir": "picotool", + "executable_path": "picotool/picotool", + "executable_sha256": ( + "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5" + ), + "cmake_config_path": "picotool/picotoolConfig.cmake", + "cmake_config_sha256": ( + "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3" + ), + "bundled_libusb_path": "picotool/libusb-1.0.0.dylib", + "bundled_libusb_sha256": ( + "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237" + ), +} + +MEMBERS = [ + { + "path": "picotool/", + "kind": "directory", + "mode": "040755", + "bytes": 0, + "sha256": EMPTY_SHA256, + }, + { + "path": "picotool/picotoolTargets-release.cmake", + "kind": "regular", + "mode": "100644", + "bytes": 767, + "sha256": "e40085002e565de850554e772c90f1a1c4cffb545f853eaa231b145e498f7c0f", + }, + { + "path": "picotool/picotoolConfigVersion.cmake", + "kind": "regular", + "mode": "100644", + "bytes": 2_303, + "sha256": "61485a18a59a186d7cb82ddf7686c5f1f4def81726568ae49fd0033db368f7e3", + }, + { + "path": "picotool/rp2350_otp_contents.json", + "kind": "regular", + "mode": "100644", + "bytes": 367_931, + "sha256": "1838713d5f94316c4c61558cb82d3346831235dc41f9b0f02151e6eda3f22fc2", + }, + { + "path": "picotool/libusb-1.0.0.dylib", + "kind": "regular", + "mode": "100444", + "bytes": 328_336, + "sha256": "b3d0c88bcb04fe61e4f56bc304a31fef56e19ee5ec2edaebd0fce44afb2b9237", + }, + { + "path": "picotool/enc_bootloader_mbedtls.elf", + "kind": "regular", + "mode": "100644", + "bytes": 29_484, + "sha256": "c5d17fcbb4f1ee41751dc782d86c1fc6629d8da3484e1a1cadc3ba4bec4d6044", + }, + { + "path": "picotool/picotoolTargets.cmake", + "kind": "regular", + "mode": "100644", + "bytes": 3_856, + "sha256": "043655d4bc215fb3a05d4a04f92cabc0be9bbdb0834717b0211e80106b4d1aae", + }, + { + "path": "picotool/picotoolConfig.cmake", + "kind": "regular", + "mode": "100644", + "bytes": 96, + "sha256": "ca12b6fee18e6583713cfdcb2e81886aaee741d40304ea2cc88c4f5b2df2b6c3", + }, + { + "path": "picotool/xip_ram_perms.elf", + "kind": "regular", + "mode": "100644", + "bytes": 34_004, + "sha256": "afac0e166ee9b632f84717c7db02e7e5e648f03a51cc4795938adcd282c6103b", + }, + { + "path": "picotool/picotool", + "kind": "regular", + "mode": "100755", + "bytes": 5_673_800, + "sha256": "a4b3c4e64dea7b99e810c5c777bf13fb2722b8d0355e0b64a28244ddc1b0f8b5", + }, + { + "path": "picotool/enc_bootloader.elf", + "kind": "regular", + "mode": "100644", + "bytes": 20_104, + "sha256": "9ce3c424d61226225d2c1f5b80a8854f15174b09dd447e58e4439240b274fb3d", + }, + { + "path": ".keep", + "kind": "regular", + "mode": "100644", + "bytes": 0, + "sha256": EMPTY_SHA256, + }, +] + +NONSOFTWARE_MEMBERS = [ + { + **next(item for item in MEMBERS if item["path"] == ".keep"), + "classification": "packaging-metadata", + }, + { + **next(item for item in MEMBERS if item["path"] == "picotool/"), + "classification": "packaging-metadata", + }, +] + +COMPONENT_IDS = [ + "picotool", + "pico-sdk", + "mbedtls", + "clipp", + "nlohmann-json", + "littlefs", + "oofatfs", + "whereami", + "cmake-package-scripts", + "libusb", +] + + +def canonical_json_bytes(value: object) -> bytes: + return ( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + ).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_path(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(canonical_json_bytes(value)) + + +def recompute_semantic(observation: dict[str, object]) -> None: + payload = { + key: copy.deepcopy(value) + for key, value in observation.items() + if key != "semantic_sha256" + } + observation["semantic_sha256"] = sha256_bytes(canonical_json_bytes(payload)) + + +def referenced_license_paths(*documents: object) -> tuple[str, ...]: + """Return the complete checked-in file closure named by documents.""" + + paths: set[str] = set() + + def walk(value: object) -> None: + if isinstance(value, dict): + path = value.get("path") + digest = value.get("sha256") + if ( + isinstance(path, str) + and path.startswith("firmware/licenses/") + and isinstance(digest, str) + ): + paths.add(path) + for nested in value.values(): + walk(nested) + elif isinstance(value, list): + for nested in value: + walk(nested) + + for document in documents: + walk(document) + return tuple(sorted(paths)) + + +def selected_roles(version: str) -> tuple[str, ...] | None: + function = getattr(RELEASE, "_rp2_license_audit_roles_for_version", None) + if not callable(function): + return None + try: + return tuple(function(version)) + except Exception: + return None + + +HAVE_SELECTOR = ( + selected_roles("0.6.0") == V060_ROLES + and selected_roles("0.6.1") == V061_ROLES +) +HAVE_BUILD_POLICY = callable( + getattr(RELEASE, "_audit_validate_rp2_build_tools_license_policy", None) +) and callable( + getattr(RELEASE, "_audit_load_rp2_build_tools_license_policy", None) +) +HAVE_BUILD_OBSERVER = callable( + getattr(RELEASE, "_audit_observe_rp2_build_tools_license_inputs", None) +) +HAVE_V061_ADMISSION = HAVE_SELECTOR and HAVE_BUILD_POLICY and HAVE_BUILD_OBSERVER + + +class PicotoolLicenseAdmissionSeamTests(unittest.TestCase): + def test_roles_are_selected_by_candidate_version(self) -> None: + selector = getattr(RELEASE, "_rp2_license_audit_roles_for_version", None) + self.assertTrue( + callable(selector), + "[red] RP2 license roles are not selected by firmware version", + ) + if callable(selector): + self.assertEqual(tuple(selector("0.6.0")), V060_ROLES) + self.assertEqual(tuple(selector("0.6.1")), V061_ROLES) + + def test_separate_policy_loader_validator_and_observer_exist(self) -> None: + expected = { + "_audit_validate_rp2_build_tools_license_policy": { + "value", + "repo_root", + "build_root", + "picotool_lock", + }, + "_audit_load_rp2_build_tools_license_policy": { + "repo_root", + "build_root", + "lock", + }, + "_audit_observe_rp2_build_tools_license_inputs": { + "build_root", + "repo_root", + "provenance", + "policy", + }, + } + for name, parameters in expected.items(): + with self.subTest(function=name): + function = getattr(RELEASE, name, None) + self.assertTrue(callable(function), "[red] missing %s" % name) + if callable(function): + self.assertEqual( + set(inspect.signature(function).parameters), + parameters, + ) + + +class PicotoolPolicyFixture: + def __init__(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-picotool-policy-red-" + ) + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.build = self.root / "build" + self.repo.mkdir() + self.build.mkdir() + policy_source = REPO_ROOT / POLICY_PATH + attribution_source = REPO_ROOT / ATTRIBUTION_PATH + self.policy = json.loads(policy_source.read_text(encoding="utf-8")) + attribution = json.loads(attribution_source.read_text(encoding="utf-8")) + self.reviewed_files = ( + POLICY_PATH, + *referenced_license_paths(self.policy, attribution), + ) + for relative in self.reviewed_files: + source = REPO_ROOT / relative + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + self.attribution = self.repo / ATTRIBUTION_PATH + self.distribution_provenance = copy.deepcopy( + self.policy["distribution_provenance"] + ) + + def close(self) -> None: + self.temporary.cleanup() + + +@unittest.skipUnless( + HAVE_BUILD_POLICY, + "[red] the independent picotool build-tools policy is not implemented", +) +class PicotoolBuildToolsPolicyTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = PicotoolPolicyFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def validate(self, value: dict[str, object] | None = None): + return RELEASE._audit_validate_rp2_build_tools_license_policy( + self.fixture.policy if value is None else value, + repo_root=self.fixture.repo, + build_root=self.fixture.build, + picotool_lock=copy.deepcopy(PICOTOOL_LOCK), + ) + + def test_exact_separate_policy_admits_both_nonredistributed_owners(self): + value = self.validate() + self.assertEqual(value["distribution_provenance"], self.fixture.distribution_provenance) + self.assertEqual( + [owner["id"] for owner in value["source_owners"]], + [ + "picotool-composite-build-tool", + "picotool-libusb-build-tool", + ], + ) + self.assertTrue( + all( + owner["component_kind"] == "build-tool" + for owner in value["source_owners"] + ) + ) + self.assertEqual(value["nonsoftware_members"], NONSOFTWARE_MEMBERS) + attribution = json.loads(self.fixture.attribution.read_text(encoding="utf-8")) + self.assertEqual( + [component["id"] for component in attribution["components"]], + COMPONENT_IDS, + ) + self.assertEqual( + attribution["distribution_provenance"]["ownership"], + "packaging-provenance-only", + ) + + def test_policy_rejects_missing_extra_reordered_or_changed_frozen_data(self): + cases = { + "missing": lambda value: value.pop("nonsoftware_members"), + "extra": lambda value: value.__setitem__("private_review", True), + "schema-bool": lambda value: value.__setitem__("schema_version", True), + "owner-order": lambda value: value["source_owners"].reverse(), + "member-order": lambda value: value["distribution_provenance"][ + "member_inventory" + ].reverse(), + "archive": lambda value: value["distribution_provenance"][ + "retained_archive" + ].__setitem__("sha256", "9" * 64), + "config": lambda value: next( + item + for item in value["distribution_provenance"]["member_inventory"] + if item["path"] == "picotool/picotoolConfig.cmake" + ).__setitem__("sha256", "9" * 64), + "targets": lambda value: next( + item + for item in value["distribution_provenance"]["member_inventory"] + if item["path"] == "picotool/picotoolTargets.cmake" + ).__setitem__("mode", "100755"), + "libusb": lambda value: next( + item + for item in value["distribution_provenance"]["member_inventory"] + if item["path"] == "picotool/libusb-1.0.0.dylib" + ).__setitem__("sha256", "9" * 64), + "version": lambda value: value["distribution_provenance"].__setitem__( + "runtime_version_line", "picotool v2.3.1" + ), + "source-ref": lambda value: value["distribution_provenance"][ + "source" + ].__setitem__("commit", "9" * 40), + "distribution-ref": lambda value: value["distribution_provenance"][ + "distribution" + ].__setitem__("ref", "v2.3.0-1"), + "license": lambda value: value["source_owners"][0][ + "license_texts" + ][0].__setitem__("sha256", "9" * 64), + "provenance": lambda value: value["distribution_provenance"][ + "attribution" + ].__setitem__("sha256", "9" * 64), + } + for name, mutate in cases.items(): + with self.subTest(case=name): + value = copy.deepcopy(self.fixture.policy) + mutate(value) + with self.assertRaises(RELEASE.ReleaseError): + self.validate(value) + + +class V061CompositionFixture: + def __init__(self) -> None: + self.generation = generation_fixture.GenerationFixture() + self.root = self.generation.root + self.repo = self.generation.repo + self.build = self.generation.build + self.policy_fixture = PicotoolPolicyFixture() + self._advance_source_fixture() + self.build_tools = self._build_tools_observation() + self.combined = copy.deepcopy(self.generation.rp2_observation) + self.combined["input_sha256"].update(self.build_tools["input_sha256"]) + self.combined["owners"].extend(copy.deepcopy(self.build_tools["owners"])) + self.combined["role_documents"]["build-tools"] = copy.deepcopy( + self.build_tools["role_document"] + ) + recompute_semantic(self.combined) + + def close(self) -> None: + self.policy_fixture.close() + self.generation.close() + + def _advance_source_fixture(self) -> None: + lock_path = self.repo / "firmware/versions.lock" + text = lock_path.read_text(encoding="utf-8") + text = text.replace('agent_version = "0.6.0"', 'agent_version = "0.6.1"') + text += "\n[picotool]\n" + for key, value in PICOTOOL_LOCK.items(): + if isinstance(value, int): + text += "%s = %d\n" % (key, value) + else: + text += "%s = %s\n" % (key, json.dumps(value)) + lock_path.write_text(text, encoding="utf-8") + + provenance_path = ( + self.build / "rpi-pico2-w/pyble-build-provenance.json" + ) + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + provenance["picotool"] = VERSION_LINE + write_json(provenance_path, provenance) + provenance_sha256 = sha256_path(provenance_path) + lock_sha256 = sha256_path(lock_path) + observation = self.generation.rp2_observation + for document in observation["role_documents"].values(): + document["build_provenance_sha256"] = provenance_sha256 + document["source_identity"]["arm_gnu_toolchain"][ + "versions_lock_sha256" + ] = lock_sha256 + for record in observation["role_documents"]["arm-gnu-runtime"]["inputs"]: + if record["kind"] == "toolchain-pin": + record["sha256"] = lock_sha256 + recompute_semantic(observation) + + policy_path = self.repo / POLICY_PATH + write_json(policy_path, self.policy_fixture.policy) + for relative in self.policy_fixture.reviewed_files: + if relative == POLICY_PATH: + continue + source = self.policy_fixture.repo / relative + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + self.policy = copy.deepcopy(self.policy_fixture.policy) + self.generation.tool_lock["inputs"].update( + { + "rp2_build_tools_license_policy_path": POLICY_PATH, + "rp2_build_tools_license_policy_sha256": sha256_path(policy_path), + } + ) + + def _build_tools_observation(self) -> dict[str, object]: + provenance_path = ( + self.build / "rpi-pico2-w/pyble-build-provenance.json" + ) + policy_path = self.repo / POLICY_PATH + attribution = self.repo / ATTRIBUTION_PATH + owners = copy.deepcopy(self.policy["source_owners"]) + input_sha256 = { + "repo/firmware/versions.lock": sha256_path( + self.repo / "firmware/versions.lock" + ), + "repo/" + POLICY_PATH: sha256_path(policy_path), + "repo/" + ATTRIBUTION_PATH: sha256_path(attribution), + "picotool-archive/" + PICOTOOL_LOCK["archive_filename"]: ( + PICOTOOL_LOCK["sha256"] + ), + **{ + "picotool-member/" + item["path"]: item["sha256"] + for item in MEMBERS + }, + } + for owner in owners: + for record in (*owner["license_texts"], *owner["notice_files"]): + input_sha256["repo/" + record["path"]] = record["sha256"] + attribution_value = json.loads(attribution.read_text(encoding="utf-8")) + + def add_nested_assets(value: object) -> None: + if isinstance(value, dict): + path = value.get("path") + digest = value.get("sha256") + if ( + isinstance(path, str) + and path.startswith("firmware/licenses/") + and isinstance(digest, str) + ): + input_sha256["repo/" + path] = digest + for nested in value.values(): + add_nested_assets(nested) + elif isinstance(value, list): + for nested in value: + add_nested_assets(nested) + + add_nested_assets(attribution_value) + document = { + "schema_version": 1, + "profile_id": "rpi-pico2-w", + "target": "rpi-pico2-w", + "resource_kind": "rp2", + "role": "build-tools", + "build_provenance_sha256": sha256_path(provenance_path), + "distribution_provenance": copy.deepcopy( + self.policy["distribution_provenance"] + ), + "owners": owners, + "nonsoftware_members": copy.deepcopy(NONSOFTWARE_MEMBERS), + "inputs": [ + {"logical_path": key, "sha256": value} + for key, value in sorted(input_sha256.items()) + ], + "license_inputs": sorted( + { + record["path"]: { + "logical_path": "repo/" + record["path"], + "sha256": record["sha256"], + } + for owner in owners + for record in ( + *owner["license_texts"], + *owner["notice_files"], + ) + }.values(), + key=lambda item: item["logical_path"], + ), + } + observation = { + "input_sha256": input_sha256, + "owners": owners, + "notice_records": [], + "role_document": document, + } + recompute_semantic(observation) + return observation + + def make_esp(self, name: str) -> Path: + path = self.root / name + self.generation.generate_esp_evidence(evidence_dir=path) + return path + + def compose(self, observation: dict[str, object], output: Path): + return RELEASE._audit_compose_v060_license_evidence( + esp_evidence=self.make_esp("esp-" + output.name), + destination=output, + build_root=self.build, + repo_root=self.repo, + esp_notice=generation_fixture.ESP_NOTICE, + rp2_observation=observation, + firmware_version="0.6.1", + ) + + +@unittest.skipUnless(RELEASE is not None, RELEASE_LOAD_ERROR) +class V060PicotoolIsolationTests(unittest.TestCase): + def test_historical_composition_keeps_exact_seven_role_bytes(self) -> None: + fixture = generation_fixture.GenerationFixture() + try: + esp = fixture.root / "historical-esp" + fixture.generate_esp_evidence(evidence_dir=esp) + output = fixture.root / "historical-composed" + result = RELEASE._audit_compose_v060_license_evidence( + esp_evidence=esp, + destination=output, + build_root=fixture.build, + repo_root=fixture.repo, + esp_notice=generation_fixture.ESP_NOTICE, + rp2_observation=copy.deepcopy(fixture.rp2_observation), + firmware_version="0.6.0", + ) + self.assertEqual( + result["third_party_licenses"], generation_fixture.FINAL_NOTICE + ) + inventory = json.loads( + (output / "release-inventory.json").read_text(encoding="utf-8") + ) + self.assertEqual( + [role["role"] for role in inventory["profiles"][-1]["roles"]], + list(V060_ROLES), + ) + for role in V060_ROLES: + path = output / "rp2" / ("rpi-pico2-w--%s.json" % role) + self.assertEqual( + path.read_bytes(), + canonical_json_bytes( + fixture.rp2_observation["role_documents"][role] + ), + ) + all_bytes = b"".join( + path.read_bytes() + for path in sorted(output.rglob("*")) + if path.is_file() + ) + self.assertNotIn(b"build-tools", all_bytes) + self.assertNotIn(b"picotool-composite-build-tool", all_bytes) + finally: + fixture.close() + + @unittest.skipUnless( + HAVE_BUILD_POLICY and HAVE_BUILD_OBSERVER, + "[red] picotool policy seams are not implemented", + ) + def test_historical_entrypoint_never_opens_picotool_policy_or_observer( + self, + ) -> None: + fixture = generation_fixture.GenerationFixture() + try: + with mock.patch.object( + RELEASE, + "_audit_load_rp2_build_tools_license_policy", + side_effect=AssertionError( + "v0.6.0 opened the v0.6.1-only build-tools policy" + ), + ) as load_policy, mock.patch.object( + RELEASE, + "_audit_observe_rp2_build_tools_license_inputs", + side_effect=AssertionError( + "v0.6.0 invoked the v0.6.1-only build-tools observer" + ), + ) as observe_build_tools: + result, _generator, _verifier = fixture.audit( + verifier_side_effect=lambda **_kwargs: None + ) + load_policy.assert_not_called() + observe_build_tools.assert_not_called() + self.assertEqual( + result["third_party_licenses"], + generation_fixture.FINAL_NOTICE, + ) + inventory = json.loads( + ( + fixture.evidence / "release-inventory.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + [role["role"] for role in inventory["profiles"][-1]["roles"]], + list(V060_ROLES), + ) + finally: + fixture.close() + + +@unittest.skipUnless( + HAVE_V061_ADMISSION, + "[red] v0.6.1 picotool license admission is not implemented", +) +class V061PicotoolCompositionTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = V061CompositionFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def test_eighth_role_binds_exact_distribution_but_adds_no_public_notice(self): + output = self.fixture.root / "v061-composed" + result = self.fixture.compose(self.fixture.combined, output) + inventory = json.loads( + (output / "release-inventory.json").read_text(encoding="utf-8") + ) + self.assertEqual(inventory["firmware_version"], "0.6.1") + self.assertEqual( + [role["role"] for role in inventory["profiles"][-1]["roles"]], + list(V061_ROLES), + ) + document_path = output / "rp2/rpi-pico2-w--build-tools.json" + self.assertEqual( + document_path.read_bytes(), + canonical_json_bytes(self.fixture.build_tools["role_document"]), + ) + document = json.loads(document_path.read_text(encoding="utf-8")) + self.assertEqual( + document["distribution_provenance"]["picotool_lock"], + PICOTOOL_LOCK, + ) + self.assertEqual( + document["distribution_provenance"]["member_inventory"], + MEMBERS, + ) + self.assertEqual(document["nonsoftware_members"], NONSOFTWARE_MEMBERS) + self.assertEqual( + [owner["id"] for owner in document["owners"]], + [ + "picotool-composite-build-tool", + "picotool-libusb-build-tool", + ], + ) + notice = result["third_party_licenses"] + for private_owner in ( + "picotool-composite-build-tool", + "picotool-libusb-build-tool", + "LicenseRef-PyBLE-Picotool-2.3.0-Composite", + "LGPL-2.1-or-later", + ): + self.assertNotIn(private_owner, notice) + + def test_every_substitution_fails_before_evidence_publication(self) -> None: + def member(value, path): + return next( + item + for item in value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["member_inventory"] + if item["path"] == path + ) + + cases = { + "none": lambda value: value["role_documents"].pop("build-tools"), + "missing": lambda value: value["role_documents"]["build-tools"].pop( + "license_inputs" + ), + "extra": lambda value: value["role_documents"]["build-tools"].__setitem__( + "private_review", True + ), + "role-order": lambda value: value.__setitem__( + "role_documents", + { + "build-tools": value["role_documents"]["build-tools"], + **{ + key: item + for key, item in value["role_documents"].items() + if key != "build-tools" + }, + }, + ), + "member-missing": lambda value: value["role_documents"][ + "build-tools" + ]["distribution_provenance"]["member_inventory"].pop(), + "member-extra": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["member_inventory"].append( + { + "path": "picotool/unreviewed", + "kind": "regular", + "mode": "100644", + "bytes": 1, + "sha256": "9" * 64, + } + ), + "member-order": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["member_inventory"].reverse(), + "archive": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["retained_archive"].__setitem__("sha256", "9" * 64), + "config": lambda value: member( + value, "picotool/picotoolConfig.cmake" + ).__setitem__("sha256", "9" * 64), + "targets": lambda value: member( + value, "picotool/picotoolTargets.cmake" + ).__setitem__("bytes", 3_855), + "libusb": lambda value: member( + value, "picotool/libusb-1.0.0.dylib" + ).__setitem__("mode", "100644"), + "version": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ].__setitem__("runtime_version_line", "picotool v2.3.1"), + "source": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["source"].__setitem__("commit", "9" * 40), + "distribution": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["distribution"].__setitem__("ref", "v2.3.0-1"), + "owner-order": lambda value: value["role_documents"]["build-tools"][ + "owners" + ].reverse(), + "license": lambda value: value["role_documents"]["build-tools"][ + "license_inputs" + ][0].__setitem__("sha256", "9" * 64), + "provenance": lambda value: value["role_documents"]["build-tools"][ + "distribution_provenance" + ]["attribution"].__setitem__("sha256", "9" * 64), + "build-provenance": lambda value: value["role_documents"][ + "build-tools" + ].__setitem__("build_provenance_sha256", "9" * 64), + } + for index, (name, mutate) in enumerate(cases.items()): + with self.subTest(case=name): + value = copy.deepcopy(self.fixture.combined) + mutate(value) + recompute_semantic(value) + output = self.fixture.root / ("rejected-%02d-%s" % (index, name)) + with self.assertRaises(RELEASE.ReleaseError): + self.fixture.compose(value, output) + self.assertFalse(output.exists() or output.is_symlink()) + + +@unittest.skipUnless( + HAVE_V061_ADMISSION, + "[red] v0.6.1 picotool license orchestration is not implemented", +) +class V061PicotoolGenerationTests(unittest.TestCase): + def test_entrypoint_uses_separate_observer_and_keeps_build_tools_private(self): + fixture = V061CompositionFixture() + try: + loader = getattr( + RELEASE, "_audit_load_rp2_build_tools_license_policy" + ) + observer = getattr( + RELEASE, "_audit_observe_rp2_build_tools_license_inputs" + ) + with mock.patch.object( + RELEASE, + loader.__name__, + return_value=copy.deepcopy(fixture.policy), + ) as load_policy, mock.patch.object( + RELEASE, + observer.__name__, + return_value=copy.deepcopy(fixture.build_tools), + ) as observe_build_tools: + result, _esp_generator, _esp_verifier = ( + fixture.generation.audit( + verifier_side_effect=lambda **_kwargs: None + ) + ) + self.assertGreaterEqual(load_policy.call_count, 1) + self.assertGreaterEqual(observe_build_tools.call_count, 2) + inventory = json.loads( + ( + fixture.generation.evidence / "release-inventory.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual( + [role["role"] for role in inventory["profiles"][-1]["roles"]], + list(V061_ROLES), + ) + for owner_id in ( + "picotool-composite-build-tool", + "picotool-libusb-build-tool", + ): + self.assertNotIn(owner_id, result["third_party_licenses"]) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_picotool_observer_toctou.py b/tests/firmware_tests/host/test_v061_picotool_observer_toctou.py new file mode 100644 index 00000000..60bbaf5a --- /dev/null +++ b/tests/firmware_tests/host/test_v061_picotool_observer_toctou.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED races for the retained-picotool release observer. + +The observer must bind one no-follow, descriptor-anchored distribution across +archive validation, installed-tree validation, the runtime version probe, and +the final evidence receipt. These tests inject deterministic mutations at +the version-probe boundary. A pre-probe path walk alone is not sufficient: +the observer must reopen and revalidate the exact archive and installed tree +after the probe before accepting the observation. + +All bytes in this suite are synthetic. The tests perform no network access, +firmware build, legal review, or physical release qualification. +""" + +from __future__ import annotations + +import copy +import hashlib +import os +import shutil +import stat +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +import test_v061_picotool_license_admission as admission_fixture + + +RELEASE = admission_fixture.RELEASE +RELEASE_LOAD_ERROR = admission_fixture.RELEASE_LOAD_ERROR + +VERSION_LINE = "picotool v9.8.7 (synthetic observer fixture)" +ORIGINAL_TOOL = ( + b"#!/bin/sh\n" + b"printf '%s\\n' 'picotool v9.8.7 (synthetic observer fixture)'\n" +) +CHANGED_TOOL = ( + b"#!/bin/sh\n" + b"# Bytes substituted only after the trusted-tree check.\n" + b"printf '%s\\n' 'picotool v9.8.7 (synthetic observer fixture)'\n" +) + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +class PicotoolObserverRaceFixture: + """Construct the smallest exact archive/install pair the observer accepts.""" + + def __init__(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-picotool-observer-toctou-red-" + ) + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.build = self.root / "build" + self.install = self.repo / "firmware" / ".picotool" + self.distribution = self.install / ".pyble-dist" + self.tool_directory = self.install / "picotool" + self.executable = self.tool_directory / "picotool" + self.archive = self.distribution / "picotool-synthetic.zip" + self.repo.mkdir() + self.build.mkdir() + self.distribution.mkdir(parents=True) + self.tool_directory.mkdir() + # The installer publishes its private staging root at 0700; member + # modes below that root are separately frozen by the archive. + self.install.chmod(0o700) + self.distribution.chmod(0o755) + self.tool_directory.chmod(0o755) + self.executable.write_bytes(ORIGINAL_TOOL) + self.executable.chmod(0o755) + + directory = zipfile.ZipInfo("picotool/") + directory.create_system = 3 + directory.external_attr = (stat.S_IFDIR | 0o755) << 16 + executable = zipfile.ZipInfo("picotool/picotool") + executable.create_system = 3 + executable.external_attr = (stat.S_IFREG | 0o755) << 16 + with zipfile.ZipFile( + self.archive, + "w", + compression=zipfile.ZIP_STORED, + ) as archive: + archive.writestr(directory, b"") + archive.writestr(executable, ORIGINAL_TOOL) + self.archive.chmod(0o644) + archive_bytes = self.archive.read_bytes() + + empty_sha256 = _sha256(b"") + self.members = [ + { + "path": "picotool/", + "kind": "directory", + "mode": "040755", + "bytes": 0, + "sha256": empty_sha256, + }, + { + "path": "picotool/picotool", + "kind": "regular", + "mode": "100755", + "bytes": len(ORIGINAL_TOOL), + "sha256": _sha256(ORIGINAL_TOOL), + }, + ] + self.archive_logical = ( + "firmware/.picotool/.pyble-dist/picotool-synthetic.zip" + ) + self.lock = { + "archive_bytes": len(archive_bytes), + "archive_filename": self.archive.name, + "executable_path": "picotool/picotool", + "sha256": _sha256(archive_bytes), + "version_line": VERSION_LINE, + } + self.policy = { + "source_owners": [], + "distribution_provenance": { + "member_inventory": copy.deepcopy(self.members), + "retained_archive": { + "path": self.archive_logical, + }, + }, + } + + def close(self) -> None: + self.temporary.cleanup() + + def expect_probe_boundary_mutation_rejected( + self, + mutate, + *, + role_mutate=None, + ) -> None: + """Run a synchronized mutation and require post-probe rejection.""" + + observer = RELEASE._audit_observe_rp2_build_tools_license_inputs + real_archive_inventory = RELEASE._audit_picotool_archive_inventory + real_tree_validation = RELEASE._audit_picotool_installed_tree + probe_calls = 0 + + def version_probe(args, **_kwargs): + nonlocal probe_calls + if probe_calls == 0: + mutate() + probe_calls += 1 + return subprocess.CompletedProcess( + args=args, + returncode=0, + stdout=VERSION_LINE + "\n", + stderr="", + ) + + def expected_role(**_kwargs): + if role_mutate is not None: + role_mutate() + return ( + {"synthetic/input": "0" * 64}, + {"schema_version": 1, "role": "build-tools"}, + ) + + with mock.patch.object( + RELEASE, + "_read_lock", + return_value={"picotool": copy.deepcopy(self.lock)}, + ), mock.patch.object( + RELEASE, + "_validate_v061_picotool_lock", + side_effect=lambda value, _label: copy.deepcopy(value), + ), mock.patch.object( + RELEASE, + "_audit_validate_rp2_build_tools_license_policy", + return_value=copy.deepcopy(self.policy), + ), mock.patch.object( + RELEASE, + "_audit_rp2_logical_file", + return_value=(self.archive_logical, self.archive), + ), mock.patch.object( + RELEASE, + "_audit_picotool_archive_inventory", + wraps=real_archive_inventory, + ) as archive_validation, mock.patch.object( + RELEASE, + "_audit_picotool_installed_tree", + wraps=real_tree_validation, + ) as tree_validation, mock.patch.object( + RELEASE, + "_audit_expected_rp2_build_tools_role", + side_effect=expected_role, + ), mock.patch.object( + RELEASE.subprocess, + "run", + side_effect=version_probe, + ): + try: + observer( + build_root=self.build, + repo_root=self.repo, + provenance={"synthetic": True}, + policy=copy.deepcopy(self.policy), + ) + except RELEASE.ReleaseError: + self._assert_preflight_and_probe_ran( + archive_validation, + tree_validation, + probe_calls, + ) + return + + self._assert_preflight_and_probe_ran( + archive_validation, + tree_validation, + probe_calls, + ) + raise AssertionError( + "[red] retained-picotool observer accepted bytes/path identity " + "changed at the runtime version-probe boundary" + ) + + def _assert_preflight_and_probe_ran( + self, + archive_validation, + tree_validation, + probe_calls: int, + ) -> None: + if archive_validation.call_count < 1: + raise AssertionError( + "fixture failed before exact retained-archive validation" + ) + if tree_validation.call_count < 1: + raise AssertionError( + "fixture failed before exact installed-tree validation" + ) + if probe_calls < 1: + raise AssertionError( + "fixture failed before the synchronized runtime version probe" + ) + + +@unittest.skipUnless(RELEASE is not None, RELEASE_LOAD_ERROR) +class V061PicotoolObserverToctouTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = PicotoolObserverRaceFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def test_stable_distribution_uses_private_snapshot_and_revalidates(self): + observer = RELEASE._audit_observe_rp2_build_tools_license_inputs + real_archive_inventory = RELEASE._audit_picotool_archive_inventory + real_tree_validation = RELEASE._audit_picotool_installed_tree + real_subprocess_run = subprocess.run + executed_paths: list[Path] = [] + snapshot_roots: list[Path] = [] + + def version_probe(args, **kwargs): + probe_root = Path(kwargs["cwd"]) + self.assertEqual(args[0], "./picotool/picotool") + executable = probe_root / args[0] + self.assertFalse(Path(args[0]).is_absolute()) + self.assertTrue(executable.is_file()) + self.assertFalse(executable.is_symlink()) + self.assertEqual(executable.read_bytes(), ORIGINAL_TOOL) + self.assertEqual(stat.S_IMODE(executable.stat().st_mode), 0o555) + self.assertNotEqual( + executable.resolve(strict=True), + self.fixture.executable.resolve(strict=True), + ) + self.assertFalse(executable.is_relative_to(self.fixture.install)) + self.assertTrue(executable.is_relative_to(probe_root)) + self.assertEqual(len(kwargs["pass_fds"]), 1) + self.assertTrue(callable(kwargs["preexec_fn"])) + executed_paths.append(executable) + snapshot_roots.append(probe_root) + return real_subprocess_run(args, **kwargs) + + with mock.patch.object( + RELEASE, + "_read_lock", + return_value={"picotool": copy.deepcopy(self.fixture.lock)}, + ), mock.patch.object( + RELEASE, + "_validate_v061_picotool_lock", + side_effect=lambda value, _label: copy.deepcopy(value), + ), mock.patch.object( + RELEASE, + "_audit_validate_rp2_build_tools_license_policy", + return_value=copy.deepcopy(self.fixture.policy), + ), mock.patch.object( + RELEASE, + "_audit_rp2_logical_file", + return_value=(self.fixture.archive_logical, self.fixture.archive), + ), mock.patch.object( + RELEASE, + "_audit_picotool_archive_inventory", + wraps=real_archive_inventory, + ) as archive_validation, mock.patch.object( + RELEASE, + "_audit_picotool_installed_tree", + wraps=real_tree_validation, + ) as tree_validation, mock.patch.object( + RELEASE, + "_audit_expected_rp2_build_tools_role", + return_value=( + {"synthetic/input": "0" * 64}, + {"schema_version": 1, "role": "build-tools"}, + ), + ), mock.patch.object( + RELEASE.subprocess, + "run", + side_effect=version_probe, + ): + result = observer( + build_root=self.fixture.build, + repo_root=self.fixture.repo, + provenance={"synthetic": True}, + policy=copy.deepcopy(self.fixture.policy), + ) + + self.assertEqual(archive_validation.call_count, 2) + self.assertEqual(tree_validation.call_count, 2) + self.assertEqual(len(executed_paths), 1) + self.assertEqual(len(snapshot_roots), 1) + self.assertFalse( + executed_paths[0].exists() or executed_paths[0].is_symlink() + ) + self.assertFalse( + snapshot_roots[0].exists() or snapshot_roots[0].is_symlink() + ) + self.assertTrue(self.fixture.executable.is_file()) + self.assertEqual(self.fixture.executable.read_bytes(), ORIGINAL_TOOL) + self.assertEqual(result["owners"], []) + self.assertEqual(result["notice_records"], []) + + def test_rejects_mutable_private_snapshot_executable_at_probe_boundary(self): + """The probe must execute an anchored node and verify it afterward.""" + + real_subprocess_run = subprocess.run + probe_calls = 0 + + def version_probe(args, **kwargs): + nonlocal probe_calls + probe_calls += 1 + probe_root = Path(kwargs["cwd"]) + executable = probe_root / self.fixture.lock["executable_path"] + replacement = probe_root / "replacement-picotool" + replacement.write_bytes(CHANGED_TOOL) + replacement.chmod(0o755) + os.replace(replacement, executable) + completed = real_subprocess_run(args, **kwargs) + self.assertEqual(completed.returncode, 0) + self.assertEqual(completed.stdout, VERSION_LINE + "\n") + return completed + + payloads = {"picotool/picotool": ORIGINAL_TOOL} + with mock.patch.object( + RELEASE.subprocess, + "run", + side_effect=version_probe, + ): + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "private picotool snapshot|changed", + ): + RELEASE._audit_run_picotool_snapshot( + archive_payloads=payloads, + member_inventory=copy.deepcopy(self.fixture.members), + executable_path=self.fixture.lock["executable_path"], + ) + self.assertEqual(probe_calls, 1) + + def test_private_probe_cannot_execute_a_transient_root_substitution(self): + """Restoring the checked root must not hide substituted execution.""" + + real_subprocess_run = subprocess.run + marker = self.fixture.root / "substituted-private-probe-ran" + probe_calls = 0 + + def version_probe(args, **kwargs): + nonlocal probe_calls + probe_calls += 1 + probe_root = Path(kwargs["cwd"]) + moved_root = probe_root.with_name(probe_root.name + "-original") + os.replace(probe_root, moved_root) + try: + substituted_directory = probe_root / "picotool" + substituted_directory.mkdir(parents=True, mode=0o755) + substituted = substituted_directory / "picotool" + substituted.write_bytes( + b"#!/bin/sh\n" + + b": > " + + os.fsencode(marker) + + b"\n" + + b"printf '%s\\n' '" + + VERSION_LINE.encode("utf-8") + + b"'\n" + ) + substituted.chmod(0o755) + return real_subprocess_run(args, **kwargs) + finally: + shutil.rmtree(probe_root) + os.replace(moved_root, probe_root) + + with mock.patch.object( + RELEASE.subprocess, + "run", + side_effect=version_probe, + ): + try: + RELEASE._audit_run_picotool_snapshot( + archive_payloads={"picotool/picotool": ORIGINAL_TOOL}, + member_inventory=copy.deepcopy(self.fixture.members), + executable_path=self.fixture.lock["executable_path"], + ) + except RELEASE.ReleaseError: + pass + + self.assertEqual(probe_calls, 1) + self.assertFalse( + marker.exists(), + "[red] the private probe executed a transient substituted root", + ) + + def test_rejects_world_writable_installed_root_before_probe(self): + self.fixture.install.chmod(0o777) + with self.assertRaisesRegex( + RELEASE.ReleaseError, + "install root|mode|unsafe", + ): + RELEASE._audit_picotool_installed_tree( + install_root=self.fixture.install, + archive_payloads={"picotool/picotool": ORIGINAL_TOOL}, + member_inventory=copy.deepcopy(self.fixture.members), + archive_filename=self.fixture.lock["archive_filename"], + archive_bytes=self.fixture.archive.read_bytes(), + ) + + def test_rejects_in_place_executable_mutation_during_version_probe(self): + def mutate() -> None: + self.fixture.executable.write_bytes(CHANGED_TOOL) + self.fixture.executable.chmod(0o755) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_atomic_executable_replacement_during_version_probe(self): + replacement = self.fixture.root / "replacement-picotool" + replacement.write_bytes(CHANGED_TOOL) + replacement.chmod(0o755) + + def mutate() -> None: + os.replace(replacement, self.fixture.executable) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_byte_identical_atomic_executable_replacement(self): + replacement = self.fixture.root / "identical-picotool" + replacement.write_bytes(ORIGINAL_TOOL) + replacement.chmod(0o755) + + def mutate() -> None: + os.replace(replacement, self.fixture.executable) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_symlinked_parent_component_during_version_probe(self): + checked_directory = self.fixture.root / "checked-picotool-directory" + substituted_directory = self.fixture.root / "substituted-picotool-directory" + substituted_directory.mkdir(mode=0o755) + substituted_executable = substituted_directory / "picotool" + substituted_executable.write_bytes(CHANGED_TOOL) + substituted_executable.chmod(0o755) + + def mutate() -> None: + os.replace(self.fixture.tool_directory, checked_directory) + os.symlink(substituted_directory, self.fixture.tool_directory) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_retained_archive_mutation_during_version_probe(self): + def mutate() -> None: + self.fixture.archive.write_bytes(b"changed after archive validation\n") + self.fixture.archive.chmod(0o644) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_byte_identical_atomic_archive_replacement(self): + replacement = self.fixture.root / "identical-picotool.zip" + replacement.write_bytes(self.fixture.archive.read_bytes()) + replacement.chmod(0o644) + + def mutate() -> None: + os.replace(replacement, self.fixture.archive) + + self.fixture.expect_probe_boundary_mutation_rejected(mutate) + + def test_rejects_archive_mutation_while_minting_the_role_receipt(self): + def mutate_at_evidence_boundary() -> None: + self.fixture.archive.write_bytes( + b"mutated at evidence-mint boundary\n" + ) + self.fixture.archive.chmod(0o644) + + self.fixture.expect_probe_boundary_mutation_rejected( + lambda: None, + role_mutate=mutate_at_evidence_boundary, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_picotool_production_evidence_schema.py b/tests/firmware_tests/host/test_v061_picotool_production_evidence_schema.py new file mode 100644 index 00000000..4cc8cef5 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_picotool_production_evidence_schema.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED contract for the checked-in v0.6.1 picotool evidence schema. + +This suite is deliberately offline. It distinguishes the reviewed production +documents from the older compact synthetic fixture: production admission must +bind the complete canonical policy, attribution manifest, and every referenced +license/provenance byte. +""" + +from __future__ import annotations + +from collections import Counter +import copy +import hashlib +import json +from pathlib import Path +import stat +import tempfile +import tomllib +import unittest + +import test_release_v060_license_generation as generation_fixture +import test_v061_picotool_license_admission as compact_fixture + + +RELEASE = generation_fixture.RELEASE +RELEASE_LOAD_ERROR = generation_fixture.RELEASE_LOAD_ERROR +REPO_ROOT = Path(__file__).resolve().parents[3] +POLICY_RELATIVE = Path( + "firmware/licenses/rp2-build-tools-license-policy.json" +) +ATTRIBUTION_RELATIVE = Path( + "firmware/licenses/evidence/rp2/picotool/2.3.0/" + "distribution-attribution-v1.json" +) +POLICY_SHA256 = ( + "d0b5920297c78280d509b93cc0ab356839e8b2adfacaab42f352150390b645e9" +) +ATTRIBUTION_SHA256 = ( + "9d96bdff715a08582506220d3ba1e332cc3b86dd9eaa13e7d7169856734a6e88" +) +COMPACT_COMPONENTS = [ + {"name": "CMake package scripts", "license": "CMake-package-script-grant"}, + {"name": "Mbed TLS", "license": "Apache-2.0"}, + {"name": "Pico SDK", "license": "BSD-3-Clause"}, + {"name": "clipp", "license": "MIT"}, + {"name": "littlefs", "license": "BSD-3-Clause"}, + {"name": "nlohmann JSON", "license": "MIT"}, + {"name": "ooFatFs R0.13c", "license": "LicenseRef-ooFatFs-R0.13c"}, + {"name": "picotool", "license": "BSD-3-Clause"}, + {"name": "whereami", "license": "MIT"}, +] + +# This is the complete repository-file closure referenced by the production +# policy and its nested attribution document. Archive/install-tree identities +# are a separate build observation and intentionally do not appear here. +EXPECTED_REVIEWED_INPUTS = { + "repo/firmware/licenses/evidence/mbedtls-LICENSE.txt": ( + "9b405ef4c89342f5eae1dd828882f931747f71001cfba7d114801039b52ad09b" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/clipp-LICENSE": ( + "72c5d1a6f8c76c88b4559d26710c3a298a0ba9822ab3d187d0b4a5b2e8e2b219" + ), + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "cmake-copying-cmake-scripts" + ): "8c33ed647ccfaa4e682ffe56b677f4d5d0dc51e2ccc40038015df784c7be51dd", + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "distribution-attribution-v1.json" + ): ATTRIBUTION_SHA256, + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "homebrew-libusb.rb" + ): "5da3b9c199276c00cd14ef7da6a10e4dc7fd8b65eaa638ab356a049597491d74", + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-AUTHORS": ( + "27fd82b016838197d5276ba98d2187ed5a1ad0e171767322f356f0a9f6395941" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-COPYING": ( + "5df07007198989c622f5d41de8d703e7bef3d0e79d62e24332ee739a452af62a" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-README": ( + "1d3dc1c6be134b121b0697ad7190f8c20911d509f2dfef69f72484c95b8a2b87" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h": ( + "8ef05de838404213f63f3a27a073aee0e2bc072dc4415734772e756d34def7f8" + ), + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "libusb-version_nano.h" + ): "243da8ad18f946e4ee896b296ac56834bf6b50c6211af1b62603483b60f58d50", + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/littlefs-LICENSE.md": ( + "0cb4ff1daf5fdc1359c6a6ee3116092f08fc100c9d58b1b77ab17bfd801f856d" + ), + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "nlohmann-json-LICENSE.MIT" + ): "86b998c792894ccb911a1cb7994f7a9652894e7a094c0b5e45be2f553f45cf14", + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/oofatfs-LICENSE": ( + "4449f912502843f47a0a258e740054f916a21586f849a27ce40f936c095e2f6c" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/picotool-LICENSE.TXT": ( + "483f865953435b66c443dee7558debe3cc3cf8fcbb6a112fd9fc6a795d53f1f6" + ), + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/whereami-LICENSE.MIT": ( + "3633afb48d2f5fb9a1942eead37adbe0ab6c70a79468c1b69e5a1e771ec4b119" + ), + ( + "repo/firmware/licenses/evidence/rp2/picotool/2.3.0/" + "whereami-LICENSE.WTFPLv2" + ): "8b3d9ef07a62cfaa0194bdd64fa1bb61bbc18e4a156a83f8a52d937bd945239b", + "repo/firmware/licenses/rp2-build-tools-license-policy.json": POLICY_SHA256, + "repo/firmware/licenses/texts/Apache-2.0.txt": ( + "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + ), + "repo/firmware/licenses/texts/LGPL-2.1-or-later.txt": ( + "5df07007198989c622f5d41de8d703e7bef3d0e79d62e24332ee739a452af62a" + ), +} + + +def canonical_json_bytes(value: object) -> bytes: + return ( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + ).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def load_canonical(path: Path) -> tuple[bytes, object]: + raw = path.read_bytes() + value = json.loads(raw.decode("utf-8", errors="strict")) + if raw != canonical_json_bytes(value): + raise AssertionError("%s is not canonical JSON" % path) + return raw, value + + +def referenced_license_assets(*documents: object) -> dict[str, str]: + """Collect exact checked-in license/provenance references recursively.""" + + result: dict[str, str] = {} + + def walk(value: object) -> None: + if isinstance(value, dict): + path = value.get("path") + digest = value.get("sha256") + if ( + isinstance(path, str) + and path.startswith("firmware/licenses/") + and isinstance(digest, str) + ): + previous = result.setdefault("repo/" + path, digest) + if previous != digest: + raise AssertionError( + "conflicting digests for reviewed asset %s" % path + ) + for nested in value.values(): + walk(nested) + elif isinstance(value, list): + for nested in value: + walk(nested) + + for document in documents: + walk(document) + return dict(sorted(result.items())) + + +@unittest.skipUnless(RELEASE is not None, RELEASE_LOAD_ERROR) +class PicotoolProductionEvidenceSchemaTests(unittest.TestCase): + def test_production_validator_rejects_compact_four_key_attribution(self): + fixture = compact_fixture.PicotoolPolicyFixture() + try: + compact_attribution = { + "schema_version": 1, + "archive_sha256": compact_fixture.PICOTOOL_LOCK["sha256"], + "components": copy.deepcopy(COMPACT_COMPONENTS), + "packaging_provenance": { + "name": "pico-sdk-tools", + "license": "Apache-2.0", + "repo": compact_fixture.PICOTOOL_LOCK[ + "distribution_repo" + ], + "ref": compact_fixture.PICOTOOL_LOCK["distribution_ref"], + "commit": compact_fixture.PICOTOOL_LOCK[ + "distribution_commit" + ], + "ownership": "packaging-only", + }, + } + self.assertEqual( + set(compact_attribution), + { + "schema_version", + "archive_sha256", + "components", + "packaging_provenance", + }, + ) + compact_fixture.write_json(fixture.attribution, compact_attribution) + compact_digest = compact_fixture.sha256_path(fixture.attribution) + policy = copy.deepcopy(fixture.policy) + + def rebind_attribution(value: object) -> None: + if isinstance(value, dict): + if value.get("path") == compact_fixture.ATTRIBUTION_PATH: + value["sha256"] = compact_digest + for nested in value.values(): + rebind_attribution(nested) + elif isinstance(value, list): + for nested in value: + rebind_attribution(nested) + + rebind_attribution(policy) + with self.assertRaises( + RELEASE.ReleaseError, + msg=( + "[red] production admitted the compact synthetic picotool " + "attribution without frozen production bytes" + ), + ): + RELEASE._audit_validate_rp2_build_tools_license_policy( + policy, + repo_root=fixture.repo, + build_root=fixture.build, + picotool_lock=compact_fixture.PICOTOOL_LOCK, + ) + finally: + fixture.close() + + def test_checked_in_complete_evidence_is_offline_frozen_and_admitted(self): + policy_path = REPO_ROOT / POLICY_RELATIVE + attribution_path = REPO_ROOT / ATTRIBUTION_RELATIVE + policy_raw, policy = load_canonical(policy_path) + attribution_raw, attribution = load_canonical(attribution_path) + + self.assertEqual(sha256_bytes(policy_raw), POLICY_SHA256) + self.assertEqual(sha256_bytes(attribution_raw), ATTRIBUTION_SHA256) + self.assertEqual(RELEASE._PICOTOOL_V061_POLICY_SHA256, POLICY_SHA256) + self.assertEqual( + RELEASE._PICOTOOL_V061_ATTRIBUTION_SHA256, + ATTRIBUTION_SHA256, + ) + self.assertEqual( + set(attribution), + { + "archive", + "archive_members", + "components", + "distribution_provenance", + "libusb_binary_provenance", + "schema_version", + "source", + }, + ) + + versions = tomllib.loads( + (REPO_ROOT / "firmware/versions.lock").read_text(encoding="utf-8") + ) + tool_lock = tomllib.loads( + (REPO_ROOT / "firmware/release-tools.lock").read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + tool_lock["inputs"]["rp2_build_tools_license_policy_path"], + POLICY_RELATIVE.as_posix(), + ) + self.assertEqual( + tool_lock["inputs"]["rp2_build_tools_license_policy_sha256"], + POLICY_SHA256, + ) + + with tempfile.TemporaryDirectory( + prefix="pyble-v061-production-evidence-red-" + ) as temporary: + build_root = Path(temporary) + validated = RELEASE._audit_validate_rp2_build_tools_license_policy( + policy, + repo_root=REPO_ROOT, + build_root=build_root, + picotool_lock=versions["picotool"], + ) + loaded = RELEASE._audit_load_rp2_build_tools_license_policy( + REPO_ROOT, + build_root, + tool_lock, + ) + observed_inputs = RELEASE._audit_picotool_policy_input_hashes( + policy=validated, + repo_root=REPO_ROOT, + build_root=build_root, + ) + + self.assertEqual(validated, policy) + self.assertEqual(loaded, policy) + self.assertEqual(observed_inputs, EXPECTED_REVIEWED_INPUTS) + self.assertEqual( + referenced_license_assets(policy, attribution), + { + key: digest + for key, digest in EXPECTED_REVIEWED_INPUTS.items() + if key + != "repo/firmware/licenses/rp2-build-tools-license-policy.json" + }, + ) + for logical_path, expected_digest in EXPECTED_REVIEWED_INPUTS.items(): + relative = logical_path.removeprefix("repo/") + path = REPO_ROOT / relative + mode = path.lstat().st_mode + self.assertTrue(stat.S_ISREG(mode), logical_path) + self.assertFalse(path.is_symlink(), logical_path) + self.assertEqual(sha256_bytes(path.read_bytes()), expected_digest) + + members = policy["distribution_provenance"]["member_inventory"] + self.assertEqual(attribution["archive_members"], members) + software_members = sorted( + member["path"] + for member in members + if member["kind"] == "regular" and member["path"] != ".keep" + ) + owner_ids = [owner["id"] for owner in policy["source_owners"]] + self.assertEqual( + owner_ids, + [ + "picotool-composite-build-tool", + "picotool-libusb-build-tool", + ], + ) + roots = [ + root["path"] + for owner in policy["source_owners"] + for root in owner["source_roots"] + ] + self.assertEqual(sorted(roots), software_members) + self.assertTrue(all(count == 1 for count in Counter(roots).values())) + self.assertEqual( + [ + root["path"] + for root in policy["source_owners"][1]["source_roots"] + ], + [versions["picotool"]["bundled_libusb_path"]], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_protocol_session.py b/tests/firmware_tests/host/test_v061_protocol_session.py new file mode 100644 index 00000000..3a5d6cec --- /dev/null +++ b/tests/firmware_tests/host/test_v061_protocol_session.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED host contract for PBLE/1 v0.6.1 negotiation/session hardening. + +This suite deliberately drives the public portable ``Agent``/``BleLink`` seam +instead of pinning a particular parser or state-machine class. The same exact +semantic inputs live in ``conformance/v061_session_vectors.json`` so the native +C harness and Dart client tests can consume them without re-authoring bytes. + +Expected RED baseline (firmware v0.6.0): HELLO payloads are ignored, every +opcode can dispatch before HELLO, valid RSP/EVT and CMD-id-0 inputs dispatch as +commands, and disconnect does not clear a negotiation state that does not yet +exist. CRC -> EVT ECRC already passes on the portable implementation and is +kept here to pin validation precedence while the surrounding gates turn red. +""" + +import json +import os +import shutil +import sys +import tempfile +import unittest +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _support # noqa: E402 + +_support._inject_micropython_guards() +try: + import importlib as _importlib + + if "micropython" not in sys.modules: + sys.modules["micropython"] = _importlib.import_module("micropython") +except ImportError: + pass + +import pyble_proto # noqa: E402 + +AGENT = _support.RedReason("pyble_agent", owner="protocol-engineer") + +HERE = os.path.dirname(os.path.abspath(__file__)) +VECTORS_PATH = os.path.join(HERE, "conformance", "v061_session_vectors.json") + +CMD = 0x01 +RSP = 0x02 +EVT = 0x03 +HELLO = 0x01 +DEVICE_INFO = 0x02 +SOFT_REBOOT = 0x22 +CONSOLE_INPUT = 0x31 +OK = 0x00 +EBADREQ = 0x01 +EBUSY = 0x07 +ECRC = 0x08 +ERANGE = 0x09 +EUNSUPPORTED = 0x0A + +CANONICAL_HELLO = ( + b"proto_versions=1\n" + b"app_name=PyBLE\n" + b"app_version=0.2.0" +) + + +def load_vectors(): + with open(VECTORS_PATH, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def payload_bytes(case): + has_ascii = "payload_ascii" in case + has_hex = "payload_hex" in case + if has_ascii == has_hex: + raise AssertionError( + "{} must carry exactly one of payload_ascii/payload_hex".format( + case.get("name", "vector"))) + if has_ascii: + return case["payload_ascii"].encode("ascii") + return bytes.fromhex(case["payload_hex"]) + + +def oracle_frame(ver, type_, opcode, id_, payload=b""): + """Build a frame independently of the production encoder, including VER2.""" + payload = bytes(payload) + head = bytes((ver, type_, opcode, id_)) + len(payload).to_bytes(2, "little") + body = head + payload + return body + (zlib.crc32(body) & 0xFFFFFFFF).to_bytes(4, "little") + + +def vector_frame(case): + if "raw_hex" in case: + return bytes.fromhex(case["raw_hex"]) + spec = case["frame"] + msg = oracle_frame( + spec["ver"], spec["type"], spec["opcode"], spec["id"], + bytes.fromhex(spec.get("payload_hex", ""))) + if spec.get("corrupt_crc"): + msg = msg[:-1] + bytes((msg[-1] ^ 0x01,)) + return msg + + +class RecordingLink: + """Records only the portable Agent's already-public link boundary.""" + + def __init__(self): + self.sent = [] + self.accept_send = True + self.message_cb = None + self.connect_cb = None + self.disconnect_cb = None + self.oversize_cb = None + self.info_payload = None + self.adv_name = None + self.session = 1 + self.terminations = [] + self.violation_count = 0 + self.closed = False + self.command_closing = False + self.closing_calls = [] + + def on_message(self, cb): + self.message_cb = cb + + def on_connect(self, cb): + self.connect_cb = cb + + def on_disconnect(self, cb): + self.disconnect_cb = cb + + def on_oversize(self, cb): + self.oversize_cb = cb + + def session_token(self): + return self.session + + def send_message(self, msg, on_published=None, expected_session=None): + if expected_session is not None and expected_session != self.session: + return False + if not self.accept_send: + return False + self.sent.append(bytes(msg)) + if on_published is not None: + on_published() + return True + + def terminate_session(self, expected_session=None): + if expected_session is not None and expected_session != self.session: + return False + if not self.closed: + self.closed = True + self.terminations.append(self.session) + return True + + def record_protocol_violation(self, expected_session=None): + """Fake only the link-owned bounded counter, never frame classification. + + Production ``Agent`` remains responsible for deciding whether one input + is a violation and calling this seam *before* publishing its reply. The + real BleLink counter/termination behavior is independently exercised by + test_v061_reassembly_hardening.py. + """ + if expected_session is not None and expected_session != self.session: + return False + if self.closed: + return False + if self.command_closing: + # Accepted-SOFT closing preserves the normal malformed-frame wire + # classification, but it has precedence over violation accounting. + return True + self.violation_count += 1 + if self.violation_count >= 8: + self.terminate_session(expected_session) + return False + return True + + def begin_command_closing(self, expected_session=None): + if expected_session is not None and expected_session != self.session: + return False + self.command_closing = True + self.closing_calls.append(self.session) + return True + + def reconnect(self): + self.session += 1 + self.violation_count = 0 + self.closed = False + self.command_closing = False + + def trigger_oversize(self, head): + """Model BleLink's link-owned debit-before-upcall ordering only. + + Frame classification and response construction remain production Agent + behavior. The real portable BleLink's oversize detection, tail + suppression, and identical budget cut are tested separately. + """ + if self.record_protocol_violation(self.session): + self.oversize_cb(bytes(head)) + + def set_info_payload(self, payload): + self.info_payload = bytes(payload) + + def set_adv_name(self, name): + self.adv_name = name + + def mtu(self): + return 247 + + def mac(self): + return b"\x00" * 6 + + +class ProtocolSessionContractTest(unittest.TestCase): + maxDiff = None + + def setUp(self): + self.vectors = load_vectors() + self.root = tempfile.mkdtemp(prefix="pyble_v061_session_") + self.addCleanup(shutil.rmtree, self.root, True) + self.now = [0] + + def new_agent(self): + cls = AGENT.attr( + self, "Agent", "v0.6.1 HELLO/session observable contract") + link = RecordingLink() + agent = cls( + link, + self.root, + unique_id=b"\x12\x34\x56\x78\x9a\xbc\x9f\x3a", + reset=lambda: None, + clock=lambda: self.now[0], + ) + self.assertIsNotNone(link.message_cb, "Agent must bind on_message") + self.assertIsNotNone(link.connect_cb, "Agent must bind on_connect") + self.assertIsNotNone(link.disconnect_cb, "Agent must bind on_disconnect") + link.connect_cb() + return agent, link + + def send(self, link, msg): + link.message_cb(bytes(msg)) + + def negotiate(self, link, id_=250): + link.sent[:] = [] + self.send(link, oracle_frame(1, CMD, HELLO, id_, CANONICAL_HELLO)) + self.assertEqual(1, len(link.sent), "canonical HELLO must produce one RSP") + rsp = pyble_proto.decode(link.sent[0]) + self.assertEqual((RSP, HELLO, id_, OK), + (rsp.type, rsp.opcode, rsp.id, rsp.payload[0])) + link.sent[:] = [] + + def assert_wire(self, link, expected, context): + kind = expected["wire"] + if kind == "none": + self.assertEqual([], link.sent, context + ": expected no wire output") + return + + self.assertEqual( + 1, len(link.sent), context + ": expected exactly one output frame") + frame = pyble_proto.decode(link.sent[0]) + expected_type = RSP if kind == "rsp" else EVT + self.assertEqual(expected_type, frame.type, context + ": TYPE") + self.assertEqual(expected["opcode"], frame.opcode, context + ": OPCODE") + self.assertEqual(expected["id"], frame.id, context + ": ID") + self.assertTrue(frame.payload, context + ": status byte missing") + self.assertEqual(expected["status"], frame.payload[0], context + ": status") + if expected["status"] != OK: + self.assertEqual( + bytes((expected["status"],)), bytes(frame.payload), + context + ": refusal must be status-only") + + def test_shared_corpus_pins_bounds_and_current_app_bytes(self): + constants = self.vectors["constants"] + self.assertEqual(1, constants["protocol_version"]) + self.assertEqual(192, constants["hello_max_bytes"]) + self.assertEqual(8, constants["hello_max_offers"]) + self.assertEqual(32, constants["hello_key_max_bytes"]) + self.assertEqual(32, constants["hello_value_max_bytes"]) + self.assertEqual(32, constants["hello_app_field_max_bytes"]) + self.assertEqual(5000, constants["rx_reassembly_deadline_ms"]) + self.assertEqual(8, constants["session_violation_limit"]) + + cases = {c["name"]: c for c in self.vectors["hello_cases"]} + self.assertEqual( + CANONICAL_HELLO, payload_bytes(cases["canonical_current_app"])) + self.assertEqual(193, len(payload_bytes(cases["payload_193_bytes"]))) + self.assertEqual( + {"unnegotiated", "negotiated"}, + {c["initial_state"] for c in self.vectors["inbound_cases"]}) + + def test_hello_request_semantic_vectors(self): + for index, case in enumerate(self.vectors["hello_cases"], start=1): + with self.subTest(case=case["name"]): + _, link = self.new_agent() + request_id = index + self.send( + link, + oracle_frame(1, CMD, HELLO, request_id, payload_bytes(case)), + ) + self.assertEqual(1, len(link.sent), "HELLO must get one response") + rsp = pyble_proto.decode(link.sent[0]) + self.assertEqual((RSP, HELLO, request_id), + (rsp.type, rsp.opcode, rsp.id)) + self.assertTrue(rsp.payload, "HELLO response lacks status") + self.assertEqual(case["expected_status"], rsp.payload[0]) + if case["expected_status"] == OK: + self.assertIn(b"proto=1\n", bytes(rsp.payload[1:])) + else: + self.assertEqual( + bytes((case["expected_status"],)), bytes(rsp.payload), + "failed HELLO is status-only and cannot expose caps") + self.assertEqual( + case["violation_delta"], link.violation_count, + "HELLO semantic classification must update the one " + "link-owned session budget exactly once") + + def test_only_successful_hello_opens_the_session(self): + for index, case in enumerate(self.vectors["hello_cases"], start=30): + with self.subTest(case=case["name"]): + _, link = self.new_agent() + self.send( + link, + oracle_frame(1, CMD, HELLO, index, payload_bytes(case)), + ) + link.sent[:] = [] + self.send(link, oracle_frame(1, CMD, DEVICE_INFO, index + 40)) + self.assertEqual(1, len(link.sent)) + rsp = pyble_proto.decode(link.sent[0]) + expected = OK if case["negotiates_v1"] else EBADREQ + self.assertEqual( + expected, rsp.payload[0], + "only HELLO RSP OK publication may open command admission") + + def test_inbound_direction_id_crc_and_session_precedence_vectors(self): + for case in self.vectors["inbound_cases"]: + with self.subTest(case=case["name"]): + agent, link = self.new_agent() + if case["initial_state"] == "negotiated": + self.negotiate(link) + else: + link.sent[:] = [] + + self.send(link, vector_frame(case)) + self.assert_wire(link, case["expected"], case["name"]) + self.assertEqual( + case["violation_delta"], link.violation_count, + case["name"] + ": violation delta") + + if case["expected"].get("effect") == "console_input_empty": + buf = bytearray(1) + self.assertIsNone( + agent.console.readinto(buf), + "pre-HELLO CONSOLE_INPUT must not reach the stdin ring") + + def test_accepted_soft_marks_transport_closing_across_reconnect(self): + agent, link = self.new_agent() + self.negotiate(link) + self.send(link, oracle_frame(1, CMD, SOFT_REBOOT, 90)) + self.assert_wire(link, { + "wire": "rsp", "opcode": SOFT_REBOOT, "id": 90, "status": OK, + }, "accepted SOFT_REBOOT") + self.assertTrue( + link.command_closing, + "response publication must close transport-level malformed-input " + "accounting for the accepted reboot grace", + ) + self.assertEqual(link.closing_calls, [1]) + + link.disconnect_cb() + link.reconnect() + link.connect_cb() + self.assertTrue( + link.command_closing, + "a successor connection during the same reboot grace must inherit " + "the global closing admission state", + ) + self.assertEqual(link.closing_calls, [1, 2]) + self.assertIsNotNone(agent._reboot_at) + + def test_closing_keeps_malformed_wire_semantics_without_budget_debit(self): + _, link = self.new_agent() + self.negotiate(link) + self.send(link, oracle_frame(1, CMD, SOFT_REBOOT, 91)) + self.assertEqual(link.violation_count, 0) + self.assertTrue(link.command_closing) + + bad_crc = bytearray(oracle_frame(1, CMD, DEVICE_INFO, 92)) + bad_crc[-1] ^= 1 + cases = ( + (b"\x01\x01\x02", {"wire": "none"}), + (oracle_frame(1, RSP, DEVICE_INFO, 93), {"wire": "none"}), + (bytes(bad_crc), { + "wire": "evt", "opcode": DEVICE_INFO, "id": 0, + "status": ECRC, + }), + (oracle_frame(2, CMD, DEVICE_INFO, 94), { + "wire": "rsp", "opcode": DEVICE_INFO, "id": 94, + "status": EBADREQ, + }), + (bytes.fromhex("0101025f0500f75c519f"), { + "wire": "rsp", "opcode": DEVICE_INFO, "id": 95, + "status": EBADREQ, + }), + ) + for message, expected in cases: + with self.subTest(message=message.hex()): + link.sent[:] = [] + self.send(link, message) + self.assert_wire(link, expected, "accepted-SOFT closing fault") + self.assertEqual( + link.violation_count, + 0, + "accepted-SOFT closing must suppress every violation debit", + ) + + def test_repeat_disconnect_and_publication_cut_sequences(self): + hello_by_name = { + case["name"]: case for case in self.vectors["hello_cases"] + } + for sequence in self.vectors["session_sequences"]: + with self.subTest(sequence=sequence["name"]): + _, link = self.new_agent() + for step_index, step in enumerate(sequence["steps"]): + link.sent[:] = [] + action = step["action"] + if action == "reconnect": + link.disconnect_cb() + link.reconnect() + link.connect_cb() + continue + + link.accept_send = step.get("accept_send", True) + if action == "hello": + hello = hello_by_name[step["case"]] + msg = oracle_frame( + 1, CMD, HELLO, step["id"], payload_bytes(hello)) + opcode = HELLO + elif action == "command": + opcode = step["opcode"] + msg = oracle_frame(1, CMD, opcode, step["id"]) + else: + self.fail("unknown sequence action {!r}".format(action)) + + self.send(link, msg) + expected = {"wire": step["wire"]} + if step["wire"] != "none": + expected.update({ + "opcode": opcode, + "id": step["id"], + "status": step["status"], + }) + self.assert_wire( + link, expected, + "{} step {}".format(sequence["name"], step_index)) + link.accept_send = True + + def test_eighth_violation_closes_before_reply_and_terminates_once(self): + case = self.vectors["violation_budget_case"] + _, link = self.new_agent() + frame_spec = case["frame"] + + for offset in range(case["responses_before_limit"]): + request_id = case["first_id"] + offset + link.sent[:] = [] + self.send(link, oracle_frame( + frame_spec["ver"], frame_spec["type"], frame_spec["opcode"], + request_id, bytes.fromhex(frame_spec["payload_hex"]))) + self.assertEqual([], link.terminations) + self.assert_wire(link, { + "wire": "rsp", + "opcode": frame_spec["opcode"], + "id": request_id, + "status": case["response_status_before_limit"], + }, "violation {} before limit".format(offset + 1)) + + eighth_id = case["first_id"] + case["responses_before_limit"] + link.sent[:] = [] + self.send(link, oracle_frame( + frame_spec["ver"], frame_spec["type"], frame_spec["opcode"], + eighth_id, bytes.fromhex(frame_spec["payload_hex"]))) + self.assertEqual([], link.sent, "eighth violation must suppress its RSP") + self.assertEqual( + case["termination_count_at_limit"], len(link.terminations), + "eighth violation must terminate the exact session once") + + link.sent[:] = [] + self.send(link, oracle_frame( + frame_spec["ver"], frame_spec["type"], frame_spec["opcode"], + eighth_id + 1, bytes.fromhex(frame_spec["payload_hex"]))) + self.assertEqual([], link.sent, "closed session must remain reply-silent") + self.assertEqual( + case["termination_count_after_limit"], len(link.terminations), + "closed-session repeats must not invoke termination twice") + + def test_oversize_reply_requires_a_safely_correlatable_header(self): + _, link = self.new_agent() + self.assertIsNotNone(link.oversize_cb, "Agent must bind on_oversize") + + unsafe = ( + b"\x01\x01\x20\x2a", # no complete six-byte header + b"\x02\x01\x20\x2a\x00\x00", # unsupported VER + b"\x01\x02\x20\x2a\x00\x00", # inbound RSP + b"\x01\x03\x20\x2a\x00\x00", # inbound EVT + b"\x01\x01\x20\x00\x00\x00", # request ID zero + ) + for head in unsafe: + with self.subTest(head=head.hex()): + link.sent[:] = [] + link.trigger_oversize(head) + self.assertEqual( + [], link.sent, + "unsafe oversize header cannot be echoed as a response") + + link.sent[:] = [] + link.trigger_oversize(b"\x01\x01\x20\x2a\x00\x00") + self.assert_wire(link, { + "wire": "rsp", "opcode": 0x20, "id": 0x2A, "status": ERANGE, + }, "safely correlated oversize") + self.assertEqual(6, link.violation_count) + + def test_eighth_oversize_suppresses_erange_before_termination(self): + _, link = self.new_agent() + safe = b"\x01\x01\x20\x2a\x00\x00" + + for _ in range(7): + link.sent[:] = [] + link.trigger_oversize(safe) + self.assert_wire(link, { + "wire": "rsp", "opcode": 0x20, "id": 0x2A, + "status": ERANGE, + }, "oversize before budget limit") + self.assertEqual([], link.terminations) + + link.sent[:] = [] + link.trigger_oversize(safe) + self.assertEqual([], link.sent) + self.assertEqual([1], link.terminations) + + link.trigger_oversize(safe) + self.assertEqual([], link.sent) + self.assertEqual([1], link.terminations) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_reassembly_hardening.py b/tests/firmware_tests/host/test_v061_reassembly_hardening.py new file mode 100644 index 00000000..0e09b38c --- /dev/null +++ b/tests/firmware_tests/host/test_v061_reassembly_hardening.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED host contract for the portable PBLE/1 RX deadline and abuse budget. + +No BLE behavior is faked. A recording object supplies only the MicroPython +``bluetooth.BLE`` calls made by PyBLE-owned ``BleLink`` code. Fragment timing, +run replacement, violation accounting, exact-session reset, and the request to +disconnect are therefore deterministic host-testable policy. +""" + +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _support # noqa: E402 + +BLE = _support.RedReason("pyble_ble", owner="ble-transport-engineer") + +HERE = os.path.dirname(os.path.abspath(__file__)) +VECTORS_PATH = os.path.join(HERE, "conformance", "v061_session_vectors.json") + +FIRST = 0x80 +LAST = 0x40 +RX_MAX = 4096 +TICKS_PERIOD = 1 << 30 + + +class RecordingBle: + """Call recorder only; it implements no controller or GATT semantics.""" + + def __init__(self): + self.disconnect_calls = [] + self.advertise_calls = 0 + + def gap_disconnect(self, conn_handle): + self.disconnect_calls.append(conn_handle) + + def gap_advertise(self, *args, **kwargs): + self.advertise_calls += 1 + + +class ReassemblyHardeningTest(unittest.TestCase): + def setUp(self): + with open(VECTORS_PATH, "r", encoding="utf-8") as fh: + self.constants = json.load(fh)["constants"] + self.now = [0] + self.mod = BLE.obj( + self, "v0.6.1 bounded portable RX reassembly/session budget") + + @staticmethod + def wrap_ticks_diff(end, start): + """MicroPython-compatible signed arithmetic for a 2**30 tick ring.""" + half = TICKS_PERIOD // 2 + return ((end - start + half) % TICKS_PERIOD) - half + + def new_link(self, conn=11): + # D11 pins an injectable clock at construction. Tests must never + # mutate module globals: separate links/VM epochs need independent, + # deterministic clocks and the production default remains ticks_ms. + try: + link = self.mod.BleLink( + clock=lambda: self.now[0], ticks_diff=self.wrap_ticks_diff) + except TypeError as exc: + self.fail( + "BleLink(clock=..., ticks_diff=...) is the required D11 " + "injectable portable-clock constructor seam: {}".format(exc)) + recorder = RecordingBle() + link._ble = recorder + link._rx_handle = 10 + link._tx_handle = 11 + link._info_handle = 12 + messages = [] + link.on_message(messages.append) + link._irq(1, (conn, 0, b"\xaa" * 6)) # rp2 CENTRAL_CONNECT + self.assertEqual((conn, 1), link.session_token()) + return link, recorder, messages + + def record_violation(self, link, token=None): + fn = getattr(link, "record_protocol_violation", None) + self.assertIsNotNone( + fn, + "BleLink.record_protocol_violation(expected_session) is the one " + "shared counter seam for reassembly and Agent semantic violations") + return fn(link.session_token() if token is None else token) + + @staticmethod + def first(data=b"A", index=0, last=False): + return bytes((FIRST | (LAST if last else 0) | (index & 0x3F),)) + data + + @staticmethod + def continuation(index, data=b"B", last=False): + return bytes(((LAST if last else 0) | (index & 0x3F),)) + data + + def test_shared_constants_pin_inclusive_absolute_bounds(self): + self.assertEqual(5000, self.constants["rx_reassembly_deadline_ms"]) + self.assertEqual(8, self.constants["session_violation_limit"]) + + def test_deadline_is_absolute_non_extending_and_inclusive(self): + link, recorder, messages = self.new_link() + + self.now[0] = 100 + link._on_rx(self.first(b"A")) + self.now[0] = 3000 + link._on_rx(self.continuation(1, b"B")) + self.now[0] = 5099 # 4,999 ms since FIRST: still admissible + link._on_rx(self.continuation(2, b"C", last=True)) + self.assertEqual([b"ABC"], messages) + + self.now[0] = 10000 + link._on_rx(self.first(b"X")) + self.now[0] = 12000 + link._on_rx(self.continuation(1, b"Y")) + self.now[0] = 15000 # exactly 5,000 ms since FIRST: expired + link._on_rx(self.continuation(2, b"Z", last=True)) + self.assertEqual( + [b"ABC"], messages, + "a fragment at elapsed >=5000 ms cannot complete the old run") + self.assertEqual([], recorder.disconnect_calls) + + # The expiry consumed exactly one shared-budget unit. Its remaining + # tails consume none; six explicit units reach seven, the next is eight. + for index in range(3, 12): + link._on_rx(self.continuation(index, b"tail", last=index == 11)) + for _ in range(6): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_deadline_arithmetic_is_safe_across_ticks_wrap(self): + link, recorder, messages = self.new_link() + + self.now[0] = TICKS_PERIOD - 100 + link._on_rx(self.first(b"A")) + self.now[0] = 2000 + link._on_rx(self.continuation(1, b"B")) + self.now[0] = 4899 # 4,999 ms after FIRST across the wrap + link._on_rx(self.continuation(2, b"C", last=True)) + self.assertEqual([b"ABC"], messages) + + self.now[0] = TICKS_PERIOD - 100 + link._on_rx(self.first(b"X")) + self.now[0] = 2000 + link._on_rx(self.continuation(1, b"Y")) + self.now[0] = 4900 # exactly 5,000 ms across the wrap: expired + link._on_rx(self.continuation(2, b"Z", last=True)) + self.assertEqual([b"ABC"], messages) + self.assertEqual([], recorder.disconnect_calls) + + for _ in range(6): + self.assertTrue(self.record_violation(link)) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_gap_and_all_discarded_tails_count_as_one_logical_violation(self): + link, recorder, messages = self.new_link() + link._on_rx(self.first(b"prefix")) + link._on_rx(self.continuation(2, b"gap", last=True)) # expected index 1 + + # These are tails of the already-discarded run, not fresh violations. + for index in range(3, 40): + link._on_rx(self.continuation(index, b"tail", last=index == 39)) + self.assertEqual([], messages) + + for _ in range(6): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual( + [11], recorder.disconnect_calls, + "the eighth logical violation must request one exact disconnect") + + self.assertFalse(self.record_violation(link)) + self.assertEqual( + [11], recorder.disconnect_calls, + "closed-session repeats cannot request disconnect twice") + link._on_rx(self.first(b"closed", last=True)) + self.assertEqual([], messages, "eighth violation closes RX admission first") + + def test_orphan_run_and_empty_ble_write_have_bounded_accounting(self): + link, recorder, messages = self.new_link() + + # One empty GATT write has no FRAG_HDR and is one malformed input. + link._on_rx(b"") + + # One orphan continuation starts a discarded logical run. Every tail + # through LAST is suppressed; it is not a new violation per packet. + link._on_rx(self.continuation(9, b"orphan")) + for index in range(10, 16): + link._on_rx(self.continuation( + index, b"tail", last=index == 15)) + self.assertEqual([], messages) + + # Empty fragment DATA is legal transport syntax. A complete empty + # message is handed upward and becomes exactly one structural-frame + # violation at the Agent layer, not a second reassembly violation. + link._on_rx(self.first(b"", last=True)) + self.assertEqual([b""], messages) + + # empty write + orphan run consumed exactly two link budget units. + for _ in range(5): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_empty_ble_write_discards_an_incomplete_fragment_run(self): + link, recorder, messages = self.new_link() + + link._on_rx(self.first(b"prefix")) + link._on_rx(b"") + link._on_rx(self.continuation(1, b"stale", last=True)) + + self.assertEqual( + [], messages, + "a malformed empty GATT write must terminate, not merely debit, " + "the fragment run that preceded it", + ) + # The empty write is one logical violation. Its now-discarded tail is + # suppressed, so six explicit units reach seven and the next reaches + # the exact disconnect bound of eight. + for _ in range(6): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_oversize_is_one_violation_and_all_tails_are_suppressed(self): + link, recorder, messages = self.new_link() + oversize_heads = [] + link.on_oversize(lambda head: oversize_heads.append(bytes(head))) + + safe_header = bytes((1, 1, 0x20, 0x2A, 0, 0)) + body = safe_header + (b"X" * (RX_MAX + 1 - len(safe_header))) + offset = 0 + index = 0 + while offset < len(body): + chunk = body[offset:offset + 243] + offset += len(chunk) + link._on_rx(bytes(((FIRST if index == 0 else 0) | index,)) + chunk) + index = (index + 1) % 64 + + # Tails from the already discarded run, including LAST, stay silent. + for tail_index in range(index, index + 5): + link._on_rx(self.continuation( + tail_index % 64, b"tail", last=tail_index == index + 4)) + + self.assertEqual([], messages) + self.assertEqual([body[:8]], oversize_heads) + for _ in range(6): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_first_replacement_is_legal_and_does_not_consume_budget(self): + link, recorder, messages = self.new_link() + for value in range(16): + link._on_rx(self.first(bytes((value,)))) + link._on_rx(self.first(b"fresh", last=True)) + self.assertEqual([b"fresh"], messages) + + for _ in range(7): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([11], recorder.disconnect_calls) + + def test_disconnect_reconnect_resets_budget_and_rejects_stale_token(self): + link, recorder, _ = self.new_link(conn=11) + old_token = link.session_token() + self.assertTrue(self.record_violation(link, old_token)) + + link._irq(2, (11, 0, b"\xaa" * 6)) # rp2 CENTRAL_DISCONNECT + link._irq(1, (12, 0, b"\xbb" * 6)) # successor connection + self.assertEqual((12, 2), link.session_token()) + self.assertFalse( + self.record_violation(link, old_token), + "stale session tokens cannot debit or terminate a successor") + + for _ in range(7): + self.assertTrue(self.record_violation(link)) + self.assertEqual([], recorder.disconnect_calls) + self.assertFalse(self.record_violation(link)) + self.assertEqual([12], recorder.disconnect_calls) + + def test_accepted_soft_closing_suppresses_fragment_violation_debits(self): + link, recorder, messages = self.new_link(conn=21) + for _ in range(7): + self.assertTrue(self.record_violation(link)) + + begin_closing = getattr(link, "begin_command_closing", None) + self.assertIsNotNone( + begin_closing, + "BleLink needs an exact-session accepted-SOFT closing seam so " + "fragment faults cannot bypass Agent-level admission precedence", + ) + self.assertTrue(begin_closing(link.session_token())) + + # Each input would normally consume the eighth unit. During the + # accepted reboot grace it is classified/dropped without accounting. + link._on_rx(b"") + link._on_rx(self.continuation(9, b"orphan", last=True)) + self.assertTrue(self.record_violation(link)) + + self.assertEqual([], messages) + self.assertEqual( + [], recorder.disconnect_calls, + "accepted-SOFT closing faults must not trigger budget termination", + ) + self.assertEqual( + 7, link._violation_count, + "closing classification must leave the pre-existing budget intact", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_release_evidence_lifecycle.py b/tests/firmware_tests/host/test_v061_release_evidence_lifecycle.py new file mode 100644 index 00000000..3db1e83c --- /dev/null +++ b/tests/firmware_tests/host/test_v061_release_evidence_lifecycle.py @@ -0,0 +1,1002 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED integration contract for v0.6.1 private hardening evidence. + +The tests in this file deliberately exercise the production release entry +points. Synthetic files are used only as fixtures; they are not physical HIL +evidence and cannot qualify a release. +""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import inspect +import json +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest +from unittest import mock + +import test_release_finalization as finalization_fixture +import test_release_v060_promotion_lifecycle as lifecycle_fixture +import test_v061_hardening_release_gate as hardening_fixture + + +RELEASE = lifecycle_fixture.RELEASE +RELEASE_LOAD_ERROR = lifecycle_fixture.RELEASE_LOAD_ERROR +REPO_ROOT = Path(__file__).resolve().parents[3] +PROFILE_ORDER = tuple(lifecycle_fixture.V060_PROFILES) +HARDENING_PARAMETER = "v061_hardening_result" +FINALIZER_PARAMETER = "v061_hardening_result_paths" + + +def has_parameter(function: object, name: str) -> bool: + return callable(function) and name in inspect.signature(function).parameters + + +HAVE_RELEASE = RELEASE is not None +HAVE_HARDENING_GATE = hardening_fixture.HAVE_GATE +HAVE_COMPLETION_HARDENING = HAVE_RELEASE and has_parameter( + getattr(RELEASE, "create_hil_completion_fragment", None), + HARDENING_PARAMETER, +) +HAVE_ASSEMBLY_HARDENING = HAVE_RELEASE and has_parameter( + getattr(RELEASE, "_validate_v5_completion_fragment", None), + "v061_hardening_summary", +) +HAVE_FINALIZER_HARDENING = HAVE_RELEASE and has_parameter( + getattr(RELEASE, "finalize_public_bundle", None), + FINALIZER_PARAMETER, +) +HAVE_RESULT_SET_VALIDATOR = HAVE_RELEASE and callable( + getattr(RELEASE, "_validate_v061_hardening_result_set", None) +) + + +def sha256_path(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write_json(path: Path, value: object, *, private: bool = False) -> None: + path.write_bytes(lifecycle_fixture.canonical_json_bytes(value)) + if private: + path.chmod(0o600) + + +def release_license_replay(fixture: object) -> contextlib.ExitStack: + """Patch the release module exercised by this suite's direct calls.""" + + stack = contextlib.ExitStack() + stack.enter_context( + mock.patch.object( + RELEASE, + "_audit_load_tool_lock", + return_value=fixture.license_fixture.tool_lock, + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE, + "_audit_observe_rp2_license_inputs", + return_value=copy.deepcopy( + fixture.license_fixture.rp2_observation + ), + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE, + "_audit_verify_v060_esp_semantic_replay", + return_value=None, + ) + ) + return stack + + +def git_head(root: Path = REPO_ROOT) -> str: + return subprocess.run( + ["git", "-C", os.fspath(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def hardening_summary(private_result: Path) -> dict[str, object]: + return { + "measurement_contract": "v061-hardening-seven-scenario-v1", + "scenario_order": list(hardening_fixture.SCENARIO_ORDER), + "scenarios": { + name: "passed" for name in hardening_fixture.SCENARIO_ORDER + }, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in hardening_fixture.WORKSPACE_ORDER + }, + "private_result_sha256": sha256_path(private_result), + } + + +class V061ReleaseLifecycleSeamTests(unittest.TestCase): + def test_completion_api_accepts_one_profile_exact_hardening_result(self): + self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) + function = getattr(RELEASE, "create_hil_completion_fragment", None) + self.assertTrue(callable(function)) + if callable(function): + self.assertIn( + HARDENING_PARAMETER, + inspect.signature(function).parameters, + "[red] v0.6.1 completion cannot yet consume its private result", + ) + + def test_finalizer_api_accepts_the_exact_five_result_set(self): + self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) + function = getattr(RELEASE, "finalize_public_bundle", None) + self.assertTrue(callable(function)) + if callable(function): + self.assertIn( + FINALIZER_PARAMETER, + inspect.signature(function).parameters, + "[red] v0.6.1 finalization cannot yet reopen five results", + ) + + def test_assembler_uses_the_version_routed_completion_authority(self): + self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) + validator = getattr(RELEASE, "_validate_v5_completion_fragment", None) + self.assertTrue(callable(validator)) + if callable(validator): + self.assertIn( + "v061_hardening_summary", + inspect.signature(validator).parameters, + "[red] report assembly cannot yet admit hardening summaries", + ) + + def test_finalizer_has_one_shared_result_set_authority(self): + self.assertIsNotNone(RELEASE, RELEASE_LOAD_ERROR) + validator = getattr(RELEASE, "_validate_v061_hardening_result_set", None) + self.assertTrue( + callable(validator), + "[red] v0.6.1 lacks a shared five-result finalization authority", + ) + + +class CompletionFixture: + def __init__(self, firmware_version: str) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-completion-lifecycle-" + ) + self.root = Path(self.temporary.name).resolve() + self.candidate = self.root / "candidate" + self.candidate.mkdir(mode=0o700) + self.profile_id = "esp32-4mb" + self.firmware_version = firmware_version + self.source_commit = git_head() + self.artifact = self.candidate / self.profile_id / "firmware.bin" + self.artifact.parent.mkdir(mode=0o700) + self.artifact.write_bytes(b"synthetic candidate install bytes\n") + self.install_sha256 = sha256_path(self.artifact) + + self.pending = lifecycle_fixture.pending_v5_payload(firmware_version) + for record in self.pending["records"]: + record["source_commit"] = self.source_commit + if record["profile_id"] == self.profile_id: + record["install_sha256"] = self.install_sha256 + (self.candidate / "release.json").write_bytes( + lifecycle_fixture.canonical_json_bytes( + { + "fixture": "candidate-bound-release", + "firmware_version": firmware_version, + } + ) + ) + (self.candidate / "HIL_REPORT.md").write_text( + lifecycle_fixture.hil_report(self.pending), + encoding="utf-8", + ) + self.candidate_release_sha256 = sha256_path( + self.candidate / "release.json" + ) + + profiles = copy.deepcopy(lifecycle_fixture.lifecycle_fixture.pending_profiles()) + for profile in profiles: + if profile["id"] == self.profile_id: + profile["install"]["sha256"] = self.install_sha256 + self.release = { + "identity": { + "version": firmware_version, + "tag": "firmware-v%s" % firmware_version, + }, + "provenance": { + "pyble": {"commit": self.source_commit, "clean": True} + }, + "profiles": profiles, + } + + self.operator = self.root / "operator.json" + self.observation = self.root / "observation.json" + write_json( + self.operator, + lifecycle_fixture.operator_input(self.profile_id), + private=True, + ) + write_json( + self.observation, + {"fixture": "fresh-physical-observation"}, + private=True, + ) + self.output = self.root / "completion.json" + self.hardening_result = self.root / "hardening-result.json" + if firmware_version == "0.6.1": + self._write_valid_hardening_result() + + def _write_valid_hardening_result(self) -> None: + value = hardening_fixture.valid_result(profile_id=self.profile_id) + value["source_commit"] = self.source_commit + value["candidate_release_json_sha256"] = ( + self.candidate_release_sha256 + ) + value["install_sha256"] = self.install_sha256 + value["qualification_source_commit"] = git_head() + value["qualification_executable_sha256"] = sha256_path( + hardening_fixture.BENCH_PATH + ) + hardening_fixture.attach_result_workspace( + self.hardening_result, value, self.artifact.read_bytes()) + self.hardening_value = value + self.hardening_result.write_bytes( + hardening_fixture.canonical_json_bytes(value) + ) + self.hardening_result.chmod(0o600) + hardening_fixture.GATE.validate_result_file( + self.hardening_result, + artifact_path=self.artifact, + expected_profile_id=self.profile_id, + expected_target=hardening_fixture.PROFILE_TARGETS[self.profile_id], + expected_version="0.6.1", + expected_source_commit=self.source_commit, + candidate_release_json_sha256=self.candidate_release_sha256, + expected_qualification_source_commit=git_head(), + expected_qualification_executable_sha256=sha256_path( + hardening_fixture.BENCH_PATH + ), + ) + + def close(self) -> None: + self.temporary.cleanup() + + def create( + self, + *, + hardening_result: Path | None, + output: Path | None = None, + ) -> Path: + with mock.patch.object( + RELEASE, + "validate_bundle", + return_value=copy.deepcopy(self.release), + ), mock.patch.object( + RELEASE, + "_validate_qualification_observation", + side_effect=lambda value, *_args, **_kwargs: value, + ): + return Path( + RELEASE.create_hil_completion_fragment( + candidate_dir=self.candidate, + profile_id=self.profile_id, + operator_input_path=self.operator, + oi1_observation_path=self.observation, + output_path=self.output if output is None else output, + qualification_repo_root=REPO_ROOT, + profile_qualification_result=None, + v061_hardening_result=hardening_result, + ) + ) + + +@unittest.skipUnless( + HAVE_COMPLETION_HARDENING and HAVE_HARDENING_GATE, + "[red] v0.6.1 completion hardening is not implemented", +) +class V061CompletionLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = CompletionFixture("0.6.1") + + def tearDown(self) -> None: + self.fixture.close() + + def assert_rejected_without_output(self, result: Path | None) -> None: + with self.assertRaises(RELEASE.ReleaseError): + self.fixture.create(hardening_result=result) + self.assertFalse(self.fixture.output.exists()) + + def test_actual_writer_derives_only_the_validated_public_summary(self): + candidate_before = { + path.relative_to(self.fixture.candidate).as_posix(): path.read_bytes() + for path in self.fixture.candidate.rglob("*") + if path.is_file() + } + result_before = self.fixture.hardening_result.read_bytes() + + self.assertEqual( + self.fixture.create( + hardening_result=self.fixture.hardening_result + ), + self.fixture.output, + ) + value = json.loads(self.fixture.output.read_text(encoding="utf-8")) + expected = lifecycle_fixture.completion_fragment( + self.fixture.profile_id + ) + expected["oi1_observation"] = { + "fixture": "fresh-physical-observation" + } + expected["checks"]["v061_hardening"] = "passed" + expected["v061_hardening"] = hardening_summary( + self.fixture.hardening_result + ) + self.assertEqual(value, expected) + self.assertEqual( + self.fixture.output.read_bytes(), + lifecycle_fixture.canonical_json_bytes(value), + ) + self.assertEqual(stat.S_IMODE(self.fixture.output.stat().st_mode), 0o600) + self.assertEqual(self.fixture.output.stat().st_nlink, 1) + self.assertEqual(self.fixture.hardening_result.read_bytes(), result_before) + self.assertEqual( + { + path.relative_to(self.fixture.candidate).as_posix(): path.read_bytes() + for path in self.fixture.candidate.rglob("*") + if path.is_file() + }, + candidate_before, + ) + + def test_missing_wrong_noncanonical_or_symlink_result_mints_nothing(self): + self.assert_rejected_without_output(None) + + wrong = copy.deepcopy(self.fixture.hardening_value) + wrong["profile_id"] = "esp32-s3-n16r8" + wrong["target"] = hardening_fixture.PROFILE_TARGETS[ + "esp32-s3-n16r8" + ] + self.fixture.hardening_result.write_bytes( + hardening_fixture.canonical_json_bytes(wrong) + ) + self.fixture.hardening_result.chmod(0o600) + self.assert_rejected_without_output(self.fixture.hardening_result) + + self.fixture._write_valid_hardening_result() + self.fixture.hardening_result.write_text( + json.dumps(self.fixture.hardening_value) + "\n", + encoding="utf-8", + ) + self.fixture.hardening_result.chmod(0o600) + self.assert_rejected_without_output(self.fixture.hardening_result) + + self.fixture._write_valid_hardening_result() + target = self.fixture.root / "hardening-target.json" + self.fixture.hardening_result.rename(target) + self.fixture.hardening_result.symlink_to(target) + self.assert_rejected_without_output(self.fixture.hardening_result) + + def test_preexisting_output_is_never_replaced(self): + owner_bytes = b"operator-owned output\n" + self.fixture.output.write_bytes(owner_bytes) + with self.assertRaises(RELEASE.ReleaseError): + self.fixture.create( + hardening_result=self.fixture.hardening_result + ) + self.assertEqual(self.fixture.output.read_bytes(), owner_bytes) + + +@unittest.skipUnless( + HAVE_COMPLETION_HARDENING, + "[red] version-routed completion hardening is not implemented", +) +class V060CompletionCompatibilityTests(unittest.TestCase): + def test_historical_success_is_unchanged_and_rejects_a_supplied_result(self): + fixture = CompletionFixture("0.6.0") + try: + self.assertEqual( + fixture.create(hardening_result=None), + fixture.output, + ) + value = json.loads(fixture.output.read_text(encoding="utf-8")) + expected = lifecycle_fixture.completion_fragment( + fixture.profile_id + ) + expected["oi1_observation"] = { + "fixture": "fresh-physical-observation" + } + self.assertEqual( + value, + expected, + ) + self.assertNotIn("v061_hardening", value) + self.assertNotIn("v061_hardening", value["checks"]) + + fixture.output.unlink() + supplied = fixture.root / "unexpected-hardening.json" + supplied.write_bytes(b"unexpected v0.6.1 evidence\n") + supplied.chmod(0o600) + with self.assertRaises(RELEASE.ReleaseError): + fixture.create(hardening_result=supplied) + self.assertFalse(fixture.output.exists()) + finally: + fixture.close() + + +def synthetic_summary(index: int) -> dict[str, object]: + return { + "measurement_contract": "v061-hardening-seven-scenario-v1", + "scenario_order": list(hardening_fixture.SCENARIO_ORDER), + "scenarios": { + name: "passed" for name in hardening_fixture.SCENARIO_ORDER + }, + "sequential_runs": 50, + "workspace_provisioning": { + name: "passed" for name in hardening_fixture.WORKSPACE_ORDER + }, + "private_result_sha256": "%064x" % (index + 1), + } + + +@unittest.skipUnless( + HAVE_ASSEMBLY_HARDENING, + "[red] v0.6.1 hardening report assembly is not implemented", +) +class V061AssemblyLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-assembly-lifecycle-" + ) + self.root = Path(self.temporary.name).resolve() + self.candidate = self.root / "candidate" + self.candidate.mkdir(mode=0o700) + self.pending = lifecycle_fixture.pending_v5_payload("0.6.1") + (self.candidate / "release.json").write_bytes(b"{}\n") + (self.candidate / "HIL_REPORT.md").write_text( + lifecycle_fixture.hil_report(self.pending), + encoding="utf-8", + ) + self.release = { + "identity": {"version": "0.6.1", "tag": "firmware-v0.6.1"}, + "provenance": {"pyble": {"commit": "1" * 40}}, + "profiles": lifecycle_fixture.lifecycle_fixture.pending_profiles(), + } + self.fragments: list[Path] = [] + for index, profile_id in enumerate(PROFILE_ORDER): + value = lifecycle_fixture.completion_fragment(profile_id) + value["checks"]["v061_hardening"] = "passed" + value["v061_hardening"] = synthetic_summary(index) + path = self.root / (profile_id + "-completion.json") + write_json(path, value, private=True) + self.fragments.append(path) + self.output = self.root / "completed-HIL_REPORT.md" + + def tearDown(self) -> None: + self.temporary.cleanup() + + def assemble(self, fragments: list[Path] | None = None) -> Path: + with mock.patch.object( + RELEASE, + "validate_bundle", + return_value=copy.deepcopy(self.release), + ), mock.patch.object( + RELEASE, + "_validate_qualification_observation", + side_effect=lambda value, *_args, **_kwargs: value, + ), mock.patch.object(RELEASE, "_validate_hil", return_value=None): + return Path( + RELEASE.assemble_completed_hil_report( + candidate_dir=self.candidate, + profile_evidence_paths=( + list(reversed(self.fragments)) + if fragments is None + else fragments + ), + output_path=self.output, + qualification_repo_root=self.root, + ) + ) + + def test_actual_assembler_preserves_five_distinct_exact_summaries(self): + candidate_before = { + path.relative_to(self.candidate).as_posix(): path.read_bytes() + for path in self.candidate.rglob("*") + if path.is_file() + } + fragment_before = [path.read_bytes() for path in self.fragments] + + self.assertEqual(self.assemble(), self.output) + completed = RELEASE._parse_hil_report( + self.output.read_text(encoding="utf-8") + ) + self.assertEqual( + [record["profile_id"] for record in completed["records"]], + list(PROFILE_ORDER), + ) + self.assertEqual( + [record["v061_hardening"] for record in completed["records"]], + [synthetic_summary(index) for index in range(len(PROFILE_ORDER))], + ) + self.assertEqual( + { + record["v061_hardening"]["private_result_sha256"] + for record in completed["records"] + }, + {"%064x" % (index + 1) for index in range(len(PROFILE_ORDER))}, + ) + self.assertTrue( + all( + record["checks"]["v061_hardening"] == "passed" + for record in completed["records"] + ) + ) + self.assertEqual( + { + path.relative_to(self.candidate).as_posix(): path.read_bytes() + for path in self.candidate.rglob("*") + if path.is_file() + }, + candidate_before, + ) + self.assertEqual( + [path.read_bytes() for path in self.fragments], fragment_before + ) + + def test_duplicate_summary_or_preexisting_output_is_rejected_atomically(self): + duplicate = json.loads(self.fragments[1].read_text(encoding="utf-8")) + duplicate["v061_hardening"]["private_result_sha256"] = ( + synthetic_summary(0)["private_result_sha256"] + ) + replacement = self.root / "duplicate-completion.json" + write_json(replacement, duplicate, private=True) + with self.assertRaises(RELEASE.ReleaseError): + self.assemble([self.fragments[0], replacement, *self.fragments[2:]]) + self.assertFalse(self.output.exists()) + + owner_bytes = b"owner-completed-report\n" + self.output.write_bytes(owner_bytes) + with self.assertRaises(RELEASE.ReleaseError): + self.assemble() + self.assertEqual(self.output.read_bytes(), owner_bytes) + + +class SyntheticV061FinalizationFixture: + """Small copy-on-write fixture with real hardening-result validation.""" + + def __init__(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-finalization-lifecycle-" + ) + self.root = Path(self.temporary.name).resolve() + self.candidate = self.root / "candidate" + self.candidate.mkdir(mode=0o700) + self.source_commit = git_head() + self.pending = lifecycle_fixture.pending_v5_payload("0.6.1") + self.artifacts: dict[str, Path] = {} + self.install_sha256: dict[str, str] = {} + for profile_id in PROFILE_ORDER: + filename = ( + "firmware.uf2" if profile_id == "rpi-pico2-w" else "firmware.bin" + ) + artifact = self.candidate / profile_id / filename + artifact.parent.mkdir(mode=0o700) + artifact.write_bytes( + ("synthetic %s candidate\n" % profile_id).encode("ascii") + ) + self.artifacts[profile_id] = artifact + self.install_sha256[profile_id] = sha256_path(artifact) + for record in self.pending["records"]: + profile_id = record["profile_id"] + record["source_commit"] = self.source_commit + record["install_sha256"] = self.install_sha256[profile_id] + + self.pending_report = self.candidate / "HIL_REPORT.md" + self.pending_report.write_text( + lifecycle_fixture.hil_report(self.pending), encoding="utf-8" + ) + profiles = [] + for profile_id in PROFILE_ORDER: + artifact = self.artifacts[profile_id] + profiles.append( + { + "id": profile_id, + "hil_status": "pending", + "install": { + "path": artifact.relative_to(self.candidate).as_posix(), + "size": artifact.stat().st_size, + "sha256": sha256_path(artifact), + }, + } + ) + self.release = { + "identity": {"version": "0.6.1", "tag": "firmware-v0.6.1"}, + "provenance": { + "pyble": {"commit": self.source_commit, "clean": True} + }, + "profiles": profiles, + "documents": { + "hil_report": RELEASE._artifact( + self.pending_report, "HIL_REPORT.md" + ) + }, + } + self.release_path = self.candidate / "release.json" + write_json(self.release_path, self.release) + self.candidate_release_sha256 = sha256_path(self.release_path) + + self.hardening_results: list[Path] = [] + self.summaries: list[dict[str, object]] = [] + qualification_commit = git_head() + qualification_sha256 = sha256_path(hardening_fixture.BENCH_PATH) + for index, profile_id in enumerate(PROFILE_ORDER): + value = hardening_fixture.valid_result(profile_id=profile_id) + value["source_commit"] = self.source_commit + value["candidate_release_json_sha256"] = ( + self.candidate_release_sha256 + ) + value["install_sha256"] = self.install_sha256[profile_id] + value["qualification_source_commit"] = qualification_commit + value["qualification_executable_sha256"] = qualification_sha256 + path = self.root / ("%02d-%s-hardening.json" % (index, profile_id)) + hardening_fixture.attach_result_workspace( + path, value, self.artifacts[profile_id].read_bytes()) + path.write_bytes(hardening_fixture.canonical_json_bytes(value)) + path.chmod(0o600) + summary = hardening_fixture.GATE.validate_result_file( + path, + artifact_path=self.artifacts[profile_id], + expected_profile_id=profile_id, + expected_target=hardening_fixture.PROFILE_TARGETS[profile_id], + expected_version="0.6.1", + expected_source_commit=self.source_commit, + candidate_release_json_sha256=self.candidate_release_sha256, + expected_qualification_source_commit=qualification_commit, + expected_qualification_executable_sha256=qualification_sha256, + ) + self.hardening_results.append(path) + self.summaries.append(summary) + + completed = copy.deepcopy(self.pending) + completed["candidate_release_json_sha256"] = ( + self.candidate_release_sha256 + ) + for index, record in enumerate(completed["records"]): + fragment = lifecycle_fixture.completion_fragment( + record["profile_id"] + ) + record["status"] = "passed" + for key, value in fragment.items(): + if key != "profile_id": + record[key] = copy.deepcopy(value) + record["checks"] = { + name: "passed" for name in lifecycle_fixture.V5_ALL_CHECKS + } + record["checks"]["v061_hardening"] = "passed" + record["v061_hardening"] = copy.deepcopy(self.summaries[index]) + self.completed = completed + self.completed_report = self.root / "completed-HIL_REPORT.md" + self.completed_report.write_text( + lifecycle_fixture.hil_report(completed), encoding="utf-8" + ) + RELEASE._write_sha256sums(self.candidate) + + self.license_evidence = self.root / "license-evidence" + self.license_build = self.root / "license-build" + self.license_evidence.mkdir(mode=0o700) + self.license_build.mkdir(mode=0o700) + self.lcd_result = self.root / "lcd-result.json" + self.c3_result = self.root / "c3-result.json" + self.pico_result = self.root / "pico-result.json" + for path in (self.lcd_result, self.c3_result, self.pico_result): + path.write_bytes(b"synthetic private gate result\n") + path.chmod(0o600) + + def close(self) -> None: + self.temporary.cleanup() + + @property + def bundle_files(self) -> tuple[str, ...]: + return tuple( + sorted( + path.relative_to(self.candidate).as_posix() + for path in self.candidate.rglob("*") + if path.is_file() + ) + ) + + def _validate_bundle(self, bundle: Path, *, public: bool = False, **_kwargs): + if public: + return json.loads( + (Path(bundle) / "release.json").read_text(encoding="utf-8") + ) + return copy.deepcopy(self.release) + + def runtime_patches(self) -> contextlib.ExitStack: + stack = contextlib.ExitStack() + stack.enter_context( + mock.patch.object( + RELEASE, + "_expected_bundle_files", + return_value=list(self.bundle_files), + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE, + "validate_bundle", + side_effect=self._validate_bundle, + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE, "_audit_verify_release_evidence", return_value=None + ) + ) + stack.enter_context( + mock.patch.object(RELEASE, "_require_checkout_clean", return_value=None) + ) + stack.enter_context( + mock.patch.object( + RELEASE._WAVESHARE_LCD147B_GATE, + "validate_result_file", + return_value={"fixture": "waveshare-private-summary"}, + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE, "_validate_lcd_report_privacy", return_value=None + ) + ) + stack.enter_context( + mock.patch.object(RELEASE, "_validate_lcd_gate_source", return_value=None) + ) + stack.enter_context( + mock.patch.object( + RELEASE, + "_lcd_qualification_snapshot", + return_value=("fixture-lcd-snapshot",), + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE._V060_PROFILE_GATE, + "private_result_snapshot", + side_effect=lambda path: ( + os.fspath(path), + sha256_path(Path(path)), + ), + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE._V060_PROFILE_GATE, + "post_oi_nvs_evidence_paths", + return_value={ + "post_oi_nvs_receipt_path": self.root / "unused-receipt", + "post_oi_nvs_slice_path": self.root / "unused-slice", + "post_oi_nvs_acquisition_log_path": self.root / "unused-log", + }, + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE._V060_PROFILE_GATE, + "validate_c3_result_for_observation", + return_value={ + "profile_id": "esp32-c3-4mb", + "gates": lifecycle_fixture.profile_gates("esp32-c3-4mb"), + }, + ) + ) + stack.enter_context( + mock.patch.object( + RELEASE._V060_PROFILE_GATE, + "validate_result_file", + return_value={ + "profile_id": "rpi-pico2-w", + "gates": lifecycle_fixture.profile_gates("rpi-pico2-w"), + }, + ) + ) + return stack + + def finalize(self, output: Path) -> Path: + with self.runtime_patches(): + return Path( + RELEASE.finalize_public_bundle( + candidate_dir=self.candidate, + completed_hil_report=self.completed_report, + output_dir=output, + candidate_release_json_sha256=( + self.candidate_release_sha256 + ), + license_evidence_dir=self.license_evidence, + license_build_root=self.license_build, + repo_root=REPO_ROOT, + waveshare_lcd147b_qualification_result=self.lcd_result, + esp32_c3_qualification_result=self.c3_result, + rpi_pico2_w_qualification_result=self.pico_result, + v061_hardening_result_paths=list(self.hardening_results), + ) + ) + + +@unittest.skipUnless( + HAVE_FINALIZER_HARDENING + and HAVE_RESULT_SET_VALIDATOR + and HAVE_HARDENING_GATE, + "[red] v0.6.1 finalization hardening is not implemented", +) +class V061FinalizationLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = SyntheticV061FinalizationFixture() + + def tearDown(self) -> None: + self.fixture.close() + + def test_actual_finalizer_reopens_all_five_results_before_publication(self): + validator = RELEASE._validate_v061_hardening_result_set + output = self.fixture.root / "public-v0.6.1" + with mock.patch.object( + RELEASE, + "_validate_v061_hardening_result_set", + wraps=validator, + ) as validate: + self.assertEqual(self.fixture.finalize(output), output) + self.assertEqual(validate.call_count, 2) + self.assertTrue(output.is_dir()) + self.assertFalse( + any( + path.name.endswith("-hardening.json") + for path in output.rglob("*") + ) + ) + public_hil = RELEASE._parse_hil_report( + (output / "HIL_REPORT.md").read_text(encoding="utf-8") + ) + self.assertEqual( + [record["v061_hardening"] for record in public_hil["records"]], + self.fixture.summaries, + ) + + def test_late_private_result_mutation_leaves_no_output_or_staging(self): + validator = RELEASE._validate_v061_hardening_result_set + attempts = 0 + + def validate_then_mutate(**kwargs): + nonlocal attempts + attempts += 1 + value = validator(**kwargs) + if attempts == 1: + path = self.fixture.hardening_results[0] + changed = json.loads(path.read_text(encoding="utf-8")) + changed["raw_log_sha256"] = "9" * 64 + path.write_bytes(hardening_fixture.canonical_json_bytes(changed)) + path.chmod(0o600) + return value + + output = self.fixture.root / "late-mutation-public-v0.6.1" + with mock.patch.object( + RELEASE, + "_validate_v061_hardening_result_set", + side_effect=validate_then_mutate, + ), self.assertRaises(RELEASE.ReleaseError): + self.fixture.finalize(output) + self.assertEqual(attempts, 2) + self.assertFalse(output.exists() or output.is_symlink()) + self.assertEqual( + list(self.fixture.root.glob(".%s.*" % output.name)), + [], + ) + + def test_late_workspace_measurement_mutation_leaves_no_publication(self): + validator = RELEASE._validate_v061_hardening_result_set + attempts = 0 + + def validate_then_mutate(**kwargs): + nonlocal attempts + attempts += 1 + summary = validator(**kwargs) + if attempts == 1: + result = self.fixture.hardening_results[0] + value = json.loads(result.read_bytes()) + row = value["workspace_provisioning"][hardening_fixture.WORKSPACE_ORDER[0]] + receipt = result.parent / row["receipt_file"] + raw = receipt.with_name(receipt.stem + "-response.bin") + raw.write_bytes(raw.read_bytes() + b"late raw replacement\n") + raw.chmod(0o600) + return summary + + output = self.fixture.root / "late-workspace-mutation-public-v0.6.1" + with mock.patch.object(RELEASE, "_validate_v061_hardening_result_set", + side_effect=validate_then_mutate), self.assertRaises(RELEASE.ReleaseError): + self.fixture.finalize(output) + self.assertEqual(attempts, 2) + self.assertFalse(output.exists() or output.is_symlink()) + self.assertEqual(list(self.fixture.root.glob(".%s.*" % output.name)), []) + + +@unittest.skipUnless( + HAVE_FINALIZER_HARDENING, + "[red] version-routed finalizer hardening is not implemented", +) +class V060FinalizationCompatibilityTests(unittest.TestCase): + def test_none_preserves_historical_output_and_supplied_paths_are_rejected(self): + fixture = finalization_fixture.FinalizationFixture() + try: + baseline = fixture.license_fixture.root / "historical-baseline" + self.assertEqual(fixture.finalize(baseline), baseline) + + explicit_none = fixture.license_fixture.root / "historical-none" + with release_license_replay(fixture): + result = RELEASE.finalize_public_bundle( + candidate_dir=fixture.candidate, + completed_hil_report=fixture.completed_hil, + output_dir=explicit_none, + candidate_release_json_sha256=( + fixture.candidate_release_sha256 + ), + license_evidence_dir=fixture.license_fixture.evidence, + license_build_root=fixture.license_fixture.build_root, + repo_root=fixture.license_fixture.repo, + waveshare_lcd147b_qualification_result=( + fixture.qualification_result + ), + esp32_c3_qualification_result=( + fixture.c3_qualification_result + ), + rpi_pico2_w_qualification_result=( + fixture.pico_qualification_result + ), + v061_hardening_result_paths=None, + ) + self.assertEqual(Path(result), explicit_none) + self.assertEqual( + finalization_fixture.tree_bytes(explicit_none), + finalization_fixture.tree_bytes(baseline), + ) + + unexpected = fixture.license_fixture.root / "unexpected-v061.json" + unexpected.write_bytes(b"unexpected v0.6.1 result\n") + unexpected.chmod(0o600) + rejected = fixture.license_fixture.root / "historical-rejected" + with release_license_replay(fixture), self.assertRaises( + RELEASE.ReleaseError + ): + RELEASE.finalize_public_bundle( + candidate_dir=fixture.candidate, + completed_hil_report=fixture.completed_hil, + output_dir=rejected, + candidate_release_json_sha256=( + fixture.candidate_release_sha256 + ), + license_evidence_dir=fixture.license_fixture.evidence, + license_build_root=fixture.license_fixture.build_root, + repo_root=fixture.license_fixture.repo, + waveshare_lcd147b_qualification_result=( + fixture.qualification_result + ), + esp32_c3_qualification_result=( + fixture.c3_qualification_result + ), + rpi_pico2_w_qualification_result=( + fixture.pico_qualification_result + ), + v061_hardening_result_paths=[unexpected], + ) + self.assertFalse(rejected.exists() or rejected.is_symlink()) + finally: + fixture.close() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_release_version_routing.py b/tests/firmware_tests/host/test_v061_release_version_routing.py new file mode 100644 index 00000000..28406cde --- /dev/null +++ b/tests/firmware_tests/host/test_v061_release_version_routing.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""RED contracts for candidate-version release-audit routing. + +The current validator checkout may be newer than an immutable release being +replayed. Candidate semantics therefore come from the candidate version, +never implicitly from the validator checkout's ``firmware/versions.lock``. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +from pathlib import Path +import re +import shutil +import sys +import tempfile +import unittest +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[3] +RELEASE_SCRIPT = REPO_ROOT / "firmware/scripts/release_bundle.py" + + +def _load_release_module(): + spec = importlib.util.spec_from_file_location( + "pyble_v061_release_version_routing_subject", + RELEASE_SCRIPT, + ) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load firmware release implementation") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(spec.name, None) + return module + + +RELEASE = _load_release_module() + + +class ToolLockRoutingFixture: + """Materialize one valid tool lock without borrowing version authority.""" + + _BASE_INPUTS = ( + "firmware/licenses/excluded-cves.yaml", + "firmware/licenses/license-policy.json", + "firmware/licenses/rp2-license-policy.json", + ) + + def __init__( + self, + *, + validator_version: str, + include_v061_build_tools: bool, + ) -> None: + self._temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-version-route-" + ) + self.repo = Path(self._temporary.name) / "validator-checkout" + firmware = self.repo / "firmware" + (firmware / "licenses").mkdir(parents=True) + + for relative in self._BASE_INPUTS: + source = REPO_ROOT / relative + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + + versions = (REPO_ROOT / "firmware/versions.lock").read_text( + encoding="utf-8" + ) + versions, replacements = re.subn( + r'(?m)^(agent_version\s*=\s*)"[^"]+"$', + rf'\1"{validator_version}"', + versions, + count=1, + ) + if replacements != 1: + raise AssertionError("versions.lock lacks one agent_version") + (firmware / "versions.lock").write_text(versions, encoding="utf-8") + + tool_lock = (REPO_ROOT / "firmware/release-tools.lock").read_text( + encoding="utf-8" + ) + build_path_prefix = "rp2_build_tools_license_policy_path = " + build_hash_prefix = "rp2_build_tools_license_policy_sha256 = " + if include_v061_build_tools: + if ( + build_path_prefix not in tool_lock + or build_hash_prefix not in tool_lock + ): + raise AssertionError( + "source tool lock lacks the prospective v0.6.1 binding" + ) + policy_bytes = b"synthetic v0.6.1 build-tools policy fixture\n" + policy_path = ( + firmware / "licenses/rp2-build-tools-license-policy.json" + ) + policy_path.write_bytes(policy_bytes) + policy_digest = hashlib.sha256(policy_bytes).hexdigest() + tool_lock, replacements = re.subn( + r'(?m)^(rp2_build_tools_license_policy_sha256\s*=\s*)"[0-9a-f]{64}"$', + rf'\1"{policy_digest}"', + tool_lock, + count=1, + ) + if replacements != 1: + raise AssertionError("tool lock lacks one build-tools digest") + else: + tool_lock = "\n".join( + line + for line in tool_lock.splitlines() + if not line.startswith((build_path_prefix, build_hash_prefix)) + ) + "\n" + (firmware / "release-tools.lock").write_text( + tool_lock, + encoding="utf-8", + ) + + def close(self) -> None: + self._temporary.cleanup() + + +class CandidateVersionToolLockRoutingTests(unittest.TestCase): + def test_v060_replay_never_inherits_v061_picotool_policy_from_validator(self): + fixture = ToolLockRoutingFixture( + validator_version="0.6.1", + include_v061_build_tools=False, + ) + try: + with mock.patch.object( + RELEASE, + "_read_lock", + side_effect=AssertionError( + "candidate routing consulted validator versions.lock" + ), + ) as read_validator_lock: + lock = RELEASE._audit_load_tool_lock( + fixture.repo, + firmware_version="0.6.0", + ) + + read_validator_lock.assert_not_called() + self.assertNotIn( + "rp2_build_tools_license_policy_path", + lock["inputs"], + ) + finally: + fixture.close() + + def test_v061_candidate_selects_picotool_policy_despite_v060_validator_lock(self): + fixture = ToolLockRoutingFixture( + validator_version="0.6.0", + include_v061_build_tools=True, + ) + try: + with mock.patch.object( + RELEASE, + "_read_lock", + side_effect=AssertionError( + "candidate routing consulted validator versions.lock" + ), + ) as read_validator_lock: + lock = RELEASE._audit_load_tool_lock( + fixture.repo, + firmware_version="0.6.1", + ) + + read_validator_lock.assert_not_called() + self.assertEqual( + lock["inputs"]["rp2_build_tools_license_policy_path"], + "firmware/licenses/rp2-build-tools-license-policy.json", + ) + finally: + fixture.close() + + +class CandidateEvidenceReplayRoutingTests(unittest.TestCase): + def test_release_dispatch_forwards_v060_candidate_version_to_esp_replay(self): + evidence = Path("/synthetic/evidence") + build = Path("/synthetic/build") + repo = Path("/synthetic/validator") + bundle = Path("/synthetic/bundle") + release = {"identity": {"version": "0.6.0"}} + + with mock.patch.object( + RELEASE, + "_audit_verify_packaged_build", + return_value=None, + ), mock.patch.object( + RELEASE, + "_audit_verify_release_inventory_evidence", + return_value=None, + ), mock.patch.object( + RELEASE, + "_audit_verify_v060_esp_semantic_replay", + return_value=None, + ) as replay: + RELEASE._audit_verify_release_evidence( + notice="synthetic notice", + evidence_dir=evidence, + build_root=build, + repo_root=repo, + bundle=bundle, + release=release, + ) + + replay.assert_called_once_with( + notice="synthetic notice", + evidence_dir=evidence, + build_root=build, + repo_root=repo, + firmware_version="0.6.0", + ) + + def test_v060_esp_replay_forwards_explicit_version_without_checkout_read(self): + receipt = { + "schema_version": 2, + "notice_sha256": "0" * 64, + "input_sha256": {}, + "executed_artifacts": {}, + "execution_identity": {}, + "identities": [], + "evidence_sha256": {}, + "release_inventory_path": "release-inventory.json", + "release_inventory_sha256": "1" * 64, + } + marker = "\n\nRP2 / Raspberry Pi Pico 2 W\n" + with mock.patch.object( + RELEASE, + "_audit_v060_canonical_json", + return_value=receipt, + ), mock.patch.object( + RELEASE, + "_audit_verify_esp_release_evidence", + return_value=None, + ) as verify_esp, mock.patch.object( + RELEASE, + "_read_lock", + side_effect=AssertionError( + "historical replay consulted validator versions.lock" + ), + ) as read_validator_lock: + RELEASE._audit_verify_v060_esp_semantic_replay( + notice="ESP" + marker + "RP2\n", + evidence_dir=Path("/synthetic/evidence"), + build_root=Path("/synthetic/build"), + repo_root=Path("/synthetic/validator"), + firmware_version="0.6.0", + ) + + read_validator_lock.assert_not_called() + self.assertEqual( + verify_esp.call_args.kwargs["firmware_version"], + "0.6.0", + ) + + +class CompareCliVersionRoutingTests(unittest.TestCase): + def test_compare_cli_routes_repo_and_strict_source_version(self): + with tempfile.TemporaryDirectory( + prefix="pyble-v061-compare-cli-route-" + ) as raw: + root = Path(raw) + left = root / "left" + right = root / "right" + repo = root / "validator-checkout" + left.mkdir() + right.mkdir() + (repo / "firmware").mkdir(parents=True) + shutil.copyfile( + REPO_ROOT / "firmware/versions.lock", + repo / "firmware/versions.lock", + ) + + with mock.patch.object( + RELEASE, + "compare_build_roots", + return_value=None, + ) as compare: + result = RELEASE._main( + [ + "compare", + str(left), + str(right), + "--repo-root", + str(repo), + ] + ) + + self.assertEqual(result, 0) + compare.assert_called_once_with( + left, + right, + repo_root=repo, + firmware_version="0.6.1", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_runner_stdin_hardening.py b/tests/firmware_tests/host/test_v061_runner_stdin_hardening.py new file mode 100644 index 00000000..e9523e7f --- /dev/null +++ b/tests/firmware_tests/host/test_v061_runner_stdin_hardening.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +"""[red] v0.6.1 RUN/stdin isolation contracts. + +This suite deliberately pins the new portable console lifecycle before the +production implementation lands. CONSOLE_INPUT is response-free, so an idle +write cannot report an error; it must instead be discarded. A live run owns +only bytes admitted during that run, and disconnect clears queued bytes without +ending a run that continues to execute. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from unittest import mock + + +HOST_DIR = os.path.dirname(os.path.abspath(__file__)) +if HOST_DIR not in sys.path: + sys.path.insert(0, HOST_DIR) + +import _support # noqa: E402 +import test_pyble_agent as agent_support # noqa: E402 + + +_support._inject_micropython_guards() +CONSOLE = _support.RedReason("pyble_console", owner="v0.6.1-runtime-engineer") + +BIG = 1 << 30 + + +def make_console(testcase): + cls = CONSOLE.attr( + testcase, + "Console", + "v0.6.1 stdin lifecycle requires the portable Console", + ) + return cls( + lambda _stream, _data: None, + lambda: None, + clock=lambda: 0, + tx_capacity=BIG, + tx_refill_per_ms=BIG, + ) + + +def require_method(testcase, obj, name): + testcase.assertTrue( + hasattr(obj, name), + "v0.6.1 stdin lifecycle method Console.{}() is missing".format(name), + ) + return getattr(obj, name) + + +def drain(testcase, console, limit=300): + result = bytearray() + buf = bytearray(1) + for _ in range(limit): + count = console.readinto(buf) + if count is None: + return bytes(result) + testcase.assertEqual(count, 1, "dupterm reads remain exactly 1 or None") + result.append(buf[0]) + testcase.fail("stdin ring did not drain within its fixed bound") + + +class PortableConsoleBoundaryTests(unittest.TestCase): + def test_terminal_clear_reuses_preallocated_ring_under_memory_pressure(self): + console = make_console(self) + console.begin_input() + console.feed_input(b"queued") + module = CONSOLE.obj( + self, "v0.6.1 terminal stdin clear is allocation-free") + + with mock.patch.object( + module, "bytearray", side_effect=MemoryError("injected"), + create=True): + console.end_input() + + self.assertEqual( + drain(self, console), b"", + "an acknowledged STOP/SOFT terminal cut cannot lose its effect to " + "a replacement-ring allocation", + ) + + def test_idle_input_is_discarded_without_a_response(self): + console = make_console(self) + self.assertIsNone(console.feed_input(b"idle")) + self.assertEqual( + drain(self, console), + b"", + "CONSOLE_INPUT while no run owns stdin must be silently discarded", + ) + + def test_begin_activates_and_clears_the_new_run_boundary(self): + console = make_console(self) + console.feed_input(b"stale-idle") + require_method(self, console, "begin_input")() + self.assertEqual(drain(self, console), b"", "RUN admission clears stdin") + console.feed_input(b"new") + self.assertEqual(drain(self, console), b"new") + + def test_end_clears_and_returns_to_idle_discard(self): + console = make_console(self) + require_method(self, console, "begin_input")() + console.feed_input(b"old-run") + require_method(self, console, "end_input")() + self.assertEqual(drain(self, console), b"", "terminal/STOP clears stdin") + console.feed_input(b"after-end") + self.assertEqual(drain(self, console), b"", "ended run cannot accept input") + + def test_disconnect_clear_preserves_a_live_runs_admission(self): + console = make_console(self) + require_method(self, console, "begin_input")() + console.feed_input(b"old-session") + require_method(self, console, "clear_input")() + self.assertEqual(drain(self, console), b"", "disconnect drops queued bytes") + console.feed_input(b"new-session") + self.assertEqual( + drain(self, console), + b"new-session", + "disconnect does not stop a live run; its successor session may feed it", + ) + + def test_overflow_from_run_one_cannot_reach_run_two(self): + console = make_console(self) + require_method(self, console, "begin_input")() + console.feed_input(b"A" * 256) + require_method(self, console, "end_input")() + require_method(self, console, "begin_input")() + self.assertEqual(drain(self, console), b"") + + def test_end_also_invalidates_a_pending_stop_byte(self): + console = make_console(self) + require_method(self, console, "begin_input")() + self.assertTrue(console.inject_stop()) + require_method(self, console, "end_input")() + require_method(self, console, "begin_input")() + self.assertEqual( + drain(self, console), + b"", + "a terminal boundary must not inject the predecessor's STOP into a new run", + ) + + +class PortableAgentBoundaryTests(agent_support.AgentTestBase): + """Exercise the real Agent callbacks, not just the Console primitive.""" + + def rotate_session_at_event_tx(self, link, opcode): + """Install one exact cut between logical EVT creation and link TX.""" + original_send = link.send_message + observed = { + "rotated": False, + "origin": link.session_token(), + "expected_session": None, + } + + def send_after_rotation(msg, on_published=None, expected_session=None): + frame = agent_support.pyble_proto.decode(bytes(msg)) + if (not observed["rotated"] and frame.type == agent_support.EVT + and frame.opcode == opcode): + observed["expected_session"] = expected_session + link.disconnect_cb() + link.session += 1 + link.connect_cb() + observed["rotated"] = True + return original_send( + msg, + on_published=on_published, + expected_session=expected_session, + ) + + link.send_message = send_after_rotation + return observed + + def assert_old_event_was_not_retargeted(self, link, observed, *opcodes): + self.assertTrue( + observed["rotated"], + "the deterministic disconnect/reused-session TX cut did not run", + ) + self.assertEqual( + observed["expected_session"], observed["origin"], + "the logical event must carry its creation session through the " + "single expected_session TX chokepoint", + ) + for opcode in opcodes: + with self.subTest(opcode=opcode): + self.assertEqual( + agent_support.evts(link, opcode=opcode), [], + "an event created for the predecessor session was retargeted " + "to a reused successor session", + ) + + def test_agent_discards_idle_console_input(self): + agent, link = self.new_agent("v0.6.1 idle CONSOLE_INPUT discard") + agent_support.send(self, link, 0x31, b"idle", id_=201) + self.assertEqual(drain(self, agent.console), b"") + self.assertEqual( + agent_support.rsps(link, opcode=0x31, id_=201), + [], + "discard remains fire-and-forget", + ) + + def test_successful_run_admission_opens_fresh_stdin(self): + agent, link = self.new_agent("v0.6.1 RUN response admission opens stdin") + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=202) + self.assertEqual(agent_support.rsps(link, opcode=0x20, id_=202)[0].payload, b"\x00") + agent_support.send(self, link, 0x31, b"live", id_=203) + self.assertEqual(drain(self, agent.console), b"live") + + def test_busy_run_does_not_clear_current_runs_input(self): + agent, link = self.new_agent("v0.6.1 EBUSY RUN leaves stdin unchanged") + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=204) + agent_support.send(self, link, 0x31, b"keep", id_=205) + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=206) + self.assertEqual(agent_support.rsps(link, opcode=0x20, id_=206)[0].payload, b"\x07") + self.assertEqual(drain(self, agent.console), b"keep") + + def test_unpublished_run_response_does_not_open_a_stdin_generation(self): + agent, link = self.new_agent( + "v0.6.1 unpublished RUN response leaves stdin inactive") + generation = agent.console._input_generation + original_send = link.send_message + + def reject_run_response(msg, *args, **kwargs): + frame = agent_support.pyble_proto.decode(msg) + if (frame.type == agent_support.RSP and frame.opcode == 0x20 + and frame.id == 207): + return False + return original_send(msg, *args, **kwargs) + + link.send_message = reject_run_response + try: + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=207) + finally: + link.send_message = original_send + + self.assertEqual( + agent.console._input_generation, + generation, + "a RUN whose response was not published must not begin stdin", + ) + agent_support.send(self, link, 0x31, b"still-idle", id_=208) + self.assertEqual(drain(self, agent.console), b"") + + def test_unpublished_stop_response_preserves_live_stdin_bytes(self): + agent, link = self.new_agent( + "v0.6.1 unpublished STOP response preserves stdin") + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=208) + agent_support.send(self, link, 0x31, b"keep", id_=209) + original_send = link.send_message + + def reject_stop_response(msg, *args, **kwargs): + frame = agent_support.pyble_proto.decode(msg) + if (frame.type == agent_support.RSP and frame.opcode == 0x21 + and frame.id == 210): + return False + return original_send(msg, *args, **kwargs) + + link.send_message = reject_stop_response + try: + agent_support.send(self, link, 0x21, id_=210) + finally: + link.send_message = original_send + + self.assertEqual( + drain(self, agent.console), + b"keep", + "an unacknowledged STOP must not clear or close the live run's stdin", + ) + agent_support.send(self, link, 0x31, b"still-live", id_=211) + self.assertEqual(drain(self, agent.console), b"still-live") + + def test_accepted_stop_clears_and_closes_stdin(self): + agent, link = self.new_agent("v0.6.1 accepted STOP closes stdin") + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=207) + agent_support.send(self, link, 0x31, b"stale", id_=208) + agent_support.send(self, link, 0x21, id_=209) + self.assertEqual(agent_support.rsps(link, opcode=0x21, id_=209)[0].payload, b"\x00") + self.assertEqual(drain(self, agent.console), b"") + agent_support.send(self, link, 0x31, b"late", id_=210) + self.assertEqual(drain(self, agent.console), b"") + + def test_disconnect_clears_but_keeps_pending_run_input_active(self): + agent, link = self.new_agent("v0.6.1 disconnect stdin boundary") + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=211) + agent_support.send(self, link, 0x31, b"old-session", id_=212) + link.disconnect_cb() + self.assertEqual(drain(self, agent.console), b"") + link.session += 1 + link.connect_cb() + agent_support.send( + self, link, agent_support.CMD_OPCODES["HELLO"], + agent_support.HELLO_PAYLOAD, id_=213) + hello = agent_support.rsps( + link, opcode=agent_support.CMD_OPCODES["HELLO"], id_=213) + self.assertEqual(len(hello), 1) + self.assertEqual(hello[0].payload[0], agent_support.OK) + link.sent[:] = [] + agent_support.send(self, link, 0x31, b"new-session", id_=214) + self.assertEqual(drain(self, agent.console), b"new-session") + + def test_natural_terminal_clears_and_closes_stdin(self): + agent, link = self.new_agent("v0.6.1 terminal stdin boundary") + agent_support.send(self, link, 0x20, bytes((1,)) + b"pass", id_=215) + agent_support.send(self, link, 0x31, b"unread", id_=216) + agent.poll() + self.assertEqual(drain(self, agent.console), b"") + agent_support.send(self, link, 0x31, b"after-terminal", id_=217) + self.assertEqual(drain(self, agent.console), b"") + + def test_natural_run_state_keeps_its_creation_session_through_tx(self): + agent, link = self.new_agent( + "v0.6.1 portable RUN_STATE exact-session event ownership") + observed = self.rotate_session_at_event_tx(link, 0x40) + + agent._emit_run_state(2) + + self.assert_old_event_was_not_retargeted(link, observed, 0x40) + + def test_pending_run_snapshots_successor_session_when_running_is_created(self): + agent, link = self.new_agent( + "v0.6.1 RUN_STATE creation is independent of RUN origin") + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=218) + self.assertEqual( + agent_support.rsps(link, opcode=0x20, id_=218)[0].payload, + b"\x00", + ) + + link.disconnect_cb() + link.session += 1 + link.connect_cb() + agent_support.send( + self, + link, + agent_support.CMD_OPCODES["HELLO"], + agent_support.HELLO_PAYLOAD, + id_=219, + ) + self.assertEqual( + agent_support.rsps( + link, + opcode=agent_support.CMD_OPCODES["HELLO"], + id_=219, + )[0].payload[0], + agent_support.OK, + ) + link.sent[:] = [] + + agent.poll() + + self.assertEqual( + agent_support.run_states(link), + [1, 2], + "running and done are created after reconnect and must bind the " + "then-current successor, not the RUN requester's stale token", + ) + + def test_terminal_transition_snapshots_session_that_reconnected_during_run(self): + agent, link = self.new_agent( + "v0.6.1 terminal RUN_STATE binds its own creation session") + + def reconnect_during_execution(_mode, _data): + link.disconnect_cb() + link.session += 1 + link.connect_cb() + agent_support.send( + self, + link, + agent_support.CMD_OPCODES["HELLO"], + agent_support.HELLO_PAYLOAD, + id_=220, + ) + self.assertEqual( + agent_support.rsps( + link, + opcode=agent_support.CMD_OPCODES["HELLO"], + id_=220, + )[0].payload[0], + agent_support.OK, + ) + link.sent[:] = [] + + agent._runner._exec_fn = reconnect_during_execution + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=221) + agent.poll() + + self.assertEqual( + agent_support.run_states(link), + [2], + "the terminal event created after reconnect must bind the live " + "successor even though RUN admission belonged to its predecessor", + ) + + def test_console_chunk_keeps_its_creation_session_through_tx(self): + agent, link = self.new_agent( + "v0.6.1 portable CONSOLE_DATA exact-session event ownership") + agent.console.set_run_active(True) + observed = self.rotate_session_at_event_tx(link, 0x30) + + agent.console.out(0, b"predecessor-console") + + self.assert_old_event_was_not_retargeted(link, observed, 0x30) + + def test_put_ack_keeps_the_mailbox_items_creation_session_through_tx(self): + agent, link = self.new_agent( + "v0.6.1 portable FILE_PUT_ACK exact-session event ownership") + data = b"A" + agent_support.send( + self, + link, + 0x15, + agent_support.p_put_begin( + len(data), agent_support.pyble_proto.crc32(data), + "/session-ack.bin"), + id_=225, + ) + agent.poll() + begin = agent_support.rsps(link, opcode=0x15, id_=225) + self.assertEqual(len(begin), 1) + self.assertEqual(begin[0].payload[0], agent_support.OK) + link.sent[:] = [] + observed = self.rotate_session_at_event_tx(link, 0x41) + + agent_support.send( + self, link, 0x16, b"\x00\x00\x00\x00" + data, id_=226) + agent.poll() + + self.assert_old_event_was_not_retargeted(link, observed, 0x41) + + def test_get_events_keep_the_transfer_creation_session_through_tx(self): + agent, link = self.new_agent( + "v0.6.1 portable FILE_GET exact-session event ownership") + with open(os.path.join(self.root, "session-get.bin"), "wb") as stream: + stream.write(b"GET") + agent_support.send( + self, link, 0x12, + agent_support.p_get_begin(0, "/session-get.bin"), id_=227) + begin = agent_support.rsps(link, opcode=0x12, id_=227) + self.assertEqual(len(begin), 1) + self.assertEqual(begin[0].payload[0], agent_support.OK) + link.sent[:] = [] + observed = self.rotate_session_at_event_tx(link, 0x13) + + agent.poll() + + self.assert_old_event_was_not_retargeted(link, observed, 0x13, 0x14) + + def test_natural_terminal_cannot_clear_or_reclassify_a_successor_run(self): + agent, link = self.new_agent( + "v0.6.1 portable terminal/successor stdin linearization") + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=218) + + original_finished = agent._runner.rsm.on_finished + injected = [False] + + def finish_then_admit_successor(ok): + terminal = original_finished(ok) + if not injected[0]: + injected[0] = True + # A scheduled BTstack callback may run at this exact Python + # bytecode boundary: predecessor state is terminal/admissible, + # but its console cleanup/event publication has not happened. + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=219) + agent_support.send(self, link, 0x31, b"successor", id_=220) + return terminal + + agent._runner.rsm.on_finished = finish_then_admit_successor + agent.poll() + + successor = agent_support.rsps(link, opcode=0x20, id_=219) + self.assertEqual(len(successor), 1) + self.assertEqual(successor[0].payload, b"\x00") + self.assertEqual( + agent_support.run_states(link), [1, 2], + "the predecessor terminal event must retain its captured DONE value " + "even after a successor reserves RUNNING", + ) + self.assertEqual( + drain(self, agent.console), b"successor", + "predecessor terminal cleanup must not erase the successor run's " + "already-admitted stdin", + ) + + def test_stopped_terminal_cannot_clear_a_successor_run(self): + agent, link = self.new_agent( + "v0.6.1 portable stopped-terminal/successor stdin linearization") + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=221) + agent_support.send(self, link, 0x21, id_=222) + + original_stopped = agent._runner.rsm.on_stopped + injected = [False] + + def stop_then_admit_successor(): + terminal = original_stopped() + if not injected[0]: + injected[0] = True + agent_support.send( + self, link, 0x20, bytes((1,)) + b"pass", id_=223) + agent_support.send(self, link, 0x31, b"successor", id_=224) + return terminal + + agent._runner.rsm.on_stopped = stop_then_admit_successor + agent.poll() + + successor = agent_support.rsps(link, opcode=0x20, id_=223) + self.assertEqual(len(successor), 1) + self.assertEqual(successor[0].payload, b"\x00") + self.assertEqual( + agent_support.run_states(link), [0], + "a pre-pickup STOP emits only the captured predecessor IDLE", + ) + self.assertEqual( + drain(self, agent.console), b"successor", + "the predecessor IDLE cleanup must not erase successor stdin", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_workspace_acquisition.py b/tests/firmware_tests/host/test_v061_workspace_acquisition.py new file mode 100644 index 00000000..4ad29f90 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_acquisition.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Workspace acquisition contract; all fixtures are synthetic, never HIL.""" + +from __future__ import annotations + +import copy +import errno +import hashlib +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_v061_hardening_release_gate as fixture + +GATE = fixture.GATE +SERVICE = "7079626c-1ab1-4d50-9e3a-000000000001" +COLLECTOR = fixture.ROOT / "tests/firmware_tests/hil/v061_workspace_acquire.py" +CHALLENGE = "a" * 32 +BOOT_ID = "b" * 32 +ADDRESS = "synthetic-selected-board" +RECOVERY = b"PyBLE workspace recovery is required; reconnect by USB.\r\n" + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def synthetic_binding(profile_id="esp32-4mb"): + if profile_id == "rpi-pico2-w": + import test_v061_workspace_pico as pico_fixture + value = pico_fixture.binding() + else: + import test_v061_workspace_hardware as hardware_fixture + value = hardware_fixture.binding() + value["profile_id"] = profile_id + if profile_id in ("esp32-4mb", "esp32-c3-4mb"): + value["flash"].update(chip="ESP32" if profile_id == "esp32-4mb" else "ESP32-C3", + chip_id=0 if profile_id == "esp32-4mb" else 5, + jedec_id=0x164068, size_bytes=0x400000) + if profile_id == "waveshare-esp32-s3-lcd-147b": + for key in ("application_usb", "loader_usb"): + value[key].update(vid=0x303A, pid=0x1001, product="USB JTAG/serial debug unit") + value["ble_address"] = ADDRESS + return value + + +def boot_observation(kind): + erased = kind == fixture.WORKSPACE_ORDER[0] + return { + "schema_version": 1, + "boot_id": BOOT_ID if erased else "c" * 32, + "challenge": CHALLENGE if erased else "d" * 32, + "mount_attempts": 1, + "format_attempts": int(erased), + "format_completions": int(erased), + "remount_attempts": int(erased), + "remount_completions": int(erased), + "program_calls": 2 if erased else 0, + "erase_calls": 1 if erased else 0, + "workspace_attached": erased, + "recovery_attempts": int(not erased), + "recovery_emissions": int(not erased), + "complete": True, + "fault": False, + "overflow": False, + "media_state": "erased" if erased else "nonblank", + } + + +def acquisition_fixture(directory, kind=fixture.WORKSPACE_ORDER[0], *, + receipt_name="workspace.json", profile_id="esp32-4mb", + validation=None, install=None): + """Fabricate byte-level host fixtures, explicitly not hardware evidence.""" + receipt = directory / receipt_name + erased = kind == fixture.WORKSPACE_ORDER[0] + media = dict(GATE.WORKSPACE_MEDIA[profile_id]) + pre = b"\xff" * media["size"] if erased else b"\x00" * media["size"] + marker = b"synthetic-lfs2" + post = marker + pre[len(marker):] if erased else pre + observation = boot_observation(kind) + challenge, boot_id = observation["challenge"], observation["boot_id"] + binding = synthetic_binding(profile_id) + blocks = media["size"] // media["block_size"] + response = (b"" if erased else RECOVERY) + b"PYBLE_WORKSPACE_OBSERVATION:" + response += json.dumps({ + "observation": observation, + "workspace_probe": [4096, 4096, blocks, blocks-4, blocks-4, 0, 0, 0, 0, 255] if erased else None, + }, separators=(",", ":")).encode() + b"\r\n" + watch = [{"event": "watch-start", "challenge": challenge, "monotonic_ns": 4}] + if erased: + watch.append({"event": "advertisement", "monotonic_ns": 8, + "address": ADDRESS, "local_name": "PyBLE-synthetic", + "service_uuids": [SERVICE]}) + watch.append({"event": "watch-end", "challenge": challenge, + "monotonic_ns": 10_000_000_010, "status": "complete"}) + install = b"synthetic candidate loadable bytes" if install is None else install + validation = fixture.validation_kwargs(profile_id) if validation is None else dict(validation) + validation["expected_install_sha256"] = sha(install) + values = { + "schema_version": 1, + "measurement_contract": "v061-workspace-acquisition-v1", + "observation_kind": kind, + "profile_id": profile_id, + "target": fixture.PROFILE_TARGETS[profile_id], + "firmware_version": "0.6.1", + "source_commit": validation["expected_source_commit"], + "candidate_release_json_sha256": validation["candidate_release_json_sha256"], + "install_sha256": sha(install), + "qualification_source_commit": validation["expected_qualification_source_commit"], + "qualification_executable_sha256": validation["expected_qualification_executable_sha256"], + "collector_sha256": sha(COLLECTOR.read_bytes()) if COLLECTOR.is_file() else "c" * 64, + "challenge": challenge, + "boot_id": boot_id, + "media": media, + "transport": "pble-run" if erased else "usb-repl", + "board_binding": {"device_id": binding["device_id"], "ble_address": ADDRESS}, + "install_readback_sha256": sha(install), + "pre_media_sha256": sha(pre), + "post_media_sha256": sha(post), + "response_sha256": sha(response), + "advertisements_sha256": sha(fixture.canonical_json_lines(watch)), + "measurement_sha256": "d" * 64, + "status": "passed", + } + measurement = [ + {"event": "acquisition-start", "challenge": challenge, "monotonic_ns": 1, + "binding": binding}, + {"event": "install-verified", "device_id": binding["device_id"], + "install_readback_sha256": sha(install), "monotonic_ns": 2}, + {"event": "media-read", "phase": "pre", "offset": media["offset"], + "size": media["size"], "sha256": sha(pre), "monotonic_ns": 3}, + {"event": "boot-start", "device_id": binding["device_id"], "monotonic_ns": 5}, + {"event": "boot-complete", "monotonic_ns": 6}, + {"event": "observation-read", "challenge": challenge, "boot_id": boot_id, + "transport": values["transport"], "response_sha256": sha(response), "monotonic_ns": 9}, + {"event": "media-read", "phase": "post", "offset": media["offset"], + "size": media["size"], "sha256": sha(post), "monotonic_ns": 10_000_000_011}, + {"event": "acquisition-end", "status": "complete", "monotonic_ns": 10_000_000_012}, + ] + values["measurement_sha256"] = sha(fixture.canonical_json_lines(measurement)) + files = { + "install.bin": install, "pre.bin": pre, "post.bin": post, + "response.bin": response, + "advertisements.jsonl": fixture.canonical_json_lines(watch), + "measurement.jsonl": fixture.canonical_json_lines(measurement), + } + for suffix, raw in files.items(): + fixture.private_write(directory / (receipt.stem + "-" + suffix), raw) + fixture.private_write(directory / (receipt.stem + "-acquisition.json"), + fixture.canonical_json_bytes(values)) + return receipt, kind, validation, values + + +class WorkspaceAcquisitionTests(unittest.TestCase): + def validate(self, receipt, kind, validation): + validator = getattr(GATE, "_workspace_acquisition_snapshot", None) + self.assertTrue(callable(validator), "missing physical workspace acquisition validator") + return validator(receipt, kind, validation) + + def mutate(self, directory, receipt, value, key, replacement): + value = copy.deepcopy(value) + value[key] = replacement + fixture.private_write(directory / (receipt.stem + "-acquisition.json"), + fixture.canonical_json_bytes(value)) + + def replace_raw(self, directory, receipt, value, suffix, digest_key, raw): + value[digest_key] = sha(raw) + fixture.private_write(directory / (receipt.stem + "-" + suffix), raw) + if digest_key == "response_sha256": + measurement_path = directory / (receipt.stem + "-measurement.jsonl") + records = [json.loads(line) for line in measurement_path.read_bytes().splitlines()] + records[5]["response_sha256"] = sha(raw) + measurement = fixture.canonical_json_lines(records) + fixture.private_write(measurement_path, measurement) + value["measurement_sha256"] = sha(measurement) + fixture.private_write(directory / (receipt.stem + "-acquisition.json"), + fixture.canonical_json_bytes(value)) + + def test_both_complete_measured_acquisitions_are_admitted(self): + for kind in fixture.WORKSPACE_ORDER: + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temp: + path, kind, validation, value = acquisition_fixture(Path(temp), kind) + digest, snapshots = self.validate(path, kind, validation) + self.assertEqual(digest, sha(fixture.canonical_json_bytes(value))) + self.assertEqual(len(snapshots), 7) + + def test_missing_raw_measurements_are_not_four_line_evidence(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "workspace.json" + fixture.private_write(path.with_name("workspace-raw.jsonl"), + fixture.workspace_raw_log(fixture.WORKSPACE_ORDER[0])) + validator = getattr(GATE, "_workspace_acquisition_snapshot", None) + self.assertTrue(callable(validator), "missing physical workspace acquisition validator") + with self.assertRaises(GATE.QualificationError): + validator(path, fixture.WORKSPACE_ORDER[0], fixture.validation_kwargs()) + + def test_every_raw_sibling_is_reopened_and_byte_bound(self): + for suffix in ("install.bin", "pre.bin", "post.bin", "response.bin", + "advertisements.jsonl", "measurement.jsonl"): + with self.subTest(suffix=suffix), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, _ = acquisition_fixture(directory) + sibling = directory / (path.stem + "-" + suffix) + fixture.private_write(sibling, sibling.read_bytes() + b"corruption") + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_stale_challenge_or_boot_id_fails(self): + for key in ("challenge", "boot_id"): + with self.subTest(key=key), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + self.mutate(directory, path, value, key, "e" * 32) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_wrong_geometry_or_candidate_or_source_fails(self): + mutations = { + "media": {"offset": 0, "size": 0x200000, "block_size": 4096}, + "source_commit": "e" * 40, + "candidate_release_json_sha256": "e" * 64, + "install_sha256": "e" * 64, + "qualification_source_commit": "e" * 40, + "collector_sha256": "e" * 64, + } + for key, replacement in mutations.items(): + with self.subTest(key=key), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + self.mutate(directory, path, value, key, replacement) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_private_acquisition_symlink_and_mode_rejected(self): + for unsafe in ("symlink", "mode"): + with self.subTest(unsafe=unsafe), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, _ = acquisition_fixture(directory) + sibling = directory / "workspace-pre.bin" + if unsafe == "symlink": + moved = directory / "outside.bin" + sibling.rename(moved) + sibling.symlink_to(moved) + else: + sibling.chmod(0o644) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_measured_counters_not_unchanged_media_decide_zero_write(self): + for field in ("program_calls", "erase_calls", "format_attempts", + "format_completions", "remount_attempts", "remount_completions"): + with self.subTest(field=field), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory, fixture.WORKSPACE_ORDER[1]) + observed = boot_observation(kind) + observed[field] = 1 + raw = b"PYBLE_WORKSPACE_OBSERVATION:" + json.dumps({ + "observation": observed, "workspace_probe": None, + }).encode() + b"\n" + self.replace_raw(directory, path, value, "response.bin", "response_sha256", raw) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_incomplete_faulted_overflowed_or_boolean_counter_fails(self): + mutations = {"complete": False, "fault": True, "overflow": True, + "mount_attempts": True, "format_completions": 0, + "remount_completions": 0, "workspace_attached": False, + "media_state": "uninspected"} + for field, replacement in mutations.items(): + with self.subTest(field=field), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + observed = boot_observation(kind) + observed[field] = replacement + raw = b"PYBLE_WORKSPACE_OBSERVATION:" + json.dumps({ + "observation": observed, + "workspace_probe": [4096, 4096, 512, 508, 508, 0, 0, 0, 0, 255], + }).encode() + b"\n" + self.replace_raw(directory, path, value, "response.bin", "response_sha256", raw) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_missing_or_duplicate_response_or_probe_fails(self): + for mutation in ("missing", "duplicate", "no-probe"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + raw = (directory / "workspace-response.bin").read_bytes() + if mutation == "missing": + raw = b"no runtime observation\n" + elif mutation == "duplicate": + raw += raw + else: + raw = b"PYBLE_WORKSPACE_OBSERVATION:" + json.dumps({ + "observation": boot_observation(kind), "workspace_probe": None, + }).encode() + b"\n" + self.replace_raw(directory, path, value, "response.bin", "response_sha256", raw) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_watch_missing_short_interrupted_or_wrong_board_fails(self): + for mutation in ("missing-start", "short", "interrupted", "wrong-board"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + records = [json.loads(line) for line in (directory / "workspace-advertisements.jsonl").read_bytes().splitlines()] + if mutation == "missing-start": + records.pop(0) + elif mutation == "short": + records[-1]["monotonic_ns"] = 100 + elif mutation == "interrupted": + records[-1]["status"] = "interrupted" + else: + records[1]["address"] = "another-board" + self.replace_raw(directory, path, value, "advertisements.jsonl", + "advertisements_sha256", fixture.canonical_json_lines(records)) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_refusal_rejects_one_real_selected_advertisement(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory, fixture.WORKSPACE_ORDER[1]) + records = [json.loads(line) for line in (directory / "workspace-advertisements.jsonl").read_bytes().splitlines()] + records.insert(1, {"event": "advertisement", "monotonic_ns": 8, + "address": ADDRESS, "local_name": "PyBLE-synthetic", + "service_uuids": [SERVICE]}) + self.replace_raw(directory, path, value, "advertisements.jsonl", + "advertisements_sha256", fixture.canonical_json_lines(records)) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_native_usb_refusal_does_not_invent_unavailable_boot_text(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory, fixture.WORKSPACE_ORDER[1]) + raw = (directory / "workspace-response.bin").read_bytes().removeprefix(RECOVERY) + self.replace_raw(directory, path, value, "response.bin", "response_sha256", raw) + self.validate(path, kind, validation) + + def test_reordered_or_nonce_mismatched_measurement_fails(self): + for mutation in ("reordered", "nonce", "boot-after-watch"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + records = [json.loads(line) for line in (directory / "workspace-measurement.jsonl").read_bytes().splitlines()] + if mutation == "reordered": + records[2], records[3] = records[3], records[2] + elif mutation == "nonce": + records[5]["challenge"] = "e" * 32 + else: + records[3]["monotonic_ns"] = 10_000_000_100 + self.replace_raw(directory, path, value, "measurement.jsonl", + "measurement_sha256", fixture.canonical_json_lines(records)) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_full_retained_binding_is_revalidated_beyond_compact_ids(self): + for mutation in ("truncated", "usb-path", "flash-size", "unknown-transfer"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + path, kind, validation, value = acquisition_fixture(directory) + records = [json.loads(line) for line in (directory / "workspace-measurement.jsonl").read_bytes().splitlines()] + binding = records[0]["binding"] + if mutation == "truncated": + binding.pop("flash") + elif mutation == "usb-path": + binding["loader_usb"]["port"] = "/tmp/not-a-physical-device" + elif mutation == "flash-size": + binding["flash"]["size_bytes"] = 16 * 1024 * 1024 + else: + binding["transfer"]["operator_count"] = 0 + self.replace_raw(directory, path, value, "measurement.jsonl", + "measurement_sha256", fixture.canonical_json_lines(records)) + with self.assertRaises(GATE.QualificationError): + self.validate(path, kind, validation) + + def test_unbound_legacy_receipt_is_rejected(self): + receipt = fixture.valid_receipt(fixture.WORKSPACE_ORDER[0]) + receipt.pop("acquisition_sha256", None) + with self.assertRaises(GATE.QualificationError): + GATE.validate_workspace_receipt_payload( + receipt, observation_kind=fixture.WORKSPACE_ORDER[0], + **fixture.validation_kwargs()) + + def test_private_result_reopens_every_workspace_sibling(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + artifact = directory / "firmware.bin" + artifact.write_bytes(b"synthetic candidate bytes") + result = directory / "hardening.json" + value = fixture.valid_result() + value["install_sha256"] = sha(artifact.read_bytes()) + fixture.attach_result_workspace(result, value, artifact.read_bytes()) + fixture.private_write(result, fixture.canonical_json_bytes(value)) + kwargs = fixture.validation_kwargs() + kwargs.pop("expected_install_sha256") + GATE.validate_result_file(result, artifact, **kwargs) + for kind in fixture.WORKSPACE_ORDER: + receipt = directory / value["workspace_provisioning"][kind]["receipt_file"] + paths = [receipt] + paths.extend(receipt.with_name(receipt.stem + "-" + suffix) for suffix in ( + "raw.jsonl", "acquisition.json", "install.bin", "pre.bin", "post.bin", + "response.bin", "advertisements.jsonl", "measurement.jsonl")) + for path in paths: + with self.subTest(kind=kind, sibling=path.suffix): + original = path.read_bytes() + fixture.private_write(path, original + b"tampered") + with self.assertRaises(GATE.QualificationError): + GATE.validate_result_file(result, artifact, **kwargs) + fixture.private_write(path, original) + + def test_private_result_receipt_paths_cannot_escape_its_directory(self): + for name in ("../outside.json", "/outside.json", "sub/receipt.json", "receipt.json/..", ".hidden.json"): + with self.subTest(name=name): + value = fixture.valid_result() + value["workspace_provisioning"][fixture.WORKSPACE_ORDER[0]]["receipt_file"] = name + with self.assertRaises(GATE.QualificationError): + GATE.validate_result_payload(value, **fixture.validation_kwargs()) + + def test_two_acquisitions_need_same_board_distinct_nonce_and_boot(self): + for mutation in ("device", "challenge", "boot_id"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + first = acquisition_fixture(directory, receipt_name="erased.json") + second = acquisition_fixture(directory, fixture.WORKSPACE_ORDER[1], receipt_name="nonblank.json") + erased, nonblank = first[3], second[3] + if mutation == "device": + nonblank["board_binding"]["device_id"] = "a-different-device" + else: + nonblank[mutation] = erased[mutation] + snapshots = [((directory / name, (fixture.canonical_json_bytes(value), (), "")),) + for name, value in (("erased-acquisition.json", erased), + ("nonblank-acquisition.json", nonblank))] + with self.assertRaises(GATE.QualificationError): + GATE._validate_workspace_acquisition_pair(*snapshots) + + def test_real_firmware_getter_json_roundtrip_ignores_mapping_order(self): + import test_workspace_boot_observation_v061 as runtime + for kind in fixture.WORKSPACE_ORDER: + with self.subTest(kind=kind): + workspace = runtime.fresh_workspace() + erased = kind == fixture.WORKSPACE_ORDER[0] + media = runtime.Media(blank=erased) + + def format_media(bdev): + bdev.ioctl(6, 0) + bdev.writeblocks(0, b"\0" * 256, 0) + + vfs, _ = runtime.vfs_fixture( + [OSError(errno.EIO, "synthetic blank or incompatible media")] + ([None] if erased else []), + format_action=format_media) + if erased: + workspace.mount_lfs2(media, vfs, observe_boot=True) + workspace.boot_attached() + else: + with self.assertRaises(OSError): + workspace.mount_lfs2(media, vfs, observe_boot=True) + with mock.patch("builtins.print"): + workspace.boot_recovery() + observed = workspace.read_boot_observation(CHALLENGE) + # MicroPython's object key iteration/serialization ordering is + # not the canonical host evidence ordering contract. + observed = dict(reversed(tuple(observed.items()))) + reply = {"workspace_probe": [4096, 4096, 2, 0, 0, 0, 0, 0, 0, 255] if erased else None, + "observation": observed} + raw = b"PYBLE_WORKSPACE_OBSERVATION:" + json.dumps(reply).encode() + b"\n" + decoded = GATE.parse_workspace_response(raw) + GATE._validate_boot_observation( + {"challenge": CHALLENGE, "boot_id": observed["boot_id"]}, decoded, kind) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_workspace_collector.py b/tests/firmware_tests/host/test_v061_workspace_collector.py new file mode 100644 index 00000000..b88944b3 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_collector.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Host-only collector simulation; no fixture is physical qualification.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +import math +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_v061_hardening_release_gate as fixture +import test_v061_workspace_acquisition as acquisition + +SPEC = importlib.util.spec_from_file_location("pyble_workspace_collector_host_test", acquisition.COLLECTOR) +COLLECTOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(COLLECTOR) + + +class SyntheticClock: + def __init__(self): + self.value = 1_000_000 + + def now(self): + self.value += 1_000_000 + return self.value + + async def wait(self, seconds): + self.value += math.ceil(seconds * 1_000_000_000) + + +class SyntheticScanner: + def __init__(self, callback, history, *, stop_failure=False): + self.callback = callback + self.history = history + self.stop_failure = stop_failure + + async def start(self): + self.history.append("scanner-start") + + async def stop(self): + self.history.append("scanner-stop") + if self.stop_failure: + raise OSError("synthetic scanner stop failed") + + +class SyntheticBackend: + def __init__(self, artifact, binding, kind, history): + self.artifact = artifact + self.binding = binding + self.kind = kind + self.history = history + self.reads = 0 + self.scanner = None + self.mutation = None + + async def prepare(self, candidate, profile_id, kind): + self.history.append("prepare") + return {key: self.binding[key] for key in ("device_id", "ble_address")}, self.artifact.read_bytes() + + async def read_media(self, offset, size): + self.history.append("pre-read" if self.reads == 0 else "post-read") + erased = self.kind == fixture.WORKSPACE_ORDER[0] + result = b"\xff" * size if erased else b"\0" * size + if self.reads and erased: + result = b"synthetic-lfs2" + result[len(b"synthetic-lfs2"):] + self.reads += 1 + return result + + async def boot(self): + self.history.append("boot") + if self.kind == fixture.WORKSPACE_ORDER[0]: + self.scanner.callback(SimpleNamespace(address=self.binding["ble_address"]), + SimpleNamespace(local_name="PyBLE-host-fixture", service_uuids=[acquisition.SERVICE])) + return b"" # native USB need not capture an earlier printed line + + async def observe(self, challenge, transport): + self.history.append("observe") + observed = acquisition.boot_observation(self.kind) + observed["challenge"] = challenge + if self.mutation: + self.mutation(observed) + response = {"observation": observed, + "workspace_probe": [4096, 4096, 512, 500, 500, 0, 0, 0, 0, 255] + if self.kind == fixture.WORKSPACE_ORDER[0] else None} + return b"PYBLE_WORKSPACE_OBSERVATION:" + json.dumps(response).encode() + b"\r\n" + + async def close(self): + self.history.append("close") + + +class WorkspaceCollectorTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="pyble-host-workspace-collector-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.files = fixture.writer_fixture(self.root, "esp32-4mb") + self.output = self.root / "measured.json" + self.kind = fixture.WORKSPACE_ORDER[0] + self.binding = acquisition.synthetic_binding("esp32-4mb") + self.history = [] + self.clock = SyntheticClock() + self.backend = SyntheticBackend(self.files["artifact"], self.binding, self.kind, self.history) + self.scanner_stop_failure = False + # Only the checkout authority is replaced for this host simulation. + # Dirty-source rejection is separately exercised with an actual Git + # checkout in test_v061_hil_preflight_hardening; no production bypass. + qualification = {"root": fixture.ROOT, + "commit": self.files["qualification_source_commit"], + "executable_sha256": self.files["qualification_executable_sha256"]} + self.qual = self.enterContext(mock.patch.object(COLLECTOR.gate, "_qualification_snapshot", return_value=qualification)) + self.same_qual = self.enterContext(mock.patch.object(COLLECTOR.gate, "_same_qualification", return_value=None)) + + def scanner(self, callback): + scanner = SyntheticScanner(callback, self.history, stop_failure=self.scanner_stop_failure) + self.backend.scanner = scanner + return scanner + + async def run_collector(self, **overrides): + options = dict(candidate_dir=self.files["candidate"], profile_id="esp32-4mb", + observation_kind=self.kind, binding=self.binding, + output_path=self.output, qualification_repo_root=fixture.ROOT, + backend=self.backend, allow_disposable_erase=True, + scanner_factory=self.scanner, clock_ns=self.clock.now, wait=self.clock.wait) + options.update(overrides) + return await COLLECTOR.acquire(**options) + + async def test_live_sequence_collects_actual_raw_siblings_and_receipt(self): + self.assertEqual(await self.run_collector(), self.output.resolve()) + self.assertEqual(self.history, + ["prepare", "pre-read", "scanner-start", "boot", "observe", + "scanner-stop", "post-read", "close"]) + receipt = json.loads(self.output.read_bytes()) + envelope = json.loads(self.root.joinpath("measured-acquisition.json").read_bytes()) + self.assertEqual(receipt["acquisition_sha256"], acquisition.sha( + self.root.joinpath("measured-acquisition.json").read_bytes())) + self.assertEqual(envelope["challenge"], json.loads( + self.root.joinpath("measured-measurement.jsonl").read_bytes().splitlines()[0])["challenge"]) + self.assertEqual(self.root.joinpath("measured-install.bin").read_bytes(), self.files["artifact"].read_bytes()) + for path in self.root.glob("measured*"): + self.assertEqual(path.stat().st_mode & 0o7777, 0o600) + + async def test_refusal_uses_live_counter_not_a_substituted_boot_message(self): + self.kind = self.backend.kind = fixture.WORKSPACE_ORDER[1] + await self.run_collector() + response = self.root.joinpath("measured-response.bin").read_bytes() + self.assertNotIn(acquisition.RECOVERY, response) + self.assertEqual(self.root.joinpath("measured-pre.bin").read_bytes(), + self.root.joinpath("measured-post.bin").read_bytes()) + + async def test_preexisting_any_sibling_refuses_before_hardware(self): + for suffix in ("install.bin", "pre.bin", "response.bin", "measurement.jsonl"): + path = self.root / ("measured-" + suffix) + fixture.private_write(path, b"owner bytes\n") + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertEqual(path.read_bytes(), b"owner bytes\n") + self.assertEqual(self.history, []) + path.unlink() + + async def test_explicit_erase_and_clean_source_required_before_hardware(self): + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector(allow_disposable_erase=False) + self.qual.side_effect = COLLECTOR.gate.QualificationError("dirty synthetic source") + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertEqual(self.history, []) + + async def test_wrong_nonce_fails_and_closes_scanner_and_backend(self): + self.backend.mutation = lambda observed: observed.__setitem__("challenge", "e" * 32) + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertIn("scanner-stop", self.history) + self.assertEqual(self.history[-1], "close") + + async def test_source_changed_after_measurement_mints_no_receipt(self): + self.same_qual.side_effect = COLLECTOR.gate.QualificationError("changed source closure") + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertTrue(self.root.joinpath("measured-post.bin").exists()) + self.assertEqual(self.history[-1], "close") + + async def test_scanner_failure_does_not_become_an_absent_advertisement(self): + self.kind = self.backend.kind = fixture.WORKSPACE_ORDER[1] + self.scanner_stop_failure = True + with self.assertRaises(OSError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertEqual(self.history[-1], "close") + + async def test_zero_write_assertion_cannot_override_actual_nonzero_count(self): + self.kind = self.backend.kind = fixture.WORKSPACE_ORDER[1] + self.backend.mutation = lambda observed: observed.__setitem__("program_calls", 1) + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertFalse(self.output.exists()) + + async def test_cancellation_retains_partial_evidence_and_closes_every_owner(self): + async def cancelled(*args): + raise asyncio.CancelledError() + self.backend.observe = cancelled + with self.assertRaises(asyncio.CancelledError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertEqual(self.history[-2:], ["scanner-stop", "close"]) + self.assertTrue(self.root.joinpath("measured-measurement.jsonl").exists()) + + async def test_wrong_install_readback_never_reaches_boot(self): + original = self.backend.prepare + + async def wrong_readback(*args): + binding, raw = await original(*args) + return binding, raw + b"different actual flash bytes" + + self.backend.prepare = wrong_readback + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertEqual(self.history, ["prepare", "close"]) + self.assertFalse(self.output.exists()) + + async def test_truncated_pre_media_never_reaches_boot(self): + async def short_read(*args): + self.history.append("pre-read") + return b"\xff" * 4096 + + self.backend.read_media = short_read + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertEqual(self.history, ["prepare", "pre-read", "close"]) + self.assertFalse(self.output.exists()) + + async def test_timeout_is_a_failure_not_a_missing_response_success(self): + async def timed_out(*args): + raise TimeoutError("synthetic acquisition timeout") + + self.backend.observe = timed_out + with self.assertRaises(TimeoutError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertEqual(self.history[-2:], ["scanner-stop", "close"]) + + async def test_candidate_changed_during_boot_mints_no_receipt(self): + original = self.backend.boot + + async def mutate_candidate(): + raw = await original() + self.files["artifact"].write_bytes(b"changed source candidate") + return raw + + self.backend.boot = mutate_candidate + with self.assertRaises(COLLECTOR.gate.QualificationError): + await self.run_collector() + self.assertFalse(self.output.exists()) + self.assertEqual(self.history[-1], "close") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_v061_workspace_hardware.py b/tests/firmware_tests/host/test_v061_workspace_hardware.py new file mode 100644 index 00000000..e34b8c36 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_hardware.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Host RED for exact-target workspace acquisition hardware guards. + +No USB, serial, BLE, flash programming, or physical qualification occurs here. +""" +import importlib.util +import hashlib +import asyncio +from pathlib import Path +import struct +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[3] +HIL = ROOT / "tests/firmware_tests/hil" +sys.path.insert(0, str(HIL)) +BACKEND = HIL / "_v061_workspace_hardware.py" +if BACKEND.exists(): + SPEC = importlib.util.spec_from_file_location("_v061_workspace_hardware", BACKEND) + HARDWARE = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(HARDWARE) +else: + HARDWARE = None + + +def binding(): + usb = dict(port="/dev/cu.pyble-test-bridge", vid=0x1A86, pid=0x55D3, + serial_number="TEST-SERIAL", location="1-1.2", product="Test serial bridge") + return dict(schema_version=1, profile_id="esp32-s3-n16r8", + device_id="02:00:00:00:56:44", ble_address="test-ble-address", + application_usb=usb, loader_usb=dict(usb), + flash=dict(chip="ESP32-S3", chip_id=9, revision=1, + base_mac="02:00:00:00:56:44", jedec_id=0x184068, + size_bytes=0x1000000), + transfer=dict(initial_baud=115200, baud=460800, + ram_block_bytes=64, flash_block_bytes=64)) + + +def uf2_image(): + records = [] + for index in range(3): + block = bytearray(512) + extension = index == 0 + struct.pack_into("<8I", block, 0, 0x0A324655, 0x9E5D5157, + 0xA000 if extension else 0x2000, + 0x10FFFF00 if extension else 0x10000000 + (index - 1) * 256, + 256, 0 if extension else index - 1, 2, + 0xE48BFF57 if extension else 0xE48BFF59) + if extension: + struct.pack_into("<I", block, 288, 0x9957E304) + else: + block[32:288] = bytes([index]) * 256 + struct.pack_into("<I", block, 508, 0x0AB16F30) + records.append(bytes(block)) + return b"".join(records) + + +class WorkspaceHardwareGuardTests(unittest.TestCase): + def api(self, name): + self.assertIsNotNone(HARDWARE, "[red] maintained workspace hardware backend is missing") + value = getattr(HARDWARE, name, None) + self.assertTrue(callable(value), "[red] workspace hardware seam missing: " + name) + return value + + def test_backend_exposes_bounded_async_acquisition_methods(self): + import inspect + cls = self.api("WorkspaceHardware") + for method in ("prepare", "read_media", "boot", "observe", "close"): + self.assertTrue(inspect.iscoroutinefunction(getattr(cls, method, None)), method) + + def test_exact_private_binding_is_accepted_without_hardware_access(self): + value = binding() + self.assertEqual(self.api("validate_binding")(value), value) + + def test_binding_is_detached_from_caller_mutation(self): + value = binding() + validated = self.api("validate_binding")(value) + value["flash"]["size_bytes"] = 1 + self.assertEqual(validated["flash"]["size_bytes"], 0x1000000) + + def test_wrong_profile_geometry_or_family_is_rejected(self): + validate = self.api("validate_binding") + for field, wrong in (("size_bytes", 0x400000), ("chip_id", 5), ("chip", "ESP32-C3")): + value = binding() + value["flash"][field] = wrong + with self.subTest(field=field), self.assertRaises(ValueError): + validate(value) + + def test_binding_cannot_substitute_another_device_or_unsafe_endpoint(self): + validate = self.api("validate_binding") + cases = [] + value = binding() + value["device_id"] = "02:00:00:00:00:00" + cases.append(value) + value = binding() + value["loader_usb"]["port"] = "/tmp/owner-file" + cases.append(value) + value = binding() + value["loader_usb"]["port"] = "/dev/../tmp/owner-file" + cases.append(value) + value = binding() + value["transfer"]["shell"] = "arbitrary command" + cases.append(value) + for value in cases: + with self.subTest(value=value), self.assertRaises(ValueError): + validate(value) + + def test_unsafe_or_boolean_transport_geometry_is_rejected(self): + validate = self.api("validate_binding") + for field, wrong in (("initial_baud", 9600), ("baud", 4000000), + ("ram_block_bytes", True), ("flash_block_bytes", 0)): + value = binding() + value["transfer"][field] = wrong + with self.subTest(field=field), self.assertRaises(ValueError): + validate(value) + + def test_pico_install_readback_substitutes_every_actual_loadable_payload(self): + image = uf2_image() + actual = bytes([1]) * 256 + bytes([2]) * 256 + calls = [] + def read(address, size): + calls.append((address, size)) + offset = address - 0x10000000 + return actual[offset:offset + size] + result = self.api("reconstruct_uf2_readback")(image, read) + self.assertEqual(result, image) + self.assertEqual(calls, [(0x10000000, 256), (0x10000100, 256)]) + + def test_corrupt_device_payload_cannot_reuse_the_candidate_hash(self): + image = uf2_image() + result = self.api("reconstruct_uf2_readback")(image, lambda _address, size: b"x" * size) + self.assertNotEqual(result, image) + self.assertEqual(result[512 + 32:512 + 288], b"x" * 256) + self.assertEqual(result[1024 + 32:1024 + 288], b"x" * 256) + self.assertEqual(result[:512], image[:512]) # ignored extension is not flashed + + def test_duplicate_missing_or_reordered_uf2_records_fail_before_read(self): + image = uf2_image() + variants = (image[:1024] + image[512:1024], image[:1024], + image[:512] + image[1024:] + image[512:1024]) + for invalid in variants: + called = [] + with self.subTest(size=len(invalid)), self.assertRaises(ValueError): + self.api("reconstruct_uf2_readback")(invalid, lambda *args: called.append(args)) + self.assertEqual(called, []) + + def test_workspace_overlap_and_wrong_family_fail_before_any_read(self): + image = uf2_image() + for field_offset, replacement in ((512 + 12, 0x10180000), + (512 + 28, 0xE48BFF56), (512 + 16, 0)): + changed = bytearray(image) + struct.pack_into("<I", changed, field_offset, replacement) + called = [] + with self.subTest(field=field_offset), self.assertRaises(ValueError): + self.api("reconstruct_uf2_readback")(bytes(changed), lambda *args: called.append(args)) + self.assertEqual(called, []) + + def test_short_nonbyte_or_failed_physical_read_never_produces_artifact(self): + image = uf2_image() + for result in (b"short", bytearray(256), None): + with self.subTest(type=type(result).__name__), self.assertRaises(ValueError): + self.api("reconstruct_uf2_readback")(image, lambda _address, _size: result) + def failed(_address, _size): + raise OSError("physical read failed") + with self.assertRaises(OSError): + self.api("reconstruct_uf2_readback")(image, failed) + + def test_nonblank_precondition_is_explicitly_incompatible_without_full_device_zero_write(self): + fn = self.api("nonblank_prefix") + self.assertEqual(fn(4096), b"\x00" * 8192) + for invalid in (True, 0, 256, 4095, 8192): + with self.subTest(block_size=invalid), self.assertRaises(ValueError): + fn(invalid) + + def test_known_native_application_is_not_mistaken_for_wrong_loader(self): + self.api("validate_binding") + application = binding()["application_usb"] + loader = dict(application, vid=0x303a, pid=0x1001) + with mock.patch.object(HARDWARE, "_ports", return_value=[application]): + self.assertFalse(HARDWARE._usb_exact(loader, absent_ok=True, alternate=application)) + wrong = dict(application, serial_number="OTHER-TARGET") + with mock.patch.object(HARDWARE, "_ports", return_value=[wrong]), self.assertRaises(ValueError): + HARDWARE._usb_exact(loader, absent_ok=True, alternate=application) + + def test_duplicate_serial_alias_must_be_idle_before_open(self): + self.api("validate_binding") + selected = binding()["application_usb"] + alias = dict(selected, port="/dev/cu.duplicate-test-alias") + with mock.patch.object(HARDWARE, "_ports", return_value=[selected, alias]), \ + mock.patch.object(HARDWARE.subprocess, "run", return_value=SimpleNamespace(returncode=0, stdout=b"occupied")) as run: + with self.assertRaises(ValueError): + HARDWARE._usb_exact(selected, idle=True) + self.assertEqual(run.call_args.args[0], ["/usr/sbin/lsof", selected["port"], + selected["port"].replace("/dev/cu.", "/dev/tty."), alias["port"], + alias["port"].replace("/dev/cu.", "/dev/tty.")]) + + +class WorkspaceHardwareLifecycleTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.assertIsNotNone(HARDWARE) + self.temporary = tempfile.TemporaryDirectory(prefix="pyble-hardware-host-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.backend = HARDWARE.WorkspaceHardware(binding(), repo_root=self.root, candidate_dir=self.root) + self.backend.work_dir = self.root + self.backend.kind = "erased-media-first-boot" + + async def test_boot_cannot_precede_complete_pre_read_or_repeat(self): + self.backend.adapter = SimpleNamespace(boot=mock.AsyncMock(return_value=b"first boot")) + for phase in ("new", "prepared", "pre-reading", "booted", "observed"): + self.backend.phase = phase + with self.subTest(phase=phase), self.assertRaises(ValueError): + await self.backend.boot() + self.backend.adapter.boot.assert_not_awaited() + self.backend.phase = "pre-read" + self.assertEqual(await self.backend.boot(), b"first boot") + self.assertEqual(self.backend.phase, "booted") + with self.assertRaises(ValueError): + await self.backend.boot() + self.backend.adapter.boot.assert_awaited_once() + + async def test_failed_actual_read_does_not_authorize_boot(self): + self.backend.phase = "prepared" + self.backend.adapter = SimpleNamespace(read_flash=mock.AsyncMock(return_value=b"short"), + boot=mock.AsyncMock()) + with self.assertRaises(ValueError): + await self.backend.read_media(0x210000, 0xdf0000) + with self.assertRaises(ValueError): + await self.backend.boot() + self.backend.adapter.boot.assert_not_awaited() + + async def test_wrong_transport_or_repeated_observation_never_contacts_runtime(self): + self.backend.phase = "booted" + self.backend._ble_command = mock.AsyncMock() + self.backend._usb_command = mock.AsyncMock() + for challenge, transport in (("a" * 32, "usb-repl"), ("A" * 32, "pble-run")): + with self.assertRaises(ValueError): + await self.backend.observe(challenge, transport) + self.backend._ble_command.assert_not_awaited() + self.backend._usb_command.assert_not_awaited() + + async def test_wave_hello_validates_chip_not_board_profile_token(self): + import _pble_central + import target_smoke + self.backend.binding["profile_id"] = "waveshare-esp32-s3-lcd-147b" + central = SimpleNamespace(send_cmd=mock.AsyncMock(return_value=SimpleNamespace(payload=b"\0")), + disconnect=mock.AsyncMock(), is_connected=False) + with mock.patch.object(HARDWARE, "_usb_exact"), \ + mock.patch.object(HARDWARE.importlib.metadata, "version", return_value="3.0.2"), \ + mock.patch.object(_pble_central.PbleCentral, "connect", return_value=central), \ + mock.patch.object(target_smoke, "validate_caps", side_effect=RuntimeError("stop after caps")) as validate: + with self.assertRaisesRegex(RuntimeError, "stop after caps"): + await self.backend._ble_command("pass") + self.assertEqual(validate.call_args.args[1], "esp32-s3") + central.disconnect.assert_awaited_once() + + async def test_close_never_resets_or_implicitly_boots(self): + adapter = SimpleNamespace(close=mock.AsyncMock(), boot=mock.AsyncMock()) + self.backend.adapter = adapter + await self.backend.close() + await self.backend.close() + adapter.boot.assert_not_awaited() + self.assertEqual(self.backend.phase, "closed") + + async def test_raw_repl_reentry_explicitly_exits_previous_raw_mode_without_soft_reset(self): + writes = [] + class Port: + def open(self): + pass + def close(self): + pass + def write(self, raw): + writes.append(raw) + raise OSError("stop after entry bytes") + serial = SimpleNamespace(Serial=lambda **kwargs: Port()) + with mock.patch.dict(sys.modules, {"serial": serial}), \ + mock.patch.object(HARDWARE.importlib.metadata, "version", return_value="3.5"), \ + mock.patch.object(HARDWARE, "_usb_exact"): + with self.assertRaisesRegex(OSError, "stop after entry bytes"): + self.backend._usb_command_sync("pass", False) + self.assertEqual(writes, [b"\x03\x03\x02\x01"]) + + async def test_esp_erase_needs_unchanged_candidate_and_verified_full_chip_digest(self): + validate = mock.Mock(side_effect=ValueError("candidate changed")) + adapter = HARDWARE._EspAdapter(binding(), self.root, validate) + adapter._check = mock.Mock() + adapter.esp = SimpleNamespace(erase_flash=mock.Mock(), flash_md5sum=mock.Mock(return_value="bad")) + with self.assertRaisesRegex(ValueError, "candidate changed"): + adapter._erase() + adapter.esp.erase_flash.assert_not_called() + validate.side_effect = None + with self.assertRaisesRegex(ValueError, "complete flash erase"): + adapter._erase() + self.assertFalse(adapter.erased) + adapter.esp.flash_md5sum.assert_called_once_with(0, 0x1000000) + + async def test_esp_write_retains_exact_loader_and_never_adds_force_or_reset_flags(self): + raw = b"host-only firmware bytes" + validate = mock.Mock() + adapter = HARDWARE._EspAdapter(binding(), self.root, validate) + adapter._check = mock.Mock() + adapter.erased = True + adapter.esp = SimpleNamespace(_port=SimpleNamespace(is_open=True), + flash_md5sum=mock.Mock(return_value=hashlib.md5(raw).hexdigest())) + tool = SimpleNamespace(main=mock.Mock()) + with mock.patch.dict(sys.modules, {"esptool": tool}): + adapter._write(0, raw, "host-input.bin") + argv = tool.main.call_args.args[0] + self.assertEqual(tool.main.call_args.kwargs, {"esp": adapter.esp}) + self.assertEqual(argv[argv.index("--before") + 1], "no_reset_no_sync") + self.assertEqual(argv[argv.index("--after") + 1], "no_reset_stub") + self.assertEqual(argv[argv.index("--connect-attempts") + 1], "1") + self.assertEqual(argv[-2:], ["0x0", str(self.root / "host-input.bin")]) + self.assertNotIn("--force", argv) + self.assertNotIn("--erase-all", argv) + self.assertNotIn("--encrypt", argv) + self.assertEqual(validate.call_count, 2) + + async def test_esp_failed_read_keeps_real_bytes_from_becoming_candidate_evidence(self): + adapter = HARDWARE._EspAdapter(binding(), self.root, mock.Mock()) + adapter._check = mock.Mock() + adapter.esp = SimpleNamespace(flash_md5sum=mock.Mock(return_value=hashlib.md5(b"good").hexdigest()), + read_flash=mock.Mock(return_value=b"evil")) + with self.assertRaisesRegex(ValueError, "actual flash read"): + adapter._read(0, 4) + + async def test_candidate_byte_change_prevents_esp_write_even_after_successful_erase(self): + adapter = HARDWARE._EspAdapter(binding(), self.root, + mock.Mock(side_effect=ValueError("changed candidate"))) + adapter._check = mock.Mock() + adapter.erased = True + tool = SimpleNamespace(main=mock.Mock()) + with mock.patch.dict(sys.modules, {"esptool": tool}), self.assertRaisesRegex(ValueError, "changed candidate"): + adapter._write(0, b"new", "unadmitted.bin") + tool.main.assert_not_called() + self.assertFalse((self.root / "unadmitted.bin").exists()) + + async def test_cancelled_ble_command_bounds_disconnect_even_when_backend_hangs(self): + import _pble_central + import target_smoke + started = asyncio.Event() + never = asyncio.Event() + command_count = [0] + async def send(*args, **kwargs): + command_count[0] += 1 + if command_count[0] == 2: + started.set() + await never.wait() + return SimpleNamespace(payload=b"\0") + central = SimpleNamespace(send_cmd=send, disconnect=mock.AsyncMock(side_effect=never.wait), + events=[], confirm_caps_mtu=mock.Mock(), is_connected=True) + real_wait_for = asyncio.wait_for + bounded_cleanup = [] + async def bounded(awaitable, timeout): + bounded_cleanup.append(timeout) + return await real_wait_for(awaitable, min(timeout, 0.01)) + with mock.patch.object(HARDWARE, "_usb_exact"), \ + mock.patch.object(HARDWARE.importlib.metadata, "version", return_value="3.0.2"), \ + mock.patch.object(_pble_central.PbleCentral, "connect", return_value=central), \ + mock.patch.object(target_smoke, "parse_caps", return_value={"auto_run": "0", "mtu": "247"}), \ + mock.patch.object(target_smoke, "validate_caps"), \ + mock.patch.object(HARDWARE.asyncio, "wait_for", side_effect=bounded): + task = asyncio.create_task(self.backend._ble_command("pass")) + await real_wait_for(started.wait(), 0.2) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await real_wait_for(task, 0.2) + self.assertTrue(bounded_cleanup, "BLE teardown never used a bounded wait") + self.assertTrue(all(0 < seconds <= 5 for seconds in bounded_cleanup)) + + def rom_context(self, value, rom): + adapter = HARDWARE._EspAdapter(value, self.root, mock.Mock()) + adapter._check = mock.Mock() + tool_chip = {"esp32-4mb": "esp32", "esp32-s3-n16r8": "esp32s3"}[value["profile_id"]] + factory = mock.Mock(return_value=rom) + tool = SimpleNamespace(detect_chip=mock.Mock(side_effect=AssertionError("autodetection is not permitted"))) + self.enterContext(mock.patch.dict(sys.modules, { + "esptool": tool, + "esptool.config": SimpleNamespace(load_config_file=lambda: (None, None)), + "esptool.targets": SimpleNamespace(CHIP_DEFS={tool_chip: factory})})) + self.enterContext(mock.patch.object(HARDWARE.importlib.metadata, "version", + side_effect=lambda name: {"esptool": "4.12.0", "pyserial": "3.5"}[name])) + self.enterContext(mock.patch.object(HARDWARE, "_usb_exact")) + return adapter, factory, tool + + async def test_expected_rom_is_retained_before_exactly_one_connect_and_no_autodetection(self): + rom = SimpleNamespace(connect=mock.Mock(side_effect=OSError("single connect stopped"))) + value = binding() + adapter, factory, tool = self.rom_context(value, rom) + with self.assertRaisesRegex(OSError, "single connect stopped"): + adapter._connect() + self.assertIs(adapter.esp, rom) + factory.assert_called_once_with(value["loader_usb"]["port"], 115200) + rom.connect.assert_called_once_with("default_reset", attempts=1, detecting=True) + tool.detect_chip.assert_not_called() + + async def test_modern_rom_actual_chip_id_must_match_not_only_selected_class_constants(self): + rom = SimpleNamespace(connect=mock.Mock(), IS_STUB=False, sync_stub_detected=False, + CHIP_NAME="ESP32-S3", IMAGE_CHIP_ID=9, + get_chip_id=mock.Mock(return_value=5), read_mac=mock.Mock()) + adapter, _factory, tool = self.rom_context(binding(), rom) + with self.assertRaisesRegex(ValueError, "ROM chip ID"): + adapter._connect() + rom.get_chip_id.assert_called_once_with() + rom.read_mac.assert_not_called() + tool.detect_chip.assert_not_called() + + async def test_classic_rom_uses_actual_magic_without_unsupported_security_info_probe(self): + value = binding() + value["profile_id"] = "esp32-4mb" + value["flash"].update(chip="ESP32", chip_id=0, size_bytes=0x400000, jedec_id=0x164068) + rom = SimpleNamespace(connect=mock.Mock(), IS_STUB=False, sync_stub_detected=False, + CHIP_NAME="ESP32", IMAGE_CHIP_ID=0, CHIP_DETECT_MAGIC_REG_ADDR=0x40001000, + read_reg=mock.Mock(return_value=0), get_chip_id=mock.Mock(), read_mac=mock.Mock()) + adapter, _factory, tool = self.rom_context(value, rom) + with self.assertRaisesRegex(ValueError, "classic ROM magic"): + adapter._connect() + rom.read_reg.assert_called_once_with(0x40001000) + rom.get_chip_id.assert_not_called() + rom.read_mac.assert_not_called() + tool.detect_chip.assert_not_called() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_workspace_pico.py b/tests/firmware_tests/host/test_v061_workspace_pico.py new file mode 100644 index 00000000..aee1bb49 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_pico.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Host-only RED contracts for the guarded RP2350 acquisition adapter.""" + +import asyncio +import copy +import importlib.util +import inspect +from pathlib import Path +import plistlib +import struct +import sys +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + + +ROOT = Path(__file__).resolve().parents[3] +HIL = ROOT / "tests/firmware_tests/hil" +sys.path.insert(0, str(HIL)) +SOURCE = HIL / "_v061_workspace_pico.py" +if SOURCE.exists(): + SPEC = importlib.util.spec_from_file_location("_v061_workspace_pico", SOURCE) + PICO = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(PICO) +else: + PICO = None + + +def binding(): + return { + "schema_version": 1, "profile_id": "rpi-pico2-w", + "device_id": "0123456789abcdef", "ble_address": "test-physical-pico", + "application_usb": {"port": "/dev/cu.test-pico", "vid": 0x2E8A, "pid": 5, + "serial_number": "0123456789abcdef", "location": "1-1.2", + "product": "Pico"}, + "loader_usb": {"port": None, "vid": 0x2E8A, "pid": 15, + "serial_number": "0123456789ABCDEF", "location": 0x01120000, + "product": "RP2350 Boot"}, + "flash": {"chip": "RP2350", "revision": "A2", "package": "QFN60", + "unique_id": "0123456789abcdef", "family_id": 0xE48BFF59, + "size_bytes": 0x400000, "secure_boot": False}, + "transfer": {"kind": "picotool"}, + } + + +DEVICE_INFO = b"""Program Information + target chip: RP2350 +Device Information + type: RP2350 + revision: A2 + package: QFN60 + chipid: 0x0123456789abcdef + flash devinfo: 0x0c00 + current cpu: ARM + secure boot: 0 + flash size: 4096K +""" + + +def ioreg_tree(): + return {"IORegistryEntryChildren": [{ + "idVendor": 0x2E8A, "idProduct": 15, "locationID": 0x01120000, + "USB Serial Number": "0123456789ABCDEF", "USB Product Name": "RP2350 Boot", + }]} + + +def uf2_image(): + records = [] + for index in range(3): + extension = index == 0 + block = bytearray(512) + struct.pack_into("<8I", block, 0, 0x0A324655, 0x9E5D5157, + 0xA000 if extension else 0x2000, + 0x10FFFF00 if extension else 0x10000000 + (index - 1) * 256, + 256, 0 if extension else index - 1, 2, + 0xE48BFF57 if extension else 0xE48BFF59) + if extension: + struct.pack_into("<I", block, 288, 0x9957E304) + else: + block[32:288] = bytes([index]) * 256 + struct.pack_into("<I", block, 508, 0x0AB16F30) + records.append(bytes(block)) + return b"".join(records) + + +class PicoAdapterGuardTests(unittest.TestCase): + def api(self, name): + self.assertIsNotNone(PICO, "[red] maintained Pico workspace adapter missing") + value = getattr(PICO, name, None) + self.assertTrue(callable(value), "[red] missing Pico adapter seam: " + name) + return value + + def test_adapter_exposes_only_explicit_async_loader_operations(self): + cls = self.api("PicoAdapter") + for name in ("loader_present", "connect", "erase", "write_image", "write_prefix", + "read_flash", "read_install", "boot", "close"): + self.assertTrue(inspect.iscoroutinefunction(getattr(cls, name, None)), name) + + def test_binding_is_exact_and_detached(self): + original = binding() + value = self.api("validate_pico_binding_details")(original) + self.assertEqual(value, original) + original["flash"]["size_bytes"] = 1 + self.assertEqual(value["flash"]["size_bytes"], 0x400000) + + def test_wrong_flash_identity_geometry_security_or_boolean_values_are_refused(self): + validate = self.api("validate_pico_binding_details") + for field, wrong in (("chip", "RP2040"), ("revision", "A4"), ("package", "QFN80"), + ("unique_id", "ffffffffffffffff"), ("size_bytes", 0x200000), + ("size_bytes", True), ("secure_boot", 0), ("secure_boot", True), + ("family_id", 0xE48BFF56)): + value = binding() + value["flash"][field] = wrong + with self.subTest(field=field, wrong=wrong), self.assertRaises(ValueError): + validate(value) + + def test_loader_and_application_usb_must_bind_same_unique_id(self): + validate = self.api("validate_pico_binding_details") + cases = [] + for which, field, wrong in (("loader_usb", "serial_number", "FFFFFFFFFFFFFFFF"), + ("loader_usb", "port", "/dev/cu.other"), + ("loader_usb", "pid", 5), + ("loader_usb", "location", True), + ("application_usb", "serial_number", "ffffffffffffffff")): + value = binding() + value[which][field] = wrong + cases.append(value) + for value in cases: + with self.subTest(value=value), self.assertRaises(ValueError): + validate(value) + + def test_ioreg_accepts_real_dictionary_root_and_equivalent_list_root(self): + present = self.api("pico_loader_present") + for tree in (ioreg_tree(), [ioreg_tree()]): + self.assertTrue(present(plistlib.dumps(tree), binding())) + self.assertFalse(present(plistlib.dumps({"IORegistryEntryChildren": []}), binding())) + + def test_duplicate_uid_or_changed_usb_location_is_not_silently_selected(self): + present = self.api("pico_loader_present") + for change in ("duplicate", "location", "product"): + tree = ioreg_tree() + child = tree["IORegistryEntryChildren"][0] + if change == "duplicate": + tree["IORegistryEntryChildren"].append(copy.deepcopy(child)) + elif change == "location": + child["locationID"] += 1 + else: + child["USB Product Name"] = "Unreviewed device" + with self.subTest(change=change), self.assertRaises(ValueError): + present(plistlib.dumps(tree), binding()) + + def test_device_info_requires_actual_chip_uid_capacity_and_security(self): + parse = self.api("parse_pico_device_info") + value = parse(DEVICE_INFO, binding()) + self.assertEqual(value["unique_id"], binding()["device_id"]) + self.assertEqual(value["size_bytes"], 0x400000) + self.assertIs(value["secure_boot"], False) + for before, after in ((b"secure boot: 0", b"secure boot: 1"), + (b"flash size: 4096K", b"flash size: 2048K"), + (b"revision: A2", b"revision: A4"), + (b"0x0123456789abcdef", b"0xffffffffffffffff")): + with self.subTest(before=before), self.assertRaises(ValueError): + parse(DEVICE_INFO.replace(before, after), binding()) + with self.assertRaises(ValueError): + parse(DEVICE_INFO + b" secure boot: 0\n", binding()) + + def test_full_flash_read_uses_explicit_range_even_after_blank_erase(self): + args = self.api("pico_read_argv")(0, 0x400000, Path("private-read.bin")) + self.assertEqual(args, ["save", "-r", "0x10000000", "0x10400000", "-v", + "private-read.bin", "-t", "bin"]) + workspace = self.api("pico_read_argv")(0x180000, 0x280000, Path("workspace.bin")) + self.assertEqual(workspace[2:4], ["0x10180000", "0x10400000"]) + self.assertNotIn("-a", args) + + def test_invalid_or_xip_confused_read_ranges_are_refused(self): + fn = self.api("pico_read_argv") + for offset, size in ((True, 256), (0, True), (-1, 256), (0, 0), + (0x400000, 1), (0x3FFFFF, 2), (0x10000000, 256)): + with self.subTest(offset=offset, size=size), self.assertRaises(ValueError): + fn(offset, size, Path("read.bin")) + + def test_actual_picotool_sector_padding_is_zero_not_ff(self): + footprint = self.api("pico_programmed_footprint")(uf2_image()) + self.assertEqual(footprint, b"\1" * 256 + b"\2" * 256 + b"\0" * (4096 - 512)) + + def test_prefix_write_is_only_exact_two_workspace_blocks_with_no_execute_flag(self): + fn = self.api("pico_prefix_argv") + args = fn(0x180000, b"\0" * 8192, Path("incompatible.bin")) + self.assertEqual(args, ["load", "-v", "incompatible.bin", "-t", "bin", "-o", "0x10180000"]) + self.assertNotIn("-x", args) + for offset, value in ((0, b"\0" * 8192), (0x180000, b"\0" * 4096), + (0x180000, b"\xff" * 8192), (True, b"\0" * 8192)): + with self.subTest(offset=offset, size=len(value)), self.assertRaises(ValueError): + fn(offset, value, Path("prefix.bin")) + + def test_readback_reconstructs_only_actual_payloads_not_uf2_metadata(self): + image = uf2_image() + calls = [] + def read(address, size): + calls.append((address, size)) + return b"x" * size + raw = self.api("reconstruct_uf2_readback")(image, read) + self.assertEqual(calls, [(0x10000000, 256), (0x10000100, 256)]) + self.assertEqual(raw[:512], image[:512]) + self.assertEqual(raw[512:544], image[512:544]) + self.assertEqual(raw[544:800], b"x" * 256) + self.assertNotEqual(raw, image) + + def test_bad_uf2_fails_before_any_physical_reader_call(self): + fn = self.api("reconstruct_uf2_readback") + image = uf2_image() + for invalid in (b"", image[:-1], image[:1024], image[:1024] + image[512:1024]): + calls = [] + with self.subTest(size=len(invalid)), self.assertRaises(ValueError): + fn(invalid, lambda *args: calls.append(args)) + self.assertEqual(calls, []) + + def test_short_or_nonbyte_flash_response_is_not_an_install_artifact(self): + fn = self.api("reconstruct_uf2_readback") + for raw in (b"short", bytearray(256), None): + with self.subTest(raw=type(raw).__name__), self.assertRaises(ValueError): + fn(uf2_image(), lambda *_args: raw) + + def test_image_cannot_be_written_without_verified_full_erase(self): + cls = self.api("PicoAdapter") + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + adapter._tool = Path("/host-test-only/picotool") + adapter._run = AsyncMock(return_value=DEVICE_INFO) + await adapter.connect() + with self.assertRaises((ValueError, RuntimeError)): + await adapter.write_image(uf2_image()) + self.assertEqual(adapter._run.await_args_list[0].args[0], ["info", "-a"]) + self.assertEqual(adapter._run.await_count, 1) + await adapter.close() + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_failed_erased_readback_never_authorizes_image_write(self): + cls = self.api("PicoAdapter") + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + adapter._tool = Path("/host-test-only/picotool") + adapter._run = AsyncMock(return_value=DEVICE_INFO) + await adapter.connect() + adapter.read_flash = AsyncMock(return_value=b"\0" + b"\xff" * (0x400000 - 1)) + with self.assertRaises((ValueError, RuntimeError)): + await adapter.erase() + with self.assertRaises((ValueError, RuntimeError)): + await adapter.write_image(uf2_image()) + self.assertEqual([item.args[0] for item in adapter._run.await_args_list], + [["info", "-a"], ["erase", "-a"]]) + adapter.read_flash.assert_awaited_once_with(0, 0x400000) + await adapter.close() + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_complete_sequence_reads_real_payload_and_preserves_erased_tail(self): + cls = self.api("PicoAdapter") + footprint = self.api("pico_programmed_footprint")(uf2_image()) + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + adapter._tool = Path("/host-test-only/picotool") + flash = bytearray(b"owner" + b"\xff" * (0x400000 - 5)) + commands = [] + async def run(args, **kwargs): + commands.append(args) + if args[0] == "info": + return DEVICE_INFO + if args[0] == "erase": + flash[:] = b"\xff" * len(flash) + elif args[0] == "load": + path, admitted = kwargs["input_guard"] + self.assertEqual(path.read_bytes(), admitted) + if "-o" in args: + flash[0x180000:0x182000] = admitted + else: + flash[:len(footprint)] = footprint + return b"" + async def read(offset, size): + return bytes(flash[offset:offset + size]) + adapter._run = AsyncMock(side_effect=run) + adapter.read_flash = AsyncMock(side_effect=read) + await adapter.connect() + await adapter.erase() + await adapter.write_image(uf2_image()) + self.assertEqual(await adapter.read_install(uf2_image()), uf2_image()) + flash[0] = 0xA5 + actual = await adapter.read_install(uf2_image()) + self.assertEqual(actual[544], 0xA5) + self.assertNotEqual(actual, uf2_image()) + flash[0] = 1 + await adapter.write_prefix(0x180000, b"\0" * 8192) + self.assertEqual(bytes(flash[0x180000:]), b"\0" * 8192 + b"\xff" * (0x280000 - 8192)) + self.assertEqual(bytes(flash[:len(footprint)]), footprint) + for operation in (adapter.erase(), adapter.write_image(uf2_image()), + adapter.write_prefix(0x180000, b"\0" * 8192)): + with self.assertRaises(ValueError): + await operation + await adapter.boot() + with self.assertRaises(ValueError): + await adapter.boot() + await adapter.close() + self.assertEqual([args[:2] for args in commands], + [["info", "-a"], ["erase", "-a"], ["load", "-v"], ["load", "-v"], ["reboot", "-a"]]) + self.assertEqual(adapter.read_flash.await_args_list[0].args, (0, 0x400000)) + self.assertIn(((0, 512),), [(item.args,) for item in adapter.read_flash.await_args_list]) + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_picotool_command_is_exactly_selected_and_input_is_rechecked(self): + cls = self.api("PicoAdapter") + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + adapter._tool = Path("/host-test-only/picotool") + adapter._tools_guard = lambda: None + adapter.loader_present = AsyncMock(return_value=True) + adapter._child = AsyncMock(return_value=b"verified") + path = adapter._new_file("admitted.bin", b"unchanged") + self.assertEqual(await adapter._run(["load", "-v", str(path)], input_guard=(path, b"unchanged")), b"verified") + command = adapter._child.await_args.args[0] + self.assertEqual(command[-6:], ["--ser", "0123456789ABCDEF", "--vid", "0x2e8a", "--pid", "0xf"]) + self.assertNotIn("-f", command) + self.assertNotIn("-F", command) + with self.assertRaises(ValueError): + await adapter._run(["load", "-v", str(path)], input_guard=(path, b"different")) + self.assertEqual(adapter._child.await_count, 1) + await adapter.close() + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_child_timeout_terminates_and_retains_partial_command_artifacts(self): + cls = self.api("PicoAdapter") + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + stopped = asyncio.Event() + class Process: + returncode = None + terminated = False + async def wait(self): + await stopped.wait() + return self.returncode + def terminate(self): + self.terminated = True + self.returncode = -15 + stopped.set() + process = Process() + with patch.object(PICO.asyncio, "create_subprocess_exec", AsyncMock(return_value=process)): + with self.assertRaises(asyncio.TimeoutError): + await adapter._child(["host-only-fake-child"], 0.001) + self.assertTrue(process.terminated) + self.assertIsNone(adapter._process) + self.assertEqual(len(list(directory.glob("*command.json"))), 1) + self.assertEqual(len(list(directory.glob("*output.log"))), 1) + self.assertEqual(len(list(directory.glob("*command-end.json"))), 1) + await adapter.close() + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + def test_close_is_idempotent_and_never_implicitly_reboots(self): + cls = self.api("PicoAdapter") + async def exercise(directory): + adapter = cls(binding(), repo_root=ROOT, work_dir=directory) + adapter._run = AsyncMock() + await adapter.close() + await adapter.close() + adapter._run.assert_not_awaited() + with tempfile.TemporaryDirectory() as directory: + asyncio.run(exercise(Path(directory))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_workspace_receipt_writer_safety.py b/tests/firmware_tests/host/test_v061_workspace_receipt_writer_safety.py new file mode 100644 index 00000000..39e33355 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_receipt_writer_safety.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Focused RED for workspace-receipt path and concurrency safety. + +The raw boot logs are synthetic grammar fixtures, not physical HIL evidence. +""" + +from __future__ import annotations + +import concurrent.futures +import json +import os +from pathlib import Path +import sys +import tempfile +import threading +import unittest + + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import test_v061_hardening_release_gate as fixture # noqa: E402 + + +GATE = fixture.GATE +HAVE_GATE = GATE is not None + + +def noncanonical_workspace_log(observation_kind: str) -> bytes: + values = [ + json.loads(line) + for line in fixture.workspace_raw_log(observation_kind) + .decode("utf-8") + .splitlines() + ] + return "".join( + json.dumps(value, sort_keys=True, ensure_ascii=False) + "\n" + for value in values + ).encode("utf-8") + + +class V061WorkspaceReceiptWriterSafetySeamTests(unittest.TestCase): + def test_workspace_receipt_writer_exists(self): + self.assertIsNotNone(GATE, "[red] %s" % fixture.GATE_LOAD_ERROR) + if GATE is not None: + self.assertTrue(callable(getattr(GATE, "create_workspace_receipt", None))) + + +@unittest.skipUnless(HAVE_GATE, "[red] v0.6.1 hardening gate is not implemented") +class V061WorkspaceReceiptWriterSafetyTests(unittest.TestCase): + PROFILE_ID = "esp32-c3-4mb" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory( + prefix="pyble-v061-workspace-writer-safety-" + ) + self.root = Path(self.temporary.name) + self.writer = fixture.writer_fixture(self.root, self.PROFILE_ID) + self.candidate = Path(self.writer["candidate"]) + + def tearDown(self): + self.temporary.cleanup() + + def paths(self, observation_kind: str, case: str) -> tuple[Path, Path]: + stem = ( + "erased" + if observation_kind == fixture.WORKSPACE_ORDER[0] + else "nonblank" + ) + output = self.root / ("%s-%s.json" % (stem, case)) + raw = output.with_name(output.stem + "-raw.jsonl") + return raw, output + + def write_valid_raw(self, observation_kind: str, path: Path) -> bytes: + raw = fixture.workspace_raw_log(observation_kind) + fixture.private_write(path, raw) + receipt_path = path.with_name(path.name.removesuffix("-raw.jsonl") + ".json") + template = self.writer["erased" if observation_kind == fixture.WORKSPACE_ORDER[0] else "nonblank"] + receipt = json.loads(Path(template).read_text()) + fixture.write_acquisition_fixture(receipt_path, observation_kind, receipt, + Path(self.writer["artifact"]).read_bytes()) + return raw + + def create( + self, + observation_kind: str, + *, + raw: Path, + output: Path, + candidate: Path | None = None, + ): + return GATE.create_workspace_receipt( + candidate_dir=self.candidate if candidate is None else candidate, + profile_id=self.PROFILE_ID, + observation_kind=observation_kind, + raw_boot_log=raw, + output_path=output, + qualification_repo_root=fixture.ROOT, + ) + + def assert_no_output(self, output: Path): + self.assertFalse(output.exists()) + self.assertFalse(output.is_symlink()) + + def test_rejects_every_unsafe_raw_log_without_touching_external_bytes(self): + for observation_kind in fixture.WORKSPACE_ORDER: + for case in ( + "symlink", + "broken-symlink", + "hardlink", + "directory", + "public-mode", + "noncanonical", + ): + with self.subTest(kind=observation_kind, case=case): + raw, output = self.paths(observation_kind, "raw-" + case) + expected = fixture.workspace_raw_log(observation_kind) + + if case == "symlink": + external = self.root / (raw.stem + "-external.jsonl") + fixture.private_write(external, expected) + raw.symlink_to(external) + sentinel = external + sentinel_bytes = external.read_bytes() + elif case == "broken-symlink": + external = self.root / (raw.stem + "-missing.jsonl") + raw.symlink_to(external) + sentinel = None + sentinel_bytes = None + elif case == "hardlink": + fixture.private_write(raw, expected) + external = self.root / (raw.stem + "-second-link.jsonl") + os.link(raw, external) + sentinel = external + sentinel_bytes = external.read_bytes() + elif case == "directory": + raw.mkdir() + sentinel = raw / "operator-sentinel" + sentinel_bytes = b"do not replace\n" + sentinel.write_bytes(sentinel_bytes) + external = None + elif case == "public-mode": + fixture.private_write(raw, expected) + raw.chmod(0o644) + sentinel = raw + sentinel_bytes = raw.read_bytes() + external = None + else: + malformed = noncanonical_workspace_log(observation_kind) + self.assertNotEqual(malformed, expected) + fixture.private_write(raw, malformed) + sentinel = raw + sentinel_bytes = raw.read_bytes() + external = None + + with self.assertRaises(GATE.QualificationError): + self.create(observation_kind, raw=raw, output=output) + + self.assert_no_output(output) + if case in {"symlink", "broken-symlink"}: + self.assertTrue(raw.is_symlink()) + if case == "broken-symlink": + self.assertFalse(external.exists()) + if sentinel is not None: + self.assertEqual(sentinel.read_bytes(), sentinel_bytes) + if case == "hardlink": + self.assertEqual(raw.stat().st_nlink, 2) + if case == "directory": + self.assertTrue(raw.is_dir()) + if case == "public-mode": + self.assertEqual(raw.stat().st_mode & 0o777, 0o644) + + def test_rejects_every_unsafe_output_without_replacing_owner_state(self): + for observation_kind in fixture.WORKSPACE_ORDER: + for case in ( + "symlink", + "broken-symlink", + "directory", + "symlink-parent", + "preexisting", + ): + with self.subTest(kind=observation_kind, case=case): + raw, output = self.paths(observation_kind, "output-" + case) + + if case == "symlink-parent": + external_parent = self.root / ( + output.stem + "-external-parent" + ) + external_parent.mkdir() + sentinel = external_parent / "operator-sentinel" + sentinel.write_bytes(b"external owner bytes\n") + linked_parent = self.root / (output.stem + "-linked-parent") + linked_parent.symlink_to( + external_parent, + target_is_directory=True, + ) + output = linked_parent / "receipt.json" + raw = output.with_name("receipt-raw.jsonl") + self.write_valid_raw(observation_kind, raw) + before = { + item.name: item.read_bytes() + for item in external_parent.iterdir() + if item.is_file() + } + else: + self.write_valid_raw(observation_kind, raw) + before = None + + if case == "symlink": + external_parent = self.root / (output.stem + "-external") + external_parent.mkdir() + target = external_parent / "owner-receipt.json" + target.write_bytes(b"owner receipt bytes\n") + target.chmod(0o600) + output.symlink_to(target) + sentinel = target + sentinel_bytes = target.read_bytes() + elif case == "broken-symlink": + target = self.root / (output.stem + "-missing.json") + output.symlink_to(target) + sentinel = None + sentinel_bytes = None + elif case == "directory": + output.mkdir() + sentinel = output / "operator-sentinel" + sentinel_bytes = b"directory owner bytes\n" + sentinel.write_bytes(sentinel_bytes) + elif case == "preexisting": + sentinel = output + sentinel_bytes = b"pre-existing receipt bytes\n" + fixture.private_write(output, sentinel_bytes) + elif case == "symlink-parent": + target = None + sentinel_bytes = None + + with self.assertRaises(GATE.QualificationError): + self.create(observation_kind, raw=raw, output=output) + + if case in {"symlink", "broken-symlink"}: + self.assertTrue(output.is_symlink()) + if case == "broken-symlink": + self.assertFalse(target.exists()) + elif case == "symlink-parent": + after = { + item.name: item.read_bytes() + for item in external_parent.iterdir() + if item.is_file() + } + self.assertEqual(after, before) + self.assertTrue(linked_parent.is_symlink()) + else: + self.assertEqual(sentinel.read_bytes(), sentinel_bytes) + if case == "directory": + self.assertTrue(output.is_dir()) + + def test_rejects_symlinked_candidate_and_preserves_its_target(self): + real_candidate = self.root / "external-candidate" + self.candidate.rename(real_candidate) + self.candidate.symlink_to(real_candidate, target_is_directory=True) + sentinel = real_candidate / "operator-sentinel" + sentinel.write_bytes(b"candidate owner bytes\n") + before = sentinel.read_bytes() + + for observation_kind in fixture.WORKSPACE_ORDER: + with self.subTest(kind=observation_kind): + raw, output = self.paths(observation_kind, "candidate-symlink") + self.write_valid_raw(observation_kind, raw) + + with self.assertRaises(GATE.QualificationError): + self.create(observation_kind, raw=raw, output=output) + + self.assert_no_output(output) + self.assertTrue(self.candidate.is_symlink()) + self.assertEqual(sentinel.read_bytes(), before) + + def test_concurrent_creators_have_exactly_one_winner_for_each_kind(self): + contenders = 8 + for observation_kind in fixture.WORKSPACE_ORDER: + with self.subTest(kind=observation_kind): + raw, output = self.paths(observation_kind, "concurrent") + original_raw = self.write_valid_raw(observation_kind, raw) + barrier = threading.Barrier(contenders) + + def attempt(): + barrier.wait(timeout=10) + try: + created = self.create( + observation_kind, + raw=raw, + output=output, + ) + except GATE.QualificationError: + return "rejected", None + return "created", Path(created) + + with concurrent.futures.ThreadPoolExecutor( + max_workers=contenders + ) as executor: + outcomes = list( + executor.map(lambda _index: attempt(), range(contenders)) + ) + + winners = [path for status, path in outcomes if status == "created"] + rejected = [ + status for status, _path in outcomes if status == "rejected" + ] + self.assertEqual(winners, [output]) + self.assertEqual(len(rejected), contenders - 1) + self.assertTrue(output.is_file()) + self.assertFalse(output.is_symlink()) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + self.assertEqual(output.stat().st_nlink, 1) + value = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(tuple(value), fixture.RECEIPT_KEYS) + self.assertEqual(value["observation_kind"], observation_kind) + self.assertEqual( + output.read_bytes(), + fixture.canonical_json_bytes(value), + ) + self.assertEqual(raw.read_bytes(), original_raw) + self.assertEqual(raw.stat().st_mode & 0o777, 0o600) + self.assertEqual(raw.stat().st_nlink, 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_v061_workspace_usb_output.py b/tests/firmware_tests/host/test_v061_workspace_usb_output.py new file mode 100644 index 00000000..216cacd6 --- /dev/null +++ b/tests/firmware_tests/host/test_v061_workspace_usb_output.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Native USB output regression with an explicit hostile, host-only FIFO model. + +No board, BLE, serial endpoint, firmware image, or physical receipt is used. +""" +import builtins +import copy +import json +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_v061_workspace_acquisition as fixture +import test_v061_workspace_hardware as hardware_fixture + +HARDWARE = hardware_fixture.HARDWARE +KIND = fixture.GATE.WORKSPACE_PROVISIONING_ORDER[1] +PREFIX = b"PYBLE_WORKSPACE_OBSERVATION:" + + +class OverwritableFIFO: + """DTR-low finite TX queue; only modeled host drain events deliver bytes. + + Writes return their requested length even after overwriting old bytes, + matching why that return value cannot prove native USB delivery. + """ + def __init__(self, capacity=256, *, drain=True, cooked=False): + self.capacity, self.drain_enabled, self.cooked = capacity, drain, cooked + self.pending, self.delivered = bytearray(), bytearray() + self.writes, self.sleeps = [], [] + self.lost = 0 + self.on_sleep = None + + def write(self, value): + self.writes.append(value) + raw = value.encode("ascii") + if self.cooked: + raw = raw.replace(b"\n", b"\r\n") + self.pending.extend(raw) + excess = max(0, len(self.pending) - self.capacity) + self.lost += excess + del self.pending[:excess] + return len(value) + + def sleep_ms(self, duration): + if type(duration) is not int or duration not in (50, 100): + raise AssertionError("unexpected or unbounded pacing") + self.sleeps.append(duration) + if self.on_sleep: + self.on_sleep() + if self.drain_enabled: + self.drain() + + def drain(self): + self.delivered.extend(self.pending) + self.pending.clear() + + def finish(self): + self.drain() + return bytes(self.delivered) + + +def expected_output(observation): + return b"\n" + PREFIX + json.dumps({"observation": observation, "workspace_probe": None}).encode() + b"\n" + + +def execute_source(source, observation, fifo, *, inspect_spies=None): + getter = mock.Mock(return_value=copy.deepcopy(observation)) + serializer = mock.Mock(wraps=json.dumps) + modules = {"os": SimpleNamespace(statvfs=mock.Mock(side_effect=AssertionError("refusal cannot probe filesystem"))), + "json": SimpleNamespace(dumps=serializer), + "pyble_workspace": SimpleNamespace(read_boot_observation=getter), + "sys": SimpleNamespace(stdout=fifo), "time": SimpleNamespace(sleep_ms=fifo.sleep_ms)} + def imported(name, *args, **kwargs): + if name not in modules: + raise AssertionError("unreviewed import or side effect: " + name) + return modules[name] + def printed(*values, sep=" ", end="\n"): + fifo.write(sep.join(str(value) for value in values) + end) + environment = {"__builtins__": {**vars(builtins), "__import__": imported, "print": printed}} + if inspect_spies: + inspect_spies(getter, serializer) + exec(compile(source, "<host-generated-readonly-query>", "exec"), environment) + return getter, serializer + + +class NativeUSBOutputTests(unittest.IsolatedAsyncioTestCase): + async def source_for(self, profile="waveshare-esp32-s3-lcd-147b", *, transport="usb-repl"): + with tempfile.TemporaryDirectory(prefix="pyble-usb-source-host-") as directory: + backend = HARDWARE.WorkspaceHardware(fixture.synthetic_binding(profile), + repo_root=Path(directory), candidate_dir=Path(directory)) + backend.work_dir = Path(directory) + backend.kind = KIND if transport == "usb-repl" else fixture.GATE.WORKSPACE_PROVISIONING_ORDER[0] + backend.phase = "booted" + backend._usb_command = mock.AsyncMock(return_value=b"host transcript") + backend._ble_command = mock.AsyncMock(return_value=b"host transcript") + with mock.patch.object(HARDWARE, "_usb_exact", return_value=True): + await backend.observe(fixture.CHALLENGE, transport) + called = backend._usb_command if transport == "usb-repl" else backend._ble_command + called.assert_awaited_once() + return called.call_args.args[0] + + def observation(self): + return dict(fixture.boot_observation(KIND), + challenge=fixture.CHALLENGE, boot_id=fixture.BOOT_ID) + + def test_old_one_shot_stdout_loses_prefix_in_hostile_dtr_low_fifo(self): + fifo = OverwritableFIFO() + wanted = expected_output(self.observation()) + self.assertGreater(len(wanted), fifo.capacity) + self.assertEqual(fifo.write(wanted.decode()), len(wanted)) + raw = fifo.finish() + self.assertGreater(fifo.lost, 0) + with self.assertRaises(fixture.GATE.QualificationError): + fixture.GATE.parse_workspace_response(raw) + + async def test_native_query_survives_fifo_with_exact_unchanged_parser_bytes(self): + for profile in ("waveshare-esp32-s3-lcd-147b", "rpi-pico2-w"): + source = await self.source_for(profile) + for capacity in (64, 256): + for cooked in (False, True): + with self.subTest(profile=profile, capacity=capacity, cooked=cooked): + fifo = OverwritableFIFO(capacity, cooked=cooked) + observation = self.observation() + getter, serializer = execute_source(source, observation, fifo) + wanted = expected_output(observation) + if cooked: + wanted = wanted.replace(b"\n", b"\r\n") + self.assertEqual(fifo.finish(), wanted) + self.assertEqual(fifo.lost, 0) + self.assertTrue(all(0 < len(part) <= 16 for part in fifo.writes)) + self.assertEqual(fifo.sleeps, [100] + [50] * len(fifo.writes) + [100]) + self.assertLessEqual(sum(fifo.sleeps), 3400) + getter.assert_called_once_with(fixture.CHALLENGE) + serializer.assert_called_once() + reply = fixture.GATE.parse_workspace_response(fifo.finish()) + fixture.GATE._validate_boot_observation( + {"challenge": fixture.CHALLENGE, "boot_id": fixture.BOOT_ID}, reply, KIND) + + async def test_native_query_serializes_once_before_any_delays(self): + source = await self.source_for() + observation = self.observation() + fifo = OverwritableFIFO() + spies = [] + def mutate(): + self.assertEqual([spy.call_count for spy in spies], [1, 1]) + observation["mount_attempts"] = 65535 + wanted = expected_output(observation) + fifo.on_sleep = mutate + getter, serializer = execute_source( + source, observation, fifo, inspect_spies=lambda *values: spies.extend(values)) + self.assertEqual(fifo.finish(), wanted) + getter.assert_called_once() + serializer.assert_called_once() + + async def test_max_counter_values_fit_bound_but_still_cannot_qualify(self): + source = await self.source_for() + observation = self.observation() + for key, value in observation.items(): + if type(value) is int and key != "schema_version": + observation[key] = 65535 + fifo = OverwritableFIFO() + execute_source(source, observation, fifo) + self.assertLessEqual(len(fifo.finish()), 1024) + self.assertLessEqual(sum(fifo.sleeps), 3400) + with self.assertRaises(fixture.GATE.QualificationError): + fixture.GATE._validate_boot_observation( + {"challenge": fixture.CHALLENGE, "boot_id": fixture.BOOT_ID}, + fixture.GATE.parse_workspace_response(fifo.finish()), KIND) + + async def test_exact_output_ceiling_has_at_most_3400ms_requested_pacing(self): + observation = dict(self.observation(), extra="") + observation["extra"] = "x" * (1024 - len(expected_output(observation))) + self.assertEqual(len(expected_output(observation)), 1024) + fifo = OverwritableFIFO() + execute_source(await self.source_for(), observation, fifo) + self.assertEqual(fifo.finish(), expected_output(observation)) + self.assertEqual(sum(fifo.sleeps), 3400) + self.assertEqual(len(fifo.writes), 64) + + async def test_oversize_or_non_ascii_rejected_before_first_write(self): + source = await self.source_for() + for added in ("x" * 1024, "\u00e9"): + observation = dict(self.observation(), unexpected=added) + fifo = OverwritableFIFO() + # MicroPython may emit UTF-8 rather than CPython's ASCII escapes. + with mock.patch.object(json, "dumps", wraps=lambda value: ORIGINAL_DUMPS(value, ensure_ascii=False)): + with self.assertRaises((ValueError, RuntimeError)): + execute_source(source, observation, fifo) + self.assertEqual(fifo.writes, []) + self.assertEqual(fifo.sleeps, []) + + async def test_pacing_is_not_delivery_proof_when_host_never_drains(self): + fifo = OverwritableFIFO(drain=False) + execute_source(await self.source_for(), self.observation(), fifo) + self.assertGreater(fifo.lost, 0) + with self.assertRaises(fixture.GATE.QualificationError): + fixture.GATE.parse_workspace_response(fifo.finish()) + + async def test_duplicate_marker_or_missing_bytes_are_not_repaired(self): + fifo = OverwritableFIFO() + execute_source(await self.source_for(), self.observation(), fifo) + raw = fifo.finish() + for invalid in (raw + raw, raw.replace(PREFIX, b"", 1), raw[:-10]): + with self.subTest(raw=invalid[:30]), self.assertRaises(fixture.GATE.QualificationError): + fixture.GATE.parse_workspace_response(invalid) + + async def test_faulted_incomplete_or_overflow_record_remains_unqualified(self): + source = await self.source_for() + for field, value in (("fault", True), ("complete", False), ("overflow", True)): + fifo = OverwritableFIFO() + execute_source(source, dict(self.observation(), **{field: value}), fifo) + with self.subTest(field=field), self.assertRaises(fixture.GATE.QualificationError): + fixture.GATE._validate_boot_observation( + {"challenge": fixture.CHALLENGE, "boot_id": fixture.BOOT_ID}, + fixture.GATE.parse_workspace_response(fifo.finish()), KIND) + + async def test_bridge_and_ble_paths_retain_original_unpaced_source(self): + for profile, transport in (("esp32-4mb", "usb-repl"), ("esp32-c3-4mb", "usb-repl"), + ("esp32-s3-n16r8", "usb-repl"), + ("waveshare-esp32-s3-lcd-147b", "pble-run"), + ("rpi-pico2-w", "pble-run")): + source = await self.source_for(profile, transport=transport) + self.assertNotIn("sleep_ms", source) + self.assertNotIn("stdout.write", source) + self.assertEqual(source.count("read_boot_observation("), 1) + self.assertEqual(source.count("json.dumps("), 1) + + +ORIGINAL_DUMPS = json.dumps + + +class RawREPLGuards(unittest.TestCase): + def probe(self, *, stderr=b"", complete=True, identity=True, cancel=False): + temporary = tempfile.TemporaryDirectory(prefix="pyble-usb-framing-host-") + self.addCleanup(temporary.cleanup) + folder = Path(temporary.name) + backend = HARDWARE.WorkspaceHardware(hardware_fixture.binding(), repo_root=folder, candidate_dir=folder) + backend.work_dir = folder + clock = SimpleNamespace(value=0.0) + observation = dict(fixture.boot_observation(KIND), + challenge=fixture.CHALLENGE, boot_id=fixture.BOOT_ID) + output = expected_output(observation).replace(b"\n", b"\r\n") + test = self + class Port: + def __init__(self): + self.queue = bytearray() + self.writes = [] + self.commands = 0 + self.closed = False + self.query_reads = 0 + def open(self): + test.assertIs(self.dtr, False) + test.assertIs(self.rts, False) + def close(self): + self.closed = True + def write(self, value): + self.writes.append(value) + if value == b"\x03\x03\x02\x01": + self.queue.extend(fixture.RECOVERY + b"raw REPL; CTRL-B to exit\r\n>") + elif value == b"\x04": + self.commands += 1 + if self.commands == 1: + uid = backend.binding["device_id"].replace(":", "") if identity else "wrong-physical-id" + self.queue.extend(b"OK\r\nPYBLE_PHYSICAL_UID:" + uid.encode() + b"\r\n\x04\x04>") + else: + self.queue.extend(b"OK" + (output if complete else output[:20])) + if complete: + self.queue.extend(b"\x04" + stderr + b"\x04>") + return len(value) + def read(self, size): + test.assertEqual(size, 1) + clock.value += 0.001 if self.queue else 0.2 + if self.commands == 2: + self.query_reads += 1 + if cancel and self.query_reads == 4: + backend._serial_cancelled.set() + result = bytes(self.queue[:1]) + del self.queue[:1] + return result + port = Port() + with mock.patch.dict(sys.modules, {"serial": SimpleNamespace(Serial=lambda **kwargs: port)}), \ + mock.patch.object(HARDWARE.importlib.metadata, "version", return_value="3.5"), \ + mock.patch.object(HARDWARE, "_usb_exact") as exact, \ + mock.patch.object(HARDWARE.time, "monotonic", side_effect=lambda: clock.value): + failure = None + try: + raw = backend._usb_command_sync("readonly-observation-placeholder", False) + except BaseException as error: + failure, raw = error, None + self.assertTrue(port.closed) + self.assertIsNone(backend._serial) + exact.assert_called_once_with(backend.binding["application_usb"], idle=True) + self.assertEqual(port.writes[0], b"\x03\x03\x02\x01") + self.assertEqual(port.writes.count(b"\x04"), 2 if identity else 1) + retained = (folder / "usb-observation.bin").read_bytes() + self.assertTrue(retained.startswith(fixture.RECOVERY)) + return raw, failure, retained, clock.value, port + + def test_same_raw_repl_delimiters_recovery_and_control_lines(self): + raw, failure, retained, _elapsed, port = self.probe() + self.assertIsNone(failure) + self.assertEqual(raw, retained) + self.assertEqual(port.commands, 2) + reply = fixture.GATE.parse_workspace_response(raw) + fixture.GATE._validate_boot_observation( + {"challenge": fixture.CHALLENGE, "boot_id": fixture.BOOT_ID}, reply, KIND) + + def test_nonempty_stderr_preserves_failed_raw_and_never_returns_observation(self): + raw, failure, retained, _elapsed, _port = self.probe(stderr=b"emission failed") + self.assertIsNone(raw) + self.assertIsInstance(failure, ValueError) + self.assertIn("stderr", str(failure)) + self.assertIn(b"emission failed", retained) + + def test_incomplete_stdout_still_uses_absolute_12_second_deadline(self): + raw, failure, retained, elapsed, port = self.probe(complete=False) + self.assertIsNone(raw) + self.assertIsInstance(failure, TimeoutError) + self.assertGreaterEqual(elapsed, 12) + self.assertLess(elapsed, 13) + self.assertEqual(port.commands, 2) + self.assertIn(b"PYBLE_WORKSPACE", retained) + + def test_wrong_uid_fails_before_observation_command(self): + raw, failure, retained, _elapsed, port = self.probe(identity=False) + self.assertIsNone(raw) + self.assertIsInstance(failure, ValueError) + self.assertIn("bound physical board", str(failure)) + self.assertEqual(port.commands, 1) + self.assertNotIn(PREFIX, retained) + + def test_cancellation_preserves_partial_bytes_and_closes_without_retry(self): + raw, failure, retained, _elapsed, port = self.probe(cancel=True) + self.assertIsNone(raw) + self.assertIsInstance(failure, ValueError) + self.assertIn("cancelled", str(failure)) + self.assertEqual(port.commands, 2) + self.assertNotIn(PREFIX, retained) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/host/test_vm_epoch_lifecycle.py b/tests/firmware_tests/host/test_vm_epoch_lifecycle.py index d850677a..6883c444 100644 --- a/tests/firmware_tests/host/test_vm_epoch_lifecycle.py +++ b/tests/firmware_tests/host/test_vm_epoch_lifecycle.py @@ -2090,11 +2090,18 @@ def reachable(seeds: set[str]) -> set[str]: session_context = reachable( {"fs_do_put_data"} | defined_calls(get_after_wait, "fs_do_get") ) + cleanup_helpers = {"fs_close_local"} + cleanup = code_only(c_function(FS, "fs_close_local")) + self.assertEqual(len(VFS_EFFECT_RE.findall(cleanup)), 1) + self.assertIn("mp_stream_close(file)", cleanup) + self.assertIn("nlr_push", cleanup) + self.assertNotRegex(cleanup, VFS_VALID_RE) + self.assertNotRegex(cleanup, VFS_TICKET_VALID_RE) raw_functions = { name for name, body in function_bodies.items() if VFS_EFFECT_RE.search(body) - } + } - cleanup_helpers mixed_raw_helpers = ( ticket_context & session_context & raw_functions ) - {"fs_do_get"} @@ -2161,7 +2168,7 @@ def reachable(seeds: set[str]) -> set[str]: for name in function_names: body = function_bodies[name] effects = list(VFS_EFFECT_RE.finditer(body)) - if not effects or name == "fs_do_get": + if not effects or name == "fs_do_get" or name in cleanup_helpers: continue guarded_functions += 1 with self.subTest(function=name): diff --git a/tests/firmware_tests/host/test_workspace_boot_observation_v061.py b/tests/firmware_tests/host/test_workspace_boot_observation_v061.py new file mode 100644 index 00000000..baf9593f --- /dev/null +++ b/tests/firmware_tests/host/test_workspace_boot_observation_v061.py @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Measured first-boot workspace observations; synthetic host cases, not HIL. + +Each test imports a fresh module instance to represent one MicroPython VM. +Counters must observe real constructor/mkfs/remount and block-device calls, +including attempted writes that fail or leave storage bytes unchanged. +""" + +import ast +import errno +import importlib.util +from pathlib import Path +import types +import unittest +import sys +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[3] +SOURCE = ROOT / "firmware/pyble/pyble_workspace.py" +CHALLENGE = "0123456789abcdef0123456789abcdef" +SECOND_CHALLENGE = "fedcba9876543210fedcba9876543210" +COUNTERS = ( + "mount_attempts", "format_attempts", "format_completions", + "remount_attempts", "remount_completions", "program_calls", "erase_calls", + "recovery_attempts", "recovery_emissions", +) +FLAGS = ("workspace_attached", "complete", "fault", "overflow") +FIELDS = {"schema_version", "boot_id", "challenge", "media_state", *COUNTERS, *FLAGS} +RECOVERY = "PyBLE workspace recovery is required; reconnect by USB." + + +def fresh_workspace(): + spec = importlib.util.spec_from_file_location("_pyble_workspace_observer_test", SOURCE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Media: + """Idempotent fake writes expose why a checksum cannot prove zero calls.""" + + def __init__(self, *, blank=True, geometry_error=None, read_error=None): + self.contents = bytearray(b"\xff" * 8192) + if not blank: + self.contents[-1] = 0x7F + self.geometry_error = geometry_error + self.read_error = read_error + self.program_error = None + self.erase_error = None + self.read_calls = [] + self.ioctl_calls = [] + self.program_calls = 0 + self.erase_calls = 0 + self.last_program_arguments = None + self.before_program = None + self.before_erase = None + + def readblocks(self, block, destination, *offset): + self.read_calls.append((block, destination, offset)) + if self.read_error is not None: + raise self.read_error + start = block * 4096 + (offset[0] if offset else 0) + destination[:] = self.contents[start:start + len(destination)] + return 0 + + def writeblocks(self, block, source, *offset): + self.program_calls += 1 + self.last_program_arguments = (block, source, offset) + if self.before_program: + self.before_program() + if self.program_error is not None: + raise self.program_error + # Deliberately do not mutate the bytes: this is still a write attempt. + return 0 + + def ioctl(self, command, argument): + self.ioctl_calls.append((command, argument)) + if command in (4, 5): + if self.geometry_error is not None: + raise self.geometry_error + return 2 if command == 4 else 4096 + if command == 6: + self.erase_calls += 1 + if self.before_erase: + self.before_erase() + if self.erase_error is not None: + raise self.erase_error + return 0 + + +def vfs_fixture(effects, *, format_action=None): + state = types.SimpleNamespace(effects=list(effects), constructions=[], formats=[]) + + class VfsLfs2: + def __new__(cls, bdev, *, progsize): + state.constructions.append((bdev, progsize)) + if not state.effects: + raise AssertionError("Unexpected extra constructor") + effect = state.effects.pop(0) + if isinstance(effect, BaseException): + raise effect + if callable(effect): + return effect(bdev) + return types.SimpleNamespace(bdev=bdev) if effect is None else effect + + @staticmethod + def mkfs(bdev, *, progsize): + state.formats.append((bdev, progsize)) + if format_action is not None: + return format_action(bdev) + + return types.SimpleNamespace(VfsLfs2=VfsLfs2), state + + +class WorkspaceBootObservationTests(unittest.TestCase): + def setUp(self): + self.workspace = fresh_workspace() + + def api(self, name): + function = getattr(self.workspace, name, None) + self.assertTrue( + callable(function), + "[red] measured first-boot observation API {} is missing".format(name)) + return function + + def observe(self, bdev, vfs): + self.api("read_boot_observation") + return self.workspace.mount_lfs2(bdev, vfs, progsize=256, observe_boot=True) + + def snapshot(self, challenge=CHALLENGE): + value = self.api("read_boot_observation")(challenge) + self.assertIs(type(value), dict) + self.assertEqual(set(value), FIELDS) + self.assertIs(type(value["schema_version"]), int) + self.assertEqual(value["schema_version"], 1) + self.assertIs(type(value["boot_id"]), str) + self.assertRegex(value["boot_id"], r"\A[0-9a-f]{32}\Z") + self.assertEqual(value["challenge"], challenge) + for name in COUNTERS: + self.assertIs(type(value[name]), int, name) + self.assertGreaterEqual(value[name], 0, name) + self.assertLessEqual(value[name], 65535, name) + for name in FLAGS: + self.assertIs(type(value[name]), bool, name) + self.assertIn(value["media_state"], ("uninspected", "erased", "nonblank", "uncertain")) + return value + + def assert_counts(self, value, **expected): + for name in COUNTERS: + self.assertEqual(value[name], expected.get(name, 0), name) + + def healthy(self): + media = Media(blank=False) + vfs, state = vfs_fixture([None]) + mounted = self.observe(media, vfs) + return media, vfs, state, mounted + + def recover(self): + with mock.patch("builtins.print") as output: + self.api("boot_recovery")() + output.assert_called_once_with(RECOVERY) + return self.snapshot() + + def test_unobserved_mount_keeps_original_device_and_no_diagnostic_side_effect(self): + media = Media(blank=False) + vfs, state = vfs_fixture([None]) + mounted = self.workspace.mount_lfs2(media, vfs, progsize=256) + self.assertIs(mounted.bdev, media) + self.assertEqual(state.constructions, [(media, 256)]) + self.assertEqual(media.read_calls, []) + self.assertEqual(media.ioctl_calls, []) + if callable(getattr(self.workspace, "read_boot_observation", None)): + with self.assertRaises(Exception): + self.workspace.read_boot_observation(CHALLENGE) + + def test_no_boot_record_is_not_reported_as_zero(self): + getter = self.api("read_boot_observation") + with self.assertRaises(Exception): + getter(CHALLENGE) + + def test_initial_constructor_attempt_is_visible_before_the_call(self): + seen = [] + def construct(bdev): + seen.append(self.snapshot()) + return types.SimpleNamespace(bdev=bdev) + vfs, _state = vfs_fixture([construct]) + self.observe(Media(), vfs) + self.assert_counts(seen[0], mount_attempts=1) + self.assertFalse(seen[0]["complete"]) + self.assertFalse(seen[0]["workspace_attached"]) + + def test_healthy_constructor_does_not_claim_attachment_or_inspect_media(self): + media, _vfs, state, _mounted = self.healthy() + value = self.snapshot() + self.assert_counts(value, mount_attempts=1) + self.assertEqual(value["media_state"], "uninspected") + self.assertFalse(value["workspace_attached"]) + self.assertFalse(value["complete"]) + self.assertFalse(value["fault"]) + self.assertEqual(len(state.constructions), 1) + self.assertEqual(media.ioctl_calls, []) + self.assertEqual(media.read_calls, []) + + def test_only_actual_attachment_seals_healthy_boot(self): + self.healthy() + self.api("boot_attached")() + value = self.snapshot() + self.assert_counts(value, mount_attempts=1) + self.assertTrue(value["workspace_attached"]) + self.assertTrue(value["complete"]) + self.assertFalse(value["fault"]) + self.assertFalse(value["overflow"]) + + def test_erased_scan_records_real_format_and_remount_attempts_and_completions(self): + samples = [] + def format_action(bdev): + samples.append(self.snapshot()) + bdev.ioctl(6, 0) + bdev.writeblocks(0, b"\xff" * 256, 0) + def remount(bdev): + samples.append(self.snapshot()) + return types.SimpleNamespace(bdev=bdev) + media = Media() + vfs, state = vfs_fixture([OSError(errno.EIO, "blank"), remount], format_action=format_action) + self.observe(media, vfs) + self.api("boot_attached")() + value = self.snapshot() + self.assert_counts(samples[0], mount_attempts=1, format_attempts=1) + self.assert_counts(samples[1], mount_attempts=1, format_attempts=1, + format_completions=1, remount_attempts=1, program_calls=1, erase_calls=1) + self.assert_counts(value, mount_attempts=1, format_attempts=1, + format_completions=1, remount_attempts=1, remount_completions=1, + program_calls=1, erase_calls=1) + self.assertEqual(value["media_state"], "erased") + self.assertTrue(value["workspace_attached"]) + self.assertTrue(value["complete"]) + self.assertFalse(value["fault"]) + self.assertEqual(len(state.constructions), 2) + self.assertEqual(len(state.formats), 1) + self.assertEqual([item[0] for item in media.read_calls], [0, 1]) + + def test_nonblank_refusal_is_measured_without_guessing_zero_writes(self): + media = Media(blank=False) + before = bytes(media.contents) + failure = OSError(errno.EIO, "incompatible") + vfs, state = vfs_fixture([failure]) + with self.assertRaises(OSError) as raised: + self.observe(media, vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assert_counts(value, mount_attempts=1, recovery_attempts=1, recovery_emissions=1) + self.assertEqual(value["media_state"], "nonblank") + self.assertFalse(value["workspace_attached"]) + self.assertTrue(value["complete"]) + self.assertFalse(value["fault"]) + self.assertEqual(bytes(media.contents), before) + self.assertEqual(state.formats, []) + + def test_idempotent_program_attempt_cannot_be_reported_as_zero(self): + media = Media(blank=False) + before = bytes(media.contents) + def constructor(bdev): + bdev.writeblocks(0, b"\xff" * 256) + raise OSError(errno.EIO, "incompatible after write attempt") + vfs, state = vfs_fixture([constructor]) + with self.assertRaises(OSError): + self.observe(media, vfs) + value = self.recover() + self.assertEqual(value["program_calls"], 1) + self.assertEqual(media.program_calls, 1) + self.assertEqual(bytes(media.contents), before) + self.assertEqual(state.formats, []) + + def test_failed_program_attempt_is_counted_before_backend_dispatch(self): + media = Media(blank=False) + failure = OSError(errno.EIO, "program failed") + media.program_error = failure + seen = [] + media.before_program = lambda: seen.append(self.snapshot()["program_calls"]) + def constructor(bdev): + bdev.writeblocks(1, b"x", 256) + vfs, state = vfs_fixture([constructor]) + with self.assertRaises(OSError) as raised: + self.observe(media, vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assertEqual(seen, [1]) + self.assertEqual(value["program_calls"], 1) + self.assertEqual(state.formats, []) + + def test_failed_erase_attempt_is_counted_before_backend_dispatch(self): + media = Media(blank=False) + failure = OSError(errno.EIO, "erase failed") + media.erase_error = failure + seen = [] + media.before_erase = lambda: seen.append(self.snapshot()["erase_calls"]) + def constructor(bdev): + bdev.ioctl(6, 1) + vfs, state = vfs_fixture([constructor]) + with self.assertRaises(OSError) as raised: + self.observe(media, vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assertEqual(seen, [1]) + self.assertEqual(value["erase_calls"], 1) + self.assertEqual(state.formats, []) + + def test_program_buffer_identity_and_optional_offset_are_forwarded(self): + media, _vfs, _state, mounted = self.healthy() + payload = bytearray(b"sample") + self.assertEqual(mounted.bdev.writeblocks(1, payload), 0) + self.assertIs(media.last_program_arguments[1], payload) + self.assertEqual(media.last_program_arguments[2], ()) + self.assertEqual(mounted.bdev.writeblocks(1, payload, 256), 0) + self.assertIs(media.last_program_arguments[1], payload) + self.assertEqual(media.last_program_arguments[2], (256,)) + self.assertEqual(self.snapshot()["program_calls"], 2) + + def test_readblocks_forwards_original_bound_method_without_observer_copy(self): + media, _vfs, _state, mounted = self.healthy() + self.assertEqual(mounted.bdev.readblocks, media.readblocks) + target = bytearray(8) + self.assertEqual(mounted.bdev.readblocks(1, target, 100), 0) + self.assertIs(media.read_calls[-1][1], target) + self.assertEqual(media.read_calls[-1][2], (100,)) + self.assertEqual(self.snapshot()["program_calls"], 0) + self.assertEqual(self.snapshot()["erase_calls"], 0) + + def test_nonmutating_ioctl_commands_do_not_increment_erase_count(self): + media, _vfs, _state, mounted = self.healthy() + for command in (1, 2, 3, 4, 5): + mounted.bdev.ioctl(command, 0) + self.assertEqual(self.snapshot()["erase_calls"], 0) + self.assertEqual(media.erase_calls, 0) + mounted.bdev.ioctl(6, 1) + self.assertEqual(self.snapshot()["erase_calls"], 1) + self.assertEqual(media.erase_calls, 1) + + def test_format_failure_has_attempt_without_completion_or_remount(self): + failure = OSError(errno.EIO, "format failed") + def failed_format(_bdev): + raise failure + vfs, state = vfs_fixture([OSError(errno.EIO, "blank")], format_action=failed_format) + with self.assertRaises(OSError) as raised: + self.observe(Media(), vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assert_counts(value, mount_attempts=1, format_attempts=1, + recovery_attempts=1, recovery_emissions=1) + self.assertTrue(value["fault"]) + self.assertEqual(len(state.constructions), 1) + self.assertEqual(len(state.formats), 1) + + def test_remount_failure_has_attempt_without_completion_or_second_format(self): + failure = OSError(errno.EIO, "remount failed") + vfs, state = vfs_fixture([OSError(errno.EIO, "blank"), failure]) + with self.assertRaises(OSError) as raised: + self.observe(Media(), vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assert_counts(value, mount_attempts=1, format_attempts=1, format_completions=1, + remount_attempts=1, recovery_attempts=1, recovery_emissions=1) + self.assertTrue(value["fault"]) + self.assertEqual(len(state.constructions), 2) + self.assertEqual(len(state.formats), 1) + + def test_geometry_fault_remains_uncertain_and_never_formats(self): + media = Media(geometry_error=OSError(errno.EIO, "geometry failed")) + vfs, state = vfs_fixture([OSError(errno.EIO, "blank")]) + with self.assertRaises(OSError): + self.observe(media, vfs) + value = self.recover() + self.assertEqual(value["media_state"], "uncertain") + self.assertTrue(value["fault"]) + self.assertEqual(value["format_attempts"], 0) + self.assertEqual(state.formats, []) + + def test_read_fault_remains_uncertain_and_never_formats(self): + media = Media(read_error=OSError(errno.EIO, "read failed")) + vfs, state = vfs_fixture([OSError(errno.EIO, "blank")]) + with self.assertRaises(OSError): + self.observe(media, vfs) + value = self.recover() + self.assertEqual(value["media_state"], "uncertain") + self.assertTrue(value["fault"]) + self.assertEqual(value["format_attempts"], 0) + self.assertEqual(state.formats, []) + + def test_unexpected_constructor_fault_is_not_erased_media_authority(self): + for failure in (MemoryError("constructor allocation"), RuntimeError("constructor bug")): + with self.subTest(error=type(failure).__name__): + self.workspace = fresh_workspace() + media = Media() + vfs, state = vfs_fixture([failure]) + with self.assertRaises(type(failure)) as raised: + self.observe(media, vfs) + self.assertIs(raised.exception, failure) + value = self.recover() + self.assertTrue(value["fault"]) + self.assertEqual(value["format_attempts"], 0) + self.assertEqual(state.formats, []) + self.assertEqual(media.read_calls, []) + + def test_snapshot_is_detached_and_challenge_echo_does_not_mutate_record(self): + self.healthy() + self.api("boot_attached")() + first = self.snapshot() + expected = dict(first) + first.update(mount_attempts=99, program_calls=0, complete=False, boot_id="0" * 32) + again = self.snapshot() + self.assertEqual(again, expected) + other = self.snapshot(SECOND_CHALLENGE) + expected["challenge"] = SECOND_CHALLENGE + self.assertEqual(other, expected) + + def test_challenge_must_be_exact_lowercase_128_bit_hex(self): + self.healthy() + self.api("boot_attached")() + before = self.snapshot() + getter = self.api("read_boot_observation") + for invalid in (None, True, 1, b"a" * 32, "", "a" * 31, "a" * 33, + "A" * 32, "g" * 32, "a" * 31 + "\n"): + with self.subTest(value=repr(invalid)), self.assertRaises((TypeError, ValueError)): + getter(invalid) + self.assertEqual(self.snapshot(), before) + + def test_sealed_record_does_not_count_later_user_storage_operations(self): + media, _vfs, _state, mounted = self.healthy() + self.api("boot_attached")() + before = self.snapshot() + mounted.bdev.writeblocks(0, b"user file", 0) + mounted.bdev.ioctl(6, 0) + self.assertEqual(media.program_calls, 1) + self.assertEqual(media.erase_calls, 1) + self.assertEqual(self.snapshot(), before) + + def test_second_observed_mount_cannot_replace_first_vm_record(self): + self.healthy() + self.api("boot_attached")() + before = self.snapshot() + vfs, state = vfs_fixture([None]) + with self.assertRaises(Exception): + self.observe(Media(), vfs) + self.assertEqual(state.constructions, []) + self.assertEqual(self.snapshot(), before) + + def test_unobserved_later_mount_cannot_replace_first_vm_record(self): + self.healthy() + self.api("boot_attached")() + before = self.snapshot() + media = Media() + vfs, state = vfs_fixture([None]) + mounted = self.workspace.mount_lfs2(media, vfs, progsize=256) + self.assertIs(mounted.bdev, media) + self.assertEqual(state.constructions, [(media, 256)]) + self.assertEqual(self.snapshot(), before) + + def test_repeated_attachment_or_recovery_cannot_change_sealed_record(self): + self.healthy() + self.api("boot_attached")() + before = self.snapshot() + for name in ("boot_attached", "boot_recovery"): + with self.subTest(api=name), mock.patch("builtins.print") as output: + with self.assertRaises(Exception): + self.api(name)() + output.assert_not_called() + self.assertEqual(self.snapshot(), before) + + def test_recovery_emission_is_counted_only_after_print_returns(self): + media = Media(blank=False) + vfs, _state = vfs_fixture([OSError(errno.EIO, "incompatible")]) + with self.assertRaises(OSError): + self.observe(media, vfs) + seen = [] + def output(message): + self.assertEqual(message, RECOVERY) + seen.append(self.snapshot()) + with mock.patch("builtins.print", side_effect=output): + self.api("boot_recovery")() + self.assertEqual(seen[0]["recovery_attempts"], 1) + self.assertEqual(seen[0]["recovery_emissions"], 0) + self.assertFalse(seen[0]["complete"]) + self.assertEqual(self.snapshot()["recovery_emissions"], 1) + + def test_recovery_print_failure_never_claims_successful_emission(self): + media = Media(blank=False) + vfs, _state = vfs_fixture([OSError(errno.EIO, "incompatible")]) + with self.assertRaises(OSError): + self.observe(media, vfs) + with mock.patch("builtins.print", side_effect=OSError(errno.EIO, "console failed")): + with self.assertRaises(OSError): + self.api("boot_recovery")() + value = self.snapshot() + self.assertEqual(value["recovery_attempts"], 1) + self.assertEqual(value["recovery_emissions"], 0) + self.assertTrue(value["fault"]) + + def test_attachment_failure_is_faulted_recovery_not_successful_mount(self): + self.healthy() + # The overlay calls recovery when the real vfs.mount raises, never + # boot_attached. A successful constructor is not attachment evidence. + value = self.recover() + self.assertFalse(value["workspace_attached"]) + self.assertTrue(value["fault"]) + self.assert_counts(value, mount_attempts=1, recovery_attempts=1, recovery_emissions=1) + + def test_boot_ids_are_new_for_distinct_module_vm_instances(self): + identities = [] + for _index in range(3): + self.workspace = fresh_workspace() + self.healthy() + self.api("boot_attached")() + identities.append(self.snapshot()["boot_id"]) + self.assertEqual(len(set(identities)), 3) + + def test_random_identity_failure_permits_no_constructor_or_format(self): + self.api("read_boot_observation") + media = Media() + vfs, state = vfs_fixture([OSError(errno.EIO, "blank"), None]) + with mock.patch("os.urandom", side_effect=OSError(errno.EIO, "entropy failed")): + with self.assertRaises(Exception): + self.observe(media, vfs) + self.assertEqual(state.constructions, []) + self.assertEqual(state.formats, []) + self.assertEqual(media.program_calls, 0) + self.assertEqual(media.erase_calls, 0) + try: + value = self.workspace.read_boot_observation(CHALLENGE) + except Exception: + return + self.assertTrue(value["fault"]) + self.assertFalse(value["complete"]) + + def test_missing_or_wrong_sized_entropy_does_not_create_a_valid_record(self): + self.api("read_boot_observation") + for entropy in (None, b"", b"a" * 15, b"a" * 17, "a" * 16): + with self.subTest(entropy=repr(entropy)): + self.workspace = fresh_workspace() + media = Media() + vfs, state = vfs_fixture([None]) + with mock.patch("os.urandom", return_value=entropy): + with self.assertRaises(Exception): + self.observe(media, vfs) + self.assertEqual(state.constructions, []) + self.assertEqual(state.formats, []) + + def test_entropy_failure_still_emits_one_fixed_recovery_line_without_record(self): + self.api("read_boot_observation") + vfs, _state = vfs_fixture([None]) + with mock.patch("os.urandom", side_effect=MemoryError("identity allocation")): + with self.assertRaises(Exception): + self.observe(Media(), vfs) + with mock.patch("builtins.print") as output: + self.api("boot_recovery")() + output.assert_called_once_with(RECOVERY) + with self.assertRaises(Exception): + self.workspace.read_boot_observation(CHALLENGE) + with mock.patch("builtins.print") as output: + with self.assertRaises(Exception): + self.workspace.boot_recovery() + output.assert_not_called() + + def test_counter_overflow_inside_initial_constructor_cannot_authorize_format(self): + media = Media() + def constructor(bdev): + for _index in range(65536): + try: + bdev.writeblocks(0, b"", 0) + except (RuntimeError, OverflowError): + break + raise OSError(errno.EIO, "mount failed after counter overflow") + vfs, state = vfs_fixture([constructor, None]) + with self.assertRaises(Exception): + self.observe(media, vfs) + value = self.snapshot() + self.assertTrue(value["overflow"]) + self.assertTrue(value["fault"]) + self.assertEqual(value["format_attempts"], 0) + self.assertEqual(state.formats, []) + self.assertEqual(len(state.constructions), 1) + + def test_program_counter_saturates_and_marks_unusable_observation(self): + _media, _vfs, _state, mounted = self.healthy() + for _index in range(65536): + try: + mounted.bdev.writeblocks(0, b"", 0) + except (RuntimeError, OverflowError): + break + value = self.snapshot() + self.assertEqual(value["program_calls"], 65535) + self.assertTrue(value["overflow"]) + self.assertTrue(value["fault"]) + + def test_erase_counter_saturates_and_marks_unusable_observation(self): + _media, _vfs, _state, mounted = self.healthy() + for _index in range(65536): + try: + mounted.bdev.ioctl(6, 0) + except (RuntimeError, OverflowError): + break + value = self.snapshot() + self.assertEqual(value["erase_calls"], 65535) + self.assertTrue(value["overflow"]) + self.assertTrue(value["fault"]) + + +class WorkspaceBootOverlayObservationTests(unittest.TestCase): + PROFILES = ("esp32", "esp32-s3", "esp32-c3", "waveshare-esp32-s3-lcd-147b", "rpi-pico2-w") + + def load(self, profile): + path = ROOT / "firmware/board_overlays" / profile / "_boot.py" + return path, ast.parse(path.read_bytes(), filename=str(path)) + + @staticmethod + def called(node): + return node.func.attr if isinstance(node.func, ast.Attribute) else getattr(node.func, "id", "") + + def test_all_five_profiles_explicitly_enable_observed_mount(self): + for profile in self.PROFILES: + with self.subTest(profile=profile): + _path, tree = self.load(profile) + calls = [node for node in ast.walk(tree) + if isinstance(node, ast.Call) and self.called(node) == "mount_lfs2"] + self.assertEqual(len(calls), 1) + observed = [item.value for item in calls[0].keywords if item.arg == "observe_boot"] + self.assertEqual(len(observed), 1, "[red] official overlay must opt into measured boot") + self.assertIsInstance(observed[0], ast.Constant) + self.assertIs(observed[0].value, True) + + def test_all_five_profiles_attach_and_recover_through_observer(self): + for profile in self.PROFILES: + with self.subTest(profile=profile): + _path, tree = self.load(profile) + calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)] + for name in ("boot_attached", "boot_recovery"): + self.assertEqual(sum(self.called(node) == name for node in calls), 1, + "[red] official overlay must call {} once".format(name)) + + def execute_mount_prefix(self, profile, *, failure=None): + path, tree = self.load(profile) + nodes = [] + for node in tree.body: + nodes.append(node) + if isinstance(node, ast.Try) and any( + isinstance(child, ast.Call) and self.called(child) == "mount_lfs2" + for child in ast.walk(node)): + break + else: + self.fail("Overlay lacks its guarded workspace mount block") + events = [] + media, filesystem = object(), object() + def mount(bdev, _vfs, *, progsize, observe_boot=False): + self.assertIs(bdev, media) + self.assertEqual(progsize, 256) + self.assertIs(observe_boot, True, "[red] overlay omitted observed boot") + events.append("construct") + if failure == "constructor": + raise OSError(errno.EIO, "nonblank incompatible") + return filesystem + def attach(fs, target): + self.assertIs(fs, filesystem) + self.assertEqual(target, "/") + events.append("attach") + if failure == "attachment": + raise OSError(errno.EIO, "attachment failed") + def sealed(): + events.append("seal") + if failure == "seal": + raise MemoryError("observation seal failed") + def recovery(): + events.append("recovery") + workspace = types.SimpleNamespace(mount_lfs2=mount, boot_attached=sealed, boot_recovery=recovery) + modules = { + "vfs": types.SimpleNamespace(mount=attach), + "flashbdev": types.SimpleNamespace(bdev=media), + "rp2": types.SimpleNamespace(Flash=lambda: media), + "pyble_boot": workspace, + "pyble_workspace": workspace, + } + namespace = {"__name__": "_overlay_boot_observation_test"} + code = compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec") + with mock.patch.dict(sys.modules, modules), mock.patch("builtins.print") as output: + exec(code, namespace) + output.assert_not_called() # only boot_recovery owns the fixed line + return events, namespace["workspace_ready"] + + def test_attachment_seals_only_after_successful_actual_vfs_mount(self): + for profile in self.PROFILES: + with self.subTest(profile=profile): + events, ready = self.execute_mount_prefix(profile) + self.assertEqual(events, ["construct", "attach", "seal"]) + self.assertTrue(ready) + + def test_all_mount_attachment_and_seal_failures_prevent_workspace_readiness(self): + for profile in self.PROFILES: + for failure, expected in ( + ("constructor", ["construct", "recovery"]), + ("attachment", ["construct", "attach", "recovery"]), + ("seal", ["construct", "attach", "seal", "recovery"])): + with self.subTest(profile=profile, failure=failure): + events, ready = self.execute_mount_prefix(profile, failure=failure) + self.assertEqual(events, expected) + self.assertFalse(ready) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/firmware_tests/host/test_workspace_read_status_v061.py b/tests/firmware_tests/host/test_workspace_read_status_v061.py new file mode 100644 index 00000000..146db07a --- /dev/null +++ b/tests/firmware_tests/host/test_workspace_read_status_v061.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Read-status admission must match the pinned MicroPython block protocol. + +Synthetic host tests only, not hardware evidence. In particular False is an +explicit failed read, although it compares equal to integer zero in Python. +""" + +import errno +import importlib.util +from pathlib import Path +import types +import unittest + + +SOURCE = Path(__file__).resolve().parents[3] / "firmware/pyble/pyble_workspace.py" +CHALLENGE = "0123456789abcdef0123456789abcdef" + + +def fresh_workspace(): + spec = importlib.util.spec_from_file_location("_workspace_read_status_test", SOURCE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class EqualsZero: + """A non-status object must not gain format authority through equality.""" + + def __eq__(self, other): + return type(other) is int and other == 0 + + +class Media: + def __init__(self, read_status): + self.contents = bytearray(b"\xff" * 8192) + self.read_status = read_status + self.reads = [] + self.programs = 0 + self.erases = 0 + + def readblocks(self, block, destination, *offset): + self.reads.append(block) + start = block * 4096 + (offset[0] if offset else 0) + destination[:] = self.contents[start:start + len(destination)] + # Even a fully populated FF buffer is not valid evidence when the + # device reports an error or an unsupported status type. + return self.read_status + + def writeblocks(self, block, source, *offset): + self.programs += 1 + start = block * 4096 + (offset[0] if offset else 0) + self.contents[start:start + len(source)] = source + return None + + def ioctl(self, command, argument): + if command == 4: + return 2 + if command == 5: + return 4096 + if command == 6: + self.erases += 1 + start = argument * 4096 + self.contents[start:start + 4096] = b"\xff" * 4096 + return 0 + + +def vfs_fixture(): + calls = types.SimpleNamespace(constructors=0, formats=0) + + class VfsLfs2: + def __new__(cls, bdev, *, progsize): + calls.constructors += 1 + if calls.constructors == 1: + raise OSError(errno.EIO, "initial mount failed") + return types.SimpleNamespace(bdev=bdev) + + @staticmethod + def mkfs(bdev, *, progsize): + calls.formats += 1 + bdev.ioctl(6, 0) + bdev.writeblocks(0, b"formatted", 0) + + return types.SimpleNamespace(VfsLfs2=VfsLfs2), calls + + +class WorkspaceReadStatusTests(unittest.TestCase): + def assert_admitted(self, status): + for observed in (False, True): + with self.subTest(observe_boot=observed): + workspace = fresh_workspace() + media = Media(status) + vfs, calls = vfs_fixture() + workspace.mount_lfs2(media, vfs, progsize=256, observe_boot=observed) + self.assertEqual(media.reads, [0, 1]) + self.assertEqual(calls.formats, 1) + self.assertEqual(calls.constructors, 2) + self.assertEqual((media.programs, media.erases), (1, 1)) + if observed: + workspace.boot_attached() + record = workspace.read_boot_observation(CHALLENGE) + self.assertEqual(record["media_state"], "erased") + self.assertTrue(record["complete"]) + self.assertTrue(record["workspace_attached"]) + self.assertFalse(record["fault"]) + self.assertFalse(record["overflow"]) + for key in ("mount_attempts", "format_attempts", "format_completions", + "remount_attempts", "remount_completions", "program_calls", "erase_calls"): + self.assertEqual(record[key], 1, key) + else: + with self.assertRaises(RuntimeError): + workspace.read_boot_observation(CHALLENGE) + + def assert_refused(self, status): + for observed in (False, True): + with self.subTest(observe_boot=observed): + workspace = fresh_workspace() + media = Media(status) + before = bytes(media.contents) + vfs, calls = vfs_fixture() + with self.assertRaises((ValueError, OSError, TypeError)): + workspace.mount_lfs2(media, vfs, progsize=256, observe_boot=observed) + self.assertEqual(media.reads, [0]) + self.assertEqual(calls.formats, 0) + self.assertEqual(calls.constructors, 1) + self.assertEqual((media.programs, media.erases), (0, 0)) + self.assertEqual(bytes(media.contents), before) + if observed: + record = workspace.read_boot_observation(CHALLENGE) + self.assertEqual(record["media_state"], "uncertain") + self.assertTrue(record["fault"]) + self.assertFalse(record["complete"]) + self.assertFalse(record["workspace_attached"]) + self.assertEqual(record["mount_attempts"], 1) + for key in ("format_attempts", "format_completions", "remount_attempts", + "remount_completions", "program_calls", "erase_calls"): + self.assertEqual(record[key], 0, key) + else: + with self.assertRaises(RuntimeError): + workspace.read_boot_observation(CHALLENGE) + + def test_none_is_success(self): + self.assert_admitted(None) + + def test_exact_integer_zero_is_success(self): + self.assert_admitted(0) + + def test_boolean_true_is_protocol_success(self): + self.assert_admitted(True) + + def test_boolean_false_is_failed_read_not_erased_authority(self): + self.assert_refused(False) + + def test_float_zero_is_not_a_read_status(self): + self.assert_refused(0.0) + + def test_custom_equality_to_zero_is_not_a_read_status(self): + self.assert_refused(EqualsZero()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/firmware_tests/test_build_matrix_rp2.sh b/tests/firmware_tests/test_build_matrix_rp2.sh index c825e4d7..befd1ff3 100755 --- a/tests/firmware_tests/test_build_matrix_rp2.sh +++ b/tests/firmware_tests/test_build_matrix_rp2.sh @@ -56,8 +56,8 @@ check_lock() { '^"rpi-pico2-w"[[:space:]]*=[[:space:]]*"RPI_PICO2_W"' "$LOCK" check_grep "versions.lock gains an [arm_gnu_toolchain] pin section (BLD-4 eq)" \ '^\[arm_gnu_toolchain\]' "$LOCK" - check_grep "combined source reserves agent version 0.6.0" \ - '^agent_version[[:space:]]*=[[:space:]]*"0\.6\.0"' "$LOCK" + check_grep "combined source reserves agent version 0.6.1" \ + '^agent_version[[:space:]]*=[[:space:]]*"0\.6\.1"' "$LOCK" # The ESP input sections remain exact (normalized to their key/value lines # so an appended rp2 comment block cannot false-fail this). diff --git a/tests/firmware_tests/test_firmware_roadmap_ledger.sh b/tests/firmware_tests/test_firmware_roadmap_ledger.sh new file mode 100755 index 00000000..4ab6ecfd --- /dev/null +++ b/tests/firmware_tests/test_firmware_roadmap_ledger.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -eu +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +exec "$PY" "$HERE/host/test_firmware_roadmap_ledger.py" diff --git a/tests/firmware_tests/test_no_leak.sh b/tests/firmware_tests/test_no_leak.sh index 1b660022..1067d727 100755 --- a/tests/firmware_tests/test_no_leak.sh +++ b/tests/firmware_tests/test_no_leak.sh @@ -62,6 +62,23 @@ run() { "$NOLEAK" "$clean" rm "$clean/firmware/ports/.arm-gnu/leak.h" + mkdir -p "$clean/firmware/.picotool/include" + printf '/* %s third-party build-tool symbol */\n' "$(tok_proto)" \ + > "$clean/firmware/.picotool/include/picotool.h" + check "no_leak PRUNES the exact pinned picotool root" "$NOLEAK" "$clean" + mkdir -p "$clean/firmware/ports/.picotool" + printf '/* %s authored leak */\n' "$(tok_product)" \ + > "$clean/firmware/ports/.picotool/leak.h" + check_fail "no_leak still SCANS picotool-lookalike authored directories" \ + "$NOLEAK" "$clean" + rm "$clean/firmware/ports/.picotool/leak.h" + mkdir -p "$clean/firmware/.picotool-cache" + printf '/* %s authored sibling leak */\n' "$(tok_product)" \ + > "$clean/firmware/.picotool-cache/leak.h" + check_fail "no_leak does not broadly prune .picotool siblings" \ + "$NOLEAK" "$clean" + rm -r "$clean/firmware/.picotool-cache" + # ---- Red fixture: a forbidden token in shippable source fails ------------ # Token assembled at runtime; written only into a temp tree. local dirty; dirty="$(mk_tmp)" diff --git a/tests/firmware_tests/test_pico_mount_recovery_v061.sh b/tests/firmware_tests/test_pico_mount_recovery_v061.sh new file mode 100755 index 00000000..eb9e981a --- /dev/null +++ b/tests/firmware_tests/test_pico_mount_recovery_v061.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# RED wrapper for the v0.6.1 Pico blank-only mount recovery contract. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +PY="${PYBLE_PYTHON:-python3}" +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH — required for Pico recovery tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_pico_mount_recovery_v061.py" diff --git a/tests/firmware_tests/test_pyble_console.sh b/tests/firmware_tests/test_pyble_console.sh index 4d55922b..7dda9fe5 100755 --- a/tests/firmware_tests/test_pyble_console.sh +++ b/tests/firmware_tests/test_pyble_console.sh @@ -4,7 +4,8 @@ # # Wrapper so run_tests.sh (which globs test_*.sh) drives the F-25 rpi-pico2-w # console host suite (port spec P3/P8: run-gate, [stream][<=200] chunking, -# token-bucket budget, readinto 1-or-None, 256 B stdin ring, inject_stop) +# token-bucket budget, readinto 1-or-None, run-scoped 256 B stdin ring, +# inject_stop) # under CPython. RED until agent-engineer lands firmware/pyble/pyble_console.py # [green]. Exit non-zero on any failure. diff --git a/tests/firmware_tests/test_pyble_device_config.sh b/tests/firmware_tests/test_pyble_device_config.sh index b04af7a2..e369817c 100755 --- a/tests/firmware_tests/test_pyble_device_config.sh +++ b/tests/firmware_tests/test_pyble_device_config.sh @@ -3,9 +3,8 @@ # Part of PyBLE (https://pyble.dev) — see /LICENSE. # # Wrapper so run_tests.sh (globs test_*.sh) drives the pyble_device_config host suite -# under CPython. [red] (Sprint S3) until the owning engineer lands the module -# [green]. DoR-BLOCKED banner is inside host/test_pyble_device_config.py — commit [red] -# only after the gating protocol.md §6/§7/§9 freezes + specs.md FR mirror land. +# under CPython. The file retains its original Sprint-S3 red→green provenance; +# the gating protocol sections and production module are now frozen/present. # Exit non-zero on any failure. set -u diff --git a/tests/firmware_tests/test_pyble_fs_v061.sh b/tests/firmware_tests/test_pyble_fs_v061.sh new file mode 100755 index 00000000..389c5bf7 --- /dev/null +++ b/tests/firmware_tests/test_pyble_fs_v061.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# RED wrapper for the v0.6.1 filesystem hardening increment. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +PY="${PYBLE_PYTHON:-python3}" +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH — required for v0.6.1 filesystem tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_pyble_fs_v061.py" diff --git a/tests/firmware_tests/test_pyble_fs_v061_native.sh b/tests/firmware_tests/test_pyble_fs_v061_native.sh new file mode 100755 index 00000000..05568d99 --- /dev/null +++ b/tests/firmware_tests/test_pyble_fs_v061_native.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# RED wrapper for native-C parity with v0.6.1 filesystem hardening semantics. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +PY="${PYBLE_PYTHON:-python3}" +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH — required for native FS parity tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_pyble_fs_v061_native.py" diff --git a/tests/firmware_tests/test_pyble_info.sh b/tests/firmware_tests/test_pyble_info.sh index e6639668..15302f8b 100755 --- a/tests/firmware_tests/test_pyble_info.sh +++ b/tests/firmware_tests/test_pyble_info.sh @@ -3,9 +3,8 @@ # Part of PyBLE (https://pyble.dev) — see /LICENSE. # # Wrapper so run_tests.sh (globs test_*.sh) drives the pyble_info host suite -# under CPython. [red] (Sprint S3) until the owning engineer lands the module -# [green]. DoR-BLOCKED banner is inside host/test_pyble_info.py — commit [red] -# only after the gating protocol.md §6/§7/§9 freezes + specs.md FR mirror land. +# under CPython. The file retains its original Sprint-S3 red→green provenance; +# the gating protocol sections and production module are now frozen/present. # Exit non-zero on any failure. set -u diff --git a/tests/firmware_tests/test_pyble_proto_version_guard.sh b/tests/firmware_tests/test_pyble_proto_version_guard.sh index 1dfdbaac..f6fe0a8f 100755 --- a/tests/firmware_tests/test_pyble_proto_version_guard.sh +++ b/tests/firmware_tests/test_pyble_proto_version_guard.sh @@ -3,9 +3,8 @@ # Part of PyBLE (https://pyble.dev) — see /LICENSE. # # Wrapper so run_tests.sh (globs test_*.sh) drives the pyble_proto_version_guard host suite -# under CPython. [red] (Sprint S3) until the owning engineer lands the module -# [green]. DoR-BLOCKED banner is inside host/test_pyble_proto_version_guard.py — commit [red] -# only after the gating protocol.md §6/§7/§9 freezes + specs.md FR mirror land. +# under CPython. The file retains its original Sprint-S3 red→green provenance; +# the gating protocol sections and production module are now frozen/present. # Exit non-zero on any failure. set -u diff --git a/tests/firmware_tests/test_pyble_runner.sh b/tests/firmware_tests/test_pyble_runner.sh index c34d85e0..f0015675 100755 --- a/tests/firmware_tests/test_pyble_runner.sh +++ b/tests/firmware_tests/test_pyble_runner.sh @@ -3,9 +3,8 @@ # Part of PyBLE (https://pyble.dev) — see /LICENSE. # # Wrapper so run_tests.sh (globs test_*.sh) drives the pyble_runner host suite -# under CPython. [red] (Sprint S3) until the owning engineer lands the module -# [green]. DoR-BLOCKED banner is inside host/test_pyble_runner.py — commit [red] -# only after the gating protocol.md §6/§7/§9 freezes + specs.md FR mirror land. +# under CPython. The file retains its original Sprint-S3 red→green provenance; +# the gating protocol sections and production module are now frozen/present. # Exit non-zero on any failure. set -u diff --git a/tests/firmware_tests/test_rp2_overlay_policy.sh b/tests/firmware_tests/test_rp2_overlay_policy.sh index 02b8ec9b..cf7ec383 100755 --- a/tests/firmware_tests/test_rp2_overlay_policy.sh +++ b/tests/firmware_tests/test_rp2_overlay_policy.sh @@ -71,6 +71,7 @@ expected = ( "pyble_info.py", "pyble_proto.py", "pyble_runner.py", + "pyble_workspace.py", ) tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) matches = [] diff --git a/tests/firmware_tests/test_spdx_header.sh b/tests/firmware_tests/test_spdx_header.sh index a1f1a597..285f76b2 100755 --- a/tests/firmware_tests/test_spdx_header.sh +++ b/tests/firmware_tests/test_spdx_header.sh @@ -47,6 +47,38 @@ run() { "$SPDX_LINT" "$tmp" rm "$tmp/firmware/ports/.arm-gnu/missing_header.h" + mkdir -p "$tmp/firmware/.picotool/include" + printf '/* pinned third-party picotool header */\n' \ + > "$tmp/firmware/.picotool/include/picotool.h" + check "spdx_lint PRUNES the exact pinned picotool root" \ + "$SPDX_LINT" "$tmp" + + mkdir -p "$tmp/firmware/ports/.picotool" + printf '/* authored source without an SPDX header */\n' \ + > "$tmp/firmware/ports/.picotool/missing_header.h" + check_fail "spdx_lint still SCANS picotool-lookalike authored directories" \ + "$SPDX_LINT" "$tmp" + rm "$tmp/firmware/ports/.picotool/missing_header.h" + + mkdir -p "$tmp/firmware/.picotool-cache" + printf '/* authored sibling without an SPDX header */\n' \ + > "$tmp/firmware/.picotool-cache/missing_header.h" + check_fail "spdx_lint does not broadly prune .picotool siblings" \ + "$SPDX_LINT" "$tmp" + rm -r "$tmp/firmware/.picotool-cache" + + mkdir -p "$tmp/firmware/licenses/evidence/rp2/picotool/2.3.0" + printf '/* exact unmodified third-party provenance evidence */\n' \ + > "$tmp/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h" + check "spdx_lint PRUNES only the exact libusb provenance header" \ + "$SPDX_LINT" "$tmp" + + printf '/* authored evidence lookalike without an SPDX header */\n' \ + > "$tmp/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version-local.h" + check_fail "spdx_lint still SCANS a provenance-header lookalike" \ + "$SPDX_LINT" "$tmp" + rm "$tmp/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version-local.h" + # A non-compliant source file: no header. printf 'print("no header here")\n' \ > "$tmp/firmware/pyble/missing_header.py" diff --git a/tests/firmware_tests/test_v061_config_hardening.sh b/tests/firmware_tests/test_v061_config_hardening.sh new file mode 100755 index 00000000..1c695f5a --- /dev/null +++ b/tests/firmware_tests/test_v061_config_hardening.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 config red tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_config_hardening.py" diff --git a/tests/firmware_tests/test_v061_native_hardening.sh b/tests/firmware_tests/test_v061_native_hardening.sh new file mode 100755 index 00000000..183d5bc1 --- /dev/null +++ b/tests/firmware_tests/test_v061_native_hardening.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 native red tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_native_hardening.py" diff --git a/tests/firmware_tests/test_v061_pble_wire_native.sh b/tests/firmware_tests/test_v061_pble_wire_native.sh new file mode 100755 index 00000000..5870edeb --- /dev/null +++ b/tests/firmware_tests/test_v061_pble_wire_native.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 pble_wire tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_pble_wire_native.py" diff --git a/tests/firmware_tests/test_v061_protocol_session.sh b/tests/firmware_tests/test_v061_protocol_session.sh new file mode 100755 index 00000000..1da3dfe9 --- /dev/null +++ b/tests/firmware_tests/test_v061_protocol_session.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 protocol/session tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_protocol_session.py" diff --git a/tests/firmware_tests/test_v061_reassembly_hardening.sh b/tests/firmware_tests/test_v061_reassembly_hardening.sh new file mode 100755 index 00000000..d0a151a9 --- /dev/null +++ b/tests/firmware_tests/test_v061_reassembly_hardening.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 reassembly tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_reassembly_hardening.py" diff --git a/tests/firmware_tests/test_v061_runner_stdin_hardening.sh b/tests/firmware_tests/test_v061_runner_stdin_hardening.sh new file mode 100755 index 00000000..74c5d777 --- /dev/null +++ b/tests/firmware_tests/test_v061_runner_stdin_hardening.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +PY="${PYBLE_PYTHON:-python3}" + +if ! command -v "$PY" >/dev/null 2>&1; then + printf ' FAIL - no CPython (%s) on PATH for v0.6.1 RUN/stdin red tests\n' "$PY" + exit 1 +fi + +exec "$PY" "$HERE/host/test_v061_runner_stdin_hardening.py" diff --git a/tests/publication/test_ci_contract.py b/tests/publication/test_ci_contract.py index 6d85d1dd..f15d8523 100644 --- a/tests/publication/test_ci_contract.py +++ b/tests/publication/test_ci_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re import unittest from pathlib import Path @@ -10,6 +11,107 @@ class CiContractTest(unittest.TestCase): + def test_pico_build_is_a_real_pinned_arm64_ci_job(self) -> None: + workflow = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text( + encoding="utf-8" + ) + self.assertIn( + " build-rp2:", + workflow, + "CI must define a distinct real Pico build job", + ) + start = workflow.index(" build-rp2:") + following = re.search( + r"(?m)^ [a-z0-9_-]+:\s*$", + workflow[start + len(" build-rp2:") :], + ) + end = ( + len(workflow) + if following is None + else start + len(" build-rp2:") + following.start() + ) + job = workflow[start:end] + + self.assertIn( + "needs: [no-leak, spdx, sha-drift, patches, firmware-host]", + job, + ) + self.assertIn("runs-on: macos-15", job) + self.assertRegex(job, r"timeout-minutes: (?:[3-9][0-9]|[1-9][0-9]{2,})") + self.assertRegex(job, r"uses: actions/checkout@[0-9a-f]{40}\b") + self.assertIn("fetch-depth: 0", job) + self.assertNotIn("submodules: true", job) + self.assertIn('test "$(uname -s)" = "Darwin"', job) + self.assertIn('test "$(uname -m)" = "arm64"', job) + + logical = re.sub(r"\\\n[ \t]*", " ", job) + outer_submodule = ( + "git submodule update --init --depth 1 -- " + "firmware/upstream/micropython" + ) + nested_prefix = ( + "git -C firmware/upstream/micropython submodule update " + "--init --depth 1 --" + ) + self.assertIn(outer_submodule, logical) + self.assertIn(nested_prefix, logical) + self.assertNotRegex( + logical, + r"git(?: -C [^ ]+)? submodule update[^\n]* --recursive\b", + ) + + for submodule in ( + "lib/btstack", + "lib/cyw43-driver", + "lib/lwip", + "lib/mbedtls", + "lib/micropython-lib", + "lib/pico-sdk", + "lib/tinyusb", + ): + with self.subTest(submodule=submodule): + self.assertIn(submodule, job) + + self.assertIn("firmware/scripts/install_arm_toolchain.sh", job) + self.assertIn("firmware/scripts/install_picotool.sh", job) + self.assertIn( + "firmware/scripts/build_rp2.sh rpi-pico2-w", + job, + ) + self.assertNotIn("build_rp2.sh --plan", job) + self.assertNotRegex(job, r"\bbrew\s+(?:install\s+)?picotool\b") + self.assertNotRegex(job, r"(?s)actions/cache@.*?firmware/\.picotool") + self.assertRegex(job, r"uses: actions/upload-artifact@[0-9a-f]{40}\b") + self.assertIn("if-no-files-found: error", job) + self.assertRegex(job, r"retention-days: [1-9][0-9]*") + for artifact in ( + "firmware.uf2", + "firmware.bin", + "firmware.elf", + "firmware.elf.map", + "pyble-build-provenance.json", + ): + with self.subTest(artifact=artifact): + self.assertIn( + f"firmware/build/rpi-pico2-w/{artifact}", + job, + ) + + ordered_tokens = ( + 'test "$(uname -m)" = "arm64"', + outer_submodule, + "firmware/scripts/install_arm_toolchain.sh", + "firmware/scripts/install_picotool.sh", + "firmware/scripts/build_rp2.sh rpi-pico2-w", + "uses: actions/upload-artifact@", + ) + positions = [logical.index(token) for token in ordered_tokens] + self.assertEqual( + positions, + sorted(positions), + "the isolated RP2 job prerequisite/build/publication order changed", + ) + def test_firmware_host_checks_out_full_history_and_pinned_submodules( self, ) -> None: diff --git a/tests/publication/test_public_claims.py b/tests/publication/test_public_claims.py index 94d10698..4a04cf33 100644 --- a/tests/publication/test_public_claims.py +++ b/tests/publication/test_public_claims.py @@ -83,12 +83,12 @@ def setUpClass(cls) -> None: REPO_ROOT / "tools" / "web" / "README.md" ).read_text(encoding="utf-8") - def test_readme_identifies_the_qualified_v060_release(self) -> None: + def test_readme_identifies_the_owner_confirmed_v061_release(self) -> None: firmware = markdown_section(self.readme, "What works") normalized = " ".join(firmware.split()) self.assertIn( - "qualified public v0.6.0 release", + "owner-confirmed public v0.6.1 release", normalized, ) self.assertIn("all five exact release profiles", normalized) @@ -100,7 +100,11 @@ def test_readme_identifies_the_qualified_v060_release(self) -> None: self.assertIn("`waveshare-esp32-s3-lcd-147b`", firmware) self.assertIn("`esp32-c3-4mb`", firmware) self.assertIn("`rpi-pico2-w`", firmware) - self.assertEqual(firmware.count("Qualified v0.6.0"), 5) + self.assertEqual(firmware.count("Owner-confirmed v0.6.1"), 5) + self.assertIn("automated qualification records remain incomplete", normalized) + self.assertIn("LFS2", firmware) + self.assertIn("fresh globals", normalized) + self.assertNotIn("All five exact-byte HIL rows passed", firmware) self.assertNotIn("v0.4.2 hardware-tested beta", firmware) self.assertNotIn("Planned; unavailable", firmware) self.assertNotIn("in-progress `0.6.0`", firmware) @@ -115,11 +119,11 @@ def test_readme_caption_describes_only_the_visible_app(self) -> None: self.assertIn("generated MicroPython", caption) self.assertNotRegex(caption, r"(?i)pictured|board|module") - def test_readme_try_steps_use_the_qualified_v060_release_safely(self) -> None: + def test_readme_try_steps_use_the_published_v061_release_safely(self) -> None: try_section = markdown_section(self.readme, "Try PyBLE") normalized = " ".join(try_section.split()) - self.assertIn("qualified v0.6.0 release", normalized) + self.assertIn("published v0.6.1 release", normalized) self.assertIn("four ESP profiles", normalized) self.assertIn("Web Serial", try_section) self.assertIn("Pico 2 W", try_section) @@ -131,27 +135,31 @@ def test_readme_try_steps_use_the_qualified_v060_release_safely(self) -> None: self.assertIn("back up", try_section) self.assertIn("Flashing erases the board", try_section) self.assertNotIn("v0.4.2 hardware-tested beta", try_section) - - def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: + self.assertIn("https://github.com/PyBLE-dev/examples", try_section) + self.assertIn("branch chooser", normalized) + self.assertIn("editable", normalized) + self.assertIn("/examples", try_section) + self.assertIn("pyble_hello_console.py", try_section) + self.assertIn("https://pyble.dev/learn", self.readme) + self.assertIn("https://pyble.dev/features", self.readme) + self.assertNotIn("examples/github-import", try_section) + + def test_current_public_surfaces_agree_on_v061_publication(self) -> None: combined = "\n".join( (self.home_page, self.site_copy, self.support_page, self.roadmap) ) for wording in ( - "v0.4.2", - "hardware-tested beta", - "browser install/recovery passed", - "release qualification pending", + "v0.6.1", + "owner-confirmed", "esp32-4mb", "esp32-s3-n16r8", + "esp32-c3-4mb", + "rpi-pico2-w", ): self.assertIn(wording, combined) - self.assertIn("Production Chrome install", combined) - self.assertIn("interrupted-flash recovery passed", combined) self.assertNotIn("full HIL pending", combined) self.assertNotIn("use it at your own risk", combined.lower()) - self.assertIn("ESP32-C3", combined) - self.assertRegex(combined, r"(?is)ESP32-C3.{0,180}unavailable") for stale in ( "public browser installer stays unavailable", "public installer is unavailable while v0.4.2 HIL runs", @@ -160,11 +168,29 @@ def test_current_public_surfaces_agree_on_beta_and_c3_state(self) -> None: ): self.assertNotIn(stale, combined) + available = " ".join( + markdown_section(self.roadmap, "Available now").split() + ) + self.assertIn("published firmware v0.6.1", available) + self.assertIn("owner confirmation", available) + self.assertIn("five exact profiles", available) + self.assertIn("Pico 2 W", available) + self.assertNotIn("v0.4.2", self.roadmap) + near_term = markdown_section(self.roadmap, "Near term") self.assertIn( - "Complete the app, PBLE/1, resource, and remaining firmware release", + "v0.7.0", near_term, ) + self.assertIn("automated qualification", near_term) + + def test_readme_distinguishes_app_source_from_store_availability(self) -> None: + firmware = markdown_section(self.readme, "What works") + normalized = " ".join(firmware.split()) + self.assertIn("0.2.0+8", firmware) + self.assertIn("does not establish a live store release", normalized) + self.assertIn("offline privacy policy", normalized) + self.assertIn("multi-file deletion", normalized) def test_production_browser_claim_is_bound_to_public_evidence(self) -> None: evidence = self.browser_validation @@ -230,31 +256,31 @@ def test_post_release_attestation_bounds_the_completed_hil_scope(self) -> None: def test_public_surfaces_link_the_release_evidence_and_changelog(self) -> None: self.assertIn( - "https://pyble.dev/firmware/v0.6.0/release.json", + "https://pyble.dev/firmware/v0.6.1/release.json", self.readme, ) self.assertIn( - "https://pyble.dev/firmware/v0.6.0/RELEASE_NOTES.md", + "https://pyble.dev/firmware/v0.6.1/RELEASE_NOTES.md", self.readme, ) self.assertIn( - "https://pyble.dev/firmware/v0.6.0/SHA256SUMS", + "https://pyble.dev/firmware/v0.6.1/SHA256SUMS", self.readme, ) self.assertIn( - "https://pyble.dev/firmware/v0.6.0/RECOVERY.md", + "https://pyble.dev/firmware/v0.6.1/RECOVERY.md", self.readme, ) self.assertIn( - "https://github.com/PyBLE-dev/PyBLE/tree/firmware-v0.6.0", + "https://github.com/PyBLE-dev/PyBLE/tree/c8f549eeabe6d2b8c2766eab022517944bb09c8c", self.readme, ) self.assertIn( - "c2940281a14feddb55c48de15ac18087e9317d1b7130e514fab5a209b046a1e6", + "71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd", self.readme, ) self.assertIn( - "0c7230d6708797c241160ba71fbd37e6b22f180a", + "https://pyble.dev/firmware-v0.6.1-owner-confirmation.md", self.readme, ) self.assertIn( @@ -301,7 +327,7 @@ def test_public_surfaces_link_the_release_evidence_and_changelog(self) -> None: ): self.assertNotIn(stale, unreleased) - def test_public_specifications_describe_the_exact_beta_without_overclaim( + def test_public_specifications_distinguish_beta_and_qualified_release( self, ) -> None: combined = "\n".join( @@ -339,8 +365,11 @@ def test_public_specifications_describe_the_exact_beta_without_overclaim( self.assertRegex( self.hardware_overview, - r"(?s)`esp32-4mb`.{0,240}hardware-tested beta.{0,200}" - r"`esp32-s3-n16r8`.{0,240}hardware-tested beta", + r"(?s)`esp32-4mb`.{0,240}Qualified in v0\.6\.0; current " + r"v0\.6\.1 source requires fresh exact-byte qualification\." + r".{0,240}`esp32-s3-n16r8`.{0,240}Qualified in v0\.6\.0; " + r"current v0\.6\.1 source requires independent exact-byte " + r"qualification\.", ) self.assertIn( "The exact v0.4.2 public-beta bundle covers exactly the two enabled, " diff --git a/tools/ci/no_leak.sh b/tools/ci/no_leak.sh index e481084f..28db24bc 100755 --- a/tools/ci/no_leak.sh +++ b/tools/ci/no_leak.sh @@ -62,6 +62,7 @@ if ! scan_forbidden_files < <( \( -path '*/upstream' \ -o -path '*/.esp-idf' \ -o -path "$ROOT/firmware/.arm-gnu" \ + -o -path "$ROOT/firmware/.picotool" \ -o -path '*/build' \ -o -path '*/__pycache__' \ -o -path '*/managed_components' \ diff --git a/tools/ci/spdx_lint.sh b/tools/ci/spdx_lint.sh index b26c79e1..3561441c 100755 --- a/tools/ci/spdx_lint.sh +++ b/tools/ci/spdx_lint.sh @@ -42,6 +42,9 @@ done < <(find "$ROOT" \ -o -path '*/firmware/.esp-idf' \ -o -path '*/.esp-idf' \ -o -path "$ROOT/firmware/.arm-gnu" \ + -o -path "$ROOT/firmware/.picotool" \ + -o -path "$ROOT/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version.h" \ + -o -path "$ROOT/firmware/licenses/evidence/rp2/picotool/2.3.0/libusb-version_nano.h" \ -o -path '*/.idf_tools' \ -o -path '*/.claude' \ -o -path '*/docs' \ diff --git a/tools/publication/prepare_google_play_assets.py b/tools/publication/prepare_google_play_assets.py new file mode 100644 index 00000000..07d58cf3 --- /dev/null +++ b/tools/publication/prepare_google_play_assets.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +"""Export store artwork and verified, unretouched Android screenshot copies.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def run(*args: object) -> str: + return subprocess.check_output([str(arg) for arg in args], text=True).strip() + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def properties(path: Path) -> tuple[int, int]: + frames, width, height, kind, depth = run( + "magick", "identify", "-ping", "-format", "%n %w %h %m %z", path + ).split() + if frames != "1" or kind != "PNG" or depth != "8": + raise ValueError(f"Expected one 8-bit PNG: {path}") + return int(width), int(height) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument( + "--capture-manifest", type=Path, + help="Local JSON: capture environment and screenshots with source, device_type, order, key, alt_text", + ) + args = parser.parse_args() + for tool in ("inkscape", "magick"): + if shutil.which(tool) is None: + parser.error(f"Required tool is missing: {tool}") + output = args.output_dir.resolve() + if not output.is_relative_to(ROOT / "local" / "publication"): + parser.error("Output must be below this repository's ignored local/publication/") + if output.exists(): + parser.error("Output already exists; select a new directory to preserve prior assets") + output.parent.mkdir(parents=True, exist_ok=True) + manifest: dict = { + "source_revision": run("git", "-C", ROOT, "rev-parse", "HEAD"), + "artwork": [], "screenshots": [], + } + with tempfile.TemporaryDirectory(prefix=".play-assets-", dir=output.parent) as tmp: + stage = Path(tmp) + icon_source = ROOT / "app/assets/branding/pyble-google-play-512.png" + if properties(icon_source) != (512, 512) or icon_source.stat().st_size > 1048576: + raise ValueError("Canonical Play icon must be 512x512 and at most 1024 KB") + icon = stage / "pyble-icon-512.png" + shutil.copyfile(icon_source, icon) + manifest["artwork"].append({ + "filename": icon.name, "source": str(icon_source.relative_to(ROOT)), + "sha256": digest(icon), "width": 512, "height": 512, + "alt_text": "PyBLE blue microcontroller with a white terminal prompt on a navy background.", + }) + feature_source = ROOT / "tools/publication/pyble-google-play-feature.svg" + rendered = stage / ".feature-render.png" + run("inkscape", feature_source, f"--export-filename={rendered}", + "--export-width=1024", "--export-height=500") + feature = stage / "pyble-feature-1024x500.png" + run("magick", rendered, "-alpha", "off", "-colorspace", "sRGB", + "-strip", "-depth", "8", "-define", "png:color-type=2", feature) + rendered.unlink() + if properties(feature) != (1024, 500): + raise ValueError("Feature graphic must be 1024x500") + manifest["artwork"].append({ + "filename": feature.name, "source": str(feature_source.relative_to(ROOT)), + "source_sha256": digest(feature_source), "sha256": digest(feature), + "width": 1024, "height": 500, + "alt_text": "PyBLE: MicroPython over Bluetooth, with a code prompt and wireless signal illustration.", + }) + if args.capture_manifest: + source_manifest = args.capture_manifest.resolve() + captures = json.loads(source_manifest.read_text()) + manifest["capture_environment"] = captures["environment"] + seen: set[str] = set() + for capture in captures["screenshots"]: + kind = capture["device_type"] + if kind not in ("phone", "tablet-7-inch", "tablet-10-inch"): + raise ValueError(f"Unexpected device type: {kind}") + key = capture["key"] + if not key or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in key): + raise ValueError(f"Invalid capture key: {key}") + order = int(capture["order"]) + if order < 1 or order > 8: + raise ValueError("Screenshot order must be between 1 and 8") + destination = Path(kind) / f"{order:02d}-{key}.png" + if str(destination) in seen: + raise ValueError(f"Duplicate capture: {destination}") + seen.add(str(destination)) + source = (source_manifest.parent / capture["source"]).resolve() + width, height = properties(source) + conversion_source = source + processing = "Metadata stripped; 24-bit PNG; unchanged RGB pixels; no crop or resize" + if "crop" in capture: + crop = capture["crop"] + x, y, crop_width, crop_height = ( + int(crop[name]) for name in ("x", "y", "width", "height") + ) + if (x < 0 or y < 0 or crop_width <= 0 or crop_height <= 0 + or x + crop_width > width or y + crop_height > height): + raise ValueError(f"Invalid system-bar crop: {source}") + conversion_source = stage / ".capture-crop.png" + geometry = f"{crop_width}x{crop_height}+{x}+{y}" + run("magick", source, "-crop", geometry, "+repage", conversion_source) + width, height = crop_width, crop_height + processing = ( + f"Exact system-bar crop {geometry}; metadata stripped; " + "24-bit PNG; unchanged cropped RGB pixels; no resize" + ) + if min(width, height) < 320 or max(width, height) > 3840: + raise ValueError(f"Screenshot dimensions outside Play limits: {source}") + if max(width, height) > min(width, height) * 2: + raise ValueError(f"Screenshot aspect ratio exceeds 2:1: {source}") + image = stage / destination + image.parent.mkdir(exist_ok=True) + run("magick", conversion_source, "-alpha", "off", "-strip", "-depth", "8", + "-define", "png:color-type=2", image) + # Screenshot content must survive conversion byte-for-pixel. + run("magick", "compare", "-metric", "AE", conversion_source, image, "null:") + if conversion_source != source: + conversion_source.unlink() + if len(capture["alt_text"]) > 140: + raise ValueError("Alt text exceeds 140 characters") + manifest["screenshots"].append({ + **capture, "source": str(source.relative_to(ROOT)), + "filename": str(destination), "source_sha256": digest(source), + "sha256": digest(image), "width": width, "height": height, + "processing": processing, + }) + (stage / "asset-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + assets = manifest["artwork"] + manifest["screenshots"] + (stage / "SHA256SUMS").write_text("".join( + f"{asset['sha256']} {asset['filename']}\n" for asset in assets + )) + shutil.copytree(stage, output) + print(f"Prepared {len(assets)} graphics in {output}") + + +if __name__ == "__main__": + main() diff --git a/tools/publication/pyble-google-play-feature.svg b/tools/publication/pyble-google-play-feature.svg new file mode 100644 index 00000000..96fb4201 --- /dev/null +++ b/tools/publication/pyble-google-play-feature.svg @@ -0,0 +1,43 @@ +<!-- SPDX-License-Identifier: MIT --> +<!-- Part of PyBLE (https://pyble.dev) — see /LICENSE. --> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + width="1024" height="500" viewBox="0 0 1024 500" role="img" + aria-labelledby="title description"> + <title id="title">PyBLE — MicroPython over Bluetooth + PyBLE branding beside a code and wireless signal illustration. Editor, Blocks, Console and Files. + + + + + + + + + + + + + + PyBLE + + MicroPython. + Over Bluetooth. + + Build ideas on a compatible board. + Editor · Blocks · Console · Files + + + + + + print('Hello!') + + + diff --git a/tools/web/.prettierignore b/tools/web/.prettierignore index b3bbaeb9..d9be5b35 100644 --- a/tools/web/.prettierignore +++ b/tools/web/.prettierignore @@ -8,3 +8,6 @@ coverage/ dist/ public/brand/ package-lock.json +# Byte-for-byte source snapshots; their provenance depends on unchanged bytes. +public/reference/firmware-v0.6.1/protocol.md +public/AGENTS.md diff --git a/tools/web/DEPLOYMENT.md b/tools/web/DEPLOYMENT.md index be74dfd8..4a0f201d 100644 --- a/tools/web/DEPLOYMENT.md +++ b/tools/web/DEPLOYMENT.md @@ -46,6 +46,12 @@ Review at least: - `/support`, including the public contact address; - `/flash`, confirming the install button is still disabled unless the firmware release gate is fully satisfied; +- `/features`, confirming its v0.6.0 snapshot label, semantic feature reference, + full-size diagram link, and clear boundary back to `/flash`; +- the reviewed functional diagram at + `/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg`, + including selectable text, useful title/description, two-dimensional + inspection, and no generated board likeness or external image reference; - `/learn` and all ten ordered tutorials: setup, first program, Files, GitHub import, Blocks, examples, hardware safety, configured hardware, Pico 2 W, and the Waveshare LCD 1.47B; @@ -345,11 +351,14 @@ curl -fsSI https://pyble.dev/app curl -fsSI https://pyble.dev/privacy curl -fsSI https://pyble.dev/support curl -fsSI https://pyble.dev/flash +curl -fsSI https://pyble.dev/features +curl -fsSI https://pyble.dev/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg for route in learn learn/setup learn/first-program learn/files \ learn/github-import learn/blocks learn/examples learn/hardware \ learn/configured-hardware learn/pico-2-w learn/waveshare-lcd-147b; do curl -fsSI "https://pyble.dev/${route}" done +curl -fsSI 'https://pyble.dev/features/?source=redirect-check' curl -fsSI 'https://pyble.dev/learn/setup/?source=redirect-check' curl -fsSI 'https://www.pyble.dev/privacy?source=redirect-check' curl -fsSI 'https://pyble.org/privacy?source=redirect-check' @@ -365,6 +374,10 @@ Confirm: - HTML returns `Cache-Control: no-cache, no-transform`, remains byte-identical to the checked export through Cloudflare, and revalidates while `/_next/static/` assets are immutable; +- `/features` is byte-identical to `out/features.html`; its fingerprinted SVG + is byte-identical to the reviewed `out/` asset and returns `image/svg+xml`; +- `/features/?source=redirect-check` returns `308` to the slashless canonical + URL with its query string intact; - all eleven Learn documents are byte-identical to their nested `out/` files, each slash redirect returns `308` with its query intact, and an unknown Learn pathname returns the authored `out/404.html` body with status `404`; diff --git a/tools/web/README.md b/tools/web/README.md index fe2ed2f0..295d1aea 100644 --- a/tools/web/README.md +++ b/tools/web/README.md @@ -7,9 +7,12 @@ The statically authored Next.js site for `pyble.dev`. It explains the PyBLE workflow and capability-defined board vision, distinguishes that vision from the exact profiles available today, publishes privacy and support information, hosts the policy-gated browser installer, and publishes the static PyBLE -learning center. The production selector currently offers qualified public -firmware v0.6.0 for five exact profiles: `esp32-4mb`, `esp32-s3-n16r8`, -`waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, and `rpi-pico2-w`. +learning center. Its release-bound firmware reference presents an original +functional diagram and an equivalent reflowing explanation without depicting +fabricated boards or pinouts. The production selector currently offers +qualified public firmware v0.6.0 for five exact profiles: `esp32-4mb`, +`esp32-s3-n16r8`, `waveshare-esp32-s3-lcd-147b`, `esp32-c3-4mb`, and +`rpi-pico2-w`. ## Why Next.js @@ -28,14 +31,15 @@ introduce a second page source or an application backend. ## Routes -| Route | Purpose | -| ---------- | -------------------------------------------------------------------------- | -| `/` | Product story, verified capabilities, platform vision, and current targets | -| `/app` | Stable app landing page for iPad and Android testing channels | -| `/privacy` | Separate app and website privacy disclosures | -| `/support` | Beta quick start, troubleshooting, and report checklist | -| `/flash` | Fail-closed Web Serial installer, exact profiles, and recovery | -| `/learn` | Ordered tutorial hub, compatibility legend, and learning tracks | +| Route | Purpose | +| ----------- | --------------------------------------------------------------------------- | +| `/` | Product story, verified capabilities, platform vision, and current targets | +| `/app` | Stable app landing page for iPad and Android testing channels | +| `/privacy` | Separate app and website privacy disclosures | +| `/support` | Beta quick start, troubleshooting, and report checklist | +| `/flash` | Fail-closed Web Serial installer, exact profiles, and recovery | +| `/features` | Release-bound firmware architecture, operations, profiles, limits, evidence | +| `/learn` | Ordered tutorial hub, compatibility legend, and learning tracks | The ten statically authored tutorials are `/learn/setup`, `/learn/first-program`, `/learn/files`, `/learn/github-import`, @@ -45,6 +49,13 @@ The ten statically authored tutorials are `/learn/setup`, the sitemap, delegated explicitly by the Sites adapter, and served by an exact Nginx mapping. Unknown `/learn/*` paths retain the authored static 404. +`/features` is a static, versioned explanation of qualified firmware v0.6.0; +it does not replace `/flash` as the authority for what can be installed now. +Its reviewed same-origin SVG remains available at a single exact, fingerprinted +path and is accompanied by equivalent semantic HTML for small screens and +assistive technology. The adapter deliberately delegates only that asset under +the `/features/` namespace, so an unknown feature path retains the static 404. + `pyble.dev` is canonical. `pyble.org` is redirected at the hosting edge, not by this static application. diff --git a/tools/web/deploy/nginx/10-pyble-dev-https.conf b/tools/web/deploy/nginx/10-pyble-dev-https.conf index 64c530a6..aa12cebf 100644 --- a/tools/web/deploy/nginx/10-pyble-dev-https.conf +++ b/tools/web/deploy/nginx/10-pyble-dev-https.conf @@ -76,6 +76,12 @@ server { try_files /flash.html =404; } + location = /features { + include /etc/nginx/snippets/pyble-security-headers.conf; + add_header Cache-Control "no-cache, no-transform" always; + try_files /features.html =404; + } + location = /learn { include /etc/nginx/snippets/pyble-security-headers.conf; add_header Cache-Control "no-cache, no-transform" always; @@ -158,6 +164,10 @@ server { return 308 https://pyble.dev/flash$is_args$args; } + location = /features/ { + return 308 https://pyble.dev/features$is_args$args; + } + location = /learn/ { return 308 https://pyble.dev/learn$is_args$args; } diff --git a/tools/web/deploy/vps/deploy.sh b/tools/web/deploy/vps/deploy.sh index 603596e1..fa1cfab8 100755 --- a/tools/web/deploy/vps/deploy.sh +++ b/tools/web/deploy/vps/deploy.sh @@ -835,6 +835,7 @@ for required_file in \ privacy.html \ support.html \ flash.html \ + features.html \ learn.html \ learn/setup.html \ learn/first-program.html \ @@ -1134,13 +1135,14 @@ cleanup_smoke() { } trap cleanup_smoke EXIT -for route in / /app /privacy /support /flash /learn /learn/setup /learn/first-program /learn/files /learn/github-import /learn/blocks /learn/examples /learn/hardware /learn/configured-hardware /learn/pico-2-w /learn/waveshare-lcd-147b; do +for route in / /app /privacy /support /flash /features /learn /learn/setup /learn/first-program /learn/files /learn/github-import /learn/blocks /learn/examples /learn/hardware /learn/configured-hardware /learn/pico-2-w /learn/waveshare-lcd-147b; do case "${route}" in /) route_file=index.html ;; /app) route_file=app.html ;; /privacy) route_file=privacy.html ;; /support) route_file=support.html ;; /flash) route_file=flash.html ;; + /features) route_file=features.html ;; /learn) route_file=learn.html ;; /learn/setup) route_file=learn/setup.html ;; /learn/first-program) route_file=learn/first-program.html ;; @@ -1171,6 +1173,48 @@ for route in / /app /privacy /support /flash /learn /learn/setup /learn/first-pr "${normalized_headers}" done +feature_diagram_path=features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg +feature_diagram_body="${smoke_root}/feature-diagram.svg" +feature_diagram_headers="${smoke_root}/feature-diagram.headers" +curl --fail --silent --show-error --max-time 30 \ + --location --max-redirs 0 --proto '=https' \ + --dump-header "${feature_diagram_headers}" \ + --output "${feature_diagram_body}" \ + "https://pyble.dev/${feature_diagram_path}" +cmp -- "out/${feature_diagram_path}" "${feature_diagram_body}" +feature_diagram_normalized_headers="${feature_diagram_headers}.normalized" +tr -d '\r' < "${feature_diagram_headers}" > \ + "${feature_diagram_normalized_headers}" +if ! grep -iq '^Content-Type: *image/svg+xml' \ + "${feature_diagram_normalized_headers}"; then + printf 'Feature diagram smoke failed: expected image/svg+xml Content-Type.\n' >&2 + reject_post_activation_smoke 66 +fi + +feature_redirect_headers="${smoke_root}/features-redirect.headers" +readonly feature_redirect_location='https://pyble.dev/features?source=redirect-check' +readonly feature_redirect_status=$( + curl --silent --show-error --max-time 30 \ + --max-redirs 0 --proto '=https' \ + --dump-header "${feature_redirect_headers}" \ + --output /dev/null \ + --write-out '%{http_code}' \ + 'https://pyble.dev/features/?source=redirect-check' +) +if [[ "${feature_redirect_status}" != 308 ]]; then + printf 'Features redirect smoke failed: expected 308, received %s.\n' \ + "${feature_redirect_status}" >&2 + reject_post_activation_smoke 66 +fi +feature_redirect_normalized_headers="${feature_redirect_headers}.normalized" +tr -d '\r' < "${feature_redirect_headers}" > \ + "${feature_redirect_normalized_headers}" +if ! grep -Fiqx "Location: ${feature_redirect_location}" \ + "${feature_redirect_normalized_headers}"; then + printf 'Features redirect smoke failed: canonical query-preserving Location is missing.\n' >&2 + reject_post_activation_smoke 66 +fi + if [[ "${expected_installer_state}" == active ]]; then test -f out/.pyble-firmware-release-selection.json actual_selection_digest=$( diff --git a/tools/web/package-lock.json b/tools/web/package-lock.json index dbd3e73a..6dc30379 100644 --- a/tools/web/package-lock.json +++ b/tools/web/package-lock.json @@ -858,6 +858,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -874,6 +877,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -890,6 +896,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -906,6 +915,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -922,6 +934,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -938,6 +953,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -954,6 +972,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -970,6 +991,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -986,6 +1010,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1008,6 +1035,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1030,6 +1060,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1052,6 +1085,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1074,6 +1110,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1096,6 +1135,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1118,6 +1160,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1140,6 +1185,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1429,6 +1477,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1445,6 +1496,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1461,6 +1515,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1477,6 +1534,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ diff --git a/tools/web/public/AGENTS.md b/tools/web/public/AGENTS.md new file mode 100644 index 00000000..1efa721a --- /dev/null +++ b/tools/web/public/AGENTS.md @@ -0,0 +1,96 @@ +# AGENTS.md — PyBLE contributor guidelines + +These rules apply to humans and automated contributors working in this +repository. + +## Project identity + +**PyBLE** (“Python over BLE”) is a free, MIT-licensed, tablet-first +MicroPython IDE. The Flutter app communicates over Bluetooth Low Energy with a +compatible board running MicroPython and the portable PyBLE agent. + +- App: `app/` +- Agent firmware: `firmware/` +- Open wire protocol: PBLE/1, specified in + `docs/specifications/protocol.md` +- Initial validated ports: `esp32`, `esp32-s3`, and `esp32-c3` + +Support is capability-defined, not tied to one chip family. A new port needs a +maintained PBLE/1 agent, BLE GATT peripheral support, sufficient resources, and +the project’s conformance and hardware-validation gates. + +## Open-source and clean-room boundary + +1. All PyBLE-authored source is MIT licensed. +2. Author changes fresh. Do not paste proprietary or unknown-licensed source, + protocols, UUIDs, board profiles, examples, or product-specific pedagogy. +3. Keep PyBLE identifiers independent: `PBLE/1`, `pble_*`, `pyble_*`, the + PyBLE UUID base, and the `PyBLE-` advertising prefix. +4. Run `tools/ci/no_leak.sh` before committing. The gate is the canonical, + tested list of prohibited identifiers in shippable source. +5. Third-party dependencies remain under their own licenses and must be + recorded in the relevant dependency and notice files. + +The pinned, gitignored compiler/tool trees at `firmware/.esp-idf`, +`firmware/.arm-gnu`, and `firmware/.picotool` are third-party build inputs, +not shippable source. Source gates prune those exact roots while continuing to +scan similarly named authored directories. + +## Spec-driven and test-driven development + +1. `docs/specifications/` defines public behavior and architecture. +2. Change the relevant specification before implementation when a contract + changes. +3. Work red → green → refactor: + - add a failing test in a `[red]` commit; + - implement the minimum passing change in a `[green]` commit; + - clean up in a `[refactor]` commit while tests remain green. +4. No behavior is complete without an automated test. Hardware behavior also + needs the documented HIL evidence. +5. Sign off every commit for DCO compliance: `git commit -s`. + +Allowed commit prefixes are `[red]`, `[green]`, `[refactor]`, `[docs]`, +`[build]`, and `[chore]`. + +## Repository layout + +Keep the repository root limited to governance files and these directories: + +```text +.github/ app/ docs/ firmware/ protocol/ examples/ tests/ tools/ +``` + +Root governance files are: + +```text +README.md LICENSE CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md +CHANGELOG.md CITATION.cff AGENTS.md CLAUDE.md .gitignore .gitmodules +``` + +Put specifications and decisions under `docs/`, scripts under `tools/` or +`firmware/scripts/`, and examples under `examples/`. Keep raw or unreviewed +screenshots, signing material, builds, assistant state, and local notes out of +Git. A deliberately reviewed, provenance-recorded image used by the public +website may live below `tools/web/public/`; its raw capture must remain in an +ignored workspace. + +## Naming + +- Product and wordmark: **PyBLE** +- Domain: `pyble.dev` +- Protocol: **PBLE/1** +- Native modules: `pyble_agent`, `pyble_ble`, `pyble_runner`, `pyble_fs`, + and related `pyble_*` / `pble_*` files +- Flutter packages: `lib/ble/`, `lib/pble/`, `lib/editor/`, `lib/console/`, + `lib/files/`, `lib/blocks/`, and `lib/localization/` + +## Hard boundaries + +- Do not edit upstream MicroPython or Blockly in place. Pin upstream as + submodules; isolate any unavoidable MicroPython patch under + `firmware/patches/` with a written reason. +- Do not make USB serial or Wi-Fi the primary workflow. PyBLE is BLE-first. +- Do not introduce paid or closed features. +- Do not ship compiled `.mpy` examples; use readable `.py` files. +- Do not commit credentials, signing assets, firmware binaries, IPA files, or + generated build directories. diff --git a/tools/web/public/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg b/tools/web/public/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg new file mode 100644 index 00000000..ba49e75b --- /dev/null +++ b/tools/web/public/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg @@ -0,0 +1,303 @@ + + + + PyBLE firmware 0.6.0 PBLE/1 functional block diagram + A functional architecture diagram, not a board drawing or pinout. The PyBLE app communicates over Bluetooth Low Energy through the PBLE/1 transport and protocol engine to identity, filesystem, runner, console, boot, and lifecycle modules in a compatible MicroPython device. A lower strip lists the five exact HIL-passed release profiles and their constraints. + + + + + + + + + + + + + + + + + + + + + + QUALIFIED PUBLIC RELEASE · PBLE/1 + PyBLE firmware 0.6.0 + One embedded PBLE/1 control plane · five exact HIL-passed profiles + + + + 24 PBLE/1 OPCODES + + 5/5 HIL-PASSED PROFILES + + PYBLE: MIT · NO AGENT TELEMETRY + + + Functional architecture — not a board drawing, board detector, schematic, or pinout. + + + + + + PyBLE app + iPadOS · Android + • Discover and read capabilities + • List, transfer and manage files + • Run · stop · soft reboot + • Live stdout · stderr · stdin + • Persist label and autorun + • Configure Identify on ESP + + BLE-FIRST AFTER SETUP + + + One-time provisioning + ESP profiles + Desktop Chromium · Web Serial + Pico 2 W + Verified UF2 · manual BOOTSEL + ERASES INSTALLED FIRMWARE + WORKSPACE + + + + + + COMPATIBLE MICROPYTHON + BLE DEVICE · QUALIFIED PYBLE IMAGE + AGENT EMBEDDED / FROZEN + + + + + + RX + TX + INFO + + + + + + + + + + + + + + + BLE GATT transport + pyble_ble + Advertising · service UUID · local name + RX Write / Write Without Response + TX Notify · INFO Read + Preferred MTU 247 · valid down to 23 + Fragment reassembly · MTU-aware chunks + + + Identity & capabilities + pyble_info + HELLO first · DEVICE_INFO · INFO value + 15 emitted capability keys + Stable device_id · label ≤ 24 bytes + PyBLE-XXXX or persisted user label + Optional GPIO Identify · ESP profiles only + + + Boot & persistence + pyble_agent + Agent starts independently of workspace + Advertise and wait by default + Optional /main.py autorun · default off + Bad user code cannot block advertising + Persistent label · autorun · ESP Identify + + + Security & privacy boundary + PBLE/1 v1 + No application authentication · client trusted + ESP Just Works pairing/bonding is not access-gating + Identity and label never authorize operations + Advertised label is public · avoid personal data + PyBLE agent sends no telemetry and needs no cloud service + + + + PBLE/1 protocol engine + pyble_proto + VER 1 · CMD / RSP / EVT · request IDs · CRC32 frames · opcode dispatch · OK + 11 error statuses + INFO 01–02 · FILES 10–1A + 41 · RUN 20–23 + 40 · CONSOLE 30–31 · IDENTITY 50–52 + + 24 OPCODES + + + + Filesystem bridge + pyble_fs + LIST · STAT · offset GET + whole-file CRC + Windowed PUT · ACK · resume + .pbltmp temp · CRC verify · rename + DELETE file / empty dir · MKDIR · RENAME + One transfer · 128-byte path · workspace jail + .mpy / .pyc rejected · .py + data + + + Run & control + pyble_runner + RUN file path ≤ 128 bytes + RUN inline source ≤ 2,048 bytes + One program · idle / running / done / error + Idempotent STOP · runner-only interrupt + SOFT_REBOOT · response before side effect + Stoppable /main.py autorun · opt-in only + + + Live console + pyble_console + stdout · stderr stream tags + stdin for input() / sys.stdin + 200-byte output chunks + 256-byte input staging + Paced; flood output may drop + Connection-session isolation + + + Lifecycle & reliability + agent control plane + BLE remains responsive while user code runs + Bounded queues, buffers, retry and congestion work + Connection generation + VM epoch binding + Stale responses, events and work are discarded + CRC, resume, atomic commit and recovery evidence + Pico filesystem transfer during RUN → EBUSY + + + + + + + + + + + + + Upstream MicroPython v1.28.0 · normal user runtime + zero PyBLE upstream patches + + + User workspace VFS + /main.py · /lib/*.py · ordinary data + FAT on ESP · LFS2 on Pico · has_sd=0 + PBLE jail does not restrict user os/VFS APIs + + + User program / VM + machine · os · VFS · asyncio on every profile + Explicit pins, buses and hardware-safe user code + micropython.kbd_intr(-1) can defeat remote STOP + + + Standard hardware APIs + machine APIs available on the selected port/board + Explicit pins and buses through user code only + No agent GPIO routing, pin map or detection + + + Explicit bundled extras + ESP: upstream NeoPixel module + Waveshare exact image: ST7789 + boot splash + No display opcode · generic S3 omits display code + + + + INSTALL EXACT QUALIFIED IMAGE + + + EXACT RELEASE PROFILES · TEXT CONSTRAINTS, NOT PHYSICAL BOARD APPEARANCE + + Shared: PBLE/1 · MicroPython v1.28.0 · capabilities · files · safe Run/Stop · console · exact-byte HIL passed + MTU 247 observed · chunk 229 + + + + + ESP profiles · native C agent · NimBLE · W=8 · FAT · ESP Web Serial · upstream NeoPixel + + + esp32-4mb + Classic ESP32 + Exactly 4 MiB flash + No PSRAM required + Approx. 2 MiB FAT workspace + + + esp32-s3-n16r8 + ESP32-S3 · 16 MiB flash + 8 MiB Octal PSRAM + Lean, board-neutral N16R8 image + No TFT driver or splash + + + waveshare-esp32-s3-lcd-147b + Exact LCD 1.47B B-version · N16R8 + pyble_st7789 display runtime + Fresh-install splash enabled + Persistently disableable + + + esp32-c3-4mb + ESP32-C3 revision v0.3+ + Exactly 4 MiB flash + No PSRAM required + Approx. 2 MiB FAT workspace + + + + + Pico profile · portable frozen-Python agent + rpi-pico2-w + RP2350 + CYW43439 · BTstack · W=4 · LFS2 + Verified UF2 download + manual BOOTSEL copy + Named LED usable from user code + Identify and NeoPixel are not claimed in v0.6.0 + Filesystem transfer during active RUN → EBUSY + + + + IMPORTANT + Profiles describe compatible image constraints, not carrier-board appearance or pin maps. Matching N16R8 memory does not identify a display board. + Only the explicitly selected Waveshare B-version image includes ST7789/splash. Installation is destructive; everyday use after setup is BLE-first. + + Public firmware v0.6.0 · PBLE/1 · source and complete machine-readable release evidence: pyble.dev/firmware/v0.6.0/ + Original PyBLE diagram · no vendor artwork or board renderings + diff --git a/tools/web/public/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg b/tools/web/public/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg new file mode 100644 index 00000000..064fa364 --- /dev/null +++ b/tools/web/public/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg @@ -0,0 +1,311 @@ + + + + PyBLE firmware 0.6.1 PBLE/1 functional block diagram + A functional architecture diagram, not a board drawing or pinout. The PyBLE app communicates over Bluetooth Low Energy through the PBLE/1 transport and protocol engine to identity, filesystem, runner, console, boot, and lifecycle modules in a compatible MicroPython device. A lower strip lists the five exact owner-confirmed release profiles and their constraints. + + + + + + + + + + + + + + + + + + + + + + CONTRACT HARDENING RELEASE · PBLE/1 + PyBLE firmware 0.6.1 + One embedded PBLE/1 control plane · five exact board profiles + + + + 24 PBLE/1 OPCODES + + OWNER-CONFIRMED RELEASE + + PYBLE: MIT · NO AGENT TELEMETRY + + + Functional architecture — not a board drawing, board detector, schematic, or pinout. + + + + + + PyBLE app + iPadOS · Android + • Discover and read capabilities + • List, transfer and manage files + • Run · stop · soft reboot + • Live stdout · stderr · stdin + • Persist label and autorun + • Configure Identify on ESP + + BLE-FIRST AFTER SETUP + + + One-time provisioning + ESP profiles + Desktop Chromium · Web Serial + Pico 2 W + Verified UF2 · manual BOOTSEL + BACK UP FIRST · INSTALLATION ERASES DATA + + + + v0.6.1 hardening + Same PBLE/1 operation set + Fresh RUN globals + clean stdin + CRC-backed upload resume + LFS2 · erased-only first-use format + + + + + + COMPATIBLE MICROPYTHON + BLE DEVICE · PYBLE v0.6.1 IMAGE + AGENT EMBEDDED / FROZEN + + + + + + RX + TX + INFO + + + + + + + + + + + + + + + BLE GATT transport + pyble_ble + Advertising · service UUID · local name + RX Write / Write Without Response + TX Notify · INFO Read + Preferred MTU 247 · valid down to 23 + 5 s fragment deadline · bounded violations + + + Identity & capabilities + pyble_info + HELLO per session · DEVICE_INFO · INFO + 15 emitted capability keys + Stable device_id · label ≤ 24 bytes + PyBLE-XXXX or persisted user label + Optional GPIO Identify · ESP profiles only + + + Boot & persistence + pyble_agent + Workspace-independent agent startup + Advertise and wait by default + Optional /main.py autorun · default off + Mount refusal → USB recovery; no agent + Checked label · autorun · Identify writes + + + Security & privacy boundary + PBLE/1 v1 + No application authentication · client trusted + ESP Just Works pairing ≠ access control + Identity and label never authorize operations + Advertised label is public · avoid personal data + No agent telemetry · no cloud required + + + + PBLE/1 protocol engine + pyble_proto + VER 1 · CMD / RSP / EVT · nonzero command IDs · CRC32 · exact payload checks · OK + 11 errors + INFO 01–02 · FILES 10–1A + 41 · RUN 20–23 + 40 · CONSOLE 30–31 · IDENTITY 50–52 + + 24 OPCODES + + + + Filesystem bridge + pyble_fs + LIST · STAT · GET + whole-file CRC + Windowed PUT · CRC-backed resume + .pbltmp temp · CRC verify · rename + Mutations blocked during PUT / GET + 64 KiB reserve · 128-byte paths + .pbltmp hidden · .mpy / .pyc refused + + + Run & control + pyble_runner + RUN file path ≤ 128 bytes + RUN inline source ≤ 2,048 bytes + Fresh globals for every RUN / autorun + Idempotent STOP · runner-only interrupt + SOFT_REBOOT · response before side effect + One program · opt-in /main.py autorun + + + Live console + pyble_console + stdout · stderr stream tags + stdin for input() / sys.stdin + 200-byte output chunks + 256-byte input staging + Paced; flood output may drop + Run-owned stdin · idle input discarded + + + Lifecycle & reliability + agent control plane + BLE remains responsive while user code runs + Bounded queues · 5 s reassembly deadline + Connection generation + VM epoch binding + Stale responses, events and work are discarded + LFS2 on all five · no silent reformat + Pico filesystem transfer during RUN → EBUSY + + + + + + + + + + + + + Upstream MicroPython v1.28.0 · normal user runtime + zero PyBLE upstream patches + + + User workspace VFS + /main.py · /lib/*.py · ordinary data + LFS2 on all five profiles · has_sd=0 + PBLE jail does not restrict user os/VFS APIs + + + User program / VM + machine · os · VFS · asyncio on every profile + Explicit pins, buses and hardware-safe user code + micropython.kbd_intr(-1) can defeat remote STOP + + + Standard hardware APIs + machine APIs available on the selected port/board + Explicit pins and buses through user code only + No agent GPIO routing, pin map or detection + + + Explicit bundled extras + ESP: upstream NeoPixel module + Waveshare exact image: ST7789 + boot splash + No display opcode · generic S3 omits display code + + + + INSTALL EXACT MATCHING IMAGE + + + EXACT RELEASE PROFILES · TEXT CONSTRAINTS, NOT PHYSICAL BOARD APPEARANCE + + Shared: PBLE/1 · MicroPython v1.28.0 · capabilities · files · safe Run/Stop · console · unchanged 24-opcode contract + Preferred MTU 247 · fallback 23 + + + + + ESP profiles · native C agent · NimBLE · W=8 · LFS2 · ESP Web Serial · upstream NeoPixel + + + esp32-4mb + Classic ESP32 + Exactly 4 MiB flash + No PSRAM required + Approx. 2 MiB LFS2 workspace + + + esp32-s3-n16r8 + ESP32-S3 · 16 MiB flash + 8 MiB Octal PSRAM + Lean, board-neutral N16R8 image + No TFT driver or splash + + + waveshare-esp32-s3-lcd-147b + Exact LCD 1.47B B-version · N16R8 + pyble_st7789 display runtime + Fresh-install splash enabled + Persistently disableable + + + esp32-c3-4mb + ESP32-C3 revision v0.3+ + Exactly 4 MiB flash + No PSRAM required + Approx. 2 MiB LFS2 workspace + + + + + Pico profile · portable frozen-Python agent + rpi-pico2-w + RP2350 + CYW43439 · BTstack · W=4 · LFS2 + Verified UF2 download + manual BOOTSEL copy + Named LED usable from user code + Identify and NeoPixel are not claimed in v0.6.1 + Filesystem transfer during active RUN → EBUSY + + + + IMPORTANT + Profiles describe compatible image constraints, not carrier-board appearance or pin maps. Matching N16R8 memory does not identify a display board. + Only the explicitly selected Waveshare B-version image includes ST7789/splash. Installation is destructive; everyday use after setup is BLE-first. + + Public firmware v0.6.1 · PBLE/1 · owner-confirmed publication · release metadata: pyble.dev/firmware/v0.6.1/ + Original PyBLE diagram · no vendor artwork or board renderings + diff --git a/tools/web/public/firmware-v0.6.1-owner-confirmation.md b/tools/web/public/firmware-v0.6.1-owner-confirmation.md new file mode 100644 index 00000000..08dafc4a --- /dev/null +++ b/tools/web/public/firmware-v0.6.1-owner-confirmation.md @@ -0,0 +1,19 @@ +# PyBLE firmware v0.6.1 — owner confirmation + +The project owner confirmed completed qualification on 2026-09-11 and explicitly +authorized public installation on https://pyble.dev/flash without further tests. + +This release is published on that owner-provided confirmation. Existing +automated qualification records remain incomplete and are preserved unchanged; +this confirmation does not invent or certify missing automated measurements. + +Firmware source: c8f549eeabe6d2b8c2766eab022517944bb09c8c. +Release metadata SHA-256: +71f6aca6df07e31a1f54c7f70a48d82c7fe26b1c8ca0626a8ffc57c804fbb1bd. + +Profiles: ESP32 4 MiB, generic ESP32-S3 N16R8, Waveshare ESP32-S3-LCD-1.47B, +ESP32-C3 4 MiB revision v0.3+, and Raspberry Pi Pico 2 W. + +Firmware, manifests, checksums, license notices, and recovery documentation +are the previously built v0.6.1 artifacts. Back up your board before installing: +installation erases its files. diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-adc069d73fba.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-adc069d73fba.png new file mode 100644 index 0000000000000000000000000000000000000000..543c76ec9befe624f4e3c1078419b12f97573ce3 GIT binary patch literal 162935 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 Xxzt{DL7{9a@ociv$s_59&tCZ-wq;W@ literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-c37bd12f3102.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-blocks-hello-workspace-c37bd12f3102.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfaab3fa465ad64180ea50026f3dbbaf23fceb5 GIT binary patch literal 163073 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 BPf7*ki{K@Hnawa2P_F>CW1d&&>l2cdZs(N;6 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-65fc3f839336.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-65fc3f839336.png new file mode 100644 index 0000000000000000000000000000000000000000..14eb46a87de99adaf6c33fdf5486ca216541fc56 GIT binary patch literal 100907 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�}w7oWY+ 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 P*Tr7a{yp=8#e@F=hpbn} literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-ab466a044a11.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-examples-import-complete-ab466a044a11.png new file mode 100644 index 0000000000000000000000000000000000000000..6e06aee16b74aa951e4cddf33cb69e5f54b414c2 GIT binary patch literal 119787 zcmdqJ^;?u}8#Ovei6Dq5AtfT9AT6EJB_Sy(jdXV@-6AciAV`;VcX!9oNDK_k07LAH z=kGo?B$w! zd{%YQurLFBx>B)GyMQM4D{*M8P78F6Lg_Jbq#fE>iv@bl5 zozzgJde#-35JrdJsE>0m_*pBR%H#iYOzp{D3JbNY$F7%_KMlAH6@iE}d?vx?L=$qM z*(0o+qW8V>bGXeJp9jd^2qN@mvYT~w6x4BDvIpQqnD96 zjY*9eDAMW4%FfRF3Rb`c%GdxG)Igmh#kJRhVdhJDK*6YXy*7Ucb~sfh^BvQ$a(c-% z?rx?5>dfcKR}(+c+%Q5j`xQ zgp}tiD}I~mOS(i~Ux-OMSHf~z;PU$V&wGEzo+N{wfng>orT;6lU`|$+*zFLHO1=Oi zYuPi`@@Jmh-xZye)_^;$h~oCn*}1tPjBY(f@g_0E#C^wSJ$bPN&FvvEi5w1u_{;ct zIRCkAG=ZG1H^rhS@;+>;SlKu@_}DJ#{d=6y5G8TQhnycjzIq`~^%XEh=7 z^YgY24sBCY2}>DI>l+#}(s4xdAXjK*FHK3cj$%TvgH7bt?y3e$Q6`kxF!SAWCV){6 z=0(4+#j)cL%CioO)IG z=b>Qv{^$A}RL7So5ovIFhPAe_k!#`dy|(rnhsBhVhKiJi2C=-nJQ&&4EE;~yuDv?C zyp=2DOtl?#O-;?TWhF(^kV(1HZyQQz|ea-MH*q=CNOQ*8Ck9$L_wqu$Odisp#oLBS^V^ ziO7GLtF+0fpO?|mAx9$i)PtGqQ;bZEzoyjpxq2C3Y&xHs7?oKeou6#OB5EioD8wWs z*|}eaw6~)L;!wzmh}=;cQSuKAT-n|hvCY<_u61arSb-w<&M`qV z;w6JECm%b9^|L>j!!)0v);U?V;qzs8>C@# zh6Tg4POOCX2b>~8byXQ54wGYI>!y<^@C5m6f3Y@8>-+cbDQv5cLcLfcd4T#>W^UzYds5^%1&LaS-q0sA@TxWNN& z{8Jqtbzszowcuqm42%^xJhHrlB&Nr|$EbB?CA_YV@6)GGVDYx}jS*P5)r^=oRJ#f} zot*fiVIpcWRec4Utp?_#C8RkCen>3uPS!D!TfL3QdTd%o>psQm6g`B1A_gdBU9w5Ro)kZ@R*s9k`l#$P06Jk<37m#(TS>|5RVagzy?HITzqYP zJti(rT-RIh zp`xaCM^RTdvSVg+L-R+nb>kKxa%4eDS{h5w^ISZr4*=uA=_xJui^R>%7a}4eQLY{@ zUwUYiJ6qMA6NaJV;o;r6bBTo)_F*`K_!Z#>Cv1AxdBJN>2jc1FnaGE@w62=E5D>82 zVrCYgMUfFzM%}ZyNMYIgK-(Fy?QerU$rznK2 zt%-Sh@*mjQp>FF(tgIMJ+Ds2`*LTIlCQ2!MK8l=~iTNtx?(Xo^hK==OE7~1YRAfIZ zWenja2M0%1R<>hxL1}69_O^LwSeUe|LQZ8-{Qmwvb#2LQ#Yj$`v-Vb2CUA0cij8~U z)6+vm$CUs!sI&fQrB!6k*}S$637e5rROQTJS{gB{K|8A(o2B}jdpwh#osto)12ctF zy#tI4ADsS3Jo)wncgw4imh`=`+gY{yiMz#FquIIw!*snqyZ*9%bxj?WeHJKm(u?Wh zqA?L~u9fn>csF-+1l)Kmy?NH!rb_Y`qU_r*FE6>bvR)QwSA6d5lu+%)TC>&qkq{sM zRZmg)D}?EshF5#S-*k6rgTb^qmVg6>yIBl z7RM4icwj5y3D-2??6Blq{WGZ~X!4AO#kV)lJd>t&!kmVdw|0o% z^#ji&X?REo)l1e~)ef$Ax=Jl@XkBhWfzeQ!Ky^(GtNZw-?##lk=*+uVSgcI*-)5hj zX7ByXDDf>TW1B40>)OgmUC8fp{X=5vtg{twsJ#0T$&Pi3vok@sNTX_O*}Wh_5Hw}? zCZ-|8X-12?<2*}urnom$##`gUYwp6Sa%p{C+*XQ()vnalwKn~W4+I9=K6gJ*CuhRG zhh?xG{&9Cm!H@v`9Q_f|V`XKXf$q}G{o}(Od%omJtLJnKn7%a$j`N0X%dsBZdGz^G z3euUsGVVfdgnd>B2==k}`F_KWrB0`CXOi9&zQ*(1cBbgE_ySrW@iE8?-n#cTDeQerMM#pL>G3wE zq?s!*kUp*{kbbE9e?0PA3DDX7JtC#mGAi+j&noi*yt|hOG9m zQ!c`rUa6|7JXx*@GVMYP-_D=8h57rFBN_8Ni+wzt+Z{H)qkZoPJCWD$i8so%0R4s( zg3H+DUY+yv)$MH=TU(a(_4S?UO4R&`-szCl$g{0nsYTm&VeH*ZebiJeYKtZpzW`r@ zHy)+wBIo{Y(r2UJzyfetbi-2+ILZ<^CJ@tfcMp2wsvvRe97h*QctE+A8SOncY1IAs0 zsgWtY=ElfM@^BLNTFR-wD`75J^OvlahHo$)9v;@`FGCo$s-C1fC@DoB2T-uS@>+l7 z>hAiGl5*&joUdfYQlroq4Kt|3wZ{Ow30RzZ+J|Az&_bV^HUy|eD7MS!M5n)grpLqt zeEIUAx3?GatGJlbmP+zXO(weaFPFWDn3P7L7)>pxegoH}&^OlTw$e$pl4o9wpeXJ@ z)887{?p?8_@mrO#T9tE@S&b3em)TDz*Cr^fN~GFS9y&QiIy@G5@r|l2$hoFyV6yvS z!lE?(Jqf?%T|2DOYR9917VD~VLs+CPGs4ywK9H4N8<<5UjYH0gBljgCrYn#GCcO|n zuOEh7SXiu`Ofv;eXhfvvseu;h-o&xg;@ zH@#xR!mwQin`D>&%e2T;0b82qxnZL4JW~KDnJd|8&<@ZV> z#=#U%9)3Ev(I6&_q^X=!MfnCUD|T8fIoc6O|yK{#9N zEvaNe$kScwrl*v?d3p2&#>UkFflvDC?v0@Qct&k!=WMc?{T8xDv_^oBzhJYMdw6)y z+`=M!D7B;n?qRu)I5O=1(gHaQks! zOLLZ-avQ zXv#ywGj-wDcHfxZaprr8Jf)cQ&}wx6R4S$?d}fVU9Vjoyho3RX;|_3M5z=9FbbO78 z6P;->bepE4V@TfVw*+fPS65f8LyUlcXw14l#!DR8Ei;r=@Eb?&!4pCW&vznq*2@fi z4AbQncpc&$uCDH1Sh!1}g6l>m8;*+%aza;P|#MQjx zSVf_|2(VP6ui!b3{DDe4IhN$|vtC#$8lY zbc@~UT0!~A7R6Jd7REF`UvqHalaPP>-Cd=T5*!)wBg<6?8HS*=^Tl1-Tb8u@nf3~v z53fU@IL-anN$Bzi`xXI3LhuAG=~O!*6T`!{id7>a&!4v&vLyvaL>TX0oah8@f)B29 zsrFa`@am6STUkGUqLg@b;mW58UX^H-BP2PKBX;?mza*!3buUxzA0LkgxZEk5+ufNh z?{ZCjK&fDszh5sd8@!MN_E(qJgr~ypY$y4hBsu?}fG3Pl#A6~v2n0GG#Pl{aG&Ect5a9inY|lm$ryj(DTf9=Ubbm)~70qJ#=nlzRX86bM7@n1?~`oIm7$3#`s!^uCXsC z5ht-~T2Y$huQ@sHEIAHWk9j!nnv7k%Rf$!9@Q6$d2#+~V8=CWs2i#zDO$ZhL`29Qf zONnBAef@ND{NT68`DVX_6p5TFoC=MaWq=*L=NlWz~y)&yHVTB~fqs z&y{#UBwj{I_rmT)Q&Hul(vXu=)~>uUGJZEJCre3LXUrP96aK2p>k(03W2=`DK!6ub zU$Q&1ar=r2bB#ADQ^h1C@d-!Hy(!IWg08kkjJh=cRL5%ut?PHM>_gg ze1pwHGj~O?;RB4(`Skhj>#knf4JDImo1||kU*AMw-zrh@zk;iqm4Ns`^Q91zqBxG? z%*@PdPF` z%tBw5(J|4DWE90fH$Jt;iEF|7di@cckDSTD$!R^I z>1>@fcWV3MiAcTsp232i&a8DB7Qrb~r|2F%KUZ%dJx%ji@l%6!`kD z@?RByKny}%-8@*GI$zJvL(P8Ke3%$wkWzSSsyP3&-m|_1ck5+tbzL2++qU%C*|{${ zXTf^~U26*Q0C6!1J_>kuZ}*Sv?D+0YsZM#apc%{Tg5TlkO*S68H8pkkg!mFbJ<@a_ zu_Eqfj!a6uY0TvNlCcCIQh)1oe?oNv4GrB9`TL#hhkA>yBF-aaL>8M>=j$%J6<0_G zjpstWHvtV2@3$o`AaE8vaL3mtc^dx7Y zq4y%THh-C|x?Hs_uNhJC()XGL=mJ;a`uh5+NZ#BW7Y8;!wou>K*Z0!O`$O~MA|9np zx;2ns-WfrMv$L~;b~|1C!V2>8v^+dDW9v*zOev0|v1-d1A=A^`r@IW}== zW@-7Hp>IUbqK2r1ISYIZ75A-}r>lL5e_TD&NGiqE z!fY{8Xm#+_)Y^mIP4_u_U+2bZ=NB9j#;Uuq&f5sV2_^N{I=a8Lvmz&^@@TRwH8w$= z`?E1Yiv6FDok&n;A}S@i%H}Hzld!SLDD&(>4kT>1Hx>^lCxW~0-aEWEt^PY|IG9C7 zR;H9=`fe1?UZsbfuTwtRs)NEe-lUS?5~iv9SlBI*sToTjK8t)d`;~9%a%jzlFe$Ux zV9F#tuT`UzSP)zYClQLr&_d;_glAO$N=ylVwel60|D&pkqL{M@VP|(LnR#El;iyCBHW7+b?8-u%+GqvJztiCFoRl!bnP8|aSg>6ow>}<33g4|*@B)!g{ z&<{)d!k3pL%gfpC-@mV-uAaZZK(EnYa-Wsek2odP?DbCUr~It7<~I5_EuSQ{b!`>D z;Aa3?ciP9BTWA3vnKo+e==f=RqM_Maq!SsK+7@^prsNCXwtq^=r;mCp@6v8S#mO0u z-zNDRHYdpT?5k4&>Qf=1sFD(v&@l2(yv-SJ{CF8%&i0qZ{JE-xIX@-mGXTkO%NWLg zzn1Ci<+hl7mEz87DTpj6=S^9p?BX(0GUXdy5335J%cKs}XG=;X_hXQ&_jDe59r6OP61}FsO3zFlBQ~6+wch}!KXK$UcY`#ylUZ6V{QB3Q=w)> zU2Pq!<(h37dwI{$09PlPjpfASfnTMW5rp&95>X${@q}XH+@5kQRUXgXTzuq}U z$HocSgnLrG-G}#7S4eql@C7Bo> z&-;u_T78%?#P5*JYURwfy0Q}vNCcs5*V~s3_4Qz(JY{B%3h$Te3@5P&*S7}Tpp=LA z;iE_6{3TZ?-rm=*IXKJg#%NFX78T^JnfweePI^m$I(k4xWi@zYpVgoHV+9l{{$qlHFUz8K&UPj?+q(8;Wqk9EGxJ`&+uMI+#8lAS zc-&&+;+4Kk&3k!B*+xa$wI@c06FZ!>;d0k;udeS(*HnDIxNz5oYDX}OQ>0N?)e0d_ z?f4(7STp97mg3XV(Vb>zE4^0=@%aFm-4ZQ*+xHV*uEk<`vGw zKNQG>8rycq$0vy+2!UvIVs>)_u8r;G)uj`D+Jl|W6FlNJO0tkiV{ng?K219Zr=0JN zC_9lCc1}(L^mDluszRwJzwHXhkB*I}OZ0c|?4|{iKa$nerBGE>jg8%*W0)K9@{p73 z*H<->GW*4Ze|RXdx&LO(q^Cq6-maZ1 z?vnM^6=@5!E%mG&?N;GN$1l*@o?2ShkS8Pxw=Vh1_x&m=qT*x6cENM$lqa@8%cG#c z^x|Rt6cvR>gOvSMnA=u^siWa9?jMs7BiRdo)LZyi0N!||_Zp~U zmg+Hk<)#Pv0x|*A2H&%@|NKJVEDU-JXFzdpszHLghHu@kvRpUr=}$WK3ou#H1BY8_+&%QvRfZAGVskoDK}4&w8C2(0uziN z9(PQ@filbLTNCx(>$Vi__Dm;h{hHg2By6k1NIv*9qqx|drsFYd#ZXWKJ4@L?WgN@T zH|M=+C3qMPNYF30o#Qm;Mei4YAOmjwB|7bmu6z*?LVQ7i7Z{#bo)INe1JH>5Zd>40 zyzFLRnY7xvC-mxBYqex&IAzR{&IyPcj|f73yWFMoM?zKxSU69hBqs zboZbw|2_fnqiKdNOF)}jx zn!dMrI%`l$re@(>5|fnVv~*{m!u5?xhA9MCfw@J+Ep6-FTDcgm7t5B;+23<_DW}I* z8WLGppLw!0Qw3}=$upt3o{`6`+CcAl0nn3(bowoVcNgcWoz%13I_2~8AY(KPzFK+a ze&*)3F+VWj)gINWTU*k=HbjP=mR@$H9~>bp##U;b5N9K$BSu+!G)^w`6LAxOA4ex7 zPo3M<)jpsgk+-g-p2oc=$g6M^LB3numO7Ew%P z6~*psC(;)TAvrzPbktVN^71AacyD=kytp;josAT=UjZP_+Lg|9^m$1b7Vx9>S-%Op zU(kK)ayXE{J%jxQXTR{R+@Ec3UC$=(6r6qJ0oqGW$cYzaprtx2AG=K=;=^2a;q5=5 zyg>nCT18!z0RQob#YXM#+VB@mwrgkKc%7c;7rL|mrMCE646P!fN#Kw9YK1Ds#}0r# z0#7mJZ88_?mv7%>^!2I01u$42o-rlI%&fpK(2)4r?vBM&aZcHfA3Cy*wm9N7CcB!w zeSHy#NHSK?M?Jz)?UEVlwj4sDas0Q3HK<(%J6!7J?) zyaHuCJq2fHLJxK}2ypJ7J}X;gjW1kZx00W20g`1FDhzGNSzmMEqBz3$d2?v@`!`d; z3tA?Yi{L)?*zp2<1Y@Fh%)*Z;buW@H&nFzCqpOUIF}302Ry$4m z^49LApvQFZ^4ZzN*O;WY047s(QlyDfggp)JSTjOBlRsRpVP);ee}Bpq1#}en&nsK& z9TyX@UuF~T(2j|?82Pl`wA8v8KypFD0RxXzMEby+ZIqpNp3BF_$G+SFY)D%tAzlTF z^NVw~)njGgEe!(c42lr}KFJ$xgB1L;%Kd{QW8ham-;<(sidt=BVd4G~ohiAz?r?^e zNLFaiclm5Qikn z_lBaPw1y;-PFP0sHP5ThQBhn)*4E<_lkb2J{YGD@n2Ww|h&iivc0_X#3MIF(DF!0w zie6Z#y28!T5z(^05ROCrN+=ODcXRp^M`%9?O;;^?&25$Gw*|85U;2x><282>>G%f) zZ73Bo__*kSK1@$J;jxS_?gL8h;09#*uZoEZtx8k9C%GLSAI;$x^?dL_>vr@`vI9Xk zppFq=kN6e>gZMUdS?aFt5CC`LAqn}6@eETD*gXQDLDl`8v^a8FU*ACN^KQ<9ijDWH z(kd?n>5Qcvzc8!$d}5|MIY1GoTD;w&q-IEVDqCuxyCgH}Ad`Sc@9kUyLPH4X z@TV2hpZtw)L=%XD4g&$5yusClQ(idAQ!2qU(8jN{o|7kVJAOogwHl+_`fBYcPIB#T1#^(*xO=DeoGQs!j;xHnTK0Qt18kJ!W-vb?b@{ z9+abpzKMwm{r){@!K)5XXF)+hF@MPiq@;5;z$+P{9B+P8Ff=3czT&YE-Xb;A&`eKj zR$^&^l%6cXZkNW^M_234{K}XMY3x-+5=cWmAcI>(%Dz(Fx5HQ^Y-?Rjf-P7+JEpWKGH?#UsEfwn{kwPW z+*zBO7pmd(~jVLJsU;(~t1C$KcAB_p}NYtHt4$HK$= zS@NQvIL{4?kBXY=SKyUc-L0L{-Lvy`IE!oH_s>F?%ksJN;{9(}Z zt!Hr*`9;P0>PqybC?g1T*6YTRPjFpP>Z3JH-ZMp`B|}0pRhu!Km^B4h|KNg1$;xK-o71pTHI4-_%k;@e zzkO?Zb8TRlR#k;ZErCP&pyDCfqyD9==cid^&&mD_^xsc~!g5S`f)l{Smg-ri}f zHr-1tUPW{`IbdfCjVCCCLaphVo~D0py)#*;B`GS33I;2jl&?t7#m%-iHKrh-&f6kE>I`gAL)?=jN{udk$GNz&%gi;9~Y4e z7ZBjFAFcxL1Ah~Ie5=^yjg77>X#!gc;Wo50BvMIBUDmB;wdFG~$-o7)eOq5& z47`h}N?B2{J$oSeC8NMFgA7PV_+iqGsCNo-75Oz%WN^(SpIsgefp_}({X2>;3Efch z+?^L4iH>xIHNM4?Pm9FU()z2fO_C~(mXdofM<&Lng0q4fn{ta z^~~^D5W(8xjAo5BmR))|8juT%!HA$6<+tMIR1A5x1I|M~@@jRwsNsLz7jqrlek?J+tIzdvkNzPt(xYii1*aUW@X zc5Cjh14h!`E+!F4{Hdddt=YUoOv+((_-df-Gw?_hL7xdg5=e}2Ck-UZ(XQ&J07(uK zVm*UB`Wu5rW##3i9HtErKTJFmDN3B7@05tFq8U0zs8*`XchQB z@8lwNYs}!$nIHaPwd9O4ZblMFX}o!+MxxV$(N-B zDpX#=@4iwP{0*H(UrRQ9l#)zg1P{uRR$J@NFCYNoU*v!~e%Sm%g+s!h7~0#*DM}OM z@BiQ-8E|r@usFnoZ53JAvoS#!O>}mlVVuLo$!7C8w~K=VouFXkukLQaMT-jYgzFGP z?eP}?!;&3b6jk-w5`&lNE)&v33tSs?c2QjKIy~KQ*+-LvwP^Pa4 zO<$p!rI-wgvPOSyA1LeRMS} z0lgRIof45oa_zRNGL+GYiDu!@|8=j^xp{cNpQOyq^KR1#?$KBve*_vB7~@JH$%#UP z*7jYeF`R8-fZ7>ThQFlmp0&j zIboRAgj0e-xSyna{_l+FBek8jNu#z!Xu`)*0_l5^>f$GFMwCYK(EjVzR~^p<1_w(G z-3BGa&&40C6SZ9Dy#6utp827QUSf^-!2aO5pzQ;{J3oT=u~Y+&^-)kh{eRto z2y#c@3hVgTROHyq+{!6dEkWtMx_Dt>nuJFty;N30;#(8#RBZsJ%|ip2%<2jt4Y$0W z>p=5&V6PIq=fIS2k)UsmMuLvCZH511{EuxD6RQ93z{LOWCEz~^EUm3&$vlV_G@L5Z z*^ye1e*clg+PVmbk~76_wKLb?pTeA_d|?G9Da-Wlc=lMnqX|A{ZxyTo|{zh-sQgsE*Xle-=Wl!aqzjo*XnB=Ak}9-!d$OsHvSW1z54R^8OrlO z>CeKv>0Ud#rY;8w&}o0y)uy~{^VCNmHYzTT&IR>Ut^*!qSC`h~aa0f#8Y1!;gd!H& z0ur?Q+hb_i-L@YwRd0$=oQ>TaoUaUd9G};E3{l+vX;2VB#%VR{D26;f{UP3IiiG(f zVN?x>ICVXa1?1k%r6irk?Z>NitoQk6(3r>X+$O-~T|sQAK&J}V7sO*(P5OydLyvjV zJuhBrRav2a0OgVYc*!sP3C}$)7ng0=w#pkF5FGV9?&;!3?v$?)J^HPstD@2#U#R7_ z0d)xp4qnRbO8)??+0=N%vLt1hF-#4za6R<{SL>!&vmrD*Tn-}7ccRkT*5>qIXa~{Z zeJR1-L*>lWuQ#FNv;hS||hJQji#LSA$P zMJyiu(Xv6=Z}C}F|0nW3ikI~>hk%kOsK?Qjv0~Sm&jVrdfRZw&7ts8zuD_A@W7N4S z8?NlG@JUe$fs+HebmHZj4uj=J5BTz7A5#|;+A!b_k}dV8Q&VocCsQcZSCK+&M*AOD zR#)fZ+|G>?2uMkT1}2-2KaQ#P+^%2L=1|pq*TXH-n*GZfZA7vKE{|B%+;08B9c=j8*Dz+33=r5(&^wpyx(Q}WP7<_wl9OgnPZ*XaO$avh*513Z(c5PL z$?mnbD52#-pG$%|=)x+Z%6}u>^OPqdBI5EqgicyvF+cP%czw=)N5d}Ya5a^zPwiZV7>gtb{^R7&GUhOx)Fu{U+%~ZSJN)d%p57ukr`6;Pd zvF=+56IyF)>-LULHm?iEK^`R8txs~?KzX_6Gw+7bfn2WtOyB`M9>KFkOwUGFED#R^ zL?}50DG?Crj(9i1N>kv#VX6|j|g46X>4SIHs)gnwfzoty)yTKCm<*p z5*lhS-W{FdJ-gNajRQL@MmZU=v)Gi*voS=|lmYQY++0!8Dx}TppBLZk7W1EvD?{Yc zUWd34USSgvB{;*Z7V49V6PS#l7fwdTriSG+z(ntpj-hi{aN7VWvu=WwZ`*@KI(28q zM?)Hoo;)N;Ng!MU5}ob2IsG37cX(3aHerB3HX^S*8gH&WIIU*nfHn07M1M)xVu6G+ z9o(5gLqj7Q@q*rl6sG-Ap`k-{*X|V6d)})jCDGKsa&z?+<#2?q_NhUv^AZMid0*lF z*aHDyUnkNtf~V9pxG!4GHKx`OrQkKE9ue90?Rj^l#Xb_expcT~@JpCr0H%G5!$*Sx z3FSk)*g1b-i<8scWyXCPWky(hE;KQKEU2hFilKYE^7&}U^s@RO$V>O;DE)?btf}#0 zo1%g4#fxCD+k+GYuf~5R_!XB=rn5&0T)a@%wWEO~=$f4+DeEzWweXA41zCjfbH8{g64J@KQIRrB}Tb=mznubj4b_JF?qcZ{aic(FG22 z$wX;F38Y0$@RBYi5NnQJRk%F?(+&AeM-w1*tp$4%7}|(r&O?CT^_MHMDg0i% zvW;h-krdUG`4h7yP*8Hb&kiIP-1c;?PcQUH9ubVKbGp#dIV_?g$_=|BIgdc(`+c1? zxy|(zBcULIKPSarCIe|48@~=f^5}5AzQmRa8=g2+4=dg~#SA&LXJ~3z zckx7AAv*c>^cFL9LD0gOe?RBO@Zc+r)gRD-iUGa_lG3wz{H3znc?S{Jy?bo&m>3xL zH(rP%q}C0jIYvQfz~|=5CM7j>E;c4Lbs&tn!KmJQU%|(mC{0~e{b<@S>R`d=8ftwb zBO^0i{gJx(>cq*qc3A?hU6L_p{R;`>m>}0Oq%Ll_eljF*)?|bu$8#*0^1p5HQM$T*qt2%KA zU7k^M-N37Wgd#6T7G+mvza^f#sFhVvFg^I%0j4$xpysW40QXq(ZoIgF;qJv%5izb5 zp}QQ`Enc5~iE09oS*XV;B+_~hk0#h>?R!uSQeILRtm~gb^qA*EJ~w@+J{LlkoLb34 zpFs;o@cdXAV1;Fx$DjANRQNqW`cWIlM?ZWnLsq}{Mv6EFY}|r>!tI%;Xq3%bw<%>M zrIt167?2xf0}Z#VJ!s!Xm{1OYZ2)(K^KNuiP3TmM?i6cyeo;5Z@{+A|^N0^_(7;5AoqaK=J|42dMG$6OO48 zaDD>P8BQ_4W18+)oX!lgd#hp0O-2Yfv;dHS#j9mR{n6=P)s@Lh2r$pJuk-tYNgj#n zSTXX&#N3h)lrtEqPa71PI%iX7)vTio{b+8!a*YY!XxeMS0|X3>NY4>uiKA6iRQ8WT z*DryVH8M4IfZjhkJ8O%Y4Y&rp3$_6jmNCBv0lR8_=e;Qq&XV5Vf{R!C0({Op8DLGg zaV<1s-dr(LONQ?p2rhW8Mrhle8-v6@Q`6{3?+mB|T-O)shN;cX&A5wNYHFS2*ndcu z-4qoc5YoveQyxWjdLh7u1kbRvyE`z?;}8-V*{W$`d@{>nxlm8y+aXH`7)jTkLM@F- zOA=sorMSRr00b+l{EpgdM8<=oJdiZ+Qxb^?vL29H#ofBBq;RkYrzFTvhlfchDbuYz z$TzzIkCJ>_xVqp8h9x4%jKX`LDiDVRM_5C{wdopp2&fQggV^nz0nVe9&-9<}@}0To zu2S4=!-_$R)_D1dd9%y#mMEx)KY$voln)W14p7xX2kT))9F21?_yLxYO#j0PaD;($ zuD<~J6hw-{?B(j!q|jxWmQ7RYKr)xIqDC{|bwT<03^v!h_f1kf?UUImEup<9T>Y8V z)rqS4>ZQhggq;zjQ(K&}va)X5A#~>*Gzr8y?5OkJFsuHiBYAJlQvz_rATqw_=FGHi z>3CN>Ec}7ZVU~3LS!=5^lvdAW*J=K8F1d&K#S(LIed{!-j}p92tgx6mY>EF5ZBU_a1TTm0 z4l#4zcv5(sMRT4ww$-~`I*iQmUv^UimbKs7N~CdiiEY*EdgF%|zJK`xn|W=EF5P_0 zuzAlLsg>q_@$vfLR0wRB<(ZANjdY(Ik0?axdW&yoXD6!O5@S4tP9}I;04v})5hqjK zj=Fz9Aj#C$Nc|r0(-L_cXcdGGjFPQkRuK{PNO&Fp*}f>G{x~54C6UjRNrC@VBRPHn z8(!_Vq3fDf^$oV2idf)VgEXu!llV?gPa7W2@B#f`crmxvXyHIX_cn$5>2)LUYXEOr zSx?Zl=0a=ld`nt$9Y_ieH>{5h39hzcGp!wu$&XPQ|FgzGtcz=)j$%nT_&V26g#dz>q&!X5>s13%zD;n{fwaWxGr}DKwRD34=>AyoVY7b5=S!KE^>1s zrob$ECNuft5mFF#3N2pnfhZD($Ux?~UuUmsez@>yXkYOX;0)$mgEh&?q%KU=1THQv zkOgm~m1SY8lJBoyFW?PGYY4EK&R~nz7{VansUUC_Te{GOCm5U)Bw2TL=Dx+%d3?zS z-z=fo1@ZIh3;M3aVb0{jxudzoqFngxEbnJVL0BxOStxvV4xCSn*pkjpRMpw-TG1f4 zwIx&4F>Jy@jdY9)YHL^rKZVZESHlMRu<#uGr-ZJLslmeT+9_Xx*TGi+VzRd$O(L4X z{HA#y<~kcTYNe!{olK~+gUFSkE9d^koD;rHb zJ=~#$mK1AMMRF~8mUrD0YBgOa?qz~aT-~CFE{h)OK3~8=;4D3J ztP$o!zNtjSK~DO?pT(Q+w-dOAmO*$cU{Hs9$o&%-B_b{7pa{^x;L~wwFF}PG5D`9h zt{Is8KHUNuUp1#(P5R4W%y;&WhJ@Hnj?E0ur(_dAZwkE`zz!W~dXAL=2w`I%;*YUu zLPoBw8Qn6wt;*sixcg^6zo_>ORljjC&jvWA9BXx5HHZP;v+gw_nCcKaR00C2Ez;6f zlQm#jm$~*lR4dZ#&|5qsy)A$+E=v}X4U6LQw>gDh^far#bY{~+_`msGMBO|R9t{Qm{XgYcOn-Ih5$dIJx*QwW4KMD_f;(?w3_QT?pMC^mVQuvOw+sr%gWyQ zI#VG>90w+v;EppRwRuSb1mbW zxn*9b&X=9XErkJ71v-Xu>2{qY(0C5hQ!+#Mw$A&hl=(RutoiC%hM6N)?v9SHL0DI( zNXWkZ(=ZPsV)o|hXD<9x+cnXI_`~k0kfKD7O7@sRO*|sy1GSjh$=|eIQx?S^bbj~BC6f&nRt+FCCK@s)+F0>6(~vJb?jfC4SI>; zTR=8EQO45>!E|QPbpdCZZgnOYcIw5OebieRW>7Eu0T?_;DH1lGJ;T=Xj9y%{$F91* z#?wiwyZ%u;@1m2I-z;o+u~dZ6LIOyxO5QMXe&_4`9AN@WLoLbw=R@jkzu>po>TNJi z6S-9jpQQi!+5|F!xVX4ZM>CLzvjS%*!o#0~h4Ss&6A%D8*fE$lf#gq^%eg!I@)p>uN8(QDco2^Fz7C@Ghq8BnGRZx)n3?&OG-Kn6#EQtG+lFmGv&|sW;^hNuAAq=2mv*1(bIJc z00|8W_x+YtT^H2#Mwkq&6gE|;CkeRGLZLh3m&}}#ij@h!h5NS_i`;lT zFWmA0u{2nyXi^HkokHP6RdldF?Oz!gnLHUQ?0Sa*PDNI;rpCulTD|vp_bz;E(r}kT z;S&mq#;ZtE0xs)WDbMDJNlff4y{ff{)a%_IE)njh-h8w`Qvg zVH)1CMIN7>-9e|LjJbL7UUErza6E-97Q}fgkmAI5z0n zal6%07v$&XuXd$-^Zqylhk~N`FYYc8XS%msqR)E_3HW9r4 z0v)0Bb0!wVC7?QG%cmO1`J7Lv2UT3%C*?Zy!MhH*@z_@Ydg)^h>^|tn$od?}t~zxt zgbOP9pe{GU1|KKr>C4>!xX8^b68tXcqJ;=oGGT<-L({IO=Y~JP?A?Hr)?*g16$0VI z+v+l^=SY-RWrj5}l+M=$Dm`8LVf;TU6s6^zl2WcF@Z_VZD}j|3h)21wT=sSJ-7PPT zn93RC{yA_6P-A68=q3p)*@X+_;`usF(^LNXRkg5uwPLo`UQh)bM%Q{I>-Xg4G1Fca z6c-Ea*fTY5ickl80_N7weQpFgC%oEK;Hw1a(%iqoitEzAfyh}#A8@joR407V8CKYi z`$X&(U@rRf4zDpO_g58!4iCC0kNWg3e+(eOLGFmHLai!AWo5s*I*+!NKadIm`(Iy-Yi#RnW=4{&#Gq7d~&5_r*#$OWE8n+x)bMW zSA^+mb_DoN5c{sr=lloLtH5WhdS6^p@&$RbhthZj=W9Iwqt$q^62@t0TdY$TU-hkR zH>>@5QRB9`;S8lsT`F_a1t8G(D0~jF!11q!w*ung68~wP(VwHE*YB^0z^kvKz!7H> z7&s#kCnRu22ik}cZiQV0ly|}Wf$s_cxn}+a*Q2+VoTOEk*NFOyUIq}l`!V1S^y96Z z`Ny9>13RAOmQ_ncP+0kw-+p0-4YTGgnHynB(P`30rS!UFU2<(+Rz#eb@hl*qrAy#j z2-p>b+QbvI!_&KH*9zi6%_>$1tnD!l#r4x6I4P`=>`Gm z?o?4k5JZ#?5d@@r=nyICjsb=chOPmI=DoMabB^ctt@V9>eQUk5mIsg2?7g4oj_baz z>zQ1v{0MM~IDk!>j#jbU8LOpEmk`?N6upQ&ms@|Xp8GbSn=(R1M^buHnSs4tUb=Kp z0Bidw!Xy{41h|IdFgco)4}YF_*Ve~sW>V5iZg|lBs;~t+ z;lWdUhAGTnFrzFpWzWt;xcHgRY~;u^Ec#1^_+ggPxUw<^U=+G>pMcNuVtFnPq_H^>T!Y$jdfrBEb4YF=N){q3#LrKZt0mL1Wz;?C|M^jg#EHG-m7t zdhP)Y03(d|CB{quI(NSOlP6NJzl~@f|4d3>+x7r^%~+LnZ)@=;LeME#?Uu+rJPB#c zDXDPA-g9fdl)6Q6kjvZKn|IG&#Bp-O`!e*6K8ewz)zq3?)19XLHd|@vrC;bHcF6Gs z6idNNV>Q57t^u%Hxm@RkmAIoua zcoQ_##<<=9>NzSz-SzO8s-1?pmGx+#H^W3WTLw)2UXd`mzlLbe*cdGq@p{pcm6ffp z%#Fl1gq?pVXV`yZeQPy}s<0McCZc$d<>FE|t;J$7b}t$@3RIjb-$pM=3j!zyrUv_m zhK2x=MP^h0Zr5>M;u>-pJ0@V+cAJ{M+g>W)`Sci+7w0&{-e$IP=^m`MtiMgoSZuQ$ zVFKb7K2(eN9v#+wkDfOn2Ds16ZVQ@a+tbox+iSO=CI_9J`f`JoCCK`@bk`37AKG-V z=;K1!-jMP_GF!%zA9o+E)XF0%_+@Uct@zG%1z|1P3&(*p-h*7^6^Gg+HfC*L9&+-; z=-Adk*0h5zgz|Zn$4E|Yr;T=5Q_=L;$w0t6SRHw43@1)}lP0Bp{~@$Xc1rG;OnBGx%GP-XEB7Z2DB(dx|Q%a^%TgU}Q6^Ub{X#j2qP=x_AErwP0b{$yj&GLie}z8PVt z;mXb_M)&n|VCI5@nt5!%rg;SZa0j=9iA9u@h$3+WwKhN4FjVS-`CwJ8AHr+cOqX6T zeGk>0s=_-AO*crn7tuX?KjH#*!uHDc#8Hg!J@Dslv7LuU1INIcw;C8C8S{EHhH`I@ zrJ)NTB!WRhU0vf($O?0oE}%=N?$L74h)EtBbHdU~Kavaowzs<_4qEl2u#t+I+=c={ zn7X^LyA{Uk(yW7~3Fpy2!fVht3>ZxfXaRb90l&fMInNq43HxGpE!hxM78N02VSt}# zGV4aBb{&bbI9L`_`6XIA%MbVMBVO-#L5=`;=)+BF>b4^h2>9 z6ne76I0chVG*4MM4t=(JUG}pfqX3tFz>Nn%?E<6af6YEib$j2%x6Kw=s4akdfOz}ni{ z(#{UJdVxWSF^wBGxD#j2^i8a;SX>q??TLO;+T|^)1J! z6x(xvf56E(jCV!=XIq;*psZf?LCjXumlH>4W?XJzZ0zkP^2$rTK^?u+H`;v?0N4Dk9+ix?cuX^ChGGmL!ZEKCUxeb=C-zT+@CDbu-gyP0JTxn7e1 zqPzeFh(Z#S6cPqS;3hYl{OVcC=g$u{3VdI$JZE9_Q2p5YOoXaYpvHc86kmOmQC$7w z`Jx1*kcT*u!fl2#F zqi;?qrL?ab>yg|Yo<0XP%>Mmx$lu(0!SaKiqdDr#A&zF3A66y76K4k#Qsn;g{^H4n z6NsECuQUxt#JrZdI`aH5ufeq4I1n!Wc&%_x=;fTDlr4y^7}+55r&>>syu-2hUw^*M z7kbA(!E|3PdCtH-^5p0L`uVMJONm``@WL5%tz4fCjkx*W3!<1J^*7E=TP!g-()K?; z)juxS9Al-3o)b%EfqVE5Z}0a-`19i1So813S_TMB-{V;Q?>GA=#=Y-n6;+D*pQ{ll zW%z{2N}5@#N)}+5RentI-}_cuFMqi8wjIlV{T$qj4$ce?Dy; zx#H|OME%4dA;-Z&HJ}HqdCsx1#oeDRM&@yUyaLDKl(vp8kZQa$gs(N}ksGs9SHBT5 zlbD$J?tM1Kk)D#$MV)|^_g*dH`Jb!dwuxb%=Ax&I4hc~MvfcJE`(nK)gZg^R)*WeE zyBGiShEi8mAG(wjGe`)El7n~D%A}PxkKHaX6=9!KQc;;VPLrN1Dzbu*{l`ma&6)d} zgnL>7<|S@~!jDFb?2*!l6ECnICfmlqf{JT`e&P6F`Y+7&kL#~h=Oj}#7rsJbY3ay7 z!BC$huUBmDXEUDN?0CZX7X%lXdv=el3ppJ z|Kzb!p9q4CB6UNM&>p1~_5w?u;0$G->e0r_ya{6y69+2*Vz>p<8%QQH;eyv4KL7J> z?nv~swpOGM76S=24oF#moHmP&*(&&f-5y);Zb3&_m+`N7JCB+}cmUR`t%TR?bgpN~&nTD2D0n7;7 z7nC<~U>s4x6DCvZ8nN&0-MfAaeAx|lpFf|^;^WuN5(J?|>OBauzh7rOQx#JvqRHj% z>8>y80RInRm&IeJs!@Z@S<}w0t)CoGai^T+|Jvvg*li^xD0d6yLvp zr?2YZvHa!?lXob^Y!m<88b`GOfT^){b&W(2-6^y)$29?+TKA*1^B|3GX=AL53lUOr4`qpj9_o1Pl)w*r;e_MONJ@E;U*zrrKk*o=z zl~S-fb>>VMVm$@P$(oS8pfrQ@?*|)(2CmUn+@J_zaGuKu>KO~UnENLF(f3%sDTOvi}#F>t{{~!+{KMl z)tpzevvi;1J|7|{2W4|0=tuW)*-k!?Jj!@bF#6TDQC|y44+1|%-Bedkowuf#IyJ*@ z`j0%S&Xdo5KoIVgC69dG7Wb9tvw{1c*%0yY!a~dIk*cMY#R^@k*&Z0Oe@(hn+tu~)>m#}I zTxZ__@w+=+>poDRX!*^aOiUT((ps(kH0l5^wS#Bb*dh=$t-O5m?)wwZ_Yo8Ima@Ej z|9&SlDl(LR`~tkaEWWFm=2eo=BlPAv@s5n>D6aS%BarM&QdS_=0<70$mOqJQLwqWp0=LV+s%x<332TUJQ%>XboEH`oUkA2gRJa@ z-id(5`+Zc@v5G~Vszat^Wg6-lfXkkSU5CgZA1mfg{h;RpBa6jM;&MF5B5!J+?S)wz zz32CJ$2d}Z-yB{&seto*LB;fYsG+HVuvz-LfFCl8h&*Ug5^)ttf6<0@a8QBKJh z3Uz%2{bE8sSql-Uvi+uDrtv`e@skpBW8qe=wB+O>4bP$QzNrd}UXEjV$tKBPQ&NcO zj^(M^5Eh4lWjX!vuZvM4dCjj=)x1P&vk7sewl;Cbd_b1jwZzR2Rm$mwV>aEH7zJIQ z?n+W12&6U4O1Yze5I-W!k9o#<9l*1yp4t$x%U=;+)}RQJNP;|*)CsDDOzD40!FZ8f zclWso`sszdsIM6oPw_=L*)(Q@0_-Ukf|>UrF@O(-gSz4)%noGl$$Zm@xe5z>b91ns zJrd#wE7qPvj_Sm=g2z8YK%}4!>7CnLI(*mvjb;AIvtZm#;?`PRWnbS7GmfJ)S6};3 z);JWG1qD%p{DT|3PkBtvY!O1Bgzp>+$VYS#at+T5F~1`i6QpJTTBV!tvlOHV3MmS_UoFrB=ID;r*4DUeK#$)}J%9e(zJZp82FT3b zb|t{ODkv)2M^cnw(bn5r2jhN~LSsI~lsu(hagV{qg1R@R=<%Mfmt z&{6MY7qbwVcOQN}T?_^wsQKrUF@XU9#9@$Kn?2s(a@X`vHiI*H7+`qKs&tTrs-VSMFKV9rLxE0m6v>0N`+#CyBR;IaqUf;oX%o)a1=`=Z6WktXpM^es zwy92guHOzkVksd>{b*o&YHa+ysp;){fwG43zxAZ|$OHF`Ik|^7&VRZ11zEF^CB8p@ zY7`U{oF>YNiO~vZ2H)&3xC}X8?8=Px&4VoRz#ZS+*#chW zMJnI%YcnoEK8MF@$7V+dW@;}$Yt$frCUZcB`0bHa^1|l*;4%!pLo6H|Z$FRff-$d^ zg9D2w`v;g9j6sx56@l#V{O0rey1GxPjfk(WxcCr=P=k&$8rq`4ui)>O_dN2x-_)Cq z#@7e=)HR)BVnV3JrumYvG+^j={xUZai*(EA5r7?<1$VBtcM#r31woZ~00nNi-)pNW zDCwInXcTl3o|bUQIe@WBIBeKu zNgircVf$g<%9}U$ATlp4FF(HR`F zd>f+soFYP?Vy20xvMK(@IPKH`(5k|KlmYay?kDkehe4*XZ+M z!wTEy3VHOr$DCgS!S0V&d_Xqc%(3vy5wBw+%}v)gD{zOJX>IfVt!9z8xuO$1mZw8` ztm%5uOJ???B~(-PjlJP&ktxOY|Hc;hTVPqUwX1-I1z9_7=9m{fOT}RqiDMnIz0V|N zn)d$tBY}%M#U|3uZ_7XOI<9GDCwK~){*kBp>A@3!+&%c~&c`nn^TAAh_WL{j`-$^X zG5@!F=5wKlSH57`$DS$aOw+!8^X6MqldRHPy6)F+3yp%3!}KN`L_vyrCmn^P=$8}B)T zhet*baMH9fB`HZ8#5_MI#@jo4@7#HM37jG!{};&i0WzPEn(AlMEFNKoLFagQaNFP_ z17Nh8e&s*EKF~cLdvKC3j}Pe24v;?q9efE*=$lh);OI5GRyw4q=|?XT3_M2S<5X0V zPj1C$4H)Mw&pBXWfG7=326##M`5ooq;Srqc?0Egg7#qHml2Klxn*}bpUsCS<`5?fo zd^(3{^UbLSOcSk;^@XxBcNDlC%4=xInwqi$;jGG(Kd1bOgqHh;K3ackV`B`5yALx7 zaJ7~>EkOjf26`JH%mli<{`UIPQ48~~=!lR8a*6GVemj%Hq@-24Mrw8-AC7eE?t`9l zD*4}}gIx2J_ok_5nE+GVV9B|guTFP>(>e-tWG8d_j$YHgHcVlGhB-%MB;CN^;Ax)q zina4h=V4zVeoNq5Pr<-2G~qT@9)R|u((dG%WrGotK{~Sna(T>Y^<%H(0PGFr_Je1*8`tjo)$Z1SFj+Ex+ongx-bfGrgagJb}7xG%BsIOC(s z-kw+mjGfxYoui|tpZ#0E+&V}m?J>BNRCnnOc%?WjXx6-?C%LAeFdj?qY9pwrq-EG> z<3Kpe%JU|2fNm{*R;RaB2L%@UFvju)TZYQY>qXW;yDI?Skg*wy;N_UT-H^dz2V%>m z+tjP78x8KeeTP~=1n&rh_E3H^VXz1QAKgdF59*+*IYdU*U4MK^PccpNwxlF8Dly8L z6&j~nx0v?!c4g2Ppg>fv2h$K?`M*~|I}5S|Y8gFdEqCv-M0tU6{L<3l*`XY7dJ+2v zD)P_J0pxuuD&D!r_B>I*5K<|$z6d28qIdxq35dV7yYF@RO?$5yNFD^y>q)9pL|jhG zca_-}vdp*f~26h-v!a_MgbD}ZYW_TenMq<}}7uND)mfP{< zoAX->PWUu5YJIqT0sKAPz@Ql@^jdhsA8!91SQ6g?d(m0WT+SQM!Y8}G0xBGYQwUSe zcQIOUvrj>rm5`bATFhl_B5;Z=ggpIVI)oMp;=|B;Snbdl06Cgdpe#G$B9} z{P5}1Zclr9S=qB6KUg*9Ai*eujwnRKUFg8e17;KCZ+y<-k29W~@qutc+rcXr9ZUH; z?em}}|C+X~enkz|V!UDEJN>R(!pRxKxJq*Djs9|cz1hUYi^rHR8(9_;iUDYG+pMP# zo|~H+Opo->H4jYWlmy~TE_7dKMx2257_z(kuvvVD9Y7Xbqj*LSTIqrEQyJi7P4E95 z(T}1wqd*iG??R{ zL3u)O^}{2fg#nwT)m1ZsIG`3PxX7Ep?uQg;s$5J)P98vpwjz`g7O?~@i?DDwW7!si zGz&S8x<{2LB8h@Yq55r@f)(Qnt#%IX+ulL6O}-(nmB41M2j3|Nyx zmoNV0iJHRMPI!Yd&7u#06#$;`w#k6W{sB9SY&1-aqH2=@nOq(>C&)&uujI5S=a{Ei zH9ks|8QCtxA!DbT!etu8_DfT^m@L-~YaSln!(?R4m$`i&-4CmTDJac*%B5E5{O$!L_7|9>ddQaZL~9(48e;K!A;vQ{F# zK%-E#9w`L3xp49usgIxEth|s~zWI2BMY+!EbQe&$MkgjD)gi$p-p57ew4AeV{lxGw z=wnnhwU&E*vO)@+6i{WH2f3=!Yl#wk6J_MYog@=$cPsaHYoWS1P@$#i9F}9yH2PxC ztpev8lBtIygTqo(Ht@^#3k~)4BXRe|Cm{OhTGYVx?3{tY7pyBX!$|j6yMF__cVa+N z3c1)n1#Do=OLy#nHiBv7ynI;)hOFDGZ_Ae=w6v;-3D|&YYey{&h@1%9#T7p%FqfZY zzT>>RRzoYgH)|ut$_A;dc%jrgCIo%VA0R#;+`F!>_LOY@&~hBjszsI?CKc?BL-|aY zFMqw<+1dH7#Dp3lbfDitkB871QD7&~SL#9yM{hj+#rgp#52d~QvtH71sBKANYrv+2 zut4aa!#78XY}88Z)swb})q+Zdd`pz!ZYiCsOn}(h>4hB}b}snihYyHkMssUsX27=U z?LeXS^~9cAAQMGchY$m-y=%9-v3t#P=t0T2v@DEX4j=z=|4hcx0KNqsrX??b9n8fL zzDExTRx~x&&x7VgYvh!Gk1RDE4}= zmc&SC1frbjqnZ!(EKAcS8ASfwb@T@&BhaSil-Iz=v#1K?26bMd6EpAY$E@zt&gokpqj$)Jy^Hel z^0EXY5+;KONA2?RW@Y3ikR3OiVX=J-_%$hM7h?6STM@SfT>yAq;?9C!I z&KLbJ+neDgz4>k;C_Tx52?#!wJuM+n%`SmPHyhRw-1F*%-9?unCl?BEXY<~1TC3e~ zfa6nq=&!y;$^pnBAR0kr$aT%lAf*FgAoknV-iC#D=`rvPu>_)9O%*Sc1L}^I2Ve%# zu5F^-5X`~8F-kf;J?%K_Q=1j6reayU?VqtHuv-HHqw#f;nCfr9x@^vjtpZyUX22gD z_I4P6d%V3k7BJwvWybF8A|H~r7~{6<1i##G8SL%3!+;6`u+%B^_6T`R6&Vl)BW=3- z);BjELfM9srb2X*8&LSHkSP6bIX18U%E|i#i+n6}%uo3N1MP==pC$kqAlM*ROgRO` zspd|DR)3yl>2A?X!P@4`T{*dlydEt?@JoNNUSnf2F2HY56i5l>WoxR%Jb<(}&3Tl( zc>e9VTmvE+kM(Bc|5gLF%6pfELZEADWMUx*A^rf3TE7+^;$Dy$8*}{BDg9Jl`&lqB zCj|@$*hAb4n==?Qljl*=DsI&NQ;y&^*Pn;bunP(EE^zD*?*tZD zI0zRJk%561Bf?fub0-wvOvVl+Q+wW**y=pg(t_wN5uwZAXD!Fdm+JP_$9+aStp`ehN7Km5mz&ySAX>;I6Tq@xBcV*XDR1%7AI785wnTI(R53 z7z>@j>wVU9UMYMJqFErGJAod{&l&~GL3zF1w*+o8>Q{INazJquON{f6LoEx1TjmoQ z5*f00Tsw{lSc9S$-&V_*V?U?5OK9^}x9S`B-U&0vQ{C9%KwCCgcz6SGHNs{KSIdZl zJ8b8L4Unh-?o`i}Om~;I^Gu>KqDCh;%Q7Y8rdIMVqmwltML9~-W zgq{6DpPm;E-g3z*THtj6@C}>0lr*Y=)Fcbl5m4B5`?B>l#JwItqyy`ug1wK zn{htVWiqohlb?fq4}2n^FMt>|pwCN)u(XE4$7>~a!x~JZwQIMgY_a-C6{g|LC z`QFsCJrhd)=W)Jkz-bTNa2OyGypf3BT?*izSzliV!yx}p({LbEf9_QhDPLUh@w6`@ zi%=p30?2M2)17MNl?Jxoey7_uUK~JJ$XW2Km_xE>0^--W!`kJM{9o0q2gB(=`xIgpdnJi;wq$ z?6}`kr|Q+xx?TVp!xB{gJp;aTGI$fz(HD8yW|C&_TTD0M?Q>y=F2V(yQAQI3^1Qr- z9ej>PqpjG1+CS%?F<$A-cBTAzghk0$4edFYhenakU;Q=@{7PE2u@Q$@kLqzHuDt+< z+`}zI(5n~j4Y}OCC&M$JBOAe=SKJLO)N?2}K?6y%bKh(%b3c1*ekCT?gCnBwW<_Wc zqJp^~?8}?jh7GQXE3ZUPy&8JI{&H}^3k5oW$ zsGO;Ny|{ZD`N`YEFEs3R{^l~YQ~y?8q{qfSL+_2B9ut6&fiFJovIR6fKGhQUvUM98 z@^i!e`DV0`j8QPp4-C35Uf5xmC>zy(W!-o#E`Z#NP+q(-ai!R~Y+_oAc8qv$Cdxxz zQL)=#<7;B%KEZv%Q6dicD*1*j40A4=>0*uLzLprfjIlb_pZiH8bg| zC%s?2;?b+|lMC^{&!K?^N5r*IG}C{ekptYg3YOonz3rJ%dl^_-C{g-Vpas)r4FXLd zI6D+Hc60=Rfy|i<>3h;-!es$(r?BcavWXaQ@ERNSy>gI_1Fbe+T4hx|9%3iVTnHmx zuq-ehTLl7WKl`5ZRJ&qN|Efm*atx>qBvbG(nYAY`Q@5-BSmS0}xj}8KDyMtnIk$$g za_@MShR5J)R5Yn45T%zZih49LQ@FQaL;{(ujq&<#U~8)5mnOH48_l9m4%iVY(y8W* zq;HKsU_#tZ?H4L2f+uh|f};qit@smK#AjyZs?z2mn=S5;OQ8IR&8I(n)kt3e8xg1_ zP*Qh>+TYsx26z8J(F&l=e2R>lj8Sk3ULyoP^^`%5c3cuqX&W!JM)Y4ZGcy==X>5dfXG7zJRT6p6^n?u& zm;1KYl^%K?M@#P>4U75vU|0parJ%6Lu5h5Iwg*vp@g;yhwdMf|hP%KVuvclWQ!Et# z5RHCUB2`9XufurxCvg=!-GI$Ekf!@h@jAEOZ~>2tnw3{?Y5Uo2;4qo_xbzx6i=9?D zf+6e$!d7fKf4|Q_s7UEOuw^pq~yZxXz3nxC0KJ&`!?InkuHN*IQT=^(p7) z7SiHvTEwU!GE61LGy^SId9z6XWKGBzs{(9v-V*#61$Hw7((GggJ3DRFXTH3IW1LoJ zdX~Vp!E`YHEkKPxO({CIfjV-8EH|EB+;p8nBQec|L~y4u+Cik$V{bPuTcULQX%t#7 zZS^KqTJ6{sR`#o}B%yp^o}#b%9F7%JQ#Y+;IUuhUhV7Wzvg z7(F%-rgt_Bwbte^J?6cynesgL+~XSM<0S3+AN>sq-|>k3tDh=-QOhQNQtYt2S}xfe zVVe6}mTLkR;te@o6Ytx8I|Yyte*Rs%mV#bu z=-i5ZtEz&+s(fEend{JICr(#(1#)-n=WZ=e4 zbew^qIrr-$q=SIU=<+`C6Nrn2{6AsS0g zobT)xAm1_th&y+-ILW4HF9Nl`=E;2Dq!cKk+~#RS`+ID(+B+SG@%qq|tGjh6h`BF- zZ=8Rd;U_pekB6~S*2T__4{u)bY?YQyqqqgsG*?O%ZvaXHW;vC8qw6f%iuU|U>UQIS z3l*r6n%|J*&+iGpA9gnf`xrt@b)L7pT4Wao&s``-e+xTFg2?k!Bdx1OwJ}fG7%Qnu~i&4FlSWcH_qchb%36 z8t|lAT;K5JB`Z9@@t)80tMIJ2lAcDq^@Jmbp>655LHOx0!#o$d!32TWN z(de?8QU&VKdGmRxIsEto1tld~a(y`8N5|a#Y}FGUu*|+|Y|IMF>#>581Ll~sH1)Rs z*11nj+2p$f38-H{FEU>^V;(7dnzR%arjma{Tvr(tHT4X6)`H7PgCewqF2c=c*`vYUNQU)za)0Mkf4eO6&tFU=|AR~5U%JIr3~#W!eFDb zmDyp0D~T^P(%acw-DLWkOu7^Piw`O^0Vq@9=yJo`9?sI0han^OARHkV+vb8Y%{w;= zbPJ~|@z6Y|WLfJc&baSYk(C6L=JL&A_U z^Kkw#YODPrp;Put| zr;8M`oAl=zHAV_idVWk%1npS>dM3Ho9E}Iv#8M~kePaLam>vxWvqXI|Mt(mKWWXR| z86}9oC+Y-XchRqA*`;vrE&&ATzvVQW=Iryv?>rncbjl8VK{-nbG6z>PLDA=KGnaP( zJ$1K?r;Hg5eV8%H$vbj-*bydZ&ZUs zv2s|MiRE^O)eB?|4p8?ELk^aB^cQ0gD}f0i2@0UT)8BD`G$Q#k!bAq##W ze?SGb)R7(2?{yOgbL{Y_(0Y}&u4rRA}N#|DF0w!A7<6= zh^{G?f}JX2hvM!r!-!hPfNHA=jpcD4aD=|9H_s%5LS}1olZKq)gGxjH+#@)j&H-yj z{cCzrUcRbGRf;Ej#2n*Q-FM^OJ=Pe`-GPGiA{wSG@ZK;4G%8iS`^`iW3gwBqd+Yk+ zyf@(5W6%=SaGP4Q4*Y4X?5&u=zAivw9G{#rXOAPc)&2MiFPG?#3{$S6&ZGJdg?WODn9ii;GRmAi;KB$bOEmTxYCC z*0prS1i{RNa$3edlhSQrdOPdDD;WU;v-(U?$ONL%xJ&wmZ%RhVdxC^x47fD|JE;b~ zUT-tk2za-FaB%$nhG3nXzWxEJ+w`L9mY3jOJ0aFu%hM@IAbsxY(L~&!MpMIb7FSm< za!LgJAWcRDx-BwOat5=KQw+pBE6u`g-DxDX?yX^*9TrO*-$N(B;&=hW0M@~t{ zQL4ZR#S5bIgwgIk&r%mcHqayZXAl&pF^t!nExqW(y2skQNa~w7i?MY=YsC3aXJ6mT za+ob848d90ep41df`%|byYaQBJIuIgToy|8lpdUX9Pe}XJbkm%-ZeQem9%c1h9GYxiMGOoJM3WC|!Iy{bN1DCfQc1_Y2Y8hRQl8}(tgg+M)5#rWg zCNF`Z*0zufKMFt5VmP$DvC%HRv_2-f^Yf9ini{p5`;j;&{6wx%$18vn0;8hLnhAO# zT8fvO*IfDFY)?ei>$;?1zNZNU<(ck`MEkk^>J!&U4jnqAU9)lqB)X)t&_;N9?RRI@ z-t>@?k`mvTWO61URs4=B_8G<-@O^ij0-*1-Fd*`@1&2956IEOdoo)(a*ZDCZVhd% z%yvXTcLYbV!NHQ%4GlA|8n6ZOOl4aWYSC`HLhs+d2ih^zqKV4!Q=M5%#3V<5!c?J^ z`qS?sZy@jFufj(p*$k70io()s{nA5OB5)rt|nYS#Y&q9TL)6^Owc1>ZKgaI3 z=G)QkMoR?10h$29-mZRg%BVZ{jGGIA8T=BbcoV3TcU)JnE_Gh}>|W}uBvzZ&*s|K&-@m*gfL+Ecl;>D30m#n;Q`PE>Y^^Lb&ctci zhb8Ip*AqDgd-Wq#-ayYoKi#XY#I^*4@fu`0BxLJ!7=Y{^EFE*6|7BDA_t#lVUGlu*@K|$E^y0+>p3)&#E+Zx%f4m&L-X>q0 z|KLH=g8zA-4U=#5HZCrXb($g9di>rurt^(4Y>KIOS{TG0a}zwlbtafy=j*OOSO%z45DVYwu>+zt++p>nJ8iC@5eju&oUXU6XVct_@&h8ST8B7p(T_)3DP_ zFh^$g53@Jjii)|WZFFoY0~I!R>3kUMNB5(Mc8Q8c2&ka8@8Du`SuLMkGg?#Wz9Em* zwC$}W9FAWD_p1DH>e0vPj}OCakdNVyRejm&-V>A<56^O0!9?iduA$lEKVY#*`vz9yso6o8^ddnv+;Xz@oZKt#py1#)a8`?* zaSuepBj{Xrrj}bLtl2+=hMq?K2;sc{cA9T-m1A8PN*q|@2oX_N_dNX12Yb6Ou1=iW ztXtzQPH|^-Y6JB2^rAB6@y(1S0--jz(I%(<5`9vb8)w}SSc>)Lc)mbN%>kO8S5nM! zjo4S`&%-p}BE4{KqH341fE_(5uI)~*57)PEa>p4x$lxq8R>jm?QL1^C9FLei!m@M1 zHt{XFMuXwud?wbSvx2d)wRi5^sc37XSXdr6>PqHQ+$(C%o$6@Xq5X7yXY;B__iXZN zI8^pi9fpE-nJkKL6`q|$yH?uJMhpxWde`y=;8)mnuG><2(H)DGIiuw2T9OgY4l@TZ%hI!0xCU7+ zD=+ID{}q4mz_H|?W~c8*qYr%+Fdqobk(W2^D>}u=$!XJGQ=#ts0&g+?OTr1*06Hvkt)*%LtV-#off>EM%24s#*1mRjgkr?BFZ<)q zpVIJk?VzT#pB<=#bUrmum{V;9vc*)@ydzR9EYn?Sq}PC&3XIfPwVo^M8ynd7(}QRa z3B*iPy)b+KI*InxiQ{L^)W4@>dD@qK(Qc-*-k}lrL5Ibt?j2xc+}_>+Vg+)_-|%p0 zFQ!jRz%x2HxF&0?Hc7K|QvnkRrhHKPe^_1AUMO>WoTA8w^(8*|VkDu#gQDxL03X38i&G5ziV3I!xfD;%bMe0jWFtZW1lw}Jv004eyFKFJ^uR8 zAyq``)~!r~IU0yvi!1CQ8RfrgEaXF7>0Kh*%Zhts(HM2IXcu)B$LPch2ijVyW0-`Q zUj=ih{-nJf%>qt+b~D{0;|kFbqIE1R$UWPh>h`2Z&0@-9-kfr4VhSLdotrc2DB^?l z44=@*HN#MOf`u9dqepbQM$z!>sHqh_RG-d-S=lpap5DH`Fmz9neAG){Dx1m05m%y0 zL?*wpvqT`&=BNwF#fUf6Q?fV?KO@P^${HE1zOcq9cnv5i-QC?2V*p3NUKVeP;D6Cz zr2sr!Dt7g{2@bWd!(+Z_QFF3gW->DD@Ho$2N+Nno%g5hiVPL?_rj-5!7_M+TwYiO9 zuZFei#lo5of+M^`@drWNr7Qm`1y1u}5fSlDR!F8Qz`}vUt{KhIVhuxXTZ`;CBdb00 zm$apFckUdj4e)M*VwUD0-_`ObZCpv`q(eCMU`l?JM({WZiMMMlgShK{i-lzn?i^CX zu+7JO9X8oeY>etUJN515jRUQ2uC9}qL~@Y+?X6H#&WgO-`uyD|hy_Wd^ivJgL7dr8 zn9k15PW8o6f6 z)>&EkG?FQlWGN}E-bi1*Y+G{PF&YzmtTS1`7oLj^ut!{<$y-=h(C{BUFX+hmkxi+l zvho=$>CRMn;=L5V#kG~?MsyPf94D=fBV7CS>F&!xU3|NVt&K8 z&w@4+ez#GBwnFVDW@cuA0w|ovc==h$I3FgdfKionIv8zUOy3LhV>_W z*owaDuvgcQseu2=$OuJestOZ!>CBlkSB(a~^Ei@iOfT2OU|(%NGa`m^{v{Az-`Sp< z@?RTIO=Sl_DsH&&-?d-omhdFtn!r1Y_>8uvKOg?McrS$2%{N^Kb)eNkd8mPepPwIs z>k*hv&MrIr0T@m*2oxXW1QKKl@&aP791me}_{=+E=KgwB)ml5v4MBxIIX z#G{$t(x~`Je#8J|?qhF;$+W}R(;PeiRgVpUN#OO+^B3M^kXU{9B}SD!l8br+B28ao zg%nL*4Nagpmbod(!U>o1mWFwGU+hx4x5X?N>g!um3|maPuLI2#se@RB-;lnfYcg5Q zFltK6;5k)#yUa9|^l0mZEPYIHwEH8wh_JBho8vaK19>yaQ`Xsgr!4udd6Uf@CL3y~ zyh$QtyUqo>rZZiER6?SBMsCD?*70fz-b^Ye+LAmj;1iBy=pd^4=v!}gckW}V&s{`B}3-S@WV4ogXS7f+x0L%nn!T`P9# znuPs!sFU%uhRfQe*Y4Do*4CdN)smr2_h%#;3JKdk^>BE5h`%y0?i>iRN9N{i=He3u z+AyFn%u$Xg$vnY%A>0Fk%69@Q(~|)2msvnF5fy7_%hX{~mkw#BS1|XKjS!%uq_k;x z6B88`RD30W1gl?~$1CtSRXH$P%=zThX()YE^G&Z07TF0}Enk50O=mTn?*pe+bThq( zhF!jA=ZBO2^dge5Zp~eeuPSy$j5yz%M2BbC z*x0gjvcu5YyBwv8P}~?cN7kcDZKob8&9q;%?k4)9@GEp8JVp&-IX!KbaCZxB59`VK zKc95)QKNrdgxRNNg&T$7@kJpeSXo6w!~%MB6qNHPg*@GtMsMev55CIC;3QjHo9!)p zapaUMlz^Fc40*xelnFi=+AO+97EMc9KD9=>(c>< zVgrxk4wDhE-IM;vdi%xZ>Q}X5ekp$j{h55T*@04DvN;TOsJQ6)eNakuRj^ifv0cF) zT`qNe=N`ngh8GkZ?{@j|<-gSQl&~ zUVZ#{3i$9nvNIau4qnc1-fLjG&Q%)V^RP+kk)^5T#{+lGdou3tJK3If2 zbKv!AeMy(46LLNYs2cd*lX}dT@QvE|y>wI@FEf1?9Vns9VB5SH@~MH8zRSmI)O#6t|<3)0#tk_-O(sj%W`iCoeTbF$1#+H(Kqx;V)vn zawxGSGC`%o;KSud2Z7(Bn*a1J=xzY>K;JPjH1vj3=s*BX6&Gd${a2m~wPf57fT_Dq`Qp`Sd@)bjr0NByiF_+1CO_3zy~ za`!GZ*c)*ys)95C;MWH!2C|pFX%kAe7*DxT1ugZ*Z8v=6Agx#$tL4a#5wdv&#M{B} znbV&DD1n=dob2M83t-Ovg+bq-ASS5G(4|;Gq4D5zyW2v(XDHeqKI+S|>7iffQAl}Q zSyK}}Cz4Oe$e2MA_3tvNbNfD_$^N0VR19hBC z(DXnu>?jcf%{=s}Yx@@}d(h9T?~FRz&1BnQ_^CKF{lIPjMv{=9M!}H*>J>rP^*WA} z7d-kuv@0s$Bp&&%y0^i$>5z!kb9jt^%sfv^OS`@4g+F{jLuMZr3BIHKA(Ti0iA%6g zlZc^g@6R`?s*#Nd-RUuf-oy*8E#!*E==O#qPz}KOGq*agIYy!dM$E9XL;=EqWe&}i z9DuvPJhFX?jjdSrVi0)+01y-)S00Leu)ch|i&a68fvV=czBEWOFR;|!k(4|L2`%rA zTAr~28H4cmZZiy27T>z^j#6K_U_ISwM8j`-$I@~!tx)b`^7rcMEEd7gx*Wr$r8Qe zDg>n&vbPxA-_!6JYZv{HW-x4tzR1fFi%2DA{tdB1(FCW^QP)#@rZ%=P< zbG312%YyasVyvx^lGx%u=*O*F^TY(`yTNLhn1n1`LQPGLvh&lk=QwMVu;Hh5@&LFm zEwgT4GJ*SfaE-C9?cMR_NTDVu2(9exYX+;p3WR1@Wj@BJ3fT#{`n(5UJnQCAQqXko~rxHyL9Bl>d(pAB~rb5{?wInFv1E3QZQ+ zA`^QD&`qo_ae)c&<%RLvQNngffcurLBr>&Rf=igM+0s&B<-BN=h+X!fPsFgSf&dvl zK|}MSFX<9ISY#po_{jR`>%%XuCYdlTZ--*|b6lLy>q9JXi>N~$0^0H@PV(4~k8B~F z7O-u+UcQtsRy4E8M7JJoEmAO1Z}>0*WeuJ12a$c)V$aL%Nbtsry9pvqdte|uL!{RL z62rrquGMg&mCxz`ptdI8W-DoMp>&=-Ep_uoCK}BIR1Q@>6YvZL(Vu?L{i0lC-z97n(G+ z)9$oR>wKS=5BJCC`*?gGzkh${kK4Fa=UmtOdcR+<=XxayKtLRtX#;>;YQ@*@--F<% z`f_!o-{v#~~g{{V&9vUTe%Sgzj)cZE$u zg8k;-DXvEy`k%3#TaJ&`V4>o9u)3ncpdqZKr#<~yvY01_24i&r&&LD@E*CkMWM-ek zBL53-c7>U%Fnm@CkuHrwR`lDqZ?84K+iGg+$5Vaeh_}|X^ol>OjNe;TvkP&Wp>g%J z>@L$q;U0O91@LhLCo!e!zb~J|=`r#VnwlD4A`a2-+rbBs*~QC2Xr21v+wRUz4n-2C z>Hofbq{@1<`5z}gt5b99CA`}i1Gb2J;#WJoyf#-yPVIJy$!J4}#C&fd+ny9MK$gj$ zVtt=;WESQoGka{?T6SVSD*SMy(S_8uFOd$@QbFMiE7`^;L=ZFlP`@}aB_$`S*R1vZ z4xvP$EZT6U$7Z3ko)`bUV_o~yw$b;kTD1!3fL=wZE(~m`-9cWfR$T{zXsK>~^X7K+ zyL|!d1T5ag%}be7*Qvp>R`dF613$IXr@+~X|6H0tJYtB(&F0*uEhj$fdU3Mo=4Vlf z-5TGZ$ctOy_|;dtkN^9#0YyJ8A=%?B#K%VKyM8X2=z`Wz0 zq5d?!C~9^H2Nt*EjE)ko?{4(&e_maT&q{VRvoO-*jCl^op53!ZFWaxaXTxfr%s@9c zdLsB5*^By}MV>GcoK7-4MOnm5NlIhrw zNc(c`n&?DYo--sJ8>P*+C@WW~SE@k3TVqspx$b8j_UM29`RCxYX$w23kf@W--XC{Z zoF_Zab{qNa)lr7GWp7aG<*z(z>rVf-Q8~FR_=z@&A??`FE@yRIOiDGLZ9%5bNmiD2 zlga8-t0_>TEiEU?%k(fv3B=rHzZ{`^`UnXna7DUR?@smj%_Lk$k#H$V zUPeG^RyCXw%m;gwL&q5k6}&mzF>oSIGdY?-l}A{3?aTymmPyZ5h&1d`JTuPx_;El< zqlLbNUEe>zY-;0F{gciZRra?z^lD*1%r#k>j#bZSIGfH)ul=`gS(H`>d~#q4oxDn0 zc=t0B9@XCwnHt&lVsa`ArJIkcAf}}G&e&$@o6AV>|KTYAG|NKR1I{18Ah_SJU3BZ5 zu$FfHIO)I76BrZ;rFTVBZ)I?Cc#@I6nOU{Cj#K3@r_{Gh2iB3S$^E1@m9UDRV#cIb z7o%37dN^qMnLW=yIMK0i1%-;`aBY!19VSBp{`@{qdavgDI|;e-PZ%gYl*UN!C8JH; zxoG3amG9T>s3zfd?-=~FJgeN_XtGSLB1p*1bD&=2S_cowar|4%rHq@)OIT8JDXp2L zn?qlpwYNAla%cJDTd@WB1NI4X_#yY{Q_<%rD>j=OEk#u(O&Ym2oG9t^8irfug-n7Z z7A7`|9KiLQnwnAyln!)qxGj-pCf8Zyp~myq=imB^vPowG7`DmvL#^l*@fn%P-f$8T zy_oGV`LV2Q1qTNQraGJoZ*?LC2w5?b04Lq-uifZM&&0Zf5Cr=0C{*x#7r)WC>>!L- znkhHvB6JHP9Ld)jQaHP_%Yx-nO==iRIG;N$&Qss`J%2@JWEZpCuE5G^*yI;yN49@r zx77!MShsI)3P>RD-@U>W+RY$^+qsKB-=t-x6MN}`{p8iwx8$7p8*MqE1^2BKzUN}F znH`x-*RGx}j9?zR_7N(jf=n|W-fR_?VblKzixbDV^uk2V^@#tH9XklJ26j;ckw7KX zv!wREu4P^Eg`Ar!kq~0dk2fWtYj!Wb#VKW_ z5F$sJsBsi03ljp!&|2GNta2w1z5k3~4Z%x-ZRwgH-Z9Ig%?fm>swm62E@8U8`Y}fz ztvgjmBjd(KXcVbllHgoS+ViQE|t9e7pvFib~mpeu|xseS(3C0|; zqY!}oa19sNs>cHCX8I;^^1J{2KxYqwo!YkT?8hD2X$P_Wk-E!z;67)YCFq>T?$#gG}8d1~cJHss>zonXSW76G@pW_dP(KhK{ zdFCPfNzIeGQMQ0n@LMIOKe*6Q3``{EF7<&-0e!{ zoaUF1lIm@;`SFo~QjHxl>w1oeu*eA&#W}u_D`l_F{5Os7>FHmm=TmcseF+MyPGi?0 zg#gTH+P7V+#8yd9Z+JhJ+-6QIg*XQ?nZOL;^C*BsgLt-EGHeQ3Dm~Bd)uaQ4a(>?> z*%XbM=+qCJ#h>)H(PrE}eL8wE`9`U@j*C9mkXFi#Z}Yvjr7TZ$vh0M~a&FffR&!M9 zWM33Jzem%#GK=rhnAOGWv74g)J+6)~j}jy1i=2G-wDw%i!lD!j0GPgZLKa~1a|!2J zT!)%$yQEBOdQOqR{eIVbGzm7Hel-6+jUhs{*o#DzeW>yh!O|fKhv}f(EQ{$i4XgoO zU7Aw4bN9J^h-#{B9iQdxCZXAU`ts#=cPF?q%Kd=@{v@HSFB*F{-lZgCnOscmX->BS6?cKf< zn{5>#IcDKG^J4>MXduHLsKy-F$W>LqD)VVDFlj}FN_K{|hM*Z& zwJe31Ws~R@bX~&n@yMLNy~BOOmpEf_tBasTox>!b@o~G{MunS*aWa_o5Fn<{>tsKD zrr@s=g^t;mi`@M3dYbJHKAn$+g&Wz}jMVNuC^gKIRl;gzf?2GJ)vXz9;z-Qps zRrzxw|N8a+e>#HyjW1mdr9@F2x3LkiRoxk$$4)W_aPfzbpox9yzRk(VC&F#jt z3m%|3cd6bx`HX*UJGbkzXC2i4T!laXwKQt3C(VKlYfQJ|BSGw!Usd|BG3~&M7m*Si zWIsGXJO3};!8Y4ZCMzStRSr~R$0iOcttK1dKzs~V4QjOmYsvT4@~NCoKB?C7-+S}x z+NKYgHkpsSdnbtV=k2vsVf4Vs&%Qaom!uqWK-adxTc z9`~EXb7wLu`r<{EWmn&7h^0TxGf)VS36&gmd=0mY7uae+mwd+_fq-hvS~zhsvxs%vh}|Gq=S1#)KsJK<1G*jv2fwoRm_-WJXsq@0`kF7-89G+4`uX zz+3hsegcnzSF>96W|`_gwXtb-)4GG=7W;+Qkj%Kl9UWw3WcnMQ^GyD%r(ayIJhHsC zpx#9Dgp$&yq!2z#poWbq2~sN{LF1uDLmAeeii+9?G*^SyL7*b8lK-(FtKKT2P(;&D zkh7P*y+?(IOG=^GWD4>$cWvOl%afs(*VpSDMThBIDpk{24AY z>w|wDO&`tnl%)h3a^jRCecqS+t#I>kZ~$aOpaUHj+=<~jsLYybQdM*u z`DfN}auT@AimdZB=9QH_f*jr_Fz{L{H`nS_2PuYou?GqPZI1>d3%J*O zcgItSma$IVPlcfc)d?73Jn{U<+^UJeFGQrp0zo zr@CcA44@<#!)F3=FCR+uE4(Zyh&}bKW;Ua`dc)jQ*y2RFK-TzDl1_&6kFFI8L{UyKNS{P;3gp>~3)%sm-FkBiC1fi=fM9;G=}eZQcoL4P{-+^$&l zH;e~syXUnk`$b~tUY(8F?d9b~LM!}Zq?aiVijCS?Y2n2mchrNDxSN_w`p%!H zUBVRB%+tl&`S|EhXME`9rbp+(VoqL~)_pT5KK{y{1P6%#(~NCelh^Cat*kop^64uU z9QHx4fw1?@zNJ@JJ>~F}r!2GbKf8~XaqrEs#dv>O*5o(JBEv|_%7Z^oz2!qiJDsqS zZJ(gg@TGe!9QQnvt#cOmltbF%Qrn`B^eT&*dQ1rau{vNO8tsE6A1+tG<%VuBu{z5{($XkCnD|fFUnGU`JDSRDzqfJ&l zRk{<6LbYb>N9Vja?MB?DA9{N30KHwR>KxJLy0}!#^wYc5JOQ%$_BmDy2dsDF>j=T8 zK+f!wFeiJ})I&~;tehV!G?E^zbFHYVGOTKr@L(5v5>e#YSD(Zb?!5S`(En;^U_ih# z9vkJnqW1>X#OUa>+6|mi4wEKV8drpg+{Yn)CjIP=_VCHv;!^>nJVXM8nR)iCj+F0U zV@ETUQJ9YX1lv`FN^VY`+yI@psh^bC{KsP}$5t|5{ zO+^vuCy+~&e|Xz(FC{;jmbDAt5O-0yKT)A_E+NHc64D4N8j_7NHq56qMPrP@TeGNB zn%Iq6p6utsZZVQI&83xL9ck(|%e+}SAl!v2p~JaeOn_B({vUh+-<~}l4Xt*eHk{wr z)~2myJ>?hxwoUV8CI4J&d2TAh`8SVrF1wN$pW{5h2g$3>U3@YkS!1MBEFhToqD?Cy zo(=>akw5kYm*n;)alO8GOf&l5KR7OQoXoTx*@o|*lkL!E-bTfqjgQ}s%5^-{rRZ&- zx>kWRXgZHz!CS<|ZjR$8F81<=507OuV8#8wY?YTd9X?!Z^YYgw2R?rMIN0RD2=p(l{c^XMy^`!*@Q`{|TL!@-#`QPcXTekKxO68sjEDAOZ^pqk3S-4j z|KAbZJ`NQo%Oje;e{b&#kV3?>{i0kwF|3b+L-Eq3J+xJi*zS5(PAa5INl4UWJJ^0t zHNKx4E@xrU+n8a(ujs$G^J|o1>e~u3-Ezbofotxb>+OjbGd_0oWncYUO+^&%Etxju zsb+ItLlfMhkHrS+zb2^Y6nSi_OrNom79G>=sR-3qZphJcKuCHae4cyizlO=`Gcv8> z>Pj~Xi&7qv>>0|XjWGcS=t0TAJSgYN$K??6HR>dh%tHC7D6k<-b^`XNPmi9xLD=qr zaOe5u=n$^_#FW+&eed!10@g%uM7%P>4>7ic4$_4zOt5x@8HK@*{P75@`LZM|tD&&UCx64!K-k z3<}5f90zI?;bFXy_f*JnNLd2KU1YOor-Yc!(T40AEl^o~inv8z(R95bwh~dnxZ0?d;MhwZ1A} zW7N&gCupBSl`_uokV2rc-@wTt&o9cVSh=BsIMG=>*sIV-9j=S5vzsmD>u-(YfXL>h z3e9;?&b&SP`!vuLcEPJ#tY~41F)>&~5f zf{tCs7X$XkUHJKOXEkMDmlEwNWaB_|^zuJ4P_-kf%B`T_oh<(y=~p6C)H8Ld$kmKN z$Ls#x)tsDnGcz*_TN@kM>*I91@p~%kKR{oXwCjx@LyuH(&DYn8wzK2Mk3+5Y{dccm zz@FXMHb#E>oSgMzlX1r*qn zI@x7x_F#aLEQqOSr9fd40lE?{|5Q0XrH#i|z z$U9;L_Jkil_^AE<;e@8%+#)e#;YvpVYp~mA65fd3;EaVMt-xS*s$XBbwfj1*z9-5} z%5c+&pndI5$`oX!qg^#BBKq}8yr&$Oy1Fr~dUx}iFk;s8nMSgWl$)}R#b;SI=S?Fx z5|1rKOTYAM6hqu==x#y`8U2Bm_;^4MCy)gIUzcBOlKeOjhQ&L`}DQgDChTgfIMvpc!* zf+nSoP3LQrRj>-MHA)ksiRVnde=v0e7Xe0;zI~3{Suj;+V2PpQ&e%6~lN@*u zV7LsBniH@R#DK=JDuVKFAZ<+ zw_qOXqKn++&v}R724L#h;m63j`KWhm_Dr9z%FK|zj_E5e6^IR+b9+ul+FM(3Duqle zLua>Rd)F&!cf<=>5%&)HxsFM~KMQ|`2)t;-o1=VHBVrdv>5q_yJjE|0S*`~({i(Q^ zg446Jv$G&v#IU2kKTtC#v2(_G+&~sdc+S)zbxP96$+bl)mj?rcdaA{YYa<*I!Uz)y zL@M;@N!OfasYXENJms9{IGOG@`$s8wu^%G(LEe)?2#vz+thy!qn7H?P}O>0BR2Dpb}uEkT8j^HAH} z`UiT@6`dttcm|u(ed!kFdTGv@Nd(UpW;bu)&Q=H0<3>Q}5Oe^SuE}bN^WZSco!r2u z(!&@pN1a%UZo+OlG_~^9KP1RUd#V?LaUKM_^#%q8zKo6nschOV$-AFd$2my!+7mOw zfFe(;h8rU_ncjsHl$4Z|+60|&WImv<84rB}j>o+kf7#PsHS5U{uVI95LLlM@4i(Xx zF?V;8v7h(^oQzEA4Nkpc(BoBd)50R6}utd%Jl~;bL18G4KV)9MpMovm}pD z(vr`a>LZT5oRS6)B<#Zc8&>-_wY(^FkJn^Pq6M+!t@V~O1%k44=~BU~?OXQWXg+xJ z<^)UKSiqUc%6uyloC4oJlsYBukd*AX7ao`zDWF~5Mpuh zek{*}a2s<9FMs`{P?1l`au7q<6u}OadCP4`J9?1jys$@B{q@E5=(#^C&)392_H;r~ zu^>M`zoYvJmGN8Fx}s#c$nGdlbUtmVXfTbIcmycym7P<=Ln*cwN?Vt-h6$_?=v=J3%brDoJ|mj+E9L%y3d2XkJRxYf3yS1N zSJ&GOcqs3t-~a&FaP`}ZV>wP_V?S;wn-FX-nuAhKv&^Iz<=m2 z`^llz-9+6A6;(;eaj2CI>cgoG7W>p<*Fht=V~5y!V8NSe%?`Tv>K9oGRvxFM*is0#~9QyqMf~xxkLQgR~%;dR|(DQ%*F4w zr(W07*N0$Gmj8Tw7X(RA`OVjCh_|9Chsst$rm`o6fsyfRmWGv=R*pYyl{pc$gmj7t z%^-jNy4SuLAmGI9dasaZ4JY= z_|AG@gS!*Zr+u_4$Ng(iYz%e zyk<3zf`_O}mFC9?i%5Bf+WTu!{dZS?OOkM!6;+G9G)H@L<853b)<|wGGoj1k3Ke}g z!lz(LJ97Ucq|7ShRudDsFp0f;=n`KQ&LK@Z6-;}xjUC1B!<*DQT zREnP1^;#kd_uX1ufLpgq@1)w2cdL&VR96eMaj)kPyKyHoSc*?MY|FlVtAJ{wH97;> zi6cy2L~zSL3QSBW>MUj_c0m#J8dku%H|oU&3*ixIP;(D8rS@Lv6T;0oh60jsKfXSD zx9r&p@!9M{3ygr?EIPVQR27C7R8@Hajz5zm&$5|Q?Cc@z<^oV+1SS~@A>p5Le3T#J z#8s{dxeGn9y~h<*%2Q1m=~)F$>`h0nUZ&A8a81L~(oT%GB zeg$!!skSd=PwHr&+#x~zDps>YD~)szK;Z3ZMjbINsJ^O{l@A;^FiNR?y+bwmRD9bhl}qWzq|6GxOM30gJtp_ zn{ROA+ubYm7@a#_M>OL8d zVvq-E5!pl2`?hYK4kMJxF4e*6mxH<#{f6fAZqW}mrF!>n0j}#p(T;)S{10@A04OD1?d3G;NiR-J%@W_$RqN6+hsM-}WEoMc$LW z6r<=XOnT4Poj4QTn>wC(1=S90HP6tq*Cd$f07Jir`5g!!m}U5l2zPNASY-G`nuTSd}|`#muEJy@iW|H-=uR#;n2~H^-_* zK$~iB>KS@+Ew9M!R-aN;T)WyQ_`OA^JgVouZ`lgB<~TVm6(-{VHoCab4u^{H@!e0z zrEY*$6bE|1d;x0~wTNTGwQb6w9hoD!F}4Y7QR;mruh3U43UJrE_n{%&yj<|J9bGHW?=YO!;)yf`A zojkCk`>RQ*;-N_^K0~H5<47H+F$Sl(b^Athua@e~?^chd=GGs}4a^~2$A%Rl`H*4k zBIF`1nbrbd(<37-W3+NDH$HHrQFQg)A|rXw72dd*o8J*=Db-RULQ$SN2KC=bh}{l< zq#lm4&CWWirlFZD405qh(eE^>6k@p2%4%A%p2VN1`E=X=cwHgRM;D%%GPI1D zd5^hEWl;Kn4-?zgS2B2dV6TUzTecXqV;7v!Vk(Nkd}cB&lGfLISKb6S+mbljXnR?X zR2QqBX_$dG4+Bs&+#M^#URXnFG%sf4MV^Sx4CWxH!Xy>hD80f*^HXyp2otxO#Sz#E zu_=Vqrmr?D`_n7FzUF6rZFdJ5Gch5^y+Y|Z#o#Am_Q9}aNwk8%qsO05yE+aD%J6hX zk6eR7eo(~xGMe^zol{fdppD>aLyVdQC(Rb6eSt-Y9P)^_IA z#2-NX9!qAAX#cSxDc~Fgeb7@GPRLLpTG(YiYvwu_GU(U@_CkWoCS(JKwu)bqf;?xz2-(IMw z+3XGD9IJ0*wrr5d{k8rL$80Svx53uU7Di(QRa}MuG|YX7%3O$oMQkj&(v%v_q;X*sbwgfgC{0VbW}8J81V;5NF?<@i`NMn#08V@zLMR5ZTORf-E)ldR|;oWUmR z-|(_EM?aISiIX7Qd3M+78L`2YXJeE(iAv`CME{+;8jRPiZI6V<{HUQ}G}!^r6)Ik>mzGQn46E0z`{bb^Ldnj< z1iDWEM}J9xnt*;NKx8HpFU(QsImL-G6&-IG%r+N3*sVUo#441#bJPsOi-nn#x7s=l z6EmP)o$m(7(r!IJLO0V@Q$5abSUpa=l9n@b`)%-5#k*%}jSO|y8|iF5aCr+!Zhj`R zFloK4A9w=m_EO)FfP{jg={rt*5HspMsta+BC0bIQO&^-6#&|#pKaZLNG~6U zr@tgaUBf9isFt9WuC~RusMXBO&W=-K!p8tne6c;~g3oz7CWdeIkJa;HwyO|gjIm|Y z#&X!dZQESJ!BA<3&(lLjNu0}NiUJfI$YZ$zx$Q&ZZA&8LJvRX+;}ehAKS&oOZl-+h zoG3B)16u|PV2!0>?gh9(2b|~Gft&Ca2NcXRtX#&z(%sJ&F6@0V$FCR^C=l3DB5a+{ zjujd^UVbTeR4qvmDdfzzvGsf?;ASs^7TK=5PW>AOe!~*RBPQkvozrSIw&LD!XG7&o zt6pVd@y2kwI93i|YR8Yhz7nkDZP3<6Fdmxd3zHJF!EB}(0?(NZGy+$&%=Ak0ves1p zBllOWPpi*{)eNpdJ#1}R6J_Oa4G9rSHUi^Af0DIOCAE}kBuckn%aeWEg&eAMLgZY+ z2#oT_4-3_4OcOh^=fa1_060USvLsk8v{HnE9WWYk?ni!k@J#JX+m{b$L$hsLb-Mz- zFmIB)v%27(YWyXQsUf*MP$KZGiU4X&@6U3a@8`o&eissObD&$%)d7cWZ1$UFQw?)2 z>%r|pFh~gC5XFts>o~#+4eldvZ&o)(W&=<~8-=cKhX9SY|L__`zrBTJr$4-J0_cO- zXW_+sCW12IxyKT7aq>C}|7s{iOUf$rY9FlbnQ*BLJOX&>^`+Ne_l`&ba{7TG(R2~8 zBdN=np~C8yijZ?-T=nu&y5(SN_I=oTKf_540MoX`(R#QxP=PC~gjG&ixBYSc#PGM3 zOibY0hp#|%>A@|vYCtb+Q|8X&=*YM!w-m#BUXC(eZRBCGP|;Mc(M?((GTg=LO4acs#hU6o(| z0b7Ju3x8K9p!NjUAt-U*^qDHC5aZhyTY{#4!WTd|+k>_*cEXt0gn(a;r4zfMh=V*9uxKT;_)*kDtlxS0K5qnVtxrlMM!NP1m)t7IU zOH;;sy$XRLjv5u&*$4Mn+%9C?+(q1jP)X~V#ikKZ?A-%qIk3UH7hYtdInn5GUKFBt zhww!iZV75%F1PU2&k=_?b+F!|m8m}IF0{%gQHs=`!3j`q$DHUa%3AzB(DT}Bt8Ghr zo6CYH3f1?Ple@9c-A8l@{3xAn({d4K3hwoMSh-9yFL84_<`MWlq@`C|VxN&S zK^V8W%yHtb5=($c{dsC(9R&j1gG++2S2Ry#7^ zV&+WiWJOlNL5SA_p&{F=`Ju!=e5|v`0uDw1Cq9qO!*=Qzpjm`cBmQ`I{KcrL682c)g5eTm?lq2Nn+nu8S6Ige-M3jTm6wHsG4b+`!@Me` zPIR$OO*W{GCELhS&|iHdQv&zvGh%{5=i#$!5ZKV+h6tl}*IRcp)EmmmrAD%jL>75o zNHbq8rxbV__A0#1-b}NcO7Wn1qIU@E1^o{Er@eE?&6-YNa$T}ZV`C${_O>W_eq%LpKhej+t&^ZF!|=uI>wRuVM}_!I zup3atSAV3gd}Zr9pzbd!hlgp7w&2?Dq70KjsJ5&6-EfQ}C;TtZ?2K{1>^v-S;5Cm=G zj5I@3sO^W`6mMjPK$ZvlkG2S5#C?4+DpqogbI}x4;&bp+Givb_wr!4Y9^r`eSu+r& zu6>#q%X#U(e1v=RUM1H`R!FiSFLuZ`8;iUqGrCWMun}(Y9dC*8488Pd-c8hRsne;dd>zV z)kM9*HJk@&^DZIlrf0j&1wldvKAE`Zv5%3I7c^u;Cb4oJ>cAk|my1v$P#d|t@#Cx^P_;%h% zp|-`YF8A$xI$bx;t;5!`l#%f)M6QEFG!35V#>7r&;2*@Qtv_(rf&T#);RN!&x9<5M zQ~sUrw;zN2E$1i)-BS|?se*kp`TO%OA46(um9`v|*@D7@p`C8T_79oaHR;3M6v9lN0vsf_z!q*r^W_Hwh2j zYh-rV4sRxDXlUGM%~;QEW(GL8mXy$;&HfOoXhv6HPYZKePLFgVzkI6F%0x=m4byL| zTwQtjZpSkGfTjKFDt_?+1SLR+!|l--ybVZpA*_W7{I%K}-qSPfWYovK)qLyvgFx)n z#h%}rXuW}(yL!0&vejraLND(fI)oyJ)?TiG0f8w1)bl zxtsX;>Ct-`W_~$N0Lh`K)6`f1ApUuE$wrVejtfb0tiL-p{=Ef`ET4&5LDI!<=S&Yl*1>0au4^*o9d$J{HEUvzDSGhg`*?@un)__}gVo^{mpC27 z64x^jWQX=^>t&esMMzY)-d1(iFn@k^zYObvbz?#2cf5~(FPwD1M!I4?9f$3)6=4MO z3lR17m-WvVHcQ)a*Zb4GHI49QuMPQgq44YQN<97RM1V|p-t!dPcq-(fM@7r|2S^Um zU}Y_{w`*pbT*tw7|4RTp7LV;M<1ii1+r8rT&co#-dpekij=B6Lob;LV$4W@Om0xxc zC9aTe$BlEMdxONXO-JXQ*K)4aUrc|IVD{Wwb@Y;HJqmOrmLLtOCRayC?e|J1ekpNM z!pCj;R91G-x9HTXCjk%c-_P9Lr2{10U@`q&WVNcQYN-&hr0T6}HVylpNE6tu-HvP$ z5)63IkIH-gcaAR9#E)@<(mgS(4wox9ErIQ#vZA60ZLq!l^ecVjRTGcNruy|&p>ox? z=$S}FzSRFL=55EYmP)js_4P&6vuc7VXLE~~q}I!{N|0GOCIAUGG%>Mkj(GG37C}dp zUQHk)!*4x2l=hpn63pYbH1f)^vEGq#d$_?wX(3@@@%R2GWh*rlKh^d$60{IEL}c?A z8WuTeu)*Nm6TWuM+PVXO-Lj)yDK+bcw-xT$zuyaTu+z}{yAL5{h2!nptF0e)VH)c6 z6&cvvs{3nV=pJ}TD<0En3zls2i);Gzlpi|8@(R_rjD{J?v5yAAfpR)j3M2z?6+NaV z;ZcWF`E1Nm#)%Q5Me^duV*wuWZ{NoCH^ij=k6}zUns~+@d3x#(qztipN&u3%Ui)fQ z7i*ZNhQ^mH4b`mdy>7i@z16;tAD@0{yovezxwthKI)0Xw*R18|H})vnj7{daQkHA5 zW*|rWbU;8r^X$9#ADiCH%QO{v$n(IH9Xt2(GL&^nd3q!zf0{%0-(y14X)tx1^+1M- zJJx324;}>)X2w=?OPjSakBn2@vLGwA>KeDWd&R^=3--Jeo0M~Ln~q=Y6CzD5Wva>1 z(!6ow(NsWLMTH*imRnEttwp-VZ%r5$SaJE?ve`|icuP3$nhIXNB6!KCHbR-~DS2+; zuQmDrcdKwGGGHX+Bgxmt3lzNmL4450jp+j+I(W3@V$UaULWBqsc!=KLD>U?Z-Foi5m|$NBdju%P(Y z9^fkR!1>B()-g)YQ-Kldy3TTYyVGElwrSPGF7SImdA|ea`1J>Wc5dR=vpb0mA?fyQh#G$-gb4^F7;|Z& zgM`}FL8nd~iC$OX^2~H|%Qx7D13Ve&J)NCy31$qCNNvEwcX$(}_SmGysr2JutGx7{xAKs)*57U;y! z+`V+)-=7cNCNH?nwR{En1{pRB7c~e|`p??ACvcIthKHwbYVfPa z5-DhP8h<@`U4CasT3!2v*_l^_vE70c0U$vPl4*`A6cgG3VClzkQ029@a=}VRB!->N zXHrlop3A8MCIZ!Gra;+6Jgr#WNN9t4ciliTqFm~>{t;6X`!PK(e~DH#kXKid%aSGY zR^nYO^5V_h-Kp>2e?<1k=Ivp*mw)V)$#rw%VRU!_KaYw1fr;yc(XfuTaVip;>z z>8eIhMWM?4_f>dMH4P6>bN7G5uJ2~ppeiL)g)TadhY*elqXoC~#A>Kn2@zR*iOR|A zgd!J;+xHi$a{p5IKWl|P<~v7%i23McZRg4L+D;4BdYv64A{LS8dC*4mOaRFsq?Qn! zyzV^ya10{E9}wG~J^N($kw=wCe7OocG;HN*k!=>3AoK~=0PuGG(Z6p@bJUG<40z`| z2%OVR(q zL(dEQ*LssIwL=Ykk z>~BWok#Hw3fQ8JUuhnX`5O)1b^gi9@q>d~zi{G+$By5@C2I{=o479OhH2;Xb;qOIW zVts9eEz$8+WIB=;r@t9NS2S9cIbzxWb%$D+%ytQhjS+dS-uai)V(1TjlaRPhvTXRU zH@Vos?RM|)JAIa&c(@)sH3xG_@KXVe>_{1WYUYCFkDPr;1iWr}Wx*rw#Z?GtbxBFd zyW`W)A?4u~bm}5vWMug&!V99~JXfdZV57XMQZwZx)Y?5JYxzzQ77ldOvUBrR8PMi; z8lKunYhMaqy3R|>_50h%2!|*j0G9UWN3J%$6gtAiUY6SCq;ObI?^Zu6r|c(H+O!KH zGv7bdG*;hahIGYmZ+iHoq=F?7=&e_K94h4(n$=v6P#ZzA#1`QEZM@q9Asn+TZhhZk z#9<4*)W^0NKhGOxZyhI!7-@lV74gn7sIoRB`qe-&pZ4*trjeXTNVy4p`Db0$(c%x+ zaFF&W2K)Hr!{97vIDa50<*IVHvlGEXAi&k&`AFOUQ>@*s7~Eirh_}2>71=`z_YTcZ zHJNR=w%_dAM+l8ifM2;*lfx;HJ^sFMcBEmPl91bub&lsS`c!xoe)>On$Z#3#>m(6| z^;RR(uQ<#u1Ps;J7ix-Oc0WrUhYpy@bXu(CC5>Y21KYLGxfdTOOx(^7AZ15DPm!@% zmU1TYqacdD&nsJjG!3)Z7RC{l{R!n+NMhr%H`vA!EE)}QIWbHPcH4D!i);CmM{#v) zo?G$ZgH{!IOMe97wL%G8K{)|!Kv7 z&~co+a?43*q!n|u{+70{tkYg0Nn~0=lg+Cf^mHT*EkgG|4BSb-rPbhBKPo0mJFM6|M0D> z%-xW&E>fB)S@1u83Y98OTfX)}s-5nZAH3nG2u%%QGOGMLWsLu@hhRol@%)RhS8=6lcZ@MSHe!|Rm>}M zvWE8*p(oxbr7O)Qp#9-E4eNFke93C^)E;=(qtv~_;7tp{*wxpkyA!nIBnhFmZGtu_ zYyPVueD2hHF@4@%UWSbiRy$5sHi%yP`c#2C@J?{B6sUAYv#d#8Ufcg*o`q zvSyZCxl}HAt>fs$RMW~VDy6?Jl@)%0?BPj(YvjwH%w;i`07y_#jf(6EL@Qjt+-IlR zX?-XR8Mwp^n=c59B-xpNTpFvMVA{IaS^L^!lZa$0rzeMEcx(2zce?aP?#Z0)V_Qao zW|{a}j{Hmto0;Y#Q8hpZw- z!<*ed5KvBSfY3b5FVH|pPqe!MzYsC~Xg4+7q~7OX_jBUfy;a;r({f5g8kIEeBG%ml zvz!G#T^&0}Bl}A!Cqj9!&5_zy!AX#^u9V;e@ntZ(jgqZTFdy1KGHezEd&Wy}) zxA*s#0-Ub`R_%PGd!Ld-d=IcgW_HoX=`Ph#kp0iMsj5JdS}8S>T^#8xXSh0T-%s09 zx={ECUCld)v&1(+V6Y|CA6hm>)=hVy-F5!<=WIUpfq<_{Wbz6yZ3IUgB?bQq@$GW0$kpOe9GZhdX~1$X&Rut)2~?C1$P@CMA$qba_`r-y4*kg&6M}9HB5^ zgK=C!q_dMi5&8+p{XYb;I!SwuRf^|@q`Pz?7y)>AsDuj%!6!d3=U5k(gC5~4Af^e8 zGA<^8EUTx+ODy29CB*aN-TmIzYA^>xXe4K0RiuKau$dWj@B~u^JHKcz5w$EkGve}E zJ8qMRQN^d0N*33Lta25ER3CJ3f`eRkcJ|oRRL|$<1?kq!A2wpp-5!d4$0?2!Mk{$J3yE` zE|!pLICK$8G3rluf&Q=QNo0p!uTbLDbx{hIUnsv7&o8r3tE$8<-m2F$EaPBD%jG8I zn6mTj^bm2;u}ho(BV#|^ff36;Qgxhnos$v0(fHU~Zob6P3o-@EiL0yxi4bN#eu6Ru z%hEV7^RJMq$kHZ65IIv}G79rhhi|oP6jW@;1NQhWFSiN;SMfPnv0d!L<*I(*^Yw^-JSpA$C1e7 zXDZ( zIFU$|U0>w6*-8u?+`QO;%I({?E1E(!huVQ>B6ey_b>8VGYb^sU8Nx^Er0sJZ$s>+K z)4e**L`q`TRM{J1OXwLQ1l=X?nCyDejWp67?@~4VW5`K{jTEi0VDsaP)J%0 z#-0=swsW(bMMe%|V=JkZta7C)x$_mUZrpNrSM({<3b0(ay?1%!g-;)kxmUDtAEUHyC*0%PXt5s_M}2_iqTOD@6Gk4J`q=tQmY`O}Z){F`l<|1A@B7-gOe6j0C4i zi(?q7(T?F^(^apuUc2NvZwP;LE)JLw3zC#CrT%)#n6QptFs!Vk^Ms58p&HAXnU}s} ze`Y_LN9V;QQwAz!@9ka^T&0!%;Ye&DwCu!aZz0FD!W=Fc`;LKuavV67O(+^w@UcO8 zecl#fU~F9ON=I+%(6nqh3z1TcNT!e5mYE5%vy;=M)vK5v`vgk;{8m`_twS2mm(f&( zhP1Fnhjjw=k5+nA_;0QBo-|HKDdpNy;O1s*e^|^d&Xh(6;TpTB`9GLuv$J-Ywky=u znjyqo0{h9Iz8LsQy+i34BCATvVNS+|d{yYr!W_5R9vyi5&84;0Dc#2=3}d@fvee1) z%p8y_tecw}$$W0(aHXvhDfP>*exlqmnc8?*z6T#E#zJ;eN=ji7cTW;{J)xaxOWKADq zxzko6q+avk#9DXYxRpU-R^ciQ7bH6j=V~iYs;CfD=xYSX^C(3{zlXQm+8iQks8&tFHt$!}Qss4@p93T7oE8oH4p!Va+ z($X(kd(Eyl6?tyzRwS$^O(`Zsni5i)Pd$UF074QOIEdT(zC;pwd-O7=Q*LNr$VeeX z1w?<1XPXy4W|E`6CKAIn7y|yImEEy-?@-1yv+ZqssIj|uplXtYzgFW{dSjI{f zD0kq{Ol|bHPWOtxG@S!ME0OuOOK!1*Izu5O1Cq(caEvGt*}P6I$PkDAxf;?G37bJ8 zq^8H|n}!Ahm;RL!nrScE2t>^hY4kT*G79x{&VFUsB2MZb8ge=^+1b};E}@cctt%Wz zY&CHG!BNDcGn--kv;F7KvU{E!W>myGBP?OaH2U@);bCODMnqsfS?j;v7V;3mnYV4v0a${arZ!Yww9^p z?DR@W*K`wu#W#BjH4qEhH?aPn2pj0|iazvHQ`n|5qj%z**(hnSGu&}%7bcQo*RS7v zz|Iu{a&0H-Gd(l+-yN_XP(k$24hNgln^!v!;Yg{NnN8O|m2l0PV&_-RIf+rKJ18m9 z{%%|I=cdj=o=EIrh+cY!hzEt#VZwWkR_Y&^Kph?D)#x?EJLSi-uTNg*ayHn8c(3*` z1@5TZDhvSMmP@TJBD&fQ#W7l3Q998cnm@*^ejGLp9VFtnM%8Mr7#Or;U5W)pC6RFZEHzO>=V`9jY1`jvHYQ>c&84`UvN2dk;<#6peFwPK703 zZr@QjDyY}B3@2s4%xD1uq>1V39=tr+E7`kU#56QCjvhNkHwsinN37+U@wzsU2h14o z^U9ihb2N7&Gb8pyB7g`min#P)s{D6qa++V2iP^Pluf=Ok-G3rRh2*#}B#s1Rzc3pt zoaq>3@rYZS?bABRxTx{IqfJ`cvN}8@#IahY(@ZUVckI*J=Bab>m-46gnfZCH6n{50 z-Ew*ue|b48DM|79SGMlV=vRp8EaMAzDl1dtCj!_x#7&n(Sj8yM?)aS?*R@}j7#G>a z_0Fnc9|`b`QEQLAgllj>z*V6_%P>TGkU|pJm-k_?0(K#!(yrwEZo37kW`KmYlOXk7 zdbzkN^ita95dNzZgO$6l=XlEV&=&fRlsP;j44F@NtCfZ9FTPq~n^n_&S+}*Xd!UrV zNy>2IYAbnQXdt>e)A?(mI|4mg#!`IzX$g?TAPYd`_HY zp9+m7eSn3npG}QORN_Pg>E)VP7HWO+zW7;7WCqpt*Dwr=2P)mUe;;8{&Z;#IZO3U@ zevtn9H>FZolAB&w-Yntt%rc$ag!_~bSl>Th(hcOhtyWCy(n*b{lWpVmxi-7HKW6uw zpM3qI%ptv_Ieo@BB@_8mCk@g=1*ZLL5Od z>alUQ4-YGMpL|x?1hq#?#?9{Fk(?-$({M>$Yu;%a_XGZ#FPN}I=o8U#!lCgT8m!bT z>0_1C09S}PIq)SUFS~j$pU0O{m2TA#?>HdD1{cdoP#H<&@(qLtU5dfFvb@Hb`@p;K z>uP%&YQ+MTQnQ`9p4lRk_U?)ZGdL;{*n;e_+Ynj}o0<|MCg5UxN6Vvgo?Ecxl^HJ) zbNjZ{Kj!=5^nzk_$+g`$lhiH=_$}A{d(61@x|63AlhUS-RXz_7=SFtN zjn=H!c{WeQGY?tBeQ|mdwnN=_+C$cSbPE9pOfQTLjO3olFTRfhbR9LbN1hPy6E^pu z={5_9__;Ex%1{1*`+!K~s-&&6oOlAzZx5On>ACRUhX^yCL+DXqcesx~)H?fB!aW8_s$QL`#G9jDTUHs>P=VcF(3i@j1W0Ra#SKgeuG zD^4k^JBS^tOhs`4P;sy&(;s^-W>(QXaCh%as+MEg!Z#QG7-2^J)hx)&=oAaCK%m#p?AU<-}%*=T>=sVDL z>G9s`uEb}gxmS_max6uPxiiEgiRS7U#i@I#D1wMx7ctFrL^M}d4- zR$)#Dd^L_KpfKZ!e%IrKfLI3*N5lImIi-Lyf|iL}N@ZS2duQ&}qrymNxW4QPFfcrW zEC@WWdyQ#0v1IT9)NkHfdc40?Px0YlFEt$<-C8xs0^#){W09Kz_?#2C!zY$c<8dos z!neE}qyK8|40L(W5+-(Rh4h?GU@-2#7m2NS)w(nPrq$pNCa`Z6%@tl@Mr0^NJgJ$UcqT03T99yq3Bksp|nk*#b zu0oCiH^^lI|FNwSU&#higBpg1W1uv~(7DNKU4h(Gp!DJMmO+GxvOP}>#AmNBMMHu= zL<@pkkG@Fs>Kl4VO7jgYj;2tMA!ulL4r*6FN3Jbm>@FBYX@VhuLH z=ZQ}M08jByyiQ1Jy=q(H)L?IKIL;koj@Jr#F3cIaIP4C^f0}C2zC>_INK5aJ=w*Z*VL<_Giud zs-_^%MmzuEr9jdB8^4OHSoMFzQ>yS89pF9O1{q-1thv}M@N$J$mnFS^J|ZT3L6Yi> zHoS48|4gMb2dwjZyZw*)3__~XA;Y6pOe;er>5{OVqg0-{i~89S~A`|N0y&UUeIIu1_GZXsLS5sep*QF_5Ma5b3Y^ zLG&}Dd2~QEbDZl7dG}V^s*lYg4l`JKN^EbHTUl(41VEurkmd_Up*`p@9Ey8XR^-p~ z|3bIc3f_a4$Z3vF=(K;oBd>9vO?{ z2q59YXQawar&6168P8mYK*o3r&huuCUjEU_U!k$H*^)=wLFo7U&z14gy(qW^oK+z? zdI`5hh`@9F#KjSiW$^`IAMnxcKWTKp^y9P8xvd*WfI`;jo6G>Z2K9Kax|V?O#QWX> z@JK-+=Q`k#5QjA_-~i=nko5zHE@Lw`>(eMqFmCuD#f1bF0`C7{11H3T$A#KA>>-4PXt7b1uH zY$#Y-{-D1A7|PT}pJCemIZHI*8!CZd7$7cdtOJBklzfTL&2&}p(|jvVgKYHDss(%* z#IhpX{f4iR*@hL*@xCG4$RKY}mkuoIYGKqF`@X6Dy}nUUP=%^GdH090j-b+@257mc z3yzSA*UDvhj`v}^H-11&Fo6I;(6IVE0kuu;d%G@P+Z$R?Ve1SHjiT?ZT`v>P^DeXX z6y`%8uS5OI@xD=H-8a;0sN%eamwB$zE4_dGo#+}8Cp}#57`d~hpSr($2658o=}%XT zGME>lMsC0Z!>cA&W3jA2{gXTrM_gJNGab1(xb!tgF(~R8eA0$?2j}pkI{knF)~i zu80{so(>C_VnJbHqIGmha$}Ej;HeQI@9>kOjmmasZ!NmXN!jGI_H=6DsBQ1_rN-O+qRB zu=GA=B%6GLvhBc}ff^u}xVek8 z^GwXlq#>Oe7%)shT@(LEBkBOFQPKua_|2C4D@X_fA%OcJGBJN@#4jr=iv`?|b-WG( zblhs>BU}LZq?91N$pgx*G`@5n)XW1dVtfUKhdktS#l+mj>HloVrdE7Szphkev)zx6 zh+}_&6hk-p7^@J(cK>`UBU=p4Ba{?DB{KRkk7H2pI+8U03C(L6A11uQ8ML!xco#u( zRN;Axm|qi(SrW^yQaCA9)y*JTV7sb z<+t+^3#UsuE>Uuv>&&YxGhHQlj+6JhjCvQ`fG5)78X3%}(CeHgZ=sQwp)2PpAX6O(n`VAfWJIH>}l1zch z@-82WAgkNTf0d$4_CH^3x@Oj-y1ZM`a((h>ht$OLzr|&LKltU#rJ?@q-stAN=>r zFk+k!9!{(oe0rH`Pf)ZlFBmw9arvLO=OHmjX8NxIh&FAyelz@OvGcHJbG!V> z%Tfu5-`wH9M_@=;aoEcl#dVJpQ-3t*vnt-H|MO)NaGFGiIDWL4%h6Vn7iZkcSw4nzIRXh^FnulO_4moL}19-DaRzffUVa@u?UvQry)u7xhV#YX20=n{DasKN|fB@iwNC$@LBiVC3_l1 z7VnX2&mGIfJe5xoPY697wI*{xeCAeSk}+^Q_yI!W_pBB(Lr;D7Jpq;1-a=kj=F>k7 zPPBE+tOb8^*^Ud#yfvX~wV3i5q)OfZUrn{f&=b>}<379&66ttxMRs;_cOiEc%}mKw zD&B(9(oz`;kN|BrZv2~zISEn%rv6kP%fs)S_!ogRXea6>!8(~^o`U94jb}}~L#0da zB&DPapjs-P@Cd;XzLFBq$A1a1C*ZQyN_YYQ*y1-G9L|EgLMW?VIJhX9xw`fB6Mhyq zRTHJ8rB~`gnc-m9{&#g({)Ti!`C?d8Rg$E?xDmi;)z(mUH4*G?DM38g{^^rDVx~Le zOzfmE6hfNsFNn!MbM=Pj6c3btLp+myM%Z4dbHc;(aoP)KW{uYnO}A(Hp`LKN{NFot zDCCXz{X)hWuUXSq`pU0})D z`1`B)O0?kRf*lfWivwofS1~QnzX7KiEPg1`cnoN=KV7uq?@Jk3WzTm%J)G1b+5c*s zTFB|m&!1~hSQ#H01T3_Cu^Rx(7A|AjR z@!8S?mnS`y?KmN2E!8rD9b|hR&1*AVFDL+p=lJZbEL0N(t}mDVN_bO`~WaASzm07c^lr59OJ zhf9w!8kiAajX-$;eEk>~D8&IhFZZWD$4{I{(#kWG(v6L0nqGrqtpLe@%7+~UM4?_Dek3<+IyBxFM*MVPyZPxGJ_r2*wqyRKM0@}xCIO=k#fhz(nQ;! zC0y+HyO12A428=eQ-OZFQwm=z6)GN1wMN~6t@8L=ZLK(v124~idF4Ui0GScZ%OME$ zOl{e)DrR#8!W$tgc~hO{4;NjI^vDCp8p*}$TPBF%=8n_e_yB2^=0cX3gu)ee!^)SR zP~LgAQXZbHP-2vRldc~K#_O`wT#=1{gN+^3!d;DCs+B3aEYoPDV$^ zo%c5$9n$LSgaAfm|Jw1gqyfk)R)mJY9!xZC)h#2iJ@a1haa2#1R)Nj18!D9DCoRIP zdVKsx+Z|9s*5$JeC{|pg&%U|m_BDn-5OS7ba(qflx5V~-;s$om(;o{D*R1UiM<2Zs z^y=Bx$mxqSI#OCalY~OAokrSZ1wZ}G0DwI9uG;b|u2}JQYKpR?6i?bZb}N{9wwqDxNA1lJjy=rs8P_v zP@$Q>cB>n_{3_SkU=Wby5nD2HwldR-nA-D-5q@+_QBkpZA=P~5 z%d0b(#aT@xvSw;}Ds;g~tdzaNV8k$duwx*jzFvx#k1sXg3!mog&^Up|no#|=eWAj6 zJ%kSDGQ46_sz*V40?o+WHKrorS+zJ^!!i1XgHb&0-FkXnjggd;l%VsXqYhj6gu8}@ z1`MG(lyiSB{anMys3yhz`_F=L=01bq*RNl9cMc`GZ!Kk2q)Ag~60U=6LQhX`R#t^P zl=5aK!1n+A$dHkc@Mw6(_17@fcGZtRI&z5M2>GGcw~cp_RG&{YHHEfJ;@w?}<2UY- znt0ok4mvJwtd)CA7FO?LEgjD}F>`ZE(4wE+N_dNsYf%RY&pPen$MXy-QkBK_B*Vfq zUe_m5ou>}1zkkPC@tSk{rKwXtdAYgiyLdDVbgK62Op2g^$8`u#>j!Suimll3Y)yEo zxlcYst5}a{;#B8~N)cB6OB?H(GQm-S)B|=Asn3rNe|#iSS68R2tFN9~7qnUpv(;0+ zpT)}k*rY~?Sy}TQ#$qlsC^k2C#PH;)Q_ZGD z8HI(aus~2Pt>dJEU&6!58D-1L1oWb?LR7CFIyq?uJ529X6uz627%tm(mF@iTqw@rZ z?bXS)uU|23F@tlPLT(?V7#UNDpFeFTA|6scJ_Lm*!eSOWcPowdYd@DG_A-z!vjc{d z7*r|jBIBY%7&bPxn#WhNh1k!N$OV%hI#JT2^ z-MfE_puARu!_(CqPoui#g9XCHfq(80N*NPYGs=7Xlj%U0j8j`N@t0 zs|h*0=T9X*m6p+{T`3K5}lotJ^C~umbz4>m@V6d2U zQo#dnn_}Ib{`Pd(29^#E>esGaqoUi&_CofwaK5Xi`SpN&Nl96cjb2O#UN}#sF8ip! zen>$7#&<-1=aGK-Jqb07IqKow0?WpQzQ2Y`1Ul(m@wal8xysvn;Z7|&XGe;!uG6%aIXt>M^3GGSd z6W4mXgLZm)ddi(~fLZMHbap0=jfK59!_pG@r+4(UftL1e81bHg>F7)1M5MhJa>XAR%U+gui>EgsqUI%cG^xmQ>l+i2fIQeBQxMMYi&hmFjYO2-soRg z-7Fkh|KMO!p&zxi!wcn5?|IXb9OW$)T!0B2S`iqwDHT3*CJHL~+#|s5V!AgWS8A@; zl7o#+)u}`tcw~Mlthk`~BDzvKgM&TQY7&CRLao=H zxUy5*iT@RE9Eg;)@+4mT@!6_J;~b`8ShRk8hhNfJzyO6tNA%70w70*dqM}kA36#97 z`|WP)qb?qGEv+=7Q?xTT^`c+D?j`C9l94=d;smPMb-(MO5UZ{Au-$^INS~6H(P;dj zvrSe~(&IWVcQRFW4w0eBzB~0`GOsXCVvjI*jnc-LfO?L zKA%ML(in<)=3^EoQiqI!^M@TS85|0-1c6uiZvo5GgGdrj^9ySQRs~h;l8$ z_28pS>>?Q!u;=yAIXnWbQSRhuf;zr7$ry#SA---0||-kL?DRk%rWB zd4C5BveWAjRlw}HZ-qvOWYylfNU2$~);7q(_AO@4sz7@kGWbEwQw*wE=N%;?QrEBK z@<(@fhkhQ*r^3u>Y)ifb;*z!F*7`#SD+RH=W@SFMiu80Dt?GT<(xst00YcJi`vR@# zJYfa9mCr{er)-3|#Q6EOe0_bRqM}sca2T@THzNN%G>9Dpf#7EGQxD*SW%T(ER_$;} z@+(M0up6qv#ZX>RKDn_yVRyq}^~-S@Yx9nnr6rs5@$#xVI+0#G(WB{ES@QHr^s5RA zO=EU5z|<7%?EWyeK7}rfS!E;l^V+SWo2w8iom_d-VpH0(`6X(&!a!M;g`K_EXm6?% zGc(`b)b#So6)lvNwMZttqI76PL}^xE^Q;4gLJa5Y=H|w8gNGpw%^KYbXRj)6cVAX{ z`4xe|PvZ-;G#3W#s5W`w=y4nGWGoi*NC&G(D(SR_mc}e}Br`M9t--%&b-5mf4l_5m z9slb_L?GtRG}K@#kTLNW3=N*tJ}oSyMxpF0L)UOCT}$T%f0WZU>anP+MV8YR_ffQO z2zfRa>IQ}`Rq%E^av7?AEIsPE6$6z$BHlM3vp}Br1k~HaAh)M+9`f`*S5|jlAaQiF zhyNa4yZF69hSWgE8`LZ;3N#P-;3l)pHBubr=qj+eqAqPZRc^fXzFtMxZ>6}lX!-c35xM!c zHOg&Qr5G`T2$GJiafiiP&Haya3kw>qt^#8MzovLT2yVK7#oTK&A>(cTe8e@5YlG^? zsy}{656Eq^raAGwo@@3H^;u}j;D#E9pUGs2HQX3E9<(m^!pSKd7rybzs?K$g3;y$1 z(C%Hjdxr%#yPZ}S%=2)OUDeamg1iDE%F{o@SGGN{Gf9CT@;@@kW@hDRNJ*WeIT-o; zKsZZnt?6%WDYUk5ONj?)`PJVK>M;%LnuRO3N{LpCxr=A!{I8B9ygi>GXb>ecUuU}n zA7j_U10GcjW1~m+PR9Jc*k7oG*Ck|LHDzr5`ZeCz+w&y?w)v;alfPT|{^Hf6J<2@F;q3D3OX7|4D=l;};RVf;apNn`_LrLt1W*2cZ{=SD z0|VHV9lG|@wU@paU+-5__)hx!Ma1{`yNCbP>^m50=B4$=TP-qZ6)(e)$6I=ahP^ZW z4^buSinK7!m&F@LTpO0x#5^}e_728nGX++`j`gY73L{tZfz(^ewLhLNirg774Lq`Z z|8)F#%FFs64u=jt)Z5b+LBru|eqQgJSnOS1$KNmHyW+b#5a@MF8qrS%cWk7Y)QA{v zjM)ChI&TX-WA<>jFlP&LiB7z8iq_lCbuALIAmcae~1O%dA z>sWh%8EMPU$8ol|4TgtTw>@6h-$%8zX(~DvXJ%z4D`u}PZfrYjuZDH|Kfe^+dd1nP zgoTw=T|JV7w6haH;0Wv(8hWo3D_k&J zNKN_zj9D#11~TI3x4^|}Utj-lfF)SxO>xV};v!>o*261&m%ZO^^E%ETqRf6vY`X#jha)wMV^Y9N^T%LGBV-w(_4Z>{bmZ~2=|OkVI&%ro6F>q zVZF9(dY1Tz2Ar%UXvSt9MP6P$ncxPISelqRo6@Yp#9P5U{9@>nM}GCYmH_rx`exe; zW}NGlD>LWs(%pwGsjlYvN};QL`JM8oyAQNbUz_r?GKGyccsaH**lI>OWKoYQ`l=#g z7J?aCmu}6_1U53tl7~zi9E^o`m85roh)LA*D2#l!y3s1C_}W=gd0k4z{p4cn1eJ%< z)7rYao$br{3Vv`uDvbYGl=F-*CDA_$-X({H)@pyRYaLMrCZHk!YL|79{cD~r5VU2Sa@Y3b?mg@H2H!a-|?8^1fJ2YCjm z^e6rG1h0sQc*-{lQtL#s^aahTNV#s8TO&5<@NJM#qDLD!`3ww!rN;hVEW(4vUveTk zvIm?~$8A)f+w?+dD6mQ!f3Kzd<3Rg>gUWrZ(dJ7~WJYpdb&!Q z5_MmXWna&5S5{wJY{K|~9pWuwf01xKcafHn@7T4t?(XjOA%v%slahgf0s30Y2km3+ zK_G*v7<`t*x37wVG|x&e8e?*GXfe#)#l|K;>G!FJKy1F1r9cc8@x@@je*Id$)%$sI z_?^ATSz_650}q{kj~4=gc8)(IP{*lmWE5A}I=yCn{-Mon8^hRD`^Rb};Er0Tt<{gW zH@CLlxow(yTUxrOyZ>TD3!^M-O7Q%{7{bEOF3nJ{xaiZwuyQk;r8w6y`>Snl2{fm_ z_VfT0J zfkN41OlYoQMJh*4d3I(d%YYT1zrR20U&~q5)uIA>Sks?-Ud5$i3e}{(bzrf851BY* zQwmr3jKi-_uT6-E41$~D9MY=H2wBU4j6ja|x?2B5>%F%W7JH^VE6Y-CE}C!$aHrJ|+0!$uwgs0z24QdW7Pg}li%Hn#M&f}NfZ+7tGIj@iW;j*i8J+H42w?Rz`7 zqMFnZ-&F-A>UVRBqkYBTXoCZScQNmN`n_)v2x=xu%ER+{W(uOA;SU{cT#1SYMD=4naKnD1Q!T=vP_s+~tGv|XSR-bvrx*@xYd*=gb zghE!`7N`%J>_;keg$fN`b1sQ)L*%oPI)TFgAy3Ob)S6q>s$132xr4X5Y@Bx>SQgHH za4u2 z-waq;8)K5Fok^V!PLx`5Mv|imbWC3rPcC0S*h00m*CoF>ynQFUh!76k$ak)V!`{@? zEDqW~pP7+66>#uQm-pr}D~I>&{(^1wWLjXI06RbQ9)>RtH*8z**B^6G_|m=W43#k<*=@x}>a9ms-WdDX*!`-F&G%fYuarlDssklgF zzcBCY3a-Qx=2O1l5m#m^Ab%}J9xy#Ml^dN}RgpH`QGM_W+E?#!n>-tL_~K=1d{_|P z&n3PzGovmoeN~I@_nYqW_ba?Yc;X7F-JP%P0 znuFoWKY6@@nVu{nkwj&PqK`hNW4cyzrRn(Z$3ZxqOjr;tBXv?ce&No=e2o`ghuxF{ z^Z-mk52JZV77siU?`aur66WE{^iArE9RAHdB(+X7Sj2G5Lb%XiYs^7&`SN7usc*FP zUvq}-$j{h#8 z{%wkJD10_$xyNgA*qG~c@{FEoZmzS9akoG|#j*B-k6FG?$vWrMVuLzXpDgoK)Rk6E zu3u-4y5I8f%)y7bFq4r>yi&+|<8q6*QR{YcNJ?5E6>HL_&NQ{lbvl9agEt~L)IReh zJNS{!l2ZnKJgeCBvfNRXIfU$gye9Ln1`!BxvVU3i2t@F|h6sUp>wq9!kBzn2n@0XbyDfkJ z6dP;yz(EqXO}AfUGgSG>;9%Pz5HGwpOtijF44`+8~M#dd;*;foc ztbfjLM88_^p6cn+!5lW}a5A}vj~?A2BqXFdf4+Ty+stvI>2&VdKfS-Jybz7plj%C$ z)7N*^XzSGa98lIRERpdI4Ywn&v=sl@(}>Lg#~RJ-|DT_IB^eqXzWH_U%&TXB ze{}barVR|}2*fDCR?a9$xT%?S&16(tICUhJ_9O`@YH6!w!6_@ZM5(yAnCnfNGKJXn z>j`L4oC0KCV8@8)Af9kd_O;|^%FKbTn4Bn#1^CZrcK>vumY~44YnU=l zIXE;rnzdqhe`wEkv@8b*K^B1S`X2Gs1%C!4(jB%@=j!hlOnAMLyw#Uy)R(hVt;Tc~ zAcLT(sVRpAEbnjyaw%gHjYiKx9&NaJl-rs=f5_OjuB-FnNLY`BhJkes;O#IA85N_W zlcaxo;7xdc^o@9p*LZpE=*QT?R=@%v9TH7_j$O8^OARwWuMV8g5%(<-fLBe-M;XiZw zeX?kA8U;xjGXeu;4CYoI`oRv#8M2Tk!FsYtw`XW5yQARFNm5c#@_2U!*^?(vHU}+4 zMv}$|x~e{2TYD|ar)vR zYj$GDjyD%#MgM&EUF0A?0e1WGG{j0c%uW1PJA&@u9|ntAHQAd_6ANqITsI#AM2?|( z5~Vb-XkW%4D;lS)dMh}g_VZH)=9VBqRAqU&X=Gs=;EQ$g~l1Ek)b zMMtq47y5Vw1zBWuwe{P$;-=YFIe=kpCR>XdEvBl{fK5Y7tLu90rQ6}&1cG)=r{f7vVW_}hJyDiM?=vbFH?u=p=h48`9Lj-UdWtNp~t8DZ^43$f`bRgSp+@D_^ z)TnseqY?|4_|N|RG{2VtHDX|Z9RP@&!B`oFvm{3+BbS@zHe`br#au2TYxA~7-Hpio z7)@}MmBOuUZF0eYfG5l~7OWIZpkYmD8cP_j7Po$Qltl1?nm{mLV#VcFm8S^Ytl@og zA$4Oajv8SBB_D*h`XR93=;&-|_fTeSZ4KpArVf~MczC$A;+dj>Rj4+bBILXziZ#Ax zrH`9k!WjmySJdoVLNTv-H<1W(iVY;LPAu7KXFP770UufKg}WdFU@Y7yZUYIyN{rFI z9}Zo9m0k?AXYc_F{+j(_xEfSPDj<@OYfzI5#Q)ieS7(@)q%NO!bauA9Yp24^Eeam# zxpU+h)zvq{&YcS`DE8W4K1GKbk8Bsi4KMKs@Merud%>-rBMCN_yuj3W zpja<>Xd@pTWI;F7Kx@kX0{{A6mAjxPHq=mPhl-Bv*YM7L)E|KII^A8)GBOf%5?GM^ zI7{q{pFn)X2+~`aEB3y(@@p7rr9qhf9632{xfjV#!sa+XKfkg9MDj&(BBzVA$}{k| zn$l)z49o!U^F=Fe(G6+g#A|-6{8S5!iH+?|i}Oqszj!gND7>q zwaG0I)27&G!V2Vr$yp$U>+0+O4H6UxeR@MBGENTC^ z4Ig;W)s2FH{@LR%db$R)Af61@9{ytIQZ#Pq!vO;sf0{=x1F5Q?*4wJI?l6!R~@z%X*`{GO29%%I5mPqs%`XJ zxPs-CnUfO+#{u9NCT5^AzPl}wW2URCyGmVDR0M5U*w|=7)+Nd-EQ_MLJw~-4c~*sBhFANH;J%TA%S{lusJ}|^1Z+T0sKRMsX{iPQ z%^YdHQjfiBmx{%#`uIn^vRbrqXG&|fbF8wGW?EWWOp0}Ru3s5;YtXzuSfoLs%npNpend1ONam$Cy znAvqA+0Cr1gaz&!1OxbF5|x^nmDMvk%26{Kryt$O&zV#LQhbzYa)`l`&AN%0KoKtPq^s!14$R zW=2I($+9NR%~{O#^bDtf%2+7E6K}M2x3_=H30nU3_-{XJQ)W-BWPcqh`|DV8rRVml ziKCOFY%o5~#Uv&sHo1pe!|fnh$X;b8Cu`U2)^nWmtLNe5jQ{lMrmLr+@2z`qFr^n3 z-cs~eL^U<_Yx06^vEDP;5}b@bFME14fMTHv43|p7ym_aJ{Zcrj44n0O$nN02dmklw z!Nbd&;v#${4IhFggG)D!q)m4%e>?$5D$$OExH++X<5e?oI%>9um-w>hI)5a(x$W?9 z399)2XRO%tD7=hv@q_E&25_+>kRbOsigIjm*~R#`lB(dOpn z<~k{{ePkOT6HNo_7`EQ&Mqjv`sHka!&4%ca7vm)_=YkcAYC*xdfa~5|x*hFJ;SY=8 z3bM9l)vw`ypuQ|ve2lVx-vb-XLd0cR+%V2o?bEtVL}}losJx9*EP(O;0sbu1q!0;Y zCHKpysWF0ww6L&J1%{C@t@0|xDxmWWITu<*g}E-v3V!R10_qDs7y(WN(3E(u3zSV> z<;>A+!PdwtO8B4jUvsA!q?Y)eykEgPgM%&KB|C&>$mhl$H+lfKe zVW(e77Jtl72W9@tlnr?+F2buW!frqFCB*i}0z;!CDw43z>n?d>2h!jiQ?eO+*xofi zpN$XiZF^LQF0{1V1e0eLLbjR_i^3dTJkBW`FP_upu;M=Y`W^xJa;|MysN7*i6(#;* zuf0K=jRpPXS@rI!{E=9fm-p{Iy5r*`0g!43(GqgGo4~X{TcOWZUrSGKr+f>pyU7(7 zhn+SWgG#%bK;)8BrljBG%Ds?p0H_Zi@IB;$?H@mWpj>2&{rHd}5l#TvP6)%I?&qGm zH$64YC<`(j9F>(Zp^#^oCFE?AH@Gy@5gxix8&}VBQlqas9gJ|pJgtFqK!6!%q37h&Qe1eYax@OR zED3-*7gr}(RSeSY;Mc*qqze=wxcbI@e)-0Cwb*j^FO)E(43w6(Z2=QR6gy$;T4e}i zoJ@#cfLRXi58OZ+!n9f!|7wTy(TveuY@ZOZsI8~7JdCkyFfi5xiyoZMFrSCTu)jVn zcGYJdywtGpaMXu)tLPoT)N>{$oRY ze`Vm-q*v`FKr9CyEClylR750aEGXQ_i0h*`Ns2#w$YwR=A3R)n?C?y#+N)>XVmv7cr}M&}j$NY}98$?F$Lv+m zIxvuhF_;JxmN@7$?WWVg2@qRmD@^M?Ho1^IB*Tzhy}$3c)YsEvoe_wVlke&6O>J(L zg@L$&dkdltLBlp$ijMwnJ6JFs9u#ZCIhXkP^DPqrwLB@N51r3}E0-3_%FV47e3tYD zWQJI|pya;_-f%qLr1f<(h;9uVFg5UwbB>hE*;1)D@$OQURZt&dx$u zppGj)q>6d?)7TPRfAl}vn@p;C2x}VLO4>lPVYURlf=X(2aGfFEk_Q@xVVwH~^{BeB ze0HO{(~Ic5{!IxB!jNWbkNI=r{<-qYK|B4%si=9bpQoc7pcS?%_1 zv(V6%woldm#1gr|6~Er)zd~f@IYDat+jASz7T8 z%A1dez##!MnH#sap2ot#isTU#z69*C*&?)91Oxt71Zm>lJw0sgcX2LZbZcwRDY2h- z4R!+gN@tXj+eI+tU=gOSuC9j84mnpS*7}JbxBwt`ty$; z;Q{Wur{3VkjrN!1+21nqq)$P5t7y|#tC*s;snPq z3MGeUZQQ?q3nHbjB@;78zGqZ_=!qnF`l+3JpGnWKDt6PLfSM9FQov6xARn(nijuGWFv}?ZyC_F2H|ym$sm`wj_j1c2fPtcE9g! zaDSmW@!WLe_31GxuF{;^V6S`1Rlb^0a!J*%-6~Vzu<)4w4 zrwr90-CX-l_8(!p>@C8}gBf1E?z_z%nMMC-P*A#u|3)##;FLCvt0+HzMM_7Dle?~c z_uA0zP{3Tl;N*g183;56B@`BN`3DBJaF5}E0SK9_?sfJU{YWxSiqbUzA-MvhdCOJJ zIF;y_7*`CPxaC}$Sw)d^&VJEjR^&TVvx>gaQC}%MbH~F&AB5#0P^)_LW;GnT+n;~$ ziEDs!0W%HnzX3zZVZ5Pr!aXm>Q;~dbvr*0FY;0aL8u@{3va_=@;G5li|4R4W?duRY zvtD4ev=fR^>gnqdD}Ik!eLW$wR=sb?_~Vszj~OT)2nboI8IP+;XUGF@OJa4pprFIE zs{zbqGxof4zITdlJpER!ADnd!m*g7Mw6wOqGRjzHS_X^dT`R*D=3e;Aul^jdZ*l&| zHi_Ego2@LfceZ3YPafvzV8%O{Wh5=%C7U))WH8y{q;Q zS{lz%m}cc>s_-vvGSe#04+hP!r^#<`Z^K1_r?W{&NT7CrxF&xgTfipjIbaa$-#-zG zJhYVGKCeSrM+6hK2@*xeC2t%~VID5D2Qm%HP=9 z%F2&dfji*H_FUZUtn73ZJ-z5}gM(VB6mCBPzcgpax(d_6C6(dgvnhhxC(Rus_rKjU z&{1H|VXKmAP6d|2FOpl2G*bV%yU;&V<2?=K!a9)Hotn}0{k$qCP4M%C1gFP~!bF=b zJb6x-F^~;(xW@7=lrew{#6XD6%tV>x7TdggE+_Z;+_A%uQl0Hv@B4tW;#`PY;L$A+ z+Xq!GVb>~#bM0oVDk1jM#FT#cm~PpZXF0Moy4>CiO3=f@Ip9qIsxqr>X~{Ko66iw! z2Q)M`ZVZm@UNOmen>JwMYN_@H66cxDljQ1+J}8|lyW;66=#q!dT3z+WEi}wNlV`y&Rc#`>_->LC;YnWUU)1=edTr;z~)_*tBJEHRnHC2L&wx9gXYaB<; zDSvS;w!-sDV|L5gzB>OQPF!h!byl`STS-|uQb#j214R(e)oY&1K zWBDGJ>0`g|ZEeh&#mzRfM!U6diUZP3>?;ARVg^}?wzyFTrmFufy>B1$aB(eAX6-6G4gf@<$n{|N(m7_?jg6BqF2EI3 zzd;>dH>O=0XdVR^u%`QtL|9l(9Bsp-JtLh86FZ9+rVI!_Ui(FHSrrv}gg!p2+m(g7 z)|q)(YMv54nJg^)9%T91*$PR<#+jMf9u@<6F(O%ZHbDGD!of;) zfeIMj9~Z0m*{c%Z&&pRZC&;;E+I4g-09RO;txn|I94Qk|83)IIT{U`fnr8 zJI&o=!Oq!f^Er;TX0UTUFno`Vo!x6uaE7SBAAKKbHPE$?Yg;q=?U|3ct3dLA04_G* z1~OQ-)L40;GwSL;q{FMg6buKZpvLI;vwMDo%zzS=mTCc{q!+tS`+Lx$!3~{2hp2^c zOuQ$RHeEG~LvP8ItmqWd?UY&~YjJWPjuXiPHKR}HYM$+;@AGMh=#jqN6;5M+pP_ub z!Q;C5+`Za9f0i!#?zFX@tRTIg z`C&zecy%pAEYROy%zgUV`bs0+Tp$t)1g`2z+rei(#^wU)uVmxGeLh9ld*Kg`gB=4XW1CCXw46WcI+&;Vdo(lw?9U=e-j6_3Vn_qCDPE+%XV-rnN+_LFUU z`v}VDWJg6s1+S3MPPgaPD_41M@FYXYgcK3JjU6w`Gn+M%x1wTBG)+gV3DY9YH}0Nv zm|uyh-jEPhTU%9V?Q(WW(PkspvDSppATLk%+{8)Qd`^u-v_zJ(&4TZi>)|OC{2(fo z>cWLB>*)NN`tjqgRoui2K156qqrL0rwe4qQvwi*_JY%%|cm7WPNr<~HRqknWwO2jf zvav5wenBrkW+!t7%(C<6-!i}f79;9tA$`e8QPE#1_C~_e(l886AHKF(KpUw>s20n$ z)?6qAe3JB}IUr}jp>c6(&!0ah>}fE|$j$5sqL+r419M(q7IL={scvi>GBhwC$3Paf z>DDti7(ImZmyZZe+T5~?&KIk{e_hda`mFza-~bU&m(s}CR1ZP|PB&p=d2NQoVNm== ztbZND=e~>%S6A88ynjz!wY69Q?&IK!fDv-f5f3gxTp28B7>iLm#bpeJrE0^cG&DBW z>-)E#>J4&CHpSZAy(77RcL5VUcd*r_bk3iD1X8E=_HEB>#=y@rW+@@u>Zx}1&~FkH zMA42-o}0vGjqYpW!_Sm7J+RZr$q&gX1NGFa8o*I?ExoJJ!zHZK+$0iUw}WL|454Hm zLB7=OZ2^cILC7K{G4V1mrZW-~?fb$PJUXmB$YBM@N3cU$)lIPfsJ2`nuZEEkqQPma zh4#R}hMcY&oYtTBPx~#@Eb9lqL2fwXqAq>T%RdOu7^1etc4yk2x5VRCrPEBTR<~q? zW83LO%PPj)^_YnePCvwUcW^RS|K32}gbSnl6-WvAZqwIAIH7qq(v#bIXIz2()Fzp- z*{@9o|7eHE$QUl|12^(tLx89~!Nm`UKlMQAhB54n7&8(vT$kBsm)OBC)?O<7K;8ZM z^N;FWdICYi*V=4K|9tY_?|31}M&31_jU4R@`5!5@K=6%F?bFR4E z7ik{`NJIdhJry^UB5}ZC@Fsi+N9E+*W%2Hw3HyT|!-Jvb-{We#!@vrBY2J76f_I(O zy?b{GwC8KPBbbK@oMVIHkUv*wutW!acd3V~gDA5aPz3H5Cc9;5en|AP0pA&xCIrl~ zQpC2np$hg`I;-F0^1k>R;-ZLHTASe$Bg;zb>H*N+1tq?y=qLAXlnjSt4_ z?&t~M&qNjrYrIz?NdJGRvj}g(dykl1zGL1;MAsd2n9oVYl0~K_j9rg=`l%^5G&1O( zmWFFJ#*)~`Vi1mV2Ij^E!!1KV%Y%7pP9!)BR~NEBtDkVCIG87Dk`^L1adb;-N3_^S z8Sj9q|62mB?~uwtBmG<95fZPj{vb-Nwj3*KwO|w{Dq*cIH}x^kHF`O$Pjijd-^R;; z2+Z&oG&HOnWSu`cgBuzeVPp-2k%p9%ln#H0=$lnBq+Fgsw5QOF5ThInQ@O9^ZruwJ z7C}M5K5KMaYsj!oQr*GG+Fd*>F-CX?eYNiKD_9tO5)Erx*WV~cXzA)^o|r~68cEZi z-ZnLL+*vd*s9r6G*yJ@>eqAen`d)g!;N#_>nd-dh+kn4E>fgHS4*4Gp%(RfOjEiJI*^|yl|;lZ905!{Yk7J3kPjRF5TTFDmnAP3 zcfrWT#4FjSDZ`aL7zs`(WECF zg{J_RX=$w%n-?@qI$*RdvoIJ7lLGC3QP`n9m-|LNW~^hb3{tF0mVCd7Vc#_52UKQSI{Yv(SB8fm4(hQ#b zdo3MIIl}3M`FW0wU7_P>#)6}NmY(5BH8r)U=xD9Wr?sS{d_h=lHdJwWXD=#tljSUF zR(ie)ID!tuC9N*62(U_jFSy@r&Oed7oPb-|LF$$c-!Eoq83X1+0mK9$G+)Aj864SH zi7_xJcNwdMAeEt;CqC{2er(lVuO7#>ulhhtqA)T93QufX)&4ipnz7_Lep@hu*~*wX zIN}hVXWZO(9F{&2nmZ0#bn%edddI#!`r;@r=Z$378l&I|d=`RYF?b0nC++X$^`$|G z5)N933&Z^l0Ut?4e}6goq9s{g`k`>+7X-ax^EWr?V2O4Y#*lIjSxwcB!y! z{yu;O!lIPo2)ZuaMT=d zIHfc-M_yhz83<}%7b^xK0kR__FSqwes2a~2~-J8TSeY3 z7h1Hh%6&`2P;*n2?qY#X4l(ok=eE*l{6Xcx`}=9UDVp)fJrB_ECnDdeuu-L`Z8H1y zu+({6zo~z@dM^kKNLcWL;%`p>i@3Lri#mdX{ z8W34v6#*GaYDT1l0Y*9pR74u2C5Dg~xS#9FZz9W@T=S`OSBH4U|qwNc-F?)jv6!pT;q+dnqo z!%bv(5H2u&sz$ zM#CT0iov}CIikIO*_Te9?rsm?(e_&)P~o3nVmWi7VZ`B8S*9FdbUX~l8^Miut*sW5 z#qO4F8=eBt1rXVa0q2Xfy2gL?YKpOgPSw^px2XZ1=EmD+WgrHGePPUkkTy?)eKi&k`7XJ-Lug%Xs~VbN!g zNvt@nkk#&;s|6;x&-HWe13G&}sylI2P3VlKmU8ijT5-VJqX~9u4<3Yn73_;|G8#P6Mp*YU{{h$efs~ua3EvYv`%v=aAw{)0Fe0 zT$`GkGxD)|y!rW0{;dWWE)%d%MMal}!hz{spIL2eD4h+V|}qoct@0f~&g6#ieIs7U00wZ-atUmw1U zJWx-7Cj(L)2=UXPcm5zwkKg=|>cUutK*E$`HV%%?+-}W)>bnpivJSt&N%FdLv9X~D zb}<$v0IJ_~-A?|idXyg2%y}8#dIJvm29W1p+N9oDO5MYX~>M{JT@rvYC2j$q- z_7Qc+p@4~~!vu(lwnJrNbL5Q z{j}5TL|5^8VtjZHQSJKmw}Di86*Ap4XI{W&oBN;Zg@ywbz;(TH&}>1|UiCmxOO72Z zm>Em?;83xmO8e`359fXM@MYy*od8LqKt*-u?>|)x3=*O4Mh_I{CK&>;HbbPWj4XfW z@U?W80rOq5wl`pf7?G^37Z<y-5rb+*f37L&>QI5Ux~^(P z##T>v_w@Uvd-Xk#RBnB4#F6<^&?<&zvt4GwT+#RgfpXhaD$H{oV0HKFo;ppqez^hfdeED+R^8|I+;eZ*!Kjs$Z{7~JzeD_j%+dem|7Ebl4&jBl}RM&s^zfLGy zOPpQjgI(A!r>q=<;1^_QBcDxh=xJz1ri=ZV7v*E^`$YvhA|fu!DZtL5FP9iE^IVGq z!rTja8t&OwTs9n<*i1Hdvum8w3k-1X`8X?Q!*LC|CU{ML0H8jb4W5{|T(-Pc3@7xR zyVu{AReJUD6Y4bk+uC}Wm`-iIy%>YCDJm}Dn~Ar_fhhpn=5Vvp!UpU@kTO{cmt?_z z!P#R_;eK6UdnLUr>~^xXjeQJ|trcC?(j<=bTg4z;+%{OKgIQm{e$keDZM=lwxm%de zy(>Bh^<=(Ig#7QbQMuQ`LTRlcFRx5(^l3tR#{WZf@P;0M5^H@B|-{4x3hbKD|d4)x-o5x;QDzc|1CTrJ*h(r+>@0inF$S6;r@d=AQ4` z9?gGte0=6nH*_SMo4%+UYDQlOyvR-^Ez=H0)oBewb~-MF{4?f%o;y#cfjD(%B|Oda zBYU<&j{)%S+{MzM31qvb#&LnX%4tKRm=4ivsp@B;P^W-S!=y=^9JDhidYN9V4B+Id zrJNC4h5 zO_1&h3-D%&GLp9m@O)jS$LiB1-Yllf709Fd=p{O z^n#=CxTEk|mzI&u11pbeWYKA~{L`O`3on+x!m#;R8^U^AoOPm*j~LT)J)rs_Xkm(G zG-#oE=U&ZWy|Y0l%%jEkzj+fD_e_1Mjeoff+|$em=i2e>QM3 zp^~djv*#Xab7|1YChk=D_M6>L<+Krqi}nNoBnk*;oony$r-O0&%D#WU&R8zi>%P4f zr$ae1vAZ(UaEycFtNTm;G&69M+{G$+#y1Z0x)@bB_$W^=uTtfVt!$bW>PE$vlU7ie z80L-wpsU(tE}+e^bC$1gWO>5Z!~{C(0f#mln(OaqMoa1Fgc_6&aT^M8Go=`_yvltL zHa}E_i;X>x-(K%;r41mxz$@OnHwvsYHM8+#rn5lV-$J{8dShLftq@%0@47pI*_8uo zE99EMO9VDgIha$pkOoR)NCXT>Ro}d8M@RXN?ASP#CX~63Mptf4+W-g>&w9D7_##^V zCym#b3X(^kU98a+BQK}}sjSMPZ zLdgOWOKU5MyfTS006~X32CUuLvK1~e1lMSGA0E6}N!&Qn-IbJ!zGDi*Z^=BoY!0p!ooE_Kv($eu5Hlp0MztVfGB{!6&ybL+B*O z44zdVRb_h|96joqx|yeIL$k{(m3=Uf^IU1{lPy>1P=WOe#}IT^$m}Z{JG=Q(s)gCY zr%(A}12fz&RnC}cX+p-LI~8%9XsAc?asfex3ieCjH^(C}G3l^{q2(OnB?i|bTUku~ z4szP5ff<}+an?xq9nck!VSTd@E1<)wrc((hNKs=o-WtouO_nlx>gZ$uFVM*T3mWP2 zyEV%>xmZ`v=H+EMmJ%8@)|i;6sX_lE7wGBlT=zjA@Ov{YvhX3af%1?P*4AsYExe}8 zkwL7K`b#5MW0`I@c#AN{G!|lQ4dcs1E9!%|*7La7F}JV^9i7X|AJp!#GXe*!(Iof2 zsMwI5sH5WmncfX>I$WTQiKD84fdNF^Zm)&P{N-m&0`nUVLN6g|K}2*(o?LNULC6B> z>SDAY4^QUy2E!}J2FYFW52rD;WQvdv=6<2UTVs{LyKJ`ptnE;k@d5LzN^-INptEd4 zokGv&&+}n7?5*tNJdh&;HlQ*o-r8AdghH|H{ri+?G*9-n`Rkw6zk3phWn==Az_sQ{ zEklTsGYC8GT6*>sk8F)7AInn0&$T?jd2wGbkj&!~WR_lXSIBpAni-V8pG5K~Ek&hd zP^RHRZ(job0Vr|f=_$+4T1{E8adLGxJyj+U@Th{+wSJ7bXCWsjCb)M=b*|Z+cO9** zjiL&vWt0@X-0;m@>1t_K4WM;)=Urc22SToRDdMeRH}oQ8K+N*wc5=PdA_FmgiIqMq zTS0j5(UvpteW7}UOixDlyhmchsOC!e73iy}1Kk7Mn2HuQ`rYP%Aw&wE5cuNr-_3U@ zM7+!H!7G4rl(}pd0ZYVXWMD?m=Y=tLZmY2tGug_3jLZ|Oi7V{Amesv`EwC><7M3Hd z*TyLYgVR0_taO&N6_QkBc6a?nkNt{fFFt}|4ZwISQ1U|gRzC-^wM!%FSimFlp?eDP zVe}}WYVRKyX;*JMg;h)S`XC;*t|)`YBja=o(s!1gfsKwTYV~!p)Y6yaIe^iC!!hW* z3t0!$no#f<3f?GA#>zt(3-z7oh1l(i6?u^IKZCl+QNaD-unyFQYV`Vl1XWMZ8yp>N zly5ZL+P$Nq@**pX1L!q4y&#D*$7Z%dE-r7H8@lUgoQ>j9P@@tRDR^Bbu@MKLnnAgb zR3lamWgk^zt4um2PKucaErArLt0U)s<5xar5`?Z~Jg_uslOR>A<%1X+sLiUgd>`t?^s8 zGUS<>np>u(*Df|VnohL`9M7}6*ZnxV2%A!(4G$Qptl(7z2U}7SBLr7y!mro3A$8-B zJyl2OLZx|6aBy(~F6~7w=m_i1a~JO(bK6G(7T=2G`Pkp8Pd zNb=^D075>jo_eT;fy7uJ!5m?xuA!b;Uakc#XRxIjcNT%B8atd*V3QPZgE8R7$e2hA zR7e-jpYMAA{$KJST=)p!5nICCL3u{y+qVN-&=T44L&$Vc?A?HL7MgC$p6UGJa~jY+ z)D5NW&97umTPIlK|IRjYd^Uq5q&}fDir_;g#Jv3Et%ILB%@=j_1?+go5OU6Gxf}I|1C`asv z_1YYI@cMx+-u%F>A^NdwX?}hoxHp8i-AV18gADimacnTS14M&vO%uE@>bGynSpq>4 z(qHmnDf?h4v?VvnH6^u|EUO0!TMtMPav(qJ?U)5*Fs61i97G@0{YT?+ZL)auG?Fs%=Hp|N^QF~AS@xsoDbbG2x z3_YDEl&3#F8U{0}q0I?8qf8mZOOW~73*omLKRcRPxw`uFG|WuRYsHBxlK%OpS}OU; zhnMAs7C#$Fi|?Rpe3g`B2+Zw-0*d_=u@@zh+g&x^rI3CO(Q{YneOYXIX_1UYTm z5ZMNS2^37lU4w%_CS`5j-fd`Y?8;DM2A(?(9uy}xm~Dh3KuHJydIq*!51Q1_!6cts zx_WwM7XksvDxccYQja8)G{wcmVRhW$yPyVnNw#!YW@V*a>uroY`JiOwBCARo8jB=M zc2?^nj&b~sx^4n?TA>W0Xoif;eSyg=kDAM}~wBnw~$M%%yy5Ne$+buDl^ ziQwVkG1NBbP5`PB-1>!cv_J%zu1Dfjj3SG}cg{4%(0e&8$=w0}bg)-}{sRdnG` z{LOf%vB7b6DvP!}gmqv)!Js(#5ORBdXr#ezEp-~mWZ_O%gNCiJT}(w~@4-7-KbPLa zgT|hHScsm$-k*YcmPf%~={JYJO-)l+FN+&Az8$W_vH#RKzxi!(T~G2;+mdux(c~mp zGMx%K0M{82F{*CKlLd(}Zl?aiPYF21Lvd&nL`IDEZLA@FmIcs&C~bP`3{tvv7rLWA z(jo|e-2aO9XYiHV4t0n9s=;2oaV`)#>Fiir-@wVAi7PnH(tO~fJUdYi-?DB*DEUPs z3wWwY?yJ{-yA1{WU;ajmb>XX54!(*+rJo2n1%*2+G>o1cbBr<{Vqg^c<&GJP3JPvA zKivL}JId+;s7_&vjE?sNH2!$j4njcroWZgiE$D1Q5J4h$JwLUvV(bG?N>a=Z`M%H_ zB4}Phzu;ZGaXt`M9vgQnXNR}PHGyBhqqC<6R}@6$vNcveR2`oQCt?5E6e{V_`kvZ zhWNjlftwnK*}%T6P&gkO%ewIOY)LvOh!SOQ1R{pg^j1hbfNkoxJcJp}RXBh-&E}>Z zCwCB^C{c@p+UqXy%Fh)bWLjC}W&}N7fLmpHeYVC}>FjNzy3G6UZ2k{$o?uoCTlqlfbtfwB!q3)Nfbl*Pt?v-5!AC=wWeVGu- zHPB~61~Vb$AHxMV*p9j}K4!YGNqq9x&-EQtx40Uav7Hyu2%h3Qck1WZ(@nlmEDh4RAgQ%i_sfXG5 zA|0j_`Rw*>=`P@I!_GV($Ot@@zPJlfG3nhlb2|`Mc{tM!9}YZof@?;rY9}B|lk5a1 z4>Bz>O&t#7VgUHhKE4U1InafYp=U#?Hqu z+(8?ZVSsU|12W80qBoPyAH!`FjsQ)FAa4!n`{nmXo@ zuHi8@^e4idsbBVU(5)L_cLU89R!(tS`5;#pw?;lr>-(>?0H1?f0*H+T)J6;y8#yOB zo)OGDKv=}b%bN^68ge!O`&3ij8nL#!yA7~b1em%R7-UC2gC@1h=!9Z(lK_ZQyIqJ2 zQ*#kDCh(`#L5+|WE7G=1?iqk*7PGv3VL?U8Jlc>@>7;dUlwMI$8Jm%G9V%5o7s&dV z7$bx3?FBr0dPgira&o>rytYXA8V$1mg^rg~Q&xm>U!&6zr#(kMGu1!K%)~|-J;Nm> z-6f-%aG2#K=s-H;V<%~sJ_ib@vgKw$Xu;euyA=RNM&M4#&yTz%>Afc|OdHBsJmR6y zK^8SY#@wl?y;0HTF^>AWh9|*znf2T;W{av%M}LXsQe>@9lCjF6%}qUt zZazi)_I!VXHk~b)?B*C>O@X6%)7%1TPiD|oW;V#_Ck^<|Fvu3JUsd+ z9v)t27=1@c8TJZbd<5%-5NmgLZK!_0gF!xQx~ntA9%s-2nJDcnzwyU`& z8NX%YQY9PLZ9A0PhTTl|Txn;5WL0dM3ahcfAodaRNPzc>2LhYxn(M=BN86favjD8$ zq_}IOtHX>O|1C_L19(iCGVl)~4gT%Ukp`?DXb`%am~9^ExN3SXP@&@)bh6D08_e}K zeSdv;m%zXZ1r6gO@B7dQ5|%Gvnmsl8yi4S!z`H*jJFP!TB=;H*npaUXEh_ zDha)$6&WMR4~oQ{iq4<|j*gl)Oh9$=`E%!@poPoydQCvUm;kmblVNw!KXMnEKBtyS z9Klx0m5b9>#Cx#0$f8`Ib!2mN4#2U2e1R;*W`<*jHW!Egd9AIW!+}liCE2=UCo@=7GGn{S?*13Qk3HphwO)N{+tqAcm?PTFr!^Q{qc)Q$ zgW#KAdR66XhFo?Ks+ixk?Z4hHXoo2!+P?19oR9c;+Kd9n^UDLN{+v4%Y!s-?k?Agm z&NFvAcF)Xt=PR(H-M}(OGXls7w69-*OG2WU$Bpw-Q#>csz*HL2Orbw*hnfYlvUwT8 zn7_;pe+0$w{^biH?iWQ_h1|>iBrjZ5V-jT*r)y|9=pOVhAO;-4HOud+ouG8_d;2zO zt)5%|8NJtsH#3P6@Og6rH?MuB_UacL1!|re*qow7BL>_X{iR<2sa&CShD8@lNCj{WhkS=vz`yS;mK z0JMGhNd6B@84!~IRs$THOeh5Ild9}dGQ0!@IiwhniTjMK0eGkZ?XtAAG%{bDU6R`f zy#1g*rm5>$1_uq8z64ba8#nhtY9Uh8)}0z?hpVe6O7jxxZ9&St z^`)cLw77S*1G958&1&)OJ9e;j+<&UWf3kA3lgmZ;B2Yjtt4=$B1uHZ+tU;rW#9mGY zbQQ36$ihjEA|IY{p(SxSXhSd`1lfl=fFKsF3DK9?Yz6PMe0ngs+bEy=B_ z(`@XV$Wq&(+ZWO1kaxg&Z*6HQ6MJ&dx{wW0&Z(*MJe2R8Dm=eZ`$Vn_R+Js^1JGI+ zn#M19DU6Shs;leI(6QZ3{XdKx8Y>kf52{FxAvfHR$bs@@*h}K_QeJ%Al}3 z0*GLWl!N6jNYNWclU(i2zCLo)26ck-AKf|tOxxRIplw!ljDhd#%j>PgI z_H!rN8D*Z-j&9Ab(~&6#mh!xGkEDWreQ=(0O;*B*I$)$AlIWKLpqC{;JkXD339h*j zH2g8Q{il^rP*{z1S86cL^AB{a5MR662ROXq5?kIR{X*Vwkb$NOK!a_6$v|`K`7Qox6hbkA6uyaRkZrVT2f=mqT&_wE2w1h>~Ksdo47NLl5Vg{r*;AQ5aWh52T| z(Pqc;BKgGnySsmGJx;RG31JAQkk=MdbOEnGAx z2OaTi6I7AoO<=Cs+C0_DuzQQ_6%1;jp0LhA0A-UJjeN6#<^Xl!%ucBjpggFQ|6S0p zUkRNw+}<<}Y?n0!5Gm_Hb6XSr_TXN5mQ3V|0)8g~QcP?%-as5L4n7jja>kMp5?r#- z@=~6s`uW%`f!a0u2rMo#z?Fw=RcV^v>kQLPWTEf%9Cn}M5V+Oq4I!E1n==_IHqFV3 z{`-639t$G^@h}G4XRZT3`z{4?|Obd$YD)V{w81?xSAZ4uQPFS0` z(5+{`*5#ZjI-i#GA@H`W9BT)Zp1B#yCfO4U2;|rfX65>;l)!fZ-={UoJnc=nA0A_G8l`jt#G_xpK zg(Yj&bA#=<&6KH`ST67JEyOw9=z_e%n=q`KcO#@`RCMcE;0<;p8l=gcNXUtpWJes_ zd-_TQG`zhPe?(f zL-wPAQV5b%;N-jj0RaeFyp4an#YI&h|E+_8r5mUTC(2PBm|~aEOnZmvsOgA8m)7*O z^o<>T506dqN{c+-!P6jpo=h;r_Gp2Dk^ZyL>RXJoY(9$Xd_9!oJRluPjDxn3*JR$>+ zH^{YgCTxT11Ui#o0j&1mPMbiEy#t{La?_U3(5NCi74xG0?7VhWNL8lS=lZd0rdvOq zQ!BjT8}EH{$M-aoa)!A85zI<5D6*pY{7`b+2=uz}c>LC4HI>WCL^B-*;5L0&jsX9< zto88+NQHGHF!6~UV8p>LE8_K)$EO;)_SM3k#M@(41rHTuQ1v|k#)GH)$9JD!0(uFp z>7tPbpm#!P-X4LJ6pHz=?oL zUkwf#?vDEI*|V{+!Q_TG;KYK$QC;O3E?GKk{)^cEo#qS}K#_>TB6nrJ%7P-1>Zob>a%E%!;N;qX+rKOpx z5kYO{VeJ7rz#d?>U_7&$%{yRlRxw{DJ~#IcV6kGX+{X{^7)u*xL&i`r0Y ztBbXxC%Q%47-+@LqZ5>%_+XL+@ik!3)IUAhA0quhmtb@8XqknzA)58TvQ|j5dCcAC zWLT#yni>qk+E{;l3)#W@xBIo>n${}ttX z`1~CmcRjbSvuz~Cc9XlIbE{PYNMbKA`6CoTmi+OY9;r$#qWLD#7JzT zVIE%vgYy^h#Xjxj+?UR&LWJf6o)=!vEf%Jh4@7UUAD>#h? z44z*47osXZXmKC|U_$Kfq)YY=u%14;8#c7pQ8ovy=9VmusOU&CE+SAK6922MUa-E(d^Mk-Y3SGgZk7YHKboVGWc)FzA8YJG`8n48-K(Kic*mE$dUu8&BSw z-5mX$_-RnQU|o_Ld_gn*ZAPfnk_#3u0Dx0_vyTNNd#U=|j#CU=TJ94B)_|YibvT$N z+iQ*kQiw{4p12G?SB)1K7<$G!Q@{xdRf{-vl-#5O!!0nkcvUz)StMl#E^<5CWJa-A z5=e-xV8R8US3u$(zP;~STlYte*}wPxo%lp|RJQHkroC&)U>NcG z>-5Zqm5r>~=_ik3`ig+qAgfB|^tf}$0^UTqwuV9sH7PV)BTs{YvdzpaKq*nIbmsDV z$_^D5mna0Zi=r>?Kkk#lmuX|p|CjY|!oS2%?BV}Dt$Foy{1coVe|p+Vu29IefQ>~y24+3yn^_Sz40KTJzMV6xsXtQ03W;>_$cAKUS(8{McL?? znboKb#RDf3F+M)QR9y4&4yZH8uoB4D!D5}0i=)9jpH))QNXeo*bOv;$ogMw`vS5la ze)#)id|1uG7%P8=wL!(W;`ayj+E3)pQ!~vf1cvxSDfP*W%#dZE1??hwd4=K7dBF_maNFn8r+_a!l#~VTu1V;YofR!dR z3ctFF0_F}9JvL5){$k>PwOws(rFLG>F@=<pt)-*yzvycw-{Nvig@ zw+TnzaLFJ}mATA_!oAQ*`Y1q&I^%mi>+l31g7ui5G2%EY7`y}i-8V5LkY>dOx+h$f zt7f28=G;iNZOndUDx?jjy|=F_NgMAymcM=Vw&d^|vgsQeBec5EWIT0DCIW`R2o7+H ziyNQ{X!QpdkpOss?Z!~lo%TeE&6|@aPMiSNH7K*siagl)!rQa(8sIA|Rs)!$TFM*A zf~QoawPFB%gOblpKOK!W1hfdamW|f=55m8H>aYgQmFMqC7B@e0hzB2hD+f;Zyx@%m zTJi`h-7yE#jPi1?>RrfjKv&NyOFr|btZ>l2BhN_^7Pf;?AV2^nKiQBqmv{+ZxNNWO z>aiJ1jx@-Z;dkrI^TG6QR3GvE`-Faf`Uhx^TkvtOc`IqQFT&DMbaGlz1ZAaT$ytA1IRHwkugXh{98^shc{T-2c`U{ zXXiCPKW0660bJfxjJT`akNP=YN_w2Z>f-ETZRNo_o(=LBI#ExBfJ2A8DI9(Un2e?H zyFg2uGAu?qddfv~sIm4}`Z{oLI+@>2<3fw#02$Rv;|5wg0l&8!xWIJksQB!(I;<;O zMO#NN#HsFV5KW2-mXACT-^GI;kebG=@#FT&UKT-zgMtLSxz8P&>P}p6URqmesIMRK ze<<|}&POrx-sGCg28;&O*HNk;7+{$KvSjKwQ;63G4K;(6hE?gi*Zsgd?bf|(g?#+% zut9+q4(JKJ*!>PnW@}E>>_w=oC&V& zZ0kw|`xzREWB9y^8vqhJmJ>wUKe1S7MkByPO^&LsKT_oe{@|`BC-DTsVOq9d7KS!6 zbue3Q*@vyo9;29M^P$cMi3Ee`i<2Nl>D4)O>L@1{cm2W!J9QW<`0&o(FV5;tuhoMB z32J8DlU(fKGA!`xKofTLvL?jD!~h|bRTeH941?PSDvXj;RC;snn+em-q?r`!X2WoZ zg7%Jf)wFbO78VtaTLB_oWl=H+GQl^i>#A;GQuhRPsR7WWG?^MX*f>c#yS_S9^k{$AL4+9)~-er_BLztf8Ra$}#gfN8t7>%Scr zkC6wfWWZ9s)Jy-ib`Ko-=$#9gd#sG^oeOM`Z|qdj!kozPfBT|OR1_hbR!37??V&wiuRS`mp4jxD(uItK)5ii5oc@0M>ArcM)3CF0(0Rqz z6Q0gyR`)dxv4Xf=xwP~+qX+0d#4L7p_QrN5 z`PTmQv@-MQwYs`G{-Ync(+`tTANP@&*yj~!3>9G{`JeA;`J2d`oSfNDg}kS09Jw)CQVt5wP>fJGe>0btgGExy%z08y zJE~pGdGDYazHfu!6K1a5)qHsNpHLV2)<>JiTY5>m)m1bRv=W{Oe}4-#RaI1fH%`)i za^v`UIy%|fk!sm1{@a;Wf%7qrsr*97tyOj9J-E-=1)=ANjO>i&Wj)K2UeCGqNeF5e zrY|}Y=iE#|uxM{TY>>*sgR&JZHgbp;wsqq1ov(x7)YNw#qVu8-P64J}}^2e~(N5^>6~? zNDPM|3cuaY3m@GQ!Q6WM;}DQ!z_Nhdv43j~Y!lGkF7ti)%;1jT^d6_1`x*RK*|Xnf zpw)VFy?TTjfu!ryO6PX|U1AvG$Mut$U!V;M4Dz| zRN)chGP^-#QwfxZSxNWdm;E7OA*luTd8Xq;EtJ-0`{NOty-uxWQz)j`{_g0s4{68O z|M1O z?i`9Gh-^RVPn;5UY7<~p8AF~sSGU4l6)9?8E^cdUJ9x%BWxuj~&LbrB ze2^CpudaMj0PGZm2R+29t7#Zs;;X78YemD-3sU&Njs@MFyS;rR4T**-DZ_{PANyDK zbq4YCbrZ#d z7Dn!*mou7NdT9QI89!epe)pbbv&MYy-sPuZVZ_W2O{9ZIoL-L_KVh9v@HG;!_u#FrmeO$z->e9Rz!4pytWaT( zTza$(hGa0TE~6n1r;Ubc$c7ID?B7Q`9{aqL^nmse4lK6D@q`y zf1M^t_JYgRZqm~mYJquaN*n8j1uFJe&o8fJ3E78zk5Rg^X(1~hqr$Sfn?p9}ng(eKKgXtq5uBsHkYkyf(xqz*R z#o*fU(v@{ll)g{}E;|FC#{b1*p~`|LlWL&h$Awyf8EJJk6%Y!AlqGf}oVv5Mi^SNY zIWd}|;9lDhr-9|3`P9X~aHu#ajK0+Jp@fJ?-172biA|vfqWR8CpMkS)X6eoXD)f3k|05j&Uarwq+;zTdAw6n(YVn6wXi|#Ot^nkD z{sMhN+r8Sfq6|+9!xfBMUO79&d$anNHRs_jfvqi60pzVanpm4}(~3saXH7)&3GlgJ-IUoEMmnIu!xB6) zp$e{8?ZdLNMk?4;y?tAMw}HI@XY8|ZgYjZdcIyfrf1dul(zf5OmE`Naisn8aJEb*~ zYr@WB4kzh^z2BP0$H#G>S6S%G7cF0(m9Wr()Q`Hw$e|@~UM;;HIo)}yU#H5|mTibe z6*DiMc#!T$I!U6}g@>8@s+%{`)_~q)as*puq^0VASvqX2uI)07ty6W`8ZoSys#s`0 zh7zGRY|b2K&-WTaCkQ)DQr^MQ%Aj!k3MK&e%S@Cv4wuQ(+) z=^Ea`v(+X!DM!La6Q`s%Q~H+l{{8!coKknn`JVA^+4kS(SM!p>Z0KykY%vp*Q2NDx)x4=`-9b~Q(%18vvt+n(ozV9iIXQ) z7}(AU<0nUw_nrUW+~V&POiA)_q@Q&Q;>KmiCSZ36BYhF3>sh@$V(!^sB?h&zd0|sr z_xiZmI1m|R<>edA=+d*Y#Ip0v4P3<~WZ<@Qp-G#9g=;fiN>mCI2@nC8gk|-@|zH_ozL5MT6svYRMH|OBfs=?8NKVGLTW# z*vOt|*Q_h}Xi74lmE{0$9Ol3`AZY-3A>%a#g<7cW&Fkt}a{q=8+&mVp#4|eeeN2}_ zKHJ$BMvtd6r|)Z*bIGb2Y2p4Be8S-EaC*PzK9KSx_0T~RBeEu#!yr$c(Qb7`cf!hS zf>-8SN&2Qa^Vv<7`sSAkeDzI4)^Dgq$>Xz)HR1!m%dP%HO!H9xb}=P7XW8Wt5(B@t zT4h{vP+NR(>}%2O5W%i_j5+qL9LgkB8x=qC;M+$Gn`>sVT%!@;-Pp`pK1gLp|D6o; zQ19V0)cH+)&BzpOF4Wk>h}HM6Jak6>IBWMGZL!xIj}s#!ke$HXgT z#dwHyv6yrM^es#lKXtSYm1Wke67WDj%K-c>LM4 z)j3wAa~+nsdq3zPYdVC?7e6cqb^?$^Oc zi2r|oQMMtH!n|_9dqY&%b=JLd^|-R>V8v#~p#2_ccQcq7)6@{fzEZF3{W%b`f$?2v zb%OBND6zoa*3%OsN!r+xkX`N}P5VaQkiBb72N1wJVNeZ>7AeF-J6%CgBM9P>zTj&5{qT;FS2h1 zZ!#VMfjEzq2@CS~!|;|MA3KOmy?8ITbq1klYUyR<>w_{N&v87H94A@z<3~@NJZWCr zLN_%j6gfCJsIIAQN_uoir`XljycLaX+XazaxIwuav92xoJhV!I6idx*7zt4{tt@S# zVh+9}LCX-yA0|oC?$hnhL)Na-N;o1Q({0Yu$Nh1L4LU7SAAF5n; zcEKosg}KelEQe8%7*;bI?l?p6+mIrM##1k<`4@o@lRzSovh;H=+2&gi-`7XdA-ve%_;2u`Z9!2 za{4*dA>Lc)rL{gwh-melU(qRXcx#rpF)v=e(!?v-2${8cb6iw!et$dknJ-I+j(tp$ z+U#VO^iX^>4VP`U9ZHvxks*6eN=j*MH3g3spPQTW_#AB1^ytuWi2^54(W}oG#5YZq z5`@AV8XB5nd6|hcAqWH#tEH8^WMaSot>Pjf9pBpT*tPjg9oQv@1{*DEa#O zy3T$IrxSG@1DC^zy0?5oX2`85rxRk&W;szQz|PjTbah<%z&D?Ibb2_)y4ph zyFF+k+KbCJzd!GOZ+ilrP&~nFeQXlm)g(h*qT8TH%R00vJhrCN^Kp1_px;=C>2!G)cp6q2Re}%HIx@V3;!&Q%PC_Ih7I@%N1B$jZUqS7z>~E zeD67|kvwogQJ|Sh?Jcv_BR^2lg^HO3<8}C6S(FA=Q^|JE2PT*_TS)9qLHy?nK-~a8 zn3aim`-#FHBk*MPx>B`Th)aK&XQE34a6UF2L$ZRiVmU+EhsSB6NfrgiGcmn&hwsQq z+NKtR^1Qp%f!LhZbvn`Ix@szmwdpQ$rZYKdY3M!@FgsAlZg1P`X!y`4xy9Dx<#P)( zQUK;g)B%jXytF+pf2+oH*VEH;xf9k@wq9{sZZ6|R!TS}TN6AG`I$X@|?r!N!PPtd?}=y&+;BgJ}m)O@1eP-iW&mtr)ta5do&V zWouTpiBW@tyu#jJ7CSRborq~UMFM!dMLU7uq9l6cB)tL(9{TE|+RZPul4!Wf)J3I9 zL*V+sTVl`7&Tj_9LxR{dl4wPoS>Wz<3LM%zW-K5^fVIHt zI$G9fC>EAvLeUCw!$si7w6<6d=^U31Lu2)&HFQ)6cyFVeyhJijAjVk|b7$BBZvG6GqcS-+vd%7sU@ zw)gK>EG`EIBI*IAD&0siUq;gkJB}rIp%9HR+^slL18yQ*ntmzp8x_z_C(t2i2Bc^b z^<#g2S}1m|Z?9PTFfmIg7jz>$usFsPKr1S7-Up{!I-N&+Zs*SY=iFSP;fn#uj~G*) zCf}`eN&K+D<;xMjO%D$b-}3*2y`$>REyG0}%+OGZ7d`Ngy!=XlAPd6QHUgZUr{?gV zM-!%}Vk?wsgnPhDXrO3mfbsJX(p*>1BC&&)v>O-4+%NiI*%TbTxxQR}c-*jCw2*sZ zfPM$7g=?r>#|t|Q8Rvz#YPkr6*)<3e+xz>w~l@+&3RIjvOD~kR;)8B zU+Qv9;X;|lO1_CzzanyX;V>pyvZn*=pZS6PAnY=&4kUaYul3oCk)9YX1Ay{8+~{X8 z3wOM)Eu55=;Ny!#&dtRPxSE)J4(y)2jqYDuG{tlA`F<&fHVhZGD5fkeuf5%Bfmc9a z^Ri!GUsNNbu&zl62+IIoRy-U8O*u@B^7fuFrVgoL{+XaOJ3X}_Yxb|65htFwv61pQo%1ux} zAhFo4{&S5i3Xa0@Pr3I1=W5LC!m4^6x^B53S{*FNuV^o{(pOeLQX;DCj27%!3z~!H zo28Q-v5^7v6G4_M06-*E1Sx7ssC&z=jQzPpbMdf`vojw)UD@Kr9{%+X;qE5b*mCOz zxh;70&-Svib5~{Ro?(6hSx3Uo+c%<@%fYb4ydIV6F%~Yp{N^B)sfo!Yds~l2m?umA zLEh|629zfaeJDhVv#)Is3NJA+2`c|5AtdBHn3tQIJ3EkjZ#ixvVF+81o56Z~qE#7w z82aw6A?iQhilMLE?aV;0L@O$s$+y{`yhGHZ@m?*l3%dqh8}p5r zo!Y(Uw*AG5oHl^@Rg^lB!PE8N6J}MriA0;t<+EA_!7%@9r(AL?c_-d?+rFdUYinm? zT(rkGgZ{fwqM+z+B0V}wPu8vD2@YxjliZc7s-bj}5^EdeqtIu3e3j}%3h0fF)9Aa` z%|x+lG07?=h{Xb0Qc!5%S+W&l=)(%TqyGJSQ-j(MAC~)Oa1)&wYOo5MKGy`NZG7Nn z9UJtTS3*_nG_VW1FAjSZXFiAUJ#j>G=dRCUHG}C6Pas{@6$UxM#Q|y#?dP|GsZ%$a zVk;Iu2cJLhV~<|x5KmtjSZM{L=~Ci+#@$c2_ps1-0Rw`N1yC5t^TQxbH-oq*AFg5x zkW9HelbY>Ei#Hs54BV=mhKyQr?@6amUzLzR3l4d*Z*MFpL%v0h@LeuIz;Ep@Fn z%7Vf|GuRlwIF?00;s_@`rIt$qG&jZ8>ykGIH>zUD=QiBCjC^Ajd3&d)GehCO`F=Fa z0)eI+E$imy zw!Scg@jpUkuB4|YCnpbCbkxYm2uze3ZxNjrvcL>>q_05ww9~TvLLiu$v!QNLrgG1p z0U-+mp7J4aUQL*<4+FLz0&q5hI?ns+j(iw<^I^+Vf$~De4FaBryQ`Jd}Wu?=dqZ83DFc=-J!%0KHr4v&L`rWh;fX6U-mo0wc13ZZEfa(!~<8(wJBla^FDB88m_ z5h;dwKVnNY%z3aAXC)o}CJ+o@{A6pxS@t}~u1pS{0wGrY-3M`T@ylH}Zzg!OO>aX( z6U01tG&G`mOCCsdXB$A$a9oo4qQHH?MSFWoz1%|S#p3`r?C9)#?lRMDM(8Vql~ZcY zj{{*AP1Rz+>>&PYGUn!{b>+$vDCz1^HNoU(weoH&uCI?n`}N-dfLqzcCCuP~8cEI+ z!3&_hPei!Y+rZX6Ty*g10i$r8+~Vecq{IauX7F!rjqw?I+!Qakm$$*d zaIukLuf9sz%uK^)%Em}JDdp;A4CwGk6^rkVcl5c|Jc)u6d6RP+E#m#fp~!wnBHMPM zl!y*KJUZY_YLom@`-FU91D&M#g&n$E6c4Fh91-c!TI$=8NOsXgO8E3V$@mj_g^K7S zEOL^rp*P>Z)OOUCS3pepia%-h$Vmm8evcL6P6bd*#@O6bQzLB|ZQIblL%<_U zSlal8(mZ6p{136L3`HU`m$&FUe%k1c-f~9I;;n4*YR`L#C{C8Vb2P7m?c3fvYB$r}Ec?l5 zUntyEPt@J-Cesh&gBS-&eYn6_nka4m>N|7fYVsfOX-^n(gXgB_TVSV<>!^FMu?MdV zky_j!ds_EXL_(GUk*3+BaeHg4{lO%_;3AQ&$aUThiNbrZQv+y>#CWp9b2{~LM8m|x{A+`A2!s=6Hu4;`VJcdR}qB(huW&)qM4p- zsNcUFf);K)bZSAWxR!|HYY&KCb_4kiimd1wLN1#%9=>VlQ+{s@Z{ij3xOtuEy za#vV!$J|X-*{(%11Z)hhOtO&@E2u5&p^`(e^ad6PQ=`}Sy?R4NMEtVA;lmWDuS-hq` zRtzObf#2JPS54&9W2Rdb)&`w1I4XcASBW1-aGte60fRvOb)@snHgcb9*+U&UzG7(} z_s4C6Rb*G-=Xm<`wE$yXyuU_rW)kUG zdrnIHAr0>CcPGZW%G7tS&%Ywh_h{$jvdUTk%QI0S40nIcdh5dMt1yLFs#Lbz^uPD~ zm%|fVp|4ck$L%Hsz&${8bcoSqrs&6{mIYz!fe$v4j7F3IEl$ZA%~GoeAp`U-^Mgy? zxcAciHN1|J-RN`8b`|bQ@~e(DAeaSg{UvXNoXC-{U~J6gW(Vy)|(45fSg! zfBTi*lFT#Q{tjvjS`8@mF6gfV-IQ9oyF<*od#QOwa5_~(#cXd(L&KeHZ$S*@h9N8p z)15Wq>gwvTz5=}-st*RxAASYbTW<-{54S&g`=3>jPw?{J&YbxZJDp_(R{;~>jaTFa z=pp^niMzRC@y11#bTF#N2Lq5CG(l|X=-{4QLf3ui?^kX?e>vdn+*KCr1FaeJFIP)F zS~!f`^PQv-6vu8i84qQCZoyW+^)K z$Nh9PQn9)^>NxZWulG{cOMg1pz52`jkktV`bjg>M%L+XJ9OOOac6HO!r}Ov;$B!R3 zC4m@g*)D@Dp)KAoU?c&Y0%`)L<=zsvamOt6P>F0MloCxzhdgrU4?D0}`XSOAr|AtX zExltApKs>7M>T8Utdf_I>bShUvjLjABs6nNeVjt!&PZyYI)TgOc1{kMk_-f$0Yf!a zOR=$Pwc620WzSi%ktt~dJ-%tq-^dp-TH^X|GjnnpAAhw9K+roN3o1_h_p|rC2Ynod zJe(F96uf8WoTD8l_mk&7ar3Uy05U9yFI=cmG#XyMu!#qjk3VaQ|F*}5tNk73|82ng z-xn80_qiqV5_Zm$E4M(Jn~q`|<)Tsr5o;&YJe-#!F&ZTUot*``e@l{?45=KJk? zpYJ1lm(yVM`65B*^`_VU-dZ7_`yZ+9W@T9HF#UGN4oE)6t*AD;)}u**DS)3=-`ui^ zl2F_~-$Ej3+UBbv@D2VmX!9?3-r;PtMt66oT5XxJxo+5|lN;rOtIZKn@6veTNt~t5;@^%Y#bQ!8NXkj>r~ zi?~>8dQ;E!z_&tI+m~ToCZ9Ha_lWrB&p)F%i7ahSRp#;5ZrNq#$IV8qiI^P1zPt7t zFHuF%*P-ssFZ-vb1NFk>K-%rFr+U)|qK)EQ>A3=Bxr8kzL z#-AXENeZbTEFdSvYIyTtJ^;qXNJJk0cg%lUZBNe$I zwFC{ynVs)yEgdXV^}W1oO9R}md0g{O)axi<8d1QSs(Gn+UoZ<-`{&_|+gJ7*_lZ5% zQqV!Rl6z6n9PJ>(7;R6f3rCNUI785`oI4K?!1d>r~Ig2u{sj(_uBXOmy0v>_W> z80lkiL?`0BWFFAb>fTU+5s+j(2BIUlC(d-&NbarmEp{EGm<1N@8kZ@YHlUm`KQZm9 z8ruB$1Xl}uo2N*C5vBtM4VuEtzjo2ait>Vpi@^jHKKS)l!*8z$-*NPyHA)O%IK43G zI8DjPBvQ-N^sFnd-3x)O9YYuHg?)Nid)~@wgmK9ke|#m5S2siN&bWU}#U4+LYqBG$ z3c_?1Lf3MeYI=lK!qC0hsS2uLKY{wDWOyFFH*7|SQu4{8tb1XpiVmk2c(Y2~XWwgY zRL`{)O!LMgz8rR7Ip|;i1cwl`j#IH8U(a${ysvpSLVB92PMN>{?xYE#&w~nO{C3&9 zpa4E1{ps$ZDUyD9IF;i+)g5WdZ?K5XvMY`S0Z8w+QjHM}xWWvkCN$EAsA%L#T29f1&zgBI#&} zxK|fLL8t1m!!%5yx0nMXrR&{0{PR+F)*tu6@2${X0UrMsQ~sNcj_7;d0NKq(%>-iK zJW#bz*>e`BbUrU-aqdPPk3{(7lo6g8blG%vc8=TYm;y_VyD&TWEZ(X9_B!?aFmZlk zvVnHl>{xv!nH(GcFyq-XeW*jt!MI;Q5W3jR6jHem8iA{NT2!P9S4sz=<$3EMZycPP zjBv4Vq4cib3gBoFCPI`$r6YRXYBs`0KLm~ffjNMYh9*b_SDRmJ+Ll8syv6fVoCyv* z6WT*jWD@gvC7TZiUKJ9aCT!1$cE#~X$}{%~B71fNv=%71^7alw_#k!XX=p_VGr^4C z(S_T_mdIvdjy;5Y80&j=S6Q&Y!UP!L$-5ZPbbU-2C(eu&AowTr{ea)q}6 zKw0-ecVS_rSf)t04+3mJD4#tp-v&i$)5n@f;alyn{jl+xlAxxMPM8=2+Jm)(MnvHB zl8#}G>7AX$7|W#vk&xznDqO}d=R&J1rG*WzYXy={93Aj6-1u~dAC%3c`m|E@ojY4t zZ4ddmxhZ3#4e32TOr~BJ6z38}&>uKEk6!a=4dt9c@HfEY4QGb8TXKO#GAZ)xBB^EO z=2`@r2Qr}c1K4>G@Ic%|wZi!s3vF(7S{i^`cL4%T(7a@88)0(NZ)&)Cvr&*{uy=PX zK-q-LT5C5SPSEn98Hr8O?!zvWg2o@oz)%>OmPRE!hJj`AyG`@&hkY5Vrz?V6%}!g| z*e18$#4>tj&VLk_mCj=vtixqek@nLk1HHNcIp&Yd^kfvu$Ej>GLQa|<@ZtAL-^O4# zK)Wlhe&5s66QibRG4o8seHIFK3*~qWx(j+@|+}gFhP+zRW{Z`jKfQ2 zhGfx1!wnl^jQDIg926>|LBL%M+Ma}U@Eve6u~L2@ugGw&f2Ao2(E&hJZ(He!Qz6WK zgb?)gp03>t(LvYay08N@J`Xe#jE$i=MM?U!>W^EJLLBUN z)nnU@;%p0>#IhpF)6o78A4*65;F=!Tci=`B*LV1R$v6mn%|qD_HLTB65U7#CPEK+K z*c9plD1_2aZn^62ZUKF#K{yUfZY-!vfCB>E z4FVwpsv&3^sq`GlH4%IB<2uR+HS@7?7Ao1(sod|Er+p$!do&uY?-}^u1^72EaqOvuX0*SIm#i-g@ZIDOWm0F$$eYMd3Y1 z%d^;eIg%V(t&-WkP|K7bFyjWl&Q(<%Jz}69TAk-a-K(V)Q&R{A87{*>cc?z26vRMr z$G1f0d$YRlI<`V~W%@Fqp&`rjqTKPt5txbyAcTMc!!MmX4383Na2fDlmarl0*BbTo zV^bp$T$@kT3aY?ZdgR`alV&4X0>k5izA`ggnzuvLW_?nVPh}!-TG(n(3>EwS8mH-) zA`Cvc@dgc^;VKm>9F=DTvmP#kC=tk5I0-1y5o!a%ujP}yA3EB^vCF$v@|!jPcn?tr zu*0~?$(KOE9yE+iORFa{{JR;7vD~KAWN?|J@W-3x=H6WR0l!rs@G{(wegMsB{;C6t z$!~7rL3yF;V$^DHZ*RQjUSCdG^&&JhG{ceFgr+K82gwQuno<$y@q@9mWteS5b)G29ywWz~qXoj~_>iMwn02(qt*)8VwqIyZYW3{2W5+4P_ds z&gJYdO*{L^a=FKb_doU2j>2I#jS~~T&^JH5f(Z(OP>Bs$)ziANEazflXXnM!X6zm} zes77A7j>?(BiWlBJ3k-sib};WTnPbw-|%5lg05t*e|I-YZM(oAUMO6ZYBq%9hHb6c zzTGk}TGg0P?UmkIFz;ve&?X%Yp2OF~e3W*+xj`si7zrcP3?%lvkJDz$x?*Ev&y;a- z9uD+Bi7(W5-qH6@*MkO;W*fB`fVGKUh6Zz&iWr^3$6&&0K5WT%G+BUd7lQ(!ed zln*PxS|$NSvwAJr98TT9EF;>*|LDDsswgShjmKkKLQC}k65L%;HPHo|lU-?PX$!RC zQQ*sOG|>AD3LkuYASzNN1ly+VJLEGx1TBi|A+5~!dwVBEzctx1BgAib;v*82wQ0g@ zeEA$ck<|^_cVQ+Tc2&qMrv14G^lK#&YT11N>{hGWzPfsjU+&zxrlOf{uf%=6y$AH~ z$v@B)iz<{JWAqHQO}DN};;_~$&MFe8o}_?^-$~xx(E?%;xR-;6%-6P}b#+6!#-Wn~ z$X4DW4;d;3q|C?wKo~qzo~)unz$8;PD1yJ`FZqFh{Lhx6a+0y!voJUp$@{PF!f)Y5}jFd=<`RTktpVGL%cddDUkXI5_;PBP3mvtGT=U^e*J6f>8o z*%QT2YER+vIL|F0(}O8tcKX{ANp6{N%%3?VF`oN5Zm&bq=bHIzV4B>ebLk%M&?CLw^=@`>!mXmBGr+?up2IipMW2!(5AfgNjWH{yacPc z@UWsXOBg^lia7T62#9&i)O4e^T>v3&rmltsK+MVx&70)r|8iskA*>zi1>Gd8|oXxVz7LH(uM+&f14#>I?KM=s4 zO-g9_t;|OfKk)5x?mEpKgGBW{B+;;$WL&h7}!MO5u z9QFEib1GNNb*h8RPSoqq%*ZJ>gv1#3ll9*&iuALyD+c%5CnXnLuZe&)p;a3l%$b{H zfT9WW^~~E(gWpZudL3A%7_42Yr7kJ+ADntgr$z1_jPX#hfMV*^Y>8YJ&6P=v(f7Dd z?$dz7IJ!p}+0g`g*EF90PJsS>ttOC+2cUe}DI_MksC4-7;UN3Le^DeK7U zF*Uwy2-5li!)MoSL8tWTV9%4DmJ}X>j?N8I@v7WCK79d_aU|f}+_GiF$bTnDd%<(W z?1e^LAhNn(U8#2g7ieXrf*$1)}g-X31_u+hEg? z`RAWePfN4*_;X&l4gs_c1hV%mBJ-!%Nqh;Vl3SY%18;Oo9>&KGt@BW6_&vR(Z~xML z^-i_tI;LK^v-?sl<9TmNgZE%HWcvA@BQ2Wtr2*ZN*x2>?F7==MLvA`X)(XmeqxtB1R*O)#cu_t%KpU53V7M-B(7 zZOK^6{m|97`p*<|oV)F)hKCOAx_4ZoU2IpN*0;<1V3^uB?~PyYq3_Ah^0Kuq)UsRz z?NF$G5-3EVjDMg8JciSOrtc^2zWt={)#&mWenO$oLe2o22nmsbl;`WZHI04$!mQVA zX^c?lx`=5Wa2wa(N^HP40{pGc{EyS03Dr90nsb$TM$lgWPR}SGJzW{Jiuwy4nMt>6 zRhoc{haAMuR$^^s?KU&k1*AbjbH}d#^kL`PLmt$ZFDK(N&mk2hgmgN3<=^1fDNwD> zXaD$EdJM_$ZMC%SgvJ0dvvh4gAxdHu;#6}01o)31)3UE+KRrtVX(5!Od&bQFZXY-i z1#$u?08ed2KVJRu^YPLDy@LGzhF>Q=&4K>;{|TbC*7dM1*t0mARazE6C93A+aWI&^^! z1l5$Apbw8A4|nbefe-^-Dw@pUxM6gbrfQW{@zxddpsEsaiLwR5srM@v)i=-C1y~<) z$f&ybjk*fL?&ghN1OvJFlaetdEiDc5Yi^SsHLM>Ed52iaHBN>XHy?igU_mm=tV=G$ ziIQ>CZ5=wHK_0rE7vrDa2L>L_qQTSAk3h>TtR+g-xtiITnHJCmV562@!XXc9P|I_& zqD9>vK7(*xl>yck#8f~2II08gz#O~6ZD7gej?a$!5Y<6@&U9MHrX@mZsAz~FDYAY= z+1njdq;m3O8U=&r8x(W0x3oFq7&V%VM@x!`2%%hAx?kUWy?#dTSKUIEZLHeJm}i1@ zxFc*JP=%@`uHJ>c5)S1312B=KgUHrNCe%KokyG&y*mkT;iZfj_s0Wc7p)e@$>Y0(g zUIIrt1`mT;*UY?WQ1PE9Po6~3KrQ<|Mx2%pw}7$`0E)68^C(MzW&nq9^kqFeHkqHF zKU=YP`X9?dvg2&xKYKTW#P)!pVag7(jrhUWA~moDctdrcmPs=?c~U5(=eYr(h#Pv? zfutV7q|MwY18<lTV*S6|=Z zlzqz~syJlFXN+b7-v{nFg z5DFTR6abML$t-ZWTD!rnvU_*QJbPzA5V?*6SOazTj0FIFYF(9h6VN2>FKl{ukH;KI zG%9ggoZMUwC&_8x--RgqJacnFxI{G+kdoiwPu z@XV>SA3qGH83A6PfE!Lz@ayqX&Iru$*w36VEaTsSdg?P@)c<30^NRVoxf=xOKt=%& z*T}>Id76u_?>>WIenYN9ndp{w7#bP@E`Y8cFe|ZamYARh0xTRLs(zo|+?Eci8zWeo zXl@|dA@E<0Fj0U}*ohRdA^@D+s(%+l6*m1r7r=cbmK^LSxRGb^Isk#US=iVFlU^Sx zA;WyLfHlG|_qPN%>Q?#Gd0TMT7XS+mHOK)bT5_qo+pe{}J&47x0fi`2+R{?*ud#b~ z{rogf_)Ew1%y|^ zB0y{^Ou|g7ZDm;32Voh%bURzhp^925GELf)nN!6_|DU759|dNcBXDE5Pq($fqXA|u zWOYE+dB)zp=T)el(-~*ynhQOELn25QJl7i*;e_4Bk}m&}g_(OL5Sqw9q!+yaZXEE`7oeme5-?FXFog)LfO$kzrm{~O=|P6wKc3N zk~H}cB;~Fi?KzANn(QI02wqYu5jG21w0uBOyO?i7oQ?;PV?Z-rV^4$SxpN;|mlSrW z+?UEp{G!4_cvd6jL-0fTgmO`eYX)W(X@dc$q$0{6?s#jf{}EI0oDgg7ws+~{asjf_ z_i+H-t67|811#!i8QiH0kEFj|d~vz@)fZBY3i9mi?|Zc=h8C`_Z683?>&Lo3PqIH629aj*i~}tr;)HX(y~M8l8vJyVMNc%7Pn_l13qM_EUh{ebbRGh zTegjp??n+*e`J^#YuD~f9NQlTb;R8w-19HG>CJ58`)W#CPD)s)8J5S~i!;HMe7KEO zYp^mIx!?4>rem@P!tz+89)a#~0P~jPM2KB0=i*E0@7$Ll&N+DS1GXdA!E(hs2qlqZ zw>{4(9-!YY=EIZzS-+8cxkvp-1D`QySQk>PyFm{@K=*YZRRyED*7-}|A zCC|*j-fc@GI5>ukJcJPa4EU@_sIE!KMdnAbdV6Edlf9V*O!T5m7-pvGC?3v}kXJc= zhLeXh5;N7U9gMn^OR2Mse;QEd=S$VVPyPO@4n-)SB@R|rcuT`Csom4%uxr#mDzCuH z4diSxybVODv-v$)&r`>mi(6Npq9$iGd2 z-h!`R2pC&r1R(Y{8+JZ`W#XahRKzPeaPQt4q;Ll3P^u1g-mm|tNqepXLch_yd5wwn zs(zO|6C}!+P;tXa(&&n%-+(avazzLTb@`>dD3E`HM9~o%VGQf@A|oR+A_gm)k+=`j%&Ad&}-2K)9%h zx~N!`pUuNUlNPp^LE$R1=d58yhyDI}x*dG7t{I`i$|M(nA4>#Dw(G~&Uzw6*vPvXM z7q}I=A-v-l7^@y+s_-%}n22f?Dnrl@j8KM3~P90IgU&=kYd75-057{3r}T_%cioYTPJaAQCk+tTi!#fmTNhFtDtiz_7V{mw62|iN5Q-(jg+O zQrZil-K)r(6{qjtjp*9y$EluONu#59Q__{|+u-mxOXCl7;53V~EwI~L%m$HoD`4lw z>Qdnl9g$dx;%0#i%*-J8RTm)x2#d+V*8!M3fMt@@fPKZEsA*o7vnuI6Svdt7P{aA* z8YJ?9-!mSN4>#C&Ayq-rPT-J7aFTK{Cr%WS4)IwXIIP5ZNe5xvCjE;kaCu0FO)%sR zMs{AM?sO@ju;pYr_$S4qHZ;Wh$F2J9CJ2AQdYWT6)4AQYBBX7@nsu^YOWSg>qbsyE z=%s-kh{+OsUTvzy3w;$5hPW&@R~J005E-)(HtsH;N|ei1?WO2sjQ>uLjEKm`GW%BL zdR)6!I(!{r>|i|u?bjD@d|9Mhb;f3HoGlxFe85*j(#0gxi@bV7@p55aMsBY4&*2(E zMVZXl8X+tD(`O9i8>^iKh*#wY=n%xz`fNAa>S_DOCLtXs~zRWA>w{uX5gIM!D1)HaZ&-UKsFdIfU_!qbje9}zl z$vKSjjae?fd!Opk@cS?_PY*}j6NmliU*2BXzY-g%;~R8XK{1$HViN?`$lN3f23?Mh zHZHb!fa(Y3$)YT$xE{P++lWG}wyUT|VT1G6Jtf?$L45rrWBJ`C@sKtPH*#4XxuKOQ zIQy^U4>3&7rnDy+LMoBx@9v#t^&KkVx6`qUBXF4`QJ2>${=ub&r-nvxh*CM;z$ilj zX0zK*O9uR^)w2q6&Pe%Ox8tqi6PpctQ;9-}@r7^TJC-#;I-QEd{2dr{fz?*;{RXp} zn^dLA85_Ppx&83@8`^OBdU7tsF#9{|U+R>uqYDQEv> z-$|6t(C?s7I?IBu%U_Qk?oFjszOdZteH|In5pM9VFHw!(E#K*;;aaAb%^%SX6@>PR zufr3MEoq4Mt*n?BRXH*DuTB`$sLi}{aJ?%&UvDFSW?)3$=+SqoTYbEEXTSVU|AepI zP;I1uLVx-B2RIYWmUkw;_aDAW-G-`Ky{s`!__Y)>t?}CAOnm5Cl+N!<>a@!d`UZy| zf5X>DO>A9m!qK?z?Ui%i_xhvykN;bp`ne+cuTij>o=;y28<(QA3G*|C!u}-9BsQs` zA%Ats7Dn3k4B?c&f#?H_lMc1^>Bl$ zI8UCfJ($}QpHfhsR(V9ZeQ&}%TRtzr?AeI&wfy^?#A!X1#BZ5SVSzrCqMLgD!*!XO zP6rH}7Fvu-oCh8o%HDUsQazj~kWH9>wG!ohgtN2^`d4Ztbfna(*d?ps{CR0oqP#=Z zrIznJqWS|PZah+?D60={#XZ(D6_#MUFYJTh{B$|HQXPJvV4YE(bI??HQ=OVZCAb6_ z`gj?nFkc;Vll5|5HF?+Fj=}Ej;=C4XiOrl!^x(=*IUf%^d9oIa5Elp&AL67_Vh665 z{1%2y#u$~Ny$Y0}s*daHt1hn?@RbcJYiEeKQHOjioMkOG=(rv|&C~Gth{)O&!h}oZ zbcLR9K3SpnEUN$a<*60+=-FNS7&SO;x~qJsAC%n{L~)F?k0z~u)oowk=S|@H7(I9w zF{&1`BFxJ5M0-wBa+?O%t@*XBm@5BxD|4JE>QHTnHzs09Pie$mc>HnqRIB(}RlDSS z9!i73GMS!VewnsuzDskCG?>VxySwL_lm3jlmr+`}O}Rs>F3HMY#FX`C8cIHS>uo?C zj^4U-l;Pabz(WsBNd-5zZ`!nlHaa8gCeB~{Q8`QJ;K3x~OeAI~o+z3X>dAP<4dfZd z`fuxPDsR~Ei@mPHQNAyCI!UL-O-Wd%-c#FUj=9N_$$CLd|T%H3Mi(-1k~WYopT)4%fJL(Ez>8gaN6 z@jHumX~Z!sNMZ7NEpa_FNX>Mus?kIF5K(>(9v5`wtIJUBxv!R)mwmd+pr$rBb(C_A zt4;2UBZ^cV>uBvMH&nU=(8b zHe_VvY{S!w*HNXgNjZ5e5R~`RP8xZ(hkQn-&GHH<1^kOxAFyP&w%yUU&HrnMsUOT| zPj=uzUPmKSoA5C=$4SQ+f*rl5N0FP%#xJBS=;fpuPtP|iSYyYtN-T--6?q-v*WxDV zZ+FvM6Xm;hxuJ>b)&8+lq^I!24jtN{FJZ849?ztH?2j3otM3!F&rU1uFa?!XdK{bp zFmqF?P75Ha6#KY>CnFg=85}x=ro%E?5Hn`kja1?bebe(cHirr^$MJzbj%I7$SljU_ z68uxoYiUcCh`FN*ih)DU!CGc%_j@bc-O2dtv$!5=%=~$I!d0r zvHpnB-R zQ<8QVL{Yc~wfb`Xd*UAXThN=Hi@SaM3RyZ*q-F~JqEsr0foO4EskFgHkbDi>ERDNh z6{l|cHN#@&8qt|vJ!X8kYbHa*p5i2X8`}DQpZ=ecn%O*td}6ke#1`9Qpi6uBDxvmL z*iIj!$dj?4(q?pf%$1jj=YqQ%4@xe1s4FVm zOfsmBe*O(S-rjx2grLyU(0Obx4vvlCj$nh`uHMjudL}b>FG5AheoGtc?npEibLe74 zcF0f)9+}W(x>}|rLy(eP%e2r zX&|*i+>9yW%*uu^AcNUIFe;%tRSM?OHW=fPrms+{k0yF(3q1rk1(a&Ps&s*vvvgB5 zk@13i5LMOYU(pG2iIeOePjYF2N;G+};{P(OO^Ca42a*~Xs8?as6{uEdei}X^HR1dr zuS1@>w_{}wDtrWT%Bq84;?wv7Uh{1gvc5*0L5^=t|JB9i7bNixPmUAlqArKPZnJOA zO02?Cs_icJUWrOKLGthSt*uX%F8$WApTF-Fc{BtoqxKm=Dw7ZaMCdN$i}iqTy|t~F z+Y$Tk?+TsaFIGO@3AS2+knFUEqzX#@{m%+(Utz<-E^UQR7jNCj@HzKKbixS{)`40C zxSD_e(XwrD;gHl^P6AkXX+nILZPRG=<*YPmG8OKEg4{(Wp5}B7$|PUs97nWXlX>B{ zli%k#+I^=91u7mHy7-Tos&c4T;GMq`M$A;r+%xm8`?%7iLy}w%NhKW>P9Jb` zMEPzCv6vm4n;iHSs+_7d`>{dJ1d)x3^}p;tAbB`s?AKY{#sujNc-yXns7qf%~J-Cc>ckenlp57Ja}~Yo@7k5t9q|w)>5_ zH}V5Sd6PB>dS^;iYJ-(fzOIY!2c7dnkhUCKe4uGEI!%}f0 zoxX$vVj-7by0upF#8^z#$9GTpqkMNDKYz=D!mkJH4LGOdE6AfhUU;yL^B_8}K5NKd z$M8VX_S!$5=`?p+iA3>)$x7)od+wKw43CnnwY`dh!WL&}(o3isNlDrIx_I!!Dv;&z1E7|ii zl=OkKk;mZQQF@nZ)1tz7J;bcT_FQd(3TK&5(_@R8CBQx>Hus$n> zD8I?oEYYW!!_@-aai@ z$&16^6Xau{bsZaz_!3+=LC^ycmlLpG!OxWkdS1DYkB)@2c}D)_m{##95FbI7-E2eHin zj?Mi7A;(@6pI8HS%2~1lw68Kg z%^XqrYDM@aq=wZw#TO=XZ;y-TP?x`kiVQTx5F*2Pbrg$*ihvOzG=DoH;%c1&5*M5wki9$r5O)3PZ+Aa^ zbQQj8V(aeogipU9N2ws*(yFfCkjXdv5^0v3zBFv-=kZV^?lMZqoFG#V@(XF8}_204Hck Ac>n+a literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-42cfacddd965.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-42cfacddd965.png new file mode 100644 index 0000000000000000000000000000000000000000..0ad45bfa933e8d1ee51434dd1cb90549fb84acf4 GIT binary patch literal 120808 zcmdSBcT`hb_b!ZbL`BLuDxgTQ(5oW7swfhAk93gUdv7WtM`;?G^iD`ZkuEKW9J+Ku zqy$82=q+FZfxCE*=RMv#zJIG>Mwl$&+sVduACyB-?2yP(sRq` z&+RR#8KO+YxnoYVh-jb`7x~BaSNE0%_28kygaUbI|HlPmZKD6RJGa55ukVp4r2RRF zjt)ORDm5!BIV`}?$;rvc*f^eJY|R(E=jY~%24=Mll8j+9$}EX9Zbu}yc-cDc*6`Qd zfwH)g@k5o2905^xj^g4o-hPKUNmv7d;m%pUTMvKQrAC6qZJwyMn^6%ro;*K^e)d^V;?>&)<*Y)A>JWsC-uTSaEeh&_VkIU=+DAn}_+v zPfSMquw|&qN-yk89Y@528**!_C;xHs@YvE~zr{JJ`3ygAh`SnA^d?=$C!u=a`lmmv zYQK8dEf=G&))%a_`t1&n_Wxs0-+o)dujxxR7P|YZ`>ju53Zj_sujv}k?{gAAmz_ho?g^|>vD2(^6mOt`+c>k9Or<{C_l*=NM&SP5R{4T1mO)^p4RCvWZ99QznXGdAf4X}44Iynd_3RegEaVMrnmava^> zq-#XuU(I5ac%N!autw4R%_+&%V%w-m@nq6sxG%6FG6 z2Jg1EM~Mjuoz^0Qk`0&7{bpTl+P56<`5rWUk7{t zABNW?Ssa~Qbc2jh)YQ~-?vmDbjID29IeFhP1|Rjbw6d3xe08Qm#N=d#s5TDBg$q&C zk8kqv3CMasR(%%wMJ{uBsB`F}ke)YB&-xVBr{kgrtx)vx=n^HZaEyce2OhW1viVJ9 z>1`g%Tyk(pW2*i!>n*YzA}v)1WD z!Za6s;4M{ERT9B1v}w&sJWbuu(2#HUGP0uL!J|iI)2NE(X3bjH=_wJMvv?2Qn@>SO zvw+hESy~DkOE+BRNKsqfI6jzGg?Uy~5{QcmLHIp!;mT{rN5V(sYjxK+Q~zAfVmo}m zX&6!e#K+bgyGHxNC+P6`gTZQ3;n@#geF~P-Ju{>lXHS>$T#Q=wlQTz%l$o%Pm>1mt z`)NG8be%bBc^wzDeL#lgudX_G*KG_jH6J&@Uca0)G<0-xk@LQfo4740`B8WUQC6m~ z=OQK|otv3CW!mc`lA;HN!t2M!t$ZrmKYxC)KGjf*4=B^MQVjRKl

sKp%zD(w3av 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

Vq+MubLycvinrp7UcNZDVO+q9)!r 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%0D^rmt=jesD0H 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 J`|ORs{vTTYd-nhU literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-b1df22dfa70a.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-files-multi-delete-review-b1df22dfa70a.png new file mode 100644 index 0000000000000000000000000000000000000000..23ca86ec0c2264278f1e9e5671f5afda5094006a GIT binary patch literal 128242 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 +

An iPad cannot perform the wired provisioning step. Use a compatible diff --git a/tools/web/src/app/learn/waveshare-lcd-147b/page.tsx b/tools/web/src/app/learn/waveshare-lcd-147b/page.tsx index ff0ea956..bacd5c3d 100644 --- a/tools/web/src/app/learn/waveshare-lcd-147b/page.tsx +++ b/tools/web/src/app/learn/waveshare-lcd-147b/page.tsx @@ -2,7 +2,9 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import { TutorialCallout } from "@/components/tutorial-callout"; +import { TutorialBoardIdentityGallery } from "@/components/tutorial-board-identity-gallery"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; +import { WaveshareBoardPhoto } from "@/components/waveshare-board-photo"; import { examplesSnapshot } from "@/lib/tutorials"; import { pageMetadata } from "@/lib/site"; @@ -154,6 +156,13 @@ export default function WaveshareTutorial() { N16R8 carrier.

+ +
diff --git a/tools/web/src/app/page.tsx b/tools/web/src/app/page.tsx index b159eb77..be0901f6 100644 --- a/tools/web/src/app/page.tsx +++ b/tools/web/src/app/page.tsx @@ -21,6 +21,7 @@ import { import { WaveshareBoardPhoto } from "@/components/waveshare-board-photo"; import { firmwareVersionUsesFiveProfiles, + isOwnerConfirmedFirmwareRelease, releaseIncludesWaveshareLcd147b, } from "@/lib/firmware-release"; import { @@ -83,6 +84,7 @@ export default function HomePage() { const preview = localFirmwarePreviewSelectedAtBuild(); const firmwareRelease = preview ? null : firmwareReleaseSelectedAtBuild(); const publicBeta = firmwareRelease?.deployment === "public-beta"; + const ownerConfirmed = isOwnerConfirmedFirmwareRelease(firmwareRelease); const waveshareLcd147b = releaseIncludesWaveshareLcd147b(firmwareRelease); const qualifiedPublic = firmwareRelease !== null && waveshareLcd147b; const fiveProfileRelease = @@ -95,9 +97,11 @@ export default function HomePage() { ); const firmwareTargetGroupLabel = preview ? `Five exact v${preview.version} engineering targets` - : qualifiedPublic - ? "Qualified public firmware targets" - : "Public firmware availability"; + : ownerConfirmed + ? "Public firmware targets · v0.6.1" + : qualifiedPublic + ? "Qualified public firmware targets" + : "Public firmware availability"; const firmwareTargets = preview ? firmwareTargetsForLocalPreview(preview) : firmwareTargetsForRelease(firmwareRelease); @@ -107,13 +111,15 @@ export default function HomePage() { title: "Provision once", body: preview ? `Use USB once with the exact v${preview.version} engineering image for your target. The four ESP targets use Web Serial; Pico 2 W uses a verified UF2 download and BOOTSEL copy. Local preview bytes are unqualified.` - : publicBeta - ? `Use USB once to install the exact matching v${firmwareRelease.version} hardware-tested beta. Production Chrome install and interrupted-flash recovery passed on both exact profiles; complete release qualification continues.` - : qualifiedPublic - ? `Use USB once to install the exact matching qualified v${firmwareRelease.version} firmware.` - : firmwareRelease - ? `Check the protected candidate instructions before provisioning v${firmwareRelease.version}.` - : "Check firmware status before provisioning; the installer is currently unavailable.", + : ownerConfirmed + ? "Use USB once to install the exact matching v0.6.1 firmware. Four ESP profiles use Web Serial; Pico 2 W uses a verified UF2 download and BOOTSEL copy." + : publicBeta + ? `Use USB once to install the exact matching v${firmwareRelease.version} hardware-tested beta. Production Chrome install and interrupted-flash recovery passed on both exact profiles; complete release qualification continues.` + : qualifiedPublic + ? `Use USB once to install the exact matching qualified v${firmwareRelease.version} firmware.` + : firmwareRelease + ? `Check the protected candidate instructions before provisioning v${firmwareRelease.version}.` + : "Check firmware status before provisioning; the installer is currently unavailable.", }, ...workflowStepsAfterProvision, ]; @@ -141,6 +147,14 @@ export default function HomePage() { firmware targets. These bytes are not a public release or support claim. + ) : ownerConfirmed ? ( + <> + Public v{firmwareRelease?.version} firmware is available for + all five exact profiles: classic ESP32, generic ESP32-S3, + Waveshare ESP32-S3-LCD-1.47B, ESP32-C3, and Raspberry Pi Pico + 2 W. Publication follows the project owner's qualification + confirmation. + ) : publicBeta ? ( <> Public v{firmwareRelease.version} firmware is a @@ -297,6 +311,12 @@ export default function HomePage() { ))} +
+ + Explore all firmware features + + +
diff --git a/tools/web/src/app/support/page.tsx b/tools/web/src/app/support/page.tsx index f1de274f..3e6ff0b8 100644 --- a/tools/web/src/app/support/page.tsx +++ b/tools/web/src/app/support/page.tsx @@ -5,7 +5,10 @@ import Link from "next/link"; import { ExternalIcon, MailIcon } from "@/components/icons"; import { PageIntro } from "@/components/page-intro"; -import { releaseIncludesWaveshareLcd147b } from "@/lib/firmware-release"; +import { + isOwnerConfirmedFirmwareRelease, + releaseIncludesWaveshareLcd147b, +} from "@/lib/firmware-release"; import { firmwareReleaseSelectedAtBuild } from "@/lib/firmware-release-selection"; import { pageMetadata, siteConfig } from "@/lib/site"; @@ -51,6 +54,7 @@ function qualifiedProfileList(profileIds: readonly string[]) { export default function SupportPage() { const firmwareRelease = firmwareReleaseSelectedAtBuild(); const publicBeta = firmwareRelease?.deployment === "public-beta"; + const ownerConfirmed = isOwnerConfirmedFirmwareRelease(firmwareRelease); const candidate = firmwareRelease?.deployment === "candidate"; const qualifiedPublic = firmwareRelease !== null && @@ -83,7 +87,16 @@ export default function SupportPage() {

Check firmware status before installing

- {publicBeta ? ( + {ownerConfirmed ? ( + <> + Public v{firmwareRelease?.version} firmware is available + for all five exact profiles, following owner + qualification confirmation. Select your exact board on + the firmware page and back up its files before + installation. Only the Waveshare B-version image + includes the TFT runtime and fresh-install splash. + + ) : publicBeta ? ( <> The v{firmwareRelease.version} hardware-tested beta is available for the exact esp32-4mb and esp32-s3-n16r8 diff --git a/tools/web/src/components/flash-status.tsx b/tools/web/src/components/flash-status.tsx index 021c9ec8..cb17f6d8 100644 --- a/tools/web/src/components/flash-status.tsx +++ b/tools/web/src/components/flash-status.tsx @@ -10,6 +10,7 @@ import { verifyFirmwareProfile } from "@/lib/firmware-integrity"; import { hasExactFirmwareProfileDescriptors, isExactPublicBetaFirmwareRelease, + isOwnerConfirmedFirmwareRelease, releaseIncludesWaveshareLcd147b, firmwareVersionUsesFiveProfiles, type FirmwareProfileDescriptor, @@ -156,10 +157,14 @@ function policyFailure(release: FirmwareReleaseDescriptor | null | undefined) { if (!release) { return { heading: "Installer unavailable", - body: "Hardware validation is still required on every profile included in a v0.6.0-derived candidate before the public installer can be enabled.", + body: "No firmware release is selected for this build. Check the current public installer before provisioning.", }; } - if (release.deployment !== "public" && release.deployment !== "candidate") { + if ( + release.deployment !== "public" && + release.deployment !== "candidate" && + !isOwnerConfirmedFirmwareRelease(release) + ) { if ( release.deployment !== "public-beta" || !isExactPublicBetaFirmwareRelease(release) @@ -379,6 +384,7 @@ function FlashStatusForRelease({ : profileConsentItems; const everyConsent = requiredConsentItems.every(({ id }) => consents[id]); const candidate = activeRelease.deployment === "candidate"; + const ownerConfirmed = isOwnerConfirmedFirmwareRelease(activeRelease); const publicBeta = activeRelease.deployment === "public-beta"; function chooseProfile(profileId: FirmwareProfileId) { @@ -485,11 +491,13 @@ function FlashStatusForRelease({

Installer status

- {candidate - ? "Protected release candidate" - : publicBeta - ? "Hardware-tested firmware beta" - : "Qualified release"} + {ownerConfirmed + ? "Owner-qualified release" + : candidate + ? "Protected release candidate" + : publicBeta + ? "Hardware-tested firmware beta" + : "Qualified release"}

@@ -498,11 +506,21 @@ function FlashStatusForRelease({ role={candidate || publicBeta ? "status" : undefined} className="flash-status__message" > - {candidate - ? "Protected release candidate: hardware validation is pending." - : publicBeta - ? "Hardware-tested firmware beta: exact PyBLE v0.4.2 browser installation and interrupted-flash recovery passed on real esp32-4mb and esp32-s3-n16r8 hardware. Complete release qualification is still pending; this is not a qualified release." - : "Select and verify the exact module profile before installation."} + {ownerConfirmed ? ( + <> + Firmware v0.6.1 qualification was confirmed by the project owner.{" "} + + Publication record + + . Select and verify your exact board profile before installation. + + ) : candidate ? ( + "Protected release candidate: hardware validation is pending." + ) : publicBeta ? ( + "Hardware-tested firmware beta: exact PyBLE v0.4.2 browser installation and interrupted-flash recovery passed on real esp32-4mb and esp32-s3-n16r8 hardware. Complete release qualification is still pending; this is not a qualified release." + ) : ( + "Select and verify the exact module profile before installation." + )} {heterogeneous ? ( diff --git a/tools/web/src/components/site-footer.tsx b/tools/web/src/components/site-footer.tsx index 2c431eda..7aeafa2d 100644 --- a/tools/web/src/components/site-footer.tsx +++ b/tools/web/src/components/site-footer.tsx @@ -23,6 +23,7 @@ export function SiteFooter() { > GitHub + Features Learn Firmware Support diff --git a/tools/web/src/components/tutorial-app-capture.tsx b/tools/web/src/components/tutorial-app-capture.tsx new file mode 100644 index 00000000..c4af369a --- /dev/null +++ b/tools/web/src/components/tutorial-app-capture.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import Image from "next/image"; + +import { + tutorialAppCaptures, + type TutorialAppCaptureId, +} from "@/lib/tutorial-app-captures"; + +export function TutorialAppCapture({ + capture, +}: { + capture: TutorialAppCaptureId; +}) { + const record = tutorialAppCaptures[capture]; + + return ( +
+
+ {record.alt} +
+
+ +
%W{7bTNe3TF+NNb5Zk{fl~Y_jW^q2TF{mUUxrbzKpoqG2Ch~Ff{Zw zwT8IitoLQ@mwn0pIx#+8?Y@b*$t5K>(>gD?28zh>@2yq)!_a2oi?_U%MewS4n%6rJ_k_Cz<>Sir{k{BC7>E zf-8%KKiI$7Z$+3_8$q~L_J_z3tI-|4D)aeL2nCle0f7AG*jHmng$!!3sg?s;Z0v|wCM#y_%Zk`1B2|isi5j>I z>Djp}v=sot!9+f%>50c?lAZIbVw<)Anm(T}SqJ0UMe16gB0eHS zCDuyKmyyMXH2DK4h9Nb^{s=XQ zzttdPa`6Japb^{lrB@qYo7ptVeHFxy-eoScLBs-~?%aHCGK7gAbv`OQc;K~t-9{A< zk~a`@1|S=@OG;-5g(fHr+JHnizu9m#5`ZNqcGuO_L3p6w{F&iTZ!dh`+AqyY*}G_f zLM~ptD*5u|^?bvQ)zGr?xPx5;C;}qM;R*&d1+V8QxICehg%n}|K`-CN7H8h`I~zzZ z5Qi$?CIz=4oLLMO8@Ibp4*(_SCeRTOso3$QB^l^+18Dyia15x7KuB&5`V6n+RPL|@ zOW*{%Q{`YyUdzj7DsIJZA*iF|b!ofOIyt#~AK-N8k7=dHZ#@QX3KBe&DRU|uj-URm zkhZz%im`#Jrepxcn;KnRBoczs5B{JVuf-i0FN!CFQKb#M5+3gX4!KB0B@MnEmQX)3 z5)0+y3{8afAvg8Ohkl~EhOqoFI(z`?!q&OgU&2B++BYmuojNrRHJ^IMqv>gV2h0HQ zx|}25y*mNzXBUS@M$qLWkLjJijJP^%qEI*qg#QK)^xY#yDAi$ z7(@I}hnw_(2d(vvjnx7l3t&@^o&5Oq-<&#=DEt9^)&5Klfv>;6-hROXVw0FqTekq% zNy#jS`WOF&D_0%?hbMThXEn>z*|Ojc!oNeJYS4ux1K)oGYF<$)BujlcY2Uwpkf_8x zUMd6nz;!Tt66O0o7CL8v5z&EE0xeNk&D983Vu*f#oA4F@9Xmn3qnW|+Rc+J#7I#0z z^ywL$sy1Y&=|%SlR{@@Pb9a*x32iLfUW(xIIPN%Xjbr${hwU%~;?CiQYL$L(n|euE zS)L(=&Q60Cs@GpaLtpYl4vNw5ccO50KyP{?FQ1y4`u_D8Tb(Y~T5Gt+Kz0Xb4w2ae-*G z2=K!6fXaigT>x=SP+g6Lx#!MD3OV^NyNQ+xabUnEFqlqYH+}EJc%cUk>BC9?5qaDaWW=(7{$={a$glavb1>Ahd{qg@osJSqXj%dwb>wbMdG=uzYXW^ zS}ysG4t{@>(>FCfZZ)voZopewS~?xmo(Mvv0s;cS-km2W_t7vn%+K!9za_%zKU^*f z?Q(12))|H0L#J`8INxnnRy{~1AyMTrUwjVX+%Xs{4<)^4&tJ$wmn0B)9zK!;UJdIT z6nyS+nYGCooeEjFbHLtgf>MK(y)^?rf0)3SQ@%{asnsgH2V6q+^^r4hv%JCgDjdz< zrgK?KbRc~RK+)dTl)|3Dp1?BTn#cjXL{l3;$y;JLDmHRn5&O=^2g<~27m*q{fH+^{ zr48$SFR~rlT`zSmg~=3=-@mf}%4S+y%nu@QwB|q2E?ApA>=1qyV(=gKLn{211F}%c0wN+8cEpihd{ms@Si%ZV z2ovn{0e=MAoHNv~$_lkOIXMANM5&kSb3?1{EYQ;+I}X5CU?uo127kiMc4!~UU$V}0 zP*qc-hMO z0yd<(gke&MYBN`|n99hhdNtf{9Y7~W1%42s_C|opkUVNE>o5r5MBL8o*s1epX2BaK z<>sz`@4cs*JtJmr3SZF~D}h1mZa)+Q&<>azT-QE+{d&veU}%iTs(P=;32JdLMz>(8 z5X$Q>kxdgUu4U9?DcVyk&`#2wny(ywsQLV`AvX90XdO3svh_j zWwgZl&JJMXp5yYPN?N3dIWmN_^Tdf0KVCJQ31iok%qR-Im+pmN8@v|Fg3$*9OC$1H zFp9>SO}ilCb!*r$V%qHTq0e<%e}(9lLDoCf6g(%uiGL5FMp55tZcgu!jIQoA zAk*&d@3u%RLQTh4bH#ZFxSEbyO$kDfcLHdO5a3dYU97DyLV#Tdc5KLS*uwo+bZBTZ zj*yr_Q1{yH+qZkSNomFP-9>(nb;d^xQUMH(T$agqe!X!jP%{*B#ePJD*`lezjeH3Y zPwrj-8_#I3%c2`eYzDr&zM+Bp)$Xaqr6rI9yy;RA*BrvA>4SyD0jfbEhi{J~#g?tL zgp#lC_V-=DXi@Sw{j?vc2@I5jtV_?*lIbz}!u=O|*fH$2qwEQ*9|CC5Z(Xk^jUO*H zjGt;|iu-b=v?fB9k0GgzjM2R%Vy2 zA;^Qmqv|q)vbuT=0Geyy)mx@-BkSa%=r1%ShgcCYN(rPBi@NC(^yJYNH<%5Ye#g5k z|GX3a^>B=~mX@t<$Ijqg*W~9*1eYeKvg($<^_aj;BeYabPRv^Zm)p9L3hCzGxC{-+ ziBeJ(Rds@Ej0&WNbh&;wVzd_*7op(J2(;y*AS8 zzNT-;X4MB;G#kqNY-`j1hfyyUm8;h%or0|S-{tIQij&=~vO49M-24QjWgC6LLZf}!H;S&8!wmUjya@@p>8A<${AZ*50|FMgRzR&`Y3zDU%}S-C zg9Acjp%J%<0OLBfG9zd8gzIJE%RW0g1U$L#TR9J$CM0qMLc`QTT3lH8S){c2?_%6F z;+NlKD}INLopISlW9X3nmihgLAoFh7+|sX!Iv}aBm=u~9GB7e;xvWB^XvETzxUl?@EDY^C-n!V*y`#vkMBcK zc5dV4mt@)hv%&u^!h*ov;1t&V0Q>Cu^AkwJ2()+*)MNdd>guNQsN1r~71B^$iq+YXYx z)#>RrNZ9%dT?L5G)94Qtq<`yic=!JOqdlb3ZNlC}S^%Id&J*@IL zdYb;ccN=N|Hy;Pp_ci&z8iAj@wz2hUEurS2q@zZV2&nA&p^oDQ$do#ApS>S!wmhsM!Sd6oxY zmU01I|LAcvneso~8E~?@GN9%T^()166c|MvLxMma z9z_;OU(JXU|5W||IzIL64Mtp6yFV`m#S4NEcOC~^x6cG+7m5Y{(?IjrO1GYcUb(`i zlRKHW)IX38=ywCC;&^)AO}cSoZEdT3dLQCj2!TieTLV+*8xqpZuA1Yzv&wL^wcyYi z8yh>5KsI@m>6qPLACKH$>IiMigzRh(FBAsdoq;Np1filL1-~7$i3r+ZiC)ys7exI5 z0|a#RCO7wb*!LWG!lrA(9rEqK6+KOQwy9?Hm{->|;xGSys~#p06&?1&kdIlE4w$6^ zGXmx&z;J?Ng4P?iNCHWQ`Dgf_vxK(qi;IgxHhp}2b=9M_N6%U+cN+tzcI7hql^~O&`~L8)8xSmEL>B`mXFQOBK1W5F^^U6bi4IO-D^lm4$*dsO1gAyB~>PSpKq9$X=J6W`;3;Z-I-YK~5{=@};*| zCAw#Mc^Rx%34_-hflI~celgH8Xaw$`qW*u^;|Fj8&L|w3vZ;CqQ-+fZ3lv+paOqu|f9QKQy!nHK(G3 z*n2s1X4)JunGd?Yba^Pj#WD#p!?$_@eK~_ijL?oC;<##3e7KY%lZA41aj_XY+@qZ> zOeu}bT&96L1O)JVAS5|4A#e1A8AOxAV`Oj|VHXo1$ppIRF)k?9zCTAN3xjfi!8T2P z`KgkUmttadUuXEYpHE&T={-%)q2b8P{B|wpTKwS;{{PykdO5g?u;-%*7!@j)iZ{?V$8S0EilnjlHamMtLBW@;t?sr6ep*X< z4Bd=sT6Z4MJ2y7ZX0{4bX;%~#g|%xI0hQT2|3$KKN>b8Wy{6!hm>A$=-+ad6YItq- z@NjdpMdusvZYC!$ffjacM3`NO==v`ze|$+?v7K+bm+xy%38_em{H8Gtk7 zj-B1JFg7+AdA|*Xg^86FIVUv=Z~%XL`a)pVTeCg{_@CH^ifpi2j@!&@&cIycqiTJz z0$Rxe0;-qK3>le0Wq2${U5`ZnF=U}MGy#e!UlS{lo+7>|6{r*cP|BbbcPrU025$|; zL26G}pd!@itG`N-S=-u+6>BM@-C~78YIbPD=%!5yKkRy}I~_Rb@kgeA%e=+QOI9&j zU9>U;Sl6u^HzvzM*IzL(q{Ik^2Fa)0C z9f5g7X&Og1IkmpE^)UUpMyn-HB;qyBc)$Jn^{YekmO5oIsUp4uuaxvx#vpb z43Cx<76!8HG&J}M3XCYkT==B?{JuS(BWxp7BKqMlq7fApot~YY3OoMs<421*^#hxkN!>bh=T8OTAP+P46Ix79u{ly70iv5#aReCxGe(?U&#r&(7!SJ#2b7wWLQG zH`?A#0(byHOiYIwYHDr??v|>L^LOZXz|yt;z<{w=V%QZed;i5*9-_Awia%-*=c!k%Tn_CCtoS@yBu1Gz z=I~)~|7!Y+zYWR!O(awROoPDDQ2=<(WwE`!7jHh1k}m?T5j;&^psX&tqWJH8ydW!kw9I-&_Lf_;(tc#x zGmz`d-!_?pp#Toev4qORH*eq8iYqr7D_O!h_~T$?e-c873zU?nL6R$vjAgQU%jAJUp{ncsBhvbC1S zP}Tb*KN9PnrlTdUSxeh&FjN7XG8Z@U`*(hj%I({PRyGc+Ya$`d4+n3z(hNhv0%~L0 zJh5xPxJ(r`WAX*!p}ZE>#dTJ26YDyvRWg)#GXYX6N|*6>`x!Q zYqYdvFyGGlTJrU6?Ie_>TW@ouCwwjVoVUTk^4%mo%k*n{>B%e=rUxZwukQ!7EJ_B& zuvNGBeCm3jmp$NsrE5t{Ob@-+{Brl8to5H>(toeQmJ~E$mE&uOW3^HJKN2D1-Z6?M z$BI^c)NuFsW&>rOldRCY(lLwr&sB{r~A5GP%AQKhl@S&hUDHfL{pSIM?PffL$g|Zwh9}w2r4TWm1 z=IkBSts@?r$dNv)dmh6g6lF)RJZNRfth8EgSx{pF+Lznzkxp)K=xvux&)#v{$ll2zzoVP^9Bh`%-3 zDEYnQ)av4&rM*7|i~YHrtp)>K1@{S#yNyW6oqv$Q`@>v;NZR|Yl-|@c9lJW)H8r|B z_U-2s0&82l9Y&)vhiy7$RWDvym8qI1h{l{Fx(RFgqRjv6chaNu=aUn&51)KwI)+kAO<9i=fW*`kB&=wZRn`RbdpGVNg(G}U3;Gb~Vld#uI?vs%X;QEb* zm|ot~zSTh7>c{Zkr}>b6e0CPey(~&?qB$Z+(NM5?_s$L%nUawqQ(R=s7mMiWxEZPjNiPlQLzCEY*rJR3J1ZpX4r zU;Lw~eVuI!-hm{Kb_!Bbr*F=kRt@aBA=UMJ8;*|d2Q_MRHiq4%#l8Lawfm^%NibOu z*SptKWQ^Hlidhi^Qba{~FH1CWo?ZK<(#K){i!ZNTpHQl?5%2tkDKC-aFXo)#?{vi7 zZ3q{yr4x`}9?W`acfY#xI;)6R)yG`Rw2XY1;_@k76wNmiTv_Suw^$a4@TzH&5c|k= zTvLi==s~ScMs~lq_x|vy&Yb6Q&s-^!t3KCdM3>i@rqu|0No9TMnSQB1YR+lPU+s4_ zn8-tqD{R{9Q&AB|J)taFNaCHe;uIbCbE;tp>cT@Y z7r%%2mDIdFKQZ6Fn5z_fAI@+s8+)7L3}w^@VJo&LR+-<2mB%*zEX=g^+S}(J4csYU zsHGdWv*9^0M^;qqXXAGkJi5l(Sbua_wBhp|4-O@(U+n(mfgog1T6#hDIr{#1sZzF{ zU!2#VIbA^^nP(Y;N1T3vmsjkoBhgPt^sj`N!xXbEJenJ?62*M>HC=_6E}xxB!GUDy zW0qCMr!3oG6{}3glAdacHQVjSIz8q^RVmpQa3@wKMFPbi*|zNM=`WFUyvL%pQjcif zY%_N@!r3MygEfpt>!S%3!fL)8?e;nB>&Oom6UI7ct9Al+ka!(fmmk=@VU;6tSlGgQ zUho3p>UzIMFu(t^_Q%gd8$1kOs%SGP=kWa#fbeXtob^%ZUg|l&K5|VCkwk zk6sx$tp;YPGA=KxP#%}2O^Otv-u zdWbGOCSXaNUm*U>xu4jDi=%q#X`^DvFRu`rCKA_V?D`(OAbg zIRF3^xYoi2J&vw%0EBXKb;XIITDW%2gr(9Q#-Kn9uUb~gcFTjCfJXbSm0x!O0(MDB zNwhXH7c4IhUj_t9TbJoC?83I{+Kp*b&_V$qgpw|Nv0@feiA~WZKP%BzprC|k&8g>K zXDXIcbjs&mN7V4vd&{1bF09V%KE2H?moQ7WRG-%Po%*k0K6?YL`0hMsy|IO?xTKht zvT{%p^JsAkw|nBLXFP7SD9toG_`doAhUSblU?5>9GqDh9{>1{GtN)& z1|URQbMF8L9JFE^nOFiw_!5P?Lp^nXkU=Z@>&J^_kK4DUAQ$f$Xy za9(ci)5dm61jxgKk15Keg?67ayh%n?m2gOK9ork)7-f>A!BJc`6co!9TYS}Hrbh=- z0!Y5iQhBRPRJa|adiU*DTwmbh+U1#)z}iT^vJLSC7OnVfdE$)wm?Z>rC)aWXot`&x zRuAA{82?Z=*n|lpfjjTg33hg(EnS9plIrxAOAJ#j#x_e=l4`M3E>6q~OFC0tQ4KDu z;8t+Xr+0f>n@KSKXBn|@4`v}T95%1-e{h{yP<*ku;pZRn{oA)I33AdJ&OFc~K|qRd z*QxUZ6|O;QN{R#}_gg3m4WU3OcD|R{5l?rAg90Dl70N&7JxsBTfayDsW=05Azqj}Y z`2oux(?nBHRsDuxvY=S0oJEf!~vuAimE;7mYd-g z)|n51tkVkJRjo5d~QN2r6NyRhfTI4U?hmM;5wtc{(5A$uI(@?^eJ76ONNG<}{6WyU<7 z)te0~Y^W5~b$(44Yc(PQrKqX+2tv=5$f#%>?8A}t$qRoht*zVd-YK?LxY6-BtfWD` z2@47AO-C#RVD<&$yr9rf`A{SeK-vIKf*EGudA*yO3L~076&e(z540Y@M2=Y-RndCi zV#rs^lHjvH>s2S=;^I=-2dfUloU!@z$*ooa5uD z)pv~%aj8Edi_^@?woFsap=TUH?N{G=tSe$QR5tY>EZN;<=0I-=*~LUzhp)>rZO?q3=)!(H`v+3pfwo~5SUd^APPlk zUtgrBC*mf}O(3N|%2FA5Yg9!MDfR9BT|)pJ&;U*#tQ-0uWQcoXV}ejNUL+y+Dhg~N zRHo~4Bn@_B{T;mBv^5?csS+pRK+bnwLD55WSIp6&w6Up+NH!<2d&A|csJX9X7kLon z&Pz3h%cp)-C@Sg7Te_?IzIp~vLOO@Y5ygbB>Kdjy{C3i~XfJ!dsD4G)n9_rq3+`VN zH&^Dqw?Hc;`bdgulj}GE+J@i_%mmt{`^FAm=U(i(^7E=zqnyzH9 zvORWXh-jyv%QU4dm*_)1$Cb0V!dO%@B&xQIsh2$`5X9FgAN^kr~G{$t7F8 zCms;-Ko=~V*#!iOzg#+m@8Q&{Z;g$YsHw+i#|MXp9S@KQpzj(t&ot@m2g9%n2aiDi zizk*_MG$m>62|BO-bH(h&fZK1AR>oHN3M1w+jh17ae+-wGg7kiJ8(O&B0jr{={c16 zSk#UUSCCz1@H@MUbDaq}V!XQ6uO3)9|k69de1);D8_j? zdY4SL_kQuj%53+f^&*!0YFQ;uG8ZY2a|LPj@yl!Eu^jQug#-teC|6OIe)}25%o^ph z92`2lp)owOkjrTbkrRl^8Aa%`y|*q@pI$@vPp-XgujWp9)|b$d8OI^`$9jaDTE^IF z{4qOw@t$p>o(!;VZ7!W5+(@g)@ZZSBBcf`luhR`y%1SFy1%(MR5_;Fz`xOqgw)Qsq z`brFqJ%K-%ax)6ku5yt)+QLiS{`Pv|v>tkWM!kD_(I_spl!D^jYmm&$QfBO>}HS63S*W~}4v`2{HQM1LF{RA{*7A%AY#&}=T3k1dW- z60;V-URa@St~1<&koDO2B^Q}&|Ix*nHDS9qR+Zz%GZBiaJz}c%mr{Mc+r6);PKd3i zKWlAQSU;vKYir9S9)1n?ZT$Hl&Ay+7$E&My+E3X+`F_xJIxQs>*C>_7IuLk&;RzA_@#%AuU zklVf;vb(Ggt?+-l5158#UB%tKy(5{M3<90sm>XN_z29D!XaC&7!jrZaER(Eoo@z7#t*8r(a>2_tv()Xak?wfYzN%JckFS%u^!)q8NP9qpf(nmsMZbWP_C^xvYP>t6ee z*2y}CtRCIF1G#Ss&7oh&I@*qkM#Rl;K|g8e>M0Ryfiq{emS0d76Y5b<%(DbHFn{mr z`WZHl`vthAFp_zKQF^$$8zLCFi^26Iga_vYG69sW^49J+5RD*+((^LL0@Q=&!=IV4 zTh#AVKe=OG8GB(WYHDst!c2-3<`*y>w%!sumc1rMlEM_LRjh*Igu2@=u*gg02lLb6 zD2uX6z2kKM+T+=Rcy9OCimBq;<%cldc0*lkoO) zcw+bj2=9N*xc{lKyDZ3puR^Ut_)Z=20`xDfiv z&l#{i9^2a3vAgkOT$E9~c=PLt>nku^*;p4P-rPLd-DJui@9zEzk}xpz$ARt|%nsO% zw@5MgEdX=J*~i7k{<55PTpp{A%gw!`J*o{PDQ?HsYA@)+N?XE!pAzGUTjVb2yg>_Y z4#u4ygkK&`Huleg2pC543Fb%1xw%2moctb2-T}zF$?2YQ5^)%SVPG*BvkEzj#x%)r zQX%)31rfFM&#Df_DnV~$@3Z@_F5j2%KmN_Fd;Hs}nYk_U=}A+=R<%Yj{|*#sZJpWtZ!xoBmf5FTN3jzm^C#|SzhsI90$O$EdDe%_xuS$lF+P{QSEYkQCN zu$F||Ws3pOTX2-v>?~^lm8KcQft(ML075cSMuY^}oNoX&#Smipjf#xhZ(hItWuIq7 zg$zRk2H_11f&Dr_V}U>o)6#EzR<)ukD!oQ6wt!fc7dW~sD=UjAtE?;n&67KO=8~Z; z<2}3t=gy%Ei9|$2^O8`24zqGUAa5Z|(hT;k#D{9fcsFqrrrtlNSal;d|J6e11Ei=S z#~8OVBa>0IKe<1)w7f=1wym$_aN&wYoo)5lgQ1uwX9KeDO4|p-H4IFCl1Un%7NSPV zoj}<0mo%J;;pcpn=3Y0Dl}?|%P);m(=m~Sw^B1V<=9ApoeYVL5iVZG7MhmA%EON}{ zT+v|nemt`naM5%K9K^TH&nLN3 zA%Lcc5eWb`CJ@dIr+tERb&8@Qnbg>!0UaP6fD!n~cdFlM=UeOS?FD_!Q_{7%&85u4 zj}MN9bZ>je_h3ZiFeHCaVIlCOK3act_;Xu`eRC%U0i)fGA!^+scceuNCui>X7Y`6! zgO=uRwGv)C2_nruI^qqgsLp|xqO>m5_wPnVB?l&eZ9Twdn8FX>svkY;pdY)p}hi#*G4L50#D3r zT}c!B0a&phR;gA2fr+heV$xV79pB{fx-+-HT*BO9fFT!>xU?9Ew@>EZzEWSQeWiKxNu~%;7XJ;4_il;96G#3#}vRINP*XcHh?E~;d z{-ybKsr1)d_inxCU1)RsS-9fj^=_K}+-=5pJRRQWr$k^Tu@Yr(@^}qq75%j&tQxJ! z_4BS==we><_^i?Fn^V4Q28X9WJpsc4b>8rmXSW#Q03j}zkC}jCkpN>)Owc_9Oxtg9 za}Uf_0@@5XN+4E(0mlOv+Wx(`LDydNX(%<#Yt9i6EZgk4rV_EvgZvQ!Bed~-d~Cs* z&)M1e1@^x`kB?6VIZFqGS+1bZ8+cH&*w~hwa-B+HwjE7uzfTm4Ge z1e&c`?-JoT+Pt#RDuo1PjOp3^`-!z@c-{6n0D-$|Es!vF@pMQ~&`c_^(37r|hq*e) zD45D108}(F=DIrD^@yruM`kU%bZiBL?DwF2~t!S|9Z0gxagDK>h8lJLWy0&`m@#_WX zpad*zvXmnrJ8wyth$a;8?udA+u5jxLa4FThyafhPxw<_DtSf=v#2VLqQO94T zS!={V>G)A7V4HSsN@J_`Va9;074gShfX8mVpD#+=8co#<9o{glYg#_%qwY6E7N(U4~4+M74Mgf#yQa35XAB_-5u+aZ_eD4KrIgoUF9?q529XPC!oL0vnW_Yz zj*QK-1bYo_JH)j3b`f5Oy$cR~Paq6sA`OONeUqIgBEm4#5LKINjsrmWa;c{B1o1le z^g^j#C@3usczubuqjTl4rONJu!3)BWw?H_vn~hew-5dOw<)x~2Kyj)M@-^C4i& zg0dh12uG1y)d&M+RDaPKWNJJU)-gO21jRdg-a z()lla-@1GFVWOHKXL4lOpR0k07jTW@I@!tCPRD}4i8*}Mlks_6cXBDp{{GbP`TcT4 zQoEk8$63BVEXD0u={39dRcGY=o$8iNsHer=>>>0s)v)y0NF}9N>1sb}GD-npCHZ8x z=;V=&3g)-rf$8N=Z=>>w(mZ0DFm@%tV&}F#Pyz)N(b1noka-DQB z_yKiWwKXLTIEH!Rf}>aJdajWeZ4}_joF*UuycZ@6T3fS8rB^+={eR2hO&M>o<_cjr z>YUg7bzA7GydTr2U9r1+7Wa*>=IaHQ>UW0Ea4$#qO5vWl(Qtnb&T@~jJz|XhEHKRC zd2I2*n;IZx!JIeKjloDf51OwukE=}YwnVdr6x<0{i+`mqL!2uHyu`sG~fYrjpC0IEa<&yEX7y)1Rqy3+wj^h~D z7Z@-52LwF0>E2M!c2eO4@S+B3i6vtB>2#0?%+R)53+W<^xWM1>)_xuSJz5% z_7fgwP%l;0b)baWP8jFrx}D>cjM{DQZ}&&ZN(DH(L*Gky@R+_iUp;qxKm`OHr{Bn? zzZT+FHq^dR9^fYr{}xMn$`K1YY+-Y6nZb#u8%Jm46jWM`a&yz@X>m!71Qox3cI|&I z5Y_Z8)p6%x^w~t5A2ZQY86SGcZ#n(hvBUg9gMG%@G}cxNZin#yjQ8NjF8;b!%SFzW zO6wF)PdnbQdvT5lrmS^O*&+UjJ$_4HC!ENjp#5{VWSg0SJN9{z4A#d!#u07G zGiTSY({^6+7P%40{Kq}RysP&`qMS`93)s4sL zPSWcz&DS{9(3V+Jaaqj|X=ub7y3>OuTo#Cj5t+c`4M8Ut?k3^?8p7Vlm7%juYL-`j zJ@4WLcQR#-d_XA3Gr$GPA@y5s&|M`LbRvpM9xmaRN_Hv$#0B zMA$Qy`5{sEv6`+`l;KSq9_dPR|Bcx?Un=89>p7M%;cPF}6gPCgO zTJL<+qMbmTZAyu%yrc#tV>&i9wR+ZnUqsiu9hVakJ3b7_2&c>QCj*FheURH%q1BKe zSCCa6xjb?fZ36ucj={>bCi!;(Q!6Sjt@zct!TRN=3s##5=^VPs~dzQ%W$MY(GtxGM3n%^QpOXtt* zhsBsPPUCnbz@bIe?0!BA%PCyki{zI8a3-4cV=YU0F}~@C{?^BL@NJ#lLkhu{Z(!U3 z`iIt|uVcu9eVDb7&{M1To2=jeg@u1WMDd}lY@h;2Igkt|yiG``{~1m{sU2#Nu4IG8 z>uh@)o%G03S8$t<5CL+EKzNcAd(3Tj-UNzsIwrn2kVgbR5jZ~J-7bt5P!kdDsNaeM zWO-|@c|c$wOqaz05eD2eZSy+-lR;IJMM~j{PnV2aTAlj6k4HFEq|&!Dww}<18^vh$ z9VJeim7Il{?8PZ;o>(;rU4#3#{ckA!gq`^JuJLP8Vu)<`_WiLCWTDy5&A4spyhf^C z(#evB1_lPIs8B!UAOoIyacSw!UR6{?6u_B+l9H(FeTaZCFj=wlx>^x__TnZpA-*d< zC^C5i_CbfdV~6gEkC!j8qJjcODDtkN;`>*SHxW_t%7BTUmy)uDyOM&!kGgtaU^Rhc z2Q+40HD@z|`4lMe7<4!IS5t|h7Pmm`((C(oyu-so(A*Ty?17hqw(l9RFQ>V~u~ng# zu9S{lJsE3RgnT`J9W(zX+u2#NB{VrucOh_@F1L3P+^VRIyMzdqCHvQsZn$#$@>!*@ zaEqvzHskJYxpy&<-+o@Azb0Uo2A_Ba(yNiddy0$)$Hj>yB~cF^Ynv0u$;o}*Nzix1dO)n20VL5W4*imW9oZIm_LyE+|TIKJ=M^ z#3{p$gTn}?^F=v}RLb|0>s>T6v$Nrm39>Iv(sbKQ#KrM~g9&MG-Fo-_yp;|W33=mp z**G5e!&AvOZlJbtFY=X;F%La2tsWyt#9>H(2Ku%Wb5E|#eaugG)32P=E*!2V@zF*l zxEe~NhdKWX^EwK}jD(bwSeBBarkYQe{#&WVR28%PXJ<>!G{ne`uI+)N5SBvDii?0A zT~FIEjh4r1Of5z)cQ_NJ2|9XGkpTnmNuRG%-1+&okWD&twUp#O0)Q=d$$|m?6Azto zh7Wq$@%P)t)D$)RmC|q0@eUc6=j5ELsTl)2SZrKu!E>FVL7hrcB`f?6VN4IT2cqoP zrn->N0WJ`H0j^uDJ79b6Ibwa&9z7}h+P}y->iZaKSe5b5o!Vu=7SPD=&U^fh?20u4ZvP<+`o?n2v<36a~WUT7;-{9 zcJ`tltPCGYR0H7(yI@^b-Zi(Em1U06hyhR_OhT+}?Qa22^!@tEG>;k_X$_JSDtAg0ALN-IM>gk{bZHf z!28ZDQLU%6o&O-{cn5knPoEkoYHQ2ns09WV-J3KpX!!O`6OH`%@%<4#(c_-cPqy?3 zMa6djW&cr8b?+HgT9P2(!%awmBy{CUBsgzR0l*~9|57fx7u6U3E=Ka9(Jk@R3yzM& zPYhG?g!>bF!{u^lXqZ5L6EuZ60K|HMl=Q(95fR_baz{>9k{(kIsF8s!-O~ds-bN6K z95#^=s!ai`cK~->oV1P(B}{4o6a+Niq1oDd2U|)NN*H`|`_rdN%36WCne}aL!Rv)= zP={OW7^#QuMnpVSQVQd1pD3T3{MtpT9j^mx^d>i3;pmbDEA7X=1Y%mpQf-(_R6HfK zC^}?RMInjpO`wi+vcVuIeFt8oqCii01DB)}ud*dSguhXfb&6J~iq?_O@E|B&)TO8L0r2*;* zqwrI>c+ksj7%VCW!sk7s4cg@uX4|uIvc@1F_z2Y9kB{8}iKovV!@$V+q+`qtbiQs1 z9v_}>G!Bm$b=_6e(C`N~=BH0LKs{70K`?mm>5|fMU_ij2$NF`U1$*P`ZD3{f$jIml zeNQ*TZDym9fxDC;Gv&`()L_;kDJcg*4}z0D3xfaf^Q(DSS1{$f&CDd4*<)zsQPXHG z`Rq0VmP11GL1Og*K$mHGZl5U{Y#1DQDVLEBbG#pK>%91M@JE?jygz@oRmh3}?KP0# z$`kO2i;Dw_8GUTO+Shs!n-?!KfK#iyxY*m<&+alKq2L)EO%I|^H-u_E0en6+eK0vU z=aUl{XiuIXixvUeZGBVIyY+RpxG(n4G(e|*Mi-q+8|4Sgk^my)6s6XDl%h3_w&WmMcHci?o{AEw+uVn@j+72s&&jr?&doenD zJnmDcASee>-m4hxoD~olv)807cXoyu^aaxM`Z}vxq`?LtIT$<)d=*H zzP|e^s_GzlkD6?`iVE8&!|lYu!Eq7z51?ZT2E^-iiZgTSFzyR&diu4m@7?jwx;Lks z?QiE92Zt2x&HG+eynUsNoA3Np$_Gv#xfs)15&xXBaNaJ!!O_}Oto%Mm9GI}?*yaE} z&&thB^8gPLlGZh}BWE;}i0@)z9=>?tfq;|P)Z83I&wYAd;pC!s0t1bXe1hZKf6%h9 zw8y%Gr~+^UF%nv4GZN3&deR!iAXL3d4i{~D&OrtPtX+YM{b`9PUgsAum&;6kODg=H`^*+z7w*b=$~iXXcnx8DD#0 z^F^KjsT7Y$;*&cOM(VrY0?w^VSFZUZ9p`x!?2&g!J{KX2dM6*!oCC=&QrYKB(~TkD zIyz9A{W!iv82K;&0la#JRaRCuF*PM(A94|M zRCTa=Rs`g?dCI4>nZJEYr={Jh|F+lNsHy&+NX83&Do9uD4|a8_+M%DU$EfswIojzi zj3#QPT)LLfb9-KBiow#rhyp~+YzjOM7N_Ac>6n_Bc<}P2P@G-LO&UpQ`OFW6on-HK z0K7|SNhZB6g;iEwuBfW&t(H@Qx3O{VES7|fMB%lHL69*l>{NgtZI>;xw}%U)hkg26 zC2gw(m z0fC(*xpu=94@FOnER~BSBrVCJzPDLGRuNH6Z}jZaRk9(LPE9Q>X<6C(V46@`XYD;Z z>jyqOkk=05TJm*|7C%Wz9a^b`u|MR%O-Bb>IMw>s^7%OFd;l$?tfo80a(*<%g`JE5 zV|Fq1Bt~gMMEKP!$dr@2Oxfbv7%m4`&Zjd$ltW0Yj*n-sfF;ZArXz@P_=BLAx0Iji z=5kwy*|*E|l({Q6K+u2=2xtXA;6WEqtl@flMrvuJ?s}+jyWe zIr`Sc%553&cnO)HLP{mmQcVytJgFmwzog4+q;CwY-o$dc% zt@CddHT4?k^D;7qy~4p8I5h{(mtAN(=o5h)-{2~jS>#zTT^24t;st_Ji}N0C|6X@6 z$@WdIxQ-4BT`jFZrL3>R=w=!RPEJnf?>;GN9j*8J@Z|vn3dH*E=JQ=X_yVIN3`gVB zr>>#6Eu{!4TsV|de>RK$0D8&Pp2NWqavr8oCpXDu3CJ;hH$_b@HaNg@L=VcMgice@%JOkt;7`L)Dh$wEDQhI7{C53}ax>#8s%u69xwZ zJGJHQIAu_;fJ^U>oUmx=q%t6X1Z(DfAFe#wn?g@}DP@6eS+=shBi#H)P`7}I(39aE zN??&qt<47_qe=4fS>P^g4gC~!>6Ks;1#N$PbmbM56N8u6gtu5W{a2w?)GFP?NFW;&EP97}YGV&$CZw(O6 zr7bY3qJ^cpVCRCMpx|58SppAC$s82gwOTOsCJm3nA@{di>F`28BMkEL!f|&$7LY<( z=0mWienIil=_ku{vsBGUEXu1kiG!;iHcOSw@g?S73~UWZ+!{M#^7n zjdJa5%fjWmU5caxE>ICED!|@$(YpghX>02pOn&=5tlRQLcO(&}BU(L6lI5_vKn5d? zh~fdf%eQa0piI~KIh&Bv*3^WRF5=&0zO-FjUoUFoi0p0pc%gm>x6Ab+KmLDn?54;z zwtr%@L>5D|Lztjmti zEnPeNU!9ZFHq5tn<*~hAhd+A;;{#Xvit_STBEm*SCH8U?EzkJ5wn{UjO)A$2e0Bt+ ztE;QnVuE`6`$`VlN4yUY;^Xd>r+7`~5V{BeqYc{3a=Sh&XYn85zsAAI%7SUrJ@Ud< zo>Aq<`DTbJHlv}$#E#Nb%}k8@Dw-BpO##i>x~xT&apW*%28J~e@%P+ib68UvnY3lG z&R7qyfO$wtfQM%LMFLt%-=DH;`o!0Y+h>E0U~_v$G4!{$s&+OuXyFVxJ~{w?3Ij7U zdgrG*a5ii7ilQorhVZL(@jVkNj?U3Re~5zrB`GK{iU*U9&61q0(9ADEa}^qo^2ROR z9x4tFNmknp7VKqd52;Qxc5-3>CbI2UE~F{jwO}Yt+Y{T+iod&2fA>RvpSpg}Tj6+x zLJd+45Wh#VAd8A<*l$wgl}0LOc3WWk!*~hiieb^wCOUIYmLIpe!@4)ou{Zemd}D1l zBlJk02Kb%Ezkam!OeSNqMN3ojM+fVI-7(G0uZr0!V69M!PIl#`!Kv1jmyxjxb(zzB zc7WK!-Gf7VvajykspxP$1^vyb*TDL^H+(UOn2@M^wGq7Os4==T5fR04zPdSmpR7*Y zvvZ#3J8NDrm}H2M>XuK+SR6SVv4C3+Od_FJThTDbNU1!!sS%!caVC86&QQqG%Vj7k zYdv4N=5~Lxn{H=hed<}J+#m(3cxo3f#a_D4y|t}RBi=6i3VAbePo@ z^sm#UzKaj^sYaP{z?q$@k_8-}#Y+~|US4hmNG}$a`A4L{K>#kUO@Hp_z{=zh-%Sj8 z=g&tm6)PELc3_LWuAtiD%EQ1qTPi_(HSC z@ANtPd-vi%T4sry4B}lXFcN*fi&Icgbeffzmhhta+o4{9`L-fRT!8`xY{f?fDDcMU zPQM-s`YWRN#B*PgMS)4?U0BEl)fSWqX`erzg%uWd@nXK_DDJ6Kk6TbzW!3^dZ=3}& zcM%wu0PgAQ*P8%fy?eKda%Zl#AA%!|53ylm{wNU5l0aYz*le9wMD4}(_c%#SWZAVy9vP72#f zpYwkBkb7YG9)tbF{g!hN;LV}QG!rlITtN4WjE&71NK-M?u*;+asTOwW6wpCTqoub! zY&Zi7Dl(=Lr+0{e@7SlTsR?=4x#Szk@K|YRXh7KwT;=^hDGO>ewm1y866&>^Uui&7 zsxW^XQuBNPS2i*N0J{{s{!9)IEnuyQK4a=wq75k)Py;7n#0&-U)aVv}Zy!Zj}gw} zU;9q?OcfZn?YA#qy^`EjjL<~@v<%$SgvE{NAjO3__|(#p6bPvR1qi%Y9C%L*43gnc z1HY<=1_n2PfTylb5_Ygl&&fHLy)B53IVN%O!lIMpO(jsSLBw*h>7TN?$RzAQJotgs z*-v-xy3PFp4WpUTrf}FCDaFM>;QT~EK?!Y5x*9zpwDbiOZyv;w5pA6&h;M1dm#gbM zp^F15HCutfi)zGA(La~ptr_X#I70sE2gAbg4 zSxa3i$b9TB=q z@TOL{+_X2%&5R&#d+hEGO{blhv3Uz^op@7IQveEYOS#~szA|i-*X`qHN_5`mxi^*3 zH!$!91dn%i9F(rXBs+N2W87;CO~QKRpMCA88<~*sZNn;B>fy*&C~TgitiF63j5#yi zXut0WRgNtkjTh8}7cXC?y>i>q>_ycsrL1fl0F=hq_9FjM@&D(wK>G1I02)j+HQ|0m zG=nCxGvt%%_9`J`bpIVoL`D#D>6yx)!l2=do2Z-k#v5=@@B*iG6IWGKQDf&Z@F&ca z>i+@|$V`0x8~}YR5m6CR?(4YTN;e{JzA3u_vsrK*@&k5#$GqzQ%+I(y{V#prUD!Q3 zpzo^n2vbQbyY=Je*shavc``E!AdUNWf<{FDJz{}(RgaWP|Ju@0{Tpcauj}}IMftoY z^p8L-bl;BZ)_)Uu;jgD+*SD&Egs=H&n9lHk?y&v;|MbgNbz-fp{I@jtQ%@DAGd zq|&iH^yI%S@UE-5aSxOJL`s=+>8g%Nz_>iYU-_varv`2N>J$-7NM6K=uhpj)y< zPDb{liAQ>W@t%r0O2T^_0fj?Tov82KxK^cWQ?W`6Us`$ysj1~7n$aZRqyFddGBW^c z|JmXqft+j!3tyyrY-CPtW=2M7<09ZX3T-y8hENK%0~RMMH@nK75k)Xux~30(n1$_q z!MHXU9m6P^8Kj>+u}FjZ8}z+s8bg{TZ|0wa2ZfFE()xv670Yn+U+?NN3i^^Q04)Vq z##I|l;CTWBYWNUo-6fLs^q*VpNA)o$XS$&&9JE}|lalJ|>WPx8s9uT3K59`c@cZ?q-!AtI*O1 zmSM`_nwmrikI70|`HQ7}N=KL#w$K86?O(fmCmA*Qi9xLdS6MagzzYBShIffJh9qIk z4t}|p3a`{6@&2D744nAPPi=a>)UatXAPm z?{J1bXIz2vdI1+en}F5P@^hLV#I!1Iv|P6&l9MCi=Gp7&?UfNV1X9mpww3aKT#e+F z86&}D)(3Qm*o`(Ka%!rfZHz}uIg;tyk5PO5>+%--AuxfI&LRm3N{}u4Lt~vSqIwMR znD>!3A(u#jWxA5{G+yGBPF9wypnT@;{wkhhs>0UZ{s#9C(4Gy=ao3e3{=EtP&H(`7 zb|gmE`mgLSPE+{Rt?OiD7rR$0>wQCEXorc;ki6i106~L|!Gr``A1)8< zD&|JrqF3-L6db^KC}2$u23QZ2p4^0{kOXdFSUVW@d4rhOwE$_U0gnm9{Sq4j2}w)j5r}7~Fy4aS2PLQoso_^qNSNW6 z%MfJM)fJm+!U;XM{w@`gEV!qVFSRKY-@OB)!Y~*B_FD{tImy$Jk&)bIV6`O-NHBO0 z=q4tsWMi_hU;dXOBBSp*Y>_HF5PN#6#=T}{oo|WXoS)z3H)Tpq#?Yg^?4$>nHZ^SHXj~u|m z(X|qNIOgW+yt+64cz;_73Xay>xsswRh`2v@1zYt=+1z= zam4q3NG@eT8FR+Pd3jLPJM1B$%xns$pR&tGIzN8Qke3HS0nH!t^XgEI_>r2jVouzQ z=>bJ1wxEnbdy{{JE&*B%FJ8RB_?WgXDE3oz`TL()9w^74wGWRD0R~mLmjF2@bk7?= z>ZSgWkeai1=z#poa7*9{pM#Q`nsLuPiZ4xr{+Q69ry%j-gcbp&aFAU6`t^&=a{2w{ zUO=eH?pQS>#87MnB9SCDf`Xt{r=X>bnJQtu!h(NjeHYfUuEQc~;^gYAsHrI>Yr!lm{OSd^zhD!)e8ns-acb9_I{gP$Y@k)sMNR<(b2EnDnf=1# zg{Q{G8}*s)KYm=G;X4clD=|=${|ZEbYoPyu!K=eMt^wIxj24#A+MzFOM5=jUDF)4Z z?DIc-_z>e|Jz4J)4pREACvF&9)b+8-wMo}Hp^G~4Iv5vAFMML-y05qx;2p~SgiNY$ z5BYxj;M=Yf->5z4`Md8IC1G0+M+{>GK$)3?F<3M(x!hb@(_2|j_r^VU6{8)A5wEq3 zdqJLVl#Y?gWY#JzG@ZN&t|Ix;&IrN71yLeuo>q_v{sO|CU_&q$E-y6s;~h=aQ2{dV zDpHR2AisLmm@p9u z*?9(h*r>Uy3u1_CcLbWSpe8_GlhaODdKWg51SrqmH8%IvC=_9-7=^ zWt|Hnf(cr$hbwKhca1hKqc`XionAM3taE$(nx_7>vB_#Id8E8gKgwz(x$NHth4pqT-Q%|V ztnzUye5IS;O!OYNfX}%dpz`UUd}MWw6PhZvnk#kO%6!Ks~&X zp_Vgzq?T3u(u8AhZrGetSqW}^QPf_OJ+DsXT4f%-(OTKd<{Vu-KE4*4lxhaT8a>Bse_WXQtPEqAp=5AE796@VSNg1Er;E)?2O`S~2#JV}3ca5(N&BbuVw7yvN)AHH+U7?FbU%7e(GYAK( z7vwdwvv;hvh^RQ;hlO2?o89|O+C5KowH<_kM_Af`k%^FMk&?VsGVBZ~_1VHxZ}^5n zx=b4|{#P3Q(r=`b#@nF@iU2|O2bF7(ml}Q#GQikv&dr6r)#sx<_=1JQclZeEZHB)4 zeW-MP3sAEfVz#xm#t;+17Qz?$6mjL9^R07&NLl|-S!Fk%|-A(>dJY?cQrQ?Ga zV?6UttmH#zm%f$PA0|GD{_Jiq-DqZEp=x3hee>qmN4b`3rqL)g08~co ztu*!4)?Y4MxG+ic+C=h2r9jctP{HW8clgn4|w^Y9oD>+t=5pJo11PFP>** zMZM}QIA)5tmuE==&83j?s!yM68tyq{!AD89a!1DveKPcYVf&)w=x8M%G!)f*g~?eN z8}kX-gz3U1p%G>F?Ag1<>GIY3G-)MJd1SxGuA-}$yz98E0{@~-T0)aUxsuu1D#Pp- zTD!Uu5ag#~ZXN`>xA2mb0U-hXUnV@^-WU}P&pK4jQoINmpy%h;?q08&4*qE)B`bMt z^IkmKens7uP`$gqpRQ8iL2@05P!0%Xpel#Qqx+t8Rcj%eO*z)Te~o{Vb_#*e{|pDi zWm3|Quu+1SB$qe$>^5ea6c3)JnK1D3YE}vk+|L`gZr6)ol^iO!&8&6Yc?vSa5@92j zyTyqnDR6qQNbj$TjEPi_`--{^;WXSBb+8l*3lA3$3Nz)5yP}U| zVqkbIjx)(KN9FQ+trQKfxPV3M_91H$3T;)D|A@-Hp?0+dgqt5e{Pxi;S1kuThv~OZ zj#o|=$4{omxA!_cbcJ7$%-5~1E~?cHVp~8;BW2Oj*3<`D{9wS$QvEB3WV>Nn{+I99 zu(W+27yDvIQ!q|2Tulm#zbVPct#17-xi!-z(SNASAoVkQ(N;b6k-~Ge0!682>w}kq zNxDuyaaC1R7Ggkxw{p~76x}~ImM?#@mkr7)zeg(5h+`io; zB`JCQV@G%KFsyiXr)@f)+^Y8N+ljlVm{#N2`B_FGR>$6oVasZxR`lb~PM@Og=<=KO zRco!qY?Q>wWY`qkxOwwQGYTXlIaYr%0mF}tEYg1P?FMI@tkzn29M{FUJJ@yzgtcno zadl=15X+A0Pmb_chHK@UB%2s-Gk1Z6n;rNsE>^Fs1rN>pHzvWM7NhI7qKf&Q=cxQh zVZ8esoF6}YcmN&QRO~HS`arCe4^z?6VU}cMX6geuki+4T;vG6%eEb8C<0+DwkENxY zZby2x`O$jr8i6FNn0;uL{ai&wJ56Z6mpYjIlvTA!4u(a8-t^dWhK}md+0&;LU}i#9wob4|-#6gwg6> zj;u`uN~JWYFZcRZ3aRKn_y8s7R;x*3SJW^v$W;e=#rt)(LY=Jcd*@b0N|Y^;S}JaXbmKyV4~cN|icl$BI8Gzzq^@ ze>HmSKRVjys_o~qs!lB{;|@5Bd1PZ?ve4I3P$hPQ&4>!A1)%ka#_9gKVawF(Z)p?w zouy>k+A6lAWYw1!5Ur|P5AY#eZaYTmO+u@%H_oa$D{t5Q7C(F!UMoPxxQNs8al!ZR zbOBhkzkWHv7Hl#rQX*)T#+>tr_g5gKxF3d7w@d<=t&uwOI_A$1+JI94X+!_Z*zP}n z+G&0{6!#>8lls~U`Nf*l ztGHMNO!D>*^1`QYG~Yjvut|GEn^WV^8!~iQf_E zuYV%0V?N*CUk@%~@&5ha@c&`IU+M2}ycNS-$`1>0YH~;%B*)ps4`s4PGZ9B3)eKHv801@`AHKE;jhvg9-v(Z;x_}fSGi9O|C zZAUKu{UV4~vVXm(-~azN411b!>dOdd|7N_lombJ*V?_?9s0j3|4h;C{aBDJg#L{37#8cPIhls7fax`l(GSCSb$8oc$~ZE zU5`)ZlU6qNO$aneq@EW;U$W?0B~W=aZs)7JF%kg>nZ5 z1{6zvwK3HyYH3xjmd{hAXQZt^_|Kz6AX;%wEqtL+QB%ve_4YnXK%ny2^dm1XA2AAy z8F7K*0Yk`n$#f^e&$eGNU8`~1x8AiBs8~^iXCVmy?^w*W7w4*ghakm;0WJ;>i7?O? zI2``x+VxiriR|==Di$;10Et2$-b5?;`SBgiQu7#Epg`zSg*Mi%bauW4`Ci7rZ@F5W z78}0vVVAE`J{a3ML4%QAu%Jk_*CMMc8} zkKu%2^_dTQ`gUV;;|e8Z`sykb)bo4lpisla%^)#4Dqv=1MQ#(Hy<`U0ZAM0X>F@Rb zgKEy&BLi|qqtG&jYi&zM22L@m?=VrB*Qk(G7C|JGS8HvB(%+$r_5C5SxIKw zw8=~|X9(M{+1pv$TfOi3zVlt@y3TN&f6jAt71^HQe(rnSYyH-5{Z_lwqxXDiQAS6j zYK#jFuSEO0Lsu#I7fPln&Ya=5u6z1<@(v0bGqQ~L4QXo{a3b_0>TX-E&mH1D_nAk{ z&@i5sEbvVyszHL0Y_;tqPPY-?5dY zrEq3vCzl1>);L2+($-!nh|)S#8S$&$kzYR{UIRJ!FJtu=8^3(Xsi@Es9~~X|F(fS= zSa-gww^zW-UElgm2!mt_B$o3HYj2XINxf25MyCJBdl1{c7xb4UF)T!T{J=HzC^{d4 zARGuxq_O?JmHSenf3-V?q(~te9K%5D-1w0}=)m<16B=o06$EnBj=?n!dL<)}z4O(U z18#S++y?G81&4&_SXiWkOWoH$_BJNwJi4!69tpsyyRaWM=(1W*1x&KjgLD5@{6=AJ zZm&PNWR^53SquL&o>LuVrN5SMz2ObN5WSRw0%P%-nqG$=U15L2JntMHdK28xkXUF` zBnuNTrr&Y!L4#Cj10Q~G#^1l;ac!enjpZyIEv-aSqbUCa{_`hW^~%*?H_!sB1nvyM zI8G!JhU({+!!y|eR;VtaU(Xtod9;gficZYwHU@*~^$4lN-pa4Kzf_&K^2{A&pl^_g zGgMdawtnycR!-Ab6a7_0{_ODSbM!79ox_8IBSjKiTwZ;ZeGwFN!ftAJ?@mKZ6xBA} z|E=*QbR>3LKeXNlecJ9{_;s~*&A;Yix`A$!DJ~YB4lFg8x~R6qxt}M#u^@mrMTn4< zl-jdwhlYkJs86$}bqi-WW-wn~1PFtJjZMwRcq~igOPs+Qu$gRe9dEbGTegT8Xq|%f zXI!eZwQdW0<(tzAaETedICd<@{$AI6^QO+S@X6uFFRbnE_oI9^1-!Q>10AYHhu+=s zC%CJH!qw$qR)<%r^T%s<4JfIoIK{XO7yI*j0dY#%*x0b?FNN%Q&BkK%#>YSguwEeQ z%U-$EKfD2*B-zEpygrx8h0u3jU#U(zds#KBvQi)E)QgRieXxGbWbXo@xLs2eg^p1g zhE09ha{_=V_wd^70h5iw{ZA&oQp4lp1-`ojzP%8Nupn@UaYIgRtT?m@K2l=yI>WlX zfstl@w6O;Q-ldwHT;Q6+dNGK7+k$V~K9jwVel(8v#lmb zm$`LoE`-~4Zc5e15U9iWC8I0RiQ5U6nGrm=g$%yocdp~c;_HJ}8$0{!?%sPXq3d4` zd{gk^(J&v&Na{=#^Q;>_9bi1_$CFat}H4{uz{uR-*$H&nVhyW9uUicCl6>b6)G zz4AKe;Q=K?z%{pkI}Uc=gCYoyi@a(lz6I7%OgL0HZ8zV+V753fUAl>QVeTt+iIdX+ z2rJh2ZM9)jgOVWk^+RX7U{_Y%FI3Hiv4;jJRj}=9YiOjZ;(lfpFTZ^cHT(%GxTSGi zera8u(FLIgUDD(d=^-IxHPHLg1G+zND?;|E8k`KQ6g#Pj}r%34D6Qs3S_8gOjs26hA8`>a$ml zU%#-Q4RtOEWTRO(Ku{YDS@uIKqh)>vl?DueT^029G6sY|Oav^DP??rO=cJ?Y`HQUpA7u zRa%z`-USCIkM4s9d9Zx|9M6J)I84K5Y|arjDkI5Y>S??E?A8PT1Cc3V= zWyWs!BT&z<6J1v~<&8sA;T6U`mpwf;?GrIPC=^Q9$ETKg{?)Z{H5r*_rFNs(*LQwe zpd1BRhfCw1ogg4bP(6$6u++4*&9AJg!hAlf8XO~CQ(B;djEE8)S%#w0gV_b}JO)w? zx-Bg&NaiF|=B)2dHGXM)Um3vOfxN&ih^_i~e19tLyAyin5u6uJ6I>9XkEi(0&u8(r zWh1pR`#)dpcLC?&I>(Kb`T6eab>4EfXrYV*CwY$#iUtR^hc%km;8An1w5)m1j?YUeb zcVWPuR-j%*Nl6P{2&|Yd;+t>Kxy}IDb1*aCf=A_#ZEx*p>1HAqPu*Ux>1jH1o+3SB zy-B&^)6YYOph2AuxhL&x_H2*2kAbpz!<)@Q;IRe|HO)IMSV`{3+KsubKVLPZA$!CC zsY-2K-Q3l^;uE^L5P(2Ox5qwPuD-6fvY-O?J;ag(Bo!^$CCS+-phq1W3+5U2n+WE} z$Y`*&hztF;T|cLqrkzAtPN|NbrlVV8a5hJciRaj74KR4Ek?*CHULDEBnb)oJ-Mwo$ z-z2&7J|G~V0C9b#c5(Ui?CW&TsId;ldt~^Si?C0 zZy2l$a)h^BUE`s$EYc~soeejAx-B<17Y!b3#CHnN7LufOZih-Tgoe3hT9nO>c3J*} z;p6m4KhL~%H!=E(Q9IzZbFc~C`;uh!fe>sUoH5P{ii(<#9=(s^HiF&*S4C*a2o5DD zry>yI^(!1QP6kZ!a3@H3>m!Su=6kw*17ehh${(c>GR;{ZK4?@_Xfm8;6<~7ggO*%x zGZdq(!Si02pQq-d!a~L{8AeGJR3iEt$i;t{;PD|_cgu!Ld~hDW5N{%)x4}eo_0_tX z2Dy(POL6N}-y#XHCuL19%hW|uP*aP{VBbfTxfUxTL+G#P02UD9`5?MiFr(Bc=FL=o z*?8qQ7rW@)fOsoY(;FsxJycC2E*%Sk9t&qxm9ve29C-ZHDQ#utSHK zDe`y)YU~*7V&NhI<@{)><|$W2F!Hv;rOMEY;@<^FeMr>LJ-?>L2>NLvyYE3@b?(R^ z*7=(&(``m$oJKT>iUvrD-F7R?#)`POt?ime4KjVUc0XRHP+xqia|+{80>K8~01JzX zH<^8SOFH=bS#L{ap;V4wyd^M!Nyrw9o&|I+1Xei!N9NR~7*o$vM~}2~GHiXq3Cs<7 zbvIA_G<;tWU8J8sRB9VnyOVF9Q=WdiLZm|U;e;RaJZ$y9z$Mtgl>s@5i3YB!^V}-q zDKb%JSV4sVjPO1?#L5PX8!6i%=Pl)`x|Ma8%0mdsS5$0Yj|K$3~W~&qiOd*1p8C6KD3eu5xf#_wfx62YM!kUR$WQ za3(k#G1Od5z_teaO#-04uc1oqMTG;;#-^ z6d))D#n8qsh*;?+RJsDM79Jr*UHk-(u0X~cEShb#g z?ateU3k&d$>BToDq(A8ASF`^z)rMyQZm_H&WpGpTU2F2qJ2LC3sscqkl|Eb9nHzJZ zwyhlZL=~=>a@-G`uW)Jy-8XSw&HP;cq8x-nfhI~ zoNQoSYT6mtsT2_KOwdca!Gm9DRF{4-aIJ5#Jyu}q4|dI)55CA*TLWwBoT2MO&Kq;; z`NrOl+;(6n!8*`Mc--dH&5IcHdc+I6hY$y+fTHAHH+kT!sw8T5ef}Ah31MG^J06dO zxMw|nX=w?&k>sOqvfETR4~|i%oN*gaT5!_u6dR<=_V=hH8Sphe`~JFn3F9L@c_#R^ zXk@y9eNE&w=HG8UfN|syQN6Z1WUu<6KlBFq{uV>A{k{atWtQCZ;#-+22|ds}JOg}; zYt~7Z>3wekTH=8z(Jg_`zfjw{obpfWsmJ#gYv1_lO_Yx#U$|hjH^?o-%afpxzN#icFr{1cBW?dWpvp&ftduvof6*ie8 zc4bgUWl{EO*8cdJ}k+D&4BD3W{LrKn}DMO zBonv*!tkA+XvKFktep3~w@e;oZG2C+7_Gt>(}}xiLBG^hn2PZfcGx0aMXch z(qJ~hfdLmn!R-KGt^ezMyfJma(N4Y&LkVg_yB6yRl%43fGF?o zto#0S7vKD9PH}{AVLwjZ%_+AMs-EhM&qckXpC$xb`a4D>cUBhDQBjPO^xPXJ@+_e)|;Cq1u_j-Cl+u{`}R z((I?#Ie}@JSk*Y%E@lk>s*(QhaNr`^LoAjX>+>6}^?k%KEdPP8E&2Qhn!1)^b^^{b z9xC#WJ!4vW&(;=QabI{Q{#!Uyk!tB_1N>&^?k({w;3=CRiwyX({4}S`S7tETr3ETb zz6P^3LbV6CWtZ@k-CLq&uQfLyV`12vikQg46)wn;Ta{f8G-WZ zwH+noV|`OgrW;Mc=V;D+^Bsj4x3si$(V<{)VY}sQPF|h{WI8l72_3-DU0unVnX0)Z z`O1s-mG}D?l&(Z8dT!AHUQiXx8vf19obn8<*pB3Ouq<5ASgfEoyL8hffs+fsp#-6E z4^GD4_`T9$|CdB`MYDPsJ?e`W1TGvBwJc6gzlBC$&Z({S(MN^H#AKzXf7Ua~1s*r_ zyJv?G5*Pu|Efz>-NMcwIkTT??q)2secYyb?zA^>5>xl_QXQyILCMKho>r_V0&9=Pf zi=A4&oo@3oP68TQY9-bKBJBH+3Z}RI^*YM~P8uiO@(CGStFAoO#kYPs+5C$Eq>jP1 z=Vq3<>W47UoZ(+=a75(fUce)zjO{`s#iE&u2J?R8aY2Y|xCCB3@dof+2;MOZiymhb zPu-^P-{8C}Dq_e_k^jETmyvew5WUY0>p`)h@YJ6SkCtZ|C@BRK`#+(eKPO?G(8xcM zyVqlCgoJ!6i@y|f5q?wZ;$G9>d!B~p`n6xDnV?xcBX1@oUWoKEqjze*N5HS4hnLuL zC3NLOXlPkP_{KIZ>3GTog3gCUiN5xDS2DiV2T)~jWH_0b-CAwe+{7Ts84f}=tPjI~ zpH{!BbH^Q;B#Bm4!ri<*^7F)Z2N7)@bw)&MYkNfFgUQ5+v?M)Zr*>1q6$b#t49ek< zk(Ff^#hUqzjrMZx1ngU4mOY0Bg@xT7UG|r9uc&B6wIysPxk@B;Ql7*5x{!&WyZV>| z8&VoyQr-Xf5ZTWPu!G9GkO2`T(dj_b@q>OamebNa&Oz1S7&BDXIGS@ne7t z`(LNY5iom@L}6i8;@aMHuK~MDQzCZp;tlx3pSzFA09Rly;T=S~d;3rTfDUtPt}`<5 ztjat064t|~o6;7E{x#iIRaM%zHPSajLQc1xBJp24H^U|_sLUUHGSJ(&kRvx&OH1R@ z_+4od3o8ru>zs-t4X2JqcuNSD6lc9Fmp|-(m`P{QR)|}#_!h88st@t5fcA=46@j~Zo`P%aG&CfAc$`p|lke&z6etZ96NKjB8xcVEDfmM*}k(eYJy~jWQI*@*2 z{|CkcJv^MNnTeWPon%@o7l-LO{pHsisprzrQ7`z`gH&&eGcf2I8R9n^Y!S%ZD?EId zxMcFJ^M}C)j&ILFnV9G!?Dv>Oz#2O#saj^&*_p%}>PDs=7Mg!fE&N!Zb*$?N;vf7W zl);*5xV6a6#-XLHjEPyKm6jHD*vk^iGs=Aa{OH)x7~P{?k42in|7(7o1}x=X=pqpj z6C-RLcjB?TlG8?(eW{F`3?O*fH8r@L*-Jo>JAUFs?~q_FX48HgJ?Jm;>Bd12DX_A z<)#&BT0ttNi!Up+=S)V%+m?G*6_8w(FCi>I52CZ5ZXeN)iR1=Ht8`N+LD*S=zEJL2 zW1|8>p?0~ZOB0FIHquO6U41xx#V{*u!W5yQ>G-mfB0f@8B2ruDp7Ljov6BanS@Gp^ zf|#=cz>46GtGnp*$HWaTxgdIy%^$X7&b^d@Y{lA+mnK$v*bb;DylURw-ZTzm9ohC5 z4v0jM=rx@ASw4MTvI$#RVsrQX?aRd`>WKHPoSfgCD&F=JC8^^44?_t3x@qzCsHLvk z?eg}SpHtk01G#`JpAsdRUf;1O8>FP6;mCEi7$zkpMUV7wArusq!-tnNbX`LO0%(t) zIHmbett%_#D>zF!`M24tI)u#Ju;AQfuwKs1D!wI=qzd8WaR~<5Hq)+e)n3m6130)@ z(7x^9FT%*>RDZZhA8J4rIK?!pmX!6P|00Be7Y6cmZ{M4Px&svj4R{elqC@Dx!Ile$ z*2xZu@;1eDa%jlNWbg|J6tnU8jx1gJ)Bu2f-_Q_i(!k{gmT_9AD#HcIebVZW!X@_A zJ6;lC5fI4vy=%+AUOG`Z5|n-t#2ocOFN7&7FyQIE#-3@a!wZVV;EOBWdF-3GdEz9A zKa_|@Cd5vYXVZ}d9uGRsf{<3pgt}I}-Zs1|FwL3S=-Hn;Xo4>99?8^BD= z5(XdW=vxnEc@rNd#uX;0%nBasx~zE^0kiPJu19slfTQ~ySvx7gG-^QZ12i)-(wS`r zdopxsuZ&vo1943n+C*|(;a0a(>0CXYq;=bnD`_A%A|e}A0m(krvf+GSp~iBbv-|C2 zv_Aw>!ohU~IWS9CQUC*b>eMOoW!K&}f=tA-Zz}bn5Q_~AQLe6v*ZEbz1eZkKsT!}$ zt#55?F4Vu4u3VZ?X7c##>g+nd`aX^6=IRMpqi^)}zbrZF=%`;o1RV_x>zyVbdGH=e z_PZEIKio+Rjz01&8lavW8`=WIJ^3u+&QPLVbu1|sS6J9Mm#>-qF~h^t6X#gk)YSMh zpuSof(tD?&`Ln3cT12pHI9Oi*!ue-h0Fpd@k`}K6;bMrs!LXLvLltiBe5kk9m6OXH z8XAJq-nY;6V$Vv{K^dLYN0EJ$U8o#@V+(V0VE`$w-BqkW{9ei@+^Xp@8<>!a&UvSh zs?OO9HTk{s@f$lY6%-*4a91d{#A{wd=BZrsvLBkjvp=3*Pgxn3Hw&7FXwIFc&o+j?JasDkc8BWTE$m(HFXE&Oh6(*+f9EAuNq-L&*H zxe>>YpVat5`=)_DhK<BwIs4!<{R({o2p9YY6Y zv=R7TGb4k1Wzfq=UE^kIyh`Trpw3qn%7zc(&wQh5ejud0Jpu*=|4>wf9r5UWgLrbj zPA-^0!p2_Kl>c^S=JcV;737A5F!NVQxeBzk_N`l=DN(TzF~Vl@+kFvQV7r>{KKz~2HVuJVX0;nP{Ng(q z6R59W(0!;DcIF&c*Tlp``5_P3-{G5>z6u_?{^}2v3Xy2-DZAN6RXDnj-=@B4=^L{m zhH6|3do(mD87}0mzFXzEiQhvn%w_C4$IwsGI?z>EH?@ReMx<(>wjVG>?4MQWD*2avW~RbuL+lOE2f^*V|(h z1WW!4b%7w_ny_%za-v;@;qoIwOEO(>qXGiHFr?N1>Iz9Rerja^#ACZEcCpyYBPy9~ z8Gh@BJYaq4Trns(o2k+=g~@^iJ{?JU{1lnGrR+@B#qs2qkJoP3kZ}p!=tH79HXbZ?*1)kxVX3^CnF!Ef3KEY&CSlxOZmunmaL0si8?U~nUl<^L~dOg z99%)j+uyz&0|XS<61kJS7UmbxFM2NvUj43y6<&AW^%_%e6Ej|6S@bqvw&FbxRU0W9Nst!&vA`Ngg)R=T10;xg z%7V9JG&(x9i6ou5^lu18v>hAF`ivy7d2v0b(AOgLVJ=9gL;6wzgA$r1t_A6F4>O5?53X9ZEawp4ld74PBZ1 zm|N_M+AOehA|pI>Dx=f1qnzVQF7sbo&eAxYR6xQj6*3#cywAH`Y1s31(p$~6OECZA z^pzSbb2Mp8+ka;b6&+o^a`&~idD@;$qw8{cZAvILOOG#4L&A{}dGSAN&jR@@lTbwqz&*8J|~QPedo`aSI9W@S3ID$k0&EM&H$a6qqsa zy&|rP4eYwjR`+GBN0J5x?>%{pn(cj|nc0UPWkKl2*U7)19i%BAxuW-F z{?Isj)cdDL!QcId9Z&yo?_?3O%DKO+4i|vC& zcxNc$!4GH)qODi%=~_Dkfwz3OjFez<(u$$&{1X8~)b zX2u6N!9QC|JsWGd9*Msc_%fuS^sl>3O zLt9;2#JcEf+lT6zI`if2uw>IwhxNDYqlLNIQr^-v0O&S}&!krGb(^mr zcBK8!(91%5mDbK?4TO6(bP!`LvG)_dapD`5A=>>gF|kwImpJX)hUt}jpGHBwfRKQ= z9TgGA<}bA{_k*^?`+-tk)OQ|ax9}zvkp?)Zx+Xk@s6~@=|M)qtV)n)OFKIyTVPScm z-Z{8y;_qoL!>q4!uRqofEGBdb?iPH+y?Joz9M|n+{9_-HfB;!r2C560@p(Vkw}MD5 znf|lhf(aoDq=6iSX_4t~Dn$j$u5t>o>SVXH(9k$6IDMo>^n74Zv?#d^_yKH%Ld$zY zXYZ8gmpo|En7-1kZsMDS-6gds8l<2&p9R)leK?c(G7#4EV0qVw*DNfqM0Z$f!&-_N z-o5f7FD)%KB04;+V442ZDG`cuG(uuqJ9otk(=IVNuVYUw%rA9MB*U&J4N=PRV`Lhp zHzs1lOAh}yq#qHt^?JZYNLBnp+=EjZ;hGWgG3j8qMQEiRDhz>+r2$^=bK{MAFYs)B zyOn;A0Bqs7YT-uz<3vL==@BxNw$Gh%7evsDpo*D~xgD0_kgL_Q=zCgP7Q+p028hRe zPwzArGN7QN6rA_)ihxWx_{2s7yMnPapxy?)%4*o*XuL%7F9rl$DCYU|)rWV}Gk^=C zgtQQfi+CM%1;PI&nBr)C-hV7Huj0;066%7MlS60EGEfz6m%HtwAejLT{6>x{?Zrku zzGNypm3!m&of8#BzUDns^js=EJm%*Cn)lC7cnxBYb`{FPOnT|C&WvFE$Vl@4i?axADcfE#oo{(CS|2ymaMEt_e2&`2U(@YorjWFAsxc~1R z9QI=M_j>$vE=H=IWQW*|m9Odm@B8O!?WX}@tv@5+-*VADIabuPe=_^u-<0<1pTvg; zNRc$`M+zjo_4B;h9&%t@U5+Fwxb2(MOFf>R#p^$wHyD3?dceX zvIyh(%GD623sB*$kdS!`zGf?d7OT`@5zA};eTH_zuM4zJY`~u$Cc1$TsWrV|BwGGq zh=~8cgi%xaRlQdY(p8=;{#L^B*WA1t#Cu!%_)e3wSY4Hu^|#<}wqm{7H1eN$%bRqb zC8?WDuesxpX|T!bUjt3}$M4_M!gv37NU{GpDUw-$<=3}<+0;2XYAcU%3zCisA#3~< zZ{ohsFGhMv1UdRsw_C=3`gB)O3F&<&g!2RFc@fc&<E(O=kusre!v--pVmcEJ)2x8 z#qr5Q##oj{c%DD6l=pN&lw~CDqo%d*4oeWfi(cf9AlKuAf*=hlptP=k9qJD#_Qm`H?r_ zHbeicm-_Rf#&_pjh#;amyg()!to8r?bC;9hOUwQ}RPEX7(&AP)Rs{KP2_fvGQ=_6v=u9gS_&_qn?Uy}|?;LlkJ zb2jY%HP-mA6+!ste;}$e$No;+@~*p!8Pf^tq)0b$Y7ca=mkp;iuU_QtRP3|8;6QPf zQf#rX8@edQTA73~i{n0f*c!$)zN))5R=4*Ondo8tnZK|hB_-;dGo3`u=R_5TlctIJWLseO!bSPXdLyeQ>fh(MC0e=u2tI(nqZM!lamXaS9Cha zed;^l6&C$@BfS1QUOk~c6{?iz{15u=9&4`!_1`ngoO_8lrEKIeiDu#`v_?Bh4(Au* z(b9l5CEr`G|9E{xYo)^R!{9hoLzF}BX#ZW?S5nMxxM-H zuacz^1mRs;QqKHd^OoZpEl({LKeCM?KAiUxA z$opNylMaE-ejW7wH!9K1&u1O33bmT@voD@73Hzut&JOb;_v3i#KQGOX`p4WnP@3i6 z8QDZ-zS3u)G1IlvsqQ?7UNl7b)0f+1`x@!peJSzPBz2+E#n}nNd6oG_+gi92YDVTb z`H2ntt!2_9*p9fSl8aZAP@kD?soOIb_SNpIOZYw zPZ-y`UdxU_BgKgREH-x%QZ#hf$*EnZgMnp7=dThdeBvXbSpePD^eg34*26=TfRH7o@n*|@DBt=rV~3matw~G1 z5%QO6mvJNbQmZPT!qmv(zUnrvhj9f8w^ZjRi-9syb5fhcZKwvb$dt31S;U z?R$nO@8hR@%Ij{`x$T58x0r_Ioh!<*9@f?#F8I)@gPUx!k9vI4yH$VOQ&rDo`w26< z*^<7mpIz}_@!HNTWvxp-bIYtt8_Wk`9#s*OouD5tUU2NR>K3^Dm>4n4_rkjmeha9R zpL7`Rj5P1l>;FtSUVqF6nF0;j$II5u^ZgfA z5`g~GM_2ZJ-eiB74~Ls&=o-f*ky@0*pa;d1$v2~eD^{YE-LVTM;#qZj zDyZlcb;)V1>!_+au4Rk{`hPmg$ND9H(T1V-S}pde4j1Az>azI$8ly;nAu>JS?G^F; z);owEFgF`X`>TAofq}%nB%d`Q>XRgN66)E#vkT?j12;_6UrV}`e))opw(_1l(J)zo z%FJ2ul4H1HQZSmUlRb|aU_r3^u96-%D*X}E)VZ7Ki{!qdvRo@c3A1e|GA{S9>qtuN zr<>III(PRw#+yxra#suY^+{K%HA4bO5E{ds38HSZ2^`qrx=7~65$9+P{3W`EAB_#KEEN44F2-MH^Qpu4W`4_OJjT&dwJut zf9|xCN8h0M#VT}O*7J)94O{7^m|eRX@7Mdz51gJO&plN{>JJ43qBnQE@T=bIUud~o zmBW|^m(JljrE^&jhy7L1{p1Y3TOTz5ad;N+*W3$I-4?sDX49}J9+m!Y>$~*VC6&bA zb}l+8(9nOtJ=vC{f6eU7AX(5;=&fAPsUl)vpuKU;m#MIg#eXoboRt3jR?Hnvoo&y; zYt^eyXnZi7?tuY)x?1TuD?YB<_{*#z=PSL%5US!V)F&ObHghIW`-U*NV6x<3`^)+a z)Gi`|#y&i|aEyq^?tH+*UxiVYye}J>o5B>OJ6lfOU)prTOybWhNvNfMiCR`)gik{#S5MOCQJo}FU3{Y%wt33<{2L=A@lsUi zNNt&Y*FN@h>>Iwa*dNh{c}v6gyr`8Lwz>vI#UyK%kSQZ9>`pstd|QJ0`BU@*XXt%? zI$NZ3y?$-ZFoiT;nc@q4>g8n6=?mjYG$H%Pd9Zx95pfTF%_Y0C0I2L%?P0rBk~?b8 zSY9b8zUVR8Ye~ql`O)_}TpO9Liti-J*MGAxF2pJBGIG)L(vQ-YDLZ}*nC{`>d<|5x zP+m*lWnZhq*PLh??M4@TN8RVZIH7x|Omr~$_NP#S5{PBI&;H_+szmnYXjNh5EBZj` znJzWwALE7Qp^}}-;Oz*IITF|kghSx#vsF*iE|D3-N%0zgQO5$65XNYJ4Krqh?UFYh zgE({?j~$$<;rO08`OK zBCp|c&iC^UElZnOFuLQomOuy3>JNkMm3O^Mnc4X-HP`LeHr!qF@P_~PSj4@Q%2KV( zwGIVxyX}Q@3P_hFP8}(Af9CsLlRYmB>CxFwL?a`vaq@eWpQ0*VTBhzJPaIDRDVQ8< zhmjc@d!&QHuiNBguT03AWPm5bvX5V}LBHf_rJ>_aYD)XyyF&#lur6l8u?oG1T|@>1 zDw(V;+3y}+Ulp_=>I7|n_vX-+^mCx!+@wC$S!(7;n-ZHH8BDf9Q1`+9+N1U-s z1;k4u23&=x%Von;<5my5oYA(Gp6iz=qppGwp3MG^KOKxHer%e=f1@h2P}8)2HGYUX zn-7~1*gw}e5HhDFm7qe|$Ie@yBNeC!7}(=OI@BjeW?S^jysNy73r~J|>78Cp7D({< z1Z(%!p4%$oCmAT>qch({a#HTEHU#-@-*G1)v4BL6&pB(G>2ver5h z>9ev$QR$T3U%eJoP1dyf=p+gFCmD(%F!>+W{E1Wr4DUTRGTAPwD%pA%OU6*B;jNij zEs>PU^rVDY5>aM*dyX8%vptn**KO}gIalB9;OtD!#wcM(K2oB@Z7c3E|H>Tv4`lCj z6q@yE8c zq1XBiIQGkY!%^Okp5qA{O4RB@*a11y8ZYTuW%?RTQq^S@m^Vdi^l2XN?1*INRV!Gw z!^8`xwAyD1ayKD_3knzu)n79aK?dm6q$~zBuV;?>cVl#*i zgp3PS^7U@gO;CBXS!V1vm02oz0v#N)#>w-@96~XYb^;^p_woN}2xZF5#1Lo3?B9KZ z5F_&VQWB&r4h~)k#!-?>Hv?&ZySQN0qxl{K)w~#gB=vWlm36wKb;$ zE3chEqW$N17+asw#!kHVB#JBTfBZ?9^943yX2hXEkq@jan()(^WB;NT!QxDsQf*P1 zN7;UKRCgS}hHOjjj8oi66SUk=@;dIs@s;vbjW@6l7+3Ptr$-iTeVh;1s9Fm~am zHzATo{Tszh!nn8D(9n%8f%Dg>* z*?K?o3CqKKg{LD^!1hE(Zer}46C1;p9i_fTU60)DSq$>oYtieqlyHQPU}Fe@bZ=T+ zUFapk@6^*$mQJfYv#rqlFNW00b=GIACdgys*pWhgfyJTD|J)$LRh3+Z@T-0zSJpnt zQS3}G(fls}ET{h!fE8OZ@cF-|)YZNG&newMMMW_h=m(@Gc?Fr74*iS;Q`Q?OLKtsW zD0}&56p0KSy=LInHwZ7&^bN3AfJ>%!Fla;%JD?&C`1pix-yS2(qu#8P_m2;rmB`N~ z07U~Y@Bn&L0%b;hm{f$LfLg)^2FHV7GkITu1SbJyfur1VJCE=hWe#2=)KF_nON+xi zA{?6iU226;I(!_A##KTAyy=_Bs7SScRu=DFuP=lW6rk zc5wLRLbox{LsvOCA>kJ%M1>t^t{w~w5s~gI6zqTH5`?)|P8Rg?1bS>t1!f~0_30&L zWx3_$VQ`{{bcFf)Fo9Om=jpG9MobADfk!-;UIpQ2FNfx)rTPE5=CQ@Oxvm%CO$3`Z za4=j0-5>pTe?5K-uY@8I(FB(8bQ7YY1`lP?Xl($*2!~ht!U9JEx3DzaLwU%-U3I#w z?D+kx1&!c_%6xf#jocJ`T?kFj*a#Pe`VH`XzA=2(S z)U9oDL94`$Fczn75Jvyh+OlU|Wbb@Bta3Q&QBcY>y5&Pa@5-ePE-xi2I)v%GZm;pr zOqke$uXEDU65x~%{SQXvFl98wyg#|iuep*;F7Y+x8}ib@MF^l){VA92W=1+uC%=Dg zgEthuz7ZHon+g{l0V*FD7$Sz{u?!G0`*n_)6!?0sVYG_yFakt`hY|Q3k}&N2)c(zZ z56W8zo#f@OyVzwC%+uco=eP(uK`G*%lE0p%{jlezPnHqGk7U9}E*^Y@mz3;BFVwvf zFitpP8peEPPtgg#3xzx5Z8%uO0w_?l>jN#Ds+CGz;I~s8Off%^)Qk+7mD;UR!U&VV z?fflHSUxH6?M8SKUhT39ZQA*RleQm%uL_4?fM?R$Nv#4^IjuHVS-jOOpp?@CfXAKsL?6EMBv2z{UX!iS78o=LO<{ZiheY z8M{bJ9Q|za^7FMzN(6Iq3bbToD#Yl?L8HSogb)Xrl2POP zJzoGV5cau4hk#sZNE=@09pM@G^KswhXmKpz+%S|MOf+zqn{tpQ4{|BJ(|9{E<$KE9~ z)HtOacZ@+>gNfBJF_CP9?JyBXXKLD&!k8IuMtJY_+W4vEWMtG}*ZF>*Z-NC%%5NTpYj6# z{}fu zi^ZiINlfQI{Z6yNi>`Jkc(?lI*=&r#fAZk)R`0I>7(*Q_J3T$4vg1bLf5*7Ldl8dU zSt(0j=-Q59O;ZO_AwP|ksyD%y{yy3{$JQSgcAJ{cVUDftFt=PYi)~s&gZ2X$Ij=@< zgW;iKhw0<7!O`R)c3nz)V~75D|8NFVL(0fV(3U9A@dgn?sB&5f)~~hrX_k?h*->u8 z0*3eZ;kOPHBoL+${2ntzJ5Q3P4n5tDlaps=6&*c<_3~M-O30bd(^ZzM5hbleU%PrS zwjTI!3(V4kW4U zciFd^Y&x4K>33#aJ>nM}H*>Sgw3}>DY=V~%*D?y#Zr@=0S2d?TUPtZW@*5C^(bCh) zc>44&8JW(pkla}6`PO#%Bq|iqA*|TcM)}5_mP6XkeJb~M7dk}+H?A&jKD!WkCRW&X z3HC$KqSTa^Uv(beUwz#m>REat&b~@{e!h42i!X?Vq5DSiNEy7;p!us<*zboR5|D?2 z$bDz1pFc^`pv{mAlLEBSkm<|I%X)OlCTjXRF3KU{AVz0i__LB^&bUyu1l-ZwLS0qW z&}#ZnV&cOTR2QHk{OXY-B%QI>2A~~h9~3sFr!zy9sF!SU%i{Wdxov!IH#WOP%; z)^W+cnwMp4j9hk~cIkv4sy&l=HdRoE={Jasea)EJn++xD%24|fDvphTt%?<0o4x$I z+JlHl9yzdxsq2Ih(ePXTS&lnYIj|Ua7Q@cPRms}Kcw^vUv-{lYX+jC>ZRn$$Svw{H zJE2X-8+6dC7n2Dh${_>siB4{IVN%^6Ut2HP4D8BOVZH|KQry{{3ry2XU^`y_IWU7eohI*PD!ewKHt(suU`j0rWmFf_wB!V(z?^fYK;@b z4U$2Y0?OD7%f?;eez;t4ouOHD-dNPaRUnqk!=%c%4GTB2w3gV0(%=my$vlPw#l&8*1yx1R|FK?&Mv2{th*QLtzhO|jEFaYGEe9nfrK*g zob>dUj`srtpWW|R&>q6$$tsMlXb)&{T20>*>IFocVCz6|>A>mK*3joD97uyQcu=oa z5*lviaoT`B)eoc|Lp8!Q#v?K;EScpc73U?h?|fC2l^~JH4hn(<;Uv(Yn2|d0Qwd{U zA0HMO%Li2!i!3)7Qc~`?eSHXZ?VmqCRbR9fic3vPBNW^B_m=|e78Zaq(7|kM(7nT) z{vVT}l~G1UIvXRSRPCeXlu(Yt>VH|F-EUipR3f^k-#LV>KP*Z1e3D+u+L1?47=jxWbNcDbRB;jX2{y{<_$&>w^PU*~b}El#eh zPM@~5e?GLo;AiLWhL1)`O3fv*DrO-z9MM>^#(vvrG#=P;0!u%ZDc_) z84!d)ZO35G7J4jnLt7CRg716$=t&spP;ku&0*WAd(d<ij0hG>(tL2P~=1w^IkWosAzC>Dl2on zHx6nL5JDIk)%0it`VT;VTo`T>oj7G1-9k{mdJ0M~D)C|&pnRSn=Bgx{?Q@P>i{b|<{GFc`jbCaXP!&}|tWsP3=#8OU1K6T4*L|b%z z7qU^&f|o|lHVHI|8JsWj^WOww>cY}eWW!__c%TiFYL5`_;Vn2gT@Xct+<-qtVH9QWBC*(m6ZdD&i~D{o))P8lcJZ8nvk2wZmHt zBiGTWZqI-4E`oT!Kl*kFk0uSG*j5}L9R&$?f zs*rotV(5W^Md*~nA?&IGI*`y$TEki)h-+5vT)EaQi_bB$7|6O>$$^+~89|mabf~VZ zOc!=k%c4Qag`4sZdF$@}9uf$EzrmQmmIFn9^r4=gc7+3I9fSisMVlH!$RemdH|2s7 zG;qP!+^X@s9$e9zLYp4dNM>MuAC#1$7yIUW336Ycr<8h$sbt~<5me+2?~4jqTJV^G zI2%+q2?`12OnKcCO@z)^h@P%6>820{hW7=9h4!C)3t6hB&Ft;V2GU$nOFiN{Wj0c` z^OlXAs>ayAX0yDgI7^1|&pTA~ZIbNxaVWqYF9fkMf<1t#4=Qw`lr%Ze4M;I1g^{VW zOg~RoEOE;#>O#joP>f|Z|hZPJZYjtSbbY*#+t<$^vRqxtop-?NT z#pzKT^s;yqx>)rc^C6=RSn)X^cQjeiejy-mg6!BabswL^Z)WB!h)%^Jv-iR1bHiCe zp?90-H|p%Ht!W(NUTowga#c0H{oCN3M_@pMJ3QEU;&4X(OCe5)t2*S|H3qV2k3NZ` z()^jM_WYMH*uLIgZrG+kc@5+r?=8I%6n*sVsY;Sa8nWlie)Ymx%JaN?jfaW`)8pf5 z_p9g3p@D&xqhk>XDJkeWZ4XZo}(U_ z6J~_zJHqCFrh%$GC~&t7^lYIIHmInjf%Hpci-m^U_A2j^U71Z!(+N6&$M{ha<*wlH0c5GPjg1S?v!1zIDh+eQzn>Y<%7n!;$(NbA z)Id)LtQKHbkr5GB&1xF>(m{kCbg5--og|HS@$`G(QWZc;2A&EB;~k58!y`SH5I`rY z`nI|>H9z-5OHpxg=WlVvJ9q9t?U^QQvM%1MkFmSE1X0hJxqHw%W@pRB)<$!UWyuwE zjcjbfnSF*UfcC?C4Hec5;f_ zO~=Gp?%Ql}gQ8Om57N4mwK;XIQDZe8y9urlQBka+!)a?l4#25`@@^yaqk%l*d{{6E z5{|NhB_#v~G)O;HR(UeFFeF>uwaR^$B@dgRI@G1yZ1@DeS)=_gnO7JbQ6_`Don3(u!!sg%_=D>(n&PIf>(`)YNJ)!6)-Z;D{JWeqW|gjd=o{+&5PX1 z?F=O&OU{e=MJuXoVr8-U`iaH&`o%^-9HDZ(^ExQLu#THpj zhkqBBRCRN3_<%eUUXY^1X!oj<%>P+DcBvpsvt2+RFq%9n=Tt0%Tg) zH=Hpc0u}#^C<;O}dfe1YkMYMs-v0Vx%@O;f!PT6IuZlv&t2l~Vs{ai2WO$OLf_egJ zgbJe_?9+=*j#ci%gz4S~^n&i}ccS!SeAVQS&dLe1y;~O|EnH8Y2WKR}>&6>f!p1sx zk9{&K4t;vA6u2k@*^j~=7!L|;UAR^6lsg*HZHa70J{dUeFzB}D%584+gI}}sZC>OA z8u!R;T+%J^g20%S`g_%lbOBCeqiw^JZK^d75qyckNo}k}p&=_^B=ERoo{sa`3qL$E zbwcJ|yC*KaF1_>H2=uwT1tXG|kW~|5VCwCxR{6EzEpF#vvkow`aiF!IFmMy_C(>2= z_baXCN;dwhR~&c({5b%JG(&oNRX<4V5VsX3*KhnCJ|HqzO512(=G@DLLW?YmXrlI0 zQk2Lh87`$ZSW2Gs_^oZx*!BEEw3WNnd%|-g=!CB26PF>UR--UTXesl?8z?9!JUR1mO%(dg!bnb&D)X;LzZ zxPIDCYXI(z(?ncNP3`n&WdQ-SvElLc3)PAT!$7vVo=_FfkX&PBl5N0 zihuOTX0h>Gy5YZ*wgD9^%+E@Lm9fvZI_u^f77*WUK+iRoC(T8wy2_uFZS*zz%}KhYubYySw9{X_=EJ z-D*ZueP@f{ZEDvN7_O$U=>C)7Nx#J@hpe}1E%itq9ic=f<{0U{DZlclvsNVD^4qp_@)T&{0=Yz`nW{=FUNjP z0edW9JVJS}l*6^a$vI!3<(UTs2al$Ph{PM4smB57P3m7)?@GK_Tjym#5M&H{>oun; zFc2wHBuA(p&YRlyP6_=)FpL8|5Z-K5x`#ZJ zNN`x3c?MIV@atDkp8U=(qg(+m5+s1l!*t(A(*+@is6JVQY2w0K63E>EXx;x~`u{8v z;?=~D#vg_)$`VxP0^^+sz@Mow-dqNb7BHV-hkvtoc37AvfS#UbWS9aXLuI*hV!lsg zMpBTU8~*YNymhk*eRU3SH`3CVzybu&1Hg}rCJ@rjJ=!NNCbk@0kd>Kfxoz7BAo^fS z^;zJ~%BrjDcXXI59?V!}{4qSd54g6!1LGWcFgS@Mb<^h_BimQHj$0G7pE%4<1=Ra4 z4z=+akW%2n<3J<)29gpV*0pcnO9CMXz;BHvHuNB$B2nMb0GN+P{|dbX!9!EJfiNt4 z-Z;>$ckSHiG~IvBqT~#=a}1Sl!6&x>uNE+h!0rLzajyT7JOURmf))|wXo0`qSXNn4y5cFww?27t zZLd(=Xpdi^)7yKWg`1$9Te5Uz9(5FvkN_4N6JSr&NJ|oZ4qX7R!c-pmCj%fPnwkbU zK7kd(-7Te{a8^}y$4VEd{*e?cXkxoXG$crEENw&Z29S7vnw$(8jg*wsdD+Xyn|n&R zUQ;RNkXi#;CahdC=f^uT+uI#M=rioXO!$!1G&&SR9{wnjZGFu}b{z+k9!VO4hTURln7C{OD2>DEfGV}^vfzV~{0u8iW0 zWXAFt{y|J+M8wck-&4pf`ADQ_Q{UbX$@N>H`Lis!xz{QmZWE<{_~2-bU73rHe)S<- z_`a&gu=DOipIyP1wz=h0tdaw5xHF%{%L&v{RZU>EksMlI*1WQqvuk&CaiK4J8 zq745{BoHp5P^iSjg0$k|rBE(0A%P0;0uTq2V3EB%6+)8#khE>p8Vuo)NMw=kdbg?g zyJgFa|7b(hAvMpz7uJ64o>EUV@%*LcSiOE99N&=wY-UBV{$gz;rrE6d=*kLlXnrDU zoXP7Hf31q}p6pIA($>TeR8yK;Tgjuhc~@g%_I6Ovbisi|rc!cnu$Q!S0s{>Tcif;b z^SRN?kQ|-eP3G)5&^0^jskJ<1mY$x@sYgx9iRj^AwP@E>kOlZ#YOOr)l8kIQAxrb5 zn`(12y7^;)OV3IfhlUzH&Kh;3<58>nAG5DeY^$+zW39{mLOca_F>!U)20VDV?Jl*o zJETF~np?Yq!Jq+k6uPdwP1b*e><-Bvz7OLv?lapLMkTBKwP$+c77QDVTjvpI%4Iw0Ajb z|B$n%YU+u`1gDsY*5EK`%Y6yG2zTDKSl! z?v96J1Wmt!{SEBy14by6O#zBsQxhXQf*}F`JT0}aQU0gy5+7OY=l7zVNlJM=FgeLb z=%S?TZ0g;uT;i=Roz&w&_YQms-g6pa$CVn`*x#&eFmo~E42nbOVa7w!nR$8Z{QgRj zp=NCo*QYw~&era-Qd#ao`FsmW9!_U|;>u$0{XHH7RpH>9HUiiv1TM?clK82VHRc3h z^U382R7f+ENzBfs2`!=Je%@}Qs{bV~e8>B!{#=EOtb@ZZd-gD`-K#H~Pe&?hXe@syzLa5T=QwH)qi_}RjU`BQfZ64a!*m7(&A)@SU|hw5XSnFz z#z0IAO^5@BTS+1Ld7(DoHAo^(*p%TyL#YyZ^q;V2N@8PThWwc7kD0tx zqs1Y4Y!cjAn8u?rO-XjPz3vc7ZPA2=%lo^a;!*v(gpH$G?cDP@6-XupXyFY&FJe8?Ly^PA@H;AiD~#ipdpD|E%3>XZLArtnT%7 z9v%h{l0OE8Vv3iO%=6S-T)LVX8Z;fN@|ZuO1e*4u}fH>28wd| zY37^Ie_>E1G34gf@1^Yv%W2@PGSLUkXfUHkH)m*5;S|~CdG`5&UsTMx%)g{u)E>ti^b=z?jy~tHG)R4JPbk127 z&-%c_7c<8aCVWv1dP4;~W0QncTyFp{8i9n>VQy?))OmVhPU#xaQ>Hv+&Tp-=(2{z3?Ne);XZ7}Nx=y}(Fnqe2At!-j_N y_*;|Tt|h;u@Y_T5t@l6~-?w4o|8=~?4f3`u;+2%PPn)dOF(b|y{+g|0d+$Hg+tNG$ literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-first-program-editor-console-86552d68afaa.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-first-program-editor-console-86552d68afaa.png new file mode 100644 index 0000000000000000000000000000000000000000..3b09bd8106dce80f8655b9a01f6a3021de6a4a69 GIT binary patch literal 85382 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

(=h#cVFjH-w%e90g205W~Vq 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

mMZ)6fQ!e>e?qKyQ;%I%4bjhUWg8# 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%0DlYUcR%R`Oco0e7azgWn?{dd%JJ=U_5Q&U+w z8fl}~wY8CF5aCTErv5w5D#g9)9DcyM=<)$&(}eNe)|R$I!Ki+@nJKW%OT*@i_y92& zv@hk*nnz1}grxr+1p(jan`_RxJ)g6b2A4a_ulDV8lpU-4qwCw#Z;!UwOLgHWi;G|T znzo7Bi5lqs`!{eZZ%t|(t8&C^k(-nuO;M=8PML(t<=dBU6D?9Jz zwxI)?ADNjseHnwY5BWs)Lu0?*lh%8h^dQ$eLZGaKGHS+7Id5<$1|ZenwAE zpIDjgsZEQ?0YjD=T}sBMbjWh^`}FCWew@M6=OJd35g%bXBsZKDNpB}jg zCAeOm?VFU8+A^%O^BKg>V06?JF;3mZ2O%2VqMob!)tl4>&P#oK6&1BWN=X>{oKL)I z{?D5D_DyH^xsKeOh4yNnwl1C2%iG!sln6zj;rQPICn_Eb3oiBo`eMT-?#(r3Iaw}q zB94wu$Bs)JKud`D(0x7G_DJ5jU}7@2G@oE-h;UUZeJl5y7X3S{t^Xp;=U#UBxckJt zXK_vMHYY|!M8dmxtV|n4X&D%(D=QxaWBT=68#wNs;)6;+ObiuO)P2^2t*@?blTT66 zSxB=x6{FS|cRJkSW810w&$s;R4#2O4Px*H#;q^Nj%-VMI$_HNEeU3m5QK)?v_v_bf zwrkg}xsG+2bC&e~_;HV=bXYznJ6j4oD%CEVaSjd|Jr20EefREXUE^sx4(WWBb#%Iy_hXk-ynWe;#hKG;`WROk`jbzM^NdWc`kA9c51M&LY|Z2K@K(? z4hI;Z9B44zsBSu+f3=C|85lGL2%>)U_69{o?KOJy=4X3{!>NU9yW zJq*&p*tD&`zd6ge_S?5_1Fuulk4PPN?CfCOy(5sDn+s-uFXAH9oK$(`ICk_~5 zf&H>Y3XUhM>%OIBt%UpgU%iXG)6&g5cj|*Cq;)dymU&tDS5{s?;tBI?i8W2A{>M2D^JcH$I}d50dhZfu**3Key=@a8*q$_WCRvwws<;I zmhP`N_-cJy5bx>8=W{7=&)M}i`g(f{OuD3wi;Lg4wid9rzHD|RSY5p_;Y=VPb+m^` z-^*dV|E<3HfTUz-jX$TSr&r6Js4NLP#RjE>u=CKa!nF^H>GbapT680i<+#ila*JFW zew~^jVqXIOYN{HW6F3SyTwg%%Ar`=+rKdf${anTexVe&{z&T@Itx&CBeX4m#S{#ykcLuPz zTj=t$foU;EHtC-NXJjeuwm66(W%6a(+KglPUP<``#411vJRCaX2cD@(?S&C2z(bd| z?9FlRxeZ}M8fNxKW@l|9qO@DPx~^VyT@Oj~d|OM;SN0XVkOu=qf#T{1TZ;F2_9NlX zRtsE%69aX$oOmIHkuUi2y(D9P~AS&YBA<*rr-ng;D=JRK`NOC2!qoYr{6z4xT^p(00nnN@K zW}aNCW`j2E?D`g3^+lENSFbLa-Wtv17&l9)AYK5dVRdBQ==t;KLQ=Hh;o)zM^@mU` z4gwT<*+h@=+cRD;!2Ij=?fMxMitMmv1d!Dc69(|Hk6o?%TK)B&jDT~+#Uq=@)!p+j zElMw&JT8@!{UI&wU18y~`FZI0>O*$cy)cRzCm4c-J1^|w!=&es7%mr?Jvii zJ2-v-K<>>x1OhQLnK?T#n_N~V;`1ex$4H8M>!hrV?7G(O?uX3{g6f6N&dcxK3kziu zzgT>PO!cj4#-WYf||(vb7uZ1q21yzBM(x zFDdaXcMuj9X8R(OAJo-3zcfERgZJk=p`^58O@)sNo8>XdjKq&0H$kX|fS};VPoHXF zXoH4i?7v@ex*i@1#VfOKYNZ^^x8!wMo0ypXfEy0=NYVWR;a?x~BzkzbIgx~2xGjk9 z-kHFJS2&MKqv2Op%ggtzCZ~S>=%ytqySY^ZC!SBD4h}#QR%uG@J)XqaCbO5}FR5_L;Y0_Ag7;}@ zC36h%9e(HA^8cwcwpK4R25k~n#OQh(vBMFwN?n_%e5SU}4D$9;-~<1VzIOS6HOqnb z%+uMQC@&YB&%+NI376^p#d8(a&JU*WLC$VT$53FL!X@fDuw*AEre50L)&49sbt;ht ztB5i?C$pA+G^NZb-5|&R=~E~2J~eaFGm8;*v)V=mm_{NGYj26-O)afs$0xny_Mg4^ zA0O{YyXE`rBFmfYg?V`ZDTiIX!2W<=`^n)mRp=vft+B7W&C+Ch9&^9-=uDYNPf1PP zVorLaK0G@;?OR-|0K1xujO_Ly0jW>!@HvNd(^CV*(f$2^Z+!3Ul)KMXnv~?{UuMx! zT3T}1O9SZD_2S1rq^2sEvoF<@eVk#?>6~dDB0@sGp`j@7b@=1s*ur^93JsSMQq&^r5fo zb12s(ZclyP_7+wTl*e#ywn4}WkeK94`uqCSd-hp{2nKV4E=$gFWTVQDwoRM(`J$n| zPk#S?1BQ@;36?EdT^(=Ve$Tl08pDHghkUeeYc#RMY?Ap8MrZ zg&TOS&+g%XQ(=n_Skc|M<@VMawk+$Oyx^MTMLrotrTDEs_5ra(}415z--x~XYPYYu8SzaRV zR68OC4u=RG+{sD0$7MRPw6u?TM_U`X;w(oBvgrz^`GO&hd{+rCTS-$-s}7r!hK31`A3uibOMXBOwwH9B($_pF zTN8*D61s}HV1NBoyg@u19&DK=3-?M&O9TE04T~g^rXcVN1*xnEJcd*qA8UxTypm>qrm!c&-oL2`t#bvu))IKbn<9x>?QKfE{{{@ z7dXFfyUfuLDRJ=7q3E>66PA`pT;JW5G+i}S^}{p6%6^=!?69^992o9!1CBvKT=I4u z^bN^sF_jS!5$97-nDDR&bND7}^{MhN9==#ylmh*w!&%d&*{pQ2bNBBbMSUiO4fEGR zF`E1~JHHWv>P{av-OSF;ZtNZp{a1nQjI)WkIn?e~AU6VB-ya7AghxbQv9^x$%JU2F zF}Qi>?6}M{14b#jW!C&Ep~6XDzC*dncql<8)mRhje2mGWT!xOEFkiw}bT4$zqR zo>E*I*lRuMzeU^0X-h(a2pI0hMUe^1gS9&Th3U18KaUQ*74Aq%|7c^Tc_+OzZ9?mS zSnum`#|63Q$eKqSTsmLwhj|IG2D_KOs{o14o@SH3P23OLjK`yykI$ZOj{Tx+0SrB( z+&em6)X5!6C14hHoHqY@6p zo}}W?EI{B_f8~pg_Jk^3HoY}RB4Sx9sK|_hh@haYH0J2&DEusPdj?J&6{03U!L3Nc zf5TV!(EP#ysTlW28q`m*7;|onAlg6`5Ax%wdktGJ{a%yuTu|D|C%lqR!$ZETk+7b_ zYu)>r?cKlX7aF$);NOUdA0S1p8<( z(UIM>{u|m@NR!`SGh;LPVN)XXza+>XN~#U{ByIA;!p3SCKI%YHQ>8 z5Y$iNU-Y-Qu(A6u9L~I4edw&-#-HNFvqX8DeTV;=RN>EGzZUyF=Dz>v-hA$Yr{awp zrziRN_dtAui2K0#02^$^>RBTFSNFyE7@{{8a7ujE#-wR8OcQVEV}Jv)4r7Hr88S zCG_2t5=%b^ zMMJ(_ihto}VQ*S@%^o;VC1W?MFXB1CZr1Yp%!n}%Q3{tM^}6h7Gbf@6Y+~kO&hLWw zp?97wo02$wTpYGDnoozcl+@~qacI_J_N~SkOsK+aQ8;oVz_L1k;k}3p<&E90`A{RT zYQ@jO0$FYp9J9*EBec2_I5~wzeJRsDBCg~6VI4zRI21)#+pTTjv5ma<`>fghyfBo)pg&M?C@WW%x7XzBybd?3Vkngydv!plQL`cPP2!*w@`l zFSR{Mq*YL$QRC0FV-+CfW7+)tUgYzz=p>BrN*4@nv7RGF$a8pDTzkGrg)o}Qq{Yx@ zL~&xW`x9UXQzTF4^g8x@MlOA;WiC&!dap)x^ zER^DzGHl39!sxRTCeFc|Wsa$;JRArgVNikS6|_6`wiJ9PG`ZZotIW9eLyb-M8yQ+r zqo_@Pqq&HuPvMp^rx|A}YF7(BF+JV3bsU(7K)s70+~1FNT@4gcQx#nCFa#Koz-+S9abh?(Mk@tQ*%l-qyOLjjAfton$AcND z4P`w(+z`3gO2VMtEl72q2J$nqaLSc=w#m(2abNSHeSuoC^Ra8$Vs6IEu&@f0C}U59 zw*K#Hl$(KzPn({0C`^&qs~w6Nk#Thl$U2Nqc2tp61IQAtVU(oqBVtedZiwc8%M zy~ST)&R#AZek3UY7t%8~F07Jz8{2G5q)Ei+>T)!ff=4F&Z7M~&WxhT&qm#&`ZJ765 z6vO0&=G?;8cC0Ri*|=>AT@BlHflU=>g4b-<`MK&hUp$V^$hCD3Z+P;A4hv@Nd20H- znrYoM!o(5g3Wcz3oDk;uD^A_OOX(P-TbPV)1}u%UWN!=h%L`aLJa_(lHe>V^l@z08 z54R7BosjuM_@ER!(4L3ulP}d@y4d^-IwE$2867YKFzcZI@>FM$jX1t&P)m_sEjctg zTAg3UILd>YP~|>fWU3mq4wzWA+@{vV?}8D?@)yb@jgH>9p<8iF+we>x8bx&90%$C~ zrK7AlpRj!NR(pFdZ9+50ts!!MuDRT@fByh6x8w8ZXP5r5m#PwjR#@t0X|u)=l7-O>?^hYDIo3`-zON+F zHK8qgPYh6Ss~i;1H8ebzLMyBu46~V3Ht%-rnrb1;UUi(Bay`LF0GW0N{A5A}o#4KB z^;fzsM|pl3RUQ5V41d344Go)Z1ZC%Yh^!E>E?F$(60K}$!Pk~i49aMXIeAY*ip~ko zQ7h~yu55v@y0*I5%E6QANp&>zU^xHM#|eYc#@Q*VrKM$obJdLE0vR735z!bW;vJou zQS_V#z3MZCcILnfp%up+T!zG44FWec!%oIR4TP4;coTesKpd9{Ky+g&jgXcY9k`haOXb? zcH~%)ReJ6FmV3QO27h$?L5|@-(qYzrB(%q5bT?ic$VSlXm^49Z0WMy{d-Ga=ic(A?_Y%kmr+oCX+L&$jGI zdz1nCom22gbX!-kov2H{6F8)vrjK)BM%pNSwJLhDPQYilVQ7f9rTpx2rLE5Y4A)!? z6iZ;c3ota2ee0QJr^xXoKc8H!A~N=U&NIM=4JYEZY6Rqf$Y4R#;>YDF4cdH-CVrKF z4mZC)pZHLL;5tOocb|B#$mk#}4v;Xl3_N42D2{%bM`?9CixOUo$abMbs*%Jkr|!r_ z9@}_ulg*l~SuR<_YZub98&($&V!>oS2#%au&)F?_myP;7lpM38bOyb^q0b#Kbgtel z0ci9^=r2)yt_MLYyO#Z%jVx7HSX{^}$U8jGE3_*uWJipT%oD&pzHQi7P89WdUcZ@^ z_xiO!+1hdjuZT!vF6cZ$Dn%L=~ESeVZXaDbcth zrhpu<7-Yk5TgnbYZVV7g=2I~A?}iwkcV91-EzhV>=c|HOWS8Mlf#sP5fHcaGB`Iy* zm^sIyZ;_n;Y==3UsKN3c;|FTRNpF}vJ9fmobmvRzXZi#XmJTO)47z!EZQi`ajm~0* zMvRyM8`TXi5}WHl4t~^w$U;NLEL}IlTt|lwFxq!1qBAqYPXeRQ1nEj@EW=TW2e8P{ zYr@n+UN$xux!QDwD9*fZ8~)z+xBXMDa76aIQ4C~z%O2I@=*AxW+E};Ua75SQnRIhx zCy-9CYf}+5l8srN$-q?s*4&eyQ8w*}oVCcOHe23Mq_Of|MP#sz(of2q0jWBDge5|% zSw=0wvsqjB(S;w6f2c}~t}a-u))JU? z2YE2o#4;q0H#7cw;rnI6|b8q8iV!X z>55hcm!+C%j4>ADHBf8vS*6w!H`5CykC##WMD6PqRi_{24F>giH#bWtQW|sCuV2q* zPc@CYam%17mF4y-C}>y>FDpFW^qNtxO}OT&!`FhL2{FCq&Qtn4B!S1ngITN7guccS zYB=`QHatrW&`k{WduV}i)w8sLSi7Q_p1ICb@21Rix>n(++mFHEqp+UOHjqg@*y5q* z;swL$3U1RL#8Ep<)^5KFhnR?0;riFO z>auoSjs88TpavypS&`XUeYfgYE7OeD)=c(c3c?!_MA-}}gnGxe^x{BuJd@v7)0E#= zuU)os3o$67c^D9?1NSVzdSuTjq~PPU(+t%O43uCDwI>xsSQG-S)awqtL9#fAPFG~E zQ3wn3dkOck&rLqeh+I?PG@hU-j!#SsVlqlD+`RXakqiAQxed<A2<(SB}2$$tzPW}3#~_$)X^WIEsREufPA}?7_UAW8&h& z!jYGGA3l9LYd!-LYZB}^yiM zr(vWysf5!zVtRpLGJK7+XgDziCH~yiuJXIdN_-WX5$J0 zTPx3XDlI9I^QOzeDh6s>_^YnsBlGvAYHDvjd}!wdtHEuh&|KJXYIZ6qyrUGaWF@P* zM{@8Zf&QfX^_|<`pSNVu_B1HS1SGS{XggEW!x;>UGlI=!XJ_BHvB@R;0%U&t^yR8@ za|M^_-e(Zrqu?@Y(vcp|opDB1maUZiTV`hFj^)tUSSxJ2JFOBZ0#KOx`uTzTXT1-@ zTn!%r*HkaxMzGzPe?kru54#5Y3p}=b=u%*Sl5NY^>gxCTp&CUgKSGl^Uc7pxbXOmS zfgIO$It5)%0H_8hB-GU*mI9MN24Rbo)RP^b5|=1_45f5Lr?|`k_hw-z~~)#Ioco(^K1xP^`z;A9*_NN z<~Abqo1inY?)t61*sQ$*J1vR7Iy*P|5RbJ@u1sN#KfZroU1np1J#`2$LnH?qYTMAM zUxooWIno{a&SwzV;%9sN4PD)<>gr7v9J)!TU%KO<8pnIhvqzpVtlW#H$RDUY2XZ zY2Ze`sN4kQWoIY&y+Am9a?+B`n-W08Kg6$utdyNy zHpG{gm)|gmjf^xK`1*2p%I1yZCkcgxR9}x!QMj1}cY54wwXU}gbfn`VgZ+1;F_d<6 z_jUgg45G}>3;keLQPcR-Q`1Xc;#rG(J?96x^s_zXf!@n?cX>2wN;L$TfqipBhF!PR zmvt#lPRj9=z9)Wf#*b60J~0}^)WUN%1qK5 zNja3x%Jlth`Te(3U$JOinwz$58vQ7S|Ju;dP?k|`DQsWjfz8ascu=j^a_r^+^YLC~ zh{idym}HdfnH@WKI`-FI#xZcTDJ=Bz%3I_L3kw~Fivv;6dn&ppWI&>2NPuYpAO7 zt9<*mg3#Y-JwhT4&&^>zO_ZuQ;u5af7#I*&SNPywNuWCVtw)*!Q9JhTb*b^{qe(?s zSy`z#!!|1!keBcY$FS0r)Rd4hcGlbmZ@3#YB<4ac;20O4-6!HjQMH=t68(dJ*1XC7 z@R1{qb&I1VOd@M>Il~)xUhd)WU?(gyfAc277TUgluNYi9Xx=9&rFiek`T4P5d(iUl zOa6p8$9%)dmG`o^r&KOp)X2!reu-nyh>!grBR_IwCTGoe0(t|PfoK41VW{woK){^- zetKmFBC=D{3tpbo%U^6(m>U=ztf0GP+jx0s&PraGWZp*TclE?(+7t{w zELiK9(-3^eOo3CUvY{a`9%5ePzwu>qpI(iA`SOQCBxRO=X*u3Yi>^7*W2Xplm+XG# z`k^F-Qm@A+*7-MiT9HkBw%fo)6NvjpwIMTpAxX@xZ8cWGvsvzv^6?|@`kh;NMEO@m z?*S68Nd1X-BY#GzEa^(`oI>Y@z=VqlUCm3N))r}cUBf#53r+=x&{6tUML#1Q%z*<^ z1;A1eVJ=2}`1ov;+3(0!TW? zn&(1(JT0%`_UB5dl?e3ZjGyiJjoHAonWt}Q!IMu*nKug|?YWbrVhec22pL*gTaO_9 zEDX!0dqPZwNC@On!kAUyragS{0C*J7y6QZCsm;%qEq~MGegKBUi-B1BI=+IMg_vIV zhLdb``d)(2_IGQ*CuS01JV4>gVD;jfG0cfjZc`Tkaf*qoej~j1EcB5LWBWA<(LZRs z0}LLt{i*`c2@wdiGn&sMa@M|f=E|%V3WY*`j1At5fCbVYa;B^VIe`VoY=IX?b#*n; zWeczL;Re_s=S}2?ym@nUMfQ7Dgyk>y;M-|?0w8eEyyA2nCN|$pNe<~@jLgo$sC6?8 zY=!&nVCYt}%oLebdbOP>XpgopUpW6J%Fx}Rb&=MAvXe74;|BB|?Zjg2+OvCserzrj zysqgJbz%!}i=Y>a-admiP|yH<^h9I4bQCgah!d&}cz^&y-fKDd8(88gsLsN4axD*o z%d+Bf6ydHcLT`FsH3hXYU>tE#0a?En*%5J3QPE%e&P|!#8u`7ipXw_@vmX>I&B$-= z7AKwhSy0e!Io!m<{ZaYS$lDIsQLqW#MHEUix1*Dj8_SPpe0Fw>gz@mTN-yg+VkIW- z^IrJc;x_NZlQo+xSO}v$AYmemIy(ZH8%!j|$~krmLsgTOUg$-d@Xyela!Ulufrx;}|dNCjyh0l7F34T#KK}w4#XIy!jHwuVZlA4#*rD z`Jy$3Zv^pvxG3wf$82f9HkZCS{~kNB80TgZ)|^Se!6&Os)RVE~`9=A_n0zL@h)`!3W%{l~T~( zb$Kic(nUb`kpC3y8bKJcet=0stKYdJfLR^nxvHx06$qO5t?!ROR9kXhUL=xF=Hu|% z*1li{tp)A4RLtrE5AK_=3$Z#lzZZZ3Td|`@UxZ@h8-c-G0jYk4ph)_?X?vG>r_p|% zMLs{Ey2^JGhr7wFITT?GW|;?(#Wm)CT8G2%uWg(Yg0rDuI`kdLk%(A>4LTSw;wCxq zD19vwL|^*jkDrOP`rcipB@5kCisW9Wl9g^Npr*V$d*&5_8NqT11;&hzF-&d?ChJpO zN87cLS#gyWoHmUyAS!z;0G)E}=g(E2DF!b6S9NrbwxB7Bc4f;)fV2ZeluE&1(UjYg z07tWF=+oQd*({~z|uNZvN#S;A6MyhYw6>d90n-9O_T9Nn?)8y zwsUV9jW`I%oiCN_Hmwlal`=f*0^FwUyLNp;fy`mV_q3jM8Q;P1ch> zkCnSJdM+Cwn}j7*=&^1|tAjf-*8Pr(p7k_9CX}7Maw2fak@)X}O(F$?b5RrS5el#k<_M zwmfj`bQp`e)=@}Jj-8n@=Ku_ajQkWVb4_Nhj3NSO$GwS{hZSjaIP!|Z+UPYK@FuV%-+35%-+rWrqA56b^=(ei+F9rQ7nsz61DBV0$r}w&6}%rFvo2| z^-a%TueZloUB35^Z0a?8#-+yryh1{r8a2O-6uv!y^3N}OG22L(caMM>e$euSo4f1I zy@FG#Ec)Ki3x}17Mq+d*I`D5bZrK~ayHr|ktan6 zWE86t!FOHNHv2yT60H-co?cNV3&(Hk__9o)##NiU?&89o^`UdwSdZ4hZ|AjIU zqT2qOGT}d5H3T!f>CPN09aBkz0zM3euwPW+Cz+FrGYhPV$+!wN&a{O3jNS6Qefy=n zo9FNUKi1wmEXV$jAHEW0q!0})krHW_h6+)lK^aY@off4*lMotIvye{th#`kypj^~f(=^q`b^E%JZ`5Eu=8lM=(V&hzXt@s5*yyF#V)mB}3v-|D; zA=Fqg6Wd_Dx2{?J?u z8<~B8VHG<`DE)#k%o0l;S~<4kaQFK<^u4gdt|MCU=YMV8D*6cY68gIn6IQk(tsVdy z0px{@@rl2`6>#DA?!ka8T&O!0b*J@IRAbA9hLDi+d-!nUtN8f1gaqB%59*5swW^I#%WH0LjzKPG*wk@IIk2cTgeKqM?YWk|WJ48?Lj8I4dMqK%!*Udd{_ z(xcN>jOBJAN%Pj509t~IvTtj)IeFHN8^>j3kq#Y-n!q~=6xGWv%uGt+7IsPxLPWcb zdfWQ-KbqemYsosfJk6sNPko&GNJQ4EGcf=fT>tBiG&LL;(3YfZ|J>2Bo^s=bzWegL zM2J~zV9C2{2gHT<_vdCNVqB}LC^54}Ah8fxd-9akHiAJhsve1&xx%LVs&c&UOtM*+9PL84l*%y=qbm9gb48Y++zC_))kwI zejfp7D5f95fMXH7^tMc8swWZxJ#TjYh74m8=pX*s^E4KG>`NzeO1EWQb7oZ%_p?K% zm(DG>1BD`Mor9ui-ENU*E^<2a|IiuZmwJFt;j1---R862Y!+}bzRg^1GN{Q)Lf$^V7$*;(6K#&kPz|g+yXt@f^xk80p zFKKrjF*NK|E9VcEhiWA$qIZIrSPofkQ)6RJqRXp*+8@&+Oof{_Zytmg!S64hYx?JC z?|aIxULQorgu9@!eN^;hC@d`OU4LrpAEO7V?J&E|@hTnO$AN+UuQB~>Yg~KJ%#p9% zf*#lFgW??Std+Dpv`j88QlCF}K#q}huP6~wOyHu~gvTJD73w|6h+4*X`aA(PWZy6A ze_y`)g%R&gGBeXF7VOEpTmY}k%X4}s9C=nWakpPlH{~-1FSd`xR4tNqgqu1?2;W<|XBJ>xOkN!Z8aZd8g#%DS&muHUMbNab4XPP7Bk|i421NTD_S9@tkZF zdS7H@>;S+O>Q)T{{|qVqv8uJUvo1Ck)Ri#F>Wb-Q59SYHHw#nYoDproQA>g8KHkD}Yjj}C!~u~%5kP}Kkv@bR@ed!b-?$U5=qEnMHW z?HgZ^{I9*{*RW^x(|gGryM3E8IU!KchH{FkhKP(!f0mV9*b~{a5Gg=hOH-3<(iGO~d`6-SnHH zmjy4~Nx4&or>q`w2bQo|9W{D}goB!)Ufj#}@>$lPAT4dXqoNXd_Alu&T4uNGBi&}{WPh!FPtP@}yRB{oJ)!S^uwjFATXl1@(aA-Cufmp> zyPhc#evjVPt>h0Q0GqcWqa(~f2eN@0k z^Y}k^9)6lo&u4^%j|Rdz0{dOL`L_qhAN})IWr_@i_YDgABtP=w_AWUyVDayu694r% zn=Cv`%E% z5fMDt(~LT*jd_iUP6Pjah2E{yCtReyN2$=`+(|dR%dO<3))dKgZ5C~aq6%&M^TMC* zR~FrWuY1=5zj|q*tCFH@^A7s0mF<5%^=`eYK?^d!7jOH5D=Y6*COFg5bN&xWS96N2 zwvnpuVP)f;O0}<2KD9MW|A1qEO~CJk>o3@G7`%ue6~0I4(z2 zAG}Paa6r<+%wl7>hv!!f|NkBc-;{{(<1==2GwI2^z7Z&7o>M}{AHV7M-{UaQe4Q2i zPXAraWs>*>y>m_jX2T;&duKd6|L11ALsLZQQ-Zv}nWoF$eu70NMY>8%Y(Yr^kY{?G5-xGX^bIxB=v)x5@Fr1IoNt`48p znvjfSo2nC4l?P+F|J=TOCQA(kUE?=|#BY#(v*V^2-OU8rF|h$dQsK0%p%i&edv^DY z&ZyA-aU1tbj$gLszfIl3dus;;4TGwK3x%+T<-LWwkE1(jY2VOO=&bc5V0J&6|ctcUw_*+Wt9U#P9!F^~5#t_uu0OB>#W?g#9u>LR_4P%e}Wp z*4uOF#>3MOZ-C3%l4F@n6hrvqtK`Hx$NU3;I7*^xH|)aTC4)U5!oqvdhxzfE_uaDt zd;i?ncJKaPcKGc6Z$m>KvF;qgX6c!kz7Y{h&d!pPeU(OKPdKxc|NeFUBzAd%)2XuU zIB-W?`g;0OOq;pu9x=0uiqc1(EzkIK7GUbl@hLSt1PO$srnXiN%_0yaj6QckbOIk@ zafy3+dK%QctwiFR@;1XXzruW)_`9LQW)qt>;+X%?+#CSL)wl28`REj-v>X9I2CM;H z!n+5O->!jI9swdRB~?Fh|3p5nqvL%Kuf43SXK%R>-^YJnK61JrWbB5nn3AAST-@3^ zpRmXxFJ6+=k@GS+nL}h>;LMC|U0ogEd<--mMfnkxhI-ZE62nqgUD3;e0wInDo!RgX zN2@u%djGDLmUlBu?R-1EG*g$hIsC{i$OYw>H>46VO?_)?X{Xbo$kaHi7w>-`PyB{> z3;NH-ei^cfm`zSh=$3xlYgDn3{ACi66;6s;CXqgi903BZsKYZodE);#B;d4_GCz0- z;TWznXSN`r{Q$yVX66NATNX0sa*C%wCN6xwG~;cX31eL&BaYMlT&#kE4J+!_rv>hw zI02>wY6JVxPf!8fawMaf*dVTtRgf~B$^B#6;M-7_-UphZh{&t9iIYD|PoQwc774z_ ztZ;>y;b21?Zs^RJGgp-fd5o@6QEU!|NW00c4Xb0Gr}%{Xr8bhF@!pc>aA@;pFZl?` zFub^8O2HsPe}6yNXj_TShMFC3k^jGLeb+Gi*TD}p4>M|Z*L?e0UCgRmokC@&AD(W> z*AoZM(x5V<`CIq(1(VMqAt5ASL|Du$5kUSTxqW8I3lOm{}5`mU3QZ66%e0cECbN8-YNA}47wUqUJ10S0{3-M%!-aL9g zQPI(N2ZMt*oJ}Srr@{~m=q!S#leMxiLi>AjA4Gf(7zt0a zmuM&kU06N`>^MPC=#Z$H<>+G0A?jQ^ZhIajB?8)`Of!jz@zLq&chL1D>FKhXJcwz1 zRzss4>ZDAUOvj94Q&P5Gq`erU7*>W(N0p6fav|P)p*vN5Q&Y^@ze1Va{2Sz32sh84 z-~06Zc>*HUh-+Odag7kYGO--n?5e>f!B|~F*d@&VDc{oAn2yjY+Irp2jwEj=V5}|6 zuTmpZXW$w|Q*lwz*Wuv`u)JYE*>iGX>y<07Mv?;po)W9UR&2}b^Xfm&l1-s$yf}Ed zb|3X$(>rJ_d&SOv;1r%mMXh!9Z=wNvop4f4@qKY|pJy@Ix^)5uW!w4r`R!4i!5F`I z{`{7|KMkn-Cr_SCA7cQ?2@O=dJz~)cNyr%u4QU4l^7>e%;xr!Vj?SaKo!qF}8+CrZ z0f|CCu&=Cpo?g@u7T-!wPi*kPs5AE09793CtO}*7)n?5rL zmi;yFje$%kFj`rRp`(g6p3y@Ye!7_xc+wMg*ocMf8*GohTnQP9}>8?&`Vk7 z&Xe`@?9g=SP{$C;YIS<>A_o^7AD@8QVrn?e-{n%w)E@y4nab z=yMAT*p*J9D$Jej+>zmMsMwo@Vbe(E1vQwdvBM~5^X59uzEd=IXSA$nG* z=JJV)ZfyL|1|?;RB%+xbJk8>LV%`wf<}ZD4vMnrl1v z<1+AXh0qZUlrZ%#G{w;3u6(r59MRG`NTiRgbw}oZ{Q9}=boZL8|CvyqZOadyyKvsu zFGN;BLFgyROqSQFO>oVcHH_XjV7bY;k4v8@6c8e8ek6!!^8&coWf%s4v2x_l@0gPU z<5U?Jm-`ii+y@b@MtcTnAj|cC9hp%=GHjdUkbn^^@`^ zcZuw~|7!3Gw!xCp(%v@HBvvl2&-s?ej;%p?u)DjLa{V)nMB#~vNy2^ahfQ9;3`O{~feO z5G~z){J7KA60~pZ(R&XZFs=Cxgk1|zeb5I;&`crfb|OT;m`l4uM&!pX{94b$#&|F| zzxvZcnHmEJEg9k{e*a$STjWuk5spx9#u}>vE3X14B*M_EEj-ygu9wcAA0UoX!n4?7 z$W?q%s^T959Ucb*?s&}_C4FZ+B{U%v&!68x#LSGpI59)M76L)7Z%wEX{6MEdz^QHO zvbKv~X$gw*U^>c6aa^TmF)ZX@4uNg=j1rtgJiO% zp#e7AhmIb7pZ%*#Oxz0f&*{F#4fe;sy8qz9grZbP_L1XNM^Gp9aek+6 z?l@6;nE?2(F%`rbfUYS`CGw(cT3Q6Zw0Ks??+gjXQljDH-AVU z`ARPzjDQsVPpEr>}2_ zg34(iMSwv$VrlDi%yD;+S`a$pB5P|-mzRO+e2V!j`yas_skCLP@{Jo~6=4S}D=B#_ zJF=hRc?cGo1>x`B9dU171wp13TP%8S&DwSnG#+=CmK*_^dl{GbJS}aLl#~?e(!|5O zveNu`BcyNj%bOAj3Hgpf5I@DP<{LuXFE(RceNMSt==%L;c24PmlGta@9^)`gxUlKc zPx3f|{GrF8jbi|R0>`cQZUzd9tt&Z@$9U6eRsPJz+|+!pI7h%Y-`!VChY(I<8c@@3 z#fn`rY4(*0FkcJ)`uCoTmq>J0mgXx$!~?Ji5(&Xv`(Gp_QAWN$qH|^!>Nad2Idu3V zBs5jE&Yw7XmbJ&A2LYH~ni~3zd--?Y#$u_@bm3}avr0m_k^dMfVRBw?GJl`g%%y_B1p!QF8K1H1&AdWf^@NqjimSC*K>r7aSV| zO`b)%tN@Z=jVk-9l$4iao_$`qrKzR0nRi!69?FWYkSb!j-M0*HwDlV{JO^g!VOiN_ zsaqz(A!N=xLOV|?o{EVvJhbEK(;0#oREjmnXd@37n|z*JjInH{e)eAm#(zQaQc_w@ ztiy4G>vpp>ZFOZal`O4pwWNI4bm}=jM?UxHRlb&1nB&`V>5ZE_hTF+b z-+YyvY=rg_V$7k}d3kw|q1rFG((RWYEl#T^xU)ElXVWI7gaavhc8;JrIO1Xw699T&N{UhFXpa z$U*PZ1wpp{03nvE!Hg-B*E){NfM4me6^)~H@4C9EjA;8x_(%(11h1Rzrh>XnTw36qwX2Gc}P)QKPM4rpbu?0q64;W@bgqIAEhc9+#!w zxf9OgL14!$2Kc1r^H_i!tQ6bW)YOt(`;1}>BO^3w_9{4;({JkR%rpIPNJv!jFi}oF zB;){ss2Pd{q>is%y#hc5Y@t7oG0z80`KyIwSFbU*iglTrC+5z+U&M!_K(2&y6Vsj8 z==Sq-gOWp5R?wqNGeaD|ksBRH;ln;NLrL(I*#p8n9ffyo3t*1KPPD@=ac*IMsC0O; z?@{nl&^ItbWBn*}yA%xtHqmmYvoP$SFPd&nT=zw%jTORPBt#et1pS@*nC4c$(9;K* zbu@(>wGa*=u12YMEFq0D=WT=_7=zhB6kNuXtA3lgWdCOToPj|PK!KiURw26_S#`ez zZLPD{EVmS-r+d1Q*Q;h@`5iIFE*H!hL-q{?8*5S=;Fx-&p1yCJtG2G)pP> z8FIC=S#je?{$6J2_J33QASPj=3E#zX6sM$*9{ox`o1uQNFE_XLNqa}f35!deuFcfj zw%u!#-TL(_(kB%FTrxdd;>8!Tf-4VE-nUR6i zsj4pev|N~2AuwdaHOP2T%CKbiw$gM+$5 zndX3Qee3T22$~Bdih9nV3ol>~THnjW#GV@1DMf5i(g6~F3OS$sjcInNtSp0;M;kU$ ze5khk!Ise0rfxer7&arp%j?=0kZh|OZoQKiLT@10FlFaB5cB4*U%vvK7ZxBK{wg(f z8_vomO^1U%TYWfE3(ZjpC`X7#5V<+L&$_RQ{)fxJ{JQy>FIh-_p!ikfI-?X4T-H98 zPXrs~%nq~j^VeM#n@zFsE6I9B$o7o?AH|dI28YG;^i0a3w|)gWpW!6`_HDN3D`q{d zPUEu8aAAE<&P+F*wb<;(!+Fr4YFML*DpuRau?w0D zhGa#*Ddx^^Vn~&HiRK8r(AI{9I=`h{+c6zS_ z31$5)7Kxh;lFPFbJ>_I;FB3@#(48U~%bR5;s0FpIl*(jcv$SSjet-CK=S0>L_kDsu* zC@!}9GDk^?WIF`c08tikI@`W|TR@??tF80pfdOZGTboSdJ$eB{F*1K8-3)a(skR)lH^6*7*zh3sAcq)#S-44f zNPt`A6^64;gM)*^X^}B(91+5&nRX$jS#dBiDZOK&r>XgwKH%brlA#-Qsp0Y~(^qkE z1773c)`VW$Q1`Y)WN~cybW(Wo5W8-Q>cH^ui|7omg?omtt_;S#On9D<8(f#V|KaHa zoZN{~=?M>0C$8+@Z~F38Tx|aHApa&|G4UK?vpYU7&!MIG4%y=4Ez}jA(pjpBnHxeL zpqHhfqq9p$NN76^jqj5u-9K)k?r83o0BFKalpDU_G-s4kvYT@in{V;=_ZM*9^+@5y zy?maFo(>XB)i)O=XFMKw$3Q&;hN7f-_1OT4+d;H87!llUx7*ijZQ1u-{Zem?raZPX z>#6?Q&Vjp%^S-KT=jCqP5Jgmchq5j|?a`PwNjp)nPsBObj*Ch;rq+1ZUeUWwi?eAk zzogbq5_I^j$P{`4jC&X7!8q2j0-g7cD1yn_RQVYlka*has7q6YV%FKPzU%ejq!7w$ zC7t5qt2NRz?SOO~4GmEp#@u`ai@Aj~t?;ax&F-u*v!H26DWPLt-`IHKgh3D9$EtjG z>~}|Hr<*q!leJwY=#QM!?+z-zc8z#s5fy%JMm!o~r;VMNQACS>pno`(=e?tFQ~s`} zMQg6l)|5VI_1pomAK_GvJGb3()dUaS$}^!Bxo_bLCAgGp_3Sev?e34>P`zi(n>Hm% zPJ~N9ifHrAelSW(%F1>hIu!8Em0&0zVL>%2eCSXby1PVOYhWzhGma@5MfFT8@mafMtFE;X|nz0S|JwX;h@wOn0YgIFb>5Y&E!WTEAaOM!%g^J`Ai-=!3p{ZRP^JtAyoRlE4% z`>V=^kIk3(?`Z^wj1^2eje^KN4#!4P7s~!&2p`;{K zqJ+mkc<_819j!8mzp%9>pi-h~dKeIhqZe$2k<}wV^mzP5=R$(&*VjsrVZ`(UDAZ>Q z%cELjk!a7W?h|B)7*($gL=y=XjN;Cr*fDWt4~&jxY6pPR<0EF%=Bh`FSF3@nwu|L()<_m zT3vuZk9-(EN;GcoUBRle7#*EkoY%S-RM11aywzRzVd1Gl`3fa&k1TA&5DSzS$WfW6XZY~96v_ln3yP1b>u3=+mM{-p;?n!~Y_s5SPbgwatun+Qw z=F`@#d{=MM;oU8gZiM1yrXXtJTxWgc=r5xp}_&a|E6&Fx37Rk zqU^zwZL6WAP6fhTE9?6~GZZ{!t}A*>AD0d#UWr=Tm#Qv@@4HwQY$q zq) z4u4QBw`$kX@cH`DbUAifXbCAOkP2l?_X*llZhThGwvFyfk?)NMlt-ud9#(sV3ZM)) z+?mJVI#93N_y;1wXzP|OuC*-;4EwmaUc|+je6FTv#K!+QVc5)lpo9mrS?-L^v*p>k zx>SKNGq>3;mxwo=&K|(qDYgigcGos_9z9`jaD<7euV)D#`*vq(v7C(pwdnj)x0QxN zncMouqP?HW#R52N6yfCWBDKf`Ru}qJ2xKF@{}|Ctn71HJXRJE$9vtx`xsNT=cC_t; z-No7axFbeSM4L5>t2seI^hTcxUcS7KW~2_o<=(D&sec-?*B`T`ksvx)0VYx&;Ewu! ztdQr8tQt=mCcV`a>b&G#?t9_l#WWb+6O%bK1gJ9=_*2vpg|A#0><=YSogs>a^UI@Z zvi$nd`1Ev7lZM6lzTXX5Pgj^rplSD-{CYhlQ)>0hz}K&G7cX)k(Fe_n^b67n^jln}JBUfo5y^ z$^TrzkS=-Nf@)xsruD&gTeDc?vS7DCYgb#?69s5{u&)YXHp+1Tvi<$VlD6EJyKt!#Jt zP0~}48CC>hXW&A|2wlm#mX;&v7KMis#p$*%P)naXHx+oW{YFx|Jv($t4a)s}o;*1Y zTqppQf_Lt6kd%mEziA<->~Vd;|4XYz|JZAF^K%!P6#`A#l?)qt z0E~=0gf|Hv#YIyIZS6*R*mWQ@ zoP86C;Ml6|6PFx%81Lbn^#3J1kz%->b&A8A|;k{$iX?n%9HQOfSyNnwN0C0fj-1qk0%`UdH z@C>cj-;YO7S8q>IP4IhFg<h*p$3~GwG|*oL-K8|6>YY|>Ac7u zo-&09G_xOn$LG)o4=C99xaR;$hKMXdO1q0Bd4A5Gv_^#NsAYVHoz{B4v(>*-7viMo zu8}NV(@(PEtZ6l%jP1pd-iu6mTj5DLikvTPO|6aVrVJlMymHY%koK~wwy8wNU3?hqb1;AXT89b4HF9K zBDPERV=ThP=BANAD)`lw)D?Mv)v6Nv=Ta)UbEu82-g=<>iztd9B_~^7Z#GGOj&SBH zR7FYJot8+i+S)3+|Me$PHMOlmoP#&6{9q(P()md5rH$$FQh;Fp<0^VO`l~syIU#M? zHz}FLJ~o-Rt7TZNgLTfJ{a?@Vnv3Q@M~4J;ez@DZY7)=vJG=LQq61_wc~VzRL8Te< zyuf}#4vu5TZU+gNvZLUhFwFQj4qDz=Lc;IR&%MDj5JY?4x$WDvwDQ_f;gfY@2Hur` z4*Q>gS0Ean+bI1YECk)|BR=f?X{Fdp7cU+1k3nlRM;h-31e@TeE*O zQ@$lT?BG8iR8YWz=o+mAv{CfCoF}dieZBk~7`P-uVlsPj8|R z3YsbKVyDJJs|W=2YMwqlxptfAxVg7J;Q&j$rUzud4>dIXz|$ezBw@^3{RfDHkJ zjMjOq_dkX!KVPMfT(x0{2+7n}vg$XM?a{vtReoJm-%OR`B*BU8{UqkX-_3N4>j%EN zpHuG7trMs0=E(O~FI7tgBfjB3uI))0Qu)c{l7rJbTfbIMbS5ree$K|u_T}3*e%3(;j5j~O z0JuJU5Ygz%lSEqyedrQ^o~tx?)1ayFNZ4{O%94-)m;XUR?l3a~>ClFJ&Rx6CkW8_n zRoUaWNA{Uz$Cb$1tK(!f=5WP2;(U}90_g-Od%?l*wEQ_iifyz>adBwixTDeuRMs`< z+H!GuLX=2TGqgB5_-FsQ4?)8zsHGdG1)=c-Q=bhK6e43C#)3Ovt5z-FC`OCxud@)q zMLI7qv07F~2Zvu|CV6{$$^#jumhnv)#lD8K)Q^jFpU200KoS!e>eLA`zBJ-JqwAG=VM$6R=u{T2$t(zdq3P&y#`Jyp2%Ly!#iqh7te?6+^jgM2*% znmt~;&_z*0_3c%Xvpzn383!;Qug`|oTYI1Xxt09hAb`^6r3>V9$}SWJ=-}qUSwytZ zBixFPNrHEy0=#E@)?Ne6!L@X?FF; znVsv_-Tm3CRv!#(0#?Wm4+F94AhEqg`4XIb4#OSWFTrXG|Z9< zZk{rELVwJ@k}N#VNrkY7wfZk0dwW{xk(j}4{-8=a&p!4oMrq!MC$n;L7zk;tCKW>l z8JFJDw{|$GeL&-ydNerP6m>c}1$HoLhCzJsD?NVhoTZH2s@qNiVq{e=8vKVMuRo1uXxN5Ln+zZ&?K-R*-+1cmH9ias(D4L-7!;Is zKRh!H(m)vxN%rfPh%yxMdhS2+C=|YJ5|17gREOaa;y)VBoO@W}f%n7RtgOs_rF^dG z21CL~-?L^0<4a1)UgYN9P8zjv%*}&f@os+pZ=QYod3cPPMp9MI+jvRy9z0(O~fgE%^C!R${@XX8N^y2^+3NxNf(PI?1 zr2w`Fg#K65;%(0m<%0r=lZ=;+0+66>EVPH7tw?9Z#0rGkf~WY{C+Gwe8#}bLh#*m> z9Vaw36ACjJN9|$vDkfkk&ysuF$&!kr`qP)MqetNg{`L8DQrj_?UGL49Z|=JBawK~x z^?dBX!X|B{`uWP|%%^xAxPWK$y79m3aq^vR3ZkNoWTdFxQnuTTwkg=y2!SySl_}st zk3k`i=p5qQx<53`p?Q0zn)z^Bo_kr@DHOd>QHD4il!HDbbvOh{9(?-b3AGAidxEg- zXs0x;0=oq^c9|MffNH-NBXul%1Bu6Pa)A@_&ptv;AV;P+8~dKMqgVAMxVUx+^?$$-@u8eD% zAq+L#nns(}Du3?Wb{V%@*peW-t|V*3A@^Xzp3zw7szKrid-Em~a$!eQ*MF*+j6j~bO)$kce}<~n{TEQGgBp_vs0le6O-FSJhbOmB01blVK(7%C#<9E?;3g{nZ~rjw7O}xBilZ1 zeDc1NgN_d2)-4h@W}x%h-za@~JnH6?K6DOl?P&7Dds(o*kfzUfo^f3b%1NIN|L>V3 zYM9n0;_JLUzUY{|yr;D5!94;VXz#{E$ME+=O*^?wOo&d67Xxf1;Itq}xH2?|@P{2f zF{qTc%_=CK>V|Sdx4j_^d?RKiHhFA}!{`bMK7rgtdzumv4^!S`K1FeD|2#m+NW$-X z08p5$enWSmm-DKKMA-1KnKw)I>*h&)aAf4e#EMqlL`1M4-eN6y_Qmt45x9e(VYFh+ zUwlGM&D7Bxl=t2-vh=vUYX9RwfQ+jXf&_+LpyfPX+j|g@ED%>c3JO*qm(iPODE4*_ zH?+IQR&W77eiSp~?e%%9+qc(@n0MHHAwWCg+_?+e&}~PN5_wWXb0g8P>EzXt2g~ki zO{Hj?s%Gn_6iUM-glV|TgsH#6}P&8|DNM z#O#uiqftZDr4Z_etkQZ2t<#^^S_rBz1b>u|o+!fdeQ3x*SAH582M+WSpw$4e6x}h8 zIF;Df=8KAGslkzIXN_HKPK+D9g}Aqo{_V<7(d7ZH&1OmpVd!S(fGCzKWiQY+eo0xG zI1@*hMiAzr+=NE>2E{6)h{1e-FQ+19`9dnuj;0)tq?5}b078#wlc;E|to|?}nP(Gi zh1A&CIJ@6g4vsXZ2d?AryQzA@S!(|_!Z~3x!gZqm-tVtZK9z>wiBvV1La|JKjZq-t zr$$C9JqI}WRS(i^0=*P*%41*O;WLWJZ56AkVEoelW*r&X)yl?;keze5d6SobhO+9- zoBhMX=>->58ytokI8_*9ENpFT_U`AmDly;B#RWB-$G)M5bw<9&sVIYeyPM+>N|F3u zTIN)m4$e$ZuXB4Zxx5r~d{)6o%TbvHu!R~VVfx3{_Od4FF*%-cZ8a7|r)>R{AuUy- zaa$b^E_~!!Mt**Hb~fbgb~Cztz1Qi!hk$`|KeuwS{M7wN47_efuE}jcSSKImt4lR? zZ0vdmCZuX24YaCd8eFN(l4@;55-4dN zEUgWQt>KgeBQS^JQN-13F1spWV|6d>y^c=Ra4VNq_F5&SYCih06GcTpfO%qbK(-7B z@dv$qY8}Evs2b3H5QkVyr{WF0!Zxzzq?D~EsHQ5Knx2)0dk!Bq``nbq!hMmp@7lqx z_V!JdmOPnet%{}^i_aE4;dB0%yC5R7H1*Pw3dYwGDA1$VBeu2|VC{j?!4LKIehEKT$YgFL9zDY*%hx7xLqiM%^xoMgpQPxe%v`FD>Fs5GDpi+C7JpGo zdrMME+_fWWl?M}lTEx7J0FE_9Juy$knTlU!wr;OSaWr>q!L{d^nKWswo&~G#7WeY* z0_7NLs+EryMz7y%_l7*_42#G+|HO8vQS22#BUwrcVb71LDY~@r{OhH9{qZbE?)a>m8$_t}X*SV{I*m|2gPGvd%tn-&|oB+i5Z`Tbnc;kxdfcD5Ifq zKs4(H)JUT-!5~5zHaqJKf@`ow+o!}OY=elqibnGmTT>0h4_v(T?ePOiBvZsf>+i4c zCC2MqO{;x190(|sL0*;GmMH;|bO5GezH<(f73JeQ3`LB{_2G(#k-w;Uk0N=A+t@?{3_0E&Q%VOYn#$k8gbFUTkvv4H-N@BH@676}wBr zY^0G9s=0f;$%Sct?C*c4!~2cDWcvW(2vi5_8kpm>)d>Vwg52?= z=NcS4N&5*FqeyXKM)Sbdt(VW8J6C5LY|`O=%=FLFZ;7ao zllp!-3ZN5;uSocW_4o9wBO`5W%y3I$aemdCANR&-*^xZAsa}9Lt)Z)$D!%cUlXRhL zvsvLw7uNzQ!g)7&?G9k-fQ)1JGbw3(UHJ9e;4ypSeKs~3`UX7&3e)A8`nXY3P!g(d zu&_vW=GnYVcolrcV49pQ{*~#c+)C6>kU}CAA!qW$e8L*d;F04Ew;_2)00JIH?k%zJ zrPR!|4qk|lwkw;rZit0J5R%9sr$hDS8xNhA)ARx>srR_I z6?WKElzV!6LniNt81!(=WE@pgKWRTDWf z-(+N*RaF)2UMT)}92qClznzbtI~*491AZ7JaM`Cp4w;jr(71va)ZMOKR9iyjE}!)& zblaq*rKR69MfQD9;hw6+IpRTulLK)C$xw}*t!;|Y*d@SQt8S?Aovo$wl<{VgQas(Y z@Oc?IP)nW>&xn(XFI51l(=lV=B|KL{t8&q^E0nE^i%))IB=g8fYhmw<0#CT})XPG9 zL?$x>B}#|F<{uYN@U~qR3_htObxOUhcqVRs(Ztmod>d#ZkL3LP2Lrh~^faP9R6~dlckW!2 zQ}$#~y%B^Gj@VYM8jV%!O^J;;rc5s8TaGxOC6MuOQE|lgpql^Qc5^$2fzc@|ODk%9 zSBl%83G>#Ecdh4jQP{GUUVfr^*FcDlZ;%`}`)RwbnUT!)k!W+ZoCL3L`D6Mc3HxbG zUApEYqkPNQ_WJIQ+dnZ$l6~H$>PD4l^_WB~+uWjLwe?{)X}5tv^`}-hxC6Mgs^HPZ z?6Nd|*V?Rqw6r#|*m`hkz|+$^CM!#{u&|JxiQzQxr74MZ=1yQYfYpzm2Q5$Mg%TBQ?TFY2M}EuYnK$$EJkCLYOaLjWkNU*&GASzSC8J^D zV`?K864f(m3LX_swO`A6!vi@To-|lUpWELPDsCr9O-+q6Z*sHjWS1oX#oEhd0ypNL zUYuNDurwTL%P=uFuf!#*A_^W`Wrad*xMS8@Na#>cDF<`4_*i}b1TO;y)Cwe_2AL%$ z?7xYYCnhC@v*<(EBdv}!2fGCb_yFzLSJ%^-o{?#|)Rqw{?jo5XBy{83c=2d^9$RT! zhH09?WN)a*4Xdv={i>UEDKGe-My7t{V54^b&Nuas+S-B-r`lOcw&3m;=Xp|{ zJF<~9b(cN$(agTi3EdgB0*hrPsWE6im)!NBwFXohtJiRP@O<1k-_rryJ&>Ymtx02z z$4H1c!|X6QcSgO}(5SvO>svODq@&phBO@`XJvI3q&JwA1N@V2j6Yt4cx+i*0n^;;_ z@nf}o(F!_y6ZheA7-%aNWx`TEr-$2MP2Rw*VkMmSPWPt>8;(Xh{OY(ZjE4G95X zu8T>aAwiF^(r1sX)o{x=dhiG2l(kgC0xm7dct?o%08`nAKpnXljMrtu9lwpAP?2eF zYV2+Ay2(jS$#_cJMO#Kjwm$2{9;L(M_A*JH-JjF%ZGQ5bvljZY2>etj!v5x8<(IZYBl;ftD>qZIxddSVRpE*DN$pz zwSSV0lg2|W?WT=lgs9*2kcG>~Ilo6z;VJQYn~v%t4P|!f_A%>7H7GsIf=M}_XXzvW zi>LDF(*f4=6W~2G8gyi5w5B*MehAn-I-4bI(ek)ACI+RP!?dt7sTekcePAkN_RYGo zd%R_KLAx+R=*B=1EfKz^nR}fR;LEtUxXKtWhE(ffZ*eIyxEgd% z4b%s{wLKUjY;lf4rxbXv+}Jd$&*m{#$$$QaDj72OK)6YFTUlziwYAsfIp@6~lpk}@ zI88?89#E9G@z=y8_}^LqQ6S4|?@)7Mx!xm5-(xAy_Pt9zqpcmXkew|rugE>`u;Ur& z&x>-A^5o5V@iaJBhj%~&?ai@?Ge2^9B#=)A4TdRZX^Icp6v?4%h92@Wp9Ns`J?+R@Mu7#nVX7|fsxIDCjx ziuT@O(z*T7X=w+ksa5Fn`GZj?TeLO*@4O=3`^jLh~Z`ZlpSd{gO+xrTs{&{Ke@Z<@^2p&wv45}X9n zklj3pPVZ?X!^Y?VSn|Anspjx+i3PqtHSufc6{lWEx}`w8ks- z^z@91$rJ_;G}UM}slh4z!uZFJ$DNn_3baMKU>0cosRto~f$v-!)3 z@H!WCT3*IvQ8<0tI@7H43G7i~0RF))U5(E1!y;W&4bpHgN?zXDj(OiX6no1`P8!C< z);Baf*(@P}%;5X6tFza_Bq0=D5Xv^+W?c=7X(2gZmeuYt0X{w-sl|FJHOE`+{fltz zSZVB9+J{55Y*Z@;M06ht3d)$rQIU}n4z?qT8jE4mUngOOy&vEOQJaP9@Nn-gx-4gW zB|%9=&j}%ofU`M2bu+t4(&vt|1OMy~uI?xHW;L`!c-tT6N#}+7J+hBtRJjZmGZR~3 zXSA@eXa&($3QEcz;?}&e=-8E4@sm^aN(L}5wu~&{ zYX^x#M+|Hb$wVg?NQq~fm*adA0GAHFb3H6B(?gh`1y7O0-nC{Fb7Ui@VwQ(RmhjZ| zSXHiQK-E}=+XKq;0w$-EB_g)_Ze}ELQu8*S*%m8%l+QH ziHQTr`sKX}sO?nsx2SqEN(ASx1Wa~xE!x6I*D{AH0@sZp^1HdU${M;G%%ym)@_=Rp zT^e>1%hHb@&k=(~QK3BE#UfhPBfm}p`;(eXEY%I&`WI|%J8j!_QvxlWhN2r>1lw|R z9GuuWVy$p~Q&3VxTNgcif*R%XSdM62PK@U%4Gj(9sVOe8YAj2uZyz6EZ{)9b8Ogf4 zwU&6umKWR*f=a~c=CY^6z2${%qfq1g%pvVl(1PDQHg=%;a31f1xSUv9`{Yo7iyy*P zo}KjJY}1>kZ$RPEx_3BhxFbzmj$d3n1V^)B(Zd}#z7AEpSZRyfb~iR_SND_$2nZey z_4b~*o}}tiwc`N)VZl%gT!MPu{;&fV94$7FrH=`urQP^C-&fz5G2wUf+<60RcD+mU zn_5%!7R_hBUbvqArVo4@`kl;G!9wwQN4VrBhoY)o-s@Q;g`z@TQBh@RXvmF=a8_4F zYbHf0$3{pn@ldJz8Sclm#$i8fwH&4Im$jr%cU_HFr z@^A-J7I@xCUfQQESdS?7%KCDYc1k$VC~#cb|8%0HQ(}I)oTqQ@0d@1;l8&L~F2Tb} zzEwLs3bxmDO0p0^Kt|JpMPDr!WmVoh($33%od{}N!D`1Cml(RP+}I^Y%*`j4b8Scb z3;S|fXPf-}Pkfz^T|6Wy>0G_=W4vKWMaAzDv;SS?xIpEB$l3wBldhCMcflWz1H*$9 zeV?4NCDyUpQwAx^K1^SKX8+(0>}5*|G7ic=j8tgrPPXkIBW}GLNww z<+yIR_KDuGa(0>k?QLkLD}|?KmSHy8zIySDmc(kP{Bo7(m!s_e&NNlWD+}Lv3O|1~ zE9;qfA(TVQS@Fob;O6udtPmQnx_lNGEiMydIfxloeSak7j|gM?#-dPB2jN)7Fm)QI zf)J-U`>=VdtZyA(pZ}h~=Pp(&nI1+>CR(%L+^bV5sudtndQrV(Yfe&#I=3H2TaMSw zxMCF}!nGrovif|LoQfer;G#CQY%zOzCbBOoCcCY?P&)GEzvm)EU*~sSpNPz;8Ou4A zs$29B;jp3oT$xP{xjUH(cQp>Q$^5Ri#+3Q#n)dc~nswF0Y5In1!THT1uaM|!D0sd% zZ`d2J|0jrI-iE}3)BhAb1H)q7=;a~*N;zK`qeU&nP3QFW_NK1Bd(T$t|HwNlVsNlm z`dfK`1FcsL3msj_I5mf_W7IxQIMguk`l740GMq^A?Vup;WqN;uKY<06paxb zaO0|b{#`@sB_LAYTtj3^Jhj$cvl#E4q)(t znNX9@YWmZIjjeg67vT%M-Ob(ipE&vp>-qgpQ=ON^?&=T~n77|@59a**>#qsB)?A-E zh9GoB*kzn&_0>`eEdz#9lP+xAo#u22lE1+gPi@Mie7&GCWul!|fNx zJ@grGJ)}*uB@ISu+B3rW9N3qgT5>EDc5TqQHS2ILSvMhzh|`AC{08l)6xV?P?kEEn z2PzVX8_4Gp5>gur2C89Z&436_lcC4Az5lM$WCFhsfgMBu3P^X#E|83{csu}xK!%7O z;`j3HTl1`vP*5d9!rBQjP9mB39=jyQ#wp^--l~#Xy3SI95P+5k7 z-d!)lQ~^jfhyfFluLZyK==E8}yS;|b?iVgu(QzU6!TplguHP^qJg}ug&bMST+Xx~Ela2x69{@(N&U7C5Dt<;`?uLi6*;v7Ed=AUR)zxresy(QasSD4-40c7pjceCceYg#E=I z88PX_`G-t&g?|tRM+(xgi!^21V?@X?;FNbQY|s=nY+4rs$uLs&m=G#}8j_HhST@N< zFMVz%{>{Xgp7mvQq%=0}gs{@UWiusw^X)6OjUywGfA{she$GzT_maaD-l{q+zJ2*d zv3pi&cdgAWyI@b59JoZaRd$BSdzt?(LIUA6rB0C5aTU-mTx8xX|J2yOgX8dJuR8Gz(pH9efOFTMLQ4x(+u#`ip7fx|v z>rIZ}0m1={#1UXIX}RTFpdHP;86K2=ana%atiezWDj4D&!NJA<7+S<-kXBVP&% z3Sgs4AKPejO*nIw1(}tk%=xoODF-K*H9dM%-pYxK+n@h6HOHI~Gj$aCA^#0iy-KpoYFEINUdXj6I z8XyPQ*gtxe7u+XE#~rt5J1DJ(J+PzjDu1Cu_+C=W zPiPCR8czs(N7Xelb6sO4)_uEBs7kE*M`~7MGMsFwVV81fV>6M34%U}H$I&~l&h_tR z(}cnxT>efa?HE*5)k#hLr-V7Yw@ki!zTae7mF4i6R!T{U9mNAeKLJjQ-cI!%HHA3= zN{*7p2C^!fmY)WzxJsMz`JaF4-6>YxEs11(s_eV3cXmG~+ZSnLkZLDvn&-E9)0T8w zWqiNuytkh`ET&@*sqO9K3!H`dVaRPkt=lkWkvrALPN}%j3FM3|GTKLb+a!~VU=fVB zk{Y1+>w%0rda8MDa`~#k+#g1|uEy$IZz(Puy1lu{CJ{=WtI@I;+|q2_?mYqrZ-1(7 z2!j{JUAzJT2ss-id0i!)@QVPU^> z`OawGt;d#`GT6e{|A~<6>dhv3Uv|b|QMgS@S&e(#4|B@S>ahtpKYBtY~!)7xi2-~>P zHLmxAnhBg)Hku7v=E2I!cr!%{M`n~%fCFp)=*6a6&Ayu>BVo6zffv!t-pyPt07Lnh z&_)8q^ORR=$aU8qf6gDn!?lKre~4fs^KTBx03d-a>O+@~3W3A{ia1B;o>9E>;s3hl zf?;cNOAT6RPb$fWz?-TUbi>u04_ezI7-WWS=J~T4H@Xm{q5DENfaq$6KZ5TB)N3Pi z5103$m#B+tQi5_#sY+R+woS>-#4dvAHQ}R>hpm6$e?cN3r0@>>5_g3sv}NosRQ5z@ zV__5ZxfVmO*yqpx;NoWkrqX~3SHW#f0*D&+|J=WpU;ImlCvQ`N#?H-73U4<75bIG! zFG|y@T-txXydK~X6dd8;BnSE%6cY0RbyQecbcT1uQi8^}Y@0I-*-!l}|118Zxn&#F zdq`y+wWlx1}=_*0=Sz#P)(sg5=g}2eF3Y$5TF>zZZO-GLRx+{Bp%W{`nwZ#; zFdfb(=S^r)Igw7*U->xUx8qF1Bbt^MR;rgvn4h1rGY;(9K=BEtUQzd1{#aiR#6?E% zg~6pNmi{A~hL7K``tX=W_Tao zQReJJ@7enHA_Tz!NYIpI{^?U!GJ+LV1bE$lC#f=LuV4uo95t$vJrfo6#HYMq0nH?v8M6U30gTbM_3x{dCNu?F_|fd<&ToY=$AeFz{@b^gtzZ;u_9S(CqL-gJq5gvDlN7_tp32$E>854|897fi4hGT0 z-|7`#4q@u)aNbYFVz2ii9XM2bu~+p8fHa{m(O%-?N^rR8;__$Gw`K($4X7&8Rb%mf zoH&Zhnp$ZAmhEmmxJil-lcU&=WGyY zts{UbHJYrRd(>2rt}2pILNA3A9j{#Z8gn}AgzscmLU*}u4-YRgSJd(!`S_Lv)iKqd z>nR7cJ3m%xo>kHN@Nf0d^j5_vhMd=1FPbg9L)6dDiza^j*sq+Jn%Fq*K}$IC>|qu$ z@X8ErbQDzsk^|WM;=9ZJGJv3L2{$qc88)0mZeZi;+DfV{?khJ{h9iio+T6)xvI!JD z;@@IR-#+sSIbG+`14i|-?_kDedO35qKCQA-1#(VXZ!l5;2Q1`bF>oZ*^Ch(+w(Dy+ zv};HS8z2|^A#dbEvk5vDE8-17jr28c&D@pj$7t>V+Iq1PQdN*K%1rPN!#?jj z?8Gd*HLyiz5o{aLGB8iaEI%js%Fh*`ZCCL3k?qqCCDxXpNltp`AQ54%Cx6@dmJXRyYhyuk3Aw=g_wPLizciWnniEc* ziApzB&dSPSZtQJ_#;Y1YozTKCx3J_tM7~x`+PnELOdRd4QTwN&gBvXj#QZX$lfa^h ze!u_DbdO{bQvOOxw?W!<_wHR(O{0_!a?5MhZ-I}YCnw!^@7dD`lL`oeMIqyHHZ&Y` z6mPFtFM%VbB-MTVcpm=B6im3PWr9A3f7?B#w;rhf@uQVK<9f#XMf*4ns9K&WS)L{s z$PU%oZjsXVZXUSc?W_F52;lYz8p|9hxT=L0o`5#EDHaegE#J1=ARttJXxZ~4p?Gsrv`&-A$v+W1NSc(~515Pk_%qQIQ6 z0LYUu1x>^tPRdeU#`zD#rBUMIw|63T)~VrMgQ_Ysz;Tqlb7ij`$~pMZK}(NZutnQ2 z_2(6wsjekt%YGG(edBbj(luB22B@Hb5Roz^8XPsl5E>*k^^VpwgBKww3foY8@80Lu&zTui zEpUk@wmShxW=W26oEU3C5(a8ELN~zToE2jqV9VnQaxK-=bYjXn07EOuDC6}6?|%N! zKyjOQ+~E}ZPkP#BV3dC4>bus0)PlS6wo4F->p5;Jv+CzH?=hl}jO`lPsH8lS|_0 zi;L7}w0&6qSP?AUkW1HZ&3wwJps$k|%jutuJDE08Xt_Yv$44zbCWL8#oN4!1>snoXte(eKgyXV|u z0YIE)UZxere|`c`4C%K3Q=mRGGD-SLpm)A)LgxGT^^FaVoG80%9;C7RI+<}@BIljy zv#~JdU8dz#XQBuH$&N`#P2gV9JfH0l{M8y=s{n?1IyxK$=>D4fHDj}@13qR2mKGLpCTpsZ zo%`hT(NRm1ZNld6Zmr-67kKwDXW}xW-;V6q&|dE9*I{j4Sm0dsR?E^SzxMrRNzbpJ zYw8;sV8fpEYCBK2pjd<6XjoS?;RWAr^|@_oe%x#3)4NTZHaq$Ie}UmR^L;o7s?b{I zpu6Ig_FL|9_qcYWaeK7B5OaJ7ocRuO_sTJ7W(WI9&cmB>3^`hk)(?| z`FyM3YOH~5JfBwxk|#-f)?&?e3}K;U!H8kx4922sPTmQaY|vy!LZ9v{>xZv&kl!ma zRT|pTGpMrARHuAq%(xD0ME6D(f2E(@tncpT=61PZp(Xc1z&`(x?||pU!|!!NVh)Qzr7Y3+mAxpDsz z^d>yVCK6DXt0%8&ppUzLsmkBB!j^dX6dv6~m*)@D6jt4mrmmACvo;S*iwL z=4hZ-D8iD%05K+xtf#JCy!D(P8B4UVWAA?ogp|T-1DSi#0jfn_Mbxg&@&m`o!HSh0 z*Kb%~OsfE_64-sjfClIk97^1VJIT1a7ww}8Uk%}iu6$fHH^-96C6Q|28M5;8m*RK< zgI^Hviu^O(y)LeNmfC%7GsFYWk&#EII-xnro*OVpe4Fww^c}huLUuNp42&&v{fb)} zsSr(oHXsN66wS>aladk(eH#)x%NDY-)X_e}-se)ZAS-|h)+{G6y6ghkI`*o3lsv{B zHx%pE^>SZsdD*Ad4&SPu`-!Q;0>3tOm0c0nJlqXE@L1UHZ;aBMoif=|>sij7fZfu3 zI-RA1s1!}-#uQ^ho=v&3l8$B1bZJvTQ&MVLX4!DrpW_spI}E|z_tosNB#*&+XQYiG zWX1m#e*fPU0Qb3jw-j(SCXAzzw!%@);j|Ii_@LT{O|p)p1i z5IuqVJ-N8UZ6n^+n* z(&M1DhqSc!cS_JMAZ`q&aC-UmS9$Ar&6*!Y3Dve!;|)W!o>xcJdp$#iqmTL}gEZ0$ zp7m!|U$9V#qPFJQ$F#P#LR&!2xiJ+Z8~eassxFA%!vJogMz@0=%OE4TyM*e=HRCI3 zx>*95;^W6-BK-Vj?tCsCqe|b!ja)qkPAbR=xv)QEnCH|so9I#xH}JYWv((QL0A+^A z34m=D^gr(`$v8Rr%2}R1eM8$oTKbQF@B8un47kB8TLtFW;@6-1fi!aM+I7H(dQ)VP z+3llV46~0uZrq<>T&2?)+I;liC;}etIF9g3)=S;FpELuk=#;*sjvONZ3m%L1nTT|J%=IlRd z(el(={=Hv5`dhkAm+LPjbidG_{&oAm%NRfn6>pqgbF&$@9WC^1q5)^jxh}JT;+>j0 z-`!r|&Ud#fr}fA5EePIigtY<4wW-}w&B!D`v8U3gu#lUZk^GZa!R@5Ppwg~ddi){{ zNB=lOKC+P>jg@4;JVCKtD|;sri3_9iwMmH*_%~1C-BpU~e~5jggdtRxb07y{l9h1f zSn1O4J(hU7?(Vcvu(Acxp@vSq0mS!^FB2duti7D z>#AvKX<8w}M$j6mA2wObg#_aq&;xD!7CpB^!dvZC?AkkohNGu zlBQDGk?QLIl1911#&j+&V1#@u!-(s z9dgtIhwFigNP1e+oJG>f8~1B9Cz}(zBpR7rVQfuZ_aGa-^SaMxqNExgT|Fb=?&{`J z54W((U2^Z9+P;q;jTuEj%*M|+#dMmgr)(A@)oQU4UgeVJMI9QoK%sY$w-GzSE{oY? zTk5}0LfO7_REq;u(NdRSI`sRzxtRjXz&(jFJaX^qZYinC{1P}x7U-c3Qg#&Bd3*hZ zBf@y8P$v<836vXAj{mV*P?)1a%98OaLUpuX^en;@5~Redw7{@iM>Y z>Zw%!iGow(M88*l>BSl9`_uNfrKCZG0*;u8CG+bgx~+1rDN)IeC2t z46QntxS7=KBe3!{_h#E+Es1(MejzvTxj8g{2fuRlFCV*OrhLZLRJ92;HQB(-O9uuk zbebYgH&s=g{+^*37ayghX9VD+CZe-cJlvjOa|HvV9Wf3=_hbF^{6T$Y43yjp-g%`N z1G%|5aCH46=%I9$fxWqpwq;)FWMFVAkH>?JY=(xjr4^3U(ePGY+#=-#eY@FJvoNZ%oPH2e)$nT3;8wvJv;K`Ze7FqaUvhAmJ&(L7CJG%NJaK zJj48K#_#`W+-7QVHls(aX7;!54gBK>oM;$#*L2^|T}7Wu?K7X3VOQPbit6kE`HkDX z?!SpoD|_e}VqtoIL!8WamcW`!Q!^ujFz(h6((_-t=+5MlCk822TQAc7rBysN$bM*G zX0gExC=kpBe(D|NXe!yL8io zaxI=-p^}1oD=@U!_bZBR{a)XSp?+Q&MzQ1D{`*6iD*t^U$a?wjWcU^}{yQ1|I~o2P z6=4qizb^z#j*~OfynodN$jnAZ6KpHBNxytsH{wS`I!DBUX*uB1W#Rv$k%@`Yw=MPc zNh8RRh~(?NxF`yB8VyEMW>t_RNIJ_`w$Wseg?G3in;HssJD@PSXV_Ei;%Zvui$lcJ zq{f!f8C&UbMmbZz{_es&cPvx>lXdU&o6Fy(`w{p${+3m2l$Ot!HzGFJDOKm!HwNks zP+De&e}&8!?U2cInjKFgGv_{1ZM22;(_GuVlMR#-G%+>GhciKYUtMjcZ276H%e%Le zKEGhoP8S`gaX%ThzZ&hKEuw`p(Ov*I;pfia9|KKZce%GDX87Ub$B%Cr<$kPl&(4<) zcmW`WXc91R+D2jCnz?8x9h`ua>9LE2KY+FG0r}D`^Z$gyUn>s+epccMZ2=ILl*Pmw^@)Pkf;b zWbzF&OmU*}-pT=5NYlddba_45UhLX-DEcGWyw=Zsj#uf6T|EcA3;?`kG15VEO?g;R zjdcf@IK$&+%@z?wzZI#eX?ZOe`MUK^-7Bx#GB0gNHKh#=ptmq6q^`%X&ceP5s_-M9 znsi&(F4uFfPjyrRD_9UMY8E|4aVes9v>_0%2!kr3F)~aI0(vP1UDLlm+>1At01xIk z9(M@t$r3bo&Lt+pZE?rEN6v#3C&oY)TGkDzdgaYMt+?(G=smz|S<(uexL7+xo%7}l z7C<||ee$b{_k+ktM>rx+%WulIcE}T117{YdjDz@Jz3f4E$fApWSGF&E5*IfNT-dfd z-+K6Buh$?fddl9X@pY@`PAS_b6E#4Ht^tka${Tp7b`b(VTU$J67f-DUq}tZBdodAN zE1-!8zEWJQE*TV92%OPio?5^_1LYHZwjoIq_>$_#9RhM}W@nKC82dy|vD#*OQDY#8 z#Sq#qBI&F*LUg2nUe*ARX=+histY7egx3qV`Pg-!4QZ(YF&q+vqR&Rk5+s0kT=)jSYC|?7_FOhV5O{ewto7 ze!dN#GTBLSu8TW_P%V1``IC;bRi?naHrKBzq}FG=4S)X(Pa?a_TMW$R!$y z=y;i6(Rz>`850o1iiPJj+m`#n*<1|>y|zw;4&oRsyzz1&ed%7A$|_#7BFr zk%606YI9eLSK?0U&>M#8DuFjYrW{|ld+#qF`d%OBtzS3$B`tc`&m>@WFdIQ>VYXho z?pFU{#6{V@;t7yYE9Smx?wabKODJlmcwhu6Wp7Tb&A_8RGNVCb0!(-&X8*O$jWL1eX@d`Uk6?b_7O$U*)0xK|J z_MW_HY29w#V7>Wi`D%Uc$B2xhEgHkM|BU!l<1Y9M3vTDaG0sovyfIg;Am14+U=tv> z-ebxKQo~Ub^t?F%5uS6yiAx6oYUXx{Edc=>Lm;C?!Emq!0d?q50~|^y?m0GCqbpn- zXe;Kj(flH?6$MG!G0~WZ&;rpWGgCt})YaMhr_hUicVH-(AdMj zupyioc0xL6@DXU!5yTqUj#|NsXMwAi1e?JwnAo>aLVYKg* zl1fNY0^i9s7y`)o9hr?>^|JM#cpq~mJAyou$-sViznT4JoE9SE1Z^_);$kjfQU@VyL!&AQ z0NCypo=KyTvcOhx2fN!&#qreDAY5=?{O-Ln)?fUT`U40LOia2K^pbTbi@NjkK9nwB zh(a6Dc-^-_ZWCinbXSXoOi1P(tMVImIR+WJ!Y|tq3Z%l0h&CyDP#Y=CYQ4602M~mk zVX^_C8llDmI;iD2cNPRrphA~-E%WFoRUp%N*084i?eKoA_7vbHTBpe9MXyBSPu>uE zsZ{Q@+>MkYugXj@FqE;VQG2?wr2dA&jbR8c8$>a05P1XSD4Lr-TH+M*Y>UTYj`Ki? zD6@8?Xd}l$lzpw+68JZ24BZ%m0Od%Tuzr}#qmwBzYmqzk_O3T+gM+bB0xQv9)iDVo*gzMO=}PA!7SXR>NEY4m-TKFe{0~q(njNiVno_ zD?n4Qg~DeOl9DQiGyr^0NKTd+t;iYY9S?wogUsI8M~{G-De-nhk~`-zFl(!Y0gF#C z7-$;;nI892FKv#)$$}n*lD2l?u;1zlI8iZ-SbjlS`V<%qr+^w3;W+Q+9XMK<03=XU z4K#Ur0yy2L_}~mtHbV3>vzwPst8}jO;*P$-*UL-@FSNe$9fG$JOWeEYC~ZYy$5pZ4 zC-NL;Zfbrb+X^l#+m=3rjxvN%U^b4K>$_a5@uRc5U%fi2qD_PToEpPYc;KuF!5LW1 zUc_RQ!jAjG+Lc+MdJASnVq%Apv9aS7&)K+6$YX*((H3}@iWUakV+~;Z%oIaB>p{ff z5!h3kg~LrWRYT@R0QE>C*%5aOw_yspONjjgNP`KeW@1_V*qCIu;iL~Ict7;hH=?n$ zVD8=OuG$Y{K$Edc8t%w^VB8SSE*hc=mf#UN3&Co+Hf6Kb(L&t)cV7=h>$2R&F3yVk z!Y(*6a&|-53DB}Q0PUPMTMuirl7j?CuemQxVLmM$7AKbk66+Co7wAZD%SJR2Q5G0P zA{}EpyL|f5<;Q%vsViO#Bsqt!UQOFgRV9F``|*v#AqzGhA`R%xgJ8Q3u_$P9;6qb6S9N+RGAm#h$e1@O6AHPFUl3_>G)6>%mT5tola=bh-SP>YF zKuMTDzeEEo-tHo^BCA{no>A-8*FRE)F0XiB^q$x)KfYLL(3D{)4Yb6`dO>iXY6y8B z77N`)G&pDuP`asdd^0FFZ?=NWtc0X&785f%&SG>FN1W{Jq0vwn8`p8?bG)tg6EwM*VEg#Y4oaXhw2uhZ5-ggtgs>nwoUvYF z3)BWs(ppAFc5NG+Z0{&!UhmbJt#{+WF|77M4aURj!&q0aEjZ%^&W zNt6{`l86Y?oga+hHYkw?tI=Ex@U)*$+o|BK@|%T41~_@lM>X>6n-R+SVZOfLurnjY z5R6AaFb_#!B5LC*UaNbj=g4&b1b?~;GPsP4V)ZN&;)2BYjp!x6@HiVfW1|9>$>|W8 zDFy@=ee3O&$x8e8LuSAN$pRu!iB1XGh`@SMT_*B?$8|TOHt;?H=Ag^vd^Tk46{0sZq+N3U zU}jc1UyV$?HE@^fYGgdz&FQK8=O4iy7Q9L=-wA)*Of=p4RS&QGvu)dxGi{T!8_0CR zQ$$S4j?}gbErT=jL2(ULmF}G-!Azar8juk{IpM7gw6gb+lI$f`2!7uP%?_F@+&3IL zu`-B*bnoC)8PGP>Q|R-Py1WnjH&(Z_wG46S{BF07R|@gU*jiBj%>J`I+L_syA{<+Z z9ou}cr=Ln}s4+9qeFUdj!+Y8v!0hIb=#O%8beHDLvPCW_qp0?ZYi2MC&A>Y5pcj9Y znd&7P5I_<1H@Mb1FO|*vd7D72+?6?iV|M#_!2p>9mkubLP=v!wYM=h|2@w^ox8PzQ zGGB}&b>xcK>p3~O2=~xj5zJ4Z>4gG^3*cVBNrQ+Ik1xLW17va3V5%mt{@AxeGE~uP zgP+}G=!a*O$zY&>vMu!-T|igun4OeTNq?3pjO*0pbQJ|mQJp>dxd#SC0@hMco4cq8 zz0^EjqOPfK=Qq*NA&=qk<|CXol|BOAXKe6L0=etcd<@cE%XHVzhYYe!M)f+Zvi=Yex3)DG;5;8v!n3|gMS)+Ib+Q@TiH7h^pp7Xw?1^B3N)MAV7N zE}CAfxoK(m9UH6EoGIozFdxux?|K4H)8m;0#PiLPfx9Bt(G#5MfdfCkvB)qLj)>#^ zyBALEgT2}v^rJzB9HjI}0TL!NYLXZ3C{kKmPOhTkxn>L{^00{&;GcOjra(=DMd$RKPi@G4b zFo0tgr6^?3IHq=&$9u~}en*}@u#Eo1eRbFn_Ew7Mdh_Op8S@aJ0*ECQ#Xj)J5;$KZ zC(`mue2R;K7vl)!eVjMMmX&zUO;M;7mnw5*Lgfg|MspjRB*;L}FBzf#>R}0x4e&P_ z|B2fV+lEzB)3egLzrPbr#ek@0J=-9GHS)k>A(y0oq}~s^0ygu_?d{nw>;Cgy_tCc> zeC|LtoxfFF!K`%Ubshir`5*qV($EzX!~F03Lsa&jy?ZY#D`@;**Q^oiV_W23Y$7eU zTBk!}){DJn=H}$*{?E@?tyuvc#r}_hYV};C5>rwd@Sz=JyX|~Ws72qt08s4 zO?7bvpe;Ct74h0;KkN@8cjCcxOdw#=>nXH(Q{Ra%!Y~rRK(lvbf>oe2f}^$S`79Q{ zO9NI2w}*p6bn37Vx}8hWE=HmAI6XmV5Bk!%3#5s9fP4r$Jcnv;!hz{BU)Fm&I2;9W zwNSLn-PPp z3zjQ-t|`L$8$Sd-JAc;1#E+0pp$x^Xo&)IDt*dljP4IE*jx}=-l|ZkS1@d2bop^u4 z%aeBys~XrQFa!h}p-|8g%C_}Doo9#R7w;e(k=Sv!o(hG{o7vd2Pf}BBKt#DP$v+Ir z#wWd^C6)v13VuV{AW9gWvd3425O>}O>TbvwlR&?;9CL0;SSY-iJ*=gLut40td|dh% z#>5l`&W}N5-v$JIV0|We&{CH8;)D4VToNmo*$H15jTX8f7JV+AH8EMU&EQ3VAgD<0 zcBNoCjEtx#dij+e-M-~O3g_{_pUX&jL-R^Orb2r0Lmjwp*b>rna;%RKK%YGb(rE(- zhKM5(^i6t;T;1WR07V0&ETrCNUM1=gyZd z9fq(5&q>q^T1c;~oHVSYfM&q6@AaD%(?Ba~p&BS68$i2nb`Uqik^{NpP$j2_@!^9D z3B=?mE&u8BTcmYg-K+<{1c}J{RFAeKIxm-ALtJVnrswA$lEE!rK9{N+TTqY-A-yI_ zsuNM!=u9!H$8U;eWJHHy!b2@1j(Byz3uHsVS?7Uc11CD^Lr-N;p&x51=z=IQaSV<_ zAL^odB|Lco6`C>Nal$syJl=#LND>XBsr}5GkbVlfdzVn(SBA%^wcpj0`SLavu!By0 zZzps)8fqUW!bMade}oFbAuzD4$cq_;pk>qj`?Wj=U1%^396@t{t*x{x^VR|szE>Ut z#?Hwwu>r0fil+-K0BIw_ZDy$KeclF8YI9~Jh5*x{f;Tpf?CG!!X*a7 zQnSYhOw^7sUcbIXcxyMx0N4c_6pw(W`J#QmP;te~X$#OZ7W$0G!3CI_^Z<~~rXXep z&cu^cU9>9QJdsX;O&A15L&a@9AHW=;=tg(Pct^6X7XAIw-OYLw|47eu>(;?MB4lR1 zS6Qlrv)imLW~~I?(x5o+P#iL@$8M?8Mf~0xkUQ=wL30NOxVdy*l3>hIg-H~|petFf zzk{DI6P~deFk+Cf3VsJJ_bw>4IF}*uoQGlPo*fxZg2A*p#%akNK!7f|YT1F~qJt`Q zfq`_0w?}H=SPh^TrqC2nReg13? zE|Se3&lB(7HTi225Z!Oj37#9IjUTfzGd%^1GqBcTz(I0M&m1&7iyn!Ik|?dM6QUlv=5k!i%Nk}hfr7tZ!6+08->)mVEG6p zkbKbI+yoT?QOipsE$wQ+%sYu)VGS6Vdp&!#y`fpa#{u9R&|9@+l1nDnfxpc{LO)kx zl#yd$r?893=-~IYf4qndOXB3o2b_2*5ja22M0m3(;m{ zv&;bRcbSBW2(;rGu*NF;0|2KN4wt#>__{HOA}-MZjFbE(g%Eeyy+VrU>s`LE9nH^z z`!2uM2`d7kz~cDwW-R8{9%AfQUZxEr;oH3c;Fv9~tK6kCR%t(Qj7@B8Tzf(|mE#?o z#IYCx)SmM+iF}Hm&>lF)KHao$DQX$JCrHKtta45~6*yj`+~_{EcG0hcXcGMwiIhq| z=kDHpqI_&D2f6{>u{7k{r-vedYYT2P`w*pXbLM7508P@t!3KfkiRXy=Np|JF2i-uI z<^T>3pr;C{8h7g~@!2Op)*pnaC@Gy(=o$sjgTgH+sMBuLBqYQRLgLM;5U5v(OP?-S zz|ePKRhGxIv~J2Zlm_8m@|-IUHofuHbNBAuL$UYYRRO+ZvsX`k=z=dams3%iDW~}Z zVhaQM!B#hXDyMWN8IhT4qPervsBH78b6CJGz@hIk3bqM6dZ0-L^&a%9Ba}yS+>rZHkm1pO5wf6hsz)fLgFe zRnyjvYtCXgF&m{6Vht!Eq^2gWif5NwBA=fP(GZ#x!ocMn@3mct!G=tJcxARl#CiW#23j`c z4?wx8|h@k8h9mV8EFNylcTj z1-2ta<7reV(cEbjj3%EJ?Cwc%o)2+K~sB98&2`-m`52*5*pYwL+FSu)V0dxv}zRGf7 z_d@P!@}66M_}7t#M#U;G9vprrf&BpS5?KdGe+JSmfV*_T_zZHenKBI)oz5zIGzuoz zb>MQm3n!w8Ch@NOjuRpGHNo?W#v9oSopifJYDNaP>`@)`WVy}gr2#m><1j!@;A{7X zwvOdsMm+5iG|IZ0j`m|S*XN@*iu+qwK*UX;^y1_p8j89GqGiwpBOKV-Ab_aDk4&IL zlvJVvwnm#NWa}26s!1fR^_^JU%qcD|?w-?z$%Jgg5M1NBAFpWNUw+;x91ty{Ne9-5 z7sg|8vf#d3Pv^6QI@hjWH{aS9C6IszF!&kXL8#*-nok$X-xm`jg1eaR2iTqwLtD7K zh5xeJO)OY$mA3dRI4~CtGZQR%qpvK#JTYRn!B74lD}=lxX!O7a$Dn#K*^tp#({53zrO9lGaMfeM>f2+~vdtCmui+er+T&!1v9?a) zr%K;bB_38O)e8#_-DAG*R)spQu(-wlgI{bB3T8FedkMVBS6+vhXPc|y+!Y7fGChX{ zm+S+^T?h`$l49<{#&UK8(@$ZW*um|~G?L!Z>bnc_+y>ZW~s+EV)A`hWlmF4@LYsBJyT~477?%y8M%q5FDiRm7f%#U13 z(DuR|7DdA)cCP+^d`Q$S;>T-w;m+^7l%NwlaYgu5#E#yuO3d)VGNuxD4@>hrOs4r5 z?dx0h?XHqospo$byLe(5KzX?duw(%l&(1vYdd)DNZNKI<*kvFPb*N(>3%Bt5x^R-> z54xAY#-|=l3p~3|?9#nus`+cxX?E}4^FR0>U;3)Ke&;H&xF1*U{MMN$Y%Mq9}((LC&J@iUCV_uu%sU$OrAb@>*tF8P&#f2$aK z|F=*-DX~(Me*vTa&sViT)LG76d{KKca)9ALs4-Xwx&1Kg_DS+W`djwzLR7Fq^2)-K}Vk9B(b8~q)7Kf$DVahmJ@vdLLcxwoLnxbC) zE7Pdnh4ZAP<2Nt&%2_{|PtO@8FJ6?6{lz=i^yv4%c@K&KVHb{&7QiC2n&zqOI79Ui zgG|(DrViHZWLj}`>yQK`vMn}dJ%&I^UHZDVycf5~3UZuPz24FhTzS}U?Rr%)v60Z# zE0t1;ssB?`6Ftfw>SxC7qCx!SYI2#nup`cun~Hazoj&Zc;FIQf)N{`^7*dIKQ~!QxYjC9@;plaj!Yj}J($Pg9OQTfiMqa;B zpy1(mPkGN)R^W}c)RVkmAJT>hMRP}z+0CcKyG{!&c}jN=97Vk@?)u8`nrjuz-;dth zQYBvhG2F25aZ%-2-*UTp{B0+q(VsAQTN$PcF9fQk-=NcrL{6Mlswh>yr%mP ze*9e8wDf8?*N(&-xO&p3DzAUf7xcN`Z_^4jC@&T-6~~djkkrTh%H#M$43)O@nNfJV zFKni8-Sdlc3JGN}v1E&h(fn%5&CL_zXX5qs+%+)iQ zibwy!R*&r|4)UN5H}Xx-43++VyJ%*`?{$MCD%JTAf*sGT(G%$xdwlvsKq!US2o6QLtxE zQgOzhbH4J%aL(xHoXcNBA?3SWK|Y2o2LL6bdOqE)Qo z(lpVwDwqvh=FGhRc(_Gc`+0^EMM24#D4a!W9ys=a-0ff`;xGgVDLJ{WpWip|AIBD%hzo-6zmJ-buhBB$ zY!um&$Qs%kjZ{q1_E@`hl%-v?@!K&@vXOV0UEHLxp!G1e2wOy!IfOAViSXGua!sKr zaM7}P=Z=ws!TAjuT>qrUaeMM{3QY$85pTAU2|k4BNcB4(^FAxrGBkSKT*RNa+g_5+ zPN-6S=JPrKp(*bu--lhACkWmzUzMHI;>V1iu5l{LeU*3GCr)nddhWHsk0^+S^NVVR z=lFIoOL)yJX9cQ9Ix0Kkot$`D__nVndYYVSc0S8{cnV^nTwPuBfusgym6$)TWjFD<*I14*z#a$YgLIoDH$2)y_Mfn^OxH7t zimK;TDoUPLmDN{#z0$@oZrz13CH{R_Z#HR^6tTfgqdc%We2k_euet{!X4Q%U-GCPxNHko_D&heob^+w>7UaNG$d;LLDeyQ zrB1onu662qVabQi!R!aCW@=RqAS-EAcC||1FGr5nSUi&VXT@*Vg^mJ|87YTqHNvQsSFTqNv6-A96wmU5k zec1Kyo~02%Klp?uFMpSq)Tq#;nL;GU12p0##=-skYqI|>LW7?y{qxyCP z?-v!P8MF!`*^HJwXgOn!(CxX66?%3M|M-iJEkn;I1~po_RuZB_p0eXqlpcS}_`GJ@ zb%EW0c*Qc2kTQ$5M1mvo{F+!zgGWE#=5gHz=D##Y zpfJt2qh~8u2Rjs(jF^Zx1)hF!9|j#i@Ao2odOGJHNk;Y zZ}NS5dh7$4GJfNC+E~q#nh(rkicg)X_nDnpF#Y4=Q&jeg(%HvkccmIx1#Q){A=};} z^GBVK>{pDVX(MZR-S>WG&5Q-Xfq3Lt+*rEHMOqd)g_&(ZGv2au1|_H8Dx6 zsCZCzPq|HaRWSUMk~uS*VDvnn7Yzz0A||biHLwJ)SGVbv6-KR2 zNxHh5NMDPeHRZfr+DShbwA2`%B0hG@5D>x zov~-^hNvj__Nl!$x?JYYvr0dDENwZR7XJQ$Q7md`Qn7{JyJP?hO$uTkn~Zw~Yaa7y z(J-p_h!87YnQKLOY2G`@gH!F2eU0?@aWmGb|HES(?A3*kbPeY=F7{DK#YAu5(}Zd{FX4 zFDp2s!Y)JcV9sM$4!^HSF*`HSWtT%icm3*G)G!|ccu?ctI^UtRT_$Nw^Io0jYo=g> zyw;v5#kOCl}@So6s`n#xv%#rza zbeUXN@9s*T&qC@JuM3or3pQHOMOTjomX=s{E*R_dH_muy>@2aNP29}ivcpnAUCSV3 zTa-ixcdg@|hr4qB>FFn=U%s*!_PYU=8t~n?Qy#RTiaQ}*w`MO`hBVNa-*wfGE!T7M+}CYJ!g|=$`>BH&Q0$K@c-p8VMjLP zmIf}B2cBK>ppK2_Psa6D6rbH*z4_srOCMhg3ly~m1^Blgo?*(Ic;{v3BOk_R6;v?; ziC1(cJIq?AIJC;Dq4BE()MQu4i*7G>2|LK|k{4LgD{w=o&T2u3Ejr2dP)gurgvk2Y zywzG*HJ{X*?2h zX)H}l##$;B#@Nd?vW|7cj4jK1pYhh`{k;FY|GxLX=XvgP?m6e4`@QF!d!PHPxe)8! zKDED#%YiRHha3aogel+Xg>^~uVY>}~Tu!KvnxWZrBs8i`(s|35mQT~_Nvs+v<27pT zLB-@lVpso=IOtJMO5rI8!YJ{zd+jG=dO>FgD>qPvz#t7~!3rtsDLR){Y zs_ofFFPNo*^AOMGs{b-puYc3z+|?Ymb~iZ%o2=@4FZ7JVTmHu=H(a9k=h0s?Pjg!8 zOwkPj`VZGSkMzK?{>g@>xVmwMy!y|X4!d>tIQa@k*rC!x4}}wlR|3Zhwcur!e6%k{ zhAWBJT81JJaD{*7{Sv;St&3vCHH<2K@WB31R?eMZ&~{f&<8=j0T6DPf4l~Ajh32+@ zrHn7Cl)(7;nA5W%_LC0$k@M#`fa5P|;>90X?S^@jzEl%q)1a%f#ZQ`gC?#2KDg$fm zE9B?mdu*OErJr;=J=2h@*EGu#3r`?;($s&4^*= zqeyNk+`%i4FBq8GPNGNcT&B$&9EdA134{{BjKBH0WJAsdS znGGBzb01V}NM)6T0k_GDXm+HOe*o^w;juTInzup=uTJ#jNb04rR{WLc+Y|#!6&Cge zt10jmf1m2Mri;kbOjBmthx7e}$oj1xZwH;fY3oBpGzJCa6Hy99`B?u`q1R0v^Qt{T zd^f3LY`A009(;wJru#dNCfNiA=7w{w4SVUE)Xd)Zn^w&k6-)to>wbf+u?_Z4kU0Lr zBx0L@Pom?G%qnz|U8zT&yI+NsmHP@k-o0yxU$3jP-u{H^Lq2<|36UR)341vks%mOvu`$STpHKK&F{UoqR=45c*XUDn6eKS-c z%v^Vsr}fqTD<7OoDK~8GJUeEpMgWw=eQA*PeyFo7)f9{?1Kwb#XYXGIWNCFxQlE-0 zu(;*rBp*hzB-xPRdnh$qR;2KiFOK#@YtMf8L5b`ZEn=>~CH>-!pEwe$af-ZiD{#m_ zv`Sw{sV9FO2aZkQa!cQh!O#?dJb)OOnk!P5$5*>-;+_6JU4sm=dZjDi|BuY2Vp6X% zdLycHar@Lrf$E}(nFU7ktb<|hru0F>M z=WqC+yLIx^qV0IH`DxH~cLin&rU<`EUB{i}Y|4#*UYq0)T^Qr^j7lYETSZilzi4}5 zl~d3h$hlvcV`S6N`i zkLs~>vH^c&b{~513MP8AYNNCoT~pEbx~R!IJ5nsQ@EOuh-D}aY&%1t0U|`@^doNr4 zk|m;C_v@;+eHes?eEamNm`CsIF9HMvKzLJ~%Wc#G(_+F)bz!f%6uQF?rE93XaMs8UysP2FH zKFbhScEnsM*YUcSZn$mOdLER?$p8Gzm@xEc;&cMM(Ch*#7GN2e0Tx}qv=V}b(tK^c zuKIvmS3co0UM{%azUB}N-Ws-!DD1cB7aRhF2Qo|91rIV|d;4O?ue1vcj~p=&GUj=y{R6=O5?`hw8_Z^gL0H0rnML zX9BuVHX7bJN9wqoh-#4hv_1_RlC8#-IF#uXf+Xhrlg@e&1Z=FaI>@Y*62mF@;flJ< zTH9)2by=OT^*WZY(vgl*kQ~#hAqT${t;9radoJ~v)@%*_qC5c?%1v>8*qRaQPbhHL z8#}OLn7WC@+z+lkC? zc7hGg#EQhQk%t}0H7eG?5p^QsXQjG;X{AXL%Kd9q?*Mxupmrb8M1pK?edF5+0uF+{9r!FS=a_T_~zYx2@+)* z)UYl}l2W}U*mIQHe>rUBuftIClD->w53DwBTPdCa?t$#EJ{p0sF}tyv7|Q`u;rg8h zgKEN%zjJ%+EB&m0r^q&+?fLB|urK4JfhX$~t;K(ieF+nirCP*}X zwT~Blv`a{rr*?5oC1$#MsMneJB0oPCsLzXw&Hl?5FE;s9KO(1^ybI4L5A6#`^)?0z zWQ&);X(I!<@7K^TfEHlSTpanQjM$W#>D5E2-oil47${?V$_d*IWC0~e+sg34po11K z=TbVz5f;3q$9e>ZQQUxb&$6|Pva&i7{qY=I1wkKjZgBB(ETxk}&_Sds(#LC;+}wB% zgEl3hx~DVkn?2Y)L_L~oL_t}&!ZcwtCEQ@$-&Y&)5LqXX(p81D?ejh=p@C;paVHM! zAYUET4~JE|6f#DKlD!Xt=lqbYY4(MR1(lVl;t;3Id-omMl%2I@0L{Je^)9Z%vBMt0 z8{p^HNtnhhJ7Gwvi$Hp+fnA}s(WYpPeIz4P#?py!n7r58We49b!d@~%+Z(8S%M7=s$}7@y8v?mgSQZcXeJ%eAAams)Goxg#(=3vv-q8|?SC z7Sx~(Hru#>_PHk-I4g5ZGGZ@yX&k6fPc-#Kto??^ng|612UKOYyzg?GEMMlo5@)}q zE{Qm;ZE`jwO&*@0d!TmfHIPA5|4A+FvrRWgU2$z2mF47yJ=^}k;nRb_2jG)`dgWZ* zi12C01DX({bj5UCfrlc=uju*qaB0=7)8~(7!f2X6&CFtUw=^o zI+!f^;GkFkS#Oun^r$F4UNIP6{v_Bo!5;8FePW_PC~#Wq7N`pc)P|5>c4KLluflO7 zGO8#7$S^7YjcE8WfD)&|L4>y&V#Ft~^Wr)8rz^+w{!+>&4Q6RkCW9SsuHPDzI-JdkcG{rwMd=40OaD~T)JiXCV=BG7{iHY?tw8vjDJ$2^*!Y^ zFNi5XVLqKQ`u??nrU}e6^%!J}lv{VA#|oD071=6-;pYpN>`MgY(@mfnTu_&!9xbBa z48Z6XXOpN6bU>uACVHRnfSJoX0*n&8H@#daBLRFR!|pT~by`+1Iz9NOC17qq1cIvp zk?S616JkZx&%mFM-m}TT`f@WfH8%oSTa`~)12yBl@3BcWq8&QVZv_0K)&gk^h9nhR z$OeszHGe_Zp>6_ZFl;N~@_uL;hFfTEyuY~~L^)0@XQgtDsKQ`<;Se2<)?W7xrLbSb zC;`E}n;TqJFdw<2S&g*y#^Px(aM)R6^7jI7JJW?+ceWmZ2_#k>w+7$*)hHn;UKC`W;QDC~AAL=r76@52WP8(fES)Ps8xY}kNNjdh&v2+A$TK!yZ# zGT(T@g(AYO2#iwc?oN0n?~sDIzCO=YHrZJ&7eq?wN<%{T8`bC&fZxNt0L_sC;PN~; zsPDKp^`o$cM|f6tHZOY<7hdcszyMsiM`; z=TB^Ll@MpYDt{4?VgwerWP0uLUl|<&_;^klyvGe8!z|p)$|~#5^Ld*ZaIo!h<>wjK z90vF8*#pwMVM!Mx4?fgAcxK1R&q!A`CFZV3k(=M(ufMy$wj#W4w{}FNfWN<^f)@ny zA;bq@4TB)f>z1^P%y0~t@HPwi5jV8e;&(y*j(MOPcnL`MKScs!>Q52B4`KhGVgSke zr|AFB`S(xN|6Z>A|8LT*lD7e{FJ_>viTWePe@Pt2+*l25HFCIfiG9SjfsV0u;W_*8 Fe*q&=<0=3E literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-pinned-source-68a784c12ada.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-pinned-source-68a784c12ada.png new file mode 100644 index 0000000000000000000000000000000000000000..0bcb64f89d257d75422af2ac7eca6b35b41fef28 GIT binary patch literal 146003 zcmdqJg;$ha8$LRUfGD97(xaqEhjfUfl1eBb-Q5fg3IZaHgtUNk*U%{-Eje_I1V;3#YfRdiZl#N(a&k@ICcAg=Rnc1is@!Di{ z)-bQLonj?^{`ar_{<0;PTCoh+;K0bbL+E01eClI|pjdY{N&}%%+ z(t7ale_rbYA@>HPB>(5tobxE?e_zjDKK`HIdU%T{?c4u;1#<82|LZak%r%<-efi#Y z`rpeue0i((e=l=44Wj)&HzRpR*_Z8qFLV3<7q`Qad&ePXj%bzc5S!Fdzp=jg(W;Q!~-4@rQBhunJ}EXyR%SWcIip7)4Ya+%-0%>MV> zXxxMFmlxWes{hYDjwF=5t|rJ%%yZ6PqJ8{z-m4)3SCmRi$g1 znO}oeSyhd(-ILGXh|0FPG-xXP}mO$sH`dd=MZyQ?FTV?gio%ta}$Mm|3c<4gCqBurn{iBw4z zR@Pc_()3RR36%BEYUbzl7!;D^RaNPzQPdwaG;*K)vpx-~5)v3+q`uJ6(Um!jIt&dB zsS<(zjQZyJzpF;HZZuHb;^q_`lynWudqyK7X=2a2TZ8i*q3-am`9Z*WXU?o zR)r7tg$`75Uk`AN-<^CQ{sko!amO9bQf!+W&~5)`ch*hI%F0jHk*VV) z`VGNPX@=T;}aA5rlx`U`OLFAI{ZRH z;_PGrs^AXF1Pq0y%9e2U?;5ziEVu`*LccX7_p#sR)r=oMy~F2wLoy2S9FgVy!W2pb z1jG{m&8^H-=4UL+fzT$?!wt3P0UuP=)eWqzLn|u-^oRup-wh81g@+@z%KJAE&K!w- zMn)!4NlEf*YHHcBrEoYSjc}5$F9bIrg(_1klv?D-J{jdT*PkR})juunZ+f@w_6U82 ztnK{zT3BIMz|476PEkI?Uj^3a^Qb?6WUt)!S>;K^Zee?P5dWL#b|rArwTPb~lmfw9 zTP)7oY~X&%TxQ%I_8oQ)58J!D-$*?a5Km}xG&}QxH+8c=(;LoGFfuWDr>i@+zOu2o z*);_ZPfDUpqNK97TdQd>Vt$FnE|YWANU4u~`Cd%=4*pwlN%w~^?Dr5Cv|MZdBni7r zCdgmm{BpJM~uh@U?O<0Y^WBliH8%VXq1ImzZ`V z5@du%lmtiuOk%0;>M@4RzR8u=;pkxTOvZhOy|m2QkQukv{%%-?mSt*h-E$Y(0vwUs zK8(+v$^IK*k@%-4CmhCmM0@+kk%@`d!+t z1qFIC1*&_}N=TxyfeFzum*>YiKAQtxQ;JUtcnnP*whu%aEAC zd%C*NFKaRj;bPZ!n)^o8LN7CWChra0+S}Xv)7{O$${GrG!ot$h-oc^6Xd@9}yf;g9 zL`{@(etOF3e(>z$$B({Y#ms&!zUAfI;-5Z&pnR*Nga6XpoQc6wCEW6>O*QDJy`Ao~ zU%q_tJfDGEH=0;legj{g)tSA6iOHqsJMh+){i!~IC8E&s#f%Zx!u-5%V4#q9G~pQ;SLu zel+&WHY=~FcDQF;Yl@T2^^VPXT_#Cn(L{7yZ~wGN68DJ{7Qb8WlZHPtKB+QhW;yqs z{u9|U^z`&!;&gr#zdDd`K6JJkFNutbdISN_NXqTbGd4E%ToK#*WSy}$G?ZUJh?|Zf ztJ8(dOBno?bB{Z?#@Q9p#^PR`%QkAHUR_-s)PF*1lCf-?u|ER?rfnUe*bV3fNh#@} zAM^FrCP#F15lQu85qzYi+!jPBDJdeW@!@j!5AyP!KYBzs=~?*m=O>WVd;9yZ-za9) zW|51DHDw5vg3|h@x7X@LLuqMbLIQd4H@QE5{yd@OCiL;SR@Xl|+E#$=PE1U4+08dA zT9>>1=no1F6=8z~Yu9)uD*7gGdP1I2l-Q&!+h!09Z9%=W6?vIa;@BC>CUqBk zgFJM6qB16&Z0F!0tv-Ed=utyvrvH3e8nxrnr*&6b4u1aloIg2!_vT)jYmn8!&{_5M zNg^IcdZwnGB|qD0jXfj+agCdOZ^aVA^J;?|x;(nN`hHed#~)IRIZozgJ8yEP*q@OF zve&Mkd>6@mLToEDU}L?j{o!qI$3Y>FgAvtZo&*Yw)W#U5v87t$rtY5Z)9p@Otue%; ze{?kAEJi&J{{8lr!A9MyTLp!=D}le};HL=)2s?v(%J|u(xfva!P~b(qXWnyWGKV2$ z%4AA{y5zo1zR1YfTg|=qCsCyfvSS`}&%2pr6=iAI83`aTC0Hvht=+!e=A@fD&$(s5 z!3fWg$}kfvYkOB0&Nam6bDKnX?Iuaaplpx~(%9!gi-90_#R#p|l?5wQDmjQTz)?al#s7k(i)vAx! z+}dg_`6*%N;(~hYEDwq%BVXKjd<*UlbsC|ZrkR=96C0!=mNdb%k0XwMfdB0Zv#-={ z%A4myL+@^KA~ls@%eJ&ZpVqK5Z2j=5yR->IOiT=9T!^2;NESD+ZXv6>+F#^c%n>V5 zzPh$54wIZzkS=yH${;U44}{JWZtm#j=9EF#ooej0u&}Tbs-Z{hzFB&gf#{~}vb^Br zSi<3>95@oSFl*uQ^XXI1apJF*a+(SWw|Z!RTc4?n!0MK`3zR@y+o9-qy6?t&pS7b8hT-voz*$HVALV?*Nr?2 zb#`4THDh8_3N^^Fh#x7hZCvmP8I*qawKb@03QS%qj3+l2Rl8bQTkjqnWmQysuim!* z{{6e%(Aq;8uVYo;d??t=%*Un=7s&E#xkbJMsCGQ#=mWpB{&%JjhzB7DJ`)>gI!StU z;7!9MP}BFMp8Ki045&65l_q!?8R=(z9R2`NSxr`u>FVlY5s;6tx_v*u6LR?-nH;U~ z`ZbPWM@Wm!bBz_*uc!Ny6$I2?;|NuCa(bSW=zPT6|70`v>C@Z6 znS@OZ4Gm6?=fQ@%8wpcecHZ}nH2=)y_SxajQ`6?`FD2t?8w10+A z(ZNCd`RL&bnF^~Mj|XiS2G4TY42|~$^EUD(v?3HUVaJ!8)I#*`6oQ=tzri$-oq9g{loq%jmOtly12b7#kY4iNNOacRv1;d0|Z0*x0C= za~*1r#kZgdt3`;ih|o4jxuVwAf(CqVEw3O|bktqOAIz9GLy{=9j*BM7%|P`Enf5w= z#?IU}J^rU?rq15U>G#xB-P{N-BV*vjg(nss<+$NzAp!fN?I{@-MP3LoG4Xez$A-uy zL$^!2r-Co_UP5@s#;hjm$$YnxtS&Au-M3$;X=+M4JIAazb|T?y5!|>nVoZiG6fiQy!8% zyn#q8zgx_H4>Nw6e~biE*VmnU0t`!J@$%~a{$HXZW5suW8oI?lrDaV%7;ysK!Op>f zhJ!z7zGPnAFlDP}N=DP+dAn4*9t5hZ+nPb6>3m;TN3$bE+vCqbPiM>Er=Ybz>gV4h z6Z%*25_j+K7#^H(#;*S`C$rLQx+$5`q0kxY7iRc>w*~FJv$M0_zWew}(&-L{A(o59 zCHUI%%JL&_o`B>jn9G!VxGG3oi1^c%STw))l{e^lQK@lC3eI>{E>$x+fquk~Ne0Kq zWnU`${*r3!GLllZd-CNJlW6%drL!;oJZfIry9ULA%BA+4*LR-!ce3yN0#cVibcZ8c zY&R!(PzH=VR72KIU|3uL3;aq#LIbYfrZ3^d$e7)Bp}ry~DS790R~H>U{jCyOwy+KzW_iw1k z+6Wyx`@mlBY$KEBy&ZxQc}x5rR@-Uu{Xi$XR^HT$m>6QKncC2#q^uHF28Jvpm%g{} zv;)emk1_cHkRN$5=ImDE*S24_;h;`eoRRf&-R93^fM)P>xiAepa&ihtiJ;N+x=5W% zxm%k__nV%by`yw6MsI6XJ@k>in-m$_lCYA`^!&MjZ!v+QqT-gq8qjpnd35lMIBv{xodfu3C`t|Op>7on}K*4Mu+gmd@aZ$#7O zC2GSx{A4&|Q2w?3aE+6ltPKY_%gpin_iC`}l#H3VyvrAGn3>z~=gX_uKg27wzmH|)|Q9-YjDW#0r)y>7U0AoZmu)q!f*G!eWb8vsnB#H3QVkV$s=U+embou#vHwC#ON*^`Cw=K10`MwW{LXV-AC)ym zED~;EthR*&dROmEm(2##2vg3~UM4+fXEq(^&#K$IxVU63j){vc^+4JrB_+pmxY4*A zxr~gCmAaq4i6V@~!MWek)lSwMkrWZs&_HF#7}D0>huK?Vzl~MKRsN@^kM>E<`f6Ck zcb>ewJg{N&nS-IW_Hij z)HI}wrRb@3sHtWPM<_|r!Be9K?u#js42mh^PDwm7kiV3owi6{kT0dj3CN^NXa=Kr9 zC@9qOR^1Aok}@=;1?|F+7k(+V2yk1@S*<$&srE2fn^U}7V$;i4DrU2b5;oV#QGI-> z71=^Z@Slydd%yPez({d^T;8bFZ!csi)El3hm)|;Di#|*n)Y5iYC%!ZZdG=3}U)KP| zpk`}``DA$*^SLxs#ZH+ZKl0(jLLGQ%X-nr+;$B%67N2#cB*4vd053_Gllis0v{P2j zKa`Y`(yT3-h1)_G^z|>xWGcz9A3VC_>xSP`xv-NUva!ivxZN7DN6m0!$RBVQ}K52n_8yg!SfI3B9 zhKGkUii$SQAgrydNcn6o#23;&{r$^vwB3_kRTT@ml&6;$pvRuD7Uou(0j2>TUmN`{ zqb&V%LPCP*#1{{32`Ywsl!r(Dre;csK6T5Tn$z;<#v+H-ccPPV25#kT)}dd0*jwQ% z%)$)(@_>@W$}J^k;sEmr9m}1%%g33Op)amJefmy>&a9{?VEVw@{GD5|lEsua(LBf2 z%$>E|*m89`DT`pVGvr|I(AQb845_Ta6eTh8;aq6vel6Mn*@MJ55vD`4wm>}7MSq;<{{NJYBo0DGc|Bx5 zmDNm@TKk$r_c1)Evb3_jzrU!@EehnJuo#b|Ep@lc@M_;*MPJmsUU+5Y3uk9%Fw5tm zjExI4>SE{TAEal{u;K~*LO*8_wkr>ggCpM-gUZnJDD5#Z@nLs@Dq;&gIIh>+(UIlo z%<|j-QIr*s?1xQ&QuVd5bbJEcgx_+n6Efg>NKXF7O5Q5JaU0_To>M9WAF1w&_8Kz1A>Se)41N&=x`_~R7pN(*=;+;As?6iirM{k>HoIJ0)j?|CGY z$W6>!sDxNziJMp%Sr1>@eSH63X}XH4+Qa<;CAsJLHq5wT+up^wWvrj%F}y82ZZMZQ z-|t7Qp^wm{H2c-v2>g%kUB=Hp|GSNLaXJn^ZXbwdm7clcqEVa_6(MT9$HOQ z6(-Z;#^NshrRF{7D}VD>iIQ?!a9oJ;@C?5{1XMV+d7V}NJ8LQf@fL1Y6fNi7^G8UtLu+l)y;jwarRP z^9|{hfAv|Sc4hpd_Q#zgYP;6C50=c98+EyLW{@er*~GHRdsU|G+$>M92xQvr>H0^z5bC5W1wH770+d9IUG*QlI~1ufn(8E;??5< z*77bczJXrvlLG64iNxx-kctCdxGj%MQrP6=#3FP}NnM%*pt)tEZFHlTFDyHZ)8*uW zK_K+B1Y?YM4A3+0G~Y>ETeAYd70WJ#ABN9bD+Y5huVv1u$ovinih_cI@x*vZHw^(W zY+20>9Z2QZCa^FlAY-HvQ zY|C~(lph<$M1b9TimH_>wYlch&{{9*Oqma;;agl@&ip@}8wa_({2R~Fb~zb#ondhB z5dR4O`$#O9TjbQ7iH}?gJw~W_IBKQ(6JwIWX!Sbr>DgK&?bNiQnsdyr!Ssxbj3RnQ zMgv1bJD4Nl^*DPxD^g64>}uYOvK}l&f{q5Uke0`W#b)!gDiq8@hGoeKejCF&N)(+_ z_DW!Oqn>{L7Zvh1LK)0YU_J)(yKluo7HZ1R&o4YD9>ddtoU&|lmYw_3zB0xoX>g^{;m4Y#zSbP1d1XjW*%=OuS1}Bq_9)358 z>KHza|1IPRjbQrBP2Gr0NeF206JZP$?0S?V$WOVq@*Bd&#zsU!^iEsN&zZ5dWasdh zmYMb&xWf0?@6NU7M@42iwoh!P@Nuy)O4|ZH+%|E{m~hBeaeRE;hfo4NbM?}29>Rkn z_*7UB5^LVqLqbgS&gn1dLf_E4Ar`);PR0!~J!U+Mt0-yvx16IfqaW%}(5dX7k!4X? zr9Sx75*dAt^%O@4@9R^0Gx$-T2$bJx`0>C-tpzSA&y^d&12VEMK=!EK{kMm52H;Ui z8yV4k{rVNO(E07{o4FT)gYy-aLa<^~IB=EX>guXsuH+NYP(a1;Fmz{RL{|zQ?d@&A z4|z#LC8Z@FkdWuao?LDw!*TT?%DTEfBTwBg#YKc*RpW$mT-_&FE~e$Jdj$8ylDl z{Ms0zatJFtC;0?zun#3bFY)wkKb80H;=7jEiW(2&CS*K9e`NjpXn7B z<{Imw8cA5&>uQ?-BqmyYTv1&U2x@!$Px{7}<{g@t6??nM3Pozyn? zgNZ(yHeK=#4qWT!K66!NWi(Hkn{>w}XJ(S485N(<2!C8~6ElJ-qw$Gjh-DfYgM+*M z#;g&{mWzuHdag5G=Ye#hB=|b;{BLsM{4OeJ0sHN{cz93g>0`FGvTTYK7=|BKnqYJ1 z=9*(GLXeG*CEWzwBUB^a7+VwL^UBP<8EqTHv&?wm9Ljp#%&>}X_x<#t$;_AS3zAoz z*OkATDtbw*n)qz?cLT0KZZ2_cU^aFn1}y*sfA;3Ceh=5>ZXQf;QBqP;-NhkjpKYO* zk&&s#b}eVOg))2Vh9%Z%eIr3&!@{9NqD_@S{4g%{#^-|{xf z;=`QMneAM)X?)6vXFQt9JW;RDoy%lS8j7{IpCIawMex;}QwMgCX13?xFj4PK+x}&N zC!sHsZW{Q|+o6E?^hlDTlgFI%cZ3-SB{JIcKFdBG2E6R;Bi#wDj9=eOA3 zrm5M@^lpROGZztrmaYC@PpC%{zy8w@WtA2-m)iVs2sIkqV}b{JCdS7fP!P7QvT8wO z<>l%4_~LG1V9hTt`Ox+^!w-fC)EXt>B) zv5YZndaD^SM}HL`9K3^t<)T%bJ9O^qTCJi!=ES;;d_2Fp;i9_PGr-PoHbdP|#(+`Z zz}oO*d3pH(F`4)`yIie!);1Rjm0}7J@nZ6qeEii+FKtQ49}Vflu|cqpe}Kl{K-+@@ zo};Wx$UKlad|pl&vc!Flt#Td4H#W6JY~0>3`?1Q`l(8Ehes(`fPDO5Wy3Dgx*e}f8 zOWH3|7OlMKNT}Hm8<7f-&Xd1=;W5d$1Gm!a+rrkN86feG80gFSn0}N5aZn~F)(%ip zQuuw#XC}*CCn6>?ur^cxbj3pC!|@leeQV!HLz&1Dp-)M@EP#&20TDFV^%c)@OvOcc z)0sj`t0P(X6DU+S(FE96NdjtFeeYXLT};-f#JW16AL~B=?J_v`=Oob) z1A|J0?n}^duWnuWO9g1$o-<*yDUy#Djy$P$eRRy@Me6SE!DTeAob)m50xKFs4R4d| z_m;Af^|)R`a$2C{Z168XNvx7 zK=o7q=M6zx#TWD03v+y8iu0KZhkoS3%pUAlPMJS24D`HUW9-*3O(~@LH(J7-0cZr8 zC#|QaXHal3%~MVwB6fFoKVxRLgk7zznYFehm4NYR01@YZxlc`=uBD;zkcz53OCiym zCy|VlDl|1!w<&*nzQwOpNLpJ(4!y9wB4uHrg7qUR>XowIgQ_Z-u|szS1y&Cg6_$Yp zVGm@`j?H!^F6A2lK%LBD-m-oBq2>G7>pK%%&uoE)rxg{pFbO1raviOiXYp69E}*1-Q-jk zq)4IQ92FwRJU>u9U>1Ji^8^+pSulIlR}gH|8to3Q0_)Xm4-F4WSO|8aYDANJ$YA6MOxBEc^cWP&QV8 zI^BzAJgPGK_XUwOHcUy*R7nOVjfrPycvi(LaS%odbS5-_Tdw6WgSB2nFe+tadYVuOD$g|`Cm=Z69#zF79;=A<TbrHoHo-bDk!8cZj3)5#tZh8RLFTXaG~JzUm$PEO&QIBgaO zYo+1G%Y48s5e=jxMH~E0L~(Xe5k4OupL7yW`g@q0UHu8aN3`a5ahr9~SHibBT~zug zt+sG>@3tCMq-W-BmCV4kC<@7VooAmX66e|FQ#YDZk$(u83ls+ z?%{4rcb+YN-NxpUUvYRi7ybJ53j?cKX~4^YK@}?vWI=DQ{17*HDZIQ5D5$<$Ta>(3 z)3O#8&(+joVx6mNYB)`LpB0?{<+z1H*{C8^RWv7^48=7x$OHrgKnGY@UY6}Nj7#k5 z>N4DqWhEk}S|g@^#u2f$_RPI|92vWxmW{m@x+)#ZqVdYagaPmi5P4BIuKxc1+F+q` z%&z_)C-rxpTQcv$4!1i99@6TBQA@f9E^e=V%_zj^uL|S;AT+5Cx@6-RZH{Z0+SL>s^C*-eXqh@XI-mjIvMV|3P7`~xiH6_^emz{*D(NfJ z&+$2SA=Bp}-F4V&?&*?fCs{Y~xIZ)=6ZhvO`Ysk;kgB+1$!kK!{JZ}?>85-F|Bs3a z%;)|eK+E6;<`rI>X-Q*adOs|D)wh!P2LEBq>6+(KD-N6hUv8FDZ0o2K-6g54{6zed zI6wcvN=iH)Fm)_#E%x@GXsfF6R@N2+#d&>xcP`PxgClY3<2PV$;`GB_S{ZfR*HN+M z7tc-=(tf}3Hh4EQE2}ZUdSVwi0m!HyGL-P3kyXly#JQf<3U^UA?oNZauq0f zw#M=YU~sCYaBDTZ z62(I(@O0=~8L)?DrEdf7w%LZOa%l5$s(VEBgT;qIP2a-r4f%ntm-m0d+j1R_2P~KUZ++Py zu7b5o>udeM52}hlD64A7yD4Cf9#-Z?Pv@zfzEv+nT863OCC-gYUic0OfPHWd& zbj&|U`9SB)8dxAR9f{K9m|YmOE=C@yOJ-sMndblQFRYhUz%(@^@ZYs*xiKF;h@>_| z6lwo%M`u>&kO~QD8DMpC82r9VEd8J8nfx_(6GnnEH)dv8M$!;0V)4v4GGV${&_5ti zM|1~Yq2J0J9bC6|J5P7Bp<41yi+jvOX+50fj!4D+WC0A{ePr*#$|?JQy{rup-cw+k z)+saIfRq{fdfm1B`%1|KI6;3tT!{4{j)`$skv#hW+L=x5X_oqefbX|&<@ZK#K%HaJ zsP#Xmu1?)&<4+x2>fmx|bu}3f2oHpM`P0kAIc@AaAE$`)rZ`fYCOh&b^4W^!d7aI- z5wNCiU!M&$)m3b1*)~~g97QXMjZ(1ORF^>Kbf5p+4C~148)Nnv@E6gI73vba1Rhyj zsOU$jNNUqvgl@U@xeEkKT4w*mFr3rrk1}vq$}#K*@c#b;H6K`CV#b=G*XAy;KHh1HCGeO}TxcAbwXV4$Zb_Ek|5z!Vb` zYk2rit)pr9uZw*B7&vNnnk5Ks@BGCec>Xeudc8B%r%c%UqO>!f!@Knp`yoF5b7djm zrYvr_5-e`KN-$0U_E{ArF$}TmEM*~3NW`u#qSMo90DD-yk&u6i)UtK#pD=~j^heC= z^(_4f`ooJnT|wx&9XA|9PAZk;`b~qv^hn~n6hftk3ULDlxLu>zxHOUJK);d-qz}Uuc_HBc74fybz1Cm z%MPjSW2)!G1GLJ*F}I0daX?Iv_VS{NW>9<=dDH99d}H$8?@!;~7qA_ZFREMJU<+IW zZZX*GIlEk}S5F(k^gwl~p{nZ5O}sJuuRH(14rp9N#KgBi%L((-OoDYcdY!v&{K#e< zKsN2rAhbQS^LHqlfy>1MIn#!T)P=kRK3CiNLzAg%LSr5-^F>L;9}f2zqEWAJ3e)=b zqv$AC)R$AB(C)0Hx7Q#0pbdEwg&I5^T^mml>2E$BDIbVTs(=557<~QLKZgVk?6gmJ z74biuZF2!ca*O{s$q|@cw%Yn_IY?dS8_!EBD=`x^i%m^U^CNC{+3TaEEC29-0Eg$m z`)^Gsryy@T*IBF814kU-z19bd;m-6T?j}KKZJPEK8#VUuAX8Bd1~E31L!UaJ)(qxPwha+QtX;K>D88y-ui4)b}L+n8813;(q*>6~jM7EJ*-` zYi+Y%ppJ>JR$!Pcm= zhsUg5@Pz&av8}CbI!y+@akr6)eCis`&C{P^95 z=)iZv0s}kWY9smp!n_;G|F`gDdb1e0fDJ7ZhW@uCzWVHUx&)v53OwdM_eo0J`z(%7 zGd&c=4IRB?+;xYwQ;mk$+@=G!=TqvC_C0e~9Vz~I0D5fgR?C-tt<@@(R9TS;!P=uEBgKKLYS1?UyByg&VSleZ znrmENU(XNR?HU({h}c?6_Y|rNf&GJMC5QHv_Yk|?UvVWimuKpm^=nbzSi{jxXi=NS z-K?cOnEvppLXywI7syAC!xzFwYZ=@9=lykPP0@3(z0Tml?Z=%N85qh=5{orT9q0k~ z>+0^#MaLWKI3N(1YbxJvHqtN&!h6UASXyjq_1PeEEZSH+U)D_YP?d;^sbmkWg;Te3 z6Nk1#1l2D82KcJOBGb*un{~gNTDPpCRAzmCL^2FCjqwUMLa67-Q``C@K}My7-(|K< zD=mT0^vul6_WB6{fs3PawCBO@?W_G_AHqkJp5xZ2kZ>gU1`QiqQTqYVpg|2EsJr$G zf3NHQ14LX!UD(EkMT*yzfau9in|iUakx_|f`#l}EL$%czXFkW`rp7iX-5WW%lTB8i zC9wUK@{Z2VBCCh&Y;3CwGEn{XGGo@P&dyFkD0HB8K+LrD1gCy?(h`_!G<1#+4wipn zLl?)UY%%6*9_!4PD~YyDPS&=&4-F5yZ66|72cRAYgX#-zQpWUPr+A<~XRq@wnORtj zz?xs|&s{HOQP?b}xa|vW1=PY*RH$-o&E`|D&luSoE*!qC0~VZ)k#WB*Tx@5+=Q@#3 z$15~iQ82g9IrCSxJz@q{uD{d~ix!u{%r!T^I2jJFzZ~${8@f0JsCl>A3m>HR9!N3A z{kf|*3JQJpb*Q|&d`{J*SYHkdBWW%N$*Iypz6cj9jHC#N0dYkQQo`S|%1Dh4O z*e>?j-rGekua`98LDgk5J%Kqm3_)WgJkKUfg)i1Q~^8lQpj^SKQ5+sYuba+8Tt4x_ZYr zry;433kwk1=+HAgQNQGJa&v#?n`^ZI;FUyDe`ai$V;R7ao{ zc*@>nwca>k#PyK$fv^5GXKw;$wEejjtXLb~KSX|+$6Rc5R)f4fKmYe|Cp)J)sOh8E ziz(O^MDL_TKgnxs0w^zKz;e-ei4t-_&-QJ+$UmL3hVKnxL#ysKWv6W^*!ZS{#V*-w>Odo2Td6u-zpkvD+Ma%@DS_<#(SY zKIi5ZnV5PCLs>sSnw#cM2>?|&?bb=&LskO-*8ejg;{9fq>{@ z(XIKs(i0b*lG5+Y*K0f&r(1dNQtr*0YHt)C9UTM2Zs;|m{a6LJy`v+okWiwo_jycp zH9yez<2{z}Jg+89Z;V0fUiGIP6g3xRa-4~MA6=wWm_ZLPojpBnV%-OoqN>Kq-kuZI zS2y)2#ly`_zl^K$gcJSYBkU&6!94&%mD{mH{`2SCz>TZlS6gW{QHJUJt0(rREyY@E zoq`I8gNwU7?bzP}=%M<0uZz8&fy}I5w{}`D9)se3H=0oiHFWN=6CVxeR;%*BfFm%q zIE`<<)S`Bu7thN}Eb0p3Rbg8-nR2`AAPpy58ye1ljO%AJfto;J#TO`-}-sjeEhx24unQ;#ZSTWc6pV+09l`AvX^=-@1N;aio zz*cL2iRah_4jLRLi~@6A%tKn~in>04+){jw1la25Z_6QiA+T9*Y}lM5fzQP$HaM!J zz^Z+XzEp2n#|FuYv)d6y)@E5;KbbeRyjj!`mU!*X4a#Kw>6lg$&jnppioJB*x$1`x zvQ}0s1R^_645h<6CMPGk0<=GTAl28`KR<35FO#=Z=2+?s6-%#8>@!L6xN?(o^}hBx z<~%;DVXd`jS`VOb+gS)au^6NoE7aHrvd*Y%jVAH2?a zzs}Jy`RW?2`JXVb9onWTG`BkubDq{X0|TO)EIO{JQ+54$xAK9dp&s^(#>Nzoe&4{E z7a}M$Rn%G0dHEZAQ-wdW+_Z18i}7kOU6HRhxd#-bX2RrZP1({+yXxKb=b@o2RX94pH?RT7;muGM#$G(5%b3L{0W!1=;_VedL^JCZO zTot4l@KS;!4%2SuR3F{f=}*p1H2a4Q-DZGf!s)!Bs^fZ8Lox$g#5^T>Ew_P(*B+wd zvyTTc>5uO!R}z~^|8-nnhg$sgUT3x8vhr+gDZGv{jc-mE;{iFj`$tiaKmw?Z+r>V3 z4p(Sr2=xr(RU_bZACNz4r6_J1w744tvK#C=pR%gzZ?H0LH;YlBqBIIMz5$|YKuPE8 zYoMNg8Fb}5T=K@TxQ^uILJgR{pxK|+Z_sj@2P&nr2AMRx;WO*!_qpaA4XaEz{+mXa zps6^}aOK{!FHwgQ9cd#F{Z0Q^cbaC+KTExsUHCZZ*XF?rv-*eIt0w~(xAA}+48>3Sauv9Pdkz50S=-51#% zH{=)Oub2#ndS7(youKMa7UlHYYi~2EH@dSpe}bwe}(TwJC-21HIydA}fxQX0e`~QOsVVubG5z;12eGX3b0j!J5#WvWbYN ztaaxpqF(NG`yKeAe_35@bWr+mH@jyp&!A9fQNt>^j@R}JNG!W<|BikeOY!y2Qn;$B zDum;>b`umtE0}h)lIV|I)1;Pxri)jep2C}B#kR01gf2{Ec6PS25Y@-pcB`o7Y{Lb) zzkj%LA%MI&lC?Ek9=N=0l%A2U+3N@nWR*KjaH$vS^xgCfWM4{OKqv+4&;82xy2Cci z%LrYsn1Bvx2;~v>p*0zgUD}Gh%ugFzp=`mu;Q=wIQ@=K1HS{h7xEw#)pAyIJ_VHPL zm`kTPuJ!Xbq%bf4o_=y+(B>y|zDS!$8UIXq##1Lx8O%WRTYz z-57nT(sM20cVq$v7F;NAi1Q|b506m!wOsm-jy=wr3usI z(Z%v5Wn_vMzLLl#56J3^iyro^DLA^?8~^4Qx6@<4mgZ2$VvPAsdWBDJj&cVk|2Y(X z#!&em_-RT>K#l;0xSk=dDpve3>{V<0$*>Wo={mztQq_xZreUv>hrmdCPL=Aq_gcrh zGWUF^x%s6{-FB4=!WtZQ&G5qr!^aQ2eA)@f;=Pmkl2Z006)J)1m6Jb_8TdkIaKy4_ zNxKdPcP*@O7HpPdZ<Lf_gLP+y1B6tHHN}fys!` z(Z0Rvyu7pc5wqWGZ~dkH;pgaN*xV>I%#U)C_V-UPp9?rn^7QP32q0Df^~Ak!^}%Jm z2Des2*3AiqO!T|^H?Yk61jBdbn{Q06pBsU&&GcOQsqBkzKKw$A7%F@F*v`m@52=ba z=f@^R=##p7`hEf0+4tbUsg;ISumLoC`@~^vJo4Xj=<4Xlx(RbMJ6;D1fm7BC9wlJa zc&$DR>=85HsDeyPOv@`P&Na+iv#yHHM#%nTu-wV^?%8q7&z_m>UIh2BArtB40H|EP znMd2ZiF=bc{cx5TIjq){TU%Reg?Z0+hX{-6!YmwjYK&2fr$f`1LVvfB7o`rMf7$Pc z@;SR-!Dqb_Z*F5LjFxM$8BfyfeD5Ie{Y@>sxYhzuB0HDqII%kzrV6V z{QQRcJJ2*KuNjR;_9fIgrh3d&-Wvui!Wrp*{y>4lN6HJw*1;-a(fCq7lz%wzyJPh% zf%)NUeo=kda1XVO@B(0nND5QR%gb}}^V@(8bjBFyNYz`xJxnYtcjlwMv9E>(zaBy)`3d`vKmLP0$He*BMinS1D|l5p&*4 z$70^5Ag@P8Mk1mTy7R#peDf}ZPq%KvXI;jRi{NWah#CDkW+kPbC5gIOv2op^+PqO7 z=8Txz2N^ZI1SkPW`8|NWXd2#WEx0+pNA(R8rQZN9h5rDq~P_u-Q7}QUQ*mU zckV>Q$IF7n1w3X$4N@+Shqv(wnZRk4%Qug_>z$n?RtE(HV$#EEx%c}VvnwysPr0A+g zxB86T=Vw`2)QNW8$@cNw37DCfn3>YQdrcL z#n>0n1F@aJZ%9>Ssr2ge%5IC{c8Y8YHFdna!ZCx*Wc}CR;0$!t$qk7CkQ79(<6s?> zQe3lpqf-=qwO}SOp7XxXQ|WKZE{xtps>*;IVhtyJL|ErF2TVz%FC6IhQMHNeB;Gdb zG?$kIVoz8_3S~x$*+EDyZESP{*ou_~P}|(~NyGK!K*~b%HR?xSXEFUrioZx~CjixF z7&m{kprL!A)e#&oZqb|Xs6lXgtjhOnek1YetGhnFvcK%72Vdz7`_G@u5MWa;_@M75 zY~=$K+lv!WT9R>U5-<$R?tS&R4UViUri>7cfe6|;hx$;Co`DlWAo{K$2^~2puw!BB z>TB<`-1A)@;X|TBY-fr&7BR88y>Zj#1ubI)0&zIum`6*<5CRvzLYKKNW#dRdvS7m9kCTiX8h zu0C>mE4;zk`7qF{A-yaL>Md+1A}K9>6P`m_PDCkumiU9FeM$O1s?2r~_1O;qshp|A z0Z?WBO$a-T01jAMUS2-2n3#lk*9Tf}>*n3P%*3iA=D5L=;<>>D^2rt5biI;(pY8WR z@wWOmKonQ`$6x@7jn8H4ExImd^pnRuOH0deR}tiZz`!HP#=XgWdC^lNAfQYp!9gsV zruu|!7T2ZxjfhY$Zn|2fh-K?QS6{lkl6Z9&4`*TV8=JubGIjqP+Ei{fNCOUVw375p zfT4xjcB(1{Q2ICQZDA7#6BwAMMLhr>@687{hw3=>YWKf4LS5HRhOdK}xN zxE*PvxNdRE**@F`Op?Ia^<~12AWjckGzNFSA@SLF&K^6R zIi4%^>cqpP8ag`MJLJ>Vsq0TUv!16Y{0#-^BEWb~Qt2zYDd~&h3cSaYp z(5i^zdDL_a_6-#11^!;n^|P|c$*fpoUXkTMO#sO1sINN-t7~f=Py)z`c%66eTHC^f zt<1?ptT%YD@W@9DsM*=sQT0psDPAZ65Pj8_v%)$lVohrCa9W$2sVscuw7w4AF?qIj z-9bg^U^`ojSw7(@Ddqwwyc>!x3c&ry`1n8C`>554?9>);rmx+zrltnCYuawW%EiI( zfamo#oV8^W zkrIP0rBu4RTUxriV}JpMp6|Tf^*rxd?|<+5W^JF%ewNJ4UDtKSar{oL+Xqo#3B~8I z?dOo;--qaK9V$XlIt(p1ZN&F-xKYWE9AA!)-_}km=<@})5js*3DU|M1`?VB}yWYBW zOL=Ym*u#hK!X2=sycX(kWha{NYazu8)g(GsGkdR;$dnl?b#+>(`wOo&pl4_6cdVvV za_rB5$MgVpKtK*esz${N6bT6L^-nDi7v(4}rDkNzB~^^JKn1n!TQH+qX}-ypNsRj} zRWpVqqfi}13!7bn(!7b|Xv_Jpk*%&dwp1M#ztWHpB5eeEs;?}hNHIO^d-CWh=8^dP zd~#-HDL2mZWWjprpd+XlaVEY{uQ2{eF=>2qS@yX-uV2Mcgug#YoU!br3p%`SHt1xZ zrrR0dWz708ptK=Jj}kdlpKTEhdi(YySlasY&0xt<`NPdk5nIJK=6C^gK1(5PLGS#l z^|>BvX|=bD;aco$GK_XIC%XB%xuJ|CR7MX~h1rU=V@m3F*O7WA+ygHMUC-LInF_?W8>RH7N%q!&!C96bCf=W55_`#TGB=Df%f*G5_GX&L#*R#q*UN` z#wV;YxM9eo4eO_n)~ zPFNI-KGe~%d*e`L~aAHn*n)1Iizgen@Q7|b8Qb8|!W3&v+;SM3# z0`AwZUq4V#n0xKVHjTtEyiDZa9vsRy`(5L0ahSQJ#Nj^dcxZJIX)Im2c{4mCly+xy zG<>Zz9QLgwn%QH@e{9x`-Ja-HOgcwKKROlotl|O$AGR$C2u13FgQM}0>(N#QDhKNP z&ofj;7PmmVy0({+asCoj&+G>BcAQP$C}PWntl{APU!(GVDk*qI|l2J2#g}EUc_d zgk2OT%$?N!g7a_$JDQh!Jet!@FgeEhVTu8J+0Oa$gAEx#w^Z@TlutzEw9mqy{8CZ9 zJzEI&9A}G+@cT;~*j=!zW?6yF>}CE`+=iCBpOx@3?N?UM-o^ zOs`lirhhhd!D)m}0SZfVe(mHN2Y&1uP{__LY#ZW&swKVC{1v3NP@CpmAH>i;-SknV z+*JU4$00V$7O2A|E0JyPpLP&-RSt=89;H}_$yE4(iddSA*@q#TJBfem4rcoDDlR}X3sboX}9g{?9kfN zL7*$VTPs$$_L_JXI7iAkhG%Ym|E?S<`^L+Qb9cHa%58h#jhDCm{?VBIQrdAMBFsW{ z`&&Q1nwpyUXV}{4OXSo)nHf;ku-&(zQ3q`xe+D2(Wp8iDx5fwZF}7TgQ(U`zIj+vy zN@&V)rbp>jQtz_En4LqU*dXwdtRT0sse7h$Y;C$rfz!T)QdmTU6*KHub4E6d<5is& zyCFo~h)fVY*D&9y7barE1YCy1;^GLJ*)|Y$5j=YQ_*HzoUaVlUAh=#?G3}xaP|&Ma zs5oee;4yt5qmT^CV`e4?Qd``#N#=I#&$RF3gr}ukt=JzbqpzUEmj(MD>|^{w0v9gP z)fI9=r!C6nB>Ib3Tr~2?pC%=p)3T^&Mf!3!b=FWfl#-MrblTsZSe$$a;v52^v-;4$ zi{hHOsIH+AWa0v(mIF*{0TM^=8ZMj%S>s6(l52OScX~UuIo7a3X#HOa(zi8B!+K2n zpt|G}8_R&0<`$`pILsEGmg6S&<7PXpi_f^|A z{zKdDu5jeO;Qgo&^CmWf9gNsgn;J<160y$p3*i6w7KelrVH2~F=A0=cZb~XDMa7mB zWDA7tBAD0jI=o9}N6e^(c89LZfXU?XV~M#>@NTdiklNuqvA>labR0j9!+ZSa>(Bl= z1oMBt{;v>ncr~qjBf8J2_R_Pnt)oCMZ*Bb)5FmloHtnTx|H!B_?En34TP-78c!VvK zSh3Zn7RndLTlR5(i}QaU$lC`N-FnAtxu1t+9ZTiF?sSxz5w?gEcpu{a{_cPNI|0jI zg3muVVB|M|uK`riknw(10%k}tO% zEJhiqUepp2Ncwj#wp@U++rO^`9sdR+ZbIs;m4=C3d^y%Nvjqj#HAHUk@5|Y9>RcS3 z&)oAL+wO*`zZ1*<_@GUW24QiF)9nF2Hm|_h@|pk7>j@$M1==osXlqW~l=yFg;DSJ) zO0ikJ>PWuB4|zC=&#(OZiO5g#98+1UX7PVa$eaH7th$I)ss4RMJ!rU=_slUp;=eD+ zChrJ+TqNiBrESI^k(Uf*|FMbw`J0WWtYwecK3@6n4|0w)KhqJZ`q=a~nlU5xaT@l& zk6!-0IcbN>>+Wv-?NzzO%>z(KCL|_qzI9XjA5=nXU0c)G36U?v8}N5wk1%$kCnvY+ z-vuw?g9rU)PV}_2&VOJlzI*2ha{u>71aj*IsrQfx-a&{n_&G&~Ct7BcoVNXf#yan* zeodiq{r7soPr*}ZLmvh&jBM?EnJ9|Q5UcSH!1Mfa=tV;4O-kdNkr8X~MB!v-Pe94P zrwe{+T<;6O-}40u)h%Hx#%Mm1jPfi+4BCi;nu_=3H2_kD7&8k>@THvi8>scyDYNPe z@C>1mZ^Wmij@jP_!Th|m4`gKXF*GD3biBt%-bY8DfDX3h*Ln%Q`4)%~{^KRkR-AQW zZ=KXl&1>%HmB!ijNGkY}XXsv4Xn7b6`kMHfKmVWSaCmEweBR7^?w&WpV@LP#b0i^n zqAV-~{Et~XUFHr;dRtVT~cw1-AfXT$s#VO^*-(L_~y^o}& zeXk1#1-`q=WXJ)v;#>Z(01J*##zhKfY6SVbc|)MA`o%~;0W>!y$(M=;TDIxbSTza> zNa$&0EG_%3T-EmEl`RYm?n^j^rQiAIN(76hW@rD};1?E_MOq}Asw3oaG#q-OfqD)M z)L)GZ4Yhe~Y^UNPTVIZ4d8MYxHYZFm#PIcGXEe698r1!Kh8HDZM%~sXPsjE#Hdf&e z-Shh!7yu;I)(wQee}As}-aSrAwekWdCnxBHK7R7#4>Juq`QM^>@EyKt(*Jeo-$m|M z;C(IBJuuudO8Liq&3^sq)6?bUjzR6eIonsd2^xZzpvS`G&(Pj$HU#2so#n93<2(D{ zXij5hx(+>CP}^ST(*%ng&=Z_{dO4SR1OQXVS zrdV;`^NH%-IydstaDSUs-ypVs?LeX@JNbe9Xfp&Z5iyh`0IPib^r^(-$7Cl??E2G* z)c@+N8ZEntz-aJXh=^#HSD@1S1^8Vpskh~$xu{sY{$n-u={XQ$@c^dSjFpjl1`lNb z#8~emBTZG>1_r1ArL+8UC4wnT2Y^)x$$VP}CU;ajJCqg7&DkLO@TavpucO0?^2~^lxx1%_C+%z2f&*qSLe(8-BVb^Vh^HJb`?Rn7eMpF;;dAn2*%<#4u-VyNXS)6& zmygPUk1r}J3Q^93Z0^H{CrNT5BV4jCK_Z1>(~{QF!N&vO$Y8M`S57w2R}2Bz0RTMu zbxz;656~R}tQ|C*HU}m8#&lneba^AGcx@$;@7V&FW!o$g((`OtA*%@|Mb`az;AHVy zjGPt+{TC>%Il@z$ezqBI4dx{z8=bpI`4)niGiT0#*Z+(MU`WYmxG7&0(`*EZg@W}! zR@IqMN9RlN&-)2#sbhuD{t>#0nu3jdXNRQnXaA-UO36J##Oap=tOCk8_N;h{OtJn* zQDDSH@kzvML(f;8j8f+=UVH(8@LA|C8YdS}TWwGKUVw59jYn>-ZLdYYxQfcn z6JB19PR_?NT@;Rad7Yq4OAX_9rxg_y?e6Qza-U*?FPU#PCMz$W59=vOJ85_JoXmpbj8m70(cl%n2szBpbvHb$z&z+i1{-PqVN$PmZ(tE1y7bAxAb|2kOi zC*Q1tj1R)yZ!GE(#Q_zu8Snmjf@QbyY9l);PvYq2%a zZBVbqqF;?U-D0^_)pY@&CQ&5f`i_N(#P%Qac$m-7JbeC~9HjQZ!om2as{viam4{!B-*s{HsfnUjdzv;IKri8ZIse{VO-s%$ZN>@?BNEfy$FOt$j&b7fz? z1ZQS8o}D8NdTVm)P2P8+Tw%1xNC5+JZ!go2lq=V+y@C3eJ%0s&kUBdbgO%p7`V-l^ zcdvl<3PeX))>|e_3K893)#(4(pvV@Y(vLKUVrH|N@g;-k??13y3(ja>tSoL5#I%^x z)pzL&zBhw4i{Z~N)6gHy!rkHVg~(Qn{``4)M@Ri+SSPy)Fu0m02E>}^$3L?1{LqPD_di@(*(!NSR&~)Lq&$maS ziT9s@v~34nE)5Nh^Ei;oTrG;!G)0kCerfx*J0Tj}Z376ufXQP(W16!}-0%fhAKeTEVe z^sQ}dUL`$pU+Oi}H!{ir#TD%`UGWyOt<5cLAN8K;)~h_t{rrb{=wfdNn!r{;ffNGF z`d%|RElJ>-WaZ~4rI*vU7^a%NT5<3#8C?rp-i%(wn6vcS_NW2hjep`nqfT%MinU9bzF7Wg0* znh^(vfABGb;Tk+0Ah4|q-|-(8-3vDh4i1y)2SC&)IYf@Z zNPvkTgywrZcpE}ZnUF{XyuEN?5UiY%njch$+{;D5YEoZSRmFvFBx3~Nrrg7ar|asj zLnCf{a?&3*8?p(VkX8GC^M!;ZSpHTfK;T~=K<9@tB)oa^JKWQcwZ=e!PYj4yXmV>C zEUK#ms<~kyIr^dXh95qSp6Mjk{;-KF@b3?+yuiEC{2Niw_j;V88=F=Hv_?jYwH6*0o_L(#M8!P?SJ0$pU z(8spk@tNN`EFULayo89q{^PYNo-|d&$(R>roIqw;+0p*cto}i?`PILSDSv&~!WY>F z7f-^)tgrOkz5AY<$_59roA%>kx(wC$`IN=!0{wk~QQ2ONjSc}J+SEyAk1 zcZ=Wtlg%a!{R!sBlNVcg3W?q^aW@T-@5T!Vhcs0+C!|j^A}BO;&T52#hDJ?TSR^w) zzcJyO`sHxrm0+)}bvDFif@cWaHQ{%3gq1X`PPDbjHS`a+?j(k) z%bLlZfj!`^u`yNW<6w2v3J)xB=1#+g++(;kMwyEJK!1N(K$-CyZ0NIG}GV`MnOmXVi{B3=*-ctTv)#vFoV`i}G@1C9Z@S&<^)?-Tl#*5=R0;%o!d z9~UDc7&VW4V%;YT#&Fu(sp0BY1Bufs&2z7O7_RfNXEZPUfZo_EDf*fA8I~}CL-iwj zj2yp!zo+N^U>$YxPdRvhKJaSab5{|?>q9@F0Q+>UEjKyZUB2`_MH`=2Eu0kveq z+JQP)kODM0Vvkv0Tf>V`)xHElFSzibIX-F!E3f=5AQD?VJncy+$*?X+=;(qu;8svk zO;Ml}3#7q06KkPz-q6fxjwRZ;;ieQI*j%s9Y3I1cOXy*26x9Z+L?zB`?Ftk|7A@(p zZ6%RbdJW1ke7w9HKbI^x^c&B`I8SoQ^v&So9|_|oWolRanRyK}4UX2Q>aF+FP&vY+ zr%vgt%ApLJvMq{MBU1380&X-xp~YcZZ;RX-&7}|k2yZRhxZ-MpTEg7f@Sru11Uy9O z65y4TB6+}Dq1A|nA3dgFR;vPL^ir0O&cPA_rI5msB9;0K^y2nVN^|Cwd$7V7%q;eV zST%KVb2UN^xSdMXFzuR>mJL29HTr>gEW~y%bKB{hw$ZP9M3{Y0#1i% zf1V2;*sUl7e9+p=g153~ux~_M8k>h23@Ef@jg7+%oNXvDP-)0J*+E1?*`ezsrlke> zB(knt7uPB?Wc5fC+A>6vNdVp+DVGnQ*Dm2N!OL`NCI{=#CGa6X)xRb~fwHTedhcMc zztRJknsk=7a3W+!N6y@%qc*ml%`n&Ct6jOhLWxoS{DIe{$_jf z&sHul>->^GoQls@cAGrATVg6EFIC^TchcP`6x!QI`Rb(Wiw z1iZ*ujBIUOW{XtGIU;OZ0Dqlcx-QdSXmueu z+W9mT;*8efUWJ5gjF|Ozz^c0Lg?EwS5@xbFgTuM|!Bxhq#bz|5B71K{Wq;o?(}h-6 z_DnZ!ybr7L88lluee&e=0fu19-0$s!jHk~v+bFcyu5#WC zhhWZ|#8-fc&pn#YlnJ2Ggq1&E)HXGRkH?A2CdF*oRe5|->r+x2x>LTdWl{X<{WyqC zN=i!X3Ip@&FIqU@NKe(tFeSHrWbJG8wLz04D5Ofy?w&#Ptj@{Zn}{83U;2R=y>kW`nu&O9tv%?(rG zHXL6b7O@L+-ZKVY_Ey!O!Arw^=phRT6uBPFT+M$uQtw64Qq|P?7|Nhz3sufrl=I*r zg)$(3^IpOoK_$$e)Vh2A{C1}Oh#*A&Z5?0kNw+_k3lclZEG=IiGT`Uuw*S^okAvbFW~1?zS$K$3x-rywFGR;#ic zNY9c|c23bT^f7qg^3SJ~DrI@{L=hktdBWJOtuLUy0_X>bmRY-64Y`||Tuz-P$(C(y zYN`WY%0H!C#==5 zK4__s?--O`@U~~>3i?IG!M+a-&opO3ljiFb8nqF5OUl_VsY11!r3<3wlA{IhGu--p zzO9F^Ut`Kz^J7&*LAuFbQuzMEhia_Yfw-ikWaQGpkq-kTya2t_ zGy&6-5aeWE%{cT9nrzG3uoE8zAh1 zk{OaLy48w+yfZL~f;Gv`V4qIFX;K3Csg5{L9OsL1ao4j{1m&!)dfliTTNOW5{M(1y zLU@va{NHP~Af(alZVYxUdSR7<{Syk3zz2_v_I6A7wd-gDNOKX%=L6DSJvCUQi@x^c zUb*KzzPLyz{^ZGd*h=4cdvjS;O8|iVgx3jI?1%$h`cP{t9Uq?q)ZQAKoAtUI9~VOH zQ**4;fcx3A9+*n=A?z@1kUNKKccowq5^cKj^76FeoUOIB@l@{i?A)KwR9iR;XYsM)Z9k0$uRE%u64k0sW2_A>ez$l00=I_iZ$kbHv)8C`-jBl z%z_-fUjGG+(d_{v`6*EGP5UOt7`82|z{Z&6ydnr8MB^d0Md&Nu2-sZJaNyy9M!my` z3xkndq<3^0YsJAfO_Pga7>BvKpLury`t4K|#7^Be6~BE;oo>GGXJu=tU0*b(JbUWw z>B&iAc752mAE`V8F5o?gV<%GTw74BRF1Lv;$u^f#XxZYEiflmdbMs_!4A%*G9>4)a zSox`m*`W}3>j;1n^LaP=DM?M&gSr1&NC^#rDG~p=`b|_FaZ21;4_E(3_ z`Sq17Pk5rQn;W?%o8JNla`1)t2EutL)vC(T@UMwGz7G#SRS6#6+ZuVi4pq(w?-&sX zjE)Ar>Lkji7-!JlU3VNSciY+B%~Lk7m1abINDm5RWZJ=O_C>@b7M4}V)dl8V^gcu8 z_(ugR8@rhnV@1SiRQKaq+HA%AYYfyKZfk6_dfmSdr7SM66s&D;uWsa$hVpq((LSm9H!V30$4#o(TcXKH$(#{29{PpIa#8yuN+ zrAUgiYHseDy=L>y&kqP(tpR;nJ`Go1t@J2qyb5Z^3t`(25j#>3gwY|~$+69F#z5d- zN><~8ftYS>q1wy!pegwdFYkH4cpU(Kx;x-PpO(v+jM8mFE!Vu0P_Nh3BXls{rvr{f zl>N_(gdL+L~2lGm^Lb)t*m~I=zN9YL7XNlnfGxxPR9vVzRjt4H8{3t$cXuPIaGG z+JH9;Szj+{808=eI+5{Nn%V6XJ=9Vx4t8%}rP+#a7yev5rf(d8 z`}1ILh(Q?Q8u`X;G-^$=>=dlubR65;1pFV;Rgnxg!3NB)yITcg0$UeHYX2Uq16{k= z?|iGdfQcU&2>{RC(`QOLrlvz?OXl)GOIrrVW4YstJTrt{=?`c`GIRy@v zLE8A^hspM};~g9s+7Kh+BA6FYZh^7t-v zqF}`iSx8S$j}ln9JqhOS&!3^Lega4=B-H#T+1b&WSf;+-UKq^uCFkG4fe8#<=vw3o zEj~{nJQN2KQ6joH}kMia=u(Y&4T z+jrhC_@68CcGGb$S{aT{kLhz98hG^eH8pZx|CXy38eH9ozMVuq?*F5t<^OalqDaU< zD)qky{FYYXuV?+AtV-hl^z;6?E8vI!Z73uj8#{-(A3}` z%O4EZy1R8~y-~*{xjz`IeTS%w!h1V9v`-x)J){tNdBZ5$SGTXbR0@jmgyiIcqUEan5}Ua;c?+AW2I4`HwtG0B8DmQ@|J44R$^Uei z)b^%o+r;5?cQE@Ej^1dHHw+fBsz85bN6VObO=&fR|5udwb?X%vuau zglB1A>?%QE0p4jR&@A#}qH=&pU{KIkH8N6G$CopUlclBuoD zC3&{AxVQrzR|I!K?po?GK7QG}c%t5XAX&(~A{xA_xpldq8Af;lD7i(`1ld!dHi; zu8f5yDL?7o_xAM8lixZNR!Dq25qQo=h4AV|&iLJ-w1Q0Ykz#U8>CP`#xKWBWH=6O4 z^PKYxNT%M@9tW>$%ze13?IJi=KLbgB`W{^kJD`A4FLwzVV&x3E59!=T<5e|Sj-Lgpb}=++5<>}{1Q#*kQ>Fa zU%PSREtG2PckK|P4sz2bwMR6q1v3@`p#p&rd?F^yeQ65 zleNJc>{xdysiSJt>*Xoi-$-95+S(q1`-TT`(wFX!x~My@-zQ-Zc-@s0Q*}W>R9e+p z)u-V@WMuQ#IzIta8!gP~3j9W&9K}_);STytytP#qG#R|}n0Z-ZtARU|$>3syi_Dd> z1;nl{E|(9=-b}*@urGoqz*zyKgoXV~CkmvrrLbfXKeG6b!iCT?%^OMO3%j(o)WK%!Z$Q&Dzp1OBw!5iIG& zAHc%TzkKEfi&ENTTgg#dQ?jhiymU>t&BO`B?GCV*dB4A&u?J#*>UnRM?;4&0;0lyt zQ4PR|1%y08UtlX={za~LwSgp1XLYNO8@IgVvI@;l==00>cfKktF z*fPGUsGzqM!Eg38`E4e7=3OX9{#6N>1J5AjLvX z9h>m9>tCT>aJj;tTrJpj-K)73MC!aK)B!De21-G~>(gRT%bF|2td>Lx8=uGPY8MGz zn2TwS$TN*lHS&J522%Sx<-W9nJ9++xeW;_>;;kittxt+YGZrXQhas3r2KFWg=)fJ5 zODh#S7{fU(`!*EeUJ)th>)!+hZOCpYz*Z3KNq@ic=wZ81$PCoGiYDr5eoO0f9j*MV zVwnA7D?ch65CAS6fMi1>p%xW~}W z*i;Kpji10-@T}5CM%$urb7IJ8TaDoh%AjC1Cwp2B3qJDbgW_-cbEp=RK8kp;`s-DC zw`ET7!2FC45~~N_(x_qB^5SGQGqGsCZS+`2M7E#;=v3bQ-K5tMmpdA$`+$F)n1@%#ZNotG!LNYvP#vB|*v?YQ*pGiKf z5)KRrU2+WRDIdl`UEiX@R8UGG+WKK~!N>6MCRnOYR2qb8I`^9Y$FQ} z447vVEy&DkK3F?~nVmqR+u>T8s~Ewf>M-U)Duz4Y2AED`4{F!XARNGfF;1&0&~@Bh zJ0zv#cFr{jm!fy^w+L~qk;y1+VPP*zWk>REpY*uLfI`iQ8oqj6dH~?eluX|EW`u~ zojiS7cPT$)iz@;g$lK(U9+c)hAidYP8dBoxA)4uBZ2?l~+WOq+IEsoQz z)6`|px|YRc;*6e=LCe{6*>nxbJ^n};&s0C^g4Vcr@%ZtZuPy)ONM=9#cFP^x69NJijQh!*`@V32u56Zkpp9|SH z>jM~w0Q)g!zJ9K0O(${g(P+u;=9HnK;jiy{4G-_Fow~qm-B*SU!_n~TbM$R3S7pyM zJ8i5pJ1zzAu{#f`gV?fQ#L6WRXP|HR+)3-ysZ$4>cDL}RM%{-d-FkDSFNnJ0JRcTB zAN9r7G6+;7T$Xt?^D6Bdhs?oH-Xs;otf)aOu5E}uEP!~HG2?zh3u$7Iq}^QRY*kAfiN10M_u#Kdb= zQi<5mK24{dud;aA@`mf7hpvA?1D(!Ca_)9-pt{x4CPG)4p|V5qgA+5 zhS>md$$}}vZn_nhlwgLt+VME7eo4HOdDzl?mj0Fqe2hy(4Q9hE#%a&^;NYOY$c7mR z<9i?ek~~-kJR-G)E5k`_(UnwfDrzuc3%n=+0Rd>jsDIE?quJ|+s~Ra4sDE&D2&v4- z*SX{%hXtoJwTc)u^YXf@wztq%P@*7ELrjZ6fD+7@Dfu~j9^ISA0tmyD%;9Eey8i(C zBaPC*Yh%;qZ)A)E6)3Txd<#8jq|FTnHGw1VU>A?1*j?Le1`0wA3{Ia$%bX_w3+?U( zpf-ptJ1Vrqz%764<=o>2Pa*S?=#27R@JjWC`BaNV=j6zE^)2?L7#$T!zj)y_Gc#v7 z>pGAe$yf0*;q@zmcBexqU;enyuc`i|I(UZ>po(~jcNcLYyPxWK%UnKhvuQzK3`^Z? zQC_ppqL^9{!#h!Zo{rmWY)q^ouqOMT#$Wb&sGv|FIP5AgW&CBfXnC-V*aIp`=c%7$ zxtErfv5eT)`~SMq?n6Sat`iQ{41zsU?dsZ763amxVjdkuibmiOQ^~Z%x2V`na{BRZ zW8u1FAg!;)z`bidW<;PhyVDn%7c&OIzd?OV#Xy#gl$W3zKz&pS9m&LwHZMS9*>bR`^KH6Cs-p2GM&%DkO!cc!u{IR+LTo+zcQGYChFK7cb5QfFwe}$PRJtT zS24fSMncaFj39Wf#c;>+9P)w^>h%6O`ICeIein;G=q`oH_J zu{%V0MD?vn`#L2n>3KjHrkbNRrlpS#mJ|p#8MHfD%DGCg%UL@kO-;UFDF-p%4Z=;d z0`BOL<#;ai#lW{0&=M&Ygbb$c3g81pV7L}mh8D4c{XI zMjVXzSkWE)B=$`)x$VB`1&BY!yA+Pp6748%t2Z8;z_64a_0m{O&dl7O?2O0D{WjS# zIs=->R?@SsTPuA0%BNDBhn|`&a{BEdzz5`Fz?3jU@d3+~nF)v8sLi>qvp-#m+qF1i zK_dQCIVSk=AUE#C08eZibmFZn%If-u%=IP{eBtq2n_bFKqMjvK1`eMS@k1&7w=f|P z;~#@#lIK2MzlyjgCd^J6(>_^vfzTt6m-QA@H8ZzVJm*QL+sl^dMs)c z7J0&>Q-3Tu_onr_D;VOsh@A(r4U9AjGBZ0Su}ehi?T}T~{`|~B)lne^QwwgZ=f2I% z%zWr#uaro4E%{R*SEj*IZw^YUtvP4Uc}_msxF`F%3wA|M9Ebm0%IgMpmqKf8H&hE| z?bN=%>$%VqpS-KdyB;b%OXQJ$Jb6qn<$PT14Hp!+XjILFl7$YdD8RTrio%MR(^?Z$ z;5fYoYz(=l&{TLJXuv=YfOI!cEc|(CuJ)ZfF~5<~+}vugHHg6EGYwY+oww)H5hzB> zPZzD?!soPxL-0UEN{zI%#+x%;=kY3Y!Da)PGf6(O9x8|7JDB6nwrI^6Jzxw|de9J( z3S`XSwaF*mYl>|Ywvy{pa|)Yz-sDMWWNo|0ObfiA%GKN}yY2M@iMC}^R8*hSKdL;Z zNG3gW|9&W-M+o+p&H2u$5A@0Vc6~4aq`LY(aZ{r!(osHU*>#35rtb)H@A-jvW@2(S z1Vn^W^>7~(!CJ$g=f$P2h5P<3{w6DZ<-KAeZ4PE*kJIS4&4%)?19-j&GY2@(421m* zA}2}#SBb<4lV^jkYzDBSO%+Fy*roIodzDtLOpmhtZF~-kBVia0Z7!H=p`}v>AHM+? z;T$=6{hL_#bdDztukRCQe8W4eH6|^6I`buBqw{#N{lZU%3Y=z|9=Ob$H|OsL(Tg-f zK@CRe!K`mz1jz^m8UMLhw?JlklsZG>-`C)z=R5Huxn!TYM}sAn;1T$|oe7mtQ-xj2 zX3!?h_5seK1#>e}Hc@2Dt<;UO1BJ&>iWRj0!4vGx2WwqH&qU>6;ZaPMOSDUQo(l!T z!RBTjlkSrqhrvW961%;(h%d5lcdUZOJ8|ndug_*tw^fGTDyN3%r~Pp7@AAW=9?pD3 zWnEmVu{=_e@ym@t*!EoIV$^2nk5(wAArJY~lR6^&wovHBFnW_F7Z!${q2bMRlwP<; zFXFOY=)wDV&)-bmeD%_h`Jt=K(9fg89+%i2dxwVRW@3-9>@Qs@Kihgk9>+@tM@P3p z#|6oR@Z9z{i{k7bjp`kYSA8=Hz31#Kh;zbffEvmXfKYgjro%(is^3dsy(Qo<+?*$l zn4Pq~S>XfG>+^*P)!6JSV-Cy1kJIuaVLH0AbFot`J|7=nvE9ngoOa=w+E^6fI5Zf) z*`9TPm<4$i?cER$MYV=cI0<;oD^*;V30bRPaOc1kXixlc>D>dl!>c-qd(Wz0q`I~Z zZ^VR4b`1~=PBpM>Uu%V){9^ZaJaUm+7~<3O)*5_*wyP2^2VCotO~=x0N2ap{tWtDg z!Y2|q0v|PXn@OZ^!`iH-1|PAUcXoDO3Q>z}D7^JUHLz!9=2=B<>QC?B^wlEC`*f*m zs#emcP^~+}9)Wa%&v*CCi?ee3HsQ%iO~=GyM-$N=M^OA2IZ%bQ!3qWxEMzAF&E|PO z6JS2am=p-xM45N$axFWr#@RY2!|-C7gk9<255K-8+;H!7X3NR$DB;@rNEeYxzj#L)jUd==Ny7SXddmkl@-! zHp4_{zO!so$LG{XCY9)oN(jlJXO}YMzOjIkmX@1nVvYfb->N~&d?ysO*D$rGF7>j; zu3i=stEkTY)O~SsY6|zcSlIq{>*5lsJ;M0$?A&=;TG_57IhYlqUGGozyiXc@LsR|& zW59g652sguYyU+`NE%4@;|mLw{}wMd@+8y~%`<&m_;AD=Drqp`R76epHBx)E)JZm) z_cIiypXFbgFsoh|%V6lUTS}@v_kH50&oeQ{d2O#pU)9YD2Jw~$azZC_hAEK!*-ya3 z#@c3gJf2iqUK;U@to9vpUn~KsE2Q!Gu!${l8GE>qyl*tgdLwNjM^XwdSk9p5>WsyDlXErgS|+L za}KH}!-B(`K&T2&h#=pnB?06Qh0|zXfVjc}E?JVlax0o*Uw5fHi}8%BYk!UdyXE#x z!Zs#mzEA{Z9-d=}&*9iH&ayq)1)<_<`4Su6D7{$Qq#Jpb*lP-{^wO14ySZox&$wZ| zM9PGI;sTtU!O5{s-obohFFQki@{pR!r-ZQXn$~MUDV+ll1;pk=kQ2q<+uy#%#I!L; zWAQ#F#(dp<7i|>F^|IE|=CYRg%#UYqK?5p1@GzEG1+gs@d;+E}RO@_}Shen9NSf*~ z4xLE}d-Q;3lYaG!JK1`3XX#z%uWOt~={efL3mCB?Z~>a(@wYUK~AK&?SAk?)x;}!+4c_WA1v74ePF7 zN4^TErH>sPu7Afb+x|;#K0Wy@*d`7rf@c{-TY}$6oIcIA!r%xa1J|(_n2k7}TGpDd z2=SbXjjE%0W?r2#{qg>O?MzPXJ0W5BHY0eMo%hylp!-<_j0_aPm=%%wBgh7z>!(MERX}H$uK9!xvpr_-S8OaMQQuh z(K;}+&G*I&nW+{acMQyV6SP~j)iluPu})2&sPX1@`gsw-X`$WSfSfOBntIdKAM{u&m;%*z(i($bRc>{3wO(Sw<7pIfn-f`?ykE`J3E0ELI{?(brn zq!=*3aYZUh7sk;+C~-WWv-DT5^PJGq(kefil))VI%^)D*$*G{VKL3jOkpj|aLHpXO zs#j3D2SKS+vV1fpZo9B~L&JP3DFCV*>nH_LyMc0{!J#2;lkOYkP@F~{=gzWyfr4E? z=!FScr0-%CljXB;mH0MqcaKWYoyE^m9u}Zx@|0M*YZf$}*RKyoarRU}nYCyoaH!o` zvo}Abr3;5e!B*P7xSxuua9FE-0xe>4-Ly}(@vIDIJd)DztG#fW6Yp#y>D;N3woYJ z1@!2w8Hk=J`P|iwHA)Y%cioVjF2-$-YLptwJ5?WKv;itYEndDI7z4>++IzbiAf>C=ng0C7F+Rhs3(2Z(rNKS*5_ zkHV_Y@Qp~oV!yt=ePz~?1b`ffkRBWcIz~#2xP{+yJ|G&J`yzRC=`t@{;JxSG5@i=p zZ(sy$=g&c&A6F@!7e=hlDvTaYV_E98sC;op*Oa3pPlf^pMLpHKw>h7!BLR@zXA@Vg zdu*VNmCWEi4Vym#ZwH$9Z3JybbhCGXBW)oFEa1q#-~!-GIAg zpj=M~%H00tGvo*vC&fm|1>pG*^ufwS5|XAYFClRpw5fN@20yvG?Q`vgsI7gQ(#eto zE+q8I>mbm{*xPE&UKq9I z)n7$w#-xjj$5WrNHY{#zZtDM>Zjue-4+f?-u+i_mcyPmE#EKaFsGLaOnN@xp-_VmU`>2E=>Za!tQSYfI$sZp@%}75 zA_ymk?T6BadAFZ0_M{l)(Yfy&?Qf#{f{HM+**1M_z+PYI_(I@GbVj!}b0T!brM5*S z2kL@xa1)1amG$RHDIkvs;Tc-}JpVS^oi&}avAjfu5EbgQMs%i`A_`#lw%th`HRV+L`3SwAV#V&r|;_EEqL}P+@a*C=`N&lp*_yRl(Wz@a9c1 zoIVs8Aa*&!U|~`3Pv1JPCK>7TwjjwOw%i{zN4K}NA`>qe!1hpR4TI5x+Gz~DKG+2vEqH#gY$G}cPa3r#x4w7bN-}(pYg+ zVlnh1CETbgSNr8*X zXkl{lN#dxmQpEDIB4F@9O}dd0+Z;Yo1j+-5bR-Y=w!#EV?Tuj`eDj*lJ!v@+!?T_E zP@gxIc&n-Ws1c2ly>huSw%l!R@vP0+>yS6x(K!YM7!oRB_M82jptbz`T`{R6(Iym?808a0dly{3&7rsR=EIdg&-B>AU9fld z1|6a3-uM;2N3tgQ)1NLDYxV^oO_M4Yv{tVkPISgW6B>EtM;k2Wr4xkD>UjpwntWg{ z-}}%mii-l(vM-!L_#W`;^Pgc+%F>-N8KuMZR#+bZ?ep2 z&08zow;Q>&a%E;^rOs}oIMe-J>!L5c!?i$QQb)Yu6hNA z-O`tO&nPJ=VeE4Z+mj$Gvt8^DhQgf{KW|8BsXEBzj0e6NB$j2jDe3-kHJB*=f%MH4 zx!I|ydpSw1Yg>8lprtsE^pyhxL1Fh1{==lCWNKL+48IsjH+CPm%+LAnp5Rn+J^) za#qbZo}P?w3w3AbFmCm8f{(1VGa-I+VPxp41R7QmsgEC&D|84TBAzv6zjaG@WY3OY zNXSZ~&wO@`7BYiaFF$e}J-y1lzEBV=2Zu(=*vgW0a&m&K?!5>1?3S#Y98^Zq?wmE3 zeW`wXsn6oF(EdfIqM}@ju~)5Xyg9~QOnX?gMV1DbW@SRz>Y4@ygq2M6{~u|89aZJJ zegVS^QBe@EQ6y{>K}1ocRa6vIEI_(MKuSuQg&PGG0SgI95m7p%Q&PISySw|H&yKVA zZ+zeT*UK0VJ^LuE^{hLtYhE+%??m;W**-%FTdL$^xhZg5IcLy%0~=%$9d(`-#{rb5 z3*6i$L$%J*oF^Ra>(oUY8dTZo%y{nbsbeqSzgIhb=1fDhIvWBmKtWqim@eou3(Tgv z%TKjZjS(i?tv19#G-NQ9gBuHLs+{WKgm&gnIl2i?;=Ry1z^#~&U-nsm5@{qKk>KW?Z?_K!h&yT(*+ z$xpp~w;X4gi+kiBJox<{ikK)>%5-POj*f{k1xW57#d-6^;=vxF0?s6u0;H|8`*(*sN3g z#$VhLZaG#`=HVCZ9jZfR+^ehahc=m4AE)(id7MH~9;GTeJS=Xo14L28Q=oJ)d>XxF z`13h!wQaYXnwqKvIS8Zl4f`BYagidqLaP%o!zc`+xzv*Zy$! zdXceFI9xzP(9Pg;tq7AnGYG8puEne0J!fqOe}+yb57vekQO>oM;a8;iCkeIDVLtZt zKcZL13WDb|*!=EZ{F~7CdsklwV^#~=MPL_|l~n`v>1kIC?k6pSNi7saM{ zb4#TMHXj}O6&q3v2o&LUU|`oSTo;K|C5(0ybKebxD!6KyX_&>Vx zT?&Y|{v}FPB6`oDC0+5r;;u|cTTv9Ms2dw5uUJ~t1RMCZGU$;RUTmIaeVKz;7K}E0 zZKUpLkBwN3CFQH>PlglspqfD2c;>?vQT8nXFeyV(@gYOB)$Q~He@))o< z{CYBLY|N?S*E5wRlV9+GtszSF5H8^7Jo?4GoS*q@Q#+rG9SwYiic1Zi@R>tY+Znh2 z;qK1hYbrD`J)PSB>)eHBZc0<|h9aF;Y`;bUx>0>olkxKW#FQ{33cOiOKUJqo63WAM zWbWR*TOXqr+Uot!z9D{B>j(Lq8W&Lpp z%CeJ|dmlcqaD~QT3(0lLd#=^`!|6SnwrtwZ%Zmd>o#7`JY;!Qhdg1Cyk0bg{7gFv1 z{i1OQ9r=T}1gfJ0ue?l0zO2GQ%xo@Sy;6CYr@Fbf^yv9xn^1wkqUV;8(fgiDJHQ$? zp6o85ZWg(ZcaTsmq9WfF zSYcjKq*~_NviqVfEOE9i9YPvtfU5mQhSas*lFyV;hE*Hk%fb1{?c>L{9_Amvd^zY; zC+RDw9min|tDi!C7+|C@M*YLCC4PSpmBPqXh(ZLDmKg1-NB%r2O%SmHQqHVZ36*nq zI(qV?FD%czcs7U2HJO+YeFzaS$!FYGfdjW$r|u}1h->ki8!@+hAO3FtRUM(&lzKC7 zVhe8kH^|z>l(M&O9~CdYjmwBYeAiIZCYW|j#ngt&MXPpQ?JfN*1|8m&+*|-rsI)FQ z?JjxsvYuQ~=zq(Hzg{J~?TlvUSf3ckB&bUees^Ykk)k?8sz`0+P1oLsKE>-!IWAaO zB*7fltL}$5uk4mWXkM zlP7mDy%e+y4rXg@l^W9+85t@0-EoL->)z{^vbL$wvF7FGZYWDO-AqrPADZrN(el1i ztu{j05g$I0MM`(}^ua_Tu# zL(>5KSCjto)v%$4#s>Hnl^4!+b!H)(OSXeZRE~XAqE#Cv`$7ZmD}u}AqPrvG^jo~( ze!6C?z40^k5Ygs;yZKqVUKwRl+~b;qsW;C9ST3^|>CkK3+WM`;!c88pEtJVR1rzaf z8>_f`|M|GK{fG%=zrcm`xS?4+jMYdgQE@@QJI$*nPDSq6$6!^ug8BFljhFX5^E`-P z%YVM!g=S0b^3P`yF8(pBOWLwgp8uzgBffI^@7$p(OJe;?Rq*ctRmYNDW5SJ#I>2^i z5uB^W1RclV;NZs)i~RR1q&sBrnZC}ctt>31_{MNx2#-MS;upD$b+r2|KT!XrL;B}m ztV%tVY1+`hxW&z(kL0|MS2|qz*_Ms=)BzhAg>^mt?_Z8!JbU_rnTg3^E?x%p>ziRg zoX(MNVq|h(=byLq*Y8QMYkuozpcqu#k^g^98s8NKU!A=`Pe%(6hd@C%vfXd|WnDMS=Dz`snp`g;I1^lRPJ-w2zJZtB_q8>{U* zT(|Evz5U{Eb=O_{kroEj66}inW_MNp`yJ(Y46ark4Jh{Da}urKX8(sE%Glo?pw(Oa zS#m1s(Q^kL>1Tibo8kNG@wR2+-yL$8=u;-28K^4&fw-fq%Q-O6p8Br{VY~oL_ryTo z;&xQutj$y$6~G&E8}BOO$)E2(;z`8GlNfFIfYIV;vuz1(2K3O^{!@m(JE5S*%WG}3 z&k(Gg=Ly>3gqq{WUk|f-YloiP5Np$a9=-e9ULx1$zh9^T|9$4fpZ;GBYX0+m*8P9@ z{QmQ8{{KpV7OzCxuJ9jS6ZRxBc#t3cJ40nrnausL{|Qm9?D_ZK8?_FfoTI3!blJwt zEIJr{|ILvjIFP8;>_~81;@w=k2*BH$i)jJ71B1vb9v zb{)NM%G0p`a+@%5mN|cQ;Cttmt1c;+tC!%?lXt+rB+>xLQnh;io~Kne*$@Bk$wd#w zZ0yHL{SfCfiTJ5jnwocAnGYT3^9+0?Hc%1hcnO@XcjW7lEJ9ipU-(FHV+2F!6~x;f zDPyVj+zHLrH{Egr`u7~YvG`(kq>=qX7Vul2l@zpCLkZo3bt+Cdf`Jk@}D1j)*;AM05dp$13zK! z--Vsy9eZ9oNoZAn-v%mP7Ur^@3Lr$i_+AU^t{JV#C?cQxPj!A-R z&W8^lL|c+gwVM;UDOM?@77zU_cz6)g3J;IUsYu0?bHG%eKYtz)oQBVML3z8bo;IE` z4Fl&!uXbiWM94?TNFMrc=Gk5tubqUdIDvyRB~@lpqNE}MSe+!$ZB;J z%HT6YkpsPTAx6$5&<2+mmv+Jd;0i3whz7X+ChtV&iO9)9C$93qCDWP^f@=LGSHdIV z$$?j|#3shSi(*CX5m?;q?Y%mnEwmDu;W0h*P|12!_S>zs56^QhuX-$>h-+zPZ!s(G z$iCq_NwsuvR-NN`0Jy`=Ku|DQdJyLR{(Ygry>C%AOLP#`X*GVm{^C!psQ$ZrqDA*6 zP(5He!O3>Zd;j@kZ&OB7?JtnYwktN&AVU4p;s}r``qWGw6XF@4;2!JhsZ+|=;;D#~ zG6cFAVXR+`$AbX%TjwUi=GN$!k3skjV@uH5m43rnMJ?djD&Y*7msaHp*$)@QP z1e^u#jU6K+inI9(l}{{}TtV~7wyP)e9!Thj|vI-0Bqi} zabIeh4ZIVM0G0A?W?Hwrw$NpOLl}deqaq^olD>(gj1qZP3;n z?j2XftZICE`YT0DG0!HU1tcZ+#5t+sCTaO7SxO1ykM z^?VOj7Ws6eT_{AZ3tD~*;BC1UOvk)w(>NQakk6XctOaWSz1+Hcd<35EKRvjgTsr?5 zu*IDO{gz6D4M%qXaXd8GegH4hto7+WfT5~I@p{F+0~>pwa~4YGCX$a)TY0``$=n%4HGC&0WyV+)m&af(zI1mw7Rw+BsmWsJu)gP z3B<5>M|8B|4NrdOw|UMJdexhb*<4V=`)nZZe-A6bX8zvYjV~NywWoFiVa>vP6}}E@<@3u^WVl=oA3klcx~|rs`P!{p zbY^W9Y&f@6$67Z%S{C|%>f0;Bv8T6}+jy3#(ZP`bq%f>4Pf`~H6vAiNxqi%rRV<%w z~a;ptJ=L2 zkel$rxQ$a;R*8H(I=?JTq%wnbe|nbs2pA4An?-{M=k;9fR@HWpig_{$dcHW zYLjL;gS4{aH4&c=ZQguiZHUVOk_=bDY5#>83`19^R;e(0n{nSH_eG!(lbPABr;TTISyI=8r!0ewjOLd!{5)8X^*066m!*#%R7MK! zj_ftfiA*K;TRuRmsW=;p6W3BDR8;J)czPEWbU>MQ7)?0Tv{OSk&CQ2qWJ>6H9iH<% z8Xhrw?_=|tYjx?>!T}mddU{{WUOs3zFJ|x4kFs<&6%zU}G*AFVB@7^b>et*gF#m7S z^5&2}4`Yc)Q=>FzEZ17LY3#R~B|?5U(9kMU?ng#=*13}GtYRT|ZzdmHgB0Rck#3<< zp(g!vkiLKsN5Wls776P?77eVu6rG)2g|iLon>co z_TA!v6h8AQ)(%PU^8vMNsD?lKvEAyNO&RMUes$#gsq}_5OpmmDzE~$SY%>OuR+jxk zUb?--l5J!ix=#T->u09N_s`TvQ!imL|F{yVg}^EQ=>tEFK$pnO&u?P#r9=aU(J>2# zlR#IWCzoP=;#BuyTA>1lD;aHmZxa-l=9U0n;3BDdsAIf467cI@X?CC7@C=# zRD;p;K$D6YJp{oZ8}z zIWuqc|7vc9DD3i;X(gQ_M{@15`4X`0Hu^u+D^hBV)2Z9o4(^8CtI3YmXVk_X`wFv6 zb{w73x%rs~c|5O#tmd}v-E}?NfmW;De;bUW>-UOV-Q4S(7qeoo(_kIB#+2RCxm;?x z+GM7yiHmX9r@r3akQuly#SJ&7IZ{#`1hCNE%_r80Hakak*%aDgh z_Kh{2jVMqM(&N2g6B*KYcw6D_rQ5evUd&*=CSJyUq$fc~ze0 zlVetSP7lfeAFHiq>!iS{jt(@}b68fK6MWb%0 znElC8iF+-xrsl=i>fE7iOLJ8*SDa$?GmPi-5u2=sb>;@Ij-B+b{Pjr8ozyWPW&5L|GfdzDz+m>+(a~sAJ&ZuH48po zk7ki*4d0#cXzAg5L!0hw&9{^JrjgNh0A9N#?%lfw4b?9EdCOh}esZvU!>KQ9v67uO zji-JzG_X+=Iyi^7vRc8Nj7`!vCr`ybGLjXcg_!*Lwpcygv2EK$lj#Fl*-dJv_7L?C z=2PDH`0A~o_0p^@_^`WA#&fcx>oee^si@w^+Q0)^zpe4vqj^f~ixAjEt8Fdo%G}OM zx^UNMl80|WWjk+Y-yp`2RiJy%eO zQlMGGc+d5Mg88Kd#c@L$>r|MF9p&TmbO@f=&FguCNtnsWZ)|}XuGrz8330;e+lkcu3jP8IMk_lYzkl@fPi6$z!*LKWc$_1C0B^!1!3t(8i7H zz3VK%l=tQ{?)7kdz`JKl_3?e5(!M9wrz+i6*-BcXALgas?DfI;wWuQ<#e?OEMz5>1 z`zW?rwAL?GY`4h2(R=+3}GOeRvV$?VDbb1vJm9h7FrnN<2B6iL;Q%4^<3jLuhI zJH0-}&VD*$H#2kgtKuC}n67vYC7T{W^Z9axsyNc~#?kb}S@~9*HG7F#<7DaZO3f`2 zuO&){7@Hg%)z#IrJA;Zi;$7=A)>2D@ax@Km@B04rBV z*2CNsuj4RjX2^;dJwYJg$xCJdeV`9Lm^Z)aYwXhUM8j)wWxt@5#FRA3ullzIKHRDdyw$`6GG20D(jxXx=2X?uXw2{ z9baC|n=g^xn)7S}dhf-feOL9E6;{HTE5jmo*m@w_C^@K) z*)KpUioMT%zSq$3{5j82etrkH(7OBQYhf$odZg$R(OSCsx@P#X1{$P$4v<%1y?7F3 z&%q-{USuFb7teAdC^e}=pp1^S55OV#g}buq@AixJ^_j~XoUp3Yrb_}>Wyc}}h_WA4 z5%z5T({U6dsD^;?Szn(N!`uAFs+lHKXGPh zm1jC)F>2MFQy>T?MlWJgys30*!}fxyb~*p@2QM$`qdRCB=eM@AUQq07;mUMf0Pw%NbV(dobW5O~P(OCX=oVPDKG+OW!dxbj#`kbi{06z|~u#oH=+_XX5)k$ItqEvg#`mR5Z<2l?MD}P(-H&bVG z5!$^xZz?cr3m2<9w%Zb->pUf1N3cm;qrHP0b z965Q==zy^KSRlD4F*1$M#n#5MDnXJU>1vbS@K>snLeHm2h1}O}p%!M#@@s_?a89`*^#^c3zCC{e zsz%`+0%YRhauvOuC;DCZ$UD_ZN60Cj)4Q>i^bVn8V)>_rzwMLa`xh&eAqnjI6O}!o z&vg2^9ck?`{SjqFoC6%_=NgQAPg~dDZdOoi_^Cky?`(}?PTsDL4vGtb4$kJQ4Z#D| z(e~_cy88R8o7(?Ri|B~G?kB#-Bg4IGu1!6!SeEDw)U~ze&ppoh0C~Ir5S2%rv9WQG z!B}g^|9BI)S)Rkd`W<8DLmtCp{9cLk<`}4i#_8b{!W|iQX=hR~u?o`6+H&zLDC^%T z?vDcmuw6}}`ts%cllDvty%fzd7SfA)IwiKX^Sq8XXe9o5vyBU z?mzX>#Ni+40MEYveQCn%x|{ko(tC`IsAmRia$!Zk!N_5G*&0f#>$h$l0Qo1$B1qIj zK!ERFQ;8eS-S(~#XZt|=nh|;y!OL1&*PLl@BUzJBz_y8PVU-RVp}TxFn!IK^H6|nf zQlG~QBEaq^x=b4u7Wx!mMk5U&Nk8pBDmCJTxIw5k$#nt&ko^^hCnt?y!xwnxkg~V; z&M%bMbX#|szU|L;yxIY-(i8J!{9i9$HAlD?D6fUS9`mPfl^=lFVW$a1lRK0 z8-kyhzUaS0MQ;Q$#n}iOE|1&t|31QP@5GN^U|3-AVeu7hI8}W#kb2`a2?_PCUe2An zc1vs91XldU2n$(+fyTkN0Izyx8%LYOJ?{q&RFg5AW+f4+c;LT)`GWvFzwJduMS=S> z?D$zsIg~`s2;%0)8hHL8cIvG1BQcl5#Hf90xrIc_$dif61BnVu%;JuZ%U}nG>Nkh= z`hHGMT4YQeBywc3H_~1c7r#pxmQ%C!OJ7e9g^JFf+`C#5+uSj1hk04LzQ-HnhV>Y?Q1a)P12!cTk z|F`gPMQ7S_Cu*nqpB7FQ>BflU35ynSFf1LT&8Q;)lR}=e@ivib}rnal3 zq77m6b$4mcok~uwDE*Z6;o7s_J*d7gz*{I5CfQhzW#bQNA`VJ zjuZwv5x|BWczRQ^S;k^wY{0HVtbQ~8K$?ZU`m zwiXBTXgZiE(tU!FZ?cSs25Z74(vnS2DB9lK*e{K+AaZNOxz~mN8P-N<5YOK+W^2pC zKR_u9+3BmS10j2{$kM{1w1vQnKBIs~CBF6*S~<{-gSQ)U;h*=4-y>9sf6TCNe0K7S zONszp-L=Tc!SkkrA90$@3YV3X1m$Rbg;#;pu~nTw%?XsBOl7}G{`!iNkZJ|y{dZsA zy+V8|wtYK&We#$2l`Y32Q`0RTzOpDo?(J&Tu5@9Ao1_p41x_F-%|SrV#Nxlpcnc0Y z1qxse*KS@90*{lr{>n-Fz2vf=i^75{Gj|S&efR(G>*n6L>)G#&P^4%f<0M8>y{z2a9s8wdv7c$+n%w%N_uK_NvpGf?=~gAB zJzL`UI3Iz=;+?0(I<%F2agkuOeEs_M<0r>o7bp$DpX1&M;OgrDC*{{?z{RY$Gl5$|d`ku6MM90+E zd&h@3w|>+-F6xH7=>L6TvY7N7YuvFw>)@d?l*ec*&YU>|a>Dvj7GGs2aEuR}J4djM zA-+If)(Ia0(=ekysn(4%Lj|S6o9zI^m3W;?3Mtafi3-@A!TQ<-EG#49l6p+WO9zgvcxI zr9S>)Y)`;53zd#MaAqopFX84*p}df@I_C|)m?(lalx#NSfa|d_;J3zG5PQU6)c5kU zSVe#_o09cHkj~&@#{8hfx_wkrEA(rj6?NQ}!+Y?*fINRIGD-#Jv%UVjN6KiLn86ov z(#cX8kiMTe+i%uox`E!v**arIUPF(EQ8wP14^F+CTC8Tiu4N82BpC-YuALoEPihp- zmm2+R;TXl;p*MLa5zcX$s#vH(B75p8NPa%1_dps0Ln`t|XNYo!Fmq#^dXo#Y%4xz> z@U`0s?fR$#>FLh!55Xz4P5srodwjvaONDbG>vv%l(QkEx-H*Kc$!fkbzUnIxQaBJH zXgZ@gs(=sw7I=`rfu~2R94iuL=J?{dbP`e|24b1D<5?q*_GW^z#2ce~|8@L!zy6BT z!stBbWf8;Eq2e72aUAKH$;uFKNK z1pycG=+?i52<+XH?oEWc>Z8cF@kY#Q+bQXnhx8u8Km}atLkQK{v2*8TXyo}!#`hC? z*L$n;_I|eW?;tc*nC@Z`Gm@0ejCOQw!RmBU!i_^`t%u#{tn0bcpCv|)P6p0cu$m#1`i0!PtUVf z=hl`+1psnz$$5JJE*8`=w>|RnEUN))1nZL(**YL<>Udr5Jc+5zA(jS3g@v85T*6fC zDF*2pNb_R|O84TW$*$-arq^Y9Eqoa;Z4kY`x9e1rr1CB7bYeBO+0HSOzy!r);q?j? z9bsgHPII5K%|uX*W#U#eUmQv?9@K_}H&#G@E$fLwsTb%s6sF2|xECuI{3 zzo21}Cl?GpIX%Q^YZ>|&SWeD&EUkIkmlyBwB&1FR*_-f{L&L0o?_9~HB%D6$o=kmz zC$$>51W$?IP&I}A)K{=(B3YS@woPjV7khpJ@LP^a1* zLWAuWSI0bqDdrc8xb~hl-vlQaZ~+Kw*Y*HMbLck-y<`UC=|>tG6U|d)#I(B!?jt@4 zZO@=iBJ{IfJg?SL=T4Soj0@8Aa%J{#-1~4}27rZGvnd-Y`>tn8W16r?;sGpkX`f&V zgR)HlzCFk*RD_LGlBPAf132?A5?+SL3-5MqBZ~~#h76L|mRR8q6nrn=#d@)TToUl` z#*Hm710_R+^~#U!Mtg&%Z<1Eedy9iR?BEkLHRUQIx27Q2)p*iOejhye5KJyG#J^@S zrucNUxj@mZ)uN_ZBGl4ALeNW#E-(B=e4o5BSBA-~YJ!u5_?~lW=dskcu<{QdOqU5Y z^Gk2Wh_*PP&4w@xUt8%{$2@iYM`4~BcnVJ7WsY|iaaE)%rihEl|A?qaaDkkb^TGh- z5y%CPu(O9$&iMHFEOac5m9^?`-!ae<$pzD>!0f&B95xd*?$)yleI(-40M(vda`O{m zzb+}m!yTL8YQ_bo42j2})f-{)?|gkDxRn0BXRlN-VO@Tpr7jEoRZ&5xOZS}C+7BB7 zG>yM%YLMV!2b*xj4uS;R_*1{6{aIaAfK7@HKYY5z$M^Gm zGG@1mrBW``GGz^F1aKwg`ZpP#8@pb_)OepA21#22`X!v)l9ETlRlNo zN`_$S40O1Jj7GUF9V8RQ-qQ)OaXqU`qk&>`ql7yH2udd#xd9Uxq^+DImL42=iiRT7 z;oVm+D>~Md?|R0t@-W6j99hP8sJ13x#zBp5i{t@bGAqd z3onsBwf3b}?9o+iyVmb_0>#^Q^ym$U>**Z-aeVol#c6!lL7J{CGPyQC`RC$0zf^8p z&u&#ea<`@q3QXWk?eX#k*-cGLUlisRnuEC#U}Hui4tj%ijf58sk;%R6TfrZNv+__p_*HeKYw>^*6$)#^lQzH1I^0A#_|s{@+4jZx3m ztR@TC#}bmUeoWbyc7m@gI5FQyK&a#ZH>5+(i0nV$=-pqT$LWJHYgV15Zlsqw~8^maOt zzB8$(&q&y3L8im5ERpl9vegeV#9yuHouU%Jj@OA>O~UEYv#;CP+3679kr!xor28~R zs)Ff-TZK8~)4kCz2?_~@v8>&mK5%qp5>nytBhaN|BP90peonH@oY~c?14(#ihM%>X zaPXN8>oxcu{T7zayAAG?jK<}s{5$e<6_S0 z%DlN%RR)u36XU_{_)lx4|dXh*XV`xtV6&hZO9s<9ieMhY)2z#=*Xo%JO+>Uj%VR1ON? zy?1Y6Dl+3iyc!=iKBy}08$TtqDFtXg1}`1M8a(lPC&?nrBGBbTmj<6CxoAElwRhc{ zAx&RuYJ_G2;?{?|IpnTEa0;-J5VyjMXw9O<7}i%@KqSmz;fQ#IRmt}^Kl5@g%(XSd zm=cUJlc7epl(FO?@1;^0pul6J*hd5%(k4`_qID+~lSXQzp-SltYl?ui(vPZd(dx*B z8yNu)jmE6almQew!J(&8X$d1Rol-rtrd@^u;!u>~z{*IEyHe*nN0#(sGaAkX4?Fud z2ho$9_X;If&d@QP&#WG(s{g6ILwjK0`B|MjRIN!C)yG2V*?r4UUbbeVRBT9wg-p`Z zuz7kl^f@M@S@6e1m7!|NN^sU2r?qwK6G6CgX&)K630oia;eKwMm(7+-iWwbWZnfNA zwYzPXAp#SM(5U4S-q!E46CqQ4w!(9v{{H)=BNa4eV*e&kcZ(C`+{Y}Rb;jN!QsfoK zzS-EXU%S7IHZ7}MqJ7G_zS&(4Gsa>sq0cCz?(eyE9&%%>d+cqoYzOD!NHZvs0aZr9 zFiA~aomheR{AM{$&u~Fos7ng}0u%;Epa9d*T5RnowH|6n60&dy z7RVcD5@F0V`YXkt z#l|-9w@GN6`wWT8(Hq{^K(}G{F}581shtXoL=sV7W9||I5!0hG&wJV`vP$RH=8CJr z+HNx{IV5amTQTR)kX-OVdC3jxNPk0YaE%_)^VmK`KDY=T#O5tq4)gPSWjtEW4Qq(? z_}e0Dz!0T{apXNms?iKT5Ki%}Me&1+MOz#lo-2J^3@Jv9-4JDXNOU9pH`%Ih(in11 z84&K$INaf%5qNW`7Ct2(U=D(yUv{XeL#Yoe61PU5P;o(nr)g*%DTw~LtnulT>D4y> zi}utotCq{DDian3sT14rt4x`1PEOAFkU_=-@eAcpMWjL7Yg3zT2#_W$WZ2jo8Q`=! zXuqY;I5ou9#cyl{;LCO8sJ}X_uU7$c9=uEW{KeU9l~Y^E-J4JwGidlBwgPm{^20p$ zH*jG3zw^wh?=J+YxcWtMq36wOD0qe9bpj@Nl;ed(T=K;R;D4HtkhFaRSWKWnQtso( zL?a;OlLMN5un~}f;5J^o&&^Yov3Te1-Nb*5e$*m|4TNcIpmGM{5Qg+Hgl<#YCrR%r zZskacVfQ*a$XDc2VaHCL*^wO$Do3agD{_-bzYAkptt?Fk1zw<(l25MQe8gN zCb&N4?11}nWX8%>^UqJ5TAO0~iXUO|fC4(PlF;U5)yOJh3pE$nZ||A4cuQfnLQ7cz zne7gVi61{HUUkqgsBdmoP=e7B5mz+lYh|(8pozWs3(`c(63Fvblf;WuifY42Hx|^X z&uEs!JeY-*L1nC_WctG72`8!}$h#}!r&{_HE>DaoQ2|Ais|$h#!RBZAJ}2igr1+xx zEN7{KJfoFSCa;_Yt#!kg&A4zILZEV=N(xOJCcFff$*s6ucQZGd5spsC`CCO|66kYN z^MS6?=iR5mY3A83h`fLDTt=DfGt!h`$ie9h4tDdCY}z+W!e0vSdEE~Rw^?qJB%;^Q zJ8_v0xVfKbi9o3Z?;s`e%vqv!#f#8t@5)A*C*2bhDsCgyi-ROO z(acX9fpCIz%TvUDk4&F`k3pbY%UF#0XfDG8xVmB$M4qC~{CkI$*47U8v-eJkyeohe zprM&rf=AYRaWVGe8Im`>PCbL=CaC-Xv%3=tK3KrJEOj++Xu984+e)^tyL)`<`DXNC zhE+Ft6@RWs&loRX8kPAQS3UdguQ42U)b83hQBjZBw(O#V`Hn z;U$9%_43ST2tLHo4?|o+nFb%YM?s?J{C=crs#|c$FfcISv>>FbxNX%Tak}Ak{)c#4 z0|nty#Gq{Z$~A$W*Of^ln&Z@d5@=<>#p+`;XnX|B;ve|Fd#6fgdSXbASGWH*vN*3B z&+DJsLm1A@HQVrttSp})uLuQJl5EgI-|(KM;o}P?xbvVhTy@#=OM)x)!GWE(Ze8zi zWF_Z7Q8>3c#p?8go#*gJUS+a<6T}7s&j<^#D5oDM%CBJSw_jy6L057 z)+$U(Kz2Ap!anIt()8=;y~BGiQeTmdnLh{pa{*Lj^UFnC=3Q}hq4(#&`%PW@MMV;v z{e5WHC&{%T$xc_Mjt(kTwQ0_TGPcuIJwMwdfY*_amkGd z`yeZ5zDCKTRZg_o^444_Fua~0{2WR6s=?0(b%{buSwH;bAlk^<;VNQVOOm|f`|NlL>RV_{t`Wt3bfB8jqK`K)Du3qzv1+A0S|*`v{aQ&cui?Z`H(*%H2Vj!xQj+wR?u;`$c-p9KXp zgAc(jR0lyfp5|uIK2Ob^I=4r+pr|Y|Y828z(jGyrs2X1!lyP0CDpkt;$t;%V-~VZg z1k8L(zl>q)W6-2Sv%N$I%KQ4%r#_XC-Y^T)J|4TFGvz=Dx-IwGa_wgWqHZfkgeAx& z72t>nFZ-L^e-1cf|B#iEgBArEr{bhxkTv-ga+)or@Hf{9tD+1S`N zPIHY6?28upatU2y@(WD%y}iBrVY5uwVWCbGb)<`iT{X3-?EzC1ciQ)`jLmxof*Yds z1X9d~51&>O!9_s}lAHrg6dy5AVXss%xQ@Sc74wv5yMB zDw6|r+#?9XyggQxREY(c>cT7F84w z!y-{_)}^(WVNYf`aYdQ62`gEzz7Tix${6#tf@v%bmXyEPm{O3jko(NyFgy&28W|}X zZl#e*doyA>>_KlOCZIJVRP>CO6^d06kO680A*dQJNob3CG{T5wpRH!wF@H#m#P0J4 zfdVa!Mi0~a$nECg)vnALJXOtLPy0!)>q&!mzq9>5866S6;9C?yLUsoGtaM)V5L1(} zHa{R$(Z+od;(Zk-=a8zw4ToOQnp#5rIXI8o75q!h9(x*Y?q(W zi!8o;R!85BVL7=`O|LN6jr-R&nmnH1hJ`xySvMM zBdlLP3)HB)FGZoT=5jvPapXnwpKD3uCJx#U#~XpDJ7B9r?;Ql_Mk*03kc70iul@o z<(ACkMlo(rB94JX+(jIJXfbGNDp_`1!CavLz<=UMtvobGI&O1{*x=;W6?zOM-dIhA zvzHYq?84Fv1g1r7M^K=o82u6GH!(OANi-4fOWrt)(ydqA!WnnmG7H zU2T(gv&USUjVq?qr@O!HqZp6)+GnDnAW2gU4n`ScCJw;uJ9p;LlnF^HDGBd5dP`-| zk30#VvCnk{CC8a*Gm~Ct^HO5T!r|ni{bP621w-*SN8GB7wdqaWWB59Yd^kP}tQ4!h z3$3zcr-=h>ua1@6H~dT(7R897cPC7bTt$|*Qk@8-@-AN0ytRbI3snXQV#FmR!}YRw4clBc6m`rG95?)wZ<-?=MUc;YI6O-k5o zNW?Aq6ZAKSENj!+jpYHdV37H~)A8qVEBH##? zFRo)5E2V0W|2Si_n#+LvVg`&cxgVCRSkCmnBF6Qr?>~MtDHv?l;+gD{45IfzmlD2K z$oo#hB4hhslisAeHNQz|*7Mba&?v3jvEH{<9tz;g`{9=wuYZEsE72xpcGoAuE+$k{ zl7OT~g;GLy`J-2L?KvUcj!&1j%|>QoijSSzZq0V_I17s}@qE!wW)v=QjRja*MwuqV zC~9@8HHE+=2-jwv$aJ{@!PXvlfhA#WB;pr`k|Y#pXi&jevJT_f|_#Byjjw)c+tkjU-Tid!> zW+}SmLxEpV_XH9OQhDti#5jkRUUMj3k4bP3CftfA6Hk`r!pt1CJn&aehb)n{_}NIYI6`ofyM;*LI;_$Xj5R-aI)q1bsVFlpewt$p3z_t*vX*?QnwaofG+{q8z$=t_AkWBQDG*m~S=7x$0va1D~{9WaJjoiaLz@ zO?3T`3azzpuY!B_vr4|0&?TS!IN3WuHCJ{rUO}Hkw2fx1hE#CK*j1Fqp<5;tn6C>= zj8ws0E7pw|O%(8GXG9So1-977*{C;gArlZtk;^W3|i(HzxAr;+$uAQ#@hrfy-Ay^MrA^yD=`}Ie2Cs(kd39G$u?tao!C__-EUellSCWhN>*{ex1xl`?j=phTvh81|cr2${dBJI$#%Hvdw_l>*XIjL51eIO++ zMWn37k)4#@82v4C|DnBw{g2q9vZAi?0MT8&f-pfylB*7tXDEwd$y+*jke!2<(OI|t z=Qg{{%)ye3#o;q$TN#WKE7uCGVkLb}zex1RPCcWaY9Y_2scE~=!;puZquzxdlJ4-% zrQ^*Co8Ip|J;X(vxTu(%T!rVy|8RKF$`I`e>NGW!BKk=Sp9<@sq5JToQz4P2(BU#m zIoa;YruW|Oi$DG#M_G<|iO(i5d_TWt0%;f=))glb-0+-pVZf<-o^B^6b>;&3}ORH!*~$__n}=?+GUQ}j1h_gkt8h!syr|xgNyTdW8)7xBxEl$& zE#e3!0f{s_gX3%J+j!~KQ@;?ugm|7GQMg=ddUveNBq&oPqU>U~xgDH3N=3`zQ)N5b zoFrX$oc?E7b)XeJ>F5owho<+bv9$ngvI{-EU;T?Zau*_7;1w#~X2#5~^wCOQ%Gi2o|(XpvC;%M1(;qha3R7xl;H(Rgt z$SBl>H}MSxEC)nepP+vd{Po*b^5!+?M5{S{0!yqqdJ~k8zKmJsj!Q*dhM-E@*N7L9CF}R0$i|qNhsmJZ`p`YatN>m)-Xg zAvf|)$ag&iTS4J8E8^8U>8-)O%CIXRhTmf8%ge@7r>bk*e0@tX8t+DW9V`%?Cc5%G zt!~M|>DgL%b&RbSwZvBg7r7()kmCiS2Lx<8JQS{XewJ%+*a-H z!LWw#x+HcO>Xy$|=Ie;cxwgrG@=2MZ*=J=48-WnK!8mkDMIYKl5l`!bAi)ujV2R~y$W|{mPJC`Mp~R4 zl?UqD;IXZiIQVj9Tk8B|Q6EPWB`6Mf?s$QF%gLF1b*wf;oJ6k2(#JLeBsOm8>+YSz zPdtqwR%jDkcc5o!A{^Ru3{q9^uws0;JQpju_ncbd!ZX)1X>-4pAl@jl#>xAC{Kjn- z72=oL7HaD}=K?Xtxa!PwrJV0ubPR^54KV@jBFpoKptF5sSd3=wNgE~olQEk)Il)B3 z^tpi|8+rj0QDBa&zjNmfF@4<9-5jiLH;F_Eqyq;R}a!fmvzt zscWNF$eZj9>k|M>cJ!844(?s?4@Yl;zkP;EJTiTmx}>b1vk}&yN;V6-TH)=^0$LU0 zIo)d*8S`ytHK04JWIJ#qH2aPSCb8%W{fN+RVueYVq7INndY(D5bbpXsE5Zy7 zDJ+*vZt!5Gf^|LK<|-U~ALem}Z%l${jUKdEvc^NyX*R zPn?kR!?o4l-``G}Q-1PS%9zw1+Xd^PEQqVj@|^ot9a$bC(Pmj_%D~IES&L@z8QTMTBLizoBD13{`B2XPUuW|Fhqt#3t8(q4MnP1h1(Xg!1q20@ZV?0} z6{I8u>Fx#*5s(s)E|r#U7TqBo(%ndR!3ZE6nkt1l1)PA4-e=cg@2EIPxY!)SV%Y@l%1$eNLibl;5pCS&&*1x! zFi{C~Ed{SNFj+fXtxnk9)Cqc|zfM8Kq9T==KRsjrKStb&LC*hArsh)c!{EMZpTVDb z)M!Dor*N)um~PT}1jDJaB$_FpXN-&nIIu+^OlTlAx(3}N8XAwZGzMfGA7|y{vc#~Q zJz4LIIHB4<_ofD!n%&sgm~yTK4GclaETllfK)|WMsrmnC@tw9uO-NV|Sm7*eho5Ub zFZMRBEvo*_)}-Z72*B;mL-m{{u>W3?hsq?92NVG{X)sAV0;XH(auYeMBvjDy(u4N5 z`|}H@gZ0J-iDoIn!xV@JNQ(#)-1kY5yh12yhpJrBuXtU74_{>n(5vjGiyc{{sV*2wY;Nr%{j&Y5bPInWdd|*HqaL2gA_-!>^KC;V=F}*l@|!hPNmNTHIA?4I zAabD4dfAT5_e7n{t4m0hs8q~0x_eh_D@@=6xUB|S0nBmbF!_NM=7RgE-xX;Qf=(PZ zr4u0%#Df$Y7`fjyC*45tDsYp^4MEXaQiTGvJQ+Y*0N4dDXMLx+Z{Eved4z0w=N%~> zF~cA`0JMgGe<=|;`PW_O!p|z9YV06)yXRUbhrWOo6&MCkUpoGOb@rAawV+9nIzxL0 z-Z}@LBLBB5({US5NMp8lx=}LXFE+fD>rL;;42Wsy=bb30xz~RSbh*d^42MA{b$1D zU*GoU>tAxNznZOozFz+Sed_(c?&kl&feX}YwZC$JQ~%|Q4&yZtk2SrOBmLKpsL5b< z=ip~&Gc#WP7r7k})q=EO)G|1I91#c-NeLJ*y`{^O5}K$JkoDI2r@ z^jIHF>*27J`k&u}k8m-1i?<8zfFP)b6dKyY8ofHhqocDMi?Ui;Utj;nHR{hzwJ2nY zU0niqT;NAv2WQY8gNmeP=pY&WTOI=G#2`v}v>PgB>A&wH8&xc~_A;HsMLF3J3MXfz z$xk<;VE4GVPybe_h-Nd0<=*3b;)@YOKu993Bx_*ih4T)Tbx13}2$c_wj_>Yo+Ka1y zOBny|_ib;my_XKgkNMs^b^5x(&7aQXlk^L%hNi1hMc6MS-;}k?1CRONkK>$!^RBLk zW$;TcOJ2gE8)Vds}XUE5q^x-_F5$VG4!G1URYvF@>i<=5&c=U4&NZnYh^+W7;@YC$zFXqhTyiC z{!=$vVn3(mS)70`skYLS4r%g)20J z*fF$TixKL+>FlD!!8?EZ?=38@NXl8|BR%<$uQ|rgAlyk5!%?_yC7t^)I=5Bc;XH?d zbGi0PFxH=S`v1YQuZ5$QE=N?v_abIqQ>!868R2)oSiC>cpp;A05z|xnoPkZRbLNGO zKjvRo1;r$<14A^=&O#r5CMnP{6>eGmDrjZ#&`w@d5*0`z6aCR4^AzcauS?FOO#-w@ zu28o)q}zj_-~>2{rfL2Cd$KJM2*;%4WPEGxaL|`wlRG71KFxN5PY^!rBbTSF;UAai z=vBkKo^kx~&_mQA%1SLZ#_jWs52*H^uIeAG3(U4$>M^ z!A{`{42MEWCf=ns{Dzk{}#6lH-JV+nz%T(E|W6$!xXhBV)GU_OBC=#f=Y zj*-IOpBwUzIjff&JJGeu`w7BNX{Pecx^s|RZ(|}Eje@rZ9DgZk|}RLdK9A5 zEN7W(U(+?2kgN9w3PP|{i;owBE-`%cs8cXY07M$4<%2J=c+o?b$Ec%WD`aH&?~MhM z{S-rW3i!bkDva=N86Hy4AddeM+wkcoQ*KdFV{>!N>FK5YwNY`vEY6Pi>pQax4ZyWn zx#I1^z(8DZ=Y07&K^9JF*od(yxIcrh_^(f$T92ZA_#Hmid+UIHmT&PGXz!r65xoMY zJO8}o2uW{#9n364iY7TZ7$0lGoKCO7krMNum>AWfeR;6<3ZxuG1*0x%jlD{!8cilD ze3v3g0`E|OnGOkz>G<0{%6xfq^FQ`chm3nR)dLhDYdAYOP;xv2i>UV)Peg8h|JRw& z&mi{r$PwoUi$Zt{qCX8qV9P+;YY=9YXH+67Su(LLb%TS%Ima50msw|og=;TdyDvXuOfEkl1r(5VKcmkVW zC;0w($Ka%aL7u$Gkx?Z@_bal62QGaUIJ!~FZ~`NhQE!3!8w?i7&OHSvX(0hz!7f0U{doUT%XDI*)BA=lx&_&S>Z0K|fyZ8UWo)ah(;qxcE+2XD2hfE1$WfJ0YR+ zNQVk$W~vfbGo+^%xK9*)cYkSS#*B}D0ya+U7&w)y%}8I{nWX0^W7Y@b5OQjTt{Wqj zG2o+*#EwQ@8>U9|Zu!q5%`u>5du2%hcwP;$h#jz9q=5-!iT~_$5a`*Im~Ry{X_cak z!9>>1>e5%R5C)>=_ibmN)&{5+^BxXOt*m&1`wNhd^bg{ZlQphWy83Kx78^ZPdRJO{ z=zjNc^=q#WAGFNPgTVL}Qv|tw{&{7S4!(V-QC&q0!Ac?E^yTpO7HD};UVztJm5(d3 zr`w#bvDBt?pB{|DfIT+7u<#LNWuP`if&fOsNY`QLs0JTCH%ck?>&J z;h_VtlVPxJlBOH#SJ{LKb_`SPJdN2YEhv9A-w5&9ufGM#7*T!U1yskdN;_7NlUqQ~ zF$3I@ar<%Iyh?Dc6ch|xS|a(Q=Vh0JsVMI+c^VLqz0ZnMPi|gy8@B~LQm(z`WdN`e zB^Nv4!=QLKG^7mFP>E;HuE8FVl^X<@(RUF3Tg_b~jfw&&rGB*8ySw|vrPI@CTK#fh z1cE1ZP4I0DKntd|%w_Av0>D<&ADH(_Wzx2tFt9xsNn9zjHLi>1)Vc6LyF(>?hl`6C z9K_)9N`W&o>^&Uj1)$Ieq{#6648-{72laR;$k9wt5qFkTep%mO0N^JT$OMI0=53wo zd|gFJ7dRRChzXcKh)@~;FuUrSOs=N{0eWs5|qek z*#RN?H$wx$_m?g~nEzl6CYyeTUre@Pjj{=mg9;up5IwuI)m3@zN*#7m zU=(XDRCquhxB+JzP$v^$rkUkb4Jz=hwYF;N8o8T4e7FpMc?_o37?_wzkm1*R;}w{l zIfaCVF88Gd@rWXMTCkUcuc>!rBnh+=F>sk(bDJh6&cBBVJ2{bAFLuGGM&P-FslbB- zWqyaHTNz3@4}~7TgcS%~0^|yWivSLjiX1Qd_bZzb0>)&h!WY(#eW4bp=jUn}06RCB zxGxn(Cnut|36Fu@_=|)4yy8rYd1(nmHP-z_WJmJmWMO@_}74y{N^| zG?fbAE11#PP4U%!seAf6cB9+`nj;MOxI>>h1vPsQ6JiV;S^z3<7#)p)c}OqH?F=mv z5)!(~_ErEZdozB#x4SXgWfZgtox(l<@uE3Rje0VUP5i|OL25fQ*J=U-rU9$UY9CEW zN@D#p&1vZQ?AIoTfy&LJKIF$(q{jiC@88wS?FzB*xX^32!~=6phUFA)NWPhpx_(P zZXuLX-5RW}`rHKw@FldLedk zl_x4{aaF%r9wt}dep`DO%eG;!b$0xr7)uwZpcFxZGHLn-MXQM*vVFP@M#IBV``^AH zNf&46`_LvK#QvDUg^uv{<^#Yb-Qvbkb}lOl=&X%o;?a3j!8TI6(TFpfDxdM-niTS)aV-W%hx_jK6u&)5Ct9HXG=Z(?F{?h2)+%g4O=2xH70D$D~-OJ0Inpyb$ zQFFX(u>HI-)~Cqfz5ts#y-d=_7njtNu1ZL9oh9z2+fLuRR#7RHm?1AG_v7pcl=nz4 zC3@L7S`LmdDD3>^!w?Zw0FrYU4}5VR>+0&_eqq6-NL0>1>*%FnQ z$ZB>ekLw7JEaj2}!c=(&qwNj{CvGOF4{14k2$pC$w!Dmuu^wxfOlx3 zHz+AJIXEuttYkw6tR@T%Q_d_b?W*XGJqr8`g=Pq6z=xq93nntcYmS=J`keD_*VBe4 z?X7PGkM+_ofqTB3r@`g2mYZPCvoINO^N#r1n$+zypc_ernvvg+NKH;vcNoU&45hqYo*p-fp{)9S{L}xxYiCH?oS?gx|8Q+ zV%FdNPSGF?xGC+A^9RybREfpKnvHJBbrPtIwY1j0g~u}4i_2Q^|;Y^>UQPtSDK)MK$X&W=$8 zdq|`1?Pqra-izesKl&UTTnl!(9mzd@KMdImL0%4%W~Teo`@mh$4^*x2)XPlb1YM(H zGK-F`F3g{`C|c8zlY69EVwMQgJBEgbQLj@|cLm0!un5C?R$)dpGuCHJPl%3w0uK=J zb}ww#(=RXoQFoq*Gr+~Q#cQJ=qM*rj`)YXMpAx*hGLX|eFz^wKWprGltXu!80-w#_ zAiBA;ai04i0;0y*BO6Sv3A5jzt_HW?15vDOXnWJ%+aA{bS zLhx0x?bEs6a+koRN?C=};0A>huv@@riv$v$*Tn|U!3+UaUb{%A85gF#N5Rl?!YV!r zadC8z??JSFhk-H;&CN=srka|4LLD$EH>E2wuvTlTGtXX&K{~0#aQvhHt`x2~mEN&G zDNHT%D=Na-+R|r$9|g;qvHnVD#M_N9q}wHXAtlVn$W$o^hY)-NeiYz#0ct&VgX_qC zD0F&%ZMzH%4D7BC`!z}u6)@fR_YuJdWePAZr6B}3IS0TP1Kr$w!)R38-kwWaJJFY) z;5pJNv89Wi<{|z&I3UP4j9yKXGs1uO_OV4iw5Efj1o>0c)Co-M=n=hNg|CBRKo6)r z(Cf#RmF=qnk9rVxK{%j|m+#4MtNpb$&36~yuic^3XS_~p_0qk4GUf6gfaoi7ZGw!D z5NOe&Vc{Bicc1dw@3dor*UmaM4*leJ`14pJ*9B&mnHYV+zI}QA=%Xw{0Ch=KhS#T)@rGSiD zT+CSrBVNFr13G)20Rg67^Q##@>-#)|Z;M^GT)O@+FAq5dXi7^KWZDJ;g;9ul^mGuP7}VxF0i(b3oUUUSULF%nAi zFE$;ST2zf_xg8uE=y#iW9WpqxLgT@3m?;CtUkdS$zc$fHWEOCxt^VdUd_)o)HkrdW zgcH^~ZeSxh$dPZhMS{p~eq2$~(<5SL?gWo_&7#FvK661B%%`-+4U-c%PPyZIdhIo zuMf=iO}{ogoi%>SES7UU-42$@FU7j@d?17E`-{1 zTsT$|tY&OJy}w1PP-=Gvo+QURV6|}_P=9^wGxqMw@^>f8YGLYZ7e`6eG;+; z=@F0P@1#Z@+%PpI(QM{_k`lO~A2kNAutk#?2XWQ;MUCD=0lvRJY2Rvh?zYDmcDbv) zQ@hJ?G-I`l*2*@$s`#&~{qyx2+5aM~gETPF@*_4`=3}20-JWYO9OQpq?3eyqo57W{ zMW8&34dioGS?S-)@&n_nq76U40xV%1GG+dSrKz;gJ61TbDgOP`{``^62bwNXQL3s; zTTo+PgkLTLzs$WeG#fclBa|?UeNUTa-6-}e&yg2M3W0;|ubW13i!G)~>`|V6#6^tWG&X{$({27vs9bPw=Kbgz z1({Ta-Z^Hj&od4nuq+hQe8XO!#FWKb#2npS8i~ZvDbI7(yl+k9WjWP-@5-NSVvgXy zzdAf;T-?&~xmVliZwED252}R7&fZ_nET!X4wgO>r1LSy`qro3pQ$A`s|G5Yj<>*y#oxE0) z&Spr#YgHz<0ljR4^A+gcP?>_mwTIAqm_o1gEd*@f+nmlq*WJNvB#|5p7 zV0;k~V5lyfXbv)ch5%GjQMBVk$EAeDHJK9+>EGb3e}br?E>e|`@KrSt%1SZCOm}eK zjT=OSj;B&yYqHI$s8j2|Tj~$?*@Vs~7X5y3c*KXrHTV8H86vauFFj;8G0jX@ZT8Hj zd{5e)aW@dxj%ZC>_L$WYA>T~?82^?3kS*pN5;Xo7N`O%}O|0T;;VOMG@_ph4Cp^M9;h||qcr}M}X z&r~W((KfxkQr*Yo1fd0yCES^>_YK$0gWhm&c)e0ndtxUe_>ewCrPxV`yo9Q!wLH+# z8L_$GcYAZ&Lhol`zVu8ok=;G>N(<)z@|X9O21|q0F*bK-ONC9Xw<&J9XkueUc*~ardJ`;J zNs|qwUV(U4E9|Ik;no1^l2;ohrr*(|J=4)caR;r?W{t}yv zOaT6>mHuo{Eq8xq&DPSJ`=d>z52jc%GKZy7QAsry3!4nv`gc{S?w1H0XlCqx4hat^ zD!R!c_|U)1mcPA?w-94y+WBOwQy1e9UN|rg1W#e$hW0P{GZUu+k`cfS5S2I7JUf{ z3YwOVj#H*dwkkQfv584$Uh$)|xY&@!YK_y5in!m=7cOfHA}BV7)F#3FEPchguCTW5 z-Dg~=nf^q$L|*bVUrU7~bw+M$%Pv{+M9iOR0DrVshtc|2ig736W8Pv7MlYMy=B`DG z$mp$)c$kgN8K=ZL5u=tfXGTeGDS5#;zhKeT`arAor+~>A z(Q_AVUgl2RLNolD7FFIqo7Su%SY{8zH7i$c-J+LG{Tvo2KF&)u){nsOJSl3LSfA0v zxUW*=eXnm|c6q(sItcI0&)q%Igl99p=vRAr*h$_UHrOn%;OGy=Gdy}^EvZA^p2g)o zGqb8Sp*UPIE^=J*?TwZxQT9+rdf&a$avn?h#p`0Y%RZrr>WDMV%IRCioKLSiKO$vf zLc>8bX8WpyRc33Vmfc&EUixa`tjMokeD;|{M_qk~uU*z9#NNUSqm$C8QR$chFCIVp zndyZFyaXxj`r>DPe=Itm`0gGZ=gFgg5toglp zoI}*Vvo|-YIf$}3ZEW9rt7b9QjjEPdDEl&_5Z>VerFi6l5()jRoQ1Vzl4`S00^LwhaxWZVKeM`L=^EWfGnz0v8tcG}58+yXt%& zlp7p(le$nHj)PTOI$QOZe^1AqIMM8Gj}Uxhq`N);B8T8fgm_+YvG%#J;!^7nim!FWeu|ozNq7 z<|A(kToR24tnJ^nOipTeHWtOMn8nvpr|Zm28Y)TYtXnc!X??AW-;E80+7;n1?5f(! zkj6Q{sTk`ktH1oy^}a@4$=Hr}G45P3%^J*>;0(kqe!DnF=*S*FjAc)NPpaQD&^R35 zQ_t6(?kZAIsm6+e@to9T+%Z;V$L_P{i_7oKP3^0W94?_)6MJp7`AeT3@0}6#;f(S8 zV1ozO-mjt>#o>;Qa_*+#PuUYpB)u}dzVJ-=4SpWI+@bOOgcX;n%VrbF6S9Ff?hW^F zDSdSwZxC_w4R0-YV>zyCmQ2mPZJtilcUUhf=<85cRo!Y+f+3S8UBRLm6KSo((f=Ig;u*cYB*05oW}}hpx3jxjQ$IfY-Lw}5fJ;l4&lP84 zU%iBe_DknaGBsBg@g+nhdGC{?1UF@ZV%AAWRG8$Gkb9_o+@)q-rkPopk>_i-iN~4} zuT|+joRUcBRGC8=pEa*wz?HVTRu+aCk8-CNP z2UVS8CHDA$b64#|cxbA8B&WV#I<*iNfw%R~CA5lzMr}A0+H(dIUv9aa&tg(gt}XSi zH=WfXOf!y=mxP2#(N1vPOrJ1x=K^z4`PHRio-3;(l%{SK&WmC;2E-p=kwf-H;U}Rr zF~hoz_37|1W8T4;Cp0STpk&>Cc%{>Ty;7#jer3vTVX{p_Q!9F-K^fI+M z@$rF~%nH$Y#rgRqna1RN*Fj6j9N|^usIvY;~c%Gw@ zZNBy!6m_9N*bO5ipDIqF-(9e^Z$(omed|HYuUSu)@My`P(c#xSBi1FonZ5~*e6TIB zr)|_Hl{?F|H3!_2=X%A+d6Uvvq2!`J(X*w4=@!ks&=JAx%l$hu=NOf__PM&9b?OUt zp2x*=9vA{0gCpW-^^MI3_2J#HncGbLHN6eid9t{&b_eKWM;VER67w! zM^nXaHYfS6nyb2FOwFtu-b5nu?N?)Aej2jA6`oB=-UEA^tq}rW(`i-=o{5De=Na`# zmE`;^Kz>Qvup^vV%;AGkb%eJDxk)4HP7+uaY+T5w-3z|RUhq*FGi9`y zX(}tXcdKpkw3=-c!YkS)7P}WQs;FDQ+ONv-E%+WB)D_ zUr<+-X6O1@p37OAk)XqTAXevr+mZpr8)u!|;{2IOJe=>w=<<&YJ5sJy#pZ_yplc5k zTZRmDpfTR8|NPOm)LK-@r9$1Zup~J*H!`SNMZ!!2RhsPA{3IUk_wW)XdYRPC0eY-< zu6t4>*769`%AMXdcFac|vlVYv-Uyz!@dP(_=GyJh93QaXZ9jjFh@dP_1SFWB{TrUE zxseQ*X6%H2gj`)wF-z!i8{OVgPcjBd--1cX*RMW`9hV%oeBTU>yaua+MnCh5=W-Hs z$kVu*n&Q=TUXc;e;|x7iB%I=iyBl%NHdb+J)wOq|;i63<>`|0kPz2okc$qaa5W&9W zGz*&BpN^C23RZo5C@6oax=Xr)alB-sG7%lksko+TAlj?VZs#N+hMOw1CR!RFdsW?y z>fjsNXm7PXmRc+&q#g~Y$HSFJgKgI&C zqa;-EOz`Jl#AqrOSe>CNnsc{Q>No?vURqC)N>fI~7Z$60`A2?1LzKWkJJ|B3Jy{|w z_~|^7GY+gqr}l|PrMp_D-OVQhG%4|9nvyq1(5;_KcD-pFIpeaqG(Ot*YJ%f|fH!RN zO*^t<+A{p{++lUc9l;C50|Sbu&f6$vzqM7;zsVb17bxdg@Y_&=k~+QG)?HvMZVCDq zXp(K68zY1r)3@~*P53TyEQ&WiJ(~3I%Wx*N-gY^tQm#6E!{OT+1)0Rp($Vr4<>BOb zV|{*u6LI3Ob@k-=vg7V^Xeyz+F5!1JH6G`O0_wq;yY8|34Qtw{{z4NJv+5!=xRZu> zsp&9#sk5x-w>34~^thQj_x&jdjAXtBRaY`~ND%(qA@Y8x-@}}ll~sDN9zWK9_X7=@ zYJuC%n?SLI-z5~^J3QTp`Fbs!U4n8%wKCAa4i-`>VNS|7B{ z)S&jLP9T<*T94s%0WXx@)sYVx>M@Fk@{0N8v_-CC%%vJR+0rcB z+a-@+6lX_uMA)ZlHQfE;LSx-BnvsrQJ+aF785kZ#L`_0bzl+_MR@%Hl{jMa{up!o6 zorS8Zgj$Pnt@2>Xm$9geg@HYYHPcpxXKNwg%wau`hM(t1&w88w?t>VdQ7JB`Akqon zlz`tbzRAMY^6hF?9I8*C^xSR~R)mq+#86Pwq|ndy4eDfLZ!t*sLcfGO57pANjv10~ z4GWwO4Mp!`NW-NvlYx$FzP0QZm@pt>fA9_V1r$^_jj`Tp9F&z3MVEqO#Pe(__2}+W zn}p(3_5de#vNzM)7AQ`(#u69t51>H-rCP_*H%h}_?O#Y#E4EGU&HA^iq3{dDw%^XF ziU>dLRAB%+r&ilD}@+QCHbo!=nAnJ*tL!e&nh z5VacRY$*jKsv~)|x@wF46Wv>QYW0(k*v?v zN0DVeJ`qNxF5S9QdT7IGCQx_oBmdG)kECITsk2u&t(Om*$%_jd1W+O3VGVZ#VN~9k>2_YE2qiBafa>` zuj(UrNtNNPt9#NnDJdvd4aRbDuXr^=JGzH8jfiSp=3V*+zM5~h^(Dh1q^X0W!i=dX zT@RN?-A+vfkn5mtzaS?ypG2HWpq_<|6aHTu3i-+Nitz8>wPE=#P25fDeaLq)lu>pj zM7(+@u}~-$^4?m_a#SRdK@z?1V#C8#H98gZXg{K8ft<|lqd1#w`0`aWJbU`%J@&@Q zUwf!DAJdir4wYv_sMpJ{0rtq5LpOI<6H-{bBl=6wYeI!~P|n?mzDQ zDsH(_pt!%AY()K|qM~wZKmSyFv85;R)ent|Kg+QNhmq!-Ew=2%JXX9g-gVioLuq=7-=f=$#|ll=wyyc)=bs99De|qinJ6k+KJ3J95}w;>-22hAR>bFx8v&sC}kpkWlmZ}erUkvEjE5ua9!xVr%?>yu7WMB zqtp3C%xZcWz8c`;KRYfH%#|Gq)i09of|<3YyH2Yg(?>U3Y@m&*|?iTW+A_i~ksjqTZ8 zo!P+;ulsvCHJ~WhtGL2d(M#%^N3XaGv5=t;0+T1a5?xkK?bVa=fxYE{bbRs>%5P@i zfVaOYsOi=LBC!}v!kqmYV`c3v4gv4Y%5binOl9YH=o@QZSph{n zd6lM%mS*GxpHj;SyM!#UPHxLs!cwc-Z&0(GG$gRvx2>{WwDi-p+*)pEpu;Cn^R9XK zbMjq@0RJZ%wAWB*;uci^a(GaeQD{}aK-s*jCq2jeHf?HQ1U!`j#Wl(0Ip-F_t6N)s zO6a_PYV;{O?r}*zZRwk%ZFL3duP0!a%F56(lb6^W_~zB$t%AAtfntq2%l5=n>fDAd zIgIq(>aksc?FRy?X>n)Uf(|Og)!0+UB6Nqj3Af9~ zC%FcW+^Tj|96g&>2oy~GC1Q( z^;f0g_znapal@r}^HZ0j-oP-+8_7 zPwH>fkmo_9xLVj2=Y+4c2b}+svNC+%-&apcPtON~k$fPGE4#usA&eu%l}ij>QUeg&$J+uUh(MH{pG>dlTPp^b~X?#6UuJ#pVWN4l$(tiS7o zDi@jrHS*HSgk9iv_3+bHoSv>ezo##b&ljMO_4oPF*&P=fqi7?AJD@i&Dk4OidM>G}M`+ALn4xi}wk>)mvPdQO4O{^#if;Wj;Rg zGJ8y@7tr1r-Z|z=U|yIEMGuKlGKgmq;3}z63mlu-Q#>B>GP{;818Kl1yXoySXO3pr z8M_BO<_VT=wze@fOmtpVBKEdw>#PjdP3S8Lq>{Y_ctC)stKmd{Pzb?o==OZeO^d{^ zw@EbgVR@dMJQXBHuRNxVve{fgdHT>C9obPy(M8EpJ?!Hiih%Q9+uXd3UXz9XB(`U% zfOvhTBq}C@Ljz*Z$qfe7=+9zAE~?nu*`-;;>PbkCOQ&%x*j* z?p=&?4KuTmEeLM`F{r1``pxyBF4qvs;eZv366OR!^y=UW}KSPt^x)mE^!FWH8@K7Qw?2^p$HqMCOf=W+si!*7nKN*^c>@|)4dDYpu zY)avElf%Jz)Z<;gyF_w}YMg=)mP zRhO+UJ9egNeD%>{6gkQg1(}P=^TSUReloc}^T_~=8~wIM6@=~UaX(MrwO0zmF01r1 zbP?tF+JFyCoK4cEbWUVq8QyKA*SoF|owhn!5Y?DpFW$MiDPO%k+TKC&U=59#8Mn~X z&=CiiijOpJ&l2WzF7eZg^JPk$DC#othX=IdVr1)BhGJ_mE19#`)z+tW1W|L_V7?3d zhO@bEL&MAI3p);U@GRLGQMr`F%^2hOPN%*Vta!0!*2@Rs&ZLW@_v8vc*r()g-EcRV z9#+!ND=%z!j_Kv53VO~SVFSok^_UwU?@*LvgHmIdNp)4V?voVxrD4pNhxDOP-aOo+ z#^a)YU6Q*>?rLUa6#wblYP8#8S||VB(k&!PZuFY7O>twfFDGJWJvD_9ZQqWQsLPrM zmy#!D^>_b-6?u^{qURO}_UZQL<`qcC6<_04LB_D4Y&t}cj^)#0o2e2?H>5o1Z~0x= z0|$6Q{m%*0Y5r+62QwZ`wcYi1oWg>R;PB)zf61<~XL$^v*zJmw>Zj0oj;{Nz4Hd=^ zg8q}ayE;xJft{&pe@?G8w5t4}RVj8G2`$P=EKUXb$DYPkjwuDaRjT*grZCYou&EjeiGJz`_#O-lqeZoV!2MU!ty5}1khr+UBR9% zSgL7^La}P9Qs}}}KxCr7J`=YPoD7@W9lDs14-h;N{3-d5nxD5uB_-)a%{n6%%kq!+ z49vFdyRxO3PxwD<@HuWE{0NlP4jcPGz_KaP!E0--8?aO#oi0G-{sZ+=?wt%zdn0Kav+OP> z#+)%2lRYjtk&R?p;EOz29y>R&^{?rj<5Uie&S^n#$#bM_cvi;YK%B2m5Vvg^$bUZ% zN6lKc4qdJo$eQhx55lhN?EDx)kcjAgqMM~=-#tT@2h~Ke_tWJW8Y@a`8=LROshy<= zjq>CkD6*uVko6T>62^gaW3G&OdID^o$Sy}$+4%SdOmDvFi@1Rt#NhUB8BNqi3OhKs zjPT`O?UmQ_>kBPAL3q+--<~YE!Zb)d6>1*w(6m!AmST75{kL z@?=J`qdOHMhdRfdH7L~bp37;69HdZy7e0)b!ZB3#_M8WY?)iJ_GC84Yt;Onx+_f^Rr=<{$9USAZ~t_>G)Meq7kobSz;mvWEBEV{7B4=gfA zWcb|;3G<;Xi12fW+U7yC%gH6{!HrByuf;+DO%&Dr4YmrN4^C+TA&Gm&LP@z)F$2SSV&0`K(hwEAJ{#=ZQU-=U38DH z3a&vhk7nOa79VUv20i3c6syu)o_Fr(spUUuX;f?77jn?ShCOF!II3!$gY3;f1?9q^ znv3>Tp{}8ZGk{pS20iUHE92_w3f6X@3v)hbyI@?Cuw&o-Lq))7&}#O`ee#Kw1gE0n zRenjP53$@~R<-2GKjGBECr*$V=Uum1Bas>m#4DJ}8JCJG^DuW;Y4f`v{Zt-vTULsy zrk?NwPHY&oH*#ePx(9q;&c69~$248de6V)WfGLFHvnxcLV;Ik_&41njGsnNrlanVm;-ZtJs) zZU~@)%B{B%CHAEKGn*#RU5NGbd+Vym5$DazYzt;*stIgK-&hjP4ghHxsSn(oZJlUt}hhFuXk4LxR(@|bhvSD_{_ahV}#=*g~kng z^;ZZ$C2H&*>%V7eMp**&nkr0Tr95>57a7$7{kq7QL)fzG_QkAEG23joj8~h*b0%A7 zZk8kC8t8o#lqMLaz3-sHiz4h@G#Y&LSz|mJ$={%tNuHXWmw{#zHJgr9&ve}uzBD~jMbj4he ztOqc)0ZIfjN)Zx)Or7MCUyuxQB~UkJ9*z2PTamG{rpYpi;jFd5aViFHw}(FTn(6pg z3D+da6D_dH#|Ewmk`iaNjd^YN;G6y+9rpU@NNj27&y(%uUlr%4rln=vo4uhTM@~PD zI8FJCdi0!?Z)~_0fAF1khIcWp=0U@f^BMNB)3XYahj(tEZSL$!|0;9Woy>reI)-J2 z@X@tN^0v$?eC_kCOvqN8)qO?}H-VU!U0sB#Ts%qD@iML4ne=0=J0)Ag2uqOQ5&^otXvL-|T|hgLF;E-pwI)t$9dDn?vTE?~Abl z)n{{C)tZDS6K$OL%B<=rCGraL1w+nLclz{9vn(bVGk%OF@ES8^MGq!g=CZ)1hvXkj z_m1D!)ZuyW3`B%Gcivj8JXpJi`y(LH^8slTLX^r+f4@$hi0f@bW9m*_Oaa3Bt^PIC|`cg6Dar4xJc;KY}_h^rsMr!?#- zq=G=5nms~qOij*1njNO6~V$e}Mx>q|59d0AQ>WR%x3%^6pSZ&G$l*GI%-b^IKO z_BcBkEG3MMit_UFV*^II|PK}pLDqV-ukXdUd@HX;bf<$S#XWqoHZqxN4G`Ala zy0Bb5FwJ^AUL$yDg#pTh9%z@doavWddT+>lf}Rll#7!7~qr!%d2FLa07qTTd0ac{h zEI4e;CO;!-i=m;E@Y$K*@pZ0VDd#3rI8TX0_DeB;h$z}oIpf9NfoM^RClxE8b9-m_ z1*WNU{uUj0JkIC6**?pKwmHnjq+IZ_Rr?)1HK}NqSpW}JTubN$*KG8}ppd)EDYfGS zlFCf^^lvKjGmo@;CfHxNSH#4E{i>vHTtYTZL6U;&c;uC-T%rkjnGXP9xJBoAoW2K5 z&Q8k9&moI)j-z&zi+gwrI~%QNFo>D!yQH@mgw?!_8Y5tlQ4jU`w8-Y24}Ip-R!Rgl z8ro#>QsLk!ndl8XY`_MvIHN`)Ko9r`L1qEVBlk$&0a$*IfmSJ0nC5!$ie!M)S1wH4 z>{onvcn14c#yqPZF78k?vsBWw}JL3Hvx!>+W_w~=h3&J+nsro2g z=Wj)lxih?2G{3UCU)Bi~5hhiXX`0$pTOJ7vZ0T zNc6`sX_m0*d-8t8TcV7$!#z~cPWnBtHI>~nZZP=hg>Wz%sj?D^tGxss&Y zP@sF<((nsZz+V%i#5((&oQ!*v0tkc z%agk%%KY^1*k4eK=$!dpdtwR0wPd=0jmD&{${W|xIgvp#;r69tG)QB>DN4l=Qv;{i za1w#|X4L&y zK`Av8k%l-#`-(^-k)Fc z*O%Z=B&OIT*zbDp4IVo72H?_$S$fiS2avRd%q-q%uB@o|8D<#7Z#8G4`mZ0tzt?PF z?Dg%ds7Gn|u+inRXS!pbF?M}?K<7@;JNyroI+&4diO&E(y|+k&7!M&c{Mv(HX^@#5 zO^iw1U6fIzi0nV_{zpw~kBsnje04ri2~tz+pS%UmwfkzoDYwp_h(UnWf8w`hJZ*qRW+qgyfN~hP)@c})h zV*D+#)0sZRk@$8z*fkbJ|L4)E-|r3ZKYj2-YA$2~z=TsjH(s3bWnS2pW$qjQ04MDg z+ak-26H~FVEJ9`-0@FR%*NPA(*({^gE(!GBhEoYIeF*yrJJ1~R(T1$=H;-bxOeY*KS$n@o|! zL}7WRYTYks|JMBQev1y|i}YaK6lh?KjQ8oa%un{zR1e@UJ-L&>MXl-EUcs0c(}BLA zc_Z`Ozn?CMt4mxce9+rT_1w)b(Xc$gng~2_UX+*XicNO+t(EkjQwIL+da0u%e7w~&w6#zl!HJ}|anu;yDmG@tR z3SS~u7y4g84i+Kf#1)mV08_c5@2;V(V2B{1<#PqRBRP7D@B9N=5%P(2SA0GDzum!0 z7HHHv24TD)YW(|!Cc_O7cJQCCA0zZe0>LH+T5o>xu&r&+K4TnJz_2t=ca}xGIh zsUhjB7TdzjXW;4cA|&ywj2`d74HpFE6ycdwYwcC5IFWh*c3@%%)!UP7r$2_vf8h$V ztgkU(%(F_VDyq|PspmNO@0-4WMk!D{0=)LKTgq`bHaEwm!Lc;?f(g?CyGS0Lj#ognnlu!OWx_cX$<(!uK4>uSN_jq z{`;O^{nsk}{g)U2kN)Kvl~O#b_{sVOHMA*MC8KAF(PA#Ia4CB&FizNoaPI%>%aTN7 ze}(!vE-MDiQ;0a2nTBD=k?HB%Ad~IsFnfSJ=D!0z9I4Gm{W@Hdqa!0PVP;0&53%H> zrIiDRL9kpxtc4}i{_{-!{LgYhvB}9P(5grRC6q}_pu$;G;$$;Oga(2#h>>y9^uRyY zJ3$M)Oh`dTJq4^d-Q2c)^Fd1brktG3+=c*5AKExLT;k{dP;>r#UtizD zTpnJI$*CZ=n{o;vK|wFQy(ucFybzCO^2#@j)p6o>Z$ZcqVDr|Wq-54k>m>0%e!KuD zJ?rHz4lr{D*CY0?*U4UaF>x&YJ7e|t6M#}fPXExy@h2ppDGSOJB~P|>AD4hLx**J% zc4cTp!I(7+sv`;)Al*CZ2vrz}No0d%AedNyiww1ynHlJnypeWW*RkH7o<=PENWtvc z728!AEyXver{ydL;zb8VLEpkt2CPTf-JJ?u!~bXF+@i8+_Pd5Fw%`KehCJsckVVMQ zE!MMF7rSO?c)*+bEMg_&>9IWmGp*nU%_M3$KAsxFEa~Z2bKak{^U5;lK7r}CT->k+ zd;&Ih_9!sY%K_(HM(@|Z$H$*%?P5!H5FZ+N2cYdx-P_w+$}Pwh65glf?(RO-SwchZ z_0ps{O3;a*1FQ11KVjmMLhFC255c>wL$yvm92QqlP~Z)cr1%Xwtt3Jg9S6a*rSoNf zA2?(SD#uFPwzppqKe8f50&1X5Y=be;(Pq8*JYa};I!5r}+4!$t-|;-k|M2OPGK?x7 zv7~>_aLwGD1SW%FSJn;EGwyG#0`j-~sR&uv{vPiee&xx%n3-_&-F19)j_si7P~Ms#ly? zM|Jg&o9nKgo)e!W#l<^ia-~atIc8b6UnrF-TTI6*yg-u-4-d~g{@>j#Tm|Yw#d)E^ zFJHdwN}G^<@Zbzgg!fi?&SUd@VOrWYP!xn>Mk>9LU${oSHiyH+VLt6u4|}+ESN_lE zfNov7xeNaJx4UHHzO7v8o-!a+L;{!vH%lw`?x4^~toqU6?&?D?K;*?Yzn-HW)QEzy zrC8fRR=aKbSFc{(0^5|IFUig{+7fMVtEkjg48wzq1UEd?TdK2fVLBSrxh}!PR+-ZO z{M|3)g(jz_jvQHnfm4#-lQWL(`Nm1zU`nE7nZZG-r=xQdhJs0i4j&ULSY7>o^k-0_ z0<=v@l!#$g!?`{nIiRZZB;p$lB_&{+0d0=+Ad+E#+w%Zd^2o5-m;j@Zgg0}87#g(* zXSExkP5;>*^}f6Df8N2(XD`f6s8>Mb-gbHXHc57N4#*CZ#or#g48Cpp_Cuf!hUj@C z?pF!bzF#%c&%nU~C7%0;){hrc#NgJ}7C3&~gcx*`j<>3DFbZ-iTRY;@{_IPe}0V);`aJt=6f*f&~N6iGk2Dy08!!7KWspPo8`kBM`2`P%Q%k!@)y`KngQK z@ACgVYgMv79YsmI<&PXM3FA}XujqM@TqORwf!CeygYanHenvK<<3|8>f&i5QW|2ugZeFet|5tD@2;XT-lz%g_75bk77&5RrTIcoob=rUB zDpT8I(M#HYW;ahh|F?Ghn;aFLFg7(l754#bs6KaV2zWlGG1X%;okE00} z9N)aj0t-t-hAt3J2r|0rrS?B#lH-h_;U9iRyAXK?YI=IeO-<>fq_C-KwPEo;uVJO7 zc>82HIo~7dO(P@MLAce%-oA8Xv=}`4sO4{h5K242nD~cqUg%efCFI9#5}lZA%OYcLam-49FdTL`li#(a`Ylr^>N|0|VCJ zwQ?U7FSF*jcAAzhRn`al`@b?tSbRbpg}_oQ=mulUO{M(yY4Wosy$DysP3B~vAXYqT>% zx8$fO{|%+*$IN>&F2Q&#=v1wN_8bhE^F*%BwR_1|RDi;vDyR`K@diXNL`>p5JUqIy zZdSbt{nJYozOLm=-)Y)pFq4}Kv-W>fF#c>xyx=thgL9z0E`mCJhDBLVFIG+vAUPPa zu&RTu}DD&3$+zkZ~hS5$6x4%e6 z^@=Imj5v8G4=&mr92cLU?aEU<=V>`T@ah~rbuw)Q3!-8v*R5q?$p3!oTEDos&|%BE z!jc;OkSv&c{w#}Eb_bn&*U!;o;3k&{KS<3Y_0{0-W2~d%_~CgDlo}2x)sLomhS`=~ z@4P5!jfFqJ{bLKVRaFB&%cx-ue2Ho#PVhG+Y8P3^Dr+ zev(A_09e6V!_ALz2*KgzP;vP&R*`6M9fUCb4y-^p#C<>-5KIbF^-(V_`$zqrn$k)d zl5*eRH}CyU7LUg4fgVBKNL?kfq+|4#FNmWyJ<3w2X!SkiQE`^Da-dWIAP9(@jm`WK zgMsMqv$sIG$bnj(85EuUCs_84dbo)TEG#Ub@SFS#raD?C>)MT?#TXQU2LqV^gu4;_ z);Q38(zE8>On31k;o#tC+9IlufcVX|?Y4V}Qikiw6`1P>tqlWX{@-ei-tpvAhb`&- z@|u%6-}oFkajQ!+?9Uc#NND z6paCCcyM*xS=(NKC4VQmP&igBNdQY^lA0kGcCcblRS>IVio=#HVu$&xGPwqDuU@%>TJUuxrEi;A z?;Fxnl9RKatv-XgmxrabxtR@AQ-<8u+Omxmiyfz5g=(yxHVp6)m(1KL0#80r_!4?N z^4NZ%C+0Gk?y1#k!JUMS5Cpl1w@pOLgt~?nF#dU4$EEF)CwFT5Y zOKoLkUQp1Oqdc1Us28LjhY#@b6DH!stS)H$ncKW|cTd66qIvOg<>vf4PMm(JH7uQ% zleea3p!i{d_S)odfBkf^xK;Ow6wqpXkfBpgx4*5!_itq7V3%Yc+$ky3?Vj9hsjoMg zUN(m9I5IIIxBG$w_Qx$n75r3tB8aDah={14$up-HH>{$)B;z!Py>6Ldmf;B5AgGQt zXq;D4QbN=YCT}|4^CI8a+ZD(B=5<-(9*UrPXz}bovvV0X*Kv#1T%;0>$nE>=ssz5>> zG)d0!^DnYW)(~T4&epypqu{u5=!!n!^V@(`Dv3Pnr0rAY($*;I)60 zh|N^nd7_LcWOX170zspx7KF5a)!dB&G7snslwwWNh_JiJ|L(5BgbvAft!3$jN}zOO zH`(%dB%zvF!X433vz!@@DXGE9h1B>r=C#(<8LVN&zv-1lRt5!aY;1t+2?c=#O$Tb+ z*-y8BkJp1vRrIDEeraeOmu1s=d2h9E49q{OCH1zgeMdN|oY0v`s7?Pq43JfPI-%=aRg$ta)U*MMVgDi-+ zW7i7Bel6&%iZe6ZuG%95Q5xHQFPdObiOEFmyK7(C*nhZ12f+#i+S6aj+^+)p_;5id z-V@%`_e&ft$X6Cx%T5slYC)RrgB$yBEy#dJpiFr!266>5F(81)vfYb;9di)u4L)yr zVJB7skE{0GS_Y;ez237!?z`9C=wV^u^<~S;4q->NEiSvB^CHJk5+86#)HXiXux2WQSAnt z1nq*y(xAJN3mPtPP0KbcZ5rCR;! zQz8yxoW=kMtniA97unDD_)j)RKK5vN@j~SLLt<|S2060g;t`^ zOO8XT4Cjc{+Ft63UdksG9*E*x*p%_{Cn6Rip&si?OUV$vA-qO~6_7*@sp-OuRbSPCX+S8}` z9R}=4w$`TIhMAUi;Hi$4ZlL$Q3kvA=U3YBWQZY0%gmM*Gj2{+xo*PYsnD3J{Mi}3v zkvVtqi-3yMo|q^NW7YCRBZK4M_qqO(f?pot)(g_`7q6O)-n5HIOBgkb^<2{)-dI4} zI68vF3e5?|fq+1n&2ZKfrPQu0VKeOOh4Rg}pJm^YEdByZh#^rNBC0Ye0yl|Y5s|ta z_7bRhHmRw|+}>_mLoRZxfG0CZuzc&Xo9`FS*Sp|U{VfK7iuKdQS$kcVv7X9 zPcHXXESa;5TYIsCIS~hSq-Jg_Z%Yf-me`yeRBz1I-^vi}0hNp0=0cR^j2`ciqrX8Y z3v^AJ(F!aws;g(O{sQ}K4o=RRhKBc`1Nrb`U?ccDbfjr;k#q=NedjN2bNB4nPtx6` z_4NYo@0NdLk%XR-T!Z&kgbAm?RNc~YF5Fyzm#RDOPr|Z*3OMv?o|1vllOk#ZRrvrPN;ro zBgP3^G4JFzfl`sKUruUV$*#^Vz z55qivlZML}k@JnO4IOV zP)pJFdtZ>3XFNT6>2%oXvFQ=*>GB^S^=fEn#GvYGS5f4-z3T)*w^dP@mXcHHKukT2%)x5_T{si^ObY;2|G1ThH&>bxG)Yz`A zW%YQncl;0nsaT%1VL~k02Nu+LK6Pj4MtQS(yapejrQc*UTAm+lZB1sZFlr$D9B)rp ze~Xp0xuEa9bcoesV?@sA6C4${e zeek@#2sKL_I@*v$7qwKrz0seenI^nDa}H9VjIG&_7qs2BoKS`M+Y) zwUz`y9cw`Lz{1O@Hn1a4k?z=ow~fPbkW$-4tY7wL(zpI70e9K)U%!;iF@W8^neWz26^&!IP@m zFI&C}2-R4NB!R>LvH{5CYeu~^EiE%P!LPnHP@_BxRFVFP#1VkaN!k0q4kLavsG^CHg%dXdx)-z>=9Mp}at51{3GusBSyFa}PX zG^oA0uP4}wT-wh0uBH#5TJ}E-eKS*2L#?bPHM$6JMm_jlRln@?O!PVZ6Xn>jX&W^f zu+FQPE3$K54b|jjY}819aKdR46mb?`iMvoIOGYn1w!t3f`5yG}1g(Ew>-65!>Gbvl zq|ok>xw$z+Yi+19C{}l7rV}pyjg6W^KBw3>)=CyQhsr^CZX#K?Lq+9wcW642hWHfnaQ#BMF+d1T#HkjI_Gk?rjAm7jH!1k3m95DKe3_fO&Pev$YC z*{jNe<=n|~yRd5SU;t_smr~;Rm-cSEuU3amE>E_%w|_}V5wx$n=X>yXKaEOq@U88& z1w2;B+}&LY)Ps${RT5I~;FuVXwYw{aWDaUGG+>tClTi=|gvnq6+8ZQ^$*k4Z3ADTm z-8mGnYsMEx>U=M^1u7_zwk6r6+>u@gVhbVxjN>Y#Y`(^Cl8oCR5mQYS(~$e~H=4(G zop4;D*?WMyMoYN_!@+C{7f-|%XJljm%=RHB#LI4as0Cn*(zTiNV`pWeAyQPS7w;C~ zIhphHL-+0z$TX(0BbEfhgp5!qD15eNu25_Q%!6s%Pg3PrsoMb8^YuAGo=kF6o`u6{ zxZzHEbyYOaS=2~IN#BphWKKhZFC}Y-uX|I^n{|BQ0Jj_{V5VRF^FVur&}cMRIeB6B zac*RV)$0_rN@kk6*;fTyJ!^32pE_ATu7Wsn+DbG7!}KIhGvlFgEZ>eQAoF*LkxUu8 zM&lP!_{O4lZPMfIw>O_@@ZbH3vQ@g!eGTcqfY^Ue&nGCDmuqINen3env$t}J9Oo_w zsP$Zjq@;QGa;o)EcJ{oPnJHf6{rdnD?1l}fFd|X%c*m84P%47=FDy!F$F_#L4tOk3 z!Zlp_bb5tr@ApSzS2BV}%hORi7q_SYX=1eK3~hb0KxoSH*C$6-Kb}@=nrw<`j4ra{ zdJeh*s|9D*JB%B_lQ{*A@ti=#Cnb>{Kf@xg<=l6uASf=*N;%#uwW+V!iyR4|@_{TG zBNVYSGpyasm7SILEPMjq?)|!*ap=wJH`_Oc`&Es}wrC9c^%WyFYgv^xr`d-+*Uy4s z6@#$tMaV2J{P6V9ef<|k9iKr{uPyRxI!T6sq5`OHf*)(C6qv*Xl^i;x!8)X;qpp** z%IW5{zX>t-3NJ_bRNb!m1U+9d3eSugl9O-g{bF=7mHTfj6ysA-0e$E@&dy@f)6>q* z&fy`6K0dc;GeV++4Y%4R2#-DIdiBwl2u%!=&9SloWwn-T^OTp0ri=9SKn-`?ZSSE-z^moAxlD~MWR!(CT7_0NJmZ_%ArZ5>s^y)xPDwZdW`_Z*a^s@7RL zRy1hl52ALqjPEzvmdJBkSa zw>-*iPX9HOuPfp7xP4UaCX2XXY;Un&R%C-qXs9e0ctfQk?8`n>2$vG z@eH@iivv+s{1e-(KeDnmA8jc(K$UMWRzn4-_iwaMQ6Y#0liQtt?z90~pto<|{-#~> z=>-Y8*q-ppKTr5BqiFK&u^N!;T?TXSY!~7g2#ueQe)tE?)37FBM_331A!@Uo0#r&_ zOxdQBzCKQ|#Y^v0;F96oTq_G&17CpY3Ap_w8&q@QoJLwqvZAdo?;R^R#`2uSg#dNd zZph^m0DG?xCw~P51|oGr#owYToOK;cWce?BX&lGni6h>u2ku!jSbg-SVWRvAHj~%d z#o0cNjg3K~^wQ0qK)yC2CSmssbBInxNP~%IlPsAt9`afPki_MZ%dMh|-+LA@+Fx-hUn=5L{ zaWWQJbATCvU!(6uXxE{zWotFFrCS5$UqU}s^Ff32{($`Y=f8@{?{s*Sl}))?tJ$mc zL-F>GGLpNx{Z8b)g93VfB~ekE>wZmsv!mfRITdj`kgIha2Z-B!zmmpuzopfvb-3)C zPSHs)l#lgX=I~t$Aw=EM(xT7AERlm4E0nuz(mXM!y=XaG+p9PaA{BJMCfA&B4s=91 z-rpk%tj6Q-PJp#SHVEoNzY|)r6OgU~)Ma4oFulD1Ib`d*LCdk$e)P#xr<(7I4&4MX zPB8bY>MwF_;A1#OKD@Q?2y3?>?N_qRi(rkUq@*D5f7N132%1^WME_ns?Hj|OYAveg z7PEAUFd1Ou1To@DX<6Bkqet<5OGcB~Q=|^pE+&YTWAw1@fHNO$*EYB9F;}PHNWSJvEmg0-|6}53vXl>B%TXy^N-gEgN^W5 zt1^D*V!L)PQ~FYB7ujBtbebQ{QIQcY7|OC^*GChnz>3ARjz^Y+-l;YQ%++5w4ZDRw z*%P4%o68FHXYRPeuW?%a#I${-}mFD@^a+FE1_(*Xz0I53ZYR~xrMSE+!l z2_LjdahxBxsURn4z807JwEdUV=rHtj;Zysk#Japak*FZ>yzZWqlr#vZ@FTli|V)cB@O zj2Z3if?zQntF$GQ3t{)Q)ZeB1asdWZd^r$K>mRMmFK_FbnBkidc$O=B||g>G&P)FmkQb?JSNvAcj%Y~twR1c`yDr$8(q;#d-rLZBSj{9nCk7jjm2a9%w#GEZzx2||esUykcLB}pCsrp2pn(cXxngK&2#tJmS2rpTk1c{rGK)Y9SAkUsA!n}<=PY-XvJ)Ndv%ZO(&dZ%ro&Ps2M!!4G+zA8 zH9QGb{J*UlA@)0sN}FbsZ{8$F&9J&IRvfiNygB>rCP~_IZ=WL8jWtO8+nF&9j1@BP zS$SmvpEn$;XGrx)Ugw)DJS}n58tnV%+Z*XH3o>L$*S*M(G4da8VG=8cOfi5(n(`-{ zKCpbx`Gxm;|K4!(J?#s#pSQr(6B;Vdv!oM2$KL3V6#APta>%EE%5~SMHas}^*EiJu z!FZdo<6A2Py9&G{z0C(ICMC7gYlLoJ=uv2K2L=Qf_S<$YZEtMw8(c$Ub$Ap$fcrzX z$^1j0uViIs8+O$NO)}A>ZI}A!@ce0mU9j~Rq(27x-5U{m|MMlv4YL36CybS+-oEiZ zMSJ!xuv^6>OYYtK(26{tgai_`2@LeyGp-zAf(H1Yh(%wSDGf*|Bj5{2WFOqaAET^puJsDIdnF${g$C>@TUC1 z7g5Dd1-D}LeQPhOGJq4nzb#q#Z=G=Fb+2=8kCne!WfFzn#%<`1LY(C6Z2?_tpree= z&wEt2_Cv!0YW}`*BS|@6pi4MJLeZ}I!|Ekm&a=UtE4se5&ezG+RIxP!k>LG%PhB0k zd>e&xa&pF!qbx6cxu0kb>cyPb|Cef&dSZuche7(;X)t95W>YZW1KHVEZLC3bxYm1% zl=PtJv6`Ni{D-lvAYcI47x-IU18x9fwID6+kHtzs#`zWKIaCM8jvW39%E_PQS;neC zSFE$l%?ao!V5&Z1ufq&PfEOqD_^ftjH&1n3p=US6GukO#2y-7Yr(T(jngsQ#RF+7x ze`SK7N&K;Yv;9AMH2+zy|3|d0|5x;||G)kolD;c{=zyxaP);H&t*k6|Xm%Mun$p)d z#gp=G#{>A&{ou2@bY$sZ56u^=I2p?KUon55)2&dm|Eo_fWH#(a>P5bU&Ma*LC$z@z z>ti1|uwCiw&}2&ss+FF8xovhF`(b6p-r*Icjz7_Dri)|PWnCA7%+2(C3wi<9(>Txl zv)bC)wE5uIfe)}5D|8yY38KRs{ZE^;G4Q{H8HI)ziGd^{7wIk#<$a$B3r zdddnw3ajVN3E8(ai5i0wPbpw$FC#683~DSsGN+GHoM7YxJPYHh3>Tg%5Ez zN;GURj+RSv`KK&RJN#tvYM~FLAIZOFYpc~SpDA%#n1p;_GXFlCS?_0Qz{+X!OR#p1 zMPM`M0+{+#&)DSwho`O5>qE7_kZofiD@rf5kEX<7<4|Uv+xk4>3yxnE#g$H#>wLqg zukIqGJm^4;rJBa3cCGzWY7eB0ARxZ*Yw)X5*_qX!#Q0MBz5`qVnvpv)4xES+Ie^qx z`BZM-9f8guBsN!^dp{qI!GXQJEf@!FtQ+i1gZZ^v@|Ech&o?8>9d(nbX44ujC2`)$Nwu6ID zA`A}zF%(;4rCkg-dEh-LRj2ifNvfGxRFya-+buGh~5*Anie$SpYUeQoMM*RjN6CC-o24*&{kL|-)Gs| z+jHLD=n9|JG0r$;RkllQI@-EO{Kx^<`S*s*ikG_-kWPdgm1&(5w>xr_hVA0TFAcS7 z>gouY?7aUCAa2A1|Tvw+tHj!J(nmtT`rG1|tV#-1j}? z0~vK!jd*xX@GWP#^|D{1Oj|FDimt!1kS>#QnZ4k%AZR+exzoru)t41gJ+`#81h;f> z1bXLj`4MC6S*Hi781+0f@1t^XG2f{GgEG>op6}_y@qgo!4#gkpQtEjSZw}x2Ejz&y zx6|f@l^$VcdC3#wX@*c3Mxqzw3pg2 zJ6l{QPoCTuDOs4tWwMe0k~9u^_bBv43|p{82)bM?F;Y@V3d|O)+_o-IO0D+R?>^hy zIPNsrbZGF><;w>aM~}9SJ*r(#*xtq!;J`dAWJkb*JVqksZuUyy^ko*;jmI)OQ*54F zHt#o=G}1K%ObI%K61<+C>+~hN1dQx9wG99FSS#q}Q(kmb< z4KaJ+s}u-ftC~))@M%faMK#-w#?IMgPHOkkH61zL2|NQ}ylyIEw_Zq9YC4_iX2fL261xrDj;B#*o?>s;swI+oL}ULP#8+M$5=X>#g#bL+iVhmckPv}H1cRW1x`0KP*XBrwB3>#EXU_pNl zvFXd2D>98Up2(h1+V*iYyG^MbjZ{S;ER%`ybqa+gk zv}k)@p5ak44lpt_Y6>jd9(Hpl4i#`~7rvWpjv@nKx(3ABT=3ru5Tbggi4r?E_c48a zec0XgWFdsw#@Xi=rfdhp5RWp+Topz5!?2dnEu&GPp#dY%R^0yU0a=DC=YKdZ_O!vm=OWiFrx zU1FE?2HuDDx~@}ux+$OUuEhS%r(NCT=NWyem|f*{%b_croTGabtQXnXy!6p~44@<7 zThrO!U-WA{siMJtKgnyiKiqSto*jv+&1i2S;n3?cPAjevNPDQoZNX;$%WZp!Oy-46 z<+uPg!0CPIA5=j@9xgI}VB&@1^u0(~t*qcQ0E1N+RSpe7_t_1kecdPu=1wa!?I8eU zQY*-JBs*@>uO>MTfzg;~_$9Dl9HhO;DK`1Q3sPA1RYPFJaqD#)e_;_- za1Y!kIWgp|VPRplG-{q-5H_F1U_N%aD_0%L*p?1sMX;KIJDXA9`4m6Q3aDl1f4P`K z@_S5o^Xe7?3fDA6Ao&E)=Lho5D#HhBt%>p1&-g4fZla$FF*2a>V$fdUbpQUzUjl`> zASW+Ro`Y|B4Az^>0e(nj2_ypR^^KB-vQH(Bsj;e)zG2+wfw)*w6UO>^5121pWig7Y zVT~RrRiGTNlFj@x2H4B~r>~MhW^CvCAb)3B6zAy$mQtq{qUvId77XCvxuc#*L^m5;H*_Pbf*{YS5P!Gr$&Z{1NR2WVNY0=tUiDc(EukH z3JZ_WJI_Bt@BQ2uJN7Q<0Xqehc>!DMM7dAN^IGKcVp zEXzXa;?OU_&fIf%SCmk+AXC1(We&NVeFgn7hO4jJ+8nvH3O~sC(h)n3@|~pDiUW%D z3yx`8my5yhB7`p#agEhx*1~;nf({w+kSTMEV%67?u!RyO=g2N#JRRA_M~7V&qyZk; z*lNrKd!z)pX@4eT_?!!+>%2fslUl1wgw}$-J*#rG)OEl<`=Dlm*Ix$a0UU$&5=}n4 zUt~&&Ut9Uu(Uz=7kJ6YfHhb}GrDl!)I&w>2UyXwRIJa%1$*#CB5N#pvlMCaGK~QNR zDm|I)`M$GET86h8sUdq^=gxWU(7=jyCdX z=Q#3oqT`+^?{w1~j)&O-ZryUfS{9EkDaLZ3BDxUqMpjlTValJ_$Pf4pXi!5arv;)?*No!1{Pv0q!jDd;+VXygxh8n*tgc!99 z*&Ozy z*I5!06N_NaBNT{WHPvt#4+(TrWVN4JCs*mn)Ey85MPTZVG$1NFlx4r(N!n6$C}aJL zzXr|zoucf0=`h<%3LKrBJ9z~;Ii_%|m)6$Eelj{`t197W8Y62&*J`v zh!g2zzvZz$+&Hw3t%Qn84yF&9C)9xF`3N5FuSZtpW&-=u^NCjYmZdRH2X2@-Lt1{o zfD^#CqK=rIHq`3@2KBJVg7g_~ou{#^&YhFZ?pyt)$If1T4K0T{sT+&sZiI;}AXU-G zY<-}f9}MHF9Z0e>E#F&KwBz_qy1N|`9NYjiHZb=ks_(w{M!df_(@H-fA%HlhqUOAl zRoieW9VkR6kY))H2U!1;-_2fHrB9Bu%Ix}8XCo0(Ei)GA2gHOUD?t1~77A$C1_L-= zUGCsd&=%#^Eqy&2YzV{odt|c`L*0RWJ5mU!==YAS$LrHn*tO za66-0^! zGS@S^^T5o!1d;g=ak8^mgNv9A)$<}iF%QgHV}SYw;5lS){6>?lTHyU*PJL*;{1$)>@LaV5^&kQP{ z31pX+jsaE_S#ow^r>RmcbOrAx5ub&b4W-fQa*D@I?d@*&7n#$YB9sqam_BTc?~|PB z$>V|m-W+&0PJrx1EsIN6LTN@>nRFm(jh;ksFI=18BH=jqR3MP|8ZdE4=|$|rt8Zxo zd3qq%OctQ>H@7q3c0GeGDK3r-;Ul9s_CsqD=HGxdd*MQcd--!w(SkddT1lg0qJepK z5%@woV#bu2WqT4tI_`K7V}VxHq%E5O-zDh7?3b^K03W{OX+pZv&}Fx~x2~mSa)~|mtM%)RA|zS^cf0`#%1#-fE<^2pDiWmXEQ%KVNf;{G zoQcXL zp$vxi!c{;c?-%B`S!APCV#y;XCkKN9^$AV+a09T(e`KwPi2IOu!)PSN+geC5hJ8+@~@G(wr%-WGHkmh!Eg?M|;< z9H|uZ*b-tzFCW?W*yhQ%!YOCQmObt5Sr#t*FXr9_27`@5lMl*+WwvV2xbf~@{W0LM zB!H(I;Us#eDtNP)g2~?b3M}n$opZ zZVOzR0lRG@mEW|AUI7C;A`@p^t0|(n6`5zw({tqTB0{e&-R*facjPF3vEuLk8_w~DiKLSuC#`-TrGwNU$7Eh& zl1Hnq^w3f55=#V*)|r|O{qp5W{_~-lglffbFcfY4lxeU~zB`Vm+p#Fo*3znb>2(59 zjo>2UWR7*aA^fDgs%o}`y>J_17#YNf*l+_B&!y9YKnBhWWs09N%EGgk7ho%aq0s(M zPm&94fxzTf_g#a|0?9icflH(+kUzrb0Hi0q>uVPnw;4tbZtfgB9xnea#~2yCfC{%xpdIPPdrlVJ46^S4TFZbX3>PKWzieR_K48fh!(R6 zhVXzaF3ddNkd^HRW*-X+VWKDJ>PO6eZU*8Jdjm<7frB|AkHhQ}+>lre-Qw)WWAb|o zp<(^-P0a3&uegAt_%6KxxX&XK7kEC*$M=&5_?({O{geZxn2+3>wu=r4A-e`9^$5AS zuq4V54<6J^R;3l6EFNv{)v^|%JH8w(ALesxQI3&mhAiQT1zm$*gkZz>*_oL;H*X#U zz(Gt>{IL+D%TiKUIL4Y39yB#b4@3n z*rvG$OuwB7!KTPvcE0kJlYI9NGm((IAh~_>x;h0l^Z0jB+_z7y;2J>;JHd0kC7Xp&a_({x*TS+($IiM_a*>guJs zvA_`mp{179U2EhcKfuIIF{nVGYMcUZZAZs^7w!9(_IovYfSKM0vH-29b?l{-nnKm? zRZrv=<6VlVYIy%az5;xDmXF?2zCF_Rx$>r5ZCi?FD3(#eEx_LZPfFgFV>&0Ln2gnK?rOPPxDeOc!0NLLL=+G1A@ z8%>)ggJm+b0}NTz)h~;z9h7vO|J76T7HX&w11l9n)sqSV%p<@GTA3bVa&s$rHq`v; z)s3EvM=B;JNz7C1z$Hc{sV+U=cY@cN!M6(U70$&%>CJ9nVjuG4O3f)6kM2?EGPPX?h&mYtKs zrF83s*|-7dSwM*gemL>ica=H-5c-yh5`MqGWMT1ZL#hAR`jd{M9LH~+J0BC==BsX&@UEleoS{N>US@H|Q17sA zN#DJ+mCJV)ZY_L}KYXy`_6^sV^}r87=ShQ)fB3m>r%MPHG*#Y=!7DX*#Nu6u{-WHj z2@f=ZJhM_ArvK?^N&`gBYcwjZH+dfBwrwwz=|4VaXl|a~irQ8}sYE2;_O+5BYe-kb zw`^I|DD4&*7WHK6^UDAJov;m~Oq{>Q!+L#_gzBgl_eV_q&c@_PgN< z5Sml6BBbSx!N5?Agu@+R#(iC3JpkOOX7t9Dg0f#7KUyn>D-Zz)#o+PDnTM(KVN{ve zY-27vXUpM&#Kf(k4i(hiN}^%lSv4?OPjhd++R=IGHgx{GT687(oMA3pVkYxD+N`Ct z$aStAZPql^KG|zkx+Gt=w8g}T8sXiQut;NZThUL7Q!sVCSg2w9`mLfxV(=3Sp|tyn z@~)#tS7pLeMUxEV%E;s;dM&knTK1O&YQwG1pQ8BI-{lvGq2L=!a1ne`-d!UWfYXRWbi%Vu5gC~onR zl1h+}AuYK*di_mI z4Cr=5-|^N-dr|*HRY}PYI)oX6i#oxEhK3&_B2vND{gWGiWoM__{6K!^fP=Zi*2?r9 zf7e9<#e!n+-gbxL)&f-dZe`prX4(_IlXo^kHXj0%qAs>z(cd<6i1wJ5gOJFBQ>>m^ zdQ^;4vwWCbde0plUS3{T`;Lq7goCS>HTBVVVGeJ3Z7pMmPm6u5c|BEqfGh9E*ISku z8E2$RE~Uz8O|$y9P-~`l1bp|GhXF&C+5pzD`X^~!e6rB~3BJP`Am-+J=yrox%=z24 z>(&Kd?@pVizaIWSS*i@U1B8}2Ty zF>P#Y?1Vfs{=@I%tA$H7?@?aq&Mg{cS+&4X_@Wu zQ{YNnL{d~(s0GqIw0bImg6sO)3#-|!J@6!Q_FiZZaVj}>wkuU3DjZT$I;j0cqaxI^ zu!yV1?_q3T2;HKSC!Ss)Cr2}(lLo{ubAVKfw=<5w-rra4?rLQtA&H+kM(%Y(Hi~Pe z?nE{F#jk~5Z^@fq@J(+IE#*x<8FK7F_t9O-RzJTd78Yt_wHm61!J*xG4Dx4UU4E1& zP?PSj2$yw$X6h4TAx<&y#BjQu*nUM{pY^Pm^*zvRn8fv;Jb6k(N9SAe-N>D@v5LXh zpPf9Jkf>l33WBO*wGkhqxw#VWUw`=$za#zY_Ueoa@EBo)quN8!?sZdB6Qw?MnR2ui z3@`g(6d}p+>ga4rgP zwe)5uVbc4>Wv#=y^SFgFpGkiD(h?&9kE#0oJ3sE;fY#W?QZR&1Qakfn;pUaaX#ETT z!miw=T-o1_weU1~?^Wiu9v(P&!PEu(0CGe5RWNAXj5c zi!zX$)%Em-<-E0dnM8Kx&L=m1q!P6f+S^UPckkZxyvnTN%QnS_v{*Zh%#?Pjc|8Y&QSxf!6h*xK5wnoOF}o<7qs@+}^A$<1~Mpm4i1 zedF%$A7~gHR0M7&Pj5*fd~C^AKfX+NgNlh-r(R!<>clJ_RYy*+}`56a^(lMbq|z2)Sf*{vnV)K#}eSsZ*B+`|1KXL8_P2zo)Lc5cMsC1Y-b+0d5~9mkyCY*J?bS|j{SV; zCBe*+0i{L5^a&NIuzWm*=7RKQoSEHu5-^!MlQrgl^&jZmdJ_G|P$4U&f!% z($S?WpVd4>aj2oU7ayGIa^3UgFE%#&1VBV5I=sK`S6NJ_c_NeeI@4<7mzbk!{ey(_sbUeHq|J9<7*$;`}sopKW* zfz3QQACwBgwz~amqX6~eALuUozc**4vkYtaG%=IW)ukr1%DqQ@UNYuUSjZ#gEp6UO z&XOc{R)>^GozXc{xX$6ig=NkB6%LlH;Xzz75Gq!eIg@9A$iRSuVsBPz&-d+cJ+fuS z7f2Cx!HESr=g9c@JWzaV@A~;^6^#rO>Qg2(Re1yjQ5OTy;sglF z0;ianeFym1Id;U&gV?OU>eio*Ij9au6ye71yLYz%VloWxgUY~$Fb-}3a^bZJ;vY@7 zKrJ`9m|da=s1ms`*$=fAw@)`5j(0)3O;=c(x$ku)RJT?ad7($|OWmQ0x>@`to-5eK zC${S{!fyc_+aTml>#Pcnm3fu!8ti5xAIS8ti97=VSluHSs)VTX#9tiPJ>uPmAX+K_ zrtodW=qo~ewxps&Hfu1!X=!iAR~VUp9{#wHeOD(|5C0%HuGGSMYBQoqefJh2kMv-Jq)5cZxP@o1-e zgqq&UZNA?F!u5P8!O7!vQYi35;?wC2u-u)gnZ)s8_m=skj9GoY-kPO$F~uK{fbgN2 zt*i-_e)LRC-gj#q27bhE@1Wpd#q6?cr!TYI=ecraARr{6%wx$2Btg0Ij<4suJ!hI; zo-Wj_?)c(CtNBbG)^IlzkLH4UAPJHKq!aFGe^$HxDZeR`H>ISp3a?;VX35K-&pdee zaGt#(9rF!Wx$bV+JlAa}C(bhO1wD55?54&>S>=Q$O$`k)0NIc>Y0h?vnRW(gg>$3>*S^^ff2av-u_r$e^H|h|qnSx~O)Fgn zEA@dX`9%txyqEa7n}M-;Pd4p{_yhl zp0k?gFEqgG#l=NqmvTFbBa89H>HxRWpVryD>5CK^snQrcbl^;hu$*nk<#sKdooF?+ zl>ME<##pSRIh;-?2o(iq5X*;UDP~wOHjZnUF0s*z&wUJp) zeZ0LNLMk^nPfZv8d7nw3>itaew}*(q(JQ3+S_M5o<~`ua2#HD_rEn0c{ghemHXNVBOdrNzQ%bx=UJ!jNHSVR;k zWDGZ~1W21=2r)59d(_exia6MTWy*(%r-2Mh0~$MOOQS?B9i1HHy8egh=AJdOpi2da z8SJxhmdX8a6bjw{dT-%Fv7wmz13p6gCgtqR6ukdvIP#>yN};!Cq#6&q&!#skL~{32 z{R*w}(yXqke*kSam;_DC&gL!*d04?|JvV^Qe{Vn}P?m7elbxC7(mVx*Vk{E&D0!!6 z+C_SqP@&DityKf7vb>SdtaUNATGO4`>BH{Y`ndMr9d;t zdcLB#YNvb%wo%HM#a%&QZ|D zz2Ny}+x7wG8LT_@7wV&7Q)#TeoN<=W$nTZml-T~O+-@i>nc0cCpl9Xe{Og5Jk zIpD0l8uW`h_kzp1bD+5;OC9z|ZcwdIG;17A>hY3h0bpmj`nOqF9Eq~FHbBM^zrV+Nnde=fZu#FgvrUiU zK_lDM``a_lzACMJ8?4lhbxY^w=SEf}{ZEwi)q}&g@T+*^D8Dw86XQ!YnnxLU`|2Y& ztRdNTc5><-bR$D$j$})T;$9j+K#O9DoA9w{-&KaJqRMtS5xt7BRNfZfzi(e&2gUsdJz;NW>v45;wKh&SJvLpI z?}N)wON#>VCxf0pNBP{|9{Z&jf;r(DQ-4>rb9(W?Lx=L*^6UIHfHqdBN0gBbYv6t} zRyVwAJgqt6B9ahNsBBHsc}DjhxtzJBy;U=8ab$8MDoU|S)Uns%jdzF2?<7B68Mr+~_KxAi& zE~gl$S~-@>t)G41Fda7!kD}dB8UNv-Oj4sPv>FGHda%$oMp2YuW$7tY8@9tbGCb*_ z=bnMeE8)HLVU1boBGB-ETU|vFiVWNER`ALLg=0D3I@(WNy^OA2G3*UOQN(GPX=9XN zs`hBR_@tJVm5ben3gB(aE2yYw?`%@|Dv6En6V8wIfu|zN0VOJG2s4!ZRSGKmyGJFq;#nO4A6aBVFg;2*)Yjc}Tl&pABkH-=Y68 zAXs*r$9N5^P-n7LdDw2~?DbF{!7HW!;R|`Jom1X`Zy8tKhSbewpe%RPXPgFJq-5Ay_C2}lETfiYqA zjc!aa9O|unRt_v~l<3_*w@Ixi4ot!>inPb_OV{z~XC+A}1TvNu7pu$3y}njj!Y#D= zP1$$TYWdyWcz&~hT?4?@o;^Eq?62f9>eON>E?wN;1HAIBIr=Vr1oC0fgJWGbQyCTG5&{WxfxS{Y^KVUn|tIaP4D3)V)oGH znEP8!*QIKzWMx&AO$(o9nboo>lD;itC9*I}m5pgdkXvj{q=&h=j3wZ~I?Gqss{}NW zeZX<@rM461Fulq#eW8T~1*;wU4$0PyEaT9i`m*W4# zN=ZR1&@{$wg}{bEfJ5K4Swn_f261AIArTPvc0}Fs{1QdAz@@C`%`XS~sxA3OyfN=3 z5|X{Y$O}0P7zkh!GFkmL)gjUEep0>IBXM#uIxMV{-B19|2Ibu~vGzDIG~~7YmdSgn zz+ssCkm_#)HLmmY*VPEsTo9ed3wR`DsV1j_IWPXVqrLijR4mhVSDnhO6qxxGFJu-_ zyR#?;PZ|YS)!fiD2XUj}Y?aG9!}i61Rd`@^wKsJ!-N|g} zhQNKom02dm%(ppgG_0cYX;hsbcq%vGsrelem!dlGNl>a`IEH<@E2pI&OAA^KIK*G~ ztzwI>Rr0k^aIa)55GXnDL< zS&`po<|?E%1Mmw%&^eo&oX!XS_om4=>ZJE`G-YcRdZBFcfXfd+6Sxx-Y913cmNDbLaz%Ufo3ji9Ox}RUINK>$cyMe}RpOz)v$O8ulK4Vtal~p0e zlV>qXKmveO$}3EDTCq@e1AWKcyRwkaTTBKxOL^GvLN(ib?<$=X_B>SHr_P+og=#XP zzGL;dE7S!!PU8(FMHAu!l+NE{VQHQ^aiRc{iYVR=Ps5%-hZIQuzK9diTm)I+RnVLs*wpC^)*+DftIW^UC{~EkQ)Rs!-pkIVfeh~XLMaf)K^~^Dw4_#X&?Uq;xesMvk z@MqTys;>!@0>I8@X~`M%%f6UTiGa;1=VyG;4Sw+%Vgh#E8N21;I%_#*rGEh%sfrlOh;-=+S z4{r$Y=oc3kBd0u6@c9PWFfas(%Miflx@t%6F5&W6S~nuw&$GgAnX(L7|1$#mlKVlQ zW({Bw{qUzAXs4TBm6JY1S2ZJ4n7aa;or-e1u`5u`Z)|Pl*IG(=RaB$bzshM1VeU@H z#w2H-#*ksIZO0e`t+QfCet7R`I7Q-bv3nnQJrK1c6N@* ziTgav%sl6ucglh9j~j5i)`k|V%QOX@a`MXkgLty*rfb%7?Pv*~ zm6a8Cd3dLTSZy4|g`S?Vb+ADO&^aWTH{H;~#%~;)9fU<=lk(8a?%jo8zSgd3_xnopH*suT;{a1jhBt{TkR# zryzubl=|ChO^1fD43zR>xB^YVC%tvp%>fgeypItdzH>eX&V& z`MCN^dDOgwk4V_vaJVu`L&JI3`tM`m>FNGpV~PUMx`^Osu0;`t0yHW(80^8*0fi|9 z&KrEPToY-Br`H{>0NXs?b}68zn+x6u3WdsnBxmNs^7vHOmI3s3!1n=_4prvntI7$* zKsUm+IJhVaI*>IwT3cHF1W^p$dPD(UPSoj323Y8{(=DaU{nqu%f9~%adQF_Yp%(_j z16TsUiv6!Yk4Z?k-oC+_EHmd?Ps2DeP(1F01ZhYg8C;YU!wW;q!;Jl-Zpql%0;;IU zVJLXVp)V&jfbOi`XWMd1@06+ON%Rc~b$fg3OP(SD8f%bngV4Sg8d#IMxT0$U0-fN| zT9L5;zn3gRz)Ff<(~BwhcbVS5mrXGDwa^!)66GOI=61VWIa^ho5>97m&|$_!WGpl{ zH_sd%9u7iZl?M~aYfK^iw4M1Fa2>Hn!liIK^%L!>s_9MOm0Eu;Q15qJRa# zxBFLhqR#f;@*Gq+9vjx$tfvR<&|qO z$Z@b|_pOVNoSa@pMMZ&zoae!FSPp}Adk>X3dd;O6Ns0;WGui_lFSm`eKl>nZECa2oa1{& zBsM6L$N)KJ8Yj_T+enNW@ z`6(%H6zAeF#XE``^Tj&QCu<@;KK$DiwzUuZ!G{@<4|rUQsbNd-!D<)?N>o(=${_(!jdcIq`#1EAgEeu0 z?G^b4(l(PwjpD;z`3$^K(a}XsIhQh%6ts3WY2a8YSYQ7Y!d@Qal2dl;8`rgA@tKCQ zXf@3CLLIBFrx&kcY0(1s_r}IXODEse&hg<7By?j~BiH%!w|KORP*C$A_dbGCRhw(x z-O*7k(0!rIZOa|N0)^VzTBy{t6ckFrBQz(VPU*5}zoNeYaqXUDUCC7<_lXk`Wu}wd zLJvNuacBb46s8I8dB8~FnQzBt>gt~9n+*unBbcOLci2z_fmmdIem?liAZId^)X+P0 zeovU3|55+|yj5d|pn8Ooy2i!sRCe0ZG-=PbSN#9x&}IgZ2|P8x7{!m0@;#E{q*(z&O> z;Wr=fOe=89LGleBCZX`c#KN9lfm6^JL0U3`-*VkCR4TRiSp+8(b=e?50|v$;B@Qk) zW8uZV+0JJlqG5_9bwcwuKsf(=LU!T)cVspGS>;1tJpQ8Ph|Tzp3fc(}3$SY#%=i z=Ab)Sf`UQ^mdBkSQbdM?;Gsj`S7diK_wmi zyQTq&C|X^;7?QBQ3zwDNzP%MwNUdap58XV}swit_HrbmqC($s3w@z;>P;z-O1CSoD z!l6w(lV{BeTE@AdY{G>L7wK7(Q($hw$B#q#>gfSCgBi~@Qvc#1^1>-nc|(ln8n*(( zDlHup8>g(Skli3k`LTaq|ACLgNed79^DR{&;h4GaQv)_b(09t-hm{ht9YhKv^t?_B z7`^I2g@yFsq0XQ34uBiwa50&wazINY>=TeMM8vp6MfITlhX&_ASUP<*UINh7LO9!l z3nNy7c^EiUMVRtIt<5F6`PcocNVyKjr>2>)aWJK{)TK?L!OYaO1LMd0?l#})6f~s< zUd44q+4t0O`b^j%0L$EkMrYpAZ0?H}_q>xy`|71na>iY%$AgcGB5X3aziFrUIsQKC zMV1Kq@qBIY)5f0B7Yr^!;)|TOA|j%BmSJakc4~p-Hs|PB0XFGK?t0{zEz<&X|NC<@ z|E!CBc#!qUMkPd~@55ptofdk2e)ym0zt83o0+PVV_WLt=XBUqMTo~QmUmh~aAgIHS zFSVm=oY|M)HzY%ta^3Qyuj!n*bYx%O)%~vj{KiAQ6j)<1tGlmXt3hX0L(YKFYANT+ z)W1t}-@co_{`-FR?UTFkKYn)N-&g^!8UAl_0)Kn)9RT_7rP%(xEPRjc_kS6x1?a`oPM!?QL;nv**n8+#ai`5nmXoGj=a`pBpUO`VSd zY#{#n@5MT!Maueko9^2K*RRKv?SNxcoPq+h8*z|tbzBxQaB%}WAT=nGzvYlk&ZL~p zNZ1->i)dy`@75uAEa&VT1PMw%Na=m9 z+H|o8PKTp(blBueav#}gWn5pKD=#U@Bfc#~cpwEp(R<&(GM@F;?J3K@i-z>ltWn{B zda_JN3$fg+QH41qL<^TBp+zVPgq(y+o-F(T1z`2Wrx_R=WMzK;e$zmQ3e6k|dI>I2 zaVds_2NQmkYCg5VEbggxn2sef9ms2AgG)=yaT##bAqWE8elQ^eWR&abpEo5XqZ_r# z_x^Va9Y_@|TKQmGSX3)aQTOzG0!Jr=z6?<}1K?u)3D%Y%iRXq;4pbLN^1}f>eRn${ zxnFG58LQqc8=3Sn_1)wBkaV=-oXnujo^W8fL^Mjb+C7mi*EXgDSWMAuygR;e@P>ta zKB%=aqdpHqX&{7sdmHJPrM4$P_qzgoOUz$DEG>FHmFnz*8x=870(S6TY@sZUEd zkn$lEIZ8Z!4x`BmSz9c@X~wYvMuuDhh-^b>4U$}S7}JEb#G$|3N3<1g{&QnvD>unf z0@jPBvvUd9xO0OD8UR6~ve14JDs)P~te)n`kz;CbG$(enL7oUti!>WuD-5rc+cNFQ z4-_h7=>h=*2o<1h-QTDE(@{lwRxtlya(c44)QHGkM*{5IcXKtoK5lfXwpJ1dAZUoU z8oesHMk7_%^z@MjdZzHXs56@kL^7nwXetI|-JsCW=)@`dU;ml!+Xj@6N>&p(QBA;;5&?k zCT^WDqC-yDot(8vk=QE=#N(0nbVbEU=<>-&b*5euA07dU2Ts%48rI^ML+{)kiTt-* z+*foGj+k&ZS=m6iAMkZ9DTeFplmw?(cF)kW=RwhI08W;kRsl!FJjh?8GQ8b*S&uk+@Xh^6VS@^Phr@w-bjrKF{>Dq2`a` z-jv^xa8TNMX}zG}G9-Tbt39(kO#i#`MT?6|CC25#3oA}@F7@0MC7)A;om7hvO zRy}QtS)Umwsu|vvvH_We6y$=Nb!qD4vEu{;WLW(Fc$<*gAUr<-ffZJ_kM zN-#p#1#NO5afLV7JT$b$DiQ!5eXaHN()_Yv+?z_BRsjDZnK>k!*sQCEa&4zOj)Xne z0OP#SvUT9{|lsY?PrUkWvrbKn}jM)JM3(=<{v04V9o?R z4;m;W3||Hd(j$+_hg^PIm$ESUQ?KkdEjVw#jGw&pTT63Chc;wEkNqFRl}CQQ86E3z zC&Ayq8tN@LEYttI%EQIgy~Zpy3FPV5T~nuiB{Bb-&pjloHLfj8eECw)u3xTpEOud} zS`}h3WIOqw(?0mttug+@KPkmN1MKsG0qD0Mt*@wm3^8TNRo4Fv<-?LBwLJ2hvX{c`|RaC~Qgydt&f zmQq%5b7=5OQ62G`E~KpkKJk$EUh`>k$)Xx}vqDPHJ~s6aE&@ z?-KC-n;(G^gH`*JRVC&Z{K4;^Hs&#M7VE_FlZAu7)+kP^RGIZ%S|NdSZh!{HJJi(< z-d+V`s`gkR&5HqHF9NH@7+1MVJZDcp2+QrI5jN0jgvOw0d#s2wM?~%bsp5Ls-rpH_ zVDG}o_`>~1$Khc>Wk^GT*F|*cBU&Waf5&OLMaTD z^ut4ROic%gLEz|OGgY9=BsN9+@xuqW)Eek~<~A6O)SydTh+F9&-K9C?Z_)WGQ;SEt z1vEqd^Lj5(lHyCF_~4{1YA=0&5*$3tQ^4KF^yzK&iEQ<M27o>kxjE_@0aPSg(##@e3Hl!nq&>+UC1?t1%!)T(gogoYsPC+EaI)P_0uGD~ z_-;UCc&HJs*#~ngXjsJM4mhy>sXtVQS#sF04Q_bOoDW7zhP1S6daoT*AwA)QV{>Gf zfCE*6pNQxUo4GP;y{dT+W+^|&dj$L6{6esu`0FLZ(5C&j`^1ek{Y|^6%XY&R>ewP3 zKZ_j>CVrm^nPaTFCN$g{C{)JA(J5OnXA^Z@e~nO|61LX*pHy=+l^RI|yFp%o2elio zUX4y59jo>M+$I1+g^RVefC+UqK=0LpGB9}@cgCELQs%GP1ADu`DQ#JaW*uDGqYRe>{o zA1J$l&_FuUiBCzni70|Ljxx;1wH-fr@R0TT>r^HQo7IC$xpPJlE#+8wy<+D~q*0Ao zTT~yZa&xn-^Q)pJVR+6h5=$1U@)ptx7G=}JUb%F<*L!XCh~bJshW}x@CXl^=Dhg>_ zYksdpmX#26N&~CRmcoSSq%4^FYx^;|6@lo^zkSPe z<6a6_VrUgfYMES%D+8-lXm`s3O(^p1JvFeL6$0WA1u+1Xyhzx_-xaXv=z}N`nV493 zy--o=fWrc*b6Xe9A;PW;gqvPaExCe^J2aqXvqpujhbx>W2k(9Kz3#F00BSoqgno7( z6$$Z3(CSml5!>+a_&kKR3Ikw8y;pOrjCo~)s6P$- z&=O))6R#`7jp*%Fl{7z|qzViTnzQ`3K`Yn&`n)v4;>y!(ysIj;@*I33E00X~`%!m1 zDAV62tIXP%H$M@n{iU*W2!U1EXWe9k`etX4Xh~p6T7NO>x56uA^(l9+b~Rjbdp-R~ z=LQ(^X*w$eCHZXsP6V~)x36EjR`_aC!2jX3h7c&7yCPJjGA)yQwUd%42rGje5Xwy1 zU^GT{cQ=Ug+Q|&`^yKF3U)pgI8YWtD3RnTHy2Lb&}N1#zFvH776Asiz7Qi)w0 z*80=KU-XJipB|z+n@2f&9aRx#nT%BdNkVUqI#%lHGEA)^C*W$eVF(!8Ea-xIU<>hr ztNzNy?$$?3%GV58M3S|&Sc&!+5itUus-XRLV!WuG&C@M!3WX#H9t$vUQ<`qrqb?{Y zcy%nss3*ap&TyQRQL?vl)K@G zyVG?b6`+kUTF9eO$~rNE9ou0Nan1nWhJw_K0k$w8D~8D2ka#1t+vB-9q)cEyk`7Cz z=arX>&|E0L=owar;>G55&yX`>+R47VQf~8I>U63;a*K;B-m_6LlpY=&%5w}o03HWf zTPzjE5Id@-7cqjLi+&FhbGq#@h5-iMw^kbmkHu{-CIR153z3sXgC_xOi7L2pW{si%E`zL$s$;;8koUV!K7biAd@{X^cFQq)pAPZfe8vfk8y;dv z$saq(i_h#KEpVB-3{pik8X}R`NGvKki!<3<0sQ9UCEenIzbqGyy!c`j<}n!XDN~5K z2vEf)uPU*T0bOom8ekl~GS6af>RcvhE{BKx*Rv#j zUD-HfU`DDxOw3fTthk~7fDH5Uy6V6%XWMNo!M^7SW^sTt=2Q6gz~MZvl_|!8A+S0C z-X0?v##oTn#g+v5mOiAWvtYhBDJMu8(kZ{XBo7n7aM8QF)eS%=bl4f&iz5*!(SA(g z4{~aEL9@KlPnujSzCWwcPc6Vf*I(x|{|ymQn>oI^mZ4U%lGuDEoc|-fOT^8w>11*} zo#a*A7Ni_Da6)@8Ki!+#-C7jjV-@gzE)#O5FgwXl0eg8X-5_iZI0|zxFQ#+&RE)ij6>upRs18v=~+}#t-d|$W(!%E{}NFWob zqrLG$`9f=<#Z!~G+O<4j5C{P?$!4SqUzvp48{3-&Jwo5>RB7lQsc7w<8>+C5kldKp zq^a0(M;OpkYu=qB9%SP%sVroK1hZ%v?pwKPLnC<-ZKMqh-PQVurO7W}F32MRa`nT> zFmgnvWPjn#X0C3j1;ji=x5&-GAye1>Dl^nlx!SFa&u9Wxi2kFCf6cP*zD`;fdiSgP9XIDgMxnYvqYV`rzJ2$1RR@8{#-lJFH_-L_m zD~YMF8G_*I)R2Sq->*M=Zxs@NiEH}7k;3X&J?gvXyR$tWwteqMg!&$!DD(3w;lQ6Q zaP~CHx_oD8cs8I8+jN6dr*aq>ybGS1Hcg+t5f#xz2D$4`ewEm4Vsr7lA`ktBs+A6; z#ouMDPl%7HuC62lski^-McGwYKQmy0i#9T<9raugLZ~Yw7e~jgZ4nnrJK8uwx&gX$ zz+2)LEEB9ykyE~7EfguPZIkX`20_B7A5KJLRd{2Vm zw(*W6!|)6s6&by@0K{c;c{=&jAAh6(Q_y62&~C>eA}lOyiZ|B5C>mzv6b*gUBiI+} z3Ruhlh59lf3;=Opv}vZxhFzo1OQP@HOA!(0b1|cy1;{*0WMwKIV=z5AIXl!m27OQT zcta>&r!JL1VDWZL3)j?GoleF^4mJMu`C^`UfjeY1c&pvp!C%FpFCKo1fhlhy@>FJK zb_`~0U92&o-bKEIKp+{8E8?0zrZe^ZEtIRY#V26%w;XZ<5Ba}6cZ5%`CTDP=N_63i zfy;DMs2>0F+ryV{e>?OO z{bx(c`?B0@q2tR$3xp14yZh}suICZC6xc_Rkiv+ z8o(oMTSEkYEV&}p??~tm1}PNvEyo;;QVCU=G~zkBaP;U=9(@c9Mg~b|31hz5eUT{2!VsGG-MfkL@$|Ez zqiUa#S!_A5KY*)M&TXg)ItSVXu^Cv(fbnTsS>?c#fIR4r&{X)|e0qL{hFLND#D{8! zx`yZFK800wVsL@Fy<(-K440uF6d3s`+_{6ohxO$dM1Pdq_flO5qs-mA%jaq^YGice z_usqA7lxpDU`5N2-&2&|d!++TmNFQhB4pij$B3q;g(zu1e*>1rV)z<5&RwVb0E782 z4!3SS^3nw;n~iJ5)uOU90&MMi$mh=JPL?D8+PmEbB82g0pJ6Zzcmb_)NW;*RcC0r% z-=r}o6(Us9HS*ZB(+zQh@V-I};RpfAhmoS^sjLiYJVu|w?f5As%pAA)B( zCRUrZU5Zo6a+Y4!4g{deXuESI=G5P4S*V^Y+G10yIfw0N(>kMHy}B%qgok90v*r@P zo!2e;tae24f;vpzNnR0B>od~6;Nbz4+!Gk!N#74(F4S2_5$L=7o^h{9$W5}hUXVwO z{Ft1X{Cfxtvdx7`Gs%Ov{n!U=np;Lkjii*43Nl;$5b41xWQT0>U|rWUa+1U9>ziAX zGFM}Tu}LuONW)~^jepTHps!P+S6@gh+cn9)6NW$;jitMOSX*5!W6Cvy(dvsZG0$$K zTschgn*2%DSC@p*XlNY~!HtD+;WPF+Nz{!^9)x#7-rt{g!%(X}0RDi12^^l$vL^Lg zGg<FQ?MfeVsohc>}}7GjHN>7dZ+%1UqA(_lf%jtA}f zuWuUr8j4Q%)#=-JgP)wO-ydmNHZY$t=Om}9Lb#F7M&{Acl^^>WL9bF|00Baa0%Q-q zbzGD_E>M49pW?(m#H?u>5lu6phT_JzgX7-LQZOHqWy9O_rBH;uDU&h017N$>A7kvB z36|J1kXYbz#-SqEEhfkLaeQ)daONJ&jBa~@#6t=}Zfw8!)6HckV|zencX>d>&q8JDb=Pa>M@lLxi&ENQ(4>}k9GjTvGD@Xq zjA0!hKB%23`Ao5$i$`VcK!3<+`vJ68fSR2PLe*GIG8%6P|GjFxcQvOmM40=+h1)Qi zbJ7TrfFh~x%61>Rgjt*I>g%!&{oO&0bZQ~s1WQK5sK{3te+kt8ojeo(Hf(+44J=xt z?mAYpYGfF|z)c+lo&&7HvDl?#3uQnd#-f8daSzskh8?d>7FXto0FU89a1M#@90IN4 z0sA^b)>>*Un>8392ufiQyWDa?%#jmN)ySp|E}Jo8otCa5gDJ-0j-5forz zsgCd)b8t`)Qi)qN_he3)Ll66V1+IuSwkb}8YzX?4z=3TzR{{08?Pl8*wfEU*hoKnl*0g*2ZTL2WMbEOCd(Lq+ft_G95`DxCG-nl3nit^v2xUo&;z^*RrnldRV zzOqxC0|p)rH5*y5l2?1MbuWR50RlV9yOq7}`UsY8xJ(+!$V)5yJ4DKKLk)1Po+keE zesJdm2D)wcm-k0#X|*++!|#6b6WQLFW(7@_+E~J7UPguvc&@2=dRDMt=lP#Ld1A1- z6T#xA1Nc%uSia}L*ohnrVG*)Du_WgRm zv(>Bc1V5*U)$Tync+yCfU4G~}kuioMvY5$KZr!T~b_d;c zELZ~*lT`1oyN4&{=JNFIEANi^P!nsn=Xjn52J+mvp##l@UPzgcG6A+^Vn>sjw1Y$N z0;KA*YhaGEWk+ZR{W0>GAo*Ga+-$1*51Svy&~m|2?%VnR7#C^stzbHLLCpOh%_boB z4~%em3su|;UwjY^s*_pcEV3M-tp%#cEC8q4lf+~Jd)Ztg1e*ii1RkNx`h|AjKVA(J-s@IB?(JqHiSksfaO?eR z)*o51R-{Ek?{CYTeU+Ni+ztdNPmC}WA&H$f0jBHDPY%(YV*{!zh>JB4*efDTDi!8U z$#c_GjBVBl3+*oc4t{J8KDWF1R($bPE|pAn5nshgthzGv=FIl(l+eM`#@#RRII19+;ly zN_|odO|*@yBi|SN_fF6PqC}k)u(SeO1Lw*npPC&9qSn!=j^45}0%d?tAi_uheK)DS zomPK;ed+AXooHPBU}g<0E=e@Z4p-~;2K;th_4jr+y;@FDW=HhFQXhceF3eI*KD0G# z;B?!xTYG?i(0$w3cNzGV8#60>Qlwb{7h~fq4#bUUu)oGTE?iu7)dO$^R+h&<{B3RuO);-4C+BQRX8zmir2W-AU9jXZH!2j z4<8;G7IN8iMHFcR4j)p87%C98?c;?pMQLGo$W-$>;Kk$)!yJHV31Fldf^)kj%C#@f z_FFl8Q1x_D1~(v-FhJvfY2O-IdWjxmW=VSn8c{=TT7&O5PD56@*1`idcOk=Nog9Z+ zUSH2l*|4Pet9~_iL-^2Q1wqh4Zr@lCMsO%#^7erv&Lo+f4@H~J@CT< z*`hN((>PnDA5I$~mjNX(wu1afr4QOB&811x?EQR~FDtD~xApUBV*4ST5wu)92XKJ( zM3Xv`N8{;@&Fe+W@$fk$_U2tcw&D*sb9S=duC7da6jUUu{fx$Qu%Kek{^(=Mg&zYn z?S9%Z5_nx*h6FASGY{(8Y0wZ=7jS=uu0TD7 zlC-kg+mjP3C`3SSkaQV3CN_lZAPyXYhhXc(O6-;1>ZEteUiw?B z0s2mNpUSHJHYm05^%^jWEB!Voq@zVHu=Rfb5Ai1NuOF-vn>eH|=1{XH`t;vu=tXGRVH?WM5jO`3XV@xp0s@-Zy_Q;Q9S``mQ6SkT>u7pNN4;*`?bq z-@p6N_J#blSS5|$GjFn=VT*YC<}|4y>nD1p0q3#19J#OW1f-pK?1IA4MD2ui{~3Xg zQo64F>s&?F&&-waV;k((PMta#pKcM$Mjv=E0kGYUH0^H~rW_ z_G=R-UyWbm5o}h|Vf(1peBVL5SD85{@n^E)t$<FJ%En&WohbAWv764-&sYk()kg95t>jtb8NA+kp2>NSTa+0wr?`bBN#C zS2{XC1HL(}+HhaPT_qH>#V8%rC5)I@Wo1=!!_+tNV7k|ER+mi!gC%u)FH`666A=-YqZdXAjoS`@u(>VtLC@Hoq{Iu930upzE-7C4@lBln%ldZ*CUR6rK_-* zQy=TqgkezJ+*h=%dZ(N{Tp~j7c(|hV*xLgj%J2W&^cxuWfObqF{Jt5?Qj(R0cLSOv zLj13RO#q{C<#G+;EyIEogWN8O8-)1!;5_+O8zgIkB;1vN_GmMZ@yy^=BD*{=$_G8} z)hS)$;-30Tw2Clo@y6}xzvQ`nlcyLNn_VqJDILPYf1W)%q(EG9yi<{87Mhdt`fCsE z8+i2gq2S6`23~szpjGo-?tSC@?qgsV8JwPD4pi@ zzr`CL*ixv%ydk_M;81#N=g>Fmil%GZap;b5@$oICx#Xa=&22w@`I2UzSlxtIVGeeo zP?nK#Y82&v@fm+4GtbGw^{MH=^G8KvIQ~MH{z<8 zGhlHAEU!~1PCcN_1(Q10PhZc=Yxv!IloocbI5xQ^N!Ac=H=cR=KXv2 zDJ=Lf2a|xoIJq=!yeJV$8#;xBYN$`{p-~i*TzG!I?fYp>4R6WmfWt==C0PmlYk}|&)8TcNwQ`(g5 zM%QAz-Rip2WPQA4s>&BX*dKo!!yl)lAg7?Av(j~h%ILf*K=;|^ssaRtp_%-THniy zuzfW^>Uj5|y(kaS0~*nt>M(VO$HRkqbg?&E{bKO>&pS+8$kszx+Oe+;uH2NutW&RD zzg}*0hKU=7&?zKZdfv|n3R0NC=Wd#5c{a3qX|baPm(3tHY!)*^+6&u(y#oiSssM>1$|D83=CG& zNh3#^TQnj*y*OHw@FF(n!+0Z`dp$u-O#gFu$(kq9$?*x5KPN$qV*msy&dbDihE1vVh! z5)wT4rGkQa1y;*jCaLE-ew~4(nqx@5{+}a{p4sJ2H@_^Z@#EcFk8Vvq_VM4PjpUg# zH@wd2rR^RKXA`jM&HdU(Mhpp5G9R!5hKJ9Yn_D7InsAJAT@zKH%nd{ZR?9wZdv??nT0v7eB8AA>ky{+Og@yK@to+5lW(oQMrZ~4<0Eg)nBILI*0Caa}f$E zGA0U0%Jj7`KDVj6J9ky;ux#N-g^|Xr!36!m8Hg~0n2fJEzajd#d+R1% zLRNns4*d4W=az{{r8$8A;cOT&%$X3zjj-+#Cw`ln>|O##fys46xBCEkSX#+()d?%=^+xi9_s zT4Tt1empdk% z#m-@je2kX!B*oSGnNBg#w@iskeCidrwkw%@^a&7hDC5VDW0c0>o#Ce*=-~qlmm)MtMauK9uh|#gvh8IFlF>2gx ztC^ZsP*GOzdug=+(olDma&TjF@M{O}+U=$EM$;&7ZY{sskV(Dl(D?;skN=)+x!; zl^bKp?`?nO`&!=C`WWSa<)zS?iBF$j-plFGu+#GlM=J*$(yP_~)9{_Kw*qVHruLy1 zI{}3a;uj+mqF{>BU4g;Q|Riow&R_#b;R!s@i(9`Ga|Ph5u7NU0Xx^NsiO;%M(8 z=JeoTvT%PXGBF{D{BCG_d7BA)fZ*ZjGQ`yeboxd@XuuNg7B zv#Ph(6llNe8>rI70mun+T5*Jc{m4_a^FN%&<3MtnyNP#70#LXFj}uaQke1Je8wjYZ zgr?t?0Ulsx$ulKKVPQ(7R?)A%l71BlqCxCp+hBB!8!XMXRWKXgi4+CHHSJQ9LXJ% zjcwr41MglX)EY;=&On{Ib_>=)<%`O(Tw})5L}Jl$`Xw*Jy!%NQs}=dht6<-u(;^2t z5%u(-jq+Z>#05Sx%zt`h7KlL))t*%SVpAQhq7>C^Xd!P_ChFU{oVzgC0bmGl(6gtn z7GIn0z1jN-ko(XpYS2cProP zZQejyy1MyOQ->k439cvO14ZlV?_Ri%YWMpOyH^xZck6k|McbDA{lNUW=rCxr4jKF# zO>y$W=aLtlZ)|!Jm?IeW<3AB@`;8k9U%bAs?NGv>hGGBF9aj|j0#%{Be@19`XKsmT z-N)`nznh7x&fhor56>?-N=ATp+hcQHIK#oM&D>#BT4B;=;7T$q9nuKRW!ZuJ_Na zIw*w;4Tt*V91b5;&Og+-DQ5rO@Ym-5`N5q>_euPyZ6@?=Q>3B^1mDVq zCVzdjYR^^Rn)YSzPf;T!BID!D(ph?Y@tHx}1TXKruXVCJikWnJwI7w;-{IPB2zMtJSiYBM&xf!Fvwy7hD6&SLr)g33-_2% z*Q|9^MM7YUmi4h?DVLNTLeI){-$avd{51Z~7Kvd49m>qA$=o*f#$=iG|ML0%)TwTh z$nCQhhXkHzKk(4}BdgF(IY#+-^?{h&ZG_0PGJ@6xM^D!l1tTFwaugIf3lX|ZP?XMc z&uoqipwSzmCWp{rcY#o5b!k|i_{}Y>!aZKzdBALRI6oA)YCP%PnuOg#JH=BM=m!sR z>3IqW5!dqAKFnRnKc0S_^HUcB_m8~rc)Qt;5_Q%#N4lTK@tpZt)CDNkT-JJq6Yc5D#Q1gDP@ViOE-T0xjnifA>_w3)sB=rjYk_x z{d#>dZ)f5{53v?4Mxy9 zp8&wZKbUly*d;MBAojnotA>`|(sG5d3ebTmKPzv*=QynQl{mDOhJ0kgLFXcJ8fjf^ zXVG*!foUzGcO1liPFs!X%=VRNlfB4OeOV-gsa(>O{JK+VKk#YG+BUV1L04uh;T6!= zW^wa42-X^KTwOPTQni<8naRlpXpOjn*d^e?LeLNsL7@t3!?i$`k_t3%6F|lW8biUO z6C-IzZvNBY!Kn^X=C)0mAv0o1M*ynb*fJ&OxZC~oMR4>0fs}(FT%k&lz`}uM{bvji ze}m?nmaeYhtux6;ZRM2J#RgKMUCA=#+oE;_cWCqK`~$xBQX2`-frtg@O)~gH2xA&l z&01RCYr?^|F3=0?MUpoQuRP_K{(PT@4LmBZev>qrBCm7gJU;E{?&-03D>ybb<^VM@ zlXB~~Kq_*;2!QsJeHWk?I2Zp1PLmFIei>3bb9juUHZyqK#hH z8FV@!$;G78Mr(T?7$gXDbcg1)yI5>D1ttzJOYi z++4oO@?sA0F84kUpfn@P1y8pCINOtoV@&}v7^oW-j?l%9AJ@_Zc2g*UrNIUT?piH4 z6p=2RO}s*{09tqBTcu?H$M8WgZwJvJvmS5^LOsRM3tJ8l^TWLRPhV!U=o)l&erb!` z`vJP!8 zRK|E7d-U~ffT|;48#VTtJJR|KRTyzdPv|DdZ~T1e^0d@+#c?!fmk~rIP0Rv9n_j)T zgV5j++B3Z|>VvRJ9WFx;IDkt=%DM1=?V<>|RG^^M$-TS(b3!si z+;Irv5(7BSPjBRDoCvSGK0SRKI;w57@zFpoo7S2*Vi2%$fmqJWhWo=8S`XcU`U0AO zXrLf}V2n|_Qo~fC-!#=%C9XJPlMfJQ$rJHp-8hBm9O=q-Amcv)c(U(knXc6llK%1< zKCz3g9fdSzM=Fm&X?6;R2PiLim%B5QvT$Wygfsw~sWE0KMgAYR04^ryLt?R^9F$or zmhs%ooz<6+?hI%=@Z$huPA)B=%{yEy2V3mI{qz=^H>X#)YnKo*J?IiyqQQT|E+wP^ zm!;b3H!G;WQMYWH?}Fx+>sSp0feB!*N#L4Wg~xDctqyyMZ6TT!0nl3+4OsYclVYcI zP;7oW%>jrjc&g@5o_nP1J>@ho06#hn$c2_PSd6~zWA)Yj6@39lz#596$Wt$c$g@x) zs(0Vigis7Xwe8W|eg(b!{QR-y=(Wo0Kzj%w?aQNhc0!3-ZB5P8`X78#P@aR1cmEIp zwXuTEH&7}^AinZ0JsXG!s_JqfNYorbBlRkzse)TRe=f2I>p9mUtX7K9v;%|f6%Zo< zpi*P6lE*|cuop{dXlTGvj>UX0Wul69AlCg%$YfXMBgDFg>}@~m@$W@?pzSZ0eb#H`E z4-E91FU`RJtxa&5!@56W`z}1Go!O{UOG(`aP)DmIXaoI_t zpqXnSv$>ssRK7D4eXbFfs`WKGFVm+12dQU8-|Hy64k~ScHuJec08+Y(Aa%h#TuXLq z+mvTPx(FOQexvEpjqiz~Q1PDtU{$0jZn4`AV7;%I0SG1@*t4^=$E6TK9m$|1N|wHe z8gqgIHSRRP{K!fx0HPVN-OcTEV+7O+LAM_e1b2Y&?Z=uL2cUM?Gv^HjUqJwnf1IBL z7=@v$>l@%G09eE0kE@%vN04hlnfPX*9Sk!97e`Pp`TbZaEN`(hEdwMdvH^s@N_D5k z0Gbvd-xO5$xEa|Cb7ttR;EgD95b8tb-++b(x)pj}B!z$f?n<7SJ-b1H`e%rF?ACDBwk{YvD(A8ykR{DFcw%buhEV%apu6 zEQGXkf|flz_|Jrod*&3XG?G7z0%t!V4G0pRj#}8|*9C>_i%dwcTIiWA?$20u?40T^ zrd7_!nHBjJ0c}4h#y6J?Wa^8cTM+>!#5e*WwKUqqUoRwDie0gE(~mV2_uUykBl&U1 z92{>KooJrdolWI$fHMSSKZ1T(d%ht7_u}IkT>4Poz64n(tnH1&fS3~WwEQJASv9J3 zO>M2wHT%7OWYg7w4Nm^8Kzy7UezXo4p@|f-79nVxz_r_-yblK0{rLIImoHj*z&kFD zE_nO4qm3Jo@xilzq@_6vAZzI2>RRK=v|H|x6c=B)6gu0I@_=By(<}*E^j|}RVNZ!; zCLnH2W;vi_rq${j)e3Y^+->tvtvn@eu7rG zC=fng&Zi866V!|P_RO#On<1fWLIgVg=1^Lww+asGN*apF<6mfHP^ z<0HRKbQ#Ajk5BP*8Rh}w2CurTEa^m4@!ikobmQa=0my^k_n|ZmZF2(?3anYvQBFjY z6>c?N_~Upuh-9F(w6zgpXDt@ws9!L;x#!b-ImTKX^tXsZ>}vR>`?E>V)|#0a?2#D1kQ-5!P>{jSNVE zeY+j^COtoZhN0@c;1GfBrW?XJ3~XFFG~L8pXwdou69Z_yLuf&H>&LmddndLct~4r{ zypjEuJE}muLTNma!1Ha)A_ZAwr(Qn*I7MFn-uz2*(|OOUHW9R{8PZ3I?HPHWyUY{n z{pyufRE$p)7ZkKUzFoahj&JHI0t9S39e*Mm&SOLj1_t6$Gn5M>O=!*e9yezIUwC)5 zJpDjn5Gy+okTFnFFxq={8zG-oJVT-24bH8uW(DZ5j8k zd|8n4mWPK&qNuVsx)_K8(|~yjL^6Fk+Gn(cO&Mb;FjH~B=Y8VbkPPGy0Xd~Ym&?sP zs(Y>wJW?=bd9DF$NY(&b4MLU1TcJ&*0f7m+HKjT@A~Mqa_8TlTIT6~y8&*=bFi8OT zyXWz|)8~zAsK0jtyFjStxIi%ye4m%gK{E?nHh=4i=mO$2%0vZixy$JD*LAI-@lIl)By;; zw2+yE;3wzY-*X-YUy3jVZh~CspN|rS*1EUXYWMBf0kSx8$2Bx8p|v^7H36aD<>`=^ zgfXLAs2xFT=*GM%-{Kcc@2SuR)Kw$^=#>jGmb09`FPw|$R?u~+Z&Hy$gM|9^q&W6e zv?czOv?Fu_D|<~OgNc^pRD4aD$<&KGiWc-?2WsbEfZmx>kLm2t1eYs~F}K#8Y@EN1 z#3)Tk+wo%iZkiB*6*}v~ZJM^`N`=vkMG;xvVPt+MAq~+qBb>Hw*|Gi-_R$q6eRhKJ z%xcGXb}ns&csBZbhT_D%doVUjAtpuZ&+G3k&cyrUR;nHHOz;z>uiu+uC^n}o-2rDm z;7d0LA+KYKitj!omcaMU-$J(n(Q^AcH!Em|82iBX9d~XrJ!uIY0jiv6R5AeE^8Y8!$*TB6?ub*ATDP<2}A?@pW!O{0f7_=1Z z10bY6LQjK;IzS26IY1IZ3h9c)C!&*CVu>fIvX(*s`a*cWLH5WN{FKb!+`Tb9<;L_% zZY2~ofDOO45-h0Tk_*i&X7~CN;`ht> zwNJ=;;w}F`utHm=0otC9Dax>s;0B)!P6lalu>A2F7k6&+YL@I*H=YhxE zcA*kL%m^5(M8Q{gAP0vjt@9^pT?2Zlm3d%6c%1>FN5?tvU#=zduK}n1&Pj%}c-psK z8LH36s;CCCQ+E;_S7N#HerG(lAyNxXB|ECfCe`OwK(1)4H_zNf;EBlLk6 z`fQ-Qw{^}A(00&Do7ifKsW@Uus{vZM)WX6-S+*}pt2%*ImY#&5&ng%LWk`P=g7*xh zP(p?Vr+FaedDOSz^%86huplq9opjVsX6pDRl2t>Td$f84n zSdo3osx}+?x&hPC2^oP1sK>#{vi#952?$myE>5kthwD!bzJFlCtQA4p%zMc8he2w3 zR>SoS0C=}-+tz-VPs6|4@h1wx_}r5+8!9cb`ME zrOiU6zB=bRz3~7HC(ef>s8?B@Ly`uMb4Pki>4{6Ul=4t>-x1 zb%bsSyQ|QpW_NtjwR)%oj##&@qF_??3g_ao(bLAxnkRNGvP*PNO zXsNmnPyGh0mib{#PdHW`hkpM#Rvi~~BhNUmg3YXJYSP^`SBgknxDBiniosL@J~7Xj zWar3;YC^<2AkguseW~J6D`W)@azIE&Ie@qWR(zizeFTv?H>RL}2@(P~+K%da8YHQM zLR}gZ%ZZnHaX6fudyfb3wCjUuZUL0|2&8&I>&WPerIRebx5E@_IhI;oCy?j~t z!t1KsM|k_r-}8*DEmJqqlCM5c?s!uAOiD@ESf2l;acQ{d?KJiYo0 zziulK1f2HSq3L6(4+)y(nmDc)8d?W@wtV-|T|+joVjW|{vu?U`(@FEaGu?<@RD|DD zgy>{CxAOHx-hHi~0&^NcL(P-M!;(-x2-07GgwE2ZtAEdDq6P&wf~f2~$J!l`g-&nk zO_iDsDE0W|Mpx*;E)U5BAaOcv0xd~8-u=Fr7en`=d3f>>!s`NW*XPiM8MI9}_YXdu zh@zqwA7Hw#|6Wv7WH#Ln<(4)=TFT)|T@%b{KcACaB_wGc1mI4Yt3i&SW)lT56cD+e zKLtZl&gjJM9{N$<`RR4DwXB8$XAw-8qq-_WGZQ%dkxE(1j-fPFp6C_U@XDRrEBoY- zWfenOReL9VzX1o(-?#dILw5;p`G1vm=zo6lpAq=q zYhXn;cz*IV0KR|y{P~CAvv(9@EahI)0$=_8MEO{xaX0xlz-=MfW@sf>t6+s_O zCsW$(!xtx@nW+VD=?AzfH1xU0ew(irRQ7*Y0K~S_TKzV-39J2`mcY-{ z)m}txZhirzPc;Kdq5Hn)ZNQ!1g=|~kk3D+C;p3A%v8NoPAVe2KBb+6M1I;zMaZAAN z4cst+76Fq-fQ$wD!Ctan#76+R;khjq7rMKZpgxi^)rCn`ufQ7oO@Q?}9Rc;8k!;D* z=I5}|qoFFC3(8D7MaAwQj(|=dDzj752RXuqyQk}KoxICzG&U)^lMW3J;y!yuN5(du z?)OIw@r_Ck3`+YQBwTO*5y%~KZ>%k3B0YK!HuGjPy<|u-N6>nqp`#WgK;I=K2yK+? zka09C-upoaig$x4fAE>Nvb%E4Q_JWTU2DsOw>6KT!-akS>`SenIdc#$W`8`jN0pJ5kB)OCVR40x@_pn7q`VIOpduC7FjL$)JYFFhmMm^3D+=o+|bKt1_` z^ZzA54N0r()$!iP&~8bk=zIn*DdPEaL`WDI4&*~Bta729$Wun$0uq0+M9Qt9L(u(o z*_=zMP~jtzPR!SE)zIM{sg6AvY>QHUt!|rL?_+lU)cw=$_Rrom0(Jqi^u|GEXjy7# zD6#n&@vHpQGl|E6Ph^jOvWRji^tj(zp&NVDzU@(=eUB(bHeDaa zz{J+x|8SV`4TPg{T_aAQ%IWfGoUPZ@7F&K#3o zU#pnxEw)r&d>b})ScHAk#MG2exdwU_-0N&G%{Ue*vB+=scWtLr?l66!tHsx4K=!`GLTL&P~oNtySgYg8bz^K#5E8`a`DLPhGR^Y)V5b?8PYYpQX zNuo-#?FJhULGhzm3M~eVHzvd(KM>$e$CWI#YA=$f^YzWmod>+Cd8Mq8(Fi=;xXlBl zxP6;|sv=YonjyPG5VeaB@Pn3Vw@<^kwOu0A`bY`)Uddu|SX)m|tbV|1+e))HC0t_Q zp0A$9>=IAtLLLUM0?B`Pl_Mgc;9^D?(Wfovuk~xJna{1QP#K~7FpZ7jc1O`5r4o%_ zow2*uvBzkPl(eD)KsIQpHFvXF|3*+4O`^;{@~5lNlm^$8WiPtByQ{A*gxP?q-b#0# zM$@;2;ojO9Wg7=|)(XCvieGqY=DytFJN%J9lbjFi;XbYx27DGi2A9(+u)WMO5bj+2 z8mb}-zC6<{U@cr^{DH{&o2LTEQxELM<+)c!bMfc^iO;N@3_h|NUrF%lF2LY%Q?;% zDi5Ff%Y6fGR{7ZJq8Pd9hG;jKVvXZqimQN?cWqCL>2ake=1#ijIu)K)`ekK(Cfo0+!s{;Inrb|=~BK=!yaF1 zQl-k_i_b0-X1YmYk%oq70)B1cl7&}!O-)TSE}vvI)7Sl6bnMb-y{Ne&(Ls;ghD zR=V))u0AXcSF$Xof&Q5M#ACAc2vCn{bJsbHK^!ad$}x|14IgEti>x^{f+#O9wel6K zYm&p9NEGcZ#n=K41FlQaaxFgu>cMn65k@}O3agEra+sdFH?5EFQNI{1^)cq;?Q<^W zLqYx%4@4SQm#1afFD?-Z^YQ|i#&$M95^DVR&c}nZM3R-xm*TmVS7J&2pWVTOELoYh zq#`S}x{~I`E=Z8I3*EZmV-E720bArzUniCjAFi*gqyY%)>qDyWtcuFu4^cq)F*-M0%nkg&1-%9y1( zs&40|0LRT09^;_ zgX$`>7aD&2%fMMQ}x!JDKdWn1;ZU9FX|Ni$}vmzPTJ5pzm z2M;`a8JVLVus`m-dBtPF8mm|5D#sJo7V|A_zs(Q%GSZ{RoVzHJU_aF-_9E6Sh*v09 zggYWuGE44l{qp4{a@5qqT$=fQj8qzWxU0v~^w{pj_bmJYl3q+?Fa4bwjWfUkTuy|z zLB@z9m!7kds!+L*JpI($>lpy&$C*h3QFKlhesm5y*9>_@nT0=hG4zvGujf}zO+oc% zPGQ}SJ1M&|F72;QVfU*kv1dqpz7(-0MRARlp)f`W1}95d?I5-mnHIxgvXwEZk2|hV zK75fM(i*VIcV73QCsBxo+lb~WUaE1R1(9Y)75;$o`B|M6L*0tFT0VyC!C;#7PbkG- z9Kqk(xX$bbmuBO7ejdi?`-T3W7hxd&XFxVq!+-N)5S=XW#*lVfR3nwC_ChhLdhda4 z$PWh&zoh>0!{A`)Oiz*amfHIn8AlIp+|d47Se~w~=Bq5a1cLX1g&oqi8CCV(7=hkw zT(98h-M9ipw*aVaDraiF0PUeg~!^vOM!ats))c1}4WPQb>c znD0cZJWLY7oz^Y$7KvJb&~MKKai`1BU)z7bQIwRa*Oz*C3sb~}*|1V(@51K<%*(|yOX_r9Vo4}_O&h&sQmnUY(_r45I^p>Jd*nF&q=bk&|aN;pVy_5 zTqK7!VPSJi(vL94ny6p{x4(0CHaE9zR#p0VLAqSY?_lj$n$WSoI$2RZ#yK~L%|L#( z-M)8fYHDrDh|76Zo2i=+3k6moce>%<_Uetkfq?;Xbj{TmkuUnCygr8WV`(L+;~uUa ze|1J9bQWBfIQfn=zqX8-FK-W*3GQo4*N*m}cS-^RIP3M^2nZUte#d$Q4u1gB0LDaW zmDMrpx05@y?aw;|KspFaZ0rA~s z-k33IpF_h#&&KrHULu4=gu(XIwd!R#YTMTbc6?7wxk2HZS#>o%-po^x&UP-395KX*i6) zliI&{Q%+7UD=$x%-~{@(hUUo{<1u?Awl^q3L%NDBZTPBWi>ZkT2}J9iUgtJKINsLU z7w^(vW&96bz(xhYTH{|Vm|rFV9fqnefE-)lCzd!Lb9uS_DnQ2VSBeOMMZUXomX#&QuDl7qZGAu=~Ze`YDPwe zsw&nC$Y@_Sd3tuYwMPKfPql2gJJ+n_IIG_q@4z%avtK}8+A^`Hr)O!RTb4f_Kk&33 z0KYv&Co&V1C3cA=zAPk1(nCVZe*X3QkCz`6iWHd0%d1=aSw9WL9DuAoM&8q56JP%2 zt5@B;RLc%Slu!pXcNy=2wes~fMq80%jI8@hNdL=980G5#M~t3|=Z#F4VD*5$cgQ>f zhn%nN$0s^*$`_Kq|Ay=SxYPF6g6$@Nj(I=1d-_t(Rq>W-`L1*@dT1G|)unCwnCuuT zEbHbhl3pE#UMYrU&C~(Qn)}y>zvjQIb^_fD2vh6cy|r>bM@>xVAzwK*G7@e(GQbx3 z1VBRBg+G6Vc0S(=wIrA+)e;}Ksp(?t!N9XIr|s`#k}(br^#z%U_1T6w1rRbtMDu-0 z`y*V&|JzBiec$ge^bJs5c)UA#3&&+nPB0v}J2_Sy-{mcMA3wj>)b#-+e9H*HQ z8|%h}vziN?Re-L#oO0zd1c1PS_jsFtj3`w@EKHs0FID$aPe{7lyi#1OKlcQH?_4L zA%KpXn$krRpa=z(0c&@2bNQ~0bclatGxVujgUyN@Jb{2O4s24UU6^n;8Hdfz&Mp86 zDOjo{v$bz*XG>3*6dpsj=9v~ly+yLO7(<2aNpiFDY*uIIdWcKu5oR2m8fUu0(o#}V zB;QGy7nEg*8yXrySbIv0`toy1H`TE-w+Uh!Cw9h_%WhY%l3n^^;p<>!t~NsCuzS0E z@Brk?gMxxQraL9ouuR1fw@urQQ9<3Mz_XXBDb-?X8h$~aY!k3LnQ2o$LhgMpee=zM zt%*LbG_7(?ijIQ>i-4M+N3+^^JT&jxfxTb(D2^Dg*S^%z%RNKJc4h#6xtB+5^ zMN>gfXav2>z^v~`M_jh7-CQ|HN7zn0KL`hi$4drAi@@JhNeAJ*vLk50ecnQgd%=c) zAY3GLFX`;%vQ>C>;uYUk|Gc_rmxw0lB%T)k$X>5X!E@IIRDE2LCDk(T1ln$+nSbrL ziHOu|$-n|Gh7P9RPbzG>Zc%d2lVA-*-2x@VPg1+L#GDN9pPG`-%+HsCgEFA*M^4G2 zDtZ&|D(+x~`*<_%=hXq`@D#KBP6-~k(3jf{+Im{!h1vDXpOT@J9c z(kd>~jLO7QxQlvY9;J^rNl(8Fb(ABJK~>;ByvWy{(Swyd3=de3~eHmHIaPpynnq`?!<}z}54s z(Z0M>0Urlh(5p;mQ_FJ4E*J4$@0|JAo*sT@QWhMot(o3*%~GLnQLC$}%S(%@q&pY3 zJR-}Lz=;sqm4Nf=?HSL@>wfpvIm!o)u-@XFyqsQ4Jh1R&xX`SqLhJ_MVbkwrXJs`F zR7h7Q;0z4fcB*Vny%>5K9xUfZUVW(Ez|l(Z$hIcld5VmjfX5>UqG!;2~cA(8p`JC)hK0Rj$L_>mwx(R z7NvT!|CxGLo>1jP-A7p0eZ@!pw2?gorWUr%k&ou3*6p9j0(Fnh_38|Z8W#uPI`d4E zpwtP64|tS@S=~m-VZv%t&qK4|K7lQkT41A}4k~M$g?`;KlAUFVc`5POTzBfFO><=l z?AG*#o-Sn?$xc7<=DTCixPgWzJsiZRF{pkiL=d{4`a@zUx6XRaI+z;x!w84BKPI2u$OxC0gI==* zVz)<|qAg*dlVGSy=09w8pUNV&re0!!2~LM*dnDT}rN~{|y=Tu#-Cxxj<=NML zA1ba~)>B#;f$ZXNH`&>AtjTizD-UE9Nwx_D7odOEkhfi$09Qc?fS&ccw(^hFeSF#$ zn`T2(2HXlC^-W6k_Q{vXsRHmJYe+@LDBuI4)RwP9(i=IEVF>O8pG7a}+>fDlP8~ z#@RWp+x*!6w+vTs=DJHg9UUAU8C4JXY&ykd`4k-kFz)V&aL^)7toUvj$4>Fdo}%1p zcASjwJuu8`1S)8loCa5{pZ?a7w%Q4}^haxxWdqBE-sp%3>z;Od@KlXZw}z zP`U@D$Dg(7V<#1i&Egd-{py|yOYeq^e6TeX4TY4WV5K6d(xZurL&-$7aZfwSj5}wQ zsY}`Z+gvc`q4y-gz%GFx=h!)x1red4VI;V3(Kt@>*#|NYoC1;9;&XTDABcC3>8TWXY`6C<;-r~lPjIyiubIPB$Jb2$$M{?1aOkr;Ao zvx;<_g9Dra!xDlyudhU?p5B;6)3B;9pNrJmy{j*#Ay{&&MOg{cQ2ZAE} zlV&*Z$N$MMikiz~@}s|J0Lp77c~Fp0gS}_;J>4-cn8}9=TJLmrP_hN;xA(=-{%_a!Ja|9X{ z_0qrH-+>xDko&haL0$c)it#TfGk(8B|37$)FeV^9MPyx1BE7>Il6^*@Jt;gCfd&H(QGJQ{HS}fF{PC2Hd~0Cp zM6d={RHMu>|G=Q)e7mfn?^I0o+M>eNdrdZ(<4ZACkA6heDt;fT{XaJ(X4NZjrz>-F z`c7<9Y|ia}qh$FFPpQ8!uG^GE^SOTpHMlq9`xhUcS?|^#rBOJhXRF-iidoXltJ#t( zY}Wc!$Wk8Mfl}PG=Z}ry4~@qPn`NtR?onITF;wTIs~Av<&VoSowrd+xRkc&bduYmk zwv6@5k6)-gym5?=aokHg{^Rd&Y#cQ!nL?vV^R0<~8OJTTRP4_`plbL2b<;o4*mauS zWUJ@{fzivoZ2s}LD-y`xcl`BtTSk&uc(912)Gvd{E+vdp2T@h0{@Pr@-O_l*!ySR! zjmvJk&*6Mx4#4yMHE`8;M`L48n3P)VI1}8@7`%pj?A-Td7X10JyU{N{0MZ~aOKY^o z?=1WYsrbSb{CU^Yt}Q=I6h6}K{PhP^=nsF*qK?;`Lw)SB=kF+G=aqT~82)0;^d4pZunU_JIFm;G-+*(DvSTzW5mHKTXlZ%pT}@T z(TkCo{_rxosb7n;UaYbz2G?LOAXlK8e#Bgc^@rTbO!Qp6#(H&n%qdTwIovJd%2g3o zrL8Ov%0lhCTjRRl-r2dIWq7c^EZVmH@!~VVtBD5s!UN84tX`d=Osv(-j;`>z;7)sUyl%7m zw^uR~u(DQD^Qub|vU>Ybt~yt~Zyq;?k5jR04MqV8L{3B1w*4&<24X@h##IeQH|$`Y z4T)T;XD2-u^<{R~^!bg)a}u%hFGhXg-?<-Ap#py`pinu5;29+nr~_@ZacCb@sBC z>@+)~);idD*Qyh?&3j{t%k6E0_-3Pb`FSCh(fDolQ4RKJ`3Z{~B{av)d{YUS^R>Q{^7NS3ePo_bb9hO^f9UH7rc7#fRYs3v`s-E)&SFD&A>dMb={(Mp1#M9y_WLFn?mi;=dP{5swrx9GjmocqwQlR(Vq?-Y_uE>mypRezj63uSB$D*@ zMWc6Z8b6#=a(G`hd1P`=2Qv1lmm=uJocR9JWxz zvDhU&M%n(Z-hRs$c=l@l<^;q3?o;aX3V1@BZK#U|KU&7am1gF?k|m&EdrN&~B`MRd zjhs8#A(@&O-4qL@o9ktHZAxNw)6|#k--B#e8CY3DL@1Y^a8$_?I=g~|$S&HJB* z0swv=%VX7KR>w9$DG7z5W_~}ghTGqL=3#V7=rvURP(ctSiCA{8_x0wh%ZBUiCv zgqDqs+fm}9VI`E02e~s!WoyQD`Sqzhq@?C29dgD@4?{Sf6SKmDiZGTiMd;zpV(3Jl zd*;|l_BCWgy=ks4TG~$x3@hX;SH{$gS3L~%;*^xRw4{oIIQG&fUW}S-5~tPN_?-`K z;M;1}WPgrnO^EQzJvojtxcYhKwdIG2=Cadinr@S zLY4cC6>ZyDms*fsc@WPY5)oQl?ultsc31xal}Nn2;oZ6VAEVP`i@9Bvv+QvqLpsUN zQBSQT?fR(# zUk?}41$Cb*^k@6jPNMJiG=2u0nj>N4{iP(OK{s=c^@#X4^dcJjb2Ty_gpmc%LB;Cz zeraPyGGGrk!)S@tE#YMQk|d&&oWCT9DvhRH1^UGdUj3Bkr{!jwhPQ4vi}Ir1h!yQ# zn8|nQC(EK2m!3@dvnj%#!d$Q?_~>zGHNbUa=dP8}Eu&~^Z%*i9V@Y0|u|Bu1H7FOY zixpBU@!|;whm#$D z4--;L=1k{E#YMuuj&&Rn_Xxw9OBcBIS9hT-B zz-A5C-VjCKAm%kRC+Kcg4T1&sX%rJ1BfcwmEQfnfGfLGkCSBif^zBLxv)8Ns&x5m; zN#30gb@p3+UdI8KI84053k5vOJi*`iqt^$Hi{FS$~>DA@Q zN*dhbHov+BEoNS71&sL9sC=pbsm8V^f-KNzaFiVRg<+9#KA=*#zhQeRQFM}wxv|ii znIq%b+VV{B)PV<^mz%+okxJIP*QovaV^6@589a-Vv271zdbUPO5tS!S`LizZ*^TFC znEAhN-L9cpB&909OByU87*!sl%JCS0jc5R9|}&M(%g( zi+J>oKWPj~C}WT{*fnP@HN4-)A{IAm?-v#RL(#(Ig$;-6oa z6PhbYR;T9%!dqLVCYj~e*jGG`j)!N4+~uxF{MND7h^?Csi{uuooGglOGpJ%6? zL_V$Zni2||o(J?-dUhmX{(Q5}#!>ujJmoRI@{i6fW<1RlLyt+Cg^R|JFnNrp%}Mca zW01cz`b|>FwOcu$F?o%{9(YN?)Aq^2+^Uv%Xd>Irms{)NEbWkFYRX*GyuQr%__SO?PO=B?GjAT88r@EKTJlYZ?yusK;(D-k zOI8aE7%Tc_3EpN{WM-8#!L=6ahj~n!^i0N&OPj^%7WIaUVoLA&1#NeUYofvFHW08JbBXiMX)ZnT+Z?j( zUH7vm6WraKV|F24xfC}%+?}SWe*|60P7Rj1=fjOx%s(_9uXGUk7gEM5B-WZ6l&MKd z=CMPT2Vo1{viz0S6WdpY$$ilM&fXN+q72G_o5d<~E|J1+P_U&AjP2gDMO?}yj;Fn~ zlw0|mbY5djW~eqnb-A&I*&D8)P;A}37^1|9IE5x0(Ki(K;Kn%=k2hO3Q4KNba+!&$ z%CxmEZ(BCge6lz5l9qNE0>h!KHho}r;g@?^W13vH?qpZ-rF_cNGc)YR$?o1hLMzVk z1Ln)kq)z_*^sSz@&$yNBx_DLR^Li>aI#R0M*P1ohf9~G$2#Okn!pMHgdjD7ETciB^ zy5~>SkzHQNh}38*$jn(x(eLk^jHkf*UFl_6MBL}`8+{5Vtb+pmGPhE#tpYnKGGa1& z*ERt;PX2L_Zo=7A!X3(F)M8#nT|HdyzVNfbVjMQwdb~Nqe4(vk*w^2*an)%y`#_a zE>neVx=EQzIi2le6uPer1$(PIfQa{iW1r|94;kohBkRR?lPIpv*!${s>#^HsxtojC zW9vgq8JGC=njWWc3|NfY`5Qe8>yW?*)LPgG2$5n8e7P)vHAbT^({Iqs>Yy6S~pqR}@RT9PDJK z8&`fp`BZ+;Rp@U&z6quHhU!1XI0tKW`_$a#sF5)`M{h^f!IjBk?&{-E?oe2{qwokcrh*xD%@z1N<@4C2bq|N5D zM@hV>2_#$MaRwlO-!o^DNzSM(_dO+8<8Q`1galbH(DG*Q*7g_YAhS}_zVnikQ<6PA8j>k| zAe_X049^?*K-s-h@gOQe#%=BSw!8`KpEax)c+`00QC}C_(-agG{0Sauf6JGvJhHe& z*Rv=guOX%IurFVDd3m#P`wm#6CJrD2_yLd_$f&wri8k{mK;lS1DHkuLh;7}uk7g%; zYEgoSdg5UjeWhsQXMqysdb7Io?*{@Ol#Ug&g-v`@Z$Je$Lpt(|SUV<9-_x_`0CIbN zwWgNQ17aS#ewr_kQpPywB`GloYc+5A_f3I%5bd84>*o$uY%Hg2%Ov5Ir{3nr zei=NX3u!-m!0^wPJVey1;k%KPshFf$;eqAlbw7ZOD84h6y*MsCkad1DQ{moC?ivTn zop?N#3aB;EgxVpG@*uz{Y=2+Dq8)}Ml&mSGYIkpawV(gjA1}AcGuA}7EE}+2wLAO6 zs8XoG7FaTaI~o&MfYx$E#?0fBubpmVrn-;K#ci|F+Z031JqFtSS05M&FD#a4kdL~L zt4X<{6n}zFe;l*+BMOyZRFS2e*%*HW)>}nq_xs$D%vR^;qd+*i67SkW=8%jo@W2>_wr@1e@bO#*}aJS8x<}(W%|F}eD?6~wN}bT1xyDEQQHtju#krJb8E!+lPOMWm zYeWgF`TYWuswlcOI-g3&l(&Y=pHp9i&ztiZba4kQyL(qMI04>DCSO+ZnHj52CJX#Eiz zX1xcF|AJE7hJc-4_N|2w0eon&@$st&;4+S~um?VV8&J)G)e{Ze^^d1dpGKYxrT7(= z_~Iy@`wB9gCn51#b-U0`-=NZuL9qcy&j)x5D)gm!nb&BJ0@su|WB;#BsQnjVF)KDs z6?cwHSaJ$tCg43)J3j+Pis--GkVSJ4S3)WFA-D1qtYzvr<(skNE2M=W*bv95r0$Ox zN4cIymdh_-?!_&?QIe3Sd5Ans`5okG_}A%n{bV6jD6+No-&Vj6OT@@}haP}81Zv9L z&BY^62)7^l1}O|2rX%A4h}wHAHbxnM#`s?YA8cy{T+gNm-1-tdO($B1hrxmiD2N_30GBXNdjJ1JbrVCwVo>M7{pR=jAA5VGOx33E|JlU@ zJVXp+^?^EI$}T#7zJ400IAUh#+XO4ne;l4sdMb10gFW1rf!kseL23)v0rS!Kd-eX$ z&Q-@qFnnN70`|=QFrEeG51@#R@v`}IKA+pE09^m$1oU^qW1mvs0TT5e?&-vAaR4a? zj-@vw9{_G|*;H_`>C4;8=J`85^v*Q+cjU{9i_Qi>8$L)~1qL{9Ipkbmp;8Kr3vL#M zK0#2#w1EPB?e@PGz=4w&8|Ik3KGP#h3kSo|P&APT_+ZNEQI&eNe^Ty2yz@1~jiJ3FT+SAg{&I)y6Y6xcp#^N5YHNflf z{^x*_0cih;3Mlb`0A#ol$Z#0gX&D;Z?)k5N@!e;WnfCJ@2Kn04)z4*}Q$iB}PsY4K literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-target-review-16aafa28be77.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-target-review-16aafa28be77.png new file mode 100644 index 0000000000000000000000000000000000000000..3cc38b636f79fc8cf508a06e028f9fa374066e09 GIT binary patch literal 110711 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%0D?lL3^O`ARr-O0NeEMr_u%N3z!<&&AC!l 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-chip-03edb94bcf73.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-chip-03edb94bcf73.png new file mode 100644 index 0000000000000000000000000000000000000000..2ee28dbf00298daf89c849cf9f09070b77dcf811 GIT binary patch literal 18618 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-ready-6b8ef886ea40.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-5646-ready-6b8ef886ea40.png new file mode 100644 index 0000000000000000000000000000000000000000..6313fb56ecd659e3cf44f4c9d8bfa6aea745f52d GIT binary patch literal 6700 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-chip-0aa751bf86a3.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-chip-0aa751bf86a3.png new file mode 100644 index 0000000000000000000000000000000000000000..382bd90e6472b92956493365949ce626a7330c80 GIT binary patch literal 17611 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 XKO%q5dI-9=An3V_vUI7WN!b4Y2`I>u literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-ready-3b64e4b0b84c.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-8c9e-ready-3b64e4b0b84c.png new file mode 100644 index 0000000000000000000000000000000000000000..f94777a46aec48165f50167c3b8b97590c49b373 GIT binary patch literal 7241 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 U3m!hj$MTjsT-3Irj7mk6QgsPK0@ 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-da86-ready-49595991ba1f.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-identity-da86-ready-49595991ba1f.png new file mode 100644 index 0000000000000000000000000000000000000000..da24eb69503bf70e90f623a7fd491a4141902d9c GIT binary patch literal 7049 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%0D*@{^P+5B0DN$jm*D2{& z6@|(1A1`l5oM9SyYf(@$_=f!d4D{LGNC%hRcZti8xxd8Sg2pH~$CiVBmsG8m9bz}3nKsdgo58Uxm4CdH*S6Bx> zVX(F0I8SdkA6s1&R{X4abl-X-0jsdD(6H3Cmb>+yd=DRZ?Mfigd)o6+#zAOfay=ZO zFIbs$v$kX_n24bhZ|U)Y@TMH>6(^a|ZRH#kabfXf{NKBa`f@ozZ`N_oUfxx(&)kYf2JP^vB@VNCklJSZZljJW8UDKPJrp& zvoO~Xft(wCJOUd7&zZtSc;!T>%nV@R?#CUy{S4M2eAO#oKNzXW(52@!Njm;d)kAQiAgq|uX2WKkaWfH-`dnXIplD*$ zMF@L$`Xz*SM*!JCH{TQ ze)CTc>IUic@e!mXg0S*m3ekTQq5r2C{m1^_ke+g#u4%z7(W|t5RO1ARjY$2uwo1A3 H+YkQ-uu2(y literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-connected-identity-58f65b64a966.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-connected-identity-58f65b64a966.png new file mode 100644 index 0000000000000000000000000000000000000000..86156e96b5b21b8931d2a2394417725eb60c7605 GIT binary patch literal 69318 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

wNaqdWw-5exBdq;P@SMI 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

?8x}9W8G@k7s(3i(Q^> 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 literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-five-board-scan-a89dedab7efc.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-five-board-scan-a89dedab7efc.png new file mode 100644 index 0000000000000000000000000000000000000000..1cd8bcc0ebfbba15f3e35a9c013377bbd75660ae GIT binary patch literal 64945 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 gihzG;u1FDCivH~;_u literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-scan-results-3558cadbe501.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-setup-scan-results-3558cadbe501.png new file mode 100644 index 0000000000000000000000000000000000000000..b7b44ba8cb1398e2f95f81e06559521cadf2e693 GIT binary patch literal 43298 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 I|MA!V1A%yj)&Kwi literal 0 HcmV?d00001 diff --git a/tools/web/public/reference/firmware-v0.6.1/protocol.md b/tools/web/public/reference/firmware-v0.6.1/protocol.md new file mode 100644 index 00000000..be63fc24 --- /dev/null +++ b/tools/web/public/reference/firmware-v0.6.1/protocol.md @@ -0,0 +1,835 @@ +# PBLE/1 — PyBLE BLE Wire Protocol + +Status: **§2–§10 FROZEN for v1.0 (complete)** · Version: 1 · Last updated: 2026-09-03 + +> PBLE/1 is a **clean-room, original** protocol authored for PyBLE. It reuses no closed-source wire format, opcodes, or UUIDs. It carries PyBLE's app↔board messages over a BLE GATT service. +> +> This document is a working draft. Sections are frozen one at a time before the code that depends on them is written (see [`AGENTS.md`](../../AGENTS.md)). + +The 2026-07-29 portability amendment clarifies that the existing `chip` and +`device_id` values are port-defined metadata and that clients accept unknown +targets. It changes no PBLE/1 key, payload shape, opcode, status, UUID, or other +wire byte. + +The 2026-08-14 RUN-admission amendment makes the existing response-before-run +ordering fail closed under local TX pressure. It changes no PBLE/1 byte. + +The 2026-08-15 default-MTU delivery amendment makes response-bearing writes +acknowledged and makes incomplete-fragment restart semantics explicit. It also +binds the ESP reference agent's bounded, session-scoped generic-response +delivery. It changes no PBLE/1 byte. + +The 2026-08-15 runner-event amendment binds each newly created `RUN_STATE` and +`CONSOLE_DATA` event to the live connection session at that instant. It changes +no PBLE/1 byte. + +The 2026-09-02 v0.6.1 hardening amendment freezes HELLO request syntax and +session state, inbound-validation precedence, bounded hostile-fragment +handling, fresh RUN namespaces and stdin boundaries, upload admission and +scratch recovery, and label-byte validation. It adds no opcode, status, +capability key, payload field, UUID, or other PBLE/1 wire byte. + +**Freeze ledger (per-section):** + +| Section | Freeze status | Freeze act | +|---|---|---| +| §2 BLE transport (GATT) — Service/RX/TX/INFO UUID base, advertising, MTU | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; default-MTU delivery · 2026-08-15; negotiation ordering · 2026-09-02 · `[docs]` | +| §3 Framing — §3.1 message frame, §3.2 fragmentation | **FROZEN for v1.0 (amended)** | G0 · 2026-07-01; restart/delivery semantics · 2026-08-15; inbound bounds/precedence · 2026-09-02 · `[docs]` | +| §4 Opcodes — the v1.0 opcode set + numbers | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` (closes OI-4) | +| §8 Status / error codes — the 1-byte status set + numbers | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | +| §6 Run/Stop/Console — RUN{file,source}, RUN_STATE, EBUSY, STOP, SOFT_REBOOT, CONSOLE_DATA/INPUT | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; transactional RUN admission · 2026-08-14; default-MTU STOP/SOFT_REBOOT admission and per-event session binding · 2026-08-15; namespace/stdin isolation · 2026-09-02 · `[docs]` (RUN-file at S3; STOP / console / RUN-source at S4) | +| §7 HELLO & capabilities — caps field set, HELLO-first, INFO==DEVICE_INFO, label max = 24 B (label half of OI-6) | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; request/session syntax and label-byte validation · 2026-09-02 · `[docs]` | +| §9 Versioning policy — accept only `VER 0x01`, refuse unsatisfiable, additive caps | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | +| §5 File transfer — read + windowed upload + workspace jail | **FROZEN for v1.0 (amended)** | G1 · 2026-07-01; scratch/mutation/admission hardening · 2026-09-02 · `[docs]` | +| §10 Security — pairing/encryption baseline (non-gating), connected-client-trust, single active writer, no PII/MAC-gating/telemetry | **FROZEN for v1.0** | G1 · 2026-07-01 · `[docs]` | + +The §2 GATT UUID base (`7079626c-…`), the §3 frame + fragmentation bytes, the §4 opcode set + numbers, and the §8 status set + numbers are now **stable inputs** to the firmware M1 stories (F-01, F-02) and the app `pble` client. The §4/§8 freeze **closes OI-4**: opcode and status numbers no longer change within v1.0. No wire bytes changed at this freeze — it flips status only. **Note:** freezing §4 fixes the *opcode set and its numbers*; the label / identify **payload encodings** (`SET_LABEL` max byte-length, `SET_IDENTIFY_LED` GPIO+active-level encoding, `IDENTIFY` blink-duration bound) remain **OI-6** — owned by §4/§7 and frozen before their S3/S4 stories (F-22, F-23), not at this freeze. + +## 1. Goals + +- Run, stop, and stream the console of a MicroPython program over BLE. +- Transfer files reliably (with verification and resume) over a lossy, MTU-bounded link. +- Stay small enough for constrained MicroPython + BLE targets, with ESP32-C3 + as the initial v1 footprint floor, and simple enough to re-implement. +- Be **versioned and capability-negotiated** from day one. + +## 2. BLE transport (GATT) + +> **FROZEN for v1.0 (G0 · 2026-07-01 · `[docs]`).** The UUID base and characteristic roles/properties, advertising rule, and MTU are stable; amend only via a `[docs]` commit before dependent code. + +PyBLE defines one primary GATT service with a PyBLE-owned 128-bit UUID base. The base `7079626c-…` encodes ASCII `pybl`. + +| Role | UUID | Properties | +|---|---|---| +| **Service** | `7079626c-1ab1-4d50-9e3a-000000000001` | — | +| **RX** (app → board) | `7079626c-1ab1-4d50-9e3a-000000000002` | Write, Write-Without-Response | +| **TX** (board → app) | `7079626c-1ab1-4d50-9e3a-000000000003` | Notify | +| **INFO** (board → app) | `7079626c-1ab1-4d50-9e3a-000000000004` | Read | + +- **Advertising:** the board advertises the Service UUID and a device name. By + default the name is `PyBLE-XXXX`, where `XXXX` is a stable, non-personal, + locally derived device suffix in uppercase hex (e.g. `PyBLE-9F3A`), so a + client can recognize a board. The ESP32 reference agent derives it from the + last two bytes of the BLE MAC; another conforming port MAY use an equivalent + stable platform identifier. If a **device label** has been set (§4 + `SET_LABEL`, persisted on the board), the label **replaces** the default name + in the advertisement so it is visible in the scan list before connecting. + The app scans **filtered to the Service UUID** — never a raw device list. +- **MTU:** the app requests MTU **247**; the usable per-packet payload is `MTU − 3` (ATT header) minus the 1-byte fragmentation header. +- **RX write acknowledgement:** every fragment of a `CMD` for which the client + awaits an `RSP` uses the RX characteristic's **Write** operation (an + acknowledged GATT write). A caller that explicitly uses the fire-and-forget + API MAY use **Write-Without-Response**; this includes commands for which + PBLE/1 defines no `RSP` and the existing `SOFT_REBOOT` fire-then-disconnect + client path. One absolute command deadline begins before the first write and + covers all acknowledged fragment writes plus the matching response wait; + fragment progress MUST NOT restart or extend it. +- **INFO characteristic:** a read returns the same payload as a `DEVICE_INFO` response (chip, MicroPython version, free memory, `fs_root`, MTU, the stable `device_id`, and the `label`), so a client can identify a board before subscribing. + +GATT discovery, enabling the TX characteristic's CCCD, an INFO read, and an +optional MTU exchange are transport setup, not PBLE/1 message exchanges. A +client MUST enable TX notifications before writing its first PBLE/1 frame so +the board can deliver the HELLO response. The first complete PBLE/1 `CMD` in +each connection/VM epoch is HELLO; reading INFO neither negotiates nor changes +that ordering. + +## 3. Framing + +> **FROZEN for v1.0 (G0 · 2026-07-01 · `[docs]`).** The §3.1 message frame and §3.2 fragmentation bytes are stable; amend only via a `[docs]` commit before dependent code. (`OPCODE` *values* are §4-owned and remain DRAFT; the frame *structure* is frozen.) + +### 3.1 Message (after reassembly) + +``` ++------+------+--------+------+----------+-------------------+----------+ +| VER | TYPE | OPCODE | ID | LEN | PAYLOAD | CRC32 | +| 1 B | 1 B | 1 B | 1 B | 2 B LE | LEN bytes | 4 B LE | ++------+------+--------+------+----------+-------------------+----------+ +``` + +- `VER` = `0x01`. +- `TYPE` = `CMD (0x01)` | `RSP (0x02)` | `EVT (0x03)`. +- `OPCODE` — see §4. +- `ID` — request id chosen by the app (1–255); the board echoes it in the matching `RSP`. `EVT` uses `ID = 0`. +- `LEN` — payload length, little-endian `uint16` (≤ 65535). +- `CRC32` — IEEE CRC-32 over `VER…PAYLOAD` (the header + payload, excluding the CRC itself), little-endian. + +**Host qualification-tool clarification (2026-09-06).** The nonzero `CMD` +request-ID rule also applies to no-response commands such as `FILE_PUT_DATA`. +Absence of a `RSP` does not make its ID irrelevant. Valid transfer benches +MUST allocate every data submission, including a Go-Back-N resend, from the +same 1–255 wrapping allocator as their transfer-control commands. They MUST +NOT let an unbounded counter truncate to zero at wire encoding. This changes +no wire field, response behavior, chunk, window, ACK watermark, retransmit +policy, or measurement boundary. Raw malformed-ID construction remains +available solely for explicit negative protocol tests, not valid transfers. + +For a response-bearing command, the connection-local correlation key is the +originating `{OPCODE, ID}` pair and the matching frame's `TYPE` is `RSP`. A +pending command MUST complete only from a frame with all three exact values. +A different-opcode `RSP` that happens to carry the same reused `ID`, and a +non-`RSP` frame that happens to carry a nonzero `ID`, are unrelated frames: +neither may complete the command, consume or replace its matching response, or +reset its absolute deadline. This is true in either arrival order, including +when the unrelated frame is a delayed response for an earlier use of that ID. +Response arrival at or before the command's §2 absolute deadline is decisive; +later task scheduling may process that already-arrived response, but a response +arriving after the deadline cannot complete the command. + +### 3.2 Fragmentation (over GATT) + +A message larger than one packet is split across consecutive RX writes (or TX notifications). Each packet is: + +``` ++----------+-------------------------------+ +| FRAG_HDR | FRAGMENT DATA | +| 1 B | up to (MTU−4) bytes | ++----------+-------------------------------+ +``` + +`FRAG_HDR` bits: `bit7 = FIRST`, `bit6 = LAST`, `bits5..0 = index mod 64`. The receiver concatenates `FRAGMENT DATA` from the `FIRST` packet through the `LAST` packet (indices increasing mod 64) to reconstruct the §3.1 message, then validates the CRC. Receipt of `FIRST` always abandons any incomplete fragment run and starts a new one; a non-`FIRST` packet with no active run or with the wrong next index is dropped. This restart rule lets a sender restart an identical whole frame after its logical message ownership was preempted, without completing a stale prefix; ordinary transient pressure alone retries only the unaccepted fragment. A frame whose CRC fails is dropped and answered with `EVT ERROR(ECRC)` referencing the opcode if known. + +**Inbound hardening (amended 2026-09-02).** Each `FIRST` starts one absolute +**5000 ms** receive-reassembly deadline. Fragment progress never extends it. +The receiver checks that deadline before accepting every continuation; an +expired continuation discards the whole run. A new `FIRST` is always permitted +to abandon and replace an incomplete or expired run. Reassembly storage remains +bounded by the port's advertised/defined maximum message size; an oversize run +is discarded as one run, and its trailing non-`FIRST` fragments are suppressed +rather than counted repeatedly. + +The agent validates one completed inbound frame in this order: exact structural +length, CRC, direction/request ID, `VER`, HELLO/session state, then opcode and +handler payload. CRC failure is always the existing `EVT` with the offending +opcode where safely available, `ID=0`, and payload `ECRC`; it is never a `RSP`. +A structurally invalid frame receives `RSP{EBADREQ}` only when the complete +six-byte header is safely present and identifies `VER=1`, `TYPE=CMD`, and a +nonzero request ID; +otherwise it is silently dropped. After structural and CRC validation, an +inbound frame whose `TYPE` is not `CMD` or whose request ID is zero is silently +dropped, preventing response loops. A different `VER` in an otherwise valid +`CMD` receives `RSP{EBADREQ}`. An unknown opcode after successful negotiation +receives `RSP{EUNSUPPORTED}`. + +Each exact connection/VM epoch has a monotonic malformed-input budget of +**8 protocol violations**. The counted units are one per discarded bad +fragment run/frame or rejected command: expired continuation, fragment gap or +wrong index, oversize run, structural/CRC/`VER`/direction/ID failure, malformed +HELLO syntax, and a non-HELLO command before negotiation. A valid but +unsupported HELLO version offer, an unsupported opcode after negotiation, and +handler-level payload/status errors do not consume this budget. On the eighth +violation the agent clears RX state and terminates that exact session without +attempting another error frame. Only disconnect or VM-epoch rotation clears +the counter. + +The ESP reference agent lifecycle-gates the entire RX write callback, not only +complete-message dispatch. Before reading or copying any fragment byte, the +host callback makes one non-blocking lifecycle-activity entry. A closed or +not-ready refusal atomically clears the incomplete reassembly run and drops the +fragment. Admission and this refusal reset are one authoritative lifecycle-lock +transaction: the refusal reset occurs before releasing that lock and only +while the refused callback's VM epoch is still the same closed epoch. It cannot +pause after refusal, allow a new VM to become ready and accept a fresh `FIRST`, +then clear that fresh run. A successful entry remains counted through index validation, copy, +`LAST` completion, and any resulting CMD dispatch, then leaves once. VM reset +first closes/invalidates admission and drains these callbacks; only afterward, +under the same synchronization, may it clear the RX buffer/index/run state. +Thus a callback paused after copying `FIRST` cannot resume into recycled state, +and a `FIRST` dropped while closed cannot be completed by a `LAST` received +after the next VM becomes ready. This adds no wire byte and never blocks the +NimBLE host callback. + +**ESP reference-agent generic-response delivery (amended 2026-08-15).** The +largest ordinary response the reference dispatcher accepts is 491 encoded +bytes (6-byte header + 481-byte payload including status + 4-byte CRC), or 26 +fragments at ATT MTU 23. Before an ordinary synchronous response-bearing +handler can make a side effect, the agent atomically reserves fixed, bounded +capacity for that entire encoded response. If capacity is unavailable, the +handler is not invoked, no unreserved response is attempted, and the +originating connection is terminated; observable link loss is the bounded +refusal outcome rather than a silent live-session drop. Deferred filesystem +commands reserve the same capacity before their bounded host-to-worker enqueue. +If that enqueue is full, the dispatcher invokes no filesystem operation and +publishes `RSP{EBUSY}` through the same reserved slot; it does not discard the +reservation or attempt an unreserved fallback response. The reservation is a +ticket bound to a slot incarnation, the originating connection session +(including a generation that cannot be confused by numeric connection-handle +reuse), and the current MicroPython VM epoch. The native ticket stores that VM +epoch; reserve captures it, and every match, publish, completion, and cancel +operation compares it rather than consulting only the current global epoch. A deferred worker builds its +result in private scratch and revalidates the whole ticket before and after +each VFS operation and before copying into that slot until response completion. +`FILE_GET_BEGIN` then waits for that response to complete and recycle its slot +before streaming dependent data. From that cut onward, each VFS read and event +revalidates the queue item's immutable `{handle, connection generation, VM +epoch}` token; it MUST NOT inspect or touch the recycled response slot. + +When a fully encoded response enters the ready FIFO, one absolute 1000 ms +publication deadline begins. One pre-created NimBLE-host callout owns the +logical message. Each callback validates ticket, connection generation, +deadline, and TX stream generation; takes the physical TX mutex with zero wait; +attempts exactly one fragment Notify; then releases and returns. On success it +advances offset/index and rearms after one RTOS tick if data remains. On +transient pressure it retains the same unaccepted fragment and rearms after at +most 15 ms. The callback never sleeps, loops, or waits for capacity. Non-control +and bulk senders cannot interleave while the logical ownership is held. + +A specialized single-fragment `RUN`, `STOP`, or `SOFT_REBOOT` response MAY +wait under one absolute **15 ms** deadline only for the current complete-message +TX-mutex boundary. Declaring that specialized attempt pending prevents a later +ordinary or bulk message from starting; it does not interrupt a fragment run +already in progress. Once the mutex is acquired, the agent revalidates the +originating session and makes exactly one local Notify submission, with no wait +or retry for mbuf/controller capacity. A successful response MAY preempt between +generic-response fragments and increments a stream generation under the same TX +mutex; the response callout observes the change and then restarts its identical +encoded frame from `FIRST`, which abandons the interrupted prefix. Boundary +deadline expiry or local submission failure suppresses the specialized side +effect and generic fallback without terminating an otherwise-live session. By +contrast, expiry of the generic response's 1000 ms publication deadline on a +still-live connection terminates that session rather than silently losing an +admitted response. Disconnect stops/cancels the callout and invalidates its +ticket before normal re-advertising; any already-queued callback revalidates and +emits nothing. No queued or late response byte may cross into a later +connection session. + +**ESP reference-agent bounded failed-session termination (amended +2026-08-15).** A response-capacity refusal or publication-deadline expiry first +atomically changes the exact `{handle, connection generation}` from `OPEN` to +`CLOSING`. A closing token is retained only for exact GAP lifecycle matching: +it is not live for CMD admission, response-ticket reservation or validation, +ordinary/event/control TX, or the specialized `RUN`, `STOP`, and `SOFT_REBOOT` +paths. Entering `CLOSING` makes existing work logically non-live, so it cannot +begin another side effect or publish a byte, but it does not yet physically +cancel tickets/work or invalidate the retained token. That physical cleanup is +reserved for exact `CLEANING` after any required watchdog stop succeeds. +Repeated close requests for the same token are idempotent. Every TX attempt +carries its originating full token to the sole Notify exit; a new snapshot of +a reused numeric handle cannot substitute for that ownership. The physical +recursive TX mutex has one lock order with lifecycle state for ordinary TX, +connect/open, and disconnect/reset cleanup: TX mutex first, then the session +critical section. The final exact-token check and `ble_gatts_notify_custom` +remain inside that TX ownership. Required termination first claims an exact- +session terminal-admission latch under the session critical section and records +the start of the one absolute termination deadline. Every live/admission check +treats that latch as non-live before the host waits for the physical TX mutex +using only the deadline residual. A Notify that already passed its final exact- +token check may finish during that bounded drain; no new CMD, ticket, or TX +admission can begin. With TX ownership, the host revalidates the latch and uses +the normal TX-then-session lock order to claim `OPEN → CLOSING`. Failure to +acquire the TX mutex by the deadline claims `RESTARTING` and restarts. The state +machine is `CLOSED`/`OPEN`/`CLOSING`/`CLEANING`/ +`RESTARTING`, and neither a reused handle nor a later lifecycle event can open +or clean a terminal `RESTARTING` token. + +Cold native initialization initializes this retained state exactly once and +pre-creates one task-dispatched ESP timer before NimBLE can start or advertise; +MicroPython soft reset and repeated agent initialization do not reset either. +Creation failure leaves the agent unadvertised. A successful GAP connect mints +a nonzero generation and opens the exact token under the session critical +section before exposing it. If the state is not `CLOSED`, the board restarts +instead of overwriting an old token. + +The terminal-admission latch reads `esp_timer_get_time()` once. The later +`OPEN → CLOSING` reducer step receives that same start time and therefore +stores one absolute deadline 2500 ms ahead. The initial physical arm uses only +the positive residual `deadline - esp_timer_get_time()`, never a fresh 2500 ms +interval; reaching the deadline before mutex acquisition or arm instead claims +`RESTARTING`. Reducer begin, immutable watchdog-ticket capture, residual +calculation, `esp_timer_start_once`, and reducer arm acknowledgement form one +uninterrupted session-critical transaction while the TX mutex remains owned. A +task-dispatched callback therefore cannot consume the physical +one-shot while the reducer still considers it unarmed. Only after a successful +acknowledgement does the host make exactly one `ble_gap_terminate` call, outside +both locks. The deadline never moves. TX-drain timeout or initial arm failure +claims `RESTARTING` and restarts without attempting GAP. Return `0` and +`BLE_HS_EALREADY` mean only +that GAP teardown is already pending; the agent waits for exact disconnect or +NimBLE reset while the watchdog remains armed. Any other return claims +`RESTARTING` and invokes public non-returning `esp_restart()` immediately. A +`BLE_GAP_EVENT_TERM_FAILURE` is an explicit no-op: it cannot stop/rearm the +timer, change the deadline, clean the token, advertise, or restart. The agent +never retries termination and never calls `ble_hs_sched_reset`, +`nimble_port_stop`, direct NimBLE/controller teardown, or a private restart +entry point, because the pinned host may already have scheduled its own reset +before returning an error. + +Exact disconnect or NimBLE reset first atomically claims `CLEANING`; a stale +token does nothing. Normal `OPEN` cleanup has no termination timer to stop. +Cleanup claimed from `CLOSING` must receive `ESP_OK` from `esp_timer_stop()` +before it may invalidate exact-token work, complete `CLEANING → CLOSED`, or +advertise. Any other stop result cannot prove that the task callback is not +already running, so it claims `RESTARTING` and restarts. After the successful +required stop, cleanup invalidates all exact-token work/tickets/TX ownership +and only then permits later advertising or admission. + +The callback carries the immutable armed `{handle, generation, deadline}` and +revalidates all three. An early callback rearms only the residual +`deadline - now` interval; rearm failure claims `RESTARTING`. At or after the +inclusive deadline it claims `RESTARTING` before restarting. A stale callback +therefore cannot affect a reused handle even when its successor is also +`CLOSING`. Thus every requested termination produces either exact GAP teardown +or a whole-board restart within the fixed bound rather than leaving a live +session that silently lost an admitted response. + +**ESP reference-agent VM boundary (amended 2026-08-15).** Static native +workers, queues, semaphores, and response slots can outlive one MicroPython VM, +so VM reset is an explicit admission boundary even when the BLE link and its +numeric handle survive. `SOFT_REBOOT` first closes filesystem admission under +the same zero-wait synchronization with which host-context filesystem enqueue +inserts an item and the FS worker marks itself busy, and may proceed only if +that worker is idle and its queue is empty. If +not, the gate reopens and the command returns `RSP{EBUSY}` with no reset side +effect. Both host-context enqueue and the `SOFT_REBOOT` quiescence attempt take +this gate with zero wait. Gate contention is a bounded refusal: a +response-bearing FS command publishes `RSP{EBUSY}` through its reserved ticket, +`FILE_PUT_DATA` is dropped for protocol-level retransmission, and +`SOFT_REBOOT` returns `RSP{EBUSY}` with no side effect. The successful quiescence check provisionally closes all non-reboot +CMD admission. Only after the transactional `RSP{OK}` submission and reset +timer arm both succeed does that closure proceed through the graceful path. +Response-submission failure reopens all gates and leaves the VM intact. Local +acceptance of `RSP{OK}` is the irreversible reset-commit cut: gates never reopen +after it; failure to arm the already-created timer immediately invokes +non-returning `esp_restart()` rather than strand an acknowledged reboot. A +new VM initialization keeps admission closed while it atomically rotates the +live connection generation and VM epoch with old-ticket invalidation, prevents +future response-callout scheduling, synchronizes with pool/TX ownership, and +hard-recycles every response-slot incarnation. It drains response-completion +signals, resets filesystem queues and transfer state, and resets the runner +hand-off semaphore, run-state machine, +request buffers, worker pointer, console buffers, and BLE RX reassembly state. +The reset clears the FS gate, busy/outstanding state, and dequeue claim; runner +`stop_requested`, `soft_reboot_pending`, timer armed epoch, semaphore, state, +buffers, and worker pointer; console ring indices/count and worker pointer; and +response scheduling/active/logical-owner flags. None reopen before final +readiness. +It explicitly sets both custom VM roots, `pble_runner_sysexit` and +`pble_fs_put_file`, to `MP_OBJ_NULL` before new-VM registration allocates or +opens either. +It reopens admission only after all native handlers and new-VM workers are +registered, entered, and safe. The reference ESP port binds this lifecycle to +two pinned port seams without editing upstream MicroPython. An allocation-free, +idempotent linker wrapper around `mp_thread_deinit` closes all admission, +invalidates old tickets/session work, and detaches runner/console worker +pointers before calling the exact upstream function that deletes old VM +threads. Admission and lifecycle activity share one authoritative lock and +counter: every complete CMD enters only while open and leaves only after all of +its handler effects, while every off-MP callback that can touch VM/rooted or +epoch-owned state enters with its exact armed epoch. Persistent runner and +filesystem worker tasks are not members of this wrapper activity counter: the +FS worker's entire-dispatch `busy` state belongs only to the earlier +`SOFT_REBOOT` quiescence gate. This exclusion relies on the pinned ESP runtime: +the main task owns the MicroPython GIL when it reaches `mp_thread_deinit`, and +the wrapper MUST NOT release that GIL. Consequently another MP worker cannot +still be executing a VFS or rooted-VM operation; an old worker parked in an +explicitly off-GIL queue/TX wait is reclaimed by the exact upstream deinit. +The wrapper atomically closes and invalidates, then mints one absolute deadline +exactly 2500 ms ahead and passes that same deadline through activity drain, +soft-reboot and identify timer disarm, prevention of future callout scheduling, +and physical recursive TX-mutex acquisition. Every stage and retry consumes +only the residual time; no helper may restart a 2500 ms budget. An inactive or already-fired lifecycle +timer is idempotent disarm success. Timeout, counter invariant failure, +unexpected timer-disarm failure, or TX-quiescence failure invokes non-returning +`esp_restart()`; the host callback is never made to wait. The wrapper owns the +physical TX mutex while it detaches worker pointers, sets both custom VM roots +to `MP_OBJ_NULL`, and calls `__real_mp_thread_deinit`; it releases the mutex only +after that function has deleted the old VM tasks and returned. Thus a paced +sender cannot reacquire the mutex and then be deleted while owning it. + +The soft-reboot timer carries its armed VM epoch and must enter/leave lifecycle +activity before reading a rooted `SystemExit` or calling the scheduler. The +identify timer likewise carries/revalidates its epoch and stops without another +GPIO transition once invalidated. Wrapper disarm clears soft-reboot pending and +the armed epoch even when the timer is inactive/already fired. This closes passed-gate races for +`RUN`/`STOP`/`SOFT_REBOOT`, console input, and filesystem enqueue as well as +timer races. Each PyBLE ESP board overlay then uses `MICROPY_PORT_INIT_FUNC` to +rotate the VM epoch and retained connection generation and perform the hard +reset exactly once per subsequent `mp_init`. Repeating agent initialization in +one VM is idempotent. Agent initialization does not itself open the gate: final boot wiring explicitly crosses the +readiness barrier only after both workers have entered and auto-run admission +has completed. Because each `_thread` worker must first acquire the MicroPython +GIL, the pinned main task's final ready call releases the GIL while waiting on +both entry flags under one absolute 2500 ms readiness deadline, then reacquires +it before returning. Timeout or a boot-wiring failure leaves admission closed. A response +callback is a static event with no captured epoch or frame pointer. It first +enters lifecycle activity and, while reset/not-ready admission is closed, does +nothing. Once ready it can act only as a fresh kick that peeks the current +ticket, incarnation, epoch, and connection token under their locks. A callback +preempted after that peek remains counted, so wrapper reset cannot recycle its +state until it leaves. Reset MUST NOT depend on callout deinitialization or +removal of a queued host event. If either lifecycle seam observes the retained +connection already in `CLOSING`, it immediately invokes `esp_restart()` rather +than rotate to an `OPEN` generation or disarm the independent termination +watchdog. The release build and link map MUST prove that every ESP target resolves +`mp_thread_deinit` through the wrapper and includes the per-`mp_init` hook; no +upstream source is edited. Work dequeued under an older epoch MUST fail its token checks and MUST +NOT start a VFS operation, publish a response, emit an event, wake the runner, +or touch a recycled slot. An indivisible VFS operation that validly started +before invalidation MAY finish, but its owner MUST revalidate afterward and +MUST start or publish nothing further. + +Response completion has transition ownership: an exact live ticket may move +to complete and signal its waiter once, whether that transition originates in +cancellation, publication failure, or a TX success/error result. Repeated cancellation or completion of +an already-complete incarnation is idempotent and emits no second wake; slot +reserve drains any stale completion signal before exposing a later incarnation. +The physical semaphore give remains outside the pool mutex. After every wake, +the waiter rechecks the exact slot incarnation and authoritative state under +that mutex: a matching `RESERVED`/`READY` slot treats the wake as stale and +continues waiting; exact `COMPLETE` returns its result; an incarnation mismatch +returns cancelled. This remains correct when an old give is delayed across hard +recycle/reserve and when a new completion fills the binary semaphore before the +old give. Filesystem no-response `FILE_PUT_DATA` work and +`FILE_GET_DATA`, `FILE_GET_END`, and `FILE_PUT_ACK` event +attempts are bound to the exact `{handle, connection generation, VM epoch}` and +serialize their final token check plus Notify with connection lifecycle. An +outer worker check alone is not sufficient. These are reference-agent +lifecycle rules and change no PBLE/1 wire byte. Every raw VFS effect, including +stat/open, each directory iterator step, each bounded CRC/read/write chunk, +close, remove/rmdir, mkdir, and rename, is bracketed by exact token checks. A +single pair around an entire handler or multi-operation loop is insufficient; +the check after one operation may serve as the check before the immediately +following operation only when no other effect intervenes. + +`RUN`, `STOP`, and `SOFT_REBOOT` retain their specialized one-fragment +response-before-side-effect contracts in §6. Their response attempt is +connection-bound and may wait only for the current complete-message TX-mutex +boundary under the single absolute 15 ms deadline above. It then makes exactly +one local Notify submission and never waits or retries for mbuf/controller +capacity. Failure suppresses generic fallback and causes no corresponding +execution, interrupt, or reset side effect. + +## 4. Opcodes + +> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`, closes OI-4).** The full v1.0 opcode set and its 1-byte numbers are stable; amend only via a `[docs]` commit before dependent code. This freeze is **status-only** — no wire byte changed. It fixes the opcode *set and numbers* (the DoR for F-02 `pyble_proto`). The **payload encodings** for the identity/identify opcodes freeze incrementally: `0x50 SET_LABEL` label max = **24 bytes UTF-8** is frozen in §7 (G1 · S3, for F-22); `0x51 SET_IDENTIFY_LED` GPIO+active-level encoding and `0x52 IDENTIFY` blink-duration bound are frozen in §7/§4 (G1 · S4, **closing OI-6**) for F-23. + +| Opcode | Name | Dir | Notes | +|---|---|---|---| +| `0x01` | HELLO | CMD/RSP | Version + capability negotiation. First message after connect. | +| `0x02` | DEVICE_INFO | CMD/RSP | Chip, MicroPython version, free memory, fs root, MTU, `device_id`, `label`. | +| `0x10` | FILE_LIST | CMD/RSP | List a directory. | +| `0x11` | FILE_STAT | CMD/RSP | Size + crc of one path (used for resume). | +| `0x12` | FILE_GET_BEGIN | CMD/RSP | Start a download; board streams data as events. | +| `0x13` | FILE_GET_DATA | EVT | Download chunk (offset + bytes). | +| `0x14` | FILE_GET_END | EVT | Download complete (crc). | +| `0x15` | FILE_PUT_BEGIN | CMD/RSP | Start an upload (path, size, crc). | +| `0x16` | FILE_PUT_DATA | CMD | Upload chunk (offset + bytes); acked by window (§5). | +| `0x17` | FILE_PUT_END | CMD/RSP | Finish upload; board verifies crc. | +| `0x18` | FILE_DELETE | CMD/RSP | Delete a file. | +| `0x19` | MKDIR | CMD/RSP | Create a directory. | +| `0x1A` | FILE_RENAME | CMD/RSP | Rename/move. | +| `0x20` | RUN | CMD/RSP | Run a file path **or** an inline source snippet. | +| `0x21` | STOP | CMD/RSP | Interrupt the running program (KeyboardInterrupt). | +| `0x22` | SOFT_REBOOT | CMD/RSP | Soft-reset the MicroPython VM. | +| `0x23` | SET_AUTORUN | CMD/RSP | Enable/disable auto-run of `/main.py` at boot: `[enable:u8]` (0=off default, 1=on), persisted. **Additive opcode** (§9), gated by the `auto_run` cap — older clients ignore it. | +| `0x30` | CONSOLE_DATA | EVT | `stdout`/`stderr` bytes from the running program. | +| `0x31` | CONSOLE_INPUT | CMD | Feed bytes to the program's `stdin`. | +| `0x40` | RUN_STATE | EVT | State transition: `idle` / `running` / `done` / `error`. | +| `0x41` | FILE_PUT_ACK | EVT | Cumulative-offset acknowledgement for uploads (§5). | +| `0x50` | SET_LABEL | CMD/RSP | Set the persisted device label (UTF-8, bounded length); it becomes the advertised name and `DEVICE_INFO.label`. Empty clears it back to `PyBLE-XXXX`. | +| `0x51` | SET_IDENTIFY_LED | CMD/RSP | Configure the **single** optional identify status-LED: payload is **exactly** `[gpio:u8][active_level:u8]` (`active_level` 0=active-low, 1=active-high), persisted; **exactly empty** payload clears it. Every other length, including trailing bytes, is `EBADREQ`; `ERANGE` if `gpio` is out of range, `EBADREQ` if `active_level`∉{0,1}. Device config only — **not** a routing/pin profile, never exposed to user code. | +| `0x52` | IDENTIFY | CMD/RSP | Blink the configured identify LED (5 Hz) for an optional `[duration_ds:u8]` (1–50 deciseconds; absent/0 → default 20 = 2 s; >50 clamped). Non-blocking — `RSP{OK}` returns immediately. `EUNSUPPORTED` if no identify LED is configured. | + +Every opcode payload above and in §§5–7 is an exact grammar. A structured or +fixed-length payload MUST be consumed in full; otherwise trailing bytes are +`EBADREQ` with no handler side effect. This includes empty `DEVICE_INFO`, +`STOP`, and `SOFT_REBOOT`; all path and offset/CRC forms; one-byte +`SET_AUTORUN`; zero-or-two-byte `SET_IDENTIFY_LED`; and zero-or-one-byte +`IDENTIFY`. A field explicitly defined as the remaining bytes—RUN file/source, +`FILE_PUT_DATA` data, `CONSOLE_INPUT`, or `SET_LABEL`—naturally consumes that +remainder. PBLE/1 has no implicit extension bytes; a future additive form needs +a capability/opcode or a documented amendment before implementation. + +The ESP reference `IDENTIFY` timer must also close a task-dispatch race that is +not visible on the wire. In the pinned ESP-IDF, a due periodic timer is +reinserted and its callback/argument are copied before the timer-list lock is +released; `esp_timer_stop()` can remove the reinserted timer but cannot revoke +that already-dequeued invocation. The handler therefore MUST NOT publish or +start a successor active arm immediately after `stop`. It records the request +as pending and queues a timer-task quiescence boundary. The first callback after +that boundary is drain-only: it consumes no tick, changes no GPIO, performs no +terminal stop, stops any still-armed handler boundary (accepting only `OK` or +already-inactive), and queues a distinct activation callback. Only that later +callback may mint/publish the successor `{VM epoch, arm incarnation, ticks}` and +start its periodic schedule, again without consuming a tick. Activation first +enters lifecycle activity for the pending epoch and then revalidates pending +phase/epoch under the identify domain; refusal cancels the pending request and +starts no periodic timer. Clear, reconfiguration, and VM disarm set the phase +idle and clear both pending and active state. Every active tick +then revalidates the exact arm before GPIO or timer-stop effects. A callback +already dequeued from any older periodic, quiescence, or activation phase can +therefore become only the drain callback; it cannot read/adopt, toggle, consume, +or stop the successor arm. Both phases remain non-blocking and allocation-free +per toggle. This is an execution clarification and changes no PBLE/1 bytes. + +Any unexpected stop result or failure to queue the quiescence boundary makes +the initiating handler return `EINTERNAL` with pending/active state cleared and +the LED off. A drain or activation re-arm failure likewise clears to idle and +has no GPIO/tick effect. Active-incarnation mint remains nonzero and fails closed +before wrap, so the quiescence seam cannot reintroduce ABA. + +## 5. File transfer (the reliability core) + +> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`).** The file-transfer wire below (read + windowed upload + jail) is stable — the DoR for F-08/F-09/F-17. Amend only via a `[docs]` commit before dependent code. Designed for a lossy, MTU-bounded link. All multi-byte fields little-endian; paths are `[plen:u16][path UTF-8]`, **max 128 B** (`ERANGE` over). File `DATA` chunks are sized to one BLE packet and never fragment. Exactly **one active transfer** (PUT or GET) at a time — a second `*_BEGIN` while one is active → `EBUSY`. + +### Read (F-08) + +- **`FILE_LIST` (0x10)** `[plen][path]` → `RSP [status]`; on `OK`, then `[more:u8][count:u16]` and count× `{[etype:u8][esize:u32][nlen:u16][name]}` (`etype` 0=file / 1=dir; `more`=1 if at least one **visible** entry was truncated to the worker buffer). Every entry whose basename ends with the case-sensitive reserved `.pbltmp` suffix is omitted before stat, count, and response-budget accounting, whether that entry is a file or directory. +- **`FILE_STAT` (0x11)** `[plen][path]` → `RSP [status]`; on `OK`, then `[size:u32][crc32:u32]`. Missing path → `ENOENT`. +- **`FILE_GET_BEGIN` (0x12)** `[offset:u32][plen][path]` → `RSP [status]`; on `OK`, then `[total_size:u32]`. The whole-file CRC is delivered in `FILE_GET_END` (not up front — avoids a pre-scan double read); `FILE_STAT` first if you want it early. +- **`FILE_GET_DATA` (0x13, EVT, id 0)** `[offset:u32][bytes]` — one BLE packet each. +- **`FILE_GET_END` (0x14, EVT, id 0)** `[crc32:u32]` — whole-file CRC over `[0,total_size)` (the worker CRCs a skipped prefix on a resume-download too). + +### Windowed upload (F-09) — sliding window, cumulative ACK, Go-Back-N + +- **`FILE_PUT_BEGIN` (0x15)** `[total_size:u32][crc32:u32][plen][path]` → `RSP [status]`; on `OK`, then `[resume_offset:u32]`. It prepares the jailed sibling `.pbltmp`, verifies any resumable prefix, applies the admission rule below, and sets the watermark to `resume_offset`. +- **`FILE_PUT_DATA` (0x16, CMD, no RSP)** `[offset:u32][bytes]`. `offset==watermark` and `offset + len(bytes) <= total_size` → write + advance + `ACK{watermark}`; `offsetwatermark` → gap → drop + `ACK{watermark}` (app resends from there). A chunk that would cross `total_size` writes no byte and latches `ERANGE` for `FILE_PUT_END`. Watermark and CRC advance only after the VFS reports exact, positive progress covering the entire input chunk; `None`, zero, an impossible count, or an incomplete write latches `EIO`. No out-of-order buffering. +- **`FILE_PUT_ACK` (0x41, EVT, id 0)** `[ack_offset:u32]` = highest contiguous byte written = next expected offset. +- **`FILE_PUT_END` (0x17)** `[crc32:u32]` → `RSP [status]`. `watermark ≠ total_size` → `ERANGE`; a latched write error → `ENOSPC`/`EIO`; temp CRC ≠ `crc32` → `ECRC`. For every such failure, abort closes the scratch object, removes the scratch path non-recursively, and confirms absence before reporting the originating status; **the old file is kept** (FR-FS-14). A close/removal/absence-verification failure returns `EIO` instead, retains the old target, and never reports successful cleanup; any unremovable scratch remains reserved and hidden. Else fsync + `rename(temp,dest)` (atomic on LittleFS) → `OK`. +- **`FILE_DELETE` (0x18)** `[plen][path]`: file → remove; empty dir → rmdir; non-empty dir → `EACCES` (no recursive delete); missing → `ENOENT`. +- **`MKDIR` (0x19)** `[plen][path]`: already-a-dir → `OK` (idempotent); an existing file → `EBADREQ`; missing parent → `ENOENT`. +- **`FILE_RENAME` (0x1A)** `[slen][src][dlen][dst]`: both jailed; src missing → `ENOENT`; dst a non-empty dir → `EACCES`; else atomic rename → `OK`. Each path independently retains the 128-byte maximum, so the receiver and any deferred-work envelope MUST accept the legal 260-byte request payload (`2 + 128 + 2 + 128`); 129 bytes in either path is `ERANGE`. + +The three namespace mutations parse and jail-resolve every supplied path first. +While a PUT or GET is active they then return `EBUSY` before any stat or +mutation; therefore malformed payloads still take `EBADREQ`, forbidden paths +still take `EACCES`, and a valid mutation takes `EBUSY` independent of path +existence. This is deliberately conservative: DELETE, MKDIR, and RENAME are +all serialized even when their valid paths do not name the file being +transferred. `FILE_LIST` and `FILE_STAT` remain available during either +transfer. This serialization is only for the PBLE/1 bridge; ordinary user code +retains direct VFS access. + +**Resume on reconnect (F-10):** a link drop mid-`PUT` resets the in-RAM transfer state, but the jailed `.pbltmp` prefix persists on flash. A scratch prefix is resumable only when it is a regular file, `0 < length <= total_size`, exactly that many bytes are read and CRC'd, and its type and length remain stable through that scan. Only then may `FILE_PUT_BEGIN` return the verified length as a nonzero `resume_offset`, re-seed the running whole-file CRC, and set the watermark. A missing or zero-length regular scratch means a fresh upload. An oversized, short-read, unreadable, changed, or type-invalid scratch is malformed: the agent removes a malformed regular file or empty scratch directory, confirms absence, and restarts at zero. It never recursively removes, truncates, renames, or otherwise changes a nonempty scratch directory or the old destination; a nonempty scratch directory or failed safe removal returns `EIO`. A structurally valid but foreign prefix may resume, but the whole-file CRC at `FILE_PUT_END` remains the content-identity gate: `ECRC` deletes the scratch and keeps the old destination byte-for-byte. + +Every queued or in-flight transfer operation owns the exact `{handle, +connection generation, VM epoch}` from its command. Disconnect/VM invalidation +atomically advances a transfer generation before successor work can publish +state. In particular, a delayed `FILE_PUT_BEGIN` may finish one indivisible VFS +call that began while its token was live, but after the mandatory revalidation +it may only close its own newly opened object: it MUST NOT install or clear +active-transfer state, clear/adopt successor state, emit an ACK, or publish a +response. Transfer-state publication and its final ownership check form one +indivisible cut. A later session may reclaim an active record only when that +record's stored generation is stale; DATA and END require an exact generation +match. These rules prevent a predecessor disconnect race from erasing or +adopting a successor upload and change no PBLE/1 byte. + +Any VFS stream count is validated before the corresponding buffer range is +read, CRC'd, copied, or exposed. It MUST be an integer in `0..requested`; a +negative, non-integer, or over-reported count is `EIO`. A zero count before the +promised extent is complete is a short read and is also `EIO`. For the portable +binary-file API, the exact equivalent is an exact `bytes` result whose length +is in `0..requested`; any other result is `EIO`. A fault discovered before a +response is committed returns `EIO` normally. Once a successful +`FILE_GET_BEGIN` response is committed, a read fault aborts that stream, emits +no later `FILE_GET_DATA`, and MUST NOT emit `FILE_GET_END`; the client therefore +uses its existing missing-END/extent timeout and reports an unsuccessful +download. No replacement response or new error event is invented within +PBLE/1. + +**Upload admission (amended 2026-09-02):** PBLE/1 retains the existing +`total_size:u32`; v0.6.1 adds no fixed-size capability. Before creating or +growing scratch, `FILE_PUT_BEGIN` reads `statvfs(fs_root)` and applies a +**65,536-byte safety reserve** using checked 64-bit arithmetic: + +```text +unit = f_frsize +free = f_bavail * unit +remaining = total_size - resume_offset +needed = ceil(remaining / unit) * unit +accept iff free >= 65,536 + needed +``` + +Zero/invalid geometry, invalid counters, or arithmetic overflow returns `EIO`; +insufficient space returns `ENOSPC`. Existing scratch allocation is already +reflected in `free`, so only its verified remainder is charged. Space occupied +by the old destination is never credited because that file remains until the +atomic commit. The reserve is admission headroom, not a promise about the exact +post-write free count because VFS metadata can consume additional blocks. + +**Workspace filesystem.** Official PyBLE images mount their internal workspace +as LFS2 on all five profiles. The ESP partition-table `data,fat` subtype is only +ESP-IDF block-container metadata; the label `vfs` selects MicroPython's LFS2 +first-use provisioning and the on-media format is LFS2. ESP boot must construct +the LFS2 VFS explicitly instead of accepting the generic FAT fallback. A +nonblank incompatible or corrupt workspace fails closed without formatting or +starting the PBLE/1 agent; migration/recovery is an explicit operator action. +This is what makes the successful scratch-to-target replacement above an +atomic filesystem commit rather than FAT's delete-then-rename sequence. + +**Workspace jail (F-17):** every path is strict scalar UTF-8 and is canonicalized against `fs_root` at a **single chokepoint** before any vfs op; malformed UTF-8 → `EBADREQ`, while traversal (`../`) / absolute escapes outside `fs_root`, any component ending with the case-sensitive reserved `.pbltmp` suffix, and a case-sensitive reserved first canonical component relative to `fs_root` → `EACCES` (SEC-4). The reserved first component is any lowercase name beginning `pyble` or `pble`, or exact `boot.py` / `_boot.py`; the same basename below an ordinary first component is allowed, and ordinary root names such as `main.py` remain allowed. Scratch names are also hidden from listings as specified above. **`.py` / data only** — the agent never requires, generates, or accepts `.mpy` / `.pyc` transfer artifacts; no server-side compilation. + +## 6. Run / Stop / Console + +> **FROZEN for v1.0 (amended)** — RUN-file at G1 · S3; **`RUN{source}`, `STOP`, `SOFT_REBOOT`, `CONSOLE_DATA`, `CONSOLE_INPUT` frozen at G1 · S4 (2026-07-01 · `[docs]`)**; transactional RUN admission clarified 2026-08-14; default-MTU `STOP`/`SOFT_REBOOT` response-before-side-effect admission, per-event session binding, the runner pickup/control-resolution cut, and the bounded current-message TX-boundary wait clarified 2026-08-15. Wire below; amend only via a `[docs]` commit before dependent code. + +- **`RUN` (0x20)** payload `[mode:u8][data]` — `mode` 0=file (`data` = UTF-8 path), 1=source (`data` = UTF-8 snippet). → `RSP{status}` (`OK` | `EBUSY` if one already running, FR-RUN-4 | `EBADREQ` bad mode | `ERANGE` over-length), then `RUN_STATE(running)`. Both modes share one lifecycle. Completion → `RUN_STATE(done)`; an uncaught exception → `CONSOLE_DATA(stderr, traceback)` then `RUN_STATE(error)`. A missing/inaccessible file surfaces asynchronously (`CONSOLE_DATA(stderr)` + `RUN_STATE(error)`), not as the `RSP`. Every ordinary or autorun execution receives a fresh globals/locals dictionary containing `__name__ = "__main__"`; variables created by an earlier run are absent. This fresh-globals rule is limited to ordinary and autorun execution: it preserves the possibility of a future, separately specified, capability-gated persistent interactive mode, which MUST NOT silently change ordinary `RUN` isolation. PBLE/1 v0.6.1 does not yet define `__file__`, working-directory changes, sibling-import setup, or `sys.modules` cleanup. + + An otherwise valid, non-busy RUN is admitted transactionally. The ESP + reference agent makes a provisional, non-observable reservation and copies + the request, then declares one specialized response attempt pending. Under one + absolute 15 ms deadline it may wait only for the current complete-message TX- + mutex boundary; once acquired it revalidates the connection and makes exactly + one single-fragment local Notify submission for its matching `RSP{OK}`, with no + capacity wait or retry. At the minimum ATT MTU 23, a fragment carries 19 + PBLE/1 message + bytes and the response frame is 11 bytes, so it is always one fragment. + Local Notify acceptance is the admission cut: only after it succeeds may the + agent wake the runner exactly once. Therefore user code, console output, and + every RUN event follow the response submission. Boundary-deadline expiry, a + missing or changed connection, or local Notify backpressure restores the exact + prior + runnable state and produces no wake, execution, event, fallback response, or + retry. A timeout caused by one of these local admission failures is therefore + side-effect-free. Timeout alone does not prove rejection: a disconnect or + response loss after local acceptance does not revoke the already-admitted run. +- **`STOP` (0x21)** no payload. Idempotent — on successful connection-bound, + single-fragment `RSP{OK}` submission, STOP while idle is a no-op and STOP + while running raises `KeyboardInterrupt` **in the runner task only** (the link + stays live, FR-BLE-11) → clean teardown → `RUN_STATE(idle)` (FR-RUN-5/6/10). + Local response-submission failure emits no fallback and performs no interrupt. + + Response acceptance and a delayed worker pickup are one ordered control + transaction. Before attempting the response, the reference runner marks the + control attempt unresolved under its runner-domain synchronization, then + releases that synchronization before entering the TX domain. A reserved + worker MUST NOT cross its event/execution gate while an earlier control + attempt is unresolved. It waits on a pre-created resolution signal outside + the runner domain, then loops and rechecks both the unresolved predicate and + stop snapshot together under that domain. After the bounded single-submission + response attempt, one runner-domain + cut either (a) publishes the accepted STOP intent and worker pending exception + before resolving the gate, or (b) resolves a failed attempt without an + interrupt or stop effect; either resolution then wakes the worker. Thus response success before pickup permits no user + code or RUN event from that reservation; pickup first makes the command an + ordinary active-run interrupt. + + An active worker's terminal classification is a second consumer of the same + gate. It MUST NOT commit `done`, `error`, or `idle` while a control attempt + whose begin cut precedes that terminal cut remains unresolved. It waits on the + same signal outside both the runner domain and the MicroPython GIL, then in one + runner-domain cut observes the resolved stop flag and commits either + `RUN_STATE(idle)` for accepted `STOP`/`SOFT_REBOOT` or the natural + `done`/`error` state after response failure. If the terminal cut wins first, + the natural terminal state is already authoritative and a later accepted + `STOP` is the specified idle no-op. This ordering closes the local Notify + acceptance-to-intent gap without holding the runner lock across TX or a wait. + A successful later RUN reservation may consume + only resolved stale idle-STOP intent, never an unresolved control attempt. +- **`SOFT_REBOOT` (0x22)** no payload. On the normal successfully armed grace + path, `RSP{OK}` is immediate; the command stops any run, then soft-resets the + MicroPython VM and returns to `RUN_STATE(idle)` (FR-RUN-8). VM teardown MUST + NOT begin merely because the local BLE stack + accepted the notification: the implementation MUST allow a short bounded + delivery grace after submitting `RSP{OK}` so queued response bytes can reach + the central. The ESP32 reference agent uses a pre-created 250 ms one-shot and + refuses a second reboot with `EBUSY` while that reset is pending. If the + `SOFT_REBOOT` response attempt fails, it MUST leave the VM running and + emit no generic fallback rather than perform an ambiguous reset. Once the + non-blocking FS quiescence gate succeeds and the response submission + succeeds, all non-reboot CMD admission remains closed. The handler then arms + the pre-created timer; arm failure immediately invokes non-returning + `esp_restart()` with admission still closed. This post-`RSP{OK}` timer-arm + failure is the sole hardware-restart exception to the normal delivery-grace + and soft-reset path; + the next VM initialization performs the epoch/reset transaction in §3.2 and + reopens admission only after workers have entered, final wiring is safe, and + auto-run admission has completed. A busy FS worker or + non-empty FS queue returns `EBUSY` after reopening the provisional gate; a + response-submission failure likewise reopens all gates and leaves the VM + intact. This is an + execution-order and lifecycle clarification; it changes no PBLE/1 bytes. + + `SOFT_REBOOT` uses the same unresolved-control pickup gate as `STOP`. It marks + that gate only after its provisional FS/VM closure succeeds and before its + response attempt. `PBLE_TX_OK` publishes runner stop intent while resolving + the gate, before timer arm. On failure the provisional FS/VM gates are aborted + before pickup is released without stop intent. Timer-arm failure remains the documented + post-acceptance non-returning restart exception. + + While an accepted `SOFT_REBOOT` keeps global command admission closed, that + closing state has precedence over HELLO/session negotiation and malformed- + session violation accounting, including if a numeric handle is re-presented + before the 250 ms grace ends. Every otherwise structurally valid response- + bearing CMD, including `DEVICE_INFO`, `HELLO`, and a duplicate + `SOFT_REBOOT`, returns `EBUSY`; a no-response command is silently discarded. + No handler side effect or violation debit occurs. Only VM initialization may + reopen admission. +- **`CONSOLE_DATA` (0x30, EVT, id 0)** payload `[stream:u8][bytes]` — `stream` 0=stdout, 1=stderr (FR-CON-1/2). +- **`CONSOLE_INPUT` (0x31, CMD, no RSP)** payload `[bytes]` — appended to the active program's bounded `stdin` (`input()`/`sys.stdin`); fire-and-forget, no reply frame (FR-CON-3). Input received while no program is active is silently discarded. A successful RUN response-admission cut clears stdin before waking the runner; autorun admission does the same. Accepted STOP clears it after response handoff, and every terminal transition, disconnect, and VM reset clears it. Disconnect does not itself stop a continuing run, so the active flag remains true and a newly negotiated client may feed that same run; only the queued bytes from before the disconnect are lost. These boundaries prevent idle input and overflow from one run being consumed by another. +- **`RUN_STATE` (0x40, EVT, id 0)** payload `[state:u8]` — 0 idle / 1 running / 2 done / 3 error (FR-RUN-7). + +The console is **observe-anywhere**: `stdout`/`stderr` stream regardless of +which client triggered the run. Each new logical `RUN_STATE` event and each +newly formed `CONSOLE_DATA` chunk independently captures the then-current live +`{connection handle, connection generation, VM epoch}` at event creation. If +no live session exists then, that event is omitted rather than retained for a +future client. Every fragment and retry of one created event retains that exact +token and MUST NOT retarget: disconnect or VM-epoch change invalidates its +buffered work, while a later new event MAY bind a successor session after +reconnect. This rule applies equally to command-started and opt-in auto-runs. +It does not change `RUN` command admission: the matching response and execution +cut remain bound to the command's originating session. USB is a local-debug +mirror only — never a runtime transport (FR-CON-5). + +Terminal state publication, stdin deactivation/clear, and release of the +single-run reservation are one ordered lifecycle cut. A successor RUN cannot +become reservable until its predecessor's stdin is empty and inactive; +terminal cleanup from the predecessor cannot later erase bytes admitted for +the successor. The clear/deactivate operation is allocation-free, including +under memory pressure. + +## 7. HELLO & capabilities + +> **FROZEN for v1.0 (G1 · 2026-07-01; amended 2026-09-02 · `[docs]`).** The capability field set below, the **HELLO-is-the-first-exchange** rule, and the invariant that an **INFO-characteristic read returns the same `DEVICE_INFO` payload** are stable — the DoR for F-03 `pyble_info` and F-16. **Closes the label half of OI-6: the `SET_LABEL` / `label` maximum is 24 bytes (UTF-8 encoded); an over-length label is rejected with `ERANGE` and not stored, and the same bound applies to the advertised name.** **Closes the OI-6 remainder at S4:** `SET_IDENTIFY_LED` = `[gpio:u8][active_level:u8]` (empty clears), `IDENTIFY` = optional `[duration_ds:u8]` (1–50 ds, default 20, 5 Hz). Amend only via a `[docs]` commit before dependent code. + +`HELLO` is the first complete PBLE/1 frame after the §2 transport setup. Its +request payload is newline-separated ASCII `key=value` text no longer than +**192 bytes**. A valid request contains exactly one each of the required keys +`proto_versions`, `app_name`, and `app_version`; key order is insignificant. +Lines use LF (`0x0A`), one optional trailing LF is accepted, and empty interior +lines are invalid. Each line is split at its first `=`. Keys match +`[a-z][a-z0-9_]{0,31}`. Unknown well-formed keys with printable ASCII values no +longer than 32 bytes are ignored. A duplicate or missing required key, +CR/NUL/non-ASCII byte, malformed key/value line, or over-bound payload/value is +`EBADREQ`. + +`proto_versions` is 1–8 comma-separated unsigned canonical decimal integers +in `0..255`; empty elements, leading zeroes except the single token `0`, and +non-digits are invalid. `app_name` and `app_version` are each 1–32 bytes of +printable ASCII (`0x20..0x7E`). The v1 agent chooses version 1 exactly when the +offer contains 1. A syntactically valid offer without 1 returns +`EUNSUPPORTED`; malformed syntax returns `EBADREQ`. The agent neither persists, +logs, nor echoes the two app-identification values. + +Each exact `{connection generation, VM epoch}` begins `UNNEGOTIATED`. INFO +reads remain allowed and do not change it. Before negotiation, HELLO is the +only command admitted to an opcode handler. Any other response-bearing command +returns `EBADREQ` without side effects; `FILE_PUT_DATA` and `CONSOLE_INPUT`, +whose opcodes define no `RSP`, are silently dropped without side effects. The +session becomes `NEGOTIATED(v1)` only after the final fragment of its +`HELLO RSP{OK}` is accepted by the bounded, session-bound local Notify path. +Repeating a compatible HELLO is idempotent and returns current caps. A failed +initial HELLO leaves the session unnegotiated; a failed repeated HELLO leaves +an already negotiated session negotiated. Disconnect and VM-epoch rotation +clear negotiation before later RX admission. + +The response contains the frozen caps text below; `proto=1` is the selected +version and no additional selected-version byte exists. The fields describe +`chip` (a port-defined target token), `agent` (agent SemVer), `mpy`, `fs_root`, +`mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led`, +`auto_run`, `device_id`, and `label`. Reading INFO returns the same caps text +without subscription or negotiation. `max_file_size` was never a serialized +PBLE/1 key and is not added in v0.6.1; §5 defines dynamic upload admission. + +**Caps payload serialization (frozen 2026-07-02; clarified 2026-09-02 · `[docs]`, S2-app coordination):** the caps payload is newline-separated UTF-8 `key=value` text, one pair per line, with the short key tokens the reference agent emits — `proto`, `agent`, `chip`, `mpy`, `fs_root`, `mtu`, `window`, `chunk`, `free_mem`, `has_sd`, `has_identify`, `identify_led` (integer GPIO; `255` = none), `auto_run`, `device_id`, `label` (may be empty). Keys and every non-label value are ASCII; `label` is the sole UTF-8 value. Booleans are `0`/`1`; integers are decimal. Clients MUST parse tolerantly — unknown keys are ignored (additive caps, §9) and key order is not significant. The `HELLO` RSP payload is `[status:u8]` followed by this same caps text. + +For `SET_LABEL`, encoded length is checked first: more than 24 bytes returns +`ERANGE`. Otherwise nonempty input must be strict, well-formed UTF-8 and must +not contain Unicode control code points U+0000–U+001F or U+007F–U+009F; +failure returns `EBADREQ`. Empty input remains the clear operation. Rejected or +failed persistence leaves the stored label, caps value, and advertisement +unchanged. + +The client MUST treat `chip` as display/reference metadata rather than an +allowlist: an unknown value does not block a conforming connection. It must not +use a feature the board did not advertise — e.g. it offers an Identify action +only when `has_identify` is set. PBLE/1's numeric `identify_led` encoding is +optional: a port whose pins cannot be represented by `gpio:u8` advertises +`has_identify=0` and returns `EUNSUPPORTED`; a portable non-numeric pin encoding +would require a future additive capability/opcode or PBLE/2. These +identity/identify capabilities are **additive within PBLE/1** (§9): an older +client simply ignores them. + +## 8. Status / error codes (1-byte `status` in RSP) + +> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`).** The 1-byte status set and its numbers are stable; amend only via a `[docs]` commit before dependent code. Status-only freeze — no wire byte changed. This is part of the DoR for F-02 `pyble_proto` (the status-mapping acceptance criteria, FR-PROTO-6). + +| Code | Name | Meaning | +|---|---|---| +| `0x00` | OK | Success | +| `0x01` | EBADREQ | Malformed request | +| `0x02` | ENOENT | No such file/dir | +| `0x03` | EACCES | Path not permitted | +| `0x04` | ENOSPC | Filesystem full | +| `0x05` | EIO | I/O error | +| `0x06` | ENOMEM | Out of memory | +| `0x07` | EBUSY | A program is already running | +| `0x08` | ECRC | Checksum mismatch | +| `0x09` | ERANGE | Bad offset/length | +| `0x0A` | EUNSUPPORTED | Opcode/feature not supported | +| `0xFF` | EINTERNAL | Unexpected internal error | + +## 9. Versioning policy + +> **FROZEN for v1.0 (G1 · 2026-07-01; clarified 2026-09-02 · `[docs]`).** v1.0 supports **exactly `VER = 0x01`** (`PBLE_PROTO_VERSION = 1`). Under §3.1 response eligibility, an otherwise valid inbound `CMD` frame with another `VER` receives `EBADREQ`; a syntactically valid HELLO offer without version 1 receives `EUNSUPPORTED`; malformed HELLO syntax receives `EBADREQ`. A compatible repeated HELLO is idempotent. Capabilities are additive within v1.0 (an older client ignores unknown caps). Amend only via a `[docs]` commit before dependent code. + +`VER` and the HELLO `proto_versions[]` exchange let either side refuse or downgrade gracefully. Backward-incompatible changes bump the protocol to PBLE/2; additive opcodes are gated behind capability flags so old clients keep working. No silent wire-format changes within version 1. + +## 10. Security note (v1) + +> **FROZEN for v1.0 (G1 · 2026-07-01 · `[docs]`) — this completes PBLE/1 (§2–§10 all frozen).** The v1 posture: link-layer pairing/encryption is **available and used** (NimBLE Just-Works, LE Secure Connections, bonding; `sm_io_cap = NO_INPUT_OUTPUT`, MITM off) but is **not access-gating** — the RX/TX/INFO characteristics carry no per-characteristic encryption-permission flag, so a normal central connects and speaks PBLE/1 without a mandatory pairing step (SEC-1/2). No application-layer auth (a connected client is trusted). A **single active writer** serializes mutating file ops + program runs (SEC-3, via the file single-active-transfer + the runner's single-program `EBUSY`). Identity is **display-only** — never gate or branch trust on MAC / `device_id` / `label` (SEC-7/11); the advertisement carries only the Service UUID + name (no PII, SEC-10); nothing is transmitted off-device (no telemetry, SEC-5). Amend only via a `[docs]` commit before dependent code. + +BLE link-layer pairing/encryption is the baseline. v1 has no application-layer auth (a connected client is trusted), matching the "personal board on a workbench" model. A future capability may add an application-layer pairing token; it would be negotiated in HELLO and is intentionally out of v1 scope. + +Setting the device label (`SET_LABEL`), configuring the identify LED (`SET_IDENTIFY_LED`), and triggering `IDENTIFY` are ordinary control commands under this connected-client trust model. The device **label is broadcast** in the advertisement, so it **MUST NOT** contain personal data: the board bounds the label length and the default name (`PyBLE-XXXX`) carries no personal data. The board **MUST NOT** gate access by MAC or label — `device_id` is for recognition/display only, never authorization. diff --git a/tools/web/public/sitemap.xml b/tools/web/public/sitemap.xml index ecf9a46b..d6e1a112 100644 --- a/tools/web/public/sitemap.xml +++ b/tools/web/public/sitemap.xml @@ -2,97 +2,103 @@ https://pyble.dev/ - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z weekly 1 https://pyble.dev/app - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.9 https://pyble.dev/privacy - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.7 https://pyble.dev/support - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.7 https://pyble.dev/flash - 2026-07-29T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.7 + + https://pyble.dev/features + 2026-09-11T00:00:00.000Z + monthly + 0.9 + https://pyble.dev/learn - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.9 https://pyble.dev/learn/setup - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/first-program - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/files - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/github-import - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/blocks - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/examples - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/hardware - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/configured-hardware - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/pico-2-w - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 https://pyble.dev/learn/waveshare-lcd-147b - 2026-08-28T00:00:00.000Z + 2026-09-11T00:00:00.000Z monthly 0.8 diff --git a/tools/web/public/social/pyble-v061-og-297b270f-1200x630.png b/tools/web/public/social/pyble-v061-og-297b270f-1200x630.png new file mode 100644 index 0000000000000000000000000000000000000000..79b9533b0f548ba7002de0251ab5f6dc7ad13ed2 GIT binary patch literal 219939 zcmYIvdpwi>|Nb`TIfTsl9MW#&SfQMiro+f~k46s7DKw;zYR-&KYNC@vP1|?%O4g)I`EyF!|kH9=z_wYq^SsgIg?j^ zovKwt_;%2um8CtsyfU*W$BGmKJ{q6xXMRWPS9_s6IKgo7+xhb1sLdG?_r2=M4Yyo1 z4R&vtId!_${=Q@Bn;TA3efBnqt3TVio_^jJZgc-y%2dkD17H4j+lu|#ZFEJ`%;ZT# zRGiu0EoFbhte?DlwimOz?xJ7V-6*>#@gjEV8JWA;fH{lal>ahX!z;G&a4^QC^yS4W zRL)`hVy&!~PcyV;rs;O=4L(n5uPnn9Oy9T=7b z(Y9Y?a0hbBrT>~-2@2&ezAkWC*x+l|2=kmik~7#vWK^MC1PyN0{kON?9I4a^ITU&3 z>f0Z6H>!gfbW}yw>LasL`L_cCtf*g__Ril9NVTG_Hmz`=|HvTOk2f?wS}pc9KV?cy zK4?>a_f@DVb>*>Uuqk0NVrsdo7=F?9F-Q&lzHI7!hVSeg=6opZam1{>`C}2a6O{{` z1CI&$`a#R8Y_l=iQ4vxqD4vUK7s!hXF&}M6gl-jHxIe#)W zbnzx$i87Ubs?B3^KS!CqT8hw)691Xkd-GfdR>o#Bt8;hPx7~{DJ{>*XAx6%`RK%aD zBVT`(NSFo1dp<%_(;2{XKVnMT3{{nAl?_h+N}e#`B1O=qDS$=MKrfl3SYxQfykt%= zR3Nx=k*@25c!N@=pC}{*+X#mj6iK-gb7f42%RMwXav3yNt|5XTNy#@0{?Q>$PzCO= z5cu^>nT#UkV1IG+gPFUGD9fh*~IIR276!yC~QLXnZ0kGvZpFXvRc>KpJ#X~Z4XTKG4PAqwRWcFT+F;Kv867szlNOsAn*c69H1~4 zwl<@}wCaj}i5>$o0ol7Kz^7!=!PF+bFq#p;d)?LWAdT?&h z2E31Y(8Zl}Y_H*)74`ReJx#W7V>k1Hta*%w;)~z+ayN&1>sgIx1h4!w>!5vmywxS- z%m*RAbUaaovyC|L*jNG7W~=7{;dAKGlQ{6|Y=$=b&(?9O1yST-784&_g=!(}0vzh3 z@v0*JCQd@=Gt@L6&9aikH)mzsp;Bod+i3@aJyUyV^WVSu&vl@FYX~nAwgriiyTgCo zpPBju3y$Yjp7+*fLwCyp29rJE?C4Z_pk-%-*lkybn$8Yxa?*umUP_f#f_S)=7cPallAMOqxWA=`o)jkm_gf~X^)Vnew1%7_k zf?0fX9Uzh?ii_Kio$w2c=Z=v{Ei=@;+{zq6$hVr2Y<$Y98=y?~)Tv^1pv1P3B-wry z7}C{Fa~EFSKmW2ic#O%{Jl$S7OJKGxa%_$ax1 zpXa$xLcSfx4!iN$nf<7Fglg{pqDYq9&16@%(>Baz?Bmf+4olb*Md?6g9!==VU9b)i zWdJkkBvtmp4noMpgJPEjG6_!KMof{at`{=Zh;kaqD~P*VYS;2#81q_XIVTHt_>6MXoj)n83X#D z2NMb=dr1PkYi4XecR?(`KOK0TfpSkKNiuKG8xwz=m2OmZkt2I*CtY1OSeZBSG3M&( zX=PYp=RF)709GD8TK=$6kC2ZDHZIyLuj;N{G(>$O<9s%0-xz^|0m<2p zO%M0mt7CT=DS#!eM}qaA4q-g42>)5t7z%6~qZ)HVTS%6p5jar8=~`m9h`*U^?9Tf2 z=3N#uE3Qy3>}$IMF-4N4odakSCU$eJCH)#ij7q^z#;+&cC~#i~t>z>6=wr6S_IWDM zpAFPtvw_AE#Q%?1wGP4FFBG6Hxf`lcG_`QzfC~`YhBRilD(vL)!`89e3h}Ar2`Hao zY;qXwlZdaomT9@c{MWOoCGaOZC5jTZOM>`u%tv2}ipB@Ixs5@_4zdBL8l(7f&HrTd zmrUUJ6w&y1%rhMIIfEV9iE~({G6ReN_Vq}$V?N!uz~Rm}bm$u}(qbHU54-zih}Vrs zg3>ju%d$1LLjK8MPery@2|6y$kDDP4x`XG;xCt%0yGr%gB$GgZh zFNH?*Fb|OUPrCnmR!VHIG1@)yM6!T}eY0kR3)qE4DoJR)?WRf_G=Ff+Ni z!8e216SR{V^hFE^7x6W2TO&XeDIQi;k^u}~fJ*rG4EsDVtGw5qo3J5H{+NYAJ5K%o zm#h|bHZcP{rx0EHG(mtWTRSQeIeQLW@^euV;4GxcfjfpM!2V2>2{%ER30*T4i&DTR zRQsj_8!1qGcns`fwDgUozfCW&CDutE6ni5L6>XU6Azc6Knhe#q64T8R3wYP?IsQPg ztD0Y+pvOO{`>n)EUA=xS*IFDLeEF59OMKGLxb~>;IWXp zP(t6)??19U)}vut&1}psPbA66!c*+7cGm z$P*4Dnkt36gqS7!&6&VD;NdJ-x_)F5d zxBO>^p#BTn=-iHqZ^N8e1B9Wv5cY#!!k(eFaHEgv>*zB=EOLRE#2P{-Gw8dJ+Fk5o zMxG$jP*K&^=b<~$Hir}KbgK1cVo$f>6dj2gGNd8HOfXa1)BLdzV=xITaPW=i8cO`9 zOKJq!%YbGLa-nPei~{&2bpz}+d2kD;`W-=>uApsdi#}>GuT$=ZcoNNwgIeYcSx^8$ z90}H*cGR*x-nN{NBTxLK9M9!dPs4UvD;}x_)s};MQ0ExHJ+h}(^PbLP9j>N_ctd0d z&7N0z4%%brG$?y-LK$bCIsK)&H9>+nf813ZlzZL+HE?zI?Yr*>NaSw)0H16?Gg8od zDwr+{egI`}%j2}!mONAv*;jftWCFjrKg#`FbY$kG0wGm3W?If{VK)*%5xm@=&!^<%AdyhO8KPKK2Fmtxn~Y)698k)9Il zR38t(9EyNDQ1a*!-edx=I8>0hsT_Ae%7vQB)OKyd9NR6|NxRYCgNB-h&yerUWtE$z zQmzHY+(AjR884_PC=&f+)Na35{pX;>11T`B8|WZUFpFKCrl2c9-D!IyDxDAjte?Rj zO!{`X5hoEYq?!<-+EHSbB{0 z+z#YH$@!j;myF1XwvEyt|38trypFvcy2mfd@uRpvr6U?$GGODu1a4(e(gd9*@xWmm zxy}z4^-8R}pP!{JWCZYoRqhBGF=xd4F>p4cO2IWAS;JqP5)b&1!HAMD-ItDoh>^<4 z^Dj0kieYhfHPYRt#5diH=;?0LeHAoL?aXxdIT7y1cV(5&WO@cx@FKTTeJ3!@OOU<$ zu2QA@F8|Jqc}_)ew)|PYqgL7ydc>$Zsl6i8WW5ud&ff>5Hb0$OuXSwG=jR6Ggb&M? zdH5fNA=n*Z+}OUmk&2CsM(uM@?7ys(2h+#3a!~9K|5@b9BhB3q_zSxfA)=}V(eE8f z@W54U=1bd(Fw>wXE6A(s&agItzO5PPxAA z2BN=lurKk-T~oneLEbnG2F?doRr>AUY#5Ye%)`A|rJn7iP4n+sw^6@Z%a7f=BhlaY z5pz;?9^zj=KWmr@UU=s>XS{h|W^bt^+~9Q51oPv2NEeU-(WB?3`56}!WQlj7jZ#u9 zvynBXY#)B>n2^?lZjs!z`iJ9Gw(sws?~7`S-#Wz|Q8+AcEG^#NrXrvqWQz!69FmMG zvk{}G_WFK)44!lHi*JK!jvVDunF#3^M2CtMgAVoP>)FgRK5Na3N~Z#y6x3Td-Ho{M zEt#ainQQ!0iZI&{Nq=FHC&eiYTfc=j=mJF1X-QZ^c|u4qNtta3KZ5?Xb$W}-9T*_) zH$(BPlE7+kSRz0F#>=UUW_9WuMvpVsM@d*0>@RjvnwKC?`RIBX$E#O%i~LB*kt$qgi`0iHBM6hiY&VdBKcGPe!owgs3=ff?ZGx zj6L&9f*lD4){Ua8sf4}vd*{*OV39}niMq3(VW{ysUmlQ<$Q0)eb^x7`B=R#FZ1?0m zKegNM%_{j8Euo+swtJtDwz%rY;~NsQ4TXM<4TdUrCh;<4Ph{=oVKE6Vx)bpI#=MR5 zsZe$M4H2xdRWAkLN~Le=-3`g4*fWb$HZtT1@t}OQ*$UO&84yM@tLr`|9#r~9uWEcR zvS(p_;LmcYbC9>Q^T9m3J?D1kXX*zSaL;WW``IQ%XfS}dLs|TUF*iZ8R=OSarPx&g zR9(ZaDO&8|R%m0D6~Oof54(R1EXzkrMW31&HF@%pckC&FvNZ(l9<~@>Rfxu@Zpmeh zX90g|GJXQpg}H<1lW{WpYD83tl$VC* z<1fPtwiu;|v4?c}Z+^bVV5*dfaIKCEwil1~mcb?>z|Re`0o7c_)Os$Jw2qCAq0JDgPHj$~sLDjEA&Y-RR9CKV z}kA$WC{j6fN44JEPK z?7T4)YA9ZV7;;8|C=KTG^HoD7ICAO5_oR2hIg#!VH(8^#CD{NmIr}8ZH7LnbgwOb057QZEMT!j#-An0C{3C#@!&z|pbF;|$T8YE&$mH=x_bRzkX*yH8;_%+ zxC_SwC>?Gkn6$<*aO$B?CPP}PhxxH&l2*;as0_ZM1Oa>Y&S9e>a8103SE(V5=A%%lKS4}JNws>w)c%G4}qPJ9oI^67EtlCL?EuyLf*-L|c zLYy};*)tLANa7|y{d|p-pT&3l)GlkMUF+v>YBQwZ_p&IoD-hvU$8n-nshwB|Lo}pE zUpR7W%Selvd!g}egQtN-M%(XxoFrreAneN7scijdPe%k8T_F~rzLqO%ngP_zQw6xz zUIu$GP#V;Dn8iF}1qWmQyg(=4b37;kC`P0F_HDHCkzW{!Ti;;Vs06)Mu zzmbEH1gQi%9J~!d<#Wxy>L@<=&7n0}owRH7y^Ul~xn@(Ba`;QCi6mHZs8ifUR&4D+ z*Rq2QNhb3eLG%`}+UW5PK!=-fptoFBNIkoTleF0lP@4P-9AY%p%Ykv8Yr2U#D{kI_ zI30%bvq`ZP@uRAdwe<L)*BFX^&64axZ zwjxXuwY(EM(a}B=)2UL+rDokgAT&D29S>h0&aT#$O}f4y9k(eITM6$Em1NIl7R&G| zc?0k zc0wEC6sZAQi(7r7KdZI!P#}b>KW3`zYzyVi%={(f0{a`~TH)+^=}sIBq9^g(dfLxt zP$|P7gyfxE1`E35VjZRsO@O%Y9)hO~yV9qFLUnMdJAIKaqQ-vjkOPY^^zIg-gB2P% z52}hrw0kME4|j(GmiwgUPczHkGui6h%;9bG)Gb8&vk*aHwswevKZu0*i6;20Tr+Z} zN%_@t1LybrW8JiaQvsqZ02xV=t=$g8@BQ8DJFq1vBnO=%_2z9lMSj0`2zDyxek&~i10@4hd^cGIpf zpn9K=huGm84Sdf$CZrf=W1upCmIe=3ptWn#g6v2Ra&lTHknHOU2goF`){!hQ3+w&2 ztwFl|DJ>}T-D9)!V)#U>hV;B2-1W7-X*pmjx%(d|oVarg`uRDkn*bNs#ANeN*3aQnrhLXnY=cT)gA-$L1tJ=ItUA2A@6j4}_$>UUk>r*CnY;e5bR7U0AXF-uK| z4sxN;+B;OA_Ans!Ol*))wjVu}#D(G)&RftX(iJIy# zW*b@Mb@Om$ygAv20^CR}SH{4R4_{NVTVkI+kO`P*gGwxiC`Y)N1*{vRZQ?@GO+wok z**F)dU4tnJHpF}!;m19DB2?!k2dHRbofzJf5$vhUwyooDAu17dE;wwlqJr$GzvzT* z4IzDo#8P=1ZZ`~{O)m=gj_qAIa*LN1Nl}$DOC)#0d$TDME%qROj&Dlz(S8-AwK1si zyMJ$RF+>i&quFivB*C+YM0Oi4o}NdL|0ahg4wM|F?N?%3-a_r;))#_tHT6_+CKm0G z2jUccdVVGhM(c>GLt&=X$ATMdQZB4M7d6 zZhIypRbJe?Y-Um=h=gD*x=Ns~a);NmymHnvf1ibgH`SNg*L`>VMLMH|HDC%U0bUIu zy)(=HPcXj7ik+__RYdPYyJE?%@#!Tc_*>AIFz@^tv5GJYBcqVB2GgwEPE>3gCQJ`% zw_VI)CrED1ZFo%&O!Aawmvlo*wuLw=mwLoenI8Qj=&%r-2f9I{zs&y(@MH{BTcQCt zyK6-zawivJu>YOnsVdesDGSl;Zz`9gqq4hzwM%@mmY@fuRzO-Ao&?cT;-cSWh{1aR zEVpFTl^r_t)L&RbvT+TY8fC9NGu7wenn%)L7s4;Yz#oa>71F<7<)FV2=*Q-9&L_5D z_Vjo`wLJbzPL6#p-pIy@L!>Z&wIuq4TKg%=Zm^KC#6J2&RLKe#7ooGvf82j}PWjSq zUE1~0Lsl8g`jxY|bO7+_DJx-FVV%DoSIHu5QrKRWlIYX5qT~(h?YmiqhyD(%7-tTk zG*y%f$OOsUExNoe{)Pz#RuFToT$|&D^I6V< zAF;-01Yf*%Bpbz%Ymh{XskXdw5hnyzRrq;tu3LvkMxqo5^KV}GyoC2637bIBA7Q2y zdq#;O(ALBIh?&Va$Rr;i8KI7K&LX1_1oDwY-w45{vVxsDorwCc zWnZMsjzAe(;L2N&AWn;LnlF-#6^=y6v!(q4N3nNMe$m&ZtfsTE_nw+oj~>^~Da;+7 zbqn23yzoCSKq-$nlQkG*zFuZ(OX75}$t7%bvzC;dHAd7^wTv}rdSF9Nx(A9FV)tyI zU}3l+YK_RoyI2no&tXV*uJ9sgxQL}en#T3gBgQE)6roZNyQ%>MCCTqg@bs?4*s42dl5{T(EyN4jW@&R0uKr(syMz zVf}pCuqz8&aGU>HjH2>xCPIOII8PAWkkU5jQRBAvrSicX`6lh*nrDNj|2AOXOjz*h zrNDg%{HVcxw|ghqt{s=d6loUfXuBpxGKmX8G@0pqPKB;P6eyKK?i^rFEAF}b^^aSbk z@x*3Z-35JHy*drwp3jt>DAEn(G2&8y%ig zv+c^_RK167o?Lm7$U$g9soejR%2P7+Sek%tcKHkxafLa!~QHs zIo{=7<=NKDrXFD@rLev4?7d~!zUqjYyd^WouPJ?EszKZYB!|&lW-NA>d$Bfn^j#xm z0^ni>asm82qxkVWW1q>(m>o#mxb4RWa*QU^?2lb=h%SY+LBxIcY9>JU>(!Q!|HXEr zop8G!0jq%IUfq(!ZWZEgCP#BJB#W5_w&N~Cqpzrj4lz758;a)){=Ocn!z^(}8?&r) zd})PQNRRloO8JAI)4}}i3!UbE@LeB>RWNXqk!!E?;oVK=DJMRa;GTUV8-MsKjldtf zcN%N=X-lVB+52{y_dMq3o3QkMgAZO?Iri4Q z^I>mA)Ie&rh1|{`O2p-s8O&y@n#b<_P+I{3l$UR%qC5KVOOzlAAT(Yf?9zpo#LcAW*_NK!mxJXF#>{^uCq7BU*7=-Rf*03L67ND4 z=yx(sc4LqdtF=BK9FR}ckN>zq@pYn=A4W4zrq*(`h2N20Dpt;Q*J|-`>1EuHX)a+-chIG@m-nN(W^ZaX)XaF0#rA?(&@NJnZ!eG zD4FsFSz?P$0y(1EsNs>?#`4}jVGd&8@$`Ba$(WM(;u^uzb}e>QSGXNI9ZYuKt*i7ZcvLXyX~!Y-kreye>^k#_nG!} zq4nMd=|FV0<<=NASb*~YLdL(aa{xvpiHYmKyXdkIX`IdU;?yHR^>)bF5bzbf3cu_7;BXla;0SkCSs>|_z6(HXW)i5%=xZk0K`}wOh5mWNV z;E&_)xToo}JJ-Ju9j^xeG>n~&;~#t`8SGDCN=}ak&!5uT6R}z(74o_7WO-tl54JAO z`!SQ*^PI?7co6%tZ-yRtVIXrT3-UZPZB|F$QGwD^tNc~Xcx(sGab1J# zf<4%pEYL5>ih*#Ph~FKFq6d&Bgb_u`czb#9To_HF8WOl6a@_)3Kst9kmwqhQbiMeQhVog4cTY)1b5;RcqdNu0w0H3GS$~W9T9IxRYfT z!EdU$(XSKLiT4$n7CI;1hpbFSRGbdD8Akarc~bA~>ci~o+kTiAUTaaQoC&W+yvm8) zZ2IoR_~NN1fc=}w8JNvs4j=a(gY;kKrXMFYA{ z_wmJx+Hts7`OdD)e@wF8{L!@x{qG?`$CDr)Tw(7L^42bLKuqjH>p4VY}U1wu(wsm+<=xccKh1ut; zyW%Hl<~+zd0K!yvwkc0`+3B@NFm7Lg>aIGZUZK(p6rSY9G_tHNY!7$zu>060N7U$C z6RsiC14{EEt7E~6*rHN{&Z&T=>IsT0ay!ieFQNdoE;M*~We(1kQ9h!1c08$Jm@i56 z-)R9nS9;?SjH{pbOYruE_)11~IO?8nRwms$pyn99PxpCj|gO^g?yDtwlCBtREF*1x`pZK+}4d^Sd3v#A;>yEJ#)nTI+%d6>dRH3IpG5xNx3ou z?*VR#Wz-jBa^(06^5WQY#?ol(Z;~MyMb!+BJw0P=TfSp^hyLRHz2f$&8^1-5nP;j` zzdunkteEao1?$x(R+PoYCh>mFeTW)5F{g8*jahQKGs4Oz$#`$4lXnGvs`N7tRohx= z`IY~{GaEt7_T6|w*Y@+HjI+nv2}|ub3CJ&S5zTrB;j7MM0jRtRudWV1+LcZ6pZauCPIIGlSZ;<0`A8w{MyA7_W|B9a}PN>yLq} z!nxK6!q%R~!M3A6a+*8df$x0VzSh28O)trbt?Q)e20wl98~*drwLyNR<>d$Q;ZA9H z5WC*qJk_lkEP%`l0TkT!5Z7{fyj=;XVU!1Mefh*Kv-#*A^Bx2!ziV&K$jXtBxhn^; ztzQnw%4{&X_K=$#^EH>VOkZC=^5~oNU^jKrx2$Y~1%lKxKLvv#xZMkBA?1#S+q1~ro&uaCT+z3%II z^_M|A9*FOJ-y(4{W!$#ycwX!i-(6lx9 zL!~+S08^>H;Cx2ud5@UQPkB-6DrZyo5^lBJxUkq=K++R>Z+U>ccF0L)$!WROFc|kl zTV_*OlY92hMLB#RRE*+Rvw$=l!6^&adJCsc(8vXlI6Pj+SH(}a(_C4hO$^NVdBdg< z{R-W$1Hfza72d+Wh`uTich(q?|4WUg#r!yFR&i0CYO*MA>CN%zzHSsksK_ z%)0iY1YWi>=RAMpE`qm7A3vD)lpGf7UwjB+XnpL?*UJUI8xWPwl}LzQD9pL zgJtP4c<`*n@tc0Lk;64RKyC9pC8*Ox!I$k#`*NIbOOYxq@*Ror4@7wL>^c2&3Y@7>(niOz{LZew(iZ`wUm|@=X`q6 zVPqt(FoYNV=8UfGlN-pr_xJ94tlheU-T70R=a9Cbd`!2=qlj*LHt|m7x~ZHum%88X zG#@c@dIe=pe)Hp`68(;spMNiv*XAl7Nyo-k!3cYidKsFf?i$stp;~O>TZ=pmc3-X6 zM|W;?6&#d%)@Yr9pCOYfbab{7rrS}+IY`fS%_J8R!k<3FXW64VpS)WDiG{ z9ByMhqx{A5J9S^ZQCFamNI*Jp2irSM9S9Axd9dff<88VAYtrnt9PX#|q5#Rl5_#vK z2kbn8^GceIDl`VWp2&STfh3_cu4spY60?%uuuO?fh3q9@elgg?~}#w zPc&&>Ys;*<(&$d}VZ+Rtl43gN@90;dFu$Px)W`z|O5>jG#i_B6scOjl9pQQ9D(|C0 z#e-+h{5{!uCh4fnhkfyS2Ew97$*L>7Pi3oF4{3QIWR?2k7dNJT%sKNQXdv!6qS#VBaC-H?4KFoM};LTz?Oe&W(a-6_?|A2^%3raP+ic z*Ag*wJx8)rxOvMb#H;e>TeX6$uri$08~(_nX~#82ohF}|Qz^!QB*^EIlSla>+WNZu z@Ru;tPPG`15~GIiXNjCAh+vc{0{?uT*(;oWWo5G2i*JFsO|hF~*I=!e;?*6FipA`h zvufQt)a)xOI(X=h-1_3rd0r^d6$^9TjDsD5UcbviZ2)bOxQ*ere)Z8?E>7v~tFQNz zXO3O%So4U4FgiM5U^6Emo{o#9W8)`68>n4CqlAl}(~%-KjXf5=!G=gYiEP}7b}w6} zQo;YeQqy;}ZoTFu~iNbRkdd`s(f$XIGpnG{ZTI- zt}dnDI`Bun-aWp(1ZX?Ib^uW$jIG*#zaJE|5Z1+|sy=;YeeaRhOhZ=HiNA)wQ#+^2 zl9f4?Gfgfi9A@dOvl%C$V(>M7N88?{hd3s?o4VxB`5qGgkcQ$h1T5KOT4`F^4heha zAC5iw&%?+yvsSTiEZbeEI@WU1a%Q0q&6kIuy;C}QB@z2et?X*9J zo=AeoesLv9J>~IwDurGQ%>ed_y&AsrPL)}k_4m-PE@t}F#G9p#CUX-h!qt21v{x1% zFH(V7G-RB9#>1U?aW~HzhBndMZ%chbi@V{%#A?HV<*A&C=Ms$q@!eh4$3zdMZDssg zIUR)G6&pLDxgqk9(NvQEZw&&Qi_ArLSHrG!ghe;KdP80OUFZ~vk_h|opg^VS;Hkg7 z%zrD38Laq%!|pe8!;TwqN}nqBJ}-TDs55Hg;X_C3g>6%?PlxQX@sDO1OSRWtN>qL# z@!k|T?@F8f-mE5)JQj12()jb?S*5psSEv5!S9q=q7=U6W+ZX>Uta7*>0?=`CKICDj1?^iZ0o93XLU z@xIQ)s|-)esnw~0*)rlEslnfUR?-BCs}y5aw7TZ2pa-72yRvtu-I3`xI)Qu{3Ic+^X$AF>XD|X*i(MzfYCTXK)gBK%sAY z`N295yGuKLPYD{zo_N7}5txfEOc+r^V^H@TUV(zE9)m`VX3jSg97B!$RnTn14r{!< zUD!^8a~m$7oMTuE>!WxiE}C6**sibW;bVIrjhXG? zv~SQv4?%EaygVxASmu0tc%P~MmC}{&^QQtAe}sFl9@7anZk&~pKY09#@${>Q{SRN0 z)pwk-YFk~D5&V95?BkAjy(4;~bnerdQEwFd)6aPxj(58e8*K;MvvnQ<;zz4J!ew&ab0y#BeHZBMnF%JW>Iqf(bSwKEu@3x>f zJ1uu`eE;^%ZN-;ze}2;^hNR#6P?3JSA9Hv6-EbRQ>(tmd6SsyAvo{`IVkX{X()4fJ z!LeMK#;Zr@+Y~Etw^NNP6@(D%Qp$LrN3 z$fBALE(>2tw?)5WKN@SIuUAusv#?ITKFBr9+PNv+GJ_W@5PjfgmB|8(9ZfSSH=M2& z3xORCK<(}rt`}9XCs3tYKK_b{E$h^ip1*m5o%1@6^Z&73WY>&Q5-TRK>o{2gX?NVt zTVF>i;RJ~sxc5Ko&O_yI4=sP(ZrfLC=0tB$)bs0lHlSB0|<(T zW^Vkg|HvB|oFAlSJg9lBK(zOdrN1~$P$IeLKkYNJlm!B8Tsf85_;cyB1UkE!!~ZAj z4R^>p#JLI#y(PI%9ANw2$fxtsSzg9l!nB!{m%RHnb_euTDBY?B90jbq%lHNO>@Me3bm;xf4rJ->&+V5MK%L;hCdI?D}gJqav zPA5J9Dj~nIz&oi!e=Wpn6@xcT{MdimSBjx$6Nyn%0HVP?cf zhnB|Nkc5-(mnCmHCP*$mSkiF_dbyY^I?8PnK9lD8_FBX58zs{v2v1H;<~479US@nP zNl?Z4?DXWpRQX%R;d9X7!m->fO{(v|6kr(rQNUo1FhPYtw(~G*C%oR4lT=5&4e#^rAs{riW0s60GWO_n0)unmHOF?Zws&WD*_ukL%XWktJ#iLV;u zJol{@L_rL$Bekx49bu3r=?=&q*K8%^;0?@Lzg@giv9q@VYM!>dmDRvq`Yls(-3DK( zV8G{$ij?OXoT$I?ezB<5S`KpT^2GgezQ2E7*t9`4>ZqCr zQ?tG7ka)O=(dU-D@zly$cc0u%HdFVtQ`Xe&NU>KnJ90Xkr`Xj^^3M*nz8hT4TJQMh z(+}$LlbW1&D<4%p$X_jop38aqG*R^W$X}0Nx)h?bK6wQg17qU~V=}gS4vs+|HrsjC1Q^w&EdO_e{iPw{h2iM9%e0;K4te zbq)WH1>S$^WlIT+lwujW6wDmzc`Er_BFB=oBaFA*Yj%egqe{)u=Dg9LE9riYn_lg` z)bWFS;FXu0d8S1=^js5r4iAliQEyMHP+|VJ|8*Y3I&0D~zIuV&B3y{wmskYbt;A0n zn!n$I4v~{{AmHNYMhYqZUr+skOkj@yFBtUJZ|a5o2~pi$%o@nM(0fxd{>_UD$f5vQ z|Ef~abEO-2xU@yN%O$Fvax84*W(2W&s?RMy%Dw{;Q2P6N-V<$!dHZ{i9pRXwtUC#} zhdH2F=BQqHGA=^IWJjf~txV4Sc7~n>^@eBGfy20=~ z20D9}6*lxc*{F?k(DM;t`5;jcxq}^Z260u1%~8Jj{3kmRtC&GZ2W%d{j*fY=+W)DY zh`RFTo8PuwV~JDcuTKhpz6^3SL08%et9HIJonN8vWcsKB^p|s(dgt%EVAI+_4L*89 z|80)*_g~lKe+;bdED-opQ32rw-thGUpA3%bB2!m1vNqjPKM2(XPl%rUk9~swvd^&_ZJ6^3AN#mV9?4mi3O7zG6lR^4~n+hqFFasdHl@o36_z43{d}_>|+|cZX z>z0*(ywc}DE*5D+ENvYshk*)0TqCRQzv^=zWH^jdyDV1RfTkJ+pLLb_ykC!vl_-W0 zdfz!gbvFpe zIfYkmam(<51p}`So%(hwuXc2kpL@0ldXUOD$RF!<|Bn*&yg`E+{@l@6bq;F`<-=o4 z^dU3DeQUoRt9~Dmy(P$apa<9pji8cgZc?>bMsnPBanDMj$BWgofheqOg;+9*(WJbH zGrn6J!d1T1Zzoq+i1U>p8;{X&oWXP;iV2;wQZl^&cgpzUNL+BtPbZLt4*_I{_xw=p#ClcWl4;OWF>c0qBfLDaNBXeF{yn`HO%pB*QfZ5> zGFM53MZu*44Wehx1|F&daPJ*bb8RphE{l)GZLBCZ93j(#CdR}~@~c1mv&u);L?1rq z3i-Ph&~)Lvy}&JRQH{0etHVF{ZA#6-ncfVP9i(->Anr)8ym+(hR>vIQ3wJ@@L-k-~ z=bOtV9hSt5(jm(lM$%lXy$p&94P%so6v-@6R*ip_)z z3oMJ6oCEXQn-?UqOzdE)5*CSts*KmK$=7>?JDa=(?Ud+-J@wW@D;8Z-H6ud|9I*T` zbv^B)e-w=HOH&m^HE#L3n6DxpOL`YY2K_z>yWYKf^bxNGRCuX7vIRV}`^N26C3cSo>)XITgDhf(WN);W0f!oV(_Um=m)1_Lrk%67wIoKgyf`17 z=&_t_cm37bGPa>4>O322jK>5YBH2ENcmQp;&bj(CN8qOL$8XY4Mxf(m#H|EHy7E{KCPhSHXs5OkS0u)a<$w@fic*S{f_*w9CPG z_T@=k0g1bwwQF5?vjJ3yha@*Ybop5k3lts&oH%fB>+|eV9sqEylDwniu{hW!V7|IykfDI93NpLtqN#W(4t7Y*=qld{) z{dS4qk@jdeDvWFdp(O51Ic1vPd_uwFSMXmHQ$7b|T5Ybj9ze9tNdhgfsbERO21^ByhcOgRA=5^ zjW1*c$Hb_9Dtk_GmngLzF;|6trENhzoID$mar(pPAc{^ddNE8nXg(LF8D`+ z;Nw(c;xDrcF?AQ^0&`vwCAvK)ApRk8>>4i zRIctUopAy^xkjdik{XcetIw_g+#QtAdg0>5i6_`gy)C)v3d#mrUyNjoa^%o zog32cOx5?@eqp75dJ??9xw=j{ei~WNS%Ar~A7CR&Lb>Tj5HhF;ao}9RZYt+|BU9Z#`B@r~YDI8q0Z~3i5&o$t5qCt9L~? z4moNIzpb}a|B!TavSP>W*8NeMWfOX+YdN+JblXFRF1xJx9o|CaP)E7Vax_sv=qN0* ztOd)#(T)8rc{kTCfAgIa#uoq)xQZ>_20LbSO<{tn?}+Ng)^m(&su~M5K ziLx5&&Akim0hqqmPKAs%O=}!MP?i=05pb|Tq3#W*!yL^03r3pg^j0ZMM9cMFTnC@W zs1L)tz1)QUk4RPX>ON5ZaO`2;x(RjaP+{u#0QDRAP}Uoc=TLp>Di6Cp_ToC?6>Oj?TpV!YRVa)jCDm!u1G4#P-C~(?NQfi?f4!J(AZj zQ+@?ncTIDKcYY$b^3~`J8uS0&!`BbS zS=2T3Cb261R*o0|Z5}J0N_;zx|7Op1>Ar->=%h#Uy<*t-T16jysX{4|zkU4s@Dk0? z&;+CHhB+&)8-66Lf3O+a6c)aFRU^;IyN)PYuB8`$hDHJNbuZuN8_Hddaq6h`9E_WY z`DX80a3(lzzTu)yD%2Q2ug8*-|Kg4+gykptZ!=;6c@q9{fQd0d4VhCUpz$oADSf+- z)BH*`8QpCeOCN*j~C*P7d=&7^%Sz?-_%&R_u`W`TtnZ!GAL*4ny7+k18q8>#F zR2{vqn#Q#iJsh2kI^89*~8@3JwI7g zGX96Zm#`aHOB3hBS`m#ZqWahItf9g@s0YgOWQwml0G)aXF&fLW3Mop0RERkxLEOmgtYAnt5ip1+-)9U#KYh_y zy-q#paH*2(D%GUxrhM*sHrVlH?3v@C2I}2u`AU(-D`>QNXQk>neyF)@k#?G`khtU4 z=NDR{I_u|v*?#A`N&-v zm9P^tIx}9uhcRCREhzlO0j3bI5C3JZ1y*VAH8fuU4-` zvFxbnu|Z#UPlKI;@GPbta(s7PnPVm})gl&b3p%u{y5B0Yit9Uw~Ukks-W-leVw25)&7-Y9A96_%by-a zbPyp&HueY(2a706T*k=;(}IdSanRwt{vEO6Rw*aVcv>b^G4D5zqU2?yx*m6$cw}n2 zaU01zd!5fq9ak=Z*9I&F!V7 zFataQZ$lSGrYS9scariOnc~J}svMj@x^z#~_Mt(?BUjySW7-kk7stF6Z(na_0Sg)h zF-mSxwxZ8=U(v!4!RNK9SAHt@5DDo4(xmoFDD;v7d{8qU;_k_Qu}Yl1>*@nT{)=G8 zG{On%_v(#g6&L`)gerO~{7uI*t{Mo#e5^bXp$XUU{%H4q9=&Mms0ZIphDoKL zwR3rVxvU2={W~$P^KAZ>Tjsmm&sJz_v$}(6(n{_fh#{64fWFNJc`Q~@nMoqY#42H#S?l^qs z!`b9J^^k3;FfJXrC5M&|46OJ8WI~pT=t<|^3liy!M19H5SiVJ_pRe&7HZb1*BEnra z1rh{sNGe4zG6O4WVy}Q!qX=gRl~>S9+KcbeqbEhLXVC05{#4k%4L#kjTlv@f8vYih z<2mZA1!v|w?3})G{KbAb0jOtbgzT(+ECv^wCx4lNRATgevVQwQifM6jEaC#zEsnHRlew?*ni{=5_oJ6>F?mS%94Kps{SNh{8Jn){=cWUE;Y1tL1&#cBNQ zDJS&Y5xP#%VDbQTO4#QA&PRp$b=HkuMV<=cl30{z?-DZ_#P#<<m8cjvi~iIxZ?i zqW=RKeRUq5POWehP}WI8Kl|f3!M`zA#;HFT3kvCHe;+rO*F<%{NOp4I)1{m@RPe!y zr;z^4r#=Ai-$E+C$L5;&&e^jZp)?}w4y*tu8nu7q?i;Z8ozK78)irM0S2yL!hYy3} z#`-^2-IBaw<-ao=LyTvL36h?T0@s8;(~9gmMm;_%6bZBg#IUCM?Q1s82hoE%kgrg( zIXrH#g9^68VwQYc1i zT)wT1xy^ZFl}45uOH!{y%a$v}Sef6*8Gc?NwL+zp=aql*2+wdPm-$PPn6{FmqGE5>BfFuD-D6p}`>f~rX|U-) z-{RJKq?uLV>J}?yvh5-H_Z{bkC>c5H-0%ajUq4P2k(*x|D8JHPSlQ`)Vx3z%%tg_I z#AC)@lqa-p*%h+A4iwD10i%n#I>ixg{@yrZ!*;Rv-dS_A2U72Xg_th<{$ptLSls-E z(V5KTs-O9;Hwyf{J{0;K^4&^k^44lNl!(0dN9Z1QQmmNAlKHB_5)F$@qm9G>Jl?LT z{h6$*-34=D*Ek{_LWdQiO${0_|K-vAA&mt7`8_hmt4xl&1%yYT+v(uUU%dwzObM^F z@5NEqn9BgnF>GhKN+Khc7-0NE%j)Cx;6zVrCe5+bzgP8n@`^HF?XP{YyUW#jW2UVl z!@m3M@Xfp4?{BuWS$&^C#%2PS5b-P<=04FbzH=|K4DXUkQf`su9dFncuF3ljT5v!Z zN-E?vm;sd`Un2OSYI6b+RO(gy;{7{v$=k2z%fb@vF0XR-C3y*>F#5XsYn-FkcX{bm!1z?maDqkk)YCaTm|&=!`VbZdl|+l6QJYy?t0~qZsV1h@d#m z+TF*d+zHZm{r`+(nNs!j!Z8?gC%Qt-r)y@2O1Iy3bY@#G4CJHx z7MJ!URpdo4NdRB%4mXogX>V8Lh_;U8PW8IzDM(dV!xp6CZ}`bSh1~aBxgc3lrsp6I za|*=g*X^Tk0Gtr{SrIeTD-m20Wnbj}tVoS8;X){&h138yMC?JIN$=M%d2RU5CZv*) z>8U;iAf$w(k@&?40J-_2ibv!9ntNU6vcFOyva384SCMOZ(r~AP9o>N&tDPI>Wt$}e z-6fbCmwipSu-o%@KhDK}E6c|}d+gw8ELknm(`EKkEBnz9kGGWHqki$*x;1C8iA3}m zru;sEUlre;cB28N<8Ym9{^1FZsQ81Xf(9ZYR3sAD!VCIwqXFIh$nubmYz{i^Y*MgY zB7iHU0K7F%S)x>!TEyGY>akEmKY2%m+%0GIUU7mW0P;INZe^Fye|4|Ezh$?6xeF_)F-l48eYLB9Ifx{*s<+L| zQlj>UvXaOmqrX2I12qYse-=8=r<0CVh8rWcM}}XRsyyC#(*tZ-=+1c>87V0hhwo&2 zIz@1qBJ<)#pr9I_dVnE$ zy&%TMO!~`(NmmVqs1*JhmftH`5uZ#F`YR<&F5b3?JY+5a4-VA#=FLDodi_MtK6*<7 z?7|hZsgd4%3df>V4cFsTdk(#pGE~&_p(}lzjCpQD#=u5co>zadj?%&8Mqe7zr?n8D zNci;N5JMsC8sj!zR=ccB?vdJ#B?yzo5{05+d!SfT4@!`4TDtcBAGyhl=KRDv+}Bn9 zoV2V=9~E#3{n(vcU`mBQKCWRhI}bug8?S=2Ul^u248-NJ>S-Ak>s9^_)_4 zM3Fj|&>VZN5LIEWjERa@MjvmKHO%#qy`#eomLLS#Z(is8%NnL`82)_vMA>yR-GWp(3o7F}Kg=zuH3$T~8rK|=g41S&= zT2vU|W~c%EOg2=WO?o>m&lL6Syt_BvM`c0zmN00M5LI9<&4MdX0kLKJewRAu)!H? z)?!eX*t@v1SI6b}b65O`D!*^&9^BMpoA7?2(cWyYbR@OcW5|g!vCQ)h08&1$qN3e# zKA1%3mt|e7S$6&PQKETaa+QvVUu6MpFc5Tr4#z?uxA#O^!zRD=AQwos2ASd0$fKqJ zoPMccqwLj7gm0?h*=+BYIY#1xLcdUf08K9drTd~BW-M`Th*A>QLy`Y!k1sJkgLOD$ z!z$Q0HGSj9@{!s*6k|H;!GeO7to;Oxl9g0GtF1@2;#JH!d5cR0wfXE}aac0t@S%?NkLY+U@FC(FDDOo5Yl>H>DJK zc!&kJ>X<9vg@*%WCn({oo@g@iP`JB(m{=lFW@z*eDQch&@idO_!UrXo6>#!loBp@0)y34mPxKW^Y3Akdx#E=;qmf~s5& zS$iujC0LPhoaK~Cj*Mo^je8p<96ope3o%ImrF;g2I>DFJeCLet(u{z%7>hC-Aoxp7 ztL%xNBbhUoW&(7`cMsanN7sC5uznRN%-S|_&ak6aJ?}T+Yj2U8RG>sSXP`t7=k{A1 z56g$LDDD7>Xg^kot1ta~?q>h+zclW5`V%*su7~sbX@Pq%7Kxbn5<}Tvy9lJt!K9Bb z3}W@Lxv?+O!Z@%I6n^vK_Qe@>_XccSio+;nx|xzJt^c95leF^O`2^ub+0om3SfRJC z+bk1<4cQMMB?RPMH@l_1^BZj0IC?t2z(Zh>&SJ7B(@q#C%0?*&19Xs5-$s%6Gq^=s zepb}nd<|>)EK>HJ|9ydhTk9N(pIP(4!#o}@Z!@v0KPIvVIY6O)Tm~>zoxKu)n>Fo? zw2(9sfX#Om$Fenm9Ra;A5x8dq8bgtvltltkjr`@040)06-e@0=#iU&$1 z&IY|8ai^!~M_=TI30dITJEv@MN>Q?K6e^F$I7p8W3OHi!|1W z+>uzOOn>qHo8>)Xbe0HPxa}B&O_AHIAFfnh`_HRNR-ZyEjq(LtgU(c`u~obqgt8b< zpt&p>6iFBug+COLnbA_X;5T7%y-@&w_A(Y>pwJB#9Q=pGf3?jr>=2x}3p2 zpqzzQX+DKiJZQ#nG&?dlp&)?9Zy6YJmefv-=OLG*sKJt!X!u2fIV-ufmgp<0ITB30 z-!`Y&0S0B7E~db9qANixD17_I7`i>5@S*8TC6|EDWH^^g-=8w&L};b&Ts)P)gczaV z`j%Z1bRe=(A=Y$)^;{A(b2224EoIuqUq`_29qC$q5B0h3sO%br?TE9IkBWV?3b(Fg zKANzQte(2ox?&)}!0|dw&9z@l*-(!nFKr(Y!xV!D-$y$x-#X@^ctWuJ(u;d>=G7CUFvLrt>q}1B~FPh#i4=C87mV$?~P- zyMV-pi623F#3*~218)-pvOq&(0eb0Ez~TW+ID7ec3dAQo9-`|zR}}*JLP|ahPmcxV z7>|k(F?3H#Fe)`HMMe#mi$^{&twBGZ>4xpcK=@RxCT5z^{qMkLW_IS!?Swbb{fo{* zTa*RVTUz;rE3j{gzxkDIk-z!g6T;tlSkv@h{HKuT`lTgow>J&_hLm5 z%)(z0g+v7f-bDUnY1cN0NUBLGIZ6nS6`=xH<=bqfw!4M&KnW2@k=I^lxrA^*fuxb{ zV&#h@^n}ZApE5GI)N!LEW{=X&?mH(0pW^~(pD7RrKri51^3gXQSX^3lK)opPu+hH8 z1@rs#`?qKb`|#ZQ{QA8Q>%64*F|k3@ZCgs%6sG68?CF#}v$nNi8vp`*a1 zi;IxUBjY_k9_fbd{^kc0ct)8D(Iwb-NmojF+#7jREo|cWzl`SLX+TWSkKa(H61mz4 z9nHo!fK+r6WOl)+qG}ouAAkfW(BlEB_ZkN8N>?u0fZ@p8XeXEa72f=kRWC{eLt-fa z%L)9TUreBUBx>HGw|v_rh_ZA0FzuIk*u?wLkBO%FS)6|H)-ZtK z=E4F+ZiRav8nyKP*}=|*+*n+(@3sPW%3Y-zNW5u>-9xSlkB;4Nrj*=>le|RJbvy{G zq#OPxC($(WX!l}JAh|D+qx|@f^Zst9{r+;wL(*1HZM1|EEM7tx9{2QVB!e9AOy%f+ z9)PIwdOvy0hrxCUkHCaK`$+Y^QY15C6pi%v>1+ zoCmOk=D(veW`0~KGFU}!=y@r6hEX)HvZe_fiBfiSxJuPnuqqjL!g2DcnWOzi9%swc zSinLTs*2)zuQ|8}{vNK=9`&tB)@JMu-=n>JM3V7sIWnbmt}T$CPMz>88xN(1aG2+ieytCk&!_?R<$ceS3 zYqjubqAZ3W-}gW^Q_{}T-PrQtibOSg!qXlC4ewQTH%{*JhQ*EV8C@>@3HH}cXgUvqxjn_hquJzfAp-! z$h-|>;>;aQdLZHF${n^{&up8{|M*7!x0_e&jn~0dKgj^Zs{}O|N(|?EW1ANP%~%Fe zml^rQzDkr88xnsNTto%k6o$43hsPrjgVhi3C@L81Q|dl$dYnTgiT`*1=~G(9g1G^>KU8{P4Pwa}R$V z5YH6LDxFh`xkKGT6TtzzzfBysM3|+79CAM(FPI~2x9tTkXiF;=8oqgW<-68zQU}6x z@L)X>71Z)Ys`%T?fU)oD;&nPWBV3=!TR)O#0`9x|NIPQtj`lA3`p8TKg?z8rX|Of4 zy5_)0PssSf-tNdmPmnh*{q(55m$a)w z*wli>8J$UlJ`D!c>F*)YWJ$RDXaZ;q87El&s|E9Ah$25!27r6ZVOphcV1sB!2Zjx> z6M#t?c2NK(NrW?3aW}N7jH`#{-`mUeo!<_g z{tb!z!@nCI$Ta%n5}VqjQmp;kvZ!IsAI!>;L3Eq?*;IN4Ic%?MM`bXz8Fzoh9>h=f ztW8mJw<`+K(Je!DAI8N~g?4~BL zNCU`o96&`Ex>WleE?DZeEcQ$tn0tzz)i&Py21B0Em7rxttyuPcy zT8fJ(oiC<{Epj*O+wQz&@IO-D-wK|KI!bUpCG>`_M9)3N&;W(eIy=n}*L6_7Xa!v* z55@e70`=!cJ0sO1s|(<3dCT&bnQ*q068}-Ar_@QIgya%2(4mcFD%e+kERKjiLunU>EwOtEl2{Dj%+4Kl*Epg< z+*mhXbQv3j2XEeUhSAyi=(ZsCQQf{kqlV{I3C6$X5oHEA4n|fEz;%)qP~W?wPER%r zD*KBj7Y^TrV8GPg2ZhSUMepjZE_gB}%e%U#MQ3;RxyOvVf5x6g$v&Ziej0(mv48o# zz*bS)S;7F7Om%#_uK;!#_XKhg$%7sJ^aSz?Q?9#Di%qQOUr~(FF)1nUY&na_ACn=Q zA3VAMkCO-xhSB%a=Z4S8{H=aog#?bJ#NV@}?9b|`sI*-3F4FKsr>FnRM)Fw-ry{=I zJSL_j7%={ecLt2BZ)sx^EQgQBh1Ks|$oalm?P})}AY2~RDQ%ZG+Wr~^Xg7-Dgs4kji8o;!#Q$|ifUdAB=YQQ0m@$(4S3^`7>pz|Z zVb-F^PixC;c=hfVeM45c3{$5;l&JrA-k{%+=v=(T8uZr(0cq0N?kJsY&zjPKlU zt5=Submgg3S;94&L?BEqP)gExk%NC_yW$``HEOib3LmFrh}cmaq&x|y=NCfMy1zjZG% z8hB#)y|tDjv;IM9f}cAs)ssy)_)yEHO7|Exoi|CKZQI)Kv?LGYQ# z(5w7mkLr8Nlp0}8UDzQlbWCHAj0glXm8GT@qPhKax@WRG>NeQf_WP zPC%@GQw3J!1+W$?kiPjW%Yb`p6of6)A~E z%t}8c`oxyg0{kTL1dOdu%_lnOgV&*GvI5+bh~^Ez=EQ;iAP0v;;C~x9y8EoyXn-IO zckvN+t6DZ^#ELjHVMKY6ve#pjtd#F?g@tdtl865YKk~LHi(wWH!MPvm@6Rp2Ju1j_ z&fNTKcK;k%q8W`)l80~?|9nK0JClVGF7-i-^ z`*j3-tGhGHA&O;zUg8wxq!KVHC78zoT#_^>4V?bD15Y9f`q5~c%9}1(6lYd|WBofk zq%czki7sJ> zO74RyIkM`3c9u~Om=vP)@-bE(&Md5Tp!8h5&<3%Qd=+WRUk9E08_FX%RD3z;2-nnF z7OU&P^@|W;At&DY#bmlv+XW$DU+{dXLY@{gemwy=p9TGiDNWsN;g10OOzecNfS1vz zs1eA)^JA&)%&k>iP;rzJwhH+b$t=F|Uw_BK=tuwEjZFo(yEbr5=`r(H2v>ct{XajQ zV+LG0yulJVh(3(3dJgFKZItAL@$ITf5S?VC?B!GM++Wyah;z;{y?nEKwwK`e=XFgl zK`)7Lq|MRZ-V_WKgB3Ls^nyej@oPOCW%;0gVDshpmcbDlRzhF;yU$+tJ<`vob$Jne zZ4GZ}BRKX0C_DTqF<2d;z63$>%RTjL_c#_$YWvP%P55!UKMr(E5`s${OAMTAx{u)*7OZvyt3#m7$kU z7R_P}iB040*&Rt0SG5XQb(WeY^@5e-ZrzIDxL125(3~dx^b0z|Df^=Ta6yyrL4S`5 z?(|CF>GkLD^H>{zqRDiONiYidCP#Csc*=?M{^(-g9hVN?F~mp)ze-5It@dW~cPki- z#=!Z&DQxv&cTQYLwKB@-Qm)x)UC7Vpl-7o)4P#d)KSXeM{#!FBwan>%dWVqiC_QD- z{^K_?df%_`<4(3FA;mNnbP5+-nfakocpe9)+sRcJ95AVgB2NX)ZyjT@r&Fjc$?GAR ze{k;&2*5i0y{P^LKyFEZK!+#ZYh4-7k^t(N0jFL1x9=?VV2UcwHbq`27NS#v1Q&S* z?*n(}($KgMr?{8#)GO&vzPk&et9x=8uTCNvSr`O~DTf?{QB6)_x!8Ad`p(k^T5ky> z!DolNwRAG^PvT$f*3lg9)w0FWHv!-ex7A-86k@KR)WwUO`p9)51;=kZ=PYw*SmzaE z#l~`@H}1$0V^)R=u@>vq8yj7?y@O*F?|D(A>x^A%cQ+dB>2!IIw=+lIF;kg6voK` zVD*Ze4E9epr;>G!$ic4kc9klvdUQ|^ej*KjqkJEh$RcIuF^^`lPX|K-sZ$!zJtRQa zxdIjrz!B}ELE~jH_513x^jAdzik&{VcxXgV$h~_nC*FJucbQz!;p|vjT~)znzr5A0 zvo6d;;XL;g>(nNBeopfztGu~)YrywAyG9PnLD1Sj*|uI{%kCXHLhUbFU-^U5R6#X_ z{mq#bx%h`+irCC^4GF2vUH3S~zdvV8Vr~f>z1sfOHe1JA-^BE{Y$CsCrPzGuM*R0+ zfBLCVC0QrRx5C$5bmuXV9U_pmOprRxA##qbEL7Y0OlmG$V?UKbvw18Xb{tA&kse-D zyNP(n=`ozZ@{`8ya{_4PE*qqa9=knW`i2v$n@tP%+xz~wrIfPlJ%d$iZN#KyB}2Y+ zv0}AJB}>!jq6Rb-!V}+XIIWEF&Das-V`8zP}H)khy&S3%x2kIPgs zNohLpfc%=gR1!}X_R!sG-xF!sw7wDB6ageC)Hy^M&QcLNjmFA&`HYfi zHPM{RUcV>Au9`D$sNTK%Qbb`^<)TW)8Ryr3-aBv{h-#p9p5aak=e9T$72yYIu{4Q85RUrLusFwNB&=ZdM=CC0hQ< zcuA7CJoQ=lVEq89=|?;1(t9E(=B_jQ5C>M*K^{J$rHr4B0(CTij0tG^Y5q50QyOwI z2Oz}3`!-yb2CK3f;Y4JHURcs8pzGg^YPXPundRqVR2<6I z&c;++aPGyYyNJHLyc5wiNPO=5C@23J@1IjO?NV=PLpX%)anLb7Oek*sQHp%mb<&+% zOZVM%x4wH3`ir}YQCj9v`ljB;!?qs97%}Tm@^qXA6XzH;M9G~{YR^jgX3{t+_<}C< zk%ZOB4TzF6r1pzHz=_E9d$D&cEWAgD)f)|#sAq;&Z?5p8nyx5h#1#=hJb}Ey`h2bonI7s8jzwKYuE3l(6H*Ea$zgRt zD>t(*4Zn8Nf1?wAee)zu;u5V(KDT`ObEmT*228gu70$HY`Q|)$kcq+h8Rxw_zR&#h zoF3ML;DGb}x>@z?8IyMCyxRJq`ojel?Bn~IiM+T^$r8pZYX+NcE`6vtV1k=S#o{Wc zX*ou|YfMl3Gj3Fss{@CU1?Gxax8Em~0323>nQ1WN?U}(VaQx#BZRA13Z;JW0e9So- zg*IPzNrIXtJP8F7#&*(SwaLlsxP3}{8a=k=6PC~5IXZh41jZAyNfpub*psvANHt47 z#N2#lJIW2$JbXi?;7~}<{r8<%P|={R|Bk8`?1Cj+(X#d+i`p!gc2iX4w3&* zjACD*B@AC=+YGQE_csN$hFj5W%`2wk>v-E1G^kiR_TH_@DQK_&%Y)ezUAuR3PJCwvQr)YyM}d{)AM@yx}NyN*pw;<1X31+u^8466C?cma6} z`lsSLmbK!S9y4SqJ1g&>L?8Kc4@(T?pun(05r^=lVv|z46v;$&B zPeMdcMQPC6gMSdhG&r{!HN9&uWnq9a2EPG(M_maXgzHm>?+lBT3n6!IK5qc&*Gy9@ z1l440w1=rrO*oQEJdfQBKVXL10^n*Kz1A3EfMpTsZY%bwBy1kQ<*IF z%9CVfXk9T)maZ#zrHwbKn+1BCoeru7dnx{|Q=t5n`4ZxPTgX-6yC}$GS_MVQGOtB5 zXlx!GB1>J~DS|b@YG{wDZIocXxO~uqU!d+tqfwxNxPHd|+E|`YYH-0dh!!LF<-Bt& zyLc*6tA4;T?lN37_HSLd^4AoeEuh@Hs8?0NwpRH^C%?EmZ;oEE`>j6bC*|t$w8Zh^ zn(^iaANtO~{5^c2)EhVWb*B(+bv(VJknQCey3(7*_dMKORJk!hoXsq;FncIq>=Pd| z4A}{;(gK47Cj%4^BZs;aI;o0)S5m+QT6x#>^CE&&9^xOjq*BZyM<6k;h5to^3?@j{ zQ%IpAFSarsQ?ZKNd8rHEaCaU_B7n%NAhpQWJ1n?AxAp}y4UV2`DowWX;*pih>dmtk zssGojTxNVl6s>tY_x>hgAagqACM`YIaE)I-w*OmoX5v|h z%7VUPagdSUqlpo!TV{0rpL1FzxA$8xK1qb!s8M4=EHOa>H7?*qO2y!Q_2{qV+Qs}Z z;Rj8Ig=DmVue&|covB8R8ore2r5EG&WufL&3hwJ5Fu`jKGR6i3kypa7rD#ajtMI`dBrq@lV%Ag30|XToEQ5Cf94C+*dT$4fHF71h5%Tb-#zClL@W=M54F?7eH+04+TBEx znq=UJSAV1-K<@BAzYjPdz^V?3E)Impk~CYZE*0gV22WdxSO{JU61LKIoaYdhu2ceX z(6X|TcBT<3Z~4mKm_(pqe#>Kc_%;nfGqmQ@Gk?R+Q6Sa^3};mwNQ|ljxPs>??<@^` zCV{OT0w%~BvB6%tFXj1c0b@Hz?P?odf$9yQO9FV2jx>l<93@$-@|~NrwTEfsU}r56 z_h(-4CgGd&wpF9r5UpiII!1itYuq!xJtf%JAlmM!YcbC~UyxeSpPwO@&NjXmjmlPM zl$vhTXELZgL&Ld_LN!>EBG2<2ahReW_CKQN)Uazwj5ECEsV2T}RK%%DiE5pVt^Y&o zf?KHCvlj4y+vZ;x%ji?62?C z{mEd4cK0TC6O0;{$uwo5{ivN3c|Py4GNA9B0R#=k5e&DT8ysX6MuVLm*+DuOIxfOZaRQ>Sc%LzD>9J+%cS zud|i=foJ9Z9=G#h<9kUHQ-Z)8#Z!r6;Jt=Vx%&ws~k27+hT#>m3-ZkknUr6iaDpR>`X>hn=s02W zS>&-F#a34i5q_z`+1jYp+U!>Y{p7j<@T~V`hnrGaL_vQ*sW5wd9zVxuN6(+BNumCm z<#O5xa6;zOAWwYVGgm}2l$^j51}>H?b)n!uGI#L+m$+VTG(Ox_B#hR z3^$)(!e-?EX34Dq1Xn_0x0;bkiB+_XO`)rniaa)v-;I?;i^}4@;^PBB&2F=okeFe{WUaO7KdZ z%6r0t72&pE$CbXe&o%GiKKSaa(1P3x5OU>F$)4q6y}E@`U$^( z;D`1{XJ}}yWet{wY)MKT2z74lo_h)54&5V!t;>>B!_VCx`@uS|w9ByP`Liwbeb;8R z6EmZRen7ZUcH6`D*=Idbf6WAu7LfNY(LU=vRpF68jc%a52z4Q2(-U{kc$&f^78{WS z6gax2g~1HXCp4%fqoP)wO}cDM`6DB}#s`AC+&I~L)K7~E+LOo+3I@D-aPT=psVvr` zswqC?)ZJt+-eA)Mgg3eqIR5DrlvcPVHOA8U7LOOo-Rl}qmVF|(i+;w|Y?!Z)Kt_pZ zICaYRDfHQzPuYMm+;e()>0hcU&DERpAHOw8PR7_@Is|*PtemX5Ea<;0iqcF7u*=#* z6Cq3v_Xh05FZ-`)lZ3)+^o^lHc^?AIbE0n(c zMdUoFEY2SQ20o^%8hn~xzRNr?+>RI+Z|L*+LfW|s?X-?BN`l14gM2nv-U`M* zfceH`{*#n9Yop$iM@+7DmpT^3OXcwqf%E+m4~ zJ3Pgx9h|LFPc5MAaPtq0e2NbC^NV?@KA$lO{UzxA>9yQJMDUHv3-@3how1sSF`8-x z(>)TjEhx`-vA#Bmf}*uy&5|IlbIw1msMRK?$h%*DwgIH}T}X~>;~8(T3Q^x|u*BVS zE{H|taWR#Qf<R z^fFxY+9f4=_EuIirujzc)-WruY~K^zOK;`W5<`rGWoF9T_mY^ex^%C`J+AW`4_#BC z6HC%!vhr){8whw8#vSU+&zJp3)(j|ddPNsd5kR9<^JDWckTd6f?2Kwt&tif{-e*ez z3P+tY(@cnuncV#-Q#+}5cGCBM2Jloa9k5oWvq4jHA;%t#UUn#V29-fz#NYrhFs6Jv zE4+xark@$dAViI2c*PB8@8`hkSJ>itfJMj*FJgsz62P}xFxFjc*j2Vz$nupU#m}vXVU8OazS#>kh^o!f$Hanp17r-3xvw007Ag%{a-bz&!|eZ4)M7WjPmVXraqT^ zy3OlXo(@v0TEPxqX$tH&dehhiGaz;veWSF7p65N$q=(uTzu@}jO(jsZDJip3^7aEw z1>e)xT8Nj_H#9?P)PO2WqoyBdJsKPE&J0pb_A>u9uw)|U|4{Yb;Z(oz|M(f^vG-mX z#ZmSuBZQ)?;v6!LB(ha<>|<}Dh$2ZE9L~u~h=Y)kQQ7ktk&(UE_xApLzQ61D{lkAQ z*X4EJ_w#-}AM4g0Cik8FU&I?tE|i?^I|t^*2q)WX_xro30gCk#M*NWbw5xrJ3ZL=l z1DsRCug{2@O-+%><*&S) z(rBuOf25-U7AJgRTiAV=`$M1RXRie4+4pVUo8RwiQ*2mQ*{$w7{(VOS3J>GO?~G^- zcJWAeTwekVuUrKafpM0l2IflkRHjfzp`>ulSMc}S2{)LD0ZIzB@*Fl=mK@@9B|FZeDw8Et}jUJut?IamY0K1Ndz2s4Ikq-q=+`vLlL)x~Q6F^ini~V~I?`~*6@zt^~n*Q=gj-j8hL;d{P&4wQy7q90mP{uIj zh+U1gvKn4`Xz<&PTh0|0r}nT`WW$nku5#n~KEf_k8qL>5i$*r3TrLWgg3ujcf}_ME zjS-|bEFF~AJ8z+jTnJ*o7wwnh^-?T!&>|17Z%AIEIt7HzG2kXlTH*4?dz7e_uDS(h zo7x48a4jnX`p%@`1lGxr#QRpj?mi{z&y(N+aho=*lQvcfO!V!O-UUpxyzk>qfvHSf z!Gr6C*gjU>I23}kAfEtE2Q^wuzC@e9njR3{N=_cKe^ ze64aucdc9Kc!*%sk*AgoJSQoc^}6pE(b|aT;E+Gj?TM z4P%$oW&)R<{+Z*WN@b}L|Dh3Nj|)|GALqXgp%YpBlN30k^igA=|Hq}eynC23GL|f;I*kS3=_DGB`~tr9%q(GKVc*GdzpaX@R~DlBU-c;Eu3@8 zV#PksW2LRf5{h09P`N~shd*=@z?D79vOW5r zG?GQCl(6dMwUg?WjdNN#Q=7Qef9@X#Y}TbPiJ(S0#r$<+=mpmKU8o+u#+OImRdKMg zT}WRvRF6;6pn3JG?ApqQ@}kdImg(}`hRfBwr}u5s=UB3dK};RXZ~6HIO@=E*-cGP* zuuj2XdX%$7fN@{@5S>7r?2b+0l13Z%NRmlrIS?c`k%>GCEJ4A#d8OI)(| zBq;0p{BY}E9rBL{m<`xeQ;>vbEY;d0cCn|6J#Ab{U?vW4tqJ!6`7ga`;ZS3`+^V5p zRob)QX2j{9N`s+pKndrn(!dJ~;5kzxYF`{up`(s93iW2P5adps17mi6L%QZ_d!3x- zFGeCW!TPZiH9iAuy(SnsKTo^?*-{t@P-bh**wD~kY8jpTV|@MInd@!ytjmBSx;YX1KO7MNIY!hbXDcAe?lib4y5~(xLkS{cVlYemAVgbxWOBXv(KTWj zAkNyR-sE2LpHi2k#yhB=mH6}HwhqjQZ!NHs^x)Bhh@$xQE1$wN%KJ;U#8c$hI-8qw zq`&Dv`}vEzP=Ls+!XCf);Gwc>08ZfboX?E$u6AYmq<9?(|L&6|N!vdXf=p8u-?nYjCT>4LA^!bSxW>t;!jJKd+A^hh3@|gpUR=l&7Q+NgcSPD0L zKMq%*WfL&rDx3HqQ{tlju`I(eSFgT7mFv;9Q0`gP8#>oxEQNMZVfo4e)KBKVyxAe< zN^=u7sZrrWD1mR}KCTFUO!=?tIgax?zh2LoI?wk!lrCS|tzh(aItITH7)s|DvVJFS zNUVDE)18g%LIt^?)ysGMH6>A^#E-ZI{;FJRzxP`VyGJF~jFYRM&-U5+I%3n7J91o$f5>2+;EqU4YerjmSNO4#zd06Z#g) zbm+%FM}hyi5&8Vnww=HgQ^O%T$@h`3Ii@%=+C}a;Jp9)}HDnRcQQ6pi`B|Em#O%v! zr(^nmIJ#^z|A&&Gt1l`6UZ99rQhXj8YkXR+RsA*3V)TUCO`kE9LM*#;^B8C;qzfZW zIf$cJh+825WmoS;0{xFe`$0Jr|3jqa(FKmgc zV8sQAx7J+w%l8u*2rZ=$PEmNmzqsaa$kZdk__88}vYwa^tiQ8avk_Mz)pK_?JaaZG z%i>*KPsaZr+ykV+(T}jqK~b>G+ZXJzaTRmBH=It85}1m=WIU-TH!n#kJ-A>g)F}x& z+YUqjYp43hZg!My~!v4b=&nHHZN-^%1Q{%E8M1aKAidTjGEXVcPe_=|7O0rz&A}w|1y*3{0)$44P`$RwRA-YdJ39we48KUrn94aZJDn>vqt*rD(u0r zxzI1DWr&(2(=#vL89symZxNr&UH+8?KVZyeu01C8U$Mb)gzhIxV1fbUt-%AjIx1=K zYL8^RDHw)IenUE1q@f+E4E+^Q>BNTZrxl(po;$zji#31MLW~BMkWJe)Kmh`{KWtlr!$#5=&^gnQ|?_Cc!;+q+tUoGJzi1*>deUblxTFpIu)F^8?u7f z(Lyan?UWih_%M>CWmkz1&N88lTcm~2U6n8D!pUqz&Jqxz8nFFlO9C#vZj zJZS6wmtXvEXYRj*Q#0iPh>lynK>*=q_esAoDv*L`J&lOG15LPV*NdBF^awOzfubMv zbHIu2om9j`II0?!ang@2h9cE3HZ($wxN6Pqoq9+BKW|F^Qy9@g0fxakzn5O?Kehkj zl&-R3SZeP?As=?v$-`{2cra#Js2@;*{(Oe76Jr+2a(F5oNClCId~2Uc3sKCF!m1$y z@m9bc9|)&Ce5Xs7Y)geX3f%BCCRtZY>VWkb&Q1sak-J1f((vp{Du7}BFH`s*JhQ8Y zV<0cO=01rs_10#$;ie2UyQ3DWXC<(gYgo{%bXSDK##HZ1Rl*ytduAeiOl4IxT(>`l zr`=gfu%avw?-Oe`EXu>2546L9n4!j6-%GEnwi(>;jLdc|!@(`APobQB>~$}; z&hVjdZ%|>4E!F9>zIUC@$#Ko;j66%fwHRD+?*C!=+)r*ao@Dg(t4F_Zy&)@QAS?AT zjCFnDn-uKI66V%NihNdIS*hZ%b$KbNb~)F)QXQ6bU(+Zv3+leUEz{c@%nJ*=ljNq> zo}N_-L9GykNv>v$bGIESflG!w+cB`Zzr}|Is)fV7xzNe_lOq`yg`XwPpvBnaS@=ea zK1-O@S(FhtM_K@LT>UvnLh+7;<2DB+`sN;#u8gp6a&}GY2W`nCO6f$036pesbmfG< z%aQ*Au5T(oq^Iv5%=v?t7jNwpP?SS04_bQ8+Qm`gE$UK<7~fdy$TW2IeLf2Jgcyyu z_aZf~bv9Rm|F(EYc#K{UBvRd{M(elLr6~}~sqy4L2`H+zx}VUJ>tCjX;U$WY4i|5y z?|oFwaLy={4NgyEA{Hf$E2z7e2acEyh&iCbFhB|EBWh4L$4qdCK2_&hpf?*XxgNXH zME7`$?)c_e8B#O@bh1n>QZayuahOp%?6!#fgOv8ipS{LnjJQf9>$L*^oS~rhB-h5_ zi~O(GMN@c*$LbMOqf+SB4V5Ze=geiCpL92PfWGEQ@LT&D@re)ZDuw^s^mKA3u1>ff zs?=2%LJOrV8b09PM(^jI(?iHgy>9rRQ;)f2!K6f^U%_B%Pk`hbn2_7pD7iHZWCNNZ z`C1hK5$5~6q6zBQyyo{XHjuz3^9HhLB!`cN)l5>hpQhW+|Y^fXQ;)J|)z$jJ7$(gZy|)oJsP|wY>KM=<{DuQ?nzoJ)`FE);Nz8;h&Tg>UoDVxGR0w#6Is=)AjS*!*A@0|yx?C~`;VXQ1kooh zvV}bqq6?wc-VtVUXSpqq4IgZv`W)ey%~)KoC``ZmYDVuvihM4@WM9z$MdEnf5!>-b z?mMXdALY`AbQA}*l+b?ps{R9T;SlCAFiZQ2^@ch{n=E$X z^%To}7{0VUre`X@?#J^Id?Ax+694cgV)=>wo}p^E~buuRKX>WXXy zWK4$CSLVJEBJF=S#VLJHgc_l0@07?7((l`);#u?*>1uy|8c6?WNUb($SoNrb zIhzW#PAA@XWQIz7jEyQ_T~N8AHWbBNzrvhtaxIDonpfXxO@2ee=tUmCTa_KP>69!Z zevN-`a!vk@V_(lEm@w~Ed$=jGb3AM6KX3L{i~2~N>P>$q&qJDf zEsj~=cKRatT8yy9va-WnkJ%LpUe?M=bio7TwMFhLGg&fACJfjKP9=E~n z)rPqyy#|h);H@qL&QLU}R8Y~?F*yyHhz_5E1T{f+18LWr{aiL9@enC`w^@#}GW%lAlV0d_Lr$WLd( zLrpk7PS@DvRpO~;%ApXlHjx!P#c@}#PRYRpms70_4;=wt-i)k~h(>K2R+$60Bqqr@ ztY~c%r?+Cn2Tm<5x-giSiJMg(c+N(wY|UY>#qlrBOVVZ=i@iUUFn+(mvaz@L*N@cX z$f^Ea>REX`;C07eR`m2-rJ*0x@$Q_Ev6-z{I@lAtgz#B>^X8d%Q-Id`;hLi}#(q>g z%{Gb$nwjN0(?c6>M`IRy*KNREf(N~OR9&HIYGGz#+R@>r`QH#Y6MXx#X_kG}1jAym zQ~8r#9RPv5Gb)CsI`G-kg{79Jn0df|_kqa5M8Q-|{cb=r*rgshSn-CZUIFL2O=s42@^a(6VQwR?gnsB7oL@l8mRx?W}w z61t*%Jq&e-R`PNZmPT`Q@*cGw7D7=?62=j>)W$ka%WUc0uXYz<#U-TWNL3#MK3 zKX)zx0YvD*9d>COHVLUTV;izYrAC}HhWx@iX}7~!6g&km0A;+ZRMNS0C&^>YFF^80 z5WgCCX5=pnEfge2H9Cin*cF}2dtIKfHTbF)OZWL7mo~Q3f&0>5CYLys{4*@BzfB%u z$koMox!_bndu4GAeawo4OtdZS`@HJotQH?d^ZZlSPurkZKm2o&Hv=YG9skv2a*v%ocHeT?MD~ZMo&JHC7%a$Hpp-N zCZ+)PW7#b(L>PhsHLAohfE6HkeiyTTRd0+;r_10_6 zp?;w<`$(8qgqV4UqmfYOgNtb65|0?pcP5D3>2{d!aio{QXaujFozuK0vxREpnCfnr zG0Gk}R`vZvW#eF(K2mR~V;Pb)Gc6-s6qS{rqP=KrPdAmG2VDeDe59r1@)#+*r#}mh zvbtFv^eWS3;+LHwrsC+)x!uTSRz=n_uPZqfP`Mu7=O4h;zv9P7NjKmb7QfJ%7W#^B z2SM^#Fn7_moSg=*Z5OX)AGEa-Og&Xb*X;4=)}56fQeK zz5ZQmdtEAN|I7gO;BA}L{d|kFd_a996+Aph%Aq69OZ?NIJQaw%-2p>X>SdBn?F((4 z6kK1&p>FDVaSF6r6bJLy748+~Y1>+g)bGRnaehK}<1dpK(dV^F!+zw|V8gCmztyKj z`PmBj2nJFhq%RPrkNCk$qvaEypFCa+>*@*LRdV6j?=AbkT7X7&!P+LuSUNYsP2k4|b-pn1b3x$_Q%;1kZFD>gFEVjr++#kF zyLebpv$`kjoC94P7+ReCYqWg4ZMm=KmVyF7D$pU+561tk$>7;}wKpsTcJBfxdU_9< zu;i@HgcSBJ$RCZ~Ht7n9J z+^yCbXc>^1Z5hZ=yi0Nofd?iy=b) zWNPf5!IGIa$-2^qZtmcd;zGV)ei8!hafu<)(Ya)S0l7MO^7X=XBxzx=L(s5&ky0yy z7EAB(-vcvnsd z&UbQFMj&t}rc(-dJ~TAe)Z2cq$^0R*MEgO9z4w`ZEvl0ih_tg3=|LEiyZR*j z0CRYMP#f^3C`W7%)zwZNK&&GEZOJI(o%M;8(Au zJgF?FmDW96TFeFameUWm?=r=`dmCj4sn*PmuyM5lDruI`hW#-%H?n0SAO~ z%@tO@9~4ey=gXs+@a;e&M-5UW=Vru4xB|=Qb8+cSZ!Aioz7d^Lf6Os{Fh`+8;(Z4L zvIcI^5Np!u_&Ul}i(OLg8864BBjJ`e8DRPq8NYV-)8C}9+(RC=((iM(=^lgye~6rn z_2R4L3EiCMS`K-=sok(4ovBbf4*xE|O}-c05D9 z@AS?c)lUbn{Qgn|4(YEyYxyvNSqp|I4`w;V1nX&@|NMuz2XPRV_T^JG18-3~34Vn4 zQ;$VAjb*c%jW~9Hnt7(E`gognwCe(E6n6m!Q{K&7T761<+X?GGgV~D@#RpAZbks0p z+>D3f=Xcp)2W??RDy8YE_Khj$nGi^e%Jh1HKm-hb9TzDpLeZhaZ>{6n(W6gp2!+zs{g(ICxpPp`-P^LBc&guF@a zy9%fqpw(zEk$XXDm*hN|o`EYkr}i5`tWK8bZFDV%V3h7*Dlm|K;TW}STzcB8v)DD4 zKIcSw^gW!$k1uOVwiI-h1m7 z*v)ylZ!LnRuL51`6rZfH+?L&-DNB4Ya`}oBk|3P2c1Vs*8Z)W0tIkHu&T@#|l)7*s zEW2ZO_lj!B;j_|FX8VN1_^_{m)7OT`MJU4S4`))^31dFTd27BNTaE={y(!PT}{ttx0y%BAvUBrEZ%3j;cX3H1|F zq(XlbBDM0khB14R0>6iZt4+k*C^|WAmd%IuzYks}lb@o(V>im?>hqcn2D9r3^2e@9 z6n_ZxtdaO)xodBrMux%|2Z^+v$Dgjf2V>0B7DU8Y-7A{gchV>(Qi`PI$?=};7Xag% z_%srwTelUtu>U^%pkhIV#oCvjUeD<;xDlYLHwLL7Bk&uDfq3k!?kAug-(Wno$*>V_K@)@lz|W0shM^NftkR&13X~WY z)my^-&nFrJ?L&$zbz(Ov<>(S1md?OSV7v=t`Q~2FY3$S=FK9Nt#ic(tDrR+wb0};X zs09;A%fO8XG!7K^4pu$FRX$`owWu&B@0O-9b4SX@%Oc|l?u=`O=OUt+GUgvTNGLm2 z!cWdm?rR9I3-3?*Ux~%=Md>v&3kpKbE<3PZj4Tm$vQ5C=y11gE;*&WRbp%~P z+p$zpAz9m4(En(v8EI;33x9s0K*!Wn)n6;4fV}gl?e>H9`?2F+48ELv5U6Pj(b1{Y zS?E*zfX9b7#iTrSM3-aL!ZJCK15K67y>B;{FYB=KtG%F?ll$}}?LjIjF~y5nvcT;E zyc>0R9MQhA<`+H3S%&u(HYfYvcfnG;H_jCuGLJQrv8#*JjY(+$#AlnP=U98>dz0K$ zwbb-2+%w84^@h|ef38>RdB@w+Q;ejsnw>Tq?9sl`(y&15-pWldt^t!p$=Lab!@}qI z-tGpz;YTMd;D>hj9JrKmQAdPl;*no%zCep)z9Pt8jW{P!TH*^vn`@MKzu}#z!!*~f z@t|Qp^~3tmO|u5%y}^^E=0H69 z=hkP>rg9j*efTWJM}4&)xKPH&k@dlH{1>+?V~!u!>rC|4(@c$|CEZq>G}KW4Q$p4V z2`V1&e%{hYu|!o|Lw$tovxPT)z|J%E6p8 zlj21TdF9c!lrmKmo4d0Ty}7x9|4tY(s&f8AJW7<`d998*u={9|Tnt4eInbTI#6)?r zd^q$R&AR(prqz%NAO6#N_s(j!G!kLHW|q4moH94TFRxNE*2hrN`Sp=0!MlK(^pop< zb}}%)*cLQ#M4@O*4siuRuNa8GD>)d@wj9`~JeL_`IOl;>q${rP3kxwIc^g!MMXU72 z4zwTNU29Uiq*Bndn_fn$tEoove5dHeRV51XFKZR*-!^4++{q(UUQ5JVsa}%{0hviH zO3dkC6B9?1WDaO|8g_W@l)j&FP|L4;7sesV@)8!&cuj&W`RQ57E9OGz=x8g|%qUB9 zb1D_j@#wFCJ58YTp|x`#sf<{nJ`j$I_BeZ>_D~{9J!UucxjkfO#W^B3l;+v9jE1mC zUGJm)8?njb@USokiYv@H%vWVMsHqz>Kkpc(Ek8Uy6kq4fXeK{amAypQaQ9%_#hf*U z$WbRAUK>3ajd{Fc$LVikYx_$&?=Ii&rRH&qmUc#IX(>z1mI+-3la||O7tMfchRc&3 z07AlZ9Aa>~wfOj!W7Gn}=*T1jC;(=GdhUpssQz&hF6~pqtbD#I4T0w7i*Uq#KJsbT zVUS&L@n}sDe;Q(cN0GEYGN;>?q-n3*E*$7bY3Bpy)GsF$`cIr(G?B*$sD?m?cewLQ ze+nz~Q~sL?KB?1nrd*3>oGAWOdk{Yqs)Ho8fA4fU|GtSNukWrdQz7)U=~=Vkvjw%^ zQGO%87p<~~P~7B8PqAgtOPl2=N_QsLSUzh4wPzrIXd0g!iCzVpv3 zca42O219W*eZ*5P&((i!$1l9waC%A(pJ$W)CItEKn3-*$fv;x%r?$srJAElINP_23 zmaZqF_U(O4rEPqsL5{(VNh;~tMOMgZ6%5t~<~UkN|5Yf77d94sjEGVqpr;>)6w6|K zOIfbG%U(Kj_2PfZ`Hw*t_6DuKcSwdkZIkrYIAUxQ`$q3!*Mh>z#Z&tyUG zTUuMgot!E_UrWt#Do-DR^u=1mT3497uKOpc9<)o(%loP+-#}(^ZTk7@OdGN{Y=y=3 zg*4Nz9ofc#J;Pp?-{dCo&a2=8Z+icNR|+2HHfckaiEHX6$B5PrDL#K7w{er_SjUTK?e*>C&~WR{tuvnDxFH(tVo<>jf81ukjx28*)V);^*v zvAc2B>e9H8C5v}sYpjv#bGZ&`g<{L%@vFkF-LJ4!!3P(_!~~;XswmI~MMio?jC@{U zrf&*c$rt+NxL7IRZESX@$|qmzmPH?FJ2vO72i58oM| z#@sjf9JUR1;Cq78Ls&-fVzoZ$%#RU1Xwi8YjO4!kM>r7e#53N<^(m48^}3)EA9~w8 zv4jy_#_2WnLw&EP4+C!Rnb6|Nu@I9fNi%Ge+#z*{DY{nq7Ar+8qLm;H�T;Y<}4TlZ!RujJ?`iq0yDsy?;WA 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�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 G?SBBY@s&RS literal 0 HcmV?d00001 diff --git a/tools/web/public/social/pyble-v061-og-d3bab0c0-1200x630.svg b/tools/web/public/social/pyble-v061-og-d3bab0c0-1200x630.svg new file mode 100644 index 00000000..60dd8db7 --- /dev/null +++ b/tools/web/public/social/pyble-v061-og-d3bab0c0-1200x630.svg @@ -0,0 +1,166 @@ + + + + PyBLE app beta with firmware v0.6.1 + + PyBLE iPad beta with one-time USB firmware setup followed by everyday + MicroPython development over Bluetooth Low Energy, shown with a real Blocks + workspace and generated Python code. Firmware v0.6.1 is available; + the historical App screenshot is unchanged. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PyBLE + + + PUBLIC BETA + + + MicroPython. + From your iPad. + Over BLE. + + + + One-time USB setup. + Everyday coding over BLE. + + + + + TESTFLIGHT BETA + + + + FIRMWARE v0.6.1 AVAILABLE + + pyble.dev + Free · open source · MIT + + + + + + + + + + + + + BLOCKS → GENERATED PYTHON + + diff --git a/tools/web/scripts/prepare-sites-output.js b/tools/web/scripts/prepare-sites-output.js index e37e7d44..bacf617e 100644 --- a/tools/web/scripts/prepare-sites-output.js +++ b/tools/web/scripts/prepare-sites-output.js @@ -44,6 +44,7 @@ async function requireFile(path, missingMessage) { const requiredPrerenderedRoutes = [ "/", "/app", + "/features", "/flash", "/learn", "/learn/setup", @@ -90,6 +91,10 @@ const delegatedPaths = new Set([ "/", "/app", "/app/", + "/features", + "/features/", + "/features/pyble-firmware-v0.6.0-functional-block-diagram-473a85d475aa.svg", + "/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg", "/privacy", "/privacy/", "/support", diff --git a/tools/web/scripts/process-tutorial-captures.sh b/tools/web/scripts/process-tutorial-captures.sh new file mode 100755 index 00000000..2ff2902b --- /dev/null +++ b/tools/web/scripts/process-tutorial-captures.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. +# +# Produce local, reviewable tutorial screenshot derivatives. This command is +# deliberately unable to publish into tools/web/public: visual and privacy +# review is a separate promotion step. + +set -euo pipefail + +readonly SOURCE_WIDTH=2000 +readonly SOURCE_HEIGHT=1200 +readonly APP_VERSION=0.2.0 +readonly APP_BUILD=5 +readonly OUTPUT_PREFIX="pyble-app-${APP_VERSION}-build-${APP_BUILD}" + +# Keep the finalized source-to-stable-key and crop contract in one +# easy-to-audit table: source, key, width, height, x, y. +CAPTURE_TABLE=$(cat <<'EOF' +07-five-board-scan-stopped.raw.png setup-five-board-scan 2000 1092 0 36 +02-connected-5646.raw.png identity-5646-ready 400 84 0 36 +02-connected-5646.raw.png identity-5646-chip 512 390 1488 330 +03-connected-8c9e.raw.png identity-8c9e-ready 400 84 0 36 +03-connected-8c9e.raw.png identity-8c9e-chip 512 390 1488 330 +04-connected-c81a.raw.png identity-c81a-ready 400 84 0 36 +04-connected-c81a.raw.png identity-c81a-chip 512 390 1488 330 +05-connected-da86.raw.png identity-da86-ready 400 84 0 36 +05-connected-da86.raw.png identity-da86-chip 512 390 1488 330 +06-connected-3dcb.raw.png identity-3dcb-ready 400 84 0 36 +06-connected-3dcb.raw.png identity-3dcb-chip 512 390 1488 330 +08-first-program-classic-esp32.raw.png first-program-editor-console 2000 1092 0 36 +17-files-multi-delete-classic-esp32-idle.raw.png files-multi-delete-review 2000 1092 0 36 +16-github-branch-chooser-generic-s3-clean.raw.png github-branch-chooser 2000 1092 0 36 +12-github-pinned-source-generic-s3.raw.png github-pinned-source 2000 1092 0 36 +11-github-target-review-generic-s3.raw.png github-target-review 2000 1092 0 36 +14-examples-import-waveshare.raw.png examples-import-complete 2000 1092 0 36 +13-blocks-hello-c3-idle.raw.png blocks-hello-workspace 2000 1092 0 36 +EOF +) +readonly CAPTURE_TABLE + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)" +SCREENSHOTS_ROOT="$REPO_ROOT/ScreenShots" +STAGE_DIR="" + +usage() { + cat <<'EOF' +Usage: + process-tutorial-captures.sh --input-dir ABSOLUTE_PATH \ + --output-dir ABSOLUTE_PATH [--force] + process-tutorial-captures.sh --list + +Both directories must be below this repository's ignored ScreenShots/ +workspace. Use reviewed-tutorial-set/ for this capture set. The output +directory is replaced only when --force is explicit. The script never +publishes files into tools/web/public/. +EOF +} + +fail() { + printf 'tutorial capture processing failed: %s\n' "$*" >&2 + exit 1 +} + +cleanup_directory() { + local directory="$1" + + if [ -n "$directory" ] && [ -d "$directory" ]; then + find "$directory" -mindepth 1 -depth -delete + rmdir "$directory" + fi +} + +cleanup() { + if [ -n "$STAGE_DIR" ]; then + cleanup_directory "$STAGE_DIR" + fi +} + +trap cleanup EXIT HUP INT TERM + +sha256_file() { + local path="$1" + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + else + fail "shasum or sha256sum is required" + fi +} + +canonical_directory() { + local directory="$1" + + [ -d "$directory" ] || fail "directory does not exist: $directory" + (cd "$directory" && pwd -P) +} + +require_screenshot_descendant() { + local path="$1" + local label="$2" + + case "$path/" in + "$SCREENSHOTS_ROOT/"*) ;; + *) fail "$label must be below $SCREENSHOTS_ROOT" ;; + esac +} + +validate_capture_table() { + local source_name capture_key crop_width crop_height crop_x crop_y extra + local count=0 source_count key_count + + while IFS=$'\t' read -r source_name capture_key crop_width crop_height \ + crop_x crop_y extra; do + [ -n "$source_name" ] || fail "capture table contains an empty source" + [ -n "$capture_key" ] || fail "capture table contains an empty key" + [ -z "$extra" ] || fail "capture table row has more than six fields" + case "$source_name" in + */* | .* | *..* | *[!A-Za-z0-9._-]*) + fail "unsafe source filename in capture table: $source_name" + ;; + *.raw.png) ;; + *) fail "source filename must end in .raw.png: $source_name" ;; + esac + case "$capture_key" in + */* | .* | *..* | *[!a-z0-9-]*) + fail "unsafe key in capture table: $capture_key" + ;; + esac + case "$crop_width:$crop_height:$crop_x:$crop_y" in + *[!0-9:]* | :* | *::* | *:) + fail "invalid crop geometry for $capture_key" + ;; + esac + [ "$crop_width" -gt 0 ] || fail "crop width must be positive: $capture_key" + [ "$crop_height" -gt 0 ] || fail "crop height must be positive: $capture_key" + [ "$((crop_x + crop_width))" -le "$SOURCE_WIDTH" ] || + fail "crop exceeds source width: $capture_key" + [ "$((crop_y + crop_height))" -le "$SOURCE_HEIGHT" ] || + fail "crop exceeds source height: $capture_key" + count=$((count + 1)) + done <<<"$CAPTURE_TABLE" + + [ "$count" -eq 18 ] || fail "capture table must contain exactly eighteen rows" + source_count=$(printf '%s\n' "$CAPTURE_TABLE" | cut -f1 | sort -u | wc -l | tr -d ' ') + [ "$source_count" -eq 13 ] || + fail "capture table must contain exactly thirteen distinct sources" + key_count=$(printf '%s\n' "$CAPTURE_TABLE" | cut -f2 | sort -u | wc -l | tr -d ' ') + [ "$key_count" -eq 18 ] || fail "capture table keys must be unique" +} + +is_managed_output_name() { + local name="$1" + local _source_name capture_key _crop_width _crop_height _crop_x _crop_y + local prefix hash + + case "$name" in + SHA256SUMS | SOURCE_SHA256SUMS | tutorial-captures.tsv) + return 0 + ;; + esac + + while IFS=$'\t' read -r _source_name capture_key _crop_width \ + _crop_height _crop_x _crop_y; do + prefix="${OUTPUT_PREFIX}-${capture_key}-" + case "$name" in + "$prefix"*.png) + hash="${name#"$prefix"}" + hash="${hash%.png}" + if [ "${#hash}" -eq 12 ]; then + case "$hash" in + *[!0-9a-f]*) ;; + *) return 0 ;; + esac + fi + ;; + esac + done <<<"$CAPTURE_TABLE" + return 1 +} + +validate_existing_output_directory() { + local output_directory="$1" + local entry name + + while IFS= read -r -d '' entry; do + [ ! -L "$entry" ] || fail "output directory contains a symlink: $entry" + [ -f "$entry" ] || fail "output directory contains a non-file: $entry" + name="${entry##*/}" + is_managed_output_name "$name" || + fail "output directory contains an unmanaged file: $entry" + done < <(find "$output_directory" -mindepth 1 -maxdepth 1 -print0) +} + +validate_source_image() { + local source_path="$1" + local properties + + [ -f "$source_path" ] || fail "missing raw capture: $source_path" + [ ! -L "$source_path" ] || fail "raw capture cannot be a symlink: $source_path" + properties=$(magick identify -ping \ + -format '%n %w %h %m %z' "$source_path") || + fail "cannot identify raw capture: $source_path" + [ "$properties" = "1 $SOURCE_WIDTH $SOURCE_HEIGHT PNG 8" ] || + fail "raw capture must be one 2000x1200 8-bit PNG: $source_path ($properties)" +} + +verify_reviewed_image() { + local source_path="$1" + local output_path="$2" + local crop_width="$3" + local crop_height="$4" + local crop_x="$5" + local crop_y="$6" + local properties metric + + properties=$(magick identify -ping \ + -format '%n %w %h %m %z' "$output_path") || + fail "cannot identify reviewed capture: $output_path" + [ "$properties" = "1 $crop_width $crop_height PNG 8" ] || + fail "reviewed capture has unexpected properties: $output_path ($properties)" + + metric=$( + magick "$source_path" \ + -crop "${crop_width}x${crop_height}+${crop_x}+${crop_y}" \ + +repage miff:- | + magick compare -metric AE miff:- "$output_path" null: 2>&1 + ) || fail "reviewed capture pixels differ from the fixed crop: $output_path" + case "$metric" in + 0 | '0 (0)') ;; + *) fail "reviewed capture changed app pixels: $output_path ($metric)" ;; + esac +} + +replace_output_directory() { + local staged_directory="$1" + local output_directory="$2" + local output_parent output_name backup_directory + + if [ ! -e "$output_directory" ]; then + mv "$staged_directory" "$output_directory" + STAGE_DIR="" + return + fi + + output_parent="${output_directory%/*}" + output_name="${output_directory##*/}" + backup_directory=$(mktemp -d \ + "$output_parent/.${output_name}.backup.XXXXXX") + rmdir "$backup_directory" + mv "$output_directory" "$backup_directory" + if mv "$staged_directory" "$output_directory"; then + STAGE_DIR="" + cleanup_directory "$backup_directory" + return + fi + + mv "$backup_directory" "$output_directory" || + fail "could not restore the previous output directory: $output_directory" + fail "could not install the reviewed captures: $output_directory" +} + +validate_capture_table + +if [ "${1:-}" = "--list" ]; then + [ "$#" -eq 1 ] || fail "--list does not accept other arguments" + printf '%s\n' "$CAPTURE_TABLE" + exit 0 +fi + +INPUT_ARGUMENT="" +OUTPUT_ARGUMENT="" +FORCE=false +while [ "$#" -gt 0 ]; do + case "$1" in + --input-dir) + [ "$#" -ge 2 ] || fail "--input-dir requires a path" + [ -z "$INPUT_ARGUMENT" ] || fail "--input-dir was provided twice" + INPUT_ARGUMENT="$2" + shift 2 + ;; + --output-dir) + [ "$#" -ge 2 ] || fail "--output-dir requires a path" + [ -z "$OUTPUT_ARGUMENT" ] || fail "--output-dir was provided twice" + OUTPUT_ARGUMENT="$2" + shift 2 + ;; + --force) + [ "$FORCE" = false ] || fail "--force was provided twice" + FORCE=true + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown argument: $1" + ;; + esac +done + +[ -n "$INPUT_ARGUMENT" ] || fail "--input-dir is required" +[ -n "$OUTPUT_ARGUMENT" ] || fail "--output-dir is required" +case "$INPUT_ARGUMENT" in /*) ;; *) fail "--input-dir must be absolute" ;; esac +case "$OUTPUT_ARGUMENT" in /*) ;; *) fail "--output-dir must be absolute" ;; esac +command -v magick >/dev/null 2>&1 || fail "ImageMagick's magick is required" +[ -d "$SCREENSHOTS_ROOT" ] || fail "ignored workspace is missing: $SCREENSHOTS_ROOT" + +INPUT_DIR=$(canonical_directory "$INPUT_ARGUMENT") +require_screenshot_descendant "$INPUT_DIR" "input directory" + +if [ -L "$OUTPUT_ARGUMENT" ]; then + fail "output directory cannot be a symlink: $OUTPUT_ARGUMENT" +fi +if [ -e "$OUTPUT_ARGUMENT" ]; then + OUTPUT_DIR=$(canonical_directory "$OUTPUT_ARGUMENT") +else + OUTPUT_PARENT_ARGUMENT="${OUTPUT_ARGUMENT%/*}" + OUTPUT_NAME="${OUTPUT_ARGUMENT##*/}" + [ -n "$OUTPUT_NAME" ] || fail "output directory needs a final component" + case "$OUTPUT_NAME" in . | ..) fail "unsafe output directory" ;; esac + OUTPUT_PARENT=$(canonical_directory "$OUTPUT_PARENT_ARGUMENT") + OUTPUT_DIR="$OUTPUT_PARENT/$OUTPUT_NAME" +fi +require_screenshot_descendant "$OUTPUT_DIR" "output directory" +[ "$OUTPUT_DIR" != "$INPUT_DIR" ] || fail "input and output directories must differ" + +if [ -e "$OUTPUT_DIR" ]; then + validate_existing_output_directory "$OUTPUT_DIR" + if [ "$FORCE" = false ] && + [ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + fail "output directory is not empty; use --force to replace managed outputs" + fi +fi + +while IFS=$'\t' read -r source_name _capture_key _crop_width \ + _crop_height _crop_x _crop_y; do + validate_source_image "$INPUT_DIR/$source_name" +done <<<"$CAPTURE_TABLE" + +STAGE_DIR=$(mktemp -d "$SCREENSHOTS_ROOT/.tutorial-captures.stage.XXXXXX") +printf 'capture_key\treviewed_filename\treviewed_sha256\traw_filename\traw_sha256\n' \ + >"$STAGE_DIR/tutorial-captures.tsv" +while IFS=$'\t' read -r source_name capture_key crop_width crop_height \ + crop_x crop_y; do + source_path="$INPUT_DIR/$source_name" + temporary_output="$STAGE_DIR/.${capture_key}.png" + magick "$source_path" \ + -crop "${crop_width}x${crop_height}+${crop_x}+${crop_y}" \ + +repage \ + -strip \ + -depth 8 \ + -define png:exclude-chunks=date,time \ + -define png:compression-level=9 \ + -define png:compression-filter=5 \ + -define png:compression-strategy=1 \ + "$temporary_output" + verify_reviewed_image "$source_path" "$temporary_output" \ + "$crop_width" "$crop_height" "$crop_x" "$crop_y" + reviewed_sha256=$(sha256_file "$temporary_output") + output_name="${OUTPUT_PREFIX}-${capture_key}-${reviewed_sha256:0:12}.png" + output_path="$STAGE_DIR/$output_name" + mv "$temporary_output" "$output_path" + raw_sha256=$(sha256_file "$source_path") + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$capture_key" "$output_name" "$reviewed_sha256" \ + "$source_name" "$raw_sha256" \ + >>"$STAGE_DIR/tutorial-captures.tsv" +done <<<"$CAPTURE_TABLE" + +tail -n +2 "$STAGE_DIR/tutorial-captures.tsv" | + awk -F '\t' '{print $3 " " $2}' >"$STAGE_DIR/SHA256SUMS" + +tail -n +2 "$STAGE_DIR/tutorial-captures.tsv" | + awk -F '\t' '!seen[$4]++ {print $5 " " $4}' \ + >"$STAGE_DIR/SOURCE_SHA256SUMS" + +replace_output_directory "$STAGE_DIR" "$OUTPUT_DIR" +printf 'Prepared 18 reviewed captures from 13 raw frames in %s\n' "$OUTPUT_DIR" +printf 'Review SHA-256 manifest: %s/SHA256SUMS\n' "$OUTPUT_DIR" +printf 'Raw SHA-256 manifest: %s/SOURCE_SHA256SUMS\n' "$OUTPUT_DIR" +printf 'Stable-key manifest: %s/tutorial-captures.tsv\n' "$OUTPUT_DIR" diff --git a/tools/web/scripts/test-process-tutorial-captures.sh b/tools/web/scripts/test-process-tutorial-captures.sh new file mode 100755 index 00000000..d440d320 --- /dev/null +++ b/tools/web/scripts/test-process-tutorial-captures.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT +# Part of PyBLE (https://pyble.dev) — see /LICENSE. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)" +PROCESSOR="$SCRIPT_DIR/process-tutorial-captures.sh" +SCREENSHOTS_ROOT="$REPO_ROOT/ScreenShots" +TEST_ROOT="" + +fail() { + printf 'tutorial capture processor test failed: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [ -n "$TEST_ROOT" ] && [ -d "$TEST_ROOT" ]; then + find "$TEST_ROOT" -mindepth 1 -depth -delete + rmdir "$TEST_ROOT" + fi +} + +trap cleanup EXIT HUP INT TERM + +command -v magick >/dev/null 2>&1 || fail "ImageMagick's magick is required" +[ -x "$PROCESSOR" ] || fail "processor is not executable: $PROCESSOR" + +CAPTURE_TABLE="$($PROCESSOR --list)" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | wc -l | tr -d ' ')" = 18 ] || + fail "capture table must expose eighteen reviewed derivatives" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | awk -F '\t' 'NF != 6 { bad = 1 } END { print bad + 0 }')" = 0 ] || + fail "capture rows must name source, key, width, height, crop x, and crop y" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | cut -f1 | sort -u | wc -l | tr -d ' ')" = 13 ] || + fail "capture table must bind thirteen distinct raw frames" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | cut -f2 | sort -u | wc -l | tr -d ' ')" = 18 ] || + fail "capture keys must be unique" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | awk -F '\t' \ + '$3 == 2000 && $4 == 1092 && $5 == 0 && $6 == 36 { count++ } END { print count + 0 }')" = 8 ] || + fail "capture table must expose eight full app-frame crops" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | awk -F '\t' \ + '$3 == 400 && $4 == 84 && $5 == 0 && $6 == 36 { count++ } END { print count + 0 }')" = 5 ] || + fail "capture table must expose five Ready-status crops" +[ "$(printf '%s\n' "$CAPTURE_TABLE" | awk -F '\t' \ + '$3 == 512 && $4 == 390 && $5 == 1488 && $6 == 330 { count++ } END { print count + 0 }')" = 5 ] || + fail "capture table must expose five runtime-chip crops" + +mkdir -p "$SCREENSHOTS_ROOT" +TEST_ROOT=$(mktemp -d "$SCREENSHOTS_ROOT/.capture-processor-test.XXXXXX") +INPUT_DIR="$TEST_ROOT/raw" +OUTPUT_DIR="$TEST_ROOT/reviewed" +UNMANAGED_DIR="$TEST_ROOT/unmanaged" +mkdir "$INPUT_DIR" "$UNMANAGED_DIR" + +first_source="" +while IFS=$'\t' read -r source_name _capture_key; do + if [ -e "$INPUT_DIR/$source_name" ]; then + continue + fi + if [ -z "$first_source" ]; then + first_source="$INPUT_DIR/$source_name" + magick -size 2000x1200 \ + gradient:'#102030-#d0e0f0' \ + -fill '#ff3355' \ + -draw 'rectangle 0,36 1999,64' \ + -set comment 'raw fixture metadata must not survive' \ + -depth 8 \ + "PNG32:$first_source" + else + cp "$first_source" "$INPUT_DIR/$source_name" + fi +done < <("$PROCESSOR" --list) + +"$PROCESSOR" --input-dir "$INPUT_DIR" --output-dir "$OUTPUT_DIR" + +[ "$(wc -l <"$OUTPUT_DIR/tutorial-captures.tsv" | tr -d ' ')" = 19 ] || + fail "stable-key manifest must have one header and eighteen captures" +[ "$(wc -l <"$OUTPUT_DIR/SHA256SUMS" | tr -d ' ')" = 18 ] || + fail "reviewed checksum manifest must have eighteen captures" +[ "$(wc -l <"$OUTPUT_DIR/SOURCE_SHA256SUMS" | tr -d ' ')" = 13 ] || + fail "raw checksum manifest must have thirteen distinct captures" +[ "$(awk '{print $2}' "$OUTPUT_DIR/SOURCE_SHA256SUMS" | sort -u | \ + wc -l | tr -d ' ')" = 13 ] || + fail "raw checksum manifest must not repeat source filenames" + +(cd "$OUTPUT_DIR" && shasum -a 256 -c SHA256SUMS >/dev/null) +(cd "$INPUT_DIR" && shasum -a 256 -c \ + "$OUTPUT_DIR/SOURCE_SHA256SUMS" >/dev/null) + +tail -n +2 "$OUTPUT_DIR/tutorial-captures.tsv" | + while IFS=$'\t' read -r capture_key output_name reviewed_sha256 \ + source_name raw_sha256; do + capture_row=$(printf '%s\n' "$CAPTURE_TABLE" | awk -F '\t' \ + -v key="$capture_key" '$2 == key { print; exit }') + [ -n "$capture_row" ] || fail "manifest contains an unknown key: $capture_key" + IFS=$'\t' read -r expected_source expected_key crop_width crop_height \ + crop_x crop_y <<<"$capture_row" + [ "$expected_key" = "$capture_key" ] || + fail "capture-table lookup returned the wrong key: $capture_key" + [ "$expected_source" = "$source_name" ] || + fail "manifest source does not match capture table: $capture_key" + case "$output_name" in + "pyble-app-0.2.0-build-5-${capture_key}-${reviewed_sha256:0:12}.png") ;; + *) fail "content-versioned filename does not match its manifest row" ;; + esac + [ "$(shasum -a 256 "$OUTPUT_DIR/$output_name" | awk '{print $1}')" = \ + "$reviewed_sha256" ] || fail "reviewed SHA-256 mismatch: $output_name" + [ "$(shasum -a 256 "$INPUT_DIR/$source_name" | awk '{print $1}')" = \ + "$raw_sha256" ] || fail "raw SHA-256 mismatch: $source_name" + [ "$(magick identify -ping -format '%w %h %z' \ + "$OUTPUT_DIR/$output_name")" = "$crop_width $crop_height 8" ] || + fail "unexpected reviewed dimensions: $output_name" + [ -z "$(magick identify -format '%c' "$OUTPUT_DIR/$output_name")" ] || + fail "reviewed capture retained a PNG comment: $output_name" + metric=$( + magick "$INPUT_DIR/$source_name" \ + -crop "${crop_width}x${crop_height}+${crop_x}+${crop_y}" \ + +repage miff:- | + magick compare -metric AE miff:- "$OUTPUT_DIR/$output_name" null: 2>&1 + ) || fail "reviewed capture differs from exact crop: $output_name" + case "$metric" in + 0 | '0 (0)') ;; + *) fail "reviewed capture changed app pixels: $output_name ($metric)" ;; + esac + done + +cp "$OUTPUT_DIR/tutorial-captures.tsv" "$TEST_ROOT/first-manifest.tsv" +if "$PROCESSOR" --input-dir "$INPUT_DIR" --output-dir "$OUTPUT_DIR" \ + >"$TEST_ROOT/refusal.log" 2>&1; then + fail "processor overwrote an existing review directory without --force" +fi + +"$PROCESSOR" --input-dir "$INPUT_DIR" --output-dir "$OUTPUT_DIR" --force \ + >/dev/null +cmp "$TEST_ROOT/first-manifest.tsv" "$OUTPUT_DIR/tutorial-captures.tsv" || + fail "identical inputs did not produce an identical manifest" + +printf 'unmanaged review note\n' >"$UNMANAGED_DIR/review-note.txt" +if "$PROCESSOR" --input-dir "$INPUT_DIR" --output-dir "$UNMANAGED_DIR" \ + --force >"$TEST_ROOT/unmanaged-refusal.log" 2>&1; then + fail "processor replaced an output directory containing an unmanaged file" +fi + +printf 'tutorial capture processor test: pass\n' diff --git a/tools/web/src/app/features/page.tsx b/tools/web/src/app/features/page.tsx new file mode 100644 index 00000000..b7272b7c --- /dev/null +++ b/tools/web/src/app/features/page.tsx @@ -0,0 +1,520 @@ +// SPDX-License-Identifier: MIT +// Part of PyBLE (https://pyble.dev) — see /LICENSE. + +import Image from "next/image"; +import Link from "next/link"; + +import { ArrowIcon, ExternalIcon } from "@/components/icons"; +import { PageIntro } from "@/components/page-intro"; +import { pageMetadata } from "@/lib/site"; + +export const metadata = pageMetadata({ + title: "PyBLE Firmware Architecture", + description: + "Explore the PBLE/1 functional architecture, complete operation surface, exact profiles, and operating limits of PyBLE firmware 0.6.1.", + path: "/features", +}); + +const diagramPath = + "/features/pyble-firmware-v0.6.1-functional-block-diagram-0d2bb826c64f8.svg"; + +const featureReference = [ + { + title: "BLE transport", + body: "One PyBLE-owned GATT service. RX accepts Write and Write Without Response; TX notifies; INFO is readable. The app prefers ATT MTU 247, while fragmentation and reassembly keep the transport valid down to MTU 23.", + }, + { + title: "Identity and capabilities", + body: "HELLO comes first. DEVICE_INFO and INFO expose 15 emitted keys: proto, agent, chip, mpy, fs_root, mtu, window, chunk, free_mem, has_sd, has_identify, identify_led, auto_run, device_id, and label. Labels are at most 24 UTF-8 bytes.", + }, + { + title: "Safe boot and persistence", + body: "The embedded agent starts independently of the editable workspace, advertises and waits by default, and runs /main.py only after autorun is enabled. A broken main.py is separate from workspace mount failure: nonblank or uncertain media that cannot mount is preserved for explicit USB recovery, without agent or autorun startup.", + }, + { + title: "Security and privacy", + body: "PBLE/1 has no application-layer authentication: a connected client is trusted. Device identity and labels are display-only, an advertised label is public, and the PyBLE agent sends no telemetry or code to a cloud service.", + }, + { + title: "PBLE/1 protocol", + body: "Version 1 frames carry a command, response, or event, a request ID, and CRC-32. The dispatcher returns explicit status values and keeps transport framing separate from filesystem, runner, console, and identity services.", + }, + { + title: "Files", + body: "List, stat, offset download, CRC-verified windowed upload, cumulative acknowledgement, reconnect resume, delete files or empty directories, mkdir, and rename. One transfer is active at a time; paths are limited to 128 bytes; .mpy and .pyc transfers are rejected.", + }, + { + title: "Run and control", + body: "Run one workspace path of at most 128 bytes or inline UTF-8 source of at most 2,048 bytes. Observe idle, running, done, or error state; Stop is idempotent; soft reboot responds before its side effect; /main.py autorun is opt-in.", + }, + { + title: "Live console", + body: "Tagged stdout and stderr travel to the app, while stdin supports input() and sys.stdin. Output chunks are at most 200 bytes and input staging is 256 bytes. Congestion can drop output, so the console is live feedback rather than a lossless log.", + }, + { + title: "Lifecycle and reliability", + body: "BLE remains serviceable while user code runs. Bounded queues plus connection-generation and virtual-machine-epoch binding prevent stale work from crossing disconnects or resets. Pico 2 W returns EBUSY for file transfer while a program runs.", + }, + { + title: "MicroPython runtime", + body: "Upstream MicroPython 1.28.0 runs with zero PyBLE upstream patches. All five profiles use LFS2; ESP includes upstream NeoPixel. Programs keep ordinary filesystem, machine, VFS, and asyncio APIs with explicit pins and buses—there is no automatic board or pin detection.", + }, +] as const; + +const hardeningChanges = [ + { + title: "Session and message validation", + body: "HELLO negotiates PBLE/1 per connection. Commands require nonzero request IDs, valid direction, and exact payloads. Incomplete fragments expire after five seconds; eight protocol violations end the session. This is robustness, not authentication.", + }, + { + title: "Fresh program state", + body: "File, inline-source, and autorun executions receive fresh globals. Prior variables do not leak into the next run. This is not a full VM reset: imported modules, hardware state, working directory, and sys.path can persist.", + }, + { + title: "Run-owned console input", + body: "stdin is cleared at run admission, accepted Stop, terminal state, disconnect, and VM reset. Idle input is discarded. The bounded queue can still drop overflow; later runs never inherit those queued bytes.", + }, + { + title: "Safer file transfers", + body: "Resume uses a completely read, CRC-checked regular-file prefix. PUT admission reserves 64 KiB of storage headroom. DELETE, MKDIR, and RENAME return EBUSY during PUT or GET; LIST and STAT remain available. CRC failure never replaces the destination.", + }, + { + title: "Non-destructive storage recovery", + body: "All five official profiles mount LFS2. After mount failure, formatting requires a conclusively erased complete workspace device. Nonblank or uncertain media is preserved; agent and autorun startup are skipped for explicit USB recovery. Back up and use the documented installer when migrating an older FAT workspace.", + }, + { + title: "Checked persistent settings", + body: "Labels, autorun, and ESP Identify configuration are validated before use. Persistence failures leave prior behavior in effect; invalid stored autorun is disabled. Internal configuration fault tracking adds no new PBLE/1 field.", + }, +] as const; + +const operationGroups = [ + { + title: "Identity", + operations: ["HELLO", "DEVICE_INFO"], + }, + { + title: "Files", + operations: [ + "FILE_LIST", + "FILE_STAT", + "FILE_GET_BEGIN", + "FILE_GET_DATA", + "FILE_GET_END", + "FILE_PUT_BEGIN", + "FILE_PUT_DATA", + "FILE_PUT_END", + "FILE_DELETE", + "MKDIR", + "FILE_RENAME", + "FILE_PUT_ACK", + ], + }, + { + title: "Execution", + operations: ["RUN", "STOP", "SOFT_REBOOT", "SET_AUTORUN", "RUN_STATE"], + }, + { + title: "Console", + operations: ["CONSOLE_DATA", "CONSOLE_INPUT"], + }, + { + title: "Device controls", + operations: ["SET_LABEL", "SET_IDENTIFY_LED", "IDENTIFY"], + }, +] as const; + +const firmwareProfiles = [ + { + id: "esp32-4mb", + hardware: "Classic ESP32; exactly 4 MiB flash; no PSRAM required.", + runtime: + "Native C agent, NimBLE, LFS2, upstream NeoPixel, upload window 8.", + provisioning: "ESP Web Serial; select the matching 4 MiB ESP32 profile.", + }, + { + id: "esp32-s3-n16r8", + hardware: "ESP32-S3; exactly 16 MiB flash and 8 MiB Octal PSRAM.", + runtime: + "Lean, board-neutral N16R8 image; native C agent, NimBLE, LFS2, upload window 8; no TFT or boot splash.", + provisioning: "ESP Web Serial; select the lean generic N16R8 profile.", + }, + { + id: "waveshare-esp32-s3-lcd-147b", + hardware: + "Exact ESP32-S3-LCD-1.47B B-version; 16 MiB flash and 8 MiB Octal PSRAM.", + runtime: + "Native C agent, NimBLE, LFS2, upload window 8; pyble_st7789 runtime and fresh-install splash.", + provisioning: + "ESP Web Serial; select only the exact Waveshare B-version profile.", + }, + { + id: "esp32-c3-4mb", + hardware: "ESP32-C3 revision v0.3 or newer; exactly 4 MiB flash; no PSRAM.", + runtime: + "Native C agent, NimBLE, LFS2, upstream NeoPixel, upload window 8.", + provisioning: "ESP Web Serial; check revision v0.3+ and 4 MiB flash.", + }, + { + id: "rpi-pico2-w", + hardware: "Exact Raspberry Pi Pico 2 W; RP2350 with CYW43439.", + runtime: + "Frozen-Python agent, BTstack, LFS2, upload window 4; named LED is available. Identify and NeoPixel are not claimed; transfer during RUN returns EBUSY.", + provisioning: "Verified UF2 download followed by a manual BOOTSEL copy.", + }, +] as const; + +export default function FeaturesPage() { + return ( +

+ +

+ A functional view of PyBLE firmware 0.6.1—from the tablet app over + Bluetooth Low Energy to the embedded agent and MicroPython runtime. + This is a versioned reference, not a live installer promise, board + drawing, or pinout. +

+

+ Before provisioning,{" "} + check current firmware availability for the + exact profile and active release selected today. +

+
+ +
+
+
+

One control plane · five exact profiles

+

Functional block diagram

+

+ Follow the app, BLE transport, protocol engine, agent services, + and normal MicroPython runtime from left to right. On a small + screen, scroll the diagram or open the full-size vector. +

+
+ +
+
+ Functional block diagram of PyBLE firmware 0.6.1, from the tablet app over BLE and PBLE/1 to files, execution, console, boot, and five exact release profiles; it is not a board drawing, schematic, or pinout +
+
+

+ Original PyBLE diagram for firmware 0.6.1. This is a protocol + and runtime diagram—not a board drawing, automatic board + detector, schematic, or pinout. +

+
+ Open full-size SVG diagram (opens in a new tab) + + +
+
+
+ +
+
+

+ Contract hardening · no new wire operations +

+

What changed in v0.6.1

+

+ The same 24 PBLE/1 operations, 15 capability keys, and five + profiles, with stricter implementation boundaries. +

+
+
+ {hardeningChanges.map((change) => ( +
+

{change.title}

+

{change.body}

+
+ ))} +
+
+ +
+
+
+

Accessible diagram equivalent

+

Diagram description

+
+
+

+ Read from left to right, the diagram begins with the PyBLE app + on iPadOS or Android. After a separate one-time wired + installation, the app discovers a board, reads its capabilities, + manages files, runs and stops code, sends console input, and + receives standard output and errors over Bluetooth Low Energy. + ESP images use desktop Chromium and Web Serial; Pico 2 W uses a + verified UF2 download and manual BOOTSEL copy. Installation + replaces the existing firmware and workspace. +

+

+ Inside the compatible-device boundary, RX writes, TX + notifications, and the readable INFO characteristic connect the + app to the BLE GATT transport. Transport, identity, boot, and + security modules feed the central PBLE/1 protocol engine. PBLE/1 + version 1 defines 24 opcodes for device information, files, + execution, console, labels, autorun, and optional Identify + control. Frames carry request IDs and CRC-32 checks. +

+

+ Below the engine, the filesystem bridge lists and inspects + files, downloads from an offset, resumes windowed uploads, + verifies CRC-32, and commits through a temporary{" "} + .pbltmp sibling and rename. The run controller + executes one file or inline source at a time with fresh globals + and supports idempotent Stop and soft reboot. Imported modules + and hardware state are not reset by fresh globals. The console + carries tagged standard output and error plus bounded, run-owned + stdin; idle input is discarded. Connection-generation and + virtual-machine-epoch controls discard stale work after + disconnects or resets. +

+

+ The agent runs beside a normal upstream MicroPython 1.28.0 user + runtime with zero PyBLE patches to upstream MicroPython. User + programs retain ordinary filesystem and machine APIs and must + select pins and buses explicitly; PyBLE does not detect a + carrier board or supply a pin map. +

+

+ The lower strip distinguishes five firmware 0.6.1 profiles. Four + ESP profiles use the native C agent, NimBLE, LFS2 storage, + upload window eight, and Web Serial. Pico 2 W uses the + frozen-Python agent, BTstack, LFS2, upload window four, and + manual UF2 provisioning. Memory alone does not identify a board, + and matching N16R8 hardware does not make an arbitrary ESP32-S3 + board the Waveshare B version. +

+
+
+
+ +
+
+

Complete release surface

+

+ Complete PBLE/1 feature reference +

+

+ These are firmware-agent capabilities. Hardware behavior still + depends on the exact profile, board, wiring, and user program. +

+
+ +
+ {featureReference.map((feature) => ( +
+

{feature.title}

+

{feature.body}

+
+ ))} +
+ +
+
+

PBLE/1 version 1

+

All 24 operation identities

+

+ Events and no-response writes share these public identities; + their direction and framing are defined by the protocol. +

+
+
+ {operationGroups.map((group) => ( +
+

{group.title}

+
    + {group.operations.map((operation) => ( +
  • + {operation} +
  • + ))} +
+
+ ))} +
+
+
+ +
+
+
+

Exact image constraints

+

Firmware 0.6.1 profiles

+

+ Published on the project owner's qualification confirmation. + Profile constraints select firmware bytes; they do not visually + identify a carrier board or promise a pin map. +

+
+ +
+ + + + + + + + + + + + {firmwareProfiles.map((profile) => ( + + + + + + + ))} + +
+ Five exact release profiles in firmware 0.6.1. These are image + constraints, not visual board detection or an exhaustive + carrier-board allowlist. +
ProfileExact hardware constraintAgent and runtimeProvisioning method
+ {profile.id} + {profile.hardware}{profile.runtime}{profile.provisioning}
+
+
+
+ +
+
+
+

Limits and safe use

+

Know the boundaries.

+
+
+

+ Install only the exact matching profile: provisioning replaces + installed firmware and user files. A matching chip family does + not prove flash size, PSRAM type, silicon revision, or board + wiring. +

+

+ PBLE/1 trusts the connected client, so use it only where nearby + BLE clients are trusted. The advertised label is public. This + release reports no SD card, allows one program and one active + transfer class, does not recursively delete folders, and rejects + compiled .mpy and .pyc transfers. +

+

+ User code can defeat remote Stop by calling{" "} + micropython.kbd_intr(-1). Console output may be + dropped during sustained congestion. The PBLE/1 workspace jail + protects the agent from PBLE/1 file commands; user MicroPython + code retains ordinary os and VFS access. +

+
+
+
+ +
+
+
+

Versioned evidence

+

+ Inspect the release, then put it to work. +

+

+ This reference is bound to firmware 0.6.1 and source c8f549ee. + Publication is owner-confirmed; the existing automated HIL + records remain incomplete and are not relabeled as passed. The + installer page remains authoritative for what is active now. +

+
+ + Check current firmware availability + + + + Start the guided tutorials + +
+
+ +
+
+
+
+ ); +} diff --git a/tools/web/src/app/flash/page.tsx b/tools/web/src/app/flash/page.tsx index 647dd2b3..07bb2058 100644 --- a/tools/web/src/app/flash/page.tsx +++ b/tools/web/src/app/flash/page.tsx @@ -7,6 +7,7 @@ import { PageIntro } from "@/components/page-intro"; import { WaveshareBoardPhoto } from "@/components/waveshare-board-photo"; import { firmwareVersionUsesFiveProfiles, + isOwnerConfirmedFirmwareRelease, releaseIncludesWaveshareLcd147b, } from "@/lib/firmware-release"; import { @@ -33,6 +34,7 @@ export default function FlashPage() { const release = preview ? null : firmwareReleaseSelectedAtBuild(); const publicBeta = release?.deployment === "public-beta"; const candidate = release?.deployment === "candidate"; + const ownerConfirmed = isOwnerConfirmedFirmwareRelease(release); const waveshareLcd147b = releaseIncludesWaveshareLcd147b(release); const fiveProfileRelease = release !== null && firmwareVersionUsesFiveProfiles(release.version); @@ -59,15 +61,17 @@ export default function FlashPage() {

One-time wired provisioning installs PyBLE-enabled MicroPython. Then develop over Bluetooth Low Energy from the tablet-first PyBLE app. - {preview - ? ` LOCAL ENGINEERING PREVIEW v${preview.version} — UNQUALIFIED. This is not a public release.` - : publicBeta - ? " The current v0.4.2 installer is a hardware-tested firmware beta. Production Chrome erase/install and deliberately interrupted-flash recovery passed on both exact profiles. Complete release qualification is still pending; this is not a qualified release." - : qualifiedPublic - ? ` Qualified v${release.version} firmware is available for all ${exactProfileCountLabel(release.profiles.length)} exact release profiles.` - : candidate - ? ` This protected, access-controlled v${release.version} release candidate is staged for hardware qualification. Hardware validation is pending on every included profile; the public install action stays unavailable until it passes.` - : " The public install action remains unavailable until the final v0.6.0-derived bytes pass hardware validation on every included profile."} + {ownerConfirmed + ? " Firmware v0.6.1 is available for all five board profiles following qualification confirmed by the project owner." + : preview + ? ` LOCAL ENGINEERING PREVIEW v${preview.version} — UNQUALIFIED. This is not a public release.` + : publicBeta + ? " The current v0.4.2 installer is a hardware-tested firmware beta. Production Chrome erase/install and deliberately interrupted-flash recovery passed on both exact profiles. Complete release qualification is still pending; this is not a qualified release." + : qualifiedPublic + ? ` Qualified v${release.version} firmware is available for all ${exactProfileCountLabel(release.profiles.length)} exact release profiles.` + : candidate + ? ` This protected, access-controlled v${release.version} release candidate is staged for hardware qualification. Hardware validation is pending on every included profile; the public install action stays unavailable until it passes.` + : " No firmware release is selected for this build; installation remains unavailable."}

diff --git a/tools/web/src/app/globals.css b/tools/web/src/app/globals.css index ffe3282a..5ead0684 100644 --- a/tools/web/src/app/globals.css +++ b/tools/web/src/app/globals.css @@ -743,6 +743,10 @@ h3 { color: var(--muted); } +.feature-summary__action { + margin-top: 32px; +} + .split-layout { display: grid; align-items: center; @@ -2666,6 +2670,97 @@ h3 { text-underline-offset: 3px; } +.tutorial-concept-figure { + margin: 34px 0 0; + overflow: hidden; + border: 1px solid #c5d5e7; + border-radius: 22px; + background: var(--white); + box-shadow: var(--shadow-sm); +} + +.tutorial-concept-figure__heading { + display: grid; + padding: 22px 24px; + gap: 4px; + background: + radial-gradient(circle at 92% 10%, rgb(47 140 255 / 24%), transparent 34%), + var(--navy-900); +} + +.tutorial-concept-figure__heading span { + color: var(--cyan-300); + font-size: 0.69rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.tutorial-concept-figure__heading h3 { + margin: 0; + color: var(--white); + font-size: clamp(1.2rem, 2.8vw, 1.65rem); + line-height: 1.25; +} + +.tutorial-concept-figure__items { + display: grid; + margin: 0; + padding: 0; + list-style: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.tutorial-concept-figure__items li { + display: grid; + min-width: 0; + padding: 24px; + border-right: 1px solid var(--line); + gap: 14px; + grid-template-columns: auto minmax(0, 1fr); +} + +.tutorial-concept-figure__items li:last-child { + border-right: 0; +} + +.tutorial-concept-figure__items li > span { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: 10px; + background: #e4eeff; + color: var(--signal-700); + font-size: 0.67rem; + font-weight: 850; +} + +.tutorial-concept-figure__items div { + display: grid; + gap: 7px; +} + +.tutorial-concept-figure__items strong { + color: var(--navy-900); + font-size: 0.88rem; +} + +.tutorial-concept-figure__items p { + margin: 0; + font-size: 0.82rem; + line-height: 1.58; +} + +.tutorial-concept-figure figcaption { + padding: 15px 24px 17px; + border-top: 1px solid var(--line); + background: #f5f8fc; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.55; +} + .tutorial-overview { overflow: hidden; border: 1px solid var(--line); @@ -3113,6 +3208,241 @@ h3 { background: #edf9f5; } +.tutorial-app-capture { + overflow: hidden; + margin: 22px 0 0; + border: 1px solid #b9cadf; + border-radius: 18px; + background: var(--white); + box-shadow: 0 16px 38px rgb(8 27 53 / 12%); +} + +.tutorial-app-capture__frame { + padding: 6px; + background: linear-gradient(145deg, #253d5c, #09172a 72%); +} + +.tutorial-app-capture__image { + display: block; + width: 100%; + height: auto; + border-radius: 12px; + background: #f7f8fb; +} + +.tutorial-app-capture figcaption { + display: flex; + min-height: 48px; + padding: 13px 15px 14px; + align-items: center; + justify-content: space-between; + gap: 18px; + border-top: 1px solid #d9e3ee; + background: #f5f8fc; + color: var(--muted); + font-size: 0.72rem; + font-weight: 650; + line-height: 1.5; +} + +.tutorial-app-capture__evidence { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + color: var(--navy-850); + font-weight: 800; +} + +.tutorial-app-capture__evidence i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 5px rgb(55 210 145 / 12%); +} + +.tutorial-identity-gallery { + overflow: hidden; + margin: 24px 0 0; + border: 1px solid #b9cadf; + border-radius: 18px; + background: #d9e3ef; + box-shadow: 0 16px 38px rgb(8 27 53 / 12%); +} + +.tutorial-identity-gallery__heading { + padding: 22px 24px 23px; + border-bottom: 1px solid #c4d2e2; + background: + radial-gradient(circle at 94% 14%, rgb(124 215 255 / 18%), transparent 31%), + linear-gradient(145deg, #142b48, #09182a 74%); +} + +.tutorial-identity-gallery__heading > span { + display: block; + color: var(--cyan-300); + font-size: 0.64rem; + font-weight: 820; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tutorial-lesson .tutorial-identity-gallery__heading h3 { + margin: 7px 0 6px; + color: var(--white); + font-size: clamp(1.15rem, 2.5vw, 1.48rem); +} + +.tutorial-identity-gallery__heading p { + max-width: 690px; + margin: 0; + color: #b9cadd; + font-size: 0.8rem; + line-height: 1.65; +} + +.tutorial-identity-gallery__grid { + display: grid; + margin: 0; + padding: 7px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + list-style: none; +} + +.tutorial-lesson .tutorial-identity-gallery__grid > li { + min-width: 0; + margin: 0; +} + +.tutorial-identity-gallery__grid[data-board-count="5"] + > li:last-child:nth-child(odd) { + grid-column: 1 / -1; + width: calc(50% - 3.5px); + justify-self: center; +} + +.tutorial-identity-gallery__grid[data-board-count="1"] { + grid-template-columns: minmax(0, 520px); + justify-content: center; +} + +.tutorial-identity-card { + display: grid; + min-width: 0; + height: 100%; + padding: 17px; + border: 1px solid rgb(183 200 220 / 72%); + border-radius: 13px; + background: #f8fafe; + align-content: start; + box-shadow: 0 4px 12px rgb(8 27 53 / 7%); + gap: 13px; +} + +.tutorial-identity-card > header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tutorial-identity-card__id { + flex: 0 0 auto; + padding: 5px 8px; + border: 1px solid #a9cae8; + border-radius: 999px; + background: #e6f3ff; + color: #174e7b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.64rem; + font-weight: 820; + letter-spacing: 0.04em; +} + +.tutorial-lesson .tutorial-identity-card h4 { + min-width: 0; + margin: 0; + color: var(--navy-900); + font-size: 0.78rem; + line-height: 1.35; + text-align: right; +} + +.tutorial-identity-card__capture { + overflow: hidden; + min-width: 0; + border: 1px solid #cbd8e7; + border-radius: 10px; + background: #e9eff7; +} + +.tutorial-identity-card__capture > span { + display: block; + padding: 7px 9px; + border-bottom: 1px solid #cbd8e7; + color: #49627d; + font-size: 0.61rem; + font-weight: 790; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.tutorial-identity-card__capture > span code { + color: var(--navy-900); + font-size: inherit; + letter-spacing: 0; + text-transform: none; +} + +.tutorial-identity-card__capture img { + display: block; + width: 100%; + height: auto; + background: #f7f8fb; +} + +.tutorial-identity-card > p { + margin: 0; + color: #536a83; + font-size: 0.72rem; + line-height: 1.6; +} + +.tutorial-identity-gallery figcaption { + display: flex; + min-height: 52px; + padding: 13px 16px 14px; + align-items: center; + justify-content: space-between; + gap: 18px; + border-top: 1px solid #c4d2e2; + background: #f5f8fc; + color: var(--muted); + font-size: 0.7rem; + font-weight: 650; + line-height: 1.5; +} + +.tutorial-identity-gallery__evidence { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + color: var(--navy-850); + font-weight: 800; +} + +.tutorial-identity-gallery__evidence i { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 5px rgb(55 210 145 / 12%); +} + .tutorial-steps { display: grid; margin: 0; @@ -3256,45 +3586,425 @@ h3 { text-align: center; } -.site-footer { - border-top: 1px solid rgb(155 191 233 / 14%); - background: var(--navy-950); - color: #8799b0; +.features-page { + background: var(--white); } -.site-footer__grid { - display: grid; - min-height: 200px; - padding-block: 44px; - align-items: center; - grid-template-columns: 1fr auto 1fr; - gap: 44px; +.feature-diagram-section { + padding-block: clamp(76px, 9vw, 112px); } -.brand--footer .brand__mark { - width: 34px; - height: 34px; +.feature-diagram-section .section-heading, +.feature-reference .section-heading, +.feature-profiles .section-heading { + max-width: 790px; } -.site-footer__tagline { - margin: 9px 0 0; - font-size: 0.75rem; +.firmware-diagram { + overflow: hidden; + margin: 0; + border: 1px solid #bdcde0; + border-radius: 22px; + background: #eaf0f8; + box-shadow: var(--shadow-md); } -.site-footer__links { - display: flex; - gap: 26px; +.firmware-diagram__viewport { + overflow: auto; + max-width: 100%; + padding: 12px; + overscroll-behavior-inline: contain; + scrollbar-color: #7390b4 #dbe5f1; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; } -.site-footer__links a { - color: #b8c7d8; - font-size: 0.81rem; - font-weight: 650; +.firmware-diagram__viewport:focus-visible, +.feature-table-scroll:focus-visible { + outline-color: var(--signal-600); + outline-offset: -5px; + box-shadow: inset 0 0 0 2px var(--white); } -.site-footer__links a:hover { - color: var(--white); -} +.firmware-diagram__image { + width: max(100%, 72rem); + min-width: 72rem; + height: auto; + max-width: none; + border: 1px solid #c9d7e7; + border-radius: 12px; + background: #f5f8fc; +} + +.firmware-diagram figcaption { + display: flex; + min-height: 74px; + padding: 17px 21px; + border-top: 1px solid #c4d2e2; + align-items: center; + justify-content: space-between; + background: var(--white); + gap: 24px; +} + +.firmware-diagram figcaption p { + max-width: 740px; + margin: 0; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.55; +} + +.firmware-diagram figcaption a { + display: inline-flex; + min-height: 44px; + padding: 8px 3px; + align-items: center; + flex: 0 0 auto; + color: var(--signal-600); + font-size: 0.78rem; + font-weight: 800; + gap: 8px; +} + +.firmware-diagram figcaption a:hover { + color: var(--navy-900); +} + +.firmware-diagram figcaption svg { + width: 16px; +} + +.feature-reading-width { + max-width: 940px; +} + +.feature-description__body { + padding-left: clamp(22px, 4vw, 46px); + border-left: 4px solid var(--signal-600); +} + +.feature-description__body p { + margin-bottom: 24px; + color: #304965; + font-size: clamp(1rem, 1.4vw, 1.08rem); + line-height: 1.8; +} + +.feature-description__body p:last-child { + margin-bottom: 0; +} + +.feature-description__body code, +.feature-limits__copy code { + padding: 0.12em 0.35em; + border-radius: 5px; + background: #dfe9f7; + color: var(--navy-900); + font-size: 0.88em; +} + +.feature-reference { + background: var(--white); +} + +.feature-reference__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.feature-reference__grid article { + min-height: 210px; + padding: 27px 29px; + border: 1px solid var(--line); + border-radius: 16px; + background: + linear-gradient(145deg, rgb(238 245 255 / 72%), transparent 54%), + var(--white); + box-shadow: var(--shadow-sm); +} + +.feature-reference__grid h3 { + margin-bottom: 12px; + font-size: 1.14rem; +} + +.feature-reference__grid p { + margin: 0; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.72; +} + +.operation-surface { + display: grid; + margin-top: 32px; + padding: clamp(28px, 5vw, 48px); + border-radius: 20px; + background: + radial-gradient(circle at 90% 10%, rgb(47 140 255 / 22%), transparent 34%), + var(--navy-900); + color: #b9cadd; + grid-template-columns: minmax(230px, 0.7fr) minmax(0, 1.3fr); + gap: clamp(30px, 5vw, 62px); + box-shadow: var(--shadow-md); +} + +.operation-surface > div:first-child h3 { + margin-bottom: 15px; + color: var(--white); + font-size: clamp(1.65rem, 3vw, 2.35rem); +} + +.operation-surface > div:first-child > p:last-child { + margin: 0; + color: #b9cadd; + font-size: 0.86rem; +} + +.operation-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 28px; +} + +.operation-groups section { + min-width: 0; +} + +.operation-groups h4 { + margin: 0 0 9px; + color: var(--cyan-300); + font-size: 0.68rem; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.operation-groups ul { + display: flex; + margin: 0; + padding: 0; + flex-wrap: wrap; + gap: 7px; + list-style: none; +} + +.operation-groups code { + display: inline-flex; + min-height: 28px; + padding: 4px 8px; + align-items: center; + border: 1px solid rgb(124 215 255 / 24%); + border-radius: 6px; + background: rgb(5 18 36 / 72%); + color: #e5f1ff; + font-size: 0.68rem; + line-height: 1.25; +} + +.feature-table-scroll { + overflow: auto; + max-width: 100%; + border: 1px solid #bdcde0; + border-radius: 18px; + background: var(--white); + box-shadow: var(--shadow-sm); + scrollbar-color: #7390b4 #e7edf5; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; +} + +.feature-profile-table { + width: 100%; + min-width: 1060px; + border-collapse: collapse; + color: #29435f; + font-size: 0.8rem; + line-height: 1.58; +} + +.feature-profile-table caption { + padding: 18px 21px; + border-bottom: 1px solid #bdcde0; + background: #eaf2ff; + color: #35516f; + font-size: 0.74rem; + font-weight: 650; + text-align: left; +} + +.feature-profile-table th, +.feature-profile-table td { + padding: 18px 19px; + border-right: 1px solid #d9e3ef; + border-bottom: 1px solid #d9e3ef; + vertical-align: top; + text-align: left; +} + +.feature-profile-table th:last-child, +.feature-profile-table td:last-child { + border-right: 0; +} + +.feature-profile-table tbody tr:last-child th, +.feature-profile-table tbody tr:last-child td { + border-bottom: 0; +} + +.feature-profile-table thead th { + background: var(--navy-900); + color: var(--white); + font-size: 0.67rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.feature-profile-table tbody th { + width: 190px; + background: #f5f8fc; +} + +.feature-profile-table tbody th code { + color: #164c83; + font-size: 0.72rem; + overflow-wrap: anywhere; +} + +.feature-limits { + border-bottom: 1px solid var(--line); + background: #fff8e9; +} + +.feature-limits__grid { + display: grid; + grid-template-columns: minmax(240px, 0.72fr) minmax(0, 1.28fr); + gap: clamp(38px, 7vw, 90px); +} + +.feature-limits__grid h2 { + max-width: 430px; +} + +.feature-limits__copy { + padding: clamp(26px, 4vw, 38px); + border: 1px solid #e2c485; + border-radius: 18px; + background: rgb(255 255 255 / 68%); +} + +.feature-limits__copy p { + margin-bottom: 19px; + color: #5f4b2c; + font-size: 0.9rem; + line-height: 1.75; +} + +.feature-limits__copy p:last-child { + margin-bottom: 0; +} + +.feature-evidence { + background: var(--paper); +} + +.feature-evidence__card { + display: grid; + padding: clamp(34px, 6vw, 64px); + border-radius: var(--radius-lg); + background: + radial-gradient(circle at 12% 0%, rgb(47 140 255 / 28%), transparent 36%), + var(--navy-900); + color: #b8c9db; + grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr); + gap: clamp(44px, 8vw, 94px); + box-shadow: var(--shadow-md); +} + +.feature-evidence__card h2 { + max-width: 650px; + margin-bottom: 18px; + color: var(--white); +} + +.feature-evidence__card > div > p:not(.eyebrow) { + max-width: 620px; + margin-bottom: 28px; +} + +.feature-evidence__links { + display: grid; + align-content: center; + gap: 10px; +} + +.feature-evidence__links a { + display: flex; + min-height: 70px; + padding: 14px 17px; + justify-content: center; + flex-direction: column; + border: 1px solid rgb(124 215 255 / 20%); + border-radius: 11px; + background: rgb(5 18 36 / 42%); + color: var(--white); + font-size: 0.86rem; + font-weight: 750; +} + +.feature-evidence__links a:hover { + border-color: var(--cyan-300); + background: rgb(47 140 255 / 16%); +} + +.feature-evidence__links span { + color: var(--cyan-300); + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.site-footer { + border-top: 1px solid rgb(155 191 233 / 14%); + background: var(--navy-950); + color: #8799b0; +} + +.site-footer__grid { + display: grid; + min-height: 200px; + padding-block: 44px; + align-items: center; + grid-template-columns: 1fr auto 1fr; + gap: 44px; +} + +.brand--footer .brand__mark { + width: 34px; + height: 34px; +} + +.site-footer__tagline { + margin: 9px 0 0; + font-size: 0.75rem; +} + +.site-footer__links { + display: flex; + gap: 26px; +} + +.site-footer__links a { + color: #b8c7d8; + font-size: 0.81rem; + font-weight: 650; +} + +.site-footer__links a:hover { + color: var(--white); +} .site-footer__legal { margin: 0; @@ -3465,6 +4175,16 @@ h3 { max-width: 680px; } + .operation-surface, + .feature-limits__grid, + .feature-evidence__card { + grid-template-columns: 1fr; + } + + .feature-evidence__links { + max-width: 620px; + } + .site-footer__grid { grid-template-columns: 1fr auto; } @@ -3573,6 +4293,63 @@ h3 { margin-bottom: 22px; } + .feature-diagram-section { + padding-block: 70px; + } + + .firmware-diagram { + border-radius: 16px; + } + + .firmware-diagram__viewport { + padding: 8px; + } + + .firmware-diagram__image { + width: 64rem; + min-width: 64rem; + border-radius: 8px; + } + + .firmware-diagram figcaption { + padding: 17px; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .feature-description__body { + padding-left: 20px; + } + + .feature-reference__grid, + .operation-groups { + grid-template-columns: 1fr; + } + + .feature-reference__grid article { + min-height: 0; + padding: 24px; + } + + .operation-surface { + padding: 28px 24px; + gap: 34px; + } + + .feature-profile-table { + min-width: 960px; + } + + .feature-limits__copy { + padding: 25px 23px; + } + + .feature-evidence__card { + padding: 31px 24px; + border-radius: 20px; + } + .blocks-visual__body { grid-template-columns: 1fr; } @@ -3780,6 +4557,75 @@ h3 { gap: 6px; } + .tutorial-concept-figure { + border-radius: 16px; + } + + .tutorial-concept-figure__heading { + padding: 20px; + } + + .tutorial-concept-figure__items { + grid-template-columns: 1fr; + } + + .tutorial-concept-figure__items li { + padding: 20px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .tutorial-concept-figure__items li:last-child { + border-bottom: 0; + } + + .tutorial-concept-figure figcaption { + padding: 14px 20px 16px; + } + + .tutorial-app-capture { + border-radius: 14px; + } + + .tutorial-app-capture__image { + border-radius: 9px; + } + + .tutorial-app-capture figcaption { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + + .tutorial-identity-gallery { + border-radius: 14px; + } + + .tutorial-identity-gallery__heading { + padding: 20px; + } + + .tutorial-identity-gallery__grid, + .tutorial-identity-gallery__grid[data-board-count="1"] { + grid-template-columns: 1fr; + } + + .tutorial-identity-gallery__grid[data-board-count="5"] + > li:last-child:nth-child(odd) { + width: 100%; + grid-column: auto; + } + + .tutorial-identity-card { + padding: 14px; + } + + .tutorial-identity-gallery figcaption { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } + .tutorial-toc { display: grid; padding: 13px; @@ -3908,6 +4754,22 @@ h3 { display: block; } + .firmware-diagram, + .feature-table-scroll { + overflow: visible; + box-shadow: none; + } + + .firmware-diagram__viewport { + overflow: visible; + padding: 0; + } + + .firmware-diagram__image { + width: 100%; + min-width: 0; + } + .policy-contact, .report-card { margin-top: 40px; diff --git a/tools/web/src/app/learn/blocks/page.tsx b/tools/web/src/app/learn/blocks/page.tsx index a90e2e0d..e7bd74e6 100644 --- a/tools/web/src/app/learn/blocks/page.tsx +++ b/tools/web/src/app/learn/blocks/page.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -47,6 +48,7 @@ const steps: readonly TutorialStep[] = [ bundled starter.

), + visual: , expected: "the Blocks canvas contains an editable Hello PyBLE copy and nothing has been saved or run.", }, diff --git a/tools/web/src/app/learn/configured-hardware/page.tsx b/tools/web/src/app/learn/configured-hardware/page.tsx index 67ad5c7b..86b25595 100644 --- a/tools/web/src/app/learn/configured-hardware/page.tsx +++ b/tools/web/src/app/learn/configured-hardware/page.tsx @@ -2,6 +2,8 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import { TutorialCallout } from "@/components/tutorial-callout"; +import { TutorialBoardIdentityGallery } from "@/components/tutorial-board-identity-gallery"; +import { TutorialConceptFigure } from "@/components/tutorial-concept-figure"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -146,6 +148,34 @@ export default function ConfiguredHardwareTutorial() { components, or circuit.

+ +
diff --git a/tools/web/src/app/learn/examples/page.tsx b/tools/web/src/app/learn/examples/page.tsx index 77ab9335..c50be52e 100644 --- a/tools/web/src/app/learn/examples/page.tsx +++ b/tools/web/src/app/learn/examples/page.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { @@ -93,6 +94,7 @@ const steps: readonly TutorialStep[] = [ selected source and target. Import does not make the example validated.

), + visual: , expected: "the terminal import result and refreshed Files list agree without any automatic open or Run.", }, @@ -172,7 +174,7 @@ export default function ExamplesTutorial() {
{examplesSnapshot.examples.length}
-
Firmware baseline
+
Snapshot's original firmware baseline (historical)
{examplesSnapshot.firmwareBaseline}
diff --git a/tools/web/src/app/learn/files/page.tsx b/tools/web/src/app/learn/files/page.tsx index b6767ddc..50e442c5 100644 --- a/tools/web/src/app/learn/files/page.tsx +++ b/tools/web/src/app/learn/files/page.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -17,22 +18,23 @@ const steps: readonly TutorialStep[] = [ title: "Work below the board root", body: (

- Open Files and create or enter /examples. If it already - contains valuable work, make a new disposable child folder instead. Keep + Open Files and create or enter a disposable child folder such as{" "} + /tutorial-capture. You may instead work below{" "} + /examples when that folder contains no valuable work. Keep the breadcrumb visible and confirm the exact current path before every mutation.

), expected: - "Files shows /examples, or the disposable child you chose, as the current board folder.", + "Files shows /tutorial-capture, or the disposable child you chose, as the current board folder.", }, { title: "Create, open, edit, save, and refresh", body: (

Create delete_me_one.py and delete_me_two.py. - Open each file, enter a harmless print line, Save, return to Files, and - choose Refresh. Use only files you are prepared to lose. + Leave them empty for this deletion exercise, return to Files, and choose + Refresh. Use only files you are prepared to lose.

), expected: @@ -60,6 +62,7 @@ const steps: readonly TutorialStep[] = [ Confirm in Files that neither disposable file was removed.

), + visual: , expected: "the dialog closes and both files remain; cancellation issues no deletion.", }, @@ -106,10 +109,10 @@ export default function FilesTutorial() {

Before you begin

- Practice only with disposable files in /examples or a - child folder. Files can create, open, edit, save, refresh, and remove - board content, but this lesson does not promise direct export to - tablet storage. + Practice only with disposable files in /tutorial-capture,{" "} + /examples, or a child folder. Files can create, open, + edit, save, refresh, and remove board content, but this lesson does + not promise direct export to tablet storage.

@@ -119,6 +122,20 @@ export default function FilesTutorial() { deleted and later targets are unattempted.

+ +

+ All five official profiles use LFS2. Back up files before migrating + an older FAT workspace and use the documented installer. If storage + cannot mount, nonblank or uncertain media is preserved without + starting the agent; explicit USB recovery is required. It is not + silently reformatted. +

+

+ Finish or cancel an active PUT or GET before deleting, renaming, or + creating a directory. Those mutations return EBUSY during a + transfer; listing and inspecting files remain available. +

+
diff --git a/tools/web/src/app/learn/first-program/page.tsx b/tools/web/src/app/learn/first-program/page.tsx index 99b41d63..2ba90b30 100644 --- a/tools/web/src/app/learn/first-program/page.tsx +++ b/tools/web/src/app/learn/first-program/page.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -19,9 +20,11 @@ const steps: readonly TutorialStep[] = [ title: "Create a new board-backed file", body: (

- With the correct board connected, choose New file in the Editor and name - it hello.py. Enter exactly the one hardware-free line - below. It uses no GPIO and is designed for all five profiles. + With the correct board connected, in Files enter the disposable child + folder you prepared, choose New file, and name it hello.py. + PyBLE opens the new board-backed file in the Editor. Enter exactly the + one hardware-free line below. It uses no GPIO and is designed for all + five profiles.

), code: helloProgram, @@ -49,6 +52,7 @@ const steps: readonly TutorialStep[] = [ file alone never executes it.

), + visual: , expected: ( <> Console prints Hello from PyBLE! once and the finite @@ -61,7 +65,9 @@ const steps: readonly TutorialStep[] = [ body: (

Change the message, Save the new snapshot, and Run it again. Confirm - that Console shows the changed text, not the earlier copy. + that Console shows the changed text, not the earlier copy. Firmware + 0.6.1 gives each Run fresh globals, so recreate variables in your + program. Imported modules and hardware state are not necessarily reset.

), expected: diff --git a/tools/web/src/app/learn/github-import/page.tsx b/tools/web/src/app/learn/github-import/page.tsx index 25d8178c..0f02b243 100644 --- a/tools/web/src/app/learn/github-import/page.tsx +++ b/tools/web/src/app/learn/github-import/page.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Part of PyBLE (https://pyble.dev) — see /LICENSE. +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { examplesSnapshot } from "@/lib/tutorials"; @@ -14,16 +15,16 @@ export const metadata = pageMetadata({ }); const exampleFolder = "examples/portable/basics/hello_console"; -const boardFolder = "/examples/portable/basics"; +const boardFolder = "/examples"; const steps: readonly TutorialStep[] = [ { title: "Create the destination before opening import", body: (

- In Files, create and enter {boardFolder}, one child folder - at a time if necessary. GitHub import does not create board directories. - Keep this current destination visible before opening Import examples. + In Files, create and enter {boardFolder} before opening the + importer. GitHub import does not create board directories. Keep this + dedicated destination visible rather than writing at the board root.

), expected: ( @@ -52,11 +53,12 @@ const steps: readonly TutorialStep[] = [ In Branch mode, load the chooser and observe that it lists only branches. Select main to understand branch discovery, but do not use that moving name as this lesson's reproducibility - identity. Open Advanced, choose tag or commit, and enter the full - 40-character commit below. + identity. Choose Use a tag or commit, then enter the full 40-character + commit below.

), code: examplesSnapshot.commit, + visual: , expected: "the importer resolves and displays the same full immutable commit before browsing files.", }, @@ -72,6 +74,7 @@ const steps: readonly TutorialStep[] = [ ), expected: "the review contains one pinned source path and one derived board target, with no hidden descendants.", + visual: , }, { title: "Verify exact source, target, and overwrite state", @@ -84,6 +87,7 @@ const steps: readonly TutorialStep[] = [ it is intentional.

), + visual: , stopIf: "the source commit, source folder, current directory, filename, or overwrite state differs from your review.", }, diff --git a/tools/web/src/app/learn/hardware/page.tsx b/tools/web/src/app/learn/hardware/page.tsx index 20e7a8dd..80575bf1 100644 --- a/tools/web/src/app/learn/hardware/page.tsx +++ b/tools/web/src/app/learn/hardware/page.tsx @@ -2,6 +2,11 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import { TutorialCallout } from "@/components/tutorial-callout"; +import { + allTutorialBoardIdentityKeys, + TutorialBoardIdentityGallery, +} from "@/components/tutorial-board-identity-gallery"; +import { TutorialConceptFigure } from "@/components/tutorial-concept-figure"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -139,6 +144,34 @@ export default function HardwareTutorial() { memory size, or chip token.

+ +
diff --git a/tools/web/src/app/learn/page.tsx b/tools/web/src/app/learn/page.tsx index 03fe186f..1b3a1664 100644 --- a/tools/web/src/app/learn/page.tsx +++ b/tools/web/src/app/learn/page.tsx @@ -11,6 +11,7 @@ import { examplesSnapshot, firmwareProfiles, tutorials, + tutorialFirmwareReference, } from "@/lib/tutorials"; import { pageMetadata } from "@/lib/site"; @@ -49,10 +50,11 @@ export default function LearnPage() { tone="note" >

- Tutorial review baseline: PyBLE app 0.2.0 beta, PBLE/1, and - firmware 0.6.0. That baseline is not a live installer promise. - Always check the active version, exact profile, and enabled action - on the current firmware page. + Current tutorial guidance: PyBLE app 0.2.0 beta, PBLE/1, and + firmware {tutorialFirmwareReference}. Historical screenshots and + the examples snapshot retain their original version labels. Always + check the active version, exact profile, and enabled action on the{" "} + current firmware page.

@@ -76,8 +78,9 @@ export default function LearnPage() {
{compatibilityLabels.qualifiedFirmware}
- A firmware-release state backed by the project's release - and hardware gates. Confirm it on the current installer. + A firmware-release qualification claim. Confirm its basis on the + current installer: v0.6.1 is published on owner confirmation, + distinct from the retained automated HIL records.
diff --git a/tools/web/src/app/learn/pico-2-w/page.tsx b/tools/web/src/app/learn/pico-2-w/page.tsx index 6a9b5ef4..7de08759 100644 --- a/tools/web/src/app/learn/pico-2-w/page.tsx +++ b/tools/web/src/app/learn/pico-2-w/page.tsx @@ -2,6 +2,8 @@ // Part of PyBLE (https://pyble.dev) — see /LICENSE. import { TutorialCallout } from "@/components/tutorial-callout"; +import { TutorialBoardIdentityGallery } from "@/components/tutorial-board-identity-gallery"; +import { TutorialConceptFigure } from "@/components/tutorial-concept-figure"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { examplesSnapshot } from "@/lib/tutorials"; import { pageMetadata } from "@/lib/site"; @@ -115,7 +117,7 @@ export default function Pico2WTutorial() { compatibilityNote={

This exact-board lesson is only for Raspberry Pi Pico 2 W. Firmware - 0.6.0 exposes its named onboard LED surface; it does not claim + 0.6.1 exposes its named onboard LED surface; it does not claim NeoPixel support for this profile.

} @@ -134,6 +136,34 @@ export default function Pico2WTutorial() { Web Serial instructions into this UF2/BOOTSEL profile.

+ +
diff --git a/tools/web/src/app/learn/setup/page.tsx b/tools/web/src/app/learn/setup/page.tsx index 5580d6e9..03f3982b 100644 --- a/tools/web/src/app/learn/setup/page.tsx +++ b/tools/web/src/app/learn/setup/page.tsx @@ -3,6 +3,11 @@ import Link from "next/link"; +import { TutorialAppCapture } from "@/components/tutorial-app-capture"; +import { + allTutorialBoardIdentityKeys, + TutorialBoardIdentityGallery, +} from "@/components/tutorial-board-identity-gallery"; import { TutorialCallout } from "@/components/tutorial-callout"; import { TutorialPage, type TutorialStep } from "@/components/tutorial-page"; import { pageMetadata } from "@/lib/site"; @@ -51,6 +56,7 @@ const steps: readonly TutorialStep[] = [ to connect.

), + visual: , expected: "the scan shows one PyBLE-… advertisement and the app reaches its connected workspace.", }, @@ -144,6 +150,12 @@ export default function SetupTutorial() {

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 z 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 Cvod1< literal 0 HcmV?d00001 diff --git a/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-import-branch-chooser-4514d2f0522b.png b/tools/web/public/learn/app/pyble-app-0.2.0-build-5-github-import-branch-chooser-4514d2f0522b.png new file mode 100644 index 0000000000000000000000000000000000000000..7da3a35b79e3f2226ebb0b83ad4531a11d9c33d0 GIT binary patch literal 139089 zcmdqJg;$ha8$LRUfGD97(xaqEhjfUfl1eBb-Q5fg3IZaHgtUNk*U%{-Eje_I1V;3#YfRdiZl#N(a&k@ICcAg=Rnc1is@!Di{ z)-bQLonj?^{`ar_{<0;PTCoh+;K0bbL+E01eClI|pjdY{N&}%%+ z(t7ale_rbYA@>HPB>(5tobxE?e_zjDKK`HIdU%T{?c4u;1#<82|LZak%r%<-efi#Y z`rpeue0i((e=l=44Wj)&HzRpR*_Z8qFLV3<7q`Qad&ePXj%bzc5S!Fdzp=jg(W;Q!~-4@rQBhunJ}EXyR%SWcIip7)4Ya+%-0%>MV> zXxxMFmlxWes{hYDjwF=5t|rJ%%yZ6PqJ8{z-m4)3SCmRi$g1 znO}oeSyhd(-ILGXh|0FPG-xXP}mO$sH`dd=MZyQ?FTV?gio%ta}$Mm|3c<4gCqBurn{iBw4z zR@Pc_()3RR36%BEYUbzl7!;D^RaNPzQPdwaG;*K)vpx-~5)v3+q`uJ6(Um!jIt&dB zsS<(zjQZyJzpF;HZZuHb;^q_`lynWudqyK7X=2a2TZ8i*q3-am`9Z*WXU?o zR)r7tg$`75Uk`AN-<^CQ{sko!amO9bQf!+W&~5)`ch*hI%F0jHk*VV) z`VGNPX@=T;}aA5rlx`U`OLFAI{ZRH z;_PGrs^AXF1Pq0y%9e2U?;5ziEVu`*LccX7_p#sR)r=oMy~F2wLoy2S9FgVy!W2pb z1jG{m&8^H-=4UL+fzT$?!wt3P0UuP=)eWqzLn|u-^oRup-wh81g@+@z%KJAE&K!w- zMn)!4NlEf*YHHcBrEoYSjc}5$F9bIrg(_1klv?D-J{jdT*PkR})juunZ+f@w_6U82 ztnK{zT3BIMz|476PEkI?Uj^3a^Qb?6WUt)!S>;K^Zee?P5dWL#b|rArwTPb~lmfw9 zTP)7oY~X&%TxQ%I_8oQ)58J!D-$*?a5Km}xG&}QxH+8c=(;LoGFfuWDr>i@+zOu2o z*);_ZPfDUpqNK97TdQd>Vt$FnE|YWANU4u~`Cd%=4*pwlN%w~^?Dr5Cv|MZdBni7r zCdgmm{BpJM~uh@U?O<0Y^WBliH8%VXq1ImzZ`V z5@du%lmtiuOk%0;>M@4RzR8u=;pkxTOvZhOy|m2QkQukv{%%-?mSt*h-E$Y(0vwUs zK8(+v$^IK*k@%-4CmhCmM0@+kk%@`d!+t z1qFIC1*&_}N=TxyfeFzum*>YiKAQtxQ;JUtcnnP*whu%aEAC zd%C*NFKaRj;bPZ!n)^o8LN7CWChra0+S}Xv)7{O$${GrG!ot$h-oc^6Xd@9}yf;g9 zL`{@(etOF3e(>z$$B({Y#ms&!zUAfI;-5Z&pnR*Nga6XpoQc6wCEW6>O*QDJy`Ao~ zU%q_tJfDGEH=0;legj{g)tSA6iOHqsJMh+){i!~IC8E&s#f%Zx!u-5%V4#q9G~pQ;SLu zel+&WHY=~FcDQF;Yl@T2^^VPXT_#Cn(L{7yZ~wGN68DJ{7Qb8WlZHPtKB+QhW;yqs z{u9|U^z`&!;&gr#zdDd`K6JJkFNutbdISN_NXqTbGd4E%ToK#*WSy}$G?ZUJh?|Zf ztJ8(dOBno?bB{Z?#@Q9p#^PR`%QkAHUR_-s)PF*1lCf-?u|ER?rfnUe*bV3fNh#@} zAM^FrCP#F15lQu85qzYi+!jPBDJdeW@!@j!5AyP!KYBzs=~?*m=O>WVd;9yZ-za9) zW|51DHDw5vg3|h@x7X@LLuqMbLIQd4H@QE5{yd@OCiL;SR@Xl|+E#$=PE1U4+08dA zT9>>1=no1F6=8z~Yu9)uD*7gGdP1I2l-Q&!+h!09Z9%=W6?vIa;@BC>CUqBk zgFJM6qB16&Z0F!0tv-Ed=utyvrvH3e8nxrnr*&6b4u1aloIg2!_vT)jYmn8!&{_5M zNg^IcdZwnGB|qD0jXfj+agCdOZ^aVA^J;?|x;(nN`hHed#~)IRIZozgJ8yEP*q@OF zve&Mkd>6@mLToEDU}L?j{o!qI$3Y>FgAvtZo&*Yw)W#U5v87t$rtY5Z)9p@Otue%; ze{?kAEJi&J{{8lr!A9MyTLp!=D}le};HL=)2s?v(%J|u(xfva!P~b(qXWnyWGKV2$ z%4AA{y5zo1zR1YfTg|=qCsCyfvSS`}&%2pr6=iAI83`aTC0Hvht=+!e=A@fD&$(s5 z!3fWg$}kfvYkOB0&Nam6bDKnX?Iuaaplpx~(%9!gi-90_#R#p|l?5wQDmjQTz)?al#s7k(i)vAx! z+}dg_`6*%N;(~hYEDwq%BVXKjd<*UlbsC|ZrkR=96C0!=mNdb%k0XwMfdB0Zv#-={ z%A4myL+@^KA~ls@%eJ&ZpVqK5Z2j=5yR->IOiT=9T!^2;NESD+ZXv6>+F#^c%n>V5 zzPh$54wIZzkS=yH${;U44}{JWZtm#j=9EF#ooej0u&}Tbs-Z{hzFB&gf#{~}vb^Br zSi<3>95@oSFl*uQ^XXI1apJF*a+(SWw|Z!RTc4?n!0MK`3zR@y+o9-qy6?t&pS7b8hT-voz*$HVALV?*Nr?2 zb#`4THDh8_3N^^Fh#x7hZCvmP8I*qawKb@03QS%qj3+l2Rl8bQTkjqnWmQysuim!* z{{6e%(Aq;8uVYo;d??t=%*Un=7s&E#xkbJMsCGQ#=mWpB{&%JjhzB7DJ`)>gI!StU z;7!9MP}BFMp8Ki045&65l_q!?8R=(z9R2`NSxr`u>FVlY5s;6tx_v*u6LR?-nH;U~ z`ZbPWM@Wm!bBz_*uc!Ny6$I2?;|NuCa(bSW=zPT6|70`v>C@Z6 znS@OZ4Gm6?=fQ@%8wpcecHZ}nH2=)y_SxajQ`6?`FD2t?8w10+A z(ZNCd`RL&bnF^~Mj|XiS2G4TY42|~$^EUD(v?3HUVaJ!8)I#*`6oQ=tzri$-oq9g{loq%jmOtly12b7#kY4iNNOacRv1;d0|Z0*x0C= za~*1r#kZgdt3`;ih|o4jxuVwAf(CqVEw3O|bktqOAIz9GLy{=9j*BM7%|P`Enf5w= z#?IU}J^rU?rq15U>G#xB-P{N-BV*vjg(nss<+$NzAp!fN?I{@-MP3LoG4Xez$A-uy zL$^!2r-Co_UP5@s#;hjm$$YnxtS&Au-M3$;X=+M4JIAazb|T?y5!|>nVoZiG6fiQy!8% zyn#q8zgx_H4>Nw6e~biE*VmnU0t`!J@$%~a{$HXZW5suW8oI?lrDaV%7;ysK!Op>f zhJ!z7zGPnAFlDP}N=DP+dAn4*9t5hZ+nPb6>3m;TN3$bE+vCqbPiM>Er=Ybz>gV4h z6Z%*25_j+K7#^H(#;*S`C$rLQx+$5`q0kxY7iRc>w*~FJv$M0_zWew}(&-L{A(o59 zCHUI%%JL&_o`B>jn9G!VxGG3oi1^c%STw))l{e^lQK@lC3eI>{E>$x+fquk~Ne0Kq zWnU`${*r3!GLllZd-CNJlW6%drL!;oJZfIry9ULA%BA+4*LR-!ce3yN0#cVibcZ8c zY&R!(PzH=VR72KIU|3uL3;aq#LIbYfrZ3^d$e7)Bp}ry~DS790R~H>U{jCyOwy+KzW_iw1k z+6Wyx`@mlBY$KEBy&ZxQc}x5rR@-Uu{Xi$XR^HT$m>6QKncC2#q^uHF28Jvpm%g{} zv;)emk1_cHkRN$5=ImDE*S24_;h;`eoRRf&-R93^fM)P>xiAepa&ihtiJ;N+x=5W% zxm%k__nV%by`yw6MsI6XJ@k>in-m$_lCYA`^!&MjZ!v+QqT-gq8qjpnd35lMIBv{xodfu3C`t|Op>7on}K*4Mu+gmd@aZ$#7O zC2GSx{A4&|Q2w?3aE+6ltPKY_%gpin_iC`}l#H3VyvrAGn3>z~=gX_uKg27wzmH|)|Q9-YjDW#0r)y>7U0AoZmu)q!f*G!eWb8vsnB#H3QVkV$s=U+embou#vHwC#ON*^`Cw=K10`MwW{LXV-AC)ym zED~;EthR*&dROmEm(2##2vg3~UM4+fXEq(^&#K$IxVU63j){vc^+4JrB_+pmxY4*A zxr~gCmAaq4i6V@~!MWek)lSwMkrWZs&_HF#7}D0>huK?Vzl~MKRsN@^kM>E<`f6Ck zcb>ewJg{N&nS-IW_Hij z)HI}wrRb@3sHtWPM<_|r!Be9K?u#js42mh^PDwm7kiV3owi6{kT0dj3CN^NXa=Kr9 zC@9qOR^1Aok}@=;1?|F+7k(+V2yk1@S*<$&srE2fn^U}7V$;i4DrU2b5;oV#QGI-> z71=^Z@Slydd%yPez({d^T;8bFZ!csi)El3hm)|;Di#|*n)Y5iYC%!ZZdG=3}U)KP| zpk`}``DA$*^SLxs#ZH+ZKl0(jLLGQ%X-nr+;$B%67N2#cB*4vd053_Gllis0v{P2j zKa`Y`(yT3-h1)_G^z|>xWGcz9A3VC_>xSP`xv-NUva!ivxZN7DN6m0!$RBVQ}K52n_8yg!SfI3B9 zhKGkUii$SQAgrydNcn6o#23;&{r$^vwB3_kRTT@ml&6;$pvRuD7Uou(0j2>TUmN`{ zqb&V%LPCP*#1{{32`Ywsl!r(Dre;csK6T5Tn$z;<#v+H-ccPPV25#kT)}dd0*jwQ% z%)$)(@_>@W$}J^k;sEmr9m}1%%g33Op)amJefmy>&a9{?VEVw@{GD5|lEsua(LBf2 z%$>E|*m89`DT`pVGvr|I(AQb845_Ta6eTh8;aq6vel6Mn*@MJ55vD`4wm>}7MSq;<{{NJYBo0DGc|Bx5 zmDNm@TKk$r_c1)Evb3_jzrU!@EehnJuo#b|Ep@lc@M_;*MPJmsUU+5Y3uk9%Fw5tm zjExI4>SE{TAEal{u;K~*LO*8_wkr>ggCpM-gUZnJDD5#Z@nLs@Dq;&gIIh>+(UIlo z%<|j-QIr*s?1xQ&QuVd5bbJEcgx_+n6Efg>NKXF7O5Q5JaU0_To>M9WAF1w&_8Kz1A>Se)41N&=x`_~R7pN(*=;+;As?6iirM{k>HoIJ0)j?|CGY z$W6>!sDxNziJMp%Sr1>@eSH63X}XH4+Qa<;CAsJLHq5wT+up^wWvrj%F}y82ZZMZQ z-|t7Qp^wm{H2c-v2>g%kUB=Hp|GSNLaXJn^ZXbwdm7clcqEVa_6(MT9$HOQ z6(-Z;#^NshrRF{7D}VD>iIQ?!a9oJ;@C?5{1XMV+d7V}NJ8LQf@fL1Y6fNi7^G8UtLu+l)y;jwarRP z^9|{hfAv|Sc4hpd_Q#zgYP;6C50=c98+EyLW{@er*~GHRdsU|G+$>M92xQvr>H0^z5bC5W1wH770+d9IUG*QlI~1ufn(8E;??5< z*77bczJXrvlLG64iNxx-kctCdxGj%MQrP6=#3FP}NnM%*pt)tEZFHlTFDyHZ)8*uW zK_K+B1Y?YM4A3+0G~Y>ETeAYd70WJ#ABN9bD+Y5huVv1u$ovinih_cI@x*vZHw^(W zY+20>9Z2QZCa^FlAY-HvQ zY|C~(lph<$M1b9TimH_>wYlch&{{9*Oqma;;agl@&ip@}8wa_({2R~Fb~zb#ondhB z5dR4O`$#O9TjbQ7iH}?gJw~W_IBKQ(6JwIWX!Sbr>DgK&?bNiQnsdyr!Ssxbj3RnQ zMgv1bJD4Nl^*DPxD^g64>}uYOvK}l&f{q5Uke0`W#b)!gDiq8@hGoeKejCF&N)(+_ z_DW!Oqn>{L7Zvh1LK)0YU_J)(yKluo7HZ1R&o4YD9>ddtoU&|lmYw_3zB0xoX>g^{;m4Y#zSbP1d1XjW*%=OuS1}Bq_9)358 z>KHza|1IPRjbQrBP2Gr0NeF206JZP$?0S?V$WOVq@*Bd&#zsU!^iEsN&zZ5dWasdh zmYMb&xWf0?@6NU7M@42iwoh!P@Nuy)O4|ZH+%|E{m~hBeaeRE;hfo4NbM?}29>Rkn z_*7UB5^LVqLqbgS&gn1dLf_E4Ar`);PR0!~J!U+Mt0-yvx16IfqaW%}(5dX7k!4X? zr9Sx75*dAt^%O@4@9R^0Gx$-T2$bJx`0>C-tpzSA&y^d&12VEMK=!EK{kMm52H;Ui z8yV4k{rVNO(E07{o4FT)gYy-aLa<^~IB=EX>guXsuH+NYP(a1;Fmz{RL{|zQ?d@&A z4|z#LC8Z@FkdWuao?LDw!*TT?%DTEfBTwBg#YKc*RpW$mT-_&FE~e$Jdj$8ylDl z{Ms0zatJFtC;0?zun#3bFY)wkKb80H;=7jEiW(2&CS*K9e`NjpXn7B z<{Imw8cA5&>uQ?-BqmyYTv1&U2x@!$Px{7}<{g@t6??nM3Pozyn? zgNZ(yHeK=#4qWT!K66!NWi(Hkn{>w}XJ(S485N(<2!C8~6ElJ-qw$Gjh-DfYgM+*M z#;g&{mWzuHdag5G=Ye#hB=|b;{BLsM{4OeJ0sHN{cz93g>0`FGvTTYK7=|BKnqYJ1 z=9*(GLXeG*CEWzwBUB^a7+VwL^UBP<8EqTHv&?wm9Ljp#%&>}X_x<#t$;_AS3zAoz z*OkATDtbw*n)qz?cLT0KZZ2_cU^aFn1}y*sfA;3Ceh=5>ZXQf;QBqP;-NhkjpKYO* zk&&s#b}eVOg))2Vh9%Z%eIr3&!@{9NqD_@S{4g%{#^-|{xf z;=`QMneAM)X?)6vXFQt9JW;RDoy%lS8j7{IpCIawMex;}QwMgCX13?xFj4PK+x}&N zC!sHsZW{Q|+o6E?^hlDTlgFI%cZ3-SB{JIcKFdBG2E6R;Bi#wDj9=eOA3 zrm5M@^lpROGZztrmaYC@PpC%{zy8w@WtA2-m)iVs2sIkqV}b{JCdS7fP!P7QvT8wO z<>l%4_~LG1V9hTt`Ox+^!w-fC)EXt>B) zv5YZndaD^SM}HL`9K3^t<)T%bJ9O^qTCJi!=ES;;d_2Fp;i9_PGr-PoHbdP|#(+`Z zz}oO*d3pH(F`4)`yIie!);1Rjm0}7J@nZ6qeEii+FKtQ49}Vflu|cqpe}Kl{K-+@@ zo};Wx$UKlad|pl&vc!Flt#Td4H#W6JY~0>3`?1Q`l(8Ehes(`fPDO5Wy3Dgx*e}f8 zOWH3|7OlMKNT}Hm8<7f-&Xd1=;W5d$1Gm!a+rrkN86feG80gFSn0}N5aZn~F)(%ip zQuuw#XC}*CCn6>?ur^cxbj3pC!|@leeQV!HLz&1Dp-)M@EP#&20TDFV^%c)@OvOcc z)0sj`t0P(X6DU+S(FE96NdjtFeeYXLT};-f#JW16AL~B=?J_v`=Oob) z1A|J0?n}^duWnuWO9g1$o-<*yDUy#Djy$P$eRRy@Me6SE!DTeAob)m50xKFs4R4d| z_m;Af^|)R`a$2C{Z168XNvx7 zK=o7q=M6zx#TWD03v+y8iu0KZhkoS3%pUAlPMJS24D`HUW9-*3O(~@LH(J7-0cZr8 zC#|QaXHal3%~MVwB6fFoKVxRLgk7zznYFehm4NYR01@YZxlc`=uBD;zkcz53OCiym zCy|VlDl|1!w<&*nzQwOpNLpJ(4!y9wB4uHrg7qUR>XowIgQ_Z-u|szS1y&Cg6_$Yp zVGm@`j?H!^F6A2lK%LBD-m-oBq2>G7>pK%%&uoE)rxg{pFbO1raviOiXYp69E}*1-Q-jk zq)4IQ92FwRJU>u9U>1Ji^8^+pSulIlR}gH|8to3Q0_)Xm4-F4WSO|8aYDANJ$YA6MOxBEc^cWP&QV8 zI^BzAJgPGK_XUwOHcUy*R7nOVjfrPycvi(LaS%odbS5-_Tdw6WgSB2nFe+tadYVuOD$g|`Cm=Z69#zF79;=A<TbrHoHo-bDk!8cZj3)5#tZh8RLFTXaG~JzUm$PEO&QIBgaO zYo+1G%Y48s5e=jxMH~E0L~(Xe5k4OupL7yW`g@q0UHu8aN3`a5ahr9~SHibBT~zug zt+sG>@3tCMq-W-BmCV4kC<@7VooAmX66e|FQ#YDZk$(u83ls+ z?%{4rcb+YN-NxpUUvYRi7ybJ53j?cKX~4^YK@}?vWI=DQ{17*HDZIQ5D5$<$Ta>(3 z)3O#8&(+joVx6mNYB)`LpB0?{<+z1H*{C8^RWv7^48=7x$OHrgKnGY@UY6}Nj7#k5 z>N4DqWhEk}S|g@^#u2f$_RPI|92vWxmW{m@x+)#ZqVdYagaPmi5P4BIuKxc1+F+q` z%&z_)C-rxpTQcv$4!1i99@6TBQA@f9E^e=V%_zj^uL|S;AT+5Cx@6-RZH{Z0+SL>s^C*-eXqh@XI-mjIvMV|3P7`~xiH6_^emz{*D(NfJ z&+$2SA=Bp}-F4V&?&*?fCs{Y~xIZ)=6ZhvO`Ysk;kgB+1$!kK!{JZ}?>85-F|Bs3a z%;)|eK+E6;<`rI>X-Q*adOs|D)wh!P2LEBq>6+(KD-N6hUv8FDZ0o2K-6g54{6zed zI6wcvN=iH)Fm)_#E%x@GXsfF6R@N2+#d&>xcP`PxgClY3<2PV$;`GB_S{ZfR*HN+M z7tc-=(tf}3Hh4EQE2}ZUdSVwi0m!HyGL-P3kyXly#JQf<3U^UA?oNZauq0f zw#M=YU~sCYaBDTZ z62(I(@O0=~8L)?DrEdf7w%LZOa%l5$s(VEBgT;qIP2a-r4f%ntm-m0d+j1R_2P~KUZ++Py zu7b5o>udeM52}hlD64A7yD4Cf9#-Z?Pv@zfzEv+nT863OCC-gYUic0OfPHWd& zbj&|U`9SB)8dxAR9f{K9m|YmOE=C@yOJ-sMndblQFRYhUz%(@^@ZYs*xiKF;h@>_| z6lwo%M`u>&kO~QD8DMpC82r9VEd8J8nfx_(6GnnEH)dv8M$!;0V)4v4GGV${&_5ti zM|1~Yq2J0J9bC6|J5P7Bp<41yi+jvOX+50fj!4D+WC0A{ePr*#$|?JQy{rup-cw+k z)+saIfRq{fdfm1B`%1|KI6;3tT!{4{j)`$skv#hW+L=x5X_oqefbX|&<@ZK#K%HaJ zsP#Xmu1?)&<4+x2>fmx|bu}3f2oHpM`P0kAIc@AaAE$`)rZ`fYCOh&b^4W^!d7aI- z5wNCiU!M&$)m3b1*)~~g97QXMjZ(1ORF^>Kbf5p+4C~148)Nnv@E6gI73vba1Rhyj zsOU$jNNUqvgl@U@xeEkKT4w*mFr3rrk1}vq$}#K*@c#b;H6K`CV#b=G*XAy;KHh1HCGeO}TxcAbwXV4$Zb_Ek|5z!Vb` zYk2rit)pr9uZw*B7&vNnnk5Ks@BGCec>Xeudc8B%r%c%UqO>!f!@Knp`yoF5b7djm zrYvr_5-e`KN-$0U_E{ArF$}TmEM*~3NW`u#qSMo90DD-yk&u6i)UtK#pD=~j^heC= z^(_4f`ooJnT|wx&9XA|9PAZk;`b~qv^hn~n6hftk3ULDlxLu>zxHOUJK);d-qz}Uuc_HBc74fybz1Cm z%MPjSW2)!G1GLJ*F}I0daX?Iv_VS{NW>9<=dDH99d}H$8?@!;~7qA_ZFREMJU<+IW zZZX*GIlEk}S5F(k^gwl~p{nZ5O}sJuuRH(14rp9N#KgBi%L((-OoDYcdY!v&{K#e< zKsN2rAhbQS^LHqlfy>1MIn#!T)P=kRK3CiNLzAg%LSr5-^F>L;9}f2zqEWAJ3e)=b zqv$AC)R$AB(C)0Hx7Q#0pbdEwg&I5^T^mml>2E$BDIbVTs(=557<~QLKZgVk?6gmJ z74biuZF2!ca*O{s$q|@cw%Yn_IY?dS8_!EBD=`x^i%m^U^CNC{+3TaEEC29-0Eg$m z`)^Gsryy@T*IBF814kU-z19bd;m-6T?j}KKZJPEK8#VUuAX8Bd1~E31L!UaJ)(qxPwha+QtX;K>D88y-ui4)b}L+n8813;(q*>6~jM7EJ*-` zYi+Y%ppJ>JR$!Pcm= zhsUg5@Pz&av8}CbI!y+@akr6)eCis`&C{P^95 z=)iZv0s}kWY9smp!n_;G|F`gDdb1e0fDJ7ZhW@uCzWVHUx&)v53OwdM_eo0J`z(%7 zGd&c=4IRB?+;xYwQ;mk$+@=G!=TqvC_C0e~9Vz~I0D5fgR?C-tt<@@(R9TS;!P=uEBgKKLYS1?UyByg&VSleZ znrmENU(XNR?HU({h}c?6_Y|rNf&GJMC5QHv_Yk|?UvVWimuKpm^=nbzSi{jxXi=NS z-K?cOnEvppLXywI7syAC!xzFwYZ=@9=lykPP0@3(z0Tml?Z=%N85qh=5{orT9q0k~ z>+0^#MaLWKI3N(1YbxJvHqtN&!h6UASXyjq_1PeEEZSH+U)D_YP?d;^sbmkWg;Te3 z6Nk1#1l2D82KcJOBGb*un{~gNTDPpCRAzmCL^2FCjqwUMLa67-Q``C@K}My7-(|K< zD=mT0^vul6_WB6{fs3PawCBO@?W_G_AHqkJp5xZ2kZ>gU1`QiqQTqYVpg|2EsJr$G zf3NHQ14LX!UD(EkMT*yzfau9in|iUakx_|f`#l}EL$%czXFkW`rp7iX-5WW%lTB8i zC9wUK@{Z2VBCCh&Y;3CwGEn{XGGo@P&dyFkD0HB8K+LrD1gCy?(h`_!G<1#+4wipn zLl?)UY%%6*9_!4PD~YyDPS&=&4-F5yZ66|72cRAYgX#-zQpWUPr+A<~XRq@wnORtj zz?xs|&s{HOQP?b}xa|vW1=PY*RH$-o&E`|D&luSoE*!qC0~VZ)k#WB*Tx@5+=Q@#3 z$15~iQ82g9IrCSxJz@q{uD{d~ix!u{%r!T^I2jJFzZ~${8@f0JsCl>A3m>HR9!N3A z{kf|*3JQJpb*Q|&d`{J*SYHkdBWW%N$*Iypz6cj9jHC#N0dYkQQo`S|%1Dh4O z*e>?j-rGekua`98LDgk5J%Kqm3_)WgJkKUfg)i1Q~^8lQpj^SKQ5+sYuba+8Tt4x_ZYr zry;433kwk1=+HAgQNQGJa&v#?n`^ZI;FUyDe`ai$V;R7ao{ zc*@>nwca>k#PyK$fv^5GXKw;$wEejjtXLb~KSX|+$6Rc5R)f4fKmYe|Cp)J)sOh8E ziz(O^MDL_TKgnxs0w^zKz;e-ei4t-_&-QJ+$UmL3hVKnxL#ysKWv6W^*!ZS{#V*-w>Odo2Td6u-zpkvD+Ma%@DS_<#(SY zKIi5ZnV5PCLs>sSnw#cM2>?|&?bb=&LskO-*8ejg;{9fq>{@ z(XIKs(i0b*lG5+Y*K0f&r(1dNQtr*0YHt)C9UTM2Zs;|m{a6LJy`v+okWiwo_jycp zH9yez<2{z}Jg+89Z;V0fUiGIP6g3xRa-4~MA6=wWm_ZLPojpBnV%-OoqN>Kq-kuZI zS2y)2#ly`_zl^K$gcJSYBkU&6!94&%mD{mH{`2SCz>TZlS6gW{QHJUJt0(rREyY@E zoq`I8gNwU7?bzP}=%M<0uZz8&fy}I5w{}`D9)se3H=0oiHFWN=6CVxeR;%*BfFm%q zIE`<<)S`Bu7thN}Eb0p3Rbg8-nR2`AAPpy58ye1ljO%AJfto;J#TO`-}-sjeEhx24unQ;#ZSTWc6pV+09l`AvX^=-@1N;aio zz*cL2iRah_4jLRLi~@6A%tKn~in>04+){jw1la25Z_6QiA+T9*Y}lM5fzQP$HaM!J zz^Z+XzEp2n#|FuYv)d6y)@E5;KbbeRyjj!`mU!*X4a#Kw>6lg$&jnppioJB*x$1`x zvQ}0s1R^_645h<6CMPGk0<=GTAl28`KR<35FO#=Z=2+?s6-%#8>@!L6xN?(o^}hBx z<~%;DVXd`jS`VOb+gS)au^6NoE7aHrvd*Y%jVAH2?a zzs}Jy`RW?2`JXVb9onWTG`BkubDq{X0|TO)EIO{JQ+54$xAK9dp&s^(#>Nzoe&4{E z7a}M$Rn%G0dHEZAQ-wdW+_Z18i}7kOU6HRhxd#-bX2RrZP1({+yXxKb=b@o2RX94pH?RT7;muGM#$G(5%b3L{0W!1=;_VedL^JCZO zTot4l@KS;!4%2SuR3F{f=}*p1H2a4Q-DZGf!s)!Bs^fZ8Lox$g#5^T>Ew_P(*B+wd zvyTTc>5uO!R}z~^|8-nnhg$sgUT3x8vhr+gDZGv{jc-mE;{iFj`$tiaKmw?Z+r>V3 z4p(Sr2=xr(RU_bZACNz4r6_J1w744tvK#C=pR%gzZ?H0LH;YlBqBIIMz5$|YKuPE8 zYoMNg8Fb}5T=K@TxQ^uILJgR{pxK|+Z_sj@2P&nr2AMRx;WO*!_qpaA4XaEz{+mXa zps6^}aOK{!FHwgQ9cd#F{Z0Q^cbaC+KTExsUHCZZ*XF?rv-*eIt0w~(xAA}+48>3Sauv9Pdkz50S=-51#% zH{=)Oub2#ndS7(youKMa7UlHYYi~2EH@dSpe}bwe}(TwJC-21HIydA}fxQX0e`~QOsVVubG5z;12eGX3b0j!J5#WvWbYN ztaaxpqF(NG`yKeAe_35@bWr+mH@jyp&!A9fQNt>^j@R}JNG!W<|BikeOY!y2Qn;$B zDum;>b`umtE0}h)lIV|I)1;Pxri)jep2C}B#kR01gf2{Ec6PS25Y@-pcB`o7Y{Lb) zzkj%LA%MI&lC?Ek9=N=0l%A2U+3N@nWR*KjaH$vS^xgCfWM4{OKqv+4&;82xy2Cci z%LrYsn1Bvx2;~v>p*0zgUD}Gh%ugFzp=`mu;Q=wIQ@=K1HS{h7xEw#)pAyIJ_VHPL zm`kTPuJ!Xbq%bf4o_=y+(B>y|zDS!$8UIXq##1Lx8O%WRTYz z-57nT(sM20cVq$v7F;NAi1Q|b506m!wOsm-jy=wr3usI z(Z%v5Wn_vMzLLl#56J3^iyro^DLA^?8~^4Qx6@<4mgZ2$VvPAsdWBDJj&cVk|2Y(X z#!&em_-RT>K#l;0xSk=dDpve3>{V<0$*>Wo={mztQq_xZreUv>hrmdCPL=Aq_gcrh zGWUF^x%s6{-FB4=!WtZQ&G5qr!^aQ2eA)@f;=Pmkl2Z006)J)1m6Jb_8TdkIaKy4_ zNxKdPcP*@O7HpPdZ<Lf_gLP+y1B6tHHN}fys!` z(Z0Rvyu7pc5wqWGZ~dkH;pgaN*xV>I%#U)C_V-UPp9?rn^7QP32q0Df^~Ak!^}%Jm z2Des2*3AiqO!T|^H?Yk61jBdbn{Q06pBsU&&GcOQsqBkzKKw$A7%F@F*v`m@52=ba z=f@^R=##p7`hEf0+4tbUsg;ISumLoC`@~^vJo4Xj=<4Xlx(RbMJ6;D1fm7BC9wlJa zc&$DR>=85HsDeyPOv@`P&Na+iv#yHHM#%nTu-wV^?%8q7&z_m>UIh2BArtB40H|EP znMd2ZiF=bc{cx5TIjq){TU%Reg?Z0+hX{-6!YmwjYK&2fr$f`1LVvfB7o`rMf7$Pc z@;SR-!Dqb_Z*F5LjFxM$8BfyfeD5Ie{Y@>sxYhzuB0HDqII%kzrV6V z{QQRcJJ2*KuNjR;_9fIgrh3d&-Wvui!Wrp*{y>4lN6HJw*1;-a(fCq7lz%wzyJPh% zf%)NUeo=kda1XVO@B(0nND5QR%gb}}^V@(8bjBFyNYz`xJxnYtcjlwMv9E>(zaBy)`3d`vKmLP0$He*BMinS1D|l5p&*4 z$70^5Ag@P8Mk1mTy7R#peDf}ZPq%KvXI;jRi{NWah#CDkW+kPbC5gIOv2op^+PqO7 z=8Txz2N^ZI1SkPW`8|NWXd2#WEx0+pNA(R8rQZN9h5rDq~P_u-Q7}QUQ*mU zckV>Q$IF7n1w3X$4N@+Shqv(wnZRk4%Qug_>z$n?RtE(HV$#EEx%c}VvnwysPr0A+g zxB86T=Vw`2)QNW8$@cNw37DCfn3>YQdrcL z#n>0n1F@aJZ%9>Ssr2ge%5IC{c8Y8YHFdna!ZCx*Wc}CR;0$!t$qk7CkQ79(<6s?> zQe3lpqf-=qwO}SOp7XxXQ|WKZE{xtps>*;IVhtyJL|ErF2TVz%FC6IhQMHNeB;Gdb zG?$kIVoz8_3S~x$*+EDyZESP{*ou_~P}|(~NyGK!K*~b%HR?xSXEFUrioZx~CjixF z7&m{kprL!A)e#&oZqb|Xs6lXgtjhOnek1YetGhnFvcK%72Vdz7`_G@u5MWa;_@M75 zY~=$K+lv!WT9R>U5-<$R?tS&R4UViUri>7cfe6|;hx$;Co`DlWAo{K$2^~2puw!BB z>TB<`-1A)@;X|TBY-fr&7BR88y>Zj#1ubI)0&zIum`6*<5CRvzLYKKNW#dRdvS7m9kCTiX8h zu0C>mE4;zk`7qF{A-yaL>Md+1A}K9>6P`m_PDCkumiU9FeM$O1s?2r~_1O;qshp|A z0Z?WBO$a-T01jAMUS2-2n3#lk*9Tf}>*n3P%*3iA=D5L=;<>>D^2rt5biI;(pY8WR z@wWOmKonQ`$6x@7jn8H4ExImd^pnRuOH0deR}tiZz`!HP#=XgWdC^lNAfQYp!9gsV zruu|!7T2ZxjfhY$Zn|2fh-K?QS6{lkl6Z9&4`*TV8=JubGIjqP+Ei{fNCOUVw375p zfT4xjcB(1{Q2ICQZDA7#6BwAMMLhr>@687{hw3=>YWKf4LS5HRhOdK}xN zxE*PvxNdRE**@F`Op?Ia^<~12AWjckGzNFSA@SLF&K^6R zIi4%^>cqpP8ag`MJLJ>Vsq0TUv!16Y{0#-^BEWb~Qt2zYDd~&h3cSaYp z(5i^zdDL_a_6-#11^!;n^|P|c$*fpoUXkTMO#sO1sINN-t7~f=Py)z`c%66eTHC^f zt<1?ptT%YD@W@9DsM*=sQT0psDPAZ65Pj8_v%)$lVohrCa9W$2sVscuw7w4AF?qIj z-9bg^U^`ojSw7(@Ddqwwyc>!x3c&ry`1n8C`>554?9>);rmx+zrltnCYuawW%EiI( zfamo#oV8&PSF*uky!CYzF9UIj* z9d@`+-}4yoGNuFAQHGGC$4Kw#PPfX01qL1lOIv@TH7q$=H$6So2{ob^7dp`SEQEW8 zy$Y^Lydn66qmprPw@kyOuwd%$YY78K8kY+Y@4S8cZe?TjqS%E!R2G1_P(61QYbK*G4M9 z9F7bp6bV0n{#-#-b>?{x-y|u95#gi<_ux>W_3s*QD{|_zhJe?w%bxuyQe)}d<;zi7 z5iFY{BT*~mQLt})W}ZHvd2_u1!!!0v{qqT0wvma@hgEbCd^om#B2lCikS^x?9{U?v zs643iKg!ZV&u@Tsb!9s}>*Q&sp6NBxfV+B1WQsbW8OE=-K*gRReyQT=}mKssT%CVTdj%oN+ zST19{?Fhl1%XEnuS$`Rl-<_~*ofF!^UlGhCY-+dlK?9NPyfi3-RB@{Bs_`N2E?}g# z*3~@>ZQM z@3bmC#KCtQ?yzW!+FP)f#tWy$HbX+*4y~l))zIMS&oxmDas@`x_fIe1xjfzvV$H<9 z4dQF8>-LprcMm3Ig?FBgShc&(tgiUd%+Vy10^~Dq-p~%CI;7&#6)yo#IJg3?M&XH!G>9NacX`0bWFZ}!jwCgP&Vn@}&cHfRg?Y4vb8Gsp zr@?^V6rygDOb|QMG}~nyDeb@sT!xg=(vQs3?I7wRzptj|lbmcEFOez%u9tdThfEU` z^cq#Gc3VGQvs6%2O@-w#J6nKMTU>WY753=QcJ32|r=?P-G#DzQK2YMzf&CBmF)>MT zy3?!;#e&dji*Y!NO_WWDB|Y*-X=r8)Y^&NxeL07QXHYkkmy@G#-NBE|kKY4v4msuV zC(yu)5t=%stEU%c;SQvhU0hocDURMUojVD##>3Rq7p_ij_I4QxtPmuzPkw&7%cEZ& z*<;xU)un*=cy^L$Zl1|Zz_aZ)vAB@=Y>w2HXZ^yee&Vm2qO9CmJh#Z@awh>IH_SDeQ zDk-&Npq-=Wkj6cK)#+b0jkabQ+8Vm32qqIXwc9iA;oV?4pm8Gl5`HT?SOvZwg!lN* z*Ps1$2wG z%UR)(wj=CJPwOej<${0Bx6(n`?cdkj^ zKmPY6Sr_OSdOfC zwG6_4AHB+J8=6k{=f8eE!7nS%ukV6F@>5F6dZ4Gq|DX~+Gjg!{}y-=6%_idUD;Sz+-?%8()Gse{TqO}_$nr||4z*5ycEtyU zVY+TNeHVD4+b@Aabw!GRGn>t(WjxLhhcy#8!z5yL0f1ED=3ElDMbi)c4b=MU zl-c)%_=Yp9d?#Z$6SuPig8A9I0g#c+#xYYY%( zduh(0?Nu629lx1yw|q9Sb0agmXXKyFb0p*;7a%-Scprava{!irA{4N&)? zivwCc=-~YPK+3ZJeEn6VtfwCn}I2P+s~#>z$Nj1o@};73k#f{3p3`o!7EAlLI`Z zb1!SI-?{}DM_3#~(~!%BB@M#9HFm>AmrtBN-2mvI)L4g1ZDwa znNVXS`hc!-D*w?5O}uENUq zEqv>Kg{4UcWw3d+U}UqfTX!tE($?Le(BTxM{7&s`6Vj557a6<|jW- z|GUWj483E3x&?+?4*B4Oq}=E4-~X|=xEKjrO1P`Uc12-jW@uIfk9E!n?4GpVUCHvm zL!|Z|umV)_@NIGQuKI4?ra7Sy&EMMQe383nv=kSE(aI~RVlgyRC?%1I{LyVt#CSIeB$G&KXk z8V;;7YSxn=b-fkjX$uy8aaaDdIrH}sAB=^fR@T-pT)A>oRW*zr=ZuepoC-NNO+VYz zmw3)iJ>6dTRzS?JjT2}q(;YnT6O;Jg} zXe*diy76aM%}B*%5{w4nPD#nKxCE8nMBsO|e&bPz6=LG{`;XN$pl4T-+Z&i-Q}$+F zS=SgtAjWzf9c`)AJ}|%pD4j*CrH`DEj{#WqDOGf1*W#*nSEr_`jSU|}AHgj4Cm%oN zMFnMLW|}tq`0%Tz=UPTm&Kwdq_)*)7=pb%ll7i5TQu?Fs*X!_bInzhoKwmKh zU3Y}G^FRbN|N?tuIjuy z$-v1Hu|*%f4*D-pTnj{fYyQ!0x-nSrDb?)6DaJquW{w>@244ST-hd&cWfo?9TFSf@ zCL00P10`)YipP%=OMl$?r1K3^{O}*4tBfVs$Tv5stAF&bOQJNqvZP&q-iB2`GtZe9 zq0Si}OezYX-PIp_d~WLN)5Rfw;?$`Zpz7;eMuU1HnpdZ2Y-Xm6>K(7EVLf05p*IoAx3Piou{nz1)GmF|9&6}qdow3JHzNCY44YPNCzGJU# z|8*^`%ZL2@TwL7_WV@>#@bf#wnDH%A%!@@vM&?&vPmb3FCnO*JPagmpFWro^F&Yw8ki@s1(9_O5H*8D|bdwLE-GV zbBR6c2?^IBF72M$xQJCI%VW}!l!Q{$tFEU9t1%cF19tXj&z^n%-a5#hAo{bj^AD~j z-_rh7u-uQoSOpm$gu7q3b#Ir3RK;h#`dhZ`*_#;hrgY8SS{@*NWQVC&xGr3%7xG+5 zxB(aqc$4cYDtw8F=Xk@JWu19?MJS&Ie4V!dfe%?AlN4jeUQH2rAwfX?E;?HAuKf{{ zDHGn(XOujkUX924U|g?oU#aP)1E@(1_4Ow%#VJ(I?+UJQ9b>-t=n*|g?SY3i0X>lV zrVHoKOB;ydZ9R>2l#7mbIphkE*9EYCz6i@UwR1P769Dcwz5&6)XpkZ@dW)Z*OdaZ)zhU@Ib2MJWmxJsGxCZ({lLAU+d>w zGyuJHlox(r3j)-2bS$IzIXK=mHWu*e+u9t|&{rF=81Sug@E%VxYMDU0<6lQdn7NMh zyCgi;zH`SU9kFL9mrh>wSXA`a*Kc0|9z*fe;)|02Jnej9D3v0Xcd#CNpe3&7`?$5n z6bq)(9P854)jf_0DXZ}g$_u)99eZS~#AjRIVGZadf>yJyZFCHjoie8E;V`0czxvoW zPhNdie;9ZL1Mgld$eD67MMZ@ZJZE2@+uXjg_!p5Ej$|&dELtbsTyL+UP`aSNi8rpO zDARvClsUYom$g3?G|pGJxrg*Mj9d2B2n4JhdSipqE4rvqgtf>R>OgU+(d6s?bY~Lk zd`?s(Cceqe{(gLhCM?k6%8PN z(|w?HaNW8X&HH-bv57X2i%PrjLok-~@%Xadlii^vWRMLl;KR4!TTy?w?jYgBf z&Sh<1WRN#FNRDKnKXocsSSAi#M(`#B=qePYX7bflY9NwpH3bB(qu;;5C5unyuD-jN zbD=XSnt1&INZU@(<I}_(`u8%5AJE&Xz-LpRINkGWloz-b zv8$`$7(1v?Y8H7(qcSzS(yosT3^WTlngOtGr-|Lx;+MuBf3&ir_{_SedFAPon9oX; zrv(gs`t;0cK;bwX%U6a7}Uw3qNO8vbCCiv$hiGy!yE!B9-fLV&bCm-N)$CjrxYA`oY1E zKYt3HVP?+G%j1yZ`GYI`pNRB3?JPV_tS?^$e)^If7~HA^JY8D$Dk^4XX2t5o>FM4% zr;sz^Z)GF062R~eK4vgngQp|>(Hc~;WA*zIDdgneL`2BnxIy+i{u5$->S-$=U@@rx zM2)gN(lMBcbCQQMrzL~8A;OZA6p4Vh7cLA5E2p6o1l1w0N*S=4G}hGA2w}g|asY5s z`QE*w4GkBe5jQ$M9t@ie?bze+W#@nMg-;8x{4I@vz`rqsH3(%&artr@+|z0X7*OC- z0U{Qf+=eFey1IaBZkkJtz4vTQAKW%z!zHio?tZtDE%4m;x!BL)LGX4obE%&HE4ujb z!5Fx)MG*|}yoB2MkN zSn0ps;ZuMBTYTHqfZ2_`%2A5>(&hZ$ib=a-0lrT;#@Ko)no57+P(``zDeKai(1&3{`=%3Bd6g4xY8H3HO?LxP`x7?W*B(8jf!rQ5*r2|Hdt}}os9%(-=i;|TKV`+#P$OFfr zdBHxaVuPaQ#*Nazf3n$6Lw|z#5h}7ve?X#l;JT-tbej1bi9`BLJ1@LX|6^E0#EdtgL&sAyF`o2@f@=j}(w$1b6STQOz_{#9 zMc6qwbie#N7=hO4``@imva=$b{=%s$)`HfHk!n(Fo3fjWxQ>#kEK&BEJ*L(wj2kX8 zqbetd_IIOo_9C_;CqrKKOHh24ZMuWqWZ*lO?SYxC{igWh_3(vLTo!X%C^;#S?o1@{#__&eG_M|#g#1#Q9KlV6L_gR-; zFuUk-_JZXyerKc%JKpUV|1R!L4Hvs-U{IbgqKt51rK>4E8$3hcu8F$(SV}|B{!n|n za#R0s+h$6Hu9CI#G1vocn42?oslCxfEnR~J&c=1vR2V~C;ZUsF2@MWb0+gAU$=VaX zip}%CT`19!@9T;59<|HpGQlXN`o?;@o(~apj3+v6q^GOau2k*g-McNhHF7C(ALl=QY{{T0tt?m%if3GH;@ zy_meMxd;PSydQ%OFLBO{D(%}hqY(_TeUC7#?CN@mMr!xc4t~i}5JO4Ssb;n%zWWlW zuw1-B|2fv40+jiU9AQLX*rQuKU_Tz-slG*m(Ju9%#*(P`&w3H~mMe%ijt0FNlnxEz zFZZ5_^E&G8?(SdTxHb&c=Eu9me702;r7<3B7Z4EncN$gpSoWS2c3H6pf*WK>h|nSY zKTE_X)Pe(VCu(<29?;|@d(7&}3gV--;b{nZ!G#aa@ewCjc~!0ek=XX$QC|u<_EiZA z7kAvQu&S1Jx+v3@C-OT;wWWPf#{ z@!3uWDo={$$dSj($|#fOT-%c6kLhGk0XG|CFc7e8bR^vxE#(jZNNp@Sco6HtS|h#q z5YU<@1w544GT@bzlk$K-$>ontUwbSg?U%)^+2rl+pMWI|g}W$F70!x?gwt|`WGksEE2d-?hJxS>DiDq$ zP@1lKq@`tkgdfZ#-cV7_;InH9he-*ria)y@lsQ69et_{LGpWf0TGlMx0uoz$lL>=?lDT=5iJJoh4k`@=hdU{$ z89R+!Wep4y2PaxviJ6hL z4O$l=VE*QN@XuB*KJ8{jwO;LAEs}~{Pj=CXiH(IR3{5=^1r6)yGUPiDraE@akqYvk znuf}qm{(WJ6jVDB^1ppkY?0XuYm$hWsUW(?6ZWxt^xe8Ku_ZXX4wG!5J9#%Go>2bm zNvqy5)Y2Nb&3bbEKz$>%4ec}J9UFc1zJ;ucEqeI%D7}xc)Pk)igd@C&IMo&$_|BC$ z>AbwWc&CLKslkh!!@<`sWIfN6nkUT{+Bf16L<3zuc;G?jL7glgDm9L%;2x5V0r>0W z!bQdYVtcyOShu54h%;MB@CgrJLtFQE!m7IJhd9M>8aLjOCE)f;;XKFrQfp=!>FpOX zO22QJ$zpqZXUyJnJ z?F`bhUEF zz_!%q^(cr<%F4=|ibD$i z)V)gW{75Zf)6vCYX{SiHZFBHtZ`Ax5yfoc`9>tTltF3d zYTPy$XTd`XWk3MuS-~B_BqjE_ZR_Moe6}-M0;2!+&cs`HI}~QZWcRbnD;I}M#Kgqx zK<>iZ4F%Eu0!xM{t*EOaR|Tq@0&b84PqZhuq35TG;$^T1fhW#rCzSQnsYx&~@Brl7 z$r)-xMVgyi5dclLZ!^fea7Ac5(i-Mhe7WN?zy>IT1k1$LL?Do@tgg;I>ktAY8Q6J> zKE}lxR96D&Sx(-`H8zed4goIzLPq&=?gtOl0di3wMcCL#1oag_KR~q1+udd=+}!Mb zA5#MB$M!#J5IWxQI1@G?yg#RG{fK z)`3O3B&jF=+%5mn`FV=#4<4L^t@MSzzmR?HZ2+(z@;l@~KqFZ*huYd$MMaTNd;8wf zV*Km7S~1i<^)clp!Ve$zz*L&Ik$V|~!g)elOGOw+v{@@FD>F*-Hda8?D0KpXm+3)#j zW`NG?c$?CaU6g0s8$72sf*&wbnE(~vXB%7b~EK@|JV%Ps$D#Dv%7kvfQ@dD&95SC;6x3?PQNazfBEulvgJ;Yy`!CB zW66x>^pWF7$H%GopTNd_U+W=o0dGMZJC@#HAnek4wq0gHsimC3z>$nbdJTG?>xWb0 zgbu;;01hCE>i5lD$YSETb2U~2r6H;^I^W@&UxqLZrz|0%D#rqLZa9O*#qTo4d=HTG z4prfn-6O>wOM$`zO17~Z%pw9di;*g<%+)Cj2KA(H^wt<_b8F-_RvDQOrWAR$G}_Y~{Mk>!_$B)!^af(JK%^ z*0_;)$0UKk$VjM97iA&CD7)d-stcylb8~B}K+~rFE(ghnq)?=&*a2pIf_t^pIY$d*vf6E$Qm~k5BbP z=wG-bklj#DN^zELY&>#T99{(l0fDP6q|YFr>736}kA|L4SO+4UZzo)KUpEv+hX|*} zx4;<#fqyw|T>uVZx|O+FKabt!)T<&QCjsNN3;5|@A#{BP?#Hsq@e~Fg1&5(tZ)8k? zG~Zzbjzx^~k5lv>YvvJd$BL)SdEcgL$`+bD7O7=}VIfSd z;GzH%laZ;4Y*9lSHBo29KDvdJxBS(f|0I8Og<#eZcaT&v_(UoEyG{|8E1Y7I;(==u zMd-RVd{}A+-Y_hBH{qcs=|6HnJ6(?NdLD-OJG|8n^BGx$R0?S7VRJJoMZ4d%@87RR zVo)T>f?150T+ouuK&cbe@C5huk=!BSw%!W@jT%M11%#F$vk*yX1Cf+BeB7 z!@~SAua{)jXX#?>2h(D_1~0qOsbn`c%9+L>Wk4q~I?X(dPd7#_#1moneyK5C^@Z-w z)noF~F1SB;w};rJAg)pQj>n=_^ec|Q`pqhU$CHb_&D17kxSt%r{JOPKgc093g|7d5 ztPXVT;?qQz^8pi&Mu&js?vIBWk1Z{StQTz9$_k2$1O5H|>Cw3iBoF@Vm)>D`*Plg8 zEgdtE^^6wD?pOD=ho+^*e@^-*OHzpMv(!nTq_`R)Uy8))x8%QiwZ@lGmoLkbm6nmC zlvjj23)04~UoG$#7ITz3+S@sWh3R0JR(VB*<1}gEi+=?2Os;rd5RM_ypc4fvcKBRoW@e1|63;l8yFYw@y80m?u~458JIv3I)hBTF_4dMGro_B|2M2y) z@5a`XuHb?iS!qxn6$#oEF)i!*bCSOM=FOW-lBQNc=4xtPP*g9{9)T70?mHD52UuF$ zGkUwj!RWnV)xVzgf3m9E|EE9ipSuFS_}_*?A|Q7^1i%wubjvk#y7ymZFmQX> z&NP<;N%vu}`CL680|b=QpJQ93_7{JI*g@RvMAs!ee)iOMcx= z!*jQ}DL7v24Or`LG+>QKTo#mXa@4*YVls>B?d&u>a)4%!LDK4yS*-hUC~pX~Xnd=- z5<#|j4SUVRTHDwzzF5_DZa7^Iit$gWsYNA=H9KVvGwmw24mC|wgVOD{h(I$Yl;QrV z{khTq=`gA9{ib*8>1{DMqB~`{T|hH%!lwrTYFQsE20YwA6;+Zno~NxCc?hGzIJiUu zl%qumX%cuO^m|F-!esf7^QLx6(Ku=rvDfe%YVP|5_`Uh?p`j_>qxGQ%(GLJG@3*(N zZHBlE*bOL-vpn6>fWQL0)2^Ud6vM^j0g=F@sILZ%u4oV}+~@%Fy+J(X-!z@UG@J3<@_~>*yi~S|c z9&C@6W~Mq3SO3Y7kmhULmiOhvw2R^ zLV5`w9h$m|w)!+;G=JaQlba8I>ri+h@$n_WbKa>&)qWRD-Wtj%%Ct6wVB!gXV=O1U z^-H}N>$5{6+51dSkJZ3y<6@sKk~O(0#AGtEOV@Mh_XURQoG}fHx#>1k!kpTA07+0- zrVkx*vv~dsmo5cDsm6KBiDc9v-L#)OJ~pqs;V1?|1qnis20)H)4!|5a9|N`|`WC5; z)^6~$kz9=7=CK*Z8ko9UeN|2iy&_?WTb|O61~ho^Hjr=!Kl+bMh;O~W+*(G^2Q939 z{mf{lUYQHHip3XV@|fIy+5w%&7aaY!;X;+QK)gZHrrNbw7YW&~H3!EE#F36tRc0?A z-Rauy$8h2dZ9BZd=CdvhT~wWJqY7iki@S6s8~7o(Z?2J?^yNDv?z%3kcc|ILpLc(b ztD#esxvTA_9nkbPI=UsPAxIq6&H{6~Lcg%3#t1d6d4WC?v9j!rrG+v z8(ee+k+z_zhO^@^#EQ%>i32+xycv_UjaAuV?*M|1|-P3yYcNbe|x>70VkTA#xAlB@?hyk%VHe7NGhUOAX5aXI(jSBV$- z0JY+8=#D{f0mZ`iv3af2zJ2%36*v$)ebe7?rnqSxTP+!Oi0rG&!GQa#Th$o6h`LT2 z2AcUUf>24y=-nA)&eFl#Z@oG_yh@NHU8rC-(eM&c^mkQR;&6%s!EkkTRSI7}5#!W8 z5X>U{kvp^WEm-))7LQ%x*2oxdFWYZ#PE~q5d$%siVeAme?GCV**`U9kF$LK_^}IZl zTc$?*qGO>l$ zyh!+SFsm?yH>gR43EK-L>u(hYhw;2cm`0#TDT9VpL*MHVSm;UXm;+zA_chDD{Et;Y z3zo!gOOg!x;Q9hR&ns|iz%p6q2_PfhB6RN+o`HASzycKmU$090)Ge=pBl%gI zQ(q1+s&3x81$AJOf!?tF&`h33!z;cli2l7s7w2ZyP*@^6)E0x!h`&mzy*c8sz-t;W z8^CHWIVi=z<}!Q3W_!T^X22F5q&x)Kk|7{ent-?Q{aT-%JRD%zQ29h=6>gV`AG9j< zcNp{)?%zKFBL1m0?yS+|LsZ3+Yb~3jD0U&hW07DKU?tnEA`ruZ_)FHa6J@FaG<2}~ zTa<8EaU*9sc`{#=`O#Aypu|OxE(R>jI=r~1_!hEAX?N#{QY!vSm(GfdGd-QBP2>Vl z$|QfEeq9iMo2^qU+G~b2K+hTKM7nmXcSy>A_+~(ZUxl6oY46f?Y;Fi=K8@gv_Yi^z zkYoga`4a`&ee>6p<5d}2#jDVKSV;*Q_^v3xR}8xoJ*|ccLlSTK8`;`+JP%_KaBpYX z-^F#%;|!dpJ_NH}d({>#iks3fC~#TI06$oE_UkBN(`NuTfxjrjR+&vNYLyc(L?bXp zyuYlTEfNJjDdNO%l<+1%goZK(0PGX#I%0)iygS-zZYBhj3-B>S5!V!lcZ;f4vB>?p zJJ8(#M!m3U>*%_=s`19hYrA{PWiGg-qCq|Ef^B6DX%qDKm*b0ckWuGUohD#VGyc&w zld{;#?kTAK-7yfvl@E9To&gj$j51b?Feb`nvrE1hmZm}KE+Vd7QrEuc1JC}pCD4Hw z1&t1Z6pM7~_@pL1{tETpRI2{T)q-8ut5#T5y1|b@7to@|pcEvvIw=dateJA$a#@U& z`AI}~hjhf;OkB&y0?UuuX8tc$Kx$v0*_TmtwIFz}54GP`y0IX>@m{@T$`)mb9D~%(Y9@sDG% zG}mFs5n%*{iqOV87lg)o(v-CTz2G+njWmn|)N1PLbtFO=$zQ+hKI&Czfae9ECya1+CM5`!6!ZXp!l2o5MjXi z1VuGpn{=Mdb5RgHFh7#RWE;V^G-4XLI6q#?MI}?{7>nubtj6(IR-;B-14!h$9OK57 zt^i_Qxfc?xH%zQ1_b8d=mLaZD*;H*5iiD*C+EJCkOAahtP#+Z^KV#cpOpAbK%*l#o}AeReOk=v`D)Gb~kys!byF-TEy*ak;Ff zYyn)w511D#LJ!DfALW*QumR(8c5vU`9nzMXsI=Xy&+md8s zDc>_ypM`Mf$D&r=CKS@Z_rMGJzH`vV-ll`Pn2gi>FAfQsGiT0_F0mHOtOA=fFK)S# zS)P&-0Z43y=@A!lsJ$2lSvEH|`Zuw821n%qCy1M$G$RH`F}Z-=86mEagCoEd@M+pH zSW-lG4QqoKA!1SDCO<9%Ui#G%yf*wQ#=i>@}}3*bcasHdQtgiNrtsdshx}L(ebrs!rF=PI zuk|{1qp@1Q@9oVs0}eCa7Qi7o9wJ+D z1mQ9@6_GI}j&N~v83hXqG*>8{7l+OBMta!xyTYs*V>K?FDkj9aXP{)ayXCE?VWf~@ zo&PaJ=Zpy1=y*yHsj60i#E_>vTOnlp$PIRw>6mDU&_9Uqm{>3wERR~P!cdS*)5QQJpeDc)P@^u;vk8SI?7-L>{X zA?^NXA{NDd?%X-(ycHE(ltEZ3E|XKz3|ax8?l|Rfmg|7ygNVy^Rzl2una6o~&@Zi7 zeRXmoOsCEPp;>9gBw{=KYr(<9YP`pf&q|1d zk`yKyFM_)ZP*HMRx{MZZil^=j6GLpPhlZFxJbZYG=gRzs6p4GM%q%jf6a?dZ{WliK zG_~gqV5CenIJN{Grj_7{Tv%c2+a5tyTGGK^=clQ!hGd~w5i45^Iussyk`t`1qyT`(6a0(8&*lz3qq%*P?{@R;D=&XEH zAfb5#o8PivTNQVVNR$%XAimleY|!24-N?C_SRETJGM3U3VJ|aPuH2%El5MePmGojU zD8pHbB&s8+wwUcV)3ZAJ`vcpeMKGPaL3+k7J7R??A;y%RhF(NynnducSMytB>WS6U zA<=eQYbG(0D1csPEFdLwd5oLdV-emDi!v4k4Nl#W+Vy2OT1~psX;IKSm#Px~94cgM zCM~aDzwR`ara{tpOjKq~sG_!nLs_JamW~oFR8-ofKX19X>##R9)~`omJgNIaBRmUO zCCl8dXqMz+aHzGpRI6J1cLTw1$4^3;jV$|Mnylv}mk0opo|x-Znql=pxvJUJyS)6B z=r$D>9Wi8?U@jDOUq>Sds&H&%i)~-i(-#WLwrqYrOf*ID>6acxXcpU@GfHyr4a;~kA>(Qv$_X*aPskTZx^O01oRm^|l8L>$A(lPBT6@Bu&& z;EK<~d_lx0dP5gF-nW3-{oL0#V*$K+6SHGkn`7R8rrS?r9($ASpT3mhE2rLGFs~=D zpFDX0afy#FC#uqXLJm@6R(PbU*G{+OI4@}{DaXA6XkdT70{gd)B9G%4k1UUIQA$?9 z`h{{7X=1pyu&YVQ=qJ)fr*HaE@9(^A^AEbe#}Xyutc?)|1E(tY$O6&Zq#OJ&hv z2rViE?I)Ve%|omA{ic^kFX_Boy-L?)-!v$B_U=h%=VOA|b~Uv!1i~P9)*!WQ z=fJ>A$nPKuM6JRQ%VP|D`t|bcy9~?A%eS1BtpjX3Cw?C%!nKEbddBMP(W2J17>pM)-06+4GM5L- z>afd#bGDNbvo>>;4t**y;_jcDc7cV{vb&b)Q2882BJU3hp{x4cYX<&O#eEEFT^mw!6%KjJGp`2V&vDh6hLs5k2v$~B z&rp6_&!voS2My=UCugcQipLSO}xx z31IhHF<2ZffZx|?_xJHby{_rDRjjfuvy_pJvG)r^ftw|^ITf+F?@I$V8y~2 z5gW#(7k$$`VWI+=kJMzR`RrF-+4f*$q#`(DxUHq-0YjUzGKceiK$Qe_tV%#zMgGC% zw#+3-3aNHnPwzMV5LQW~A!eVv*m#K*rV`wQsHE0G%tuu6b8s!)l)=0MNM zdGQ>tG%I&_)@*@Bpbg4n;@B>Lof5j9X1nz)Y33UyZWJt{QI%#DWKXXaZ(nmh3#Pq3 zTv<*|PM_tBg$8Cb>Heh^xHFBG=C5sbpkzV7q_a9O6HkVKTHEsgwq2P=8M@79|FZQx zWq~KONViNsEGv}lmW~cHES&y+RMWz@xz3-5G13w3e9~nDz3`(PaIvIcU%qDE)Yd+8 z+-vKi41gyWhpO1I`xC4@k+P`joxwaLKfI)vG#|EwC_Yg|Idr8*vFTj}%S#ldnMUe;~>SB_=J zvfAkcQ9Y-+mg_cO=a!WX+ZV+*v8a9FFP<-x0uNEq8`eBO)H&w}qNb)a^;8l_luFiP zQ+_i(evd`gYx8mCxxc)VA#~@1ylqm_>l~_omacs7GWIOYK6gH8HMOj4>U-efg{yf3 zR;;R8;k>*XdwaV%uz2mx_w=m{=XC(@077e`%;=sYbk#v&RR8HEouK*8Pd7TSpi)1G zD31AH&M>vmdHT|Er@rMockYC{Eu6VkD!Z(Wv<#YC&K%C_>Fo3d1msl_y}4(lJEaPW zidcsspg|xy7b4f|oZ8r=-A;i8njwbLg`+y;nUhmP<1uDKp(wlY`Xh^Ivyx`xvD!nT z_R5;eIo90^@=H$4C#^~z@folU<64!|{a``@;%rs=!n)6cE2sKlIHjWmYkRC@P@MrQ z1%gK}ck*+l?=t;_xcGoCUjWG9cd^`A*VFzOFENWhgaFkTRVU#AvPqSN5;5%Arzd)? zwLxLd>)6Yep-i1^1^aq>m8rT4Ouq_-I{O3RyDh`Wp|cYyX?ENT-*4+8luy<;(gy@9?5kI&1GQBfL4}L(hWKuW5Ir zQIIXrCw#(iYkOPs$Jcb`xf(W0XJ?f#bIv@bcrW~A5?F|dT&7C0S6f>dJ5-g`XR=S| z(a3gecorjx9Fef@ZPmJ;-qVjVl!eB*o2TA3VFycIydODw%N!tC2p)8L*o%kU8f1u( z?HwHmVp*UTb{tj3OrsFST68c&u&XN>UjRdN(%{m3f(a~yNCUO5or3f~E@c;{JMEWY zEBY3MtTjU3d1Wl;v=?FfU&3=@G#$+3=^Q1~xnNl1(b9GtTTal=y2bWAH&`((ts--0 z2tzy$i>AwDlWZfhau2awxxhzq$+JT##du(O?6`!4gj;JmtS7 z=%aZ;WC(T=hKr$9b(K*Cl-@_!g=;6Iid-Z!gfdkhG*Moad|WcJ~mcPC;| zqqAMf-^#u63+B3phksh0aO<|!JbLI6#!E7sPwRPepZT65M|zpgM$B*wTjFg|Q4LYV zbqk9#N`0<+9#aJx){O|8?uFI#K2*q$uUBr%%Ny^PbL-_uN{WvGbdds4zCVP4O2ME+ z){flq>XVuJZ7_n-mxdSw=MdjdWY8(Pnf8a#R_;^vl_Mq;`*0p!VqGLs{l>GTfm=03AvfoGPjoB9x5y1_?>Y)eh7iRlM!M zbC~kg8Y|pX8VVwv0?R40ICuIx8h+l#LPA{dTQm0|xhb&hC6TV`_G1JXsW(@W!vwCm zTEtHn(G)J){+oSz|dqoBAu5};@(D0kh2H}x*qO*Dp(4~9NStnPqZ3bC?)DG%MFJg#YQ>xq`! z55H3$Gwzr=a*S)WsmBhKMG%PnUE;oxx+y%=q>lqxSMAMPW44qT_`h!7r%(H*7-w^N zk|7?um|`Np=ue=@eBFd zqQI{x_i0vGn(?3S?PExPl<__9L&WVGTgmxPn8_&YV%OLUmI#v-?8I?G_(gOOJnowTY>_b-)D=5`;b)T!s?wksW;Hv>DeZr%3 z`{iBN{GuY`>DDNhIX?#Z$-H4m=G1@@5-+p%j&^79%Y(FRSDfkY7C*}8L0bLuE!>XY z43_A^JcP6=+D8u#zG{hz>OBF>Q2hqj_jh!O&8hM8YBU<|yAff8;(@vEUCG5*W`n9_ z`;9_<1Gc?6RWK-yEa0rRa!raoww`4zK-YJSAGIya<~7;mvR719BZ;jDy8MnSIVGjR zjjp3xRmQ~Avlw6T6z=g}!6pV?UB~V|AMK1rb|_#-ttYdCdepkV%r zDy&eQveHY1i^yNOo|M8D=Tf^Z1+rJt^Fa+03l8Xe%v~%eW}$BNToc*+IHWNV1jX4d zc?FWM!jP2}J5tIjDaj08mQ5TEl^cEpF>K$& zO4|KoHnEhN4o$3pi1CHVbZb;=;J~E_k@M9sa6Nr5MbFFWFMCiDvtdNlfm8IS&lN@R zsRj5ZCX<5;`otZ8n2ePh**vfv+uAL0uv^?bqVV>h-zB5=#Q`zWHr9^<%&7*mkL3l^**QLu!oKTsGqO8O+S=MHu;NX4{BQ-d@OWmH z=P^=1ToJ0b1k?No#4UajEM9D7>IQ7PAVA>Y<2&9G&euSyd`S&UTlGRXB?pKwm?95T zLXBf9iPc__N}N`jMVWoLMce%X_(v-p$dQIB42oS6iM^?2FKCpMygH8KH|+WGA@W+^ z&*yFeWDD0@?6U!hvs$x*odEc0!#+9X>-hLEs7QL?m%p zfW^lcZhekzU0ui__ID$y;PXGM2=6L5di3ahyB@|-h#nOJ!9Ql}A;&Va&?ZagD@7F}cp88$8*HK&aUOW93YoruJ@8nsL9K3u^NQK6 zAOONMy0{oTkY{RKx4ofR^6cV!4KxpKI-d9TsTEYrUC{OjBBSsy%*D>jf+p^0K?W6IE%pWl4ZxVj5-F=ltTd`OZ+3N6NxBHf76os^a6}$; z4G;NS>hIaR%-k=QQ3sy*Ap#F1oH)xp@a5fXxHK+S`R_iOHcB+cT7Q# zV|A4)-VoByx4J(u&?6Yn=o}q5l@Em>pj01 z#1J~LUA_=kW)F=?kIfp&NNGzv@8ual*d-)o z7e-GoSJJ-%NB%-6iRvLqMcKx>E#2 zOhQT;N$HdZB?To!LJ*6RMoKy)k4S?ENC-$sN`CWz_rCY{|Mug9K6qm9wbx#2&N0Ur zbGp~@h~WNEo~cDt|5AN=&H5Yh^>Fzk3T>Gq3IDaja9tJ{RZ#~98CN=Erai*Kn#}{g zkz-2j!}wE&WIvrDjV`^f0#b?j_Z#Bg%u68{i@w^5yExdjB&)x$A>O^#l7?d*tE zbV~KMch_ew^|sf49&=FMy;#fQ8Xf_ZAKfB5No>?_Ql3s>fX2X{A`09jMa9LNP$&xj zx;j{Dm4RN-?G6S}I=78ta5m;=V4JeGwQU4oTaeWmli#o^1j=Ea`@85n3?0gf3U*os z^Vfd=ZiIa^E~X#d=ycI#Qr#T4kXFg-I(!7#q> z53QP@XAIi43V549yRWnL#4pC8s7 zXB_KhOdovO2j5P-{>LlsReEjWsbftR!{L6N16@1dOd#fz-4 zoy}6uTHBx|;t^?TYMPqR6=uy?;*iC*oNEy?9PyBLn-fK74)qfEwk4Ji z-mcmHoSQ7?HyW`rWG*%nOeP-!dpRswx)d{;Qm0bLVviu5Ad}Kku{SFMhr-wiXO*hY z7GK7`nU(WQih|?<=?}H00(H-VoBE9|0)5l@|TA*=j8TRoOlnu{K^*!Zy#;2fg_ zYTE19`4#f7@6cyfR8#=D4iD7!enu|(`84q7n~_l^M8ROB>OC$YNM*=7+3wGw(KuB| z$}TQW25O{;$mn2{wTh7u6F6pkupQt5Ynh#@Phx)bS2HAofhGdaHH!o{K48_5X2>=| zX{pAEVw2Gu9wPR0vr3=lZ``^SeTcW+)ZI;ekJl5=~~;``nm4=vPcz@oo_xXAE*A*dTre)G(8T)LRayM>_#5kzvjQ2+qB#5 z_>c$@{Eai(AB93i#KbV6{K|iBc`oHw#65Wu$Sk*sRk`!Pg(~`R=@jH|Pba`72tsuY z&K*8W(7}v4gN=t#tlz+rW=$ug@W#~5-m_qtu(x)P05b3!5k^opfxH-U$~Wp4emelr zEaAF9esFM51bO(|0b4`>M~8&ZHZedgA#rgg_dCt%g$tI@TMMvk04b#*qSdc*uJioK z1{84TvB%U-b+04+Ay|XBy>X&x;&qw1uP+1GmjrG`MKJ|JPyqvp4V7O=KEPq-4AqZ$ z2tiiEuD?^!)D+>*dEICyrtEwxvIE#jTIn)pLB3P$j)vp{!OwMSc%2fTkT@*(^Axns zdH)^>b42aIZz`<}u?hV;2rfYA+4P8#i=2VVyx&&uO+R{$JR%^_6?_3vNN1JnpY3S$ee9Fm06PiTV|$pe=SblM6P5Zl_0d$zxC zE{~Sb8w1D+6$xy0^){v@OY^&GU@9c$_>CAZJa@4$HdQ`YnYR`xDt?iKTY-$2xVcJt zB_JbX^4WYO@m;O5aqcI^ak1d0KOR5-AhH0*m`Muz+2!bg3ISy(bQpKp^r4F#wcrW( zlKF@n{0btXqR{gZvPJ}g?07*P9uIq7?kZEL!8J^)g4;kz-H!h}P)7t|NZ~LS$4(%A z+0;cdA3Shgc|jDzmZF&5N1Ovm>du%MnT6SzM6xQ;zfZ;xz`N11ueQmQ-Wv=$?aXpB z0akDLA_R*tTXC-i%u&?nU|7zb(D}ssdJ6aMv7zrh_=hqzSi_0FzIsLN@@E_fCmLYy zIDeitj+{9*LQdavfA7zBG7UL7zN4eS%{zB?s$#;c&jq-&p?pw3}nc0*QJTBJS|98e{^(+iE}~X2}v-lj>6QSPWS$Rt`oeYq3QQCeQs z*gaq$vCWDg?sL@L>BhU9bB1WHH~7t)c@q2iQF6ah&~~(UeO3ZGT)fC$rQ_wd_YeQ%eVTJ{rEe0Z=e7wS6EgBkNco z$IH6M0765vZ{w1+?$Um_;5_saGTb5|p&`Qp>E&9d^?MnebkSkoGV(}CljR-dR~ZY?e)DuUrJ7^~>$=moyUD4(M){5g1+b^ACh_a=`wiFI{#jN|G;<-6jGNRowx z(N>`K?BDINtKxK`4mhVt1KR~Aj3JC|AaIDpm%r#xpB4cky}#A z4d(n`)rDy^X$3=nn3z_Y4!rrYKYT!dvlKYn1sN4Gm;0s3xDj@DUy&`;!J|&(|1}b+ z;c`Pn6xpV;g@pd6r-%6}dRdrgF6s%-wv+A(;4RVg$YK?Kjk)<^oZ>`Ymtw>}aa zUMyQ3)i&-ur^E1#OrPs2le$`~1!y}=r-E>xE)~Z8SkUeH2a6?I<*GsmEdgQJPF|Ut z8y&ha%J0MYucZVsA3MR$=KPhWJFU0`aTt0{~Wu{peXnh9dKwb}qzii2fNx*E1VMG14N#>R+lvq>M1w3E z45mhSxip1)(q)Nqt_MD=$PekiHSqyUnb{62puzgX*?vaE=-3zr;(01@CMGWq$lzHI3^g9cPt+uJK4MpJ-lPO~L<%*v7_H_3J@5#ZzS zG&WcZD&^e%mxCYSz|gco!FxYDhk{wzH7ix*HLl9F+*`?#eo`;0t9VyPQDE7l*Dq=Z z4<`$l9yYS?8H&|Ueo=xB+jiIX0hDcv%?RK~5prC)+m$X$9}{ECAnQ)dneYoQ_Ua8T zC@ulp(kEnIEtmo@BV{98l3JNI=!JwpFo{dXlmjb*j40TsP~FX~`-;8h%bWM6n;$_+ zpbpR?vjyJ_jKm2DvLwA`?-5Jkn25WkZ}D*?v#C#nw{US$b;fdMW^Ni6+KN0)e70Sw zhS2p{g10lT&qviwd@-yBBg!kDq=+k5Dvq|KKIC#e`u+GpT|Hz)r^f`gVm#}@+4NZJ z6nf~|xuW9Q)Li7Q1b~l-Ro4dsRIiZA8`Cc+RAAmRFBoU&7JgiM6cvHC0_sVVkNy0a za4ba_4ZJ5NCNeD<_S8hhe>v0jT#^X*2Iy)jpcXoYhS8C<0wcV+*WZEXP|)0*W$IuT zhX|2(?~?44M&bA7cY6Y$Qy(~yIC$^F)JFs)yt%62(VaYDS#s9GVs>G9 zxikf)-Ix1%Xd}Wf1{fbeFZ}^ zn<*s~2VRVnj3h9XZ@kfd3G7b+*MHU)74b`b9wZo-*$q~3J4TUd5xCltG}@N|(3x)L zcp>G63^->}?fLTz4gXz4XuHD&>jm&p$}Z^-86Hj!G)#@LtTOJCmm4gUD|xx&J7DPU zp&ylB((Ww4f=T0~gsl(_)eumgdGf^BtNk;m_82j-ed{cU075nqOMQ}*5gQXjnpb0w z1GECWM8~&|3ND%=2;HWGZFgB{j+ozxeu|$4f=H2uUl)pjvC}i!a%z_@aeuYFsBd5Z ze)i$p+oZl5NBj2Mw~UQr{q}d7CMTJpR_^3_GrP7H3+NFG3k&t}Et=l4&=Q#eH1sUI zymr@Szb{TdPF}DEIlSrois9PEM(7A{gNdLCP`|`FTbrAq%LpTM`cMJ+TdBT>=*5d! zm6g~x-dt6^eEDZQ_a2CsmD5k3_S%{{0(!uO(|IIwhtjhuDIIYAxBML=>zbY4Ls z`iA>VN-{DtGZo2Vo%r&rrD5|7Q4S}YDe0A02de-0cxlCti)wR8M5mDs8(Z-W$VUY? z@Cpp{j|ee}l}t>@Nxmc#_d&RI{YHP@!wXb|93Zz@dl8mMY;I}M2>cR%e=J9fUjP>X zpN>`J0ul6DZx`T3Pkx!&8bOQCRJ(h3e~OLU2~RDon~`H* zIuI53L^Z4|Ek*w9e+}Jk&Zw$}3L>K+s#P^t!A1u}Gc)}&!_W*34Q>!TqRca0t(T8R z0Er=daDov$7cOa6l-83i zSA(3IaJ=02W4BCjLj*`nGV{B?5MBo~aBtIQ&1r}3x8Dd>S^+gbe-8{6(?HwD*~om@ zH3(1e!5NYMtG-2U7l10gmWIb;E9naaEdB={U49~@+JZz-DFiW{RW>>Gex|;+KORg#nHi{+c-Pl!_)jjF*RwU@M zT>qPqu=!Bc&_f3YD_s|A>dmXNEN)zWdeb83p>o4v=WQlb)q$zQtY3Y^MXeo9KlZhhv^ z!NTg`(0>Jokn6Q!vm0c@eyb8vzFk5sg;L;d8znfeQq;S~fB^ci?oxIf_hVjc=~B}} zZyL1b@9&}4=u-ZGX<(}fk!EeOEUe(+>Z($0MAl)~3eb?cc?MEl-H7jWluv)wl1q*3 z`QA)GH1I0l#^)u<@*lP+nf#ua>0G4!&%+OYXX$9;n%vvi(1{h7Bqxb5ELVtyEs{e6 z(zZ~PHN&9ayPG^AVIgcXGB55B{c|$#H|iobWw74@RB^XbHtpG$X>&8>B_i!JtP(SW^)Z zkg7ogWTK$IoJ?YFz6;8*s-k~kO?9IfAAYY^2usWqAc0D^-ASis?&+a%W%S|B!zT+1 z{D7|87$FTViaef!0AR(c+l*SA8w(QL7*2x{&^epLw(R*c6>}@POh8Ve?@^wap`fJ? z2DWNo^~!iu@&Y#v;ptgEN$cq7ZYpP#ev(YyySlBSdqcTe)${?L8|+~!Z8m>C$o=Kor$qO9KxRiN9;{J{wY zT)KJB?YPNeRFE!?E~EZ3dokt7qogKDYh@*~mcQ@3 zex2uw2>;BNoSUUTw|MDJ??{$TPxk;dq)5A?bJ5AdUT26xtnn=@t)ds#4s1hPRPon# z!o#caF2uBxp4`V`N`L{TlkESwpU52!4J9}GXhTL9KR+xiS2pPe^bPsnv8?9fBh8^v zoH94J7*-z;pyT4(l5%I7c~-7-spfo#PETaE{fZ`|tU}T(oU&Qz*>OS2Qw`dw>5u zYf871hljegE*%=PecZJHE1mV4+@u>Rv#SB1lJeTk zMad}u?>1jr$Vh({D?abP4{sLz>?>y_PhC2qpmx$I-2kJ>Yq26$JqkJ3O;L@iu+?HA zpvFG~r4o4dRQ(m@?q!+jUVy8ssIN&Wx)edO?EO$|+V!%W|IW}vrDH#XoxVsK&c9bb z^H>S>Fse1|rHWZ^1kM6@q9X#DL$L2dl7WoIg{U1(%Ji?Z58&?1G`nWiln+tsB7`5E?=?rv`Gb}(1~=LK?3&MP6=+1cGiV1>&j&4mXU69NII zD;&*j%DHY8dOg;nY>*a1B>_$RPfDVN^`4vtiEE0uvJTW1Qytdd8+_=NQejYzRZ(wS z!vVJ-wX|2eEP1+5%@2JhKARsLgdX2pN~f}`^h2r??d-^va40tHT>SSzQ7+ZzyUl0Z z5X6EESL0Sl(lNS?i@?VxUKRpufEKWUGJYam|4jLe2TahY=-RuRKs!-YHp9Ap{rWXM zy+$)&T;^bUD9=H8vwN4?jfH88e)od)C z2qxo{c<-c{S4RHORD!)#T%sPKUp2)OQ@P<9cii~t;QX;?9A`abcdhJ5q&BfO=hB^mh2fF>X^ zKfEPJ2>J{%VB;D1cX>FwsRBdzapYqm=V=uLSeG+Fct^p+6b2Zf5-U$;|4)$YV1U;F zJBI`|R1^KuVKEbdQXs^=O}}R^zybp1cSXQIFt@N^<&?tJ&riI=0vn5M4mF9B`}HP` z?D7<&ySKw9@NN`YwovVQe#As9BK6mIJDD2>S}2wrGjPj-&+Ny(nwg!nZ@Hj7@n`{G zKGoiCEFp+sU2%8CFVaLfb-lrgYaMP;Jyo&FcXOvL^(2wHl z*MaTo7p*#2t4lvrCm^JUDyOALw{yVK8B{y+2?8*~rJ2AeS_}kW&4_^AN(lf*{Z;Pp zJiB!1l7gPsSwO|Tu@@k}9xNER7=wf^?aFWZA#Kyea>c{b99jim3VeM00Fd86Hzst! zG$gm`H6IWE;^-VUHE9 z0W&o2dHUe$gN8swEW!pi`}|7R?rq#Eev0Tk3WB~urqwY!F^p?F$4XiK7>Mz4J7r68 z@lLYefD%_$ReK<0TLo#W8(q&9yXoJx?9WsEqQH0^qA@ECQM`{K*hrKi4Y#mVEvu`hcaJQ zg^%}kC62b%ol|Xy=9^9bWD&?FZmmBk0Z&eof%r$dW#(DkqIYQ3J+0HO0axEoMC|jV z<+7`>K&OvyWWj{guOEM8cK=xCPko$q2@P=cw`Y1l_?|_h_)4Yx#(Ree97P4s2=N|6 z-kKG!mJ^{xxt z&)1!0<*mIvz1J4m*|IQf>`*h^$77MDdBvvST0ngxkFQGliX~Oz*L$5kE`oGdb~Bg@ z!UO{<<`1%v%fk;TB>&ucn>*AnK0tJ3XMcLj)zP@hcI5q{rAp}jwyu~QC;7Ti;Gx3} z*2iLF>?a#Y*3x}z+opFLe|Y+&%YyOM%Mj{i_&Gs=QYhWb0ox+W*^ z(7=jMO4?9r)9-YC3*Ao(QuN64H{w|zv%zPefNoztDE3z7=AgZ3Md>huvGI#EK5>HP zwl;Pl)+cw66ciI5CJ!xWi+}Tk`^hU}FeN{wyLad_`pQW{`rUf$v2SSCI3Y)?k^G0N zwRayUJ^Vyn@-4k2Ynq@A^WhR>x=(Sv+K3n?5*BAekIqpGSpySM<^*(8Cc z&Q5wTOGE^0$G8JNfPny|PhKY;4lbLlEF*GOIkDy~X^Boqsa zfTg1Ouf1BJ5JFFjWKo;qD??~^+uAxu#_kVc^Gps`clQi81ih5FWz-f{j{8_T>9lTL zymE-UuuuA?33h^5+0xIi4ctPfTPIgB55{$%d5M{~pV@^)dVLDUsUb7p?FR3KRL!+M z@uanTr_(7YhqhGn?77|Ewl>somb$*>c~a)y=4mg2isfYUvwc6it@||vHQD<+lN+oF zej4cGNJu|&a=X5U>&Pbk{|3-M+NP(MgCf?PQf-jxBo`h*$XSG#80(~mLSNg*jd}Do z2st_szOz7E8qdKBTv8nrYH-%i*O!8sIW{kkcX{|D7K9_fj8THJ7$znrH2jfWK;u*B z9_|3F7Ck(c1IkB(o!H9{Z}9=)ctUZf^-A=kM`|{D=OGvb{V5)>sGmPa(nYIroIB@M z7O_&CF5YxXvS-%=Cy(u8XRB>4AMp|Ox!GGTjCRF2pcd|0 zYN%=Ep3S$ntdzR&eKGI5s*<@nm^%u)6yxnkEvyJZhol6x;J)wVw+WvxUW@tBg9gH; zrUHR~17%>W;-~<`LumiUE+bQ;9=U9D<4r?<6XhSAKM>Ic0*3yYlZ#zUjE1l!+V2|p zI|j9)C_Z|50of)DFg-xrC}?T%1bQ#Lti1j+pe2%)ol6=W`l8?b<_%X?b~Xi#)Q7=U z$n*l=gya1tA_N^M(X-2;&CM$4fH~WFdH`UoLmr-YWpe3KLcY2?&z>3B*z-9EK9755 z5IAG?fP$Ga;??eV|DgyDxW8UpaN70cR8b5l$e#&|u^DM3kZ|I_ z`n1 zhk{G)**#JMeOerbfn`HzlV_cGJ!X^i@Zi9%RrC{+A~`fKcmx9MC4O;0Y_(FQ^4m70 zS9FJ#eg_6B;u8}udK~lMQG@Xh2rpeHW`U^`>$Ner<9%RiI)zU}H2+LZMMeBok*cHL z^1^~(-u~XAIZd6E%~_rnonHY6c@N)>0x#W||pyI#f0E7&$bUTs@X-pPlJF?8Wu1*&^O%xE$JGqU0JPV6->O$KL%8xMmw|%tEeSc4{gxKvx#LDMR8qiUP?x3lsrD_mi#!){0mM?8bokjgt}%lxeuoyi%GXc!lG&=e6)w&H z_yKy4Q3tG>8u1_n%6sTI{=r{i$->fN29gG|#i%lJ!+3Mh{JzZmfFBkUsk(--bdtUt5xrt|qpw?z!57 zPe8gN6dj?)3|C#D3v4tHN0O!$;Hbwkx7A3xd|z5>3c`R>fp&GlH9?r|@n6mAt`1?s zDbFj zK=GkoiNHLL{j(wJ{u!po)XIC49Q1uY3s>BfS5<{1iM|3<=#mtFdr$tyY)}gcMLaxq zHnu!>E=R)WNT#5UT9SnY!P?NUwz(OQJ^JOAkB>SYl$W79&tpis5eimTTrp`9f%O3h z$V1jJmz#9)QDf+8^eGs>%?~z9M@H^a+2UJ=8e;#cmvw!WETr z{SQQ$Yz9)N-k$m1zK#}oEfZq7W|`iEVkZzS6yrJC<3pO8u{)DOF~a7dx6|7~N4l?a zI64Y;T2T{;dZ2>u$$6#Qe_J3Xfb&Wjb#+h!$2&7JPV=h>#_w`Zf+h1>b9XV$FEuKsWuVIIOx%NDq}&|Wk~?FAVdI!6F+FFblbLQ zR4GcVipI7bz)KlKTL^@>cm+oI{KVr(O}g~SfUjR~LP<-BJVeMZ9q!V`TvsTEX0Xvt zr2UxJ%hP?FVuD4l9D8BrSwS~3Ef-GfuC{;*`)7D&b*S=5h7p;nLqUnli8?-AARGFN zWm?k#ybCBZW*l_$BSgqHZ3NUcxj?sctry9n?S7;4hIVPKSAYDN(>qQVb6d=8+<5Wg zE-+>`vgn()5)#EoJ2er_^qw(m`+R?AZ*>39?zkW8K1cC(=dr}gmAH_?S=^1iZM4q@ zZn?X3eM>j`=c|*^Uwz|^Vxl=z?Dbxgu*l#c90deCeWGK<2LSWC6hC|QM?gS;QJc&v zQPT!}wSTKcz&9ejMEmr@)1^#Bv~+ayOs9f?kOCL-9)Yv-+x*;Mz?Qe#kQ6keWMpKw zkx$Y9V->~3)~Vn8+0UvBwY+n4UC&1xJ>|aLylwdLh9HN8XZ@0p%$wA$mO=Y}%TZ>a zhd((;UY5HP%&m_f)I{pVqXzjK^qPd?9)&$tn+cH=v6{N>jnsF|E?IJit(%@PxP1-( zHr*3Os0WBDsp!;4{S+ae7zXE3ZJKs;!8kt_Z*=c&;ZPH=#w9JS<>TPb5ta_u=m?UrG3lgi0Qtt3EXV#1ii594Xi&R!s59fI}KFETK{0%KNy4B)o@eurVScH zBKOsAk3fT_5bs^Cl>atQ0czqsZ(alg%2J$j0n`E)p@T#je~HzqgYWO3>Tp7zO)pT= za>azAf#T;ypXjm-x=aM87mCb4Q`YvxtPP@XdHzntY}DO9Y$#L%QSjdtt-Z&MPR(I? z1IULmhg}K76%QQ?r~Z@yRek%{WH88^pj#KHCPU`UagNT*nQyJ*RLVtH4H$Tz)aCgLBP@%D_^(nqm>dLMMQhORV#%&nj5z`PasPu z4E7sAu)k5faf1@IG6o<214-}F;!=KoG2Ufzm1Y(75ktK{fA*B9sN*<906qdQ8*DPN9eVy9 z&^Bm#MX+HW9JDQ8IciK&N^nwtqc_kPZ={XVV}T$r2;%d&%NUNYPF&#`vqcv~$)%qOKn zq}uH4>>$}1B;!~J7#=v|5-ZmFPgd;)1_t4spaFiW^zuU|VnT5?AUT=a+-e?y=y~(X zWIZeN8V}x%u!X4m^mW_kTdQv)F1`KwUg*$tezEUPyoIjgDh+nT9Y4A=yA2Cf7VW?P z+E62<*eNnsN2>9l;)ei^v&{Fknz2WeZR#>I%kJIAFR zs287Sm|%6cE~@6hmyS5mxu=jeJyspyV_?Jv0T~;BUZAZ-g6j>jgo-#aO2YdkE&fZGuP=My+td3fS!2U$Af{)q^i3%Wd@ zYc09>!4?4aSERNXS&S7~x85-TgssIO>CO;mar4%#skJ-m(A@m1f3*wrH*EkO!;)be zVa^%gg@lgkbQAsKm z-?J^!qR(W|AOhm3xt=ZDLhnX(guO zXipryR$n_EvgIfalGW`SHw5&Udy|D+>X#;96woJkJMI}9cCsFMc3paMIwc79_OG*^Jnn%+zi0WugiI*iMN640a@<4vBfWI2+$Iyv5Up{v=b z%=~;3AdO}C)rH~+hd~+b`}Lt~*l3`dnArSp+%R%hgX!Dt!GVhdgKaubbg=Z_6x5M& z-T;i_QO!{mA;C+`}%AcWF^lAl%JW>ar3 zZAHFr47iCyNP~IkTSTrx@sgGG((^cp0)t#{AFq4RupTx#?Oq|j0%#xv#O)5QPC8LQ z%8%A}DyDwCGWA3|69Kj1O_(&hgPq+6Id_+SQfo#>0Y@f}YU7vsEKkJ-N3mR;mH0u?mtL|t~arEc{(`kn_V zf~*vjoE%EXxrQbc=jDf$Xi6HN>PzJE6}GipJu{*B77u{Bm?YuBao>ODqCc zD`1`lq3wbSd{!GkNcKlcls)HW?w0 zK0|DyZd3cV#d#-H3e{~w1ev%rXhO+!s34mP0#8-ek=KwKfpY;lJ!KOn=LMiI9_?q4 zeIL0<1)8MR!iO7DVCgZr-sRGZw&Um zzPpJw;b{z|f~p9c%t5R7NN4*cb=yVAx`4)IGyTd`fIh28>-Gf}*UJczO`RLec79hy zCEOntrt)?k9sz)q73lon0eI}Sjg9S9RxlAkNgR>+`gN;i6?7Oq@v&J(soOVhIMm63 zd4}QrrOa3nXlm3Z3grnzK!>>!csE^u?yzvErG10fS{HhHn2qV@()xOhH% zL96|R+xo};tH!ID+1X}&V>V#+2zDqYhdw{!Er2UmwglVfEVhEXdE~r~BCBqdBUe^K z?VYh1#m?d=SkRt6O>8h(I9As2^_78ns5=J3BmO&W{(ar7ifXTbywcFHJ&;Cf3yfh4 z0c+)%-A6NkO)XKBk_~h_+=;y-pi$G3a(b3YOQG|FtJ>((gxNBBL7*vjiPO|)RuGeQ z{r$6C=N}7qtnKYp!I|M`b@gG3GT9M1IM!n6e=55b4vr=jZ{L=FeFOw{ucpf=nf@TvLntqbdW zjt{W_!Ic(wY`vi^^9cMI2|*8Ig>kYRqbddC)@lqqA{9QF42Eh z)v!oNT1{6WAzQ=5W@Ms9hQ4L=bgI{|Yp zoRHg;^(c4s^6>#SbLwgbt|}e(vzZl>^}1q+xMyk6dCSX+`7>`(EF2PaPbeq`rO(2k zzI_P7Hgt>!`pgk_nZFpd04O@vhU9CVm6v#^wkNg9u5p$!_0QUos{#^No)AeA~4I2E)`BRhrYne6&{&6TTEe}{sPGHYG!HerB>@+QUk`_I~) zdmn`XrwN1Sf`HWtGYuakpNN3M7~fE|G2_1o`gI>&8w90u0CY4L75%E5XklHZ57~{0(lj=#w4}WT zeX@1g&-wane2c>tesL+IRh4tHEw*dafgurKa)!hNA1V($Y-LJiHc(QOg zfiZ!07DGFRH1rlB5h00iWMrrh(>1aG>a3-Ne6^t3nsW zo|*{33K(pjgYBOHp=*#mxyULtz(a8v#~`zR_uK`X_8wC5EzbHbMO1jH9V2y|S1>86 z&o8WKDVtXCV1k(dlVu|m8p-{3oN)CHNSc#H%}B4AU*gE^2u)~GowqF@X=;2Lb4JPmA$|G){Y+T z$&iAGa*2uShNO(jeOxT6zmM(z!K&?ZY6xVpJ5{D zp@X2p1RkqPmO<$MIUni#NKCgYubn-5R9q=l6lhfIP3=! z5=f%)77Yybiw}d)u&B82&R^>k$X2r`09_9FY`W~BnfHMF`^slHCl!_Lh?6gi){EyY z@ew%1hICq?u3}1TwGdXMthH=c1*hNLkq@lTf&vd5$Xg6gF=fik#7(xp z@=Rs^sL+95`m~IFhjX&-vVm8ZiMz8DW+D;aXJ*52O3d{BN>{T()c6{kt~_d`=}(pt zAQL|)%CG~ow!J_|ff+BV_3F)A10Pu6Gwv>s_6p2DHFz^HSiy3k;><5bHEV`jyc{Rr zo~2W-$F7t}4Y+w*^PkFU9+*bxoWgvSE;E)AxaYjsaEcq=M7`C9J?Go*J1*BJ$QyQ& zpK?I0pE@~TS0Kl(c6_ul_1YZ$Mp3n4i-47dg$VarruY$bEFRM?cQGc$-JpZ#8Ja)} zpH@JNwyZnNTtxRI?yE#(ogwMwY%lg>tY*<|m9eTow>dKlH>}IW!^4S=^n=w8RZq+z zvpUF_RpCLW=XCnWos7UxKL!t8gF}qoEK5Dq8>S@DYk^tP>hco`iipRLgDzh@;A;b} z5LEhszSWZ(3PVDL)bjim&3ZaxQ42GfrPyEiuRhj!n8$yWaMxR^Uh({gX&C=jy6WXd zLvn0@PuPKA9nV3sRHyR9gAC3m`nLEPxC)Pzzo(K=or@X6gVCLD>H1vlPR0u9WejxL za%2&1iFH@;zK53%RrnS&JR;Fo#VO&Pnx{3YEL4XMUt@14`qj-keeDKR2$-w>Mx?~O zuuQZ{)b&Ft6-UgBXuj6xsTNh}D59zyJ0iF%lrWwyHy2XE)9cvhaL_&$RWx5B|Dbwl4hhtn!v zwW;9qwY>}v5*yENzaCtbG&haxyatIB*a}qG5m`C+-E*!7C=ba$ z!%9tt!O?B67|7b8Mo+k^eM9H^W3f8d*o@Yine53E_T;+8SyJsM!ZQH18M~_D6l~ z%=riDweB)ug0K22A9G@BovnXaA5}RnemaQgpV2Z<_<}p09G;r6RaB5~HlFs-RD;jf z*1%!pDDv{+wcDurzsOH(TJEP{Z|k%Zs{G%Zk8Ejd^399w=TC~R-f(oV2}fC1S?oLB z7tefM5K7B-@;%YgHc6WKvDs|C|M#7@c`p|O|2MeM>4&-4bGKhJa|c_$aFfsdN&IDu zmTjN)WIc&wP?f0R@vtVYlC>T@^}K$E>c#5w@WCkEH|7+8Aw(OAB(Mt!v0~e#I=@GBfvh1Xig_FGLq!kvf1@Q;y{+jWT(T|g_z>2;55*FB^^+T8KGRC||D zGgFIQgWR0vl(-$;=BS+fg6?s6UD;DrbdS3kD8HfSLQhjSL9a|XX}Gre^p6pSA3yFW z4oO|Da;Al~X*!W9$S#qk^NfJX$(o6zm6b2*eK2 zRwoO&8RO|VzS?h#SYhU>7n&1fSDh<>13*-nQc;z|I&kizQR@~g%y zNoV#!wuf_1FJVWUn4B0psfdoIoEtllH(2N0JC$v?;w{8;6bTj-yZSTCbw@F6A9iWQ z)bHI&V0~=of4YNx&ENOoX>x&KK`xyR+?hKb!EMreJ`bF(O{aQvH#CP#(UbYzQzEbP znZ%Tk;MV&NmjlN^&t>>IbHaeg(s?Yew)yJ8wdp6Td@TohLdxIL_G?m5yXzi_iusm* zalYo&x9|>8qjlPQ@O{M>c<|2ExeYOdV0%yJCXBp&Yg_g17tU_A*~%t&p1yD(A9@e@ zd$dX(T?T27=;K>AZ+;-(6BO#fLYelj&93|2?a5a&yRn}+xYK7#&Nvl1yJ7ZyF-y(f zo`eWk@Nm2(D=6Sc2Lfz=r^b_sFA`EF-Nl|-sdOQYU(|lke9g*xkF)2_ZvN*oZEz5a z0u}GfTc3~8tejtn)=_f!1e^8bJJ_eAjJ|Re_o$PQX+-v+@txiMs(U@9*F;4tvfpXM zmb%q``j~I202h zdE@ql%Jp5^z~iGwTS79F4$46`=tNp>_}c78s}t}W?{%5H}H5?N|z`=^tnP! zMFSx3OxR%l`}d8|@XSXm1KbZIBGz;~3jW!`PR2S+_qGGh(GHoxdY9P}dPx{L3tnAS zd){u$DlqqI`zKJGi=O4S;a1PCJDZOydy^2a@@*RV!b+@Q zuZd^1XL08LZlQyMv!1N~PKE65B$rr;AlxGr7fqt8;Ik0iAZR`o6@lN@{ECC70IJ<` zq#DIhUGEuQJ$?1E$$bBZ`15G%i{&pb--&$sMKr_<+w8WAa4AxphT-2Sh+?q-V)^lr zTZ9?1bt)qPSu-`IfMUngtj#BqJK1dP(p908 zRy^@#Rt{3rY@YS2i1lzxE>s-iIlzX~qIpQ(Wt$k>k3N{M-&0W`UZs3ERA30Qm{zpL z_1A0*QT#twXw5Kud`s#zN9eu5uiT)XLvas5lSaQ6$;tSNqvMLm-1|6{y;U7 zhLuP73|I;o+f6*~>A5=o@z2%DN@53@%v8gSe@;EZy3(&^OBDTlKqrFuE$5r&%~x<1 zEK53&nVRu3oS(TwMDAZIZ$dC8#%+L--rCL%#QX2>tL7F^tE#pn3zszg z_b!;U7YBdMeO<-=zAULZV?$cU{i#=9h`lGF4~XpRcLO{KP!|MLRESGSM_+r#fLr_P z|DFjoJdSr(1>Z@^;pbxBmpH)n_H_f}3ES-ig=s6V~609zOrr=}=v9q$8*7I#z=pl;-mzwNVFgKwL z43Iqn!KwW`_&2P$9&0{^kM6wS5Mgn9UMW5YLtv6IU437r z9@NDuoh=!9DZw@`6aGKOzB(+bwR?LIY_U+0HUOnnKspow0qKqbQIT$jE|a5_v~+hV z%@CuaA|PE711LGfkVD6}Hs?L({QmsrLa%ciX7+xby`Gi#x>u4G5Q_W)zZF=o52GsP zhdh2yO&vk4&n~wDJ#+vNZC&9IIS!j^2c4}>8s{Kmm85dLpi2B9js6uFq03)d>dSfc z(v8zllKE1HPp!8H20Enm1*#~aPh^1JhE+M?L}~x(>yt2_U?t!LbS2#lWro&-N3egQ zqE_X7P^Jemhvbx^12#4V_vPjPyt=<{2jDNhAUIA*)j4NCopPQ4tLD+J8bW@ujpYp) zHt_8+h4SBkYy;jnv5DMn3xrcOBjZqMDdsjAmbu|N8yAvWg&w9O&ryJhT4+;&tNxi5 z7Q$?v05uP_re~-<6aaD%_QI3$hXJX4>E>ldrQU#IL#Q`zKR-}XGS${+{&Jr`f*eSR z$&o7ik&0a@GB1X0N)@urGWJikDKM%t(70GxJ5`WaHD) z#yZnU9}1p)YBHot@QL<1Ax)Vcx#=6Qlp%l5-7oN$^d?YZ!bmh}4UKa^31SCuF>v;n znGN=ony05}k*N`o83xGJJpi8ppPGuAS^&Rg&)u1cr^VYLsj|Pv#wZ|74vfK9#IBM9 z?;(%}C1av{dT!75*U$k?7qFwfYP|CbX$p&oc+@=24|y^Gy01FV_Ek{>$ubav!b(1c zT$P2ks0|Cm_1J&$1)=46{%*;A8+hM$NQ_wN=O>%!a`|The{nq@Alr!BLQqR4|KQZk zC3kjuW?IEa+pixh{2+nvXs=CCQqt*HAUeGkzyHhz00jlqRwXk#jiF%Q^Y}VD_Z+Chw<*$2p%9|0xN%gZfoBEpnrgf?`xlPVc?DQ`@_zB{K! z4dlAfmUT%7Rz~ZUlhVFA)4@>s_oC(ZB*nFECka?HY{mCRSK6FdQ1v++X)!T7dSpg_RPg!qJft-!ZnZk9(5PR!$|aAP=R!;Yp$M)KRD+(2ZxP&K6SS zlI`w7+vgocMM`#d4nI*zTnrGNQ;-Fnw8%^N&_$baicb`GW_hAn+Q4d%vXIA+0qDh~ z@E8{{xC$_r*1z(n4zb_31;lcvf!GDc(+fJ0IPLgPDF+*_aWoZ?`pHmX4KICgd)~n$ zj4%T{RoJck8tw{g#e(MlP@f=vdC+M1-@ZTrk~Qq;(_-^aGfo$U+uDuI3A2*}yecOb z){rfUiHQY?>bZdx&@R6n`SUCaI)Z@`{0P9LD#BwBfGjBGx~5JlvR;Z`fa>b0uN5!L z|BHd1eq?m?1x$70b9$BGm0egqsB3n^X~a|hZF4o?3}W~ELR0G^dWKjV0&1d88EgJg zqcHRO=>A1*wq^Y+)r&ue=@Ue9`JCHn^GWo|zQ(|-N-6*8=R`s>dRB|T*8eR~PB zv(-hlSkn*L#01tRAgKhi$2mGbKRoF?p3A75z(-S1U~~QY^$!6ihQ@^F1Kr){-@G{r zglq><7Yl_i9QGRulE#m|_jr86aN{8yp6;1jl$7vEou+_hk1H_S1qx=LULB`~-oDpBG&WN) zO+KFXaZoTxgZ(r$wJ8I2jR2q;0E-~wQN;*-z_lI!04~m)@#PRRM6wW5e6U16KM?>V z0jpviFhjO0to50;BuK%t9K_>qy0lmcUN5ANX>VH&5|vuxyYnGPUrguNtG|Ha03fr; z>FG3-lz|Ap0wCoOoD-7_fIe)bRwoUQu_x7_Y= z2zQ8_i)!T2-@7fxI@?ll7LjM_%a?wLed6YZp+ReRfk3IHIrjI&#GzMr$y{6%1m;-P zCzHwn75}oWO(i@${1QL^dEo7NasAkhSe7)?w5!)|v$AT?k6-#tjregaO}k51O7~l$Z5{i_=9Fb#-#N(2?K9M+F%e_*F!Nzd+-3 zOurt*q5jup-TeG0luQ097GJ)W4h{l8e<}L1J0Fy-;pfYjFLqN4#wEZQRaA6Ld}s*l zX%ER_X8N>4_hRe=R%kUR^CkV-`a>iJfjlE^*H3Pdj_d}?jAHcphXW|~bka1R+zCzR zs2@6?Qux3iAmHsRS)g0O>C?wc)%d4*tW?hdHcJB`<$;kcGOn(IKUsN!xgCz|X@>J& zQUmcfy@UaV9s<%3+Mx!nOG<6+$>wG*)I-X))W?s&>weW$!Z+n43~az)C;xDq{5M8{ z|3HMDoNvo@>r<8G>_S(G6QI*w0^e{aXIIipwj}y7%c)$C01~l-s3XvjMROE|I8$9C}ZNyPt|G4%(@iAF6JN6Dq!G z+A>RKz;vK?`4hQVhV2lH(1T{(D8{DMQgOu44Xj*ji-lPx0I7*4@0UOU;C7|5r5nT? znQ*|%b?eGmKv4bWI0ZHQoZP~?8QcaX;MshIiZ^HnxmZ{ZGWjgsWZWAc?V&sP-_iRn zGJb#VJ}{T0;KCB=x2v`+lI)!{wW7}ZIfiu2- zbT05u=vinZ{RDYV(6Z240HW!q=aru85SV%uA)?%Vei!g~ zlEWE(dg;&q_QyBpYHReLJfRpA8&n0GfW;oVC=%EvDEtMGb?vIE&2(uLX-9FurJW#S)&sW2;zj z#sn)+8t;ia5BK7NAQsym3rT!97sD@5Jq(UDGWkz7h*26?z<{TFu|K!)q@`PiY1*i% z#GVjpWO#3z8c2Z)syv~V3wmP#AWeU?3u_V5J;U(-dEZcS`C9+|?rTv_oI&zxbr`Dk*rUSQ}`a-ARNd=2|~;S&ty zd2zY9x%T2L2T@4AVusTH*|TTs<2>Ha5_h&IU6)&ZzBsmu9#=i*X1t&BM>JB!X)3Db zyGe#@x)h_q*N?nEecI0~@klsdcxqbm3ChiEmFvHAoMn=Fn2ZfCFW4Jf@cvQ5DZt(?=Nbk8mhA3+F- z0tH=MT!2q+)p%eRpnnHY6+5flSKST4kMF4m*0ZYg-y4#7hBdT6t6t}`w`Bwqn4%aChy=HkF`HDYhDas@V=E<>xV=p zoXn5C(4ZcM!1#dE;?Y*n986v~1M$u!NUStPa@BRPC|G|#ax1o3F!7Q323kkuuh-uX z-IKZZs69m*ncJ$t9)anD;VdU?cXqgd>2EZ?e2)zEqqVjE6w~e*)Ce*ku0DvNF3Rsz zGIT}(WfbB?=~TPg<0duq^xi^{4*h8vy~&SbVqzAMAr8*77yDPN-;284B5wBg7Hs_{ z!W5hR(GI-4$!gi}_%0u{?8%eqc*%^4v~TT&0Y$|_Uc>9cs1cZFQa@PfNQMFoJu=9P z8!a!bYJVm!J}v~tpOjkm{&M0?jE@hL_1>gHee#&E_W$&WL#oAUXiqn+1k)=((%jrGT7_!7wRFW7wr);|FOYM^NZBL2~^{}Drh(sv&x5X`Obg-1tV z9uF+g;>8~B_cvWZeew}sKPA31tB#;HlL1cax_IT7|1o_N+f||=y6bP$PeJU3Dbf-En3V>2 z64JJ^1taXf2hi{qRN3gc3!@%xmJq~Ndtt~;W}??2xM37B)`4E!^P*{Mf;7P1gROPH ztL@R%{@Q$n*#L?~`t7AlC%xTHD~l~MU*Iu3p?Vo+X_>&DCt>h!|Mj&*_hYDqLC02IeRuLtlZie-;IaIS7ok?(Ua|+6cnZ*N zA;W#R4Xgh;C9!*iM|5rbU6;w)VxG>&1HlrD6o_mm`azZ?*aVn)Q7GL$UC*=ou6TQr zs~dJ?JoEOKPCU)Lr`?II+6Sh98xKJj6H-KR4?j^pcZUp;UAObAo2ueKvadkRbJyCM zOZdc#+VKfef4Zlo?Q&Th#$RnOQZ|jkkg`ueShGpy(BEY)=(&D_ z+tjd(n~9pve@?$#ch3fxjUcXh;8cRq)72&TlCLC6tn$vR|AzWC@6(^30Q?WQ!L=$I zX=x$Ur>3T+r&!LWU*)#A@wlFXQs-PEkbiFB76T2*SMv4C51_P$YiR*PSgvFQ#rn=aAx9va0F;Ax7wn(?;vZ?CX1p6Y9=oaHeYLu(k&TBZSoy%d zk3}2)?vgT89LZ5FPjbo!Sa}z4a%3(VX*|n$U%; z?rB3hp1||_(wC);L?88U-cY&6_YMu&P_37hl_Bc!m(K_a{6BZAPP1A1gB%cD5Ti1HtmTrl=u z-+qgn55e2 zALiO+Cb}Jh)+NXc!ffj;8c$EJWSD*GZ?ya9N055xxOWs`_Sp0*ORV%QCxfGTXf%2Y zA7ofqzTpA2ff{a8`Vl`u9k7gc8`T^_t@p>fJo@sI8G5K5uMyEe_n54&wU%O5c?Q}# zpj;}IJtlka@8?mWTps`U^64|}?-9bHP-%Y^v^x2Vh5geIQaC%ipnv9WXr-`%Xv;2`Jk^-`{(Dt$e2Nm;+-;km3ri_g2; zj;+Q+gKh$z4?>xL$FB_?L;%uT_Im=g#_`(f>bzN~nj||jeH*7?)X2od_XPx*0lJ&i z!&H0J%P-)#xyi4hodYN0pQ3zmg%^Cbb_B>!`p#)1iA;?3xZO>$ib{*TnV~AT3q-u= z@x6_)bK-sO7ofps>P?kE%cqy?m(1{dH=JWs)cSq#ht=%YUCWwleU>}Wf4y0ea(^lW zd}tpZEm8mx$-#+cDyiXhmOpZA;ZsIw=fZkwxsgj>Xwjjr?(+Tz9PHUr>c#0%g%xH# z)k{BFIp0$qS3YI>o`ojJ;{2aS+l!q{gRi&d69#6S!_Gdpo5T{@u;!3Yc*qxH$CgTg zf`adHiA0W?%;tC+N@~+4#dgz{;w_|GmsW7UXwJv%>FJ3=B5TC(0a+Sgw0D>pZ& zQF3iY>hi5C2R=op;9@Twsqlh=%O{xr*dNeY>oPyYIBD{8Uy> zY49kHEqX=iSn?~1BZ_PtUk_wmKbCb)_4Cj?seh()&Eb3@T7z>aI}d9sp{M$$}{mY3E2?X(~5fx>qVX2KcQ9a+Gw zMBD)OFwiDB4xH6BUV}dK_;PKf%;GO@g0z|3@gnLWWGJAWnl?J+lWn9$e8)_h8q)Ie zD3>4>DOU^X^E`-~_ZGwS%wyL+W^WqzU6AXr7C510_pVT0-pYL|m4()2bPGEZQdq*v zrg`WEpM|R}Ni^R&eZa8BCfM-3pwXJdO+}A2)9J}!1Cv|P0m3d1pL@-`GCr;%V|}&l%QcyME(7O*GATBnKB) zL}Fqhu)6R~FR8}dbbQU~wUXSEhvxAAmO|-clcmMkUuHf^yt@bY8NuweOE7)==8Zj1 zF=j?fPOaPqp9QIE6e`GawOS%PT9Y%iJ*&F<53O~o>DukL!5A^$ngzc=;@IlvaL$^= zp+_~OMhe~P2eiT!F!;FN$ap7eNJoW8$|%y~Xd}@4^|2h$^S4ng!yT-v= zbBvAB}M9!7NkyT&s2+Ut?*9ctIC1z zzq`PCzyV)JeeT@US~sp~c-TnHV>g1WZQ16}*ZsEp0Y5h7O%|m&NgpIPHbhyB)Q5lW$jtjJJcEI_Hh~w# z<9zAs|IFihzY4HUZ_X5SzJw7pN8rwULrn*d|Az>4oboH>ML%!$2crI9KFv1 zEFpZDehq0^S;5*-%h-|ckL8xbKA9rWIyjrksWoLigei;uPd>gMw-Y4Q z6>)X54}>O`H@ZKtTrOD=%{MIf3p)fyviSiBg{1mzyAjEnot^>MW#)_ZFu6E$$a{th z^Vhs2$Q@j}%g3l%KL61b7P-Tr#U(4fbJq;Sd2=QG)Ev8SvFn(w2KGjK?IpVnTeoEz zarOG`>1Wgv>5@k-8Q~r4Rdz-*E-x+JYI^uCPRX43g`|hG_bF|RidWBS9tiEOk`M61lFFZBXEV_4J=x*9HuyTpW zIqbSR&&fGnYm4F@ZjpoY%76C&19A~~+1=f99XTG|JajAO!L5q{p#}}hb>@}W?HCt! zwCQLhIbvBNT14K5roWN>t|1Zamk?u^KAv(F{W$U3oMLZFnuZeKg0jw>1y#5I@|9^C zc+?|AE<*U}y%?GnVd+eD4vffSMCks01*#<)estg74H52-^;}A};?mi^_6*+Sm-+Xu z+OF&7JNH?cgr?<-oFs(tc&pmKiTDJknfB_llmGh~v+&zCLP_=lUXqvKjrxm6|M`C8 z{r|28|MTwNzh8{JC-?vPW)|hQGegg55uJ*>vOY562YLo-V?D%SjX5_wwp{sVP_A1P9L#+0WB&df_$|$N zk?&cWPCG0g|M!azPyBatpEf3)SU~Fe?EYJKmdTu+pXCo-^bt@jl=S~C#iFmGX_E^6 zk(TV=CyMGyNq&LN^_h1)0D9x9N7AJMSWHj z`_ECO(CT(9kgLJEP|XLB-2*zJ&zDl$ujiJA!yUkDI-*;TJ+=M*SvY8X$B~^k%VO$~ zNtJ)=Z&Ipv6uubt#84++E8-k=Ct3uBGP}LrLynS^+Ie^Tq6uGgct}VA%w0RMf4wYq z@P!+H+>dI1659SrxdA@v_V;dchcz&VwP%gJI6TI5I_cA=OECDn-I{U#+qDGam0;=s zn}me&tAht&gz&qDCs#nfCG{c8mLvyW@5UWoSST#l!>wUpK!3x{4Xy$ff#&o6b-QSb z`#5d=%VDgG$?8&Q!xM+#XWp|CAd0LT=)XWuwSAyhOsJxQIycZ!!L02kw_{Dp2acZI zzkUyl!PVuw06)>qPhAe|m?3+S#D<(;NpiJhQe_>dUzC(mruv~5cUZ&ZPWru&+&{t= zf$e7DeO8fuO$xwrdn@+mdA(#f5PX{ZyLc&R7iDjC08em=R7i-ObN*XQef2O_!Ddgx zDh)WCuy%-@Vc35x?{VdKqk^_wjrkggADReGXPJ>(lguBAiV0wie&Dl{N)m#-=SK8! zn=k#&%VVidWbL8E_0u*7c3UUkCh?NhPG;z}63O!I}4MB5NM07NZ|2G9=V`ExDJ72(jWH+PQ z?$O8IlPKdCZgAYcC-hJ(V+~LGj5BO&T$kK|olnnR=zVyY+#>Fgq$HB>@oGcFh1e0w zB0;ELKE!c3xsz?K>B%kg^#y-THMJC=sJ8y~VKHsy<;#~-fBbpW!u-$7eC(!%rgm~BQr`yRfL^(^Q9Hqv z-+p1r6%U4My}B}$=jA8+tZpkNY)3Pje|edE{@l4AnkUS@NNCWT@X?Mt3FG(n_g6)G zExn$#MJ@pxp^2o1Qb_?&f&*oN%xr)tlt1cZYis-Mds*0+yvpsb6xh1oZQlc9;k&Y6 zpLNrpSgW7Ca3SaY`^eBV7#NtIk-;G(B&}a&)%_!+?mRvHY{<^nZ>3R*i8IeAa=SBT zXRW}KmZKA}>5!(T)HE>g2%fF4ua~v%FDY;udK3ERY4OH;oT9;J-=_x>S#}HE&ts zwtOMqutrPDFJV4pXJyt63-k|-iVE`mso(s4+8QHr&(YCwq%L;ZUWCdq=JUbH?x~4y zy}h#EzOjsl5>PQ^kEsZL()cUaOMv z&U$O?b4^sEc^>o^Ov}`--RD~~K@-fK4SA}(zS<_*Naqy*&tcMe_zJ6uE zj)nSn7nwNKgwM4l*XdQ*g)p;;iz@-&h`fSAr?LcB=jRuP`Jen4QCCWV{mt^)(ved$ zH_!IovQF14eXDx+?zOdB&z_Ohqa}bgDpzZV+pq5Tkdll{2J;KqAk)$Bic4#2+J%N< zD>H>0B^D97u)EW3mkrI$3COCYWy(}w;CO!gVZXZWTfv53;rH)pi2IEC3GfHO(Exkh?m1o zr6j-gx13KHs&Y{VrKQ~zxs$?Ca1lNE_>O+Q*3L?&+Qzyo#2vrk`y5&aD(u6MRY7=~ zJBZzIg-16y0Ne{*wzzx^$d*@9Y`)oEYAO`oSs@CK1`J)BT`kY34@)#kI?hwA0uy`T z#`gtD#pgix2^kA1_uo&>70{g-7g4gUht2Cgi(e2zV+B&Fr!vx z^rVpwIvM_`rkl(;SKYSymE}Cw4>m(V?f(70gk6-{5`Anm+}&m3%kK_DN*Vx?{AO)J zyI3Q;YXgh2^Vmcj0;v-TY~9WC8ORaHZ7(V_=VQc^}ROW!Mb zx1t?2DJ?Bs>HuzmVy<>!NVkPLJX!qsyR=WABr#K`3mHV5vy5=!OQfCLQfCK;w^n7h zFtfjsJLX{eT=|!hmoJrQMV!OG)YXY;?SduoTKFs*(NeWJZGY_OQ6VDsg1>ft;MzZ) zFDM{WObk9Sz$$rUh6}x}8O(xsXsLJ13{c{rew!^7W03<7a2-<{ww&Qncd~OP!U2Xc={-X=Tixjj&pkFlwjl7ywH~+~ zLCgddK^(*3ZL|#yYe7Y^@%G|pLYAPypLUX@CeZA4XoRCdnjX8JjoP39URl9hMubBG@ zQhLrPrgz1aeWqpmSL8_On3Ma2PEk=2zz?@y3^@^0mtwlY%YIlT@G-}#~TdXvkfyMCsCu-Jts z0~wTlaVTDWd|Z(=4C_3XqM573arLScmtINRGxEdCa=@w8-S1*%_LuvLAi61PFT-nV zH5;UmGD7dk)L(Q%p9pbvThnB~BHM6~d5I|pvfjgAgn zXTPH^l92l6kHMRzv@|iqV#rF5rg6os)j*FvB+r4n_U!W-S|U(4$ofL~IXeWf;(Aj>nQ30fZ!A3phncmTCnvW;R*f7I-V)))h3V6EETd zLXKz!PPw;%*4lH{Po6$CuX`=QA_ssTlDjCdM#Ko%K6~06lL{_%!NY&W%=a+=RDfBz z4I2EjbnAgK%i&sb3p_T0IXmf+5h&tgr)-h04cDI>`TWGipA<*0eM^v+w+SCu&RlAP zm%rdI9Xe(wSmH7-zE%w$n4Fq?qj5xdxV%Fki?`LEZmZif+slV%25hO#Cw>aO4Gs>2 z9fLt@;c;;(t8cV<@vkcxMk#xHQ` znXODUfzP8ghp$owTV~OI#Q@Rx&;iqv6}zz6)fH5a{@nZ&L+H@8?AcBRm3Iy8-CKYk zH#|(So(tMS9)$3=wH}j84al2@uoydgQ3nA6pZ-cm?et}EAA+h9L7f7dKIBk)rm$V$ z=Kt?SD_kdaN^Fezgju8a#<;e1Uy)|7p~xLFvRw)a$-wHBZ673NhnHCwqsNl*b4aGi zLBb%W2%LzyUmvAFJzsMBPrZ)3{P_~4*az!>QXG*D&IbiKinbCn)5~_#-jDud6}RBt z0nqnnAhEIk@|6WmpxDwFfwLq{^CM0>Vs044l+>O0!=8-zZXHt$ko<920jP4Vd`Ks zglF=2!41@YejrH>fd1XMUAN3F3>a#y{9QEQJpz#AeXGRI12Qr)MPh}I{>QsU-lPbb z6HLj#1)~ON2MC!pj>zFSjS7G<9W82g5hhbZ%DHr8;Na%`SAj-FqlLKxFhfRTYQa~pZNI)6)WlJTWyL9C`@KYmk?K^k*( zG*x78qt&6aan@OEYp{6pPZJNn&*uYZuWn7auX5`FRykT9Qa3cGCS9R_3{4QUqGlh! z4jh#sFEU$vw6V1{Q+-*gQE_jqxV6N4$33v>&#~wS8S4G@OXE$X)v9^3-9fj~n68?U z?d4LF&Ziq&8~g*yy4oN2gLsr=og_68O!A|rVRBJH)f%BCn1LR59_45}anX8Wa#BGp zOn$`eiW1-yIzw3$_?&-U96CZdJE*kd9`hZ2Qwfe-;L^o7rOes-uJ(yI(ZZR3*13!1 zF_J=Hg1B5L=LLmVZg3c4ZeiXQB;2@Dvl*9snL4fzU_$b5M4UCS`Ot;owTf%S%#nJt zhoi}z8h)H{-|RCjvFt54)->c?&wz+UBAvi=OVw^VV~qy^0T5Ms`E2%O(z^AfPq$Y^ zAz$oRyCHKjGgZF6bav7N`N+V0?NvFf)yj2{KSJr7!YF-iaM)~XNs+IOgw--hv&kV* z=p2DzZwMb~y5EjYaK&N(I+KIkL(9k=_lZ8!h`2bn#|-gc#~#(}e*fxl^JAY?jNKf6f#dpQ;&=}tVr%cap{6E7 z8pYDts#tl(0XzH3&*D~#9|tFMW#4m{= zdGS@wkpnI5_ERvvP>ySlq>W!}^~qA+^A=vtd*;6K(T9VLEnClipvI%TN_3}%K1tse zhgR_3{@7vSLhR4Jmf)@y6+oYi9KF}EHXV6F#T;>?kMq%0GT=IOq+gY($V59ed_xw$ zNt$7iTZ|WGhm#4Iolt|ib6;W4J6k1kKhzXvc&7ha7j&Ts4x0B~@w&NGq;Q^&DRWpW z?|S*r>f8WnJxtkR!A+_wx0ME2e~hqo3`8m%23H@Ajyg5Xl@l`475ItMxm}g|+WEsD z?u4%FoEk<{k9wwJu1XnbHv;f_wXK2RWzPh7LMpbx-@rey`VMHxiRkhcoy zpAubpciX04gRrE@)f7`|iQESbuc-wnC#=P`D`^U^AjN~dZ@ayQBRZ-@=uA#bAWJI8 z+snRMvg)s{{6d<3^08pr<9`lcsXzB1*?xE6~Q(?l>(g3b0+id?%D+gEUfi%JOshalWKI6yAPo zj{W-ewy%4&s^epNcQYxx_{>8mniC5T4twYNNNi^H*MH9eZ;faIT(gCAHds|QHnx}> z-p>mzifDPP&2~epTPE_mtr8m=0A~>=)Iv!zcgCi|@M*E`XhFqYz$5mTn1>Zq68q(& zB%G$+*;H(>g1OYD`mWse5ys!pdolgkV*{TX=Aql3UKqj;1xy1= z)DNrMxp{dH0G*@)iWvG5aztO(bX#;oVC^)1ew01$aj6~#O3)UtC@1&~sa52)+1|4q zcQ}wGPJOo&ca}H~9z594)TGcVz7rs0=pAk7Ic}o3@kar2bS5UIGwBdUJ86_Kdaa*X zv+pYL4!}*&;ypVR-8UPqL6DKd>a+QVSUqF3X->GfQ?hamkC}|eZWlF``s{go!OdRq z!RZ{IXx6SOvu?6BC04uXeRN2-soA)I#*9bw*NDj3;{qVb)ed0bkT80IiP^%%Iydk>Kf^+gpb$*%dzLm5T=t9yTXUcWhfy zPF4c5@e9>FxxydhX(`b|A!FPFgZY=SpQ zQ=9NMx1h`G)L`K}HJ6r-Sr#*)KG8e0?@7miZEboowZVwt?(^lAUeF9v{yaF1;Jxk4 zyOqSsEgQU3UEp{ImtnQe@mh4!&D8p-Zf(PmjUTS90>iFhHWdUJAXAH9afQ4?HlW}v zmM6GE)5vxfrYw!5?b_?v^tFTNGS?(rjX(=* zj?}C*1+E!%ZZ3ZR`i9nZBAVEX#yYi|q;mJ>^%e7tHx)KE`v(McNf5U)0bZFmU{g~% z=)AqNP3opL$}w>&VP>G&5>pZwbP5~tT0<*(?vSXyb8?92ui<@gkMzpAx(YLZ3HU*% z(Da zo(O%O(U4l{Yn(0WYkX2ZFzzG>iRN*^j?s+vhwmVt``HYNAfZuFx`t1C_ndx1CE}{8 zfkED6g3r*-{G7{hJNV7#>F9E&vZfrGBE!R{QX80Z6=QFMp7*SDn2{--PN9UyfqY&jwsNPkmJA&N_c@M|IVwhiPX2f$POg}jSh=}_R{ z#EIQhmEhdCL-jqFC#nr{fVo6Z?LxWO(k*myOrFEnkywyJcn zjt9nyU`NXay2=#k7(`tc!W%m#1H{k2uBn)A#|iJ$Z^J(O`NIz%+QbTfKFuvLkdgW}xu{jBn>sS`&?Smk zdimx})ljEZT~keApSH?Lv)$(#@(%`+ZO~N=E+TRsYxhGBt;}nu?+^zGv=ZVH5=KUf z8##lb`nu7U(YU}-uWG^WdlnWs6Q0X~yUUHa=k&PS3oZhKB12 z;*9X1YfV?$!2&ofjNHM@^^+=^Y=(+-LrWglw;Y?a-t*S};yqluLp1M2I|fGb7!_C+ z;FPqD+#)ttgrtrhjYsqkl+=z5xCqB@x_@^9%R4@Pwih$k8c4@dsi(6Sk4sE!5nD6x z67#~Y&JWMq=<>+PJSJiFT?FYjt-l>>elMi%vs-y*YjvJS0GIdE`wMB3=e^PIWWOe! zDg&r>ZJKO2usBU?{Qi7N4)N(&0}@Bti|COHgGPX;zS}`Ko}c)9_X)c{M)4n&)2u7^ zMpIfE8ot;=>Ie%{WpYRv@xGgz8yh#bQmfcjocA{2TFv5w4`%U$F@(eHoDQW4fq!Lr z(vPO%%O=O3RAtWfRcVi23yAvmsNK4JC?|+PnElcv$&;B4Ul6MUYLD7*^2K2$cD6TM*{14GU1`!$XQqT7+ zYV0hOylXq%>&D3@^xJ_zAcUs540)`rW0KQ;?X0yOe{Heo;=e*#A#AP`x2|+>@E~}w z$1=e^%>sA>Q{V|MF;j{6f}Go5E7Bcl+`4@#B=5lP&R319b;z9Xm7g z*Z7BW%B@*W@en4+sol5x%cCDj$Pn6o*KI!Su_Dd(b&BB)n|ZE4M33ZUcgYW1l%6-0 zlzdqEc%v%dMDNGL6o=yMM7(7mMa0Icr|&S%v2M-@40okpow;pqRX)IPz1h3(x7z&J zGc21asL^T_TegZQuYwFmbG(4^(9qB(Fk$d-E~$dOL1a{~qFS2lzh zJv+M(NWQPCLpU@hl3i};)I#B+_$qM#T$Oc6P%&+mg^}||jdxy?H2c4=m>X#eLnWR4 z#tp=BVzeS3&kf*=;P!}XYUpgj{}f?TB{Z*V77|*z?4!RJ%bdL|hYgW%ulBK}^e7tF&flzQX(eGvGo?^bKnp**m%a@3 zbPgy8?`#g(MkK5?p1W{i8Yq3Wnx@Ra%X`aAh@MPT?+YPLS*Cisz6Hw%8x+-Nr3Lm* zxxy-nyI}D{ujb|K7zi05V8zZv_B~O%o2i5)_FIyEr3O!bdPWZQweg!qL(3$PZR~VT z?%4Ra=G>@YKgP?XhDBp-t^}vZcg@&LflTqhYvSOEe8s|+e--Vx~>uN?4p;{ zH8ecsC%Ycrm>P6!QT-FdAg`#Hf5YcnV?@kKyPq%we)Ua7j=f6Y920($nQ9R}C#Ftr z{z?$FaF5UZ$ND4*0V+sSULITIxHQA;#=V~lYTt6-bYf5v%TwU4{e@o|gwl2Spl2}> zkY@YPDGPwbK)f@!C$ykbmWTU;rpVC7YiUqNBW=zUDum`~(vRweax)O*7raH?94ZT( zfMb>)yt82XH9}^5M)9GOUA>)|mOGnE4PP{Ig&hKplNN22FS<$1=<1CKkRHs{<^dI{pq`=Fy=3GA+&^H?f+aT6%53m@xuJX8RGqQ@?mG-G+R(Lg=eu(@^Z1=p;H=XmK!k}ES`@x?(w6L&n zcWWY69vX}VYypLV1afS=o!!%YNcbzd26|_LW}Kd&x0RJT>g?gR(;d>;VWI%g;zEBv zwC9&w6o)JI{HPgd9{tYJ0`Qwrt!E{qC_LO{WL{^qzBk;c6z_Om{ozB>7yH+*Ut4s@ zGF5sQ3r<6bq#`f>)~06sf=fp&YtYHq4_1Ax>;;;JMo7N(M?p8SpF?UtHF*{ihnucTV>rX8 z$SJUyrB52pe*5}$3ffApmv2Lt71)FxLf>RUC(u51SGn+vPmEb?{-kOA`LjzFpxMwc zP_tdyYmf>(Y6K>iq3rfl88*-v5_?PjkYhT3@nTkZ_&F%W(*$1g*(E+-=4s1n;_3aV z%UfzYYzVdZzQ)$p-s+9z@%j1r5Z8+*4%MuG@yG7Ip5fo^x8&QM255#N9eAil@NiW@ z?~$OFBcX&P8$jUD(V2U+7oD5?-6;R#k9D{m`(U$`qj2hbtZh$e zuTkbAJb>@{iVQ6E4D7Xs7mX)VUwBRGPfEvj;cUtAINN@d{3f zttb6t?X-{Dnv~d04vf7$O#%dQFve$UwzwrS|7Qk^LZsOolh0KCUM<1Uxw*e|^I-=w z+V1l~S^&7qb*$$W^nmoOEaetXL5Pj#Y7x_wu=w)mquDlpui5PTn--lJfzHgtws3Rx z?pwloF1w?N*U&iFZ}(Bs8|+L%6^4-2#7}(7fk3!xJXS5f=S5HVVAM%w7zn7r%Qs`Lg_j2goVSttVt!E+l;>TDQVNs87~>_);=94qW~z)Oc#Si?Xq50ZIo zTwIDwiZNrgL0u)B4Qy<*{O;j0fwbvKNtbq(njDbjAjuL~ev16Z$TTDf4HjRuMe`e1 zbK_=jf#7kW%N{^?LAN+@y80hipeQBfEL^#PXxG)W`?qIyj+%M~QqB&zRlr_AQz~Z9 zYmJa&ug!kzR?*-R;s-(`&+pj7$+ft2ZjQC> zvSAWEjheYn_%8CzrWj)h-|0wwlZhPWt&9x9PWSWmN`=jJ17%VT-3TV}&i&>w{2pbj zK1**PBrB-j+W}-;*e+%b&G<&ns>(TLk>upH(XFkw)EOMJO6s-fEr=&p%sHcZjh{m< zDyJkKk~kKdCFYw`qrpg~8b)+?ADoOYq)zlP>^1N~zZw^Dp32hgG&O7N%HIE$y}1LW z=AAN8LG;;{-<4IGuI862e+RV(g)SBEJI??vp;R#o5VkA;yiisxq)>5F!y#^9^2*aK zHk}!GbsVXCAQ=Zp%(RCoixP-`w#<6xz%y{44Uxsav4u{HYk-{r+O}@qR?kS3_5AEB)&S_iOQoak(+$`7AuZ?PzxI_u z1PPx>?P+q?@Oyy*gGs;uw)0`^Fw_dBCt_`!TRnCwC%Y;gO}PwK;>EYN9l^Ix+agwK z!vc_m2D{Y^nC9-(V9^xw&J048v_Id};2okHQhJ(b-jFd;icS%7oVb2XKpS1<)UCi% zodhZ`v7{hA;PXRpp=xuWT5b-5E1>x#1M4zhaB9fI2y(tXBJp|>C=a{Tp7fi|M)NCAY+s0X{Y`_^4>Zu%C!q09Yrw^aH|+d7y}3h zDBYk)3`m!>3X;+#jk1+Z%TUtYCEcJREgd5r(nF`@oHh9Ax4++YuJh0N@0|DA8!wo7 z-}lM&tmj_$x}SRg^gc_~%_^cJ;IBi8Ct5+q3Y#FO>+pu*w>}~27(jE zHg|gE+4S3#goQt$D$bZ214m_KZ_K60XjmPlw2{S>#^xS_=};kxOWRGvuU<%e7*@bt!U=jy%ysY_RCoQEDpJ}DClJCO5bzF(iAh$-%pPgM^LE`CuG;RLfRHNwA}`_@ zbruC?D6v1pJk=fjUvg@A+#%H}B|ylXbZMV`a71ngaV|Vlx}zgFq(3AyG?}%c>T_^Y zqtSl}Jcu5eimGv%cAEdPkf9K~_;-iKbdRBDF8r6X3`7V@OWx#-jVwa8>7CL4l7FE* zJzh~m&LQfi#Kq44W`7}ya?;Ws!B(BP-1mR~-fULF$j6uaIV|V|?@^78e#EJOA!%8% zVE<`_#%D47zIq}iCKSlX!q~m@|Bt`dci8U9|L>!33DwBGuyabJXZfG9XM^y`OQk8A zKU8FR_(~~kWBM2U#gz5`-2jMymKG*Mn-{eI^W8M`1(QVbz5P3Ess9OKrwM^;iQgUd z8Y_cezd+&{B?AUMR#(V^H zAkZpc>D06Te&%#XBM%J^B?;JCAa#8)irV&TLoff- ztLG#@ISWw~@Txb**x3L&30G4)yRr>2g44&2A=n`jn(UX7)j-=$Q~$d^jJ1H4m7ia5 z=0wLgPy}t(FQ5neMH}Sh4)4iAE0`qSnHde5LF-L!{AZjMrGEVQev$`KqL8V4Ej%3f zh7jlPK=v;}6<-inM}_=r{0V0R&MR*xL-HCS=ijQYsG@TDH3WT8FJAa@{c_Ha#)R=( zXf;lz_G1*{P*)17k5JeL#8sO+UjG`haVn4r-@IgT{xqUNS?&qm1_QI+Pyt?Q{{bEJ zueZ840GlN3pQNkq42>=AbfwTGi^=2@v8Esbsbl=2frClD@k4G3vUo{1Cph{GK@GaPJoKTARS`DL!|$F z<&tOZ&CFJ@8wjPyUSoU;!Yc`BD3b0kUNr(iC358(!nnD96I%VzRpNJ!!?<+hf2X{A z!}mqIHwbqu1i{tVdUTH;DE+%ax)daIQ0?0?o*p-#1SdT5TrbSVNW@6}nRTSlM(&w# zw*y=FW7}$3*?+a{QE!p1uCZd~4*A3R!~R3|KKe6)|7(k;JyVB3^#A$yq#X6Qy`3FL z{(p5j{+3q~$`=M=&QnrOtxlHxPqxX}P1>M58TEKNI%)-Z`PDOF>1O}xJN&&XH>k~} zvdx5mUJa6y>g6wYEQ4k_Q2(k2{##VQbI{5h6tWi9A*Qkk1+FurbOMI`|18J*+}sl1 zcLONV64P#VcpO^OYoms)Qr>GFzO$0tlgZUZyTu4dXACFUHSTY( z!AFNOl`}(mow+xYbQ5Dw<5A2%7ZrdoKUPhP;o(4Sok{^ZV9T?TOkhEyP)@S+4$OZT zafWN0Wmv}o@H_l)mWT*g2(dIMgIL>8aAIZ4Wh^c8wifym;Y?^Xscdhb07cKA zuNH^O6!*5NBaF@_yXkl!_+N(Tl6hys2C`=N&7x#i`V!-}-DCxgYy9WerXlQz5p z#iYM2t1#Ph)^JTX?qo=y1Y-Fe9vU^KM2!sbfNorbjudM_KHdQV?xQV)>-)Ab6qJI_ zPgEkMf%XE~TG`uc-(PY9&s*%|+mmP{ zkV=;f3{W7bj(t6b=s0om=N{n9I(8Mcw+9&u!i;KTBaMr``Tn*e?251?tXf)v-~S#7 zMEbra!NFpqL^w%JMwm@rbAjBG{mvZ;KnJJZorR+bl>myFO@ZZ~_U@gI8u^ege6lP4 z5@v%8(WH$H8!%nLkxMqbArTQ7(0nguCL8tG>Dlip!LHu6|t)Nfttg8OoKBl=KKh4S{a20^fMS zS>f6|^l@C4CKZ_};#>(rvO`!i*~dMQ-`*?8sRGnCTiMYflduc+3vzf4XoYI-#rk-N z)$DCAtE#9J!x)=_(b@RVX+W7&U`d<`Whxl71)v)5$U`~}#G+L69wh-!juFnDWT>ku zAC!=l#e9Yu85lrH2?LV*5x9N|N=m^g{&906%7FQUQVm9D| zJax=JhREX_ZBlc5;vdsW{kdI zEGXNKEHnr}!_G{hYK=9LktqNf?%TUeQN;VZ;aJ$&CFZX6`z2)kUL4%@fp=s)dUzly zD;tI^^7gW9=L294Qf3$vKc{I|IRd6H3Bh`B(YyOh2Z7kAUu5_fGi-ZQvek_M=L6hB zagft=Xb@yk0CJs{=-PM%nCRjDx-1NQ(8iM-uCT?o_gNqBLbG9BAXgYISG&xgr(Ry# z6`-RNSIGq8x@a8@%~FyvY=ZFP5HfOEE40F|iKBngT_bYFEDh%mWI^h>d_cEsSf`;9 z&|>jXALws9Yh>8J@a1Tkb!xsp|J{xXRTez410*$D`m&+l`=HQ0H99pHmvVUr8=IOe z>DmE3Uh-cBl5wHYy8hx&krZU}0Nb@ly~HdV7W3_^v(db^8S+syMuS_6`62z6#;D^) z3^r7r3cJJX+kl)0<#bo*stkhwDhL8$;50|TUsGZP9D7QuRp|74d14{IYinoM5saHS z)Qq?~>%Z3JB6wKpg|G6kqX~7p&zdeQa-%;;OGsGQpJavz)lv`=rk@y;2%;pAssRMl zkhp zx6*u6pa(-w1vvrENStR>@k`K!^o!0j3Awbx+OOQ%tvy;e_z{bi7aOTa5;iV-pL9i+i ziUY^o%Kib>TFK>}1`u_R2aXHgxxvuUD)`Q-~SqG7KJ!u?v zwk6Nn%OD`B(Be&z>G6#CTE#E}T;QIn_hZ7~Trm_4kn8PS zCHPOifnmz?=g%jXXUJvw>F08ptC%kP@P&g2n94H&Al~HH*q)>Y1gYcwc~}DQ4jDR= zY^Bq(SQZI5F*-AH32^PR>j9=bE``pZ`k&9YFi`ALfu!G;U|dfej#?h5k^+z#;I_bJ zRI`PR^CD=s!h~;v2nWRNA^EF<_iKX`8WIjs3T1gc0gGb$X)xA#aD7t;qssQzfo|NO zWd|t#0Awpw)YYU;IKSLpZ?KD9@>U(jebuJ&6l4oaax@jecUQGRh67UO|(+e`=rtKQ5ThNTEoOhWM)@V zqrjv7=uzp#9T1(dqI(Gh?k+ag4Xk<1+{$?ER3AtKnES^PaiDEQ2 zY+C5oBz*1ZXv#Ed_@ZS1@5yV!8rwcsiC#Yg*~)O@lAPY7f4*%FwMf61{qnod>(zm| z1?h3HQHX==k@@}tPh>iv%FV4U-9S_*1VI(Z_)`Yp6qZZ$q~uea%elG6MhT;q%P4e{ zVTQu?B07_A2bNP~z|ijip$XXF%wTwVDExKN8&lh4c1g;`*sBRGAvAdX7hzH&BEGpp zd}@q6HU(Z}0w67{W}!8)H6T+937dC;Oi)_Ny}btfDtUR!JTQobj;-Wi^WZ<21rk=a z*WB_~i!uxRz;mTRBJTh_*q^2-H#B7vFJqe~L z&_;>|ESHAL3aqR&wPIO->U;9kDe96e|Ro;&H&0qWt zzjrM7U5h&*oOqQk>T_PS@N`JOUPiY|H{clV3>MAw z9_M~#{Ug{VWq8gUN5plmZf=I=n*R-eYe<;Ror+<3>>d)fQNqe*tdug>ej|#)KPGMP z3L#~b8MLauU# z@1m^mTv=Y;%iCLW(!#M=nHN9UGCmm>0n5rq_}49BO~yrdP&R1%hc14@`|Q~Rph_yE zQ0bDA*+7AlaTvrqylHQ~Bs_#Xhl_y0z}0bw>@wNWZIk45!ypJ=!Uv~z(?(`LE@rL3 z)8`iLcqnCfqGfoZa&Aid5_-T&d``>*u(Zm`Z)>a-hjjSLlIa;3FtXl43*#TjeTLvB zqT@y`Reyi~iED_C$?(Tw;g2snIB6JH5))dVM8us{AfdqvOMvRd3wBs4<=6;y7Y{Yt z&98&N)heIBO+c(C?Un|9A;eL^pjB`hK|Z=<06Kp_Q$Ud%7u=LlK&a2;NYA&?rywV z{KVMy^y#DL%>Q~@Q`0HsCr>19z=IG`@9SEm1V=_r92X9OHpdJU6Tujda(;gUdR+9V zJKj<9U4(%IeI5jU-Hp?r_5!gPu%bo9F0L(5JjbKQ5=*&iK0X4>qk>^0u|L~PDNJ_a zBw^qD)(#V7%g^H_HH10zX7sSI*!*f06xgg5y~!CaM8lP_WtLHO=kL?-VRKs(Z3na< z!RQzahm_bb7Cat@K*%)fu|i5_urMMmxTG%O{rmUoa6tPr5NvTdDrt%kSB|P3r>r?1 zq2T6$D8}19t2gI_fBcx5O8)rqqx!Hon6<;9(wL`EJL#i)UMCCTB#w%$k>_`v+uj z*dn$GBz)Mj4|F+>0hUL2)HEWa-y)@dy(1h{t(5eV-W(@5L)h`{6>zWk?`HKi@C$j5 z-i49FgC99P9WZi0iHztqzS43`klthrjK)vy!r{KOAS3o{Z z`b;$nVL*L`7rw*4(Bf=$)S3`1L`~<4w-Ys2WT`P^~9S@vs4g-`J%O_(7OD9a&bu{%qg& zS0nhr*#^MaiWEk0zZm@%jSr@9ixz?%8M|cKUktf(IX90gj))t{QmYWtC^n^q-=l8f z>-p+aR2f?#?vpFv$@pu2=#XHK!(izg{O*1V-{PM{Kr1aLzW`wJqi>|K!Hfl3N(KJq zr1$~leyVDx?a`mABIK}Oqc^8v#KIj^N8l!Det(vuTh}1i8rrKzkBRHUhgJ_z<}LaK z7tVoN+&}oqLlqhwlx;ui78D9)gDXx2PuLmW9#yg<5ov-3FghAACjl$iZT}i}?cy)L zCvOy6^o&oRa^BfHiIw;nDB78FjFK{ zM7_X{_^6vfQK(pdkz9IrRtu^x%*2M*r!gMIw~%fFCvXU_;!WbOwv?j*a_+AF(p_lc zaCn2mBj*`{dgEz-P z&jmj&+zA#l~sDq{tlbq%N@a0pmr8c+nXXjXBTpfN*s*9?@M@ndfcqD#H>Go&#YQapu$eR zojKZeua(OPJKaiL{>@@M?{CMA+O8Ll*xG%m9Ez@^eDeV*txt8UjPg%Ux6JCClARD5US z%YRkTtgZQ#XXA!?943f5l)0Odl;2_qk~ZM|of<)cJ&{3zS@)K+A?q-kREQUXc<5r< zr4Gt%p|oW;b;(c1*tz@5%E(;~h{^5hxQAUw3GixRClRGF(7rPih`0yNg8bXNSaRD% z+uhL~qZ1T{B>Ttti_-zj6>!G6Ord8Q9Di2t1xH2>-e@7Ya$$CJ5?<6}Il@I&c`z`b&QVOaPNKhy_MDo$yp8? zcSo_hCr7}kC)?VQc0tDQ6enjTaIpCsFjR>JB;?Yk-t6Pg6rrjj6h%%VMY50HK17< zNN%I#i?QhktVtTD&m0R{T}vJab~C|hY3vh;IHwc8quK1b(Y#+XT`KI*Ny}l(nO(dl zT>I%Rr${8wZB!kxH(Xq>{e9=ty0?d_Xog$Ymwfwho`n4HReXW&s}yif{m*gl5P zlocom@Svw>V5XOskb4il?2GgVCEls(DzbI!N`L}!+uY$$c7SMB@hj?*r5-!}V{xpI zmnud~oufMHsKGYV)-7aK`2aj`I!%Ml03k^<;TATPYPbT6QLf<%Z|BJ=Lws^M?>@aI z`dx~ZGrqW9ED`(RC1=|rJ>K<_?#MK+$|h)PBDXu-DW9#pxjQ}hc6a$ykPSDJG#Xrw z)ayCb>h>d61=ScFa|;K2SkEA>e5lHbMYq3FSVC$3^ZI zg&u5alsPO86n6g2SZ}=lp|e5yJOz_M%}AB)u(B%E_ElyoN6__%Ol`u=kPptM`CD3+ zulGfAtR?uAjvxE9*;9+@jvC?@Se@`eCq-8TAyM?;F6od8O=v^T6mwt4XOEUe7A_W9 z+%{0~I9$ZauAU$~VK|i5V%s|_?;YSLU{onPHL#ri%5j1%E$*XSw|;x4Dw}?}hOs2N z|7G9D_4e*o;Kf2aQQ99UdeI@2ltHTU9SKzyzEw{tDiqos5JvasE?b7Rv)t*3Iyowd z77L)r`5IjzQ+iNuHTsF;3Zj&hH~g*8-Oex6uJ4foI~^Y0-v4lq&x|Uv)FeP8&UU#= z)PCp{K?sqdVdY)v^$!wpv8-Spl9XFQc|V{Prl$6`d1?=~-E|Lh=Bp9xEY|^3Z0tan28CJWeWPxTaywaF>0fbjtT-=S1ncqs?-TfzCld zb(CP@7goL?w9Rr2H733`z5L)EmS+`gpW}+7I%wrV<>*T^T^~FsGjfy7p^%X2izXFF zXdNt8{w@w|L_tVDmX2NbqpeMOZGS4-l+PIq6e_ zc~duvO{R_TFiw-|cv3z8zDqwhFn-tY-D)#4Zb?3PVktFl_DK zMY$Ann)N#ly|~`uLV(dkj0vVO@Y*lTx~BciIy|u5gn^}Hx7QZEYh3>r)iqVi694=d zabUlX?!)A(1XVG<*LV_|TSo33&YzY2J$@N3AEk9K(nS`s`>}nvN+rnA@38N#=_^w84?>!LFH~!<5zMP^xc6fEt_zRLJLZbX z=dB0xMA+z%o!!}RajHJH0Fd)B03wCA zzpss~({!Mxt$G;q(jp+l*y)O;&S}%ZOO)5rRbyNg7%wyRY>lcKZ=$I;K4Wv~<3&zQ zUAZ)y<4G;>%%o1SY|Du?CRM>7Xk5cq?N#a(u8>phywNCNZ_*UiziYkIzU%OrA0)G- zVDa@s&gISMbTo6Av}7+gy*mD>>C)vZC^Q%yX4J0!_`|8G8EX3-hf$Xqb=R*JZ{Pi$ zmX)b|=Z$Nrg`0a7>TR~pX zOA@eZgUTBu?79x3!YZEa_m*$_4>CS>Tl;*<=Eu)fIxXDNpn13S#rlgDapAmC5g7(l z4o_fKc_btzOwM34bIzP`+ze0cxy7%EE_58`)pDBR7FchrH6G2029woWY|AKMH(JH( zBb6#`pV3*{j?6uaINrp22_?uwNasD$l9hA*;D&{4% z?0ZAz9R`fhN$~CMLM#xgQ|5+@B+`H5}E31%$oclQMm|7?-E{%PMByQ&)f8VS^sqY^<5wKDYBTXYu(qs+3{OGetK?$Fy7?h7y4|S;rpG(*r^G2w*sdc{pv?y ztaZ7t^%UD?N``rHud(MLL)5d^);B|t4jOmMXZP1_I(KHGEpQKBzxIZF4ULNz0g_it z)V=E5rv~w3yP=&t(?LlzYe50eI2iT5?oFHC0S<;Db(*H(=pU;abN$^1+kIaj)jbk- z%F2rIH;`^w9bC>FUM;ox{_$ey)R&tg8^arI#@!5Jc#QLcEF#T+T*TA3`I{OXTF?$_ z*u>I^id7MNERT6L(r9QZjB>jmuQ;crX!XBs0jJ=`)@!+j)4FVx={vKjf-hnH_VP5* zWfr^T8j-y%CEB=n-n#JsH}LC8N8MsUP{+#J%BDLz$8)m64omCt+2YGHs&cCC%uxg2 z?t_NHVtIA3aW)9E^bOws{1#k5_GdRAJZOkE;9q+%F!3QWyoN2@-$8xx1oajuWA*d! zFx+K8hxJPV^9`t|_M?|j=*1I1ZpBEn%%)vz+g@8kBl}wxqAb7}!_GODvl#^->DjrT z2pz7qoC#xMqPN9G+eT5R^DFl$`au)r8Q@$otriczpi2H4Th9I4pAyb4Lda3NIBqHp z_GK60#vUAOv{>Qoz>-5`v6*d2M}dt>v8o33q^ttY_`HMXL*Y3hiN*yx{;Y-#L( zHJ^Pls$h5>IBOfNUzU+5RZUlVvNMKXj(gzBWn2%N=*m|^H9)7g$8Ag)m>di&EV*l& z#|e6I-)hj4vC=UMhkUaqFxowo7iNh+L63svSNbr5^UWx_s{@$SS<+x|kD{zv>=4}H zJsaRL>84D(lt}E6Qr+ds1>cw4xlcIRKtHBS@UaaKfjid7qb4TBKu-dlZ*9Ff>BFMx zPz9j~b^6TI3AJvkq~ug7w0e=R*?BNC^DAp>U8OB7-Pq;oRJHXhvjdC`WffBbyn-*m z-Rn&kaSr+!^&^4^F?6c;QAyjG`R>frzMRoG2cOF!QgJL=hj_l~rOV_Q4^q3lF9|PN z^U9{zZMn1IB!j8>ErbV)U&;fY2KaqRn8Upk*al8ZYQ)!UO-F*pWhgRRz3gF=cn+K= z_j=yyamxld^+lO$XEq!+U>CaC=SdxH5q>|+zbA-PQ0^JU2Ke4pE4$Ytc+5x6U}|8y zYD4-Xjv(!%o7qT00Me9HQe~PAc$p(ZL|o zAVyM=){KoFDKZK2HRuoQyDzR$z&`!zxM9pYl51I*Q5RY0_1@4$ulbSW0-)}4Sm$85 z{>cmTf5{qat!aJCeJ?HoP7NZr-Y-VI?{fsLnSbKsoF7x??~ z*?l>1{s|PBB^oc1PvFLS+brt_FhbZD(tSF0w?|W}s~nqczJ>V(*LoH$wfi}aZ+c1c zb!7knm_0XDyu$Px$=NO;^Xl~ehIK=J*ky9eLwi*7*4NE_t9B{0YE^fsu}LqtpUYA< z!7wo07SS&N#pjCp!{2ryeZ5^kyjoBY_CcMpHFq)KBpNm346B3fJX)!&@?XDednPn> zV%M)^D5IZEzj4lH-c!_PW4~J#n{E&r0ukHAK51&#-8oB)%WSpNW^ID#5{3(sTn~c- z&ZPRfT4I~Bc8D@67iJVwRAwih*>Uv63b;K;)_Q~xmd@6$(cZ%|1FBYj)^NnNUz;+s zLekUns@JkmWWZ^Y#|aVA`;G%C%DzY=sR=u>KHaclVw#w2_2OWp=}@^cPniQSbJfL9AnbnMsLR`Cj$jFa)Ue@<$wh5Km1_Q`tuY_)xX-t$QgH zi{ALk)*fsUt{vNO0`?ZPJ00=HOt`(k)IMZc?9AW zAtAx_aIxef_;Fpy;nPXX^<-=cBl&d1w z|E!C=WMNcuW)8#kegjYfr_vxsaleU21b&|_U1!pAoTy$2AGGs#AM9pB9^cThgU$ZQ zfX%(;kmim}w=WI9;Ebd}wO8*(ptqJ7xrB_C1&23R5_fHTmzSmh9^7^u zh<9qEAQPM09r$Xlznl_MZZ-_5fi9?Tpr<-~mx5;vF*g$Q?O|i5Co8OH|DV7S2 zdxz`Ok^J`7-w{7{z7uShX_#r_+W9uIz}*x|xn2~FLOG6kr53t&)1ta4xwLi64&I%Y zMO?M)e2dl;FqY_o7(4CqZPGpVXMjZC@o)2DH?E^A&fj!QhC>*O>ITusdV@!-H-0E{&N z(W2VNL_1EkmJ|Z&qjk@?aCdVld$v&mn%RzyAjWNfG(y1q^&SU+INGx9b$o>WRyZGo*)yT$%Q?BS~HTf$R7WsV}Dlwsof*3})rpEM%i z7_3oq^Xo+flqYxgUrh1iGw3GIp-Qospapw}&-x+RIo;*Q>Am7>S8GPQkms^t(Hu_3&{R)en zY{@H!5Dl}Hegl(caq9UHuF)>o`Go|ArvpcQd%qYIDPa%Rn>Jo+l0Bs@;yv?LgwSMu zGGJp~!`x&lqNus8wKu{RJG{zWJrtYhAq|Gx9?&st^v7~WN|R;I$sq}L-*5rcVM?T@qDoN z)xoYm9fCdV*K~vuWe={T7Sqx;V;aO~NO{A8&a#Wui7E)o{SYF2$Yi$impyc=Pn#a^y?x8c@+RtV7Hsu;4g0|ar(hA^2CIPO=h_v(j{iy z_0fqYpe(-_JZff7Nx=W^^6aMG?EB9V?3M{5`zs=*d@@oSDj+cSE%~D28l(>+C#O3~Gpghb_x&hypR{Y4RXpOG0|>4NkrY2b2U_tIv}O12r*CIaN5 zKvCKv=*I8xTAUeVhZ*$r4j67}pazua#t-2V!|DBqj$!F4ZH zIRU9CQ&%$zhK+=rdrCsq)7I=sUG)^z(AF+!taGe^(d&6`!0c zz0gZ6jmpb>nRawY$L|lvo;dL22})&B~qd% zb0!7mqdtd6OduiH{`-3W!>SKd@>8nXBzC&@vC+^OnX!bymC|GogVjmO6RCvM2z z_fmtzGc$7w{Tzc!v&T~ueqV9rS;=lRRaL*jl2*2>X6$9?c|s49>h3woP`-s6=7E-% zIeERNoD<(a{X1gnuK27V@SOQA-@ku93jgoFdJ=(| zF;b*>_@sq|44uJQvTNtiop-LaqWkZLS0NMAErW;kLhp`%kXz;uZKB?oL#SBo-1`L* zOi{CbP*yU!%D4Q~Je6%gx%e;v{^vpB;p<)ePkEg1(a$tDL+#CgN~(;^zb3cOwer#^g-Mv7&~F z+dI1$^J~XFv}BJZfYuax%{m>*^ZKZ||2I#O$3G>f$J#X)QY1+{l{AG*NYbwBvovvi z;)r>u#Tt<`cD}sbsO_Z+Jy!z<;W=B<9s#+dD&kv>VDn#(?6``hb`a?C+-+V?7d(1c zusDV!O@lUQ@`{qZIcp;7?3qL&%F=R7Q&UC-3p@Tn!bhC|?eo9Q6`T4VBjS>Q-5=>} zF^i@)v^-|c+9Oagth_xhX>nRdf9yGD#XQ0PQ(3;xw0)N-B5kHWLduSaOlypG3Q@((vw8t^+KT=9b2R;WEFg z5)u=U(KtIBLDLb?7cj8PDl%+xVd2FK$EhZ|Lf2N~?ZP+v&dHG2sN=RPzb&*H+(yCv zPx+|;Yi)YO}91(^&UIcD;uYXlk;dFYX@~sqM2`@Al*4srXO3@Bcv4As^2@| zU@cp&sb^!e3HJ&q)k3Ro9Dc>X-g&q?Uu8dqgVD9S6<4ltMaPu~n#`LFWMKzcYLXxX zyj?g%e7(@2mN5J0j=?Z)QW4VI6AnNBsNS2t?y@_=Hul$fe8hZ%b%fnHvvpFbFW>EmfKwrx>4IzzC~;%EP$%79 zNtd;yL39%2v&~J&CtjkskTC4!rGC#OrHh2R%O^k}!;4xvG$7Z!HXYx}=X+ILbMs47 zqAA{I2M2k-t5YE|zgOO6WOcY)i^AW9q`$-c2!!xrKzZ~KKQ38Ane7f<@fyy`4O`*} zmgXq=>^lich7-_m6wt`W2N1$fTKb&ms=elc#E@>=3vPULa(t;-dFXn}Kk*Jq|(3LWE87J2pxVFw5;`}FvbeFQ zwu{|dy;~Q>1Ll`Am(N^hua=dLD7oQg;Ux|DS(CA{@YA?={*dO_XR6Kxa+)?#M;RY3s7dClD_1HR`Gv2~SwVz(^cjLZSSg2PRx zpQLblE@ZC#AhwaLlMH2V6Be-FI@j;fEzp&c9fR%DA{82`f9B+MNmWPN%j>3-#kk25 zjmzpB9Xe@un{%4u3bXy;JT|@mx%{K}hiy25Nz#%$>p{-eyLPMKY6o#M9t#QmC zFksY8b#-->K_x#)dk&)mVT(yp=D%#m-yqY4;~$bpH;=tPPe!k;iMAOZ;#oVDI#_HO zgiJ^ITu#?lHR;MKL*mo#_Ux8v-7F$-ru|vnsWOsi-tAc<_V!$QojEgsm5y2vZza0I zz{AtewZ8j{adv(CPB-7%Tm2%u&U~gB z2`pL)bqvbNHI};C*5g(NvV+%WMSa5LoL^@3<*Fywxb8{w6`OKsTQ^Z_)DfpG5YDDR_DLa~3z$)z5z) zuSMG!(3J!V4Lus_&9pY(sPc9bc8BbNBrKgeT8O)Ls}+!@lSEfk<|6O*TDKo;4+UYO-B%s)0k2qGqJv%8Y4KnO2=0|?SF8P{3PEqQXnav zx?5MAgz`0bCEfQSyWu7E3ejbbNJgebQ0kjvvjD8EtLkP%G{8)*DX6|<*mkPHPf0&b#Q8a$+)+XT61C)cOWZH zLa7Ask$R!)W9yn-g+Mx4hPzI7%IRu~$|ilR)n8$%dGhGE+(a1<6^2U&GuL8SG&Afx z-Z4B$`bUOBAR~}qH@&rY&f87xo=#^NGrjrYetBxs!6fup;iEHv3rS&BbJ%l!R_U*o z+X8D0jl@Q0?L0nQq4p%DdXEgrd@`3(g4!8y+UU4R#;pEreJ3&Zl=;lJ!x8Dy>en&f zE)r`uhxZq*yd$J)ChBjw;ELNZK&iH?FzLiBd`_;87L9qE6TA&xa!0 zn?@TI)FqS%&Rz?)POor*T zI8?06CbaXtRLjut&W=a-BXKP>^6ktwhwi}>5W2d<{UlE{=gxE&&2r;87lBuNTzWmk zCUJD@!e69fpUQko=?`%H!Ui@&&>SQ)=fie@(nMuCxYU*-cFReEz|GCgGyFUWWM|G% zOHY@c3VXtj6Q>lk5RQNLY<}fi0XpgH-zV_%q}LduvPeiwc8MZu93oTOsgHX1Mik1v7@RFtXo$98hSK=pS8?CfOQ+UQqok9lddL$3tU( zy@48?G?6Sh+V3keNooW~F0;@0u-Gj5x8YA0bTs?(ODd5zaf?I}Vp-cc&VH_MY;88(+iZhN*vRZK;RdQjzUwvElP z#i94|80xo*Q7Gb9{d{&a!(m=r>??BGjWM!(F7v@7l=mM)(KD2?BbIROmvr?=b5`m< zl(1vNbU1!^?l?}A&CsCS4vLi+!*HmrF(m`p*gku<6j*Vb;|sogsm*nL_H?tY^1VQ; zLb;~HrkizAO51_9y=tb}OBT7bk1_LW2}q;8P$uv4SAqMVca~Rg$)eC!)6LoV=CdBw z?))KZ*VB>rgF@RW7ZO1Z$0WhD1R{H@W~j8?#xK|4DkU-x)pI_*>}P*EY1pb>`KZ8k zM_Hu7Us1v^jWb&-lUmTt{k?6RZj_tOHHMKaM#3`i7fWNU^(LCTWL$Mn?!&B8}0 zFwJMq5CoiY5$_1&RP4O8+Ri;Qd3Zb?6Xz6vi|GJ>1k5vWHGlpfPk@;N`z zao+W+{<(26dl$=s>gw+{*r-d5HLS~jafyie;?=7kj5W$CmRMb@?QAK=9yh6CR7`hg z6PrWm0&173Ofe%LEDcAOwBWn8tNRX=d{*CG7c!p&zR|m2qMF;+60OsrxHvFZVO}lY z6`h4yIObE0(?<7{*Ivv}uBiqsU_I7wrDT2SqQ#9u6zON?shgk#!o2mvh8 zp3EH$oe$a`4FXv$Xg7}8?7S$Kvtf}xx31EBZD8c)=2j*+7#pcVC-LvBDc<4sYtkz> zQrw@7hXr`d{oz9qFyz*6pEKPS3n3oS-8QCkJ86_o>0 z_dCN14Q524LFwsv)Of6>mEv!S(qE3+*6(;OKkP(4w>;|Bz285Y{-jAEqc`p~I!lFV z$LMumt~L!S1C#bw-J5RE2iT-ypL|!NanGO_4#WswyK={(dk|rtG43J_50^PO*fY>JeUVAw?s5(LObg4#3PhW^*_E8^ z4>FsUeM69B3<|;YMR;c?HWc~x@3+^aPJOC)`+`)eh>4$HJZ0A{4eH4R>#~sZp5NKa z%vt4rOCcaS3KH=g%h=q7lCpB&OJfYG%sPDr7X>?-g%(>xZI@OvGJ4V;JR7s=ueF}- zEM8>CRj8>$9p1e0=uJ>IIqCTh?*v7#t`bCh`9dP2LS$-8!c+Gv@xNMfCScSrR+fS< zCGcP`xm+67;EjpQsymmX=DlWOFnz>WGTYUJ5$eqppeDg_S$-U)463>DyMD`wa)@&v z8dJ~PrthP+!`gqBY+GpKV<@gz>D1OjC%%BeUKb(D%pmJ_Buj!juORL?DZ6HZS)y3R za9lioZu023;{U%1D|k|-Y6u~SdCYuz4OSCxa;D~4kA1#*^V01gLpYC2`+rY}KW?-78d897g#MzkGQGR&Yy=n=n&(j|9$KEvec1 zzqotru&CFr(SMYUY*Aou0Ywln=oBewl$Mf~R_O)-X$AvOS`d&BkZz0H(; ze_fNq5i0!iLQ5{_R=wYYMLGpmgZpdsBQu|-JcwF2xuFp!=xWhrI9Vw5qksGvDKjJj z&@(4Kyg_AI763VjU(({0N)oPD3A?}6DzQ*h8sETBi8WRyNevdMeeI=X%H_svUSUW$ zb4w~?Ac>WcWZWu#$nqV%B^&mF(ro&jN)2c0*bB<#5lO}ImvsP3-pzsDmVNy0hYl?^rT%}5#$Ir?CBqMyj7*8__0`&dgA5KiF& zHhLR)rRCR%lahQ1nV>-T{w@6U$M0@V5mg?C)?^chsicv(Y$VgfY|I2d6#w>eipbi2 zy%u-6DJpD|dU)Z-bPGQ?UvhQIvvtZh(@o!AYS)#l*96rw(s+^Arn8|QK$5UizbvmfVy3t2VysRaJ7TRv6X>YM1}dRFw}^p!Z3-FA$M1MgFOr~h({ zqoiNu&9>TWZ|Z~!e-u_( z1yZ4%J@nsUfV!_ts%4)+hmm+Ou0D(T*qxsw1U_YMJRqi+v=QkPZ_&rtR694SUC_M48rA z2c;u6jXTTHC)nMFyE*0j9{kGj=#2~xfJF~`J}cd#`g8bzzj2D0A&VpTt+gkCE}Lzthk~0&n@q|@4E16aRzn367p|~K z@48i-9L!{~JI;N*XE3~<5=o+zfWmFCN;%4o-q_}0z09ta-bTTDb&{&e4a&X5Cf3rf z;?bIQK!=4WIg)d1hJ&Zoku13SHEZc<;llX#g zJP69Yc^vjex>`(0PhRk$&@6Nf@5|Opp(7GZhj<|)`uIp|ZIX!Jb?Ob&vzOI1OQ;Kh zHTK}}h=$_fG>khx!nwFR(Aov!-Kp-z0WINq2~yg{70Z$5WBP1tf3E(D2;R6p9-)4` z8GLSg)#O^8wej{3Qmv|I-l^^z`I5mNQpJmXDb8iL7S;3m6gOvO3*r;jCt60hO!`;| zuHOB6*O7D% z`diG$bUo3VcLeF|doWk`Hu`vbeJ_TGoQV3qfC8|ioL;6 z;4$2|@#5x0P^^HPI#j|qVvU(SX@k6c?Dopf()m|kX^+3GxV(6CM9%GeJ)XmzOe4Xh zfZlXAuF8>-U+bgWPG6A>@dxs=RNVH1s`A(+6<;gZ5o(PJido9jKYm0pM}n^PQPA#w zmU?ba07j&LzWd!^wN(rv7`}fO_j?mlQ7S`>-C4{FT`~!SGI<))%&KMc%S4od>1d}F zxnd9OU@?=?EYuOBuE81y#BkfZ%gu@jDP}pu@)9`b8w6FxU!+Qr#2KDXv0WsVQq{`EoYK(L%KkF~dZjTBW1lqj~rR`l< zZ&WLj{<~hUJW=S2lfB@pZW2ZQ08A|KG8WB*Zvc@dSCW?!T z&+T8)SFJinpq3zdWtBt2PhxQ^}GWC@|muX+SRYw9ME8TTF9! zV3#C$vA@)Qws~wUK5-%_SZTgWUzY?jc=^R%#I!<)m{ppR{^2l`Yw8Nminy%kQLcdu)gU z*+Qh@NGfZjRB2myc;D|0*5UWz>=>-W=NC z3?jI+(&Goa%ohibfjyUj)fz_rgD&~qp$eD6x-!=`EfQ*8E{`9x{BAP=98?^x0sPR4 zmNm^BO7?WEDJAum$q*Y1H9#kmjrbZyhS-_yGdR74PSnw}D0e@|BMbY;e{~W(3ap2d zyIjj|R+p13w1jt{10m`ixYR0bJ&Z2JJT47X{Is-qH@!)b*s0GR!`cR2 zl~cEDZw1D1bxNtE4sZPN!^;a*#o>^y=Q&O4yBm1&k(;zBT`{#ydbeUWzJ2e0WTL^= zc5FF0|I9MQC>;VLL65)#`54EEDMpVwR_9uXoUfA4MO~D4aDD}9=Hjfk$koZr-ps}F z_K)2~HsFIfM?fk0w;e=%W!kigZa1LWv{TE<$|y#8fOV~5uR4}6g94aPyyHnFbWgYI zmOp8aR?7;AU+l}uJKEn=aZi2*HmV1=y-%Ip>f5xq2i?_h2Q3UVu3tQsxZs(XZtG{> zYmsi>pG$JqOVK=fqAGy~d)s8iYFDN}i`%5Hv|mIlGB1Ig+d^3mg|fZ{MP&8c%Z|%= zp+zLlIUU&Xr15^bE5#*o_RG;sN-`}2sE)GAm@R4$@faDa=g+bu z3k?BV`BHOo)cvHSc?BxgSMG15xS*kYmWJN)^k*x$h)FJA-2jP^?hsYPu1j$_LYLif zapiO1z=SaN+Vv;uC|>2zlO>*<#C19URx~ceFfqkbFb;QZxC zh;l*^iA?PC<>3tI0zfu7==ewv#arTJhMjghadKY>bX{=9-Z?Sw?u^CjYVbf!g(b~s|AcB^#G=IRdVRbpGh?h}C zq1R=vU&SP7&)v54mb=nsiEjr45n%^ z551D}C?nL5RPkh14R!)v3~zr>7U!Q04Gjx>`3Cb9p=2S+Oud;YZt+j+{gL})@QX<> zcydduNG@G1c)ZR&_}Of88$-$(ou%UYe7+*sLVoLt>sHrFmP#@Eqss5Uc>1Gf<%U;2 znkIbd^pTI*Zr+Mw!@{KgGVkn+bXb^@G0^$^@YBT+#daM&M!K%4(T3ft1RFJx?EUrr zSHf>e5l-$N?yU5_z#h`dNcazoF~};%Rl4+oQDV^iV%wrW)jT0B_D6=9|7Eb`)5fKz z!A>Pq`L;6jMT1qFt7h^raFWIS?!6MOY&<+vlqML?adUg%?mjG`ng7|Dq*ZDhNX}#a zexU@7-A`j{{7yF6XEpdx6ou*0F-s1p)$(KD+dm8AWTER zfa?NpNbybQ%4COvA&$+Z25{-Jp6SYr&8f*m~5t4p-93&(iWJ)n2e$si`` z!b!t*U-mg7s=Eo(jH6eC2Qr8YFPuSze8W`nYBAB~93LLUya!+Qb=Cr-mWW>-IA_mN zHZ(WuSiafHQme|md|V*TBSWtc*99JdzNT;U@#fgUfX**FE3(@HhYl-rf!72>7>BD! z2ppV*=QGvRa?I5}K1V}dS!V-@N^Q5GufQMY_^Z$Jp^x2tR_LLx9~l2h31zG>5vil2(JoyySBbc-Go!Ts7~`ty(`!k-gSWYl+JgS7IvS~YZqmm$KT%-g-s8BMHf>~o_xM9zi&pt3d zEL*(|YaPMixz`!Nq?7!o#49P9lUXJOR8v5_+pSxQMtjxm@lKVG=@idJqQ(0h4;+#w zCMP+IY^E~88Zcm3JaFV73sHx_vC<STs-E-6%<@aA zOc=$AcHXz`;`rEyil?&Q>&_iW3iXYHS6Z zCp_OttSDp@paBkk@VopJ3A6l}07?`^ja#LzHvYNU{1lr7=-zZQ3RtSwnBQ0=^)2&RU4z-q2e)cu=vP|yKUUVXVc+=$2%p>lKS$gXjl zy^7x<)`l4S@ogAGMQIg}^2=858NSMU%N6}Gfly0Xl9N+N{RqqJEgtRd<2`?j7o&G_ z9jZM;CEu+@cK<-DV)Op;U)9{o*;bwtdv@5v)tbSQ>4TVgq@rw1)>(-L)?OKo3$^?9 zvqdi5l73LZIHuTn?{DArF4`LDP;kVUS~5RM4_V-?^nLoPT^OBQHaIs?f5B`N0{{WI zm`MH-O;s)R#F?Dk+uKtOTr0YZUWu(MU@BhdQEJyIXJG5H>irQuKWb7};n*M-9;#aS z+zQk%~E_lQTZWGMD=xj=pNEC~7jxf?+$MHKQ|T`811`6-*<<;yOE zF7_H8Tmo#hYYmJwpC9;xGXt?w@ZUO^^_1nWZ-O|<)jc`RN|IVqF^gnTdyvvHy!Yq> z$i@-3F^>jkn18LB=Q$kKI~`&LkB%5`TW9o!+Y}q`M#24z3=JA^cHei0kw&7;8UBD6k*(X_52mm?@mQ? zDRF11seOC%r{m&LWnJ&enjq-%()(}{3KPuY>cY%>vNOPrOLi6$Qi1%oLmQRrT!hW-l`kg;jqHv_1!|N zC#uQP?=q|TktKvq7NK(>Ntp!5O;xB_&Y=e-*Bda~E1e3%&+eY)g%<0?5(4ou$ zI!|eP_I>1;E_A@OpF@rAl<)Qmdr+1TP~LsGX^g2}2E=7mz5hy_hP!)Ze$HW59@EX6 zFKchsA)3%kTe-(EkIi6uzZ;*Y#z>NeP40|$_iJw-^NAsYJ4ICdci3UW$bslYrpe4Y z&PF$1XGr}x#ftf40vAW2x3XT$9F9rD9%xkN{T1QHavw8c<0l{7c>->ZOmw_3`GJqe zk5F(l^1H9&CQrW_e$^J8GDrmlqBDE5MxKooO#jG`_G3TDE1w;8JM|j)b7k}=e2QBf z9w3VsaZtJNHuWs@WX;~D>ZysZ zj^+~+D6#3^u^QSM;Tt1qM#><1Q(`t>9h9}h?Tn^)+%LB#C zy$6+7+T;6qA&^EtRE#L9fy-)HYW){<^^y~sLO@y6WV!5ie6ZD-aE!J7Py@w~J@{P; z_)Dx7Z4EowraqsF%_KQi)7=8K!b*I)c-?T^DW)akH#7RfP$j`5p z`W*;0KcN?JR0cN=BaN5b>L)3x-tX&^fyDHP6}vb7jj%LobJ86jH`2LC&BBAvz^ASR zP=JNFhCoKb*qhMt0Ss@Yyp@pvD`Y7+6@@>!?{8#1D9+sAjB&I*IkPt@=tc6RY)`uoMu6=DK@ldsCoQi}SVdVMfQ_jiZgY%<5J z2FsKn6w#6Ae?@=`Q&(HN)!bv2sZ80b;-P@gEy>_tV%JddA#me(+%~(ziT~|Zrg~nS zxqJ(K{6aF6efGmib170|-d;G=%3KCbW1&E=us(i8Sx3dP?h}~agQ%oLY@$m|{T&G(i)6%6&0pSXy zIt*GtuicZ6yS?s1J*b?iomZ{5Fscils!z@+_Xn@sRyr%=FPl#suA0q<(h}A{7ZD^V z#&Aie7Ak%^#_$lf{2{nCcZ=klLjPiMziNp)pU2wDm%-BA2K@jnmdlRr2WC}+X?fs? z%mK|!{RPLhj3US$Ep4jb(Qg+SE_j)UxVyxOCso)&LeB?JKF(jF2$-{8c{!?c;=UKf1jAmiTf3UgB|c1X1Pao zvF*D-gfVH3>S=*QobB=-*nf}TDnqHjm(Up`0qwK|%}eOP@sqZ;c8y6ru?!@5N@Beq zMQN4oai#gNZZnyaXGYdJBI-Er(erUMZr9y}zrqDG_EDgC5`EJBtIn6iV?Olk*|Ve( zLUObZB9!;RqDZyW#Fy9+-EkiUHNOrBL)|}m3GM-dZ=Wfb-o)qY^XUq6NWRXNP-rq4l&ARihZ2@O|!;e%5I1q z6@mX&x6<|=)aC>vj!CEN4TMLOz@HL2P+ROe#Xjj;)1@oY7H<=!MPs%2DWBhO9LQU|w zmy=vpjJf{F?-EVTXQ+QB4ACO<;VEHIbs>e0##j6UPHZ^>T1QAYAJsSHw6Pkh5WbH$Acy+4q(1; zSPxa>NSr2=-6j3~LE~!eABD1LvU&`thn(iUS*ge1a`gs_j1s zf_l?=)98hyx5R2S_r&(`_AI&~5x&I8fBjIW+&)WcRgdJf=;ye@@8|OB=L3%*A=O>m zx-t0=NAEVKTNK|RABj?EBw0EA%XO7E(Cc+8uUfrMGLQjz2-H_vkcwU=gu=&{k$@-% zo5m->(_vqiG2i(5>cI2|$@1WS9-FS8vZ?8Z9}(<%h-C-CSSTrp+6w_65;F}~582_q z-7-B2b7$bD;!mA}D~at&M$UO_!|mnq?zri+U9(#<1$gk1g54~7+(eDMU=QN#-$JkD z9(DGM8INZplLbf#lBBtl---L9ii$Qw@I8eiA}Sh>BJr0$rg zNhX0avxzb1Z|ae%30}x|pf>O}@Ipk8m&qrDqV)-=_{ILYmA`WP{LFVtE#eG%!LTc* zgTf0D3K8)-dU14SiU?j9&3}=}HVN9!iQi=TIAh*cetO}%aUU7|4yxeyCnIlpam054 zVN(4SxjL{e*MFrb-ljL^yb<>gkqX0hLk;{Mw&*ePiuJChs{BZCXTgVYxJ__i{G%e9 zNa5xRdovR)-9wFGy{9jQ{(0u$r58&tj^3R<0rD&xQpe*~r$F$`((!0p+&Sz0_;sSG zGu}>i5_AT64>g?W=>Lz&g4w+eulD-@o&S&@01RK~`olXhE)RcEwi>(9ePm@*FVMPl`jV5WgOy<=a_h?HU#anEgjtraM{;~$xn!7dm^ z^zT#nuMwZl`DBA!Mc#`Q>g%|Mli$2ea19GhI@Km7KWO3cUUsXG`{%9yX-n+DmUzW$ z=ZIbvIT}6?xkB<(f0%OZ5C0mWSGkudXpbRs`I#^3@xgEMdoX8Ah$bnSvqm*ky@ zVZ`Yt*lM8JaYaf`6wi4VSf3rnSeBSzbQ9SMgb9|i)!k0_N)r|G8NX-h_h;NFoDQz0 z5^Ns9R%OYRG!gTZp7c%`GV(|k&~1FJ+h#f5p^uD@!CYDAl>Nmw%%Ac{rQFk(*g&~w zm;T4`v?Jr8ch0P#SLE)?Ndfrp;fa4{jgX=bb)4$azSr(?#nhw zTiaJ#(MyX|dzwoogPKRni(d`T8T(_ekFVOCIK^OT!l;AIjGxFxgoZ9RW_}kxr0}L; zK3!Wwe8LfKr2?%K!>sqQECZH;%1!Q2QW>MTQ7C=c-*7$hs5NSG z%(_JS%!|;P8tjVj{8%G?%L)YDVa+JICrFG||)%bAHkNqTX=%%7`I2)M)y{fyL3%JpV zGK6Ij^9S`wm(lSS>28n@@!@*Cet!J^Uq1x=-?E}+<3C{iqbu%mq|A$Vqy}x z3_v^}2kLbEzok=ED>L8xLVeWvo%l1(LGc5loA`gW3KnvVy)>bi%a2TXefPe1HuLou z5|W7lwAiCEEQ_ikYraPE9K(OS#0FY)Af;(k`RUnUf$_j2oFy!~KROkwzXq5gpQc~n zlZxk}1=ON1?=LS(m4(G?;$yyT$!lspeM#cji8V0^MPx3#;It#>{U538PVctzRQ|KR znWD}4rfVQ^t}3xatvWVi3AUyT=YPTCqqxj7qsy{-O5#4r3f!oYPp0 z`9C5VIq|1w=Jc|CwQ7i=3=Lh9Jp*0NLiqOaenxWhn8am_V26*d7#Ntw;qbcR{6#TY zU2`Sw6>s{W>d{B?!${mU~Y^-7qRie3`@&gZCc=?2?P}efLzef-}--7W9oY-`%!Fx;+TC z_LyIvUE8v!r3pBw<=p@0VgBnjhHZ^ZiP}VNnNzt6D(-KJ$zGL;FKI90{=!tuifYl) zmd~`tSl-Ki^;#R|g73zEo(rchm+Iaxi>hCuwm^@%-Pq;oAf7ybW_*73q6*(PmEZ6B zKfjbTSG-&HY(e68=flmh79_PNmPV8KJSv2$PyEonQM2`Ovxry4#13pFNNf3zYv5FN ziItXa?a>)cwWlxpxjR7D}J}xKzOIqkx6eMgQFE7v_PL-PL=(Uead_&=6z#-)F zbXJ-0JMnUL4KTKW)QM@CCiE z1S)0SqIiPG!})7IhVZIdUyO@` zsQ5BBXWJQp2=?~Btd=uaObYNIq6>h=^RIiH(-3p@Df{LJR2kOpCfBe5dbIWy$bB#W zE9{w8EmDE&NjX|s{T6vjWWyX;+)DcGA2NdJ6gs>wTv5hWTkNio-**1f`)o+YMG7wI zEcGLUfJn9z2nBH68Q1O0)yui6BBHUCVk$IPQ9LNL{<_te)B8-LXo#${`Y>s*z9#;4 zV(Egx;41?!g*Il*kmd!VB~yl!KWoOw8o%ETCtr!}jZUu?ax?E#6j8c}5}tB+hm zZWOLidO)F3u7M}o5Zc;m-DGDF9Ip21Ma(E9+YS*pQr6VWjfLX?2gLn_P{Cii2;QwlX>*$xrcyB zZUlDScjBpmTHZYzaAv)R7@A;RwCM>EYGsP->AKTUeDd!xfQ7YqyFG@T8jP5)fj}Z} zn`Wt#oJEbhoY(On{Cd9eZ(g2`s9L0ay;nEwP$;E{^M^K)Q`4(p*td?RPZ-a37Q|X3 zF)u>#|LbQ~_D$M3)SZ#_*GPiM@1NMtcjHpyr?{jQU)F85(=hURJiU+IQ)YKV=LF_bM(LPY+uN)PxJ{S&%hEZ zF1#?7H_G3G->QFyBw?10A>c7TdPOI5tUp&<10#4+2_8!5qZ+@RVfVkNVBTE))wM&J z$wp>|yFZ_3m*Y?OmU!U#;iV0LX>07US?qbRE>+*qka~a>3l_|r<#$pCuM@2d9?`Xq zQhg7jqhsJ=R0~uA;&I4-#M1D#49VFD7IDwZGx7X1^5t0GaR`ux#rfH|Zeq%KWn1Y# z>tD%w9Bymymz{WVP;ps8HVU!#3n>~J8q@O0hCoAz3x3q#jU)) z&Z~dL6p2d#w6IdODjeuy5(|zl`E^??D*&;C&wt$ zuBxPsZanwD!rC(-aRK4X9MRiqx@ETa5ajr%TGRhBSI0YRmyH=D*CkH(%a&{WD0rWT zckDBmWLtP4m#&AKHHCfk~_g-zB}r%FkDy) zsn97v^D6(fmtCBtk)?9@ywoc0r2qTwSpVmHHVlk!8GS-HMA(mH*afj$2q0VhWQ5<* zvR@%h;bWp#QwU^+RB@Xp15oRZAioIB5$Befgg8cW@yj7tS2Hvox3jD>qW$64$i=PS@IQMP@qw=(A_n%$l2s6m17j1n^RN6LFejEPY3~2%+-jjYRcDSyW9dcYe=NIs{ z!AEE@n0!9=6HdJSPV3d}`E@@K0RdLvIqSN>UY)$aZNX$SS{a(WIg7Cv>SH~4)^IUL zyRe~bIaagK!6}@{bz$Cwfv#)%>oL}~N`u|k3Xs@-2q~xFlQJ_pt>`>>qxjI!F!}gs zk_dsyJ55Ev1w86@XjLWfb#724|7Whzz5NJ9=)xtEl|1sb#^RUo3^v(^(F!3W(k5k087u^9cMnvy`lC{4d*8{y0sK*z@Zp|;FSCmP%!k;hyUIX;k$su2t@IoGD~yTo$cstP zYy-9*uP3kB5uPiAIE*mucml~INMM%hhCYN*z*X$ve9I(%FAI=RCZ&R8trF`THjI!( z>-)93%5v8f4fb#b&CsVe+m1)dRaOJ8PJYgTcD4&U4~U{zgKYLUrrNT;WT`4agr(<8 zBePcVQ}5%0O+~*q0vqm;Y_8R^R}7ATj^1APr6!-uUk%m(Upym8>;k}YeJ`m!ZGZAS zUi-^&ez5mR^aSGTGtHmK9Q*u<-(e|T5BfA9cvFSpd5Q0oL#4Y1+17cGrB|iz*7mEI z*Cba5A_8U6f@@yL=IFd7NmdhcFkrIKwQUrkd}cfhQVZS+A3d(*$kocJ5RTg@hpZME zJFjf7D4}0x0g6qL&Cs5mWsp-BtS&2i(N40W*@TXn1wII$pja{7A?Izo!^#?|ot@pm zla*@*I#~*o1eAPt)HVfa{p{EW2Ec+wsVl6~fYGv6+HdvY< zj$}UfhL;keP(^R=-_!*tSE-Lgg^YkqP+Z7$TcKC*#AZi0hDR4keegov`3U<--g#Bm z*UBd7YEtgVm$7GoV*rer9%$$@GcdflS({Pud^NZK+{Lnai#(|D7Pq>QI9Oz1V_67g zR3Yd`LgaUd?jF>}Z48=skXj78mfLOahFYdhlo3t{+ey{4oFWYl2 zEtDoD)6U@D1>KcO5v$&?LUQSP^ubz` z3YY26+ij(75v(yc@kwqsnA}uUfr0&k`nf$>BVVpgf+A+Na791!(fR}lUX!0CSBKrK zX;^=KRN8G!$WU`$_8Gcn|kGcerQU++o9LYXLFauApp(LRx~ z29V}?=~ic`@@ zHc~lE@~RtUf(xM{wZ3V>;pevojdR{qRSri}%|kqxz*i6b=v=?OIf}mhoU6^=&=pCk zNAwKUiJjYm4i*$D*yFi(w-CU#8uE!ku@{blFoHP> z7{M%%!78H3n*TtZae`MX;yXHS7Do*z9 z*i;`V2gA)V=(wm%gKD~u=oBr-dN*Xq`AieEM22sU90GKhhG3%?E)eu?H4}3iZ1|d) z$5G%VWyx>NQ+qU>IUf}b8on=eK!2FkP;xoka`Zve{R5mP4A)m4L5z^=(3g6<*Qs!O z%N~ZKiTsdk z6gQdkWYe#f<2ZvV69e2aj0U9J0z#O0?)Ig>BP?t{p$a0N*aY?-#iM05mw_tMw#BUo zhViPE-210h;Z56EOcH=)AikZu|!HB6@P`z{{iVv_H+C3(M&4fm{rP$lRQP z%3w&&AwmwsdHlll`@@i%j<~zbL}^6NS#OcoO36VkfX0njx63L|Jk2G@fl^-wJL$pi*T52$QKa z*#HGbHFD>6Q)IbUayaFn^_DebOU>ejNSXC? z0Mcf-IgG#@5dQ9iL?Na`(K7~zpvF5ujvM~#A*8^DPPre)Xqo3nxcs_2MBJV)Cw>44 zZ_b@bA!0LHqy%##-G2;v8`vMoAt==FCnAx3)ziE#T>Q&D7C7g~Cg`yx~$J zUQWwi(jpdO;p+MMyl{RP_7X)f`?YVVz2p-;q0u?y4&uFYr>pD|S(!=+SDcwPxG)?& z=V^VR)&o_tmvk5$BytfcDJctREH_^rOo~2{zj4NpX`6z}fs9?ZfJXQ5#AXqC(&xXr ze->ryt0j13n`yNizmyId1KH&M7&@=!_$ch@8O^uUFcvmec?aM``z9}_lpTI%BA`es zx3g!~)?iWWR{P?`TYk-c;K{Y%_y)#wv^rClz{rtK>sY^gEN6Fn0dT5WC)ypJv zV0&~30ahF)m69c^U3qEg4p8eDZ=z6am;U-If!Bvn?XXdcVhHTR2T0T&%vcbFXGJ)< zzp|#;k9{`s_e^vlWEZNo#s3Qx{~iD+UpuWl29f7m>B1icqreV@gzm)M(W_bUGnjs` z(6`zm^%?p2_~Z}c_&ge(#0m+fRL63>szqty5;*8M z7BY9j3daP){e5OH89$tfKcMCV&vJ=;E09v#tKGhImsX^s3XCXo-L-cPZK45j8(MLi z@1h0bsDh*95et)c?loMA=nt%yfzlXyqP?n7G4@>79_oVGR3E)0_C*{*bJNLZ=} zEBpg}3|%JZOo5ke*rAVGm|CO7u@z|35=_2KB$BA_S;8)cWSACPj6A5)UrmE-;b%=@ zApTc$zi=%VVz#iue4)t*HV>KQ*A7vJOfH+#-G|hk=_c{+UCv`pkae-WFe(q`zFMGG zv1+>Q6A9h2bZCg=;G9#1qY2%-Vh11VEBybnQ(OYDe@e2;_|6)^<5-bw` zBlMkD9;Y@<6wN?7F3pHDzf<(Av`BaFq`yU{C(_0~pO5Qs|VV4V%E#YPQ)xaPahCoB4+cfP~(zKqAuP~`?na2y0T$(@BG3Qxq<1lbo`l*D4qb;KhIYLO@rc#}6 zhHD@>!hc-4M7+Hk^bx_M@@*hHv?1_B)I)z+28U8KRVB|8M?Q)}#ly8S5phf&!o|6@ zI8o0#oCXMq>tWSycV#OSbZ#nZ94zS!-kiDGl6G{^!hYbm{uFTiCw@44{QeRJbjo;S zkOU+W#{)U8;^{*dFwAa`7`*Qt1TnAdajKGgNVSRnOD;1nTmz!x(w7x!Hz^O4-4+tD&Gd zB14Ab)(JW70!L62JCIlBijbh74z=9z8?%5jhUQ2D;8A^zX)WMX--twpH%izmw?Yr9 zM-?Go9|>vN;($4Z<9ktQ(ULRJ?nme8dt=j%y`eNq6Iwtr@xx^ z?CL4eJ`Zr#)S!eA`N52IZ-%y_L1RE4aAel5L%@4v?JnkJnHMjR5Gp+3iP<)fudX=x zmKwdWE(Is<8>@{zBB@sgRFJD*R1}3P?lX;v*Hl*Xau`aXm6$!fKh1e(s=}qf~*?=dXS9-kY4Vy>6XGB^#^H+p+fWm zkOU0yZ1O9-oyFSyO$4LL9WD>2SJWHPP4QzIc8qK_t}dc?YYz_zNnnlPF#{Nt-(}+U z#KcolsyMOw2P7J8TE%KWnfZwWHd0UoP_^#Ic$Wq;cCCXLV65Jq(jD;~z#AW!9lIM; z?lvh2+n+KkLc9Le47Bu0Xp~bshQ)i@wk3W7{PmhIL31iz;8q2u)9*}h(?|MXD$~LD z_rK-TDLb>JMt;&E5S8B_wP?)nKjr>@us_BXfG0KSxVI1S?38lPgM%Ue;uL; z`A1;hJv`i9mffueo*Q!Gqd3z5yppK!flA0 zz-a8`5tpK(`_$SK?8fFzgQLfrGjdiJzovbsry&!;!^LgJ1TUIGgYa>a5`FII52CCn z!&tNz)_EdV>{;@<=_iJia+3@#;=|kKq48A*M9ryo8E(iWbU?S(%9nZuH2OYky}>UFqgK!8E6G@ zheX)PDi2Y@li+Ye5L^y6wF)i>6F3Lej~!E^3_}h6JS}Si8e|Hj+hukaV&)g2)aH<@ z<7z4-mUbIh4P{!EKf`Z{(}m2P(MK%uj-?dkQ_L1lrWN+<7!j;$8>8{*i#6lu;2bb zj%p%lB*^_e8gt~%gZmQ6>P84re<;`r7@m#u5O~Tntqylwi?$8$iHOnw?tBR)aUMPT zkeHN2D+8(DJqA#@ENV7R1%}D^7wW6Phc@NMA+Y^eQNwzVL$J`UlTLf;e2nbL{T@E5 z$Jdi;?FD8%H%JpAq~p8kn@^z}h$^-eW%r2#to;yyupuC4$PJM&>=8CxNKnU^$YVeniCa=*;yAyH%gCrx5uFy0c!cqav2@Ys2y=1PGAiS?e*)T`IuJD=V7d;bDN1X1v|x>ArTmpSOb*1bVQD( zIt^k+72pD}BPfhoI>K3Q#AP4%IaeVD5AXH^wXxXWnSNRKzKk_Ww$x70eQTd&1TEBD+=OtLL?~o1QEC)F$|ZUnen9 zKbd<-L|4+0ZCJL71 zxmVr={9=)@s`rQFbH`_tMS0EwY#_?hpSyjDy0J&0EdM9-j{Co;j8h8hzetKWdfY!A zeEJ1!?Bt(&+cgb2ms;m{{08hDmz^u`u;}*BA0vM zLl@;5<%@e!GgD+hs?{ev>=VONuDW{ri<4G#x_@@?9|ABD2s`=T>B)gjo`IJs9oKR{ zmXmr}z$(T!4&1>!8#_H;Lnr3X3P>n!0a!rrc)gStgB^Ak_6Pc8Hc%=e;=^M!=f_y& zUJ~6oJp%t5W#%^87X3IrB5w3j2;ZZll&_Ydw3}6IE~zg(<(G^YZv>%kEaErK#F-7* z(6Z>@A#4zDWd8yA*X!0K{r_zU6eEZ3Cit+nV5ZX`IKM# zTCe{uH1toB0z#y{_)c8my;FuKw7%A0a?=K~PNz)|qoM&I`Hds}FF*#M7CqzeSQ^^z zLx*zBTOUxt8=U%9v3P^%_9?+J(Hc4gJTh4z*yHi9nW8oo@p1<4Xs1vmYNmOYhVCck zexN@sUOBHL#5L)Up8LWiaR3|p9ouDwXh_A6;)zQqR1eBOO*9_8NAzfo;nK((EJAftY| zkf$Q&mfLTt>Nngr&_W)Xy%QB2mWPeB%u8yM#GIFA4x3*LCJ&WW)SbUa1BYJL%}Tlz9tQ5 z?t8MHGp*}3yxPfLqEHIuc$Mc+hOS-Z2bX2DvT^~Io8kWoylu>SZncbO-ML5IglLqM zn3C7dj2xq$w^3g2S&l=|3D~ww-XE7~a6ts05mc|Hu-}T-lCjIM34*UxuJsk|@ z?a8?VBQG4H-4}x0qKXO;PYphJo)vA-JW5G;c*uWsWy&O?*8aAKih9I{>k_&}dH4Ra zus~mxsJU3ir?~xM>g_q#X9W(^mq5oVvj-$EcEkfuQKazwtt%>bzX?}$*Ke-4iOHXe zoKvCmzcJvzavmD?mfez%C>!PkXGA(v8o%{k^6|M44kXWFZyjQiD?H~IhPDOpFXF`s6xYrQ?EC>f3;Sjxpney;Tg3aUR(GdhC+445>Gi9Z++ywSrGr!N$`K}Mpl;D zaQr#+ykLi0aqeqsH;>b#!ZZAJ@7~Gj*wfP-Dc}APuY-7X<>*Sw4@xFuPdSS4FL2{o zW+&6`=G6`4pUk^($-MP>-hYx}n|rvD2U+FJ6O6b!QOtU;@_)i(L+5#>M~|mR{};Iz z47KVata#P=2IBVXU_yk1Ec%uaxM!+eUaF@wyr)5Xh&S&uv_|w9eQZzsH`%xOJ6Cc` zbo9vS(lbIudx7BACW>vl^q-_E>H0M9|ZP)UB&%&IxwC>9IoLPuYQ~Rq5B_|WE!|6 zol|;|=->q%mKU7VkMWi#TXX}wxw;V|^3l@tKQ%-zq3LMu+^&4~_LOmtkDK4>&uKT< zxv;)RHbQuhz5f#ZsC@$qo%gd_)xAQilgD+3(k(N1{+f7^{{cAepI7d*6tn-l!h8IG zdXrWE%K)IFZ~gYKFKGsh66fSSIblDEKi_IM8cIvJbXeNr8jU_`*U`uN9s|3p!3xt1 ziP+ojuGlqpxev^Kr+CYk@i-Q<^VEy=(g6y4>2fwi9o#{pR{O87x+~DpF_>;^U}n>F z!X5)l%uzv&iqOWs9sf2X>aAEnXRuHhxq=?ECN>jr4t3|LKvrIS0uOJi6mps%gqSre z*%_SN@%f1;@vTpgNbW=MjUk5DRstdvAd8bc+j8X-k{GC(_nd9b;k@}DSFdKiJ$+$z zuf#eyd2!%NrmFWPWDCJ<896&EIap;8mv~2v@vcU^4>4l~B*>4wjDt*kuM1T2Q|Stz zP->!%)vA^Jpa^HabqfzKBANqQDem9NTDAY|g+}p|3LQQD{L;ijqeMeF=M$cGl<`^0 zgk*y9Rjh}SoT%yt55Oy*m$x&$CG^3lU_Rzz%@jsf4_^hO9YFYN;H!*tB%IG|Bp`yQ zH45^&=mBX6j&DD+7#&B{#gk`~jSrF515b~w4*&Jh?&wWUWft?!ljT^{QK zB*HP8ir~1meq2V7TutGuLZS~#UksF^gbpF>%Vqxa5#e$PgVt7_T|0Ga1V=L&muaU6 z2LMlsAMb|aU-8|`xf70f6m;Bhq991j0enGPsad5nu4mbs0ZBQ8#MVD*AA0^RX%bMu zt!eTzj$+F z2tq+G7o}Nnx)E6@<%+c#OPsZcRhTa1$bBwZXMGft)Kr}EC{rnykUF(WIR`MU3Pz9r ztc=-7m!cOE7{JlhYF07>L;v&Z(W@LHu+tqnB`^t7CWMX06x-! zJ_gcv^ z0HO}Sin+j?OPSj&d5P|g(yg1F5Z5qheFJav0KjX$FY7wujCYJTRsg3PyVllfbs5Iw z=Q|?5RUy;9kAc^1_fDb@i1R zA|Q&k*^TksZyzeeAUw-N$c09D0A?BmQr2sl!N6ini)F3Kf<*3k_s3t5#9`2K-m5zb zfTzg-mwz@ZWxYRU?Ly4_L8mjpP*dIIF3nfF#yN@>xgn@*l#KC2Bl9 zXgkmIG>E=ZATr$npY(2ol*wSZL+K{)pLW&)h5RySX?H2Ag&jAXbb}Dx@Db1#abv67 z&JTZOw1H@!Kb1fe87TzjAcHxwAotIQo-8_rQosuQ^*LJ;ch)h-+E6K1?jhpZ2i`go zsRuBsab0(N0Zb#4g@CJ2hO}wh_KGcespQ5Q77cQhdel#!sss6<3sgV9VrKlKkieeq zo{@5tErHB?m|*&xDl(G910Ltc^>-pdt+DvmfJ3Df`l5z7y#pe#iF@gr66ix3N@e|)scK*1p8=d`56$kNd>0L3s7&J!s!IMp9(~H z7;l&_tR%dp*sK#q$q&4o-oP^Deu9+yCU9ROvM{JHWb|u$b+BT=acy_sZdC5z&poka zpf@FxYCEFw4l3pcE8znex zy0JqToV>C)z~NMfzD+jK9~CJuX^qS}TE#{f>5bV#Px^|rFiFuu+ z=t@xVZ0!n4J|~)IV`QDtry(5USro``EhY>1xJ|408Y)9AJA>ZeN#lBTHSy(i2-<5P z8JrNazE`(3Y8MCSL@lT(u9izC#qg>gxyuYgh4<=!^VA`(Ajs8gR6!4gVn#}BkeCR? zP-P4kYfq#HY(G;(LE}g5x`H1gWVi2P3WUk7_-8fdI|;Xduy@mou&bQsi@zD91b0J? zk_Pp9NWod%stIS!e2^Upb+?0QIZTFC*{g7z(z}%cCLfM`;+u>GDo`~v7Lv9PJ$GwsYv-2aZX!_Jy2AMQYb*!*L=YE_?$g5BiIca0A6Rf_ zZ!M}akPkR6j;|I{5!}yEsf~*o@M0%jXmFC&Z4?~lSw!^Gh~m)vV_v}0;E3gwL^Ra@ zcCUVDUK~9R%S}&?lB8Dgj#en`euY6c1dl`xxDSx5$q}-BwsyBGf&NPE(Dj1Z73tt5 zeS{{pUEb>%RqVMPxwou(vMUP94|gcH#Ch6BdwH#t(|q7Isws-EXr?9Fv5VjX?R0yT zigne;obnRuW<56Jni-b)-lDj?Qz!A-`^$}!JhcU)bb>+4lmjBW_Q+OiZqXRWjc(vz z=6vEvop!ULCnfX^`ltTT0jj6 z8Y147s`7Znp^)-k0K`}_gnv|{G|hFHloWl}Wmng1(4b<5CmOJoo{?-Wb<$Dj;19s) zB&6X`Z?~I!{=(PazXu)XEP`)UKYD2SlVz+PsX>7$KdfYC>=n={H%b5wJR|i_T5!-( z?2hhoy@=J>b&2cKku9izRh-K#R#LuEK4o3ruAbZv_@*+|_;@Sw_h2+=jJc{;Iru{H z9qd$F3RJs+PlvMR&a`|?b#}l4#_7dCMvjZNO3QRJad&;Eu$KlXf@V{+T?(e%hH`0~ z4YY-}hb3Z%OYU#^8aKsp<-(B)_bkdvHyyOX6O$O3$MJzkKi`#G$?8HgiF_QOwu z7Oend3MlDMd1h^PUx>-cQPw*^7|*+1{kf&5XF zxOjsk4R}PT{gPzy_9)Kw+;puztEvWtv|HRz^8ryuX*pG;whkRI0J+B5Va;f{Wv(d- zy{U2!-D=lDnlB%-NFF7mdcuP zhNc<rv@iuiPAaO9hVU23B?=Qi z0s;c1H0_F^<#4XtNfJ{;OM>*^%yY#L+Rg>J2SsZ-5mNLd4RJcwhN)Ft9ELNBz~s3e zqKA>L7!;Qaw_n;)g6_LQdPiFkXvZ6vWvC*Q=y7hYY%Y$;Fkr=IOvQe4F;YM`!v|`; zK=--XVdTPNU2FtvBNgy8P4|kecARi8b@xC`3+yxjkJnuva-Tea2nxrMol)||&~#!C zrw)%(o(pI&l~Of%&U&8Lz{N4uEpQoqT;Bb-UlWL^A05rU;XzhPG>pHNE53uTW}o`? z0pc6#QID=~f*ta8+zaDx1xx4@1|thSvo+%8R=S15SiJ! zll-GG>0QkUyU)tHpj>T@T`ln;VY~rdC9u1dY27}`YrRQ%^0ek6OmQ3=Uu6BQVD|69 z_&-2mXQyR)<{}Kcr(U1-U;X&r!le7pi8>_VMyZS;HvUQR%_z=;?~^knoj zz{Gg{6k_8ybuv}5L6ALOH%@@BO_dU#PEPMdR-)Nbg;sr3MLn5Z`MGU5Zvj{=f@1;B3!s!e0+4Y z22I*Q51&w<$?LSte{$;{3>0|)2qu3W`xMYhs)cQ58u2+*6Z6^ ziSOW;P;)&we77=Z``vG6iHaKl-F&~;%}rMm=+hOu)xV{-drE?w1R#8a+s`*Zk<}LW zCGHjoR1*?ziZ{LBRJf(r4>W4fb^IVBWl{c+_-&~P=1_>vlTU(;JCRzn55j;+^gM^j zCrum{V)}>tBorAd=QVAgxe?j#+m~3^Ur@{TCMG6-p4UMPfj##%I$HYqX~F%khvN_6 znFzftr}-sSTF$5;W-C?Xp~!vHLBrb$8OhN03zWQBFhMJvs9+r1$r3p;E|J-n(>F@6 zX?%--;6!)6DEt22f(01va0~MrQ%wNNm7gM+GItOV$DJN(+Q+jvhE14>+{4|cgUlAp z0Ra?re@nrYCA*5xz~{m%U!gsh1K{D^bAQ$*uF}ZJ0|$ihf@Ugc1}Ov`{a}wX@@#h& zF}^^H{>SoTfSA{OWbz+Js2ni2b^_9v?kG45LOujc;x&Y1GWoieR*LORE)B8oo?CvL z_q>)Z8el+cDZH_s9(mD6{t3)y#}UV+0gEau|Fv&A2-Yo8C%lO^q*EtDK0UE1lrbiC zm(LZOtemB`@cA&QHEM3sn4K8*)D^o#Mm?n)uusIV-MWYjgX-!B!L9TtAK$>f?La|k z+o+s&p{SWtJIgM+_jf8*i^`=RTmIp3I+_{8sRTMs(x3)rE3Dv|VF8g+)UR*TP&ngp z0ShBz#P{(4Iy%0YTadAn`0Zxo@S(eBDy-#u?NC++=0LOPAFs@4#$|>Z&%^fg^@YhP zy{kZIh6Fo~H*eo8l0F-J;S(s^0c}9pMj?b-{pJtK>M~5D1$J?xj&5&W6h^sio;h5-niXD9fZIoW{hu&O`;1IJqrAvLAvU3TkM4yy5Oo*v1^Uq)^ zGN+rv$R~lOuPn>ILG1Cm8JMx+Kz@`daM_OMI-(c9J{mf9*KV zoO9i~*W-oNxw7vJ3HUby5kcQsFTR(Hvw7z+PRm)7t891g2E76Q4c-a`(xQh%f|gxs zMT;uIlE$$i&GcUFbB9Z^0>kSMda{&5zwiE$Bka{&!SUjAshz_EbXy!(VGOse1c6sz zcY&^yI+47qEB)lPo-!vDrCj-eZLMWZ9;J`z$@pnefb!f~oDB50S}L{Os0+F-b@g1h zFG!3!xK2@Yy+8wX+xyHJFkOG9E2Q0b+_se7+t%%UAnxhm>3N%(SqgB5kfyAJ7mGDo z=-t@}*P%rX6`3I-D8)*VlPCnqS76^MGFb?ya8$KrJ*2lNIy*aiMvlEtlc5j-Fwty4 zj=@n>UB%vM`;Q9juXDV12Rn;l2pjYpm-xW0)ZCnGgq~*SXxeTw?Dp_LFYo*Pl0+RL7dxk2== zTHnD!+su2FWiRzX$pr`(CXzd<9SJ2d0?dm-D#2;~sJ>ibR_(99SEMt$!}ud-43L;B zIWaP7T||>?UeNoSJD=Umy79*Hs*lx1F0FqYznX_HaRRqx2~dNKhKDT3Ct+j%^CpYN zd>*G6RVokl{><-?wi@^5dJT}+4EHF(lSc?{sa?StAR)p7lLQI1@=x?sVV~BslUMJ* zx`(M!fqg2iF$gB>HaVAutb{km4Ye z(jOS1_T>b{>`I5+{7&GU8iBxcwf|faejg&{xC+A8@z(@(g%^|R5vLXL={t4+W+Zgb zjlb6x%2x!XDsG1~(h3O)5mF0^0N^J$x%u8b3SP^2fN$TTWJ?CQ*@a2SyVJkV()9}@ zNr<4ramV{YhW6?mkR6^974<|M9+PPB85sl58&M{Qv1%B+u;~4D{p`uK2!RC+tx_At z)*@MOx-ME&+4d`YZF#{(;IVmsAT@3Z%k$;y8toJ@5F?kvIkxD2-zW2asbxT_xl(jvnIm}laP)jr>Liz&1;LWXLZXL1IIS{utNSwE`?H( zvNSXpNOD1f`$jJ#Ee_YddtAU=grtKSD^R>auTNOgl`y(wTZR;$5MTlgO(0eBhhI-Z z-W#(45y;XX(T%2s z^&@*dS<%AEI}l$$dv|QGdeXS5$)VWza_0dyaO<=`)7Xm5#g-u6c4)Ibox_g$1SjO- zDL+s6ZKVZ9$pcToe_-;ULR`h_j_HM=`R?3Yo$$TjoZU0O|K1Iw7)e6{Ot$BFvZsF5 zP{ij!jpxY9l)tG$Nv>Lf1VC?tldJc4O*e^z@;UGRTpHb7R)AaNZ|n^`yjhR5IOW_v zZH2N6vfP>%nFrF$klR2hvl*1!cl!L4-p%1WaIj3zL;f=m<9)PN_$y1xp5g(ls__dF1ve~__e+2byQFXW_l zsVPGG3Qgp}Jl@<)P?wE}Dn(DIq5nmx{c@)Ldm8NYZ}_iQ8(5{+bcdp*-4>t>PB zz;b8%HD>&hw*8|gg)ba&I79Yw#F)xZ^!^32tn)WmH91~%T&?2<9qC&yLfJ0Ge(05l zV&!uybO(@$I}4g(;wy7di18Jqm%b5<5_|}>%c`ZNC{BwUoDgd-q_j3DeYDJgB;X(u zjmcfmPg{C}Y_?~w*0<-^gt)mw8IpHh;eD9{*VX<3=!a)dL(V_AFTYc_B5Jyb>L0T)$iN0oR^6QIEDLP-$q#!l-#w!lr3(ld$ zk$6Hn?7WSJ0%9bjq@=o*S`CCk@2JTW&fgWiZGN4<$a z4de+zT2>_Ul^l^9M+mK{b`sE)XYuxAB8eQdjgVl1DOeze)HbVzI7wK@GhgDmB}I`^ zDF7dQdxD40ZN0f%%+W~!B(=9SGLq-PmqpzMv0JE|R+E&WnDOa$=xzDm!K73I+rLTW zQ#_XB_LK8?YGUBtIZAKG2Lv-DL=8Ys$uHCoK`xxeJ5P^v@GK$uq&1uHpeayp-*;3f zruv&02d2Qo1Ka|u*D6@#a>T{0vw0Y9X5Gyl_tz%I^KYu zAV(IGbmb*p;#1}dz@cl6I81^dMB>VdC6Xwah}VEMLf{ogf5!0-mxt3~PNO)oQ4PLC zeE*gWb9WdP-<|m55mw>ob402I*qy;H(8ineCY}V5<+qxDKSKLP5ivsW(sjyK#8 zQ6K&2-vB57&PDmxhp&Q6jXwx)*nZsR*FA>+K$jc;=i{lCT-HzU#w*{Z#tk@Zh3ka^ zv4hE+^iWq4EaYQ?5$-OvO8N3% zR5|?OfPoi6z1$j_S$YNXI>XUDChf-(!2d>Q8#xO-JgM1py7XU6IplO7n>)KnV;het zKmX#&9O(v;)8Ro`3s7171(Z8E?uy4*N&5=KYnw%^B+VW0w7p`IyZt(&bpc(#+^T_?{XTi?HT7#Amj2S z+xHvqpBfent|3yo|1PF;BYyz;7y-Mj*EvUU#Nh*#kdTyqvc|{;0-|fWKz4$g6xB|E zJ!Z)%{eH}nv-wXfIep4~w(%MMp3}i;6tjIlw8Jj_f;61R5zSJ!;Kq1j-j$ zseD!Y7tS&N-z+U5G$=0YrD+=eA;_iU1x42CV2M7QI8m`S7S7V(`s#KUKQ8viZ@c85 ztn&WRGyMe@`&*Zk1A-N`_u(6_%hNz=_%e7u{%L^(i0pQVIImXY=bsH}tc>-Dt94CH zs~xMnAZPHju%Br8;9%3OXh0TnSw9+FDb!-!*tdZfd8FSG@% z)(JhcBRtx7EO|bjAHETAD;-fak6+^brOrNnC4A{Gj_2`9+W&{%L|w_#OZt2q_drfO>2N3I>pK71xN91D6fMPtQ zLmVW1_U!6n)obVXtk{z}tvY~x%dkHB`gjF2pi_?efNO;+to;|Ce9*lhq43qe>28rH zbqrU-5xN^$5G(vt@u82Tb6R{y-;NMH0=-ol+(~oQArA9-yonj^!oBU62j<^40GCI*`)k=d* z`XY)YiqAp%32&PMxoB6z2|niJzfNG+(#~>t(;XC_mBPqCKlgib?D1!B8@eu|`Iw8@ zE>E4)J6{=H6=nI5V5EuQOrQQ6=$LAiH8e(6;7DGzqSUrG+-|38sZZZP+Cb*ar+_FHMn zhq4Q8tx;XkjDVxCoziI=17z-yLzMsNOF7N(b=;fWnfAZ+zj4zo?fcGNCshofb$-?1 zYj&5N`H-#uUw_>HG05OI*Z$VH9lsm=fBz=!IXXPHxQ(cy*>*Q&BftRp1O)WvRP55C zpuU*$X2%mST*ZL3oQlaZLa#3x>h)+v}o%|Iu6qAueI6t zEs{sSihfSK)F4qNVKB#+hnYdDzkA zSJBQw=-vI>wA_z_KZM34z2)H<{OaIa=vE+E#?HP}==4-rNcvB5RAoc>L&?j^Mst}= zOR?uJlu8Wnl+=wa-MB$ZWL)2-RcC#0;K~*yJ7~uBqTw3hRo;)7qI077psFZRmeXTTWHF3C}fP+*w)$;mOZrIJX+DjdP_0MlaP2|My$ZsFQ}cTT)a}%G@NnO z`a^WE!5$T*O{gakc~+&}25;I@h{DgW_n&#>zkKP3-q~DEC$|cj`YEkDl_{}CPh@Pe zr>UmL@_`{oD#(k!+pXitbVPPdS^GyyCL$tZO4Z{xt!c~M0t~I`Xd>P1J9m0(hy>#00tMvF1$=80`$9&0 z--L&yudL6#Yz8cTXIBV)hKt@02MZ^Kg{$l9$!APD%Xt{OMO!qA`*S`Hq^ZcZI?wN& zeedG5rD)T4tE^8#1{G2jS4AH{-T1)sneq3tW#t>Qmr^3E@-k@th`)aGx#Fv|9s+`$ zOe@Qki28lM(RoZtD&w=&6QXuYiHu)nj3&cuZd!m|l43bPpc5ZY*dMzy-v@#H32Sw( z{3V_46pbPZ98cx8bCgUp_jS}Du1AGb>G*kx7Ml0T38bD4)|ORID`92Mu!(8lZOnRJPJQ+SZru` zM-~}*uiEXq{Z$>fZUe+021F$)b1+ic>w zAVZ?oil=SNeKARq&RUA2;JEFNd7qB|mL{zBxmINC=Ii;>zda7dwit+Rln<$fMXsmH zX$qBbl}Vyoq2&2#VEZYVjJxCS?0s3cR<|4ep$gKC`QS<&@rs(QgPqK}07^y+8VeTt zRXX5*DB%QQiRMT+UD`#PmdyBVtlecW>9)vK%|Vt%UGO4Bd#SqekWZ$76Ps;^NY?d$W_tC8$WjJiXM!*Ra&xq z9Fn~Vsj{-7DU&|&Sae>3Aydom)ln|$>t7za!OtpL3x(kQPB_GA3y@f*+TTIp9~{hg zEy$F05;mEyeOS7H092^`_!IAIY84dnet)pT- zlpd4FFfHk^pqqbMxUt+d9-q1B6G3V!((>5jHw~vxdiv=8jmKV2>XDznZ&R;rZGD+( zok>i5aqAVzXPH9jj;}ze&Ar&6uXH1~3MG4QIJ#E}Ie>-2AptaidAl)}&;!zo17QSU z4m~*+BXIl16Ov#Bc@7TO0A|Xwf0ibeS?*HLE)9vzFZC(k-s-qM(vh(@Y$Yus;#Xfd z!RI>6iyjX3r!bj+XH}Xnche>EAfje}idvA_%}pqxmBCh{C}%pXkLdR&=m4q$*BbHQ zi@ zv}xK)Z`>dBVAumSN{cdBLm`jYL}?O(fYO}l5`0ryZiMO%GCsbAejp~zTv<29#g>@! zsfdb-f;$t-V@f8JR$ld8q-SkI05hfo!vcrHq4x)1DZfMKgSJe~f6?j}^`qL-8W|)* zTf$q}DzXWE+DUC(cHPtATFU0QT&FFHz~-4vmac4ThHULhMJ>J&Y}zjQYQqzkutIGO zAt|{ePm*@JC%M`qXX7mT$IprumdM#Xvg2~J9=y%SD7QRO6P*0{N?-82`itE*Ew;F? zqb0=DgCa1ye%(Fo*c$QNQ2AAWn5=74PEl_5N0gNq>t>H>$ole*qORK5sDQ1O>L=O- z%hf_V;pz(LuKQSJD#Wt+=uJ;fesuNcPhugL(pS#7X`ZUM z?YS=7L4#4gM)hbn21XRo{-mpi)`(Pg?m30}hTm(YqvL5R4=b^C_^hn^+ciW!R9ne6 z01bhhlaf5$!P;PWxhzT ztUl61=>;fW}36N9bdy8Gq$3Y;*ghB%e}=SJnfJ%dS~; zvHFXUR_Csz8#k96ef0DVhxThU2PnzG`grALINhH+B*^ytmL7dtGS6b;1(n0<@c9+` z^M(fvw8$jF$;f<5@jEsgsoHI-u9<0d?_O{+y>!$(q;EI1G;~Bquoo<3#;C9Vl*IBb zP^=>HJU)};gEHd~+D1J#M!;C{Rqn|ne}}5}MEohV(%((9X7&*FWomg{K(D*f8n+Z% ziOFuhd2>R5$K->2pW8LmS>s_C^I{1j=Mfab=(47=s(47h|B{FDz4Sq;Jm$CWqP^;7S ziLl*xnvQGd$3}&(c89L0pAF(P${nF96wU3Grc=+U_*^yPEXJ;*=2yZCLl21ZEDrJa zF>NQnQHCfn!6WhNhzD$OAvs4b^e);KhzffT7) z$kgewZs$+>_U)Ums~aXqBdvRb=GJ2A!u>;!lO~rhUPtZhZ;frtW2e>Ezpq8|KPwj; zEf=+22*aeTOns6oT?*h^pZei#^2*ja^uq_E-Ef^-n~hdY{a?fnwF{*}Ql+kgYdu~p z!9GCp^~A8;MX*XdPB@<2)H_J7uj5ZgW<**-H~T-A4~HsxupUe(&?FW7w5=NZBA(Y} zI4ICJ9Tv5*m^H`7<3KIIt)gb787%VV?P8e6Z>Jfp^xwdIO>&7c8K9ax&M{~1-6R1m zeb(ZOjk%F8!=%TLo*PB_8c!|Ms$UM70mFmxN`qMu9SrYrc}eFr zX_r-;vR8A|eY1?$MEjY;Yaq)l#n2pf_n*1!hjw;xu|53ux~a9b^E=RN)1o*BNjmJ7 z_9QGA=rXh`93AziZ)^2o197V-a*IV|{e0est5CH@QMq--Wxm+n5~rpyAa18icirFd zaloSXF+&#mY!NmPq2w^@Z80DE{$0ejeENz&ndSTPiV8ZEwzhUDE=(r!o>So|!o9TM zQAY(?ndsT|k|9{L+em7yH6?l%9gM^@nFTr@Xmy^H6Bd$0p}bRD9qN9^ZOh5R@Hxe8 z8z*#Fs=}J9O4q1PTahFlxaR855@*qsg=;tcHTxdBwKn;v_ZRB`+w}(zro+5`J8j(G zQH@?#H{9Zdeqz>_F~QQOe|LD2;j?5K!Ug4U(nATYmK&%9oRn3C!;^PUUC3I&ZBp?{ z1iM0vs|?NMvgb`ZSN-7ak`KC&n&SRr)~e!13^LKhpW2n$`&5G06%52m^kL`XsNa(z zQRHURZ<%Coo;k_Vd;N4E6<_LD0HqwTm99gmAbfZ;rfgk%-iz{gP5DN~bg*vPUfy*2 zUhzc5Mw+;zJ)e(VVQjxxBe*-kEcw(X2Hm|Mv%PO89U-8?AR7}*D^1Q-`19EnTE_XA z00~J)Nslzyh42cef_eXGg_$nSR%C)?j){5f@FSpPz5-S3i-!!VQBS3_-B6x%Gu!J*+*t zhu*g3DIule;yN9Lqqc3BOS}u?4^Z+TBxllM{dCrK3yPp?$+r9SWu>JVx0)VyM)n=t zM8PbOK;2n1-#cWXvcPFZX^wG9Jq<23VY3b}0@>;7O-%ME)wn5(9~sou{@kU6UDpQ& zuW#Cx9ZD4JdshDB2gQS;!&6`PDRE1m#AJQCdRep9x<^Kn%Eb70R!&xgSyVW_vis`1 zHewgupx*TAlj+b6J0-r4A-Z4M@h!W$DdsjTQI(R?afn+s_MDqMYBWv`4U*4oo$b7LJ{NBKj; z!d^ayg+X$5s4NXx5)O<6USgSV%9(o|~K)VWOhzu7zg+TxedW9eaIR^)rN z987CA9Id^)ToAjz%!m2z2u5CSj(u&;rxN3}Fo|=qmR04zZ7uz2i)K|%#QBEp&6(z) zq<%*JNS0W8JPOS$Ga4@Tt{fwa|4n#gqS{ z?mK{sRx!DnM9=qRE?7p1iwE{pdHQ!Ke3{{9j-}@QsLyZy2U)}GoD@f!rRT(?c8i1h z#3jxBn`DB9VOSxmqBzFL!Gnf(eA-XfGd7=d9^DmMEUOtEiY6t6M-$yJQM030<9T~P z2Zh4LE&>*bVrN#Dg3t90Eu+htfegq=EE(4yiQ7h!i?34l_?G@kd4RvqynBICadADu z$J@Uv(1|T$(N&5OfVK0mo>(Q}=9_BOKE4oj??6-fpM_g1FJa=FSM@a7e(vhwrk)Vi_# z*e%ht5aiVRJUG&?E_AT+&|~4g`JiI3qUw_dZhGKocMj?=Fspb8LC0a;DcVY zdA9Q;3z%QG^lR#y8U>P_mglo!%g_NMZ^QA|&|sNc6>a)jD^+)?5)+KQ&2R3m>O1oi zsGs>HOe7;55*q|rYhp0RBTMFU`dTG!!-DLUjw|YSxwK&??~jIUIE#~Q%Dw1La7VC5 zb2_kNuZ&}IyOX*6%Te-&4<7nwv3z8g?9?jjH>CKV4%NA zyWylweYCj})7Fj+pb}7@-X?$mRS=Mow-pGS;DEP~Az^#SC8)`67D#>VO8<>hyFY<+zeorw3wa&kA^ z$|_$KAAFn$y!wEs6f4mhcTf5zgH$lB%f<%R&isN!lUY}}nu2r`ckBrzmaT6t`=Qa1 zU`kNx>!Xbh66s#q$%c`1BF-#Fv*#ruj`l|91n$OH?I^DV;>taIZSV4F1Q&3ve7^pc zd)(X{mQt}%=u~7=&Ds*H?;QBZ&YO*`5W3Db5CM(gKH>-Uud%0HQ3p% z!%d^Kgv)BIJx{Zlw%`{rN#qlntLJ)GL(Hy?|wt%N*b51+O)cf0 zPL$I6QwoUbI9E!;cBY4kSKX}wO|3;@6N;z@!0#*0GcaH>lIp*_RByos7EkG|#2Rvg zy3RGq0J-BO75k&3I`XcWr_40k6x-jabP1c$0UNi9_n$R=!&v&zLw5Zv?6~dO(W=pu zXAe&?u3(#aybCnltE%YzD2&(V;TckR{3OVekVM{bA=EK zuJ7{Yf)yP4!}d&+gz7>52zK}Mh3;9aNt*%va4UIa>R%WtLq{0jWdX-WV);cw_kVdbz*nide-R-Rz3((Kb5kqYlC z6bovmC^oBX>wz&);Aj>+LR1Czdp}{W%N@A=tr}k+H7+Rlqp4}w9@n(rZYU$h+Kz_N zXWjLD8J;|wBNus(M|p$b#C^R-ey^fSP&J%(JCfz{fTtwwNy z+Ueh|g0@>;o&*_N>5l@(je?$eYATn`+^wPaH=7#zZ(7Hy9pE3FB_u?Ys@xrTc-{BC z@-t^_hd&XXL%-&f56UxnsFimG-Hv~DcHFn%6kl1NJ-8G+!N<_oRm9fFXso_DFQME2 z3N_!AnPwo!fuf%B#pGJIMCo;QZao9}AReFzOn!ue|7 zkF|~}=V5Xq20q!R4GBrf4an*;0{z>3Z<4sj0*J`xa=?11ixrT-ZC@_XLxM`fr%&2& zUp6}m!jbx&;N5u-KSS@$mr`Ia;pSA$RJ2!{R?gMQ_nV_iIjT=qb zYpyTzpL-H1*f%SyA9jjk*Vm`ML2Z>gx8iFej37EnfGc}S+)+(x%JLZ=*BKT~aK%NR z8x(}qK-!OdPSCm_h!s0?7MJ9H0Y=hC9I{87s$Y5d3S<0+8gf1y`ZXdV)#_fdA&D~h``2R@=F01s@1Kn zE|`PMbXcEamFU3h^YiCBlBp&6>A(}<61_9govz>$#r>esqRNaPo(n})RiizDXFv4> z1fTzQ8tRPLJ^bIo02jb9pD;om0j}ttK=O#DW_D9MgH&2Z{;wX8_C-v6B?Goj8TzVZ zxEnfC*@ZL5XQ_exX%_>{LWSK}Pxw&b9IWA`g`%H$?Y!mVoG?Ao7vEFT!VSFmUvJ<^ zILasQ_2?_Ncv@N=n*WU7jZmkK>7PAUs$2*AU-URc`iL<+Ku+C`W>{$TebRtu$e}g*k?!t$6gP-4M+t zf6IiDsq}HDn@fnzCL@9=#ND8 zbA9ZUhrHNuFQ1RZu(JyzLEH=+Uqt=tarjw@dyN-*vI8PDr?4t?8M*GuOE+=d(^I8P zaizWIkwyhottqH}d$N3V{Nguz3cU-?h6#kt6-amozNF8exlz-z_C;p-!j^pfkmGVA zDFk`PJR13~lf=x}9|fbIXpqDdYLD?#@5%t+qsyA1ky*R(zS2b%BTBDBe$s z{$Vxq@Mx13&w2Y!p?04N>EP$#bH_83QqIn~+1S`%3zy|VYgPFB=8z4^VS9`1qEhg0`71+$WDj z62tUv_~EDxXP)DJ?AF7jWf=BvN{kVhsowEDnkj_RO1_@7X>>fTJ;9is{O)e?30Bs| zDJF;AZojREpw>W!QoEuG_FT|>_r&l~cRdLqCY5AZOa4wFzYbkiaNN&moA63^?HQW{ zMa5erq!$AQKLr#`nFTvbajflw+yVq88MsW1_Q9(aseAFq_el%-Z>xwBl`%e?ktNTws#x_lOPGWtov+7uH!U@FFYXlof~u2X?`jx-g-N{=14MG2 ztCiN!O7%OF--m{FDl7Quh-BLskJ{PxlrdC7BHQeNsA%tZ8I;wMJ?87P9%rQA-rvL8 z8IL{pXZrUk!17E72jS5-kN=T7j&~iu5WaNsxKi-(OWOa3-b5V|`Mu?!VtbAc|Jy_g{~@ z$q)_k|9W7^d;I?o^<0$bzrP3VVRGPqeT^UFUf-Ml`WkV_{|l!BF@$-NXY_oMQwOIBt`CL<#=G&-uNq#2lqXyx(l@;rlN~Kg{LE)g-~PjjA2}wU z!Kr@aazS}okSIR9MIz`)vzZ6~G6oR|N&Am>#kW#^tuwt;;P9}3|Lhnks^Os_aT7HL z28PTW)ykmFP4k<7NwKCtE-`+T@q{apY@m_(H+cDB2Pmie9XxPax=llrDnBgWl;PP# zOcI$P%hzyne$KH!IZlH|5s;pq&i{Akr6gr#W$8-Vz6`AUBxPcfdGmTol)Q)gm+|>6J;4Uzo4 zRrJvpUtDX?uLMSNKWF#cCsK`q(f(aj6d>esq@9_OoR=3;RmBfc*U+$m z!Tz+i`X<+lR#yxD43!qk0+&|A?!fr@-gp1qQ0>Kf8W_0Xs3Ll=t*;2bqrlmrleg>N z+)W2k+T-0uaL_L@m&`-%z}klit3tietFIJ%?g; zD>552g&^XL?|skQUU~RZu^1ZJZ@vAUR~TZg6)cUCn_pNsZIa>PFqx;BoSGc^ce8#$ z#E~`m;u>{D7e0Krm|cR9S5$1uHyiozQC(b0>TXC#$oj*ug%mD9-^dvUxsS9i$3;3# zclP%73O?mdxyKKiraJag-owV;1>16}=2*F1*~1Ip2L{9Yt^}7ds&zG)d&o(S)FUD8QLZE-4Xzg2iIz6FWtdyf8FBx}^if?>RSx zLrT4iW&MY+>7rlQr29sazn`DH(O(>uiu&T^#b>b2@Z-nk&x{Ny3S0DhIQLRYN`8RL z`MT@N z)At8hckkABcjM9*6Vioag|~Z$k7M!h@Svlk`vn9w4>sL;|K=;ZX-$eg>me>K&fR3~0q zI&9m=;=Hg3g)KFCqsb4_{K)UX9kgD)E6%jYp`Os1Gt;sgPgSwLa$4Ew{5SWIKLA(K z5=0W1pHGD>vrzJl*CwYR$HKx=_H)N@zgi-KxVpJ=j>+MvNzUcJZLGr?tfdO#}>yEYG z=r-g4dj#MN0qFyYj~{6ai^He5!D`>8Vu94RBICx3dpGt@Cxp`kSSnAx>pe*3FFRU9z1$_dYCLx z?I}vYyt~`c*@>Nry>K)#NH}s}V8A&gp`xPV>bf#6qP-^pQB|7z`!{uAVPSJi^FekX z*SmM`RLkAn;*NzPy-ysg2;&{PT2Mb84h$aO-lHR2Xbqu#@+5h*dRgx!dQDkbv$(WW`gvw@QrgW;Xld!OEfm_`-AT>F zl%o+{12gMXa?N95!J!na_}n{w7LAg6_l}=V#b=92`swJ0`skgx3jWF?RfN;h*PNaS zQ9bCwQ=y&-b6SKTl@(DhB^B-rS?-0??YI%EXEdA`Dm*IUAwQDh@Yh_5P0Y(4ED`15 zt=)=GNI32E+Q-7iRr)uO+6eX!50_HH>56QJ@5RQ(4n3v+)onjkQ~8 zX8%(0L(!2r`(pw>6_>u_yAVs)vYhqcB|*V`{Ux`)arC@1)po`85Lw{mVx3|Z{H*n~dh9ke=jM3}J%$rzdw!gr z8PoXX^}B%g8M&D{V;OUfj?Ux&p}aK;j>ea|!eO&%NGQ3+JwWiE&YqGBUUjOB4!+ zEV8YErGkOAyuf9~s7Jn-ges}0NR+V2mlmvcdR|%N`noB|dUx@+DZDU4ojg!;bFWs> zsCVxSJjj9XAC3+UjTANUvT1~zoL*d9P6%n)eSG-7_9}L7&vx&qW*0z@(B7GAbq!Kz zW>;R>>}SqMV_QKArP<)bP;=~NurEa-{sESO+^{BTEa?fC3-#a~nXP{OklDWb5P%7) z9n2}4@16=U#XE^!ln&bT3Ui| z0$49CrMEjgzLqDD-|SLHhYP0qt~v$dAm7$3 zEo$%z2tfDN6m5>JTTLzIM;~RkclXdfd-1&{=N%&v@ z_U8@&>pS?A7#{gnqY)WmuV0TP#J;nT!7VK<{iv*b7+^}gJbfThQL<WmWo#Jx02@ zrKQMbK_WITj^PEUa|Ij6((>|p?)Hj_y~g_~ofjMsa4$KPrYP?7HREngdDTM)dY;Jy zm*vuNbM6mE`s5>epPQh7OZ{#~!&L07eug+sAtWM(eO%^Xi=*%l@TcPuh}yfgvRClR z(jJdbjZc;7G1%o;p+Qex(iKg`)4K#5e@jSwbYs!vyhEgJ~7uO)}s&EC=@ov?bATmxUT3M z=rNSv=XoB|;O~VPb_Z|!^Ysl4o}7N`2!+rHYVO60=NM0(y%;zNAtZeq0D=`9WN37F zVSasJ;j&NJ$%Lf4yBjBn*f^+-2V8(3PI`$xtIPW%N*fQn*%j3Lw-I?vs9aRt_Nxes zzal`GUgO|k`1!WWH(x3zsf2zxE^cm0d2as1iV9Ugzxw<8bJWUF$xo}&(^p=(XWx6u z!V&^9EZ`=PF%Vkxaxu1JzsJv?We<0hZ6Vtk+l*9oAApp~zauLO5^4g%fvkK%R}agQ zFtOYew_gC{JCDeH(;2=}=&>dGty?bLlheMx9D5TllO8{PYPW(;NJW+8^~1udSN|m- zYvw_AGm0Mu7}4!~HSLT)sH>Y#+Ct5+lJ5B6a*mYxEVYo08lGpc;Ej!V)upAycE^>= ztByS=qot!e>Kq`Cta?|i?#xm@{#irfhLMQmSvYE{sr=bH;2Pp7RmUCg8xmCuUdlLp zr3fX2gkW|F)VQp$aOWYV4BpUszVZacbTMr3@pt^Yp3q{bT8;T5Kq7GNVzq!<7F1&A zu2C4!;&bVy03vdIDZ8z+y{UiGZ`|B)Vd8L;dE9JNbJio0F(}|(z$T@Z`p_QQ!$DHCgPR>6R>mPN-(Xvw zOFUCiQ95qhIJDnw{-Ba+#aYvr08fDe`(ox9}if)O}Jix!ik#D!IxLWVU9IJQnmV{b$*zmh^xfxhb))d($DM{R`25wqfBt+8WUV@hJ}{FJ zW4{<%X*PLR`Yu)tGcg%ZJ{3(QOqA8D_3wt`p2N2dB~xX4=)zb6Lb;CPFbr5N6xCk+OvDF|y+{SQY#O zH6>ESXD#|3|EcZu5#KOu-;OP@&nY*WV zWZxu8%yK5;doIyOyEOWv@^(|MTD!8P)zu0skz66$AOwGUQul;Vrmmy9fP66?W!se|QZ zLqL{{%f{Jaq{St~OU&q>KAnE9QDf9gsjH_e+jeDXX-P**`}IrXYad!VQniQ!_@6&- zT5QD~y>}5;bJlj8gank9L9bb-i!nt+>V!w>OH0qwVJJ(kZGh(;M;dIi;Ihi29HK&P zph&Hs)p>b&D%D4Nl*yo!3XhMUT)XV)>4~=+Xy%?v_Fr8kKH6mX`SYhI$WgCfzcw~C zeI|e~C@}^~0}AD<`Qdk3RqWZ>nY&tJO<^WMv0iUY4J`Nw(>w@HLr|-e)1@9*>ZM&I z0BYBS(iK_nuSxFXmKyrf55w5QTDC5#BUvF}TU4L~o+iX6+{MOk{ZQ)_5YQ2+J&utc z?-aYs8`munUYfa7D|p`D_2w%xRTlehp@jAN#^^59aDk`J!)2nPbVvLKou@w{B6TcE z9`Li3bneYV%v4IddWGBvFkUyl#;e-$_=YxCKtTLZeAYvhbc1XxqB<}qh{+x`X7VE} z-)diHacS#M8~bl#P+q!q+5=YK*4;%%r*j>l{O9YRMSTMf*P*+ULX-RyN^Tij_B1e+ zdklyVepC;8`{w);ER8mL&<6fU2o=_82>Eu8aI{tp)e zB>%G&{Mu|doCav@H^*9*nd62@E~SQR2?~2ySU_cCUm@dcm^QU_OYC4I)11eA@@@jg zDhg%E%g5J0HYOz_6MR-v^nJMQ>Ycv+L$3$(!JY{N@Q6rMohmKc^I1P6_B|X<^n;?J zBH?j**x~sL(l}|rbR6=jIy=THiw(3_`hJQb9wj4qw|iS<#~?L>6zp;SAt7`O?3o%C z=bk#Z)H**w?%mr1`oqtZ6k2&qzkq=ClBwoq@y6H3!J^!r*@4(!-@GxOtOy$_;q2(> z8RkNO*RD)_V%zsf@X37UmODWanCrJd?C zz&Rc+J|c#G;VET!bF+kc7!Ct|G?CXOL7O?34K{keBep)5%@NJY>Dw*7O1hSx*b0gY z06BO9YQdhN<(rffNI1dh2B{%+{`PLTu?5n)*aFPXz9;dAFQlbG`V5 zkn7g#12L>#wD|5Gp|VgyIyvLhCs~?JpTcnc&eaCcjxo;lzE<^k?kBDeEi?&TUlFcKOCK^M{ZZA z_N>waNw<(w6-XY*WqFTzMvorBd~qoCqxP4H9)g;RAE&=Hm}GEne`sWw)a$X3pr9R# zlk@iU6?7xvw=+hFA_RW@{?*>wn_g8F3zQ@Mm69J;p+P}{IX&d}$<3LfA|eh|J*LgB zGWZb+$T6r&Nd^D-v5m6{H(mIxrY0Zar_P{{qVrR%iX!n=MU2apxHZ&)np-#ol%e`* zsmR+sl;;wbt<9|jJkRq6xlK(?+uJ)@x~GB_^dw0QKTC&o!TU#<~(;wDNH*D4= zumZmo$nQb--;PJT{+e2ZTtec%0YM0YL+53T6e)Yx-Drz$?uB>hBM+`~SL8sipv((+{;PFx zN#Ibu0&)}3s@8+tpFDpKDp7RNckftqDG@@_zKPvie7+r2+>7x^!m_t^M^JZO7q4|zkZ=btr{WK7yM5sU(t7nh*8Ywzt$uPR!qFf%Xt9zJf{ASbFV z8d^OPaQ4Dgz2+bxjp(GC_Qlk{t<#!N_u^HNXVlMvc9;RGfy801@dN5$YpZ)vQRui8 z=TgBXZgrW9Obk9au(Lh7tle5&ZL9axq1V?sKRvbCTYTI6r{#s92wG)XCQ4pI(XDw) zNoBOe1X(I~X@mEqBOw+*+=M&Q<}w^~TWf@$K8p2Kn8F`#Qmf zm}F{diQ$A&X-jdPnMG#dF?5~++OePpVIdV;`=aB zK!AT?8|Cs*6OL3^CCMbgAsc>qZ%U*->7-#d1@+EgT(0i^{h<&1H5tkG0jAxoIMku3 zsmUuD?bRm3bkkmH=g#4NDVfbwnV`G49Y)L=3HVrQTGkyoR=K_XJpuR>OOH1m4lQds z?HC>oKflU8E*V#RL|u;dLRGN7f#Jfe_OFzRB{AdG0&&D?6(%l;xQz}0*=SyRHD{z@ zTyR$j4mkw{F(=Jj!SS4NDKac0+W01tkqHvx(=WRWIsUx)!^_LdX1w|YVc}pt^-5Nm z+rKG%^96yO0~`dk@W$2?$Rl&p<%sGGZ%TwdcyOpQq#=Txq&a<`(gR<&L5FW<27Tx z30MCyfU5oR-42xbbG1KnRt+8TzTJNa{di6M=8r^pSfs4PCw_Jtc1Oq3AH9^ZKm5u= z@hjE%erz%SX&?8mcxBVv(mFTsX&H&3M;AnEpmOi4)9FVgWr9a!!M^GA8_M~qMcu0~ zBO)SMey0ZrhfQ6%T}puD#GH0)skAQb(!HD3GZz;ZH&rv}$^F?{Rd-rk*k~FWlzU3e zl~6 zTZp%cFKli~+egu9-CWuZH)x=n%sWl@ip`2zT zz?N(!8wm2<*T8JXLm2F%ii)D@$H@h+j(hh4GBQm_a<`Y4eWId_0CKp!5EZd5aS|6F zZLpRwOe2V-YMbf~R!oyE#U8^yxWu1vaGn82B@r;(d@6Kj_Ddzy#^IPZ%WY|8*$ebz zP9qyBvHH+-Z~bZ;i<3zxt(|fMqFC6c@2~c22Uav}ptiJhPxkzd+Xp+t$B7b0ccB#s zo1Rk+P< z?#XJHg)W^dly-WbU@nj9+xWeNfD{kCX!J!?C= z`rgrP?=?9hBBBR`z7GUNAJG}i7#WH}fV>5kK(F-E+qW`0svib)N+Z5k#nG9)r_}>} zmy65u^-6MMf%p3mVtfhNqo7adwuvgRbF2B zHLeK2oq^!({ndwnf?~>Og<^GtYRKdH$dcvH>XtA0h+cy7l|^Gi*Tv_`DfvHRj-WYq zJc17Oo>&CG*#&rQpB16v2$B8$o&dCKEU!b9>)klL_6ZH(po7)^qMMrg$vq=Uqs5aL zVT&sMSAXw{bIsad8l&j8WOYpqpHqiE)1yZKz6SdGY`9c`5Q|vy_KsX*z`lo_nU&M5 zTS$KY7iPTtB{8sqBzrq{(W_=U^YMnai@T}|#+94jdBVdJ1-XX27Q<%_WP1J_{MlPU zK}9r^o_^;pK0ZA)W8g$ADTx2ES~meed43lsBS%|(LE94I>WasmR2gi6ECW^tY!6>w z%(+{bN3o-B(&%<@I6SXskJ#8x zcW_C(1G76CrH)f0^#{f#+h);h)`J$>W@eHKVaW7mvNVR z3}GZ8fPaAdXShA0bUcoqX=R#3r5Ky=ndHLuoJ(UZ{UPiynw(sUI9~K(7K0*Xz=8|6 zbdyPzc0%OKKRxUP?2N2PT@Z|OSBH&$!p*DdhlQ@;k)j^TMG;3w{PG2s`u1nR{C2Y?d9daS>=!k8q4Qr zd(Zu2dvV#1(=8r<{+ubfAmdx9+33L7m>;-Vza@iFgS7Y|5!(4rfRlkCYfS71oEJ}M z$OzSADSj%cZExND;j>fU-WDKAI3)Lb$Na(^D=L!uwk3@nL7FMVz`Hv@Wy(iGW?QbH zq}9E)8*am%ZQHPGH*Uq!G_FQ_@YfWVVzsloJLgMPry_Y50!%XAKHdcm+nVbe8=3j} z>A>XiZ5CJ{&R0sZ0jcoDz+_bXj!K}$V_uO`QzL=FU=X19ev68dl+~oP%*%6hc1D+% zmv5^&+-UFU_*GWcV%#)38nwI7IzJSehPAW5pPE-25)crHjs1d=k%5V+`OokpIXV2Q zM8u?ojt)B+kHxPXbGQ<)h*{skeV1I`+}+z7 zP@9}$mCDnkAxf7YloyMZlfrlRnK(c~3;RfG4H9w=*V(zNjEI zCf$S8wfuV3MYb)bceKTvzTv-G*!t){QrMEek&v)GGfB^;{tGH8z1H^jhUXy`pjQI( zfiORH21cUP)Km#SO*OT*s;a7WLYU%shvYyYxE0=>5ro^PUpl|F;FYA+DRnR}^yeWF z5!rE^G#zj(kpLPs-apyv{HNK&%}eYU756x$urLfXj_%#V2IQc=zX2`0U0*$Du&+-P zxI_3VD>ui&q-ACCM%>g`>p>bOp2Ti~uHK@Zl!*qn@i!<|>+3}&f{0&fdvp*{1;}q} zw@3!*TJ+GV?jug{U18Rifd`1Yaw&(Gh4v9bi0vx|$(z_y|W0+@@73x`BdHR0KS59l*u0rwsbHnjk~Z~UZsY|aQ? zIage_xVSe^I6V&U@$Hdixs$4?vZveY%Bm!JK#Hb zKs@HMuL;%S6*Wo}P&8U6c!xtGUQ?RLe7l_!8B4;)_7)tkGj+3F+hvaB*X6TlVj*RU z0c;wY)n_+$n48C%%R!uvU32?pb48(EM!r>jQ!897-+aW9RZq1qeD(D@dJQe0iWgkK^hs*RELN#*meSw5r^ zxa2LsItY8mN>hGk`C1@hzhaP|k_Z41Em zqidu726`;xpDMzG2FP{MpZOo)HoaAe^(QC#FY{ztv+O_S$uuI8m--q1lXTB=o{IUr zxUt}_-2o1?=T<6mvY|<@|Kk-M`}D1AfJ0g?v&_ZwJGC< zzZ)h*K|3R|>fXDo&gWT^UZvv!fV74GZVkKtFgs-Z>$67cq5nngwQ^hnf-sZ*BZixO0t;$mpI_U2WiI+q;cCv?2{T*FnH_!&fqnVAItOZJ7T_kN zxI9e8hM-ZpV>q6$bL(QjMw~lLJ2JEK(}2+@JohaH&43t)n+jh3g}M2V{A?a@^MNm} zaZqeK;@@x5_@8fjFOX+Q^qT8|vUqbyU|`_?-WaCKGGLe*5cpw{P#x|wLj4N9k7c5+Xf&Kq7$ee?z?&lz$dg% z3CqyG8mO{YZ8nvkgQD<{t=@r_~;lF|Q6#M}zGVsk)3&|`X_ph~x zyyUCBZp+EersiiEImxd|lduYC!vZ<&JDF8gp@t(K;N+=z>vsbGG&a7$u3V{giGj}D z4iYdyC!~{Eyr_ga?)lC;~!(JK#{qNuC+d0lR+@kUJS z$M+vUZV3++>KV#Z6tLJHymsqYX8sIu+`gXPwm!Jd8qRL(>O!9_QIwxfYNP_gZKj^;R9d;Tc-eg47L@eD~Pes5B5ihuYKJk!BJ1rye2SiC#`o^`kJ zJuB&ktE+3YeM^kDPpS7rWo6~XMJFq0PlY8V^(xgNVn?;yf}w_8s|DQ|ZBlxC!Ys8- z7VamzlG9Gd8W7>r-q!#4(a$_U=NYt+*kbUkQp&mwsi~>M$Hs_ZY2Y?drTxeHo`OGC zW)W8?XzR_gx0=^0d_8%$7r6h9XIxmLG@MC5>&YIe1iFN0aaKELWL6OCa|*cOPVy^& zXJuylMekXGbMH=eX&6IzdPexora1F_l&!qmtk}!8Qa!G|kI2U76E7b(uf7DPhKdtL z%Ia-R*;qEsgco4%N}AyPC8 z(Rh#Jq0!K*Q_QJp{g*Z~LdUc7^XMf(nyk;C%S+xkx(}c>g@s*$M%M>I*XI|3mcFYN zP5a8lMv3pGrAzpgL5PG7SYrWDai1SSTCH?`)j}_DYLWnvskV1G>U!OTzbQ5S3a}jZ ze)f3Vj*l$S0#R*#m(Y{trKLja-DmDsr$oRQWNl}y-S+Kiyv-R&Jbc>8(Z$8#1b7z_ z;n!|QBoGDxhkkIK^ZAeWw;X1Qp++x2rti1IMRewV{V|^@q+n%b zRc86z6UbWEyw6(QhuRGlz9PPPLek@H?`>c2Yxy3!gN|yk?w@jVfiUV$~5EgcEG5Fzoc=&1EZEV$2=SL(Y zB-I!5fnAAQ2+3YVF0zdM{9+$cvC-o+dC@9TBtZN1D>hM)?$W!L#V>ac4{2?TpRFW9 z@r;sSk3-Hw;AV=1br*Zsfkdo^tv&j{HHw9gZ`63Qd$Y1ZBtpvtGozuxXKkuiK=Xuc zuzPxX#&hW1NrLZw?PtZLG_nveqP#@TRLt)&LMj#qW(o|qChRRVFXG+@ExBx0`o>vS z>_%T`j1}12B2u>_fYjBUIds8Sf6A7-ohLX1_13CrI{T|}7*UjLfl&wH>pkqqeAQRV z%8${|&~VMk#>0Mrg!x`Evd`c|_w$1^KSL|XHU42r53 z!zz>4v$p`{MOc7(`P5|XgYbzpnpMp%9k|_?ZaqK=xOoF`bx&Eky?u*iHj=lL0I%-E z?iFrjNRJ;dAp>QB;MF2w_3_pD7OJzm+hBZeN$|Q6y9==(3+9j}Sm56>G75Hlx=UlpmU6uhKGkY1+v!YPbOaXiETCRug8*7x*aG8U#!k-9p!^M z2A7QI1}Lo;4tcCctDQ6=f1Yuyw~S)3jrtemzEN1cvhjG|vU?XjPeXXIthrKi5H zTnmK*1iXq)a43b9+UuAj7g&UZ62N7#n=LaXI;}GR<)Gra0rLc$4!@wvw9B;{D$#)m ze$hmQcX~+l_)(GBv1M}V=H*X}UAWr`0je^a_Q~z!FKc!O!_gxlkk zw6wibA}zLs&0?pcHKU!K9eE>Qaga*tSMa+UUtuOvvVZuCQSgSK)%`OVbs#pX?!XMx zJ!xW*L@YwOS=6$uv&0>2BIXo;*#P91r(i_oQ_hUXLe?UX!E_lT&_5mG87hXZ$Wr%B zl$sG8PdiXyUu|FG@&DR!7`?dp%mYF~zRQxqoxQzhKsWgs_*kb<^*OMzw}@PM%86Wg zH#axihgzANFNQcd>+NHVW&sD1x^O?-7qO?YH*d=~z!ImPr1dr7rlT-LY7YvN73*{_p-T9!V)?W0e zQ?1OLgpZF;9eN3KJzBe-_7Vhz-bPR}fBAy$ezGQ6W;G+L zqwsWe%33JtVnqb!J?Y4nTlJa`S;a-s`@(T^b^AA+&y7G5n0DWn(Tnr&@W7+`3?hkQ zAH~_Y4@$(Y5JLpjen^Yc@$~t&qMDk2R1{IMQ8(SQXXNiIzYd$Z8w{q&K!BUnZ!DUa z+hV+nweI3I@RQuY;?>pFL!xb<4%E^!3XplECpyIfn>RabeMrn1j_l#%v+s(=W8}1J zK_U^IS+^*h0Ef!VHRIzEQ7x#i@LLW$#piI`c>)1emGgs{C@S}h{)`7UD83ao#~p1@ z5CM84Fyo4}4S`>F^Hfw+>@-4qH(K>>Dm=v?pvKI1ehu_+ds@}q0vo-UKC%3LWBW{j zE8ffj4&ZZfa_&qp=G*oJk=S25)*Z@~TW@TY7n=;y-3z=|2{@gNjm=y_`LyABJ3X?r z5Tx0>nk)XBIRlU=;&`kK4_5mVgpB|O$_Za#02l2~g;s#w_Rq~K*6?{04CCIF#bEh@OlTENuO(T`8 z%a#qR*(*${S$L9LODvb!$@aDI(%ySk4wO}l*QutvT3Jv>$Gb$gE50MrrvBn=VC+YAyj*NXyGxp+%vn zak`zJ1de^WlF&Lsd77;uq5+?RU2I$)J1*wKQvqbIXgMYlCAv)K1p^#zzT3Of6Vs$9 zCupL=J{t-@xg{rj`0(0wTWe@!WUs5ZcQ+q?@f&$Q=By*Cs5p4Vx?>0}4^hY-eg|%F z`HVAJl;#=P>n~zK3kSxD4mS`K#~qwb32)3ZuPM#+zm5Hv?K;dC&2cRv3N2d5j5}21 zEMrP)+sR?Fp}1G16>)yH?u9QRxCp5^oG0;EU0v1N8yB!$l+rsrr*bcFp=u2!8)dz! z1$hfF5y#WiD5MNpq0Yh`u;$KyK%C5Xvc^EK$Yn*gI4>1|(>qM~58#apuu=3){Qf%Q z80EDpGk5&te|A{XV6z>_ua|uq4m}GX>6#pe9)nc_?m~*KG9k8A@5r#hQ4N<|oAdgC zDNN^`D@@R0jT*b1(D!ovFq1x!?Oi(S=i14;(VrtCBlj)>Ss^!CDzsM+IX5{%Eg3YM zNuTu6Qsn#sYESEp|B&=SW8L&6CU7OaeBJ%~)T^aKDLY8?Xs?rHC5_C|oTs-kJDbs= z8*u|NkZG4~p&W}w1&^H_YZPLI5(2!lPW!p#kc|BNmG<;R&N3JMD_6nEDdbiw045`* z8K+hXijWiv9(yJgjq#WCCBvp$dzV{gAo&3v?X+kGzpcI6_fzm_Rd4I8UH|1Sk}+Qw zv=1OR6-iGh}uwso_3HW9JCe1x>* zXxuMbsqYuuPIR7cw};loQBa%$ro;{mR(cx15)j&zG?OP?1$3bD%QaFAs^rdhm(S?-oURmGUOH3TU0n{>ZP+~o0bVVt2<3pF&cB?60L^Vo z74>51pEUxd!8v#9Bo~FIA7mD^-ayr_(`8j_IrRzg5*2LWwJ7V(28BWw*3uKxs;hC5 zHQL-cHQa`+1ZuX86C4!NW&h0d<~N;##%MU5jP6pFl4xh!^)0WXe`XTM*}rg^jh0Ch z%4-@rJQYlqoWJ4a;JG<`&I_<)OIQHU4iyAFIsYB{L0P%wSmdHUQOG4^ zZKVKIT2gYrSvF}s#RYLKETCnxqVS)7H&+Q>zfSq5-lr!H<=%J3ymNl*^ycPoF*^&OsfFFLXlGwH&!YL3N6paYTKM+yjd>cd;f1UoWvxQBeUguxsrg zvWm|KYN{4nWI>?_C`wEOY+b>ODCU$ssFYjwdNdH5C^L!#yq!&GFUP{}q6083-{XhLKFF5j5!Wx27iV8%2$GC~m{Oe$o_)NfOmaCLknu)T^e zNrtKH&HF7bj?73V$#POwPEIBLA3`=v`m2}E${un4F!5$%YLR+^6lRNl1<`gTu;z3q z+`3v?cB78PSJ`k~=o6L)lOBjda$ra`_-r9*b{uJ)R*~Tca35l8YwPD9x^bk}+1V+e zE~4Ld|H>7)1!j&o>^--|@P)_L?#`3}PMh<&Jkp>us@Pyd`6UMj0}G4r;)gM}-RSj> z5DI~yrNLgo-^frT!XbZZi;)Ep&q~BX!^IV`U3(p}#2CrpGQvbB6W{DN*W~Z-&$n1P z9vU%iJq;V?u>jVhrALIk(wlOe^`AO^F&cD9Uq&MDya0}RFoc|Ju&C=G5qy4jhO)}8aPa0(l4Ogm%B$TE zE3Lbp+ExYCJ}B?dc&(0{q?5R7I=LOTrn@hEsmidY<$ma83G89t!`tA?&Nkb$#KgeZ z*~Wjo14Np_*{0h!X=#@}X$L$Z_bDFziRXjzURUq?`-h;o+(xx}5MCw%>24c;m?=*8 zEz0#QijoH1rmA4BrTYHD75IwC7uZTl!|OMFv$bM#?#~Fm-p{3x&^lD?45Rc7qc-}7 zQMa-8AFX&(Q#egViV-b&hej-?(djvXszDtP)G7DPhGoLMoSYJ}Lv{IYa(a5yz|ET= zOvlU|+?iJq4@UGL?$CDHc&8Q;ac{|4)STx+twP<5`!xj|)RD4ndRb*)@C9htw0CvPe~(SuKxzzOw5( z&dXnAUYU9|lkEWk(Kc{yae0)LJ&_Bwv;OGePP1Z&>_8j)XNY=$9`-P?Jkr)mBmF-3 z<%JaZhkt%NbTOnRsj>g!7I&P7C&rr}6e4231pEL9(C zXeNr7_^|{S7>C>#FnOxZtbtZ#eP{*m9Im@$20aLx?a6kz_wad8rlteEQbp4(BCF#C zWunM+#jysQZyPUdYw{4ZEx~ZsgM4l!?8Q|RmmEj?-&LMB;AAzZ=IDM|I=YFVs#P7M z6X%JkKpod9`rU9yIo=Fxpo=Z4wsm!YP1%!*^Np9YUsd+~`;DSpFlYx*qtZS!9ZVJg zpfMu^7GP1>VOSLN)m$qTpikGgwZU}+k+Y*4V+{+$c~yai;2$lw+a*EX+f0Jn+aSdP z))&0=V`zwSh6i*ffa$-fS^%|JM+MjYA$2`^z70ytDaNyWk$4Bpg@uKtp>Vl7^`@mo z2P>)c(;`RG=hu}+#T1CJS%jiJS?ANIKhJJG2h7B+qoMI;*r#28-e)6k?qa++_#Ds) z?CH~$x{x&omK)L4`S1QLbUn4zb84=AQZd}@PfyZ0g;n|KmaZ|DzsoJq;g?MucrY>y zW~n);CBRe9jHN zJOX%<@|G}oiB=2lrrKNQ-gM{c<@M8>XYC~lDO(O&o%f^E?FwxKIH-$N4z%FB+f}xw z22o_4`PE0HKnkNDTCqfS5HCgUd08T-s9ea%axrV_s?vO{YNz1BV0Po?2S)}}>>m9y zanlbVVF zA%3;E&stj#7UZV7X|e#b#*P>5^c6?3X8s`orViKd^Q+B&5Z7&udgW&{$%teRw(=m4dizm}n?sCe$Bs!3 zt&K58@eOSR)L>{38)3gSU)V~u5_Y;DY)X&-I=CWxaAUq$mqqB36b3%AHdDSDBGSj_ z4wz0xTBv9!Hi0UqKbJeowZ=OanP+rT5YijPT6@v>d`=kTz>CX^1I!OMy0Q0-hZ0Z- z9!&>kh!%@FM*X$0)u}AZf|X?@Pf!$~g_)t8T=(12BreX9ASNh^R%9puqRzE;bcKe}@bb%Mr0L5ac^{9-;hg-AL!wr@x)L~v`8BRbf66#L7&lN`Mz*!yJ` zGc5@YQwEcX4l4<~d=#fM6=hG4Te{=GY@zR=UhB9~LU(k(-i?w5JZ+Qg+p?7K1NJXp zzHF9(Y1?_vI>ELZj^Z%m)(ZrA10Kv{P)j*FXjLTKPpVf^(KMJmUO`$KwU26nlDJ(@ zPOt~${jo#ElX`2=t$iy4JPHNhRgOyd+BCXzkq=yIgvw7C~LI`7}$O6S^)uL zpCBYRb{ZqV?=A>mZo&nx=doL=%2t+t?(812N0wX7^!)h$n0w2xDAzA~7(~SaL{u77 z6hvvHQ&CVU5tI%^y1V0uh|-93iL`Wg2+|Fb!w}LELpRKSKc4dZ-uU!>c;~wGa2<7K zp69vmeeb>YT5C542kb6vZ*J=RctoOnxL~-pzU}};ap`;m<&u9lXtHS{v1|gii*wJT zo~MUUV$Dfdt9LGFH~Qw5n?Y_UxHqWXuyi}SM9qx~f^ecN@As985aQ1$W9L$R_O1(` z;RwjuMfX*5+*hOl>U|HFm09HlfRKK&qo1B4eYR*S76r?HXG?y*WPWvTt%^-^vWK^F004mzS9}+U8SY#GgLB0Q&OpeSJu4 z3PE-5zKCBPq|B<0Ew!4Z>0c)_vSM$L?-Z0)aiqBV^(uf`mJ=gq{n1Vb<>|=U!2y&^ zxeB|ApDe>i4h``hP7bBD%?p!W2xHi#imeZ)g3q&{g!~` zbOsO(xxd!XLrKY5AJ6^jVmk&wGaZhIlGVKh9N+VY>#U@f&;~hAN{L#~NOxs`f^I%i zSshVy2u%#>#ZLI~jJ|>(4Qt~I`VjBrx$z`YjVs_nP}e9RAg~Tt(dr;cz&eCScZe*S znCgL}R-d7^3IWU%9EzA+7ot+>R_q6dOa>cTMe zg{}~^R8_n>?K;)%td_V2Hogc8$Iha2@2n=iKiC$bU7zZeU5C1IFlLq-5q-NkjN?K9 zV>6iWmR=^h30k8F>i9gpkGJ7y$4i8&cfhrOBfn%xws;L-5!@Vr%(zX4Ulq4Q{D|Oi zI3p{xZXLFCIP*htg=l3qes_Hrp6k-R_2*WzZ4=Od>7o@!(qc{sl_lF*4uY#2oY5jq z&x@hIW+JvxFIhD+T0Xnk@=K&$Ch^PJYvWzKhu6xkURdPwZ1pWs*dUpA>~M4x+3(BJcEa%q@>Qt%f`0&l5>J~V0rZr z+sj;h9teCAv{a0Q9MGQsK0yt4lx-w$+V|spiJG zaVYO6A^X(Z{Mq+vcIt_yrYu|xNkf3%OerTh-^=?cCQ^b1*Ca19&n%c#y{K;qF-BGS zeg1r{La_27*~gkklMLnnnx02Vx`_%P&;25-Udrss2BZn6v&+$+p)3a9sNQL7d~yj$ z4!j=tM7l`V%CRMFXJ?11dmRAh)xF!wusyeD=KV!2QTh9mRpUIAz46=4<`YNWO76=7PosvA`nkNuL7 z@$|GEaS+YQH*MK85`Oc>3IV^;m}-@hcc5~!@tzC601FM4TGDbQ))XOgJS3(1egY!I z@2|fa#PGp?z5c&)c*E(H<{34X=h@H<33&$Gw(xJ0>f*_3>Y}M*$q>s)!P; z=Y$nzgqcLE_HA9&^l01L_qsJj7?EFB@Xv4aK917vrgg-W{_b=|8nlsiZEpzlYYYA_ ze@(#O%H6c~ZG>)HmxS1Qk;-c~PVP2$0{o2doX&j`GxC$?*@?nw^xr3^*TRQg>(48Oe*TxcsVRa6Rlw{Pods#web$FVU_5`*JaP;UMEdZ=lB z?$}2E%7|QLWdMPM=l^&BR<_9zb*a``;csr>8uKom?M4U+|GpA=c)v6k)vkN|D%5`a z@E#|sLTl99;Fco0^f;>Ai9~;-MF022*Foj~Jiq;nXL)8)_MNAi|Gp*sG-c0!D=o9u zYV#kl#INuBP_uUuw~QeFaWU=lYQC3s8UOp9GX9UtdqAn+oj0|fIj-Ikg7e~i4z&EXYIdv)-dY*&Qvf<0< z`OoRT<^MiH_^By(zyo)0l@e4_fS@mQ%Za z$olow@s-`((wbF@_oL-_8(5d46GbyK9;U(J;Z0pvJ4WFPMN&lX-8%*>bAdVTi3z-1Zmt*H0v9flCCZTevmEEW z0E8iqC3^hu3cJNk#OjU=Vy2ZNn`p5`#^V5q2ldCu61o5-hU!U(^9)*7d!eXZ{Is2W zyjU=h68YYmNn%egfOzn|k6(Ahzfq$n02~p?T61&R&vZVxpV;{`7YF@@0L30%zF%`* zh?SKz%8xDz|JU0|PtDGfr~A8rusH1Sft(z1t769S@r?Oi;OQ=|u|uB#%n))@65oKL zzM`@Ml+4$-`6+@u4xLoh)TAC8Qa~5cj5v zaIx!d=zlT#(%jZo+t(LjpcnhXxVH~e2&Q;dN+nHMXgI1aBUXtQE|H&h2U?YYfR}HZ z6i~JS!CKi~r@Jcb5Axl~+%$uEN%DcVG%Zdm=+15Wbo*IxpyTt+O_I^=zl=-r9WyIk~R@4dA}>@zYUy zdJP!3P;X}JZ7M>I^8OSN!<{=HVP^qf@K{aYRk4SUsm>Ir5$vwBw%%lAWe{Kp3lGo^sNp}onNX`4ITD-IyxsH zAC-!+4ZoL73Y$dduC{Gh7JV=Yk} zoO0WgCn{y6_!1KIvV!knDQEckWvEyG0e2lqb@la6>E`d|Xa++RaTOx_#Dgo-IQ@W% z>@(@bDG!3T0c0j>eEsCAR-CFurinQ@HA;4Zb?b*NL;FdIiC}!xW9kCwBV?u`Q(I8( zebue2?+YQOdke-NIHF2QH$ek%@$%&lQDN1rqGriyX-RQ{!ygAvgU)^}@%MHFf*VY-Kzvk*C2}7=5`7y;q%K`YW^3I3Q6wDAUbEyFka<7Hel~ zdkq-ei;GnKW+M;b3qe(Sg^lf#s^;MR`-FWpZ0)Ky{yL%$9HgTUGd;$?00b{oWMWVFMB}+B69(wCpMSiZw6$74`oo~Fk8A1JMiCX&Huf!= z|4Djs(OUp~KIwdVt_uEcYW818XdI*=1>4`8JKjy7G05v`B|}U0*~!U?w!*i(`AuIo zcU*(~Ynku+n2|s)za3u<=oP^BR`8&RYP%TpjCz}McVczdkBk#?yB*s!kj)@WC^QuQ zo?fc*(}BH$0#k18DtZ+8GGT9K+Zn9gmuF}3Mdjpz(0#6%6ck&U>Lqq&i~=C1E;Ql^ zuK_v^SV+l;Y$`xB`>IP_k{F(aaezWPgWoZzgjpB+Y<4-*;zjLQt@R~WejYq8Qua=#DsjYfzBsnQbUP0k_!J9Q8&%}TF)gxP8s z|K#!WB-GO4y7Wf<89Mu0)IjBf*zm~8iUgs-Hr!vR`FAn$orAq(qIluQ560WfbYO@H z7k-@q1KQM~Q{gTyF6HR{L3V98SS%r2f>kO7ocN&BXA^JU-`}+<&%`63`G{DyI&ClO z{L1}i4ihjCx&6sKg7f7AejPgM{w;5&3GeiP<8$D?)D>Z2LJ<)W@CUoe!Wf?$j|=>Z zdt$H2p{W5jhz&*B;sNv^zKw{8_!y=N^ovFm%BQRA$&Z)XzLo<2X6lx6gwxx@9_)PK zyg-nxU9*Jb<#R^Hx%5Irka4ZH4v|TBk5rZ*84ah52ng@bf#G#pM#k|gSFX4NEeq88 zARHYAQ;swS*{ecA9vRWbw{EAKTUcDZ$rgCm6bvLQdVBSA`%-|J(gw$C6f8 zLw-Y~jsN@0Ii_&(4_U{}H{wLEI!gR%bpQ5GH&_TI zu#Cq0_pC##%yd2*Xxf{~>;3lU@Z-h>!p0c`6*23D(_<6#8A#6g`^)j_dag4EK1EKq z6sL^){P6`tO2Ruoul?6-K1id*IU}y?%_WJ9LGxkK#BDd`uMP?R-2ZF` zA4VLM(0TV4{Q2+Nx%T=0UoQAvM0gvZJ_#kd>?{gC=D8iZ`H+^1%gYCkTKxB7<^xN4 zmZTo&oB&_5>AS_HE3eN&|4Sgz)^2II9*DHyx%+Y>wYy<2W047ruYqXY*w^<=!s9t<-O|&iw(DSi08QNw z6Lns}!BKUfN-!R&-bt@7DKEDIa*wYKkf{{^?Wap{cpgMpgD9^~L`^STi zVPTv=nHefGp>)~~WpnCx0A}Od+`oYgxli+Ad&ce>s*0^ZfEESoRb<9z`oXRA94)OT4#c$7w6v@I{E^MU^pM@v zjP_JADd#1&7P+__u54~1hDV^WK}_o6;^Hi@4bqfpnVH&c;q2F!Df?o>;u>Xvdk|S@ zbn5i!uf|0fevlM*2rn3e`-P3dUoLBZJ;{x`mE5A8kS7{WOZ0GZLt77(xbAwK^8+GF#oIyxprA3XX1 zc_4ku;dTr_<+4hRsxk{;N%BB3fHdDkqcQ~ zMD!WZd7IxPjT8drjPu?+mUkgzz75+d7D@P9=<`n(7C0v4ti_81>V}50pFh8d8P-)` zB=+{+I%>y6MF~vc)gceZuEkW_60jRk0+u& zn#%?x#O9w38Xf4kbZR3>GR(H*C^1;3w!PhNaF7>hZ|z%4C`4!9ii(O}ToiL86mfF{ zo{mzf+%W<67;sQ<+5Ijqv`qr9Vbq_dsII}T!G%_-K9k>5pQw*^hl%Hm2rfo}H zo!5DhQ3-tOZJa}SI-Dm?c!EI!=gV)WyuE#fDxK_#W3}f3;{X%!W7GG`8lAU z@4|iojy`BlzgN_B^|aEBl>|7B3CDfY>FYpt7*lgTjdjEd;cy!d58lT68*t#Wy?5_qDhcefvR?wb#5fA} zmv3^_0yVqlZNbnLiE!Cf8nr()-Js%p0~}TW%Nn3t*|9O-%Pzn_EHv{r%Jc^cQ*~lI z-P7lxl+q*?s-sez?X*%!TkaW{{ zyw(we4444M*Z1HQ%MR$9P+!t1u58zx%pm6k_kpjlPB)GEe=IMLzCdk{*s)@FceG(0 z?96-7qAEw94`{eiiyZ9G;Z$d-JuqLHXh)59b=7>xu7;eu3X;?%1%>AqV0=YdN_5Kz zpH-QVo(qID#3EZWloe!r9?-7`->Y!miR;jxX@H!xtj`}Blt^o5*ZUIE+JVRlm=Kw; z^YN4z$1{jnAtgo6D*#<;M9SGAgzr9vUEYQ;poz3occBM8SmD6w`M~pFqb>lnVJIOM z&{+VaNiG?7BqgqU($&=!X~BT%aNM`IlXr3Q=!2wN9Rz1oX7vtF7+LG~s1*?p+FaW! zU<^)_G3R||U`yQ)V891x zbmAIZR+y%9&``cEN~i&xzKh3PTNNckb4*arZ{526-tYayM|qiv*L;XzYPJf0d3KD} z<+v!6Dlr|>uBo$@2Fm2x4Sq{GnTZqonzItLu1(*9>5EVZ5zf+~D7Z{PLz+8((wkM{{Rz!iIG|EyJc=TAxuvpzjngD=K94%-8g7Z0OcUFP%VnDhU z3axkmZbn_VsbigHlsg0`$FHt=VUGeOXx*ZJ;*Fw!`p0cL(gcg*>jP4aZ*@dM-wk#M zazL53T#C_v<8VpS%!W&OXb4ee@Qgb8%*Hx|64!arTE{^pZaM&7Hy3`Mqzbo)3 z+RFD$q3l^2?F84ZqwOI*nAS1`l#{LSSXPAOus4_BywhEcLMy7Mv;b~sj|eVS_b5w@ zMg`k+*YBeCK<8z;H%SX+492<~%-&6_CkaE3Dhkl+nX1*XJY$YNda;hf%OxU8s$Nzs zw1iRZ4I*Rvqya)lywF%)t^y%YkukbOy)-2y|ZE4+E>Ql^z(I+_K{$yW$^YzBTH3^8tJBG~{Y#~oX&Y{UT(+Q95(Z-=6 za`#|aVREpr0kWfVg*`iuhMSldoisAppkgZBWq%y(@JR@FZ4PE90}uf-E)+-;aDu#? z3%sh7DGidh5LxUtBY^lLHl1j2B{_wQf8)|s4K5R-dBe3q2;2cl&I9yf2sk;|XroHRpl6+R*U>7ePr zi(JtDJmi;K*&ru_t6OGcIfKv#!VYJiUQ4)^mYuE*Ba3L6lyGX`Z9IY1Ts&s0nzH}o zFMW}J*kAf0t8a#dMQ1zp+M^%*=!!?2rs4E*RVn)@GIAgR_$T3BB+<6o*2cKD^C3j~7}@E1X+rmt@^ zP(uR3A(4Z@gfeS(0K2F5c6Mb8=vDGj z$spx(!ju!G_jwN)&tyUe+wMgT#?`6N;v5W)$Qd7_nYU^QsWN+&zrxLMk8!hwV*xb=;MX~+EQi%kqD_WhAg6v{M;Z?eyAw&hS#n5~K@kXsP}0m7w9 z3OjlmIDWp|6=!EmFOGb|0Td!;)w{ao>(-mu>7|=nDB!IxZ*0;umo7qkK{2v&X_wD* zWQS0~U-9|#HQ-Ga9EjB#&(vY}-MbScP$pLO(~9P>i6lUA6nBJ?cePrr_p({_{X1iA zS{?t>48iSaH`^^21QdIA-mxS+*GSx0-mC?hU)6-rADLb;!TjH zVP2RRax9DJHJk$q_4#3?>X+EPc|93{!~rcZCfL^wc&e!x0Qb6nHqMMR77t`!{zloQ zC*3m^jbRsjFX=>~qIzYGPJ6n+2%++znJ$idRfW#`P~o|U@}X2ECWy^sa?`o9#fIs9TIB)FGPfd$d+gr z8>4ks)O7NL&n!2ADHAfl8p0vfw3hQq_=#N0QvV@crrW`M3XA3#%B-px*rvlFerD7z z2Zux=5MIvI08J=@$1D~R^MhK6Zhh8H8SIr@K)Bwp7GpQk%_4|T_De+MaH;D;pxIE_ z<|mU;t8FwQl@zxhEuuxVw1*u64SS78N85zyGzh6ByJDy3Zd?jO$Fx{33|g2JjI=t6 zww>Us0dWD`-wGBH+JA~NN4j5=`3|9PmjD!CX4T>~kh)G_0));K0ACD6=T;a+M6DuT zU9^YpQ?vt5!H1_v+bVI~)xnt`<{r>5-QFZv=~L9S+1Y_U;7NVPX|!CIEgR5cIbIh7 zqbR42csX+>R-{o$qv~ZT+9f5{@=VtS$K}D?y5%agiAL|?_NZQ)(IM3TwkR0+u-clZ ziNkBOw_zKPiAMyN0?c|#%gY^R*<~#yhpnvDgXn5H2YgWV9Mb~6jg-K8n@k)7FK@WW zh<&hy(<-i&b^sashb7hGT$crInh30U$`VX~nw*|KA*!N60r_x@slZYj=aTpxTK(n$ zP>CD4VfStCn%&MHYJ<~|Dt~!TI{Z5W z*+ofNL{!s=Q-Zd`R>qKe$8EIj#ckHs&>^&*tu@kHBeL7}&^SLnKJM=2B>Ct7LT4l) zhh=F(5URmKuaJWDKJ;N50x&{1Dq%DDRq-mt6){^tVsGBXjTX4+HhSxZPI0av;5I}~ zt}C(pVMO(x-rQ~Qq^EUTI}N#gS)I=ApTSzXT8?f6+>{SVjWp;;myGGTHr9+G3L z#-(CArL~HZ)TWzXqhShxl(SkfikX$QxZI^*S#LFadkjx&x+UcP196J&%we9V6yt3k zz8{wX%Sp~XNI{uS$`+&hml{F#O!rX=ddAnTy^fBidY+|bz;kOK<&sq1$u~;{L~P(8 z^7~w7MM{!{FJD@2ij8uY&~Q4o$+=*_*}-qRU#=ySgM-6&8V*sLq35p@E&>%4xCeO_ zy~u9DOoc@{3<_i%!b(a|Q zl7)8T1&RwmUA?O5woVYHUglP3U-O>^a1FSsk4uIa^wlW0Q8HE?%UXj?H8* z+zP%I7>L|tOC5-TS{tK#R7aHWsQK)5LK-|9rLFmf{Xqi(A$!=9>2CX~(13UN8Ic0n zh2BDu3E+>J)8U*^$PujD8QBhWevA9P)c@QoDx$yc71YXE)yrkJ3!Or&p?J@( z*t=!bArJ@a-WJ7G%=lHv@cYfIvb7dd5jzy$CtTq9myW+ai$FX(yJ2yROt-FI=Y0br z4mg%Bx}RmOz-g43DH9HFXgIV2Gh>tq#A56HTz}pDY+jgP*i??)!N*Z8Hkm}6AB*Ew+ZhaAh80jVDRE&7>rW1OZ7DDEOfEM{0); zA3oSGVgi61e#*uC?}4xnFIX*8~@Izbg5(2NF?a=L6=yE@fAqHv>hjKtDeY?lwuU@r{ zFOG}L&Q+6>>()_){B?@-VuABKXVmJ-3fnTO!XckHRyLCNJ&&eaWp58kw~-FpGH5Ha zLPt31G-$;tX!d3{yJCX_;?bgcIUuPpgsndfw`^b7HaVqpS_~PLgkTiNFEO{>vj7Xi zyrMq>j@D^`!ded)UFlk};_8r_$PHm(n!oF`psJc*J%}E+gRL)PV7hUt1`K-eAu=o# zonF~)tT21C1pz))_%%lt4xP9!TWv0;$|yqqpfS$%n2LBk;p2zA8Mb2gwgO_o!$bJ9 z%f|M)_RsHWI82&V(SVFp9nDbU$e*<{ngciqs7!t8Gs39)6d{Rm!(zhA74b1t@YF z`BUo^tApdHm>1y*t-deMGL%sq*j*sX7EnHLDJgTLu0GmJ&lQ9nUA{j&ssSb$>Y>Lp zfzzz*&@$C*yA7>e%2wB^s+gkgkrCg*`6kI`IIfdp1^alp2QpO$Zg)f!B}{nWAE!O2 z%;(-}vMOSZI|3UE8rw(PvBF{Hlb{y=!MaY1{NlKEqrfQ}R6v?BKaG!b~_4fA^A zm|4Bz7|^zX^d47hRnf=qi6wYAY&Y$%LoRMp7660w4%RiXcTD4;`C+@wKq&nR^e!-e zB3AgJmdewowUlO+ARXc|3Pm4gnVL3Ou4To<6s>nRVsp<%1iU(0?sq+5za58N+<5sK zjt*dTuhT6r6eZ14Qc&=ZB%ACWF{^UhQJe`230v)``milw2#7Bz2XGNG&hrelq4^>a z>>2{e#f**x&*p*oOs=uX;?B!IeA~_)VLXU-s)>ZP8t>i`D-e- z+E9m%L77#ymmGpzweYNQCp1rhZiOcb>Po~t3VQ1WM(IGqQZ=jg!|spcu~aT{fJLf+ za|ayhz79jf}LBYPvSJgXc-L(4HMq z@z@M%KzV+Sj16Z{WN**6S0Fa-5H{g@CV0A@00;jXz$%@l4tF>5v52xl{@F8~;;JuS zymyI2jNe3oRTKps;Z1TgP;g9c9lBU{x@^P1vIg7ETs|ntvN=^c;^>OA`16&wd#Tk7S!;msmXg;CXCPE$SFe^? zly80}`YrNhumJmO-c2{wi3KsXFdi;=58X@8xKNg9;I^|Z+x_)TF%$5sjY zq6yTaG&7*rkF3uk3JYztLw={gj{yttD}mbGuVS@73VgFnOE;ejA8c2H$fOs%A9Z1d zi1T*ccEdV7)Yf*(BmUJJ;qB2}F8qMQXk|q4=1yvv?W{}y^Y+P;L5?*$P_YAfYP-pa zgqZlN0Ek8amSqE6>oxvy`8+#T$xvqZ zfsnUyI(~l`9i!gV)P_h>!1nc`ere!DsH&;KKnDb*SKn495cCXiYu%36c0^pH@aiHm zLK*rofGI%R5l}47nWxzq8FywlL=>QC{e`3c_;MRWh_i(QnuyfSs=r_!^az2Pft|Gv zd;l^7h7{Fm^l06MVBcxPT&7^-4mgCo^Yy-yObyhHUtTPss@UT-unF1`$QC4jAP~_3 zR4zlcng)lKmUi;Bx25DRF&t*I(X9wb4;cV($0kOG@{wd+>?9Ge6Okc}IRKJP4i>5i zuq42iT?6A#L=%HkJhI2oA%YQvOo--jhbhXL2g{L0IQ9V3hC8k`!{i@I z6r@3G6R(L%_hZYIK^YAIOaDFl*53Q$8vKG}MRu>B z6+YmBssy4uy_c64?bZS<;04*0Ye+;R@u|NjE`HLi>M#_|tru$nlAlWVD6DlW@#f~Y z_aqVgHn#wvN5G=E0M%sNok`jwtztBQry!G?&1Im$2VIqiukX2X6PD@?4&C-h0DClW z2Rom+@j}zt0i^y2I0!f;t`;=m#YOO7ajhKWcdm6EcoC^IRHk%H`>UZF_wJh+(JAr6&CyeFOwNEz(MMZOX zj0ejb{40y^OG^dJvsZ4vyr9j*xTTS!z1U#7`1$6$Q{GDF(~fN);+1AAKEeIE=iJf! zez7A49TO9Sc5)No>`RAn>0?hS>@B!j{yq@ax}G3e=@fBah5=_Jk5LD>U+|f)T#>{J z>!JJP^@xJV&Q7-@ym@bP0p|A#Ibrt$Kz(OyY`n6y_Sqt#!Fqlq0<=zJ7EaTxA)F^~ zqlye9(>^4vP4l+3x5JEC-lgFp9Yj(zn&%lD&roW=dJQt%?^)_T{h2DgpC6oMVrDXp zQj8fc^usQ9Rn;xEW(NnMz=LpisE zFgHRIPML!2Vb|~#LK?yQAjuTIbi1cWTD8n}{$B3OdN>q5E2MqE0Z;K!8}b6fp%0h- zXbmsc5(ZQIS$JE7A(k&rFkEONvCy9>{Z_IQOxtd|Qe!#Yjy6dpkA(rx;%)?ojyWqP z_Qq0EO6=zsb#IX#N+$M55*WIsSy?-~eMv^M9pBRx0}>Oj$2zV(n!Iytb#-+Rg_?E1 zdS7I$Wu0uyr@#5GMyHk6TuTJG;*2MMz>*{G$`pWp_e`eU=q|RDr_=Rh@iqs3~C+Fdf?42CsMuvv2$5h5p)4J_kt#sVbvgx9*T^?ypTQLXGu!WUP61?3V zv;OE%W|iko5S#IbX z4t$YL4`sHnwER))c{by;igu5{p--o99gNNU{h$ByC4UZNclsLiA}Z!n!nVFbPTM(p zm{_KH*z~&_ld^MNRh4Iedj02Xissj5g6$-nDqqemNy<7gW`fNF=XJSueBg5t z^yp#qew4kr+7i)J)X$XuzV+9(-yQo?qqP0Lu#l@gk~=}USNGQ61D=V4@ZC$jLF)dW z7w$ieh`-VZ(f_;u;~;vS{(6qVI$}`pjO?EeM1HW;yy?!et^V@fF-mR|df?;uMRDIo zyeWQrZF?S@v((f%*$EC2xFwF8Uta%LOJ%94^ySo)Ybl?7EFH0?U*wwMZmcLB0jxVO%bvZ%FTDMLL^$Xd^dwq9mVm8&c}qx#!zTtkZu z;i0wI%wf1tt*k^~bhN@JR34+kJ4>C`r(f&vAing4I|l{^)-+JQo}IaTIZ^LqWP2MM z4=00=&{F);dm5{&Z=0GPQgG|LdwP1x*mJ8rE*pQ^<5}=s0eX^qH5fB!Eu^IxfOcn$ z8{MCY{W1RJ|3F3uf|q%Td)kCc^kRk=7t$3(9QnZc*EgfgEM?f(2=sMxKV1;hyd zyqXn>bci-!w5Gq;n~U}SbcS7hWuG~?Fbe2Edd)( z=P5xEU!iU>nP{9(uO&{ztVpWW^ zv`0I)UE)#E*LCrui>w-gsEr$vWnE3xxF8mAN_}fZbe4z?W?g!Z3KvV;&G+0Oq!#`b z!KnvKz6NN{vdXgFdcMKE)zDd>(|VbZhW~zfk%@(!9gi|(v3wv(_l0Q~CMt#8C+!JN z;^}pY6JNIVZc4oggRL1AQqSK|SA$6e@lp{S^t+2=)p74Q_4Ip6kcj|4)}~v*P$%YA zNP3Rhm@pwV|NYeqH}t14WX=ne5n;FA9v#F3ybd^!rP2zz|=)EDnzTVJzy`fQRszkPz0yh3&OvWsT7$ znWoiAB^8xdjQec|+l%PB-F5Z`mtpYO0JQ-7Bc3%FU;Y{+8On0?)=#DcasM_Iui9Gh z735-8xs&qQuD*PjP!xzpR~hb|I?Z=is?8xIGm}xZi0avG#fEsL+>FMLku8ag^2im2 z+fT3Y)yuafB|9ES zp zK+XAhQ!e@p&83I3ve2e@t-~NQj={(letDMftlOL1ew_AfazPa3G7Swpd`nosFx}h7 zudtmOZw`E}s03Vr8X2eB&j(%yz#YH2yx2C1g^9EeFifn3;Smw&&DB{Qf%*CQjIo^9 z$wo0`nF(KVdHCqj)$7;aK;_vDF+@!=q{Y^0_4F!yeGdSQVY2v5`La8TS!!3gxVR)& zs6ILL>R!Ko12G@~q65ZWP_HvqTe^$F`!e%A$lG%A^CxTTeW(QO-vtJe9Jj{E1YLWu zjGCqboAY6}{tuCnZ+#@+GY$L=Y9eQ~VB|&7uwyn>Df<7 zh^?=FQO8u-#0d&K!14LvVLe!Qo|%)Ii+e=~T?4CV2lf;MJncn?b6VQkV!;i5%6Zn{ zTfLlU(3^~NP>d0I`I4opNSbo-EGo*lvA;i5M%MasxHda1X}?w=-YezZ3H0zH)H!`B zIWZAhhkw96XSwnCJPl1pGYP|7Zwe8xVbn@5v{oJNKa!NJ1E$_iDOfB&O%Nxb_?}KQ zF!=>q=1dxSZ`xIho(^Z8d*1)t?pYCXU*M5`|MHBan%}iyP5RNJ7a*WFMwf+fBuPeX zsDR}@-HsAl$n9sJA5GFXxZuH@LLR=is=)QT!No;j#)>f>P=&zX*#|*taDU9L(eU=PFs+;Pray7h57-;0pCp|9|w zkmE*8%g?v#dQGmXIxV`shUcAjE&Y$pL6M6j-yd#B9$_8XYuxrZH9qoMtR3ev`d-00 zuohBs`yk(xy1$bC*)6LT#aS?g61%fJy^}DT4V;e9 zQ1bTs6Sbbuk6_?qJT2hV>s~2a?@f;j1Un;$E8jpI2_<>O!f3gKKh4*Xk!Tez!-$G# zEfW)Ih+VuS%E1 z!As>OYvbsE(JnYd-ZAL8|MmB%*4S2u#zXru3Ayz4_Lfq(g?xVCJ=4qGCW^Zu*y;?n zXFVSv<(rtA`k3NuO88ksA3WvwEH`dmy8XPZhw?yNPA>t6mzt(yC^ePIhV%;k%)O$x z%x4M6(GR%CB}z&oDk|@%*&3BpC+-l;pX?!+*J67zSjEQ9PRf=@nb#I(;!>GXlJr2l z7b29NTG)`bz|x-UXsSN9n2BEOf@ByA`WcRp5Ly%pWiXYc60~M(-Ah-@K1N>oR(dPV zTn?#>{_wzOCp!nUq#uh@(UUkUrB-rkCFXzVN=MDKo?qM^h6VLaqx5xl0MX@8<*2<6 z1DZJ?qiy4MH`UV%n_b{Co8_Yxb1Yl+^!r}0ZxD~7;Qapd z_cXRF^>XnCW*Ag*>$$DvL$Eza2CLhSqnCwwNG-~wR1#RiJGb+Vq?p+Q4qVHF9Sa~5 zl}6W%R?P4a7@1o5#Lr2?H2Z5P9Fv)@)%TAc!A56fY`(7EsomHSQ!Eu0>L_2+cTS~1 zzXO&q)WJCTMAV2b=2pn<$>n9&#;YR%)I?ufS}xexZF{ZlwK8#*t8Nr^W(Yu)mZy%H zQ_65h8R5mpGw_j9w7{f-M-Ly~%rQAEH=)eU4a@JByKSUW)W+}qXmMJ}*yOCx6O(_~ zArMGMC<7F54hL7Y8{*UKmPa$M?BUgPboj%>^eI&$4brd+1;*V6Uw;xCyVpth|CpP5 zm!6JV3Z~$2G$W$q|NhE!ExOEl_rvNKDXYfkHfF~r8luw^ac)On>ywTb8V!7n*YEb# znAU|t08licC^N6P$jDH3)}o>I#>i?q9yn=38ThT6`t)E2zo~W;`|>Qk<$W*%pXC(W zU88}gki4iA8{4#Y9|JQ?KHEjIklTt?vvuqmvricwXUoSC_N!ZBJEi>`&~ zTA&3;Gzg}L!dBbVROV-)!ethXkJ+d)>-h^QvT+@G75d%RipvsdTjcP6OUI{{g`VPC z6K$V~4k#5p=u1ybNn(6=dP20ucM}RZrOVd(Z7Ka3D zG*;t;%`-I-@>dS4SLRuq7WHnu3X@x2Rh-OMkNX(S-g7r-!DdavyV5xV+AQ*A1(eLH z1#A)CborxGDI-<(as~RtpFeke_z?ZN(2wi4jk31G=Jt^g1kaIE9bDQY z6Dva<{YpxBqhoyZPRFKPPcc6_*mY+0@EKCm?(Xi!;T@e89EMg%Nf@gv$o*$K9*jNH zxEva)kpA57Y0_huy6NhZ$8EmTL=1gypfxr-j7C^}OG%pjl-XiGbcK=6DCb~tQ|`{P zt-yn`r1j0s?|X?;^1l<;@Ty3pJxyBPK z_nO}X8-luRw1s$ixw-Kg=ra1d8|r61OX5Cy{P>6dirg(nC(4D9C54rhPv#S~i=bLf zO?}uKa`N5#_Y?5!m4)qgnqJh(ddXL7xOiW>{kF1F=!oDD&KxT2VR7g6OsJ|MU8U#d z?oZ^gg1S?{Zs|JA*B)zqfjx4ezdHWBqWb-j;5mcnVv?jfP|0}1h%uYs=n8b>$0 zN%6;bRwwCOW#fd~bF~}S^aOODB#7HwxNuj81)AB zZ)&_|9M|qaU5>I_tiTKuf{R&b=w*bx9Ue}`h1f&?{R$v%m00}*h+OLP%v-1;6MT+i z#@Hp41o*1w+#GjsnO?mBl@4s+G^K62Vuqw6IFh6tFDPa|_XAX` zwWmfDtZqv~BOe341xSoS+EZk)&#IDNy#4$fXPZ)v`PJ!`&>L_E14$U5TKlYy;#N{t zj!3MwpSYH*_!8Im>%q5g$3bf2XMk+imQSymKio0tc>`DkyWTPtWQSgUeguly8eqsq zc7Y@q_&R~jMJ#G1H9xtGZbBW6$Tx1i5_|db0vM=4qpbV0gO70l!_FXkMn=X>%_`@t zmiUJg*We(x5WN0I=XR)kUgB8;gLyDh?#(ce4=#XtkyO0)fe1+VO5r*oVSS17?u8T? zPx*3N6UfqoMNq422s(mx+B$DBa}L(W^_0Z(6pkOD?C8#t7J#w|Q$`pqNmLfFCUGa;bC=fTtnDiTIW+d&om=56e^fdSGwW{ovotC@3*jEtfU zk`y>$EE;!)WafL4tb~MW+o`-SAs`Sm8)A3QUVZ}MQ^V6#i4Sq@p0|Ml$7qW zXGV}zr``Ll$a3C%c(MypnW1W|`>Gk_M23ACY2&3+tawy>j>j1o7#@eVIj}B3=6Iot z*_}=zz;e%;mq8}>+vq3_gPxvYf5v;s_gEsz*wDz9WacQSp%QYdtLM!Jbh{v|z==9k z;J~R~Zq0s@FkK<-bx6piMt|Db@p!R|j8BiV%F2Fup4kF9**cfI>(Ve69Hw96>%4ef z1>d}RbNq1#(<5j$Gpd(ahi*&|r+@;byg4sC+*ivk8FJBx<6XG0-0rXbs6P$NLaA5R zp)}&X$ozQMa+(pUm{7h!bv3n>ba^k(Y{U3EIR4|!C$2$EKvdEfZajW9R16yv9RE|F zmU^=fMrrGImB?wsjieJ4jP}6ibQ~|&<-G3*p?$8WlyiR@1DN6KxjB8LFo8<^=FGS` z3dhm3NLyD|JmIaMIrH@6m0WGOZ(w?5bW0&sbo%i#b4$y%F#hvYRBhekTERM?7JK9A zNse76KEHzH;PQZs>BrPu6JV`?3B1eCCn2D`^R2bj=lk~pS`f)4UF78