diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8370b446..a76f9a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,164 @@ on: pull_request: jobs: + hostlink_management: + name: USB Device Management and Author Proof + runs-on: ubuntu-latest + env: + TRAIL_MATE_TRACK_PYTHON: .codex-build/reticulum-directory/venv/bin/python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: site/package-lock.json + - name: Install website dependencies + working-directory: site + run: npm ci --ignore-scripts + - name: Install pinned track receipt interoperability dependencies + run: | + python3 -m venv .codex-build/reticulum-directory/venv + .codex-build/reticulum-directory/venv/bin/python -m pip install -r tools/geocaching/interop-requirements.txt -r tools/geocaching/bridge-requirements.txt + - name: Install pinned native identity crypto source without building firmware + run: | + python3 -m pip install platformio==6.1.19 + mkdir -p .codex-build/hostlink-codec/library-deps + pio pkg install --global --storage-dir .codex-build/hostlink-codec/library-deps --library "rweather/Crypto@0.4.0" --skip-dependencies + pio pkg install --global --storage-dir .codex-build/hostlink-codec/library-deps --library "nanopb/Nanopb@0.4.92" --skip-dependencies + - name: Verify packet bridge pacing and origin boundaries + working-directory: tools/geocaching + run: ../../.codex-build/reticulum-directory/venv/bin/python -m unittest test_packet_bridge test_packet_bridge_pacing + - name: Verify native protocol, storage policy and cryptographic vectors + run: | + cmake -S tests/hostlink -B .codex-build/hostlink-codec/native -DCMAKE_BUILD_TYPE=Debug -DTRAIL_MATE_ARDUINO_CRYPTO_DIR="$PWD/.codex-build/hostlink-codec/library-deps/Crypto" -DTRAIL_MATE_MODULE_NANOPB_DIR="$PWD/.codex-build/hostlink-codec/library-deps/Nanopb" -DTRAIL_MATE_REQUIRE_RADIO_IDENTITY_TESTS=ON + cmake --build .codex-build/hostlink-codec/native --parallel 2 + ctest --test-dir .codex-build/hostlink-codec/native --output-on-failure + cmake -S tests/tracker_recording -B .codex-build/tracker-recording -DCMAKE_BUILD_TYPE=Debug + cmake --build .codex-build/tracker-recording --parallel 2 + ctest --test-dir .codex-build/tracker-recording --output-on-failure + node --test tests/site/author-*.test.mjs tests/site/hostlink-serial.test.mjs tests/site/management-protocol.test.mjs tests/site/config-*.test.mjs tests/site/object-transfer.test.mjs tests/site/tms-document.test.mjs tests/site/track-shared-id.test.mjs tests/site/track-recording-convert.test.mjs tests/site/track-device-snapshot.test.mjs + python3 scripts/check_esp_stack_hygiene.py + node scripts/audit_app_config_preferences.mjs --check + node --test tests/site/track-snapshot-native-interop.test.mjs + node --test tests/site/track-device-catalog.test.mjs + node --test tests/site/track-device-snapshot-build.test.mjs + node --test tests/site/track-device-upload.test.mjs + node --test tests/site/track-manifest.test.mjs + node --test tests/site/track-editor-metadata.test.mjs + node --test tests/site/track-metadata-query.test.mjs tests/site/track-metadata-query-python.test.mjs + node --test tests/site/track-query-continuation.test.mjs tests/site/track-shared-viewport.test.mjs tests/site/track-shared-query.test.mjs tests/site/track-region-client.test.mjs + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_metadata_query + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_replication_inventory + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_replication + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_storage_recovery + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_bulk_transfer + node --test tests/site/track-replication-inventory.test.mjs + node --test tests/site/track-conflict.test.mjs tests/site/track-ip-read-session.test.mjs + node --test tests/site/track-storage-error.test.mjs + node --test tests/site/track-version-history.test.mjs + node --test tests/site/track-multidirectory.test.mjs + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.geocaching.test_reply_queue + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_reply_pacing + .codex-build/reticulum-directory/venv/bin/python -m unittest discover -s tests/deployment -p test_directory_image.py + node --test tests/site/track-network-config.test.mjs + node --test tests/site/track-manifest-geometry.test.mjs + node --test tests/site/track-datetime-native.test.mjs + node --test tests/site/track-manifest-native.test.mjs + node --test tests/site/track-upload-native.test.mjs + node --test tests/site/track-manifest-sign-native.test.mjs + node --test tests/site/track-device-ip-confirmation.test.mjs tests/site/track-device-sync.test.mjs + node --test tests/site/track-shared-package.test.mjs tests/site/track-package-publication.test.mjs + node --test tests/site/package-archive.test.mjs tests/site/track-kml-archive.test.mjs tests/site/track-draft-backup.test.mjs tests/site/kml-original-labels.test.mjs tests/site/recording-original.test.mjs + node --test tests/site/track-bulk-transfer.test.mjs + node --test tests/site/track-transport.test.mjs tests/site/track-transfer-labels.test.mjs + node --test tests/site/radio-identity.test.mjs tests/site/radio-identity-native-interop.test.mjs + - name: Verify native Geocaching replication and signature admission + working-directory: tools/geocaching + run: ../../.codex-build/reticulum-directory/venv/bin/python -m unittest test_directory_sync test_directory_transport test_publication_receipt test_snapshot_retention test_query_release + - name: Verify native Geocaching publication and offline replica recovery + run: | + .codex-build/reticulum-directory/venv/bin/python tools/geocaching/test_directory_network.py acceptance --work .codex-build/hostlink-codec/geocaching-network/state + .codex-build/reticulum-directory/venv/bin/python tools/geocaching/test_directory_replication_network.py acceptance --origin .codex-build/hostlink-codec/geocaching-network/state --work .codex-build/hostlink-codec/geocaching-network/replica + - name: Build device page and install Chromium + working-directory: site + run: | + npm run build:device + npm run build:geocaching + npm run build:tracks + npx playwright install --with-deps chromium + - name: Verify actual device page and KML parser in Chromium + run: | + mkdir -p docs/locales/zh-Hans/web-outdoor-management/assets docs/locales/zh-Hans/web-outdoor-management/images + node tests/site/device-page.browser.mjs + node tests/site/device-page.browser.mjs --normal-flow + node tests/site/radio-identity.browser.mjs + node tests/site/track-kml.browser.mjs + node tests/site/track-kml-edit.browser.mjs + node tests/site/track-kml-style.browser.mjs + node tests/site/track-workspace.browser.mjs + node tests/site/track-attachments.browser.mjs + node tests/site/track-metadata.browser.mjs + node tests/site/track-search-metadata.browser.mjs + node tests/site/track-shared-package.browser.mjs + node tests/site/package-install.browser.mjs + node tests/site/package-archive.browser.mjs + node tests/site/track-kmz-import.browser.mjs + node tests/site/track-draft-backup.browser.mjs + node tests/site/kml-original.browser.mjs + node tests/site/recording-original.browser.mjs + node tests/site/original-format-migration.browser.mjs + node tests/site/track-map-icons.browser.mjs + node --test tests/site/track-ground-overlays.test.mjs + node tests/site/track-ground-overlays.browser.mjs + node --test tests/site/track-time-filter.test.mjs tests/site/track-time-editor.test.mjs + node tests/site/track-time-filter.browser.mjs + node tests/site/track-time-editor.browser.mjs + node --test tests/site/track-region-preview.test.mjs + node tests/site/track-region-preview.browser.mjs + node --test tests/site/track-region-editor.test.mjs + node tests/site/track-region-editor.browser.mjs + node --test tests/site/track-kml-crop.test.mjs + node tests/site/track-kml-crop.browser.mjs + node tests/site/track-overlay-editor.browser.mjs + node --test tests/site/track-overlay-input.test.mjs + node --test tests/site/track-style-editor-labels.test.mjs + node tests/site/track-style-editor.browser.mjs + node --test tests/site/kml-balloon-labels.test.mjs + node tests/site/kml-balloon.browser.mjs + node --test tests/site/kml-list-style-labels.test.mjs + node tests/site/kml-list-style.browser.mjs + node --test tests/site/kml-screen-layout.test.mjs + node tests/site/kml-screen.browser.mjs + node tests/site/kml-photo.browser.mjs + node --test tests/site/track-style-map-labels.test.mjs + node tests/site/track-style-map-editor.browser.mjs + node --test tests/site/track-style-inline-labels.test.mjs + node tests/site/track-style-inline-editor.browser.mjs + node --test tests/site/track-style-identity-labels.test.mjs + node tests/site/track-style-identity-editor.browser.mjs + node tests/site/track-gpx.browser.mjs + node tests/site/track-device-catalog.browser.mjs + node tests/site/track-device-upload.browser.mjs + node tests/site/geocaching-maintenance.browser.mjs + node tests/site/geocaching-save-recovery.browser.mjs + node tests/site/geocaching-signature-recovery.browser.mjs + node tests/site/geocaching-network-maintenance.browser.mjs + node tests/site/geocaching-query-snapshots.browser.mjs + node tests/site/track-ip-publication.browser.mjs + node tests/site/track-ip-publication.browser.mjs --normal-flow + node tests/site/track-network.browser.mjs + node tests/site/track-ip-connection.browser.mjs + node tests/site/track-transport.browser.mjs + node tests/site/track-refresh-network.browser.mjs + node tests/site/track-conflict.browser.mjs + node tests/site/track-version-history.browser.mjs + node tests/site/track-multidirectory.browser.mjs + .codex-build/reticulum-directory/venv/bin/python -m unittest discover -s tests/deployment -p test_shared_directory_network.py + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_replication_network + .codex-build/reticulum-directory/venv/bin/python -m unittest tools.tracks.test_storage_network + node tests/site/track-device-sync.browser.mjs + geocaching_web: name: Geocaching Live Web Map runs-on: ubuntu-latest diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_runtime_access.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_runtime_access.cpp index a8609239..28bbf66f 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_runtime_access.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_runtime_access.cpp @@ -18,6 +18,10 @@ #include "platform/ui/screen_240x320_runtime_feature_port.h" #endif #include "platform/ui/screen_runtime.h" +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "usb/usb_cdc_transport.h" +#endif namespace trailmate::apps::esp32_lvgl::arduino_app_runtime_access { @@ -127,6 +131,30 @@ void tick() return; } platform::esp::arduino_common::storage::tick_deferred_storage(); +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + ::hostlink::tick_publication_tasks(); + // Enumerate CDC without allocating HostLink queues/task until a host opens + // it. Run command processing before saver gates, just like the IDF loop. + if (usb_cdc::start()) + { + static uint32_t last_start_attempt = 0; + if (usb_cdc::is_connected()) + { + const uint32_t current = millis(); + if (!::hostlink::is_active() && current - last_start_attempt >= 1000U) + { + last_start_attempt = current; + ::hostlink::start(); + } + ::hostlink::process_pending_commands(); + } + else if (::hostlink::is_active()) + { + ::hostlink::process_pending_commands(); + ::hostlink::stop(); + } + } +#endif #if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) // Receive call/SMS URCs even while the EPD saver owns the foreground UI. ::platform::ui::a7682e::tick(); diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp index 475d628c..30895fb5 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp @@ -5,6 +5,7 @@ #include "board/BoardBase.h" #include "esp32_lvgl_arduino_agenda.h" #include "esp32_lvgl_arduino_app_runtime_access.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" #include "platform/esp/arduino_common/display_runtime.h" #include "platform/esp/boards/board_runtime.h" #include "ui/app_runtime.h" @@ -79,6 +80,10 @@ void tick() hooks.idle_sleep_ms = 2; ui::widgets::reticulum_call_overlay::tick(); ui::loop_shell::tick(hooks); + // Accepted configuration/identity commits must finish their ACK window and + // restart even when a saver, wake gate or overlay skips normal app work. + // This is the sole Arduino service pump; no second task or config copy. + app::sd_tms::serviceWorkingConfig(); // Also runs while an overlay is active; no Agenda page is required. trailmate::apps::esp32_lvgl::arduino_agenda::tick(); } diff --git a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp index c988a2da..cffe573e 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp @@ -28,11 +28,13 @@ #include "freertos/task.h" #include "mbedtls/chachapoly.h" #include "mbedtls/sha256.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/chat/infra/mesh_adapter_router.h" #include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h" #include "platform/esp/arduino_common/chat/infra/store/sd_store.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/boards/board_runtime.h" #include "platform/esp/idf_common/bsp_runtime.h" @@ -158,6 +160,12 @@ bool idfSupportsMeshProtocol(chat::MeshProtocol protocol) void syncReticulumGroupConfig(app::AppConfig& config) { + // Current TMS documents contain groups. The legacy TSV is read only while + // establishing the migration baseline, never over a bound working config. + if (app::sd_tms::workingConfigIsBound()) + { + return; + } if (!chat::infra::isReticulumMeshProtocol( chat::infra::normalizeMeshProtocol(config.mesh_protocol))) { @@ -1042,13 +1050,47 @@ class IdfAppFacadeRuntime final : public app::IAppFacade board_ = &board; lora_board_ = lora_board; - if (!loadIdfAppConfig(config_)) + (void)platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready(); + const auto working = app::sd_tms::loadWorkingConfig(config_); + if (working == app::sd_tms::LoadResult::Invalid) + { + ESP_LOGE(kIdfConfigTag, "working TMS requires repair; refusing stale NVS fallback"); + return false; + } + if (working != app::sd_tms::LoadResult::Applied && !loadIdfAppConfig(config_)) { config_ = app::AppConfig{}; normalizeIdfAppConfig(config_); ESP_LOGI(kIdfConfigTag, "using default app config"); } - syncReticulumGroupConfig(config_); + // The existing blob scratch is needed only for migration/no-SD NVS + // operations. Do not retain a second AppConfig for web management. + if (s_config_blob_scratch) + { + s_config_blob_scratch->~IdfPersistedAppConfig(); + heap_caps_free(s_config_blob_scratch); + s_config_blob_scratch = nullptr; + } + if (working != app::sd_tms::LoadResult::Applied) + { + syncReticulumGroupConfig(config_); + if (working == app::sd_tms::LoadResult::Legacy && + !app::sd_tms::applyLegacyWorkingConfig(config_)) + { + app::sd_tms::requireWorkingConfigRepair(); + return false; + } + } + if (working != app::sd_tms::LoadResult::Unavailable) + { + if (working != app::sd_tms::LoadResult::Applied && + !app::sd_tms::syncWorkingConfig(config_)) + { + ESP_LOGE(kIdfConfigTag, "cannot persist canonical TMS migration"); + return false; + } + app::sd_tms::bindWorkingConfig(config_); + } mesh_peer_directory_.setAutoSaveEnabled(false); const chat::MeshPeerDirectoryStatus peer_directory_status = @@ -1108,6 +1150,10 @@ class IdfAppFacadeRuntime final : public app::IAppFacade initTeamServices(); restoreTeamKeysFromSnapshot(); setTeamModeActive(team_service_ && team_service_->hasKeys()); + if (lora_board_) + { + platform::esp::arduino_common::geocaching::browse_runtime::configure(mesh_router_, *lora_board_); + } initialized_ = true; ESP_LOGI(kIdfConfigTag, "app facade ready stack_high_water=%u", @@ -1193,6 +1239,12 @@ class IdfAppFacadeRuntime final : public app::IAppFacade chat_service_->setActiveProtocol(config_.mesh_protocol); } + if (app::sd_tms::workingConfigIsBound()) + { + app::sd_tms::requestWorkingConfigSync(); + return; + } + const uint32_t now_ms = persistenceNowMs(); const auto submission = config_persistence_runtime_.submit( changes, @@ -1833,7 +1885,16 @@ class IdfAppFacadeRuntime final : public app::IAppFacade return; } - const bool ok = saveIdfAppConfig(config_); + const bool ok = app::sd_tms::workingConfigIsBound() + ? app::sd_tms::syncPendingWorkingConfig() == + app::sd_tms::WorkingConfigSyncResult::Synchronized + : saveIdfAppConfig(config_); + if (s_config_blob_scratch) + { + s_config_blob_scratch->~IdfPersistedAppConfig(); + heap_caps_free(s_config_blob_scratch); + s_config_blob_scratch = nullptr; + } config_persistence_runtime_.complete( work.generation, ok ? app::ConfigPersistenceResultKind::Completed diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index e75b85a6..55740dfd 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -34,6 +34,7 @@ set(TRAILMATE_ESP_IDF_CORE_HOSTLINK_SOURCES set(TRAILMATE_ESP_IDF_CORE_SYS_SOURCES "${TRAILMATE_ROOT}/modules/core_sys/src/app/app_facade_access.cpp" + "${TRAILMATE_ROOT}/modules/core_sys/src/app/tms_config_codec.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/screen_power_state_machine.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/sys/clock.cpp" @@ -123,6 +124,7 @@ set(TRAILMATE_ESP_IDF_CORE_CHAT_SOURCES "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/channel.pb.cpp" "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/config.pb.cpp" "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/device_ui.pb.cpp" + "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/localonly.pb.cpp" "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/mesh.pb.cpp" "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/module_config.pb.cpp" "${TRAILMATE_ROOT}/modules/core_chat/generated/meshtastic/mqtt.pb.cpp" @@ -456,6 +458,11 @@ set(TRAILMATE_ESP_IDF_UI_LVGL_UX_PACK_SOURCES set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/boards/src/board_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/app_tasks.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/geocaching/request_dispatcher.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_control_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp" @@ -490,6 +497,8 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_route_storage.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/route_png_cache.cpp" + "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/route_png_codec.c" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_settings_store.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_sstv_runtime.cpp" @@ -604,7 +613,8 @@ list(APPEND TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/third_party/bzip2/src/crctable.c" "${TRAILMATE_ROOT}/third_party/bzip2/src/decompress.c" "${TRAILMATE_ROOT}/third_party/bzip2/src/huffman.c" - "${TRAILMATE_ROOT}/third_party/bzip2/src/randtable.c") + "${TRAILMATE_ROOT}/third_party/bzip2/src/randtable.c" + "${TRAILMATE_ROOT}/third_party/yxml/yxml.c") set(TRAILMATE_ESP_IDF_TAB5_BOARD_SOURCES "${TRAILMATE_ROOT}/boards/tab5/src/codec_compat.cpp" @@ -621,9 +631,11 @@ set(TRAILMATE_ESP_IDF_T_DISPLAY_P4_BOARD_SOURCES "${TRAILMATE_ROOT}/boards/t_display_p4/src/t_display_p4_board.cpp") set(TRAILMATE_ESP_IDF_FINAL_INCLUDE_DIRS + "${TRAILMATE_ROOT}/third_party/yxml" "${TRAILMATE_ROOT}/third_party/rweather_crypto" "${TRAILMATE_ROOT}/apps/esp32_lvgl/src" "${TRAILMATE_ROOT}/modules/core_hostlink/include" + "${TRAILMATE_ROOT}/modules/core_track_sharing/include" "${TRAILMATE_ROOT}/modules/core_sys/include" "${TRAILMATE_ROOT}/modules/core_chat/include" "${TRAILMATE_ROOT}/modules/core_chat/generated" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 8f48344a..fc553af4 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -132,6 +132,7 @@ _trailmate_set_linux_paths() # --------------------------------------------------------------------------- set(TRAIL_MATE_LINUX_COMMON_SOURCES + "${TRAIL_MATE_REPO_ROOT}/third_party/yxml/yxml.c" # app facade "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_app_services.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/app/linux_app_facade.cpp" @@ -563,6 +564,9 @@ set(TRAIL_MATE_LINUX_COMMON_INCLUDES "${TRAIL_MATE_CORE_CHAT_NANOPB_ROOT}" "${TRAIL_MATE_CORE_GPS_INCLUDE_ROOT}" "${TRAIL_MATE_CORE_HOSTLINK_INCLUDE_ROOT}" + "${TRAIL_MATE_REPO_ROOT}/modules/core_track_sharing/include" + "${TRAIL_MATE_REPO_ROOT}/third_party/yxml" + "${TRAIL_MATE_REPO_ROOT}/modules/core_geocaching/include" "${TRAIL_MATE_CORE_SYS_INCLUDE_ROOT}" "${TRAIL_MATE_CORE_DEVICE_INCLUDE_ROOT}" "${TRAIL_MATE_CORE_MESH_INCLUDE_ROOT}" diff --git a/docs/locales/zh-Hans/web-outdoor-management/15-配置字段覆盖核对.md b/docs/locales/zh-Hans/web-outdoor-management/15-配置字段覆盖核对.md new file mode 100644 index 00000000..e473967b --- /dev/null +++ b/docs/locales/zh-Hans/web-outdoor-management/15-配置字段覆盖核对.md @@ -0,0 +1,276 @@ +# 当前 TMS 字段展示覆盖核对 + +此表由 scripts/audit_web_config_fields.mjs 从当前核心与配置扩展的字段表、字面量 writer 调用、带索引的键模板和 C6 宏生成字段提取,按字段族去重。C6 写入/消费名称及 presence 索引交叉检查;其字段只在相应板型能力下导出。槽位用 {slot} 表示,不表示只支持一个槽位。表中不读取设备或配置文件中的实际值。 + +范围限制:这是一份已知 TMS 字段族和英文展示缺口清单,不证明所有 NVS/SD 独立设置都已纳入 TMS,不证明条件编译分支或每个字段的录入约束、硬件能力和读写行为已经验收。未检测到中文仅是必要条件,不能代替其他十种语言的完整性检查或术语质量审阅。字段类型与结构化控件仍需要逐项对照 writer 和设备验证器;尤其不能将 protobuf 模块 chunk 的原始编辑视为完整模块交互。 + +当前提取 **261 个字段族**;其中 **0 个**英文展示仍包含中文回退,必须补齐。 + +## 已确认的展示缺口 + + +## 字段族清单 + +| 字段族 | 当前英文展示 | 中文回退 | 源码 | +|---|---|---|---| +| aprs.dedupe_window_s | APRS · Deduplication window (seconds) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.enabled | APRS · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.igate_callsign | APRS · Gateway callsign | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.igate_ssid | APRS · Gateway SSID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.node_map | APRS · Node callsign mapping | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.path | APRS · APRS path | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.position_interval_s | APRS · Position transmit interval (seconds) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.self_callsign | APRS · Own callsign | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.self_enabled | APRS · Transmit own position | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.symbol_code | APRS · Symbol code | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.symbol_table | APRS · Symbol table | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.tocall | APRS · Destination callsign | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| aprs.tx_min_interval_s | APRS · Minimum transmit interval (seconds) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| c6_companion.espnow_beacon_interval_ms | C6 wireless companion · ESP-NOW beacon interval (ms) | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.espnow_channel | C6 wireless companion · ESP-NOW channel | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.espnow_enabled | C6 wireless companion · ESP-NOW enabled | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.espnow_team_discovery | C6 wireless companion · ESP-NOW team discovery | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_ap_channel | C6 wireless companion · AP channel | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_ap_enabled | C6 wireless companion · Wi-Fi AP enabled | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_ap_password | C6 wireless companion · AP password | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_ap_ssid | C6 wireless companion · AP SSID | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_enabled | C6 wireless companion · Wi-Fi enabled | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_persist_credentials | C6 wireless companion · Retain Wi-Fi credentials | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_sta_channel | C6 wireless companion · STA channel | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_sta_enabled | C6 wireless companion · Wi-Fi STA enabled | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_sta_password | C6 wireless companion · STA password | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| c6_companion.wifi_sta_ssid | C6 wireless companion · STA SSID | 无 | [modules/core_sys/include/app/tms_c6_config_extension.h](../../../../modules/core_sys/include/app/tms_c6_config_extension.h) | +| cellular.apn | Cellular · APN | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.apn_password | Cellular · APN password | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.apn_user | Cellular · APN username | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.auto_answer | Cellular · Answer automatically | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.enabled | Cellular · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.microphone_gain | Cellular · Microphone gain | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smsc | Cellular · SMS service centre | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_default_recipient | Cellular · Default recipient | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_from | Cellular · Sender address | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_host | Cellular · SMTP server | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_password | Cellular · SMTP password | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_port | Cellular · SMTP port | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_security | Cellular · SMTP security | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.smtp_user | Cellular · SMTP username | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| cellular.speaker_gain | Cellular · Speaker gain | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| channel.primary_downlink | Channel · Primary downlink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.primary_enabled | Channel · Primary channel enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.primary_has_module | Channel · Primary module settings present | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.primary_muted | Channel · Primary channel muted | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.primary_position_precision | Channel · Primary position precision | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.primary_uplink | Channel · Primary uplink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_downlink | Channel · Secondary downlink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_enabled | Channel · Secondary channel enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_has_module | Channel · Secondary module settings present | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_muted | Channel · Secondary channel muted | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_position_precision | Channel · Secondary position precision | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| channel.secondary_uplink | Channel · Secondary uplink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| chat.alerts.contact | Chat / Alerts · Contact alerts | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| chat.alerts.message | Chat / Alerts · Message alerts | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| chat.auto_reply.enabled | Chat / Automatic reply · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| chat.auto_reply.text | Chat / Automatic reply · Reply text | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| chat.channel | Chat · Default channel | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| debug.sd_logs_enabled | Debug · SD debug logging | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| device.ble_enabled | Device · Bluetooth enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| device.node_name | Device · Device name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| device.short_name | Device · Short name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| document.kind | Document · Document type | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.alt_ref | Positioning · Altitude reference | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.coord_format | Positioning · Coordinate format | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.enabled | Positioning · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.external_nmea_hz | Positioning · External NMEA output (Hz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.external_nmea_mask | Positioning · NMEA sentence mask | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_baud | Positioning · Startup baud rate | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_gnss_policy | Positioning · GNSS system policy | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_nmea_policy | Positioning · NMEA policy | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_probe_ms | Positioning · Startup probe time (ms) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_profile | Positioning · Startup profile | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.init_rxm_policy | Positioning · Receiver policy | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.interval_ms | Positioning · Update interval (ms) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.mode | Positioning · Mode | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.motion_idle_ms | Positioning · Idle detection time (ms) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.motion_sensor | Positioning · Motion sensor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.sat_mask | Positioning · Satellite system mask | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| gps.strategy | Positioning · Positioning strategy | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| legacy.secondary_psk | Legacy · Secondary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.contour_enabled | Map · Contours enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.coord_system | Map · Coordinate system | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.source | Map · Map source | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.track_enabled | Map · Track recording enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.track_format | Map · Recording format | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| map.track_interval | Map · Recording interval | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc_ble.advert_location_policy | MeshCore Bluetooth · Location advertisement policy | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc_ble.manual_add_contacts | MeshCore Bluetooth · Add contacts manually | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc_ble.pin | MeshCore Bluetooth · Pairing PIN | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc_ble.telemetry_base | MeshCore Bluetooth · Base telemetry | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc_ble.telemetry_environment | MeshCore Bluetooth · Environment telemetry | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc_ble.telemetry_location | MeshCore Bluetooth · Location telemetry | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mc.active_slot | MeshCore · Active slot | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.airtime_factor | MeshCore · Airtime factor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.bandwidth_khz | MeshCore · Bandwidth (kHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.bandwidth_khz | MeshCore / Radio · Bandwidth (kHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.channel_num | MeshCore / Radio · Channel number | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.coding_rate | MeshCore / Radio · Coding rate | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.config_ok_to_mqtt | MeshCore / Radio · Allow outgoing packets via MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.enable_relay | MeshCore / Radio · Relay enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.frequency_offset_mhz | MeshCore / Radio · Frequency offset (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.hop_limit | MeshCore / Radio · Hop limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.ignore_mqtt | MeshCore / Radio · Ignore MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.modem_preset | MeshCore / Radio · Modem preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.override_duty_cycle | MeshCore / Radio · Override duty cycle | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.override_frequency_mhz | MeshCore / Radio · Custom frequency (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.primary_channel_id | MeshCore / Radio · Primary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.primary_channel_name | MeshCore / Radio · Primary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.primary_psk | MeshCore / Radio · Primary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.region | MeshCore / Radio · Region | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.secondary_channel_id | MeshCore / Radio · Secondary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.secondary_channel_name | MeshCore / Radio · Secondary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.secondary_psk | MeshCore / Radio · Secondary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.spread_factor | MeshCore / Radio · Spreading factor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.tx_enabled | MeshCore / Radio · Transmission enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.tx_power | MeshCore / Radio · Transmit power (dBm) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.base.use_preset | MeshCore / Radio · Use radio preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.channel.{slot}.enabled | MeshCore / Channel / 1 · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.channel.{slot}.key | MeshCore / Channel / 1 · Key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.channel.{slot}.name | MeshCore / Channel / 1 · Name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.client_repeat | MeshCore · Client repeat | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.coding_rate | MeshCore · Coding rate | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.flood_max | MeshCore · Flood limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.forward_profile | MeshCore · Forward profile | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.frequency_mhz | MeshCore · Frequency (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.downlink | MeshCore / MQTT · Downlink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.enabled | MeshCore / MQTT · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.host | MeshCore / MQTT · Server host | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.password | MeshCore / MQTT · Password | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.port | MeshCore / MQTT · Port | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.root | MeshCore / MQTT · Topic root | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.uplink | MeshCore / MQTT · Uplink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.mqtt.username | MeshCore / MQTT · Username | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.multi_acks | MeshCore · Multiple acknowledgements | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.region_preset | MeshCore · Region preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.rx_delay_base | MeshCore · Receive delay base | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.send_profile | MeshCore · Send profile | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mc.spread_factor | MeshCore · Spreading factor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt_ble.enabled | Meshtastic Bluetooth · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_ble.fixed_pin | Meshtastic Bluetooth · Fixed pairing PIN | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_ble.mode | Meshtastic Bluetooth · Mode | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_module.chunk_count | Meshtastic modules · Chunk count | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_module.chunk.{slot} | Meshtastic modules / Chunk · 0 | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_module.format | Meshtastic modules · Format | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt_module.size | Meshtastic modules · Byte size | 无 | [platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp) | +| mt.bandwidth_khz | Meshtastic · Bandwidth (kHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.channel_num | Meshtastic · Channel number | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.coding_rate | Meshtastic · Coding rate | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.config_ok_to_mqtt | Meshtastic · Allow outgoing packets via MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.enable_relay | Meshtastic · Relay enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.frequency_offset_mhz | Meshtastic · Frequency offset (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.hop_limit | Meshtastic · Hop limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.ignore_mqtt | Meshtastic · Ignore MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.modem_preset | Meshtastic · Modem preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.downlink | Meshtastic / MQTT · Downlink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.enabled | Meshtastic / MQTT · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.host | Meshtastic / MQTT · Server host | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.password | Meshtastic / MQTT · Password | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.port | Meshtastic / MQTT · Port | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.root | Meshtastic / MQTT · Topic root | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.uplink | Meshtastic / MQTT · Uplink | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.mqtt.username | Meshtastic / MQTT · Username | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.override_duty_cycle | Meshtastic · Override duty cycle | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.override_frequency_mhz | Meshtastic · Custom frequency (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.primary_channel_id | Meshtastic · Primary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.primary_channel_name | Meshtastic · Primary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.primary_psk | Meshtastic · Primary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.region | Meshtastic · Region | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.secondary_channel_id | Meshtastic · Secondary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.secondary_channel_name | Meshtastic · Secondary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.secondary_psk | Meshtastic · Secondary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.spread_factor | Meshtastic · Spreading factor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.tx_enabled | Meshtastic · Transmission enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.tx_power | Meshtastic · Transmit power (dBm) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| mt.use_preset | Meshtastic · Use radio preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| network.channel_util | Network · Channel utilization limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| network.duty_cycle | Network · Duty cycle limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.ack_broadcast | Communication policy · Broadcast acknowledgement | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.ack_squad | Communication policy · Squad acknowledgement | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.hop_limit | Communication policy · Hop limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.max_channels | Communication policy · Maximum channels | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.max_retries | Communication policy · Maximum retries | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| policy.relay | Communication policy · Relay | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| power.gauge.design_mah | Power / Battery gauge · Design capacity (mAh) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| power.gauge.full_mah | Power / Battery gauge · Full capacity (mAh) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| privacy.encrypt_mode | Privacy · Encryption mode | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| protocol.active | Protocol · Active protocol | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| route.enabled | Route · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| route.path | Route · Track file path | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.allow_location_requests | Reticulum · Allow location requests | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.anonymous_peer | Reticulum · Anonymous peer | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.bandwidth_khz | Reticulum · Bandwidth (kHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.channel_num | Reticulum · Channel number | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.coding_rate | Reticulum · Coding rate | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.config_ok_to_mqtt | Reticulum · Allow outgoing packets via MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.enable_relay | Reticulum · Relay enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.frequency_offset_mhz | Reticulum · Frequency offset (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.group.{slot}.destination_hash | Reticulum / Group / 1 · Destination address | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.group.{slot}.enabled | Reticulum / Group / 1 · Enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.group.{slot}.identity_hash | Reticulum / Group / 1 · Identity hash | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.group.{slot}.identity_valid | Reticulum / Group / 1 · Identity valid | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.group.{slot}.name | Reticulum / Group / 1 · Name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.hop_limit | Reticulum · Hop limit | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.ignore_mqtt | Reticulum · Ignore MQTT | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.interface_policy | Reticulum · Interface policy | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.lora_enabled | Reticulum · LoRa enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.modem_preset | Reticulum · Modem preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.net.interface_count | Reticulum / Network · Interface count | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.data_port | Reticulum / Network / Interface / 1 · Data port | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.discovery_port | Reticulum / Network / Interface / 1 · Discovery port | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.enabled | Reticulum / Network / Interface / 1 · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.group_id | Reticulum / Network / Interface / 1 · Interface group | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.id | Reticulum / Network / Interface / 1 · Stable ID | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.ifac_size | Reticulum / Network / Interface / 1 · IFAC length (bits) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.network_name | Reticulum / Network / Interface / 1 · Network name | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.passphrase | Reticulum / Network / Interface / 1 · Network passphrase | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.target_host | Reticulum / Network / Interface / 1 · Server host | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.target_port | Reticulum / Network / Interface / 1 · Server port | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.interface.{slot}.type | Reticulum / Network / Interface / 1 · Type | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.automatic_node | Reticulum / Network / Propagation · Select node automatically | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.delivery | Reticulum / Network / Propagation · Delivery policy | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.enabled | Reticulum / Network / Propagation · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.max_messages_per_sync | Reticulum / Network / Propagation · Maximum messages per sync | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.node_hash | Reticulum / Network / Propagation · Propagation node address | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.service_enabled | Reticulum / Network / Propagation · Propagation service enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.sync_interval_s | Reticulum / Network / Propagation · Sync interval (seconds) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.propagation.sync_on_start | Reticulum / Network / Propagation · Sync on startup | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.net.version | Reticulum / Network · Version | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| rt.override_duty_cycle | Reticulum · Override duty cycle | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.override_frequency_mhz | Reticulum · Custom frequency (MHz) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.primary_channel_id | Reticulum · Primary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.primary_channel_name | Reticulum · Primary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.primary_psk | Reticulum · Primary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.region | Reticulum · Region | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.secondary_channel_id | Reticulum · Secondary channel ID | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.secondary_channel_name | Reticulum · Secondary channel name | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.secondary_psk | Reticulum · Secondary channel key | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.spread_factor | Reticulum · Spreading factor | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.tx_enabled | Reticulum · Transmission enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.tx_power | Reticulum · Transmit power (dBm) | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.use_preset | Reticulum · Use radio preset | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.wifi_auto_connect | Reticulum · Connect Wi-Fi automatically | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.wifi_enabled | Reticulum · Wi-Fi enabled | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.wifi_host | Reticulum · Gateway host | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| rt.wifi_port | Reticulum · Gateway port | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| schema.version | Schema · Version | 无 | [modules/core_sys/src/app/tms_config_codec.cpp](../../../../modules/core_sys/src/app/tms_config_codec.cpp) | +| ui.audio.speaker_volume | Interface / Audio · Speaker volume | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.display.brightness | Interface / Display · Brightness | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.haptics.vibration_enabled | Interface / Haptics · Vibration enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.ime.enabled | Interface / Input methods · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.locale | Interface · Language | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.screen.timeout_ms | Interface / Screen · Screen timeout | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.timezone.offset_minutes | Interface / Time zone · UTC offset (minutes) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.timezone.profile | Interface / Time zone · Profile | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| ui.timezone.tzdef | Interface / Time zone · Time zone definition | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| wifi.enabled | Wi-Fi · Enabled | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| wifi.profile_count | Wi-Fi · Profile count | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| wifi.profile.{slot}.password | Wi-Fi / Profile / 1 · Password | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | +| wifi.profile.{slot}.ssid | Wi-Fi / Profile / 1 · Network name (SSID) | 无 | [platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp](../../../../platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp) | diff --git a/docs/locales/zh-Hans/web-outdoor-management/47-AppConfig传统保存投影对账.md b/docs/locales/zh-Hans/web-outdoor-management/47-AppConfig传统保存投影对账.md new file mode 100644 index 00000000..cc8ef02b --- /dev/null +++ b/docs/locales/zh-Hans/web-outdoor-management/47-AppConfig传统保存投影对账.md @@ -0,0 +1,148 @@ +# AppConfig 传统保存与核心 TMS 投影对账 + +由 scripts/audit_app_config_preferences.mjs 读取实际 saveAppConfigToPreferences 与核心 TMS writer。展开四个保存域中的包装器实参,解析同文件常量、成员投影与明确列出的动态槽/镜像/长度元数据;不记录配置值、默认值、秘密或缓冲内容,不读取设备。 + +找到 138 个保存调用;0 个没有投影关系,必须继续追踪。调用数不是配置数;一个字段可对应多个元数据键。 + +范围:此表解决旧包装器实参不可见的缺口,只有保存→导出源码关系。不是编译器分析,不证明加载/TMS消费/校验/硬件生效或所有独立配置。前缀/MQTT固定参数位置、活动槽与长度元数据使用当前生产结构,改变时必须复核。Reticulum接口/组的专用持久化、独立BLE/模块/设置和账号密钥仍按23/24/26/44/46核对。删除的旧键不是新增可维护配置。 + +| Namespace | 存储键或模板 | 方法 | 核心TMS投影 | 关系 | 保存来源 | +|---|---|---|---|---|---| +| chat | relay | bool | policy.relay | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1272](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1272) | +| chat | hop_limit | uchar | policy.hop_limit | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1273](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1273) | +| chat | ack_bcast | bool | policy.ack_broadcast | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1274](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1274) | +| chat | ack_squad | bool | policy.ack_squad | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1275](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1275) | +| chat | max_retries | uchar | policy.max_retries | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1276](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1276) | +| chat | max_channels | uchar | policy.max_channels | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1277](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1277) | +| chat | region | uchar | mt.region | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1280](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1280) | +| chat | modem_preset | uchar | mt.modem_preset | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1281](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1281) | +| chat | tx_power | char | mt.tx_power | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1282](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1282) | +| chat | mesh_hop_limit | uchar | mt.hop_limit | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1283](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1283) | +| chat | mesh_relay | bool | mt.enable_relay | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1284](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1284) | +| chat | use_preset | bool | mt.use_preset | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1285](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1285) | +| chat | mesh_bw_khz | float | mt.bandwidth_khz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1286](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1286) | +| chat | mesh_sf | uchar | mt.spread_factor | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1287](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1287) | +| chat | mesh_cr | uchar | mt.coding_rate | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1288](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1288) | +| chat | mesh_tx_en | bool | mt.tx_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1289](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1289) | +| chat | mesh_ovr_dc | bool | mt.override_duty_cycle | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1290](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1290) | +| chat | mesh_ch_num | ushort | mt.channel_num | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1291](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1291) | +| chat | mesh_freq_off | float | mt.frequency_offset_mhz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1292](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1292) | +| chat | mesh_freq_ovr | float | mt.override_frequency_mhz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1293](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1293) | +| chat | mesh_ign_mqtt | bool | mt.ignore_mqtt | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1294](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1294) | +| chat | mesh_ok_to_mqtt | bool | mt.config_ok_to_mqtt | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1295](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1295) | +| chat | pri_ch_name | string | mt.primary_channel_name | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1296](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1296) | +| chat | sec_ch_name | string | mt.secondary_channel_name | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1297](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1297) | +| chat | pri_ch_id | uint | mt.primary_channel_id | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1298](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1298) | +| chat | sec_ch_id | uint | mt.secondary_channel_id | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1299](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1299) | +| chat | mt_mqtt_en | bool | mt.mqtt.enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1300](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1300) | +| chat | mt_mqtt_up | bool | mt.mqtt.uplink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1301](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1301) | +| chat | mt_mqtt_down | bool | mt.mqtt.downlink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1302](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1302) | +| chat | mt_mqtt_host | string | mt.mqtt.host | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1303](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1303) | +| chat | mt_mqtt_port | ushort | mt.mqtt.port | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1304](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1304) | +| chat | mt_mqtt_root | string | mt.mqtt.root | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1308](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1308) | +| chat | mt_mqtt_user | string | mt.mqtt.username | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1309](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1309) | +| chat | mt_mqtt_pass | string | mt.mqtt.password | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1310](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1310) | +| chat | mc_freq | float | mc.frequency_mhz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1313](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1313) | +| chat | mc_bw | float | mc.bandwidth_khz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1314](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1314) | +| chat | mc_sf | uchar | mc.spread_factor | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1315](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1315) | +| chat | mc_cr | uchar | mc.coding_rate | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1316](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1316) | +| chat | mc_reg_preset | uchar | mc.region_preset | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1317](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1317) | +| chat | mc_tx | char | mc.base.tx_power | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1318](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1318) | +| chat | mc_repeat | bool | mc.client_repeat | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1319](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1319) | +| chat | mc_rx_delay | float | mc.rx_delay_base | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1320](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1320) | +| chat | mc_airtime | float | mc.airtime_factor | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1321](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1321) | +| chat | mc_flood_max | uchar | mc.flood_max | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1322](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1322) | +| chat | mc_multi_acks | bool | mc.multi_acks | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1323](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1323) | +| chat | mc_send_prof | uchar | mc.send_profile | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1324](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1324) | +| chat | mc_fwd_prof | uchar | mc.forward_profile | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1325](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1325) | +| chat | mc_ch_slot | uchar | mc.active_slot | 规范化槽位 | [platform/esp/arduino_common/src/app_config_store.cpp:1330](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1330) | +| chat | mc_tx_en | bool | mc.base.tx_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1331](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1331) | +| chat | mc_ch_name | string | mc.channel.{active_slot}.name | 旧活动槽镜像 | [platform/esp/arduino_common/src/app_config_store.cpp:1332](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1332) | +| chat | mc_ch_key | bytes | mc.channel.{active_slot}.key | 旧活动槽镜像 | [platform/esp/arduino_common/src/app_config_store.cpp:1333](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1333) | +| chat | mc_ch{slot}_en | bool | mc.channel.{slot}.enabled | 动态槽位,需核对读取及消费 | [platform/esp/arduino_common/src/app_config_store.cpp:1339](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1339) | +| chat | mc_ch{slot}_name | string | mc.channel.{slot}.name | 动态槽位,需核对读取及消费 | [platform/esp/arduino_common/src/app_config_store.cpp:1341](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1341) | +| chat | mc_ch{slot}_key | bytes | mc.channel.{slot}.key | 动态槽位,需核对读取及消费 | [platform/esp/arduino_common/src/app_config_store.cpp:1343](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1343) | +| chat | mc_mqtt_en | bool | mc.mqtt.enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1345](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1345) | +| chat | mc_mqtt_up | bool | mc.mqtt.uplink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1346](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1346) | +| chat | mc_mqtt_down | bool | mc.mqtt.downlink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1347](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1347) | +| chat | mc_mqtt_host | string | mc.mqtt.host | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1348](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1348) | +| chat | mc_mqtt_port | ushort | mc.mqtt.port | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1349](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1349) | +| chat | mc_mqtt_root | string | mc.mqtt.root | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1353](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1353) | +| chat | mc_mqtt_user | string | mc.mqtt.username | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1354](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1354) | +| chat | mc_mqtt_pass | string | mc.mqtt.password | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1355](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1355) | +| chat | rn_freq | float | rt.override_frequency_mhz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1357](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1357) | +| chat | rn_bw | float | rt.bandwidth_khz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1358](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1358) | +| chat | rn_sf | uchar | rt.spread_factor | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1359](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1359) | +| chat | rn_cr | uchar | rt.coding_rate | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1360](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1360) | +| chat | rn_tx | char | rt.tx_power | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1361](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1361) | +| chat | rn_tx_en | bool | rt.tx_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1362](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1362) | +| chat | rt_lora_en | bool | rt.lora_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1363](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1363) | +| chat | rt_wifi_en | bool | rt.wifi_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1364](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1364) | +| chat | rt_wifi_auto | bool | rt.wifi_auto_connect | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1365](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1365) | +| chat | rt_anon_peer | bool | rt.anonymous_peer | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1366](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1366) | +| chat | rt_wifi_host | string | rt.wifi_host | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1367](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1367) | +| chat | rt_wifi_port | ushort | rt.wifi_port | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1368](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1368) | +| chat | rt_if_policy | uchar | rt.interface_policy | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1372](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1372) | +| chat | rt_loc_req | bool | rt.allow_location_requests | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1374](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1374) | +| chat | mesh_protocol | uchar | protocol.active | 协议规范化 | [platform/esp/arduino_common/src/app_config_store.cpp:1395](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1395) | +| chat | node_name | bytes | device.node_name | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1399](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1399) | +| chat | short_name | bytes | device.short_name | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1400](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1400) | +| chat | primary_enabled | bool | channel.primary_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1403](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1403) | +| chat | sec_enabled | bool | channel.secondary_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1404](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1404) | +| chat | primary_uplink | bool | channel.primary_uplink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1405](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1405) | +| chat | pri_downlink | bool | channel.primary_downlink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1406](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1406) | +| chat | sec_uplink | bool | channel.secondary_uplink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1407](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1407) | +| chat | sec_downlink | bool | channel.secondary_downlink | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1408](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1408) | +| chat | pri_ch_mod | bool | channel.primary_has_module | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1409](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1409) | +| chat | pri_pos_prec | uint | channel.primary_position_precision | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1410](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1410) | +| chat | pri_ch_muted | bool | channel.primary_muted | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1411](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1411) | +| chat | sec_ch_mod | bool | channel.secondary_has_module | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1412](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1412) | +| chat | sec_pos_prec | uint | channel.secondary_position_precision | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1413](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1413) | +| chat | sec_ch_muted | bool | channel.secondary_muted | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1414](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1414) | +| chat | primary_key | bytes | mt.primary_psk | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1425](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1425) | +| chat | pri_key_len | uchar | mt.primary_psk | 密钥长度元数据 | [platform/esp/arduino_common/src/app_config_store.cpp:1431](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1431) | +| chat | secondary_key | bytes | mt.secondary_psk | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1434](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1434) | +| chat | sec_key_len | uchar | mt.secondary_psk | 密钥长度元数据 | [platform/esp/arduino_common/src/app_config_store.cpp:1440](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1440) | +| gps | gps_enabled | bool | gps.enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1463](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1463) | +| gps | init_baud | uint | gps.init_baud | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1464](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1464) | +| gps | init_probe_ms | uint | gps.init_probe_ms | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1465](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1465) | +| gps | init_profile | uchar | gps.init_profile | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1466](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1466) | +| gps | init_rxm_pol | uchar | gps.init_rxm_policy | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1467](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1467) | +| gps | init_gnss_pol | uchar | gps.init_gnss_policy | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1468](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1468) | +| gps | init_nmea_pol | uchar | gps.init_nmea_policy | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1469](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1469) | +| gps | gps_interval | uint | gps.interval_ms | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1470](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1470) | +| gps | gps_mode | uchar | gps.mode | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1471](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1471) | +| gps | gps_sat_mask | uchar | gps.sat_mask | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1472](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1472) | +| gps | gps_strategy | uchar | gps.strategy | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1473](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1473) | +| gps | gps_alt_ref | uchar | gps.alt_ref | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1474](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1474) | +| gps | gps_coord_fmt | uchar | gps.coord_format | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1475](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1475) | +| gps | motion_idle_ms | uint | gps.motion_idle_ms | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1476](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1476) | +| gps | motion_sensor | uchar | gps.motion_sensor | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1477](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1477) | +| gps | ext_nmea | uchar | gps.external_nmea_hz | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1478](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1478) | +| gps | ext_nmea_sent | uchar | gps.external_nmea_mask | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1479](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1479) | +| settings | map_coord | uchar | map.coord_system | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1502](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1502) | +| settings | map_source | uchar | map.source | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1503](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1503) | +| settings | map_contour | bool | map.contour_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1504](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1504) | +| settings | map_track | bool | map.track_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1505](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1505) | +| settings | map_track_int | uchar | map.track_interval | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1506](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1506) | +| settings | map_track_fmt | uchar | map.track_format | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1507](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1507) | +| settings | ble_enabled | bool | device.ble_enabled | 旧BLE固定关闭值,独立BLE扩展负责真实配置 | [platform/esp/arduino_common/src/app_config_store.cpp:1508](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1508) | +| settings | chat_channel | uchar | chat.channel | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1509](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1509) | +| settings | net_duty_cycle | bool | network.duty_cycle | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1510](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1510) | +| settings | net_util | uchar | network.channel_util | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1511](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1511) | +| settings | privacy_encrypt | uchar | privacy.encrypt_mode | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1512](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1512) | +| settings | route_enabled | bool | route.enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1514](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1514) | +| settings | route_path | string | route.path | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1515](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1515) | +| aprs | enabled | bool | aprs.enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1550](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1550) | +| aprs | igate_call | string | aprs.igate_callsign | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1551](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1551) | +| aprs | igate_ssid | uchar | aprs.igate_ssid | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1552](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1552) | +| aprs | to_call | string | aprs.tocall | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1553](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1553) | +| aprs | path | string | aprs.path | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1554](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1554) | +| aprs | tx_min | ushort | aprs.tx_min_interval_s | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1555](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1555) | +| aprs | dedupe | ushort | aprs.dedupe_window_s | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1556](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1556) | +| aprs | sym_tab | char | aprs.symbol_table | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1557](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1557) | +| aprs | sym_code | char | aprs.symbol_code | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1558](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1558) | +| aprs | pos_interval | ushort | aprs.position_interval_s | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1559](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1559) | +| aprs | node_map | bytes | aprs.node_map | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1562](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1562) | +| aprs | self_enable | bool | aprs.self_enabled | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1568](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1568) | +| aprs | self_call | string | aprs.self_callsign | 成员投影 | [platform/esp/arduino_common/src/app_config_store.cpp:1569](../../../../platform/esp/arduino_common/src/app_config_store.cpp#L1569) | diff --git a/modules/core_chat/include/chat/infra/voice/reticulum_crypto_compat.h b/modules/core_chat/include/chat/infra/voice/reticulum_crypto_compat.h index 710e484d..392293a7 100644 --- a/modules/core_chat/include/chat/infra/voice/reticulum_crypto_compat.h +++ b/modules/core_chat/include/chat/infra/voice/reticulum_crypto_compat.h @@ -14,6 +14,7 @@ class Curve25519 public: static void dh1(std::uint8_t public_key[32], std::uint8_t private_key[32]); static bool dh2(std::uint8_t peer_key_and_secret[32], std::uint8_t private_key[32]); + static bool eval(std::uint8_t output[32], const std::uint8_t private_key[32], const std::uint8_t point[32]); }; class ReticulumRngCompat diff --git a/modules/core_chat/include/chat/ports/device_author.h b/modules/core_chat/include/chat/ports/device_author.h new file mode 100644 index 00000000..8013ea00 --- /dev/null +++ b/modules/core_chat/include/chat/ports/device_author.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include + +namespace chat +{ +enum class DeviceAuthorResult : uint8_t +{ + Ok, + Unsupported, + Unavailable, + StaleIdentity, + InvalidInput, + StorageError, +}; + +// Fixed domain, canonical CMP1 array [1, session16, challenge32, public64, +// generation]. All inputs must lie outside the caller-owned output workspace. +inline bool deviceAuthorProofMessage(const uint8_t* session, const uint8_t* challenge, + const uint8_t* public_key, uint32_t generation, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + constexpr char domain[] = "trailmate.hostlink.author/v1\0"; + const size_t integer_bytes = generation <= 127 ? 1 : generation <= 255 ? 2 + : generation <= 65535 ? 3 + : 5; + const size_t required = sizeof(domain) - 1 + 1 + 1 + 18 + 34 + 66 + integer_bytes; + if (!session || !challenge || !public_key || !generation || !output || capacity < required) return false; + const auto start = reinterpret_cast(output); + const auto overlaps = [start, required](const uint8_t* source, size_t length) + { + const auto input = reinterpret_cast(source); + return input < start + required && start < input + length; + }; + if (overlaps(session, 16) || overlaps(challenge, 32) || overlaps(public_key, 64)) return false; + std::memcpy(output, domain, sizeof(domain) - 1); + size_t offset = sizeof(domain) - 1; + output[offset++] = 0x95; + output[offset++] = 1; + for (unsigned field = 0; field < 3; ++field) + { + const size_t length = field == 0 ? 16 : field == 1 ? 32 + : 64; + const auto* source = field == 0 ? session : field == 1 ? challenge + : public_key; + output[offset++] = 0xc4; + output[offset++] = static_cast(length); + std::memcpy(output + offset, source, length); + offset += length; + } + if (integer_bytes == 1) output[offset++] = static_cast(generation); + else + { + output[offset++] = integer_bytes == 2 ? 0xcc : integer_bytes == 3 ? 0xcd + : 0xce; + for (size_t remaining = integer_bytes - 1; remaining; --remaining) + output[offset++] = static_cast(generation >> ((remaining - 1) * 8)); + } + written = offset; + return true; +} +} // namespace chat diff --git a/modules/core_chat/include/chat/ports/device_identity_restore.h b/modules/core_chat/include/chat/ports/device_identity_restore.h new file mode 100644 index 00000000..965b520f --- /dev/null +++ b/modules/core_chat/include/chat/ports/device_identity_restore.h @@ -0,0 +1,16 @@ +#pragma once +#include +namespace chat +{ +enum class DeviceIdentityRestoreResult : uint8_t +{ + CommittedRebootRequired, + IndeterminateRebootRequired, + Unavailable, + StaleIdentity, + InvalidInput, + StorageError, + Unsupported, + StaleRadioIdentity, +}; +} diff --git a/modules/core_chat/include/chat/ports/i_mesh_adapter.h b/modules/core_chat/include/chat/ports/i_mesh_adapter.h index a6f392aa..34f293e8 100644 --- a/modules/core_chat/include/chat/ports/i_mesh_adapter.h +++ b/modules/core_chat/include/chat/ports/i_mesh_adapter.h @@ -6,12 +6,23 @@ #pragma once #include "../domain/chat_types.h" +#include "chat/infra/lxmf/lxmf_wire.h" +#include "chat/ports/device_author.h" +#include "chat/ports/device_identity_restore.h" +#include "chat/ports/track_binding_registration.h" #include +namespace track_sharing +{ +struct ManifestView; +} +namespace hostlink::management { struct IdentityRestoreView; struct RadioIdentityRestoreView; } namespace chat { class IIncomingDeliveryCommitPort; class IGeocachingTransport; +class ITrackTransport; +namespace lxmf { struct TrackDeliveryBinding; } namespace meshcore { @@ -64,7 +75,44 @@ struct ReticulumLocalIdentityInfo class IMeshAdapter { public: + virtual DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView&, uint8_t*, size_t) + { return DeviceIdentityRestoreResult::Unsupported; } + virtual DeviceIdentityRestoreResult restoreRadioIdentity(const hostlink::management::RadioIdentityRestoreView&, uint8_t*, size_t) + { return DeviceIdentityRestoreResult::Unsupported; } + virtual DeviceAuthorResult readRadioIdentityInfo(uint8_t, uint32_t, uint8_t*, size_t, size_t& written) const + { written = 0; return DeviceAuthorResult::Unsupported; } + virtual DeviceAuthorResult exportRadioIdentityBackup(uint8_t, uint32_t, uint8_t*, size_t, size_t& written, const uint8_t*, const uint8_t*) const + { written = 0; return DeviceAuthorResult::Unsupported; } + virtual DeviceAuthorResult readDeviceAuthorKey(uint8_t*, uint32_t&) const { return DeviceAuthorResult::Unsupported; } + // Internal physical-USB export port. Caller owns authorization and the + // transient output; implementations must serialize identity access. + virtual DeviceAuthorResult exportDeviceIdentityBackup(uint32_t, uint8_t*, size_t, size_t& written) const + { + written = 0; + return DeviceAuthorResult::Unsupported; + } + // Internal primitive only: publication caller must validate file, identity, + // immutable lease and durable revision reservation before invoking this. + virtual DeviceAuthorResult signTrackManifest(const uint8_t*, size_t, uint32_t, track_sharing::ManifestView&, + uint8_t*, size_t, uint8_t*, size_t) const { return DeviceAuthorResult::Unsupported; } + virtual DeviceAuthorResult proveDeviceAuthor(const uint8_t*, const uint8_t*, uint32_t, + uint8_t*, size_t, uint8_t*, uint32_t&, uint8_t*) const + { + return DeviceAuthorResult::Unsupported; + } virtual IGeocachingTransport* geocachingTransport() { return nullptr; } + virtual ITrackTransport* trackTransport() { return nullptr; } + virtual bool bindTrackDelivery(const lxmf::TrackDeliveryBinding*) { return false; } + // Nonblocking ownership change, serialized with callback execution. Caller + // lends replacement until successful removal or an observed Conflict. + virtual TrackBindingChange replaceTrackDelivery(const lxmf::TrackDeliveryBinding*, const lxmf::TrackDeliveryBinding*) { return TrackBindingChange::Unsupported; } + // Run a bounded job step under callback serialization, after refreshing its + // author guard. Visitor must not reenter router APIs or send radio data. + virtual TrackBindingChange visitTrackDelivery(const lxmf::TrackDeliveryBinding*, uint8_t*, uint32_t&, TrackBindingVisit, void*) { return TrackBindingChange::Unsupported; } + virtual bool trackLocalDestination(uint8_t*) { return false; } + virtual bool requestTrackDirectoryPath(const uint8_t*) { return false; } + virtual MeshSendResult sendTrackData(const uint8_t*, lxmf::ByteSpan, const uint8_t*, uint32_t, + bool, std::array*) { return MeshSendResult::fail(MeshOperationFailure::Unsupported); } virtual ~IMeshAdapter() = default; /** diff --git a/modules/core_chat/include/chat/ports/i_track_transport.h b/modules/core_chat/include/chat/ports/i_track_transport.h new file mode 100644 index 00000000..0e4078ae --- /dev/null +++ b/modules/core_chat/include/chat/ports/i_track_transport.h @@ -0,0 +1,17 @@ +#pragma once +#include "chat/ports/track_delivery_binding.h" + +namespace chat +{ +class ITrackTransport +{ + public: + virtual ~ITrackTransport() = default; + virtual bool requestDirectoryPath(const uint8_t*) { return false; } + // Input is borrowed for this call; deferred LXMF work owns its encoding. + virtual MeshSendResult sendTrackData(const uint8_t destination[16], lxmf::ByteSpan data, + bool response, std::array* accepted_hash) = 0; + // Lifetime is owned by the application worker; unbind before destruction. + virtual void setTrackDeliveryBinding(const lxmf::TrackDeliveryBinding* binding) = 0; +}; +} // namespace chat diff --git a/modules/core_chat/include/chat/ports/stored_identity_lease.h b/modules/core_chat/include/chat/ports/stored_identity_lease.h new file mode 100644 index 00000000..9a2aa4dc --- /dev/null +++ b/modules/core_chat/include/chat/ports/stored_identity_lease.h @@ -0,0 +1,79 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "chat/ports/device_identity_restore.h" +#include +#include + +namespace chat +{ +// The caller holds the identity/router lock for the entire lease. Memory must +// provide allocate(size) and release(pointer); no fallback allocation is used. +template +class StoredIdentityLease +{ + public: + StoredIdentityLease() : StoredIdentityLease([](Identity& identity) { return identity.initExisting(); }) {} + template + explicit StoredIdentityLease(Initialize initialize) + { + if (rebootRequired()) return; + auto* memory = Memory::allocate(sizeof(Identity)); + if (!memory) return; + identity_ = new (memory) Identity; + if (!initialize(*identity_)) return; + result_ = identity_->authorGeneration() ? DeviceAuthorResult::Ok : DeviceAuthorResult::StorageError; + } + ~StoredIdentityLease() + { + if (!identity_) return; + identity_->~Identity(); + auto* bytes = reinterpret_cast(identity_); + for (size_t index = 0; index < sizeof(Identity); ++index) bytes[index] = 0; + Memory::release(identity_); + } + StoredIdentityLease(const StoredIdentityLease&) = delete; + StoredIdentityLease& operator=(const StoredIdentityLease&) = delete; + DeviceAuthorResult result() const + { + if (rebootRequired()) return DeviceAuthorResult::Unavailable; + return identity_ && identity_->identityStorageError() ? DeviceAuthorResult::StorageError : result_; + } + Identity* get() const { return result() == DeviceAuthorResult::Ok ? identity_ : nullptr; } + static bool rebootRequired() { return reboot_required_.load(std::memory_order_acquire); } + static void noteRestore(DeviceIdentityRestoreResult result) + { + if (result == DeviceIdentityRestoreResult::CommittedRebootRequired || + result == DeviceIdentityRestoreResult::IndeterminateRebootRequired) + reboot_required_.store(true, std::memory_order_release); + } + DeviceAuthorResult read(uint8_t* public_key, uint32_t& generation) const + { + generation = 0; + if (!public_key) return DeviceAuthorResult::InvalidInput; + std::memset(public_key, 0, 64); + if (result() != DeviceAuthorResult::Ok) return result(); + identity_->combinedPublicKey(public_key); + generation = identity_->authorGeneration(); + return DeviceAuthorResult::Ok; + } + DeviceAuthorResult prove(const uint8_t* session, const uint8_t* challenge, uint32_t expected, + uint8_t* workspace, size_t capacity, uint8_t* public_key, + uint32_t& generation, uint8_t* signature) const + { + if (!session || !challenge || !expected || !workspace || !signature) return DeviceAuthorResult::InvalidInput; + std::memset(signature, 0, 64); + const auto status = read(public_key, generation); + if (status != DeviceAuthorResult::Ok) return status; + if (generation != expected) return DeviceAuthorResult::StaleIdentity; + size_t written = 0; + if (!deviceAuthorProofMessage(session, challenge, public_key, generation, workspace, capacity, written)) + return DeviceAuthorResult::InvalidInput; + return identity_->sign(workspace, written, signature) ? DeviceAuthorResult::Ok : DeviceAuthorResult::Unavailable; + } + + private: + Identity* identity_ = nullptr; + DeviceAuthorResult result_ = DeviceAuthorResult::Unavailable; + static inline std::atomic reboot_required_{false}; +}; +} // namespace chat diff --git a/modules/core_chat/include/chat/ports/track_binding_registration.h b/modules/core_chat/include/chat/ports/track_binding_registration.h new file mode 100644 index 00000000..35fdeb0b --- /dev/null +++ b/modules/core_chat/include/chat/ports/track_binding_registration.h @@ -0,0 +1,9 @@ +#pragma once +#include +namespace chat +{ +// Conflict is observed under the same callback-serialization lock as Applied: +// the expected context is no longer registered and its callbacks have retired. +enum class TrackBindingChange : uint8_t { Applied, Conflict, Busy, Unsupported }; +using TrackBindingVisit = void (*)(void*); +} diff --git a/modules/core_chat/include/chat/ports/track_delivery_binding.h b/modules/core_chat/include/chat/ports/track_delivery_binding.h new file mode 100644 index 00000000..4cfcd9ba --- /dev/null +++ b/modules/core_chat/include/chat/ports/track_delivery_binding.h @@ -0,0 +1,75 @@ +#pragma once +#include "chat/ports/i_geocaching_transport.h" + +namespace chat::lxmf +{ +struct TrackDirectoryAnnouncementView +{ + ByteSpan discovery_destination, delivery_destination, public_key, app_data; +}; +using TrackDirectoryAnnouncementHandler = bool (*)(const TrackDirectoryAnnouncementView&, void*); +// Installed binding must outlive its registration. The router serializes +// installation, invocation and removal; callback views remain borrowed. +struct TrackDeliveryBinding +{ + CustomDeliveryHandler handler = nullptr; + void* context = nullptr; + TrackDirectoryAnnouncementHandler announcement = nullptr; + // Only ephemeral control exchanges (e.g. capabilities): true requires + // complete synchronous processing, never merely volatile queueing. + bool (*control)(const CustomDeliveryView&, void*) = nullptr; + // Optional immutable local-account lease, borrowed until unregistration. + // Backend checks it against its own identity before entering callbacks; + // callbacks must not reenter the router to fetch current author material. + const uint8_t* expected_local_author = nullptr; + uint32_t expected_author_generation = 0; +}; + +// Normal Reticulum ingestion has verified signature and service destination. +// Consumer validates the application schema and copies admitted metadata only. +inline bool deliverVerifiedTrackDirectory(const TrackDeliveryBinding* binding, const TrackDirectoryAnnouncementView& view) +{ + return binding && binding->announcement && view.discovery_destination.data && view.discovery_destination.size == 16 && + view.delivery_destination.data && view.delivery_destination.size == 16 && view.public_key.data && view.public_key.size == 64 && + view.app_data.data && view.app_data.size && view.app_data.size <= 128 && binding->announcement(view, binding->context); +} + +inline bool isTrackCustomType(ByteSpan type) +{ + constexpr char name[] = "trailmate.track.v1"; + return type.data && type.size == sizeof(name) - 1 && + std::memcmp(type.data, name, sizeof(name) - 1) == 0; +} + +inline bool deliverVerifiedTrack(const TrackDeliveryBinding* binding, + bool source_unverified, bool group, + const CustomDeliveryView& view) +{ + if (!(!source_unverified && !group && binding && + view.source.data && view.source.size == 16 && + view.destination.data && view.destination.size == 16 && + view.message_hash.data && view.message_hash.size == 32 && + view.data.data && view.data.size && view.data.size <= 8192)) return false; + if (binding->control && binding->control(view, binding->context)) return true; + // Business acceptance still requires durable processing before returning + // true. Returning false retains transport retry semantics. + return binding->handler && binding->handler(view, binding->context); +} + +template +bool trackBindingAuthorMatches(const TrackDeliveryBinding* binding, const Identity& identity) +{ + if (!binding) return false; + if (!binding->expected_local_author && !binding->expected_author_generation) return true; + const auto* expected = binding->expected_local_author; + return expected && binding->expected_author_generation && identity.isReady() && !identity.identityStorageError() && + identity.authorGeneration() == binding->expected_author_generation && identity.encryptionPublicKey() && identity.signingPublicKey() && + !std::memcmp(expected,identity.encryptionPublicKey(),32) && !std::memcmp(expected + 32,identity.signingPublicKey(),32); +} +template +bool deliverVerifiedTrackForAuthor(const TrackDeliveryBinding* binding, bool source_unverified, bool group, + const CustomDeliveryView& view, const Identity& identity) +{ + return trackBindingAuthorMatches(binding, identity) && deliverVerifiedTrack(binding, source_unverified, group, view); +} +} // namespace chat::lxmf diff --git a/modules/core_chat/include/chat/runtime/meshtastic_position_core.h b/modules/core_chat/include/chat/runtime/meshtastic_position_core.h index b565c33c..5036aab0 100644 --- a/modules/core_chat/include/chat/runtime/meshtastic_position_core.h +++ b/modules/core_chat/include/chat/runtime/meshtastic_position_core.h @@ -3,6 +3,8 @@ #include #include +#include "meshtastic/mesh.pb.h" + namespace chat::runtime { @@ -44,6 +46,12 @@ class MeshtasticPositionCore final static bool buildPositionPayload(const MeshtasticPositionInput& input, uint8_t* out_buf, size_t* out_len); + + // Mutates caller-owned Data scratch before encoding/encryption. No heap or + // resident storage. A false result must never be transmitted or mirrored. + static bool applyChannelPrecision(meshtastic_Data& data, + bool has_module_settings, + uint32_t precision_bits); }; } // namespace chat::runtime diff --git a/modules/core_chat/src/runtime/meshtastic_position_core.cpp b/modules/core_chat/src/runtime/meshtastic_position_core.cpp index 05b62321..2a836250 100644 --- a/modules/core_chat/src/runtime/meshtastic_position_core.cpp +++ b/modules/core_chat/src/runtime/meshtastic_position_core.cpp @@ -1,8 +1,11 @@ #include "chat/runtime/meshtastic_position_core.h" #include "meshtastic/mesh.pb.h" +#include "meshtastic/portnums.pb.h" +#include "pb_decode.h" #include "pb_encode.h" +#include #include namespace chat::runtime @@ -12,6 +15,18 @@ namespace constexpr uint32_t kMinimumValidEpochSeconds = 1577836800U; +int32_t precisionCellCenter(int32_t coordinate, uint32_t precision, int64_t bound) +{ + const int64_t width = int64_t{1} << (32 - precision); + const int64_t value = coordinate; + // Floor division is required for southern/western coordinates. Intersect + // edge cells with the physical coordinate range before taking the center. + const int64_t cell = (value / width - (value < 0 && value % width != 0 ? 1 : 0)) * width; + const int64_t lower = std::max(cell, -bound); + const int64_t upper = std::min(cell + width, bound); + return static_cast((lower + upper) / 2); +} + } // namespace MeshtasticPositionAvailability MeshtasticPositionCore::resolveAvailability( @@ -100,4 +115,62 @@ bool MeshtasticPositionCore::buildPositionPayload(const MeshtasticPositionInput& return true; } +bool MeshtasticPositionCore::applyChannelPrecision(meshtastic_Data& data, + bool has_module_settings, + uint32_t precision_bits) +{ + if (data.portnum != meshtastic_PortNum_POSITION_APP || !has_module_settings) + { + return true; + } + if (data.payload.size > sizeof(data.payload.bytes)) + { + return false; + } + // Position is a small bounded scalar message, not a MeshPacket/Data/config + // frame. Encoding uses the existing caller-owned payload buffer. + static_assert(sizeof(meshtastic_Position) <= 128, "Position scalar stack budget"); + meshtastic_Position position = meshtastic_Position_init_zero; + pb_istream_t input = pb_istream_from_buffer(data.payload.bytes, data.payload.size); + if (!pb_decode(&input, meshtastic_Position_fields, &position)) + { + return false; + } + // Empty requests and time-only messages do not reveal a position and must + // remain available when local position sharing is disabled. + if (!position.has_latitude_i && !position.has_longitude_i) + { + return true; + } + if (precision_bits == 0 || precision_bits > 32 || position.precision_bits > 32 || + (position.has_latitude_i && (position.latitude_i < -900000000 || position.latitude_i > 900000000)) || + (position.has_longitude_i && (position.longitude_i < -1800000000 || position.longitude_i > 1800000000))) + { + return false; + } + // Do not claim more detail than an already-quantized input declared. + const uint32_t effective = position.precision_bits == 0 + ? precision_bits + : std::min(precision_bits, position.precision_bits); + if (effective < 32) + { + if (position.has_latitude_i) + { + position.latitude_i = precisionCellCenter(position.latitude_i, effective, 900000000); + } + if (position.has_longitude_i) + { + position.longitude_i = precisionCellCenter(position.longitude_i, effective, 1800000000); + } + } + position.precision_bits = effective; + pb_ostream_t output = pb_ostream_from_buffer(data.payload.bytes, sizeof(data.payload.bytes)); + if (!pb_encode(&output, meshtastic_Position_fields, &position)) + { + return false; + } + data.payload.size = static_cast(output.bytes_written); + return true; +} + } // namespace chat::runtime diff --git a/modules/core_chat/tests/test_meshtastic_position_core.cpp b/modules/core_chat/tests/test_meshtastic_position_core.cpp index bd62178c..da42b449 100644 --- a/modules/core_chat/tests/test_meshtastic_position_core.cpp +++ b/modules/core_chat/tests/test_meshtastic_position_core.cpp @@ -1,9 +1,14 @@ #include "chat/runtime/meshtastic_position_core.h" #include "meshtastic/mesh.pb.h" +#include "meshtastic/portnums.pb.h" #include "pb_decode.h" +#include "pb_encode.h" #include #include +#include +#include +#include #include int main() @@ -82,5 +87,101 @@ int main() chat::runtime::MeshtasticPositionAvailabilityReason::InvalidCoordinates); } + // Decode real protobuf output rather than comparing the implementation's + // own intermediate values. Exercise signs, world edges and all bit counts. + for (const double latitude : {-90.0, -26.67773, 0.0, 26.67773, 90.0}) + { + for (const double longitude : {-180.0, -107.28225, 0.0, 107.28225, 180.0}) + { + for (uint32_t bits = 1; bits <= 32; ++bits) + { + chat::runtime::MeshtasticPositionInput input{}; + input.valid = true; + input.latitude_deg = latitude; + input.longitude_deg = longitude; + input.has_altitude = true; + input.altitude_m = 1903; + input.timestamp_s = 1710000000U; + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_POSITION_APP; + data.request_id = 123; + data.want_response = true; + size_t size = sizeof(data.payload.bytes); + assert(chat::runtime::MeshtasticPositionCore::buildPositionPayload(input, data.payload.bytes, &size)); + data.payload.size = static_cast(size); + const auto original = data; + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, false, 0)); + assert(data.payload.size == original.payload.size); + assert(std::memcmp(data.payload.bytes, original.payload.bytes, size) == 0); + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, bits)); + meshtastic_Position decoded = meshtastic_Position_init_zero; + pb_istream_t stream = pb_istream_from_buffer(data.payload.bytes, data.payload.size); + assert(pb_decode(&stream, meshtastic_Position_fields, &decoded)); + assert(decoded.precision_bits == bits); + assert(decoded.has_latitude_i && decoded.has_longitude_i); + assert(decoded.latitude_i >= -900000000 && decoded.latitude_i <= 900000000); + assert(decoded.longitude_i >= -1800000000 && decoded.longitude_i <= 1800000000); + assert(decoded.has_altitude && decoded.altitude == 1903); + assert(decoded.timestamp == input.timestamp_s); + assert(data.request_id == 123 && data.want_response); + if (bits == 32) + { + assert(decoded.latitude_i == static_cast(latitude * 1e7)); + assert(decoded.longitude_i == static_cast(longitude * 1e7)); + } + else + { + const int64_t width = int64_t{1} << (32 - bits); + assert(std::abs(int64_t{decoded.latitude_i} - static_cast(latitude * 1e7)) <= width); + assert(std::abs(int64_t{decoded.longitude_i} - static_cast(longitude * 1e7)) <= width); + } + const auto quantized = data; + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, bits)); + assert(data.payload.size == quantized.payload.size); + assert(std::memcmp(data.payload.bytes, quantized.payload.bytes, data.payload.size) == 0); + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 32)); + stream = pb_istream_from_buffer(data.payload.bytes, data.payload.size); + assert(pb_decode(&stream, meshtastic_Position_fields, &decoded)); + assert(decoded.precision_bits == bits); // never relabel coarse input as precise + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 0)); + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 33)); + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, UINT32_MAX)); + } + } + } + { + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_POSITION_APP; + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 0)); // empty request + const uint8_t latitude_only[] = {0x0d, 0, 0, 0, 0}; + std::memcpy(data.payload.bytes, latitude_only, sizeof(latitude_only)); + data.payload.size = sizeof(latitude_only); + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 0)); + data.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 0)); + assert(std::memcmp(data.payload.bytes, latitude_only, sizeof(latitude_only)) == 0); + data.portnum = meshtastic_PortNum_POSITION_APP; + data.payload.size = 1; + data.payload.bytes[0] = 0x0d; // truncated sfixed32 + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 13)); + data.payload.size = sizeof(data.payload.bytes) + 1; + assert(!chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 13)); + } + { + meshtastic_Position input = meshtastic_Position_init_zero; + input.has_latitude_i = input.has_longitude_i = true; + input.latitude_i = -266777300; + input.longitude_i = -1072822500; + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_POSITION_APP; + pb_ostream_t output = pb_ostream_from_buffer(data.payload.bytes, sizeof(data.payload.bytes)); + assert(pb_encode(&output, meshtastic_Position_fields, &input)); + data.payload.size = static_cast(output.bytes_written); + assert(chat::runtime::MeshtasticPositionCore::applyChannelPrecision(data, true, 13)); + pb_istream_t stream = pb_istream_from_buffer(data.payload.bytes, data.payload.size); + assert(pb_decode(&stream, meshtastic_Position_fields, &input)); + assert(input.latitude_i == -266600448); + assert(input.longitude_i == -1072955392); + } return 0; } diff --git a/modules/core_geocaching/include/geocaching/protocol/get_response.h b/modules/core_geocaching/include/geocaching/protocol/get_response.h index 9cb5fbdc..dc90656f 100644 --- a/modules/core_geocaching/include/geocaching/protocol/get_response.h +++ b/modules/core_geocaching/include/geocaching/protocol/get_response.h @@ -4,6 +4,22 @@ namespace geocaching::protocol { +// Caller must authenticate the directory and match the local destination first. +// Only explicit cache absence qualifies, never missing revision/timeout/other errors. +inline bool decodeGetNotFound(ByteView response, const RequestId& expected, size_t response_budget) +{ + if (!response.data || response_budget < 512 || response_budget > kMaxApplicationBytes || response.size > response_budget) return false; + CmpReader reader(response); + size_t fields = 0; + uint64_t value = 0; + ByteView request; + std::string_view reason; + return reader.array(fields, 6) && fields == 6 && reader.unsignedInteger(value) && value == 1 && + reader.unsignedInteger(value) && value == 1 && reader.unsignedInteger(value) && value == 3 && + reader.binary(request, 16) && request.size == 16 && !std::memcmp(request.data, expected.bytes.data(), 16) && + reader.unsignedInteger(value) && value == 404 && reader.array(fields, 3) && fields == 3 && + reader.text(reason, 32) && reason == "not_found" && reader.nil() && reader.nil() && reader.finished(); +} struct GetResponseView { ByteView signed_cache; diff --git a/modules/core_geocaching/include/geocaching/protocol/publication_receipt.h b/modules/core_geocaching/include/geocaching/protocol/publication_receipt.h new file mode 100644 index 00000000..cb80f792 --- /dev/null +++ b/modules/core_geocaching/include/geocaching/protocol/publication_receipt.h @@ -0,0 +1,74 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "geocaching/protocol/verify_record.h" + +namespace geocaching::protocol +{ +struct PublicationReceiptView +{ + ByteView directory, epoch, cache, hash; + uint32_t revision = 0; + uint8_t state = 0; + uint64_t observed_at = 0; +}; +// Inputs are immutable; the storage worker lends a disjoint 320-byte scratch +// area. This proof observes a head at a time, not a promise of future currency. +inline VerificationResult verifyPublicationReceipt(ByteView raw, ByteView signature, ByteView public_key, + ByteView directory, ByteView epoch, const VerifiedRecordView& content, + RecordCrypto& crypto, uint8_t* workspace, size_t capacity, + PublicationReceiptView& output) +{ + const auto overlaps = [](ByteView a, ByteView b) + { + if (!a.data || !b.data || !a.size || !b.size) return false; + const auto x = reinterpret_cast(a.data), y = reinterpret_cast(b.data); + return x <= y ? y - x < a.size : x - y < b.size; + }; + const ByteView result_bytes{reinterpret_cast(&output), sizeof(output)}; + const ByteView inputs[] = {raw, signature, public_key, directory, epoch, content.record.encoded, content.signature, + {reinterpret_cast(&content), sizeof(content)}}; + for (const auto input : inputs) if (overlaps(result_bytes, input)) return VerificationResult::InvalidRecord; + if (overlaps(result_bytes, {workspace, capacity})) return VerificationResult::InvalidRecord; + output = {}; + if (!raw.data || !raw.size || raw.size > 256 || !signature.data || signature.size != 64 || + !public_key.data || public_key.size != 64 || !directory.data || directory.size != 16 || !epoch.data || epoch.size != 16) + return VerificationResult::InvalidRecord; + if (!workspace || capacity < 320) return VerificationResult::WorkspaceTooSmall; + for (const auto input : inputs) + { + const auto a = reinterpret_cast(workspace), b = reinterpret_cast(input.data); + if (a <= b ? b - a < capacity : a - b < input.size) return VerificationResult::InvalidRecord; + } + CmpReader reader(raw); + size_t count = 0; + uint64_t version = 0, revision = 0, state = 0, observed = 0; + ByteView destination, advertised_epoch, cache, hash; + if (!reader.array(count, 8) || count != 8 || !reader.unsignedInteger(version) || version != 1 || + !reader.binary(destination, 16) || destination.size != 16 || !reader.binary(advertised_epoch, 16) || advertised_epoch.size != 16 || + !reader.binary(cache, 32) || cache.size != 32 || !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX || + !reader.binary(hash, 32) || hash.size != 32 || !reader.unsignedInteger(state) || state > 2 || + !reader.unsignedInteger(observed) || !observed || observed > 253402300799ULL || !reader.finished()) return VerificationResult::InvalidRecord; + if (std::memcmp(destination.data, directory.data, 16) || std::memcmp(advertised_epoch.data, epoch.data, 16) || + std::memcmp(cache.data, content.id.bytes.data(), 32) || revision != content.record.revision || + std::memcmp(hash.data, content.hash.bytes.data(), 32) || state != static_cast(content.record.state)) return VerificationResult::IdentityMismatch; + CmpWriter canonical(workspace, capacity); + if (!canonical.array(8) || !canonical.unsignedInteger(1) || !canonical.binary(destination) || !canonical.binary(advertised_epoch) || + !canonical.binary(cache) || !canonical.unsignedInteger(revision) || !canonical.binary(hash) || !canonical.unsignedInteger(state) || + !canonical.unsignedInteger(observed) || canonical.size() != raw.size || std::memcmp(workspace, raw.data, raw.size)) return VerificationResult::InvalidRecord; + uint8_t digest[32]; + if (!crypto.sha256(public_key, workspace)) return VerificationResult::CryptoUnavailable; + std::memmove(workspace + 10, workspace, 16); + constexpr char name[] = "lxmf.delivery"; + if (!crypto.sha256({reinterpret_cast(name), sizeof(name) - 1}, digest)) return VerificationResult::CryptoUnavailable; + std::memcpy(workspace, digest, 10); + if (!crypto.sha256({workspace, 26}, digest)) return VerificationResult::CryptoUnavailable; + if (std::memcmp(digest, directory.data, 16)) return VerificationResult::IdentityMismatch; + constexpr char domain[] = "trailmate.geocache/receipt/v1"; + std::memcpy(workspace, domain, sizeof(domain)); + std::memcpy(workspace + sizeof(domain), raw.data, raw.size); + const auto result = crypto.verifyEd25519({public_key.data + 32, 32}, signature, {workspace, sizeof(domain) + raw.size}); + if (result != VerificationResult::Valid) return result; + output = {destination, advertised_epoch, cache, hash, static_cast(revision), static_cast(state), observed}; + return VerificationResult::Valid; +} +} // namespace geocaching::protocol diff --git a/modules/core_geocaching/include/geocaching/storage/author_issued.h b/modules/core_geocaching/include/geocaching/storage/author_issued.h index c0ddeee8..f6b6ea8a 100644 --- a/modules/core_geocaching/include/geocaching/storage/author_issued.h +++ b/modules/core_geocaching/include/geocaching/storage/author_issued.h @@ -10,6 +10,10 @@ struct AuthorIssuedView ByteView revision_hash; ByteView author_public_key; StoredTime issued_at; + // Present only for receipt-verified historical recovery. Ordinary issuance + // retains the three-field representation and first-revision time check. + uint64_t recovered_created_at = 0; + bool has_recovered_creation = false; }; inline bool decodeAuthorIssued(ByteView key, ByteView value, AuthorIssuedView& out) { @@ -21,20 +25,25 @@ inline bool decodeAuthorIssued(ByteView key, ByteView value, AuthorIssuedView& o if (!candidate.revision) return false; protocol::CmpReader reader(value); size_t fields = 0; - if (!reader.array(fields, 3) || fields != 3 || !reader.binary(candidate.revision_hash, 32) || candidate.revision_hash.size != 32 || + if (!reader.array(fields, 4) || (fields != 3 && fields != 4) || !reader.binary(candidate.revision_hash, 32) || candidate.revision_hash.size != 32 || !reader.binary(candidate.author_public_key, 64) || candidate.author_public_key.size != 64 || - !decodeStoredTime(reader, candidate.issued_at) || !reader.finished()) return false; + !decodeStoredTime(reader, candidate.issued_at)) return false; + candidate.has_recovered_creation=fields==4; + if(candidate.has_recovered_creation&&(!reader.unsignedInteger(candidate.recovered_created_at)|| + !candidate.issued_at.has_utc||candidate.recovered_created_at>candidate.issued_at.utc_seconds))return false; + if(!reader.finished())return false; out = candidate; return true; } inline bool encodeAuthorIssued(const RevisionHash& hash, ByteView public_key, const StoredTime& issued_at, - uint8_t* output, size_t capacity, size_t& written) + uint8_t* output, size_t capacity, size_t& written, uint64_t recovered_created_at = UINT64_MAX) { written = 0; - if (!public_key.data || public_key.size != 64) return false; + const bool recovered=recovered_created_at!=UINT64_MAX; + if (!public_key.data || public_key.size != 64 || (recovered&&(!issued_at.has_utc||recovered_created_at>issued_at.utc_seconds))) return false; protocol::CmpWriter writer(output, capacity); - if (!writer.array(3) || !writer.binary({hash.bytes.data(), 32}) || !writer.binary(public_key) || - !encodeStoredTime(writer, issued_at)) return false; + if (!writer.array(recovered?4:3) || !writer.binary({hash.bytes.data(), 32}) || !writer.binary(public_key) || + !encodeStoredTime(writer, issued_at) || (recovered&&!writer.unsignedInteger(recovered_created_at))) return false; written = writer.size(); return true; } diff --git a/modules/core_geocaching/include/geocaching/storage/confirmed_publication.h b/modules/core_geocaching/include/geocaching/storage/confirmed_publication.h new file mode 100644 index 00000000..2ccc037d --- /dev/null +++ b/modules/core_geocaching/include/geocaching/storage/confirmed_publication.h @@ -0,0 +1,36 @@ +#pragma once +#include "geocaching/protocol/publish_request.h" +#include "geocaching/protocol/publish_response.h" +#include "geocaching/storage/publication_confirmation.h" + +namespace geocaching::storage +{ +// Borrow the existing verification lease. Output is usable only at Valid; +// the original signed cache and independent directory proof are both checked. +inline protocol::VerificationResult verifyConfirmedPublication(ByteView request_key, ByteView request_bytes, ByteView response_bytes, + protocol::RecordCrypto& crypto, uint8_t* workspace, size_t capacity, + protocol::VerifiedRecordView& content) +{ + if(!request_key.data||request_key.size!=48)return protocol::VerificationResult::InvalidRecord; + RequestId request; + std::memcpy(request.bytes.data(),request_key.data+32,16); + protocol::PublishRequestView publication; + if(!protocol::decodePublishRequest(request_bytes,request,publication))return protocol::VerificationResult::InvalidRecord; + auto result=protocol::verifyGeocache(publication.signed_cache,crypto,workspace,capacity,content); + if(result!=protocol::VerificationResult::Valid)return result; + PublicationConfirmationView proof; + if(decodePublicationConfirmation(response_bytes,proof)) + { + protocol::PublicationReceiptView receipt; + result=verifyPublicationConfirmation(response_bytes,{request_key.data+16,16},content,crypto,workspace,capacity,receipt); + } + else + { + protocol::PublishDisposition disposition; + result=protocol::decodePublishResponse(response_bytes,request,content.id,content.hash,content.record.revision,content.record.state,disposition) + ?protocol::VerificationResult::Valid:protocol::VerificationResult::InvalidRecord; + } + if(result!=protocol::VerificationResult::Valid)content={}; + return result; +} +} // namespace geocaching::storage diff --git a/modules/core_geocaching/include/geocaching/storage/publication_confirmation.h b/modules/core_geocaching/include/geocaching/storage/publication_confirmation.h new file mode 100644 index 00000000..ed36a035 --- /dev/null +++ b/modules/core_geocaching/include/geocaching/storage/publication_confirmation.h @@ -0,0 +1,52 @@ +#pragma once +#include "geocaching/protocol/publication_receipt.h" + +namespace geocaching::storage +{ +struct PublicationConfirmationView +{ + ByteView raw, signature, public_key; +}; +// Fits the existing outgoing terminal_data. No new table or shard bitmap. +inline bool decodePublicationConfirmation(ByteView encoded, PublicationConfirmationView& output) +{ + if(encoded.data){const auto a=reinterpret_cast(encoded.data),b=reinterpret_cast(&output);if(a<=b?b-a 384 || !reader.array(fields,4) || fields != 4 || + !reader.unsignedInteger(version) || version != 1 || !reader.binary(value.raw,192) || !value.raw.size || + !reader.binary(value.signature,64) || value.signature.size != 64 || !reader.binary(value.public_key,64) || value.public_key.size != 64 || !reader.finished()) return false; + output = value; + return true; +} +inline bool encodePublicationConfirmation(const PublicationConfirmationView& input, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || !input.raw.data || !input.raw.size || input.raw.size > 192 || !input.signature.data || input.signature.size != 64 || + !input.public_key.data || input.public_key.size != 64) return false; + const ByteView views[] = {input.raw,input.signature,input.public_key}; + for(const auto view:views){const auto a=reinterpret_cast(output),b=reinterpret_cast(view.data);if(a<=b?b-a(encoded.data),b=reinterpret_cast(&output);if(a<=b?b-ahistory_.latest_revision|| + (issued.has_recovered_creation&&issued.recovered_created_at!=history_.created_at))return false; + if(issued.revision>=recovery_floor_) + { + if(recovery_count_==UINT32_MAX)return false; + ++recovery_count_; + } + return true; + } ++count_; + if(issued.has_recovered_creation) + { + if((recovery_origin_||first_)&&history_.created_at!=issued.recovered_created_at)return false; + history_.created_at=issued.recovered_created_at;recovery_origin_=true; + if(issued.revision>recovery_floor_)recovery_floor_=issued.revision; + } if (issued.revision == 1) { if (first_) return false; + if(recovery_origin_&&history_.created_at!=issued.issued_at.utc_seconds)return false; first_ = true; history_.created_at = issued.issued_at.utc_seconds; } @@ -51,18 +69,32 @@ class PublicationHistoryScan { // The scan visits unique index keys. Count plus the latest revision // proves there is no missing issuance; table order need not be sorted. - if (confirmed > history_.latest_revision || count_ != history_.latest_revision || + if(recovery_floor_>1) + { + if(!recovery_validation_||confirmedhistory_.latest_revision|| + recovery_count_!=history_.latest_revision-recovery_floor_+1|| + history_.created_at>history_.latest_time.utc_seconds|| + (history_.latest_revision>recovery_floor_&&previous_revision_!=history_.latest_revision-1))return false; + } + else if (confirmed > history_.latest_revision || count_ != history_.latest_revision || (count_ && (!first_ || history_.created_at > history_.latest_time.utc_seconds)) || (count_ > 1 && previous_revision_ != history_.latest_revision - 1)) return false; out = history_; out.confirmed_revision = confirmed; return true; } + bool needsRecoveryValidation() const { return recovery_floor_>1&&!recovery_validation_; } + bool beginRecoveryValidation() + { + if(!needsRecoveryValidation())return false; + recovery_validation_=true;recovery_count_=0;return true; + } private: PublicationHistory history_; uint32_t count_ = 0, previous_revision_ = 0; - bool first_ = false; + uint32_t recovery_floor_ = 0, recovery_count_ = 0; + bool first_ = false, recovery_origin_ = false, recovery_validation_ = false; }; enum class PublicationHistoryResult : uint8_t @@ -87,6 +119,13 @@ PublicationHistoryResult readLogicalPublicationHistory(const View& view, ByteVie MutationView row; while (view.next(cursor, row)) if (row.table == 3 && !scan.consume(row.key, row.value, cache, author)) return PublicationHistoryResult::Invalid; + if(scan.needsRecoveryValidation()) + { + if(!scan.beginRecoveryValidation())return PublicationHistoryResult::Invalid; + cursor=0; + while(view.next(cursor,row)) + if(row.table==3&&!scan.consume(row.key,row.value,cache,author))return PublicationHistoryResult::Invalid; + } return scan.finish(published.confirmed_revision, out) ? PublicationHistoryResult::Ready : PublicationHistoryResult::Invalid; } } // namespace geocaching::storage diff --git a/modules/core_geocaching/include/geocaching/storage/publication_history_job.h b/modules/core_geocaching/include/geocaching/storage/publication_history_job.h new file mode 100644 index 00000000..8eaea152 --- /dev/null +++ b/modules/core_geocaching/include/geocaching/storage/publication_history_job.h @@ -0,0 +1,81 @@ +#pragma once +#include "geocaching/protocol/publish_request.h" +#include "geocaching/protocol/publish_response.h" +#include "geocaching/protocol/verify_record.h" +#include "geocaching/storage/publication_recovery.h" + +namespace geocaching::storage +{ +enum class PublicationHistoryJobResult : uint8_t +{ + Ready, + Invalid, + Forbidden, + Unavailable, + WorkspaceTooSmall +}; + +// Read-only projection. The store's root/read lease must still own evidence. +// Verification result and workspace are caller-owned to avoid another large +// protocol object on ESP stacks. No submit, resume, or transport side effect. +inline PublicationHistoryJobResult encodePublicationHistoryJob(const PublicationRecoveryView& evidence, + ByteView operation, ByteView author, + protocol::RecordCrypto& crypto, + uint8_t* workspace, size_t workspace_capacity, + protocol::VerifiedRecordView& verified, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!operation.data || operation.size != 16 || !author.data || author.size != 64 || !output || capacity < 192 || + std::memcmp(operation.data, evidence.task.data(), 16) || std::memcmp(operation.data, evidence.key.data() + 32, 16) || + evidence.outgoing_state > 5 || evidence.task_state > 5 || evidence.confirmed != (evidence.outgoing_state == 4) || + (evidence.confirmed && (evidence.task_state != 3 || !evidence.response.size)) || + (!evidence.confirmed && evidence.response.size)) return PublicationHistoryJobResult::Invalid; + uint8_t bits = 0; + for (size_t i = 0; i < operation.size; ++i) bits |= operation.data[i]; + if (!bits) return PublicationHistoryJobResult::Invalid; + const auto overlaps = [](ByteView input, const uint8_t* target, size_t size) + { + if (!input.size) return false; + if (!input.data || !target) return true; + const auto source = reinterpret_cast(input.data), destination = reinterpret_cast(target); + return source <= destination ? destination - source < input.size : source - destination < size; + }; + if (overlaps(evidence.request, output, capacity) || overlaps(evidence.response, output, capacity) || + overlaps(evidence.request, workspace, workspace_capacity) || overlaps(evidence.response, workspace, workspace_capacity)) + return PublicationHistoryJobResult::Invalid; + RequestId request; + std::memcpy(request.bytes.data(), operation.data, 16); + protocol::PublishRequestView published; + if (!protocol::decodePublishRequest(evidence.request, request, published)) return PublicationHistoryJobResult::Invalid; + const auto result = protocol::verifyGeocache(published.signed_cache, crypto, workspace, workspace_capacity, + verified, &evidence.cache, &evidence.hash); + if (result == protocol::VerificationResult::CryptoUnavailable) return PublicationHistoryJobResult::Unavailable; + if (result == protocol::VerificationResult::WorkspaceTooSmall) return PublicationHistoryJobResult::WorkspaceTooSmall; + if (result != protocol::VerificationResult::Valid) return PublicationHistoryJobResult::Invalid; + if (std::memcmp(verified.record.author_public_key.data, author.data, 64)) return PublicationHistoryJobResult::Forbidden; + if (evidence.confirmed) + { + protocol::PublishDisposition disposition; + if (!protocol::decodePublishResponse(evidence.response, request, verified.id, verified.hash, + verified.record.revision, verified.record.state, disposition)) + return PublicationHistoryJobResult::Invalid; + } + // A durable stop or revoked continuation doesn't establish whether a + // previously admitted request reached the directory. Preserve unknown. + const char* error = evidence.outgoing_state == 5 ? "Local publication storage failed" + : evidence.task_state == 4 ? "Publication task is blocked" + : nullptr; + const uint8_t phase = evidence.confirmed ? 3 : error ? 5 + : evidence.task_state == 5 || !evidence.continue_intent ? 4 + : 2; + protocol::CmpWriter writer(output, capacity); + if (!writer.array(8) || !writer.binary({request.bytes.data(), 16}) || !writer.unsignedInteger(phase) || + !writer.binary({verified.id.bytes.data(), 32}) || !writer.binary({verified.hash.bytes.data(), 32}) || + !writer.unsignedInteger(verified.record.revision) || !writer.unsignedInteger(1) || + !(evidence.confirmed ? writer.unsignedInteger(1) : writer.nil()) || !(error ? writer.text(error) : writer.nil())) + return PublicationHistoryJobResult::WorkspaceTooSmall; + written = writer.size(); + return PublicationHistoryJobResult::Ready; +} +} // namespace geocaching::storage diff --git a/modules/core_geocaching/include/geocaching/storage/publication_recovery.h b/modules/core_geocaching/include/geocaching/storage/publication_recovery.h index fc323d48..c83e1860 100644 --- a/modules/core_geocaching/include/geocaching/storage/publication_recovery.h +++ b/modules/core_geocaching/include/geocaching/storage/publication_recovery.h @@ -8,19 +8,26 @@ struct PublicationRecoveryFilter Destination local, remote; GeocacheId cache; RevisionHash hash; + std::array operation{}; bool has_remote = false, has_cache = false, has_hash = false; + bool has_operation : 1; + bool include_terminal : 1; + PublicationRecoveryFilter() : has_operation(false), include_terminal(false) {} bool matchesRequest(ByteView key) const { return key.data && key.size == 48 && !std::memcmp(key.data, local.bytes.data(), 16) && - (!has_remote || !std::memcmp(key.data + 16, remote.bytes.data(), 16)); + (!has_remote || !std::memcmp(key.data + 16, remote.bytes.data(), 16)) && + (!has_operation || !std::memcmp(key.data + 32, operation.data(), operation.size())); } bool same(const PublicationRecoveryFilter& other) const { return local.bytes == other.local.bytes && has_remote == other.has_remote && has_cache == other.has_cache && has_hash == other.has_hash && + has_operation == other.has_operation && include_terminal == other.include_terminal && (!has_operation || operation == other.operation) && (!has_remote || remote.bytes == other.remote.bytes) && (!has_cache || cache.bytes == other.cache.bytes) && (!has_hash || hash.bytes == other.hash.bytes); } }; +static_assert(sizeof(PublicationRecoveryFilter) <= 116, "Recovery filter must not grow beyond the operation binding"); // Metadata is owned. Request/response borrow the read lease until released. struct PublicationRecoveryView { @@ -31,7 +38,11 @@ struct PublicationRecoveryView StoredTime created; ByteView request, response; bool confirmed = false; + uint8_t outgoing_state = 0, task_state = 0; + bool continue_intent = false; }; +static_assert(sizeof(PublicationRecoveryView) == ((offsetof(PublicationRecoveryView, confirmed) + 1 + alignof(PublicationRecoveryView) - 1) / alignof(PublicationRecoveryView)) * alignof(PublicationRecoveryView), + "History evidence must fit the existing recovery metadata padding"); enum class PublicationRecoveryResult : uint8_t { None, @@ -43,13 +54,20 @@ inline PublicationRecoveryResult publicationRecoveryCandidate(const PublicationR const OutgoingView& outgoing, const TaskView& task, PublicationRecoveryView& out) { + // Terminal rows are available only in explicit, operation-bound read-only + // history mode. Background recovery must never turn a stop into a resend. + if (filter.include_terminal && !filter.has_operation) return PublicationRecoveryResult::Invalid; if (!filter.matchesRequest(key) || task.kind != 1) return PublicationRecoveryResult::None; + if (filter.has_operation && (!outgoing.task_id.data || outgoing.task_id.size != 16 || + std::memcmp(outgoing.task_id.data, filter.operation.data(), 16))) + return PublicationRecoveryResult::Invalid; if (!requestBelongsToTask(outgoing.task_id, task, key, outgoing) || task.cache_id.size != 32 || task.revision_hash.size != 32) return PublicationRecoveryResult::Invalid; if ((filter.has_cache && std::memcmp(task.cache_id.data, filter.cache.bytes.data(), 32)) || (filter.has_hash && std::memcmp(task.revision_hash.data, filter.hash.bytes.data(), 32))) return PublicationRecoveryResult::None; - if (outgoing.state > 4 || (outgoing.state == 4 && !filter.has_cache) || - (outgoing.state < 4 && (!outgoing.continue_intent || !task.continue_intent || task.state == 3 || task.state == 5))) + if (outgoing.state > 5 || task.state > 5) return PublicationRecoveryResult::Invalid; + if (!filter.include_terminal && (outgoing.state > 4 || (outgoing.state == 4 && !filter.has_cache && !filter.has_operation) || + (outgoing.state < 4 && (!outgoing.continue_intent || !task.continue_intent || task.state == 3 || task.state == 5)))) return PublicationRecoveryResult::None; std::memcpy(out.key.data(), key.data, 48); std::memcpy(out.task.data(), outgoing.task_id.data, 16); @@ -57,6 +75,9 @@ inline PublicationRecoveryResult publicationRecoveryCandidate(const PublicationR std::memcpy(out.hash.bytes.data(), task.revision_hash.data, 32); out.created = outgoing.created; out.confirmed = outgoing.state == 4; + out.outgoing_state = outgoing.state; + out.task_state = task.state; + out.continue_intent = outgoing.continue_intent && task.continue_intent; out.request = out.response = {}; return PublicationRecoveryResult::Ready; } @@ -69,13 +90,15 @@ inline bool preferPublicationRecovery(const PublicationRecoveryView& candidate, // lookups retain confirmed receipts; background recovery only resumes work. inline bool publicationRecoveryRequestEligible(const PublicationRecoveryFilter& filter, const OutgoingView& outgoing) { - return outgoing.state <= 4 && (outgoing.state == 4 ? filter.has_cache : outgoing.continue_intent); + if (filter.include_terminal) return filter.has_operation && outgoing.state <= 5; + return outgoing.state <= 4 && (outgoing.state == 4 ? filter.has_cache || filter.has_operation : outgoing.continue_intent); } template PublicationRecoveryResult readLogicalPublicationRecovery(const View& view, const PublicationRecoveryFilter& filter, PublicationRecoveryView& out) { out = {}; + if (filter.include_terminal && !filter.has_operation) return PublicationRecoveryResult::Invalid; size_t cursor = 0; MutationView row; bool found = false; diff --git a/modules/core_geocaching/include/geocaching/usecase/directory_pool.h b/modules/core_geocaching/include/geocaching/usecase/directory_pool.h index 98b1a3d5..bf4a7a6a 100644 --- a/modules/core_geocaching/include/geocaching/usecase/directory_pool.h +++ b/modules/core_geocaching/include/geocaching/usecase/directory_pool.h @@ -54,6 +54,11 @@ class DirectoryPool { entry->epoch = announce.epoch; entry->sequence = announce.sequence; + // A new directory incarnation must negotiate its limits again. + // Keep in_flight owned by the existing request lifecycle. + entry->capabilities_verified = false; + entry->max_query_items = 0; + entry->retry_after = 0; } else if (announce.sequence > entry->sequence) entry->sequence = announce.sequence; entry->last_seen = now; diff --git a/modules/core_geocaching/include/geocaching/usecase/publish_attempt.h b/modules/core_geocaching/include/geocaching/usecase/publish_attempt.h index 5aedd1fc..61496745 100644 --- a/modules/core_geocaching/include/geocaching/usecase/publish_attempt.h +++ b/modules/core_geocaching/include/geocaching/usecase/publish_attempt.h @@ -156,6 +156,8 @@ class PublishAttempt source.bytes == destination_.bytes && request.bytes == request_.bytes; } const GeocacheId& cacheId() const { return id_; } + const RequestId& requestId() const { return request_; } + const RevisionHash& revisionHash() const { return hash_; } uint32_t revision() const { return revision_; } CacheState state() const { return state_; } diff --git a/modules/core_geocaching/include/geocaching/usecase/query_client.h b/modules/core_geocaching/include/geocaching/usecase/query_client.h index ba35d9e5..5f5d4484 100644 --- a/modules/core_geocaching/include/geocaching/usecase/query_client.h +++ b/modules/core_geocaching/include/geocaching/usecase/query_client.h @@ -60,6 +60,9 @@ class QueryClient public: explicit QueryClient(QueryClientPort& port) : port_(port), directories_(directory_entries_.data(), directory_entries_.size()) {} static constexpr uint64_t kReplyTimeoutMs = 120000; + static constexpr size_t kDirectoryCapacity = 4; + size_t directoryCount() const { return directories_.size(); } + const DirectoryEntry* directory(size_t index) const { return index < directories_.size() ? &directory_entries_[index] : nullptr; } QueryClient(const QueryClient&) = delete; QueryClient& operator=(const QueryClient&) = delete; bool observe(const Destination& discovery, const Destination& derived_delivery, @@ -268,7 +271,7 @@ class QueryClient QueryClientPhase after_persist_ = QueryClientPhase::Idle; uint8_t pending_max_items_ = 0; QueryClientPort& port_; - std::array directory_entries_{}; + std::array directory_entries_{}; DirectoryPool directories_; DirectoryEntry* selected_ = nullptr; protocol::QueryRegion region_; diff --git a/modules/core_geocaching/tests/CMakeLists.txt b/modules/core_geocaching/tests/CMakeLists.txt index f18c410b..a9f46cd0 100644 --- a/modules/core_geocaching/tests/CMakeLists.txt +++ b/modules/core_geocaching/tests/CMakeLists.txt @@ -5,6 +5,15 @@ add_library(geocaching_lxmf_wire_compile OBJECT ../../core_chat/src/infra/lxmf/l target_include_directories(geocaching_lxmf_wire_compile PRIVATE ../../core_chat/include) target_compile_features(geocaching_lxmf_wire_compile PRIVATE cxx_std_17) enable_testing() +add_executable(geocaching_publication_operation_filter_test test_publication_operation_filter.cpp) +target_include_directories(geocaching_publication_operation_filter_test PRIVATE ../include) +target_compile_features(geocaching_publication_operation_filter_test PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(geocaching_publication_operation_filter_test PRIVATE /UNDEBUG) +else() + target_compile_options(geocaching_publication_operation_filter_test PRIVATE -UNDEBUG) +endif() +add_test(NAME geocaching_publication_operation_filter COMMAND geocaching_publication_operation_filter_test) set(LVGL_RUNTIME "${CMAKE_CURRENT_SOURCE_DIR}/../../../platform/esp/arduino_common/src/LV_Helper_v9.cpp") set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${LVGL_RUNTIME}") file(READ "${LVGL_RUNTIME}" LVGL_RUNTIME_TEXT) @@ -76,6 +85,12 @@ target_include_directories(geocaching_publish_attempt_test PRIVATE ../include .. target_link_libraries(geocaching_publish_attempt_test PRIVATE geocaching_native_hash) target_compile_features(geocaching_publish_attempt_test PRIVATE cxx_std_17) add_test(NAME geocaching_publish_attempt COMMAND geocaching_publish_attempt_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/signed-cache-v1.bin ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/publish-response-v1.bin) +add_executable(geocaching_publication_receipt_test test_publication_receipt.cpp + ${ED25519_DIR}/verify.c ${ED25519_DIR}/sign.c ${ED25519_DIR}/keypair.c ${ED25519_DIR}/fe.c ${ED25519_DIR}/ge.c ${ED25519_DIR}/sc.c ${ED25519_DIR}/sha512.c) +target_include_directories(geocaching_publication_receipt_test PRIVATE ../include ../../core_chat/include) +target_link_libraries(geocaching_publication_receipt_test PRIVATE geocaching_native_hash) +target_compile_features(geocaching_publication_receipt_test PRIVATE cxx_std_17) +add_test(NAME geocaching_publication_receipt COMMAND geocaching_publication_receipt_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/signed-cache-v1.bin) add_executable(geocaching_record_signing_test test_record_signing.cpp ${ED25519_DIR}/sign.c ${ED25519_DIR}/keypair.c ${ED25519_DIR}/fe.c ${ED25519_DIR}/ge.c ${ED25519_DIR}/sc.c ${ED25519_DIR}/sha512.c) @@ -435,6 +450,9 @@ target_compile_features(geocaching_browse_runtime_test PRIVATE cxx_std_17) target_compile_definitions(geocaching_browse_runtime_test PRIVATE ESP_PLATFORM=1) target_link_libraries(geocaching_browse_runtime_test PRIVATE geocaching_native_hash) add_test(NAME geocaching_browse_runtime COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures) +add_test(NAME geocaching_lazy_mutex COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures lazy-mutex) +add_test(NAME geocaching_web_draft_read COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures web-draft-read) +add_test(NAME geocaching_web_latest_revision COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures web-latest) add_test(NAME geocaching_browse_runtime_close_detail COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures save-close-detail) add_test(NAME geocaching_draft_only_map COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures local-draft-only) add_test(NAME geocaching_legacy_index_upgrade COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures legacy-index-upgrade) @@ -442,6 +460,10 @@ add_test(NAME geocaching_legacy_saved_projection COMMAND geocaching_browse_runti add_test(NAME geocaching_legacy_saved_bad_signature COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures legacy-saved-bad-signature) add_test(NAME geocaching_legacy_saved_projected_index COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures legacy-saved-projected-index) add_test(NAME geocaching_legacy_saved_object_index COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures legacy-saved-object-index) +add_test(NAME geocaching_web_stop COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures web-stop) +add_test(NAME geocaching_web_signed_stop COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures web-signed-stop) +add_test(NAME geocaching_web_signature_restored COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures web-signature-restored) +add_test(NAME geocaching_signature_input_recovery COMMAND geocaching_browse_runtime_test ${CMAKE_CURRENT_SOURCE_DIR}/fixtures signature-input-recovery) add_executable(geocaching_local_map_test test_local_map_overlay.cpp) set(GPS_RUNTIME "${CMAKE_CURRENT_SOURCE_DIR}/../../ui_shared/src/ui/screens/gps/gps_page_runtime.cpp") diff --git a/modules/core_geocaching/tests/runtime_fakes/app/app_facade_access.h b/modules/core_geocaching/tests/runtime_fakes/app/app_facade_access.h new file mode 100644 index 00000000..1be11adf --- /dev/null +++ b/modules/core_geocaching/tests/runtime_fakes/app/app_facade_access.h @@ -0,0 +1,6 @@ +#pragma once +#include "runtime_environment.h" +namespace app +{ +inline AppContext& appFacade() { return AppContext::getInstance(); } +} diff --git a/modules/core_geocaching/tests/runtime_fakes/platform/esp/common/reticulum_runtime_compat.h b/modules/core_geocaching/tests/runtime_fakes/platform/esp/common/reticulum_runtime_compat.h new file mode 100644 index 00000000..5718c27c --- /dev/null +++ b/modules/core_geocaching/tests/runtime_fakes/platform/esp/common/reticulum_runtime_compat.h @@ -0,0 +1,2 @@ +#pragma once +#include diff --git a/modules/core_geocaching/tests/runtime_fakes/runtime_environment.h b/modules/core_geocaching/tests/runtime_fakes/runtime_environment.h index c1fe7aa6..c012c5f3 100644 --- a/modules/core_geocaching/tests/runtime_fakes/runtime_environment.h +++ b/modules/core_geocaching/tests/runtime_fakes/runtime_environment.h @@ -2,6 +2,7 @@ // Hardware seams only. The test links the production browse runtime, stores, // clients and dispatcher without conditional compilation in those sources. #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "chat/ports/device_author.h" #include "chat/ports/i_geocaching_transport.h" #include "esp_random.h" #include "geocaching/protocol/sign_record.h" @@ -20,6 +21,9 @@ namespace runtime_test { inline uint64_t clock_ms = 0; inline bool memory_available = true, card_ready = true, external_owner = false; +inline bool mutex_available = true; +inline unsigned mutex_creates = 0; +inline unsigned busy_author_reads = 0; inline size_t internal_free = SIZE_MAX; inline bool fail_read = false, in_ui = false; inline unsigned busy_reads = 0; @@ -68,6 +72,8 @@ using SemaphoreHandle_t = bool*; constexpr int pdTRUE = 1; inline SemaphoreHandle_t xSemaphoreCreateMutex() { + ++runtime_test::mutex_creates; + if (!runtime_test::mutex_available) return nullptr; static bool taken = false; return &taken; } @@ -78,6 +84,7 @@ inline int xSemaphoreTake(SemaphoreHandle_t semaphore, unsigned) return pdTRUE; } inline void xSemaphoreGive(SemaphoreHandle_t semaphore) { *semaphore = false; } +inline void vSemaphoreDelete(SemaphoreHandle_t) {} inline uint32_t millis() { return static_cast(runtime_test::clock_ms); } inline int64_t esp_timer_get_time() { return static_cast(runtime_test::clock_ms * 1000); } struct RuntimeTestSerial @@ -250,7 +257,20 @@ class MeshAdapterRouter bool getGeocachingAuthorKey(uint8_t out[64]) { identity.combinedPublicKey(out); - return ready; + return identity.isReady(); // Local author identity is independent of RF/network readiness. + } + uint32_t author_generation = 7; + DeviceAuthorResult readDeviceAuthorKey(uint8_t out[64], uint32_t& generation) + { + generation = 0; + if (runtime_test::busy_author_reads) + { + --runtime_test::busy_author_reads; + return DeviceAuthorResult::Unavailable; + } + identity.combinedPublicKey(out); + generation = author_generation; + return DeviceAuthorResult::Ok; } bool signGeocachingRecord(lxmf::ByteSpan record, uint8_t* scratch, size_t capacity, uint8_t* out, size_t output_capacity, size_t& written) { diff --git a/modules/core_geocaching/tests/runtime_fakes/sd_runtime.cpp b/modules/core_geocaching/tests/runtime_fakes/sd_runtime.cpp index 0feed7e5..bac2d05c 100644 --- a/modules/core_geocaching/tests/runtime_fakes/sd_runtime.cpp +++ b/modules/core_geocaching/tests/runtime_fakes/sd_runtime.cpp @@ -91,6 +91,10 @@ SdRuntimeFile::~SdRuntimeFile() close(); delete impl_; } +bool SdRuntimeFile::open(const char* path, const char* mode, uint32_t expected_session) +{ + return expected_session == sd_media_session() && sd_card_ready() && !sd_external_block_owner_active() && open(path, mode); +} bool SdRuntimeFile::open(const char* path, const char* mode) { close(); diff --git a/modules/core_geocaching/tests/test_author_history.cpp b/modules/core_geocaching/tests/test_author_history.cpp index 8708d728..fbdd911c 100644 --- a/modules/core_geocaching/tests/test_author_history.cpp +++ b/modules/core_geocaching/tests/test_author_history.cpp @@ -55,5 +55,25 @@ int main() issued.has_utc = false; if (!storage::encodeAuthorIssued(hash, {public_key, 64}, issued, value, sizeof(value), length) || no_utc.consume({key, 36}, {value, length}, cache, {public_key, 64})) return 13; + const auto restored_scan=[&](std::initializer_list revisions,uint32_t floor,uint32_t confirmed,bool expected) + { + storage::PublicationHistoryScan scan; + const auto rows=[&]() { + for(auto revision:revisions) + { + key[35]=revision;hash.bytes.fill(revision);issued.has_utc=true;issued.utc_seconds=1000+revision; + const auto anchor=revision==floor?uint64_t(1001):UINT64_MAX; + if(!storage::encodeAuthorIssued(hash,{public_key,64},issued,value,sizeof(value),length,anchor)|| + !scan.consume({key,36},{value,length},cache,{public_key,64}))return false; + } + return true; + }; + if(!rows()||!scan.needsRecoveryValidation()||scan.finish(confirmed,history)||!scan.beginRecoveryValidation()||!rows())return false; + const bool ready=scan.finish(confirmed,history); + return ready==expected&&(!ready||(history.created_at==1001&&history.confirmed_revision==confirmed)); + }; + if(!restored_scan({3},3,3,true)||!restored_scan({1,5,3,4},3,3,true))return 14; + if(!restored_scan({1,3,5},3,3,false)||!restored_scan({1,3,5},5,3,false))return 15; + if(!restored_scan({1,3,5},5,5,true))return 16; return 0; } diff --git a/modules/core_geocaching/tests/test_author_issued.cpp b/modules/core_geocaching/tests/test_author_issued.cpp index 050c81b2..4d80d341 100644 --- a/modules/core_geocaching/tests/test_author_issued.cpp +++ b/modules/core_geocaching/tests/test_author_issued.cpp @@ -13,10 +13,19 @@ int main() if (!storage::encodeAuthorIssued(hash, {public_key, 64}, time, bytes, sizeof(bytes), size)) return 1; storage::AuthorIssuedView out; if (!storage::decodeAuthorIssued({key, 36}, {bytes, size}, out) || out.revision != 1 || - out.author_public_key.data[0] != 7 || out.revision_hash.data[0] != 9 || out.issued_at.monotonic_ms != 123) return 2; + out.author_public_key.data[0] != 7 || out.revision_hash.data[0] != 9 || out.issued_at.monotonic_ms != 123 || out.has_recovered_creation || bytes[0] != 0x93) return 2; key[35] = 0; if (storage::decodeAuthorIssued({key, 36}, {bytes, size}, out)) return 3; time.utc_trusted = true; if (storage::encodeAuthorIssued(hash, {public_key, 64}, time, bytes, sizeof(bytes), size) || size) return 4; + key[35] = 3;time.utc_trusted = false;time.has_utc = true;time.utc_seconds = 0; + if (!storage::encodeAuthorIssued(hash,{public_key,64},time,bytes,sizeof(bytes),size,0) || bytes[0] != 0x94 || + !storage::decodeAuthorIssued({key,36},{bytes,size},out) || !out.has_recovered_creation || out.recovered_created_at != 0) return 5; + time.utc_seconds = 100; + if (!storage::encodeAuthorIssued(hash,{public_key,64},time,bytes,sizeof(bytes),size,50) || + !storage::decodeAuthorIssued({key,36},{bytes,size},out) || out.recovered_created_at != 50) return 6; + if (storage::encodeAuthorIssued(hash,{public_key,64},time,bytes,sizeof(bytes),size,101) || size) return 7; + time.has_utc = false; + if (storage::encodeAuthorIssued(hash,{public_key,64},time,bytes,sizeof(bytes),size,0) || size) return 8; return 0; } diff --git a/modules/core_geocaching/tests/test_browse_runtime.cpp b/modules/core_geocaching/tests/test_browse_runtime.cpp index 07878810..dc102a22 100644 --- a/modules/core_geocaching/tests/test_browse_runtime.cpp +++ b/modules/core_geocaching/tests/test_browse_runtime.cpp @@ -1,9 +1,17 @@ #include "geocaching/protocol/get_response.h" +#include "geocaching/protocol/get_request.h" #include "geocaching/protocol/publish_request.h" #include "geocaching/protocol/query_response.h" #include "platform/esp/arduino_common/geocaching/browse_runtime.h" #include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_draft_catalog.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_new_task.h" +#include "platform/esp/arduino_common/geocaching/sd_index_initialize.h" +#include "platform/esp/arduino_common/geocaching/sd_indexed_author_reservation.h" +#include "platform/esp/arduino_common/geocaching/web_confirmation_job.h" +#include "platform/esp/arduino_common/geocaching/indexed_publication_store.h" #include "runtime_environment.h" +#include "geocaching/protocol/record_encoder.h" #include "storage_owner.h" #include "ui_presentation/geocaching/local_map_overlay.h" #include @@ -250,15 +258,318 @@ DetailText detailText(const ui::geocaching::Item& item) require(!test::ui_io, "detail UI callback touched SD"); return result; } +void checkMissingConfirmationHistory(const std::vector& signed_record, + const geocaching::protocol::VerifiedRecordView& content, + geocaching::ByteView proof, geocaching::protocol::RecordCrypto& crypto) +{ + namespace sd = platform::esp::arduino_common::geocaching; + namespace gc = geocaching::storage; + // A distinct, genuinely initialized empty volume; restore the caller's + // simulated card afterwards so its runtime leases remain unchanged. + struct RestoreCard + { + decltype(test::files) files = std::move(test::files); + decltype(test::directories) directories = std::move(test::directories); + ~RestoreCard() { test::files = std::move(files); test::directories = std::move(directories); } + } original; + test::files.clear(); test::directories.clear(); test::directories.insert("/"); + gc::VolumeInstance volume{},confirmed; volume.fill(71); + require(sd::createNewSdVolume(volume,confirmed)==sd::SdVolumeResult::Ready&&confirmed==volume,"fresh confirmation volume initialization failed"); + std::array roots; + sd::SdIndexInitialize initialize(volume); + require(initialize.begin(roots[0]),"fresh confirmation index initialization rejected"); + auto initialized=sd::IndexRootWriteStep::Working; + for(unsigned i=0;i<100000&&initialized==sd::IndexRootWriteStep::Working;++i)initialized=initialize.step(); + require(initialized==sd::IndexRootWriteStep::Verified,"fresh confirmation index initialization failed"); + roots[1]=roots[0];gc::IndexRootView root; + require(gc::decodeIndexRoot({roots[0].data(),roots[0].size()},volume,root),"fresh confirmation root invalid"); + unsigned copy=0;std::array frame{},verification{}; + std::vector outgoing(signed_record.size()+4096),request_bytes(signed_record.size()+64); + gc::QueuedRequestWorkspace workspace(outgoing.data(),outgoing.size()); + geocaching::RequestId request;request.bytes.fill(42);geocaching::Destination local{},remote{}; + gc::PublicationConfirmationView decoded_proof;size_t count=0; + require(gc::decodePublicationConfirmation(proof,decoded_proof),"fresh confirmation proof envelope invalid"); + geocaching::protocol::CmpReader receipt(decoded_proof.raw);uint64_t version=0;geocaching::ByteView delivery; + require(receipt.array(count,8)&&receipt.unsignedInteger(version)&&receipt.binary(delivery,16)&&delivery.size==16,"fresh confirmation directory missing"); + std::memcpy(remote.bytes.data(),delivery.data,16); + size_t request_size=0; + require(geocaching::protocol::encodePublishRequest(request,content.record.encoded,content.signature,8192,request_bytes.data(),request_bytes.size(),request_size),"fresh confirmation request encoding failed"); + const auto empty_files=test::files; + std::vector bad_proof(proof.data,proof.data+proof.size);bad_proof.back()^=1; + sd::SdIndexedNewTask invalid(volume); + require(!invalid.beginConfirmed(root,copy,local,remote,request,request.bytes,{request_bytes.data(),request_size},{bad_proof.data(),bad_proof.size()},{},workspace,frame.data(),frame.size(),roots[1-copy],crypto,verification.data(),verification.size())&&test::files==empty_files,"invalid history proof reached fresh storage"); + std::array request_key{};std::memcpy(request_key.data()+16,remote.bytes.data(),16);std::memcpy(request_key.data()+32,request.bytes.data(),16); + gc::OutgoingView completed;completed.request={request_bytes.data(),request_size};completed.state=4;completed.task_id={request.bytes.data(),16};completed.terminal_data=proof; + size_t compact_size=0; + require(gc::encodeOutgoing({request_key.data(),request_key.size()},completed,outgoing.data(),outgoing.size(),compact_size),"compact confirmation fixture encode failed"); + gc::QueuedRequestWorkspace compact(outgoing.data(),compact_size);sd::SdIndexedNewTask bounded(volume); + require(bounded.beginConfirmed(root,copy,local,remote,request,request.bytes,{request_bytes.data(),request_size},proof,{},compact,frame.data(),frame.size(),roots[1-copy],crypto,verification.data(),verification.size()),"compact confirmation admission failed before recovery"); + auto bounded_status=sd::IndexedCommitStep::Working; + for(unsigned i=0;i<100000&&bounded_status==sd::IndexedCommitStep::Working;++i)bounded_status=bounded.step(); + require(bounded_status==sd::IndexedCommitStep::Invalid&&test::files==empty_files,"insufficient recovery workspace wrote partial history"); + // An authentic network head must not introduce contradictory origin + // metadata into an existing local author history. No task or draft writes. + const auto empty_roots=roots; + for(unsigned conflict=0;conflict<3;++conflict) + { + if(conflict==1&&content.record.revision==1)continue; + std::array prior_key{}; + std::memcpy(prior_key.data(),content.id.bytes.data(),32); + const uint32_t prior_revision=conflict==1?1:2; + for(unsigned i=0;i<4;++i)prior_key[32+i]=static_cast(prior_revision>>((3-i)*8)); + std::array prior_author; + std::memcpy(prior_author.data(),content.record.author_public_key.data,64); + if(conflict==2)prior_author[0]^=1; + gc::StoredTime prior_time;prior_time.has_utc=true;prior_time.utc_seconds=content.record.created_at+(conflict==2?0:1); + geocaching::RevisionHash prior_hash;prior_hash.bytes.fill(71); + std::array prior_bytes{};size_t prior_size=0; + const uint64_t origin=conflict==1?UINT64_MAX:content.record.created_at+(conflict==0?1:0); + require(gc::encodeAuthorIssued(prior_hash,{prior_author.data(),prior_author.size()},prior_time,prior_bytes.data(),prior_bytes.size(),prior_size,origin),"conflicting origin fixture encoding failed"); + gc::MutationView mutation{3,{prior_key.data(),prior_key.size()},{prior_bytes.data(),prior_size},false}; + sd::SdIndexedCommit seed(volume); + require(seed.begin(root,copy,&mutation,1,frame.data(),frame.size(),roots[1-copy],false,1),"conflicting origin fixture commit rejected"); + auto seeded=sd::IndexedCommitStep::Working; + for(unsigned i=0;i<100000&&seeded==sd::IndexedCommitStep::Working;++i)seeded=seed.step(); + require(seeded==sd::IndexedCommitStep::Verified&&seed.committed(root),"conflicting origin fixture commit failed"); + copy=1-copy; + const auto before_rejection=test::files; + sd::SdIndexedNewTask blocked(volume); + require(blocked.beginConfirmed(root,copy,local,remote,request,request.bytes,{request_bytes.data(),request_size},proof,{},workspace,frame.data(),frame.size(),roots[1-copy],crypto,verification.data(),verification.size()),"origin conflict should be checked by incremental storage reads"); + auto rejected=sd::IndexedCommitStep::Working; + for(unsigned i=0;i<100000&&rejected==sd::IndexedCommitStep::Working;++i)rejected=blocked.step(); + require(rejected==sd::IndexedCommitStep::Invalid&&test::files==before_rejection,"conflicting local origin admitted partial recovered history"); + test::files=empty_files;roots=empty_roots;copy=0; + require(gc::decodeIndexRoot({roots[0].data(),roots[0].size()},volume,root),"empty root restoration failed after conflict test"); + } + const auto restore=[&] { + sd::SdIndexedNewTask job(volume); + require(job.beginConfirmed(root,copy,local,remote,request,request.bytes,{request_bytes.data(),request_size},proof,{},workspace,frame.data(),frame.size(),roots[1-copy],crypto,verification.data(),verification.size()),"fresh confirmation admission rejected"); + auto status=sd::IndexedCommitStep::Working; + for(unsigned i=0;i<100000&&status==sd::IndexedCommitStep::Working;++i)status=job.step(); + const auto revision=root.revision; + require(status==sd::IndexedCommitStep::Verified&&job.committed(root),"fresh confirmation restoration failed"); + if(root.revision!=revision)copy=1-copy; + }; + restore();const auto restored_files=test::files;restore(); + require(test::files==restored_files,"fresh confirmation retry wrote duplicate history"); + gc::DraftCatalogPage catalog_page;sd::SdIndexedDraftCatalog catalog(volume,crypto,verification.data(),verification.size()); + require(catalog.begin(root,0,catalog_page,frame.data(),frame.size()),"restored history catalog read rejected"); + auto catalog_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&catalog_status==sd::IndexGetStep::Working;++i)catalog_status=catalog.step(); + require(catalog_status==sd::IndexGetStep::Ready&&catalog_page.count==1&&catalog_page.rows[0].publication.confirmed_revision==content.record.revision, + "restored history does not project its directory-confirmed version"); + gc::PublicationHistory device_history; + sd::SdIndexedPublicationHistory history_reader(volume,crypto,verification.data(),verification.size()); + require(history_reader.begin(root,content.record.creation_nonce,1,content.id,content.record.author_public_key,device_history,frame.data(),frame.size()),"restored device publication history read rejected"); + auto history_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&history_status==sd::IndexGetStep::Working;++i)history_status=history_reader.step(); + require(history_status==sd::IndexGetStep::Ready&&device_history.latest_revision==content.record.revision&&device_history.confirmed_revision==content.record.revision&& + device_history.created_at==content.record.created_at&&device_history.latest_hash.bytes==content.hash.bytes,"restored device UI history cannot continue its signed head"); + std::array issued_key{};std::memcpy(issued_key.data(),content.id.bytes.data(),32); + for(unsigned i=0;i<4;++i)issued_key[32+i]=static_cast(content.record.revision>>((3-i)*8)); + sd::SdIndexGet issued_read(volume);require(issued_read.begin(root,3,{issued_key.data(),issued_key.size()},frame.data(),frame.size()),"restored issuance read rejected"); + auto read_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&read_status==sd::IndexGetStep::Working;++i)read_status=issued_read.step(); + gc::AuthorIssuedView issued; + require(read_status==sd::IndexGetStep::Ready&&gc::decodeAuthorIssued({issued_key.data(),issued_key.size()},issued_read.value(),issued)&& + !std::memcmp(issued.revision_hash.data,content.hash.bytes.data(),32)&&issued.has_recovered_creation&&issued.recovered_created_at==content.record.created_at,"fresh restoration omitted signed history"); + sd::SdIndexGet draft_read(volume);require(draft_read.begin(root,4,content.record.creation_nonce,frame.data(),frame.size()),"restored draft read rejected"); + read_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&read_status==sd::IndexGetStep::Working;++i)read_status=draft_read.step(); + gc::DraftView draft; + require(read_status==sd::IndexGetStep::Ready&&gc::decodeDraft(content.record.creation_nonce,draft_read.value(),draft)&&draft.generation==1&& + draft.author.size==64&&!std::memcmp(draft.author.data,content.record.author_public_key.data,64)&&draft.base_hash.size==32&& + !std::memcmp(draft.base_hash.data,content.hash.bytes.data(),32)&&draft.name==content.record.name,"fresh restoration omitted editable predecessor"); + auto successor=content.record;successor.revision++;successor.previous_hash={content.hash.bytes.data(),32};successor.updated_at++; + std::vector successor_bytes(content.record.encoded.size+64);size_t successor_size=0; + require(geocaching::protocol::encodeGeocacheRecord(successor,successor_bytes.data(),successor_bytes.size(),successor_size),"restored successor encoding failed"); + gc::StoredTime issued_time;issued_time.has_utc=true;issued_time.utc_seconds=successor.updated_at; + sd::SdIndexedAuthorReservation reservation(volume,crypto); + require(reservation.beginReserve(root,copy,{successor_bytes.data(),successor_size},issued_time,outgoing.data(),outgoing.size(),frame.data(),frame.size(),roots[1-copy],content.record.creation_nonce,1,true),"restored successor reservation rejected"); + auto reserved=sd::IndexedCommitStep::Working; + for(unsigned i=0;i<100000&&reserved==sd::IndexedCommitStep::Working;++i)reserved=reservation.step(); + if(reserved!=sd::IndexedCommitStep::Verified)std::fprintf(stderr,"Restored predecessor revision=%u next reservation status=%u\n",content.record.revision,static_cast(reserved)); + require(reserved==sd::IndexedCommitStep::Verified&&reservation.committed(root),"restored history cannot maintain its next version"); + copy=1-copy; + sd::SdIndexGet advanced_draft(volume);require(advanced_draft.begin(root,4,content.record.creation_nonce,frame.data(),frame.size()),"advanced restored draft read rejected"); + read_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&read_status==sd::IndexGetStep::Working;++i)read_status=advanced_draft.step(); + require(read_status==sd::IndexGetStep::Ready,"advanced restored draft missing"); + const std::vector preserved_draft(advanced_draft.value().data,advanced_draft.value().data+advanced_draft.value().size); + request.bytes.fill(43); + require(geocaching::protocol::encodePublishRequest(request,content.record.encoded,content.signature,8192,request_bytes.data(),request_bytes.size(),request_size),"repeat historical confirmation encoding failed"); + restore(); + sd::SdIndexGet retained_draft(volume);require(retained_draft.begin(root,4,content.record.creation_nonce,frame.data(),frame.size()),"retained restored draft read rejected"); + read_status=sd::IndexGetStep::Working; + for(unsigned i=0;i<100000&&read_status==sd::IndexGetStep::Working;++i)read_status=retained_draft.step(); + require(read_status==sd::IndexGetStep::Ready&&retained_draft.value().size==preserved_draft.size()&& + !std::memcmp(retained_draft.value().data,preserved_draft.data(),preserved_draft.size()),"historical confirmation overwrote later draft work"); +} +void checkExternalConfirmationProjection(const std::vector& signed_record, const geocaching::protocol::VerifiedRecordView& content, uint32_t generation) +{ + namespace sd = platform::esp::arduino_common::geocaching; + namespace gc = geocaching::storage; + using namespace geocaching; + test::in_ui = false; + gc::VolumeInstance volume; + require(sd::inspectSdVolume(volume) == sd::SdVolumeResult::Ready, "confirmation volume missing"); + std::array roots; + for(unsigned i=0;i<2;++i){const auto& bytes=test::files.at("/trailmate/geocaching/.state/index/root.h"+std::to_string(i));std::copy(bytes.begin(),bytes.end(),roots[i].begin());} + gc::IndexRootView a,b,root; + require(gc::decodeIndexRoot({roots[0].data(),roots[0].size()},volume,a)&&gc::decodeIndexRoot({roots[1].data(),roots[1].size()},volume,b)&&gc::selectIndexRoot(a,b,root),"confirmation root invalid"); + unsigned copy=b.revision>a.revision?1:0; + platform::esp::common::EspGeocachingCrypto crypto; + chat::MeshAdapterRouter directory; + std::array frame{},verification{}; + std::array digest{}; + std::array delivery{},epoch{},operation{};epoch.fill(4);operation.fill(6); + crypto.sha256({directory.identity.public_key.data(),64},digest.data());std::memcpy(verification.data()+10,digest.data(),16); + constexpr char name[]="lxmf.delivery"; + crypto.sha256({reinterpret_cast(name),sizeof(name)-1},digest.data());std::memcpy(verification.data(),digest.data(),10); + crypto.sha256({verification.data(),26},digest.data());std::memcpy(delivery.data(),digest.data(),16); + std::array proof_raw{}; + protocol::CmpWriter proof_writer(proof_raw.data(),proof_raw.size()); + require(proof_writer.array(8)&&proof_writer.unsignedInteger(1)&&proof_writer.binary({delivery.data(),16})&&proof_writer.binary({epoch.data(),16})&& + proof_writer.binary({content.id.bytes.data(),32})&&proof_writer.unsignedInteger(content.record.revision)&&proof_writer.binary({content.hash.bytes.data(),32})&& + proof_writer.unsignedInteger(static_cast(content.record.state))&&proof_writer.unsignedInteger(1800000000),"confirmation proof encode failed"); + constexpr char domain[]="trailmate.geocache/receipt/v1"; + std::memcpy(verification.data(),domain,sizeof(domain));std::memcpy(verification.data()+sizeof(domain),proof_raw.data(),proof_writer.size()); + std::array signature{};directory.identity.sign(verification.data(),sizeof(domain)+proof_writer.size(),signature.data()); + std::array proof{};size_t proof_size=0; + require(gc::encodePublicationConfirmation({{proof_raw.data(),proof_writer.size()},{signature.data(),64},{directory.identity.public_key.data(),64}},proof.data(),proof.size(),proof_size),"stored proof encode failed"); + test::files["/trailmate/cache.confirm.upload"]=signed_record; + std::array upload_hash{};require(crypto.sha256({signed_record.data(),signed_record.size()},upload_hash.data()),"confirmation upload hash failed"); + std::array upload_id{},upload_owner{};upload_id.fill(8);upload_owner.fill(9); + until([&]{return rt::queueWebConfirmation(upload_id.data(),upload_owner.data(),upload_hash.data(),static_cast(signed_record.size()),content.record.author_public_key.data,generation,proof.data(),proof_size)==rt::WebReadResult::Ready;},"runtime confirmation not admitted"); + size_t status_size=0; + test::busy_author_reads = 3; + require(rt::readWebConfirmation(upload_id.data(),upload_owner.data(),verification.data(),verification.size(),status_size)==rt::WebReadResult::Pending, + "transient author lock reported stale confirmation identity"); + until([&]{return test::busy_author_reads==0;},"confirmation did not retry the unavailable author lock"); + until([&]{ + if(rt::readWebConfirmation(upload_id.data(),upload_owner.data(),verification.data(),verification.size(),status_size)!=rt::WebReadResult::Ready)return false; + protocol::CmpReader status({verification.data(),status_size});size_t count=0;ByteView id,hash;uint64_t version=0,phase=0; + require(status.array(count,8)&&status.binary(id,16)&&status.binary(hash,32)&&status.unsignedInteger(version)&&status.unsignedInteger(phase),"runtime confirmation status invalid"); + require(version==generation&&!std::memcmp(id.data,upload_id.data(),16)&&!std::memcmp(hash.data,upload_hash.data(),32),"runtime confirmation generation or upload binding changed"); + require(phase!=2,"runtime confirmation failed"); + if(phase!=1)return false; + ByteView cache,revision_hash;uint64_t revision=0,durable=0; + require(status.binary(cache,32)&&cache.size==32&&status.unsignedInteger(revision)&&status.binary(revision_hash,32)&&revision_hash.size==32&&status.unsignedInteger(durable)&&status.finished()&& + !std::memcmp(cache.data,content.id.bytes.data(),32)&&revision==content.record.revision&& + !std::memcmp(revision_hash.data,content.hash.bytes.data(),32)&&durable==1,"runtime confirmation is not the exact durable publication"); + return true; + },"runtime confirmation not committed"); + auto stranger=upload_owner;stranger[0]^=1; + require(rt::readWebConfirmation(upload_id.data(),stranger.data(),verification.data(),verification.size(),status_size)==rt::WebReadResult::Forbidden,"foreign session read confirmation result"); + require(rt::releaseWebConfirmation(upload_owner.data(),upload_id.data())==rt::WebReadResult::Ready,"runtime confirmation release failed"); + for(unsigned i=0;i<2;++i){const auto& bytes=test::files.at("/trailmate/geocaching/.state/index/root.h"+std::to_string(i));std::copy(bytes.begin(),bytes.end(),roots[i].begin());} + require(gc::decodeIndexRoot({roots[0].data(),roots[0].size()},volume,a)&&gc::decodeIndexRoot({roots[1].data(),roots[1].size()},volume,b)&&gc::selectIndexRoot(a,b,root),"runtime confirmation root invalid"); + copy=b.revision>a.revision?1:0; + RequestId request;request.bytes=operation; + std::array request_key{};std::memcpy(request_key.data()+16,delivery.data(),16);std::memcpy(request_key.data()+32,operation.data(),16); + std::vector request_bytes(signed_record.size()+64),outgoing_bytes(signed_record.size()+1024);size_t request_size=0,outgoing_size=0,task_size=0; + require(protocol::encodePublishRequest(request,content.record.encoded,content.signature,8192,request_bytes.data(),request_bytes.size(),request_size),"confirmation request encode failed"); + gc::OutgoingView outgoing;outgoing.request={request_bytes.data(),request_size};outgoing.state=4;outgoing.task_id={operation.data(),16};outgoing.terminal_data={proof.data(),proof_size}; + std::array task_bytes{}; + gc::TaskView task;task.kind=1;task.state=3;task.cache_id={content.id.bytes.data(),32};task.revision_hash={content.hash.bytes.data(),32};task.requests[0]={request_key.data(),48};task.request_count=1; + require(gc::encodeTask({operation.data(),16},task,task_bytes.data(),task_bytes.size(),task_size),"confirmation task encode failed"); + const auto commit=[&]{ + require(gc::encodeOutgoing({request_key.data(),48},outgoing,outgoing_bytes.data(),outgoing_bytes.size(),outgoing_size),"confirmation outgoing encode failed"); + const gc::MutationView rows[]={{5,{request_key.data(),48},{outgoing_bytes.data(),outgoing_size},false},{10,{operation.data(),16},{task_bytes.data(),task_size},false}}; + sd::SdIndexedCommit write(volume);require(write.begin(root,copy,rows,2,frame.data(),frame.size(),roots[1-copy]),"confirmation transaction rejected"); + auto status=sd::IndexedCommitStep::Working;for(unsigned i=0;i<100000&&status==sd::IndexedCommitStep::Working;++i)status=write.step(); + require(status==sd::IndexedCommitStep::Verified&&write.committed(root),"confirmation transaction failed");copy=1-copy; + }; + Destination local,remote;remote.bytes=delivery; + std::vector encoded_workspace(8192);gc::QueuedRequestWorkspace workspace(encoded_workspace.data(),encoded_workspace.size()); + const auto confirmed_commit=[&]{ + sd::SdIndexedNewTask write(volume); + require(write.beginConfirmed(root,copy,local,remote,request,operation,{request_bytes.data(),request_size},{proof.data(),proof_size},{},workspace, + frame.data(),frame.size(),roots[1-copy],crypto,verification.data(),verification.size()),"verified confirmation admission failed"); + auto status=sd::IndexedCommitStep::Working;for(unsigned i=0;i<100000&&status==sd::IndexedCommitStep::Working;++i)status=write.step(); + const auto revision=root.revision; + require(status==sd::IndexedCommitStep::Verified&&write.committed(root),"verified confirmation commit failed"); + if(root.revision!=revision)copy=1-copy; + }; + confirmed_commit(); + const auto original_files=test::files;confirmed_commit();require(test::files==original_files,"duplicate confirmation wrote another transaction"); + sd::IndexWorkspaceOwner owner;std::array response{}; + sd::IndexedPublicationStore store(volume,root,copy,roots[0],roots[1],owner,workspace,frame.data(),frame.size(),response.data(),response.size(),verification.data(),verification.size(),crypto); + std::vector job_work(std::max(size_t(320),signed_record.size()+128));auto job_operation=operation;job_operation.fill(7); + static_assert(sizeof(sd::WebConfirmationJob)<=512,"Confirmation job owns metadata only"); + const auto run_job=[&]{ + sd::WebConfirmationJob job(store,crypto); + require(job.begin(job_operation,{signed_record.data(),signed_record.size()},{proof.data(),proof_size},content.record.author_public_key,{},job_work.data(),job_work.size()),"confirmation job begin rejected"); + for(unsigned i=0;i<100000&&job.phase()!=sd::WebConfirmationPhase::Confirmed&&job.phase()!=sd::WebConfirmationPhase::Failed;++i)job.advance(); + require(job.phase()==sd::WebConfirmationPhase::Confirmed&&job.revision()==content.record.revision&&job.hash().bytes==content.hash.bytes,"confirmation job failed"); + require(!owner.holder(),"confirmation job retained workspace lease"); + }; + run_job();const auto job_files=test::files;run_job();require(test::files==job_files,"confirmation job retry wrote another transaction"); + const auto read=[&](bool valid){ + gc::DraftCatalogPage page;sd::SdIndexedDraftCatalog catalog(volume,crypto,verification.data(),verification.size()); + require(catalog.begin(root,0,page,frame.data(),frame.size()),"confirmation catalog begin failed"); + auto status=sd::IndexGetStep::Working;for(unsigned i=0;i<100000&&status==sd::IndexGetStep::Working;++i)status=catalog.step(); + if(valid)require(status==sd::IndexGetStep::Ready&&page.count==1&&page.rows[0].publication.confirmed_revision==content.record.revision,"signed directory proof did not project confirmed version"); + else require(status==sd::IndexGetStep::Invalid,"tampered directory proof was trusted by catalog"); + }; + read(true); + checkMissingConfirmationHistory(signed_record,content,{proof.data(),proof_size},crypto); + // A later confirmed head must continue without reconstructing fake v1/v2 + // hashes. Its original creation time comes from this signed record/proof. + auto later=content.record;later.revision=3;later.updated_at+=2; + std::array unknown_parent{};unknown_parent.fill(21);later.previous_hash={unknown_parent.data(),unknown_parent.size()}; + std::array later_raw{};size_t later_size=0; + require(protocol::encodeGeocacheRecord(later,later_raw.data(),later_raw.size(),later_size),"later recovery fixture encode failed"); + constexpr char sign_domain[]="trailmate.geocache/sign/v1"; + std::memcpy(verification.data(),sign_domain,sizeof(sign_domain));std::memcpy(verification.data()+sizeof(sign_domain),later_raw.data(),later_size); + std::array later_signature{}; + require(!std::memcmp(directory.identity.public_key.data(),later.author_public_key.data,64)&&directory.identity.sign(verification.data(),sizeof(sign_domain)+later_size,later_signature.data()),"later recovery fixture signer mismatch"); + std::vector later_signed(later_size+128);protocol::CmpWriter later_writer(later_signed.data(),later_signed.size()); + require(later_writer.array(2)&&later_writer.binary({later_raw.data(),later_size})&&later_writer.binary({later_signature.data(),64}),"later signed fixture encode failed");later_signed.resize(later_writer.size()); + protocol::VerifiedRecordView later_verified; + require(protocol::verifyGeocache({later_signed.data(),later_signed.size()},crypto,verification.data(),verification.size(),later_verified)==protocol::VerificationResult::Valid,"later recovery fixture signature invalid"); + std::array later_receipt{};protocol::CmpWriter later_proof(later_receipt.data(),later_receipt.size()); + require(later_proof.array(8)&&later_proof.unsignedInteger(1)&&later_proof.binary({delivery.data(),16})&&later_proof.binary({epoch.data(),16})&&later_proof.binary({later_verified.id.bytes.data(),32})&& + later_proof.unsignedInteger(3)&&later_proof.binary({later_verified.hash.bytes.data(),32})&&later_proof.unsignedInteger(static_cast(later.state))&&later_proof.unsignedInteger(1800000000),"later receipt fixture encode failed"); + std::memcpy(verification.data(),domain,sizeof(domain));std::memcpy(verification.data()+sizeof(domain),later_receipt.data(),later_proof.size()); + directory.identity.sign(verification.data(),sizeof(domain)+later_proof.size(),later_signature.data()); + std::array later_envelope{};size_t later_envelope_size=0; + require(gc::encodePublicationConfirmation({{later_receipt.data(),later_proof.size()},{later_signature.data(),64},{directory.identity.public_key.data(),64}},later_envelope.data(),later_envelope.size(),later_envelope_size),"later proof envelope encode failed"); + checkMissingConfirmationHistory(later_signed,later_verified,{later_envelope.data(),later_envelope_size},crypto); + proof[proof_size-1]^=1; + sd::WebConfirmationJob invalid_job(store,crypto); + require(invalid_job.begin(job_operation,{signed_record.data(),signed_record.size()},{proof.data(),proof_size},content.record.author_public_key,{},job_work.data(),job_work.size()),"invalid proof test admission failed"); + invalid_job.advance();require(invalid_job.phase()==sd::WebConfirmationPhase::Failed&&test::files==job_files,"invalid proof job reached storage commit"); + commit();read(false); +} int main(int argc, char** argv) { require(argc == 2 || argc == 3, "expected fixture directory and optional runtime scenario"); const bool close_on_save = argc == 3 && std::strcmp(argv[2], "save-close-detail") == 0; + const bool web_latest = argc == 3 && std::strcmp(argv[2], "web-latest") == 0; + const bool web_stop = argc == 3 && std::strcmp(argv[2], "web-stop") == 0; + const bool web_signed_stop = argc == 3 && std::strcmp(argv[2], "web-signed-stop") == 0; const bool bad_legacy_signature = argc == 3 && std::strcmp(argv[2], "legacy-saved-bad-signature") == 0; const bool legacy_projected = argc == 3 && std::strcmp(argv[2], "legacy-saved-projected-index") == 0; const bool legacy_object_only = argc == 3 && std::strcmp(argv[2], "legacy-saved-object-index") == 0; const bool legacy_projection = bad_legacy_signature || legacy_projected || legacy_object_only || (argc == 3 && std::strcmp(argv[2], "legacy-saved-projection") == 0); const std::string folder(argv[1]); + if (argc == 3 && std::strcmp(argv[2], "external-confirmation") == 0) + { + // Public capture only: verify the real USB author and existing native + // directory receipt through the production indexed storage path. + const auto signed_cache = fixture(folder, "signed-cache-v1.bin"); + const auto confirmation = fixture(folder, "publication-confirmation.bin"); + platform::esp::common::EspGeocachingCrypto crypto; + std::vector verification(8192); + geocaching::protocol::VerifiedRecordView content; + require(geocaching::protocol::verifyGeocache({signed_cache.data(), signed_cache.size()}, crypto, + verification.data(), verification.size(), content) == geocaching::protocol::VerificationResult::Valid, + "captured USB signature invalid"); + checkMissingConfirmationHistory(signed_cache, content, {confirmation.data(), confirmation.size()}, crypto); + std::fprintf(stderr, "Captured public signature/receipt: restore, duplicate, catalog and successor passed\n"); + return 0; + } const auto encoded_record = fixture(folder, "record-v1.bin"); geocaching::RecordView expected_record; require(geocaching::protocol::decodeGeocacheRecord({encoded_record.data(), encoded_record.size()}, expected_record), "detail record fixture invalid"); @@ -266,10 +577,169 @@ int main(int argc, char** argv) chat::MeshAdapterRouter router; LoraBoard board; rt::configure(router, board); - if (argc == 3 && !close_on_save && !legacy_projection) + require(test::mutex_creates == 0, "configuration allocated a Geocaching mutex before use"); + if (argc == 3 && std::strcmp(argv[2], "signature-input-recovery") == 0) + { + unsigned calls = 0; + const auto action = +[](void* opaque){++*static_cast(opaque);return true;}; + test::mutex_available = false; + require(!rt::withSignatureInputRecovery(action, &calls) && calls == 0, "failed mutex allocation invoked recovery"); + test::mutex_available = true; + require(rt::withSignatureInputRecovery(action, &calls) && calls == 1, "cold recovery requires a prior RF/SD session"); + bool nested_idle = true; + require(rt::withSignatureInputRecovery(+[](void* opaque){*static_cast(opaque)=rt::sharedSignatureUploadIdle()==rt::WebReadResult::Ready;return false;}, &nested_idle) && !nested_idle, + "recovery gate does not hold the production non-recursive mutex"); + require(!rt::workPending() && test::io_operations == 0, "read-only gate started RF/storage work"); + return 0; + } + if (argc == 3 && std::strcmp(argv[2], "lazy-mutex") == 0) + { + test::mutex_available = false; + test::source->activate(true); + require(test::mutex_creates == 1 && !rt::workPending(), "failed lock allocation started work"); + ui::geocaching::Snapshot unavailable; + test::source->snapshot(Section::Published, unavailable); + require(std::strstr(unavailable.status.data(), "memory unavailable") != nullptr, "lock allocation failure was hidden"); + test::mutex_available = true; + test::source->activate(true); + require(test::mutex_creates == 2 && rt::workPending(), "lock allocation did not recover on explicit reopen"); + test::source->activate(true); + require(test::mutex_creates == 2, "repeated activation allocated a second lock"); + test::source->activate(false); + require(!rt::workPending(), "unused closed source retained work demand"); + return 0; + } + const bool restored_confirmation = argc == 3 && std::strcmp(argv[2], "web-signature-restored") == 0; + if (argc == 3 && (std::strcmp(argv[2], "web-signature") == 0 || std::strcmp(argv[2], "web-signature-bound") == 0 || std::strcmp(argv[2], "web-signature-stale") == 0 || restored_confirmation)) + { + const bool stale_draft = std::strcmp(argv[2], "web-signature-stale") == 0; + const bool bound_draft = stale_draft || restored_confirmation || std::strcmp(argv[2], "web-signature-bound") == 0; + router.ready = false; + require(rt::webLease(true, true), "local signature storage lease unavailable"); + std::array operation{}, owner{}, transfer{}, nonce{}; + operation.fill(1); owner.fill(2); transfer.fill(3); nonce.fill(55); + expected_record.author_public_key = {router.identity.public_key.data(), 64}; + expected_record.creation_nonce = {nonce.data(), 16}; + expected_record.revision = 1; expected_record.previous_hash = {}; + if (bound_draft) + { + test::source->activate(true); + until([&]{ return snapshot(Section::Published).can_create; }, "bound signature draft storage unavailable"); + ui::geocaching::DraftInput draft; + draft.id = nonce; draft.name = expected_record.name; draft.description = expected_record.description; draft.hint = expected_record.hint; + draft.has_coordinates = true; draft.latitude_e7 = expected_record.latitude_e7; draft.longitude_e7 = expected_record.longitude_e7; + draft.state = static_cast(expected_record.state); draft.difficulty_x2 = expected_record.difficulty_x2; + draft.terrain_x2 = expected_record.terrain_x2; draft.container_size = static_cast(expected_record.container_size); + require(test::source->saveDraft(draft), "bound signature draft save rejected"); + until([&]{ return test::source->draftSaveStatus(nonce, 0) == ui::geocaching::DraftSaveStatus::Saved; }, "bound signature draft not saved"); + test::source->activate(false); + } + std::array record{}, scratch{}; size_t size = 0; + require(geocaching::protocol::encodeGeocacheRecord(expected_record, record.data(), record.size(), size), "local signature record encoding failed"); + std::array hash{}; + platform::esp::common::EspGeocachingCrypto crypto; + require(crypto.sha256({record.data(), size}, hash.data()), "local signature hash failed"); + test::files["/trailmate/cache.sign.upload"] = std::vector(record.data(), record.data() + size); + const auto sends = router.sends; + rt::WebReadResult admitted = rt::WebReadResult::Pending; + until([&]{ admitted = rt::queueWebSignature(operation.data(), owner.data(), transfer.data(), hash.data(), static_cast(size), + router.identity.public_key.data(), router.author_generation, bound_draft ? nonce.data() : nullptr, bound_draft ? (stale_draft ? 2 : 1) : 0); return admitted != rt::WebReadResult::Pending; }, + "local signature admission never completed"); + require(admitted == rt::WebReadResult::Ready, "local signature admission rejected"); + require(rt::sharedSignatureUploadIdle() == rt::WebReadResult::Pending, "recovery exposed an active signature input"); + unsigned recovery_calls = 0; + const auto recovery_action = +[](void* opaque){++*static_cast(opaque);return true;}; + require(!rt::withSignatureInputRecovery(recovery_action, &recovery_calls) && recovery_calls == 0, + "protected recovery invoked a callback while signing"); + size_t written = 0, total = 0; + until([&]{ + if (rt::readWebSignature(operation.data(), owner.data(), true, 0, 0, scratch.data(), scratch.size(), written) != rt::WebReadResult::Ready) return false; + geocaching::protocol::CmpReader reader({scratch.data(), written}); size_t fields = 0; geocaching::ByteView id, bound; + uint64_t generation = 0, phase = 0, bytes = 0; + require(reader.array(fields, 5) && reader.binary(id, 16) && reader.binary(bound, 32) && reader.unsignedInteger(generation) && reader.unsignedInteger(phase) && reader.unsignedInteger(bytes), "local signature receipt invalid"); + if (stale_draft) { require(phase != 1, "stale draft unexpectedly signed"); total = static_cast(bytes); return phase == 2; } + require(phase != 2, "local signature failed"); total = static_cast(bytes); return phase == 1; + }, "local signature never became ready"); + if (stale_draft) + { + require(router.sends == sends && router.identity.signs == 0 && total == 0, "rejected draft produced a signature or network traffic"); + require(rt::releaseWebSignature(owner.data(), operation.data()) == rt::WebReadResult::Ready, "rejected draft task release failed"); + std::puts("Stale draft generation cannot enter IP signature issuance"); return 0; + } + std::vector signed_record; + while (signed_record.size() < total) + { + require(rt::readWebSignature(operation.data(), owner.data(), false, static_cast(signed_record.size()), 320, scratch.data(), scratch.size(), written) == rt::WebReadResult::Ready, "signature chunk missing"); + geocaching::protocol::CmpReader reader({scratch.data(), written}); size_t fields = 0; uint64_t offset = 0, bytes = 0; geocaching::ByteView id, bound, chunk; + require(reader.array(fields, 5) && reader.binary(id, 16) && reader.unsignedInteger(offset) && reader.unsignedInteger(bytes) && reader.binary(bound, 32) && reader.binary(chunk, 320) && reader.finished(), "signature chunk invalid"); + require(offset == signed_record.size() && bytes == total, "signature chunk binding changed"); signed_record.insert(signed_record.end(), chunk.data, chunk.data + chunk.size); + } + geocaching::protocol::VerifiedRecordView verified; + require(geocaching::protocol::verifyGeocache({signed_record.data(), signed_record.size()}, crypto, scratch.data(), scratch.size(), verified) == geocaching::protocol::VerificationResult::Valid, "local signature crypto verification failed"); + require(router.sends == sends && router.identity.signs == 1 && !test::blocked_io, "local signing used network or accessed SD outside maintenance"); + auto stranger = owner; stranger[0] ^= 1; + require(rt::readWebSignature(operation.data(), stranger.data(), false, 0, 320, scratch.data(), scratch.size(), written) == rt::WebReadResult::Forbidden, "foreign USB session read signature"); + if (!bound_draft) + { + ++router.author_generation; + require(rt::readWebSignature(operation.data(), owner.data(), false, 0, 320, scratch.data(), scratch.size(), written) != rt::WebReadResult::Ready, "stale identity read signature"); + } + require(rt::releaseWebSignature(owner.data(), operation.data()) == rt::WebReadResult::Ready, "local signature release failed"); + require(rt::sharedSignatureUploadIdle() == rt::WebReadResult::Ready, "released signature still blocks read-only recovery"); + bool nested_idle = true; + require(rt::withSignatureInputRecovery(+[](void* opaque){*static_cast(opaque)=rt::sharedSignatureUploadIdle()==rt::WebReadResult::Ready;return false;}, &nested_idle), + "released signing input requires unrelated RF/network readiness"); + require(!nested_idle, "runtime test mutex must reject nested acquisition like the physical device"); + require(rt::withSignatureInputRecovery(recovery_action, &recovery_calls) && recovery_calls == 1, + "protected recovery did not invoke its callback after release"); + if (bound_draft) + { + until([&]{ return rt::queueWebSignature(operation.data(), owner.data(), transfer.data(), hash.data(), static_cast(size), + router.identity.public_key.data(), router.author_generation, nonce.data(), 1) == rt::WebReadResult::Ready; }, "exact reservation replay not admitted"); + until([&]{ + if (rt::readWebSignature(operation.data(), owner.data(), true, 0, 0, scratch.data(), scratch.size(), written) != rt::WebReadResult::Ready) return false; + geocaching::protocol::CmpReader response({scratch.data(), written}); size_t count = 0; geocaching::ByteView id, hash; + uint64_t version = 0, phase = 0, bytes = 0; + require(response.array(count, 5) && response.binary(id, 16) && response.binary(hash, 32) && response.unsignedInteger(version) && response.unsignedInteger(phase) && response.unsignedInteger(bytes), "replay signature receipt invalid"); + require(phase != 2, "exact reservation replay rejected"); return phase == 1; + }, "exact reservation replay signature unavailable"); + require(rt::releaseWebSignature(owner.data(), operation.data()) == rt::WebReadResult::Ready, "replay task release failed"); + std::vector saved_draft; + size_t draft_total = 0; + do + { + rt::WebReadResult read = rt::WebReadResult::Pending; + until([&]{ if (read == rt::WebReadResult::Pending) read = rt::readWebDraftChunk(nonce.data(), static_cast(saved_draft.size()), 320, saved_draft.empty() ? 0 : 2, scratch.data(), scratch.size(), written); return read != rt::WebReadResult::Pending; }, "reserved draft cannot be read"); + if (read != rt::WebReadResult::Ready) std::fprintf(stderr, "Reserved draft read result=%u offset=%zu\n", static_cast(read), saved_draft.size()); + require(read == rt::WebReadResult::Ready, "reserved draft read failed"); + geocaching::protocol::CmpReader response({scratch.data(), written}); size_t count = 0; + uint64_t version = 0, bytes = 0, offset = 0; geocaching::ByteView id, chunk; + require(response.array(count, 5) && response.binary(id, 16) && response.unsignedInteger(version) && response.unsignedInteger(bytes) && response.unsignedInteger(offset) && response.binary(chunk, 320) && response.finished(), "reserved draft receipt invalid"); + require(version == 2 && offset == saved_draft.size(), "reservation failed to advance draft generation"); + draft_total = static_cast(bytes); saved_draft.insert(saved_draft.end(), chunk.data, chunk.data + chunk.size); + } while (saved_draft.size() < draft_total); + geocaching::storage::DraftView draft; + require(geocaching::storage::decodeDraft({nonce.data(), 16}, {saved_draft.data(), saved_draft.size()}, draft) && + draft.author.size == 64 && !std::memcmp(draft.author.data, router.identity.public_key.data(), 64) && + draft.base_hash.size == 32 && !std::memcmp(draft.base_hash.data, verified.hash.bytes.data(), 32), "author and reserved version were not persisted together"); + const auto signs_before_confirmation = router.identity.signs; + if (restored_confirmation) + { + ++router.author_generation; + tick(); + } + checkExternalConfirmationProjection(signed_record,verified,router.author_generation); + require(router.identity.signs == signs_before_confirmation && router.sends == sends, + "confirmed history restoration signed again or used RF"); + } + require(rt::webLease(false), "local signature web lease release failed"); tick(); + std::puts("Local IP-author signature reservation/export without RF passed"); return 0; + } + if (argc == 3 && !close_on_save && !legacy_projection && !web_latest && !web_stop && !web_signed_stop) { const bool legacy_upgrade = std::strcmp(argv[2], "legacy-index-upgrade") == 0; - require(legacy_upgrade || std::strcmp(argv[2], "local-draft-only") == 0, "unknown runtime scenario"); + const bool web_read = std::strcmp(argv[2], "web-draft-read") == 0; + require(legacy_upgrade || web_read || std::strcmp(argv[2], "local-draft-only") == 0, "unknown runtime scenario"); router.ready = false; test::in_ui = true; test::source->activate(true); @@ -286,6 +756,254 @@ int main(int argc, char** argv) until([&] { return test::source->draftSaveStatus(local.id, 0) == ui::geocaching::DraftSaveStatus::Saved; }, "offline draft was not saved"); + if (web_read) + { + require(rt::webLease(true), "web lease was not admitted"); + test::source->activate(false); + std::array response; + std::vector encoded; + size_t written = 0, total = 0; + uint64_t generation = 0; + require(rt::readWebDraftChunk(local.id.data(), 1, 16, 0, response.data(), response.size(), written) == rt::WebReadResult::Invalid, + "continuation without generation was accepted"); + do + { + bool ready = false; + until([&] + { if (!ready) ready = rt::readWebDraftChunk(local.id.data(), static_cast(encoded.size()), 16, generation, response.data(), response.size(), written) == rt::WebReadResult::Ready; return ready; }, + "web draft read did not become ready"); + geocaching::protocol::CmpReader reader({response.data(), written}); + size_t fields = 0; + uint64_t size = 0, offset = 0; + geocaching::ByteView id, chunk; + require(reader.array(fields, 5) && fields == 5 && reader.binary(id, 16) && reader.unsignedInteger(generation) && + reader.unsignedInteger(size) && reader.unsignedInteger(offset) && reader.binary(chunk, 320) && reader.finished(), + "invalid web draft chunk shape"); + require(generation == 1 && offset == encoded.size() && !std::memcmp(id.data, local.id.data(), 16), "web draft correlation lost"); + total = static_cast(size); + encoded.insert(encoded.end(), chunk.data, chunk.data + chunk.size); + if (encoded.size() == 16) + { + // Consuming the same row in the UI must leave the web lease. + test::source->activate(true); + bool delivered = false; + require(test::source->readDraft( + local.id, [](const ui::geocaching::DraftInput& value, void* context) + { *static_cast(context) = value.generation == 1; }, + &delivered) == ui::geocaching::DraftReadStatus::Ready && + delivered, + "UI could not share a web draft snapshot"); + test::source->cancelDraftRead(local.id); + test::source->activate(false); + } + } while (encoded.size() < total); + geocaching::storage::DraftView draft; + require(geocaching::storage::decodeDraft({local.id.data(), local.id.size()}, {encoded.data(), encoded.size()}, draft) && + draft.name == local.name && draft.description == local.description && draft.generation == 1, + "web chunks changed the persisted draft"); + bool conflicted = false; + until([&] + { if (!conflicted) conflicted = rt::readWebDraftChunk(local.id.data(), 0, 16, 2, response.data(), response.size(), written) == rt::WebReadResult::Conflict; return conflicted; }, + "stale web draft generation was accepted"); + until([&] + { return rt::readWebDraftCatalog(0, false, 0, response.data(), response.size(), written) == rt::WebReadResult::Ready; }, + "web draft catalog did not become ready"); + geocaching::protocol::CmpReader catalog({response.data(), written}); + size_t fields = 0; + uint64_t catalog_generation = 0, catalog_offset = 0, known_count = 0, row_generation = 0; + geocaching::ByteView row_id; + std::string_view row_name; + require(catalog.array(fields, 4) && fields == 4 && catalog.unsignedInteger(catalog_generation) && + catalog.unsignedInteger(catalog_offset) && catalog.unsignedInteger(known_count) && + catalog.array(fields, 9) && fields == 9 && catalog.binary(row_id, 16) && + catalog.unsignedInteger(row_generation) && catalog.nil() && catalog.text(row_name, 96), + "invalid web catalog projection"); + require(catalog_offset == 0 && known_count == 1 && row_generation == 1 && row_name == local.name && + row_id.size == 16 && !std::memcmp(row_id.data, local.id.data(), 16), + "web catalog changed the saved draft"); + require(rt::readWebDraftCatalog(0, true, catalog_generation + 1, response.data(), response.size(), written) == rt::WebReadResult::Conflict, + "web catalog accepted a stale storage generation"); + require(rt::readWebDraftCatalog(1, true, catalog_generation, response.data(), response.size(), written) == rt::WebReadResult::Ready, + "web catalog end marker was unavailable"); + geocaching::protocol::CmpReader end({response.data(), written}); + require(end.array(fields, 4) && end.unsignedInteger(generation) && end.unsignedInteger(catalog_offset) && + end.unsignedInteger(known_count) && end.nil() && end.finished() && catalog_offset == 1 && known_count == 1, + "web catalog fabricated an extra row"); + draft.name = "Edited through shared transaction"; + draft.generation = 2; + std::array edit; + size_t edit_size = 0; + std::array forged_author{}; + draft.author = {forged_author.data(), forged_author.size()}; + require(geocaching::storage::encodeDraft({local.id.data(), 16}, draft, edit.data(), edit.size(), edit_size), "forged author test encoding failed"); + require(!rt::queueDraftSave(local.id.data(), edit.data(), edit_size, 1, true), "editor command accepted caller author metadata"); + draft.author = {}; + require(geocaching::storage::encodeDraft({local.id.data(), 16}, draft, edit.data(), edit.size(), edit_size), "edit encoding failed"); + require(!rt::queueDraftSave(local.id.data(), edit.data(), edit_size, 0, true), "editor command accepted inconsistent generation"); + require(rt::queueDraftSave(local.id.data(), edit.data(), edit_size, 1, true), "shared editor transaction not queued"); + require(!rt::queueDraftSave(local.id.data(), edit.data(), edit_size, 1, true), "parallel edit replaced the active save job"); + until([&] + { return test::source->draftSaveStatus(local.id, 1) == ui::geocaching::DraftSaveStatus::Saved; }, + "shared editor transaction was not persisted"); + bool edited = false; + until([&] + { if (!edited) edited = rt::readWebDraftChunk(local.id.data(), 0, 320, 2, response.data(), response.size(), written) == rt::WebReadResult::Ready; return edited; }, + "edited draft was not readable at the next generation"); + auto permission = [&](const uint8_t* key, uint64_t expected, const uint8_t* author) + { + auto result = rt::WebReadResult::Pending; + until([&] + { if (result == rt::WebReadResult::Pending) result = rt::checkWebDraftEditOwner(key, expected, author); return result != rt::WebReadResult::Pending; }, + "stored draft ownership did not become available"); + return result; + }; + require(permission(local.id.data(), 2, forged_author.data()) == rt::WebReadResult::Ready, + "unbound draft was not editable"); + std::array bound_id{}; + bound_id.fill(5); + std::array retained_base{}; + retained_base.fill(6); + forged_author.fill(7); + require(permission(bound_id.data(), 0, forged_author.data()) == rt::WebReadResult::Ready, + "new draft generation zero was not admitted"); + require(permission(bound_id.data(), 1, forged_author.data()) == rt::WebReadResult::Conflict, + "missing draft with a nonzero generation was admitted"); + draft.generation = 1; + draft.author = {forged_author.data(), forged_author.size()}; + draft.base_hash = {retained_base.data(), retained_base.size()}; + require(geocaching::storage::encodeDraft({bound_id.data(), 16}, draft, edit.data(), edit.size(), edit_size) && + rt::queueDraftSave(bound_id.data(), edit.data(), edit_size, 0), + "bound test draft was not queued"); + until([&] + { return test::source->draftSaveStatus(bound_id, 0) == ui::geocaching::DraftSaveStatus::Saved; }, + "bound test draft was not stored"); + require(permission(bound_id.data(), 1, forged_author.data()) == rt::WebReadResult::Ready, "original author was denied"); + auto wrong_author = forged_author; + wrong_author[0] ^= 1; + require(permission(bound_id.data(), 1, wrong_author.data()) == rt::WebReadResult::Forbidden, "different author was admitted"); + require(permission(bound_id.data(), 0, forged_author.data()) == rt::WebReadResult::Conflict, "stale generation was admitted"); + draft.generation = 2; + draft.name = "Bound metadata survives browser fields"; + draft.author = draft.base_hash = {}; + require(geocaching::storage::encodeDraft({bound_id.data(), 16}, draft, edit.data(), edit.size(), edit_size) && + rt::queueDraftSave(bound_id.data(), edit.data(), edit_size, 1, true), + "bound editor save was not queued"); + until([&] + { return test::source->draftSaveStatus(bound_id, 1) == ui::geocaching::DraftSaveStatus::Saved; }, + "bound editor save was not persisted"); + bool bound_read = false; + until([&] + { if (!bound_read) bound_read = rt::readWebDraftChunk(bound_id.data(), 0, 320, 2, response.data(), response.size(), written) == rt::WebReadResult::Ready; return bound_read; }, + "bound edit could not be reread"); + geocaching::protocol::CmpReader bound_response({response.data(), written}); + geocaching::ByteView returned_id, retained_chunk; + uint64_t retained_total = 0, retained_offset = 0; + geocaching::storage::DraftView stored; + require(bound_response.array(fields, 5) && bound_response.binary(returned_id, 16) && bound_response.unsignedInteger(generation) && + bound_response.unsignedInteger(retained_total) && bound_response.unsignedInteger(retained_offset) && + bound_response.binary(retained_chunk, 320) && bound_response.finished() && retained_total == retained_chunk.size && + geocaching::storage::decodeDraft({bound_id.data(), 16}, retained_chunk, stored) && stored.generation == 2 && + stored.name == draft.name && stored.author.size == 64 && stored.base_hash.size == 32 && + !std::memcmp(stored.author.data, forged_author.data(), 64) && !std::memcmp(stored.base_hash.data, retained_base.data(), 32), + "browser field editing lost committed author or base hash"); + std::array edit_hash{}; + chat::reticulum::fullHash(edit.data(), edit_size, edit_hash.data()); + auto replay = [&](const uint8_t* author, const uint8_t* hash) + { + auto result = rt::WebReadResult::Pending; + until([&] + { if (result == rt::WebReadResult::Pending) result = rt::checkWebDraftEditOwner(bound_id.data(), 1, author, hash); return result != rt::WebReadResult::Pending; }, + "saved edit retry did not become available"); + return result; + }; + require(replay(forged_author.data(), edit_hash.data()) == rt::WebReadResult::AlreadySaved, + "exact saved edit was not recognised after identity metadata restoration"); + edit_hash[0] ^= 1; + require(replay(forged_author.data(), edit_hash.data()) == rt::WebReadResult::Conflict, + "different content at the same generation was accepted as a saved retry"); + edit_hash[0] ^= 1; + require(replay(wrong_author.data(), edit_hash.data()) == rt::WebReadResult::Forbidden, + "saved retry bypassed author ownership"); + rt::webLease(false); + until([&] + { return test::allocations.empty(); }, + "closed web lease retained session payloads"); + require(replay(forged_author.data(), edit_hash.data()) == rt::WebReadResult::AlreadySaved, + "reopened storage forgot a previously saved edit"); + std::string web_description(650, 'w'); + draft.generation = 1; + draft.name = "Staged web edit"; + draft.description = web_description; + draft.author = draft.base_hash = {}; + std::array staged_id{}; + staged_id.fill(9); + require(geocaching::storage::encodeDraft({staged_id.data(), 16}, draft, edit.data(), edit.size(), edit_size), "web file encoding failed"); + chat::reticulum::fullHash(edit.data(), edit_size, edit_hash.data()); + test::files[rt::kWebDraftCandidatePath] = std::vector(edit.data(), edit.data() + edit_size); + auto staged = [&](const std::array& id, uint32_t author_generation) + { + auto result = rt::WebReadResult::Pending; + until([&] + { if (result == rt::WebReadResult::Pending) result = rt::queueWebDraftFile(id.data(), static_cast(edit_size), 0, edit_hash.data(), router.identity.public_key.data(), author_generation); return result != rt::WebReadResult::Pending; }, + "web file save did not reach a terminal result"); + return result; + }; + require(staged(staged_id, 7) == rt::WebReadResult::AlreadySaved, "web file was not saved through the existing editor transaction"); + require(permission(staged_id.data(), 1, router.identity.public_key.data()) == rt::WebReadResult::Ready, "web file save generation was not persisted"); + staged_id.fill(10); + test::files[rt::kWebDraftCandidatePath][0] ^= 1; + require(staged(staged_id, 7) == rt::WebReadResult::HashMismatch, "corrupted web candidate was committed"); + require(permission(staged_id.data(), 0, router.identity.public_key.data()) == rt::WebReadResult::Ready, "hash failure created a draft"); + test::files[rt::kWebDraftCandidatePath][0] ^= 1; + staged_id.fill(11); + require(rt::queueWebDraftFile(staged_id.data(), static_cast(edit_size), 0, edit_hash.data(), router.identity.public_key.data(), 7) == rt::WebReadResult::Pending, + "identity-change test could not stage its candidate"); + router.author_generation = 8; + require(staged(staged_id, 7) == rt::WebReadResult::StaleIdentity, "queued web save ignored an author generation change"); + router.author_generation = 7; + require(permission(staged_id.data(), 0, router.identity.public_key.data()) == rt::WebReadResult::Ready, "stale identity created a draft"); + staged_id.fill(12); + draft.author = {forged_author.data(), forged_author.size()}; + require(geocaching::storage::encodeDraft({staged_id.data(), 16}, draft, edit.data(), edit.size(), edit_size), "invalid metadata file encoding failed"); + chat::reticulum::fullHash(edit.data(), edit_size, edit_hash.data()); + test::files[rt::kWebDraftCandidatePath] = std::vector(edit.data(), edit.data() + edit_size); + require(staged(staged_id, 7) == rt::WebReadResult::Invalid, "web file accepted caller author metadata"); + require(permission(staged_id.data(), 0, router.identity.public_key.data()) == rt::WebReadResult::Ready, "invalid metadata created a draft"); + rt::webLease(false); + until([&] + { return test::allocations.empty(); }, + "reopened retry retained session payloads"); + // Match HostLink draft_put: every Busy retry first rechecks the + // committed owner, then (only if Ready) advances the same intent. + // Separate queue/owner tests miss read jobs starving this commit. + staged_id.fill(13); + draft.author = draft.base_hash = {}; + draft.description = std::string_view(web_description); + require(geocaching::storage::encodeDraft({staged_id.data(), 16}, draft, edit.data(), edit.size(), edit_size), "interleaved web file encoding failed"); + chat::reticulum::fullHash(edit.data(), edit_size, edit_hash.data()); + test::files[rt::kWebDraftCandidatePath] = std::vector(edit.data(), edit.data() + edit_size); + bool interleaved_saved = false; + until([&] + { + if (interleaved_saved) return true; + const auto owner = rt::checkWebDraftEditOwner(staged_id.data(), 0, router.identity.public_key.data(), edit_hash.data()); + if (owner == rt::WebReadResult::AlreadySaved) return interleaved_saved = true; + require(owner == rt::WebReadResult::Pending || owner == rt::WebReadResult::Ready, "interleaved owner check rejected its own edit"); + if (owner == rt::WebReadResult::Ready) + { + const auto queued = rt::queueWebDraftFile(staged_id.data(), static_cast(edit_size), 0, edit_hash.data(), router.identity.public_key.data(), 7); + require(queued == rt::WebReadResult::Pending || queued == rt::WebReadResult::AlreadySaved, "interleaved save rejected its own intent"); + } + return false; + }, + "interleaved HostLink save did not confirm the committed row"); + rt::webLease(false); + until([&] + { return test::allocations.empty(); }, + "interleaved save retained session payloads"); + require(test::ui_io == 0, "web/UI projection performed direct storage I/O"); + return 0; + } test::source->activate(false); until([&] { return test::allocations.empty(); }, @@ -489,9 +1207,127 @@ int main(int argc, char** argv) until([&] { const auto view = snapshot(Section::Discover); return view.count == 1 && view.can_refresh; }, "query page not displayed"); + std::array directory_response{}; + size_t directory_size = 0, directory_fields = 0; + require(rt::readWebDirectories(0, directory_response.data(), directory_response.size(), directory_size) == rt::WebReadResult::Ready, + "web directory projection was not available"); + geocaching::protocol::CmpReader directory_reader({directory_response.data(), directory_size}); + uint64_t directory_offset = 0, directory_count = 0, directory_sequence = 0, directory_flags = 0, directory_limit = 0, directory_age = 0; + geocaching::ByteView directory_delivery, directory_discovery, directory_key, directory_epoch; + std::string_view directory_name; + require(directory_reader.array(directory_fields, 3) && directory_fields == 3 && directory_reader.unsignedInteger(directory_offset) && + directory_reader.unsignedInteger(directory_count) && directory_reader.array(directory_fields, 9) && directory_fields == 9 && + directory_reader.binary(directory_delivery, 16) && directory_reader.binary(directory_discovery, 16) && + directory_reader.binary(directory_key, 64) && directory_reader.binary(directory_epoch, 16) && + directory_reader.unsignedInteger(directory_sequence) && directory_reader.text(directory_name, 40) && + directory_reader.unsignedInteger(directory_flags) && directory_reader.unsignedInteger(directory_limit) && + directory_reader.unsignedInteger(directory_age) && directory_reader.finished() && directory_offset == 0 && directory_count == 1 && + directory_key.size == 64 && (directory_flags & 1) && directory_limit > 0, + "web directory projection changed discovery metadata"); + require(rt::readWebDirectories(1, directory_response.data(), directory_response.size(), directory_size) == rt::WebReadResult::Ready, + "web directory end marker missing"); + require(rt::readWebDirectories(5, directory_response.data(), directory_response.size(), directory_size) == rt::WebReadResult::Invalid, + "web directory projection accepted an out-of-budget index"); + rt::webLease(false); require(test::files.empty() && !test::blocked_io && !test::allocated("geocaching.index.read") && !test::allocated("geocaching.index.encode"), "browsing depends on SD or its workspaces"); + if (web_latest) + { + ui::geocaching::Item latest_item; + const auto page = snapshot(Section::Discover); + require(test::source->item(Section::Discover, 0, page.generation, latest_item), "latest test cache missing"); + std::array target{}; + std::array latest_result{}; + const auto baseline_allocations = test::allocations.size(); + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), 35) == rt::WebReadResult::Invalid, + "latest query accepted short buffer"); + const auto before_get = router.sends; + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "latest query did not start"); + until([&] { return router.sends == before_get + 1; }, "latest GET not sent"); + geocaching::protocol::CmpReader envelope({router.sent_bytes.data(), router.sent_bytes.size()}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView request_id; + require(envelope.array(fields, 6) && fields == 6 && envelope.unsignedInteger(value) && value == 1 && + envelope.unsignedInteger(value) && value == 0 && envelope.unsignedInteger(value) && value == 3 && + envelope.binary(request_id, 16) && request_id.size == 16, "latest GET envelope invalid"); + geocaching::RequestId expected_request; + std::memcpy(expected_request.bytes.data(), request_id.data, 16); + geocaching::protocol::GetRequestView request; + require(geocaching::protocol::decodeGetRequest({router.sent_bytes.data(), router.sent_bytes.size()}, expected_request, request) && + !request.wanted_hash.size && !request.known_hash.size && !std::memcmp(request.cache_id.data, latest_item.id.data(), 32), + "latest GET pinned an old revision"); + auto noncurrent = fixture(folder, "get-response-v1.bin"); + noncurrent[noncurrent.size() - 2] = 0; + reply(router, noncurrent, 3); + for (unsigned i = 0; i < 30; ++i) tick(); + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "latest query accepted noncurrent record"); + auto invalid = fixture(folder, "get-response-v1.bin"); + invalid[invalid.size() - 4] ^= 1; + reply(router, invalid, 3); + for (unsigned i = 0; i < 30; ++i) tick(); + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "latest query accepted invalid author signature"); + target[0] = 99; + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::NotFound, + "latest query accepted unknown directory"); + target[0] = 0; + reply(router, fixture(folder, "get-response-v1.bin"), 3); + bool latest_ready = false; + until([&] { if (latest_ready) return true; + latest_ready = rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Ready; + return latest_ready; }, "verified latest record not returned"); + require(latest_result[0] == expected_record.revision && latest_result[1] == 0 && latest_result[2] == 0 && latest_result[3] == 0 && + !std::memcmp(latest_result.data() + 4, latest_item.revision_hash.data(), 32), "latest revision result mismatch"); + require(test::allocations.size() == baseline_allocations && !test::allocated("geocaching.detail.rx") && + !test::allocated("geocaching.detail.verify"), "latest query retained its detail buffers"); + require(test::files.empty() && !test::blocked_io, "latest query opened SD"); + auto missing_id = latest_item.id; + missing_id[0] ^= 1; + const auto before_missing = router.sends; + require(rt::readWebLatestRevision(missing_id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "missing record query did not start"); + until([&] { return router.sends == before_missing + 1; }, "missing record GET not sent"); + std::array error_bytes{}; + geocaching::protocol::CmpWriter error(error_bytes.data(), error_bytes.size()); + error.array(6); error.unsignedInteger(1); error.unsignedInteger(1); error.unsignedInteger(3); + error.binary({expected_request.bytes.data(), 16}); error.unsignedInteger(404); + error.array(3); error.text("revision_unavailable"); error.nil(); error.nil(); + require(error.good(), "missing revision fixture encoding failed"); + reply(router, {error_bytes.data(), error_bytes.data() + error.size()}, 3); + for (unsigned i = 0; i < 30; ++i) tick(); + require(rt::readWebLatestRevision(missing_id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "missing revision was treated as missing cache"); + geocaching::protocol::CmpWriter absent(error_bytes.data(), error_bytes.size()); + absent.array(6); absent.unsignedInteger(1); absent.unsignedInteger(1); absent.unsignedInteger(3); + absent.binary({expected_request.bytes.data(), 16}); absent.unsignedInteger(404); + absent.array(3); absent.text("not_found"); absent.nil(); absent.nil(); + require(absent.good(), "missing cache fixture encoding failed"); + auto malformed_absence = std::vector(error_bytes.data(), error_bytes.data() + absent.size()); + malformed_absence.back() = 0; + reply(router, malformed_absence, 3); + for (unsigned i = 0; i < 30; ++i) tick(); + require(rt::readWebLatestRevision(missing_id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "malformed absence response was accepted"); + reply(router, {error_bytes.data(), error_bytes.data() + absent.size()}, 3); + auto absence = rt::WebReadResult::Pending; + until([&] { if (absence != rt::WebReadResult::Pending) return true; + absence = rt::readWebLatestRevision(missing_id.data(), target.data(), latest_result.data(), latest_result.size()); + return absence != rt::WebReadResult::Pending; }, "explicit absence not returned"); + require(absence == rt::WebReadResult::Absent && latest_result == std::array{}, + "explicit absence retained the previous version result"); + require(test::allocations.size() == baseline_allocations && test::files.empty(), "absence query retained memory or touched SD"); + require(rt::readWebLatestRevision(latest_item.id.data(), target.data(), latest_result.data(), latest_result.size()) == rt::WebReadResult::Pending, + "second latest query did not start"); + rt::webLease(false); + until([&] { return test::allocations.size() == baseline_allocations; }, "cancelled latest query retained its detail"); + test::source->activate(false); + until([&] { return test::allocations.empty(); }, "latest query session did not close"); + return 0; + } test::card_ready = true; tick_ms = 5; std::array reply_remote{}; @@ -898,10 +1734,255 @@ int main(int argc, char** argv) prior_attempt_shards.push_back(file.first); test::profile_reads = true; test::read_bytes_by_path.clear(); - require(from == 0 && to == 1 && test::source->publishDraft(draft.id, draft_generation, author, to), "publication confirmation rejected"); + namespace web_publish = platform::esp::arduino_common::geocaching::browse_runtime; + std::array selected_directory{}; + std::array web_author{}; + uint32_t web_from = 0, web_to = 0; + const auto before_preview = test::files; + require(web_publish::webPublicationPlan(draft.id.data(), draft_generation, selected_directory.data(), web_author.data(), web_from, web_to), + "web publication preview rejected verified directory"); + require(web_author == author && web_from == from && web_to == to && test::files == before_preview, + "web preview changed storage or publication tuple"); + std::array publication_binding{}; + selected_directory[0] = 99; + require(web_publish::readWebPublicationBinding(draft.id.data(), draft_generation, selected_directory.data(), author.data(), + publication_binding.data(), publication_binding.size()) == rt::WebReadResult::NotFound, + "confirmation binding accepted unknown directory"); + selected_directory[0] = 0; + require(web_publish::readWebPublicationBinding(draft.id.data(), draft_generation, selected_directory.data(), author.data(), + publication_binding.data(), 55) == rt::WebReadResult::Invalid, + "confirmation binding accepted short output"); + std::array another_window{}; + size_t another_window_size = 0; + until([&] + { return rt::readWebDraftCatalog(4, false, 0, another_window.data(), another_window.size(), another_window_size) == rt::WebReadResult::Ready; }, + "alternate publication window unavailable"); + auto binding_status = rt::WebReadResult::Pending; + until([&] + { if (binding_status == rt::WebReadResult::Ready) return true; + binding_status = web_publish::readWebPublicationBinding(draft.id.data(), draft_generation, selected_directory.data(), author.data(), + publication_binding.data(), publication_binding.size()); + return binding_status == rt::WebReadResult::Ready; }, + "committed draft confirmation binding not ready"); + require(test::files == before_preview && router.identity.signs == 0, + "confirmation binding mutated or signed the draft"); + require(publication_binding[48] == 0 && publication_binding[52] == 1, + "confirmation binding changed local revision plan"); + for (size_t i = 32; i < 48; ++i) require(publication_binding[i] == 0, "confirmation binding changed directory epoch"); + std::vector committed_draft; + uint64_t committed_size = 0; + do + { + std::array frame{}; + size_t written = 0; + bool chunk_ready = false; + until([&] + { if (chunk_ready) return true; + chunk_ready = rt::readWebDraftChunk(draft.id.data(), static_cast(committed_draft.size()), 320, draft_generation, + frame.data(), frame.size(), written) == rt::WebReadResult::Ready; + return chunk_ready; }, + "confirmation draft bytes unavailable"); + geocaching::protocol::CmpReader reader({frame.data(), written}); + size_t count = 0; + uint64_t read_generation = 0, offset = 0; + geocaching::ByteView read_id, chunk; + require(reader.array(count, 5) && count == 5 && reader.binary(read_id, 16) && read_id.size == 16 && + !std::memcmp(read_id.data, draft.id.data(), 16) && reader.unsignedInteger(read_generation) && + read_generation == draft_generation && reader.unsignedInteger(committed_size) && reader.unsignedInteger(offset) && + offset == committed_draft.size() && reader.binary(chunk, 320) && chunk.size && reader.finished(), + "confirmation draft chunk correlation failed"); + committed_draft.insert(committed_draft.end(), chunk.data, chunk.data + chunk.size); + } while (committed_draft.size() < committed_size); + std::array committed_hash{}; + chat::reticulum::fullHash(committed_draft.data(), committed_draft.size(), committed_hash.data()); + require(!std::memcmp(committed_hash.data(), publication_binding.data(), committed_hash.size()), + "confirmation hash does not cover committed draft bytes"); + rt::WebReadResult stale_binding = rt::WebReadResult::Pending; + until([&] + { if (stale_binding != rt::WebReadResult::Pending) return true; + stale_binding = rt::readWebPublicationBinding(draft.id.data(), draft_generation + 1, selected_directory.data(), author.data(), + publication_binding.data(), publication_binding.size()); return stale_binding != rt::WebReadResult::Pending; }, + "stale confirmation binding did not finish"); + require(stale_binding == rt::WebReadResult::Conflict, "confirmation binding accepted wrong draft generation"); + selected_directory[0] = 99; + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to), + "web publication accepted unknown directory"); + selected_directory[0] = 0; + web_author[0] ^= 1; + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), web_author.data(), to), + "web publication accepted another author"); + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to + 1), + "web publication accepted stale revision"); + bool read_for_confirmation = false; + std::array confirmation_chunk{}; + size_t confirmation_chunk_size = 0; + until([&] + { if (read_for_confirmation) return true; + read_for_confirmation = rt::readWebDraftChunk(draft.id.data(), 0, 1, draft_generation, + confirmation_chunk.data(), confirmation_chunk.size(), confirmation_chunk_size) == rt::WebReadResult::Ready; + return read_for_confirmation; }, + "confirmation reader did not retain the committed draft"); + publication_binding[0] ^= 1; + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to, + publication_binding.data()), + "publication accepted another confirmed content hash"); + publication_binding[0] ^= 1; + publication_binding[32] ^= 1; + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to, + publication_binding.data()), + "publication accepted another directory incarnation"); + publication_binding[32] ^= 1; + require(from == 0 && to == 1, "web confirmation version plan changed"); + std::array web_operation_id{}; + require(!web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to, + publication_binding.data(), web_operation_id.data()), + "web publication accepted an empty operation ID"); + web_operation_id.fill(0x6a); + bool publication_queued = false; + until([&] + { if (publication_queued) return true; + publication_queued = web_publish::queueWebDraftPublication(draft.id.data(), draft_generation, selected_directory.data(), author.data(), to, + publication_binding.data(), web_operation_id.data()); + return publication_queued; }, + "web publication confirmation rejected"); + std::array job_response{}; + size_t job_written = 0; + auto check_web_job = [&](uint64_t expected_phase, bool metadata, int expected_durable, int expected_transport) + { + require(rt::readWebPublicationJob(web_operation_id.data(), author.data(), job_response.data(), job_response.size(), job_written) == rt::WebReadResult::Ready, + "web operation projection unavailable"); + geocaching::protocol::CmpReader projection({job_response.data(), job_written}); + size_t fields = 0; + uint64_t phase = 0, revision = 0, durable = 0, transported = 0; + geocaching::ByteView operation, cache, hash; + require(projection.array(fields, 8) && fields == 8 && projection.binary(operation, 16) && operation.size == 16 && + !std::memcmp(operation.data, web_operation_id.data(), 16) && projection.unsignedInteger(phase) && phase == expected_phase && + (metadata ? projection.binary(cache, 32) && cache.size == 32 : projection.nil()) && + (metadata ? projection.binary(hash, 32) && hash.size == 32 : projection.nil()) && projection.unsignedInteger(revision) && revision == 1 && + (expected_durable < 0 ? projection.nil() : projection.unsignedInteger(durable) && durable == static_cast(expected_durable)), + "web operation projection changed phase or durability"); + if (expected_transport == -2) + { + auto optional = projection; + if (optional.nil()) projection = optional; + else require(projection.unsignedInteger(transported) && transported == 1, "unobserved transport was reported false"); + } + else require(expected_transport < 0 ? projection.nil() : projection.unsignedInteger(transported) && transported == static_cast(expected_transport), + "web operation transport state mismatch"); + require(projection.nil() && projection.finished(), "successful web operation included an error"); + }; + check_web_job(0, false, 0, 0); + auto other_operation = web_operation_id; + other_operation[0] ^= 1; + require(rt::readWebPublicationJob(other_operation.data(), author.data(), job_response.data(), job_response.size(), job_written) == rt::WebReadResult::NotFound, + "operation query aliased another ID"); + auto other_author = author; + other_author[0] ^= 1; + require(rt::readWebPublicationJob(web_operation_id.data(), other_author.data(), job_response.data(), job_response.size(), job_written) == rt::WebReadResult::Forbidden, + "operation query exposed another author's job"); + router.identity.public_key[0] ^= 1; + require(rt::readWebPublicationJob(web_operation_id.data(), author.data(), job_response.data(), job_response.size(), job_written) == rt::WebReadResult::StaleIdentity, + "operation query ignored the connected device's changed identity"); + router.identity.public_key[0] ^= 1; + require(rt::webLease(false), "confirmed publication did not release the browser lease"); + if (web_signed_stop) + { + const auto sends_before_stop = router.sends; + until([&] { return router.identity.signs == 1; }, "draft never reached the signed pre-attempt window"); + require(router.sends == sends_before_stop, "signed window already sent the publication"); + const bool previous_ui = test::in_ui; + test::in_ui = false; + require(rt::requestWebPublicationStop(web_operation_id.data(), author.data()) == rt::WebReadResult::Pending, + "signed stop invented a durable completion"); + test::in_ui = previous_ui; + check_web_job(6, false, -1, -1); + // Deliberately do not retry the command while the owner creates and + // persists the attempt. Its retained stop request must prevent sends. + until([&] + { + const auto result = rt::readWebPublicationJob(web_operation_id.data(), author.data(), + job_response.data(), job_response.size(), job_written); + if (result != rt::WebReadResult::Ready) return false; + geocaching::protocol::CmpReader reader({job_response.data(), job_written}); + size_t fields = 0; + geocaching::ByteView operation; + uint64_t phase = 0; + require(reader.array(fields, 8) && fields == 8 && reader.binary(operation, 16) && reader.unsignedInteger(phase), + "signed stop projection invalid"); + return phase == 4; + }, "signed stop did not persist without command retries"); + check_web_job(4, true, 1, -2); + require(router.sends == sends_before_stop && router.identity.signs == 1, "signed stop sent or signed again"); + require(rt::webLease(false), "signed stop retained web lease"); + test::source->activate(false); + until([&] { return test::allocations.empty(); }, "signed stop leaked PSRAM"); + return 0; + } until([&] { return router.sends == 5; }, "publication request not dispatched", true); + geocaching::RequestId expected_web_request; + expected_web_request.bytes = web_operation_id; + geocaching::protocol::PublishRequestView web_request; + require(geocaching::protocol::decodePublishRequest({router.sent_bytes.data(), router.sent_bytes.size()}, expected_web_request, web_request), + "durable web publication changed the caller operation ID"); + check_web_job(2, true, 1, -2); + if (web_stop) + { + auto stop_as_command = [&](const uint8_t* operation, const uint8_t* identity) + { + const bool previous_ui = test::in_ui; + test::in_ui = false; + const auto result = rt::requestWebPublicationStop(operation, identity); + test::in_ui = previous_ui; + return result; + }; + std::array empty_operation{}; + require(stop_as_command(empty_operation.data(), author.data()) == rt::WebReadResult::Invalid, "empty stop ID accepted"); + require(stop_as_command(other_operation.data(), author.data()) == rt::WebReadResult::Pending, "stop replaced another active operation"); + require(stop_as_command(web_operation_id.data(), other_author.data()) == rt::WebReadResult::Forbidden, "stop accepted another author"); + const auto sends_before_stop = router.sends; + const auto saved_waiting_files = test::files; + auto stopped = rt::WebReadResult::Pending; + until([&] + { if (stopped == rt::WebReadResult::Ready) return true; stopped = stop_as_command(web_operation_id.data(), author.data()); return stopped == rt::WebReadResult::Ready; }, + "web publication stop not committed"); + check_web_job(4, true, 1, -2); + require(stop_as_command(web_operation_id.data(), author.data()) == rt::WebReadResult::Ready, "stop retry changed its result"); + require(router.sends == sends_before_stop, "stopping caused another publication send"); + require(rt::webLease(false), "stop retained web lease"); + test::source->activate(false); + until([&] { return test::allocations.empty(); }, "stopped publication leaked PSRAM"); + const auto saved_stopped_files = test::files; + auto historical_stop = rt::WebReadResult::Pending; + until([&] + { if (historical_stop == rt::WebReadResult::Ready) return true; historical_stop = stop_as_command(web_operation_id.data(), author.data()); + if (historical_stop != rt::WebReadResult::Ready && historical_stop != rt::WebReadResult::Pending) std::fprintf(stderr, "historical stop status=%u\n", static_cast(historical_stop)); + require(historical_stop == rt::WebReadResult::Ready || historical_stop == rt::WebReadResult::Pending, "historical stop failed");return historical_stop == rt::WebReadResult::Ready; }, + "stopped operation did not survive session reconstruction"); + check_web_job(4, true, 1, -2); + require(router.sends == sends_before_stop && router.identity.signs == 1 && test::files == saved_stopped_files, + "historical stop resubmitted, resigned or changed the stopped journal"); + require(rt::webLease(false), "historical stop retained web lease"); + until([&] { return test::allocations.empty(); }, "historical stop leaked PSRAM"); + test::files = saved_waiting_files; + auto recovered_stop = rt::WebReadResult::Pending; + until([&] + { + if (recovered_stop == rt::WebReadResult::Ready) return true; + recovered_stop = stop_as_command(web_operation_id.data(), author.data()); + require(recovered_stop == rt::WebReadResult::Ready || recovered_stop == rt::WebReadResult::Pending, + "cold waiting stop failed"); + return recovered_stop == rt::WebReadResult::Ready; + }, "cold waiting publication was not stopped"); + check_web_job(4, true, 1, -2); + require(router.sends == sends_before_stop && router.identity.signs == 1, + "cold waiting stop resubmitted or resigned"); + require(rt::webLease(false), "cold waiting stop retained web lease"); + until([&] { return test::allocations.empty(); }, "cold waiting stop leaked PSRAM"); + return 0; + } + require(rt::webLease(false), "operation query retained its browser lease"); require(!test::read_bytes_by_path.empty(), "foreground dispatch read profile was not captured"); for (const auto& read : test::read_bytes_by_path) require(std::find(prior_attempt_shards.begin(), prior_attempt_shards.end(), read.first) == prior_attempt_shards.end() || !read.second, @@ -914,6 +1995,29 @@ int main(int argc, char** argv) return view.count == 1 && test::source->item(Section::Published, 0, view.generation, item) && item.publication_confirmed && item.publication_revision == 1; }, "publication receipt not reflected in draft list"); require(router.identity.signs == 1, "publication signed more than once"); + check_web_job(3, true, 1, 1); + require(rt::requestWebPublicationStop(web_operation_id.data(), author.data()) == rt::WebReadResult::Ready, "confirmed result rejected a late stop"); + check_web_job(3, true, 1, 1); + require(rt::webLease(false), "confirmed operation query retained its browser lease"); + rt::WebReadResult historical_result = rt::WebReadResult::Pending; + until([&] + { + if (historical_result != rt::WebReadResult::Pending) return true; + const bool was_ui = test::in_ui; + test::in_ui = false; + historical_result = rt::readWebPublicationHistory(web_operation_id.data(), author.data(), job_response.data(), job_response.size(), job_written); + test::in_ui = was_ui; + return historical_result != rt::WebReadResult::Pending; + }, "verified historical operation read stalled"); + require(historical_result == rt::WebReadResult::Ready, "durable publication history unavailable"); + geocaching::protocol::CmpReader historical({job_response.data(), job_written}); + size_t historical_fields = 0; + uint64_t historical_phase = 0; + geocaching::ByteView historical_id; + require(historical.array(historical_fields, 8) && historical_fields == 8 && historical.binary(historical_id, 16) && + !std::memcmp(historical_id.data, web_operation_id.data(), 16) && historical.unsignedInteger(historical_phase) && historical_phase == 3, + "historical operation did not preserve its directory-confirmed phase"); + require(rt::webLease(false), "historical operation query retained its browser lease"); const auto discovery_before_edit = snapshot(Section::Discover).generation; // Exercise the UI field limits and the confirmed-version edit path through // the same device owner, not a direct store call. diff --git a/modules/core_geocaching/tests/test_directory_pool.cpp b/modules/core_geocaching/tests/test_directory_pool.cpp index fa31b7ca..df8188ed 100644 --- a/modules/core_geocaching/tests/test_directory_pool.cpp +++ b/modules/core_geocaching/tests/test_directory_pool.cpp @@ -30,6 +30,20 @@ int main() assert(ready && ready->sequence == 5); ready->in_flight = true; assert(pool->next(1, true) != ready); + discovery = ready->discovery; + announce.delivery = ready->delivery; + ready->max_query_items = 20; + ready->retry_after = 100; + announce.sequence = 6; + assert(pool->observe(discovery, announce, {key.data(), key.size()}, 2)); + assert(ready->capabilities_verified && ready->max_query_items == 20); + assert(ready->in_flight && ready->retry_after == 100); + announce.epoch[0] = 1; + announce.sequence = 1; + assert(pool->observe(discovery, announce, {key.data(), key.size()}, 3)); + assert(!ready->capabilities_verified && ready->max_query_items == 0); + assert(ready->sequence == 1 && ready->retry_after == 0); + assert(ready->in_flight); discovery.bytes[0] = 32; announce.delivery.bytes[0] = 32; assert(!pool->observe(discovery, announce, {key.data(), key.size()}, 2)); diff --git a/modules/core_geocaching/tests/test_discovery_admission.cpp b/modules/core_geocaching/tests/test_discovery_admission.cpp index 023a7b2c..8ef1a368 100644 --- a/modules/core_geocaching/tests/test_discovery_admission.cpp +++ b/modules/core_geocaching/tests/test_discovery_admission.cpp @@ -22,6 +22,40 @@ int main() packet.destination_hash = destination.data(); packet.payload = payload.data(); packet.payload_len = payload.size(); + { + constexpr size_t prefix = reticulum::kCombinedPublicKeySize + reticulum::kNameHashSize + 10 + reticulum::kSignatureSize; + payload.resize(prefix + 99); + packet.payload = payload.data(); + packet.payload_len = payload.size(); + reticulum::computeNameHash("trailmate", "track.directory", payload.data() + reticulum::kCombinedPublicKeySize); + assert(GeocachingDiscoveryBudget::matchesApplications(packet, false, true, budget)); + assert(GeocachingDiscoveryBudget::matchesApplications(packet, true, true, budget)); + assert(!GeocachingDiscoveryBudget::matchesApplications(packet, true, false, budget)); + assert(!GeocachingDiscoveryBudget::matches(packet, true, budget)); + for (auto phase : {"call", "nomad", "saver"}) + { + budget.phase = phase; + assert(!GeocachingDiscoveryBudget::matchesApplications(packet, false, true, budget)); + } + budget.phase = "screen"; + payload.resize(prefix + 128); + packet.payload = payload.data(); + packet.payload_len = payload.size(); + assert(GeocachingDiscoveryBudget::matchesApplications(packet, false, true, budget)); + payload.resize(prefix + 129); + packet.payload = payload.data(); + packet.payload_len = payload.size(); + assert(!GeocachingDiscoveryBudget::matchesApplications(packet, false, true, budget)); + payload.resize(prefix + 99); + packet.payload = payload.data(); + packet.payload_len = payload.size(); + payload[reticulum::kCombinedPublicKeySize] ^= 1; + assert(!GeocachingDiscoveryBudget::matchesApplications(packet, false, true, budget)); + payload.resize(prefix + 1); + packet.payload = payload.data(); + packet.payload_len = payload.size(); + reticulum::computeNameHash("trailmate", "geocache.directory", payload.data() + reticulum::kCombinedPublicKeySize); + } // Signature bytes intentionally invalid: classification is not authentication. assert(GeocachingDiscoveryBudget::matches(packet, true, budget)); assert(!GeocachingDiscoveryBudget::matches(packet, false, budget)); diff --git a/modules/core_geocaching/tests/test_get_response.cpp b/modules/core_geocaching/tests/test_get_response.cpp index 1a011522..c6accfe4 100644 --- a/modules/core_geocaching/tests/test_get_response.cpp +++ b/modules/core_geocaching/tests/test_get_response.cpp @@ -29,4 +29,32 @@ int main(int argc, char** argv) response.back() = 0; id.bytes[0] = 5; assert(!decodeGetResponse({response.data(), response.size()}, id, 8192, out)); + id.bytes.fill(4); + std::vector missing{0x96, 1, 1, 3, 0xc4, 16}; + missing.insert(missing.end(), id.bytes.begin(), id.bytes.end()); + missing.insert(missing.end(), {0xcd, 1, 0x94, 0x93, 0xa9, 'n', 'o', 't', '_', 'f', 'o', 'u', 'n', 'd', 0xc0, 0xc0}); + using geocaching::protocol::decodeGetNotFound; + assert(decodeGetNotFound({missing.data(), missing.size()}, id, 8192)); + for (size_t n = 0; n < missing.size(); ++n) assert(!decodeGetNotFound({missing.data(), n}, id, 8192)); + auto malformed = missing; + malformed.push_back(0); + assert(!decodeGetNotFound({malformed.data(), malformed.size()}, id, 8192)); + malformed = missing; + malformed.back() = 0; + assert(!decodeGetNotFound({malformed.data(), malformed.size()}, id, 8192)); + for (const uint16_t status : {uint16_t(304), uint16_t(400), uint16_t(409), uint16_t(410), uint16_t(429), uint16_t(503)}) + { + malformed = missing; + malformed[23] = static_cast(status >> 8); + malformed[24] = static_cast(status); + assert(!decodeGetNotFound({malformed.data(), malformed.size()}, id, 8192)); + } + id.bytes[0] ^= 1; + assert(!decodeGetNotFound({missing.data(), missing.size()}, id, 8192)); + id.bytes[0] ^= 1; + missing[3] = 2; + assert(!decodeGetNotFound({missing.data(), missing.size()}, id, 8192)); + missing[3] = 3; + missing[missing.size() - 8] = 'x'; + assert(!decodeGetNotFound({missing.data(), missing.size()}, id, 8192)); } diff --git a/modules/core_geocaching/tests/test_journal_replay.cpp b/modules/core_geocaching/tests/test_journal_replay.cpp index ffadaecc..05f16aba 100644 --- a/modules/core_geocaching/tests/test_journal_replay.cpp +++ b/modules/core_geocaching/tests/test_journal_replay.cpp @@ -3,6 +3,7 @@ #include "geocaching/protocol/query_request.h" #include "geocaching/protocol/record_encoder.h" #include "geocaching/storage/draft_record.h" +#include "geocaching/storage/publication_history_job.h" #include "geocaching/storage/queued_request.h" #include "platform/esp/arduino_common/geocaching/indexed_dispatch_store.h" #include "platform/esp/arduino_common/geocaching/indexed_download_store.h" @@ -3258,7 +3259,7 @@ int checkIndexedAuthorReservation(const char* path) remote.bytes.fill(0x42); wrong_source.bytes.fill(0x43); std::array task{}; - task.fill(0x44); + task = id.bytes; SdPublishPort publish_port(store, crypto, local, verified.id, verified.hash, task, issued); PublishAttempt publication(publish_port, crypto); if (!publication.begin(remote, id, reply.signed_cache, signing, sizeof(signing))) return 24; @@ -3334,8 +3335,65 @@ int checkIndexedAuthorReservation(const char* path) std::memcmp(selected.key.data() + 32, id.bytes.data(), 16) || !recovered_owner.heldBy(&restored_store)) return false; std::vector response_copy; if (selected.response.size) response_copy.assign(selected.response.data, selected.response.data + selected.response.size); + uint8_t job_output[192]; + size_t job_size = 0; + protocol::VerifiedRecordView job_verified; + const ByteView operation{task.data(), task.size()}; + if (encodePublicationHistoryJob(selected, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Ready) return false; + protocol::CmpReader job_reader({job_output, job_size}); + size_t job_fields = 0; + ByteView job_id; + uint64_t job_phase = 0; + if (!job_reader.array(job_fields, 8) || job_fields != 8 || !job_reader.binary(job_id, 16) || + !job_reader.unsignedInteger(job_phase) || job_phase != (confirmed ? 3 : 2)) return false; + auto incorrect = selected; + incorrect.hash.bytes[0] ^= 1; + if (encodePublicationHistoryJob(incorrect, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Invalid || job_size) return false; + auto wrong_author = std::array{}; + if (encodePublicationHistoryJob(selected, operation, {wrong_author.data(), wrong_author.size()}, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Forbidden || job_size) return false; + crypto.available = false; + const auto unavailable_job = encodePublicationHistoryJob(selected, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size); + crypto.available = true; + if (unavailable_job != PublicationHistoryJobResult::Unavailable || job_size) return false; + if (!confirmed) + { + for (unsigned fault = 0; fault < 2; ++fault) + { + incorrect = selected; + if (fault) incorrect.task_state = 4; + else incorrect.outgoing_state = 5; + if (encodePublicationHistoryJob(incorrect, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Ready) return false; + protocol::CmpReader failed({job_output, job_size}); + size_t count = 0; + uint64_t phase = 0, revision = 0, durable = 0; + ByteView bytes; + std::string_view failure; + if (!failed.array(count, 8) || !failed.binary(bytes, 16) || !failed.unsignedInteger(phase) || phase != 5 || + !failed.binary(bytes, 32) || !failed.binary(bytes, 32) || !failed.unsignedInteger(revision) || + !failed.unsignedInteger(durable) || durable != 1 || !failed.nil() || !failed.text(failure, 128) || + failure != (fault ? "Publication task is blocked" : "Local publication storage failed") || !failed.finished()) return false; + } + } std::vector corrupt_request(selected.request.data, selected.request.data + selected.request.size); corrupt_request.back() ^= 1; + incorrect = selected; + incorrect.request = {corrupt_request.data(), corrupt_request.size()}; + if (encodePublicationHistoryJob(incorrect, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Invalid || job_size) return false; + if (confirmed) + { + auto invalid_response = response_copy; + invalid_response.back() ^= 1; + incorrect = selected; + incorrect.response = {invalid_response.data(), invalid_response.size()}; + if (encodePublicationHistoryJob(incorrect, operation, record.author_public_key, crypto, signing, sizeof(signing), + job_verified, job_output, sizeof(job_output), job_size) != PublicationHistoryJobResult::Invalid || job_size) return false; + } SdPublishPort invalid_port(restored_store, crypto, local, selected.cache, selected.hash, selected.task, selected.created); PublishAttempt invalid_attempt(invalid_port, crypto); if (!invalid_port.attachRestoredRequest(remote, id) || @@ -3626,9 +3684,11 @@ int checkIndexedAuthorReservation(const char* path) another_directory.bytes.fill(0x62); std::array another_task; another_task.fill(0x63); + RequestId another_request; + another_request.bytes = another_task; SdPublishPort another_port(store, crypto, local, verified.id, verified.hash, another_task, issued); PublishAttempt another_publication(another_port, crypto); - if (!another_publication.begin(another_directory, id, reply.signed_cache, signing, sizeof(signing))) return 106; + if (!another_publication.begin(another_directory, another_request, reply.signed_cache, signing, sizeof(signing))) return 106; for (unsigned i = 0; i < 32768 && another_publication.phase() == PublishAttemptPhase::Submitting; ++i) another_publication.advance(); if (another_publication.phase() != PublishAttemptPhase::Waiting || select_publication(recovery_filter) != DraftReadResult::Ready || recovered_publication.task != another_task || std::memcmp(recovered_publication.key.data() + 16, another_directory.bytes.data(), 16)) return 107; @@ -3642,6 +3702,44 @@ int checkIndexedAuthorReservation(const char* path) if (!another_publication.cancel()) return 110; for (unsigned i = 0; i < 32768 && another_publication.phase() == PublishAttemptPhase::Cancelling; ++i) another_publication.advance(); if (another_publication.phase() != PublishAttemptPhase::Cancelled || select_publication(recovery_filter) != DraftReadResult::NotFound) return 111; + auto operation_history = recovery_filter; + operation_history.has_operation = operation_history.include_terminal = true; + operation_history.operation = another_task; + const auto before_operation_read = files; + read_bytes.clear(); + if (select_publication(operation_history) != DraftReadResult::Ready || recovered_publication.confirmed || + recovered_publication.task != another_task || recovered_publication.outgoing_state != 0 || + recovered_publication.task_state != 5 || recovered_publication.continue_intent || !recovered_publication.request.size || + recovered_publication.response.size || files != before_operation_read) return 130; + std::array operation_request_key; + std::memcpy(operation_request_key.data(), local.bytes.data(), 16); + std::memcpy(operation_request_key.data() + 16, another_directory.bytes.data(), 16); + std::memcpy(operation_request_key.data() + 32, another_task.data(), 16); + char expected_request_shard[8]; + std::snprintf(expected_request_shard, sizeof(expected_request_shard), "/05/%02x", static_cast(static_cast(::sys::crc32(operation_request_key.data(), operation_request_key.size())))); + for (const auto& entry : read_bytes) + if (entry.second && entry.first.find("/05/") != std::string::npos && entry.first.find(expected_request_shard) == std::string::npos) return 135; + uint8_t stopped_output[192]; + size_t stopped_size = 0; + protocol::VerifiedRecordView stopped_verified; + if (encodePublicationHistoryJob(recovered_publication, {another_task.data(), another_task.size()}, record.author_public_key, + crypto, signing, sizeof(signing), stopped_verified, stopped_output, sizeof(stopped_output), stopped_size) != + PublicationHistoryJobResult::Ready || files != before_operation_read) return 133; + protocol::CmpReader stopped_reader({stopped_output, stopped_size}); + size_t stopped_fields = 0; + uint64_t stopped_phase = 0, stopped_revision = 0, stopped_durable = 0; + ByteView stopped_id, stopped_cache, stopped_hash; + if (!stopped_reader.array(stopped_fields, 8) || stopped_fields != 8 || !stopped_reader.binary(stopped_id, 16) || + !stopped_reader.unsignedInteger(stopped_phase) || stopped_phase != 4 || !stopped_reader.binary(stopped_cache, 32) || + !stopped_reader.binary(stopped_hash, 32) || !stopped_reader.unsignedInteger(stopped_revision) || stopped_revision != record.revision || + !stopped_reader.unsignedInteger(stopped_durable) || stopped_durable != 1 || !stopped_reader.nil() || !stopped_reader.nil() || + !stopped_reader.finished()) return 134; + store.releaseDraftRead(); + operation_history.operation[0] ^= 1; + if (select_publication(operation_history) != DraftReadResult::NotFound || owner.holder() || files != before_operation_read) return 131; + operation_history.operation = another_task; + operation_history.include_terminal = false; + if (select_publication(operation_history) != DraftReadResult::NotFound || owner.holder()) return 132; // Commit unrelated saved-cache metadata through the same live root. It // must not invalidate a loaded draft/publication projection. const auto publication_generation = store.catalogGeneration(); diff --git a/modules/core_geocaching/tests/test_publication_operation_filter.cpp b/modules/core_geocaching/tests/test_publication_operation_filter.cpp new file mode 100644 index 00000000..9d9541ed --- /dev/null +++ b/modules/core_geocaching/tests/test_publication_operation_filter.cpp @@ -0,0 +1,80 @@ +#include "geocaching/storage/publication_recovery.h" +#include + +int main() +{ + using namespace geocaching; + using namespace geocaching::storage; + PublicationRecoveryFilter filter; + std::array key{}; + std::array operation{}; + operation.fill(7); + std::memcpy(key.data() + 32, operation.data(), 16); + assert(filter.matchesRequest({key.data(), key.size()})); + filter.has_operation = true; + filter.operation = operation; + assert(filter.matchesRequest({key.data(), key.size()})); + auto other_key = key; + other_key[32] ^= 1; + assert(!filter.matchesRequest({other_key.data(), other_key.size()})); + auto other = filter; + other.operation[0] ^= 1; + assert(!filter.same(other)); + other = filter; + other.has_operation = false; + assert(!filter.same(other)); + other = filter; + assert(filter.same(other)); + + OutgoingView outgoing; + outgoing.task_id = {operation.data(), operation.size()}; + outgoing.state = 1; + outgoing.continue_intent = true; + TaskView task; + task.kind = 1; + task.continue_intent = true; + task.requests[0] = {key.data(), key.size()}; + task.request_count = 1; + std::array cache{}, hash{}; + task.cache_id = {cache.data(), cache.size()}; + task.revision_hash = {hash.data(), hash.size()}; + PublicationRecoveryView selected; + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Ready); + assert(selected.task == operation); + assert(publicationRecoveryCandidate(filter, {other_key.data(), other_key.size()}, outgoing, task, selected) == PublicationRecoveryResult::None); + auto foreign_task = operation; + foreign_task[0] ^= 1; + outgoing.task_id = {foreign_task.data(), foreign_task.size()}; + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Invalid); + outgoing.task_id = {operation.data(), operation.size()}; + outgoing.state = 4; + outgoing.continue_intent = false; + task.state = 3; + task.continue_intent = false; + assert(publicationRecoveryRequestEligible(filter, outgoing)); + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Ready); + assert(selected.confirmed); + filter.has_operation = false; + assert(!publicationRecoveryRequestEligible(filter, outgoing)); + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::None); + outgoing.state = 5; + filter.has_operation = true; + assert(!publicationRecoveryRequestEligible(filter, outgoing)); + filter.include_terminal = true; + task.state = 5; + assert(publicationRecoveryRequestEligible(filter, outgoing)); + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Ready); + assert(selected.outgoing_state == 5 && selected.task_state == 5 && !selected.continue_intent && !selected.confirmed); + other = filter; + other.include_terminal = false; + assert(!filter.same(other)); + filter.has_operation = false; + assert(!publicationRecoveryRequestEligible(filter, outgoing)); + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Invalid); + filter.has_operation = true; + outgoing.state = 1; + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Ready); + assert(!selected.continue_intent && !selected.confirmed); + task.state = 6; + assert(publicationRecoveryCandidate(filter, {key.data(), key.size()}, outgoing, task, selected) == PublicationRecoveryResult::Invalid); +} diff --git a/modules/core_geocaching/tests/test_publication_receipt.cpp b/modules/core_geocaching/tests/test_publication_receipt.cpp new file mode 100644 index 00000000..f286a994 --- /dev/null +++ b/modules/core_geocaching/tests/test_publication_receipt.cpp @@ -0,0 +1,76 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "chat/infra/reticulum/reticulum_wire.h" +#include "geocaching/protocol/publication_receipt.h" +#include "geocaching/storage/publication_confirmation.h" +#include "geocaching/storage/confirmed_publication.h" +#include +#include +#include +#include +struct Crypto : geocaching::protocol::RecordCrypto +{ + bool sha256(geocaching::ByteView bytes, uint8_t output[32]) override + { chat::reticulum::fullHash(bytes.data, bytes.size, output); return true; } + geocaching::protocol::VerificationResult verifyEd25519(geocaching::ByteView key, geocaching::ByteView signature, geocaching::ByteView message) override + { return ed25519_verify(signature.data, message.data, message.size, key.data) ? geocaching::protocol::VerificationResult::Valid : geocaching::protocol::VerificationResult::InvalidSignature; } +}; +int main(int argc, char** argv) +{ + using namespace geocaching; + using namespace geocaching::protocol; + if (argc != 2) return 1; + std::ifstream file(argv[1], std::ios::binary); + std::vector signed_record((std::istreambuf_iterator(file)), {}), scratch(8192); + Crypto crypto; VerifiedRecordView content; + if (verifyGeocache({signed_record.data(), signed_record.size()}, crypto, scratch.data(), scratch.size(), content) != VerificationResult::Valid) return 2; + std::array public_key{}, private_key{}, signature{}; + std::array seed{}, hash{}; + for (unsigned i = 0; i < 32; ++i) { seed[i] = static_cast(100 + i); public_key[i] = static_cast(i + 1); } + ed25519_create_keypair(public_key.data() + 32, private_key.data(), seed.data()); + std::array delivery{}, epoch{}; epoch.fill(3); + crypto.sha256({public_key.data(), 64}, hash.data()); std::memcpy(scratch.data() + 10, hash.data(), 16); + constexpr char name[] = "lxmf.delivery"; + crypto.sha256({reinterpret_cast(name), sizeof(name) - 1}, hash.data()); std::memcpy(scratch.data(), hash.data(), 10); + crypto.sha256({scratch.data(), 26}, hash.data()); std::memcpy(delivery.data(), hash.data(), 16); + std::array raw{}; + CmpWriter writer(raw.data(), raw.size()); + if (!writer.array(8) || !writer.unsignedInteger(1) || !writer.binary({delivery.data(),16}) || !writer.binary({epoch.data(),16}) || + !writer.binary({content.id.bytes.data(),32}) || !writer.unsignedInteger(content.record.revision) || !writer.binary({content.hash.bytes.data(),32}) || + !writer.unsignedInteger(static_cast(content.record.state)) || !writer.unsignedInteger(1800000000)) return 3; + constexpr char domain[] = "trailmate.geocache/receipt/v1"; + std::memcpy(scratch.data(), domain, sizeof(domain)); std::memcpy(scratch.data()+sizeof(domain),raw.data(),writer.size()); + ed25519_sign(signature.data(), scratch.data(), sizeof(domain)+writer.size(), public_key.data()+32,private_key.data()); + PublicationReceiptView output; + const auto verify = [&](uint8_t* workspace, size_t capacity){return verifyPublicationReceipt({raw.data(),writer.size()},{signature.data(),64},{public_key.data(),64},{delivery.data(),16},{epoch.data(),16},content,crypto,workspace,capacity,output);}; + if (verify(scratch.data(),320)!=VerificationResult::Valid || output.revision!=content.record.revision) return 4; + signature[0]^=1; if(verify(scratch.data(),320)!=VerificationResult::InvalidSignature || output.cache.data) return 5; signature[0]^=1; + epoch[0]^=1; if(verify(scratch.data(),320)!=VerificationResult::IdentityMismatch) return 6; epoch[0]^=1; + public_key[0]^=1; if(verify(scratch.data(),320)!=VerificationResult::IdentityMismatch) return 7; public_key[0]^=1; + content.hash.bytes[0]^=1; if(verify(scratch.data(),320)!=VerificationResult::IdentityMismatch) return 8; content.hash.bytes[0]^=1; + if(verify(scratch.data(),319)!=VerificationResult::WorkspaceTooSmall || verify(raw.data(),320)!=VerificationResult::InvalidRecord) return 9; + output.revision = 123; + if (verifyPublicationReceipt({reinterpret_cast(&output), sizeof(output)}, {signature.data(),64}, {public_key.data(),64}, + {delivery.data(),16}, {epoch.data(),16}, content, crypto, scratch.data(),320,output) != VerificationResult::InvalidRecord || output.revision != 123) return 10; + std::array stored{};size_t stored_size=0; + storage::PublicationConfirmationView proof{{raw.data(),writer.size()},{signature.data(),64},{public_key.data(),64}},decoded; + if(!storage::encodePublicationConfirmation(proof,stored.data(),stored.size(),stored_size)|| + !storage::decodePublicationConfirmation({stored.data(),stored_size},decoded)||decoded.raw.size!=writer.size())return 11; + if(storage::verifyPublicationConfirmation({stored.data(),stored_size},{delivery.data(),16},content,crypto,scratch.data(),320,output)!=VerificationResult::Valid)return 12; + stored[stored_size-1]^=1; + if(storage::verifyPublicationConfirmation({stored.data(),stored_size},{delivery.data(),16},content,crypto,scratch.data(),320,output)!=VerificationResult::IdentityMismatch)return 13; + if(storage::decodePublicationConfirmation({stored.data(),stored_size-1},decoded)|| + storage::encodePublicationConfirmation(proof,raw.data(),raw.size(),stored_size)||stored_size)return 14; + if(!storage::encodePublicationConfirmation(proof,stored.data(),stored.size(),stored_size))return 15; + RequestId request_id;request_id.bytes.fill(9); + std::array request_key{}; + std::memcpy(request_key.data()+16,delivery.data(),16);std::memcpy(request_key.data()+32,request_id.bytes.data(),16); + std::vector request_bytes(8192);size_t request_size=0; + if(!encodePublishRequest(request_id,content.record.encoded,content.signature,8192,request_bytes.data(),request_bytes.size(),request_size))return 16; + VerifiedRecordView recovered; + if(storage::verifyConfirmedPublication({request_key.data(),48},{request_bytes.data(),request_size},{stored.data(),stored_size},crypto,scratch.data(),scratch.size(),recovered)!=VerificationResult::Valid)return 17; + request_key[16]^=1; + if(storage::verifyConfirmedPublication({request_key.data(),48},{request_bytes.data(),request_size},{stored.data(),stored_size},crypto,scratch.data(),scratch.size(),recovered)!=VerificationResult::IdentityMismatch||recovered.signature.data)return 18; + request_key[16]^=1;request_bytes[request_size-1]^=1; + if(storage::verifyConfirmedPublication({request_key.data(),48},{request_bytes.data(),request_size},{stored.data(),stored_size},crypto,scratch.data(),scratch.size(),recovered)!=VerificationResult::InvalidSignature)return 19; + return 0; +} diff --git a/modules/core_geocaching/tests/test_query_client.cpp b/modules/core_geocaching/tests/test_query_client.cpp index f48325fe..b415f8c6 100644 --- a/modules/core_geocaching/tests/test_query_client.cpp +++ b/modules/core_geocaching/tests/test_query_client.cpp @@ -35,6 +35,7 @@ int main(int argc, char** argv) assert(argc == 3); Port port; auto client = std::make_unique(port); + assert(client->directoryCount() == 0 && client->directory(0) == nullptr); geocaching::Destination discovery, delivery; std::array key{}; std::vector app{0x95, 1, 0xc4, 16}; @@ -43,6 +44,8 @@ int main(int argc, char** argv) app.insert(app.end(), 16, 0); app.insert(app.end(), {0, 0xa1, 'x'}); assert(client->observe(discovery, delivery, {key.data(), 64}, {app.data(), app.size()}, 0)); + assert(client->directoryCount() == 1 && client->directory(0) && std::string(client->directory(0)->name.data()) == "x"); + assert(client->directory(1) == nullptr && client->directory(geocaching::QueryClient::kDirectoryCapacity) == nullptr); assert(client->query({300000000, 1200000000, 310000000, 1210000000})); geocaching::Destination pending_destination; geocaching::RequestId pending_id; diff --git a/modules/core_geocaching/tests/test_sd_journal.cpp b/modules/core_geocaching/tests/test_sd_journal.cpp index 6f0bedad..de1ead62 100644 --- a/modules/core_geocaching/tests/test_sd_journal.cpp +++ b/modules/core_geocaching/tests/test_sd_journal.cpp @@ -1201,6 +1201,13 @@ int checkPublishPort(const char* record_path, const char* response_path) storage::PublicationRecoveryFilter filter; storage::PublicationRecoveryView selected; if (store.readPublicationRecovery(filter, selected) != DraftReadResult::NotFound) return 240; + const auto stopped_sequence = store.committedSequence(); + filter.has_operation = filter.include_terminal = true; + filter.operation = request.bytes; + // This legacy fixture uses an independent task ID, so the same + // request ID cannot be claimed as a browser operation ID. + if (store.readPublicationRecovery(filter, selected) != DraftReadResult::Invalid || + store.committedSequence() != stopped_sequence) return 243; continue; } if (attempt.phase() != PublishAttemptPhase::Waiting) return 216; diff --git a/modules/core_gps/include/gps/kml/html_image_source_stream.h b/modules/core_gps/include/gps/kml/html_image_source_stream.h new file mode 100644 index 00000000..48a8302f --- /dev/null +++ b/modules/core_gps/include/gps/kml/html_image_source_stream.h @@ -0,0 +1,204 @@ +#pragma once + +#include "gps/kml/image_sources.h" +#include + +namespace gps::kml +{ +// A bounded HTML attribute scanner for XML-decoded description text. The caller +// lends source storage; unrelated attribute values and descriptions are never +// retained. Callbacks receive the still HTML-entity-encoded source only after +// the entire tag closes. This is not an HTML renderer or a URL trust policy. +class HtmlImageSourceStream +{ + public: + HtmlImageSourceStream(char* source, size_t capacity) : source_(source), capacity_(capacity) {} + void reset() + { + state_ = State::Text; + raw_[0] = '\0'; + stopped_ = false; + comment_dashes_ = 0; + } + template + void feed(std::string_view text, Callback&& callback) + { + using namespace image_source_detail; + for (const char c : text) + { + if (stopped_) return; + switch (state_) + { + case State::Text: + if (c == '<') startTag(); + break; + case State::Open: + if (raw_[0] && c != '/') + { + state_ = State::Text; // raw text only recognizes a close + if (c == '<') startTag(); + } + else if (c == '/') closing_ = true, state_ = State::Name; + else if (c == '!') state_ = State::Bang; + else if (c == '?') state_ = State::Skip; + else if ((lower(c) >= 'a' && lower(c) <= 'z')) + { + state_ = State::Name; + appendName(c); + } + else state_ = State::Text; + break; + case State::Bang: + state_ = c == '-' ? State::BangDash : State::Skip; + if (c == '>') state_ = State::Text; + break; + case State::BangDash: + state_ = c == '-' ? State::Comment : State::Skip; + if (c == '>') state_ = State::Text; + break; + case State::Comment: + if (c == '>' && comment_dashes_ >= 2) state_ = State::Text, comment_dashes_ = 0; + else if (c == '-') comment_dashes_ = comment_dashes_ < 2 ? comment_dashes_ + 1 : 2; + else comment_dashes_ = 0; + break; + case State::Skip: + if (quote_) + { + if (c == quote_) quote_ = 0; + } + else if (c == '\'' || c == '"') quote_ = c; + else if (c == '>') state_ = State::Text; + break; + case State::Name: + if (space(c) || c == '/' || c == '>') + { + image_ = !raw_[0] && !closing_ && equal({name_, name_size_}, "img"); + state_ = State::Attributes; + if (c == '>') finishTag(callback); + } + else appendName(c); + break; + case State::Attributes: + if (c == '>') finishTag(callback); + else if (!space(c) && c != '/') + { + attribute_size_ = 0; + state_ = State::AttributeName; + appendAttribute(c); + } + break; + case State::AttributeName: + if (space(c) || c == '=' || c == '/' || c == '>') + { + selectAttribute(); + state_ = c == '=' ? State::BeforeValue : State::AfterAttribute; + if (c == '>') finishTag(callback); + else if (c == '/') state_ = State::Attributes; + } + else appendAttribute(c); + break; + case State::AfterAttribute: + if (c == '=') state_ = State::BeforeValue; + else if (c == '>') finishTag(callback); + else if (!space(c)) + { + state_ = State::Attributes; + if (c != '/') + { + attribute_size_ = 0; + state_ = State::AttributeName; + appendAttribute(c); + } + } + break; + case State::BeforeValue: + if (space(c)) break; + if (c == '\'' || c == '"') quote_ = c, state_ = State::QuotedValue; + else if (c == '>') finishTag(callback); + else state_ = State::Value, appendValue(c); + break; + case State::QuotedValue: + if (c == quote_) quote_ = 0, state_ = State::Attributes; + else appendValue(c); + break; + case State::Value: + if (space(c)) state_ = State::Attributes; + else if (c == '>') finishTag(callback); + else appendValue(c); + break; + } + } + } + + private: + enum class State : uint8_t + { + Text, + Open, + Bang, + BangDash, + Comment, + Skip, + Name, + Attributes, + AttributeName, + AfterAttribute, + BeforeValue, + QuotedValue, + Value + }; + void startTag() + { + state_ = State::Open; + name_size_ = attribute_size_ = source_size_ = 0; + name_[0] = '\0'; + quote_ = 0; + closing_ = image_ = source_seen_ = take_ = overflow_ = invalid_ = false; + } + void appendName(char c) + { + if (name_size_ < sizeof(name_) - 1) name_[name_size_++] = image_source_detail::lower(c), name_[name_size_] = '\0'; + else invalid_ = true; + } + void appendAttribute(char c) + { + if (c == '<' || c == '\'' || c == '"') invalid_ = true; + if (attribute_size_ < sizeof(attribute_) - 1) attribute_[attribute_size_++] = image_source_detail::lower(c); + else attribute_size_ = sizeof(attribute_) - 1; // cannot match a short src + } + void selectAttribute() + { + take_ = image_ && !source_seen_ && image_source_detail::equal({attribute_, attribute_size_}, "src"); + if (take_) source_seen_ = true; + } + void appendValue(char c) + { + if (!take_) return; + if (source_ && source_size_ < capacity_) source_[source_size_++] = c; + else overflow_ = true; + } + template + void finishTag(Callback& callback) + { + using namespace image_source_detail; + state_ = State::Text; + if (raw_[0]) + { + if (closing_ && !invalid_ && equal({name_, name_size_}, raw_)) raw_[0] = '\0'; + return; + } + if (image_ && !invalid_ && !overflow_ && source_size_ && !callback(std::string_view(source_, source_size_))) stopped_ = true; + if (!closing_ && !invalid_ && (equal({name_, name_size_}, "script") || equal({name_, name_size_}, "style") || equal({name_, name_size_}, "textarea") || equal({name_, name_size_}, "title"))) + std::memcpy(raw_, name_, name_size_ + 1); + } + char* source_; + size_t capacity_, source_size_ = 0; + char name_[16]{}, attribute_[16]{}, raw_[16]{}; + uint8_t name_size_ = 0, attribute_size_ = 0, comment_dashes_ = 0; + char quote_ = 0; + State state_ = State::Text; + bool closing_ = false, image_ = false, source_seen_ = false, take_ = false; + bool overflow_ = false, invalid_ = false, stopped_ = false; +}; +static_assert(sizeof(HtmlImageSourceStream) <= 128, "HTML scanner borrows the source buffer"); +} // namespace gps::kml diff --git a/modules/core_gps/include/gps/kml/image_sources.h b/modules/core_gps/include/gps/kml/image_sources.h new file mode 100644 index 00000000..11a7f238 --- /dev/null +++ b/modules/core_gps/include/gps/kml/image_sources.h @@ -0,0 +1,196 @@ +#pragma once + +#include +#include +#include + +namespace gps::kml +{ +namespace image_source_detail +{ +constexpr bool space(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } +constexpr char lower(char c) { return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; } +inline bool equal(std::string_view a, std::string_view b) +{ + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) + if (lower(a[i]) != b[i]) return false; + return true; +} +} // namespace image_source_detail + +// One pass: decoded '&' never begins another entity. Supported named entities +// are the XML/HTML attribute basics; other names remain literal. Numeric forms +// follow HTML replacement rules. This does not change the source KML bytes. +inline std::string decodeImageSource(std::string_view source) +{ + std::string output; + output.reserve(source.size()); + for (size_t i = 0; i < source.size();) + { + uint32_t cp = 0; + const auto tail_end = source[i] == '&' ? source.substr(i + 1, 16).find(';') : source.npos; + const auto end = tail_end == source.npos ? source.npos : i + 1 + tail_end; + bool decoded = false; + if (end != source.npos && end - i <= 16) + { + auto entity = source.substr(i + 1, end - i - 1); + if (entity == "amp") cp = '&', decoded = true; + else if (entity == "quot") cp = '"', decoded = true; + else if (entity == "apos") cp = '\'', decoded = true; + else if (entity == "lt") cp = '<', decoded = true; + else if (entity == "gt") cp = '>', decoded = true; + else if (!entity.empty() && entity.front() == '#') + { + entity.remove_prefix(1); + unsigned base = 10; + if (!entity.empty() && (entity.front() == 'x' || entity.front() == 'X')) base = 16, entity.remove_prefix(1); + decoded = !entity.empty(); + bool oversized = false; + for (const auto c : entity) + { + const auto lower = image_source_detail::lower(c); + const unsigned digit = c >= '0' && c <= '9' ? unsigned(c - '0') : lower >= 'a' && lower <= 'f' ? unsigned(lower - 'a' + 10) + : base; + if (digit >= base) + { + decoded = false; + break; + } + if (cp > (0x10ffffU - digit) / base) oversized = true; + else if (!oversized) cp = cp * base + digit; + } + if (decoded) + { + if (oversized || !cp || (cp >= 0xd800 && cp <= 0xdfff)) cp = 0xfffd; + if (cp >= 0x80 && cp <= 0x9f) + { + static constexpr uint16_t c1[] = {0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021, 0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f, 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014, 0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178}; + cp = c1[cp - 0x80]; + } + } + } + } + if (!decoded) + { + output.push_back(source[i++]); + continue; + } + i = end + 1; + if (cp <= 0x7f) output.push_back(char(cp)); + else if (cp <= 0x7ff) + { + output.push_back(char(0xc0 | (cp >> 6))); + output.push_back(char(0x80 | (cp & 63))); + } + else if (cp <= 0xffff) + { + output.push_back(char(0xe0 | (cp >> 12))); + output.push_back(char(0x80 | ((cp >> 6) & 63))); + output.push_back(char(0x80 | (cp & 63))); + } + else + { + output.push_back(char(0xf0 | (cp >> 18))); + output.push_back(char(0x80 | ((cp >> 12) & 63))); + output.push_back(char(0x80 | ((cp >> 6) & 63))); + output.push_back(char(0x80 | (cp & 63))); + } + } + return output; +} + +// Extract literal HTML img/src attributes, without allocating a tag copy. +// Callback receives a borrowed, still entity-encoded view and returns whether +// to continue. This is NOT a streaming KML parser or a full HTML DOM parser: +// incomplete tags are ignored, and entity/URL policy belongs to the caller. +template +void forEachImageSource(std::string_view text, Callback&& callback) +{ + using namespace image_source_detail; + size_t offset = 0; + std::string_view raw_text; + while (offset < text.size()) + { + const auto opening = text.find('<', offset); + if (opening == text.npos) return; + offset = opening + 1; + if (!raw_text.empty()) + { + if (offset < text.size() && text[offset] == '/' && + offset + 1 + raw_text.size() <= text.size() && + equal(text.substr(offset + 1, raw_text.size()), raw_text)) + { + const auto after = offset + 1 + raw_text.size(); + if (after < text.size() && (space(text[after]) || text[after] == '>')) raw_text = {}; + } + if (!raw_text.empty()) continue; + } + if (text.substr(opening, 4) == "", opening + 4); + if (end == text.npos) return; + offset = end + 3; + continue; + } + // Callers currently pass complete KML lines, including CDATA wrappers. + if (text.substr(opening, 9) == "= text.size()) return; + const bool closing = text[offset] == '/'; + if (closing) ++offset; + const auto name_start = offset; + while (offset < text.size() && !space(text[offset]) && text[offset] != '/' && text[offset] != '>') ++offset; + const auto name = text.substr(name_start, offset - name_start); + const bool image = !closing && equal(name, "img"); + std::string_view source; + bool source_seen = false, complete = false, invalid = false; + while (offset < text.size()) + { + while (offset < text.size() && (space(text[offset]) || text[offset] == '/')) ++offset; + if (offset >= text.size()) break; + if (text[offset] == '>') + { + ++offset; + complete = true; + break; + } + const auto attribute_start = offset; + while (offset < text.size() && !space(text[offset]) && text[offset] != '=' && text[offset] != '/' && text[offset] != '>') + { + if (text[offset] == '<' || text[offset] == '\'' || text[offset] == '"') invalid = true; + ++offset; + } + const auto attribute = text.substr(attribute_start, offset - attribute_start); + while (offset < text.size() && space(text[offset])) ++offset; + const bool take = image && !source_seen && equal(attribute, "src"); + if (take) source_seen = true; // HTML keeps the first duplicate, even empty. + if (offset >= text.size()) break; + if (text[offset] != '=') continue; + ++offset; + while (offset < text.size() && space(text[offset])) ++offset; + if (offset >= text.size()) break; + const char quote = text[offset] == '\'' || text[offset] == '"' ? text[offset++] : '\0'; + const auto value_start = offset; + if (quote) + { + const auto end = text.find(quote, offset); + if (end == text.npos) return; + offset = end; + } + else + { + while (offset < text.size() && !space(text[offset]) && text[offset] != '>') ++offset; + } + if (take) source = text.substr(value_start, offset - value_start); + if (quote) ++offset; + } + if (!complete) return; + if (image && !invalid && !source.empty() && !callback(source)) return; + if (!closing && (equal(name, "script") || equal(name, "style") || equal(name, "textarea") || equal(name, "title"))) raw_text = name; + } +} +} // namespace gps::kml diff --git a/modules/core_hostlink/hostlink_protocol.md b/modules/core_hostlink/hostlink_protocol.md index 941a9989..bf52de52 100644 --- a/modules/core_hostlink/hostlink_protocol.md +++ b/modules/core_hostlink/hostlink_protocol.md @@ -78,6 +78,66 @@ Bitmask (uint32): - bit7: `CapTeamState` - bit8: `CapAprsGateway` - bit9: `CapTxAppData` (downlink `CMD_TX_APP_DATA`) +- bit10: `CapManagement` (versioned management envelopes) +- bit11: `CapChannelPositionPrecision` (firmware consumes per-channel position precision before its own Meshtastic sends/replies and MQTT mirroring) +- bit12: `CapSharedSignatureRecovery` (opt-in management operation 38 recovery of sealed unsigned Geocaching input) +- bit13: `CapSharedConfirmationRecovery` (opt-in operation 38 `[6,null]`, plus terminal staging after durable Geocaching confirmation) + +Bit11 describes firmware behavior, regardless of the currently selected RF +protocol. It does not enable an App bridge or change the device author identity. +When channel module settings are absent, legacy behavior remains. When present, +0 blocks coordinate-bearing position messages, 1–31 quantizes, 32 preserves +coordinates, and values above 32 reject coordinate-bearing messages. Empty +position requests remain possible. Clients must not infer support from the +firmware label or management operation count. Existing HELLO_ACK layout and +maximum frame length are unchanged; older firmware omits bit11. + +With bit12, `TRANSFER_LIST_OWNED` (38) additionally accepts exactly `[5,null]`. +Existing track upload kinds and their seven-field rows are unchanged. The new +reply is `[media_session,null,row|null]`; a present row has nine fields: +`[transfer_id16,5,total,sha256_32,current_author_generation,1,total,owner16,draft_nonce16]`. +The generation describes the author proved for this query; legacy staging +metadata does not establish a historic author generation. Only sealed unsigned +input whose record prefix contains the current device public author and whose +whole-file hash matches staging metadata is exposed. Active signing or +confirmation returns Busy. Receiving input, completed input, configuration, +drafts and confirmation proofs are excluded. Candidate/private-key bytes are +never returned. Atomic staging metadata can be recovered by the existing +journal loader, but query does not sign, advance a phase or delete input. + +The returned owner is USB correlation, not an identity credential. Before +restoring it, clients must prove the author and match their retained transfer +ID, byte length, hash and draft nonce. Abort remains operation 36 with the exact +original owner and transfer ID; the query does not grant broader cleanup. +Clients lacking bit12 must not send this new kind-5 query. Web maintenance +retains correlation per proved author/generation before writes and must not +silently rotate owners on storage failure. + +Bit13 separately enables `[6,null]` with the same nine-field row, kind 6 and +maximum 4166 bytes. The SHA256 binds the complete SignedCache staging file. +The public author/nonce is inspected inside its unsigned-record prefix, not +returned as file content. This query alone does not prove confirmation; clients +must match their original signed file and retained operation, and verify the +directory receipt/device durable status before clearing a confirmed copy. +Bit12 firmware alone does not support this additional query. + +After a matching `CACHE_CONFIRM_BEGIN` or `CACHE_CONFIRM_STATUS` reports a +durable confirmation, firmware atomically marks the exact kind-6 upload phase +2 before acknowledging it. Owner, transfer ID and full staging hash must match. +Failed persistence yields a storage error, leaving the durable publication +intact for the same operation's status retry. Phase-2 candidate bytes remain +available for exact replay after reply loss/reboot. Another upload ID may use +the terminal slot; no signed version, receipt or author key is deleted. + +While the original confirmation is active, a nonblocking author-key lock that +is temporarily unavailable is pending/Busy, not evidence of an identity change. +The firmware keeps the same task and immutable input until the original +120-second lifetime expires. Temporary external SD ownership likewise pauses +work; changed media, a changed proved author/generation, or an actual key +storage failure still rejects the operation. A client retries only explicit +Busy reads within its budget, retaining the same author and task binding; it +must not release the lease, rotate the owner, or obtain a new signature for that +retry. Device proof must be revalidated when the lock becomes available. `CapAprsGateway` indicates EV_APP_DATA/EV_RX_MSG carry RX metadata TLV and the APRS config keys are supported. diff --git a/modules/core_hostlink/include/hostlink/geocache_confirmation_request.h b/modules/core_hostlink/include/hostlink/geocache_confirmation_request.h new file mode 100644 index 00000000..3ffedf46 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/geocache_confirmation_request.h @@ -0,0 +1,39 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/management_envelope.h" +namespace hostlink::management +{ +// Confirmation reuses a sealed SignedCache upload (transfer kind 6). The storage worker must +// check its hash, local author reservation and directory signature before commit. +struct GeocacheConfirmationRequest +{ + geocaching::ByteView transfer, hash, proof, signature, directory_public, epoch; + uint32_t author_generation = 0; +}; +// [transfer16, upload_sha256, author_generation, proof_raw, signature64, +// directory_public64, epoch16]. Actual v1 proofs fit 192 bytes. +inline Status parseGeocacheConfirmationRequest(const uint8_t* input, size_t size, GeocacheConfirmationRequest& output) +{ + if (input) + { + const auto in = reinterpret_cast(input), out = reinterpret_cast(&output); + if (in <= out ? out - in < size : in - out < sizeof(output)) return Status::InvalidField; + } + output = {}; + if (!input || !size || size > 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader({input,size}); + size_t count = 0; + uint64_t generation = 0; + GeocacheConfirmationRequest value; + if (!reader.array(count,7) || count != 7 || !reader.binary(value.transfer,16) || value.transfer.size != 16 || + !reader.binary(value.hash,32) || value.hash.size != 32 || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.binary(value.proof,192) || !value.proof.size || !reader.binary(value.signature,64) || value.signature.size != 64 || + !reader.binary(value.directory_public,64) || value.directory_public.size != 64 || !reader.binary(value.epoch,16) || value.epoch.size != 16 || !reader.finished()) return Status::InvalidField; + uint8_t present = 0; + for(unsigned i=0;i<16;++i) present |= value.transfer.data[i]; + if(!present) return Status::InvalidField; + value.author_generation = static_cast(generation); + output = value; + return Status::Ok; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/geocache_sign_request.h b/modules/core_hostlink/include/hostlink/geocache_sign_request.h new file mode 100644 index 00000000..85c92b73 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/geocache_sign_request.h @@ -0,0 +1,45 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/management_envelope.h" +namespace hostlink::management +{ +struct GeocacheSignRequest +{ + geocaching::ByteView transfer, hash, operation, draft; + uint32_t bytes = 0, author_generation = 0; + uint64_t draft_generation = 0; +}; +// [sealed_transfer16, unsigned_record_hash32, bytes, author_generation, operation16]. +inline Status parseGeocacheSignRequest(const uint8_t* input, size_t size, GeocacheSignRequest& output) +{ + if (input) + { + const auto in = reinterpret_cast(input), out = reinterpret_cast(&output); + if (in <= out ? out - in < size : in - out < sizeof(output)) return Status::InvalidField; + } + output = {}; + if (!input || !size || size > 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader({input, size}); + size_t count = 0; + uint64_t bytes = 0, generation = 0; + GeocacheSignRequest parsed; + if (!reader.array(count, 7) || (count != 5 && count != 7) || !reader.binary(parsed.transfer, 16) || parsed.transfer.size != 16 || + !reader.binary(parsed.hash, 32) || parsed.hash.size != 32 || !reader.unsignedInteger(bytes) || !bytes || bytes > 4096 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.binary(parsed.operation, 16) || parsed.operation.size != 16) return Status::InvalidField; + if (count == 7 && (!reader.binary(parsed.draft, 16) || parsed.draft.size != 16 || + !reader.unsignedInteger(parsed.draft_generation) || !parsed.draft_generation)) return Status::InvalidField; + if (!reader.finished()) return Status::InvalidField; + uint8_t transfer_bits = 0, operation_bits = 0; + for (unsigned index = 0; index < 16; ++index) + { + transfer_bits |= parsed.transfer.data[index]; + operation_bits |= parsed.operation.data[index]; + } + if (!transfer_bits || !operation_bits) return Status::InvalidField; + parsed.bytes = static_cast(bytes); + parsed.author_generation = static_cast(generation); + output = parsed; + return Status::Ok; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/geocache_signature_job.h b/modules/core_hostlink/include/hostlink/geocache_signature_job.h new file mode 100644 index 00000000..b821990d --- /dev/null +++ b/modules/core_hostlink/include/hostlink/geocache_signature_job.h @@ -0,0 +1,77 @@ +#pragma once +#include "geocaching/usecase/author_issue.h" +#include +#include + +namespace hostlink::management +{ +// Request owner pins the record and output work area in PSRAM. This job only +// reserves/signs; neither an RF adapter nor a network publication is involved. +class GeocacheSignatureJob +{ + public: + explicit GeocacheSignatureJob(geocaching::AuthorIssuePort& port) : issue_(port) {} + bool begin(geocaching::ByteView record, uint8_t* work, size_t capacity, + const uint8_t author[64], uint32_t generation) + { + if (started_ || !author || !generation) return false; + geocaching::RecordView decoded; + if (!geocaching::protocol::decodeGeocacheRecord(record, decoded) || + std::memcmp(decoded.author_public_key.data, author, 64)) return false; + if (!issue_.begin(record, work, capacity)) return false; + std::memcpy(author_, author, 64); + generation_ = generation; + started_ = true; + return true; + } + void advance(const uint8_t author[64], uint32_t generation, bool owner_valid) + { + if (!authorized(author, generation, owner_valid)) + { + invalidate(); + return; + } + issue_.advance(); + } + geocaching::AuthorIssuePhase phase() const + { + return invalidated_ ? geocaching::AuthorIssuePhase::Cancelled : issue_.phase(); + } + // Borrow at most one HostLink transfer slice. The command must encode it + // synchronously; no pointer may survive the owner lease or USB disconnect. + bool read(size_t offset, size_t maximum, const uint8_t author[64], uint32_t generation, + bool owner_valid, geocaching::ByteView& chunk, size_t& total) + { + chunk = {}; + total = 0; + if (!authorized(author, generation, owner_valid)) + { + invalidate(); + return false; + } + const auto signed_record = issue_.signedRecord(); + if (!signed_record.data || !signed_record.size || !maximum || maximum > 320 || offset >= signed_record.size) return false; + const auto remaining = signed_record.size - offset; + chunk = {signed_record.data + offset, remaining < maximum ? remaining : maximum}; + total = signed_record.size; + return true; + } + void invalidate() + { + issue_.cancel(); + invalidated_ = true; + } + + private: + bool authorized(const uint8_t* author, uint32_t generation, bool valid) const + { + return started_ && !invalidated_ && valid && author && generation == generation_ && + !std::memcmp(author_, author, 64); + } + geocaching::AuthorIssue issue_; + uint8_t author_[64]{}; + uint32_t generation_ = 0; + bool started_ = false, invalidated_ = false; +}; +static_assert(sizeof(GeocacheSignatureJob) <= 256, "Signature jobs borrow payload/work storage"); +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/hostlink_codec.h b/modules/core_hostlink/include/hostlink/hostlink_codec.h index f8ff8ac3..6b424df8 100644 --- a/modules/core_hostlink/include/hostlink/hostlink_codec.h +++ b/modules/core_hostlink/include/hostlink/hostlink_codec.h @@ -16,6 +16,10 @@ struct Frame }; uint16_t crc16_ccitt(const uint8_t* data, size_t len); +// Encodes directly into caller-owned storage; no allocation or payload copy +// through a second frame. On invalid input, written is zero and out is untouched. +bool encode_frame_into(uint8_t type, uint16_t seq, const uint8_t* payload, size_t len, + uint8_t* out, size_t capacity, size_t& written); bool encode_frame(uint8_t type, uint16_t seq, const uint8_t* payload, size_t len, std::vector& out); diff --git a/modules/core_hostlink/include/hostlink/hostlink_frame_router.h b/modules/core_hostlink/include/hostlink/hostlink_frame_router.h index 8358c2e6..117b9599 100644 --- a/modules/core_hostlink/include/hostlink/hostlink_frame_router.h +++ b/modules/core_hostlink/include/hostlink/hostlink_frame_router.h @@ -15,6 +15,7 @@ enum class HostlinkCommandId : uint8_t SetTime, GetGps, TxAppData, + Management, }; enum class HostlinkFrameDecisionType : uint8_t diff --git a/modules/core_hostlink/include/hostlink/hostlink_service_codec.h b/modules/core_hostlink/include/hostlink/hostlink_service_codec.h index cb1d49ff..67aa0111 100644 --- a/modules/core_hostlink/include/hostlink/hostlink_service_codec.h +++ b/modules/core_hostlink/include/hostlink/hostlink_service_codec.h @@ -14,11 +14,13 @@ enum class PendingCommandType : uint8_t { TxMsg = 1, TxAppData = 2, + Management = 3, }; struct PendingCommand { PendingCommandType type = PendingCommandType::TxMsg; + uint16_t sequence = 0; // Fits the padding before to; management replies retain frame correlation. uint32_t to = 0; uint32_t portnum = 0; uint8_t channel = 0; diff --git a/modules/core_hostlink/include/hostlink/hostlink_types.h b/modules/core_hostlink/include/hostlink/hostlink_types.h index 37319564..73af67da 100644 --- a/modules/core_hostlink/include/hostlink/hostlink_types.h +++ b/modules/core_hostlink/include/hostlink/hostlink_types.h @@ -25,6 +25,8 @@ enum class FrameType : uint8_t CmdSetTime = 0x13, CmdGetGps = 0x14, CmdTxAppData = 0x15, + ManagementRequest = 0x20, + ManagementResponse = 0x21, EvRxMsg = 0x80, EvTxResult = 0x81, @@ -58,6 +60,12 @@ enum Capabilities : uint32_t CapTeamState = 1u << 7, CapAprsGateway = 1u << 8, CapTxAppData = 1u << 9, + CapManagement = 1u << 10, + // Firmware applies configured precision to its own Meshtastic position + // packets before radio encryption and MQTT mirroring; independent of mode. + CapChannelPositionPrecision = 1u << 11, + CapSharedSignatureRecovery = 1u << 12, + CapSharedConfirmationRecovery = 1u << 13, }; enum class ConfigKey : uint8_t diff --git a/modules/core_hostlink/include/hostlink/identity_backup_export.h b/modules/core_hostlink/include/hostlink/identity_backup_export.h new file mode 100644 index 00000000..c68fcccc --- /dev/null +++ b/modules/core_hostlink/include/hostlink/identity_backup_export.h @@ -0,0 +1,56 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "geocaching/protocol/cmp_writer.h" +#include + +namespace hostlink::management +{ +// Caller holds the identity/router lock and owns a transient command buffer. +// Build directly in it, sign the domain + payload, then compact the envelope. +template +chat::DeviceAuthorResult exportNativeIdentityBackup(const Identity& identity, uint32_t generation, + uint8_t* output, size_t capacity, size_t& written) +{ + using Result = chat::DeviceAuthorResult; + written = 0; + if (!output) return Result::InvalidInput; + const auto out = reinterpret_cast(output), object = reinterpret_cast(&identity); + if (out <= object ? object - out < capacity : out - object < sizeof(identity)) return Result::InvalidInput; + const auto fail = [&](Result result) + { std::memset(output, 0, capacity); return result; }; + if (capacity < 448 || !generation) return fail(Result::InvalidInput); + if (!identity.isReady()) return fail(identity.identityStorageError() ? Result::StorageError : Result::Unavailable); + if (!identity.authorGeneration()) return fail(Result::StorageError); + if (identity.authorGeneration() != generation) return fail(Result::StaleIdentity); + static constexpr char domain[] = "TrailMate.Identity.Backup.v1"; + std::memcpy(output, domain, sizeof(domain)); // Includes domain's NUL. + size_t cursor = sizeof(domain); + const size_t limit = capacity - 64; + geocaching::protocol::CmpWriter head(output + cursor, limit - cursor); + if (!head.array(8) || !head.unsignedInteger(1) || !head.text("trailmate.identity.backup.v1") || !head.unsignedInteger(0) || !head.binaryHeader(64)) return fail(Result::InvalidInput); + cursor += head.size(); + if (64 > limit - cursor) return fail(Result::InvalidInput); + identity.combinedPublicKey(output + cursor); + cursor += 64; + geocaching::protocol::CmpWriter secret(output + cursor, limit - cursor); + if (!secret.binaryHeader(96)) return fail(Result::InvalidInput); + cursor += secret.size(); + if (96 > limit - cursor || !identity.copyNativePrivateKey(generation, output + cursor, 96)) return fail(Result::StaleIdentity); + cursor += 96; + geocaching::protocol::CmpWriter tail(output + cursor, limit - cursor); + if (!tail.binary({identity.identityHash(), 16}) || !tail.binary({identity.destinationHash(), 16}) || !tail.unsignedInteger(generation)) return fail(Result::InvalidInput); + cursor += tail.size(); + const size_t payload = cursor - sizeof(domain); + if (payload > 255 || !identity.sign(output, cursor, output + limit)) return fail(Result::Unavailable); + if (!identity.isReady() || identity.authorGeneration() != generation) return fail(Result::StaleIdentity); + std::memmove(output + 3, output + sizeof(domain), payload); + output[0] = 0x92; + output[1] = 0xc4; + output[2] = static_cast(payload); + geocaching::protocol::CmpWriter signature(output + 3 + payload, capacity - 3 - payload); + if (!signature.binary({output + limit, 64})) return fail(Result::InvalidInput); + written = 3 + payload + signature.size(); + std::memset(output + written, 0, capacity - written); + return Result::Ok; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/identity_backup_request.h b/modules/core_hostlink/include/hostlink/identity_backup_request.h new file mode 100644 index 00000000..fa32b949 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/identity_backup_request.h @@ -0,0 +1,38 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/management_envelope.h" +namespace hostlink::management +{ +template +Status processIdentityBackupExport(const Adapter& adapter, const EnvelopeView& request, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!request.session || request.code != uint16_t(Operation::IdentityBackupExport) || !output || capacity < 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t generation = 0; + if (!reader.array(fields, 2) || fields != 2 || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + reader.position() + 1 != request.body_size) return Status::InvalidField; + const uint8_t consent = request.body[reader.position()]; + if (consent == 0xc2) return Status::Unauthorized; + if (consent != 0xc3) return Status::InvalidField; + // Request metadata has been copied to scalars before output may overwrite it. + switch (adapter.exportDeviceIdentityBackup(uint32_t(generation), output, capacity, written)) + { + case chat::DeviceAuthorResult::Ok: + return Status::Ok; + case chat::DeviceAuthorResult::Unsupported: + return Status::Unsupported; + case chat::DeviceAuthorResult::StorageError: + return Status::StorageUnavailable; + case chat::DeviceAuthorResult::StaleIdentity: + return Status::StaleIdentity; + case chat::DeviceAuthorResult::InvalidInput: + return Status::InvalidField; + default: + return Status::Busy; + } +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/identity_restore_request.h b/modules/core_hostlink/include/hostlink/identity_restore_request.h new file mode 100644 index 00000000..2ccad59d --- /dev/null +++ b/modules/core_hostlink/include/hostlink/identity_restore_request.h @@ -0,0 +1,72 @@ +#pragma once +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/management_envelope.h" +#include + +namespace hostlink::management +{ +struct IdentityRestoreView +{ + uint32_t expected_generation = 0, backup_generation = 0; + uint8_t format = 0; + geocaching::ByteView expected_public{}, payload{}, signature{}, public_key{}, private_key{}, identity_hash{}, delivery{}; +}; +// Request: [targetGeneration,targetPublic64,payloadBin,signature64,consentBool]. +// Views borrow the USB command buffer. Caller must not overwrite it until the +// authorized atomic write completes, and must wipe the command on every exit. +inline Status parseIdentityRestoreRequest(geocaching::ByteView input, IdentityRestoreView& output) +{ + output = {}; + if (!input.data || input.size > 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader(input); + IdentityRestoreView parsed; + size_t count = 0; + uint64_t generation = 0; + if (!reader.array(count, 5) || count != 5 || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.binary(parsed.expected_public, 64) || parsed.expected_public.size != 64 || + !reader.binary(parsed.payload, 320) || !reader.binary(parsed.signature, 64) || parsed.signature.size != 64 || + reader.position() + 1 != input.size) return Status::InvalidField; + if (input.data[reader.position()] == 0xc2) return Status::Unauthorized; + if (input.data[reader.position()] != 0xc3) return Status::InvalidField; + parsed.expected_generation = uint32_t(generation); + geocaching::protocol::CmpReader payload(parsed.payload); + uint64_t version = 0, format = 0; + std::string_view type; + if (!payload.array(count, 8) || count != 8 || !payload.unsignedInteger(version) || version != 1 || + !payload.text(type, 32) || type != "trailmate.identity.backup.v1" || !payload.unsignedInteger(format)) return Status::InvalidField; + if (format > 1) return Status::Unsupported; + parsed.format = uint8_t(format); + if (!payload.binary(parsed.public_key, 64) || parsed.public_key.size != 64 || + !payload.binary(parsed.private_key, 96) || parsed.private_key.size != (format == 0 ? 96U : 64U) || + !payload.binary(parsed.identity_hash, 16) || parsed.identity_hash.size != 16 || + !payload.binary(parsed.delivery, 16) || parsed.delivery.size != 16 || + !payload.unsignedInteger(generation) || !generation || generation > UINT32_MAX || !payload.finished()) return Status::InvalidField; + parsed.backup_generation = uint32_t(generation); + output = parsed; + return Status::Ok; +} +// This proves the backup author's signature. The caller still checks both +// private/public pairs and derived addresses, and binds target generation/public +// under the identity lock before admitting storage or invalidating sessions. +inline bool verifyIdentityRestoreSignature(const IdentityRestoreView& value, uint8_t* work, size_t capacity) +{ + static constexpr char domain[] = "TrailMate.Identity.Backup.v1"; + if (!work || !value.payload.data || !value.public_key.data || !value.signature.data || + value.public_key.size != 64 || value.signature.size != 64 || value.payload.size > 320 || + capacity < sizeof(domain) + value.payload.size) return false; + const auto start = reinterpret_cast(work), input = reinterpret_cast(value.payload.data); + const size_t used = sizeof(domain) + value.payload.size; + if (start >= input ? start - input < value.payload.size : input - start < used) return false; + const auto signature = reinterpret_cast(value.signature.data); + const auto public_key = reinterpret_cast(value.public_key.data); + if ((start >= signature ? start - signature < 64 : signature - start < used) || + (start >= public_key ? start - public_key < 64 : public_key - start < used)) return false; + std::memcpy(work, domain, sizeof(domain)); + std::memcpy(work + sizeof(domain), value.payload.data, value.payload.size); + const bool valid = ed25519_verify(value.signature.data, work, used, value.public_key.data + 32) != 0; + volatile uint8_t* clear = work; + for (size_t i = 0; i < used; ++i) clear[i] = 0; + return valid; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/identity_restore_service.h b/modules/core_hostlink/include/hostlink/identity_restore_service.h new file mode 100644 index 00000000..73642806 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/identity_restore_service.h @@ -0,0 +1,56 @@ +#pragma once +#include "chat/ports/device_identity_restore.h" +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/identity_restore_request.h" +namespace hostlink::management +{ +struct IdentityRestoreActivity +{ + bool configuration = false, geocaching = false, snapshot = false, upload = false, + prepare = false, publication = false, network = false, restart = false; + bool busy() const { return configuration || geocaching || snapshot || upload || prepare || publication || network || restart; } +}; +struct IdentityRestoreWork +{ + IdentityRestoreView request; + uint8_t crypto[352]{}; +}; +static_assert(sizeof(IdentityRestoreWork) <= 512, "Restore work must remain transient metadata and shared scratch"); +// Caller owns quiescence admission, PSRAM work and command-buffer wiping. +// Response [0|1,nextGeneration]: 0 confirmed storage commit; 1 uncertain write. +// Both require reboot/reconnection and fresh proof; neither means live activation. +template +Status processIdentityRestore(Adapter& adapter, const EnvelopeView& input, const IdentityRestoreActivity& activity, + IdentityRestoreWork& work, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + struct Clear + { + IdentityRestoreWork& work; + ~Clear() + { + volatile uint8_t* data = reinterpret_cast(&work); + for (size_t i = 0; i < sizeof(work); ++i) data[i] = 0; + } + } clear{work}; + if (!input.session || input.code != uint16_t(Operation::IdentityBackupRestore) || !output || capacity < 8) return Status::InvalidField; + if (activity.busy()) return Status::Busy; + const auto decoded = parseIdentityRestoreRequest({input.body, input.body_size}, work.request); + if (decoded != Status::Ok) return decoded; + const auto generation = work.request.expected_generation; + const auto result = adapter.restoreDeviceIdentity(work.request, work.crypto, sizeof(work.crypto)); + using Result = chat::DeviceIdentityRestoreResult; + if (result == Result::CommittedRebootRequired || result == Result::IndeterminateRebootRequired) + { + if (generation == UINT32_MAX) return Status::Internal; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(2) || !writer.unsignedInteger(result == Result::CommittedRebootRequired ? 0 : 1) || !writer.unsignedInteger(generation + 1)) return Status::Internal; + written = writer.size(); + return Status::Ok; + } + return result == Result::StaleIdentity ? Status::StaleIdentity : result == Result::InvalidInput ? Status::InvalidField + : result == Result::StorageError ? Status::StorageUnavailable + : result == Result::Unsupported ? Status::Unsupported + : Status::Busy; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/identity_signing_key_validation.h b/modules/core_hostlink/include/hostlink/identity_signing_key_validation.h new file mode 100644 index 00000000..8dfd1cf9 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/identity_signing_key_validation.h @@ -0,0 +1,34 @@ +#pragma once +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include +#include + +namespace hostlink +{ +// Expanded Ed25519 private keys contain a scalar and nonce prefix, not a seed. +// Validate the scalar's public key directly; no seed recovery or key generation. +// The nonce prefix is secret signing material and has no public-key equivalent. +inline bool validateExpandedSigningKey(const uint8_t* public_key, const uint8_t* private_key, + uint8_t* scratch, size_t capacity) +{ + if (!public_key || !private_key || !scratch || capacity < 32) return false; + const uintptr_t work = reinterpret_cast(scratch); + const uintptr_t pub = reinterpret_cast(public_key); + const uintptr_t priv = reinterpret_cast(private_key); + if ((work >= pub ? work - pub < 32 : pub - work < 32) || + (work >= priv ? work - priv < 64 : priv - work < 32)) return false; + uint8_t nonzero = 0; + for (size_t i = 0; i < 32; ++i) nonzero |= private_key[i]; + bool valid = nonzero != 0; + if (valid) + { + ed25519_derive_pub(scratch, private_key); + uint8_t difference = 0; + for (size_t i = 0; i < 32; ++i) difference |= scratch[i] ^ public_key[i]; + valid = difference == 0; + } + volatile uint8_t* clear = scratch; + for (size_t i = 0; i < 32; ++i) clear[i] = 0; + return valid; +} +} // namespace hostlink diff --git a/modules/core_hostlink/include/hostlink/management_envelope.h b/modules/core_hostlink/include/hostlink/management_envelope.h new file mode 100644 index 00000000..3f5f4449 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/management_envelope.h @@ -0,0 +1,282 @@ +#pragma once + +#include "hostlink_types.h" +#include +#include + +namespace hostlink::management +{ +constexpr uint8_t kVersion = 1; +constexpr size_t kIdBytes = 16; + +enum class Operation : uint16_t +{ + Capabilities = 0, + AuthorInfo = 1, + AuthorProve = 2, + SessionClose = 3, + IdentityBackupExport = 4, + IdentityBackupRestore = 5, + IdentityRestoreFormats = 6, + RadioIdentityInfo = 7, + RadioIdentityBackupExport = 8, + RadioIdentityBackupRestore = 9, + ConfigSchema = 16, + ConfigSnapshot = 17, + ConfigBegin = 18, + ConfigPatch = 19, + ConfigValidate = 20, + ConfigCommit = 21, + ConfigAbort = 22, + ConfigCommitStatus = 23, + TransferBegin = 32, + TransferChunk = 33, + TransferStatus = 34, + TransferFinish = 35, + TransferAbort = 36, + TransferCleanup = 37, + TransferListOwned = 38, + JobGet = 48, + JobList = 49, + JobCancel = 50, + CacheListOwned = 64, + CacheDraftGet = 65, + CacheDraftPut = 66, + CachePrepare = 67, + CacheCommit = 68, + CacheHistory = 69, + CacheDirectoryList = 70, + CacheSignBegin = 71, + CacheSignStatus = 72, + CacheSignRead = 73, + CacheSignRelease = 74, + CacheConfirmBegin = 75, + CacheConfirmStatus = 76, + CacheConfirmRelease = 77, + TrackList = 80, + TrackSnapshot = 81, + TrackPrepare = 82, + TrackCommit = 83, + TrackHistory = 84, + RouteInstall = 85, + TrackSnapshotBuild = 86, + TrackSnapshotCancel = 87, + TrackSnapshotRelease = 88, + TrackDirectoryList = 89, + TrackDirectoryDiscover = 90, + TrackReadBegin = 91, + TrackReadStatus = 92, + TrackReadChunk = 93, + TrackReadRelease = 94, + RouteInstallStatus = 95, + TrackSignPrepare = 96, + TrackConfirmPublication = 97, + TrackImportHistory = 98, + TrackPackageSignPrepare = 99, + TrackPackageInstall = 100, + TrackPackageInstallStatus = 101, +}; + +enum class Status : uint16_t +{ + Ok = 0, + Unsupported = 1, + Unauthorized = 2, + InvalidField = 3, + Conflict = 4, + Busy = 5, + StorageUnavailable = 6, + LimitExceeded = 7, + HashMismatch = 8, + StaleIdentity = 9, + Expired = 10, + Internal = 11, +}; + +// Borrowed spans: the frame owner must keep the backing bytes until dispatch +// completes. No queues may retain these pointers after the frame is reused. +struct EnvelopeView +{ + const uint8_t* session = nullptr; + const uint8_t* request = nullptr; + uint16_t code = 0; + const uint8_t* body = nullptr; + size_t body_size = 0; +}; + +namespace detail +{ +class Cursor +{ + public: + Cursor(const uint8_t* bytes, size_t size) : bytes_(bytes), size_(size) {} + size_t position() const { return offset_; } + bool byte(uint8_t& out) + { + if (offset_ >= size_) return false; + out = bytes_[offset_++]; + return true; + } + bool number(unsigned width, uint64_t& out) + { + if (width > size_ - offset_) return false; + out = 0; + for (unsigned i = 0; i < width; ++i) out = (out << 8) | bytes_[offset_++]; + return true; + } + bool unsignedValue(uint64_t& out) + { + uint8_t tag; + if (!byte(tag)) return false; + if (tag <= 127) + { + out = tag; + return true; + } + if (tag < 0xcc || tag > 0xcf) return false; + const unsigned width = 1U << (tag - 0xcc); + return number(width, out) && out >= (width == 1 ? 128ULL : 1ULL << (width * 4)); + } + bool id(const uint8_t*& out) + { + uint8_t tag, length; + if (!byte(tag) || tag != 0xc4 || !byte(length) || length != kIdBytes || size_ - offset_ < kIdBytes) return false; + out = bytes_ + offset_; + offset_ += kIdBytes; + return true; + } + bool object(unsigned depth = 0) + { + if (depth > 8 || ++elements_ > 128) return false; + uint8_t tag; + if (!byte(tag)) return false; + if (tag <= 127 || tag >= 0xe0 || tag == 0xc0 || tag == 0xc2 || tag == 0xc3) return true; + uint64_t n = 0; + if (tag >= 0xcc && tag <= 0xcf) + { + const unsigned width = 1U << (tag - 0xcc); + return number(width, n) && n >= (width == 1 ? 128ULL : 1ULL << (width * 4)); + } + if (tag >= 0xd0 && tag <= 0xd3) + { + const unsigned width = 1U << (tag - 0xd0); + if (!number(width, n) || !(n & (1ULL << (width * 8 - 1)))) return false; + const uint64_t mask = width == 8 ? UINT64_MAX : (1ULL << (width * 8)) - 1; + const int64_t value = -1 - static_cast((~n) & mask); + return value < (width == 1 ? -32LL : -(1LL << (width * 4 - 1))); + } + if ((tag & 0xf0) == 0x90) n = tag & 15; + else if (tag == 0xdc || tag == 0xdd) + { + if (!number(tag == 0xdc ? 2 : 4, n) || n < (tag == 0xdc ? 16ULL : 65536ULL)) return false; + } + else + { + bool text = false; + if ((tag & 0xe0) == 0xa0) + { + text = true; + n = tag & 31; + } + else if (tag >= 0xd9 && tag <= 0xdb) + { + text = true; + const unsigned width = 1U << (tag - 0xd9); + if (!number(width, n) || n < (width == 1 ? 32ULL : 1ULL << (width * 4))) return false; + } + else if (tag >= 0xc4 && tag <= 0xc6) + { + const unsigned width = 1U << (tag - 0xc4); + if (!number(width, n) || (width > 1 && n < (1ULL << (width * 4)))) return false; + } + else return false; // maps, floats, bool and extension objects are not CMP1. + if (n > size_ - offset_) return false; + if (text && !utf8(bytes_ + offset_, static_cast(n))) return false; + offset_ += static_cast(n); + return true; + } + if (n > 128 - elements_) return false; + for (uint64_t i = 0; i < n; ++i) + if (!object(depth + 1)) return false; + return true; + } + + private: + static bool utf8(const uint8_t* bytes, size_t size) + { + size_t i = 0; + while (i < size) + { + const uint8_t first = bytes[i++]; + if (first < 128) continue; + const unsigned count = first >= 0xc2 && first <= 0xdf ? 1 : first >= 0xe0 && first <= 0xef ? 2 + : first >= 0xf0 && first <= 0xf4 ? 3 + : 0; + if (!count || size - i < count) return false; + if ((first == 0xe0 && bytes[i] < 0xa0) || (first == 0xed && bytes[i] >= 0xa0) || + (first == 0xf0 && bytes[i] < 0x90) || (first == 0xf4 && bytes[i] >= 0x90)) return false; + for (unsigned j = 0; j < count; ++j) + if ((bytes[i++] & 0xc0) != 0x80) return false; + } + return true; + } + const uint8_t* bytes_; + size_t size_, offset_ = 0, elements_ = 0; +}; +} // namespace detail + +inline bool decodeEnvelope(const uint8_t* bytes, size_t size, EnvelopeView& out) +{ + out = {}; + if (!bytes || size > kMaxFrameLen) return false; + detail::Cursor cursor(bytes, size); + uint8_t array, version; + uint64_t code; + EnvelopeView candidate; + if (!cursor.byte(array) || array != 0x95 || !cursor.byte(version) || version != kVersion || + !cursor.id(candidate.session) || !cursor.id(candidate.request) || + !cursor.unsignedValue(code) || code > UINT16_MAX) return false; + candidate.code = static_cast(code); + candidate.body = bytes + cursor.position(); + candidate.body_size = size - cursor.position(); + if (!cursor.object() || cursor.position() != size) return false; + out = candidate; + return true; +} + +inline bool encodeEnvelope(const uint8_t session[kIdBytes], const uint8_t request[kIdBytes], + uint16_t code, const uint8_t* body, size_t body_size, + uint8_t* out, size_t capacity, size_t& written) +{ + written = 0; + const size_t prefix = 38 + (code <= 127 ? 1 : code <= 255 ? 2 + : 3); + if (!session || !request || !body || !out || body_size > kMaxFrameLen - prefix || capacity < prefix + body_size) return false; + detail::Cursor cursor(body, body_size); + if (!cursor.object() || cursor.position() != body_size) return false; + // IDs must be independent of the output arena; body may overlap it. + std::memmove(out + prefix, body, body_size); + out[0] = 0x95; + out[1] = kVersion; + out[2] = 0xc4; + out[3] = kIdBytes; + std::memcpy(out + 4, session, kIdBytes); + out[20] = 0xc4; + out[21] = kIdBytes; + std::memcpy(out + 22, request, kIdBytes); + if (code <= 127) out[38] = static_cast(code); + else if (code <= 255) + { + out[38] = 0xcc; + out[39] = static_cast(code); + } + else + { + out[38] = 0xcd; + out[39] = static_cast(code >> 8); + out[40] = static_cast(code); + } + written = prefix + body_size; + return true; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/object_transfer.h b/modules/core_hostlink/include/hostlink/object_transfer.h new file mode 100644 index 00000000..307df86a --- /dev/null +++ b/modules/core_hostlink/include/hostlink/object_transfer.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include +#include + +namespace hostlink::management +{ +enum class TransferResult : uint8_t +{ + Ok, + Busy, + Invalid, + WrongOwner, + Conflict, + StorageFailure, + HashMismatch, + NotFound, +}; + +struct TransferDescriptor +{ + uint8_t owner[16]{}; + uint8_t id[16]{}; + uint8_t sha256[32]{}; + uint32_t bytes = 0; + uint16_t kind = 0; +}; + +// Platform storage owns its file/hash context. No whole-file buffer is allowed. +// Each append must be durable before returning true. On failure it must truncate +// back to the previous durable offset so a retry cannot append the bytes twice. +class TransferStorage +{ + public: + virtual ~TransferStorage() = default; + virtual bool begin(const TransferDescriptor& descriptor) = 0; + virtual bool append(uint32_t offset, const uint8_t* bytes, size_t length) = 0; + virtual bool matches(uint32_t offset, const uint8_t* bytes, size_t length) = 0; + virtual bool verify(const uint8_t expected_sha256[32], uint32_t length) = 0; + virtual bool seal() = 0; + virtual bool discard() = 0; +}; + +// Single-slot coordinator: metadata only, no payload buffers, queues or tasks. +// Owner is the authorised device session, not a USB device/vendor ID. The +// platform may restore a descriptor/offset from its validated storage journal. +class ObjectTransfer +{ + public: + static constexpr size_t kChunkBytes = 384; + explicit ObjectTransfer(TransferStorage& storage) : storage_(storage) {} + uint32_t offset() const { return offset_; } + bool active() const { return active_; } + bool sealed() const { return sealed_; } + const TransferDescriptor& descriptor() const { return descriptor_; } + + TransferResult begin(const TransferDescriptor& descriptor, uint32_t limit) + { + if (!descriptor.bytes || descriptor.bytes > limit) return TransferResult::Invalid; + if (active_) + { + if (std::memcmp(descriptor.owner, descriptor_.owner, 16)) return TransferResult::WrongOwner; + return same(descriptor) ? TransferResult::Ok : TransferResult::Busy; + } + if (!storage_.begin(descriptor)) return TransferResult::StorageFailure; + descriptor_ = descriptor; + offset_ = 0; + active_ = true; + sealed_ = false; + return TransferResult::Ok; + } + TransferResult append(const uint8_t owner[16], const uint8_t id[16], uint32_t offset, + const uint8_t* bytes, size_t length) + { + const auto auth = check(owner, id); + if (auth != TransferResult::Ok) return auth; + if (!bytes || !length || length > kChunkBytes || offset > descriptor_.bytes || + length > descriptor_.bytes - offset) return TransferResult::Invalid; + if (offset < offset_) + { + if (length > offset_ - offset) return TransferResult::Conflict; + return storage_.matches(offset, bytes, length) ? TransferResult::Ok : TransferResult::Conflict; + } + if (sealed_ || offset != offset_) return TransferResult::Conflict; + if (!storage_.append(offset, bytes, length)) return TransferResult::StorageFailure; + offset_ += static_cast(length); + return TransferResult::Ok; + } + TransferResult finish(const uint8_t owner[16], const uint8_t id[16]) + { + const auto auth = check(owner, id); + if (auth != TransferResult::Ok) return auth; + if (sealed_) return TransferResult::Ok; + if (offset_ != descriptor_.bytes) return TransferResult::Conflict; + if (!storage_.verify(descriptor_.sha256, descriptor_.bytes)) return TransferResult::HashMismatch; + if (!storage_.seal()) return TransferResult::StorageFailure; + sealed_ = true; + return TransferResult::Ok; + } + TransferResult abort(const uint8_t owner[16], const uint8_t id[16]) + { + const auto auth = check(owner, id); + if (auth != TransferResult::Ok) return auth; + if (!storage_.discard()) return TransferResult::StorageFailure; + active_ = sealed_ = false; + offset_ = 0; + descriptor_ = {}; + return TransferResult::Ok; + } + // Called only after the platform authenticates and validates its durable + // journal/file. A remote client cannot assert a restored offset itself. + bool restore(const TransferDescriptor& descriptor, uint32_t durable_offset, bool sealed) + { + if (active_ || !descriptor.bytes || durable_offset > descriptor.bytes || + (sealed && durable_offset != descriptor.bytes)) return false; + descriptor_ = descriptor; + offset_ = durable_offset; + active_ = true; + sealed_ = sealed; + return true; + } + + private: + bool same(const TransferDescriptor& other) const + { + return !std::memcmp(other.id, descriptor_.id, 16) && + !std::memcmp(other.sha256, descriptor_.sha256, 32) && + other.bytes == descriptor_.bytes && other.kind == descriptor_.kind; + } + TransferResult check(const uint8_t* owner, const uint8_t* id) const + { + if (!active_) return TransferResult::NotFound; + if (!owner || std::memcmp(owner, descriptor_.owner, 16)) return TransferResult::WrongOwner; + if (!id || std::memcmp(id, descriptor_.id, 16)) return TransferResult::NotFound; + return TransferResult::Ok; + } + TransferStorage& storage_; + TransferDescriptor descriptor_{}; + uint32_t offset_ = 0; + bool active_ = false, sealed_ = false; +}; +static_assert(sizeof(ObjectTransfer) <= 96, "Transfer metadata must not grow into payload storage"); +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/publication_ticket.h b/modules/core_hostlink/include/hostlink/publication_ticket.h new file mode 100644 index 00000000..92e8b23b --- /dev/null +++ b/modules/core_hostlink/include/hostlink/publication_ticket.h @@ -0,0 +1,127 @@ +#pragma once + +#include "hostlink/hostlink_codec.h" +#include "hostlink/management_envelope.h" +#include + +namespace hostlink::management +{ +// Private SD record, never an arbitrary payload accepted for device signing. +// All arrays are borrowed; encoding uses the existing command arena. +struct PublicationTicketFields +{ + const uint8_t *owner, *token, *boot, *draft, *author, *directory, *directory_epoch, *content_hash; + uint64_t draft_generation; + uint32_t author_generation, from_revision, to_revision; + uint64_t created_ms; + uint32_t lifetime_ms; +}; +constexpr size_t kPublicationTicketBytes = 231; +constexpr uint32_t kPublicationTicketLifetimeMs = 120000; +// Accepted describes local operation admission, never a directory receipt. +enum class PublicationTicketPhase : uint8_t +{ + Prepared = 0, + Intent = 1, + Accepted = 2, + Checking = 3 // Network validation pending; cannot authorize COMMIT. +}; + +inline bool validatePublicationTicket(const uint8_t* bytes, size_t size) +{ + if (!bytes || size != kPublicationTicketBytes || std::memcmp(bytes, "TMP1", 4) || bytes[4] > 3) return false; + const auto crc = crc16_ccitt(bytes, size - 2); + if (bytes[size - 2] != static_cast(crc) || bytes[size - 1] != static_cast(crc >> 8)) return false; + uint64_t generation = 0; + uint32_t author_generation = 0, from = 0, to = 0, lifetime = 0; + for (unsigned i = 0; i < 8; ++i) generation |= uint64_t(bytes[69 + i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) + { + author_generation |= uint32_t(bytes[77 + i]) << (8 * i); + from |= uint32_t(bytes[209 + i]) << (8 * i); + to |= uint32_t(bytes[213 + i]) << (8 * i); + lifetime |= uint32_t(bytes[225 + i]) << (8 * i); + } + return generation && author_generation && lifetime && lifetime <= kPublicationTicketLifetimeMs && to && + (to == from || (from != UINT32_MAX && to == from + 1)); +} + +inline bool encodePublicationTicket(const PublicationTicketFields& fields, uint8_t* output, size_t capacity, + PublicationTicketPhase phase = PublicationTicketPhase::Prepared) +{ + if (!output || capacity < kPublicationTicketBytes || !fields.owner || !fields.token || !fields.boot || + !fields.draft || !fields.author || !fields.directory || !fields.directory_epoch || !fields.content_hash || + (phase != PublicationTicketPhase::Prepared && phase != PublicationTicketPhase::Checking)) return false; + // Inputs must not overlap output: callers encode only after request views have been consumed. + std::memcpy(output, "TMP1", 4); + output[4] = static_cast(phase); + std::memcpy(output + 5, fields.owner, 16); + std::memcpy(output + 21, fields.token, 16); + std::memcpy(output + 37, fields.boot, 16); + std::memcpy(output + 53, fields.draft, 16); + for (unsigned i = 0; i < 8; ++i) + { + output[69 + i] = static_cast(fields.draft_generation >> (8 * i)); + output[217 + i] = static_cast(fields.created_ms >> (8 * i)); + } + for (unsigned i = 0; i < 4; ++i) + { + output[77 + i] = static_cast(fields.author_generation >> (8 * i)); + output[209 + i] = static_cast(fields.from_revision >> (8 * i)); + output[213 + i] = static_cast(fields.to_revision >> (8 * i)); + output[225 + i] = static_cast(fields.lifetime_ms >> (8 * i)); + } + std::memcpy(output + 81, fields.author, 64); + std::memcpy(output + 145, fields.directory, 16); + std::memcpy(output + 161, fields.directory_epoch, 16); + std::memcpy(output + 177, fields.content_hash, 32); + const auto crc = crc16_ccitt(output, kPublicationTicketBytes - 2); + output[kPublicationTicketBytes - 2] = static_cast(crc); + output[kPublicationTicketBytes - 1] = static_cast(crc >> 8); + return validatePublicationTicket(output, kPublicationTicketBytes); +} + +inline Status matchPublicationTicket(const uint8_t* bytes, size_t size, const uint8_t owner[16], + const uint8_t token[16], const uint8_t boot[16], const uint8_t author[64], + uint32_t author_generation, uint64_t now_ms, bool for_commit) +{ + if (!validatePublicationTicket(bytes, size) || !owner || !token || !boot || !author) return Status::InvalidField; + if (std::memcmp(bytes + 5, owner, 16) || std::memcmp(bytes + 21, token, 16)) return Status::Unauthorized; + uint32_t generation = 0, lifetime = 0; + uint64_t created = 0; + for (unsigned i = 0; i < 8; ++i) created |= uint64_t(bytes[217 + i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) + { + generation |= uint32_t(bytes[77 + i]) << (8 * i); + lifetime |= uint32_t(bytes[225 + i]) << (8 * i); + } + if (std::memcmp(bytes + 81, author, 64) || generation != author_generation) return Status::StaleIdentity; + if (std::memcmp(bytes + 37, boot, 16)) return Status::Conflict; + // Intent/accepted records are queryable after expiry, but cannot authorize a new commit. + if (for_commit && bytes[4] != static_cast(PublicationTicketPhase::Prepared)) return Status::Conflict; + if ((for_commit || bytes[4] == 0 || bytes[4] == 3) && (now_ms < created || now_ms - created >= lifetime)) return Status::Expired; + return Status::Ok; +} + +inline bool advancePublicationTicket(uint8_t* bytes, size_t size, PublicationTicketPhase next) +{ + if (!validatePublicationTicket(bytes, size) || bytes[4] == 3) return false; + const auto phase = static_cast(next); + if (phase > 2 || phase < bytes[4] || phase > bytes[4] + 1) return false; + bytes[4] = phase; + const auto crc = crc16_ccitt(bytes, size - 2); + bytes[size - 2] = static_cast(crc); + bytes[size - 1] = static_cast(crc >> 8); + return true; +} +inline bool completePublicationCheck(uint8_t* bytes, size_t size, uint64_t now_ms) +{ + if (!validatePublicationTicket(bytes, size) || bytes[4] != 3) return false; + bytes[4] = 0; + for (unsigned i = 0; i < 8; ++i) bytes[217 + i] = static_cast(now_ms >> (8 * i)); + const auto crc = crc16_ccitt(bytes, size - 2); + bytes[size - 2] = static_cast(crc); + bytes[size - 1] = static_cast(crc >> 8); + return true; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/radio_identity_backup.h b/modules/core_hostlink/include/hostlink/radio_identity_backup.h new file mode 100644 index 00000000..6ee51682 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/radio_identity_backup.h @@ -0,0 +1,168 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "hostlink/identity_restore_service.h" +#include "hostlink/identity_signing_key_validation.h" + +namespace hostlink::management +{ +inline constexpr char kRadioIdentityBackupDomain[] = "TrailMate.Radio.Identity.Backup.v1"; +inline constexpr char kRadioIdentityBackupType[] = "trailmate.radio.identity.backup.v1"; +struct RadioIdentityRestoreView +{ + uint32_t expected_author_generation = 0, expected_radio_generation = 0, source_author_generation = 0, source_radio_generation = 0; + uint8_t kind = 0; + geocaching::ByteView expected_author{}, expected_radio_public{}, payload{}, signature{}, public_key{}, private_key{}, source_author{}, export_session{}, export_request{}; +}; +inline Status parseRadioIdentityBackupPayload(geocaching::ByteView bytes, RadioIdentityRestoreView& parsed) +{ + if (!bytes.data || bytes.size > 320) return Status::InvalidField; + geocaching::protocol::CmpReader payload(bytes); + std::string_view type; + size_t fields = 0; + uint64_t value = 0; + if (!payload.array(fields, 10) || fields != 10 || !payload.unsignedInteger(value) || value != 1 || + !payload.text(type, 40) || type != kRadioIdentityBackupType || !payload.unsignedInteger(value) || value < 1 || value > 2) return Status::InvalidField; + parsed.kind = uint8_t(value); + if (!payload.binary(parsed.public_key, 32) || parsed.public_key.size != 32 || + !payload.binary(parsed.private_key, 64) || parsed.private_key.size != (parsed.kind == 1 ? 64U : 32U) || + !payload.binary(parsed.source_author, 64) || parsed.source_author.size != 64 || + !payload.unsignedInteger(value) || !value || value > UINT32_MAX) return Status::InvalidField; + parsed.source_author_generation = uint32_t(value); + if (!payload.unsignedInteger(value) || value > UINT32_MAX || !payload.binary(parsed.export_session, 16) || parsed.export_session.size != 16 || + !payload.binary(parsed.export_request, 16) || parsed.export_request.size != 16 || !payload.finished()) return Status::InvalidField; + parsed.source_radio_generation = uint32_t(value); + parsed.payload = bytes; + return Status::Ok; +} +inline bool radioIdentityBackupPayloadMatches(const RadioIdentityRestoreView& request) +{ + RadioIdentityRestoreView decoded; + if (parseRadioIdentityBackupPayload(request.payload, decoded) != Status::Ok) return false; + const auto equal = [](geocaching::ByteView a, geocaching::ByteView b) + { return a.data && b.data && a.size == b.size && !std::memcmp(a.data, b.data, a.size); }; + return request.kind == decoded.kind && request.source_author_generation == decoded.source_author_generation && request.source_radio_generation == decoded.source_radio_generation && + equal(request.public_key, decoded.public_key) && equal(request.private_key, decoded.private_key) && equal(request.source_author, decoded.source_author) && + equal(request.export_session, decoded.export_session) && equal(request.export_request, decoded.export_request); +} +inline Status parseRadioIdentityRestoreRequest(geocaching::ByteView input, RadioIdentityRestoreView& output) +{ + output = {}; + if (!input.data || input.size > 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader(input); + RadioIdentityRestoreView parsed; + size_t fields = 0; + uint64_t value = 0; + if (!reader.array(fields, 7) || fields != 7 || !reader.unsignedInteger(value) || !value || value > UINT32_MAX) return Status::InvalidField; + parsed.expected_author_generation = uint32_t(value); + if (!reader.binary(parsed.expected_author, 64) || parsed.expected_author.size != 64 || + !reader.binary(parsed.payload, 320) || !reader.binary(parsed.signature, 64) || parsed.signature.size != 64 || + !reader.unsignedInteger(value) || value >= UINT32_MAX) return Status::InvalidField; + parsed.expected_radio_generation = uint32_t(value); + if (reader.position() >= input.size) return Status::InvalidField; + if (input.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return Status::InvalidField; + } + else if (!reader.binary(parsed.expected_radio_public, 32) || parsed.expected_radio_public.size != 32) return Status::InvalidField; + if (reader.position() + 1 != input.size) return Status::InvalidField; + if (input.data[reader.position()] == 0xc2) return Status::Unauthorized; + if (input.data[reader.position()] != 0xc3) return Status::InvalidField; + if (parseRadioIdentityBackupPayload(parsed.payload, parsed) != Status::Ok) return Status::InvalidField; + output = parsed; + return Status::Ok; +} +inline bool verifyRadioIdentityRestoreSignature(const RadioIdentityRestoreView& request, uint8_t* work, size_t capacity) +{ + const size_t length = sizeof(kRadioIdentityBackupDomain) + request.payload.size; + if (!work || !request.payload.data || request.payload.size > 320 || capacity < length || !request.source_author.data || request.source_author.size != 64 || !request.signature.data || request.signature.size != 64 || + !radioIdentityBackupPayloadMatches(request)) return false; + const auto start = reinterpret_cast(work); + for (const auto view : {request.payload, request.source_author, request.signature}) + { + const auto input = reinterpret_cast(view.data); + if (start >= input ? start - input < view.size : input - start < length) return false; + } + std::memcpy(work, kRadioIdentityBackupDomain, sizeof(kRadioIdentityBackupDomain)); + std::memcpy(work + sizeof(kRadioIdentityBackupDomain), request.payload.data, request.payload.size); + const bool valid = ed25519_verify(request.signature.data, work, length, request.source_author.data + 32) != 0; + volatile uint8_t* clear = work; + for (size_t i = 0; i < length; ++i) clear[i] = 0; + return valid; +} +struct RadioIdentityRestoreWork +{ + RadioIdentityRestoreView request; + uint8_t crypto[336]{}; +}; +static_assert(sizeof(RadioIdentityRestoreWork) <= 512, "RF restore scratch must be transient and bounded"); +template +Status processRadioIdentityRestore(Adapter& adapter, const EnvelopeView& input, const IdentityRestoreActivity& activity, + RadioIdentityRestoreWork& work, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + struct Clear + { + RadioIdentityRestoreWork& work; + ~Clear() + { + volatile uint8_t* bytes = reinterpret_cast(&work); + for (size_t i = 0; i < sizeof(work); ++i) bytes[i] = 0; + } + } clear{work}; + if (!input.session || input.code != uint16_t(Operation::RadioIdentityBackupRestore) || !output || capacity < 48) return Status::InvalidField; + if (activity.busy()) return Status::Busy; + const auto decoded = parseRadioIdentityRestoreRequest({input.body, input.body_size}, work.request); + if (decoded != Status::Ok) return decoded; + const auto result = adapter.restoreRadioIdentity(work.request, work.crypto, sizeof(work.crypto)); + using Result = chat::DeviceIdentityRestoreResult; + if (result == Result::CommittedRebootRequired || result == Result::IndeterminateRebootRequired) + { + // The response can overwrite the input command only after storage is done. + std::memcpy(work.crypto, work.request.public_key.data, 32); + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(4) || !writer.unsignedInteger(result == Result::CommittedRebootRequired ? 0 : 1) || + !writer.unsignedInteger(work.request.kind) || !writer.unsignedInteger(work.request.expected_radio_generation + 1) || + !writer.binary({work.crypto, 32})) return Status::Internal; + written = writer.size(); + return Status::Ok; + } + return result == Result::StaleIdentity ? Status::StaleIdentity : result == Result::StaleRadioIdentity ? Status::Conflict + : result == Result::InvalidInput ? Status::InvalidField + : result == Result::StorageError ? Status::StorageUnavailable + : result == Result::Unsupported ? Status::Unsupported + : Status::Busy; +} +template +Status radioIdentityReadOperation(const Adapter& adapter, const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + const bool exporting = request.code == uint16_t(Operation::RadioIdentityBackupExport); + if (!request.session || (!exporting && request.code != uint16_t(Operation::RadioIdentityInfo)) || !output || capacity < 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t generation = 0, kind = 0; + if (!reader.array(fields, 3) || fields != (exporting ? 3U : 2U) || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.unsignedInteger(kind) || kind < 1 || kind > 2) return Status::InvalidField; + if (exporting) + { + if (reader.position() + 1 != request.body_size) return Status::InvalidField; + const auto consent = request.body[reader.position()]; + if (consent == 0xc2) return Status::Unauthorized; + if (consent != 0xc3) return Status::InvalidField; + } + else if (!reader.finished()) return Status::InvalidField; + if (exporting) + { + if (!request.request) return Status::InvalidField; + std::memmove(output + capacity - 32, request.session, 16); + std::memmove(output + capacity - 16, request.request, 16); + } + const auto result = exporting ? adapter.exportRadioIdentityBackup(uint8_t(kind), uint32_t(generation), output, capacity, written, output + capacity - 32, output + capacity - 16) : adapter.readRadioIdentityInfo(uint8_t(kind), uint32_t(generation), output, capacity, written); + using Result = chat::DeviceAuthorResult; + return result == Result::Ok ? Status::Ok : result == Result::StaleIdentity ? Status::StaleIdentity + : result == Result::StorageError ? Status::StorageUnavailable + : result == Result::Unsupported ? Status::Unsupported + : result == Result::InvalidInput ? Status::InvalidField + : Status::Busy; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_authorization.h b/modules/core_hostlink/include/hostlink/route_install_authorization.h new file mode 100644 index 00000000..ce246b11 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_authorization.h @@ -0,0 +1,32 @@ +#pragma once +#include "hostlink/route_install_request.h" +#include "track_sharing/upload_journal.h" +#include "track_sharing/upload_paths.h" +namespace hostlink::management +{ +// Platform supplies session_matches from its current upload lease and +// media_ready from the same SD media session. Neither fact comes from USB. +// CRC-valid metadata alone is never sufficient authorization or file proof. +inline Status authorizeRouteInstallUpload(const RouteInstallView& request, + const track_sharing::UploadJournal& journal, + track_sharing::UploadKind kind, + uint32_t generation, + geocaching::ByteView author_hash, + bool session_matches, + bool media_ready) +{ + if (!request.transfer.data || request.transfer.size != 16 || !request.hash.data || request.hash.size != 32 || + !request.bytes || request.bytes > 8388608 || !request.generation || request.generation >= UINT32_MAX || + kind != track_sharing::UploadKind::Kml) return Status::InvalidField; + if (!session_matches) return Status::Unauthorized; + if (!author_hash.data || author_hash.size != 32 || !generation || generation >= UINT32_MAX || + request.generation != generation) return Status::StaleIdentity; + if (!media_ready) return Status::StorageUnavailable; + if (!track_sharing::uploadJournalValid(journal)) return Status::InvalidField; + if (journal.generation != generation || std::memcmp(journal.author_hash, author_hash.data, 32)) return Status::StaleIdentity; + if (journal.phase != track_sharing::UploadPhase::Sealed || journal.total != request.bytes || + std::memcmp(journal.id, request.transfer.data, 16)) return Status::Conflict; + if (std::memcmp(journal.file_hash, request.hash.data, 32)) return Status::HashMismatch; + return Status::Ok; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_commit.h b/modules/core_hostlink/include/hostlink/route_install_commit.h new file mode 100644 index 00000000..8c693bf1 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_commit.h @@ -0,0 +1,50 @@ +#pragma once +#include "hostlink/route_install_request.h" +#include "track_sharing/upload_paths.h" +namespace hostlink::management +{ +// Caller lends disjoint 64/77-byte canonical path slots and holds the immutable +// source lease. Guard reads current identity, upload owner, media and active-file +// restrictions. This function never copies bytes or replaces an existing route. +// Ready file proof must match this exact canonical source, length and hash. +template +Status publishValidatedRoute(Io& io, const Check& check, const RouteInstallView& request, + const char* source, char* target, Guard&& guard) +{ + if (!source || !target) return Status::InvalidField; + const auto overlaps = [&](const void* input, size_t size) + { + if (!input || !size) return false; + const auto a = reinterpret_cast(input), b = reinterpret_cast(target); + return a <= b ? b - a < size : a - b < 77; + }; + if (overlaps(source, 64) || overlaps(request.transfer.data, request.transfer.size) || overlaps(request.hash.data, request.hash.size) || + overlaps(&request, sizeof(request)) || overlaps(&check, sizeof(check)) || overlaps(&io, sizeof(io))) return Status::InvalidField; + // Generate the canonical source into target scratch and compare; never + // rewrite the path retained by the completed validation job. + if (!track_sharing::resolveUploadPath(request.transfer.data, request.transfer.size, track_sharing::UploadFile::Candidate, target, 77) || + std::strncmp(source, target, 64) || !check.matchesSource(source, request.bytes, request.hash.data) || + !routeInstallTarget(request.hash, target, 77)) return Status::InvalidField; + auto allowed = guard(); + if (allowed != Status::Ok) return allowed; + const auto destination = io.open(target, Io::Mode::Read); + if (destination.handle) + { + if (!io.close(destination.handle)) return Status::StorageUnavailable; + // Existing is not proof of installation: recovery must verify its bytes. + return Status::Conflict; + } + if (destination.status != Io::OpenStatus::Missing) return Status::StorageUnavailable; + const auto original = io.open(source, Io::Mode::Update); + if (!original.handle) return Status::StorageUnavailable; + uint64_t bytes = 0; + const bool synced = io.size(original.handle, bytes) && bytes == request.bytes && io.sync(original.handle); + const bool closed = io.close(original.handle); + if (!synced || !closed) return Status::StorageUnavailable; + allowed = guard(); + if (allowed != Status::Ok) return allowed; + // Failure may follow a completed disk mutation. Do not report rejection or + // roll back/remove a possibly installed route. Retain binding for recovery. + return io.publish(source, target) ? Status::Ok : Status::Internal; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_directory.h b/modules/core_hostlink/include/hostlink/route_install_directory.h new file mode 100644 index 00000000..c4fc826e --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_directory.h @@ -0,0 +1,17 @@ +#pragma once +#include "hostlink/management_envelope.h" +namespace hostlink::management +{ +// Port.mkdir must enforce expected_session while holding the filesystem lock. +// Only installation calls this; status queries never create directories. +template +Status ensureRouteInstallDirectory(Port& port, uint32_t expected_session) +{ + const auto ready = [&] + { return port.session() == expected_session && port.ready() && !port.externalOwner(); }; + if (!ready()) return Status::StorageUnavailable; + if (port.directory("/routes", expected_session)) return ready() ? Status::Ok : Status::StorageUnavailable; + if (!ready() || !port.mkdir("/routes", expected_session)) return Status::StorageUnavailable; + return ready() && port.directory("/routes", expected_session) ? Status::Ok : Status::StorageUnavailable; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_job.h b/modules/core_hostlink/include/hostlink/route_install_job.h new file mode 100644 index 00000000..3ab4b4d7 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_job.h @@ -0,0 +1,133 @@ +#pragma once +#include "hostlink/route_install_commit.h" +#include "track_sharing/route_file_check.h" +namespace hostlink::management +{ +enum class RouteInstallPhase : uint8_t +{ + Idle, + Source, + Existing, + Readback, + Installed, + Missing, + Failed +}; +// Platform owns the immutable upload lease, copied request bindings, canonical +// source/target slots, exclusive hash context and XML names. Allocate this large +// object in transient PSRAM on ESP, never on the scheduler/USB task stack. +template +class RouteInstallJob +{ + public: + RouteInstallJob(Io& io, Hash& hash, void* names, size_t capacity, + const RouteInstallView& request, const char* source, char* target) + : io_(io), request_(request), source_(source), target_(target), kml_(io, hash, names, capacity), final_(io, hash) {} + template + Status begin(bool install, Guard&& guard) + { + if (phase_ != RouteInstallPhase::Idle) return Status::Busy; + const auto allowed = guard(); + if (allowed != Status::Ok) return fail(allowed); + if (!source_ || !target_ || !request_.transfer.data || request_.transfer.size != 16 || !request_.hash.data || request_.hash.size != 32 || + !request_.bytes || request_.bytes > 8388608 || !request_.generation || request_.generation >= UINT32_MAX) return fail(Status::InvalidField); + const auto overlaps = [&](const void* data, size_t size) + { + const auto a = reinterpret_cast(data), b = reinterpret_cast(target_); + return a <= b ? b - a < size : a - b < 77; + }; + if (overlaps(source_, 64) || overlaps(request_.transfer.data, 16) || overlaps(request_.hash.data, 32) || overlaps(this, sizeof(*this)) || overlaps(&io_, sizeof(io_))) return fail(Status::InvalidField); + if (!track_sharing::resolveUploadPath(request_.transfer.data, 16, track_sharing::UploadFile::Candidate, target_, 77) || + std::strncmp(source_, target_, 64) || !routeInstallTarget(request_.hash, target_, 77)) return fail(Status::InvalidField); + const auto existing = io_.open(target_, Io::Mode::Read); + if (existing.handle) + { + if (!io_.close(existing.handle)) return fail(Status::StorageUnavailable); + phase_ = RouteInstallPhase::Existing; + } + else + { + if (existing.status != Io::OpenStatus::Missing) return fail(Status::StorageUnavailable); + if (!install) + { + phase_ = RouteInstallPhase::Missing; + return status_ = Status::Ok; + } + phase_ = RouteInstallPhase::Source; + } + const auto begun = kml_.begin(phase_ == RouteInstallPhase::Existing ? target_ : source_, request_.bytes, request_.hash.data); + return begun == track_sharing::FileIntegrityResult::Pending ? status_ = Status::Busy : fail(map(begun)); + } + template + Status step(uint8_t* scratch, size_t capacity, Guard&& guard) + { + if (phase_ == RouteInstallPhase::Failed) return status_; + const auto allowed = guard(); + if (allowed != Status::Ok) + { + kml_.cancel(); + final_.cancel(); + return fail(allowed); + } + if (phase_ == RouteInstallPhase::Installed || phase_ == RouteInstallPhase::Missing) return status_; + if (phase_ == RouteInstallPhase::Idle) return Status::InvalidField; + if (phase_ == RouteInstallPhase::Readback) + { + const auto result = final_.step(scratch, capacity); + if (result == track_sharing::FileIntegrityResult::Pending) return Status::Busy; + if (result != track_sharing::FileIntegrityResult::Ready) return fail(map(result)); + phase_ = RouteInstallPhase::Installed; + return status_ = Status::Ok; + } + const auto checked = kml_.step(scratch, capacity); + if (checked == track_sharing::FileIntegrityResult::Pending) return Status::Busy; + if (checked != track_sharing::FileIntegrityResult::Ready) return fail(map(checked)); + if (phase_ == RouteInstallPhase::Existing) + { + phase_ = RouteInstallPhase::Installed; + return status_ = Status::Ok; + } + const auto published = publishValidatedRoute(io_, kml_, request_, source_, target_, guard); + if (published != Status::Ok && published != Status::Internal && published != Status::Conflict) return fail(published); + mutation_possible_ = published != Status::Conflict; + phase_ = RouteInstallPhase::Readback; + const auto begun = final_.begin(target_, request_.bytes, request_.hash.data); + return begun == track_sharing::FileIntegrityResult::Pending ? status_ = Status::Busy : fail(map(begun)); + } + Status cancel() + { + kml_.cancel(); + final_.cancel(); + return fail(mutation_possible_ ? Status::Internal : Status::Expired); + } + RouteInstallPhase phase() const { return phase_; } + uint8_t wireState() const + { + if (phase_ == RouteInstallPhase::Installed) return 1; + if (phase_ == RouteInstallPhase::Missing) return 2; + return phase_ == RouteInstallPhase::Source || phase_ == RouteInstallPhase::Existing || phase_ == RouteInstallPhase::Readback ? 0 : UINT8_MAX; + } + Status status() const { return status_; } + + private: + static Status map(track_sharing::FileIntegrityResult result) + { + return result == track_sharing::FileIntegrityResult::HashMismatch ? Status::HashMismatch : result == track_sharing::FileIntegrityResult::StorageFailure ? Status::StorageUnavailable + : Status::InvalidField; + } + Status fail(Status result) + { + phase_ = RouteInstallPhase::Failed; + return status_ = mutation_possible_ ? Status::Internal : result; + } + Io& io_; + RouteInstallView request_; + const char* source_; + char* target_; + track_sharing::RouteFileCheck kml_; + track_sharing::FileIntegrityCheck final_; + RouteInstallPhase phase_ = RouteInstallPhase::Idle; + Status status_ = Status::Busy; + bool mutation_possible_ = false; +}; +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_request.h b/modules/core_hostlink/include/hostlink/route_install_request.h new file mode 100644 index 00000000..2e728098 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_request.h @@ -0,0 +1,59 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/management_envelope.h" +#include + +namespace hostlink::management +{ +struct RouteInstallView +{ + geocaching::ByteView transfer{}, hash{}; + uint32_t bytes = 0, generation = 0; +}; +static_assert(sizeof(RouteInstallView) <= 48, "Route request must remain a small borrowed view"); +// Borrowed request bytes: do not overwrite the command arena before consuming +// these bindings. This decoder authorizes nothing and performs no SD writes. +inline Status parseRouteInstallRequest(geocaching::ByteView input, RouteInstallView& output) +{ + output = {}; + if (!input.data || input.size > 448) return Status::InvalidField; + geocaching::protocol::CmpReader reader(input); + RouteInstallView parsed; + size_t fields = 0; + uint64_t total = 0, generation = 0; + if (!reader.array(fields, 4) || fields != 4 || + !reader.binary(parsed.transfer, 16) || parsed.transfer.size != 16 || + !reader.binary(parsed.hash, 32) || parsed.hash.size != 32 || + !reader.unsignedInteger(total) || !total || total > 8388608 || + !reader.unsignedInteger(generation) || !generation || generation >= UINT32_MAX || + !reader.finished()) return Status::InvalidField; + uint8_t nonzero = 0; + for (size_t index = 0; index < parsed.transfer.size; ++index) nonzero |= parsed.transfer.data[index]; + if (!nonzero) return Status::InvalidField; + parsed.bytes = static_cast(total); + parsed.generation = static_cast(generation); + output = parsed; + return Status::Ok; +} +inline bool routeInstallTarget(geocaching::ByteView hash, char* output, size_t capacity) +{ + if (!hash.data || hash.size != 32 || !output) return false; + const auto source = reinterpret_cast(hash.data), target = reinterpret_cast(output); + const size_t span = capacity < 77 ? capacity : 77; + if (span && (source <= target ? target - source < 32 : source - target < span)) return false; + if (capacity < 77) + { + if (capacity) output[0] = '\0'; + return false; + } + constexpr char digits[] = "0123456789abcdef"; + std::memcpy(output, "/routes/", 8); + for (size_t index = 0; index < 32; ++index) + { + output[8 + index * 2] = digits[hash.data[index] >> 4]; + output[9 + index * 2] = digits[hash.data[index] & 15]; + } + std::memcpy(output + 72, ".kml", 5); + return true; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_install_storage_policy.h b/modules/core_hostlink/include/hostlink/route_install_storage_policy.h new file mode 100644 index 00000000..e89d01b6 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_install_storage_policy.h @@ -0,0 +1,44 @@ +#pragma once +#include "hostlink/route_install_request.h" +namespace hostlink::management +{ +struct RouteInstallStorageActivity +{ + bool restart = false, snapshot = false, prepare = false, publication = false, configuration = false, active_route = false; +}; +static_assert(sizeof(RouteInstallStorageActivity) <= 8, "Route admission must remain scalar-only"); +inline Status routeInstallStorageAdmission(const RouteInstallStorageActivity& activity) +{ + return activity.restart || activity.snapshot || activity.prepare || activity.publication || activity.configuration || activity.active_route ? Status::Busy : Status::Ok; +} +// Default-deny while an immutable route lease is held. Unknown future commands +// must be reviewed explicitly before they can run alongside installation. +constexpr bool routeInstallBlocksOperation(uint16_t operation) +{ + switch (static_cast(operation)) + { + case Operation::Capabilities: + case Operation::AuthorInfo: + case Operation::AuthorProve: + case Operation::SessionClose: + case Operation::IdentityBackupExport: + case Operation::ConfigSchema: + case Operation::JobGet: + case Operation::RouteInstall: + case Operation::RouteInstallStatus: + return false; + default: + return true; + } +} +// Compare the configured active route against a content-addressed target +// without constructing a string or allocating another pathname buffer. +inline bool routeInstallPathMatches(geocaching::ByteView hash, const char* path, size_t capacity) +{ + if (!hash.data || hash.size != 32 || !path || capacity < 77 || std::memcmp(path, "/routes/", 8)) return false; + constexpr char digits[] = "0123456789abcdef"; + for (size_t index = 0; index < 32; ++index) + if (path[8 + index * 2] != digits[hash.data[index] >> 4] || path[9 + index * 2] != digits[hash.data[index] & 15]) return false; + return !std::memcmp(path + 72, ".kml", 5); +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/route_storage_lease.h b/modules/core_hostlink/include/hostlink/route_storage_lease.h new file mode 100644 index 00000000..72cc61da --- /dev/null +++ b/modules/core_hostlink/include/hostlink/route_storage_lease.h @@ -0,0 +1,25 @@ +#pragma once +#include +namespace hostlink::route_storage_lease +{ +// One nonblocking lease for application-scheduler install/readback and UI +// deletion. No owner pointer is read from the UI thread and no mutex allocates. +inline std::atomic_flag held = ATOMIC_FLAG_INIT; +inline bool acquire() { return !held.test_and_set(std::memory_order_acquire); } +inline void release() { held.clear(std::memory_order_release); } +class Guard +{ + public: + Guard() : acquired_(acquire()) {} + ~Guard() + { + if (acquired_) release(); + } + Guard(const Guard&) = delete; + Guard& operator=(const Guard&) = delete; + bool acquired() const { return acquired_; } + + private: + bool acquired_; +}; +} // namespace hostlink::route_storage_lease diff --git a/modules/core_hostlink/include/hostlink/sensitive_memory.h b/modules/core_hostlink/include/hostlink/sensitive_memory.h new file mode 100644 index 00000000..59c710f4 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/sensitive_memory.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +namespace hostlink +{ +// Volatile writes survive optimization. Own and exclusively lend this buffer; +// this wipes memory only, not copies already delivered to the USB host. +inline void clearSensitiveBytes(uint8_t* data, size_t length) +{ + volatile uint8_t* destination = data; + if (!destination) return; + while (length--) *destination++ = 0; +} +} // namespace hostlink diff --git a/modules/core_hostlink/include/hostlink/snapshot_space_budget.h b/modules/core_hostlink/include/hostlink/snapshot_space_budget.h new file mode 100644 index 00000000..cb31cf11 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/snapshot_space_budget.h @@ -0,0 +1,28 @@ +#pragma once +#include "hostlink/track_snapshot.h" +namespace hostlink::management +{ +// Constant-memory accounting includes orphaned/unknown private files. An +// incomplete directory traversal never authorizes another allocation. +class SnapshotSpaceBudget +{ + public: + explicit SnapshotSpaceBudget(uint32_t source_bytes) + : remaining_(kBytes - uint64_t(source_bytes) - 64), valid_(source_bytes && source_bytes <= kTrackSnapshotMaxBytes) {} + bool add(uint64_t bytes, bool directory) + { + if (!valid_ || directory || ++files_ >= kFiles || bytes > remaining_) return valid_ = false; + remaining_ -= bytes; + return true; + } + bool complete() const { return valid_; } + static constexpr uint64_t kBytes = 2ULL * (kTrackSnapshotMaxBytes + 64ULL); + static constexpr unsigned kFiles = 8; + + private: + uint64_t remaining_; + unsigned files_ = 0; + bool valid_; +}; +static_assert(sizeof(SnapshotSpaceBudget) <= 16, "Snapshot quota must not hold a file list"); +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/track_snapshot.h b/modules/core_hostlink/include/hostlink/track_snapshot.h new file mode 100644 index 00000000..1795e410 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/track_snapshot.h @@ -0,0 +1,88 @@ +#pragma once + +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include +#include + +namespace hostlink::management +{ +constexpr uint32_t kTrackSnapshotMaxBytes = 8U * 1024U * 1024U; +constexpr uint16_t kTrackSnapshotChunkBytes = 320; + +struct TrackSnapshotRequest +{ + uint8_t id[16]{}; + uint8_t expected_hash[32]{}; + uint32_t offset = 0; + uint16_t maximum = 0; + bool has_hash = false; +}; +struct TrackSnapshotMetadata +{ + uint8_t id[16]{}; + uint8_t hash[32]{}; + uint32_t bytes = 0; + uint8_t format = 0; + // Set only by storage after a completed immutable snapshot and hash. + bool sealed = false; +}; +static_assert(sizeof(TrackSnapshotRequest) <= 56, "Snapshot request must not contain file storage"); +static_assert(sizeof(TrackSnapshotMetadata) <= 56, "Snapshot metadata must remain bounded"); + +inline bool decodeTrackSnapshotRequest(geocaching::ByteView body, TrackSnapshotRequest& output) +{ + output = {}; + geocaching::protocol::CmpReader reader(body); + size_t fields = 0; + uint64_t offset = 0, maximum = 0; + geocaching::ByteView id, hash; + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(id, 16) || id.size != 16 || + !reader.unsignedInteger(offset) || offset >= kTrackSnapshotMaxBytes || + !reader.unsignedInteger(maximum) || !maximum || maximum > kTrackSnapshotChunkBytes) return false; + uint8_t bits = 0; + for (size_t i = 0; i < id.size; ++i) bits |= id.data[i]; + if (!bits) return false; + const bool nil = reader.position() < body.size && body.data[reader.position()] == 0xc0; + if (nil ? !reader.nil() : (!reader.binary(hash, 32) || hash.size != 32)) return false; + if (!reader.finished() || (offset && nil)) return false; + std::memcpy(output.id, id.data, 16); + if (!nil) std::memcpy(output.expected_hash, hash.data, 32); + output.offset = static_cast(offset); + output.maximum = static_cast(maximum); + output.has_hash = !nil; + return true; +} + +// Header/trailer go into the existing response payload. The storage adapter +// reads exactly chunk_length bytes directly into output + chunk_offset. +// A failed/short read must discard the whole response; no success is emitted. +// Request/metadata must not alias output. This function does not hash or seal. +inline bool prepareTrackSnapshotResponse(const TrackSnapshotRequest& request, + const TrackSnapshotMetadata& metadata, + uint8_t* output, size_t capacity, + size_t& chunk_offset, size_t& chunk_length, size_t& written) +{ + chunk_offset = chunk_length = written = 0; + if (!metadata.sealed || !metadata.bytes || metadata.bytes > kTrackSnapshotMaxBytes || metadata.format > 3 || + !request.maximum || request.maximum > kTrackSnapshotChunkBytes || request.offset >= metadata.bytes || + std::memcmp(request.id, metadata.id, 16) || + (request.offset && !request.has_hash) || + (request.has_hash && std::memcmp(request.expected_hash, metadata.hash, 32))) return false; + uint8_t bits = 0; + for (auto byte : metadata.id) bits |= byte; + if (!bits) return false; + const auto length = std::min(request.maximum, metadata.bytes - request.offset); + geocaching::protocol::CmpWriter prefix(output, capacity); + if (!prefix.array(6) || !prefix.binary({metadata.id, 16}) || !prefix.unsignedInteger(metadata.bytes) || + !prefix.binary({metadata.hash, 32}) || !prefix.unsignedInteger(metadata.format) || !prefix.binaryHeader(length)) return false; + const auto start = prefix.size(); + if (start > capacity || length > capacity - start) return false; + geocaching::protocol::CmpWriter suffix(output + start + length, capacity - start - length); + if (!suffix.unsignedInteger(request.offset)) return false; + chunk_offset = start; + chunk_length = length; + written = start + length + suffix.size(); + return true; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/track_snapshot_builder.h b/modules/core_hostlink/include/hostlink/track_snapshot_builder.h new file mode 100644 index 00000000..3df1a1ed --- /dev/null +++ b/modules/core_hostlink/include/hostlink/track_snapshot_builder.h @@ -0,0 +1,159 @@ +#pragma once + +#include "hostlink/track_snapshot.h" + +namespace hostlink::management +{ +enum class SnapshotStorageResult : uint8_t +{ + Ok, + Busy, + Failed +}; +enum class SnapshotBuildResult : uint8_t +{ + Pending, + Ready, + Invalid, + Busy, + Failed, + Cancelled +}; + +// Storage owns source/destination handles and the streaming hash context. +// begin must acquire a closed-source lease: recording/replacement/removal and +// media transitions cannot mutate the source until finish/abort releases it. +// begin returning Busy must retain no lease or partially initialized resource. +// begin returning Failed may retain resources; abort must release them. +// Busy copy/finish leaves the operation retryable at the same offset. +// Busy copy must leave data/hash at the same offset. Failed copy may leave a +// partial candidate: the builder must abort it and never retry that chunk. +// finish verifies the lease, flushes/closes the immutable destination, stores +// its metadata atomically, and only then sets metadata.sealed. A short read, +// media change, or failed flush must never expose a sealed snapshot. +class TrackSnapshotStorage +{ + public: + virtual ~TrackSnapshotStorage() = default; + virtual SnapshotStorageResult begin(const uint8_t id[16], uint32_t bytes, uint8_t format) = 0; + virtual SnapshotStorageResult copy(uint32_t offset, uint8_t* scratch, size_t length) = 0; + virtual SnapshotStorageResult finish(TrackSnapshotMetadata& metadata) = 0; + virtual SnapshotStorageResult abort() = 0; + // Shutdown-only fallback: leave uncommitted files for later cleanup, but + // release all live resources. Implementations unable to do so must refuse. + virtual bool abandonForStop() { return false; } +}; + +// Owned by the existing storage scheduler, never an additional task/queue. +// No payload/file/hash buffer lives here. Each step copies at most 320 bytes +// using caller scratch; cancellation yields until storage releases its lease. +class TrackSnapshotBuilder +{ + public: + explicit TrackSnapshotBuilder(TrackSnapshotStorage& storage) : storage_(storage) {} + + SnapshotBuildResult begin(const uint8_t id[16], uint32_t bytes, uint8_t format) + { + if (phase_ != Phase::Idle) return SnapshotBuildResult::Busy; + uint8_t bits = 0; + if (id) + for (size_t i = 0; i < 16; ++i) bits |= id[i]; + if (!bits || !bytes || bytes > kTrackSnapshotMaxBytes || format > 3) return SnapshotBuildResult::Invalid; + const auto result = storage_.begin(id, bytes, format); + if (result == SnapshotStorageResult::Busy) return SnapshotBuildResult::Busy; + if (result != SnapshotStorageResult::Ok) + { + // Initialization can fail after opening a file or acquiring a + // lease. Retain ownership until the scheduler completes abort. + offset_ = 0; + phase_ = Phase::Failing; + return SnapshotBuildResult::Pending; + } + std::memcpy(id_, id, 16); + bytes_ = bytes; + format_ = format; + offset_ = 0; + phase_ = Phase::Copying; + return SnapshotBuildResult::Pending; + } + + SnapshotBuildResult step(uint8_t* scratch, size_t capacity, TrackSnapshotMetadata& metadata) + { + metadata.sealed = false; + if (phase_ == Phase::Idle) return SnapshotBuildResult::Invalid; + if (phase_ == Phase::Cancelling || phase_ == Phase::Failing) + { + const auto result = storage_.abort(); + if (result == SnapshotStorageResult::Busy) return SnapshotBuildResult::Pending; + // Failed abort retains ownership; do not permit another begin. + if (result != SnapshotStorageResult::Ok) return SnapshotBuildResult::Failed; + const bool cancelled = phase_ == Phase::Cancelling; + phase_ = Phase::Idle; + return cancelled ? SnapshotBuildResult::Cancelled : SnapshotBuildResult::Failed; + } + if (phase_ == Phase::Copying) + { + if (!scratch || !capacity) return SnapshotBuildResult::Invalid; + const auto length = std::min(std::min(capacity, kTrackSnapshotChunkBytes), bytes_ - offset_); + const auto result = storage_.copy(offset_, scratch, length); + if (result == SnapshotStorageResult::Busy) return SnapshotBuildResult::Pending; + if (result != SnapshotStorageResult::Ok) + { + phase_ = Phase::Failing; + return SnapshotBuildResult::Pending; + } + offset_ += static_cast(length); + if (offset_ == bytes_) phase_ = Phase::Sealing; + return SnapshotBuildResult::Pending; + } + const auto result = storage_.finish(metadata); + if (result == SnapshotStorageResult::Busy) + { + metadata.sealed = false; + return SnapshotBuildResult::Pending; + } + if (result != SnapshotStorageResult::Ok || !metadata.sealed || metadata.bytes != bytes_ || + metadata.format != format_ || std::memcmp(metadata.id, id_, 16)) + { + metadata.sealed = false; + phase_ = Phase::Failing; + return SnapshotBuildResult::Pending; + } + phase_ = Phase::Idle; + return SnapshotBuildResult::Ready; + } + + bool abandonForStop() + { + if (phase_ == Phase::Idle) return true; + if (!storage_.abandonForStop()) return false; + phase_ = Phase::Idle; + return true; + } + + SnapshotBuildResult cancel() + { + if (phase_ == Phase::Idle) return SnapshotBuildResult::Cancelled; + if (phase_ != Phase::Failing) phase_ = Phase::Cancelling; + return SnapshotBuildResult::Pending; + } + uint32_t copiedBytes() const { return offset_; } + bool active() const { return phase_ != Phase::Idle; } + + private: + enum class Phase : uint8_t + { + Idle, + Copying, + Sealing, + Cancelling, + Failing + }; + TrackSnapshotStorage& storage_; + uint8_t id_[16]{}; + uint32_t bytes_ = 0, offset_ = 0; + uint8_t format_ = 0; + Phase phase_ = Phase::Idle; +}; +static_assert(sizeof(TrackSnapshotBuilder) <= 40, "Snapshot builder must not contain payload storage"); +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/track_snapshot_selection.h b/modules/core_hostlink/include/hostlink/track_snapshot_selection.h new file mode 100644 index 00000000..1c00a975 --- /dev/null +++ b/modules/core_hostlink/include/hostlink/track_snapshot_selection.h @@ -0,0 +1,63 @@ +#pragma once + +#include "hostlink/track_snapshot_session.h" + +namespace hostlink::management +{ +struct TrackSnapshotSelection +{ + uint8_t id[16]{}; + uint32_t media_session = 0, bytes = 0; + std::string_view filename; + uint8_t format = 0; +}; +// Name borrows the command input. The platform must copy it to admitted owner +// storage before overwriting the command payload or scheduling another slice. +inline bool decodeTrackSnapshotSelection(geocaching::ByteView body, TrackSnapshotSelection& output) +{ + output = {}; + geocaching::protocol::CmpReader reader(body); + size_t fields = 0; + geocaching::ByteView id; + uint64_t media = 0, bytes = 0, format = 0; + if (!reader.array(fields, 5) || fields != 5 || !reader.binary(id, 16) || id.size != 16 || + !reader.unsignedInteger(media) || media > UINT32_MAX || !reader.text(output.filename, 117) || output.filename.empty() || + !reader.unsignedInteger(bytes) || !bytes || bytes > kTrackSnapshotMaxBytes || + !reader.unsignedInteger(format) || format > 3 || !reader.finished()) return false; + uint8_t bits = 0; + for (size_t i = 0; i < id.size; ++i) bits |= id.data[i]; + if (!bits) return false; + for (const auto character : output.filename) + if (static_cast(character) < 32 || character == '/' || character == '\\' || character == ':') return false; + const auto lower = [](char value) + { return value >= 'A' && value <= 'Z' ? char(value + 32) : value; }; + const auto name = output.filename; + if (name.size() < 5 || name[name.size() - 4] != '.') return false; + constexpr const char* suffix[] = {"kml", "gpx", "csv", "bin"}; + for (unsigned i = 0; i < 3; ++i) + if (lower(name[name.size() - 3 + i]) != suffix[format][i]) return false; + if (name.size() == 10) + { + bool control = true; + constexpr char reserved[] = "active.bin"; + for (unsigned i = 0; i < 10; ++i) control = control && lower(name[i]) == reserved[i]; + if (control) return false; + } + std::memcpy(output.id, id.data, 16); + output.media_session = static_cast(media); + output.bytes = static_cast(bytes); + output.format = static_cast(format); + return true; +} +inline bool encodeTrackSnapshotBuildState(const SnapshotBuildSession& task, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || task.state() == SnapshotSessionState::Empty) return false; + const auto& metadata = task.metadata(); + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(5) || !writer.binary({metadata.id, 16}) || !writer.unsignedInteger(static_cast(task.state())) || + !writer.unsignedInteger(task.copiedBytes()) || !writer.unsignedInteger(metadata.bytes) || !writer.unsignedInteger(metadata.format)) return false; + written = writer.size(); + return true; +} +} // namespace hostlink::management diff --git a/modules/core_hostlink/include/hostlink/track_snapshot_session.h b/modules/core_hostlink/include/hostlink/track_snapshot_session.h new file mode 100644 index 00000000..0b81840f --- /dev/null +++ b/modules/core_hostlink/include/hostlink/track_snapshot_session.h @@ -0,0 +1,113 @@ +#pragma once + +#include "hostlink/track_snapshot_builder.h" + +namespace hostlink::management +{ +enum class SnapshotSessionState : uint8_t +{ + Empty, + Building, + Cancelling, + Ready, + Failed, + Cancelled +}; + +// Existing application scheduler lends scratch for each slice. This object +// owns only session/result metadata; storage, paths and hash have one owner. +// Never destroy that owner while releasable() is false: abort may need retries. +class SnapshotBuildSession +{ + public: + explicit SnapshotBuildSession(TrackSnapshotBuilder& builder) : builder_(builder) {} + + SnapshotBuildResult begin(const uint8_t session[16], const uint8_t id[16], + uint32_t bytes, uint8_t format, uint64_t now) + { + if (!nonzero(session) || !nonzero(id)) return SnapshotBuildResult::Invalid; + if (state_ != SnapshotSessionState::Empty) return SnapshotBuildResult::Busy; + const auto result = builder_.begin(id, bytes, format); + if (result != SnapshotBuildResult::Pending) return result; + std::memcpy(session_, session, 16); + std::memcpy(metadata_.id, id, 16); + metadata_.bytes = bytes; + metadata_.format = format; + metadata_.sealed = false; + last_touch_ = now; + state_ = SnapshotSessionState::Building; + return result; + } + + // An unrelated request cannot extend this task's lifetime or cancel it. + bool touch(const uint8_t session[16], const uint8_t id[16], uint64_t now) + { + if (!matches(session, id)) return false; + last_touch_ = now; + return true; + } + bool cancel(const uint8_t session[16], const uint8_t id[16]) + { + if (!matches(session, id)) return false; + if (state_ == SnapshotSessionState::Building) + { + builder_.cancel(); + state_ = SnapshotSessionState::Cancelling; + } + // Ready remains published: cancellation never removes sealed output. + return true; + } + void advance(uint8_t* scratch, size_t capacity, uint64_t now, bool connected) + { + if (state_ != SnapshotSessionState::Building && state_ != SnapshotSessionState::Cancelling) return; + if (!connected || now < last_touch_ || now - last_touch_ >= kIdleTimeoutMs) + { + builder_.cancel(); + state_ = SnapshotSessionState::Cancelling; + } + TrackSnapshotMetadata completed; + const auto result = builder_.step(scratch, capacity, completed); + if (result == SnapshotBuildResult::Ready) + { + metadata_ = completed; + state_ = SnapshotSessionState::Ready; + } + else if (result == SnapshotBuildResult::Cancelled) state_ = SnapshotSessionState::Cancelled; + else if (result == SnapshotBuildResult::Failed && !builder_.active()) state_ = SnapshotSessionState::Failed; + // A failed abort retains the active builder and lease. Retry cleanup + // on the next scheduler visit, never declare its memory releasable. + } + bool abandonForStop() + { + if (!builder_.abandonForStop()) return false; + if (state_ == SnapshotSessionState::Building || state_ == SnapshotSessionState::Cancelling) + state_ = SnapshotSessionState::Cancelled; + return true; + } + bool matches(const uint8_t session[16], const uint8_t id[16]) const + { + return state_ != SnapshotSessionState::Empty && session && id && + !std::memcmp(session_, session, 16) && !std::memcmp(metadata_.id, id, 16); + } + SnapshotSessionState state() const { return state_; } + const TrackSnapshotMetadata& metadata() const { return metadata_; } + uint32_t copiedBytes() const { return builder_.copiedBytes(); } + bool releasable() const { return !builder_.active(); } + static constexpr uint64_t kIdleTimeoutMs = 30000; + + private: + static bool nonzero(const uint8_t* value) + { + if (!value) return false; + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) bits |= value[i]; + return bits != 0; + } + TrackSnapshotBuilder& builder_; + TrackSnapshotMetadata metadata_; + uint8_t session_[16]{}; + uint64_t last_touch_ = 0; + SnapshotSessionState state_ = SnapshotSessionState::Empty; +}; +static_assert(sizeof(SnapshotBuildSession) <= 104, "Snapshot session must not own payload storage"); +} // namespace hostlink::management diff --git a/modules/core_hostlink/src/hostlink_codec.cpp b/modules/core_hostlink/src/hostlink_codec.cpp index d4d2b13e..c0b87701 100644 --- a/modules/core_hostlink/src/hostlink_codec.cpp +++ b/modules/core_hostlink/src/hostlink_codec.cpp @@ -27,6 +27,35 @@ uint16_t crc16_ccitt(const uint8_t* data, size_t len) return crc; } +bool encode_frame_into(uint8_t type, uint16_t seq, const uint8_t* payload, size_t len, + uint8_t* out, size_t capacity, size_t& written) +{ + written = 0; + if (!out || len > kMaxFrameLen || (len && !payload) || + capacity < kHeaderSize + len + kCrcSize) + { + return false; + } + // memmove permits a payload staged inside this same output arena. + if (len) + { + std::memmove(out + kHeaderSize, payload, len); + } + out[0] = kMagic0; + out[1] = kMagic1; + out[2] = kProtocolVersion; + out[3] = type; + out[4] = static_cast(seq); + out[5] = static_cast(seq >> 8); + out[6] = static_cast(len); + out[7] = static_cast(len >> 8); + const uint16_t crc = crc16_ccitt(out, kHeaderSize + len); + out[kHeaderSize + len] = static_cast(crc); + out[kHeaderSize + len + 1] = static_cast(crc >> 8); + written = kHeaderSize + len + kCrcSize; + return true; +} + bool encode_frame(uint8_t type, uint16_t seq, const uint8_t* payload, size_t len, std::vector& out) { diff --git a/modules/core_hostlink/src/hostlink_frame_router.cpp b/modules/core_hostlink/src/hostlink_frame_router.cpp index 06bade50..9ae72d0d 100644 --- a/modules/core_hostlink/src/hostlink_frame_router.cpp +++ b/modules/core_hostlink/src/hostlink_frame_router.cpp @@ -37,6 +37,10 @@ HostlinkFrameDecision route_frame(const Frame& frame, bool ready) switch (static_cast(frame.type)) { + case FrameType::ManagementRequest: + return make_decision(HostlinkFrameDecisionType::DispatchCommand, + HostlinkCommandId::Management, + ErrorCode::Ok); case FrameType::CmdTxMsg: return make_decision(HostlinkFrameDecisionType::DispatchCommand, HostlinkCommandId::TxMsg, diff --git a/modules/core_hostlink/tests/test_frame_into.cpp b/modules/core_hostlink/tests/test_frame_into.cpp new file mode 100644 index 00000000..47de047a --- /dev/null +++ b/modules/core_hostlink/tests/test_frame_into.cpp @@ -0,0 +1,42 @@ +#include "hostlink/hostlink_codec.h" +#include +#include +#include +#include + +int main() +{ + std::array arena; + std::array payload; + for (size_t i = 0; i < payload.size(); ++i) payload[i] = static_cast(i); + for (size_t len = 0; len <= payload.size(); ++len) + { + arena.fill(0xA5); + size_t written = 0; + const size_t capacity = hostlink::kHeaderSize + len + hostlink::kCrcSize; + assert(hostlink::encode_frame_into(0x85, 0x1234, payload.data(), len, + arena.data() + 1, capacity, written)); + std::vector reference; + assert(hostlink::encode_frame(0x85, 0x1234, payload.data(), len, reference)); + assert(written == reference.size()); + assert(std::equal(reference.begin(), reference.end(), arena.begin() + 1)); + assert(arena[0] == 0xA5 && arena[capacity + 1] == 0xA5); + arena.fill(0xA5); + assert(!hostlink::encode_frame_into(0x85, 0, payload.data(), len, + arena.data() + 1, capacity - 1, written)); + assert(written == 0); + assert(std::all_of(arena.begin(), arena.end(), [](uint8_t b) + { return b == 0xA5; })); + } + size_t written = 100; + assert(!hostlink::encode_frame_into(1, 1, nullptr, 1, arena.data(), arena.size(), written)); + assert(written == 0); + std::copy(payload.begin(), payload.end(), arena.begin()); + assert(hostlink::encode_frame_into(0x85, 4, arena.data(), payload.size(), + arena.data(), arena.size(), written)); + hostlink::Decoder decoder; + decoder.push(arena.data(), written); + hostlink::Frame frame; + assert(decoder.next(frame)); + assert(std::equal(frame.payload.begin(), frame.payload.end(), payload.begin())); +} diff --git a/modules/core_hostlink/tests/test_management_envelope.cpp b/modules/core_hostlink/tests/test_management_envelope.cpp new file mode 100644 index 00000000..7695f7dc --- /dev/null +++ b/modules/core_hostlink/tests/test_management_envelope.cpp @@ -0,0 +1,35 @@ +#include "hostlink/management_envelope.h" +#include +#include +#include + +int main() +{ + std::array session, request; + session.fill(1); + request.fill(2); + std::array output; + const uint8_t body[] = {0x90}; + size_t written; + assert(hostlink::management::encodeEnvelope(session.data(), request.data(), 0, body, 1, output.data(), output.size(), written)); + assert(written == 40 && output[0] == 0x95 && output[2] == 0xc4 && output[38] == 0 && output[39] == 0x90); + hostlink::management::EnvelopeView view; + assert(hostlink::management::decodeEnvelope(output.data(), written, view)); + assert(view.session == output.data() + 4 && view.request == output.data() + 22); + assert(view.body == output.data() + 39 && view.body_size == 1); + for (size_t size = 0; size < written; ++size) assert(!hostlink::management::decodeEnvelope(output.data(), size, view)); + output[written] = 0; + assert(!hostlink::management::decodeEnvelope(output.data(), written + 1, view)); + const uint8_t invalid[][4] = {{0xcc, 1, 0, 0}, {0xa2, 0xc0, 0xaf, 0}, {0xd9, 1, 'x', 0}, {0x91, 0xc3, 0, 0}}; + for (const auto& bytes : invalid) assert(!hostlink::management::encodeEnvelope(session.data(), request.data(), 0, bytes, 3, output.data(), output.size(), written)); + std::array maximum; + maximum.fill(3); + maximum[0] = 0xc5; + maximum[1] = 1; + maximum[2] = 0xd7; + // 471-byte binary body + 3-byte header exceeds this frame's remaining budget. + assert(!hostlink::management::encodeEnvelope(session.data(), request.data(), 0, maximum.data(), maximum.size(), output.data(), output.size(), written)); + const uint8_t signed_body[] = {0x94, 0xd0, 0xdf, 0xd1, 0xff, 0x7f, 0xd2, 0xff, 0xff, 0x7f, 0xff, 0xc0}; + assert(hostlink::management::encodeEnvelope(session.data(), request.data(), 65535, signed_body, sizeof(signed_body), output.data(), output.size(), written)); + assert(hostlink::management::decodeEnvelope(output.data(), written, view) && view.code == 65535); +} diff --git a/modules/core_hostlink/tests/test_management_routing.cpp b/modules/core_hostlink/tests/test_management_routing.cpp new file mode 100644 index 00000000..ae794393 --- /dev/null +++ b/modules/core_hostlink/tests/test_management_routing.cpp @@ -0,0 +1,21 @@ +#include "hostlink/hostlink_frame_router.h" +#include "hostlink/hostlink_service_codec.h" +#include + +// sequence occupies pre-existing padding. The existing queue's per-item RAM +// budget must not grow when management reuses the legacy command queue. +static_assert(sizeof(hostlink::PendingCommand) == 528, "HostLink command queue RAM budget changed"); + +int main() +{ + hostlink::Frame frame; + frame.type = static_cast(hostlink::FrameType::ManagementRequest); + assert(hostlink::route_frame(frame, false).type == hostlink::HostlinkFrameDecisionType::RejectNotReady); + const auto ready = hostlink::route_frame(frame, true); + assert(ready.type == hostlink::HostlinkFrameDecisionType::DispatchCommand); + assert(ready.command == hostlink::HostlinkCommandId::Management); + frame.type = static_cast(hostlink::FrameType::CmdGetConfig); + assert(hostlink::route_frame(frame, true).command == hostlink::HostlinkCommandId::GetConfig); + frame.type = 0x7f; + assert(hostlink::route_frame(frame, true).type == hostlink::HostlinkFrameDecisionType::Unsupported); +} diff --git a/modules/core_hostlink/tests/test_object_transfer.cpp b/modules/core_hostlink/tests/test_object_transfer.cpp new file mode 100644 index 00000000..9f92e9e5 --- /dev/null +++ b/modules/core_hostlink/tests/test_object_transfer.cpp @@ -0,0 +1,71 @@ +#include "hostlink/object_transfer.h" +#include +#include + +using namespace hostlink::management; +struct Storage : TransferStorage +{ + std::vector file; + bool fail_write = false, hash_ok = true; + unsigned begins = 0, seals = 0; + bool begin(const TransferDescriptor&) override + { + ++begins; + file.clear(); + return true; + } + bool append(uint32_t offset, const uint8_t* data, size_t length) override + { + if (fail_write || offset != file.size()) return false; + file.insert(file.end(), data, data + length); + return true; + } + bool matches(uint32_t offset, const uint8_t* data, size_t length) override + { + return offset <= file.size() && length <= file.size() - offset && !std::memcmp(file.data() + offset, data, length); + } + bool verify(const uint8_t*, uint32_t length) override { return hash_ok && length == file.size(); } + bool seal() override + { + ++seals; + return true; + } + bool discard() override + { + file.clear(); + return true; + } +}; +int main() +{ + Storage storage; + ObjectTransfer transfer(storage); + TransferDescriptor descriptor; + descriptor.owner[0] = 1; + descriptor.id[0] = 2; + descriptor.bytes = 5; + assert(transfer.begin(descriptor, 4) == TransferResult::Invalid); + assert(transfer.begin(descriptor, 5) == TransferResult::Ok); + assert(transfer.begin(descriptor, 5) == TransferResult::Ok && storage.begins == 1); + const uint8_t bytes[] = {1, 2, 3, 4, 5}; + uint8_t wrong[16]{}; + assert(transfer.append(wrong, descriptor.id, 0, bytes, 3) == TransferResult::WrongOwner); + assert(transfer.append(descriptor.owner, descriptor.id, 1, bytes, 3) == TransferResult::Conflict); + assert(transfer.finish(descriptor.owner, descriptor.id) == TransferResult::Conflict); + storage.fail_write = true; + assert(transfer.append(descriptor.owner, descriptor.id, 0, bytes, 3) == TransferResult::StorageFailure && transfer.offset() == 0); + storage.fail_write = false; + assert(transfer.append(descriptor.owner, descriptor.id, 0, bytes, 3) == TransferResult::Ok); + assert(transfer.append(descriptor.owner, descriptor.id, 0, bytes, 3) == TransferResult::Ok && storage.file.size() == 3); + assert(transfer.append(descriptor.owner, descriptor.id, 0, bytes + 1, 3) == TransferResult::Conflict); + assert(transfer.append(descriptor.owner, descriptor.id, 3, bytes + 3, 2) == TransferResult::Ok); + storage.hash_ok = false; + assert(transfer.finish(descriptor.owner, descriptor.id) == TransferResult::HashMismatch && storage.seals == 0); + storage.hash_ok = true; + assert(transfer.finish(descriptor.owner, descriptor.id) == TransferResult::Ok); + assert(transfer.finish(descriptor.owner, descriptor.id) == TransferResult::Ok && storage.seals == 1); + assert(transfer.abort(descriptor.owner, descriptor.id) == TransferResult::Ok && !transfer.active()); + assert(!transfer.restore(descriptor, 6, false)); + assert(!transfer.restore(descriptor, 3, true)); + assert(transfer.restore(descriptor, 3, false) && transfer.offset() == 3); +} diff --git a/modules/core_hostlink/tests/test_track_snapshot.cpp b/modules/core_hostlink/tests/test_track_snapshot.cpp new file mode 100644 index 00000000..2013063f --- /dev/null +++ b/modules/core_hostlink/tests/test_track_snapshot.cpp @@ -0,0 +1,64 @@ +#include "hostlink/track_snapshot.h" +#include +#include +#include + +using namespace hostlink::management; +static uint8_t request_bytes[128], response[448]; +int main() +{ + TrackSnapshotMetadata metadata; + std::memset(metadata.id, 7, 16); + std::memset(metadata.hash, 8, 32); + metadata.bytes = 700; + metadata.format = 2; + metadata.sealed = true; + auto request = [&](uint32_t offset, uint16_t maximum, bool hash) + { + geocaching::protocol::CmpWriter writer(request_bytes, sizeof(request_bytes)); + assert(writer.array(4) && writer.binary({metadata.id, 16}) && writer.unsignedInteger(offset) && writer.unsignedInteger(maximum)); + assert(hash ? writer.binary({metadata.hash, 32}) : writer.nil()); + return writer.size(); + }; + TrackSnapshotRequest decoded; + size_t start = 0, length = 0, written = 0; + auto bytes = request(0, 320, false); + assert(decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + assert(!decoded.has_hash && decoded.maximum == 320); + assert(prepareTrackSnapshotResponse(decoded, metadata, response, sizeof(response), start, length, written)); + assert(length == 320 && written <= 400); + std::memset(response + start, 0x55, length); + geocaching::protocol::CmpReader reader({response, written}); + size_t fields = 0; + uint64_t number = 0; + geocaching::ByteView data; + assert(reader.array(fields, 6) && fields == 6 && reader.binary(data, 16) && !std::memcmp(data.data, metadata.id, 16)); + assert(reader.unsignedInteger(number) && number == 700 && reader.binary(data, 32) && !std::memcmp(data.data, metadata.hash, 32)); + assert(reader.unsignedInteger(number) && number == 2 && reader.binary(data, 320) && data.size == 320 && data.data[319] == 0x55); + assert(reader.unsignedInteger(number) && number == 0 && reader.finished()); + for (size_t i = 0; i < written; ++i) std::printf("%02x", response[i]); + std::printf("\n"); + bytes = request(640, 320, true); + assert(decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + assert(prepareTrackSnapshotResponse(decoded, metadata, response, sizeof(response), start, length, written) && length == 60); + decoded.expected_hash[0] ^= 1; + assert(!prepareTrackSnapshotResponse(decoded, metadata, response, sizeof(response), start, length, written) && written == 0); + decoded.expected_hash[0] ^= 1; + metadata.sealed = false; + assert(!prepareTrackSnapshotResponse(decoded, metadata, response, sizeof(response), start, length, written)); + metadata.sealed = true; + assert(!prepareTrackSnapshotResponse(decoded, metadata, response, 10, start, length, written)); + bytes = request(1, 320, false); + assert(!decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + bytes = request(0, 0, false); + assert(!decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + bytes = request(0, 321, false); + assert(!decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + bytes = request(kTrackSnapshotMaxBytes, 320, true); + assert(!decodeTrackSnapshotRequest({request_bytes, bytes}, decoded)); + bytes = request(0, 320, false); + for (size_t n = 0; n < bytes; ++n) assert(!decodeTrackSnapshotRequest({request_bytes, n}, decoded)); + request_bytes[bytes] = 0; + assert(!decodeTrackSnapshotRequest({request_bytes, bytes + 1}, decoded)); + return 0; +} diff --git a/modules/core_hostlink/tests/test_track_snapshot_builder.cpp b/modules/core_hostlink/tests/test_track_snapshot_builder.cpp new file mode 100644 index 00000000..872efd6a --- /dev/null +++ b/modules/core_hostlink/tests/test_track_snapshot_builder.cpp @@ -0,0 +1,112 @@ +#include "hostlink/track_snapshot_builder.h" +#include +#include + +using namespace hostlink::management; +class Storage : public TrackSnapshotStorage +{ + public: + SnapshotStorageResult begin(const uint8_t id[16], uint32_t bytes, uint8_t format) override + { + std::memcpy(metadata.id, id, 16); + metadata.bytes = bytes; + metadata.format = format; + lease = true; + offset = 0; + return begin_fail ? SnapshotStorageResult::Failed : SnapshotStorageResult::Ok; + } + SnapshotStorageResult copy(uint32_t position, uint8_t* scratch, size_t length) override + { + assert(lease && position == offset && length <= 320); + requests.push_back(position); + if (busy) + { + busy = false; + return SnapshotStorageResult::Busy; + } + if (fail) return SnapshotStorageResult::Failed; + std::memset(scratch, 0x55, length); + offset += length; + return SnapshotStorageResult::Ok; + } + SnapshotStorageResult finish(TrackSnapshotMetadata& output) override + { + assert(lease && offset == metadata.bytes); + if (busy) + { + busy = false; + return SnapshotStorageResult::Busy; + } + output = metadata; + output.sealed = !bad_seal; + if (!bad_seal) lease = false; + return SnapshotStorageResult::Ok; + } + SnapshotStorageResult abort() override + { + ++aborts; + if (abort_fail) return SnapshotStorageResult::Failed; + if (busy) + { + busy = false; + return SnapshotStorageResult::Busy; + } + lease = false; + return SnapshotStorageResult::Ok; + } + TrackSnapshotMetadata metadata; + bool lease = false, busy = false, fail = false, bad_seal = false, begin_fail = false, abort_fail = false; + size_t offset = 0, aborts = 0; + std::vector requests; +}; +static uint8_t scratch[320], id[16] = {1}; +int main() +{ + Storage storage; + TrackSnapshotBuilder builder(storage); + TrackSnapshotMetadata output; + assert(builder.begin(id, 700, 2) == SnapshotBuildResult::Pending); + assert(builder.begin(id, 700, 2) == SnapshotBuildResult::Busy); + storage.busy = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && builder.copiedBytes() == 0); + for (unsigned i = 0; i < 3; ++i) assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && !output.sealed); + assert(builder.copiedBytes() == 700 && storage.lease); + storage.busy = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && !output.sealed); + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Ready && output.sealed && !storage.lease); + assert((storage.requests == std::vector{0, 0, 320, 640})); + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Pending); + assert(builder.step(nullptr, 320, output) == SnapshotBuildResult::Invalid && builder.copiedBytes() == 0); + assert(builder.cancel() == SnapshotBuildResult::Pending); + storage.busy = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && storage.lease); + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Cancelled && !storage.lease); + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Pending); + storage.fail = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && builder.copiedBytes() == 0); + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Busy); + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Failed && !storage.lease); + storage.fail = false; + storage.bad_seal = true; + assert(builder.begin(id, 1, 2) == SnapshotBuildResult::Pending); + assert(builder.step(scratch, 1, output) == SnapshotBuildResult::Pending); + assert(builder.step(scratch, 1, output) == SnapshotBuildResult::Pending && !output.sealed); + assert(builder.step(scratch, 1, output) == SnapshotBuildResult::Failed && !storage.lease); + assert(builder.begin(id, kTrackSnapshotMaxBytes + 1, 2) == SnapshotBuildResult::Invalid); + storage.begin_fail = true; + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Pending && storage.lease); + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Busy); + storage.busy = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Pending && storage.lease); + storage.abort_fail = true; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Failed && storage.lease); + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Busy); + storage.abort_fail = false; + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Failed && !storage.lease); + storage.begin_fail = false; + storage.bad_seal = false; + assert(builder.begin(id, 10, 2) == SnapshotBuildResult::Pending); + assert(builder.cancel() == SnapshotBuildResult::Pending); + assert(builder.step(scratch, sizeof(scratch), output) == SnapshotBuildResult::Cancelled && !storage.lease); + return 0; +} diff --git a/modules/core_sys/include/app/tms_c6_config_extension.h b/modules/core_sys/include/app/tms_c6_config_extension.h new file mode 100644 index 00000000..2ac266e6 --- /dev/null +++ b/modules/core_sys/include/app/tms_c6_config_extension.h @@ -0,0 +1,76 @@ +#pragma once +#include "app/tms_config_codec.h" +#include + +namespace app::tms::c6 +{ +// Caller-owned decode staging. ESP callers must place this in their existing +// on-demand PSRAM owner, never an automatic task-stack object. +struct Config +{ + uint16_t seen = 0; + bool espnow_enabled = false, espnow_team_discovery = true; + uint8_t espnow_channel = 0; + uint16_t espnow_beacon_interval_ms = 1000; + bool wifi_enabled = false, wifi_sta_enabled = false, wifi_ap_enabled = false, wifi_persist_credentials = false; + char wifi_sta_ssid[32]{}, wifi_sta_password[64]{}, wifi_ap_ssid[32] = "TrailMate-C6", wifi_ap_password[64]{}; + uint8_t wifi_sta_channel = 0, wifi_ap_channel = 1; +}; +constexpr uint16_t kComplete = (1U << 14U) - 1U; +inline bool valid(const Config& config, bool shared_wifi_enabled = false) +{ + const bool wifi_enabled = config.wifi_enabled || (shared_wifi_enabled && config.wifi_sta_ssid[0]); + return config.seen == kComplete && config.espnow_channel <= 14 && config.wifi_sta_channel <= 14 && + config.wifi_ap_channel >= 1 && config.wifi_ap_channel <= 14 && + config.espnow_beacon_interval_ms >= 1 && config.espnow_beacon_interval_ms <= 60000 && + std::memchr(config.wifi_sta_ssid, 0, sizeof(config.wifi_sta_ssid)) && + std::memchr(config.wifi_sta_password, 0, sizeof(config.wifi_sta_password)) && + std::memchr(config.wifi_ap_ssid, 0, sizeof(config.wifi_ap_ssid)) && + std::memchr(config.wifi_ap_password, 0, sizeof(config.wifi_ap_password)) && + (!wifi_enabled || !config.wifi_sta_enabled || config.wifi_sta_ssid[0]) && + (!wifi_enabled || !config.wifi_ap_enabled || config.wifi_ap_ssid[0]) && + (!wifi_enabled || !config.wifi_ap_enabled || !config.wifi_ap_password[0] || std::strlen(config.wifi_ap_password) >= 8); +} +inline RecordConsumeResult consume(Config& config, const RecordReader& reader) +{ + const char* key = reader.key(); + if (!key || std::strncmp(key, "c6_companion.", 13)) return RecordConsumeResult::Unhandled; + key += 13; + unsigned index = 0; + bool ok = false; +#define C6_FIELD(name, method, number) \ + if (!std::strcmp(key, #name)) \ + { \ + index = number; \ + if (config.seen & (1U << index)) return RecordConsumeResult::Invalid; \ + ok = reader.method(&config.name); \ + } +#define C6_TEXT(name, number) \ + if (!std::strcmp(key, #name)) \ + { \ + index = number; \ + if (config.seen & (1U << index)) return RecordConsumeResult::Invalid; \ + ok = reader.text(config.name, sizeof(config.name)); \ + } + C6_FIELD(espnow_enabled, boolean, 0) + else C6_FIELD(espnow_team_discovery, boolean, 1) else C6_FIELD(espnow_channel, u8, 2) else C6_FIELD(espnow_beacon_interval_ms, u16, 3) else C6_FIELD(wifi_enabled, boolean, 4) else C6_FIELD(wifi_sta_enabled, boolean, 5) else C6_FIELD(wifi_ap_enabled, boolean, 6) else C6_FIELD(wifi_persist_credentials, boolean, 7) else C6_TEXT(wifi_sta_ssid, 8) else C6_TEXT(wifi_sta_password, 9) else C6_TEXT(wifi_ap_ssid, 10) else C6_TEXT(wifi_ap_password, 11) else C6_FIELD(wifi_sta_channel, u8, 12) else C6_FIELD(wifi_ap_channel, u8, 13) else return RecordConsumeResult::Invalid; +#undef C6_FIELD +#undef C6_TEXT + if (!ok) return RecordConsumeResult::Invalid; + config.seen |= uint16_t(1U << index); + return RecordConsumeResult::Accepted; +} +inline bool write(const Config& config, RecordWriter& writer) +{ + if (!valid(config)) return false; +#define C6_WRITE(name, method) writer.method("c6_companion." #name, config.name) + return C6_WRITE(espnow_enabled, boolean) && C6_WRITE(espnow_team_discovery, boolean) && + C6_WRITE(espnow_channel, u8) && C6_WRITE(espnow_beacon_interval_ms, u16) && + C6_WRITE(wifi_enabled, boolean) && C6_WRITE(wifi_sta_enabled, boolean) && + C6_WRITE(wifi_ap_enabled, boolean) && C6_WRITE(wifi_persist_credentials, boolean) && + C6_WRITE(wifi_sta_ssid, text) && C6_WRITE(wifi_sta_password, text) && + C6_WRITE(wifi_ap_ssid, text) && C6_WRITE(wifi_ap_password, text) && + C6_WRITE(wifi_sta_channel, u8) && C6_WRITE(wifi_ap_channel, u8); +#undef C6_WRITE +} +} // namespace app::tms::c6 diff --git a/modules/core_sys/include/platform/ui/route_storage.h b/modules/core_sys/include/platform/ui/route_storage.h index 5140e8dc..9c79b308 100644 --- a/modules/core_sys/include/platform/ui/route_storage.h +++ b/modules/core_sys/include/platform/ui/route_storage.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace platform::ui::route_storage @@ -29,6 +30,8 @@ struct RouteImageCacheItem std::string source_path{}; std::string preview_path{}; std::string view_path{}; + uint8_t source_hash[32]{}; + bool source_hash_valid = false; }; enum class RouteImageDownloadPhase : uint8_t @@ -74,6 +77,10 @@ bool count_route_saved_images(const std::string& asset_id, std::size_t max_count, std::size_t& out_count); bool route_asset_file_exists(const std::string& path); +bool route_image_reference_hash(std::string_view reference,uint8_t output[32]); +// Fast source-binding and length check. A true result is not an author's +// signature or an independent full-file hash verification. +bool route_image_source_matches(const std::string& path, const uint8_t expected_source_hash[32]); RouteImageDownloadResult download_route_image(const std::string& url, const std::string& output_path, std::uint32_t max_bytes = 4U * 1024U * 1024U); @@ -83,10 +90,11 @@ bool start_route_image_download(const std::string& asset_id, RouteImageTaskPresentation presentation = RouteImageTaskPresentation::UserVisible); bool start_route_image_cache_build(const std::string& asset_id, - const std::vector& items, + std::vector items, std::string& out_error, RouteImageTaskPresentation presentation = - RouteImageTaskPresentation::PageOnly); + RouteImageTaskPresentation::PageOnly, + const std::string& package_route_path = {}); RouteImageDownloadStatus route_image_download_status(); } // namespace platform::ui::route_storage diff --git a/modules/core_sys/include/platform/ui/settings_store.h b/modules/core_sys/include/platform/ui/settings_store.h index a7b2aa63..f8a2e62f 100644 --- a/modules/core_sys/include/platform/ui/settings_store.h +++ b/modules/core_sys/include/platform/ui/settings_store.h @@ -44,6 +44,40 @@ bool get_string_into(const char* ns, char* out, std::size_t capacity, std::size_t* out_len); +enum class ScalarReadResult : uint8_t +{ + Ok, + Missing, + Invalid, + Unavailable +}; +// ESP strict projection reads; failed reads reset output and never use defaults. +ScalarReadResult read_int_into(const char* ns, const char* key, int32_t* out); +ScalarReadResult read_uint_into(const char* ns, const char* key, uint32_t* out); +ScalarReadResult read_u8_into(const char* ns, const char* key, uint8_t* out); +ScalarReadResult read_bool_into(const char* ns, const char* key, bool* out); + +enum class StringReadResult : uint8_t +{ + Ok, + Missing, + Invalid, + Unavailable +}; +// ESP strict projection API: fallback is safe only for Missing. +StringReadResult read_string_into(const char* ns, const char* key, char* out, + std::size_t capacity, std::size_t* out_len); +enum class BlobReadResult : uint8_t +{ + Ok, + Missing, + Invalid, + Unavailable +}; +// ESP strict counterpart: empty is valid; only Missing permits a default. +// Uses the caller's buffer and reports zero output length on every failure. +BlobReadResult read_blob_into(const char* ns, const char* key, void* out, + std::size_t capacity, std::size_t* out_len); bool get_blob(const char* ns, const char* key, std::vector& out); bool get_blob_into(const char* ns, const char* key, diff --git a/modules/core_sys/include/platform/ui/tracker_access_gate.h b/modules/core_sys/include/platform/ui/tracker_access_gate.h new file mode 100644 index 00000000..365a3b44 --- /dev/null +++ b/modules/core_sys/include/platform/ui/tracker_access_gate.h @@ -0,0 +1,44 @@ +#pragma once +#include + +namespace platform::ui::tracker +{ +class TrackerAccessGate +{ + public: + bool tryAcquire() { return !held_.test_and_set(std::memory_order_acquire); } + void release() { held_.clear(std::memory_order_release); } + + private: + std::atomic_flag held_ = ATOMIC_FLAG_INIT; +}; +static_assert(sizeof(TrackerAccessGate) == 1, "Tracker gate must stay a single lock-free flag"); + +struct TrackerControlState +{ + TrackerAccessGate access; + std::atomic_bool stop_requested{false}; +}; +static_assert(sizeof(TrackerControlState) == 2, "Tracker control flags must share one compact object"); + +class TrackerGateScope +{ + public: + explicit TrackerGateScope(TrackerAccessGate& gate) : gate_(gate.tryAcquire() ? &gate : nullptr) {} + ~TrackerGateScope() { release(); } + TrackerGateScope(const TrackerGateScope&) = delete; + TrackerGateScope& operator=(const TrackerGateScope&) = delete; + explicit operator bool() const { return gate_ != nullptr; } + void release() + { + if (gate_) + { + gate_->release(); + gate_ = nullptr; + } + } + + private: + TrackerAccessGate* gate_; +}; +} // namespace platform::ui::tracker diff --git a/modules/core_sys/include/platform/ui/wifi_runtime.h b/modules/core_sys/include/platform/ui/wifi_runtime.h index fc5b4a4b..177e015f 100644 --- a/modules/core_sys/include/platform/ui/wifi_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_runtime.h @@ -75,6 +75,11 @@ bool visit_saved_profiles(bool* enabled, std::size_t* count, SavedProfileVisitor visitor, void* context); +// ESP configuration projection: strict persistent reads, caller-owned staging +// of at least kMaxSavedProfileCount. Failure clears staging and output metadata. +// Does not modify connection caches or migrate legacy credential keys. +bool read_saved_profiles_into(bool* enabled, std::size_t* count, + Config* profiles, std::size_t capacity); bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count); bool apply_enabled(bool enabled); bool suspend_for_external_storage(ExternalStorageSuspension* out_suspension); diff --git a/modules/core_track_sharing/include/track_sharing/admit_publication_intent.h b/modules/core_track_sharing/include/track_sharing/admit_publication_intent.h new file mode 100644 index 00000000..ff8a2518 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/admit_publication_intent.h @@ -0,0 +1,37 @@ +#pragma once +#include "track_sharing/persist_publication_intent.h" +#include "track_sharing/publication_directory_store.h" + +namespace track_sharing +{ +// Caller has authenticated signed-head/blob/current identity evidence. A pin +// survives USB-owner retirement and must be durable before the job journal. +// Crash between these writes leaves only an immutable pin; identical admission +// can resume, while retargeting the same operation fails closed. +template +PublicationStoreResult admitPublicationIntent(Directories& directories, Jobs& jobs, + const PublicationDirectory& directory, PublicationDirectory& directory_verify, + const PublicationJournal& desired, PublicationJournal& current, + PublicationJournal& verify, uint8_t* scratch, size_t capacity) +{ + const auto disjoint = [](const void* a, size_t a_size, const void* b, size_t b_size) + { + return !signingSpansOverlap(reinterpret_cast(a), a_size, + reinterpret_cast(b), b_size); + }; + const void* records[] = {&directory, &directory_verify, &desired, ¤t, &verify}; + const size_t sizes[] = {sizeof(directory), sizeof(directory_verify), sizeof(desired), sizeof(current), sizeof(verify)}; + if (!scratch || capacity < kPublicationJournalBytes || !publicationDirectoryMatches(directory, desired) || + desired.sequence != 1 || desired.phase != PublicationPhase::Accepted || desired.offset) return PublicationStoreResult::Invalid; + for (size_t i = 0; i < 5; ++i) + { + if (!disjoint(records[i], sizes[i], scratch, capacity)) return PublicationStoreResult::Invalid; + for (size_t j = i + 1; j < 5; ++j) + if (!disjoint(records[i], sizes[i], records[j], sizes[j])) return PublicationStoreResult::Invalid; + } + const auto pinned = directories.admit(directory, directory_verify, scratch, capacity); + if (pinned != PublicationStoreResult::Ready) return pinned; + if (!publicationDirectoryMatches(directory_verify, desired) || !publicationDirectoriesEqual(directory, directory_verify)) return PublicationStoreResult::Conflict; + return persistPublicationIntent(jobs, desired, current, verify, scratch, capacity); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/attachment_index.h b/modules/core_track_sharing/include/track_sharing/attachment_index.h new file mode 100644 index 00000000..e39fbe8f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/attachment_index.h @@ -0,0 +1,199 @@ +#pragma once +#include "track_sharing/manifest.h" +#include "track_sharing/sign_manifest.h" + +namespace track_sharing +{ +struct AttachmentEntryView +{ + std::string_view id, mime, reference; + geocaching::ByteView hash; + uint32_t bytes = 0; + uint8_t role = 0; + bool required = false; +}; +static_assert(sizeof(AttachmentEntryView) <= 80, "Attachment entry borrows all strings"); +inline char attachmentLower(char c) { return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; } +inline bool attachmentIdsEqual(std::string_view a, std::string_view b) +{ + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) + if (attachmentLower(a[i]) != attachmentLower(b[i])) return false; + return true; +} +inline bool attachmentMimeValid(std::string_view mime) +{ + const auto slash = mime.find('/'); + if (!slash || slash == mime.npos || slash + 1 == mime.size()) return false; + for (size_t i = 0; i < mime.size(); ++i) + { + const char c = attachmentLower(mime[i]); + if (i == slash) continue; + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '!' || c == '#' || c == '$' || c == '&' || c == '^' || c == '_' || c == '.' || c == '+' || c == '-')) return false; + } + return true; +} +inline bool attachmentReferenceValid(std::string_view reference) +{ + if (!geocaching::protocol::validRecordText(reference, false, true)) return false; + const bool http = reference.size() >= 7 && attachmentIdsEqual(reference.substr(0, 7), "http://"); + const bool https = reference.size() >= 8 && attachmentIdsEqual(reference.substr(0, 8), "https://"); + if (http || https) + { + const auto authority = reference.substr(https ? 8 : 7); + const auto end = authority.find_first_of("/?#"); + const auto host = authority.substr(0, end); + if (host.empty() || host.find('@') != host.npos) return false; + for (const auto c : reference) + if (c == '\\' || static_cast(c) <= 32) return false; + return true; // exact reference preserved; never fetched by this decoder + } + const auto end = reference.find_first_of("?#"); + const auto path = reference.substr(0, end); + if (path.empty() || path.front() == '/' || path.front() == '\\') return false; + size_t component_size = 0; + bool dots_only = true; + for (size_t i = 0; i <= path.size(); ++i) + { + if (i == path.size() || path[i] == '/') + { + if (!component_size || (dots_only && component_size <= 2)) return false; + component_size = 0; + dots_only = true; + continue; + } + unsigned char c = static_cast(path[i]); + if (c == '%') + { + const auto digit = [](char value) -> int + { value = attachmentLower(value); return value >= '0' && value <= '9' ? value - '0' : value >= 'a' && value <= 'f' ? value - 'a' + 10 : -1; }; + if (i + 2 >= path.size() || digit(path[i + 1]) < 0 || digit(path[i + 2]) < 0) return false; + c = static_cast(digit(path[i + 1]) * 16 + digit(path[i + 2])); + i += 2; + if (c == '/') return false; + } + if (c == '\\' || c == ':' || c < 32 || c == 127) return false; + ++component_size; + if (c != '.') dots_only = false; + } + return true; +} +inline bool readAttachmentEntry(geocaching::protocol::CmpReader& reader, AttachmentEntryView& output) +{ + output = {}; + size_t fields = 0; + uint64_t value = 0; + if (!reader.array(fields, 7) || fields != 7 || !reader.text(output.id, 48) || output.id.empty()) return false; + for (const auto c : output.id) + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_')) return false; + if (!reader.binary(output.hash, 32) || output.hash.size != 32 || !reader.unsignedInteger(value) || !value || value > 8388608) return false; + output.bytes = static_cast(value); + if (!reader.text(output.mime, 96) || !attachmentMimeValid(output.mime) || !reader.text(output.reference, 4096) || !attachmentReferenceValid(output.reference) || + !reader.unsignedInteger(value) || value > 3) return false; + output.role = static_cast(value); + if ((output.role != 3 || (output.mime.size() >= 6 && attachmentIdsEqual(output.mime.substr(0, 6), "image/"))) && output.bytes > 4194304) return false; + if (!reader.unsignedInteger(value) || value > 1) return false; + output.required = value == 1; // CMP1 uses constrained u8, not MessagePack bool + return true; +} +inline bool decodeAttachmentEntry(geocaching::ByteView raw, AttachmentEntryView& output) +{ + geocaching::protocol::CmpReader reader(raw); + return raw.data && raw.size && raw.size <= 4352 && readAttachmentEntry(reader, output) && reader.finished(); +} + +// Borrowed immutable backing (e.g. admitted PSRAM). Cursor retains no entry +// list; duplicate checking scans earlier entries rather than copying strings. +// File-stream integration must lend bounded entry windows under the same lease. +class AttachmentIndexCursor +{ + public: + explicit AttachmentIndexCursor(geocaching::ByteView raw) : reader_(geocaching::ByteView{}) { begin(raw); } + bool begin(geocaching::ByteView raw) + { + raw_ = raw; + reader_ = geocaching::protocol::CmpReader(raw); + kml_hash_ = {}; + entries_start_ = count_ = seen_ = required_ = 0; + total_ = kml_bytes_ = 0; + size_t fields = 0; + uint64_t value = 0; + valid_ = raw.data && raw.size && raw.size <= kMaxAttachmentIndexBytes && reader_.array(fields, 4) && fields == 4 && + reader_.unsignedInteger(value) && value == 1 && reader_.binary(kml_hash_, 32) && kml_hash_.size == 32 && + reader_.unsignedInteger(value) && value && value <= 8388608; + if (!valid_) return false; + kml_bytes_ = static_cast(value); + valid_ = reader_.array(count_, kMaxTrackAttachments) && count_ > 0; + entries_start_ = reader_.position(); + total_ = raw.size + kml_bytes_; + return valid_; + } + bool next(AttachmentEntryView& output) + { + output = {}; + if (!valid_ || seen_ >= count_) return false; + if (!readAttachmentEntry(reader_, output)) return fail(); + geocaching::protocol::CmpReader earlier({raw_.data + entries_start_, raw_.size - entries_start_}); + AttachmentEntryView previous; + for (size_t i = 0; i < seen_; ++i) + if (!readAttachmentEntry(earlier, previous) || attachmentIdsEqual(previous.id, output.id) || previous.reference == output.reference) return fail(); + total_ += output.bytes; + if (total_ > kMaxTrackPackageBytes) return fail(); + ++seen_; + if (output.required) ++required_; + return true; + } + bool finish() const { return valid_ && seen_ == count_ && reader_.finished() && total_ <= kMaxTrackPackageBytes; } + geocaching::ByteView kmlHash() const { return kml_hash_; } + uint32_t kmlBytes() const { return kml_bytes_; } + size_t count() const { return count_; } + size_t requiredCount() const { return required_; } + uint32_t packageBytes() const { return static_cast(total_); } + + private: + bool fail() + { + valid_ = false; + return false; + } + geocaching::ByteView raw_, kml_hash_; + geocaching::protocol::CmpReader reader_; + size_t entries_start_ = 0, count_ = 0, seen_ = 0, required_ = 0; + uint64_t total_ = 0; + uint32_t kml_bytes_ = 0; + bool valid_ = false; +}; +static_assert(sizeof(AttachmentIndexCursor) <= 128, "Index cursor owns no entries or payload buffers"); +// Caller lends hash output, cursor and entry scratch; no large automatic views. +// Binding validity is not author proof, resource availability or install proof. +template +bool validateAttachmentIndexBinding(geocaching::ByteView raw, const AttachmentIndexBinding& binding, + geocaching::ByteView kml_hash, uint32_t kml_bytes, Hash& hash, uint8_t digest[32], + AttachmentIndexCursor& cursor, AttachmentEntryView& entry) +{ + if (!raw.data || !digest || !binding.hash.data || binding.hash.size != 32 || !kml_hash.data || kml_hash.size != 32 || + binding.bytes != raw.size || !binding.count || binding.count > kMaxTrackAttachments || + signingSpansOverlap(raw.data, raw.size, digest, 32) || signingSpansOverlap(binding.hash.data, 32, digest, 32) || + signingSpansOverlap(kml_hash.data, 32, digest, 32) || + signingSpansOverlap(raw.data, raw.size, reinterpret_cast(&cursor), sizeof(cursor)) || + signingSpansOverlap(raw.data, raw.size, reinterpret_cast(&entry), sizeof(entry)) || + signingSpansOverlap(digest, 32, reinterpret_cast(&cursor), sizeof(cursor)) || + signingSpansOverlap(digest, 32, reinterpret_cast(&entry), sizeof(entry))) return false; + std::memset(digest, 0, 32); + if (!cursor.begin(raw) || cursor.count() != binding.count || cursor.kmlBytes() != kml_bytes || + std::memcmp(cursor.kmlHash().data, kml_hash.data, 32)) return false; + while (cursor.next(entry)) + { + } + if (!cursor.finish()) return false; + const bool ok = hash.start() && hash.update(raw.data, raw.size) && hash.finish(digest); + const bool matched = ok && !std::memcmp(binding.hash.data, digest, 32); + hash.reset(); + if (!matched) + { + std::memset(digest, 0, 32); + return false; + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/attachment_index_file_check.h b/modules/core_track_sharing/include/track_sharing/attachment_index_file_check.h new file mode 100644 index 00000000..71741ef3 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/attachment_index_file_check.h @@ -0,0 +1,107 @@ +#pragma once +#include "track_sharing/attachment_index_stream.h" +#include "track_sharing/file_integrity.h" + +namespace track_sharing +{ +enum class AttachmentIndexFileResult : uint8_t +{ + Idle, + Pending, + Entry, + Ready, + Busy, + Invalid, + HashMismatch, + StorageFailure, + Cancelled +}; +// Single-use, caller-admitted workspace (PSRAM on supported ESP boards). I/O +// handles close before yielding. Caller keeps the immutable source/media lease +// and a separate resource-hash context while this index hash remains active. +// Entry is provisional: no publication/install proof until index Ready AND +// every required resource's independently verified bytes match its declaration. +template +class AttachmentIndexFileCheck +{ + public: + AttachmentIndexFileCheck(Io& io, Hash& hash, uint8_t* window, size_t capacity, + AttachmentFingerprint* fingerprints, size_t slots, + const AttachmentIndexBinding& binding, geocaching::ByteView kml_hash, uint32_t kml_bytes) + : integrity_(io, hash), stream_(window, capacity, fingerprints, slots, kml_hash, kml_bytes, binding.bytes, binding.count), + expected_(binding) + { + const auto* self = reinterpret_cast(this); + if (!stream_.admitted() || signingSpansOverlap(window, capacity, self, sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints), self, sizeof(*this)) || + signingSpansOverlap(kml_hash.data, kml_hash.size, self, sizeof(*this)) || + signingSpansOverlap(binding.hash.data, binding.hash.size, self, sizeof(*this)) || + signingSpansOverlap(binding.hash.data, binding.hash.size, window, capacity) || + signingSpansOverlap(binding.hash.data, binding.hash.size, reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints))) + result_ = AttachmentIndexFileResult::Invalid; + } + AttachmentIndexFileResult begin(const char* path) + { + if (result_ != AttachmentIndexFileResult::Idle) return AttachmentIndexFileResult::Busy; + if (!expected_.hash.data || expected_.hash.size != 32) return result_ = AttachmentIndexFileResult::Invalid; + const auto started = integrity_.begin(path, expected_.bytes, expected_.hash.data); + if (started != FileIntegrityResult::Pending) return result_ = AttachmentIndexFileResult::Invalid; + return result_ = AttachmentIndexFileResult::Pending; + } + AttachmentIndexFileResult step() + { + if (result_ != AttachmentIndexFileResult::Pending && result_ != AttachmentIndexFileResult::Entry) return result_; + if (stream_.entry()) return result_ = AttachmentIndexFileResult::Entry; + if (chunk_position_ < chunk_size_) + { + size_t consumed = 0; + const auto parsed = stream_.feed(chunk_ + chunk_position_, chunk_size_ - chunk_position_, consumed); + chunk_position_ += consumed; + if (parsed == AttachmentStreamResult::Invalid) return stop(AttachmentIndexFileResult::Invalid); + if (parsed == AttachmentStreamResult::Entry) return result_ = AttachmentIndexFileResult::Entry; + return result_ = AttachmentIndexFileResult::Pending; + } + const auto before = integrity_.checkedBytes(); + const auto checked = integrity_.step(chunk_, sizeof(chunk_)); + chunk_size_ = integrity_.checkedBytes() - before; + chunk_position_ = 0; + if (checked == FileIntegrityResult::Pending) return result_ = AttachmentIndexFileResult::Pending; + if (checked == FileIntegrityResult::Ready) + return result_ = stream_.finish() ? AttachmentIndexFileResult::Ready : AttachmentIndexFileResult::Invalid; + return result_ = checked == FileIntegrityResult::HashMismatch ? AttachmentIndexFileResult::HashMismatch : checked == FileIntegrityResult::Invalid ? AttachmentIndexFileResult::Invalid + : AttachmentIndexFileResult::StorageFailure; + } + const AttachmentEntryView* entry() const { return result_ == AttachmentIndexFileResult::Entry ? stream_.entry() : nullptr; } + bool advance() + { + if (result_ != AttachmentIndexFileResult::Entry || !stream_.advance()) return false; + result_ = AttachmentIndexFileResult::Pending; + return true; + } + AttachmentIndexFileResult cancel() + { + if (result_ == AttachmentIndexFileResult::Pending || result_ == AttachmentIndexFileResult::Entry) return stop(AttachmentIndexFileResult::Cancelled); + return result_; + } + bool ready() const { return result_ == AttachmentIndexFileResult::Ready; } + bool matchesSource(const char* path, const AttachmentIndexBinding& binding) const + { + return ready() && binding.count == expected_.count && binding.hash.size == 32 && integrity_.matchesSource(path, binding.bytes, binding.hash.data); + } + uint32_t packageBytes() const { return stream_.packageBytes(); } + + private: + AttachmentIndexFileResult stop(AttachmentIndexFileResult result) + { + integrity_.cancel(); + result_ = result; + return result_; + } + FileIntegrityCheck integrity_; + AttachmentIndexStream stream_; + AttachmentIndexBinding expected_; + uint8_t chunk_[320]{}; + size_t chunk_size_ = 0, chunk_position_ = 0; + AttachmentIndexFileResult result_ = AttachmentIndexFileResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/attachment_index_stream.h b/modules/core_track_sharing/include/track_sharing/attachment_index_stream.h new file mode 100644 index 00000000..1dbe0d8d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/attachment_index_stream.h @@ -0,0 +1,196 @@ +#pragma once +#include "track_sharing/attachment_index.h" + +namespace track_sharing +{ +struct AttachmentFingerprint +{ + uint64_t id = 0, reference = 0; +}; +enum class AttachmentStreamResult : uint8_t +{ + Pending, + Entry, + Parsed, + Invalid +}; +// Caller lends one entry window and transient fingerprint slots (typically +// PSRAM). Entry views are valid only until advance(). No index-sized buffer, +// owned strings, attachment table or hash context. Fingerprints conservatively +// reject collisions; they are never file hashes or author proofs. +class AttachmentIndexStream +{ + public: + AttachmentIndexStream(uint8_t* window, size_t capacity, AttachmentFingerprint* fingerprints, size_t slots, + geocaching::ByteView kml_hash, uint32_t kml_bytes, uint32_t index_bytes, uint8_t count) + : window_(window), capacity_(capacity), fingerprints_(fingerprints), kml_hash_(kml_hash), + kml_bytes_(kml_bytes), index_bytes_(index_bytes), expected_count_(count) + { + if (!window || capacity < 4352 || !fingerprints || slots < kMaxTrackAttachments || !kml_hash.data || kml_hash.size != 32 || + !kml_bytes || kml_bytes > 8388608 || !index_bytes || index_bytes > kMaxAttachmentIndexBytes || !count || count > kMaxTrackAttachments || + signingSpansOverlap(window, capacity, reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints)) || + signingSpansOverlap(window, capacity, kml_hash.data, 32) || + signingSpansOverlap(kml_hash.data, 32, reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints)) || + signingSpansOverlap(window, capacity, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints), reinterpret_cast(this), sizeof(*this))) + phase_ = Phase::Failed; + total_ = static_cast(index_bytes) + kml_bytes; + } + AttachmentStreamResult feed(const uint8_t* bytes, size_t size, size_t& consumed) + { + consumed = 0; + if (phase_ == Phase::Failed) return AttachmentStreamResult::Invalid; + if (phase_ == Phase::Entry) return AttachmentStreamResult::Entry; + if ((!bytes && size) || size > index_bytes_ - received_ || + signingSpansOverlap(bytes, size, window_, capacity_) || + signingSpansOverlap(bytes, size, reinterpret_cast(fingerprints_), kMaxTrackAttachments * sizeof(*fingerprints_))) return fail(); + if (phase_ == Phase::Complete) return size ? fail() : AttachmentStreamResult::Parsed; + for (; consumed < size;) + { + if (used_ >= capacity_) return fail(); + const auto byte = bytes[consumed++]; + ++received_; + window_[used_++] = byte; + if (phase_ == Phase::Header) + { + if ((used_ == 1 && byte != 0x94) || (used_ == 2 && byte != 1) || + (used_ == 3 && byte != 0xc4) || (used_ == 4 && byte != 32)) return fail(); + if (headerReady()) + { + phase_ = Phase::EntryTag; + used_ = 0; + } + else if (used_ >= 64) return fail(); + continue; + } + if (phase_ == Phase::EntryTag) + { + if (byte != 0x97) return fail(); + phase_ = Phase::FieldTag; + field_ = 0; + } + else if (phase_ == Phase::FieldTag) + { + const bool text = field_ == 0 || field_ == 3 || field_ == 4; + limit_ = field_ == 0 ? 48 : field_ == 3 ? 96 + : field_ == 4 ? 4096 + : 32; + if (text || field_ == 1) + { + if (text && byte >= 0xa0 && byte <= 0xbf) + { + if (!payload(byte & 31)) return fail(); + } + else if (text ? byte >= 0xd9 && byte <= 0xdb : byte >= 0xc4 && byte <= 0xc6) + { + length_bytes_ = static_cast(1U << (byte - (text ? 0xd9 : 0xc4))); + length_ = 0; + phase_ = Phase::Length; + } + else return fail(); + } + else if (byte <= 0x7f) completeField(); + else if (byte >= 0xcc && byte <= 0xcf) + { + remaining_ = 1U << (byte - 0xcc); + phase_ = Phase::Payload; + } + else return fail(); + } + else if (phase_ == Phase::Length) + { + length_ = (length_ << 8) | byte; + if (!--length_bytes_ && !payload(length_)) return fail(); + } + else if (phase_ == Phase::Payload) + { + if (!--remaining_) completeField(); + } + if (phase_ == Phase::Decode) + { + if (!decodeAttachmentEntry({window_, used_}, entry_)) return fail(); + const auto id = fingerprint(entry_.id, true), reference = fingerprint(entry_.reference, false); + for (unsigned i = 0; i < seen_; ++i) + if (fingerprints_[i].id == id || fingerprints_[i].reference == reference) return fail(); + fingerprints_[seen_] = {id, reference}; + total_ += entry_.bytes; + if (total_ > kMaxTrackPackageBytes) return fail(); + ++seen_; + if (entry_.required) ++required_; + phase_ = Phase::Entry; + return AttachmentStreamResult::Entry; + } + } + return AttachmentStreamResult::Pending; + } + const AttachmentEntryView* entry() const { return phase_ == Phase::Entry ? &entry_ : nullptr; } + bool admitted() const { return phase_ != Phase::Failed; } + bool advance() + { + if (phase_ != Phase::Entry) return false; + entry_ = {}; + used_ = 0; + phase_ = seen_ == expected_count_ ? Phase::Complete : Phase::EntryTag; + return true; + } + bool finish() const { return phase_ == Phase::Complete && received_ == index_bytes_ && seen_ == expected_count_ && total_ <= kMaxTrackPackageBytes; } + uint32_t consumedBytes() const { return received_; } + uint32_t packageBytes() const { return static_cast(total_); } + uint8_t requiredCount() const { return required_; } + + private: + enum class Phase : uint8_t + { + Header, + EntryTag, + FieldTag, + Length, + Payload, + Decode, + Entry, + Complete, + Failed + }; + bool headerReady() + { + geocaching::protocol::CmpReader reader({window_, used_}); + size_t fields = 0, count = 0; + uint64_t version = 0, bytes = 0; + geocaching::ByteView kml; + return reader.array(fields, 4) && fields == 4 && reader.unsignedInteger(version) && version == 1 && + reader.binary(kml, 32) && kml.size == 32 && reader.unsignedInteger(bytes) && bytes == kml_bytes_ && + reader.array(count, kMaxTrackAttachments) && count == expected_count_ && reader.finished() && !std::memcmp(kml.data, kml_hash_.data, 32); + } + bool payload(uint32_t bytes) + { + if (bytes > limit_ || bytes > capacity_ - used_) return false; + remaining_ = bytes; + if (!bytes) completeField(); + else phase_ = Phase::Payload; + return true; + } + void completeField() { phase_ = ++field_ == 7 ? Phase::Decode : Phase::FieldTag; } + AttachmentStreamResult fail() + { + phase_ = Phase::Failed; + entry_ = {}; + return AttachmentStreamResult::Invalid; + } + static uint64_t fingerprint(std::string_view value, bool folded) + { + uint64_t hash = 14695981039346656037ULL; + for (char c : value) hash = (hash ^ static_cast(folded ? attachmentLower(c) : c)) * 1099511628211ULL; + return hash; + } + uint8_t* window_; + size_t capacity_, used_ = 0; + AttachmentFingerprint* fingerprints_; + geocaching::ByteView kml_hash_; + AttachmentEntryView entry_; + uint64_t total_ = 0; + uint32_t kml_bytes_, index_bytes_, received_ = 0, remaining_ = 0, length_ = 0, limit_ = 0; + uint8_t expected_count_, seen_ = 0, required_ = 0, field_ = 0, length_bytes_ = 0; + Phase phase_ = Phase::Header; +}; +static_assert(sizeof(AttachmentIndexStream) <= 208, "Stream owns only control state and a borrowed entry view"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/attachment_package_file_check.h b/modules/core_track_sharing/include/track_sharing/attachment_package_file_check.h new file mode 100644 index 00000000..9b8a6f18 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/attachment_package_file_check.h @@ -0,0 +1,120 @@ +#pragma once +#include "track_sharing/attachment_index_file_check.h" + +namespace track_sharing +{ +enum class AttachmentResolveResult : uint8_t +{ + Ready, + Missing, + Invalid, + StorageFailure +}; +enum class AttachmentPackageResult : uint8_t +{ + Idle, + Pending, + Ready, + Busy, + Invalid, + MissingRequired, + HashMismatch, + StorageFailure, + Cancelled +}; +// The platform lends immutable media/upload leases and two distinct hash +// contexts. Resolver maps an index ordinal to an authenticated sealed upload; +// references in KML never become filesystem paths. This job performs no writes. +// Ready proves index + available resources, not KML geometry/signature/install. +template +class AttachmentPackageFileCheck +{ + public: + AttachmentPackageFileCheck(Io& io, Hash& index_hash, Hash& resource_hash, Resolver& resolver, + uint8_t* window, size_t capacity, AttachmentFingerprint* fingerprints, size_t slots, + const AttachmentIndexBinding& binding, geocaching::ByteView kml_hash, uint32_t kml_bytes) + : index_(io, index_hash, window, capacity, fingerprints, slots, binding, kml_hash, kml_bytes), + resource_(io, resource_hash), resolver_(resolver) + { + const auto* self = reinterpret_cast(this); + if (&index_hash == &resource_hash || + signingSpansOverlap(window, capacity, self, sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints), self, sizeof(*this)) || + signingSpansOverlap(binding.hash.data, binding.hash.size, self, sizeof(*this)) || + signingSpansOverlap(kml_hash.data, kml_hash.size, self, sizeof(*this))) result_ = AttachmentPackageResult::Invalid; + } + AttachmentPackageResult begin(const char* index_path) + { + if (result_ != AttachmentPackageResult::Idle) return result_ == AttachmentPackageResult::Invalid ? result_ : AttachmentPackageResult::Busy; + if (index_.begin(index_path) != AttachmentIndexFileResult::Pending) return result_ = AttachmentPackageResult::Invalid; + return result_ = AttachmentPackageResult::Pending; + } + AttachmentPackageResult step() + { + if (result_ != AttachmentPackageResult::Pending) return result_; + if (resource_active_) + { + const auto checked = resource_.step(scratch_, sizeof(scratch_)); + if (checked == FileIntegrityResult::Pending) return result_; + resource_active_ = false; + if (checked != FileIntegrityResult::Ready) + return stop(checked == FileIntegrityResult::HashMismatch ? AttachmentPackageResult::HashMismatch : checked == FileIntegrityResult::Invalid ? AttachmentPackageResult::Invalid + : AttachmentPackageResult::StorageFailure); + available_ |= uint64_t(1) << ordinal_; + return nextEntry(); + } + const auto checked = index_.step(); + if (checked == AttachmentIndexFileResult::Pending) return result_; + if (checked == AttachmentIndexFileResult::Ready) return result_ = AttachmentPackageResult::Ready; + if (checked != AttachmentIndexFileResult::Entry) + return stop(checked == AttachmentIndexFileResult::HashMismatch ? AttachmentPackageResult::HashMismatch : checked == AttachmentIndexFileResult::StorageFailure ? AttachmentPackageResult::StorageFailure + : AttachmentPackageResult::Invalid); + const auto* entry = index_.entry(); + if (!entry || ordinal_ >= kMaxTrackAttachments) return stop(AttachmentPackageResult::Invalid); + if (entry->required) required_ |= uint64_t(1) << ordinal_; + path_[0] = 0; + const auto resolved = resolver_.resolve(ordinal_, *entry, path_, sizeof(path_)); + if (resolved == AttachmentResolveResult::Missing) + return entry->required ? stop(AttachmentPackageResult::MissingRequired) : nextEntry(); + if (resolved != AttachmentResolveResult::Ready) + return stop(resolved == AttachmentResolveResult::StorageFailure ? AttachmentPackageResult::StorageFailure : AttachmentPackageResult::Invalid); + if (resource_.begin(path_, entry->bytes, entry->hash.data) != FileIntegrityResult::Pending) + return stop(AttachmentPackageResult::Invalid); + resource_active_ = true; + return result_; + } + AttachmentPackageResult cancel() + { + return result_ == AttachmentPackageResult::Pending ? stop(AttachmentPackageResult::Cancelled) : result_; + } + bool ready() const { return result_ == AttachmentPackageResult::Ready && index_.ready() && (available_ & required_) == required_; } + bool matchesIndex(const char* path, const AttachmentIndexBinding& binding) const { return ready() && index_.matchesSource(path, binding); } + bool allAvailable() const { return ready() && (ordinal_ == 64 ? available_ == UINT64_MAX : available_ == ((uint64_t(1) << ordinal_) - 1)); } + uint64_t availability() const { return ready() ? available_ : 0; } + uint32_t packageBytes() const { return ready() ? index_.packageBytes() : 0; } + + private: + AttachmentPackageResult nextEntry() + { + if (!index_.advance()) return stop(AttachmentPackageResult::Invalid); + ++ordinal_; + return result_; + } + AttachmentPackageResult stop(AttachmentPackageResult result) + { + index_.cancel(); + resource_.cancel(); + resource_active_ = false; + return result_ = result; + } + AttachmentIndexFileCheck index_; + FileIntegrityCheck resource_; + Resolver& resolver_; + char path_[PathCapacity]{}; + uint8_t scratch_[320]{}; + uint64_t available_ = 0, required_ = 0; + uint8_t ordinal_ = 0; + AttachmentPackageResult result_ = AttachmentPackageResult::Idle; + bool resource_active_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/attachment_upload_bindings.h b/modules/core_track_sharing/include/track_sharing/attachment_upload_bindings.h new file mode 100644 index 00000000..ed3155c6 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/attachment_upload_bindings.h @@ -0,0 +1,28 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +constexpr size_t kAttachmentBindingsBytes = 1100; +struct AttachmentUploadBindingsView +{ + geocaching::ByteView index_id, ids; +}; +// [1,index_upload_id:bin16,packed_resource_ids:bin(count*16)]. Zero slots +// explicitly omit optional resources; indexed order is bound by index SHA. +inline bool decodeAttachmentUploadBindings(geocaching::ByteView raw, uint8_t count, AttachmentUploadBindingsView& output) +{ + if (signingSpansOverlap(raw.data, raw.size, reinterpret_cast(&output), sizeof(output))) return false; + output = {}; + if (!raw.data || !raw.size || raw.size > kAttachmentBindingsBytes || !count || count > 64) return false; + geocaching::protocol::CmpReader reader(raw); + size_t fields = 0; + uint64_t version = 0; + if (!reader.array(fields, 3) || fields != 3 || !reader.unsignedInteger(version) || version != 1 || + !reader.binary(output.index_id, 16) || output.index_id.size != 16 || !reader.binary(output.ids, 1024) || + output.ids.size != size_t(count) * 16 || !reader.finished()) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= output.index_id.data[i]; + return bits != 0; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/bound_sealed_upload.h b/modules/core_track_sharing/include/track_sharing/bound_sealed_upload.h new file mode 100644 index 00000000..bc22df6f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/bound_sealed_upload.h @@ -0,0 +1,32 @@ +#pragma once +#include "track_sharing/sign_manifest.h" +#include "track_sharing/upload_journal_store.h" +#include "track_sharing/upload_paths.h" +namespace track_sharing +{ +template +JournalStoreResult loadBoundSealedUpload(Io& io, geocaching::ByteView id, UploadKind kind, uint32_t generation, const uint8_t* author, + UploadJournal& output, UploadJournal& verify, char* path, uint8_t* scratch, size_t capacity) +{ + if (!id.data || id.size != 16 || !generation || !author || !path || !scratch || capacity < 256 || &output == &verify || !uploadByteLimit(kind)) return JournalStoreResult::Invalid; + const auto overlaps = [](const void* a, size_t as, const void* b, size_t bs) + { return signingSpansOverlap(static_cast(a), as, static_cast(b), bs); }; + for (const auto source : {id, geocaching::ByteView{author, 32}, geocaching::ByteView{reinterpret_cast(&io), sizeof(io)}}) + if (overlaps(source.data, source.size, path, 64) || overlaps(source.data, source.size, scratch, capacity) || + overlaps(source.data, source.size, &output, sizeof(output)) || overlaps(source.data, source.size, &verify, sizeof(verify))) return JournalStoreResult::Invalid; + if (overlaps(&output, sizeof(output), &verify, sizeof(verify)) || overlaps(path, 64, scratch, capacity) || + overlaps(&output, sizeof(output), path, 64) || overlaps(&verify, sizeof(verify), path, 64) || + overlaps(&output, sizeof(output), scratch, capacity) || overlaps(&verify, sizeof(verify), scratch, capacity)) return JournalStoreResult::Invalid; + auto* a = reinterpret_cast(scratch + capacity - 128); + auto* b = a + 64; + if (!resolveUploadPath(id.data, 16, UploadFile::Candidate, path, 64, kind) || + !resolveUploadPath(id.data, 16, UploadFile::JournalA, a, 64, kind) || !resolveUploadPath(id.data, 16, UploadFile::JournalB, b, 64, kind)) return JournalStoreResult::Invalid; + UploadJournalStore store(io, a, b); + const auto loaded = store.load(output, verify, scratch, capacity - 128); + if (loaded != JournalStoreResult::Ready) return loaded; + return uploadJournalValid(output) && output.phase == UploadPhase::Sealed && output.total <= uploadByteLimit(kind) && output.generation == generation && + !std::memcmp(output.id, id.data, 16) && !std::memcmp(output.author_hash, author, 32) + ? JournalStoreResult::Ready + : JournalStoreResult::Conflict; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/cancel_publication.h b/modules/core_track_sharing/include/track_sharing/cancel_publication.h new file mode 100644 index 00000000..c10763e2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/cancel_publication.h @@ -0,0 +1,29 @@ +#pragma once +#include "track_sharing/publication_journal_store.h" +namespace track_sharing +{ +// Caller holds current identity, media and exclusive callback/path serialization. +// Cancelling local upload is not withdrawal of an already sent publication. +template +PublicationStoreResult cancelPublicationBeforePublish(Jobs& jobs,PublicationJournal& current, + PublicationJournal& next,PublicationJournal& verify,uint32_t generation,geocaching::ByteView author_hash, + uint8_t* scratch,size_t capacity) +{ + if(¤t==&next || ¤t==&verify || &next==&verify || !publicationJournalValid(current) || + !generation || !author_hash.data || author_hash.size!=32 || !scratch || capacity(¤t),sizeof(current)}, + {reinterpret_cast(&next),sizeof(next)},{reinterpret_cast(&verify),sizeof(verify)}}) + if(signingSpansOverlap(record.data,record.size,scratch,capacity) || signingSpansOverlap(record.data,record.size,author_hash.data,author_hash.size)) + return PublicationStoreResult::Invalid; + if(generation!=current.generation || std::memcmp(author_hash.data,current.author_hash,32))return PublicationStoreResult::Conflict; + if(current.phase==PublicationPhase::Cancelled)return jobs.validateCurrent(current,verify,scratch,capacity); + if(current.phase!=PublicationPhase::Accepted && current.phase!=PublicationPhase::Uploading && current.phase!=PublicationPhase::FileStored) + return PublicationStoreResult::Conflict; + if(current.sequence==UINT64_MAX)return PublicationStoreResult::Conflict; + next=current;++next.sequence;next.phase=PublicationPhase::Cancelled; + const auto written=jobs.advance(current,next,verify,scratch,capacity); + if(written==PublicationStoreResult::Ready)current=next; + return written; +} +} diff --git a/modules/core_track_sharing/include/track_sharing/candidate_revision.h b/modules/core_track_sharing/include/track_sharing/candidate_revision.h new file mode 100644 index 00000000..05e9a6cb --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/candidate_revision.h @@ -0,0 +1,18 @@ +#pragma once +#include "track_sharing/manifest_chain.h" +#include "track_sharing/verify_manifest.h" +namespace track_sharing +{ +// Parent must be the exact verified revision selected by durable reservation. +// This checks a local unsigned candidate; it does not reserve a revision or +// authorize signing. Caller retains immutable manifest/parent storage. +inline bool validateCandidateRevision(geocaching::ByteView raw, geocaching::ByteView device_author, + const VerifiedManifestView* parent, ManifestView& candidate) +{ + if (!device_author.data || device_author.size != 64 || (parent && (!parent->verified || &parent->manifest == &candidate)) || + signingSpansOverlap(raw.data, raw.size, reinterpret_cast(&candidate), sizeof(candidate)) || !decodeManifest(raw, candidate)) return false; + if (std::memcmp(candidate.author.data, device_author.data, 64)) return false; + if (!parent) return candidate.revision == 1 && candidate.state == 0 && !candidate.previous.size; + return validateSuccessor(parent->manifest, candidate, parent->revision_hash); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/checked_reservation.h b/modules/core_track_sharing/include/track_sharing/checked_reservation.h new file mode 100644 index 00000000..3c1be6de --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/checked_reservation.h @@ -0,0 +1,45 @@ +#pragma once +#include "track_sharing/create_reservation.h" +#include "track_sharing/object_head.h" +#include "track_sharing/prepare_request.h" +#include "track_sharing/reservation_store.h" +namespace track_sharing +{ +// Caller holds immutable inputs and exclusive current-object lease. Both null +// parent/head require independently verified absence, including orphan state. +template +ReservationStoreResult persistCheckedReservation(Check& check, const PrepareRequest& request, geocaching::ByteView raw, + geocaching::ByteView author, geocaching::ByteView author_hash, uint32_t generation, + const ObjectHead* head, const VerifiedManifestView* parent, Hash& hash, Store& store, + ManifestView& view, RevisionReservation& desired, RevisionReservation& current, + RevisionReservation& verify, uint8_t* scratch, size_t capacity) +{ + if (!check.ready() || request.generation != generation || &desired == ¤t || &desired == &verify || ¤t == &verify || !scratch || capacity < kReservationBytes) return ReservationStoreResult::Invalid; + if ((head == nullptr) != (parent == nullptr)) return ReservationStoreResult::Conflict; + if (head) + { + if (!objectHeadValid(*head) || head->phase != HeadPhase::Committed || head->generation != generation || !parent->verified || + parent->manifest.revision != head->revision || parent->manifest.state != (head->archived ? 1 : 0) || + std::memcmp(parent->object_id, head->object, 32) || std::memcmp(parent->revision_hash, head->hash, 32) || + !request.expected_parent.data || request.expected_parent.size != 32 || std::memcmp(request.expected_parent.data, head->hash, 32)) return ReservationStoreResult::Conflict; + } + else if (request.expected_parent.size) return ReservationStoreResult::Conflict; + if (!createCandidateReservation(raw, author, generation, author_hash, request.operation, parent, hash, view, desired) || !check.matchesCandidate(view)) return ReservationStoreResult::Conflict; + auto result = store.load(current, verify, scratch, capacity); + if (result == ReservationStoreResult::Missing) + { + result = store.initialize(desired, verify, scratch, capacity); + if (result != ReservationStoreResult::Ready) return result; + current = desired; + } + else if (result != ReservationStoreResult::Ready) return result; + if (current.generation != desired.generation || current.revision != desired.revision || std::memcmp(current.operation, desired.operation, 16) || + std::memcmp(current.object, desired.object, 32) || std::memcmp(current.author, desired.author, 32) || + std::memcmp(current.parent, desired.parent, 32) || std::memcmp(current.candidate, desired.candidate, 32)) return ReservationStoreResult::Conflict; + if (current.phase == ReservationPhase::Checked) return store.validateCurrent(current, verify, scratch, capacity); + if (current.phase != ReservationPhase::Reserved) return ReservationStoreResult::Conflict; + result = store.advance(current, ReservationPhase::Checked, verify, scratch, capacity); + if (result == ReservationStoreResult::Ready) current = verify; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/commit_publication_head.h b/modules/core_track_sharing/include/track_sharing/commit_publication_head.h new file mode 100644 index 00000000..311a88df --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/commit_publication_head.h @@ -0,0 +1,33 @@ +#pragma once +#include "track_sharing/persist_signed_head.h" +#include "track_sharing/publication_journal_store.h" +namespace track_sharing +{ +// Durable task admission precedes Committed head. Neither establishes a remote +// receipt. Caller retains verified manifest/reservation, current identity/media +// and exclusive object paths; records and scratch are disjoint. +template +HeadStoreResult commitPublicationHead(Heads& heads,Jobs& jobs,const PublicationJournal& job,PublicationJournal& job_verify, + const VerifiedManifestView& signed_view,const RevisionReservation& reservation, + uint32_t generation,geocaching::ByteView author,geocaching::ByteView author_hash, + ObjectHead& current,ObjectHead& next,ObjectHead& verify,uint8_t* scratch,size_t capacity) +{ + if(¤t==&next||¤t==&verify||&next==&verify||&job==&job_verify||!publicationJournalValid(job)|| + !author_hash.data||author_hash.size!=32||job.generation!=generation||!signed_view.verified|| + job.revision!=signed_view.manifest.revision||job.file_bytes!=signed_view.manifest.file_bytes|| + signed_view.manifest.file_hash.size!=32||!signed_view.manifest.file_hash.data|| + std::memcmp(job.object,signed_view.object_id,32)||std::memcmp(job.revision_hash,signed_view.revision_hash,32)|| + std::memcmp(job.file_hash,signed_view.manifest.file_hash.data,32)||std::memcmp(job.author_hash,author_hash.data,32))return HeadStoreResult::Invalid; + const auto loaded=heads.load(current,verify,scratch,capacity); + if(loaded!=HeadStoreResult::Ready)return loaded; + if(std::memcmp(job.operation,current.operation,16)||!signedHeadMatches(current,signed_view,reservation,generation,author,author_hash))return HeadStoreResult::Conflict; + const auto durable=jobs.validateCurrent(job,job_verify,scratch,capacity); + if(durable!=PublicationStoreResult::Ready)return durable==PublicationStoreResult::StorageFailure?HeadStoreResult::StorageFailure:HeadStoreResult::Conflict; + if(current.phase==HeadPhase::Committed)return HeadStoreResult::Ready; + if(current.sequence==UINT64_MAX)return HeadStoreResult::Conflict; + next=current;++next.sequence;next.phase=HeadPhase::Committed; + const auto result=heads.advance(current,next,verify,scratch,capacity); + if(result==HeadStoreResult::Ready)current=next; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/commit_request.h b/modules/core_track_sharing/include/track_sharing/commit_request.h new file mode 100644 index 00000000..b8fa0d47 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/commit_request.h @@ -0,0 +1,36 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include +#include +namespace track_sharing +{ +struct CommitRequest +{ + geocaching::ByteView operation, object, revision_hash, directory, directory_epoch; + uint32_t generation = 0, revision = 0; +}; +static_assert(sizeof(CommitRequest) <= 96, "Commit request borrows bindings only"); +// No manifest or private key. Decoding does not grant publication authority; +// runtime rechecks signed head, current author, retained blob and directory. +inline bool decodeCommitRequest(geocaching::ByteView body, CommitRequest& output) +{ + const auto source = reinterpret_cast(body.data), target = reinterpret_cast(&output); + if (body.data && (source <= target ? target - source < body.size : source - target < sizeof(output))) return false; + output = {}; + if (!body.data || !body.size || body.size > 133) return false; + geocaching::protocol::CmpReader reader(body); + size_t fields = 0;uint64_t generation = 0, revision = 0; + if (!reader.array(fields, 7) || fields != 7 || !reader.binary(output.operation, 16) || output.operation.size != 16 || + !reader.binary(output.object, 32) || output.object.size != 32 || !reader.binary(output.revision_hash, 32) || output.revision_hash.size != 32 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX || + !reader.binary(output.directory, 16) || output.directory.size != 16 || + !reader.binary(output.directory_epoch, 16) || output.directory_epoch.size != 16 || !reader.finished()) return false; + for (const auto field : {output.operation, output.object, output.revision_hash, output.directory, output.directory_epoch}) + { + uint8_t nonzero = 0;for (size_t i = 0; i < field.size; ++i) nonzero |= field.data[i]; + if (!nonzero) return false; + } + output.generation = uint32_t(generation);output.revision = uint32_t(revision);return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/confirm_ip_publication_head.h b/modules/core_track_sharing/include/track_sharing/confirm_ip_publication_head.h new file mode 100644 index 00000000..e0fd9488 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/confirm_ip_publication_head.h @@ -0,0 +1,58 @@ +#pragma once +#include "track_sharing/persist_signed_head.h" +#include "track_sharing/signed_manifest_store.h" +#include "track_sharing/verify_receipt.h" + +namespace track_sharing +{ +// Computer IP publication has no device RF task. A verified, durably stored +// directory receipt supplies the evidence needed to permit the next revision. +// Caller owns immutable verified views, current identity/media and exclusive +// object/receipt paths. The expected journal is binding metadata, not an RF job. +// Receipt persistence precedes head advancement so an interrupted advance can +// be retried without losing evidence or signing/publishing again. +template +HeadStoreResult confirmIpPublicationHead(Heads& heads, Receipts& receipts, + const VerifiedReceiptView& proof, const PublicationJournal& expected, + geocaching::ByteView request, geocaching::ByteView directory_public, + const VerifiedManifestView& manifest, const RevisionReservation& reservation, + uint32_t generation, geocaching::ByteView author, geocaching::ByteView author_hash, + ObjectHead& current, ObjectHead& next, ObjectHead& verify, + uint8_t* scratch, size_t capacity) +{ + if (¤t == &next || ¤t == &verify || &next == &verify || + !proof.verified || !proof.signature.data || proof.signature.size != 64 || + !proof.receipt.raw.data || !proof.receipt.raw.size || !manifest.verified || + !manifest.manifest.file_hash.data || manifest.manifest.file_hash.size != 32 || + !author_hash.data || author_hash.size != 32 || expected.generation != generation || + (expected.phase != PublicationPhase::Publishing && expected.phase != PublicationPhase::Confirmed) || + !receiptMatchesJob(proof.receipt, expected, request, directory_public, manifest.manifest.state) || + expected.revision != manifest.manifest.revision || expected.file_bytes != manifest.manifest.file_bytes || + std::memcmp(expected.object, manifest.object_id, 32) || + std::memcmp(expected.revision_hash, manifest.revision_hash, 32) || + std::memcmp(expected.file_hash, manifest.manifest.file_hash.data, 32) || + std::memcmp(expected.author_hash, author_hash.data, 32)) return HeadStoreResult::Invalid; + if (!scratch || capacity < 128) return HeadStoreResult::Invalid; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&expected), sizeof(expected)}, + proof.receipt.raw, proof.signature, author, author_hash, + manifest.manifest.raw, manifest.signature}) + if (signingSpansOverlap(input.data, input.size, scratch, capacity)) return HeadStoreResult::Invalid; + + const auto loaded = heads.load(current, verify, scratch, capacity); + if (loaded != HeadStoreResult::Ready) return loaded; + if (std::memcmp(current.operation, expected.operation, 16) || + !signedHeadMatches(current, manifest, reservation, generation, author, author_hash)) return HeadStoreResult::Conflict; + if (current.phase != HeadPhase::Committed && current.sequence == UINT64_MAX) return HeadStoreResult::Conflict; + const auto persisted = receipts.persist(proof.receipt.raw, proof.signature, scratch, capacity); + if (persisted != SignedStoreResult::Ready) + return persisted == SignedStoreResult::StorageFailure ? HeadStoreResult::StorageFailure : HeadStoreResult::Conflict; + // Even a Committed head needs the exact receipt to be durably present. + if (current.phase == HeadPhase::Committed) return HeadStoreResult::Ready; + next = current; + ++next.sequence; + next.phase = HeadPhase::Committed; + const auto advanced = heads.advance(current, next, verify, scratch, capacity); + if (advanced == HeadStoreResult::Ready) current = next; + return advanced; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/confirm_publication.h b/modules/core_track_sharing/include/track_sharing/confirm_publication.h new file mode 100644 index 00000000..6c703606 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/confirm_publication.h @@ -0,0 +1,27 @@ +#pragma once +#include "track_sharing/verify_receipt.h" +#include "track_sharing/publication_journal_store.h" +#include "track_sharing/signed_manifest_store.h" +namespace track_sharing +{ +// Caller keeps verified receipt bytes immutable, expected directory provenance, +// author/media and exclusive canonical paths valid through both durable writes. +template +PublicationStoreResult confirmPublication(Jobs& jobs,Receipts& receipts,const VerifiedReceiptView& proof, + const PublicationJournal& current,PublicationJournal& next,PublicationJournal& verify, + geocaching::ByteView request,geocaching::ByteView directory_public,uint8_t state, + uint8_t* scratch,size_t capacity) +{ + if(¤t==&next||¤t==&verify||&next==&verify||!proof.verified||!proof.signature.data||proof.signature.size!=64|| + !receiptMatchesJob(proof.receipt,current,request,directory_public,state)||current.offset!=current.file_bytes|| + (current.phase!=PublicationPhase::Publishing&¤t.phase!=PublicationPhase::Confirmed))return PublicationStoreResult::Invalid; + if(current.phase!=PublicationPhase::Confirmed&¤t.sequence==UINT64_MAX)return PublicationStoreResult::Conflict; + const auto valid=jobs.validateCurrent(current,verify,scratch,capacity); + if(valid!=PublicationStoreResult::Ready)return valid; + const auto persisted=receipts.persist(proof.receipt.raw,proof.signature,scratch,capacity); + if(persisted!=SignedStoreResult::Ready)return persisted==SignedStoreResult::StorageFailure?PublicationStoreResult::StorageFailure:PublicationStoreResult::Conflict; + if(current.phase==PublicationPhase::Confirmed)return PublicationStoreResult::Ready; + next=current;++next.sequence;next.phase=PublicationPhase::Confirmed; + return jobs.advance(current,next,verify,scratch,capacity); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/coordinate_stream.h b/modules/core_track_sharing/include/track_sharing/coordinate_stream.h new file mode 100644 index 00000000..0f9ad695 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/coordinate_stream.h @@ -0,0 +1,120 @@ +#pragma once +#include "track_sharing/geometry_bounds.h" +#include +#include +namespace track_sharing +{ +enum class CoordinateText : uint8_t +{ + KmlTuples, + GxCoordinate +}; +// XML layer must feed decoded text from the correct namespace/geometry only. +// Fixed numeric-token budget (63 ASCII bytes), one tuple; no point storage. +class CoordinateStream +{ + public: + CoordinateStream(GeometryBounds& bounds, CoordinateText format) : bounds_(bounds), format_(format) {} + bool feed(const char* text, size_t length) + { + if (failed_ || finished_ || (!text && length)) return false; + for (size_t i = 0; i < length; ++i) + { + const char value = text[i]; + if (value == ' ' || value == '\t' || value == '\r' || value == '\n') + { + if (size_ && !number()) return fail(); + if (format_ == CoordinateText::KmlTuples && parts_ && !point()) return fail(); + } + else if (value == ',' && format_ == CoordinateText::KmlTuples) + { + if (!size_ || !number() || parts_ >= 3) return fail(); + comma_ = true; + } + else + { + if (size_ >= 63 || !((value >= '0' && value <= '9') || value == '+' || value == '-' || value == '.' || value == 'e' || value == 'E')) return fail(); + token_[size_++] = value; + comma_ = false; + } + } + return true; + } + bool finish(bool allow_empty_gx = false) + { + if (failed_ || finished_ || comma_ || (size_ && !number()) || (parts_ && !point()) || (!emitted_ && !(allow_empty_gx && format_ == CoordinateText::GxCoordinate))) return fail(); + finished_ = true; + return true; + } + + bool closedRing() const { return finished_ && !failed_ && emitted_ >= 4 && first_[0] == values_[0] && first_[1] == values_[1]; } + char* idleScratch() { return !size_ && !parts_ ? token_ : nullptr; } + + private: + bool fail() + { + failed_ = true; + bounds_.add(INT64_MAX, 0); + return false; + } + bool number() + { + if (parts_ >= 3) return false; + size_t i = 0; + if (token_[i] == '+' || token_[i] == '-') ++i; + unsigned digits = 0; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') + { + ++i; + ++digits; + } + if (i < size_ && token_[i] == '.') + { + ++i; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') + { + ++i; + ++digits; + } + } + if (!digits) return false; + if (i < size_ && (token_[i] == 'e' || token_[i] == 'E')) + { + ++i; + if (i < size_ && (token_[i] == '+' || token_[i] == '-')) ++i; + const auto start = i; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') ++i; + if (i == start) return false; + } + if (i != size_) return false; + token_[size_] = '\0'; + const double value = std::strtod(token_, nullptr); + if (!std::isfinite(value)) return false; + values_[parts_++] = value; + size_ = 0; + return true; + } + bool point() + { + if (comma_ || parts_ < 2 || parts_ > 3 || (format_ == CoordinateText::GxCoordinate && parts_ != 3) || std::abs(values_[0]) > 180 || std::abs(values_[1]) > 90) return false; + if (!bounds_.add(static_cast(std::floor(values_[0] * 1e7 + 0.5)), static_cast(std::floor(values_[1] * 1e7 + 0.5)))) return false; + if (!emitted_) + { + first_[0] = values_[0]; + first_[1] = values_[1]; + } + ++emitted_; + parts_ = 0; + return true; + } + GeometryBounds& bounds_; + double values_[3]{}; + double first_[2]{}; + char token_[64]{}; + uint32_t emitted_ = 0; + uint8_t size_ = 0, parts_ = 0; + CoordinateText format_; + bool comma_ = false, failed_ = false, finished_ = false; +}; +static_assert(sizeof(CoordinateStream) <= 136, "Coordinate parser must not store a point list"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/create_reservation.h b/modules/core_track_sharing/include/track_sharing/create_reservation.h new file mode 100644 index 00000000..7f8cc317 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/create_reservation.h @@ -0,0 +1,34 @@ +#pragma once +#include "track_sharing/candidate_revision.h" +#include "track_sharing/revision_reservation.h" +namespace track_sharing +{ +// Parent is the verified head selected under platform arbitration; nullptr +// requires verified object absence. Creation alone does not persist/reserve it. +template +bool createCandidateReservation(geocaching::ByteView raw, geocaching::ByteView author, uint32_t generation, + geocaching::ByteView author_hash, geocaching::ByteView operation, const VerifiedManifestView* parent, + Hash& hash, ManifestView& view, RevisionReservation& output) +{ + if (!author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || !operation.data || operation.size != 16 || !generation) return false; + const auto overlaps = [&](const uint8_t* source, size_t size) + { return signingSpansOverlap(reinterpret_cast(&output), sizeof(output), source, size); }; + if (overlaps(raw.data, raw.size) || overlaps(author.data, author.size) || overlaps(author_hash.data, author_hash.size) || overlaps(operation.data, operation.size) || + overlaps(reinterpret_cast(&view), sizeof(view)) || overlaps(reinterpret_cast(&hash), sizeof(hash)) || + (parent && (overlaps(reinterpret_cast(parent), sizeof(*parent)) || overlaps(parent->manifest.raw.data, parent->manifest.raw.size) || overlaps(parent->signature.data, parent->signature.size)))) return false; + output = {}; + if (!validateCandidateRevision(raw, author, parent, view)) return false; + output.generation = generation; + output.revision = view.revision; + output.sequence = 1; + std::memcpy(output.operation, operation.data, 16); + std::memcpy(output.author, author_hash.data, 32); + if (view.previous.size) std::memcpy(output.parent, view.previous.data, 32); + if (!deriveManifestHashes(raw, view, hash, output.object, output.candidate) || (parent && std::memcmp(output.object, parent->object_id, 32)) || !reservationValid(output)) + { + output = {}; + return false; + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/current_parent_loader.h b/modules/core_track_sharing/include/track_sharing/current_parent_loader.h new file mode 100644 index 00000000..97cf5595 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/current_parent_loader.h @@ -0,0 +1,96 @@ +#pragma once +#include "track_sharing/object_head_store.h" +#include "track_sharing/object_paths.h" +#include "track_sharing/signed_manifest_reader.h" +#include "track_sharing/verify_manifest.h" +namespace track_sharing +{ +enum class ParentLoadResult : uint8_t +{ + Idle, + Pending, + Ready, + Missing, + Conflict, + Corrupt, + StorageFailure, + Invalid +}; +// Caller lends immutable expected bindings, current-head slots, paths, signed +// bytes and verification workspace, all disjoint under an exclusive media lease. +template +class CurrentParentLoader +{ + public: + CurrentParentLoader(Io& io, Hash& hash, Crypto& crypto, Store& store, ObjectHead& head, ObjectHead& other, + VerifiedManifestView& parent, char* path, uint8_t* raw, size_t capacity) + : reader_(io), hash_(hash), crypto_(crypto), store_(store), head_(head), other_(other), parent_(parent), path_(path), raw_(raw), capacity_(capacity) {} + ParentLoadResult begin(geocaching::ByteView object, geocaching::ByteView expected_parent, geocaching::ByteView author, uint32_t generation, uint8_t* scratch, size_t capacity) + { + if (result_ != ParentLoadResult::Idle) return ParentLoadResult::Invalid; + if (!object.data || object.size != 32 || !expected_parent.data || expected_parent.size != 32 || !author.data || author.size != 64 || !generation || !path_ || !raw_ || capacity_ < 4166) return ParentLoadResult::Invalid; + const auto loaded = store_.load(head_, other_, scratch, capacity); + if (loaded != HeadStoreResult::Ready) + return result_ = loaded == HeadStoreResult::Missing ? ParentLoadResult::Missing : loaded == HeadStoreResult::Corrupt ? ParentLoadResult::Corrupt + : loaded == HeadStoreResult::StorageFailure ? ParentLoadResult::StorageFailure + : ParentLoadResult::Invalid; + if (head_.phase != HeadPhase::Committed || std::memcmp(head_.object, object.data, 32) || std::memcmp(head_.hash, expected_parent.data, 32)) return result_ = ParentLoadResult::Conflict; + if (!resolveObjectPath(object, ObjectFile::SignedManifest, {head_.hash, 32}, path_, kObjectPathCapacity)) return result_ = ParentLoadResult::Invalid; + const auto read = reader_.begin(path_, raw_, capacity_); + if (read != FileIntegrityResult::Pending) return result_ = read == FileIntegrityResult::StorageFailure ? ParentLoadResult::StorageFailure : ParentLoadResult::Invalid; + author_ = author; + generation_ = generation; + return result_ = ParentLoadResult::Pending; + } + ParentLoadResult step(uint8_t* workspace, size_t capacity) + { + if (result_ != ParentLoadResult::Pending) return result_; + const auto read = reader_.step(); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return result_ = ParentLoadResult::StorageFailure; + const auto current = store_.validateCurrent(head_, other_, workspace, capacity); + if (current != HeadStoreResult::Ready) return result_ = current == HeadStoreResult::StorageFailure ? ParentLoadResult::StorageFailure : current == HeadStoreResult::Corrupt ? ParentLoadResult::Corrupt + : ParentLoadResult::Conflict; + if (verifyManifest(reader_.bytes(), crypto_, hash_, workspace, capacity, parent_, author_) != ManifestVerification::Valid) return result_ = ParentLoadResult::Invalid; + if (parent_.manifest.revision != head_.revision || parent_.manifest.state != (head_.archived ? 1 : 0) || + std::memcmp(parent_.object_id, head_.object, 32) || std::memcmp(parent_.revision_hash, head_.hash, 32)) + { + parent_.verified = false; + return result_ = ParentLoadResult::Conflict; + } + if (head_.generation != generation_) + { + const auto rebound = store_.rebindCommittedGeneration(head_, other_, generation_, workspace, capacity); + if (rebound != HeadStoreResult::Ready) + { + parent_.verified = false; + return result_ = rebound == HeadStoreResult::StorageFailure ? ParentLoadResult::StorageFailure : rebound == HeadStoreResult::Corrupt ? ParentLoadResult::Corrupt + : ParentLoadResult::Conflict; + } + } + return result_ = ParentLoadResult::Ready; + } + void cancel() + { + reader_.cancel(); + parent_.verified = false; + result_ = ParentLoadResult::Invalid; + } + bool ready() const { return result_ == ParentLoadResult::Ready && parent_.verified; } + + private: + SignedManifestReader reader_; + Hash& hash_; + Crypto& crypto_; + Store& store_; + ObjectHead& head_; + ObjectHead& other_; + VerifiedManifestView& parent_; + char* path_; + uint8_t* raw_; + size_t capacity_; + geocaching::ByteView author_; + uint32_t generation_ = 0; + ParentLoadResult result_ = ParentLoadResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/device_signer.h b/modules/core_track_sharing/include/track_sharing/device_signer.h new file mode 100644 index 00000000..03244316 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/device_signer.h @@ -0,0 +1,23 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +// Shared implementation of the device signing port. Publication authorization +// remains the caller's responsibility. No identity/export workspace is owned. +template +chat::DeviceAuthorResult signDeviceTrackManifest(Identity& identity, const uint8_t* raw, size_t size, uint32_t generation, ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) +{ + using Result = chat::DeviceAuthorResult; + if (!raw || !size || size > 4096 || !generation || !workspace || capacity < sizeof("TrailMate.Track.Manifest.v1") + size || !signature || signature_capacity < 64 || + signingSpansOverlap(raw, size, workspace, capacity) || signingSpansOverlap(raw, size, signature, 64) || + signingSpansOverlap(workspace, capacity, signature, 64) || signingSpansOverlap(raw, size, reinterpret_cast(&view), sizeof(view))) return Result::InvalidInput; + std::memset(signature, 0, 64); + if (!identity.isReady()) return identity.identityStorageError() ? Result::StorageError : Result::Unavailable; + if (!identity.authorGeneration()) return Result::StorageError; + if (identity.authorGeneration() != generation) return Result::StaleIdentity; + if (signManifestForGeneration({raw, size}, identity, generation, view, workspace, capacity, signature, signature_capacity)) return Result::Ok; + return identity.authorGeneration() != generation ? Result::StaleIdentity : Result::InvalidInput; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/directory_announcement.h b/modules/core_track_sharing/include/track_sharing/directory_announcement.h new file mode 100644 index 00000000..bfeb602d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/directory_announcement.h @@ -0,0 +1,36 @@ +#pragma once +#include "chat/ports/track_delivery_binding.h" +#include "geocaching/protocol/record_decoder.h" +#include "track_sharing/network_request.h" + +namespace track_sharing +{ +struct DirectoryAdvertisement +{ + geocaching::ByteView public_key, delivery, epoch; + std::string_view name; +}; +// Only call from verified Reticulum announcement ingestion. It supplies the +// public-key/service/LXMF binding; this decoder validates application metadata. +// All fields remain borrowed until the consumer copies admitted metadata. +inline bool decodeTrackDirectoryAnnouncement(const chat::lxmf::TrackDirectoryAnnouncementView& view, DirectoryAdvertisement& output) +{ + if (!view.discovery_destination.data || view.discovery_destination.size != 16 || + !view.delivery_destination.data || view.delivery_destination.size != 16 || + !view.public_key.data || view.public_key.size != 64 || !view.app_data.data || !view.app_data.size || view.app_data.size > 128) return false; + for (const auto field : {view.discovery_destination, view.delivery_destination, view.public_key, view.app_data}) + if (!networkInputDisjoint({field.data, field.size}, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader({view.app_data.data, view.app_data.size}); + size_t fields = 0; + uint64_t version = 0; + std::string_view type; + DirectoryAdvertisement candidate; + if (!reader.array(fields, 5) || fields != 5 || !reader.unsignedInteger(version) || version != 1 || + !reader.text(type, sizeof(kApplicationType) - 1) || type != kApplicationType || !reader.binary(candidate.delivery, 16) || !networkRequestId(candidate.delivery) || + std::memcmp(candidate.delivery.data, view.delivery_destination.data, 16) || !reader.binary(candidate.epoch, 16) || !networkRequestId(candidate.epoch) || + !reader.text(candidate.name, 40) || !geocaching::protocol::validRecordText(candidate.name, false, true) || !reader.finished()) return false; + candidate.public_key = {view.public_key.data, view.public_key.size}; + output = candidate; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/directory_capabilities.h b/modules/core_track_sharing/include/track_sharing/directory_capabilities.h new file mode 100644 index 00000000..3dd6437d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/directory_capabilities.h @@ -0,0 +1,42 @@ +#pragma once +#include "track_sharing/network_upload_response.h" + +namespace track_sharing +{ +struct TrackDirectoryCapabilities +{ + geocaching::ByteView public_key, epoch; + uint32_t file_limit = 0; + uint16_t chunk_limit = 0, operations = 0; + uint8_t query_limit = 0; +}; +// Transport must authenticate source/destination. Pool separately pins public +// key and incarnation to the verified announcement before accepting limits. +inline bool decodeTrackCapabilities(geocaching::ByteView bytes, geocaching::ByteView request, TrackDirectoryCapabilities& output) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + if (!uploadResponseHeader(reader, 0, request, 8)) return false; + TrackDirectoryCapabilities candidate; + uint64_t version = 0, file = 0, chunk = 0, query = 0, operation = 0; + std::string_view type; + size_t count = 0; + if (!reader.unsignedInteger(version) || version != 1 || !reader.text(type, sizeof(kApplicationType) - 1) || type != kApplicationType || + !reader.binary(candidate.public_key, 64) || candidate.public_key.size != 64 || !reader.binary(candidate.epoch, 16) || !networkRequestId(candidate.epoch) || + !reader.unsignedInteger(file) || !file || file > 8388608 || !reader.unsignedInteger(chunk) || !chunk || chunk > 1024 || + !reader.unsignedInteger(query) || !query || query > 32 || !reader.array(count, 9) || !count) return false; + for (size_t i = 0; i < count; ++i) + { + if (!reader.unsignedInteger(operation) || operation > 8 || (candidate.operations & (1U << operation))) return false; + candidate.operations |= uint16_t(1U << operation); + } + if (!reader.finished()) return false; + candidate.file_limit = uint32_t(file); + candidate.chunk_limit = uint16_t(chunk); + candidate.query_limit = uint8_t(query); + output = candidate; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/directory_list.h b/modules/core_track_sharing/include/track_sharing/directory_list.h new file mode 100644 index 00000000..6bcf4506 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/directory_list.h @@ -0,0 +1,27 @@ +#pragma once +#include "track_sharing/directory_pool.h" + +namespace track_sharing +{ +// One bounded row per HostLink response. Caller serializes pool observation. +inline bool encodeTrackDirectoryPage(const TrackDirectoryPool& pool, size_t cursor, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || cursor > pool.size()) return false; + for (size_t i = 0; i < pool.size(); ++i) + if (!networkInputDisjoint({reinterpret_cast(pool.at(i)), sizeof(TrackDirectoryEntry)}, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + const auto* entry = pool.at(cursor); + if (!writer.array(2) || !(entry && cursor + 1 < pool.size() ? writer.unsignedInteger(cursor + 1) : writer.nil()) || !writer.array(entry ? 1 : 0)) return false; + if (entry) + { + size_t length = 0; + while (length < 40 && entry->name[length]) ++length; + if (!writer.array(8) || !writer.binary({entry->delivery, 16}) || !writer.binary({entry->public_key, 64}) || !writer.binary({entry->epoch, 16}) || + !writer.text({entry->name, length}) || !writer.unsignedInteger(entry->capabilities_verified ? 1 : 0) || + !writer.unsignedInteger(entry->file_limit) || !writer.unsignedInteger(entry->chunk_limit) || !writer.unsignedInteger(entry->query_limit)) return false; + } + written = writer.size(); + return writer.good(); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/directory_pool.h b/modules/core_track_sharing/include/track_sharing/directory_pool.h new file mode 100644 index 00000000..f1187f4a --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/directory_pool.h @@ -0,0 +1,99 @@ +#pragma once +#include "track_sharing/directory_announcement.h" +#include "track_sharing/directory_capabilities.h" + +namespace track_sharing +{ +struct TrackDirectoryEntry +{ + uint8_t discovery[16]{}, delivery[16]{}, public_key[64]{}, epoch[16]{}; + char name[41]{}; + uint64_t last_seen = 0; + uint32_t file_limit = 0; + uint16_t chunk_limit = 0; + uint8_t query_limit = 0; + bool capabilities_verified = false; +}; +static_assert(sizeof(TrackDirectoryEntry) <= 176, "Directory entry contains metadata only"); + +// Entries are caller-admitted PSRAM storage. Router callbacks and snapshot +// readers must be serialized; no backing pointer escapes its ownership lease. +class TrackDirectoryPool +{ + public: + static constexpr size_t kCapacity = 8; + TrackDirectoryPool(TrackDirectoryEntry* entries, size_t capacity) + : entries_(entries), capacity_(entries ? (capacity < kCapacity ? capacity : kCapacity) : 0) {} + size_t size() const { return count_; } + const TrackDirectoryEntry* at(size_t index) const { return index < count_ ? &entries_[index] : nullptr; } + bool observe(const chat::lxmf::TrackDirectoryAnnouncementView& view, uint64_t now) + { + DirectoryAdvertisement advertisement; + if (!decodeTrackDirectoryAnnouncement(view, advertisement)) return false; + TrackDirectoryEntry* entry = nullptr; + for (size_t i = 0; i < count_; ++i) + if (!std::memcmp(entries_[i].discovery, view.discovery_destination.data, 16)) + { + entry = &entries_[i]; + break; + } + if (entry) + { + if (now < entry->last_seen || std::memcmp(entry->delivery, advertisement.delivery.data, 16) || + std::memcmp(entry->public_key, advertisement.public_key.data, 64)) return false; + } + else + { + if (count_ == capacity_) return false; + // One delivery identity cannot appear as another discovery service. + for (size_t i = 0; i < count_; ++i) + if (!std::memcmp(entries_[i].delivery, advertisement.delivery.data, 16)) return false; + entry = &entries_[count_++]; + *entry = {}; + std::memcpy(entry->discovery, view.discovery_destination.data, 16); + std::memcpy(entry->delivery, advertisement.delivery.data, 16); + std::memcpy(entry->public_key, advertisement.public_key.data, 64); + } + if (std::memcmp(entry->epoch, advertisement.epoch.data, 16)) + { + std::memcpy(entry->epoch, advertisement.epoch.data, 16); + entry->capabilities_verified = false; + entry->file_limit = 0; + entry->chunk_limit = 0; + entry->query_limit = 0; + } + std::memset(entry->name, 0, sizeof(entry->name)); + std::memcpy(entry->name, advertisement.name.data(), advertisement.name.size()); + entry->last_seen = now; + return true; + } + const TrackDirectoryEntry* find(geocaching::ByteView delivery, geocaching::ByteView epoch) const + { + if (!networkRequestId(delivery) || !networkRequestId(epoch)) return nullptr; + for (size_t i = 0; i < count_; ++i) + if (!std::memcmp(entries_[i].delivery, delivery.data, 16) && !std::memcmp(entries_[i].epoch, epoch.data, 16)) return &entries_[i]; + return nullptr; + } + bool confirmCapabilities(geocaching::ByteView delivery, geocaching::ByteView epoch, + geocaching::ByteView response, geocaching::ByteView request) + { + TrackDirectoryCapabilities capabilities; + const auto* selected = find(delivery, epoch); + if (!selected || !decodeTrackCapabilities(response, request, capabilities) || capabilities.operations != 0x1ff || + std::memcmp(capabilities.public_key.data, selected->public_key, 64) || std::memcmp(capabilities.epoch.data, selected->epoch, 16) || + !networkInputDisjoint(response, reinterpret_cast(const_cast(selected)), sizeof(*selected))) return false; + auto* entry = &entries_[selected - entries_]; + entry->file_limit = capabilities.file_limit; + entry->chunk_limit = capabilities.chunk_limit; + entry->query_limit = capabilities.query_limit; + entry->capabilities_verified = true; + return true; + } + + private: + TrackDirectoryEntry* entries_; + size_t capacity_; + size_t count_ = 0; +}; +static_assert(sizeof(TrackDirectoryPool) <= 24, "Pool borrows all entry storage"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/file_copy_slice.h b/modules/core_track_sharing/include/track_sharing/file_copy_slice.h new file mode 100644 index 00000000..5ebdc7b3 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/file_copy_slice.h @@ -0,0 +1,50 @@ +#pragma once +#include "geocaching/domain/record.h" +#include +#include +namespace track_sharing +{ +enum class FileCopyResult : uint8_t { Pending, Copied, StorageFailure, Conflict, Invalid }; +// Caller admits an exclusive, media-bound temporary file and retains offset. +// This primitive does not authorize paths or declare content hash verification. +template +FileCopyResult copyFileSlice(Io& io, const char* source, const char* temporary, uint32_t bytes, + uint32_t offset, uint8_t* scratch, size_t capacity, uint32_t& next) +{ + if (!source || !*source || !temporary || !*temporary || !std::strcmp(source, temporary) || + !bytes || bytes > 8388608 || offset >= bytes || !scratch || !capacity) return FileCopyResult::Invalid; + const size_t count = std::min({size_t(320), capacity, size_t(bytes - offset)}); + const auto overlaps = [](const void* a, size_t na, const void* b, size_t nb) { + const auto x = reinterpret_cast(a), y = reinterpret_cast(b); + return na && nb && (x <= y ? y - x < na : x - y < nb); + }; + const size_t source_length = std::strlen(source) + 1, temporary_length = std::strlen(temporary) + 1; + if (overlaps(scratch, count, source, source_length) || overlaps(scratch, count, temporary, temporary_length) || + overlaps(scratch, count, &next, sizeof(next)) || overlaps(scratch, count, &io, sizeof(io)) || + overlaps(&next, sizeof(next), source, source_length) || overlaps(&next, sizeof(next), temporary, temporary_length) || + overlaps(&next, sizeof(next), &io, sizeof(io))) return FileCopyResult::Invalid; + next = offset; + const auto input = io.open(source, Io::Mode::Read); + if (!input.handle) return FileCopyResult::StorageFailure; + uint64_t source_size = 0; + const bool sized = io.size(input.handle, source_size); + const bool read = sized && source_size == bytes && io.seek(input.handle, offset) && io.read(input.handle, scratch, count) == count; + const bool closed = io.close(input.handle); + if (!sized || !closed) return FileCopyResult::StorageFailure; + if (source_size != bytes) return FileCopyResult::Conflict; + if (!read) return FileCopyResult::StorageFailure; + const auto output = io.open(temporary, Io::Mode::Update); + if (!output.handle) return FileCopyResult::StorageFailure; + uint64_t destination_size = 0; + const bool known = io.size(output.handle, destination_size); + // A short failed write is never silently treated as a durable offset. + const bool written = known && destination_size == offset && io.seek(output.handle, offset) && + io.write(output.handle, scratch, count) == count && io.sync(output.handle); + const bool output_closed = io.close(output.handle); + if (!known || !output_closed) return FileCopyResult::StorageFailure; + if (destination_size != offset) return FileCopyResult::Conflict; + if (!written) return FileCopyResult::StorageFailure; + next = offset + uint32_t(count); + return next == bytes ? FileCopyResult::Copied : FileCopyResult::Pending; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/file_integrity.h b/modules/core_track_sharing/include/track_sharing/file_integrity.h new file mode 100644 index 00000000..70dc30b2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/file_integrity.h @@ -0,0 +1,90 @@ +#pragma once +#include +#include +#include +namespace track_sharing +{ +enum class FileIntegrityResult : uint8_t +{ + Idle, + Pending, + Ready, + Busy, + Invalid, + HashMismatch, + StorageFailure, + Cancelled +}; +// Io lends operation-scoped file handles; Hash lends an exclusive stream. +// Caller retains an immutable source lease and stable device-resolved path. +// This verifies exact bytes only, not XML geometry or signature authenticity. +template +class FileIntegrityCheck +{ + public: + FileIntegrityCheck(Io& io, Hash& hash) : io_(io), hash_(hash) {} + ~FileIntegrityCheck() + { + if (active()) hash_.reset(); + } + FileIntegrityCheck(const FileIntegrityCheck&) = delete; + FileIntegrityCheck& operator=(const FileIntegrityCheck&) = delete; + FileIntegrityResult begin(const char* path, uint32_t bytes, const uint8_t expected[32]) + { + if (active()) return FileIntegrityResult::Busy; + if (!path || !*path || !expected || !bytes || bytes > 8388608) return FileIntegrityResult::Invalid; + path_ = path; + bytes_ = bytes; + offset_ = 0; + std::memcpy(expected_, expected, 32); + if (!hash_.start()) return fail(FileIntegrityResult::StorageFailure); + result_ = FileIntegrityResult::Pending; + return result_; + } + FileIntegrityResult step(uint8_t* scratch, size_t capacity) + { + if (!active()) return result_; + if (!scratch || capacity < 32) return fail(FileIntegrityResult::Invalid); + if (offset_ == bytes_) + { + if (!hash_.finish(scratch)) return fail(FileIntegrityResult::StorageFailure); + const bool matches = !std::memcmp(scratch, expected_, 32); + return fail(matches ? FileIntegrityResult::Ready : FileIntegrityResult::HashMismatch); + } + const auto length = std::min(std::min(capacity, 320), bytes_ - offset_); + const auto opened = io_.open(path_, Io::Mode::Read); + if (!opened.handle) return fail(FileIntegrityResult::StorageFailure); + uint64_t actual = 0; + const bool read = io_.size(opened.handle, actual) && actual == bytes_ && io_.seek(opened.handle, offset_) && io_.read(opened.handle, scratch, length) == length; + const bool closed = io_.close(opened.handle); + if (!read || !closed || !hash_.update(scratch, length)) return fail(FileIntegrityResult::StorageFailure); + offset_ += static_cast(length); + return result_; + } + FileIntegrityResult cancel() { return active() ? fail(FileIntegrityResult::Cancelled) : result_; } + bool active() const { return result_ == FileIntegrityResult::Pending; } + uint32_t checkedBytes() const { return offset_; } + bool matchesExpected(uint32_t bytes, const uint8_t* hash) const + { + return result_ == FileIntegrityResult::Ready && hash && bytes == bytes_ && !std::memcmp(hash, expected_, 32); + } + bool matchesSource(const char* path, uint32_t bytes, const uint8_t* hash) const + { + return path && path_ && !std::strcmp(path, path_) && matchesExpected(bytes, hash); + } + + private: + FileIntegrityResult fail(FileIntegrityResult result) + { + hash_.reset(); + result_ = result; + return result_; + } + Io& io_; + Hash& hash_; + const char* path_ = nullptr; + uint8_t expected_[32]{}; + uint32_t bytes_ = 0, offset_ = 0; + FileIntegrityResult result_ = FileIntegrityResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/file_prefix_check.h b/modules/core_track_sharing/include/track_sharing/file_prefix_check.h new file mode 100644 index 00000000..1fe1e8bb --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/file_prefix_check.h @@ -0,0 +1,47 @@ +#pragma once +#include "track_sharing/file_copy_slice.h" +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +// Checks an existing temporary prefix before resuming. The final checked slice +// syncs the prefix; the complete file must still pass SHA validation afterward. +template +FileCopyResult checkFilePrefixSlice(Io& io, const char* source, const char* temporary, uint32_t bytes, + uint32_t prefix, uint32_t checked, uint8_t* scratch, size_t capacity, uint32_t& next) +{ + if (!source || !temporary || !*source || !*temporary || !std::strcmp(source, temporary) || !bytes || bytes > 8388608 || + !prefix || prefix >= bytes || checked >= prefix || !scratch || capacity < 2) return FileCopyResult::Invalid; + const size_t count = std::min({size_t(160), capacity / 2, size_t(prefix - checked)}); + if (signingSpansOverlap(scratch, count * 2, reinterpret_cast(source), std::strlen(source) + 1) || + signingSpansOverlap(scratch, count * 2, reinterpret_cast(temporary), std::strlen(temporary) + 1) || + signingSpansOverlap(scratch, count * 2, reinterpret_cast(&next), sizeof(next)) || + signingSpansOverlap(scratch, count * 2, reinterpret_cast(&io), sizeof(io)) || + signingSpansOverlap(reinterpret_cast(&next), sizeof(next), reinterpret_cast(source), std::strlen(source) + 1) || + signingSpansOverlap(reinterpret_cast(&next), sizeof(next), reinterpret_cast(temporary), std::strlen(temporary) + 1) || + signingSpansOverlap(reinterpret_cast(&next), sizeof(next), reinterpret_cast(&io), sizeof(io))) return FileCopyResult::Invalid; + next = checked; + const auto input = io.open(source, Io::Mode::Read); + if (!input.handle) return FileCopyResult::StorageFailure; + uint64_t source_size = 0; + const bool source_known = io.size(input.handle, source_size); + const bool read = source_known && source_size == bytes && io.seek(input.handle, checked) && io.read(input.handle, scratch, count) == count; + const bool closed = io.close(input.handle); + if (!source_known || !closed) return FileCopyResult::StorageFailure; + if (source_size != bytes) return FileCopyResult::Conflict; + if (!read) return FileCopyResult::StorageFailure; + const auto output = io.open(temporary, Io::Mode::Update); + if (!output.handle) return FileCopyResult::StorageFailure; + uint64_t temporary_size = 0; + const bool known = io.size(output.handle, temporary_size); + const bool read_prefix = known && temporary_size == prefix && io.seek(output.handle, checked) && io.read(output.handle, scratch + count, count) == count; + const bool matches = read_prefix && !std::memcmp(scratch, scratch + count, count); + const bool synced = !matches || checked + count != prefix || io.sync(output.handle); + const bool output_closed = io.close(output.handle); + if (!known || !output_closed || !synced) return FileCopyResult::StorageFailure; + if (temporary_size != prefix) return FileCopyResult::Conflict; + if (!read_prefix) return FileCopyResult::StorageFailure; + if (!matches) return FileCopyResult::Conflict; + next = checked + uint32_t(count); + return next == prefix ? FileCopyResult::Copied : FileCopyResult::Pending; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/geometry_bounds.h b/modules/core_track_sharing/include/track_sharing/geometry_bounds.h new file mode 100644 index 00000000..d114ca94 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/geometry_bounds.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +namespace track_sharing +{ +struct GeometryExtent +{ + int32_t west = 0, south = 0, east = 0, north = 0; + uint32_t points = 0; +}; +// Index profile: normalize E7 longitudes, unwrap each around the first point, +// then enclose all quantized points. Safe conservative arc, not minimal arc. +class GeometryBounds +{ + public: + bool add(int64_t longitude, int64_t latitude) + { + if (failed_ || longitude < -1800000000LL || longitude > 1800000000LL || latitude < -900000000LL || latitude > 900000000LL || points_ >= 100000) + { + failed_ = true; + return false; + } + longitude = wrap(longitude); + if (!points_) + { + anchor_ = static_cast(longitude); + west_ = east_ = longitude; + south_ = north_ = static_cast(latitude); + } + else + { + int64_t delta = longitude - anchor_; + if (delta >= 1800000000LL) delta -= 3600000000LL; + if (delta < -1800000000LL) delta += 3600000000LL; + longitude = anchor_ + delta; + west_ = std::min(west_, longitude); + east_ = std::max(east_, longitude); + south_ = std::min(south_, static_cast(latitude)); + north_ = std::max(north_, static_cast(latitude)); + } + ++points_; + return true; + } + bool finish(GeometryExtent& output) const + { + output = {}; + if (failed_ || !points_) return false; + output = {static_cast(wrap(west_)), south_, static_cast(wrap(east_)), north_, points_}; + return true; + } + uint32_t pointCount() const { return points_; } + + private: + static int64_t wrap(int64_t value) + { + if (value >= 1800000000LL) value -= 3600000000LL; + if (value < -1800000000LL) value += 3600000000LL; + return value; + } + int64_t west_ = 0, east_ = 0; + int32_t anchor_ = 0, south_ = 0, north_ = 0; + uint32_t points_ = 0; + bool failed_ = false; +}; +static_assert(sizeof(GeometryBounds) <= 40, "Geometry bounds must not retain a point list"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/image_source_binding.h b/modules/core_track_sharing/include/track_sharing/image_source_binding.h new file mode 100644 index 00000000..4903777b --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/image_source_binding.h @@ -0,0 +1,60 @@ +#pragma once +#include "geocaching/domain/record.h" +#include "track_sharing/upload_journal.h" + +namespace track_sharing +{ +constexpr size_t kImageSourceBindingBytes = 75; +constexpr uint32_t kImageSourceByteLimit = 8U * 1024U * 1024U; +struct ImageSourceBindingView +{ + geocaching::ByteView source_hash, file_hash; + uint32_t file_bytes = 0; +}; +static_assert(sizeof(ImageSourceBindingView) <= 48, "Image binding views must not copy digests or file data"); +inline bool imageBindingDisjoint(const void* first, size_t first_bytes, const void* second, size_t second_bytes) +{ + const auto a = reinterpret_cast(first), b = reinterpret_cast(second); + return a <= b ? b - a >= first_bytes : a - b >= second_bytes; +} +// TMSI/version1/fileBytesLE32/referenceSHA256/fileSHA256/CRC16-CCITTLE16. +// A reference hash binds the exact decoded KML reference, not a normalized URL. +// CRC is corruption detection. This record alone proves neither a file's hash +// nor an author's signature; callers must establish those independently. +inline bool encodeImageSourceBinding(const ImageSourceBindingView& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kImageSourceBindingBytes || !value.source_hash.data || value.source_hash.size != 32 || + !value.file_hash.data || value.file_hash.size != 32 || !value.file_bytes || value.file_bytes > kImageSourceByteLimit || + !imageBindingDisjoint(output, kImageSourceBindingBytes, &value, sizeof(value)) || + !imageBindingDisjoint(output, kImageSourceBindingBytes, value.source_hash.data, 32) || + !imageBindingDisjoint(output, kImageSourceBindingBytes, value.file_hash.data, 32)) return false; + std::memcpy(output, "TMSI", 4); + output[4] = 1; + for (unsigned i = 0; i < 4; ++i) output[5 + i] = uint8_t(value.file_bytes >> (i * 8)); + std::memcpy(output + 9, value.source_hash.data, 32); + std::memcpy(output + 41, value.file_hash.data, 32); + const auto crc = uploadJournalCrc(output, 73); + output[73] = uint8_t(crc); + output[74] = uint8_t(crc >> 8); + return true; +} +inline bool decodeImageSourceBinding(const uint8_t* input, size_t size, ImageSourceBindingView& output) +{ + if (!imageBindingDisjoint(input, size, &output, sizeof(output))) return false; + output = {}; + if (!input || size != kImageSourceBindingBytes || std::memcmp(input, "TMSI", 4) || input[4] != 1 || + uploadJournalCrc(input, 73) != (uint16_t(input[73]) | uint16_t(input[74]) << 8)) return false; + uint32_t bytes = 0; + for (unsigned i = 0; i < 4; ++i) bytes |= uint32_t(input[5 + i]) << (i * 8); + if (!bytes || bytes > kImageSourceByteLimit) return false; + output = {{input + 9, 32}, {input + 41, 32}, bytes}; + return true; +} +inline bool imageSourceBindingMatches(const ImageSourceBindingView& value, const ImageSourceBindingView& expected) +{ + return value.source_hash.data && value.source_hash.size == 32 && value.file_hash.data && value.file_hash.size == 32 && + expected.source_hash.data && expected.source_hash.size == 32 && expected.file_hash.data && expected.file_hash.size == 32 && + value.file_bytes == expected.file_bytes && !std::memcmp(value.source_hash.data, expected.source_hash.data, 32) && + !std::memcmp(value.file_hash.data, expected.file_hash.data, 32); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/image_source_binding_store.h b/modules/core_track_sharing/include/track_sharing/image_source_binding_store.h new file mode 100644 index 00000000..52891d8b --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/image_source_binding_store.h @@ -0,0 +1,109 @@ +#pragma once +#include "track_sharing/image_source_binding.h" + +namespace track_sharing +{ +enum class ImageBindingResult : uint8_t +{ + Ready, + Missing, + Corrupt, + Invalid, + Unverified, + StorageFailure +}; +struct ImageBindingPaths +{ + const char* candidate_image; + const char* image; + const char* candidate_binding; + const char* binding; + const char* preview; + const char* view; +}; +// No allocation or retained buffers. The caller supplies exclusive, canonical, +// distinct device-derived paths, a media lease and one 75-byte scratch buffer. +// Source/file hashes borrow separate immutable storage. Legacy unbound images +// are Missing, never silently promoted to authenticated or source-bound data. +template +class ImageSourceBindingStore +{ + public: + explicit ImageSourceBindingStore(Io& io) : io_(io) {} + ImageBindingResult read(const char* path, uint8_t* scratch, size_t capacity, ImageSourceBindingView& output) + { + if (!path || !*path || !scratch || capacity < kImageSourceBindingBytes || + !imageBindingDisjoint(scratch, capacity, this, sizeof(*this)) || + !imageBindingDisjoint(scratch, capacity, path, std::strlen(path) + 1) || + !imageBindingDisjoint(scratch, capacity, &output, sizeof(output))) return ImageBindingResult::Invalid; + output = {}; + const auto file = io_.open(path, Io::Mode::Read); + if (!file.handle) return file.status == Io::OpenStatus::Missing ? ImageBindingResult::Missing : ImageBindingResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(file.handle, size); + const bool loaded = sized && size == kImageSourceBindingBytes && io_.seek(file.handle, 0) && + io_.read(file.handle, scratch, kImageSourceBindingBytes) == kImageSourceBindingBytes; + const bool closed = io_.close(file.handle); + if (!sized || !closed) return ImageBindingResult::StorageFailure; + if (size != kImageSourceBindingBytes) return ImageBindingResult::Corrupt; + if (!loaded) return ImageBindingResult::StorageFailure; + return decodeImageSourceBinding(scratch, kImageSourceBindingBytes, output) ? ImageBindingResult::Ready : ImageBindingResult::Corrupt; + } + template + ImageBindingResult commit(const ImageBindingPaths& paths, const ImageSourceBindingView& expected, + const Verification& verified, uint8_t* scratch, size_t capacity) + { + if (!scratch || !imageBindingDisjoint(scratch, capacity, this, sizeof(*this)) || + !imageBindingDisjoint(scratch, capacity, &paths, sizeof(paths)) || + !imageBindingDisjoint(scratch, capacity, &verified, sizeof(verified)) || + !validPaths(paths, scratch, capacity) || !encodeImageSourceBinding(expected, scratch, capacity)) return ImageBindingResult::Invalid; + if (!verified.matchesSource(paths.candidate_image, expected.file_bytes, expected.file_hash.data)) return ImageBindingResult::Unverified; + const auto candidate = io_.open(paths.candidate_image, Io::Mode::Read); + if (!candidate.handle) return candidate.status == Io::OpenStatus::Missing ? ImageBindingResult::Unverified : ImageBindingResult::StorageFailure; + uint64_t image_bytes = 0; + const bool sized = io_.size(candidate.handle, image_bytes), candidate_closed = io_.close(candidate.handle); + if (!sized || !candidate_closed) return ImageBindingResult::StorageFailure; + if (image_bytes != expected.file_bytes) return ImageBindingResult::Unverified; + const auto file = io_.open(paths.candidate_binding, Io::Mode::CreateTemporary); + if (!file.handle) return ImageBindingResult::StorageFailure; + const bool written = io_.write(file.handle, scratch, kImageSourceBindingBytes) == kImageSourceBindingBytes && io_.sync(file.handle); + const bool closed = io_.close(file.handle); + if (!written || !closed) return ImageBindingResult::StorageFailure; + ImageSourceBindingView check; + if (read(paths.candidate_binding, scratch, capacity, check) != ImageBindingResult::Ready || !imageSourceBindingMatches(check, expected)) return ImageBindingResult::StorageFailure; + // Invalidate provenance before changing the published image or derived + // bitmaps. Any later failure leaves the new bytes explicitly unbound. + if (!removePresent(paths.binding) || !removePresent(paths.preview) || !removePresent(paths.view) || + !removePresent(paths.image) || !io_.rename(paths.candidate_image, paths.image) || + !io_.rename(paths.candidate_binding, paths.binding)) return ImageBindingResult::StorageFailure; + if (read(paths.binding, scratch, capacity, check) != ImageBindingResult::Ready || !imageSourceBindingMatches(check, expected)) return ImageBindingResult::StorageFailure; + return ImageBindingResult::Ready; + } + + private: + static bool validPaths(const ImageBindingPaths& paths, uint8_t* scratch, size_t capacity) + { + const char* values[] = {paths.candidate_image, paths.image, paths.candidate_binding, paths.binding, paths.preview, paths.view}; + for (size_t i = 0; i < 6; ++i) + { + if (!values[i] || !*values[i] || !imageBindingDisjoint(scratch, capacity, values[i], std::strlen(values[i]) + 1)) return false; + for (size_t j = 0; j < i; ++j) + { + size_t k = 0; + const auto lower = [](char c) + { return c >= 'A' && c <= 'Z' ? char(c + 32) : c; }; + while (values[i][k] && lower(values[i][k]) == lower(values[j][k])) ++k; + if (!values[i][k] && !values[j][k]) return false; + } + } + return true; + } + bool removePresent(const char* path) + { + const auto file = io_.open(path, Io::Mode::Read); + if (!file.handle) return file.status == Io::OpenStatus::Missing; + return io_.close(file.handle) && io_.remove(path); + } + Io& io_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/immutable_file_copy.h b/modules/core_track_sharing/include/track_sharing/immutable_file_copy.h new file mode 100644 index 00000000..338a9f88 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/immutable_file_copy.h @@ -0,0 +1,89 @@ +#pragma once +#include "track_sharing/file_copy_slice.h" +#include "track_sharing/verified_file_publish.h" +#include "track_sharing/file_prefix_check.h" +namespace track_sharing +{ +enum class ImmutableCopyResult : uint8_t { Idle, Pending, Ready, Conflict, HashMismatch, StorageFailure, Invalid, Cancelled }; +// The admitted owner provides exclusive device-derived paths, media-bound Io, +// hash context and per-tick scratch. Partial orphan files are preserved. +template +class ImmutableFileCopy +{ + public: + ImmutableFileCopy(Io& io, Hash& hash) : io_(io), publisher_(io, hash) {} + ImmutableFileCopy(const ImmutableFileCopy&) = delete; + ImmutableFileCopy& operator=(const ImmutableFileCopy&) = delete; + ImmutableCopyResult begin(const char* source, const char* temporary, const char* destination, uint32_t bytes, const uint8_t* expected) + { + if (result_ == ImmutableCopyResult::Pending) return ImmutableCopyResult::Conflict; + result_ = ImmutableCopyResult::Invalid; + if (!source || !temporary || !destination || !expected || !*source || !*temporary || !*destination || !bytes || bytes > 8388608 || + !std::strcmp(source, temporary) || !std::strcmp(source, destination) || !std::strcmp(temporary, destination)) return result_; + const auto* self = reinterpret_cast(this); + if (signingSpansOverlap(expected, 32, self, sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(source), std::strlen(source) + 1, self, sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(temporary), std::strlen(temporary) + 1, self, sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(destination), std::strlen(destination) + 1, self, sizeof(*this))) return result_; + source_ = source; temporary_ = temporary; destination_ = destination; bytes_ = bytes; offset_ = 0; prefix_checked_ = 0; + std::memcpy(expected_, expected, 32); + const auto final = io_.open(destination_, Io::Mode::Read); + if (final.handle) + { + if (!io_.close(final.handle)) return result_ = ImmutableCopyResult::StorageFailure; + copying_ = false; return result_ = convert(publisher_.begin(temporary_, destination_, bytes_, expected_)); + } + if (final.status != Io::OpenStatus::Missing) return result_ = ImmutableCopyResult::StorageFailure; + const auto temporary_file = io_.open(temporary_, Io::Mode::Read); + if (temporary_file.handle) + { + uint64_t length = 0;const bool sized = io_.size(temporary_file.handle, length), closed = io_.close(temporary_file.handle); + if (!sized || !closed) return result_ = ImmutableCopyResult::StorageFailure; + if (length == bytes_) { copying_ = false; return result_ = convert(publisher_.begin(temporary_, destination_, bytes_, expected_)); } + if (length > bytes_) return result_ = ImmutableCopyResult::Conflict; + offset_ = uint32_t(length); + } + else + { + if (temporary_file.status != Io::OpenStatus::Missing) return result_ = ImmutableCopyResult::StorageFailure; + const auto created = io_.open(temporary_, Io::Mode::CreateTemporary); + if (!created.handle) return result_ = ImmutableCopyResult::StorageFailure; + const bool synced = io_.sync(created.handle), closed = io_.close(created.handle); + if (!synced || !closed) return result_ = ImmutableCopyResult::StorageFailure; + } + copying_ = true; resuming_ = offset_ != 0; return result_ = ImmutableCopyResult::Pending; + } + ImmutableCopyResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != ImmutableCopyResult::Pending) return result_; + if (!scratch || signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this))) + { publisher_.cancel(); return result_ = ImmutableCopyResult::Invalid; } + if (!copying_) return result_ = convert(publisher_.step(scratch, capacity)); + if (prefix_checked_ < offset_ && resuming_) + { + const auto checked = checkFilePrefixSlice(io_, source_, temporary_, bytes_, offset_, prefix_checked_, scratch, capacity, prefix_checked_); + if (checked == FileCopyResult::Copied) { resuming_ = false; return result_; } + if (checked == FileCopyResult::Pending) return result_; + return result_ = checked == FileCopyResult::Conflict ? ImmutableCopyResult::Conflict : checked == FileCopyResult::Invalid ? ImmutableCopyResult::Invalid : ImmutableCopyResult::StorageFailure; + } + const auto copied = copyFileSlice(io_, source_, temporary_, bytes_, offset_, scratch, capacity, offset_); + if (copied == FileCopyResult::Pending) return result_; + if (copied != FileCopyResult::Copied) return result_ = copied == FileCopyResult::Conflict ? ImmutableCopyResult::Conflict : copied == FileCopyResult::Invalid ? ImmutableCopyResult::Invalid : ImmutableCopyResult::StorageFailure; + copying_ = false;return result_ = convert(publisher_.begin(temporary_, destination_, bytes_, expected_)); + } + ImmutableCopyResult cancel() { publisher_.cancel(); return result_ = ImmutableCopyResult::Cancelled; } + private: + static ImmutableCopyResult convert(FileIntegrityResult result) + { + return result == FileIntegrityResult::Pending ? ImmutableCopyResult::Pending : result == FileIntegrityResult::Ready ? ImmutableCopyResult::Ready : + result == FileIntegrityResult::HashMismatch ? ImmutableCopyResult::HashMismatch : result == FileIntegrityResult::Invalid ? ImmutableCopyResult::Invalid : ImmutableCopyResult::StorageFailure; + } + Io& io_; + VerifiedFilePublish publisher_; + const char* source_ = nullptr;const char* temporary_ = nullptr;const char* destination_ = nullptr; + uint8_t expected_[32]{}; + uint32_t bytes_ = 0, offset_ = 0, prefix_checked_ = 0; + bool copying_ = false, resuming_ = false; + ImmutableCopyResult result_ = ImmutableCopyResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/installed_package_binding.h b/modules/core_track_sharing/include/track_sharing/installed_package_binding.h new file mode 100644 index 00000000..80665457 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/installed_package_binding.h @@ -0,0 +1,101 @@ +#pragma once +#include "track_sharing/manifest_extensions.h" +#include "track_sharing/sign_manifest.h" +#include "track_sharing/upload_journal.h" +namespace track_sharing +{ +struct InstalledPackageBinding +{ + uint8_t object[32]{}, revision[32]{}, kml_hash[32]{}, index_hash[32]{}; + uint64_t availability = 0; + uint32_t kml_bytes = 0, index_bytes = 0; + uint8_t count = 0; +}; +constexpr size_t kInstalledPackageBindingBytes = 154; +inline bool installedPackageBindingValid(const InstalledPackageBinding& value) +{ + uint8_t object = 0, revision = 0, index = 0; + for (size_t i = 0; i < 32; ++i) + { + object |= value.object[i]; + revision |= value.revision[i]; + index |= value.index_hash[i]; + } + if (!object || !revision || !value.kml_bytes || value.kml_bytes > 8388608 || value.count > 64) return false; + if (!value.count) return !value.index_bytes && !index && !value.availability; + return value.index_bytes && value.index_bytes <= kMaxAttachmentIndexBytes && (value.count == 64 || value.availability < (uint64_t(1) << value.count)); +} +// CRC catches torn/corrupt local records; it does NOT authenticate the author. +// Loaders must verify cached original signature/object/revision/index and bytes. +inline bool encodeInstalledPackageBinding(const InstalledPackageBinding& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kInstalledPackageBindingBytes || !installedPackageBindingValid(value) || + signingSpansOverlap(reinterpret_cast(&value), sizeof(value), output, kInstalledPackageBindingBytes)) return false; + std::memcpy(output, "TMPK", 4); + output[4] = 1; + output[5] = value.count ? 1 : 0; + output[6] = value.count; + output[7] = 0; + std::memcpy(output + 8, value.object, 32); + std::memcpy(output + 40, value.revision, 32); + std::memcpy(output + 72, value.kml_hash, 32); + std::memcpy(output + 104, value.index_hash, 32); + for (unsigned i = 0; i < 4; ++i) + { + output[136 + i] = uint8_t(value.kml_bytes >> (8 * i)); + output[140 + i] = uint8_t(value.index_bytes >> (8 * i)); + } + for (unsigned i = 0; i < 8; ++i) output[144 + i] = uint8_t(value.availability >> (8 * i)); + const auto crc = uploadJournalCrc(output, 152); + output[152] = uint8_t(crc); + output[153] = uint8_t(crc >> 8); + return true; +} +inline bool decodeInstalledPackageBinding(const uint8_t* input, size_t size, InstalledPackageBinding& output) +{ + if (signingSpansOverlap(input, size, reinterpret_cast(&output), sizeof(output))) return false; + output = {}; + if (!input || size != kInstalledPackageBindingBytes || std::memcmp(input, "TMPK", 4) || input[4] != 1 || input[5] > 1 || input[7] || + uploadJournalCrc(input, 152) != (uint16_t(input[152]) | (uint16_t(input[153]) << 8))) return false; + output.count = input[6]; + if ((output.count != 0) != (input[5] != 0)) return false; + std::memcpy(output.object, input + 8, 32); + std::memcpy(output.revision, input + 40, 32); + std::memcpy(output.kml_hash, input + 72, 32); + std::memcpy(output.index_hash, input + 104, 32); + for (unsigned i = 0; i < 4; ++i) + { + output.kml_bytes |= uint32_t(input[136 + i]) << (8 * i); + output.index_bytes |= uint32_t(input[140 + i]) << (8 * i); + } + for (unsigned i = 0; i < 8; ++i) output.availability |= uint64_t(input[144 + i]) << (8 * i); + return installedPackageBindingValid(output); +} +enum class PackageRouteFile : uint8_t +{ + Kml, + Temporary, + Binding, + BindingTemporary +}; +inline bool routePackageTarget(geocaching::ByteView revision, PackageRouteFile file, char* output, size_t capacity) +{ + if (!revision.data || revision.size != 32 || !output || capacity < 80 || uint8_t(file) > 3 || signingSpansOverlap(revision.data, 32, reinterpret_cast(output), 80)) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 32; ++i) bits |= revision.data[i]; + if (!bits) return false; + constexpr char prefix[] = "/routes/p-", hex[] = "0123456789abcdef"; + size_t position = sizeof(prefix) - 1; + std::memcpy(output, prefix, position); + for (size_t i = 0; i < 32; ++i) + { + output[position++] = hex[revision.data[i] >> 4]; + output[position++] = hex[revision.data[i] & 15]; + } + const char* suffix = file == PackageRouteFile::Kml ? ".kml" : file == PackageRouteFile::Temporary ? ".ktm" + : file == PackageRouteFile::Binding ? ".rpk" + : ".rpt"; + std::memcpy(output + position, suffix, 5); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/installed_package_path.h b/modules/core_track_sharing/include/track_sharing/installed_package_path.h new file mode 100644 index 00000000..d79c0b60 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/installed_package_path.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +namespace track_sharing +{ +// Reserved immutable route names. References from KML never supply SD paths. +inline bool isInstalledPackageAssetId(std::string_view id) +{ + if (id.size() != 66 || id.substr(0, 2) != "p-") return false; + for (size_t i = 2; i < 66; ++i) + if (!((id[i] >= '0' && id[i] <= '9') || (id[i] >= 'a' && id[i] <= 'f'))) return false; + return true; +} +inline bool isInstalledPackageRoute(std::string_view path) +{ + return path.size() == 78 && path.substr(0, 8) == "/routes/" && path.substr(74) == ".kml" && isInstalledPackageAssetId(path.substr(8, 66)); +} +inline bool installedPackageRevision(std::string_view path, uint8_t output[32]) +{ + if (!output || !isInstalledPackageRoute(path)) return false; + const auto digit = [](char c) + { return c <= '9' ? unsigned(c - '0') : unsigned(c - 'a' + 10); }; + for (size_t i = 0; i < 32; ++i) output[i] = uint8_t((digit(path[10 + i * 2]) << 4) | digit(path[11 + i * 2])); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/ip_confirmation_request.h b/modules/core_track_sharing/include/track_sharing/ip_confirmation_request.h new file mode 100644 index 00000000..4bbb61a5 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/ip_confirmation_request.h @@ -0,0 +1,68 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include +#include +namespace track_sharing +{ +struct IpConfirmationRequest +{ + geocaching::ByteView operation, transfer, proof_hash, directory_public, + directory, epoch, network_request, object, revision_hash; + uint32_t generation = 0, revision = 0; + geocaching::ByteView manifest_transfer{}, manifest_hash{}, manifest_signature{}; +}; +static_assert(sizeof(IpConfirmationRequest) <= 208, "Confirmation request borrows bytes only"); +// [local_sign_operation16, sealed_receipt_upload16, upload_sha256, +// author_generation, directory_public64, directory_delivery16, directory_epoch16, +// network_publish_request16, object32, revision_hash32, revision]. +// Shape validation only: worker must authenticate upload, local signed manifest, +// reservation, current identity, directory delivery and signed receipt. +inline bool decodeIpConfirmationRequest(geocaching::ByteView body, IpConfirmationRequest& output, bool import_history = false) +{ + if (body.data) + { + const auto source = reinterpret_cast(body.data), target = reinterpret_cast(&output); + if (source <= target ? target - source < body.size : source - target < sizeof(output)) return false; + } + output = {}; + if (!body.data || !body.size || body.size > (import_history ? 416 : 288)) return false; + geocaching::protocol::CmpReader reader(body); + IpConfirmationRequest candidate; + size_t fields = 0; + uint64_t generation = 0, revision = 0; + if (!reader.array(fields, 14) || fields != (import_history ? 14 : 11) || + !reader.binary(candidate.operation, 16) || candidate.operation.size != 16 || + !reader.binary(candidate.transfer, 16) || candidate.transfer.size != 16 || + !reader.binary(candidate.proof_hash, 32) || candidate.proof_hash.size != 32 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.binary(candidate.directory_public, 64) || candidate.directory_public.size != 64 || + !reader.binary(candidate.directory, 16) || candidate.directory.size != 16 || + !reader.binary(candidate.epoch, 16) || candidate.epoch.size != 16 || + !reader.binary(candidate.network_request, 16) || candidate.network_request.size != 16 || + !reader.binary(candidate.object, 32) || candidate.object.size != 32 || + !reader.binary(candidate.revision_hash, 32) || candidate.revision_hash.size != 32 || + !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX) return false; + if (import_history && (!reader.binary(candidate.manifest_transfer, 16) || candidate.manifest_transfer.size != 16 || + !reader.binary(candidate.manifest_hash, 32) || candidate.manifest_hash.size != 32 || + !reader.binary(candidate.manifest_signature, 64) || candidate.manifest_signature.size != 64)) return false; + if (!reader.finished()) return false; + if (import_history) + for (const auto field : {candidate.manifest_transfer, candidate.manifest_hash, candidate.manifest_signature}) + { + uint8_t present = 0; + for (size_t i = 0; i < field.size; ++i) present |= field.data[i]; + if (!present) return false; + } + for (const auto field : {candidate.operation, candidate.transfer, candidate.proof_hash, candidate.directory_public, + candidate.directory, candidate.epoch, candidate.network_request, candidate.object, candidate.revision_hash}) + { + uint8_t present = 0; + for (size_t i = 0; i < field.size; ++i) present |= field.data[i]; + if (!present) return false; + } + candidate.generation = uint32_t(generation); + candidate.revision = uint32_t(revision); + output = candidate; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/ip_confirmation_work.h b/modules/core_track_sharing/include/track_sharing/ip_confirmation_work.h new file mode 100644 index 00000000..e7470525 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/ip_confirmation_work.h @@ -0,0 +1,275 @@ +#pragma once +#include "track_sharing/confirm_ip_publication_head.h" +#include "track_sharing/object_paths.h" +#include "track_sharing/reservation_store.h" +#include "track_sharing/restore_ip_publication_head.h" +#include "track_sharing/signed_manifest_reader.h" +#include "track_sharing/upload_journal_store.h" +#include "track_sharing/upload_paths.h" +#include "track_sharing/uploaded_manifest_reader.h" +#include "track_sharing/verify_ip_confirmation.h" +namespace track_sharing +{ +enum class IpConfirmationResult : uint8_t +{ + Pending, + Ready, + Invalid, + Conflict, + HashMismatch, + StorageFailure, + StaleIdentity, + Cancelled +}; +// Platform admits this metadata owner and separate payload/domain storage in +// transient PSRAM, never on an ESP task stack. Each step gets fresh identity, +// media admission and disjoint scratch. Caller holds exclusive object paths; +// no file handles survive steps, no RF publication is admitted here. +template +class IpConfirmationWork +{ + public: + IpConfirmationWork(Io& io, Hash& hash, ManifestCrypto& manifest_crypto, ReceiptCrypto& receipt_crypto, + const IpConfirmationRequest& request, geocaching::ByteView author, geocaching::ByteView author_hash, + uint8_t* signed_storage, size_t signed_capacity, uint8_t* receipt_storage, size_t receipt_capacity, + uint8_t* domain_storage, size_t domain_capacity) + : io_(io), hash_(hash), manifest_crypto_(manifest_crypto), receipt_crypto_(receipt_crypto), + heads_(io, head_a_, head_b_), reservations_(io, reservation_a_, reservation_b_), + uploads_(io, upload_a_, upload_b_), receipts_(io, receipt_temporary_, receipt_path_), + signed_reader_(io), receipt_reader_(io, hash, upload_, generation_, author_hash_), + import_reader_(io, hash, upload_, generation_, author_hash_), + signed_storage_(signed_storage), receipt_storage_(receipt_storage), domain_storage_(domain_storage), + domain_capacity_(domain_capacity) + { + if (!author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || + !signed_storage || signed_capacity < 4166 || !receipt_storage || receipt_capacity < 1100 || + !domain_storage || domain_capacity < 4166 || !request.generation || !request.revision) return; + const geocaching::ByteView buffers[] = {{signed_storage, 4166}, {receipt_storage, 1100}, {domain_storage, domain_capacity}}; + for (const auto buffer : buffers) + if (signingSpansOverlap(buffer.data, buffer.size, reinterpret_cast(this), sizeof(*this))) return; + if (signingSpansOverlap(signed_storage, 4166, receipt_storage, 1100) || + signingSpansOverlap(signed_storage, 4166, domain_storage, domain_capacity) || + signingSpansOverlap(receipt_storage, 1100, domain_storage, domain_capacity)) return; + size_t offset = 0; + const auto copy = [&](geocaching::ByteView input, size_t length, geocaching::ByteView& output) + { + if (!input.data || input.size != length) return false; + std::memcpy(bindings_ + offset, input.data, length); + output = {bindings_ + offset, length}; + offset += length; + return true; + }; + if (!copy(request.operation, 16, request_.operation) || !copy(request.transfer, 16, request_.transfer) || + !copy(request.proof_hash, 32, request_.proof_hash) || !copy(request.directory_public, 64, request_.directory_public) || + !copy(request.directory, 16, request_.directory) || !copy(request.epoch, 16, request_.epoch) || + !copy(request.network_request, 16, request_.network_request) || !copy(request.object, 32, request_.object) || + !copy(request.revision_hash, 32, request_.revision_hash)) return; + if (request.manifest_transfer.size && (!copy(request.manifest_transfer, 16, request_.manifest_transfer) || + !copy(request.manifest_hash, 32, request_.manifest_hash) || !copy(request.manifest_signature, 64, request_.manifest_signature))) return; + std::memcpy(author_, author.data, 64); + std::memcpy(author_hash_, author_hash.data, 32); + generation_ = request_.generation = request.generation; + request_.revision = request.revision; + valid_ = resolveObjectPath(request_.object, ObjectFile::HeadA, {}, head_a_, sizeof(head_a_)) && + resolveObjectPath(request_.object, ObjectFile::HeadB, {}, head_b_, sizeof(head_b_)) && + resolveObjectPath(request_.object, ObjectFile::ReservationA, request_.operation, reservation_a_, sizeof(reservation_a_)) && + resolveObjectPath(request_.object, ObjectFile::ReservationB, request_.operation, reservation_b_, sizeof(reservation_b_)) && + resolveObjectPath(request_.object, ObjectFile::SignedManifest, request_.revision_hash, signed_path_, sizeof(signed_path_)) && + resolveObjectPath(request_.object, ObjectFile::ReceiptTemporary, request_.network_request, receipt_temporary_, sizeof(receipt_temporary_)) && + resolveObjectPath(request_.object, ObjectFile::Receipt, request_.network_request, receipt_path_, sizeof(receipt_path_)) && + resolveUploadPath(request_.transfer.data, 16, UploadFile::Candidate, upload_path_, sizeof(upload_path_), UploadKind::IpReceipt) && + resolveUploadPath(request_.transfer.data, 16, UploadFile::JournalA, upload_a_, sizeof(upload_a_), UploadKind::IpReceipt) && + resolveUploadPath(request_.transfer.data, 16, UploadFile::JournalB, upload_b_, sizeof(upload_b_), UploadKind::IpReceipt); + if (valid_ && request_.manifest_transfer.size) + valid_ = resolveObjectPath(request_.object, ObjectFile::SignedTemporary, request_.revision_hash, reservation_a_, sizeof(reservation_a_)); + if (valid_) result_ = IpConfirmationResult::Pending; + } + ~IpConfirmationWork() + { + if (valid_) wipe(); + } + IpConfirmationWork(const IpConfirmationWork&) = delete; + IpConfirmationWork& operator=(const IpConfirmationWork&) = delete; + const ObjectHead* result() const { return result_ == IpConfirmationResult::Ready ? &head_ : nullptr; } + const IpConfirmationRequest* importEvidence() const { return importPersistenceObject().size ? &request_ : nullptr; } + geocaching::ByteView importPersistenceObject() const + { + return request_.manifest_transfer.size && stage_ == Stage::Persisting && result_ == IpConfirmationResult::Pending ? request_.object : geocaching::ByteView{}; + } + IpConfirmationResult cancel() { return valid_ ? stop(IpConfirmationResult::Cancelled) : IpConfirmationResult::Invalid; } + IpConfirmationResult step(uint32_t generation, geocaching::ByteView author, geocaching::ByteView author_hash, + bool media_ready, uint8_t* scratch, size_t capacity) + { + if (!valid_) return IpConfirmationResult::Invalid; + if (!media_ready) return stop(IpConfirmationResult::StorageFailure); + if (generation != generation_ || !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || + std::memcmp(author.data, author_, 64) || std::memcmp(author_hash.data, author_hash_, 32)) return stop(IpConfirmationResult::StaleIdentity); + if (!scratch || capacity < 320 || signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(scratch, capacity, signed_storage_, 4166) || signingSpansOverlap(scratch, capacity, receipt_storage_, 1100) || + signingSpansOverlap(scratch, capacity, domain_storage_, domain_capacity_)) return stop(IpConfirmationResult::Invalid); + if (result_ == IpConfirmationResult::Ready) + { + // Cached success is bound to this exact durable head. A later + // successor or damaged journal must never replay the old success. + const auto current = heads_.validateCurrent(head_, head_verify_, scratch, capacity); + if (current != HeadStoreResult::Ready) return stop(current == HeadStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + return result_; + } + if (result_ != IpConfirmationResult::Pending) return result_; + if (stage_ == Stage::Start) + { + if (request_.manifest_transfer.size) + { + if (!resolveUploadPath(request_.manifest_transfer.data, 16, UploadFile::Candidate, upload_path_, sizeof(upload_path_), UploadKind::Manifest) || + !resolveUploadPath(request_.manifest_transfer.data, 16, UploadFile::JournalA, upload_a_, sizeof(upload_a_), UploadKind::Manifest) || + !resolveUploadPath(request_.manifest_transfer.data, 16, UploadFile::JournalB, upload_b_, sizeof(upload_b_), UploadKind::Manifest)) return stop(IpConfirmationResult::Invalid); + const auto loaded = uploads_.load(upload_, upload_verify_, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return stop(loaded == JournalStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + if (upload_.phase != UploadPhase::Sealed || upload_.total > 4096 || upload_.generation != generation_ || + std::memcmp(upload_.id, request_.manifest_transfer.data, 16) || std::memcmp(upload_.author_hash, author_hash_, 32) || + std::memcmp(upload_.file_hash, request_.manifest_hash.data, 32)) return stop(IpConfirmationResult::Conflict); + if (import_reader_.begin(upload_path_, signed_storage_ + 4, 4096) != FileIntegrityResult::Pending) return stop(IpConfirmationResult::Conflict); + stage_ = Stage::ImportReading; + return result_; + } + const auto loaded = uploads_.load(upload_, upload_verify_, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return stop(loaded == JournalStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + if (upload_.phase != UploadPhase::Sealed || upload_.total > 1100 || upload_.generation != generation_ || + std::memcmp(upload_.id, request_.transfer.data, 16) || std::memcmp(upload_.author_hash, author_hash_, 32) || + std::memcmp(upload_.file_hash, request_.proof_hash.data, 32)) return stop(IpConfirmationResult::Conflict); + const auto started = signed_reader_.begin(signed_path_, signed_storage_, 4166); + if (started != FileIntegrityResult::Pending) return stop(IpConfirmationResult::StorageFailure); + stage_ = Stage::SignedReading; + return result_; + } + if (stage_ == Stage::ImportReading) + { + const auto read = import_reader_.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return stop(read == FileIntegrityResult::HashMismatch ? IpConfirmationResult::HashMismatch : IpConfirmationResult::StorageFailure); + const size_t length = upload_.total, prefix = length <= 255 ? 3 : 4; + if (prefix == 3) std::memmove(signed_storage_ + 3, signed_storage_ + 4, length); + signed_storage_[0] = 0x92; + signed_storage_[1] = prefix == 3 ? 0xc4 : 0xc5; + if (prefix == 3) signed_storage_[2] = uint8_t(length); + else + { + signed_storage_[2] = uint8_t(length >> 8); + signed_storage_[3] = uint8_t(length); + } + signed_storage_[prefix + length] = 0xc4; + signed_storage_[prefix + length + 1] = 64; + std::memcpy(signed_storage_ + prefix + length + 2, request_.manifest_signature.data, 64); + if (verifyManifest({signed_storage_, prefix + length + 66}, manifest_crypto_, hash_, domain_storage_, domain_capacity_, manifest_, {author_, 64}) != ManifestVerification::Valid) + return stop(IpConfirmationResult::Conflict); + if (!resolveUploadPath(request_.transfer.data, 16, UploadFile::Candidate, upload_path_, sizeof(upload_path_), UploadKind::IpReceipt) || + !resolveUploadPath(request_.transfer.data, 16, UploadFile::JournalA, upload_a_, sizeof(upload_a_), UploadKind::IpReceipt) || + !resolveUploadPath(request_.transfer.data, 16, UploadFile::JournalB, upload_b_, sizeof(upload_b_), UploadKind::IpReceipt)) return stop(IpConfirmationResult::Invalid); + const auto loaded = uploads_.load(upload_, upload_verify_, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return stop(loaded == JournalStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + if (upload_.phase != UploadPhase::Sealed || upload_.total > 1100 || upload_.generation != generation_ || + std::memcmp(upload_.id, request_.transfer.data, 16) || std::memcmp(upload_.author_hash, author_hash_, 32) || + std::memcmp(upload_.file_hash, request_.proof_hash.data, 32)) return stop(IpConfirmationResult::Conflict); + if (receipt_reader_.begin(upload_path_, receipt_storage_, 1100) != FileIntegrityResult::Pending) return stop(IpConfirmationResult::Conflict); + stage_ = Stage::ReceiptReading; + return result_; + } + if (stage_ == Stage::SignedReading) + { + const auto read = signed_reader_.step(); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return stop(IpConfirmationResult::StorageFailure); + if (verifyManifest(signed_reader_.bytes(), manifest_crypto_, hash_, domain_storage_, domain_capacity_, manifest_, {author_, 64}) != ManifestVerification::Valid) + return stop(IpConfirmationResult::Conflict); + const auto started = receipt_reader_.begin(upload_path_, receipt_storage_, 1100); + if (started != FileIntegrityResult::Pending) return stop(IpConfirmationResult::Conflict); + stage_ = Stage::ReceiptReading; + return result_; + } + if (stage_ == Stage::ReceiptReading) + { + const auto read = receipt_reader_.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return stop(read == FileIntegrityResult::HashMismatch ? IpConfirmationResult::HashMismatch : IpConfirmationResult::StorageFailure); + if (verifyIpConfirmation(request_, receipt_reader_.bytes(), manifest_, generation_, {author_, 64}, {author_hash_, 32}, + receipt_crypto_, hash_, domain_storage_, domain_capacity_, expected_, proof_) != ReceiptVerification::Valid) + return stop(IpConfirmationResult::Conflict); + stage_ = Stage::Persisting; + return result_; + } + if (request_.manifest_transfer.size) + { + SignedManifestStore blobs(io_, reservation_a_, signed_path_); + const auto restored = restoreIpPublicationHead(heads_, blobs, receipts_, manifest_, proof_, expected_, request_.network_request, request_.directory_public, + generation_, {author_, 64}, {author_hash_, 32}, head_, next_, head_verify_, scratch, capacity); + if (restored != HeadStoreResult::Ready) return stop(restored == HeadStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + result_ = IpConfirmationResult::Ready; + wipe(); + return result_; + } + const auto reserved = reservations_.load(reservation_, reservation_verify_, scratch, capacity); + if (reserved != ReservationStoreResult::Ready) return stop(reserved == ReservationStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + const auto confirmed = confirmIpPublicationHead(heads_, receipts_, proof_, expected_, request_.network_request, request_.directory_public, + manifest_, reservation_, generation_, {author_, 64}, {author_hash_, 32}, head_, next_, head_verify_, scratch, capacity); + if (confirmed != HeadStoreResult::Ready) return stop(confirmed == HeadStoreResult::StorageFailure ? IpConfirmationResult::StorageFailure : IpConfirmationResult::Conflict); + result_ = IpConfirmationResult::Ready; + wipe(); + return result_; + } + + private: + enum class Stage : uint8_t + { + Start, + ImportReading, + SignedReading, + ReceiptReading, + Persisting + }; + void wipe() + { + std::memset(signed_storage_, 0, 4166); + std::memset(receipt_storage_, 0, 1100); + std::memset(domain_storage_, 0, domain_capacity_); + manifest_ = {}; + proof_ = {}; + } + IpConfirmationResult stop(IpConfirmationResult result) + { + signed_reader_.cancel(); + receipt_reader_.cancel(); + import_reader_.cancel(); + wipe(); + return result_ = result; + } + Io& io_; + Hash& hash_; + ManifestCrypto& manifest_crypto_; + ReceiptCrypto& receipt_crypto_; + uint8_t bindings_[352]{}, author_[64]{}, author_hash_[32]{}; + IpConfirmationRequest request_; + uint32_t generation_ = 0; + char head_a_[160]{}, head_b_[160]{}, reservation_a_[160]{}, reservation_b_[160]{}, signed_path_[160]{}, receipt_temporary_[160]{}, receipt_path_[160]{}; + char upload_path_[64]{}, upload_a_[64]{}, upload_b_[64]{}; + ObjectHead head_, next_, head_verify_; + RevisionReservation reservation_, reservation_verify_; + UploadJournal upload_, upload_verify_; + PublicationJournal expected_; + VerifiedManifestView manifest_; + VerifiedReceiptView proof_; + ObjectHeadStore heads_; + ReservationStore reservations_; + UploadJournalStore uploads_; + SignedManifestStore receipts_; + SignedManifestReader signed_reader_; + UploadedManifestReader receipt_reader_; + UploadedManifestReader import_reader_; + uint8_t* signed_storage_; + uint8_t* receipt_storage_; + uint8_t* domain_storage_; + size_t domain_capacity_; + Stage stage_ = Stage::Start; + bool valid_ = false; + IpConfirmationResult result_ = IpConfirmationResult::Invalid; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/kml_geometry_events.h b/modules/core_track_sharing/include/track_sharing/kml_geometry_events.h new file mode 100644 index 00000000..6d218ac6 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/kml_geometry_events.h @@ -0,0 +1,151 @@ +#pragma once +#include "track_sharing/coordinate_stream.h" +#include "track_sharing/track_datetime.h" +#include +#include +#include +namespace track_sharing +{ +// Input is a well-formed XML event stream with resolved namespace URIs and +// decoded text. Does not tokenize XML or resolve namespace declarations. +class KmlGeometryEvents +{ + public: + KmlGeometryEvents() : stream_(bounds_, CoordinateText::KmlTuples) {} + bool begin(std::string_view uri, std::string_view name) + { + if (failed_ || complete_ || depth_ >= 64) return fail(); + if (!depth_) + { + if (uri != kml || name != "kml") return fail(); + stack_[depth_++] = Kind::Root; + return true; + } + const auto parent = kindAt(depth_ - 1); + if (parent == Kind::Coordinates || parent == Kind::Coord || parent == Kind::When) return fail(); + Kind kind = Kind::Ignore; + if (parent != Kind::Ignore && uri == kml) + { + if (container(parent) && name == "Document") kind = Kind::Document; + else if (container(parent) && name == "Folder") kind = Kind::Folder; + else if (container(parent) && name == "Placemark") + { + if (++features_ > 5000) return fail(); + kind = Kind::Placemark; + } + else if ((parent == Kind::Placemark || parent == Kind::Multi) && name == "MultiGeometry") kind = Kind::Multi; + else if (parent == Kind::Placemark || parent == Kind::Multi) + { + if (name == "Point") kind = Kind::Point; + else if (name == "LineString") kind = Kind::Line; + else if (name == "Polygon") kind = Kind::Polygon; + else if (name == "LinearRing") kind = Kind::Ring; + } + else if (parent == Kind::Polygon && (name == "outerBoundaryIs" || name == "innerBoundaryIs")) kind = Kind::Boundary; + else if (parent == Kind::Boundary && name == "LinearRing") kind = Kind::Ring; + else if ((parent == Kind::Point || parent == Kind::Line || parent == Kind::Ring) && name == "coordinates") kind = Kind::Coordinates; + else if (parent == Kind::Track && name == "when") kind = Kind::When; + } + if (parent != Kind::Ignore && uri == gx) + { + if ((parent == Kind::Placemark || parent == Kind::Multi || parent == Kind::MultiTrack) && name == "Track") kind = Kind::Track; + else if ((parent == Kind::Placemark || parent == Kind::Multi) && name == "MultiTrack") kind = Kind::MultiTrack; + else if (parent == Kind::Track && name == "coord") kind = Kind::Coord; + } + if (kind == Kind::When) + { + when_size_ = 0; + if (!stream_.idleScratch()) return fail(); + } + if (kind == Kind::Track) + { + when_count_ = 0; + coord_count_ = 0; + } + if (kind == Kind::Coordinates || kind == Kind::Coord) + { + if (kind == Kind::Coordinates && (static_cast(stack_[depth_ - 1]) & 128)) return fail(); + start_points_ = bounds_.pointCount(); + stream_.~CoordinateStream(); + new (&stream_) CoordinateStream(bounds_, kind == Kind::Coord ? CoordinateText::GxCoordinate : CoordinateText::KmlTuples); + } + stack_[depth_++] = kind; + return true; + } + bool text(const char* data, size_t length) + { + if (failed_ || !depth_) return false; + const auto kind = kindAt(depth_ - 1); + if (kind == Kind::When) + { + if (length > size_t(63 - when_size_)) return fail(); + std::memcpy(stream_.idleScratch() + when_size_, data, length); + when_size_ += static_cast(length); + } + if ((kind == Kind::Coordinates || kind == Kind::Coord) && !stream_.feed(data, length)) return fail(); + return true; + } + bool end() + { + if (failed_ || !depth_) return fail(); + --depth_; + const auto kind = kindAt(depth_); + if (kind == Kind::When && (!validTrackDateTime({stream_.idleScratch(), when_size_}, true) || ++when_count_ > 100000)) return fail(); + if (kind == Kind::Track && when_count_ && when_count_ != coord_count_) return fail(); + if ((kind == Kind::Point || kind == Kind::Line || kind == Kind::Ring || (kind == Kind::Track && !coord_count_)) && !(static_cast(stack_[depth_]) & 128)) return fail(); + if (kind == Kind::Coordinates || kind == Kind::Coord) + { + if (!stream_.finish(kind == Kind::Coord)) return fail(); + const auto count = bounds_.pointCount() - start_points_; + const auto parent = kindAt(depth_ - 1); + if ((kind == Kind::Coord && count > 1) || (parent == Kind::Point && count != 1)) return fail(); + if (kind == Kind::Coord) ++coord_count_; + if (parent == Kind::Line && count < 2) return fail(); + if (parent == Kind::Ring && (count < 4 || !stream_.closedRing())) return fail(); + if (count) stack_[depth_ - 1] = static_cast(static_cast(stack_[depth_ - 1]) | 128); + } + if (!depth_) complete_ = true; + return true; + } + bool finish(GeometryExtent& result) const { return !failed_ && complete_ && !depth_ && bounds_.finish(result); } + static constexpr std::string_view kml = "http://www.opengis.net/kml/2.2", gx = "http://www.google.com/kml/ext/2.2"; + + private: + enum class Kind : uint8_t + { + Ignore, + Root, + Document, + Folder, + Placemark, + Multi, + Point, + Line, + Polygon, + Boundary, + Ring, + MultiTrack, + Track, + Coordinates, + Coord, + When + }; + static bool container(Kind value) { return value == Kind::Root || value == Kind::Document || value == Kind::Folder; } + Kind kindAt(size_t index) const { return static_cast(static_cast(stack_[index]) & 127); } + bool fail() + { + failed_ = true; + bounds_.add(INT64_MAX, 0); + return false; + } + GeometryBounds bounds_; + CoordinateStream stream_; + Kind stack_[64]{}; + uint32_t features_ = 0, start_points_ = 0; + uint32_t when_count_ = 0, coord_count_ = 0; + uint8_t when_size_ = 0; + uint8_t depth_ = 0; + bool failed_ = false, complete_ = false; +}; +static_assert(sizeof(KmlGeometryEvents) <= 288, "KML event consumer must not store feature/point lists"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/kml_image_events.h b/modules/core_track_sharing/include/track_sharing/kml_image_events.h new file mode 100644 index 00000000..d6b83ba2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/kml_image_events.h @@ -0,0 +1,218 @@ +#pragma once +#include "gps/kml/html_image_source_stream.h" +#include "track_sharing/kml_ui_coordinate_stream.h" +#include "track_sharing/track_datetime.h" +#include "track_sharing/xml_namespace_scope.h" +#include + +namespace track_sharing +{ +enum class KmlAltitudeMode : uint8_t +{ + ClampGround, + RelativeGround, + Absolute, + ClampSeaFloor, + RelativeSeaFloor +}; +struct KmlImageCallbacks +{ + void* context = nullptr; + void (*beginFeature)(void*) = nullptr; + // Source is XML-decoded, HTML-entity-encoded and borrowed until return. + // False stops images in this description, not XML validation. + bool (*image)(void*, std::string_view) = nullptr; + void (*endFeature)(void*, bool, double, double) = nullptr; + // Optional LineString/gx:Track preview points. Point/polygon coordinates + // locate images but are not added to a walking route. + void (*coordinate)(void*, bool, double, double, double, bool, bool) = nullptr; + // Mode can follow coordinates. Consumers finalize height semantics at end. + void (*geometry)(void*, bool, bool, KmlAltitudeMode) = nullptr; +}; + +// Description images are emitted in document order. The caller associates its +// feature's image slice at endFeature, so geometry/description order is neutral. +// This scanner is for UI association, not geometry or author validation. +class KmlImageEvents +{ + public: + KmlImageEvents(char* source, size_t capacity, KmlImageCallbacks callbacks) : html_(source, capacity), callbacks_(callbacks), scalar_(source), scalar_capacity_(capacity) {} + bool begin(XmlNamespace space, std::string_view local) + { + if (depth_ >= 64) return false; + const auto parent = depth_ ? nodes_[depth_ - 1] : Node::Ignored; + Node node = Node::Ignored; + if (!depth_) + { + if (root_ || local != "kml" || (space != XmlNamespace::Kml && space != XmlNamespace::None)) return false; + root_ = true; + legacy_ = space == XmlNamespace::None; + node = Node::Root; + } + else + { + // description is a string (CDATA or escaped HTML), never an XML + // geometry container. Do not concatenate around real child nodes. + if (parent == Node::Description || parent == Node::Coordinates || parent == Node::GxCoordinate || parent == Node::When || parent == Node::AltitudeMode) return false; + const bool kml = space == XmlNamespace::Kml || (legacy_ && space == XmlNamespace::None); + const bool feature_parent = parent == Node::Root || parent == Node::Container; + if (kml && feature_parent && (local == "Document" || local == "Folder")) node = Node::Container; + else if (kml && feature_parent && local == "Placemark") + { + node = Node::Placemark; + located_ = false; + if (callbacks_.beginFeature) callbacks_.beginFeature(callbacks_.context); + } + else if (kml && parent == Node::Placemark && local == "description") + { + node = Node::Description; + html_.reset(); + } + else if (kml && (parent == Node::Placemark || parent == Node::MultiGeometry) && local == "MultiGeometry") node = Node::MultiGeometry; + else if (kml && (parent == Node::Placemark || parent == Node::MultiGeometry) && local == "Point") node = Node::Point; + else if (kml && (parent == Node::Placemark || parent == Node::MultiGeometry) && local == "LineString") node = Node::Line; + else if (kml && (parent == Node::Placemark || parent == Node::MultiGeometry) && local == "Polygon") node = Node::Polygon; + else if (kml && parent == Node::Polygon && (local == "outerBoundaryIs" || local == "innerBoundaryIs")) node = Node::Boundary; + else if (kml && parent == Node::Boundary && local == "LinearRing") node = Node::Ring; + else if (space == XmlNamespace::Gx && (parent == Node::Placemark || parent == Node::MultiGeometry) && local == "MultiTrack") node = Node::MultiTrack; + else if (space == XmlNamespace::Gx && (parent == Node::Placemark || parent == Node::MultiGeometry || parent == Node::MultiTrack) && local == "Track") node = Node::Track; + else if (kml && (parent == Node::Point || parent == Node::Line || parent == Node::Ring) && local == "coordinates") node = Node::Coordinates; + else if (space == XmlNamespace::Gx && parent == Node::Track && local == "coord") node = Node::GxCoordinate; + else if (kml && parent == Node::Track && local == "when") node = Node::When; + else if ((kml || space == XmlNamespace::Gx) && (parent == Node::Line || parent == Node::Track) && local == "altitudeMode") + { + if (altitude_seen_) return false; + node = Node::AltitudeMode; + scalar_size_ = 0; + altitude_seen_ = true; + gx_altitude_ = space == XmlNamespace::Gx; + } + if (node == Node::Track) when_count_ = coordinate_count_ = 0; + if (node == Node::When) + { + if (++when_count_ > 100000) return false; + scalar_size_ = 0; + } + if (node == Node::GxCoordinate && ++coordinate_count_ > 100000) return false; + if (node == Node::Line || node == Node::Track) + { + pending_break_ = true; + altitude_mode_ = KmlAltitudeMode::ClampGround; + altitude_seen_ = false; + if (callbacks_.geometry) callbacks_.geometry(callbacks_.context, node == Node::Track, true, altitude_mode_); + } + if (node == Node::Coordinates || node == Node::GxCoordinate) + { + coordinate_emitted_ = false; + coordinate_geometry_ = parent; + coordinate_.emplace(node == Node::GxCoordinate, this, + [](void* p, double lat, double lon, double altitude, bool has_altitude) + { return static_cast(p)->point(lat, lon, altitude, has_altitude); }); + } + } + nodes_[depth_++] = node; + return true; + } + bool text(const char* text, size_t size) + { + if (!depth_) return false; + const auto node = nodes_[depth_ - 1]; + if (node == Node::Description) + { + html_.feed({text, size}, [&](std::string_view source) + { return !callbacks_.image || callbacks_.image(callbacks_.context, source); }); + } + else if (node == Node::Coordinates || node == Node::GxCoordinate) return coordinate_->feed(text, size); + else if (node == Node::When || node == Node::AltitudeMode) + { + if (!text || size > 63 - scalar_size_ || scalar_size_ + size > scalar_capacity_) return false; + std::memcpy(scalar_ + scalar_size_, text, size); + scalar_size_ += size; + } + return true; + } + bool end() + { + if (!depth_) return false; + const auto node = nodes_[--depth_]; + if (node == Node::Coordinates || node == Node::GxCoordinate) + { + if (!coordinate_->finish()) return false; + if (node == Node::GxCoordinate && !coordinate_emitted_) pending_break_ = true; + coordinate_.reset(); // empty gx:coord remains an unlocated sample + } + else if (node == Node::When && !validTrackDateTime({scalar_, scalar_size_}, true)) return false; + else if (node == Node::Track && when_count_ && when_count_ != coordinate_count_) return false; + else if (node == Node::AltitudeMode) + { + auto value = std::string_view(scalar_, scalar_size_); + const auto whitespace = [](char c) + { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; + while (!value.empty() && whitespace(value.front())) value.remove_prefix(1); + while (!value.empty() && whitespace(value.back())) value.remove_suffix(1); + if (!gx_altitude_ && value == "absolute") altitude_mode_ = KmlAltitudeMode::Absolute; + else if (!gx_altitude_ && value == "clampToGround") altitude_mode_ = KmlAltitudeMode::ClampGround; + else if (!gx_altitude_ && value == "relativeToGround") altitude_mode_ = KmlAltitudeMode::RelativeGround; + else if (gx_altitude_ && value == "clampToSeaFloor") altitude_mode_ = KmlAltitudeMode::ClampSeaFloor; + else if (gx_altitude_ && value == "relativeToSeaFloor") altitude_mode_ = KmlAltitudeMode::RelativeSeaFloor; + else return false; + } + if ((node == Node::Line || node == Node::Track) && callbacks_.geometry) + callbacks_.geometry(callbacks_.context, node == Node::Track, false, altitude_mode_); + else if (node == Node::Placemark && callbacks_.endFeature) + callbacks_.endFeature(callbacks_.context, located_, latitude_, longitude_); + return true; + } + bool finish() const { return root_ && depth_ == 0; } + + private: + enum class Node : uint8_t + { + Ignored, + Root, + Container, + Placemark, + Description, + MultiGeometry, + Point, + Line, + Ring, + Polygon, + Boundary, + Track, + MultiTrack, + Coordinates, + GxCoordinate, + When, + AltitudeMode + }; + bool point(double lat, double lon, double altitude, bool has_altitude) + { + if (++point_count_ > 100000) return false; + coordinate_emitted_ = true; + if (!located_) longitude_ = lon, latitude_ = lat, located_ = true; + if (coordinate_geometry_ == Node::Line || coordinate_geometry_ == Node::Track) + { + if (callbacks_.coordinate) callbacks_.coordinate(callbacks_.context, coordinate_geometry_ == Node::Track, lat, lon, altitude, has_altitude, pending_break_); + pending_break_ = false; + } + return true; + } + gps::kml::HtmlImageSourceStream html_; + KmlImageCallbacks callbacks_; + std::optional coordinate_; + Node nodes_[64]{}; + double latitude_ = 0, longitude_ = 0; + uint8_t depth_ = 0; + Node coordinate_geometry_ = Node::Ignored; + uint32_t point_count_ = 0; + char* scalar_; + size_t scalar_capacity_, scalar_size_ = 0; + uint32_t when_count_ = 0, coordinate_count_ = 0; + KmlAltitudeMode altitude_mode_ = KmlAltitudeMode::ClampGround; + bool altitude_seen_ = false, gx_altitude_ = false; + bool root_ = false, legacy_ = false, located_ = false; + bool pending_break_ = true, coordinate_emitted_ = false; +}; +static_assert(sizeof(KmlImageEvents) <= 480, "Image/time events retain one tuple and borrow scalar storage"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/kml_image_xml_stream.h b/modules/core_track_sharing/include/track_sharing/kml_image_xml_stream.h new file mode 100644 index 00000000..94812244 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/kml_image_xml_stream.h @@ -0,0 +1,175 @@ +#pragma once +#include "track_sharing/kml_image_events.h" +#include "track_sharing/xml_declaration_guard.h" +#include "track_sharing/xml_name.h" +#include "track_sharing/xml_namespace_scope.h" +#include "yxml.h" +#include +namespace track_sharing +{ +// Namespace/UTF-8 bounded XML wrapper for description image association. +// Caller lends both name-stack and source storage. Not an author validator. +class KmlImageXmlStream +{ + public: + KmlImageXmlStream(void* names, size_t capacity, char* source, size_t source_capacity, KmlImageCallbacks callbacks) + : events_(source, source_capacity, callbacks) + { + if (!names || capacity < 256 || !source || !source_capacity) failed_ = true; + else yxml_init(&xml_, names, capacity); + } + bool feed(const uint8_t* bytes, size_t size) + { + if (failed_ || finished_ || (!bytes && size) || size > 8388608 - count_) return fail(); + count_ += static_cast(size); + for (size_t i = 0; i < size; ++i) + { + const auto byte = bytes[i]; + if (!xml_declaration_.feed(byte)) return fail(); + if (!utf8(byte)) return fail(); + std::memmove(declaration_, declaration_ + 1, 8); + declaration_[8] = byte >= 'a' && byte <= 'z' ? char(byte - 32) : char(byte); + if (!std::memcmp(declaration_, " 200000 || !namespaces_.enter()) return fail(); + pending_ = xml_.elem; + attribute_count_ = 0; + } + else if (token == YXML_ATTRSTART) + { + const std::string_view name = xml_.attr; + if (!validXmlQName(name)) return fail(); + namespace_attribute_ = name == "xmlns" || name.substr(0, 6) == "xmlns:"; + prefix_ = name == "xmlns" ? std::string_view{} : name.substr(namespace_attribute_ ? 6 : 0); + uri_size_ = 0; + if (!namespace_attribute_ && !addAttribute(name)) return fail(); + } + else if (token == YXML_ATTRVAL && namespace_attribute_) + { + const auto length = std::strlen(xml_.data); + if (length > sizeof(uri_) - uri_size_) return fail(); + std::memcpy(uri_ + uri_size_, xml_.data, length); + uri_size_ += length; + } + else if (token == YXML_ATTREND && namespace_attribute_) + { + if (!namespaces_.declare(prefix_, {uri_, uri_size_})) return fail(); + namespace_attribute_ = false; + } + else if (token == YXML_PISTART && !validXmlName(xml_.pi, false)) return fail(); + else if (token == YXML_CONTENT) + { + if (!events_.text(xml_.data, std::strlen(xml_.data))) return fail(); + } + else if (token == YXML_ELEMEND) + { + if (!events_.end() || !namespaces_.leave()) return fail(); + } + } + return true; + } + bool finish() + { + if (failed_ || finished_ || remaining_ || !xml_declaration_.complete() || yxml_eof(&xml_) < 0 || (pending_ && !openElement()) || !events_.finish()) return fail(); + finished_ = true; + return true; + } + + private: + bool fail() + { + failed_ = true; + return false; + } + bool openElement() + { + for (unsigned i = 0; i < attribute_count_; ++i) + { + auto& attribute = attributes_[i]; + if (!namespaces_.attributeNamespace(attribute.prefix, attribute.space)) return false; + for (unsigned j = 0; j < i; ++j) + if (attribute.space == attributes_[j].space && attribute.local == attributes_[j].local) return false; + } + XmlNamespace space; + std::string_view local; + if (!namespaces_.element(pending_, space, local)) return false; + pending_ = nullptr; + return events_.begin(space, local); + } + bool addAttribute(std::string_view name) + { + if (attribute_count_ >= 32 || name.empty()) return false; + const auto colon = name.find(':'); + std::string_view prefix, local = name; + if (colon != name.npos) + { + if (!colon || colon + 1 == name.size() || name.find(':', colon + 1) != name.npos) return false; + prefix = name.substr(0, colon); + local = name.substr(colon + 1); + } + if (prefix.size() > 31) return false; + auto& attribute = attributes_[attribute_count_++]; + if (!prefix.empty()) std::memcpy(attribute.prefix, prefix.data(), prefix.size()); + attribute.prefix[prefix.size()] = '\0'; + attribute.local = XmlNamespaceScope::fingerprint(local); + return true; + } + bool utf8(uint8_t byte) + { + if (remaining_) + { + if ((byte & 0xc0) != 0x80) return false; + codepoint_ = (codepoint_ << 6) | (byte & 63); + if (--remaining_) return true; + if (codepoint_ < minimum_ || codepoint_ > 0x10ffff || (codepoint_ >= 0xd800 && codepoint_ <= 0xdfff) || codepoint_ == 0xfffe || codepoint_ == 0xffff) return false; + return true; + } + if (byte < 128) return byte >= 32 || byte == 9 || byte == 10 || byte == 13; + if (byte >= 0xc2 && byte <= 0xdf) + { + remaining_ = 1; + codepoint_ = byte & 31; + minimum_ = 0x80; + } + else if (byte >= 0xe0 && byte <= 0xef) + { + remaining_ = 2; + codepoint_ = byte & 15; + minimum_ = 0x800; + } + else if (byte >= 0xf0 && byte <= 0xf4) + { + remaining_ = 3; + codepoint_ = byte & 7; + minimum_ = 0x10000; + } + else return false; + return true; + } + yxml_t xml_{}; + XmlDeclarationGuard xml_declaration_; + struct Attribute + { + char prefix[32]{}; + uint64_t local = 0, space = 0; + }; + Attribute attributes_[32]{}; + uint8_t attribute_count_ = 0; + XmlNamespaceScope namespaces_; + KmlImageEvents events_; + const char* pending_ = nullptr; + std::string_view prefix_; + char uri_[256]{}, declaration_[9]{}; + size_t uri_size_ = 0; + uint32_t count_ = 0, elements_ = 0, codepoint_ = 0, minimum_ = 0; + uint8_t remaining_ = 0; + bool failed_ = false, finished_ = false, namespace_attribute_ = false; +}; +static_assert(sizeof(KmlImageXmlStream) <= 4096, "XML image scanner must fit transient admission"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/kml_ui_coordinate_stream.h b/modules/core_track_sharing/include/track_sharing/kml_ui_coordinate_stream.h new file mode 100644 index 00000000..3784ebb2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/kml_ui_coordinate_stream.h @@ -0,0 +1,100 @@ +#pragma once +#include +#include +#include +#include + +namespace track_sharing +{ +// UI coordinate events only: no point list and no change to signed geometry +// admission. KML comma tuples and gx whitespace triples have distinct grammar. +class KmlUiCoordinateStream +{ + public: + using Callback = bool (*)(void*, double, double, double, bool); + KmlUiCoordinateStream(bool gx, void* context, Callback callback) : context_(context), callback_(callback), gx_(gx) {} + bool feed(const char* text, size_t length) + { + if (failed_ || finished_ || (!text && length)) return false; + for (size_t i = 0; i < length; ++i) + { + const char c = text[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') + { + if (size_ && !number()) return fail(); + if (!gx_ && parts_ && !point()) return fail(); + } + else if (c == ',' && !gx_) + { + if (!size_ || !number() || parts_ >= 3) return fail(); + comma_ = true; + } + else + { + if (size_ >= 63 || !((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) return fail(); + token_[size_++] = c; + comma_ = false; + } + } + return true; + } + bool finish() + { + if (failed_ || finished_ || comma_ || (size_ && !number()) || (parts_ && !point()) || (!emitted_ && !gx_)) return fail(); + finished_ = true; + return true; // an empty gx:coord is an explicitly missing sample + } + + private: + bool fail() + { + failed_ = true; + return false; + } + bool number() + { + if (parts_ >= 3) return false; + size_t i = 0; + if (token_[i] == '+' || token_[i] == '-') ++i; + unsigned digits = 0; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') ++i, ++digits; + if (i < size_ && token_[i] == '.') + { + ++i; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') ++i, ++digits; + } + if (!digits) return false; + if (i < size_ && (token_[i] == 'e' || token_[i] == 'E')) + { + ++i; + if (i < size_ && (token_[i] == '+' || token_[i] == '-')) ++i; + const auto start = i; + while (i < size_ && token_[i] >= '0' && token_[i] <= '9') ++i; + if (i == start) return false; + } + if (i != size_) return false; + token_[size_] = '\0'; + const double value = std::strtod(token_, nullptr); + if (!std::isfinite(value)) return false; + values_[parts_++] = value; + size_ = 0; + return true; + } + bool point() + { + if (comma_ || parts_ < 2 || parts_ > 3 || (gx_ && parts_ != 3) || std::abs(values_[0]) > 180 || std::abs(values_[1]) > 90 || emitted_ >= 100000) return false; + if (callback_ && !callback_(context_, values_[1], values_[0], parts_ == 3 ? values_[2] : 0, parts_ == 3)) return false; + ++emitted_; + parts_ = 0; + return true; + } + void* context_; + Callback callback_; + double values_[3]{}; + char token_[64]{}; + uint32_t emitted_ = 0; + uint8_t size_ = 0, parts_ = 0; + bool gx_ = false, comma_ = false, failed_ = false, finished_ = false; +}; +static_assert(sizeof(KmlUiCoordinateStream) <= 128, "UI coordinates retain only one tuple"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/kml_xml_stream.h b/modules/core_track_sharing/include/track_sharing/kml_xml_stream.h new file mode 100644 index 00000000..bce04c3f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/kml_xml_stream.h @@ -0,0 +1,178 @@ +#pragma once +#include "track_sharing/kml_geometry_events.h" +#include "track_sharing/xml_declaration_guard.h" +#include "track_sharing/xml_name.h" +#include "track_sharing/xml_namespace_scope.h" +#include "yxml.h" +#include +namespace track_sharing +{ +// Geometry scanner, not yet full publication validation (gx time and XML name +// policy remain separate). Caller lends the XML name stack. Attribute identity +// fingerprints conservatively reject collisions, never miss equal names. +class KmlXmlStream +{ + public: + KmlXmlStream(void* names, size_t capacity) + { + if (!names || capacity < 256) failed_ = true; + else yxml_init(&xml_, names, capacity); + } + bool feed(const uint8_t* bytes, size_t size) + { + if (failed_ || finished_ || (!bytes && size) || size > 8388608 - count_) return fail(); + count_ += static_cast(size); + for (size_t i = 0; i < size; ++i) + { + const auto byte = bytes[i]; + if (!xml_declaration_.feed(byte)) return fail(); + if (!utf8(byte)) return fail(); + std::memmove(declaration_, declaration_ + 1, 8); + declaration_[8] = byte >= 'a' && byte <= 'z' ? char(byte - 32) : char(byte); + if (!std::memcmp(declaration_, " 200000 || !namespaces_.enter()) return fail(); + pending_ = xml_.elem; + attribute_count_ = 0; + } + else if (token == YXML_ATTRSTART) + { + const std::string_view name = xml_.attr; + if (!validXmlQName(name)) return fail(); + namespace_attribute_ = name == "xmlns" || name.substr(0, 6) == "xmlns:"; + prefix_ = name == "xmlns" ? std::string_view{} : name.substr(namespace_attribute_ ? 6 : 0); + uri_size_ = 0; + if (!namespace_attribute_ && !addAttribute(name)) return fail(); + } + else if (token == YXML_ATTRVAL && namespace_attribute_) + { + const auto length = std::strlen(xml_.data); + if (length > sizeof(uri_) - uri_size_) return fail(); + std::memcpy(uri_ + uri_size_, xml_.data, length); + uri_size_ += length; + } + else if (token == YXML_ATTREND && namespace_attribute_) + { + if (!namespaces_.declare(prefix_, {uri_, uri_size_})) return fail(); + namespace_attribute_ = false; + } + else if (token == YXML_PISTART && !validXmlName(xml_.pi, false)) return fail(); + else if (token == YXML_CONTENT) + { + if (!geometry_.text(xml_.data, std::strlen(xml_.data))) return fail(); + } + else if (token == YXML_ELEMEND) + { + if (!geometry_.end() || !namespaces_.leave()) return fail(); + } + } + return true; + } + bool finish(GeometryExtent& extent) + { + if (failed_ || finished_ || remaining_ || !xml_declaration_.complete() || yxml_eof(&xml_) < 0 || (pending_ && !openElement()) || !geometry_.finish(extent)) return fail(); + finished_ = true; + return true; + } + + private: + bool fail() + { + failed_ = true; + return false; + } + bool openElement() + { + for (unsigned i = 0; i < attribute_count_; ++i) + { + auto& attribute = attributes_[i]; + if (!namespaces_.attributeNamespace(attribute.prefix, attribute.space)) return false; + for (unsigned j = 0; j < i; ++j) + if (attribute.space == attributes_[j].space && attribute.local == attributes_[j].local) return false; + } + XmlNamespace space; + std::string_view local; + if (!namespaces_.element(pending_, space, local)) return false; + pending_ = nullptr; + const auto uri = space == XmlNamespace::Kml ? KmlGeometryEvents::kml : space == XmlNamespace::Gx ? KmlGeometryEvents::gx + : space == XmlNamespace::Other ? std::string_view("urn:trailmate:other") + : std::string_view{}; + return geometry_.begin(uri, local); + } + bool addAttribute(std::string_view name) + { + if (attribute_count_ >= 32 || name.empty()) return false; + const auto colon = name.find(':'); + std::string_view prefix, local = name; + if (colon != name.npos) + { + if (!colon || colon + 1 == name.size() || name.find(':', colon + 1) != name.npos) return false; + prefix = name.substr(0, colon); + local = name.substr(colon + 1); + } + if (prefix.size() > 31) return false; + auto& attribute = attributes_[attribute_count_++]; + if (!prefix.empty()) std::memcpy(attribute.prefix, prefix.data(), prefix.size()); + attribute.prefix[prefix.size()] = '\0'; + attribute.local = XmlNamespaceScope::fingerprint(local); + return true; + } + bool utf8(uint8_t byte) + { + if (remaining_) + { + if ((byte & 0xc0) != 0x80) return false; + codepoint_ = (codepoint_ << 6) | (byte & 63); + if (--remaining_) return true; + if (codepoint_ < minimum_ || codepoint_ > 0x10ffff || (codepoint_ >= 0xd800 && codepoint_ <= 0xdfff) || codepoint_ == 0xfffe || codepoint_ == 0xffff) return false; + return true; + } + if (byte < 128) return byte >= 32 || byte == 9 || byte == 10 || byte == 13; + if (byte >= 0xc2 && byte <= 0xdf) + { + remaining_ = 1; + codepoint_ = byte & 31; + minimum_ = 0x80; + } + else if (byte >= 0xe0 && byte <= 0xef) + { + remaining_ = 2; + codepoint_ = byte & 15; + minimum_ = 0x800; + } + else if (byte >= 0xf0 && byte <= 0xf4) + { + remaining_ = 3; + codepoint_ = byte & 7; + minimum_ = 0x10000; + } + else return false; + return true; + } + yxml_t xml_{}; + XmlDeclarationGuard xml_declaration_; + struct Attribute + { + char prefix[32]{}; + uint64_t local = 0, space = 0; + }; + Attribute attributes_[32]{}; + uint8_t attribute_count_ = 0; + XmlNamespaceScope namespaces_; + KmlGeometryEvents geometry_; + const char* pending_ = nullptr; + std::string_view prefix_; + char uri_[256]{}, declaration_[9]{}; + size_t uri_size_ = 0; + uint32_t count_ = 0, elements_ = 0, codepoint_ = 0, minimum_ = 0; + uint8_t remaining_ = 0; + bool failed_ = false, finished_ = false, namespace_attribute_ = false; +}; +static_assert(sizeof(KmlXmlStream) <= 3584, "XML geometry scanner must fit transient admission"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/load_publication_history.h b/modules/core_track_sharing/include/track_sharing/load_publication_history.h new file mode 100644 index 00000000..dbd855fc --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/load_publication_history.h @@ -0,0 +1,37 @@ +#pragma once +#include "track_sharing/admit_publication_intent.h" +namespace track_sharing +{ +// Read-only recovery. Caller reconstructs desired from fresh signed manifest, +// blob/current-author evidence and holds the media lease. Ready identifies the +// journal only: Confirmed/Publishing still require PublicationReceiptLoader. +template +PublicationStoreResult loadPublicationHistory(Directories& directories, Jobs& jobs, + const PublicationDirectory& expected_directory, const PublicationJournal& authenticated_intent, + PublicationDirectory& directory, PublicationJournal& current, PublicationJournal& other, + uint8_t* scratch, size_t capacity) +{ + const void* records[] = {&expected_directory, &authenticated_intent, &directory, ¤t, &other}; + const size_t sizes[] = {sizeof(expected_directory), sizeof(authenticated_intent), sizeof(directory), sizeof(current), sizeof(other)}; + if (!scratch || capacity < kPublicationJournalBytes || + !publicationDirectoryMatches(expected_directory, authenticated_intent) || + authenticated_intent.sequence != 1 || authenticated_intent.phase != PublicationPhase::Accepted || authenticated_intent.offset) + return PublicationStoreResult::Invalid; + for(size_t i=0;i<5;++i) + { + const auto* input=reinterpret_cast(records[i]); + if(signingSpansOverlap(input,sizes[i],scratch,capacity))return PublicationStoreResult::Invalid; + for(size_t j=i+1;j<5;++j) + if(signingSpansOverlap(input,sizes[i],reinterpret_cast(records[j]),sizes[j]))return PublicationStoreResult::Invalid; + } + const auto fail=[&](PublicationStoreResult result){directory={};current={};other={};return result;}; + const auto pin=directories.load(directory,scratch,capacity); + if(pin!=PublicationStoreResult::Ready)return fail(pin); + if(!publicationDirectoriesEqual(expected_directory,directory))return fail(PublicationStoreResult::Conflict); + const auto loaded=jobs.load(current,other,scratch,capacity); + if(loaded!=PublicationStoreResult::Ready)return fail(loaded); + if(!publicationBindingsEqual(authenticated_intent,current) || !publicationDirectoryMatches(directory,current)) + return fail(PublicationStoreResult::Conflict); + return PublicationStoreResult::Ready; +} +} diff --git a/modules/core_track_sharing/include/track_sharing/manifest.h b/modules/core_track_sharing/include/track_sharing/manifest.h new file mode 100644 index 00000000..a7e6a68d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/manifest.h @@ -0,0 +1,84 @@ +#pragma once +#include "geocaching/protocol/record_decoder.h" +#include "track_sharing/manifest_extensions.h" + +namespace track_sharing +{ +struct ManifestView +{ + geocaching::ByteView raw, author, nonce, previous, file_hash; + std::string_view title, description; + uint64_t created = 0, updated = 0; + uint32_t revision = 0, distance = 0, duration = 0, ascent = 0, points = 0, file_bytes = 0; + int32_t west = 0, south = 0, east = 0, north = 0; + uint8_t kind = 0, state = 0; + bool has_duration = false, has_ascent = false; + uint8_t version = 1; + uint16_t extensions_offset = 0; +}; +static_assert(sizeof(ManifestView) <= 208, "Manifest view must not copy text/file storage"); +inline bool decodeManifest(geocaching::ByteView raw, ManifestView& output) +{ + output = {}; + if (!raw.data || !raw.size || raw.size > 4096) return false; + geocaching::protocol::CmpReader reader(raw); + size_t fields = 0; + uint64_t value = 0; + const auto bounded = [&](uint64_t maximum, uint64_t& number) + { return reader.unsignedInteger(number) && number <= maximum; }; + const auto nonzero = [](geocaching::ByteView bytes) + {uint8_t bits=0;for(size_t i=0;i(value); + if ( + !reader.binary(output.author, 64) || output.author.size != 64 || !nonzero(output.author) || + !reader.binary(output.nonce, 16) || output.nonce.size != 16 || !nonzero(output.nonce) || !bounded(1, value)) return false; + output.kind = static_cast(value); + if (!bounded(UINT32_MAX, value) || !value) return false; + output.revision = static_cast(value); + if (output.revision == 1) + { + if (!reader.nil()) return false; + } + else if (!reader.binary(output.previous, 32) || output.previous.size != 32) return false; + if (!bounded(1, value) || (output.revision == 1 && value)) return false; + output.state = static_cast(value); + if (!bounded(253402300799ULL, output.created) || !bounded(253402300799ULL, output.updated) || output.updated < output.created || + !reader.text(output.title, 128) || !geocaching::protocol::validRecordText(output.title, false, true) || + !reader.text(output.description, 2048) || !geocaching::protocol::validRecordText(output.description, true, false) || + !reader.array(fields, 4) || fields != 4) return false; + int64_t coordinate = 0; + const auto angle = [&](int64_t low, int64_t high, int32_t& result) + {if(!reader.signedInteger(coordinate)||coordinatehigh)return false;result=static_cast(coordinate);return true; }; + if (!angle(-1800000000, 1799999999, output.west) || !angle(-900000000, 900000000, output.south) || + !angle(-1800000000, 1799999999, output.east) || !angle(output.south, 900000000, output.north) || !reader.array(fields, 4) || fields != 4 || !bounded(UINT32_MAX, value)) return false; + output.distance = static_cast(value); + const auto optional = [&](uint32_t& result, bool& present) + {if(reader.position()(value);present=true;return true; }; + if (!optional(output.duration, output.has_duration) || !optional(output.ascent, output.has_ascent) || !bounded(100000, value) || !value) return false; + output.points = static_cast(value); + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(output.file_hash, 32) || output.file_hash.size != 32 || !bounded(8388608, value) || !value) return false; + output.file_bytes = static_cast(value); + std::string_view mime; + if (!reader.text(mime, 36) || mime != "application/vnd.google-earth.kml+xml" || !bounded(1, value) || value != 1) return false; + if (output.version == 2) + { + output.extensions_offset = static_cast(reader.position()); + if (!readManifestMetadata(reader, raw, output.points) || !readAttachmentIndexBinding(reader, raw)) return false; + } + if (!reader.finished()) return false; + output.raw = raw; + return true; +} +// Requires a decoded immutable view; this does not establish author integrity. +inline bool getManifestExtensions(const ManifestView& view, ManifestExtensionsView& output) +{ + output = {}; + if (view.version != 2 || !view.raw.data || view.raw.size > 4096 || !view.extensions_offset || view.extensions_offset >= view.raw.size) return false; + const geocaching::ByteView tail{view.raw.data + view.extensions_offset, view.raw.size - view.extensions_offset}; + geocaching::protocol::CmpReader reader(tail); + return readManifestMetadata(reader, tail, view.points, &output.metadata) && readAttachmentIndexBinding(reader, tail, &output.assets) && reader.finished(); +} +static_assert(sizeof(ManifestView) == ((offsetof(ManifestView, has_ascent) + sizeof(bool) + alignof(ManifestView) - 1) / alignof(ManifestView)) * alignof(ManifestView), + "Version/offset must fit the original borrowed view padding on every ABI"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/manifest_chain.h b/modules/core_track_sharing/include/track_sharing/manifest_chain.h new file mode 100644 index 00000000..c09a4952 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/manifest_chain.h @@ -0,0 +1,44 @@ +#pragma once +#include "track_sharing/manifest.h" +#include +namespace track_sharing +{ +// Structural predecessor rules only. Both views must have been decoded and +// signature-verified; parent_hash must come from the domain hash of its raw +// bytes. This function does not confer signature authenticity/currentness. +inline bool validateSuccessor(const ManifestView& parent, const ManifestView& child, const uint8_t parent_hash[32]) +{ + if (!parent_hash || !parent.raw.data || !child.raw.data || parent.author.size != 64 || child.author.size != 64 || + parent.nonce.size != 16 || child.nonce.size != 16 || child.previous.size != 32 || + !parent.author.data || !child.author.data || !parent.nonce.data || !child.nonce.data || !child.previous.data || + parent.revision == UINT32_MAX || child.revision != parent.revision + 1 || + std::memcmp(parent.author.data, child.author.data, 64) || std::memcmp(parent.nonce.data, child.nonce.data, 16) || + std::memcmp(child.previous.data, parent_hash, 32) || parent.created != child.created || child.updated < parent.updated || + (parent.state == 1 && child.state == 0) || child.version < parent.version) return false; + if (child.state == 1 && (parent.file_hash.size != 32 || child.file_hash.size != 32 || !parent.file_hash.data || !child.file_hash.data || + std::memcmp(parent.file_hash.data, child.file_hash.data, 32) || parent.file_bytes != child.file_bytes)) return false; + if (child.state == 1 && child.version == 2) + { + if (!child.extensions_offset || child.extensions_offset >= child.raw.size) return false; + // After skipping metadata, compare the canonical assets binding alone; + // no automatic extension views or attachment table are needed. + geocaching::protocol::CmpReader next({child.raw.data + child.extensions_offset, child.raw.size - child.extensions_offset}); + const geocaching::ByteView next_tail{child.raw.data + child.extensions_offset, child.raw.size - child.extensions_offset}; + if (!readManifestMetadata(next, next_tail, child.points)) return false; + const geocaching::ByteView next_assets{next_tail.data + next.position(), next_tail.size - next.position()}; + if (parent.version == 1) + { + if (next_assets.size != 1 || next_assets.data[0] != 0xc0) return false; + } + else + { + if (!parent.extensions_offset || parent.extensions_offset >= parent.raw.size) return false; + const geocaching::ByteView old_tail{parent.raw.data + parent.extensions_offset, parent.raw.size - parent.extensions_offset}; + geocaching::protocol::CmpReader old(old_tail); + if (!readManifestMetadata(old, old_tail, parent.points) || old_tail.size - old.position() != next_assets.size || + std::memcmp(old_tail.data + old.position(), next_assets.data, next_assets.size)) return false; + } + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/manifest_extensions.h b/modules/core_track_sharing/include/track_sharing/manifest_extensions.h new file mode 100644 index 00000000..68c02235 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/manifest_extensions.h @@ -0,0 +1,90 @@ +#pragma once +#include "geocaching/protocol/record_decoder.h" + +namespace track_sharing +{ +constexpr size_t kMaxAttachmentIndexBytes = 294912; +constexpr uint32_t kMaxTrackPackageBytes = 64U * 1024U * 1024U; +constexpr size_t kMaxTrackAttachments = 64; +struct ManifestMetadataView +{ + geocaching::ByteView raw, tags, derived_object, derived_revision; + uint32_t geometry_capabilities = 0, segments = 0, waypoints = 0; + uint8_t activity = 0, time_coverage = 0, altitude_semantics = 0, tag_count = 0; +}; +struct AttachmentIndexBinding +{ + geocaching::ByteView hash; + uint32_t bytes = 0; + uint8_t count = 0; +}; +struct ManifestExtensionsView +{ + ManifestMetadataView metadata; + AttachmentIndexBinding assets; +}; +static_assert(sizeof(ManifestExtensionsView) <= 112, "Extensions borrow all variable data"); + +// Optional output lets the main decoder validate without an automatic +// extension object or retained tag/attachment arrays. +inline bool readManifestMetadata(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, + uint32_t points, ManifestMetadataView* output = nullptr) +{ + if (output) *output = {}; + const size_t start = reader.position(); + if (!raw.data || start >= raw.size) return false; + size_t fields = 0, count = 0; + uint64_t value = 0; + if (!reader.array(fields, 8) || fields != 8 || !reader.unsignedInteger(value) || value > 255) return false; + if (output) output->activity = static_cast(value); + const size_t tag_start = reader.position(); + if (!reader.array(count, 8)) return false; + const size_t tag_body = reader.position(); + if (tag_body > raw.size) return false; + for (size_t i = 0; i < count; ++i) + { + std::string_view tag, previous; + if (!reader.text(tag, 48) || !geocaching::protocol::validRecordText(tag, false, true)) return false; + geocaching::protocol::CmpReader earlier({raw.data + tag_body, raw.size - tag_body}); + for (size_t j = 0; j < i; ++j) + if (!earlier.text(previous, 48) || previous == tag) return false; + } + if (output) output->tags = {raw.data + tag_start, reader.position() - tag_start}, output->tag_count = static_cast(count); + if (!reader.unsignedInteger(value) || value > UINT32_MAX) return false; + if (output) output->geometry_capabilities = static_cast(value); + if (!reader.unsignedInteger(value) || value > points) return false; + if (output) output->segments = static_cast(value); + if (!reader.unsignedInteger(value) || value > points) return false; + if (output) output->waypoints = static_cast(value); + if (!reader.unsignedInteger(value) || value > 2) return false; + if (output) output->time_coverage = static_cast(value); + if (!reader.unsignedInteger(value) || value > 4) return false; + if (output) output->altitude_semantics = static_cast(value); + geocaching::ByteView object, revision; + if (reader.position() < raw.size && raw.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else if (!reader.array(fields, 2) || fields != 2 || !reader.binary(object, 32) || object.size != 32 || !reader.binary(revision, 32) || revision.size != 32) return false; + if (output) + { + output->derived_object = object; + output->derived_revision = revision; + output->raw = {raw.data + start, reader.position() - start}; + } + return true; +} +inline bool readAttachmentIndexBinding(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, AttachmentIndexBinding* output = nullptr) +{ + if (output) *output = {}; + if (reader.position() < raw.size && raw.data[reader.position()] == 0xc0) return reader.nil(); + size_t fields = 0; + uint64_t bytes = 0, count = 0, format = 0; + geocaching::ByteView hash; + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(hash, 32) || hash.size != 32 || + !reader.unsignedInteger(bytes) || !bytes || bytes > kMaxAttachmentIndexBytes || + !reader.unsignedInteger(count) || !count || count > kMaxTrackAttachments || !reader.unsignedInteger(format) || format != 1) return false; + if (output) *output = {hash, static_cast(bytes), static_cast(count)}; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/manifest_file_check.h b/modules/core_track_sharing/include/track_sharing/manifest_file_check.h new file mode 100644 index 00000000..15b8006c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/manifest_file_check.h @@ -0,0 +1,137 @@ +#pragma once +#include "track_sharing/file_integrity.h" +#include "track_sharing/kml_xml_stream.h" +#include "track_sharing/upload_journal.h" +#include "track_sharing/verify_manifest.h" +namespace track_sharing +{ +enum class ManifestFileResult : uint8_t +{ + Idle, + Pending, + Ready, + Busy, + Invalid, + HashMismatch, + StorageFailure, + GeometryMismatch, + Cancelled +}; +// Single-use admitted job. Caller owns immutable file lease, source path, I/O, +// hash context, XML name stack and per-step scratch. Ready proves current +// implemented file/hash/index checks, not complete publication authorization. +template +class ManifestFileCheck +{ + public: + ManifestFileCheck(Io& io, Hash& hash, void* names, size_t capacity) : integrity_(io, hash), xml_(names, capacity) {} + ManifestFileResult begin(const char* path, const VerifiedManifestView& signed_view) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!signed_view.verified || signed_view.manifest.file_hash.size != 32 || !signed_view.manifest.file_hash.data) return ManifestFileResult::Invalid; + return beginFields(path, signed_view.manifest); + } + template + ManifestFileResult beginVerifiedPackage(const char* path, const VerifiedManifestView& signed_view, const PackageProof& package) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!signed_view.verified || signed_view.manifest.version != 2 || !package.matchesAttachments(signed_view.manifest)) return ManifestFileResult::Invalid; + return beginFields(path, signed_view.manifest, true); + } + // Local publication preflight, not signature verification or authorization. + // Platform supplies the current device public key and retains the immutable + // raw manifest/file lease through signing, including revision reservation. + ManifestFileResult beginCandidate(const char* path, geocaching::ByteView raw, + geocaching::ByteView device_author, ManifestView& candidate) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!device_author.data || device_author.size != 64 || !decodeManifest(raw, candidate)) return ManifestFileResult::Invalid; + if (std::memcmp(candidate.author.data, device_author.data, 64)) return ManifestFileResult::Invalid; + return beginFields(path, candidate); + } + // Current public key/hash/generation must come from the same authenticated + // device identity read. This binds preflight to a sealed uploaded candidate, + // not a manifest's self-declared file. Paths/leases remain platform-owned. + ManifestFileResult beginUploadedCandidate(const char* path, geocaching::ByteView raw, geocaching::ByteView device_author, + const UploadJournal& upload, uint32_t generation, geocaching::ByteView author_hash, ManifestView& candidate) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!uploadJournalValid(upload) || upload.phase != UploadPhase::Sealed || generation != upload.generation || + !author_hash.data || author_hash.size != 32 || std::memcmp(author_hash.data, upload.author_hash, 32) || + !device_author.data || device_author.size != 64 || !decodeManifest(raw, candidate)) return ManifestFileResult::Invalid; + if (std::memcmp(candidate.author.data, device_author.data, 64) || candidate.file_bytes != upload.total || + std::memcmp(candidate.file_hash.data, upload.file_hash, 32)) return ManifestFileResult::Invalid; + return beginFields(path, candidate); + } + template + ManifestFileResult beginUploadedPackageCandidate(const char* path, geocaching::ByteView raw, geocaching::ByteView device_author, + const UploadJournal& upload, uint32_t generation, geocaching::ByteView author_hash, + ManifestView& candidate, const PackageProof& package) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!uploadJournalValid(upload) || upload.phase != UploadPhase::Sealed || generation != upload.generation || + !author_hash.data || author_hash.size != 32 || std::memcmp(author_hash.data, upload.author_hash, 32) || + !device_author.data || device_author.size != 64 || !decodeManifest(raw, candidate) || candidate.version != 2 || + std::memcmp(candidate.author.data, device_author.data, 64) || candidate.file_bytes != upload.total || + std::memcmp(candidate.file_hash.data, upload.file_hash, 32) || !package.matchesAttachments(candidate)) return ManifestFileResult::Invalid; + return beginFields(path, candidate, true); + } + ManifestFileResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != ManifestFileResult::Pending) return result_; + const auto before = integrity_.checkedBytes(); + const auto status = integrity_.step(scratch, capacity); + const auto count = integrity_.checkedBytes() - before; + if (status == FileIntegrityResult::Pending) + { + if (count && !xml_.feed(scratch, count)) + { + integrity_.cancel(); + return result_ = ManifestFileResult::Invalid; + } + return result_; + } + if (status == FileIntegrityResult::Ready) + { + GeometryExtent actual; + if (!xml_.finish(actual)) return result_ = ManifestFileResult::Invalid; + if (actual.west != expected_.west || actual.south != expected_.south || actual.east != expected_.east || actual.north != expected_.north || actual.points != expected_.points) return result_ = ManifestFileResult::GeometryMismatch; + return result_ = ManifestFileResult::Ready; + } + return result_ = status == FileIntegrityResult::HashMismatch ? ManifestFileResult::HashMismatch : status == FileIntegrityResult::Invalid ? ManifestFileResult::Invalid + : ManifestFileResult::StorageFailure; + } + ManifestFileResult cancel() + { + if (result_ == ManifestFileResult::Pending) + { + integrity_.cancel(); + result_ = ManifestFileResult::Cancelled; + } + return result_; + } + uint32_t checkedBytes() const { return integrity_.checkedBytes(); } + bool ready() const { return result_ == ManifestFileResult::Ready; } + bool matchesCandidate(const ManifestView& manifest) const + { + return ready() && manifest.file_hash.size == 32 && integrity_.matchesExpected(manifest.file_bytes, manifest.file_hash.data) && + manifest.west == expected_.west && manifest.south == expected_.south && manifest.east == expected_.east && manifest.north == expected_.north && manifest.points == expected_.points; + } + + private: + ManifestFileResult beginFields(const char* path, const ManifestView& manifest, bool package_checked = false) + { + // Existing KML-only entry points still reject v2. Only the explicit + // package entry point above can supply this admitted proof. + if (manifest.version != 1 && !(manifest.version == 2 && package_checked)) return result_ = ManifestFileResult::Invalid; + expected_ = {manifest.west, manifest.south, manifest.east, manifest.north, manifest.points}; + const auto started = integrity_.begin(path, manifest.file_bytes, manifest.file_hash.data); + if (started != FileIntegrityResult::Pending) return result_ = started == FileIntegrityResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + return result_ = ManifestFileResult::Pending; + } + FileIntegrityCheck integrity_; + KmlXmlStream xml_; + GeometryExtent expected_; + ManifestFileResult result_ = ManifestFileResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/manifest_hash.h b/modules/core_track_sharing/include/track_sharing/manifest_hash.h new file mode 100644 index 00000000..1785321d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/manifest_hash.h @@ -0,0 +1,31 @@ +#pragma once +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +// Hash lends start/update/finish/reset; it owns its separately admitted context. +// No domain+manifest allocation, no automatic ManifestView. Schema validity +// and hashes do not establish author authenticity or file availability. +template +bool deriveManifestHashes(geocaching::ByteView raw, ManifestView& view, Hash& hash, uint8_t object_id[32], uint8_t revision_hash[32]) +{ + if (!object_id || !revision_hash || signingSpansOverlap(raw.data, raw.size, object_id, 32) || + signingSpansOverlap(raw.data, raw.size, revision_hash, 32) || signingSpansOverlap(object_id, 32, revision_hash, 32)) return false; + std::memset(object_id, 0, 32); + std::memset(revision_hash, 0, 32); + if (!decodeManifest(raw, view)) return false; + constexpr char object_domain[] = "TrailMate.Track.ID.v1"; + constexpr char revision_domain[] = "TrailMate.Track.Revision.v1"; + constexpr char revision_v2_domain[] = "TrailMate.Track.Revision.v2"; + const bool ok = hash.start() && hash.update(reinterpret_cast(object_domain), sizeof(object_domain)) && + hash.update(view.author.data, view.author.size) && hash.update(view.nonce.data, view.nonce.size) && hash.finish(object_id) && + hash.start() && hash.update(reinterpret_cast(view.version == 2 ? revision_v2_domain : revision_domain), sizeof(revision_domain)) && + hash.update(raw.data, raw.size) && hash.finish(revision_hash); + hash.reset(); + if (!ok) + { + std::memset(object_id, 0, 32); + std::memset(revision_hash, 0, 32); + } + return ok; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/network_publish.h b/modules/core_track_sharing/include/track_sharing/network_publish.h new file mode 100644 index 00000000..d9424ea7 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/network_publish.h @@ -0,0 +1,84 @@ +#pragma once +#include "track_sharing/network_upload_response.h" +#include "track_sharing/persist_upload_response.h" +#include "track_sharing/publication_journal_store.h" +#include "track_sharing/verify_manifest.h" + +namespace track_sharing +{ +inline bool publicationManifestMatches(const PublicationJournal& job, const VerifiedManifestView& proof) +{ + return publicationJournalValid(job) && proof.verified && proof.manifest.raw.data && proof.manifest.raw.size && + proof.manifest.raw.size <= 4096 && proof.signature.data && proof.signature.size == 64 && + proof.manifest.file_hash.data && proof.manifest.file_hash.size == 32 && + proof.manifest.file_bytes == job.file_bytes && proof.manifest.revision == job.revision && + !std::memcmp(proof.object_id, job.object, 32) && !std::memcmp(proof.revision_hash, job.revision_hash, 32) && + !std::memcmp(proof.manifest.file_hash.data, job.file_hash, 32); +} + +// The durable operation ID is also the network PUBLISH request ID. Recovery +// resends exactly the same signed version and obtains the cached receipt. +inline bool encodeTrackPublishRequest(const PublicationJournal& job, const VerifiedManifestView& proof, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!publicationManifestMatches(job, proof) || job.phase != PublicationPhase::Publishing || + !output || !capacity || (proof.manifest.previous.size && (!proof.manifest.previous.data || proof.manifest.previous.size != 32))) return false; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&job), sizeof(job)}, + {reinterpret_cast(&proof), sizeof(proof)}, + proof.manifest.raw, + proof.signature, + proof.manifest.previous}) + if (input.size && !networkInputDisjoint(input, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 7, {job.operation, 16}, 8192, 2) || !writer.array(2) || + !writer.binary(proof.manifest.raw) || !writer.binary(proof.signature) || + !(proof.manifest.previous.size ? writer.binary(proof.manifest.previous) : writer.nil())) return false; + written = writer.size(); + return writer.good(); +} + +struct PublishResponseView +{ + geocaching::ByteView signed_receipt; + bool current = false; +}; +// Structural response binding only. Caller must authenticate the transport and +// verify the receipt's directory key, epoch, version and Ed25519 signature. +inline bool decodeTrackPublishResponse(geocaching::ByteView bytes, geocaching::ByteView request, PublishResponseView& output) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + if (!uploadResponseHeader(reader, 7, request, 2)) return false; + const auto start = reader.position(); + size_t count = 0; + geocaching::ByteView raw, signature; + if (!reader.array(count, 2) || count != 2 || !reader.binary(raw, 1024) || !raw.size || + !reader.binary(signature, 64) || signature.size != 64) return false; + const auto end = reader.position(); + uint64_t current = 0; + if (!reader.unsignedInteger(current) || current > 1 || !reader.finished()) return false; + output = {{bytes.data + start, end - start}, current != 0}; + return true; +} + +template +PublicationStoreResult markPublicationSending(Store& store, PublicationJournal& current, PublicationJournal& next, + PublicationJournal& verify, const VerifiedManifestView& manifest, + uint8_t* scratch, size_t capacity) +{ + if (!publicationManifestMatches(current, manifest) || ¤t == &next || ¤t == &verify || &next == &verify || + !uploadJournalWorkspace(current, next, verify, manifest.manifest.raw, manifest.signature, {}, scratch, capacity) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(&manifest), sizeof(manifest))) return PublicationStoreResult::Invalid; + if (current.phase == PublicationPhase::Publishing) return store.validateCurrent(current, verify, scratch, capacity); + if (current.phase != PublicationPhase::FileStored || current.sequence == UINT64_MAX) return PublicationStoreResult::Invalid; + next = current; + next.phase = PublicationPhase::Publishing; + ++next.sequence; + const auto result = store.advance(current, next, verify, scratch, capacity); + if (result == PublicationStoreResult::Ready) current = next; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/network_read.h b/modules/core_track_sharing/include/track_sharing/network_read.h new file mode 100644 index 00000000..af8a81f3 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/network_read.h @@ -0,0 +1,87 @@ +#pragma once +#include "track_sharing/network_request.h" + +namespace track_sharing +{ +struct ReadQuery +{ + int32_t west = 0, south = 0, east = 0, north = 0; + int8_t kind = -1, state = 0; + geocaching::ByteView author; + uint8_t limit = 16; + bool has_snapshot = false, has_cursor = false; + uint64_t snapshot = 0; + geocaching::ByteView cursor_object, cursor_binding; +}; +static_assert(sizeof(ReadQuery) <= 128, "Read query retains metadata only"); + +inline bool readSpan(geocaching::ByteView value, size_t bytes) +{ + return value.data && value.size == bytes; +} +inline bool readOptionalDisjoint(geocaching::ByteView value, uint8_t* output, size_t capacity) +{ + return !value.size || networkInputDisjoint(value, output, capacity); +} + +// Caller provides output storage. Encoders own no buffers and cannot express +// uploads or publications. Query coordinates use canonical west/south/east/north. +inline bool encodeTrackReadQuery(geocaching::ByteView request, const ReadQuery& query, uint16_t budget, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (query.west < -1800000000 || query.west > 1799999999 || query.east < -1800000000 || query.east > 1799999999 || + query.south < -900000000 || query.north > 900000000 || query.south > query.north || + query.kind < -1 || query.kind > 1 || query.state < -1 || query.state > 1 || !query.limit || query.limit > 32 || + (query.author.size && !readSpan(query.author, 64)) || query.snapshot > INT64_MAX || + (query.has_cursor && (!query.has_snapshot || !readSpan(query.cursor_object, 32) || !readSpan(query.cursor_binding, 32))) || + !networkInputDisjoint(request, output, capacity) || + !networkInputDisjoint({reinterpret_cast(&query), sizeof(query)}, output, capacity) || + !readOptionalDisjoint(query.author, output, capacity) || + !readOptionalDisjoint(query.cursor_object, output, capacity) || !readOptionalDisjoint(query.cursor_binding, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 1, request, budget, 7) || !writer.array(4) || + !writer.signedInteger(query.west) || !writer.signedInteger(query.south) || + !writer.signedInteger(query.east) || !writer.signedInteger(query.north) || + !(query.kind < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.kind))) || + !(query.author.size ? writer.binary(query.author) : writer.nil()) || + !(query.state < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.state))) || !writer.unsignedInteger(query.limit) || + !(query.has_snapshot ? writer.unsignedInteger(query.snapshot) : writer.nil())) return false; + if (query.has_cursor) + { + if (!writer.array(2) || !writer.binary(query.cursor_object) || !writer.binary(query.cursor_binding)) return false; + } + else if (!writer.nil()) return false; + written = writer.size(); + return writer.good(); +} + +inline bool encodeTrackReadManifest(geocaching::ByteView request, geocaching::ByteView object, + geocaching::ByteView revision, uint16_t budget, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!readSpan(object, 32) || (revision.size && !readSpan(revision, 32)) || + !networkInputDisjoint(request, output, capacity) || !networkInputDisjoint(object, output, capacity) || + !readOptionalDisjoint(revision, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 2, request, budget, 2) || !writer.binary(object) || + !(revision.size ? writer.binary(revision) : writer.nil())) return false; + written = writer.size(); + return writer.good(); +} + +inline bool encodeTrackReadFile(geocaching::ByteView request, geocaching::ByteView hash, + uint32_t offset, uint16_t length, uint16_t budget, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!readSpan(hash, 32) || !length || length > kNetworkChunkBytes || offset >= 8388608 || length > 8388608 - offset || + !networkInputDisjoint(request, output, capacity) || !networkInputDisjoint(hash, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 8, request, budget, 3) || !writer.binary(hash) || + !writer.unsignedInteger(offset) || !writer.unsignedInteger(length)) return false; + written = writer.size(); + return writer.good(); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/network_read_response.h b/modules/core_track_sharing/include/track_sharing/network_read_response.h new file mode 100644 index 00000000..b8a8d806 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/network_read_response.h @@ -0,0 +1,97 @@ +#pragma once +#include "track_sharing/network_upload_response.h" + +namespace track_sharing +{ +struct ReadResponseView +{ + geocaching::ByteView body; + uint16_t status = 0; + uint8_t fields = 0; +}; +inline bool readResponseOperation(uint8_t operation) { return operation == 0 || operation == 1 || operation == 2 || operation == 8 || (operation >= 9 && operation <= 13); } + +// Envelope validation is separate from operation-specific validation. A sink +// must validate the complete body before accepting or acknowledging storage. +inline bool decodeTrackReadEnvelope(geocaching::ByteView bytes, geocaching::ByteView request, + uint8_t operation, ReadResponseView& output) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || !networkRequestId(request) || !readResponseOperation(operation) || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + size_t count = 0; + uint64_t value = 0, status = 0; + geocaching::ByteView id; + if (!reader.array(count, 6) || count != 6 || !reader.unsignedInteger(value) || value != 1 || + !reader.unsignedInteger(value) || value != 1 || !reader.unsignedInteger(value) || value != operation || + !reader.binary(id, 16) || id.size != 16 || std::memcmp(id.data, request.data, 16) || + !reader.unsignedInteger(status) || (status != 200 && (status < 400 || status > 599))) return false; + const size_t body_start = reader.position(); + if (!reader.array(count, 8)) return false; + if (status == 200) + { + const size_t expected = operation == 0 || operation == 9 ? 8 : operation == 1 || operation == 10 || operation == 11 || operation == 13 ? 3 + : operation == 2 ? 2 + : 4; + if (count != expected) return false; + } + else + { + std::string_view diagnostic; + if ((count != 1 && count != 2) || !reader.text(diagnostic, 128) || + (count == 2 && (!reader.unsignedInteger(value) || value > UINT32_MAX)) || !reader.finished()) return false; + } + output = {{bytes.data + body_start, bytes.size - body_start}, uint16_t(status), uint8_t(count)}; + return true; +} + +struct ReadManifestResponse +{ + geocaching::ByteView manifest, signature; + bool current = false; +}; +inline bool decodeTrackReadManifestResponse(geocaching::ByteView bytes, geocaching::ByteView request, ReadManifestResponse& output) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + ReadManifestResponse candidate; + size_t count = 0; + uint64_t current = 0; + if (!uploadResponseHeader(reader, 2, request, 2) || !reader.array(count, 2) || count != 2 || + !reader.binary(candidate.manifest, 4096) || !candidate.manifest.size || + !reader.binary(candidate.signature, 64) || candidate.signature.size != 64 || + !reader.unsignedInteger(current) || current > 1 || !reader.finished()) return false; + candidate.current = current != 0; + output = candidate; + return true; +} + +struct ReadFileResponse +{ + geocaching::ByteView content; + uint32_t total = 0, offset = 0; +}; +inline bool decodeTrackReadFileResponse(geocaching::ByteView bytes, geocaching::ByteView request, + geocaching::ByteView expected_hash, uint32_t expected_total, + uint32_t expected_offset, uint16_t expected_length, ReadFileResponse& output, uint8_t operation = 8) +{ + if ((operation != 8 && operation != 12) || !bytes.data || !bytes.size || bytes.size > 8192 || !expected_hash.data || expected_hash.size != 32 || + !expected_total || expected_total > 8388608 || expected_offset >= expected_total || !expected_length || + expected_length > 1024 || expected_length > expected_total - expected_offset || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(expected_hash, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + geocaching::ByteView hash, content; + uint64_t total = 0, offset = 0; + if (!uploadResponseHeader(reader, operation, request, 4) || !reader.binary(hash, 32) || hash.size != 32 || + std::memcmp(hash.data, expected_hash.data, 32) || !reader.unsignedInteger(total) || total != expected_total || + !reader.unsignedInteger(offset) || offset != expected_offset || !reader.binary(content, 1024) || + content.size != expected_length || !reader.finished()) return false; + output = {content, uint32_t(total), uint32_t(offset)}; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/network_request.h b/modules/core_track_sharing/include/track_sharing/network_request.h new file mode 100644 index 00000000..dc84fd6e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/network_request.h @@ -0,0 +1,77 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" + +namespace track_sharing +{ +inline constexpr char kApplicationType[] = "trailmate.track.v1"; +inline constexpr size_t kNetworkChunkBytes = 1024; + +// Caller owns output and input leases. No allocation, identity material, or +// retained KML buffer. A successful encoding does not admit a publication. +inline bool networkRequestId(geocaching::ByteView id) +{ + if (!id.data || id.size != 16) return false; + uint8_t bits = 0; + for (size_t i = 0; i < id.size; ++i) bits |= id.data[i]; + return bits != 0; +} + +inline bool networkInputDisjoint(geocaching::ByteView input, uint8_t* output, size_t capacity) +{ + if (!input.data || !output || !capacity) return false; + const auto source = reinterpret_cast(input.data); + const auto target = reinterpret_cast(output); + return source <= target ? target - source >= input.size : source - target >= capacity; +} + +inline bool networkRequestHeader(geocaching::protocol::CmpWriter& writer, + uint8_t operation, geocaching::ByteView request, + uint16_t budget, uint32_t fields) +{ + return operation <= 8 && networkRequestId(request) && budget >= 512 && budget <= 8192 && + writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && + writer.unsignedInteger(operation) && writer.binary(request) && + writer.unsignedInteger(budget) && writer.array(fields); +} + +inline bool encodeTrackUploadBegin(geocaching::ByteView request, geocaching::ByteView hash, + uint32_t bytes, uint16_t budget, uint8_t* output, + size_t capacity, size_t& written) +{ + written = 0; + if (!hash.data || hash.size != 32 || !bytes || bytes > 8388608 || + !networkInputDisjoint(request, output, capacity) || !networkInputDisjoint(hash, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 4, request, budget, 2) || !writer.binary(hash) || !writer.unsignedInteger(bytes)) return false; + written = writer.size(); + return writer.good(); +} + +inline bool encodeTrackUploadChunk(geocaching::ByteView request, geocaching::ByteView token, + uint32_t offset, geocaching::ByteView chunk, uint16_t budget, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!networkRequestId(token) || !chunk.data || !chunk.size || chunk.size > kNetworkChunkBytes || + offset > 8388608 || chunk.size > 8388608 - offset || + !networkInputDisjoint(request, output, capacity) || !networkInputDisjoint(token, output, capacity) || + !networkInputDisjoint(chunk, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 5, request, budget, 3) || !writer.binary(token) || + !writer.unsignedInteger(offset) || !writer.binary(chunk)) return false; + written = writer.size(); + return writer.good(); +} + +inline bool encodeTrackUploadFinish(geocaching::ByteView request, geocaching::ByteView token, + uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!networkRequestId(token) || !networkInputDisjoint(request, output, capacity) || + !networkInputDisjoint(token, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!networkRequestHeader(writer, 6, request, budget, 1) || !writer.binary(token)) return false; + written = writer.size(); + return writer.good(); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/network_upload_response.h b/modules/core_track_sharing/include/track_sharing/network_upload_response.h new file mode 100644 index 00000000..9dfc9b67 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/network_upload_response.h @@ -0,0 +1,78 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/network_request.h" + +namespace track_sharing +{ +struct NetworkUploadBegin +{ + geocaching::ByteView token; + uint32_t offset = 0; + uint16_t chunk_bytes = 0; + bool sealed = false; +}; +// Source/destination authentication is required before these decoders. The +// request ID and operation are matched to the outstanding durable job. +inline bool uploadResponseHeader(geocaching::protocol::CmpReader& reader, + uint8_t operation, geocaching::ByteView request, size_t fields) +{ + size_t count = 0; + uint64_t value = 0; + geocaching::ByteView received; + return networkRequestId(request) && reader.array(count, 6) && count == 6 && + reader.unsignedInteger(value) && value == 1 && reader.unsignedInteger(value) && value == 1 && + reader.unsignedInteger(value) && value == operation && reader.binary(received, 16) && + received.size == 16 && !std::memcmp(received.data, request.data, 16) && + reader.unsignedInteger(value) && value == 200 && reader.array(count, fields) && count == fields; +} +inline bool decodeTrackUploadBegin(geocaching::ByteView bytes, geocaching::ByteView request, + uint32_t file_bytes, NetworkUploadBegin& output) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || !file_bytes || file_bytes > 8388608 || + !networkInputDisjoint(bytes, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(request, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(bytes); + if (!uploadResponseHeader(reader, 4, request, 4)) return false; + NetworkUploadBegin candidate; + if (reader.position() < bytes.size && bytes.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else if (!reader.binary(candidate.token, 16) || !networkRequestId(candidate.token)) return false; + uint64_t offset = 0, sealed = 0, chunk = 0; + if (!reader.unsignedInteger(offset) || offset > file_bytes || !reader.unsignedInteger(sealed) || sealed > 1 || + !reader.unsignedInteger(chunk) || !chunk || chunk > kNetworkChunkBytes || !reader.finished() || + (sealed ? candidate.token.size != 0 || offset != file_bytes : candidate.token.size != 16)) return false; + candidate.offset = uint32_t(offset); + candidate.chunk_bytes = uint16_t(chunk); + candidate.sealed = sealed != 0; + output = candidate; + return true; +} +inline bool decodeTrackUploadAck(geocaching::ByteView bytes, geocaching::ByteView request, + geocaching::ByteView token, uint32_t offset, size_t chunk_bytes, + uint32_t file_bytes) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || !networkRequestId(token) || + !file_bytes || file_bytes > 8388608 || !chunk_bytes || chunk_bytes > kNetworkChunkBytes || + offset > file_bytes || chunk_bytes > file_bytes - offset) return false; + geocaching::protocol::CmpReader reader(bytes); + geocaching::ByteView received; + uint64_t next = 0; + return uploadResponseHeader(reader, 5, request, 2) && reader.binary(received, 16) && received.size == 16 && + !std::memcmp(received.data, token.data, 16) && reader.unsignedInteger(next) && + next == offset + chunk_bytes && reader.finished(); +} +inline bool decodeTrackUploadSealed(geocaching::ByteView bytes, geocaching::ByteView request, + geocaching::ByteView file_hash, uint32_t file_bytes) +{ + if (!bytes.data || !bytes.size || bytes.size > 8192 || !file_hash.data || file_hash.size != 32 || + !file_bytes || file_bytes > 8388608) return false; + geocaching::protocol::CmpReader reader(bytes); + geocaching::ByteView received; + uint64_t length = 0, sealed = 0, cleanup = 0; + return uploadResponseHeader(reader, 6, request, 4) && reader.binary(received, 32) && received.size == 32 && + !std::memcmp(received.data, file_hash.data, 32) && reader.unsignedInteger(length) && length == file_bytes && + reader.unsignedInteger(sealed) && sealed == 1 && reader.unsignedInteger(cleanup) && cleanup <= 1 && reader.finished(); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/object_head.h b/modules/core_track_sharing/include/track_sharing/object_head.h new file mode 100644 index 00000000..1cb5eb3d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/object_head.h @@ -0,0 +1,103 @@ +#pragma once +#include "track_sharing/upload_journal.h" +namespace track_sharing +{ +enum class HeadPhase : uint8_t +{ + Signed, + Committed +}; +struct ObjectHead +{ + uint8_t object[32]{}, hash[32]{}, parent[32]{}, operation[16]{}; + uint64_t sequence = 0; + uint32_t generation = 0, revision = 0; + HeadPhase phase = HeadPhase::Signed; + bool archived = false; +}; +static_assert(sizeof(ObjectHead) <= 144, "Object head cannot own signed manifest bytes"); +constexpr size_t kObjectHeadBytes = 136; +inline bool objectHeadValid(const ObjectHead& head) +{ + uint8_t object = 0, hash = 0, parent = 0, operation = 0; + for (unsigned i = 0; i < 32; ++i) + { + object |= head.object[i]; + hash |= head.hash[i]; + parent |= head.parent[i]; + } + for (auto byte : head.operation) operation |= byte; + return object && hash && operation && head.sequence && head.generation && head.revision && + (head.revision == 1 ? !parent && !head.archived : parent != 0) && static_cast(head.phase) <= 1; +} +// Structural transition only. Caller must verify signed manifest and durable +// task admission separately. Committed is local admission, not network success. +inline bool objectHeadAdvanceValid(const ObjectHead& current, const ObjectHead& next) +{ + if (!objectHeadValid(current) || !objectHeadValid(next) || current.sequence == UINT64_MAX || next.sequence != current.sequence + 1 || + std::memcmp(current.object, next.object, 32)) return false; + // A restored identity may adopt its own verified committed history. Only + // metadata changes here; the caller must authenticate the original author. + if (current.generation != next.generation) + return current.phase == HeadPhase::Committed && next.phase == HeadPhase::Committed && current.revision == next.revision && + current.archived == next.archived && !std::memcmp(current.hash, next.hash, 32) && !std::memcmp(current.parent, next.parent, 32) && + !std::memcmp(current.operation, next.operation, 16); + if (current.revision == next.revision) + return current.archived == next.archived && !std::memcmp(current.hash, next.hash, 32) && !std::memcmp(current.parent, next.parent, 32) && + !std::memcmp(current.operation, next.operation, 16) && (current.phase == next.phase || (current.phase == HeadPhase::Signed && next.phase == HeadPhase::Committed)); + return current.phase == HeadPhase::Committed && next.phase == HeadPhase::Signed && current.revision != UINT32_MAX && next.revision == current.revision + 1 && + !(current.archived && !next.archived) && !std::memcmp(next.parent, current.hash, 32) && + std::memcmp(next.hash, current.hash, 32) && std::memcmp(next.operation, current.operation, 16); +} +// TMHD/v1/object32/hash32/parent32/op16/generationLE32/revisionLE32/ +// flags(phase bit0, archived bit1)/sequenceLE64/CRC16. CRC is not authenticity. +inline bool encodeObjectHead(const ObjectHead& head, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kObjectHeadBytes || !objectHeadValid(head)) return false; + const auto a = reinterpret_cast(&head), b = reinterpret_cast(output); + if (a <= b ? b - a < sizeof(head) : a - b < kObjectHeadBytes) return false; + std::memcpy(output, "TMHD", 4); + output[4] = 1; + std::memcpy(output + 5, head.object, 32); + std::memcpy(output + 37, head.hash, 32); + std::memcpy(output + 69, head.parent, 32); + std::memcpy(output + 101, head.operation, 16); + for (unsigned i = 0; i < 4; ++i) + { + output[117 + i] = uint8_t(head.generation >> (8 * i)); + output[121 + i] = uint8_t(head.revision >> (8 * i)); + } + output[125] = uint8_t(head.phase) | (head.archived ? 2 : 0); + for (unsigned i = 0; i < 8; ++i) output[126 + i] = uint8_t(head.sequence >> (8 * i)); + const auto crc = uploadJournalCrc(output, 134); + output[134] = uint8_t(crc); + output[135] = uint8_t(crc >> 8); + return true; +} +inline bool decodeObjectHead(const uint8_t* input, size_t size, ObjectHead& head) +{ + const auto a = reinterpret_cast(input), b = reinterpret_cast(&head); + if (input && (a <= b ? b - a < size : a - b < sizeof(head))) return false; + head = {}; + if (!input || size != kObjectHeadBytes || std::memcmp(input, "TMHD", 4) || input[4] != 1 || input[125] > 3 || + uploadJournalCrc(input, 134) != uint16_t(input[134] | uint16_t(input[135]) << 8)) return false; + std::memcpy(head.object, input + 5, 32); + std::memcpy(head.hash, input + 37, 32); + std::memcpy(head.parent, input + 69, 32); + std::memcpy(head.operation, input + 101, 16); + for (unsigned i = 0; i < 4; ++i) + { + head.generation |= uint32_t(input[117 + i]) << (8 * i); + head.revision |= uint32_t(input[121 + i]) << (8 * i); + } + for (unsigned i = 0; i < 8; ++i) head.sequence |= uint64_t(input[126 + i]) << (8 * i); + head.phase = static_cast(input[125] & 1); + head.archived = (input[125] & 2) != 0; + if (!objectHeadValid(head)) + { + head = {}; + return false; + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/object_head_store.h b/modules/core_track_sharing/include/track_sharing/object_head_store.h new file mode 100644 index 00000000..bc39bcc7 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/object_head_store.h @@ -0,0 +1,144 @@ +#pragma once +#include "track_sharing/object_head.h" +#include +namespace track_sharing +{ +enum class HeadStoreResult : uint8_t +{ + Ready, + Missing, + Corrupt, + StorageFailure, + Conflict, + Invalid +}; +// Borrowed canonical paths and metadata. Caller owns media/exclusive object +// lease and signed-manifest evidence. Corrupt slots never authorize rollback. +template +class ObjectHeadStore +{ + public: + ObjectHeadStore(Io& io, const char* a, const char* b) : io_(io), a_(a), b_(b) {} + HeadStoreResult load(ObjectHead& output, ObjectHead& other, uint8_t* scratch, size_t capacity) + { + if (&output == &other || !valid(scratch, capacity)) return HeadStoreResult::Invalid; + output = {}; + other = {}; + const auto first = read(a_, output, scratch), second = read(b_, other, scratch); + const auto fail = [&](HeadStoreResult result) + { output = {}; other = {}; return result; }; + if (first == HeadStoreResult::StorageFailure || second == HeadStoreResult::StorageFailure) return fail(HeadStoreResult::StorageFailure); + if (first == HeadStoreResult::Missing && second == HeadStoreResult::Missing) return HeadStoreResult::Missing; + if (first == HeadStoreResult::Corrupt || second == HeadStoreResult::Corrupt) return fail(HeadStoreResult::Corrupt); + if (first == HeadStoreResult::Ready && !(output.sequence & 1)) return fail(HeadStoreResult::Corrupt); + if (second == HeadStoreResult::Ready && (other.sequence & 1)) return fail(HeadStoreResult::Corrupt); + if (first == HeadStoreResult::Ready && second == HeadStoreResult::Ready) + { + if (output.sequence > other.sequence) + { + if (!objectHeadAdvanceValid(other, output)) return fail(HeadStoreResult::Corrupt); + } + else + { + if (!objectHeadAdvanceValid(output, other)) return fail(HeadStoreResult::Corrupt); + output = other; + } + return HeadStoreResult::Ready; + } + if (first != HeadStoreResult::Ready) return fail(HeadStoreResult::Corrupt); + if (output.sequence != 1 || (output.phase == HeadPhase::Signed && output.revision != 1)) return fail(HeadStoreResult::Corrupt); + return HeadStoreResult::Ready; + } + HeadStoreResult initialize(const ObjectHead& initial, ObjectHead& verify, uint8_t* scratch, size_t capacity) + { + if (!valid(scratch, capacity) || &initial == &verify || !objectHeadValid(initial) || initial.sequence != 1 || initial.revision != 1 || initial.phase != HeadPhase::Signed) return HeadStoreResult::Invalid; + for (const auto path : {a_, b_}) + { + const auto existing = read(path, verify, scratch); + if (existing == HeadStoreResult::StorageFailure) return existing; + if (existing != HeadStoreResult::Missing) return HeadStoreResult::Conflict; + } + return write(a_, initial, verify, scratch, capacity); + } + // Only a caller with authenticated author + directory receipt and durable + // evidence may initialize recovered history; normal signing stays rev1-only. + HeadStoreResult initializeCommitted(const ObjectHead& initial, ObjectHead& verify, uint8_t* scratch, size_t capacity) + { + if (!valid(scratch, capacity) || &initial == &verify || !objectHeadValid(initial) || initial.sequence != 1 || initial.phase != HeadPhase::Committed) + return HeadStoreResult::Invalid; + for (const auto path : {a_, b_}) + { + const auto existing = read(path, verify, scratch); + if (existing == HeadStoreResult::StorageFailure) return existing; + if (existing != HeadStoreResult::Missing) return HeadStoreResult::Conflict; + } + return write(a_, initial, verify, scratch, capacity); + } + HeadStoreResult validateCurrent(const ObjectHead& current, ObjectHead& verify, uint8_t* scratch, size_t capacity) + { + if (!valid(scratch, capacity) || ¤t == &verify || !objectHeadValid(current)) return HeadStoreResult::Invalid; + auto result = read(current.sequence & 1 ? a_ : b_, verify, scratch); + if (result == HeadStoreResult::StorageFailure) return result; + if (result != HeadStoreResult::Ready || !same(current, verify)) return HeadStoreResult::Conflict; + result = read(current.sequence & 1 ? b_ : a_, verify, scratch); + if (result == HeadStoreResult::StorageFailure) return result; + if (result == HeadStoreResult::Corrupt) return result; + if (result == HeadStoreResult::Missing && (current.sequence != 1 || (current.phase == HeadPhase::Signed && current.revision != 1))) return HeadStoreResult::Corrupt; + if (result == HeadStoreResult::Ready && !objectHeadAdvanceValid(verify, current)) return HeadStoreResult::Conflict; + return HeadStoreResult::Ready; + } + HeadStoreResult advance(const ObjectHead& current, const ObjectHead& next, ObjectHead& verify, uint8_t* scratch, size_t capacity) + { + if (&next == &verify || !objectHeadAdvanceValid(current, next)) return HeadStoreResult::Invalid; + const auto validated = validateCurrent(current, verify, scratch, capacity); + if (validated != HeadStoreResult::Ready) return validated; + return write(next.sequence & 1 ? a_ : b_, next, verify, scratch, capacity); + } + // Caller has verified the committed signed manifest against the current + // identity. Reuse its two borrowed records; no third record or heap buffer. + HeadStoreResult rebindCommittedGeneration(ObjectHead& current, ObjectHead& other, uint32_t generation, uint8_t* scratch, size_t capacity) + { + if (!generation || current.phase != HeadPhase::Committed || current.sequence == UINT64_MAX) + return HeadStoreResult::Invalid; + const auto validated = validateCurrent(current, other, scratch, capacity); + if (validated != HeadStoreResult::Ready || current.generation == generation) return validated; + other = current; + ++other.sequence; + other.generation = generation; + // write encodes other before reading the persisted result into current. + return write(other.sequence & 1 ? a_ : b_, other, current, scratch, capacity); + } + + private: + bool valid(uint8_t* scratch, size_t capacity) const { return scratch && capacity >= kObjectHeadBytes && a_ && *a_ && b_ && *b_ && std::strcmp(a_, b_); } + static bool same(const ObjectHead& a, const ObjectHead& b) + { + return a.sequence == b.sequence && a.generation == b.generation && a.revision == b.revision && a.phase == b.phase && a.archived == b.archived && + !std::memcmp(a.object, b.object, 32) && !std::memcmp(a.hash, b.hash, 32) && !std::memcmp(a.parent, b.parent, 32) && !std::memcmp(a.operation, b.operation, 16); + } + HeadStoreResult read(const char* path, ObjectHead& output, uint8_t* scratch) + { + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? HeadStoreResult::Missing : HeadStoreResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool read_ok = sized && size == kObjectHeadBytes && io_.read(opened.handle, scratch, kObjectHeadBytes) == kObjectHeadBytes; + const bool closed = io_.close(opened.handle); + if (!sized || !closed || (size == kObjectHeadBytes && !read_ok)) return HeadStoreResult::StorageFailure; + return read_ok && decodeObjectHead(scratch, kObjectHeadBytes, output) ? HeadStoreResult::Ready : HeadStoreResult::Corrupt; + } + HeadStoreResult write(const char* path, const ObjectHead& desired, ObjectHead& verify, uint8_t* scratch, size_t capacity) + { + if (!encodeObjectHead(desired, scratch, capacity)) return HeadStoreResult::Invalid; + const auto opened = io_.open(path, Io::Mode::CreateTemporary); + if (!opened.handle) return HeadStoreResult::StorageFailure; + const bool written = io_.write(opened.handle, scratch, kObjectHeadBytes) == kObjectHeadBytes && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return HeadStoreResult::StorageFailure; + return read(path, verify, scratch) == HeadStoreResult::Ready && same(desired, verify) ? HeadStoreResult::Ready : HeadStoreResult::StorageFailure; + } + Io& io_; + const char* a_; + const char* b_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/object_paths.h b/modules/core_track_sharing/include/track_sharing/object_paths.h new file mode 100644 index 00000000..08e1634e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/object_paths.h @@ -0,0 +1,86 @@ +#pragma once +#include "geocaching/domain/record.h" +#include +namespace track_sharing +{ +enum class ObjectFile : uint8_t +{ + HeadA, + HeadB, + ReservationA, + ReservationB, + SignedTemporary, + SignedManifest, + Directory, + KmlTemporary, + KmlFile, + PublicationA, + PublicationB, + PublicationDirectory, + ReceiptTemporary, + Receipt, + AttachmentIndex, + AttachmentIndexTemporary, + AttachmentResource, + AttachmentResourceTemporary +}; +constexpr const char* kObjectDirectory = "/trailmate/tracks"; +constexpr size_t kObjectPathCapacity = 160; +// Device-derived lowercase hex only. key is empty for heads, operation16 for +// reservation slots, revision hash32 for immutable signed manifest files. +inline bool resolveObjectPath(geocaching::ByteView object, ObjectFile file, geocaching::ByteView key, char* output, size_t capacity) +{ + if (!object.data || object.size != 32 || !output || capacity < kObjectPathCapacity || static_cast(file) > 17) return false; + const size_t key_size = file == ObjectFile::Directory || file == ObjectFile::HeadA || file == ObjectFile::HeadB ? 0 : file == ObjectFile::ReservationA || file == ObjectFile::ReservationB || file == ObjectFile::PublicationA || file == ObjectFile::PublicationB || file == ObjectFile::PublicationDirectory || file == ObjectFile::ReceiptTemporary || file == ObjectFile::Receipt ? 16 + : 32; + if (key.size != key_size || (key_size && !key.data)) return false; + const auto overlaps = [&](geocaching::ByteView input) + { const auto a = reinterpret_cast(input.data), b = reinterpret_cast(output); return input.size && (a <= b ? b - a < input.size : a - b < kObjectPathCapacity); }; + if (overlaps(object) || overlaps(key)) return false; + uint8_t object_bits = 0, key_bits = 0; + for (size_t i = 0; i < 32; ++i) object_bits |= object.data[i]; + for (size_t i = 0; i < key_size; ++i) key_bits |= key.data[i]; + if (!object_bits || (key_size && !key_bits)) return false; + constexpr char hex[] = "0123456789abcdef", prefix[] = "/trailmate/tracks/"; + size_t position = sizeof(prefix) - 1; + std::memcpy(output, prefix, position); + for (size_t i = 0; i < 32; ++i) + { + output[position++] = hex[object.data[i] >> 4]; + output[position++] = hex[object.data[i] & 15]; + } + if (file == ObjectFile::Directory) + { + output[position] = 0; + return true; + } + output[position++] = '/'; + if (!key_size) + { + std::memcpy(output + position, file == ObjectFile::HeadA ? "head.a" : "head.b", 7); + return true; + } + for (size_t i = 0; i < key_size; ++i) + { + output[position++] = hex[key.data[i] >> 4]; + output[position++] = hex[key.data[i] & 15]; + } + const char* suffix = file == ObjectFile::ReservationA ? ".rva" : file == ObjectFile::ReservationB ? ".rvb" + : file == ObjectFile::PublicationA ? ".pua" + : file == ObjectFile::PublicationB ? ".pub" + : file == ObjectFile::PublicationDirectory ? ".dir" + : file == ObjectFile::ReceiptTemporary ? ".rct" + : file == ObjectFile::Receipt ? ".rcp" + : file == ObjectFile::SignedTemporary ? ".tmp" + : file == ObjectFile::KmlTemporary ? ".ktm" + : file == ObjectFile::KmlFile ? ".kml" + : ".cmp"; + if (file == ObjectFile::AttachmentIndex) suffix = ".idx"; + else if (file == ObjectFile::AttachmentIndexTemporary) suffix = ".itm"; + else if (file == ObjectFile::AttachmentResource) suffix = ".ast"; + else if (file == ObjectFile::AttachmentResourceTemporary) suffix = ".atm"; + std::memcpy(output + position, suffix, 5); + static_assert(sizeof(prefix) - 1 + 64 + 1 + 64 + 5 <= kObjectPathCapacity, "Object path exceeds admitted storage"); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/package_cache_paths.h b/modules/core_track_sharing/include/track_sharing/package_cache_paths.h new file mode 100644 index 00000000..14369779 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/package_cache_paths.h @@ -0,0 +1,61 @@ +#pragma once +#include "track_sharing/object_paths.h" +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +constexpr const char* kPackageCacheDirectory = "/trailmate/packages"; +// Public imported versions are separate from owned object heads/reservations. +// Revision SHA selects the package; resource SHA selects bytes. No user path. +inline bool resolvePackageCachePath(geocaching::ByteView revision, ObjectFile file, geocaching::ByteView key, char* output, size_t capacity) +{ + const bool resource = file == ObjectFile::AttachmentResource || file == ObjectFile::AttachmentResourceTemporary; + const bool supported = resource || file == ObjectFile::Directory || file == ObjectFile::SignedManifest || file == ObjectFile::SignedTemporary || + file == ObjectFile::KmlFile || file == ObjectFile::KmlTemporary || file == ObjectFile::AttachmentIndex || file == ObjectFile::AttachmentIndexTemporary; + if (!supported || !revision.data || revision.size != 32 || !output || capacity < 160 || + (resource ? (!key.data || key.size != 32) : key.size != 0) || signingSpansOverlap(revision.data, 32, reinterpret_cast(output), 160) || + signingSpansOverlap(key.data, key.size, reinterpret_cast(output), 160)) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 32; ++i) bits |= revision.data[i]; + if (!bits) return false; + if (resource) + { + bits = 0; + for (size_t i = 0; i < 32; ++i) bits |= key.data[i]; + if (!bits) return false; + } + constexpr char prefix[] = "/trailmate/packages/", hex[] = "0123456789abcdef"; + size_t position = sizeof(prefix) - 1; + std::memcpy(output, prefix, position); + for (size_t i = 0; i < 32; ++i) + { + output[position++] = hex[revision.data[i] >> 4]; + output[position++] = hex[revision.data[i] & 15]; + } + if (file == ObjectFile::Directory) + { + output[position] = 0; + return true; + } + output[position++] = '/'; + if (resource) + { + for (size_t i = 0; i < 32; ++i) + { + output[position++] = hex[key.data[i] >> 4]; + output[position++] = hex[key.data[i] & 15]; + } + std::memcpy(output + position, file == ObjectFile::AttachmentResource ? ".ast" : ".atm", 5); + } + else + { + const char* name = file == ObjectFile::SignedManifest ? "manifest.cmp" : file == ObjectFile::SignedTemporary ? "manifest.tmp" + : file == ObjectFile::KmlFile ? "route.kml" + : file == ObjectFile::KmlTemporary ? "route.ktm" + : file == ObjectFile::AttachmentIndex ? "index.cmp" + : "index.itm"; + std::memcpy(output + position, name, std::strlen(name) + 1); + } + static_assert(sizeof(prefix) - 1 + 64 + 1 + 64 + 5 <= 160, "Public package path exceeds borrowed storage"); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/package_query_request.h b/modules/core_track_sharing/include/track_sharing/package_query_request.h new file mode 100644 index 00000000..f749e77b --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/package_query_request.h @@ -0,0 +1,28 @@ +#pragma once +#include "track_sharing/prepare_request.h" +namespace track_sharing +{ +inline bool decodePackageQueryRequest(geocaching::ByteView raw, PrepareRequest& output) +{ + const auto a = reinterpret_cast(raw.data), b = reinterpret_cast(&output); + if (raw.data && (a <= b ? b - a < raw.size : a - b < sizeof(output))) return false; + output = {}; + if (!raw.data || raw.size > 110) return false; + geocaching::protocol::CmpReader reader(raw); + size_t fields = 0; + uint64_t generation = 0; + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(output.operation, 16) || output.operation.size != 16 || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.binary(output.expected_object, 32) || output.expected_object.size != 32 || !reader.binary(output.expected_revision, 32) || output.expected_revision.size != 32 || !reader.finished()) return false; + for (const auto id : {output.operation, output.expected_object, output.expected_revision}) + { + uint8_t bits = 0; + for (size_t i = 0; i < id.size; ++i) bits |= id.data[i]; + if (!bits) return false; + } + output.generation = generation; + output.package_request = true; + output.install_request = true; + output.query_request = true; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/package_route_publication.h b/modules/core_track_sharing/include/track_sharing/package_route_publication.h new file mode 100644 index 00000000..4674253e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/package_route_publication.h @@ -0,0 +1,121 @@ +#pragma once +#include "track_sharing/immutable_file_copy.h" +#include "track_sharing/installed_package_binding.h" +namespace track_sharing +{ +enum class PackageRouteResult : uint8_t +{ + Idle, + Pending, + Ready, + Invalid, + Conflict, + StorageFailure, + HashMismatch, + Cancelled +}; +// Caller first proves the public cache, then retains source/identity/media and +// active-route/storage leases. Record is committed before KML rename, making +// .kml publication the final visibility step. No directory-wide rollback. +template +class PackageRoutePublication +{ + public: + PackageRoutePublication(Io& io, Hash& hash, const InstalledPackageBinding& binding, const char* cached_kml) + : io_(io), binding_(binding), source_(cached_kml), copy_(io, hash) {} + PackageRouteResult begin() + { + if (result_ != PackageRouteResult::Idle) return PackageRouteResult::Conflict; + if (!source_ || !*source_ || !installedPackageBindingValid(binding_) || + !routePackageTarget({binding_.revision, 32}, PackageRouteFile::Kml, target_, sizeof(target_)) || + !routePackageTarget({binding_.revision, 32}, PackageRouteFile::Temporary, temporary_, sizeof(temporary_)) || + !routePackageTarget({binding_.revision, 32}, PackageRouteFile::Binding, record_, sizeof(record_)) || + !routePackageTarget({binding_.revision, 32}, PackageRouteFile::BindingTemporary, record_temporary_, sizeof(record_temporary_))) return result_ = PackageRouteResult::Invalid; + return result_ = PackageRouteResult::Pending; + } + template + PackageRouteResult step(uint8_t* scratch, size_t capacity, Guard&& guard) + { + if (result_ != PackageRouteResult::Pending) return result_; + if (!scratch || capacity < 448 || signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(&binding_), sizeof(binding_)) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(source_), std::strlen(source_) + 1)) return fail(PackageRouteResult::Invalid); + if (!guard()) return fail(PackageRouteResult::Cancelled); + if (!record_ready_) + { + const auto stored = storeRecord(scratch, capacity); + if (stored != PackageRouteResult::Ready) return fail(stored); + record_ready_ = true; + return result_; + } + if (!copy_started_) + { + const auto begun = copy_.begin(source_, temporary_, target_, binding_.kml_bytes, binding_.kml_hash); + if (begun != ImmutableCopyResult::Pending) return fail(map(begun)); + copy_started_ = true; + } + const auto copied = copy_.step(scratch, capacity); + if (!guard()) return fail(PackageRouteResult::Cancelled); + return copied == ImmutableCopyResult::Pending ? result_ : result_ = map(copied); + } + void cancel() + { + copy_.cancel(); + result_ = PackageRouteResult::Cancelled; + } + bool ready() const { return result_ == PackageRouteResult::Ready; } + const char* target() const { return target_; } + + private: + PackageRouteResult probe(const char* path, uint8_t* expected, uint8_t* readback, size_t& bytes) + { + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? PackageRouteResult::Pending : PackageRouteResult::StorageFailure; + uint64_t size = 0; + const bool checked = io_.size(opened.handle, size) && size <= kInstalledPackageBindingBytes && io_.read(opened.handle, readback, size) == size; + const bool closed = io_.close(opened.handle); + if (!checked || !closed) return PackageRouteResult::StorageFailure; + bytes = size; + return std::memcmp(expected, readback, size) ? PackageRouteResult::Conflict : PackageRouteResult::Ready; + } + PackageRouteResult storeRecord(uint8_t* scratch, size_t capacity) + { + if (!encodeInstalledPackageBinding(binding_, scratch, capacity)) return PackageRouteResult::Invalid; + auto* readback = scratch + kInstalledPackageBindingBytes; + size_t bytes = 0; + const auto final = probe(record_, scratch, readback, bytes); + if (final == PackageRouteResult::Ready) return bytes == kInstalledPackageBindingBytes ? final : PackageRouteResult::Conflict; + if (final != PackageRouteResult::Pending) return final; + bytes = 0; + const auto temporary = probe(record_temporary_, scratch, readback, bytes); + if (temporary != PackageRouteResult::Ready && temporary != PackageRouteResult::Pending) return temporary; + const auto opened = io_.open(record_temporary_, temporary == PackageRouteResult::Pending ? Io::Mode::CreateTemporary : Io::Mode::Update); + if (!opened.handle) return PackageRouteResult::StorageFailure; + const bool written = io_.seek(opened.handle, bytes) && io_.write(opened.handle, scratch + bytes, kInstalledPackageBindingBytes - bytes) == kInstalledPackageBindingBytes - bytes && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return PackageRouteResult::StorageFailure; + // A failed rename can already have completed. Exact readback decides. + io_.publish(record_temporary_, record_); + bytes = 0; + const auto stored = probe(record_, scratch, readback, bytes); + return stored == PackageRouteResult::Ready && bytes == kInstalledPackageBindingBytes ? stored : stored == PackageRouteResult::Conflict ? stored + : PackageRouteResult::StorageFailure; + } + static PackageRouteResult map(ImmutableCopyResult result) { return result == ImmutableCopyResult::Ready ? PackageRouteResult::Ready : result == ImmutableCopyResult::HashMismatch ? PackageRouteResult::HashMismatch + : result == ImmutableCopyResult::Conflict ? PackageRouteResult::Conflict + : result == ImmutableCopyResult::Invalid ? PackageRouteResult::Invalid + : PackageRouteResult::StorageFailure; } + PackageRouteResult fail(PackageRouteResult result) + { + copy_.cancel(); + return result_ = result; + } + Io& io_; + const InstalledPackageBinding& binding_; + const char* source_; + ImmutableFileCopy copy_; + char target_[80]{}, temporary_[80]{}, record_[80]{}, record_temporary_[80]{}; + PackageRouteResult result_ = PackageRouteResult::Idle; + bool record_ready_ = false, copy_started_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/persist_publication_intent.h b/modules/core_track_sharing/include/track_sharing/persist_publication_intent.h new file mode 100644 index 00000000..fb3d6b8e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/persist_publication_intent.h @@ -0,0 +1,27 @@ +#pragma once +#include "track_sharing/publication_journal_store.h" +namespace track_sharing +{ +inline bool publicationBindingsEqual(const PublicationJournal& a, const PublicationJournal& b) +{ + return a.generation==b.generation && a.revision==b.revision && a.file_bytes==b.file_bytes && + !std::memcmp(a.operation,b.operation,16) && !std::memcmp(a.object,b.object,32) && !std::memcmp(a.revision_hash,b.revision_hash,32) && + !std::memcmp(a.directory,b.directory,16) && !std::memcmp(a.directory_epoch,b.directory_epoch,16) && + !std::memcmp(a.file_hash,b.file_hash,32) && !std::memcmp(a.author_hash,b.author_hash,32); +} +// Caller constructs desired from authenticated intent evidence and holds an +// exclusive media/path lease. Repeating admission never resets network progress. +template +PublicationStoreResult persistPublicationIntent(Store& store,const PublicationJournal& desired, + PublicationJournal& current,PublicationJournal& verify,uint8_t* scratch,size_t capacity) +{ + if(&desired==¤t || &desired==&verify || ¤t==&verify || !publicationJournalValid(desired) || + desired.sequence!=1 || desired.phase!=PublicationPhase::Accepted || desired.offset) return PublicationStoreResult::Invalid; + const auto loaded=store.load(current,verify,scratch,capacity); + if(loaded==PublicationStoreResult::Ready)return publicationBindingsEqual(desired,current)?PublicationStoreResult::Ready:PublicationStoreResult::Conflict; + if(loaded!=PublicationStoreResult::Missing)return loaded; + const auto result=store.initialize(desired,verify,scratch,capacity); + if(result==PublicationStoreResult::Ready)current=desired; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/persist_signed_head.h b/modules/core_track_sharing/include/track_sharing/persist_signed_head.h new file mode 100644 index 00000000..28721cde --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/persist_signed_head.h @@ -0,0 +1,36 @@ +#pragma once +#include "track_sharing/signed_object_head.h" +#include "track_sharing/object_head_store.h" +namespace track_sharing +{ +// Caller holds immutable signed bytes, current identity and object exclusive +// lease. All records/evidence/paths/scratch are disjoint admitted storage. +inline bool signedHeadMatches(const ObjectHead& head, const VerifiedManifestView& signed_view, + const RevisionReservation& reservation, uint32_t generation, + geocaching::ByteView author, geocaching::ByteView author_hash) +{ + if (!objectHeadValid(head) || !signed_view.verified || !reservationValid(reservation) || reservation.phase != ReservationPhase::Signed || + !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || !generation || head.generation != generation || reservation.generation != generation || + !signed_view.manifest.author.data || signed_view.manifest.author.size != 64 || signed_view.manifest.revision != head.revision || reservation.revision != head.revision || + signed_view.manifest.state > 1 || head.archived != (signed_view.manifest.state == 1)) return false; + if (std::memcmp(author.data, signed_view.manifest.author.data, 64) || std::memcmp(author_hash.data, reservation.author, 32) || + std::memcmp(head.object, signed_view.object_id, 32) || std::memcmp(head.object, reservation.object, 32) || + std::memcmp(head.hash, signed_view.revision_hash, 32) || std::memcmp(head.hash, reservation.candidate, 32) || + std::memcmp(head.operation, reservation.operation, 16) || std::memcmp(head.parent, reservation.parent, 32)) return false; + return head.revision == 1 ? signed_view.manifest.previous.size == 0 : signed_view.manifest.previous.data && signed_view.manifest.previous.size == 32 && !std::memcmp(head.parent, signed_view.manifest.previous.data, 32); +} +template +HeadStoreResult persistSignedObjectHead(Store& store, const VerifiedManifestView& signed_view, const RevisionReservation& reservation, + uint32_t generation, geocaching::ByteView author, geocaching::ByteView author_hash, + ObjectHead& current, ObjectHead& next, ObjectHead& verify, uint8_t* scratch, size_t capacity) +{ + if (¤t == &next || ¤t == &verify || &next == &verify) return HeadStoreResult::Invalid; + const auto loaded = store.load(current, verify, scratch, capacity); + if (loaded != HeadStoreResult::Ready && loaded != HeadStoreResult::Missing) return loaded; + if (loaded == HeadStoreResult::Ready && signedHeadMatches(current, signed_view, reservation, generation, author, author_hash)) return HeadStoreResult::Ready; + if (!createSignedObjectHead(signed_view, reservation, generation, author, author_hash, loaded == HeadStoreResult::Missing ? nullptr : ¤t, next)) return HeadStoreResult::Conflict; + const auto result = loaded == HeadStoreResult::Missing ? store.initialize(next, verify, scratch, capacity) : store.advance(current, next, verify, scratch, capacity); + if (result == HeadStoreResult::Ready) current = next; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/persist_upload_response.h b/modules/core_track_sharing/include/track_sharing/persist_upload_response.h new file mode 100644 index 00000000..4ac0f7b2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/persist_upload_response.h @@ -0,0 +1,109 @@ +#pragma once +#include "track_sharing/network_upload_response.h" +#include "track_sharing/publication_journal_store.h" + +namespace track_sharing +{ +// Runtime holds verified directory/author/media leases. Three journal records +// and scratch belong to the admitted PSRAM job, never automatic ESP locals. +inline bool uploadJournalWorkspace(const PublicationJournal& current, PublicationJournal& next, + PublicationJournal& verify, geocaching::ByteView response, + geocaching::ByteView request, geocaching::ByteView token, + uint8_t* scratch, size_t capacity) +{ + if (¤t == &next || ¤t == &verify || &next == &verify || !scratch || + capacity < kPublicationJournalBytes || !publicationJournalValid(current)) return false; + const geocaching::ByteView records[] = { + {reinterpret_cast(¤t), sizeof(current)}, + {reinterpret_cast(&next), sizeof(next)}, + {reinterpret_cast(&verify), sizeof(verify)}}; + for (const auto record : records) + { + if (signingSpansOverlap(record.data, record.size, scratch, capacity) || + signingSpansOverlap(record.data, record.size, response.data, response.size) || + signingSpansOverlap(record.data, record.size, request.data, request.size) || + (token.size && signingSpansOverlap(record.data, record.size, token.data, token.size))) return false; + } + return !signingSpansOverlap(scratch, capacity, response.data, response.size) && + !signingSpansOverlap(scratch, capacity, request.data, request.size) && + (!token.size || !signingSpansOverlap(scratch, capacity, token.data, token.size)); +} + +template +PublicationStoreResult persistTrackUploadBegin(Store& store, PublicationJournal& current, + PublicationJournal& next, PublicationJournal& verify, + geocaching::ByteView response, geocaching::ByteView request, + NetworkUploadBegin& accepted, uint8_t* scratch, size_t capacity) +{ + if (!uploadJournalWorkspace(current, next, verify, response, request, {}, scratch, capacity) || + signingSpansOverlap(reinterpret_cast(&accepted), sizeof(accepted), scratch, capacity) || + signingSpansOverlap(reinterpret_cast(&accepted), sizeof(accepted), reinterpret_cast(¤t), sizeof(current)) || + signingSpansOverlap(reinterpret_cast(&accepted), sizeof(accepted), reinterpret_cast(&next), sizeof(next)) || + signingSpansOverlap(reinterpret_cast(&accepted), sizeof(accepted), reinterpret_cast(&verify), sizeof(verify)) || + !networkInputDisjoint(response, reinterpret_cast(&accepted), sizeof(accepted)) || + !networkInputDisjoint(request, reinterpret_cast(&accepted), sizeof(accepted)) || + (current.phase != PublicationPhase::Accepted && current.phase != PublicationPhase::Uploading)) return PublicationStoreResult::Invalid; + NetworkUploadBegin candidate; + if (!decodeTrackUploadBegin(response, request, current.file_bytes, candidate)) return PublicationStoreResult::Invalid; + if (candidate.offset < current.offset) return PublicationStoreResult::Conflict; + next = current; + next.offset = candidate.offset; + next.phase = candidate.sealed ? PublicationPhase::FileStored : PublicationPhase::Uploading; + auto result = PublicationStoreResult::Invalid; + if (next.offset == current.offset && next.phase == current.phase) + result = store.validateCurrent(current, verify, scratch, capacity); + else if (current.sequence != UINT64_MAX) + { + ++next.sequence; + result = store.advance(current, next, verify, scratch, capacity); + } + if (result == PublicationStoreResult::Ready) + { + current = next; + accepted = candidate; + } + return result; +} + +template +PublicationStoreResult persistTrackUploadAck(Store& store, PublicationJournal& current, + PublicationJournal& next, PublicationJournal& verify, + geocaching::ByteView response, geocaching::ByteView request, + geocaching::ByteView token, uint32_t sent_offset, size_t sent_bytes, + uint8_t* scratch, size_t capacity) +{ + if (!uploadJournalWorkspace(current, next, verify, response, request, token, scratch, capacity) || + current.phase != PublicationPhase::Uploading || + !decodeTrackUploadAck(response, request, token, sent_offset, sent_bytes, current.file_bytes)) return PublicationStoreResult::Invalid; + const auto end = uint32_t(sent_offset + sent_bytes); + if (current.offset == end) return store.validateCurrent(current, verify, scratch, capacity); + if (current.offset != sent_offset) return PublicationStoreResult::Conflict; + if (current.sequence == UINT64_MAX) return PublicationStoreResult::Invalid; + next = current; + next.offset = end; + ++next.sequence; + const auto result = store.advance(current, next, verify, scratch, capacity); + if (result == PublicationStoreResult::Ready) current = next; + return result; +} + +template +PublicationStoreResult persistTrackUploadSealed(Store& store, PublicationJournal& current, + PublicationJournal& next, PublicationJournal& verify, + geocaching::ByteView response, geocaching::ByteView request, + uint8_t* scratch, size_t capacity) +{ + if (!uploadJournalWorkspace(current, next, verify, response, request, {}, scratch, capacity) || + (current.phase != PublicationPhase::Uploading && current.phase != PublicationPhase::FileStored) || + current.offset != current.file_bytes || + !decodeTrackUploadSealed(response, request, {current.file_hash, 32}, current.file_bytes)) return PublicationStoreResult::Invalid; + if (current.phase == PublicationPhase::FileStored) return store.validateCurrent(current, verify, scratch, capacity); + if (current.sequence == UINT64_MAX) return PublicationStoreResult::Invalid; + next = current; + next.phase = PublicationPhase::FileStored; + ++next.sequence; + const auto result = store.advance(current, next, verify, scratch, capacity); + if (result == PublicationStoreResult::Ready) current = next; + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/prepare_request.h b/modules/core_track_sharing/include/track_sharing/prepare_request.h new file mode 100644 index 00000000..d5409f58 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/prepare_request.h @@ -0,0 +1,83 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include +#include +namespace track_sharing +{ +// Borrowed request views. Platform copies bindings into its admitted job before +// command scratch reuse; this decoder grants no storage or signing authority. +struct PrepareRequest +{ + geocaching::ByteView operation, kml_upload, manifest_upload, expected_parent; + uint32_t generation = 0; + geocaching::ByteView package_upload{}; + bool package_request = false; + bool install_request = false; + bool query_request = false; + geocaching::ByteView expected_object{}, expected_revision{}; +}; +static_assert(sizeof(PrepareRequest) <= 128, "Prepare request cannot own manifest bytes"); +inline bool decodePrepareRequest(geocaching::ByteView body, PrepareRequest& output) +{ + const auto source = reinterpret_cast(body.data), target = reinterpret_cast(&output); + if (body.data && (source <= target ? target - source < body.size : source - target < sizeof(output))) return false; + output = {}; + if (!body.data || !body.size || body.size > 219) return false; + geocaching::protocol::CmpReader reader(body); + size_t fields = 0; + uint64_t generation = 0; + if (!reader.array(fields, 8) || (fields != 5 && fields != 6 && fields != 8) || + !reader.binary(output.operation, 16) || output.operation.size != 16 || + !reader.binary(output.kml_upload, 16) || output.kml_upload.size != 16 || + !reader.binary(output.manifest_upload, 16) || output.manifest_upload.size != 16 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX) return false; + for (const auto id : {output.operation, output.kml_upload, output.manifest_upload}) + { + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= id.data[i]; + if (!bits) return false; + } + if (!std::memcmp(output.kml_upload.data, output.manifest_upload.data, 16)) return false; + if (reader.position() < body.size && body.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else + { + if (!reader.binary(output.expected_parent, 32) || output.expected_parent.size != 32) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 32; ++i) bits |= output.expected_parent.data[i]; + if (!bits) return false; + } + if (fields >= 6) + { + output.package_request = true; + if (reader.position() < body.size && body.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else + { + if (!reader.binary(output.package_upload, 16) || output.package_upload.size != 16) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= output.package_upload.data[i]; + if (!bits) return false; + } + } + if (fields == 8) + { + output.install_request = true; + if (output.expected_parent.size || !reader.binary(output.expected_object, 32) || output.expected_object.size != 32 || + !reader.binary(output.expected_revision, 32) || output.expected_revision.size != 32) return false; + for (const auto id : {output.expected_object, output.expected_revision}) + { + uint8_t bits = 0; + for (size_t i = 0; i < 32; ++i) bits |= id.data[i]; + if (!bits) return false; + } + } + if (!reader.finished()) return false; + output.generation = uint32_t(generation); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/prepare_upload_inputs.h b/modules/core_track_sharing/include/track_sharing/prepare_upload_inputs.h new file mode 100644 index 00000000..96781987 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/prepare_upload_inputs.h @@ -0,0 +1,53 @@ +#pragma once +#include "track_sharing/prepare_request.h" +#include "track_sharing/upload_paths.h" +#include "track_sharing/upload_journal_store.h" +namespace track_sharing +{ +// Caller lends three distinct metadata slots, two 64-byte canonical path slots +// and >=256 command scratch, all disjoint from request/identity and each other. +// Media-bound I/O and exclusive immutable upload lease are platform-owned. +template +JournalStoreResult loadPrepareUploadInputs(Io& io, const PrepareRequest& request, uint32_t generation, const uint8_t* author_hash, + UploadJournal& kml, UploadJournal& manifest, UploadJournal& verify, + char* kml_path, char* manifest_path, uint8_t* scratch, size_t capacity) +{ + if (&kml == &manifest || &kml == &verify || &manifest == &verify || !kml_path || !manifest_path || kml_path == manifest_path || + !scratch || capacity < 256 || !author_hash || !request.kml_upload.data || request.kml_upload.size != 16 || + !request.manifest_upload.data || request.manifest_upload.size != 16 || !generation || request.generation != generation) return JournalStoreResult::Invalid; + const auto overlap = [](const void* left, size_t left_size, const void* right, size_t right_size) + { const auto a = reinterpret_cast(left), b = reinterpret_cast(right); return a <= b ? b - a < left_size : a - b < right_size; }; + const auto protectedInput = [&](const void* input, size_t size) + { return overlap(input, size, scratch, capacity) || overlap(input, size, kml_path, 64) || overlap(input, size, manifest_path, 64) || + overlap(input, size, &kml, sizeof(kml)) || overlap(input, size, &manifest, sizeof(manifest)) || overlap(input, size, &verify, sizeof(verify)); }; + const auto pathOverlapsMetadata = [&](const char* path) + { return overlap(path, 64, &kml, sizeof(kml)) || overlap(path, 64, &manifest, sizeof(manifest)) || overlap(path, 64, &verify, sizeof(verify)); }; + if (protectedInput(&io, sizeof(io)) || protectedInput(&request, sizeof(request)) || protectedInput(request.kml_upload.data, 16) || protectedInput(request.manifest_upload.data, 16) || protectedInput(author_hash, 32) || + (request.operation.data && protectedInput(request.operation.data, request.operation.size)) || + (request.expected_parent.data && protectedInput(request.expected_parent.data, request.expected_parent.size)) || + pathOverlapsMetadata(kml_path) || pathOverlapsMetadata(manifest_path) || + overlap(&kml, sizeof(kml), &manifest, sizeof(manifest)) || overlap(&kml, sizeof(kml), &verify, sizeof(verify)) || overlap(&manifest, sizeof(manifest), &verify, sizeof(verify)) || + overlap(kml_path, 64, manifest_path, 64) || overlap(kml_path, 64, scratch, capacity) || overlap(manifest_path, 64, scratch, capacity) || + overlap(&kml, sizeof(kml), scratch, capacity) || overlap(&manifest, sizeof(manifest), scratch, capacity) || overlap(&verify, sizeof(verify), scratch, capacity)) return JournalStoreResult::Invalid; + kml = {}; manifest = {}; verify = {}; + kml_path[0] = manifest_path[0] = 0; + auto* a = reinterpret_cast(scratch + capacity - 128); + auto* b = a + 64; + const auto load = [&](UploadKind kind, geocaching::ByteView id, UploadJournal& output, char* path) + { + if (!resolveUploadPath(id.data, id.size, UploadFile::Candidate, path, 64, kind) || + !resolveUploadPath(id.data, id.size, UploadFile::JournalA, a, 64, kind) || + !resolveUploadPath(id.data, id.size, UploadFile::JournalB, b, 64, kind)) return JournalStoreResult::Invalid; + UploadJournalStore store(io, a, b); + const auto result = store.load(output, verify, scratch, capacity - 128); + if (result != JournalStoreResult::Ready) return result; + return output.phase == UploadPhase::Sealed && output.total <= uploadByteLimit(kind) && output.generation == generation && + !std::memcmp(output.id, id.data, 16) && !std::memcmp(output.author_hash, author_hash, 32) + ? JournalStoreResult::Ready : JournalStoreResult::Conflict; + }; + auto result = load(UploadKind::Kml, request.kml_upload, kml, kml_path); + if (result == JournalStoreResult::Ready) result = load(UploadKind::Manifest, request.manifest_upload, manifest, manifest_path); + if (result != JournalStoreResult::Ready) { kml = {}; manifest = {}; verify = {}; kml_path[0] = manifest_path[0] = 0; } + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/prepared_signature.h b/modules/core_track_sharing/include/track_sharing/prepared_signature.h new file mode 100644 index 00000000..6a3747e7 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/prepared_signature.h @@ -0,0 +1,54 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "track_sharing/reservation_candidate.h" +#include "track_sharing/reservation_store.h" +#include "track_sharing/signed_manifest_store.h" +namespace track_sharing +{ +enum class PreparedSignatureResult : uint8_t +{ + Ready, + NotChecked, + Conflict, + StaleIdentity, + StorageFailure, + Invalid, + Unavailable +}; +// Caller retains immutable candidate/file/parent lease and current-head +// reservation. No signature is returned before blob and Signed log are durable. +template +PreparedSignatureResult persistPreparedSignature(Check& check, geocaching::ByteView raw, geocaching::ByteView author, uint32_t generation, + geocaching::ByteView author_hash, RevisionReservation& current, RevisionReservation& verify, + Hash& hash, Adapter& adapter, BlobStore& blobs, JournalStore& journal, ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) +{ + using Result = PreparedSignatureResult; + if (!raw.data || !raw.size || raw.size > 4096 || !workspace || capacity < kReservationBytes || capacity < sizeof("TrailMate.Track.Manifest.v1") + raw.size || !signature || signature_capacity < 64 || ¤t == &verify || + signingSpansOverlap(workspace, capacity, raw.data, raw.size) || signingSpansOverlap(signature, 64, raw.data, raw.size) || signingSpansOverlap(workspace, capacity, signature, 64) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(¤t), sizeof(current)) || signingSpansOverlap(workspace, capacity, reinterpret_cast(&verify), sizeof(verify))) return Result::Invalid; + const auto overlaps_output = [&](const uint8_t* source, size_t size) + { return signingSpansOverlap(workspace, capacity, source, size) || signingSpansOverlap(signature, 64, source, size); }; + if (overlaps_output(author.data, author.size) || overlaps_output(author_hash.data, author_hash.size) || + overlaps_output(reinterpret_cast(¤t), sizeof(current)) || overlaps_output(reinterpret_cast(&verify), sizeof(verify)) || + overlaps_output(reinterpret_cast(&view), sizeof(view)) || overlaps_output(reinterpret_cast(&check), sizeof(check)) || + overlaps_output(reinterpret_cast(&hash), sizeof(hash)) || overlaps_output(reinterpret_cast(&adapter), sizeof(adapter)) || + overlaps_output(reinterpret_cast(&blobs), sizeof(blobs)) || overlaps_output(reinterpret_cast(&journal), sizeof(journal))) return Result::Invalid; + std::memset(signature, 0, 64); + const auto fail = [&](Result result) + {std::memset(signature,0,64);return result; }; + if (!check.ready()) return Result::NotChecked; + if (current.phase != ReservationPhase::Checked || !reservationMatchesCandidate(raw, author, generation, author_hash, current, hash, view, workspace, capacity) || !check.matchesCandidate(view)) return Result::Conflict; + const auto selected = journal.validateCurrent(current, verify, workspace, capacity); + if (selected != ReservationStoreResult::Ready) return selected == ReservationStoreResult::Conflict ? Result::Conflict : Result::StorageFailure; + const auto signed_result = adapter.signTrackManifest(raw.data, raw.size, generation, view, workspace, capacity, signature, signature_capacity); + if (signed_result != chat::DeviceAuthorResult::Ok) return fail(signed_result == chat::DeviceAuthorResult::StaleIdentity ? Result::StaleIdentity : signed_result == chat::DeviceAuthorResult::StorageError ? Result::StorageFailure + : Result::Unavailable); + const auto stored = blobs.persist(raw, {signature, 64}, workspace, capacity); + if (stored != SignedStoreResult::Ready) return fail(stored == SignedStoreResult::Conflict ? Result::Conflict : Result::StorageFailure); + const auto advanced = journal.advance(current, ReservationPhase::Signed, verify, workspace, capacity); + if (advanced != ReservationStoreResult::Ready) return fail(advanced == ReservationStoreResult::Conflict ? Result::Conflict : Result::StorageFailure); + current = verify; + return Result::Ready; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_candidate_reader.h b/modules/core_track_sharing/include/track_sharing/publication_candidate_reader.h new file mode 100644 index 00000000..f7d4d9ad --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_candidate_reader.h @@ -0,0 +1,45 @@ +#pragma once +#include "track_sharing/publication_slot_path.h" +#include "track_sharing/publication_directory_store.h" +namespace track_sharing +{ +// Caller admits this metadata owner in PSRAM and holds a media lease. A Ready +// candidate is NOT authenticated: use current-author, manifest/blob and receipt +// validation before showing status or creating a network execution. +template +class PublicationCandidateReader +{ + public: + explicit PublicationCandidateReader(Io& io) : directories_(io,directory_path_),jobs_(io,a_,b_) {} + PublicationStoreResult load(const PublicationSlotKey& key,uint8_t* scratch,size_t capacity) + { + ready_=false; + if(!scratch || capacity(this),sizeof(*this)) || + signingSpansOverlap(reinterpret_cast(&key),sizeof(key),reinterpret_cast(this),sizeof(*this))) + return PublicationStoreResult::Invalid; + directory_={};current_={};other_={}; + if((key.slot!=ObjectFile::PublicationA && key.slot!=ObjectFile::PublicationB) || + !resolveObjectPath({key.object,32},ObjectFile::PublicationDirectory,{key.operation,16},directory_path_,sizeof(directory_path_)) || + !resolveObjectPath({key.object,32},ObjectFile::PublicationA,{key.operation,16},a_,sizeof(a_)) || + !resolveObjectPath({key.object,32},ObjectFile::PublicationB,{key.operation,16},b_,sizeof(b_)))return PublicationStoreResult::Invalid; + const auto fail=[&](PublicationStoreResult result){directory_={};current_={};other_={};return result;}; + const auto pin=directories_.load(directory_,scratch,capacity); + if(pin!=PublicationStoreResult::Ready)return fail(pin); + const auto journal=jobs_.load(current_,other_,scratch,capacity); + if(journal!=PublicationStoreResult::Ready)return fail(journal); + if(std::memcmp(key.object,current_.object,32) || std::memcmp(key.operation,current_.operation,16) || + !publicationDirectoryMatches(directory_,current_))return fail(PublicationStoreResult::Conflict); + ready_=true;return PublicationStoreResult::Ready; + } + const PublicationJournal* unverifiedJournal() const { return ready_?¤t_:nullptr; } + const PublicationDirectory* unverifiedDirectory() const { return ready_?&directory_:nullptr; } + private: + char directory_path_[kObjectPathCapacity]{},a_[kObjectPathCapacity]{},b_[kObjectPathCapacity]{}; + PublicationDirectory directory_; + PublicationJournal current_,other_; + PublicationDirectoryStore directories_; + PublicationJournalStore jobs_; + bool ready_=false; +}; +} diff --git a/modules/core_track_sharing/include/track_sharing/publication_directory.h b/modules/core_track_sharing/include/track_sharing/publication_directory.h new file mode 100644 index 00000000..fec98b0f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_directory.h @@ -0,0 +1,75 @@ +#pragma once +#include "track_sharing/publication_journal.h" + +namespace track_sharing +{ +// Copied from an authenticated announcement plus matching GET_CAPABILITIES. +// Persistence preserves that admission evidence; CRC is not authentication. +struct PublicationDirectory +{ + uint8_t delivery[16]{}, public_key[64]{}, epoch[16]{}; + uint32_t file_limit = 0; + uint16_t chunk_limit = 0; + uint8_t query_limit = 0; +}; +static_assert(sizeof(PublicationDirectory) <= 104, "Directory binding owns metadata only"); +constexpr size_t kPublicationDirectoryBytes = 110; + +inline bool publicationDirectoryValid(const PublicationDirectory& value) +{ + for (const auto field : {geocaching::ByteView{value.delivery, 16}, {value.public_key, 64}, {value.epoch, 16}}) + { + uint8_t bits = 0; + for (size_t i = 0; i < field.size; ++i) bits |= field.data[i]; + if (!bits) return false; + } + return value.file_limit && value.file_limit <= 8388608 && value.chunk_limit && value.chunk_limit <= 1024 && + value.query_limit && value.query_limit <= 32; +} +inline bool publicationDirectoryMatches(const PublicationDirectory& value, const PublicationJournal& job) +{ + return publicationDirectoryValid(value) && publicationJournalValid(job) && job.file_bytes <= value.file_limit && + !std::memcmp(value.delivery, job.directory, 16) && !std::memcmp(value.epoch, job.directory_epoch, 16); +} +inline bool publicationDirectoriesEqual(const PublicationDirectory& a, const PublicationDirectory& b) +{ + return !std::memcmp(a.delivery, b.delivery, 16) && !std::memcmp(a.public_key, b.public_key, 64) && + !std::memcmp(a.epoch, b.epoch, 16) && a.file_limit == b.file_limit && + a.chunk_limit == b.chunk_limit && a.query_limit == b.query_limit; +} +inline bool encodePublicationDirectory(const PublicationDirectory& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kPublicationDirectoryBytes || !publicationDirectoryValid(value) || + signingSpansOverlap(output, kPublicationDirectoryBytes, reinterpret_cast(&value), sizeof(value))) return false; + std::memcpy(output, "TMTD", 4); + output[4] = 1; + std::memcpy(output + 5, value.delivery, 16); + std::memcpy(output + 21, value.public_key, 64); + std::memcpy(output + 85, value.epoch, 16); + for (unsigned i = 0; i < 4; ++i) output[101 + i] = uint8_t(value.file_limit >> (8 * i)); + output[105] = uint8_t(value.chunk_limit); + output[106] = uint8_t(value.chunk_limit >> 8); + output[107] = value.query_limit; + const auto crc = uploadJournalCrc(output, 108); + output[108] = uint8_t(crc); + output[109] = uint8_t(crc >> 8); + return true; +} +inline bool decodePublicationDirectory(const uint8_t* input, size_t size, PublicationDirectory& output) +{ + if (!input || size != kPublicationDirectoryBytes || + signingSpansOverlap(input, size, reinterpret_cast(&output), sizeof(output)) || + std::memcmp(input, "TMTD", 4) || input[4] != 1 || + uploadJournalCrc(input, 108) != (uint16_t(input[108]) | (uint16_t(input[109]) << 8))) return false; + std::memcpy(output.delivery, input + 5, 16); + std::memcpy(output.public_key, input + 21, 64); + std::memcpy(output.epoch, input + 85, 16); + output.file_limit = 0; + for (unsigned i = 0; i < 4; ++i) output.file_limit |= uint32_t(input[101 + i]) << (8 * i); + output.chunk_limit = uint16_t(input[105]) | (uint16_t(input[106]) << 8); + output.query_limit = input[107]; + if (publicationDirectoryValid(output)) return true; + output = {}; + return false; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_directory_store.h b/modules/core_track_sharing/include/track_sharing/publication_directory_store.h new file mode 100644 index 00000000..8f507f84 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_directory_store.h @@ -0,0 +1,60 @@ +#pragma once +#include "track_sharing/publication_directory.h" +#include "track_sharing/publication_journal_store.h" + +namespace track_sharing +{ +// Immutable per-job sidecar. Caller supplies canonical path, exclusive SD lease, +// authenticated desired binding and caller-owned PSRAM records/scratch. Never +// replace an existing pin, including a damaged or partially written one. +template +class PublicationDirectoryStore +{ + public: + PublicationDirectoryStore(Io& io, const char* path) : io_(io), path_(path) {} + PublicationStoreResult load(PublicationDirectory& output, uint8_t* scratch, size_t capacity) + { + if (!valid(output, scratch, capacity)) return PublicationStoreResult::Invalid; + output = {}; + const auto opened = io_.open(path_, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? PublicationStoreResult::Missing : PublicationStoreResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool read = sized && size == kPublicationDirectoryBytes && io_.read(opened.handle, scratch, kPublicationDirectoryBytes) == kPublicationDirectoryBytes; + const bool closed = io_.close(opened.handle); + if (!sized || !closed || (size == kPublicationDirectoryBytes && !read)) return PublicationStoreResult::StorageFailure; + return read && decodePublicationDirectory(scratch, kPublicationDirectoryBytes, output) ? PublicationStoreResult::Ready : PublicationStoreResult::Corrupt; + } + PublicationStoreResult admit(const PublicationDirectory& desired, PublicationDirectory& verify, uint8_t* scratch, size_t capacity) + { + if (signingSpansOverlap(reinterpret_cast(&desired), sizeof(desired), reinterpret_cast(&verify), sizeof(verify)) || + !valid(verify, scratch, capacity) || !publicationDirectoryValid(desired) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(&desired), sizeof(desired))) return PublicationStoreResult::Invalid; + const auto existing = load(verify, scratch, capacity); + if (existing == PublicationStoreResult::Ready) return publicationDirectoriesEqual(desired, verify) ? existing : PublicationStoreResult::Conflict; + if (existing != PublicationStoreResult::Missing) return existing; + if (!encodePublicationDirectory(desired, scratch, capacity)) return PublicationStoreResult::Invalid; + const auto opened = io_.open(path_, Io::Mode::CreateTemporary); + if (!opened.handle) return PublicationStoreResult::StorageFailure; + const bool written = io_.write(opened.handle, scratch, kPublicationDirectoryBytes) == kPublicationDirectoryBytes && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return PublicationStoreResult::StorageFailure; + const auto checked = load(verify, scratch, capacity); + if (checked != PublicationStoreResult::Ready) return checked; + return publicationDirectoriesEqual(desired, verify) ? PublicationStoreResult::Ready : PublicationStoreResult::Conflict; + } + + private: + bool valid(const PublicationDirectory& record, uint8_t* scratch, size_t capacity) const + { + return path_ && *path_ && scratch && capacity >= kPublicationDirectoryBytes && + !signingSpansOverlap(scratch, capacity, reinterpret_cast(&record), sizeof(record)) && + !signingSpansOverlap(scratch, capacity, reinterpret_cast(path_), std::strlen(path_) + 1) && + !signingSpansOverlap(reinterpret_cast(&record), sizeof(record), reinterpret_cast(path_), std::strlen(path_) + 1) && + !signingSpansOverlap(scratch, capacity, reinterpret_cast(&io_), sizeof(io_)) && + !signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this)); + } + Io& io_; + const char* path_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_intent.h b/modules/core_track_sharing/include/track_sharing/publication_intent.h new file mode 100644 index 00000000..a179d786 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_intent.h @@ -0,0 +1,36 @@ +#pragma once +#include "track_sharing/commit_request.h" +#include "track_sharing/publication_journal.h" +#include "track_sharing/persist_signed_head.h" +namespace track_sharing +{ +// Constructs bindings only after signed-head and independent blob verification. +// Caller separately validates directory protocol/epoch, media and task quota. +template +bool createPublicationIntent(const CommitRequest& request, const ObjectHead& head, + const VerifiedManifestView& signed_view, const RevisionReservation& reservation, + geocaching::ByteView author, geocaching::ByteView author_hash, const FileCheck& file, + PublicationJournal& output) +{ + const auto* target=reinterpret_cast(&output); + for(const auto field:{request.operation,request.object,request.revision_hash,request.directory,request.directory_epoch,author,author_hash, + geocaching::ByteView{reinterpret_cast(&head),sizeof(head)}, + geocaching::ByteView{reinterpret_cast(&signed_view),sizeof(signed_view)}, + geocaching::ByteView{reinterpret_cast(&reservation),sizeof(reservation)}, + geocaching::ByteView{reinterpret_cast(&file),sizeof(file)},signed_view.manifest.raw,signed_view.signature}) + if(signingSpansOverlap(target,sizeof(output),field.data,field.size))return false; + output={}; + if(head.phase!=HeadPhase::Signed || !request.operation.data || request.operation.size!=16 || !request.object.data || request.object.size!=32 || + !request.revision_hash.data || request.revision_hash.size!=32 || !request.directory.data || request.directory.size!=16 || + !request.directory_epoch.data || request.directory_epoch.size!=16 || request.revision!=head.revision || + request.generation!=head.generation || std::memcmp(request.operation.data,head.operation,16) || + std::memcmp(request.object.data,head.object,32) || std::memcmp(request.revision_hash.data,head.hash,32) || + !signedHeadMatches(head,signed_view,reservation,request.generation,author,author_hash) || + !file.matchesExpected(signed_view.manifest.file_bytes,signed_view.manifest.file_hash.data))return false; + std::memcpy(output.operation,head.operation,16);std::memcpy(output.object,head.object,32);std::memcpy(output.revision_hash,head.hash,32); + std::memcpy(output.directory,request.directory.data,16);std::memcpy(output.directory_epoch,request.directory_epoch.data,16); + std::memcpy(output.file_hash,signed_view.manifest.file_hash.data,32);std::memcpy(output.author_hash,author_hash.data,32); + output.generation=head.generation;output.revision=head.revision;output.file_bytes=signed_view.manifest.file_bytes;output.sequence=1; + return publicationJournalValid(output); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_journal.h b/modules/core_track_sharing/include/track_sharing/publication_journal.h new file mode 100644 index 00000000..5ca1f72e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_journal.h @@ -0,0 +1,71 @@ +#pragma once +#include "track_sharing/upload_journal.h" +#include "track_sharing/sign_manifest.h" +#include +namespace track_sharing +{ +enum class PublicationPhase : uint8_t { Accepted, Uploading, FileStored, Publishing, Confirmed, Failed, Cancelled }; +struct PublicationJournal +{ + uint8_t operation[16]{}, object[32]{}, revision_hash[32]{}, directory[16]{}, directory_epoch[16]{}, file_hash[32]{}, author_hash[32]{}; + uint64_t sequence = 0; + uint32_t generation = 0, revision = 0, file_bytes = 0, offset = 0; + PublicationPhase phase = PublicationPhase::Accepted; +}; +static_assert(sizeof(PublicationJournal) <= 216, "Publication metadata cannot own KML bytes"); +constexpr size_t kPublicationJournalBytes = 208; +inline bool publicationJournalValid(const PublicationJournal& value) +{ + for (const auto field : {geocaching::ByteView{value.operation,16},{value.object,32},{value.revision_hash,32},{value.directory,16},{value.directory_epoch,16},{value.file_hash,32},{value.author_hash,32}}) + { uint8_t bits=0;for(size_t i=0;i(value.phase) <= 6 && (value.phase != PublicationPhase::Accepted || !value.offset) && + ((value.phase != PublicationPhase::FileStored && value.phase != PublicationPhase::Publishing && value.phase != PublicationPhase::Confirmed) || value.offset == value.file_bytes); +} +// CRC is damage detection only. Runtime authenticates author, version, blob and +// directory; Confirmed additionally requires a verified directory receipt. +inline bool publicationAdvanceValid(const PublicationJournal& current, const PublicationJournal& next) +{ + if (!publicationJournalValid(current) || !publicationJournalValid(next) || current.sequence == UINT64_MAX || + next.sequence != current.sequence + 1 || current.generation != next.generation || current.revision != next.revision || + current.file_bytes != next.file_bytes || next.offset < current.offset || + std::memcmp(current.operation,next.operation,16) || std::memcmp(current.object,next.object,32) || + std::memcmp(current.revision_hash,next.revision_hash,32) || std::memcmp(current.directory,next.directory,16) || + std::memcmp(current.directory_epoch,next.directory_epoch,16) || std::memcmp(current.file_hash,next.file_hash,32) || + std::memcmp(current.author_hash,next.author_hash,32)) return false; + const bool terminal = current.phase == PublicationPhase::Confirmed || current.phase == PublicationPhase::Failed || current.phase == PublicationPhase::Cancelled; + if (terminal) return next.phase == current.phase && next.offset == current.offset; + if (next.phase == current.phase) return current.phase == PublicationPhase::Uploading || next.offset == current.offset; + if (next.phase == PublicationPhase::Failed) return next.offset == current.offset; + if (next.phase == PublicationPhase::Cancelled) return current.phase != PublicationPhase::Publishing && next.offset == current.offset; + if (current.phase == PublicationPhase::Accepted) return next.phase == PublicationPhase::Uploading || next.phase == PublicationPhase::FileStored; + if (current.phase == PublicationPhase::Uploading) return next.phase == PublicationPhase::FileStored; + if (current.phase == PublicationPhase::FileStored) return next.phase == PublicationPhase::Publishing; + return current.phase == PublicationPhase::Publishing && next.phase == PublicationPhase::Confirmed; +} +inline bool encodePublicationJournal(const PublicationJournal& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kPublicationJournalBytes || !publicationJournalValid(value) || + signingSpansOverlap(output,kPublicationJournalBytes,reinterpret_cast(&value),sizeof(value)))return false; + std::memcpy(output,"TMTP",4);output[4]=1;size_t pos=5; + for(const auto field:{geocaching::ByteView{value.operation,16},{value.object,32},{value.revision_hash,32},{value.directory,16},{value.directory_epoch,16},{value.file_hash,32},{value.author_hash,32}}) + {std::memcpy(output+pos,field.data,field.size);pos+=field.size;} + for(const auto number:{value.generation,value.revision,value.file_bytes,value.offset}) + {for(unsigned i=0;i<4;++i)output[pos++]=uint8_t(number>>(8*i));} + for(unsigned i=0;i<8;++i)output[pos++]=uint8_t(value.sequence>>(8*i)); + output[pos++]=uint8_t(value.phase);const auto crc=uploadJournalCrc(output,pos);output[pos++]=uint8_t(crc);output[pos]=uint8_t(crc>>8);return true; +} +inline bool decodePublicationJournal(const uint8_t* input,size_t size,PublicationJournal& value) +{ + if(input&&signingSpansOverlap(input,size,reinterpret_cast(&value),sizeof(value)))return false; + value={};if(!input||size!=kPublicationJournalBytes||std::memcmp(input,"TMTP",4)||input[4]!=1|| + uploadJournalCrc(input,206)!=(uint16_t(input[206])|(uint16_t(input[207])<<8)))return false; + size_t pos=5; + for(const auto field:{geocaching::ByteView{value.operation,16},{value.object,32},{value.revision_hash,32},{value.directory,16},{value.directory_epoch,16},{value.file_hash,32},{value.author_hash,32}}) + {std::memcpy(const_cast(field.data),input+pos,field.size);pos+=field.size;} + for(auto* number:{&value.generation,&value.revision,&value.file_bytes,&value.offset}) + {for(unsigned i=0;i<4;++i)*number|=uint32_t(input[pos++])<<(8*i);} + for(unsigned i=0;i<8;++i)value.sequence|=uint64_t(input[pos++])<<(8*i); + value.phase=static_cast(input[pos]);return publicationJournalValid(value); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_journal_store.h b/modules/core_track_sharing/include/track_sharing/publication_journal_store.h new file mode 100644 index 00000000..704d7126 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_journal_store.h @@ -0,0 +1,103 @@ +#pragma once +#include "track_sharing/publication_journal.h" +#include +namespace track_sharing +{ +enum class PublicationStoreResult : uint8_t { Ready, Missing, Corrupt, StorageFailure, Conflict, Invalid }; +// Borrowed canonical paths and metadata. Caller owns media/exclusive object +// lease and verified publication evidence. Corrupt slots never authorize rollback. +template +class PublicationJournalStore +{ + public: + PublicationJournalStore(Io& io, const char* a, const char* b) : io_(io), a_(a), b_(b) {} + PublicationStoreResult load(PublicationJournal& output, PublicationJournal& other, uint8_t* scratch, size_t capacity) + { + if (&output == &other || !valid(scratch, capacity)) return PublicationStoreResult::Invalid; + output = {}; other = {}; + const auto first = read(a_, output, scratch), second = read(b_, other, scratch); + const auto fail = [&](PublicationStoreResult result) { output = {}; other = {}; return result; }; + if (first == PublicationStoreResult::StorageFailure || second == PublicationStoreResult::StorageFailure) return fail(PublicationStoreResult::StorageFailure); + if (first == PublicationStoreResult::Missing && second == PublicationStoreResult::Missing) return PublicationStoreResult::Missing; + if (first == PublicationStoreResult::Corrupt || second == PublicationStoreResult::Corrupt) return fail(PublicationStoreResult::Corrupt); + if (first == PublicationStoreResult::Ready && !(output.sequence & 1)) return fail(PublicationStoreResult::Corrupt); + if (second == PublicationStoreResult::Ready && (other.sequence & 1)) return fail(PublicationStoreResult::Corrupt); + if (first == PublicationStoreResult::Ready && second == PublicationStoreResult::Ready) + { + if (output.sequence > other.sequence) + { if (!publicationAdvanceValid(other, output)) return fail(PublicationStoreResult::Corrupt); } + else + { if (!publicationAdvanceValid(output, other)) return fail(PublicationStoreResult::Corrupt); output = other; } + return PublicationStoreResult::Ready; + } + if (first != PublicationStoreResult::Ready) return fail(PublicationStoreResult::Corrupt); + if (output.sequence != 1 || output.phase != PublicationPhase::Accepted) return fail(PublicationStoreResult::Corrupt); + return PublicationStoreResult::Ready; + } + PublicationStoreResult initialize(const PublicationJournal& initial, PublicationJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!valid(scratch, capacity) || &initial == &verify || !publicationJournalValid(initial) || initial.sequence != 1 || initial.phase != PublicationPhase::Accepted) return PublicationStoreResult::Invalid; + for (const auto path : {a_, b_}) + { + const auto existing = read(path, verify, scratch); + if (existing == PublicationStoreResult::StorageFailure) return existing; + if (existing != PublicationStoreResult::Missing) return PublicationStoreResult::Conflict; + } + return write(a_, initial, verify, scratch, capacity); + } + PublicationStoreResult validateCurrent(const PublicationJournal& current, PublicationJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!valid(scratch, capacity) || ¤t == &verify || !publicationJournalValid(current)) return PublicationStoreResult::Invalid; + auto result = read(current.sequence & 1 ? a_ : b_, verify, scratch); + if (result == PublicationStoreResult::StorageFailure) return result; + if (result != PublicationStoreResult::Ready || !same(current, verify)) return PublicationStoreResult::Conflict; + result = read(current.sequence & 1 ? b_ : a_, verify, scratch); + if (result == PublicationStoreResult::StorageFailure) return result; + if (result == PublicationStoreResult::Corrupt) return result; + if (result == PublicationStoreResult::Missing && (current.sequence != 1 || current.phase != PublicationPhase::Accepted)) return PublicationStoreResult::Corrupt; + if (result == PublicationStoreResult::Ready && !publicationAdvanceValid(verify, current)) return PublicationStoreResult::Conflict; + return PublicationStoreResult::Ready; + } + PublicationStoreResult advance(const PublicationJournal& current, const PublicationJournal& next, PublicationJournal& verify, uint8_t* scratch, size_t capacity) + { + if (&next == &verify || !publicationAdvanceValid(current, next)) return PublicationStoreResult::Invalid; + const auto validated = validateCurrent(current, verify, scratch, capacity); + if (validated != PublicationStoreResult::Ready) return validated; + return write(next.sequence & 1 ? a_ : b_, next, verify, scratch, capacity); + } + private: + bool valid(uint8_t* scratch, size_t capacity) const { return scratch && capacity >= kPublicationJournalBytes && a_ && *a_ && b_ && *b_ && std::strcmp(a_, b_); } + static bool same(const PublicationJournal& a, const PublicationJournal& b) + { + return a.sequence == b.sequence && a.generation == b.generation && a.revision == b.revision && a.phase == b.phase && + a.file_bytes == b.file_bytes && a.offset == b.offset && + !std::memcmp(a.operation,b.operation,16) && !std::memcmp(a.object,b.object,32) && + !std::memcmp(a.revision_hash,b.revision_hash,32) && !std::memcmp(a.directory,b.directory,16) && + !std::memcmp(a.directory_epoch,b.directory_epoch,16) && !std::memcmp(a.file_hash,b.file_hash,32) && !std::memcmp(a.author_hash,b.author_hash,32); + } + PublicationStoreResult read(const char* path, PublicationJournal& output, uint8_t* scratch) + { + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? PublicationStoreResult::Missing : PublicationStoreResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool read_ok = sized && size == kPublicationJournalBytes && io_.read(opened.handle, scratch, kPublicationJournalBytes) == kPublicationJournalBytes; + const bool closed = io_.close(opened.handle); + if (!sized || !closed || (size == kPublicationJournalBytes && !read_ok)) return PublicationStoreResult::StorageFailure; + return read_ok && decodePublicationJournal(scratch, kPublicationJournalBytes, output) ? PublicationStoreResult::Ready : PublicationStoreResult::Corrupt; + } + PublicationStoreResult write(const char* path, const PublicationJournal& desired, PublicationJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!encodePublicationJournal(desired, scratch, capacity)) return PublicationStoreResult::Invalid; + const auto opened = io_.open(path, Io::Mode::CreateTemporary); + if (!opened.handle) return PublicationStoreResult::StorageFailure; + const bool written = io_.write(opened.handle, scratch, kPublicationJournalBytes) == kPublicationJournalBytes && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return PublicationStoreResult::StorageFailure; + return read(path, verify, scratch) == PublicationStoreResult::Ready && same(desired, verify) ? PublicationStoreResult::Ready : PublicationStoreResult::StorageFailure; + } + Io& io_; + const char* a_; + const char* b_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_network_session.h b/modules/core_track_sharing/include/track_sharing/publication_network_session.h new file mode 100644 index 00000000..c0f2526b --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_network_session.h @@ -0,0 +1,170 @@ +#pragma once +#include "track_sharing/publication_reply_receiver.h" +#include "track_sharing/publication_send_step.h" +#include "track_sharing/request_retry_window.h" +#include "track_sharing/upload_send_step.h" +#include "track_sharing/cancel_publication.h" + +namespace track_sharing +{ +enum class NetworkSessionResult : uint8_t +{ + Queued, + Waiting, + Progress, + Rejected, + Paused, + Confirmed, + StorageFailure, + Conflict, + Invalid +}; +// One borrowed, serialized job execution context. The runtime owns the verified +// manifest, immutable KML, identity/media/directory leases, records and buffers. +// Bind after construction; unbind and wait for callbacks before destruction. +// No USB session dependency, queue, allocation or stored network frame here. +template +class PublicationNetworkSession +{ + public: + PublicationNetworkSession(Io& io, Jobs& jobs, Receipts& receipts, Crypto& crypto, Lease& lease, Sender& sender, Nonce& nonce, + PublicationJournal& current, PublicationJournal& next, PublicationJournal& verify, + const VerifiedManifestView& manifest, VerifiedReceiptView& receipt, geocaching::ByteView directory_public, + const uint8_t* local, const uint8_t* author, const char* kml, + uint8_t* wire, size_t wire_capacity, uint8_t* scratch, size_t scratch_capacity, + uint8_t* file_scratch, size_t file_capacity, + uint32_t response_ms, uint32_t backoff_ms, uint16_t attempts) + : io_(io), jobs_(jobs), lease_(lease), sender_(sender), nonce_(nonce), current_(current), next_(next), verify_(verify), manifest_(manifest), + receipts_(receipts), crypto_(crypto), receipt_(receipt), directory_public_(directory_public), + upload_(jobs, lease, current, next, verify, local, scratch, scratch_capacity), + publish_(jobs, receipts, crypto, lease, current, next, verify, receipt, directory_public, local, uint8_t(manifest.manifest.state), scratch, scratch_capacity), + author_(author), kml_(kml), wire_(wire), scratch_(scratch), file_(file_scratch), wire_capacity_(wire_capacity), scratch_capacity_(scratch_capacity), + file_capacity_(file_capacity), response_ms_(response_ms), backoff_ms_(backoff_ms), attempts_(attempts), binding_{dispatch, this, nullptr, nullptr, author, current.generation} {} + const chat::lxmf::TrackDeliveryBinding& binding() const { return binding_; } + bool directoryCurrent() const { return publish_.directoryCurrent(); } + // Runtime invokes under the same serialization as inbound deliveries. + PublicationStoreResult cancel(uint32_t generation,geocaching::ByteView author_hash) + { + if(!validBuffers() || !publicationManifestMatches(current_,manifest_) || !lease_.holds(current_))return PublicationStoreResult::Conflict; + const auto result=cancelPublicationBeforePublish(jobs_,current_,next_,verify_,generation,author_hash,scratch_,scratch_capacity_); + if(result==PublicationStoreResult::Ready || result==PublicationStoreResult::StorageFailure)pause(); + return result; + } + bool dispatchRejected(uint64_t now) { return publishing_ ? window_.dispatchRejected(publish_,now) : window_.dispatchRejected(upload_,now); } + NetworkSessionResult advance(uint64_t now) + { + if (!validBuffers()) return NetworkSessionResult::Invalid; + if (!publicationManifestMatches(current_, manifest_) || !lease_.holds(current_)) return NetworkSessionResult::Conflict; + if (current_.phase == PublicationPhase::Confirmed) return NetworkSessionResult::Confirmed; + if (paused_) return NetworkSessionResult::Paused; + if (current_.phase != PublicationPhase::Accepted && current_.phase != PublicationPhase::Uploading && + current_.phase != PublicationPhase::FileStored && current_.phase != PublicationPhase::Publishing) return NetworkSessionResult::Conflict; + if (!window_.request().size) + { + publishing_ = current_.phase == PublicationPhase::FileStored || current_.phase == PublicationPhase::Publishing; + if (!publishing_ && !nonce_.fill(seed_, sizeof(seed_))) return NetworkSessionResult::Invalid; + if (!window_.begin(publishing_ ? geocaching::ByteView{current_.operation, 16} : geocaching::ByteView{seed_, 16}, now, response_ms_, backoff_ms_, attempts_)) return NetworkSessionResult::Invalid; + } + const auto poll = publishing_ ? window_.poll(publish_, now) : window_.poll(upload_, now); + if (poll == ExchangePoll::Completed) return NetworkSessionResult::Progress; + if (poll == ExchangePoll::Waiting) return NetworkSessionResult::Waiting; + if (poll == ExchangePoll::Exhausted || poll == ExchangePoll::Paused) return NetworkSessionResult::Paused; + if (poll != ExchangePoll::Ready) return NetworkSessionResult::Invalid; + if (publishing_) + { + const auto sent = sendTrackPublicationStep(jobs_, publish_, sender_, current_, next_, verify_, manifest_, author_, wire_, wire_capacity_, scratch_, scratch_capacity_); + if (sent == PublicationSendResult::Queued || sent == PublicationSendResult::Rejected) + { + if (!window_.sent(sent == PublicationSendResult::Queued, now)) return NetworkSessionResult::Invalid; + return sent == PublicationSendResult::Queued ? NetworkSessionResult::Queued : NetworkSessionResult::Rejected; + } + window_.pause(publish_); + return sent == PublicationSendResult::StorageFailure ? NetworkSessionResult::StorageFailure : NetworkSessionResult::Conflict; + } + const auto sent = sendTrackUploadStep(io_, upload_, sender_, current_, kml_, window_.request(), author_, wire_, wire_capacity_, file_, file_capacity_); + if (sent == UploadSendResult::Queued || sent == UploadSendResult::Rejected) + { + if (!window_.sent(sent == UploadSendResult::Queued, now)) return NetworkSessionResult::Invalid; + return sent == UploadSendResult::Queued ? NetworkSessionResult::Queued : NetworkSessionResult::Rejected; + } + window_.pause(upload_); + return sent == UploadSendResult::StorageFailure ? NetworkSessionResult::StorageFailure : NetworkSessionResult::Conflict; + } + void pause() + { + paused_ = true; + if (publishing_) window_.pause(publish_); + else window_.pause(upload_); + } + bool resume(uint64_t now) + { + if(current_.phase==PublicationPhase::Cancelled || current_.phase==PublicationPhase::Failed || current_.phase==PublicationPhase::Confirmed)return false; + if (window_.request().size) + { + if (!window_.resume(now)) return false; + } + else if (!paused_) return false; + paused_ = false; + return true; + } + + private: + bool validBuffers() const + { + if (!author_ || !kml_ || !*kml_ || !wire_ || wire_capacity_ < 4300 || !scratch_ || scratch_capacity_ < 1100 || + !file_ || file_capacity_ < 256 || !response_ms_ || !backoff_ms_ || !attempts_) return false; + const geocaching::ByteView buffers[] = {{wire_, wire_capacity_}, {scratch_, scratch_capacity_}, {file_, file_capacity_}}; + const geocaching::ByteView inputs[] = { + {reinterpret_cast(this), sizeof(*this)}, {reinterpret_cast(¤t_), sizeof(current_)}, {reinterpret_cast(&next_), sizeof(next_)}, {reinterpret_cast(&verify_), sizeof(verify_)}, {reinterpret_cast(&manifest_), sizeof(manifest_)}, manifest_.manifest.raw, manifest_.signature, {author_, 64}, {reinterpret_cast(kml_), std::strlen(kml_) + 1}, {reinterpret_cast(&io_), sizeof(io_)}, {reinterpret_cast(&jobs_), sizeof(jobs_)}, {reinterpret_cast(&sender_), sizeof(sender_)}, {reinterpret_cast(&nonce_), sizeof(nonce_)}, {reinterpret_cast(&lease_), sizeof(lease_)}}; + for (size_t i = 0; i < 3; ++i) + { + for (size_t j = i + 1; j < 3; ++j) + if (signingSpansOverlap(buffers[i].data, buffers[i].size, buffers[j].data, buffers[j].size)) return false; + for (const auto input : inputs) + if (input.size && signingSpansOverlap(buffers[i].data, buffers[i].size, input.data, input.size)) return false; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&receipts_), sizeof(receipts_)}, + {reinterpret_cast(&crypto_), sizeof(crypto_)}, + {reinterpret_cast(&receipt_), sizeof(receipt_)}, + directory_public_}) + if (input.size && signingSpansOverlap(buffers[i].data, buffers[i].size, input.data, input.size)) return false; + } + return true; + } + static bool dispatch(const chat::lxmf::CustomDeliveryView& view, void* context) + { + if (!context) return false; + auto& session = *static_cast(context); + if (!session.lease_.holds(session.current_)) return false; + const auto& receiver = session.publishing_ ? session.publish_.binding() : session.upload_.binding(); + return receiver.handler && receiver.handler(view, receiver.context); + } + Io& io_; + Jobs& jobs_; + Lease& lease_; + Sender& sender_; + Nonce& nonce_; + PublicationJournal& current_; + PublicationJournal& next_; + PublicationJournal& verify_; + const VerifiedManifestView& manifest_; + Receipts& receipts_; + Crypto& crypto_; + VerifiedReceiptView& receipt_; + geocaching::ByteView directory_public_; + UploadReplyReceiver upload_; + PublicationReplyReceiver publish_; + RequestRetryWindow window_; + const uint8_t* author_; + const char* kml_; + uint8_t* wire_; + uint8_t* scratch_; + uint8_t* file_; + size_t wire_capacity_, scratch_capacity_, file_capacity_; + uint32_t response_ms_, backoff_ms_; + uint16_t attempts_; + chat::lxmf::TrackDeliveryBinding binding_; + uint8_t seed_[16]{}; + bool publishing_ = false; + bool paused_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_receipt_loader.h b/modules/core_track_sharing/include/track_sharing/publication_receipt_loader.h new file mode 100644 index 00000000..66c43c24 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_receipt_loader.h @@ -0,0 +1,110 @@ +#pragma once +#include "track_sharing/publication_directory.h" +#include "track_sharing/verify_receipt.h" +#include + +namespace track_sharing +{ +enum class ReceiptLoadResult : uint8_t +{ + Idle, + Pending, + Ready, + Missing, + Conflict, + StorageFailure, + Invalid, + Cancelled +}; +// Caller owns canonical stable path, admitted receipt/proof/domain buffers and +// authenticated job/directory/current-author/media leases. Confirmed journal +// alone is not receipt evidence. Publishing also permits recovery when receipt +// persistence succeeded but the final journal advance was interrupted. +template +class PublicationReceiptLoader +{ + public: + PublicationReceiptLoader(Io& io, Crypto& crypto, const PublicationJournal& job, + const PublicationDirectory& directory, VerifiedReceiptView& proof, uint8_t state) + : io_(io), crypto_(crypto), job_(job), directory_(directory), proof_(proof), state_(state) {} + bool setExpectedState(uint8_t state) + { + if (result_ != ReceiptLoadResult::Idle || state > 1) return false; + state_ = state; + return true; + } + ReceiptLoadResult begin(const char* path, uint8_t* output, size_t capacity) + { + if (result_ != ReceiptLoadResult::Idle || !path || !*path || !output || capacity < 1100 || !bindings()) return ReceiptLoadResult::Invalid; + const geocaching::ByteView inputs[] = { + {reinterpret_cast(this), sizeof(*this)}, {reinterpret_cast(&io_), sizeof(io_)}, {reinterpret_cast(&crypto_), sizeof(crypto_)}, {reinterpret_cast(&job_), sizeof(job_)}, {reinterpret_cast(&directory_), sizeof(directory_)}, {reinterpret_cast(&proof_), sizeof(proof_)}, {reinterpret_cast(path), std::strlen(path) + 1}}; + for (const auto input : inputs) + if (signingSpansOverlap(output, 1100, input.data, input.size)) return ReceiptLoadResult::Invalid; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&job_), sizeof(job_)}, + {reinterpret_cast(&directory_), sizeof(directory_)}}) + if (signingSpansOverlap(reinterpret_cast(&proof_), sizeof(proof_), input.data, input.size)) return ReceiptLoadResult::Invalid; + proof_ = {}; + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return result_ = opened.status == Io::OpenStatus::Missing ? ReceiptLoadResult::Missing : ReceiptLoadResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size), closed = io_.close(opened.handle); + if (!sized || !closed) return result_ = ReceiptLoadResult::StorageFailure; + if (size < 69 || size > 1100) return result_ = ReceiptLoadResult::Invalid; + path_ = path; + output_ = output; + size_ = uint16_t(size); + return result_ = ReceiptLoadResult::Pending; + } + ReceiptLoadResult step(uint8_t* workspace, size_t capacity) + { + if (result_ != ReceiptLoadResult::Pending) return result_; + if (!bindings()) return stop(ReceiptLoadResult::Conflict); + if (offset_ < size_) + { + const auto opened = io_.open(path_, Io::Mode::Read); + if (!opened.handle) return stop(ReceiptLoadResult::StorageFailure); + uint64_t size = 0; + const auto count = std::min(320, size_ - offset_); + const bool read = io_.size(opened.handle, size) && size == size_ && io_.seek(opened.handle, offset_) && + io_.read(opened.handle, output_ + offset_, count) == count; + const bool closed = io_.close(opened.handle); + if (!read || !closed) return stop(ReceiptLoadResult::StorageFailure); + offset_ += uint16_t(count); + return result_; + } + if (!workspace || !capacity || signingSpansOverlap(workspace, capacity, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(&io_), sizeof(io_)) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(&crypto_), sizeof(crypto_)) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(&directory_), sizeof(directory_)) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(path_), std::strlen(path_) + 1)) return stop(ReceiptLoadResult::Invalid); + const auto verified = verifyReceipt({output_, size_}, job_, {job_.operation, 16}, {directory_.public_key, 64}, state_, crypto_, workspace, capacity, proof_); + if (verified != ReceiptVerification::Valid) return stop(verified == ReceiptVerification::WorkspaceTooSmall ? ReceiptLoadResult::Invalid : ReceiptLoadResult::Conflict); + return result_ = ReceiptLoadResult::Ready; + } + ReceiptLoadResult cancel() { return result_ == ReceiptLoadResult::Pending ? stop(ReceiptLoadResult::Cancelled) : result_; } + bool ready() const { return result_ == ReceiptLoadResult::Ready && proof_.verified && bindings(); } + + private: + bool bindings() const + { + return state_ <= 1 && publicationDirectoryMatches(directory_, job_) && job_.offset == job_.file_bytes && + (job_.phase == PublicationPhase::Publishing || job_.phase == PublicationPhase::Confirmed); + } + ReceiptLoadResult stop(ReceiptLoadResult result) + { + proof_ = {}; + if (output_) std::memset(output_, 0, size_); + return result_ = result; + } + Io& io_; + Crypto& crypto_; + const PublicationJournal& job_; + const PublicationDirectory& directory_; + VerifiedReceiptView& proof_; + const char* path_ = nullptr; + uint8_t* output_ = nullptr; + uint16_t size_ = 0, offset_ = 0; + uint8_t state_; + ReceiptLoadResult result_ = ReceiptLoadResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_reply_receiver.h b/modules/core_track_sharing/include/track_sharing/publication_reply_receiver.h new file mode 100644 index 00000000..46a4f53f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_reply_receiver.h @@ -0,0 +1,87 @@ +#pragma once +#include "chat/ports/track_delivery_binding.h" +#include "track_sharing/confirm_publication.h" +#include "track_sharing/network_publish.h" + +namespace track_sharing +{ +// The admitted owner supplies records/proof/scratch in PSRAM. Lease holds the +// independently authenticated directory public/delivery/epoch binding and +// immutable author/media/path leases through the callback, without router calls. +template +class PublicationReplyReceiver +{ + public: + PublicationReplyReceiver(Jobs& jobs, Receipts& receipts, Crypto& crypto, Lease& lease, + PublicationJournal& current, PublicationJournal& next, PublicationJournal& verify, + VerifiedReceiptView& proof, geocaching::ByteView directory_public, + const uint8_t local[16], uint8_t state, uint8_t* scratch, size_t capacity) + : jobs_(jobs), receipts_(receipts), crypto_(crypto), lease_(lease), current_(current), next_(next), verify_(verify), + proof_(proof), directory_public_(directory_public), scratch_(scratch), capacity_(capacity), state_(state), binding_{dispatch, this} + { + if (local) std::memcpy(local_, local, 16); + } + const chat::lxmf::TrackDeliveryBinding& binding() const { return binding_; } + bool pending() const { return pending_; } + bool directoryCurrent() const { return directory_current_; } + bool arm() + { + if (pending_ || !publicationJournalValid(current_) || current_.phase != PublicationPhase::Publishing || state_ > 1 || + !directory_public_.data || directory_public_.size != 64 || !networkRequestId({local_, 16}) || !lease_.holds(current_)) return false; + pending_ = true; + return true; + } + void cancelPending() { pending_ = false; } + + private: + static bool dispatch(const chat::lxmf::CustomDeliveryView& view, void* context) + { + return context && static_cast(context)->receive(view); + } + bool receive(const chat::lxmf::CustomDeliveryView& view) + { + if (!pending_ || !lease_.holds(current_) || !view.source.data || view.source.size != 16 || + std::memcmp(view.source.data, current_.directory, 16) || !view.destination.data || view.destination.size != 16 || + std::memcmp(view.destination.data, local_, 16) || !view.message_hash.data || view.message_hash.size != 32 || + !view.data.data || !view.data.size || view.data.size > 8192 || + signingSpansOverlap(view.data.data, view.data.size, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(scratch_, capacity_, reinterpret_cast(this), sizeof(*this))) return false; + const auto* proof_bytes = reinterpret_cast(&proof_); + for (const auto span : {geocaching::ByteView{reinterpret_cast(¤t_), sizeof(current_)}, + {reinterpret_cast(&next_), sizeof(next_)}, + {reinterpret_cast(&verify_), sizeof(verify_)}, + {reinterpret_cast(this), sizeof(*this)}, + directory_public_}) + if (signingSpansOverlap(proof_bytes, sizeof(proof_), span.data, span.size)) return false; + PublishResponseView response; + if (!decodeTrackPublishResponse({view.data.data, view.data.size}, {current_.operation, 16}, response) || + verifyReceipt(response.signed_receipt, current_, {current_.operation, 16}, directory_public_, state_, crypto_, scratch_, capacity_, proof_) != ReceiptVerification::Valid) + { + proof_ = {}; + return false; + } + const auto result = confirmPublication(jobs_, receipts_, proof_, current_, next_, verify_, {current_.operation, 16}, directory_public_, state_, scratch_, capacity_); + proof_ = {}; // No transport-owned byte view survives this callback. + if (result != PublicationStoreResult::Ready) return false; + if (current_.phase != PublicationPhase::Confirmed) current_ = next_; + directory_current_ = response.current; + pending_ = false; + return true; + } + Jobs& jobs_; + Receipts& receipts_; + Crypto& crypto_; + Lease& lease_; + PublicationJournal& current_; + PublicationJournal& next_; + PublicationJournal& verify_; + VerifiedReceiptView& proof_; + geocaching::ByteView directory_public_; + uint8_t* scratch_; + size_t capacity_; + uint8_t local_[16]{}; + uint8_t state_ = 0; + bool pending_ = false, directory_current_ = false; + chat::lxmf::TrackDeliveryBinding binding_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_send_step.h b/modules/core_track_sharing/include/track_sharing/publication_send_step.h new file mode 100644 index 00000000..3cc4c871 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_send_step.h @@ -0,0 +1,51 @@ +#pragma once +#include "track_sharing/network_publish.h" + +namespace track_sharing +{ +enum class PublicationSendResult : uint8_t +{ + Queued, + Waiting, + Rejected, + StorageFailure, + Conflict, + Invalid +}; +// Borrow caller-admitted job/proof/records and separate wire/journal buffers. +// No file handle remains open while entering the protected router. The durable +// operation ID is the network request ID on every retry, including after reboot. +template +PublicationSendResult sendTrackPublicationStep(Store& store, Receiver& receiver, Sender& sender, + PublicationJournal& current, PublicationJournal& next, PublicationJournal& verify, + const VerifiedManifestView& manifest, const uint8_t author[64], + uint8_t* output, size_t capacity, uint8_t* scratch, size_t scratch_capacity) +{ + if (!publicationManifestMatches(current, manifest) || + (current.phase != PublicationPhase::FileStored && current.phase != PublicationPhase::Publishing) || + !author || !manifest.manifest.author.data || manifest.manifest.author.size != 64 || + std::memcmp(author, manifest.manifest.author.data, 64) || !output || !scratch || scratch_capacity < kPublicationJournalBytes || + ¤t == &next || ¤t == &verify || &next == &verify || signingSpansOverlap(output, capacity, scratch, scratch_capacity)) return PublicationSendResult::Invalid; + const geocaching::ByteView inputs[] = { + {reinterpret_cast(¤t), sizeof(current)}, {reinterpret_cast(&next), sizeof(next)}, {reinterpret_cast(&verify), sizeof(verify)}, {reinterpret_cast(&manifest), sizeof(manifest)}, {reinterpret_cast(&receiver), sizeof(receiver)}, {reinterpret_cast(&sender), sizeof(sender)}, {reinterpret_cast(&store), sizeof(store)}, {author, 64}, manifest.manifest.raw, manifest.signature, manifest.manifest.previous}; + for (const auto input : inputs) + if (input.size && (!networkInputDisjoint(input, output, capacity) || !networkInputDisjoint(input, scratch, scratch_capacity))) return PublicationSendResult::Invalid; + if (receiver.pending()) return PublicationSendResult::Waiting; + // Preflight encoding before changing any durable state. The shadow's phase + // affects admission only; wire bindings exclude the journal sequence. + next = current; + next.phase = PublicationPhase::Publishing; + size_t written = 0; + if (!encodeTrackPublishRequest(next, manifest, output, capacity, written)) return PublicationSendResult::Invalid; + const auto durable = markPublicationSending(store, current, next, verify, manifest, scratch, scratch_capacity); + if (durable != PublicationStoreResult::Ready) return durable == PublicationStoreResult::StorageFailure ? PublicationSendResult::StorageFailure : PublicationSendResult::Conflict; + if (!receiver.arm()) return PublicationSendResult::Invalid; + const auto sent = sender.sendTrackData(current.directory, {output, written}, author, current.generation, false, nullptr); + if (!sent.ok) + { + receiver.cancelPending(); + return PublicationSendResult::Rejected; + } + return PublicationSendResult::Queued; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/publication_slot_path.h b/modules/core_track_sharing/include/track_sharing/publication_slot_path.h new file mode 100644 index 00000000..cd386dbf --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/publication_slot_path.h @@ -0,0 +1,37 @@ +#pragma once +#include "track_sharing/object_paths.h" +namespace track_sharing +{ +struct PublicationSlotKey +{ + uint8_t object[32]{}, operation[16]{}; + ObjectFile slot = ObjectFile::PublicationA; +}; +// Parse only canonical device paths. Caller owns output; no buffers or allocation. +// A candidate filename is not authentication of the journal or its receipt. +inline bool parsePublicationSlotPath(const char* path, size_t size, PublicationSlotKey& output) +{ + constexpr char prefix[] = "/trailmate/tracks/"; + constexpr size_t start = sizeof(prefix) - 1, slash = start + 64, key = slash + 1, suffix = key + 32; + if (!path || size != suffix + 4 || std::memcmp(path, prefix, start) || path[slash] != '/') return false; + const auto a = reinterpret_cast(path), b = reinterpret_cast(&output); + if (a <= b ? b - a < size : a - b < sizeof(output)) return false; + ObjectFile slot; + if (!std::memcmp(path + suffix, ".pua", 4)) slot = ObjectFile::PublicationA; + else if (!std::memcmp(path + suffix, ".pub", 4)) slot = ObjectFile::PublicationB; + else return false; + const auto nibble = [](char c) -> int { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; }; + unsigned object_bits = 0, operation_bits = 0; + for (size_t i = 0; i < 96; ++i) + { + const int value = nibble(path[i < 64 ? start + i : key + i - 64]); + if (value < 0) return false; + if (i < 64) object_bits |= unsigned(value); else operation_bits |= unsigned(value); + } + if (!object_bits || !operation_bits) return false; + for (size_t i = 0; i < 32; ++i) output.object[i] = uint8_t(nibble(path[start + 2*i]) * 16 + nibble(path[start + 2*i + 1])); + for (size_t i = 0; i < 16; ++i) output.operation[i] = uint8_t(nibble(path[key + 2*i]) * 16 + nibble(path[key + 2*i + 1])); + output.slot = slot; + return true; +} +} diff --git a/modules/core_track_sharing/include/track_sharing/read_network_exchange.h b/modules/core_track_sharing/include/track_sharing/read_network_exchange.h new file mode 100644 index 00000000..629f0a2f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_network_exchange.h @@ -0,0 +1,142 @@ +#pragma once +#include "chat/ports/track_binding_registration.h" +#include "track_sharing/read_reply_receiver.h" + +namespace track_sharing +{ +enum class ReadExchangeState : uint8_t +{ + Idle, + Waiting, + Ready, + Rejected, + Expired, + Stale, + Cancelled +}; + +// One admitted operation. Owner allocates this in PSRAM and lends a media/USB +// lease and synchronous validated SD sink. No response buffer, task or queue. +// Adapter follows IMeshAdapter's serialized binding/visitor contract. A live +// binding MUST be detached successfully before this object or sink is freed. +template +class ReadNetworkExchange +{ + public: + ReadNetworkExchange(Adapter& adapter, Sink& sink, Lease& lease, geocaching::ByteView author, uint32_t generation) + : adapter_(adapter), lease_(lease), receiver_(sink, lease, {author_, 64}, generation), generation_(generation) + { + if (author.data && author.size == 64 && generation) std::memcpy(author_, author.data, 64); + else generation_ = 0; + } + ReadNetworkExchange(const ReadNetworkExchange&) = delete; + ReadNetworkExchange& operator=(const ReadNetworkExchange&) = delete; + + // Wire comes from the operation-specific read encoders. Header correlation + // is checked again before immutable request bytes are admitted. + chat::TrackBindingChange attach(uint8_t operation, geocaching::ByteView request, geocaching::ByteView directory, + geocaching::ByteView local, geocaching::ByteView wire, uint16_t budget, + uint64_t now, const chat::lxmf::TrackDeliveryBinding* previous = nullptr) + { + if (attached_ || state_ != ReadExchangeState::Idle || !generation_ || !wire.data || !wire.size || wire.size > sizeof(wire_) || + !networkInputDisjoint(wire, reinterpret_cast(this), sizeof(*this)) || !header(operation, request, wire, budget) || + !receiver_.arm(operation, request, directory, local, budget)) return chat::TrackBindingChange::Conflict; + std::memcpy(directory_, directory.data, 16); + std::memcpy(wire_, wire.data, wire.size); + bytes_ = uint16_t(wire.size); + began_ = now; + state_ = ReadExchangeState::Waiting; + const auto changed = adapter_.replaceTrackDelivery(previous, &receiver_.binding()); + if (changed == chat::TrackBindingChange::Applied) attached_ = true; + else + { + receiver_.cancel(); + state_ = ReadExchangeState::Idle; + bytes_ = 0; + } + return changed; + } + + // Visits refresh the author guard under the existing router lock. Radio + // sends happen only after returning from that lock; all bytes stay owned. + chat::TrackBindingChange advance(uint64_t now, ReadExchangeState& output) + { + if (!attached_) + { + output = state_; + return chat::TrackBindingChange::Conflict; + } + now_ = now; + const auto changed = adapter_.visitTrackDelivery(&receiver_.binding(), current_author_, current_generation_, drive, this); + if (changed != chat::TrackBindingChange::Applied) return changed; + if (send_) + { + send_ = false; + const auto sent = adapter_.sendTrackData(directory_, chat::lxmf::ByteSpan{wire_, bytes_}, author_, generation_, false, nullptr); + if (!sent.ok) + { + rejected_ = true; + const auto reconciled = adapter_.visitTrackDelivery(&receiver_.binding(), current_author_, current_generation_, drive, this); + if (reconciled != chat::TrackBindingChange::Applied) return reconciled; + } + } + output = state_; + return chat::TrackBindingChange::Applied; + } + chat::TrackBindingChange detach() + { + if (!attached_) return chat::TrackBindingChange::Applied; + const auto changed = adapter_.replaceTrackDelivery(&receiver_.binding(), nullptr); + if (changed == chat::TrackBindingChange::Applied || changed == chat::TrackBindingChange::Conflict) + { + attached_ = false; + receiver_.cancel(); + send_ = false; + if (state_ == ReadExchangeState::Waiting) state_ = ReadExchangeState::Cancelled; + } + return changed; + } + bool attached() const { return attached_; } + const chat::lxmf::TrackDeliveryBinding* binding() const { return &receiver_.binding(); } + + private: + static bool header(uint8_t operation, geocaching::ByteView request, geocaching::ByteView wire, uint16_t budget) + { + if (!readResponseOperation(operation) || !networkRequestId(request) || budget < 512 || budget > 8192) return false; + geocaching::protocol::CmpReader reader(wire); + size_t fields = 0; + uint64_t version = 0, role = 0, op = 0, limit = 0; + geocaching::ByteView id; + return reader.array(fields, 6) && fields == 6 && reader.unsignedInteger(version) && version == 1 && + reader.unsignedInteger(role) && role == 0 && reader.unsignedInteger(op) && op == operation && + reader.binary(id, 16) && id.size == 16 && !std::memcmp(id.data, request.data, 16) && + reader.unsignedInteger(limit) && limit == budget && reader.position() < wire.size; + } + static void drive(void* value) + { + auto& self = *static_cast(value); + self.send_ = false; + if (self.state_ != ReadExchangeState::Waiting) return; + if (!self.lease_.holds() || self.current_generation_ != self.generation_ || std::memcmp(self.current_author_, self.author_, 64)) + self.state_ = ReadExchangeState::Stale; + else if (!self.receiver_.pending()) self.state_ = ReadExchangeState::Ready; + else if (self.now_ < self.began_ || self.now_ - self.began_ >= 45000) self.state_ = ReadExchangeState::Expired; + else if (self.rejected_) self.state_ = ReadExchangeState::Rejected; + else if (!self.sent_) + { + self.sent_ = true; + self.send_ = true; + } + if (self.state_ != ReadExchangeState::Waiting && self.state_ != ReadExchangeState::Ready) self.receiver_.cancel(); + } + Adapter& adapter_; + Lease& lease_; + uint8_t author_[64]{}, current_author_[64]{}, directory_[16]{}, wire_[320]{}; + ReadReplyReceiver receiver_; + uint64_t began_ = 0, now_ = 0; + uint32_t generation_ = 0, current_generation_ = 0; + uint16_t bytes_ = 0; + ReadExchangeState state_ = ReadExchangeState::Idle; + bool attached_ = false, sent_ = false, send_ = false, rejected_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_query_binding.h b/modules/core_track_sharing/include/track_sharing/read_query_binding.h new file mode 100644 index 00000000..8c1fe76c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_query_binding.h @@ -0,0 +1,33 @@ +#pragma once +#include "track_sharing/network_read.h" + +namespace track_sharing +{ +// Hash lends its admitted stream context. Output and CMP1 workspace are caller +// storage, independent of query/cursor inputs. No implicit hash context/buffer. +template +bool deriveReadQueryBinding(const ReadQuery& query, Hash& hash, uint8_t* workspace, + size_t capacity, uint8_t output[32]) +{ + if (!workspace || !capacity || !output || query.west < -1800000000 || query.west > 1799999999 || + query.east < -1800000000 || query.east > 1799999999 || query.south < -900000000 || + query.north > 900000000 || query.south > query.north || query.kind < -1 || query.kind > 1 || + query.state < -1 || query.state > 1 || (query.author.size && !readSpan(query.author, 64))) return false; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&query), sizeof(query)}, + query.author, query.cursor_object, query.cursor_binding}) + { + if (input.size && (!networkInputDisjoint(input, workspace, capacity) || !networkInputDisjoint(input, output, 32))) return false; + } + if (!networkInputDisjoint({output, 32}, workspace, capacity)) return false; + geocaching::protocol::CmpWriter writer(workspace, capacity); + if (!writer.array(4) || !writer.array(4) || !writer.signedInteger(query.west) || !writer.signedInteger(query.south) || + !writer.signedInteger(query.east) || !writer.signedInteger(query.north) || + !(query.kind < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.kind))) || + !(query.author.size ? writer.binary(query.author) : writer.nil()) || + !(query.state < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.state)))) return false; + const bool ok = hash.start() && hash.update(workspace, writer.size()) && hash.finish(output); + hash.reset(); + if (!ok) std::memset(output, 0, 32); + return ok; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_query_response.h b/modules/core_track_sharing/include/track_sharing/read_query_response.h new file mode 100644 index 00000000..dc3c05e0 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_query_response.h @@ -0,0 +1,116 @@ +#pragma once +#include "geocaching/protocol/record_decoder.h" +#include "track_sharing/network_read.h" +#include "track_sharing/network_upload_response.h" + +namespace track_sharing +{ +struct ReadQueryRow +{ + geocaching::ByteView object, author; + int32_t west = 0, south = 0, east = 0, north = 0; + uint8_t kind = 0, state = 0; +}; +struct ReadQueryResponse +{ + geocaching::ByteView cursor_object, cursor_binding; + uint64_t snapshot = 0; + uint8_t rows = 0; + bool has_cursor = false; +}; +static_assert(sizeof(ReadQueryRow) <= 64 && sizeof(ReadQueryResponse) <= 56, "Query views must not retain a page"); + +inline bool readQueryIntersects(const ReadQuery& query, const ReadQueryRow& row) +{ + if (query.south > row.north || row.south > query.north) return false; + if (query.west > query.east && row.west > row.east) return true; + if (query.west > query.east) return query.west <= row.east || row.west <= query.east; + if (row.west > row.east) return row.west <= query.east || query.west <= row.east; + return query.west <= row.east && row.west <= query.east; +} +inline bool decodeReadQueryRow(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, ReadQueryRow& row) +{ + row = {}; + size_t count = 0; + uint64_t value = 0, revision = 0; + int64_t coordinate = 0; + geocaching::ByteView version; + std::string_view title; + if (!reader.array(count, 10) || count != 10 || !reader.binary(row.object, 32) || row.object.size != 32 || + !reader.binary(version, 32) || version.size != 32 || !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX || + !reader.unsignedInteger(value) || value > 1) return false; + row.kind = uint8_t(value); + if (!reader.unsignedInteger(value) || value > 1 || (revision == 1 && value)) return false; + row.state = uint8_t(value); + if (!reader.text(title, 128) || !geocaching::protocol::validRecordText(title, false, true) || !reader.array(count, 4) || count != 4) return false; + const auto angle = [&](int64_t low, int64_t high, int32_t& target) + {if(!reader.signedInteger(coordinate) || coordinatehigh)return false;target=int32_t(coordinate);return true; }; + if (!angle(-1800000000, 1799999999, row.west) || !angle(-900000000, 900000000, row.south) || + !angle(-1800000000, 1799999999, row.east) || !angle(row.south, 900000000, row.north) || + !reader.array(count, 4) || count != 4) return false; + for (size_t i = 0; i < 4; ++i) + { + if ((i == 1 || i == 2) && reader.position() < raw.size && raw.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else if (!reader.unsignedInteger(value) || value > (i == 3 ? 100000 : UINT32_MAX) || (i == 3 && !value)) return false; + } + if (!reader.unsignedInteger(value) || !value || value > 8388608 || !reader.binary(row.author, 64) || row.author.size != 64) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 64; ++i) bits |= row.author.data[i]; + return bits != 0; +} + +// Owner lends one row scratch and output. All returned views are callback-local. +// For operation 1, binding is SHA-256(CMP1([box,kind,author,state])). For 13, +// caller derives it from the immutable region ID/version/relation/filter scope. +inline bool decodeTrackReadQueryResponse(geocaching::ByteView raw, geocaching::ByteView request, + const ReadQuery& query, geocaching::ByteView expected_binding, + ReadQueryRow& row, ReadQueryResponse& output, uint8_t operation = 1) +{ + if ((operation != 1 && operation != 13) || !raw.data || !raw.size || raw.size > 8192 || !readSpan(expected_binding, 32) || !query.limit || query.limit > 32 || + query.west < -1800000000 || query.west > 1799999999 || query.east < -1800000000 || query.east > 1799999999 || + query.south < -900000000 || query.north > 900000000 || query.south > query.north || + query.kind < -1 || query.kind > 1 || query.state < -1 || query.state > 1 || query.snapshot > INT64_MAX || + (query.author.size && !readSpan(query.author, 64)) || + (query.has_cursor && (!query.has_snapshot || !readSpan(query.cursor_object, 32) || !readSpan(query.cursor_binding, 32) || std::memcmp(query.cursor_binding.data, expected_binding.data, 32)))) return false; + for (const auto input : {raw, request, expected_binding, query.author, query.cursor_object, query.cursor_binding, + geocaching::ByteView{reinterpret_cast(&query), sizeof(query)}}) + { + if (input.size && (!networkInputDisjoint(input, reinterpret_cast(&row), sizeof(row)) || + !networkInputDisjoint(input, reinterpret_cast(&output), sizeof(output)))) return false; + } + if (!networkInputDisjoint({reinterpret_cast(&row), sizeof(row)}, reinterpret_cast(&output), sizeof(output))) return false; + geocaching::protocol::CmpReader reader(raw); + size_t count = 0; + uint64_t snapshot = 0; + geocaching::ByteView cursor_object, cursor_binding; + bool has_cursor = false; + if (!uploadResponseHeader(reader, operation, request, 3) || !reader.unsignedInteger(snapshot) || snapshot > INT64_MAX || + (query.has_snapshot && snapshot != query.snapshot)) return false; + if (reader.position() < raw.size && raw.data[reader.position()] == 0xc0) + { + if (!reader.nil()) return false; + } + else + { + if (!reader.array(count, 2) || count != 2 || !reader.binary(cursor_object, 32) || cursor_object.size != 32 || + !reader.binary(cursor_binding, 32) || cursor_binding.size != 32 || std::memcmp(cursor_binding.data, expected_binding.data, 32) || + (query.has_cursor && std::memcmp(cursor_object.data, query.cursor_object.data, 32) <= 0)) return false; + has_cursor = true; + } + if (!reader.array(count, query.limit)) return false; + geocaching::ByteView previous = query.has_cursor ? query.cursor_object : geocaching::ByteView{}; + for (size_t i = 0; i < count; ++i) + { + if (!decodeReadQueryRow(reader, raw, row) || (previous.size && std::memcmp(row.object.data, previous.data, 32) <= 0) || + (query.kind >= 0 && row.kind != query.kind) || (query.state >= 0 && row.state != query.state) || + (query.author.size && std::memcmp(query.author.data, row.author.data, 64)) || !readQueryIntersects(query, row)) return false; + previous = row.object; + } + if (!reader.finished() || (has_cursor && previous.size && std::memcmp(cursor_object.data, previous.data, 32) < 0)) return false; + output = {cursor_object, cursor_binding, snapshot, uint8_t(count), has_cursor}; + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_reply_receiver.h b/modules/core_track_sharing/include/track_sharing/read_reply_receiver.h new file mode 100644 index 00000000..28662fef --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_reply_receiver.h @@ -0,0 +1,76 @@ +#pragma once +#include "chat/ports/track_delivery_binding.h" +#include "track_sharing/network_read_response.h" + +namespace track_sharing +{ +// Sink.validateAndStore must synchronously validate the entire operation body +// and store required bytes before returning true. No borrowed response survives +// the callback. Owner supplies storage and must unregister before destruction. +template +class ReadReplyReceiver +{ + public: + ReadReplyReceiver(Sink& sink, Lease& lease, geocaching::ByteView author, uint32_t generation) + : sink_(sink), lease_(lease) + { + binding_.control = dispatch; + binding_.context = this; + if (author.data && author.size == 64 && generation) + { + binding_.expected_local_author = author.data; + binding_.expected_author_generation = generation; + } + } + const chat::lxmf::TrackDeliveryBinding& binding() const { return binding_; } + bool pending() const { return pending_; } + bool arm(uint8_t operation, geocaching::ByteView request, geocaching::ByteView directory, + geocaching::ByteView local, uint16_t budget) + { + if (pending_ || !readResponseOperation(operation) || !networkRequestId(request) || + !networkRequestId(directory) || !networkRequestId(local) || budget < 512 || budget > 8192 || + !binding_.expected_local_author || !lease_.holds() || + !networkInputDisjoint(request, reinterpret_cast(this), sizeof(*this)) || + !networkInputDisjoint(directory, reinterpret_cast(this), sizeof(*this)) || + !networkInputDisjoint(local, reinterpret_cast(this), sizeof(*this))) return false; + std::memcpy(request_, request.data, 16); + std::memcpy(directory_, directory.data, 16); + std::memcpy(local_, local.data, 16); + operation_ = operation; + budget_ = budget; + accepted_ = false; + pending_ = true; + return true; + } + void cancel() { pending_ = accepted_ = false; } + + private: + static bool dispatch(const chat::lxmf::CustomDeliveryView& view, void* context) + { + return context && static_cast(context)->receive(view); + } + bool receive(const chat::lxmf::CustomDeliveryView& view) + { + if ((!pending_ && !accepted_) || !lease_.holds() || !view.source.data || view.source.size != 16 || + std::memcmp(view.source.data, directory_, 16) || !view.destination.data || view.destination.size != 16 || + std::memcmp(view.destination.data, local_, 16) || !view.message_hash.data || view.message_hash.size != 32 || + !view.data.data || !view.data.size || view.data.size > budget_ || + !networkInputDisjoint({view.data.data, view.data.size}, reinterpret_cast(this), sizeof(*this))) return false; + if (accepted_) return !std::memcmp(message_hash_, view.message_hash.data, 32); + ReadResponseView response; + if (!decodeTrackReadEnvelope({view.data.data, view.data.size}, {request_, 16}, operation_, response) || + !sink_.validateAndStore(operation_, {request_, 16}, response, {view.data.data, view.data.size})) return false; + std::memcpy(message_hash_, view.message_hash.data, 32); + pending_ = false; + accepted_ = true; + return true; + } + Sink& sink_; + Lease& lease_; + chat::lxmf::TrackDeliveryBinding binding_{}; + uint8_t request_[16]{}, directory_[16]{}, local_[16]{}, message_hash_[32]{}; + uint16_t budget_ = 0; + uint8_t operation_ = 0; + bool pending_ = false, accepted_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_response_sink.h b/modules/core_track_sharing/include/track_sharing/read_response_sink.h new file mode 100644 index 00000000..2ed10881 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_response_sink.h @@ -0,0 +1,59 @@ +#pragma once +#include "track_sharing/network_read_response.h" +#include "track_sharing/read_response_store.h" + +namespace track_sharing +{ +struct StoredReadResponse +{ + uint8_t digest[32]{}; + uint16_t bytes = 0, status = 0; + uint8_t operation = 0; + bool ready = false; +}; +static_assert(sizeof(StoredReadResponse) <= 40, "Stored response retains digest and metadata only"); + +// Validator validates the complete body, including any signature/reference +// binding. Store owns exclusive media-bound paths; Hash borrows its admitted +// stream context. Caller lends scratch and output, normally from a PSRAM owner. +template +class ReadResponseSink +{ + public: + ReadResponseSink(Store& store, Validator& validator, Hash& hash, StoredReadResponse& output, + uint8_t* scratch, size_t capacity) + : store_(store), validator_(validator), hash_(hash), output_(output), scratch_(scratch), capacity_(capacity) {} + bool validateAndStore(uint8_t operation, geocaching::ByteView request, + const ReadResponseView& envelope, geocaching::ByteView raw) + { + if (!raw.data || !raw.size || raw.size > 8192 || !scratch_ || capacity_ < 128 || + !networkInputDisjoint(raw, reinterpret_cast(&output_), sizeof(output_)) || + !networkInputDisjoint(raw, reinterpret_cast(this), sizeof(*this)) || + !networkInputDisjoint({scratch_, capacity_}, reinterpret_cast(&output_), sizeof(output_)) || + !networkInputDisjoint({scratch_, capacity_}, reinterpret_cast(this), sizeof(*this))) return false; + output_.ready = false; + if (!validator_.validate(operation, request, envelope, raw) || + store_.persist(raw, scratch_, capacity_) != ReadStoreResult::Ready) return false; + // The digest remains in trusted owner metadata, not in the SD file. + // USB verifies the entire cached response against these original bytes. + if (!hash_.start() || !hash_.update(raw.data, raw.size) || !hash_.finish(output_.digest)) + { + hash_.reset(); + return false; + } + output_.bytes = uint16_t(raw.size); + output_.status = envelope.status; + output_.operation = operation; + output_.ready = true; + return true; + } + + private: + Store& store_; + Validator& validator_; + Hash& hash_; + StoredReadResponse& output_; + uint8_t* scratch_; + size_t capacity_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_response_store.h b/modules/core_track_sharing/include/track_sharing/read_response_store.h new file mode 100644 index 00000000..f12dd639 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_response_store.h @@ -0,0 +1,109 @@ +#pragma once +#include "track_sharing/network_request.h" +#include + +namespace track_sharing +{ +enum class ReadStoreResult : uint8_t +{ + Ready, + Missing, + Conflict, + StorageFailure, + Invalid +}; + +// Byte persistence only. Caller validates the full response, owns exclusive +// paths and pins media/account/session leases. The temporary path is disposable +// for this one operation; a published response is never overwritten. Caller +// retains the original response digest for whole-response verification on USB. +template +class ReadResponseStore +{ + public: + using Result = ReadStoreResult; + ReadResponseStore(Io& io, const char* temporary, const char* destination) + : io_(io), temporary_(temporary), destination_(destination) {} + Result persist(geocaching::ByteView source, uint8_t* scratch, size_t capacity) + { + if (!source.data || !source.size || source.size > 8192 || !scratch || capacity < 128 || + !temporary_ || !destination_ || !*temporary_ || !*destination_ || !std::strcmp(temporary_, destination_) || + !networkInputDisjoint(source, scratch, 128) || + !networkInputDisjoint(source, reinterpret_cast(this), sizeof(*this)) || + !networkInputDisjoint({scratch, 128}, reinterpret_cast(this), sizeof(*this))) return Result::Invalid; + const auto published = compare(destination_, source, scratch); + if (published != Result::Missing) return published; + const auto staged = compare(temporary_, source, scratch); + if (staged == Result::StorageFailure) return staged; + if (staged != Result::Ready) + { + const auto opened = io_.open(temporary_, Io::Mode::CreateTemporary); + if (!opened.handle) return Result::StorageFailure; + bool ok = true; + for (size_t offset = 0; ok && offset < source.size;) + { + const size_t count = std::min(size_t(320), source.size - offset); + ok = io_.write(opened.handle, source.data + offset, count) == count; + offset += count; + } + ok = ok && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!ok || !closed || compare(temporary_, source, scratch) != Result::Ready) return Result::StorageFailure; + } + else + { + // An earlier write may have completed while its sync failed. + // Matching bytes alone are not evidence of durable staging. + const auto opened = io_.open(temporary_, Io::Mode::Update); + if (!opened.handle) return Result::StorageFailure; + const bool synced = io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!synced || !closed) return Result::StorageFailure; + } + if (!io_.publish(temporary_, destination_)) return Result::StorageFailure; + return compare(destination_, source, scratch) == Result::Ready ? Result::Ready : Result::StorageFailure; + } + Result read(uint32_t total, uint32_t offset, uint16_t count, uint8_t* output, size_t capacity, size_t& written) + { + written = 0; + if (!destination_ || !*destination_ || !total || total > 8192 || offset >= total || !count || count > 320 || count > total - offset || + !output || capacity < count || !networkInputDisjoint({output, count}, reinterpret_cast(this), sizeof(*this))) return Result::Invalid; + const auto opened = io_.open(destination_, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? Result::Missing : Result::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool match = sized && size == total; + const bool ok = match && io_.seek(opened.handle, offset) && io_.read(opened.handle, output, count) == count; + const bool closed = io_.close(opened.handle); + if (!sized || !closed) return Result::StorageFailure; + if (!match) return Result::Conflict; + if (!ok) return Result::StorageFailure; + written = count; + return Result::Ready; + } + + private: + Result compare(const char* path, geocaching::ByteView source, uint8_t* scratch) + { + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? Result::Missing : Result::StorageFailure; + uint64_t size = 0; + bool ok = io_.size(opened.handle, size); + bool match = ok && size == source.size; + if (ok && match) ok = io_.seek(opened.handle, 0); + for (size_t offset = 0; ok && match && offset < source.size;) + { + const size_t count = std::min(size_t(128), source.size - offset); + ok = io_.read(opened.handle, scratch, count) == count; + match = ok && !std::memcmp(scratch, source.data + offset, count); + offset += count; + } + const bool closed = io_.close(opened.handle); + if (!ok || !closed) return Result::StorageFailure; + return match ? Result::Ready : Result::Conflict; + } + Io& io_; + const char* temporary_; + const char* destination_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_response_validator.h b/modules/core_track_sharing/include/track_sharing/read_response_validator.h new file mode 100644 index 00000000..16070759 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_response_validator.h @@ -0,0 +1,75 @@ +#pragma once +#include "track_sharing/directory_capabilities.h" +#include "track_sharing/read_query_response.h" +#include "track_sharing/verify_read_manifest.h" +#include "track_sharing/region_read_response.h" + +namespace track_sharing +{ +// Borrowed expected context must remain immutable for this admitted operation. +struct ReadValidationContext +{ + geocaching::ByteView directory_public, directory_epoch; + geocaching::ByteView object, revision, file_hash, query_binding; + const ReadQuery* query = nullptr; + const RegionReadScope* region = nullptr; + uint32_t file_bytes = 0, offset = 0; + uint16_t length = 0; +}; + +template +class ReadResponseValidator +{ + public: + ReadResponseValidator(const ReadValidationContext& context, Crypto& crypto, Hash& hash, + VerifiedManifestView& manifest, ReadQueryRow& row, ReadQueryResponse& page, + uint8_t* workspace, size_t capacity, RegionReplyWorkspace* regions = nullptr) + : context_(context), crypto_(crypto), hash_(hash), manifest_(manifest), row_(row), page_(page), workspace_(workspace), capacity_(capacity), regions_(regions) {} + bool validate(uint8_t operation, geocaching::ByteView request, const ReadResponseView& envelope, geocaching::ByteView raw) + { + ReadResponseView checked; + if (!decodeTrackReadEnvelope(raw, request, operation, checked) || checked.status != envelope.status || + checked.body.data != envelope.body.data || checked.body.size != envelope.body.size) return false; + // Error diagnostics are fully decoded by the envelope validator. + if (checked.status != 200) return true; + if (operation == 0) + { + TrackDirectoryCapabilities caps; + return readSpan(context_.directory_public, 64) && readSpan(context_.directory_epoch, 16) && + decodeTrackCapabilities(raw, request, caps) && !std::memcmp(caps.public_key.data, context_.directory_public.data, 64) && + !std::memcmp(caps.epoch.data, context_.directory_epoch.data, 16) && (caps.operations & 0x107) == 0x107; + } + if (operation == 1) return context_.query && decodeTrackReadQueryResponse(raw, request, *context_.query, context_.query_binding, row_, page_); + if (operation == 2) return verifyReadManifest(raw, request, context_.object, context_.revision, crypto_, hash_, workspace_, capacity_, manifest_) == ReadManifestVerification::Valid; + if (operation == 8 || operation == 12) + { + if (operation == 12 && (!context_.region || !regionVersion(context_.region->version) || !regionInNamespace(context_.region->id,context_.region->name_space))) return false; + ReadFileResponse file; + return decodeTrackReadFileResponse(raw, request, context_.file_hash, context_.file_bytes, context_.offset, context_.length, file, operation); + } + if (operation == 9) return regions_ && decodeRegionCapabilities(raw, request, regions_->capabilities); + if (operation >= 10 && operation <= 13) + { + if (!context_.region) return false; + const auto& region = *context_.region; + if (operation == 10) return regions_ && context_.query && decodeRegionChildren(raw,request,region.version,region.name_space,region.parent,region.after, + region.parent_level,region.depth,context_.query->limit,regions_->row,regions_->page); + if (operation == 11) return regions_ && decodeRegionDescriptor(raw,request,region.id,region.version,region.name_space,region.depth,regions_->descriptor); + if (operation == 13) return context_.query && regionVersion(region.version) && regionInNamespace(region.id,region.name_space) && + decodeRegionQueryResponse(raw,request,*context_.query,context_.query_binding,row_,page_); + } + return false; + } + + private: + const ReadValidationContext& context_; + Crypto& crypto_; + Hash& hash_; + VerifiedManifestView& manifest_; + ReadQueryRow& row_; + ReadQueryResponse& page_; + uint8_t* workspace_; + size_t capacity_; + RegionReplyWorkspace* regions_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/read_usb_request.h b/modules/core_track_sharing/include/track_sharing/read_usb_request.h new file mode 100644 index 00000000..1a764c8d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/read_usb_request.h @@ -0,0 +1,154 @@ +#pragma once +#include "track_sharing/read_response_validator.h" + +namespace track_sharing +{ +// Caller admits this small decode workspace in PSRAM. Views are borrowed only +// until TrackReadOwner copies them; no frame view survives command execution. +struct ReadUsbRequest +{ + geocaching::ByteView request, directory, epoch; + ReadQuery query; + ReadValidationContext expected; + RegionReadScope region; + uint32_t generation = 0; + uint8_t operation = 0; +}; +static_assert(sizeof(ReadUsbRequest) <= 384, "USB decode workspace contains views only"); +inline bool readUsbNil(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw) +{ + return reader.position() < raw.size && raw.data[reader.position()] == 0xc0; +} +inline bool readUsbOptionalBinary(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, + geocaching::ByteView& output, size_t size) +{ + if (readUsbNil(reader, raw)) + { + output = {}; + return reader.nil(); + } + return reader.binary(output, size) && output.size == size; +} +inline bool readUsbFilter(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, int8_t& output) +{ + if (readUsbNil(reader, raw)) + { + output = -1; + return reader.nil(); + } + uint64_t value = 0; + if (!reader.unsignedInteger(value) || value > 1) return false; + output = int8_t(value); + return true; +} +// [networkRequest16,directory16,epoch16,generation,readOperation,parameters] +// Query parameters match directory GET_QUERY; GET_FILE additionally includes +// signed expected file bytes, which are not sent as part of the network body. +inline bool decodeReadUsbRegion(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, ReadUsbRequest& output) +{ + auto& region = output.region; + size_t fields=0; + const size_t expected=output.operation==10?7:output.operation==11?4:output.operation==12?7:10; + if (!reader.array(fields,expected)||fields!=expected||!reader.text(region.name_space,24)||!regionNamespace(region.name_space)) return false; + if (output.operation==10) + { + int64_t level=0;uint64_t depth=0,limit=0; + if (!reader.binary(region.version,32)||!regionVersion(region.version)||!decodeRegionOptionalId(reader,raw,region.parent)|| + !reader.signedInteger(level)||level< -1||level>8||!decodeRegionOptionalId(reader,raw,region.after)|| + !reader.unsignedInteger(depth)||depth>8||level>int64_t(depth)||!reader.unsignedInteger(limit)||!limit||limit>32|| + (region.parent.empty()!=(level==-1))||(!region.parent.empty()&&!regionInNamespace(region.parent,region.name_space))|| + (!region.after.empty()&&!regionInNamespace(region.after,region.name_space))) return false; + region.parent_level=int8_t(level);region.depth=uint8_t(depth);output.query.limit=uint8_t(limit);output.expected.query=&output.query; + } + else + { + if (!reader.text(region.id,89)||!regionInNamespace(region.id,region.name_space)||!reader.binary(region.version,32)||!regionVersion(region.version)) return false; + if (output.operation==11) + { + uint64_t depth=0;if(!reader.unsignedInteger(depth)||depth>8)return false;region.depth=uint8_t(depth); + } + else if (output.operation==12) + { + uint64_t total=0,offset=0,length=0; + if (!reader.binary(output.expected.file_hash,32)||output.expected.file_hash.size!=32||!reader.unsignedInteger(total)||!total||total>8388608|| + !reader.unsignedInteger(offset)||offset>=total||!reader.unsignedInteger(length)||!length||length>1024||length>total-offset) return false; + output.expected.file_bytes=uint32_t(total);output.expected.offset=uint32_t(offset);output.expected.length=uint16_t(length); + } + } + output.expected.region=®ion; + return output.operation==13||reader.finished(); +} +inline bool decodeReadUsbRequest(geocaching::ByteView raw, ReadUsbRequest& output) +{ + if (!raw.data || !raw.size || raw.size > 471 || !networkInputDisjoint(raw, reinterpret_cast(&output), sizeof(output))) return false; + output = {}; + geocaching::protocol::CmpReader reader(raw); + size_t fields = 0; + uint64_t generation = 0, operation = 0; + if (!reader.array(fields, 6) || fields != 6 || !reader.binary(output.request, 16) || !networkRequestId(output.request) || + !reader.binary(output.directory, 16) || !networkRequestId(output.directory) || !reader.binary(output.epoch, 16) || !networkRequestId(output.epoch) || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || !reader.unsignedInteger(operation) || + operation > 13 || !readResponseOperation(uint8_t(operation))) return false; + output.generation = uint32_t(generation); + output.operation = uint8_t(operation); + if (operation == 0 || operation == 9) return reader.array(fields, 0) && !fields && reader.finished(); + if (operation >= 10) + { + if (!decodeReadUsbRegion(reader,raw,output)) return false; + if (operation != 13) return true; + } + if (operation == 1 || operation == 13) + { + auto& query = output.query; + int64_t coordinate = 0; + uint64_t limit = 0; + if ((operation==1&&(!reader.array(fields,7)||fields!=7))||!reader.array(fields,4)||fields!=4) return false; + for (auto* target : {&query.west, &query.south, &query.east, &query.north}) + { + if (!reader.signedInteger(coordinate) || coordinate < INT32_MIN || coordinate > INT32_MAX) return false; + *target = int32_t(coordinate); + } + if (query.west < -1800000000 || query.west > 1799999999 || query.east < -1800000000 || query.east > 1799999999 || + query.south < -900000000 || query.north > 900000000 || query.south > query.north || + !readUsbFilter(reader, raw, query.kind) || !readUsbOptionalBinary(reader, raw, query.author, 64) || !readUsbFilter(reader, raw, query.state) || + !reader.unsignedInteger(limit) || !limit || limit > 32) return false; + query.limit = uint8_t(limit); + if (readUsbNil(reader, raw)) + { + if (!reader.nil()) return false; + } + else + { + query.has_snapshot = true; + if (!reader.unsignedInteger(query.snapshot) || query.snapshot > INT64_MAX) return false; + } + if (readUsbNil(reader, raw)) + { + if (!reader.nil()) return false; + } + else + { + query.has_cursor = true; + if (!query.has_snapshot || !reader.array(fields, 2) || fields != 2 || !reader.binary(query.cursor_object, 32) || query.cursor_object.size != 32 || + !reader.binary(query.cursor_binding, 32) || query.cursor_binding.size != 32) return false; + } + output.expected.query = &query; + } + else if (operation == 2) + { + if (!reader.array(fields, 2) || fields != 2 || !reader.binary(output.expected.object, 32) || output.expected.object.size != 32 || + !readUsbOptionalBinary(reader, raw, output.expected.revision, 32)) return false; + } + else + { + uint64_t bytes = 0, offset = 0, length = 0; + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(output.expected.file_hash, 32) || output.expected.file_hash.size != 32 || + !reader.unsignedInteger(bytes) || !bytes || bytes > 8388608 || !reader.unsignedInteger(offset) || offset >= bytes || + !reader.unsignedInteger(length) || !length || length > 1024 || length > bytes - offset) return false; + output.expected.file_bytes = uint32_t(bytes); + output.expected.offset = uint32_t(offset); + output.expected.length = uint16_t(length); + } + return reader.finished(); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/receipt.h b/modules/core_track_sharing/include/track_sharing/receipt.h new file mode 100644 index 00000000..d63a5c4c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/receipt.h @@ -0,0 +1,50 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/publication_journal.h" +namespace track_sharing +{ +struct ReceiptView +{ + geocaching::ByteView raw, request, object, revision_hash, file_hash, directory_public, directory, epoch; + uint64_t accepted = 0; + uint32_t revision = 0, file_bytes = 0; + uint8_t state = 0; +}; +static_assert(sizeof(ReceiptView)<=160,"Receipt must borrow payload fields"); +// Wire structure only. No signature, directory provenance or job authority is +// established by this decoder. Retain immutable raw bytes until verification. +inline bool decodeReceipt(geocaching::ByteView raw,ReceiptView& output) +{ + if(signingSpansOverlap(raw.data,raw.size,reinterpret_cast(&output),sizeof(output)))return false; + output={};if(!raw.data||!raw.size||raw.size>1024)return false; + geocaching::protocol::CmpReader reader(raw);size_t fields=0;uint64_t value=0; + if(!reader.array(fields,13)||fields!=13||!reader.unsignedInteger(value)||value!=1|| + !reader.binary(output.request,16)||output.request.size!=16||!reader.binary(output.object,32)||output.object.size!=32|| + !reader.binary(output.revision_hash,32)||output.revision_hash.size!=32||!reader.unsignedInteger(value)||!value||value>UINT32_MAX)return false; + output.revision=uint32_t(value); + if(!reader.unsignedInteger(value)||value>1)return false; + output.state=uint8_t(value); + if(!reader.binary(output.file_hash,32)||output.file_hash.size!=32||!reader.unsignedInteger(value)||!value||value>8388608)return false; + output.file_bytes=uint32_t(value); + if(!reader.binary(output.directory_public,64)||output.directory_public.size!=64||!reader.binary(output.directory,16)||output.directory.size!=16|| + !reader.binary(output.epoch,16)||output.epoch.size!=16||!reader.unsignedInteger(value)||value!=1|| + !reader.unsignedInteger(output.accepted)||output.accepted>253402300799ULL||!reader.finished())return false; + output.raw=raw;return true; +} +// Caller verifies expected directory public/delivery binding independently and +// checks signature before applying a Confirmed journal transition. +inline bool receiptMatchesJob(const ReceiptView& receipt,const PublicationJournal& job,geocaching::ByteView request, + geocaching::ByteView directory_public,uint8_t expected_state) +{ + return publicationJournalValid(job)&&expected_state<=1&&receipt.state==expected_state&&request.data&&request.size==16&& + directory_public.data&&directory_public.size==64&&receipt.request.data&&receipt.request.size==16&& + receipt.object.data&&receipt.object.size==32&&receipt.revision_hash.data&&receipt.revision_hash.size==32&& + receipt.file_hash.data&&receipt.file_hash.size==32&&receipt.directory.data&&receipt.directory.size==16&& + receipt.epoch.data&&receipt.epoch.size==16&&receipt.directory_public.data&&receipt.directory_public.size==64&& + receipt.revision==job.revision&&receipt.file_bytes==job.file_bytes&& + !std::memcmp(receipt.request.data,request.data,16)&&!std::memcmp(receipt.object.data,job.object,32)&& + !std::memcmp(receipt.revision_hash.data,job.revision_hash,32)&&!std::memcmp(receipt.file_hash.data,job.file_hash,32)&& + !std::memcmp(receipt.directory.data,job.directory,16)&&!std::memcmp(receipt.epoch.data,job.directory_epoch,16)&& + !std::memcmp(receipt.directory_public.data,directory_public.data,64); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/region_read_request.h b/modules/core_track_sharing/include/track_sharing/region_read_request.h new file mode 100644 index 00000000..a892b68a --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/region_read_request.h @@ -0,0 +1,133 @@ +#pragma once +#include "track_sharing/network_read.h" +#include + +namespace track_sharing +{ +inline bool regionId(geocaching::ByteView value) +{ + if (!value.data || value.size < 3 || value.size > 89) return false; + size_t colon = 0; + for (; colon < value.size && value.data[colon] != ':'; ++colon) + { + const auto c = value.data[colon]; + if (colon ? !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-') : !(c >= 'a' && c <= 'z')) return false; + } + if (!colon || colon > 24 || colon + 1 >= value.size || value.size - colon - 1 > 64) return false; + for (size_t i = colon + 1; i < value.size; ++i) + { + const auto c = value.data[i]; + const bool alphanumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + if (!alphanumeric && (i == colon + 1 || (c != '.' && c != '_' && c != '-'))) return false; + } + return true; +} +inline bool regionOptionalId(geocaching::ByteView value) { return !value.size || regionId(value); } +inline bool regionVersion(geocaching::ByteView value) +{ + if (!readSpan(value, 32)) return false; + uint8_t bits = 0; + for (size_t i = 0; i < value.size; ++i) bits |= value.data[i]; + return bits != 0; +} +inline std::string_view regionText(geocaching::ByteView value) { return {reinterpret_cast(value.data), value.size}; } +inline bool regionReadHeader(geocaching::protocol::CmpWriter& writer, uint8_t operation, geocaching::ByteView request, uint16_t budget, uint8_t fields) +{ + return operation >= 9 && operation <= 13 && networkRequestId(request) && budget >= 512 && budget <= 8192 && + writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && writer.unsignedInteger(operation) && + writer.binary(request) && writer.unsignedInteger(budget) && writer.array(fields); +} +inline bool regionInputs(geocaching::ByteView request, geocaching::ByteView version, geocaching::ByteView id, + uint8_t* output, size_t capacity) +{ + return regionVersion(version) && regionOptionalId(id) && networkInputDisjoint(request, output, capacity) && + networkInputDisjoint(version, output, capacity) && readOptionalDisjoint(id, output, capacity); +} +inline bool encodeRegionCapabilities(geocaching::ByteView request, uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!networkInputDisjoint(request, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!regionReadHeader(writer, 9, request, budget, 0)) return false; + written = writer.size(); + return writer.good(); +} +inline bool encodeRegionChildren(geocaching::ByteView request, geocaching::ByteView version, geocaching::ByteView parent, + geocaching::ByteView after, uint8_t limit, uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!regionInputs(request, version, parent, output, capacity) || !regionOptionalId(after) || !readOptionalDisjoint(after, output, capacity) || !limit || limit > 32) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!regionReadHeader(writer, 10, request, budget, 4) || !writer.binary(version) || + !(parent.size ? writer.text(regionText(parent)) : writer.nil()) || !(after.size ? writer.text(regionText(after)) : writer.nil()) || !writer.unsignedInteger(limit)) return false; + written = writer.size(); + return writer.good(); +} +inline bool encodeRegionDescriptor(geocaching::ByteView request, geocaching::ByteView id, geocaching::ByteView version, + uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!regionId(id) || !regionInputs(request, version, id, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!regionReadHeader(writer, 11, request, budget, 2) || !writer.text(regionText(id)) || !writer.binary(version)) return false; + written = writer.size(); + return writer.good(); +} +inline bool encodeRegionBoundaryChunk(geocaching::ByteView request, geocaching::ByteView id, geocaching::ByteView version, + uint32_t offset, uint16_t length, uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!regionId(id) || !regionInputs(request, version, id, output, capacity) || !length || length > 1024 || offset >= 8388608 || length > 8388608 - offset) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!regionReadHeader(writer, 12, request, budget, 4) || !writer.text(regionText(id)) || !writer.binary(version) || + !writer.unsignedInteger(offset) || !writer.unsignedInteger(length)) return false; + written = writer.size(); + return writer.good(); +} +inline bool regionQueryFilters(const ReadQuery& query) +{ + return query.kind >= -1 && query.kind <= 1 && query.state >= -1 && query.state <= 1 && (!query.author.size || readSpan(query.author, 64)) && + query.limit && query.limit <= 32 && query.snapshot <= INT64_MAX && + (!query.has_cursor || (query.has_snapshot && readSpan(query.cursor_object, 32) && readSpan(query.cursor_binding, 32))); +} +inline bool regionQueryInputs(const ReadQuery& query, uint8_t* output, size_t capacity) +{ + return regionQueryFilters(query) && networkInputDisjoint({reinterpret_cast(&query), sizeof(query)}, output, capacity) && + readOptionalDisjoint(query.author, output, capacity) && readOptionalDisjoint(query.cursor_object, output, capacity) && readOptionalDisjoint(query.cursor_binding, output, capacity); +} +inline bool encodeRegionQuery(geocaching::ByteView request, geocaching::ByteView id, geocaching::ByteView version, const ReadQuery& query, + uint16_t budget, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!regionId(id) || !regionInputs(request, version, id, output, capacity) || !regionQueryInputs(query, output, capacity)) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!regionReadHeader(writer, 13, request, budget, 9) || !writer.text(regionText(id)) || !writer.binary(version) || !writer.text("intersects") || + !(query.kind < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.kind))) || + !(query.author.size ? writer.binary(query.author) : writer.nil()) || !(query.state < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.state))) || + !writer.unsignedInteger(query.limit) || !(query.has_snapshot ? writer.unsignedInteger(query.snapshot) : writer.nil())) return false; + if (query.has_cursor) + { + if (!writer.array(2) || !writer.binary(query.cursor_object) || !writer.binary(query.cursor_binding)) return false; + } + else if (!writer.nil()) return false; + written = writer.size(); + return writer.good(); +} +template +bool deriveRegionQueryBinding(geocaching::ByteView id, geocaching::ByteView version, const ReadQuery& query, + Hash& hash, uint8_t* workspace, size_t capacity, uint8_t output[32]) +{ + if (!regionId(id) || !regionVersion(version) || !regionQueryInputs(query, workspace, capacity) || + !networkInputDisjoint(id, workspace, capacity) || !networkInputDisjoint(version, workspace, capacity) || + !networkInputDisjoint(id, output, 32) || !networkInputDisjoint(version, output, 32) || + !regionQueryInputs(query, output, 32) || !networkInputDisjoint({output, 32}, workspace, capacity)) return false; + geocaching::protocol::CmpWriter writer(workspace, capacity); + if (!writer.array(6) || !writer.text(regionText(id)) || !writer.binary(version) || !writer.text("intersects") || + !(query.kind < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.kind))) || + !(query.author.size ? writer.binary(query.author) : writer.nil()) || !(query.state < 0 ? writer.nil() : writer.unsignedInteger(uint8_t(query.state)))) return false; + const bool ok = hash.start() && hash.update(workspace, writer.size()) && hash.finish(output); + hash.reset(); + if (!ok) std::memset(output, 0, 32); + return ok; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/region_read_response.h b/modules/core_track_sharing/include/track_sharing/region_read_response.h new file mode 100644 index 00000000..b7cca1e2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/region_read_response.h @@ -0,0 +1,242 @@ +#pragma once +#include "geocaching/protocol/record_decoder.h" +#include "track_sharing/network_upload_response.h" +#include "track_sharing/region_read_request.h" +#include "track_sharing/network_read_response.h" +#include "track_sharing/read_query_response.h" + +namespace track_sharing +{ +struct RegionCapabilitiesView +{ + std::string_view name_space; + geocaching::ByteView version; + uint16_t chunk_bytes = 0; + uint8_t depth = 0, page_limit = 0; +}; +struct RegionNodeView +{ + std::string_view id, parent; + int32_t west = 0, south = 0, east = 0, north = 0; + uint16_t children = 0; + uint8_t level = 0; +}; +struct RegionChildrenView +{ + std::string_view cursor; + uint8_t rows = 0; +}; +struct RegionDescriptorView +{ + RegionNodeView node; + geocaching::ByteView digest; + uint32_t bytes = 0; +}; +struct RegionReadScope +{ + std::string_view name_space, id, parent, after; + geocaching::ByteView version; + int8_t parent_level = -1; + uint8_t depth = 0; +}; +struct RegionReplyWorkspace +{ + RegionCapabilitiesView capabilities; + RegionNodeView row; + RegionChildrenView page; + RegionDescriptorView descriptor; +}; +static_assert(sizeof(RegionReadScope) <= 96 && sizeof(RegionReplyWorkspace) <= 256, + "Owner must admit regional metadata in transient PSRAM"); +static_assert(sizeof(RegionCapabilitiesView) <= 48 && sizeof(RegionNodeView) <= 80 && sizeof(RegionChildrenView) <= 32 && sizeof(RegionDescriptorView) <= 112, + "Region decoders retain borrowed metadata, never a page or geometry"); +inline geocaching::ByteView regionBytes(std::string_view value) { return {reinterpret_cast(value.data()), value.size()}; } +inline bool regionNamespace(std::string_view value) +{ + if (value.empty() || value.size() > 24) return false; + for (size_t i = 0; i < value.size(); ++i) + { + const auto c = value[i]; + if (i ? !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-') : !(c >= 'a' && c <= 'z')) return false; + } + return true; +} +inline bool regionInNamespace(std::string_view id, std::string_view name_space) +{ + return regionId(regionBytes(id)) && id.size() > name_space.size() && id.substr(0, name_space.size()) == name_space && id[name_space.size()] == ':'; +} +inline bool regionLocale(std::string_view value) +{ + size_t start = 0, segments = 0; + for (size_t i = 0; i <= value.size(); ++i) + { + if (i == value.size() || value[i] == '-') + { + const size_t length = i - start; + if (!segments ? length < 2 || length > 3 : length < 2 || length > 8) return false; + if (++segments > 3) return false; + start = i + 1; + } + else + { + const char c = value[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (segments && c >= '0' && c <= '9'))) return false; + } + } + return true; +} +inline bool regionReplyInput(geocaching::ByteView raw, geocaching::ByteView request, void* output, size_t size) +{ + return raw.data && raw.size && raw.size <= 8192 && networkRequestId(request) && + networkInputDisjoint(raw, static_cast(output), size) && networkInputDisjoint(request, static_cast(output), size); +} +inline bool decodeRegionCapabilities(geocaching::ByteView raw, geocaching::ByteView request, RegionCapabilitiesView& output) +{ + if (!regionReplyInput(raw, request, &output, sizeof(output))) return false; + output = {}; + geocaching::protocol::CmpReader reader(raw); + uint64_t value = 0; + std::string_view type; + size_t count = 0; + if (!uploadResponseHeader(reader, 9, request, 8) || !reader.unsignedInteger(value) || value != 1 || + !reader.text(type, 32) || type != "trailmate.track.regions.v1" || !reader.text(output.name_space, 24) || !regionNamespace(output.name_space) || + !reader.binary(output.version, 32) || !regionVersion(output.version) || !reader.unsignedInteger(value) || value > 8) return false; + output.depth = uint8_t(value); + if (!reader.unsignedInteger(value) || !value || value > 32) return false; + output.page_limit = uint8_t(value); + if (!reader.unsignedInteger(value) || !value || value > 1024) return false; + output.chunk_bytes = uint16_t(value); + if (!reader.array(count, 5) || count != 5) return false; + uint16_t operations = 0; + for (size_t i = 0; i < count; ++i) + { + if (!reader.unsignedInteger(value) || value < 9 || value > 13 || (operations & (1U << value))) return false; + operations |= uint16_t(1U << value); + } + return operations == 0x3e00 && reader.finished(); +} +inline bool decodeRegionOptionalId(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, std::string_view& output) +{ + if (reader.position() < raw.size && raw.data[reader.position()] == 0xc0) + { + output = {}; + return reader.nil(); + } + return reader.text(output, 89) && regionId(regionBytes(output)); +} +inline bool decodeRegionNode(geocaching::protocol::CmpReader& reader, geocaching::ByteView raw, std::string_view name_space, + uint8_t depth, RegionNodeView& output, uint16_t* elements = nullptr) +{ + if (!regionNamespace(name_space) || depth > 8 || !networkInputDisjoint(raw, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(regionBytes(name_space),reinterpret_cast(&output),sizeof(output))) return false; + output = {}; + size_t fields = 0, names = 0; + uint64_t value = 0; + std::string_view name; + if (!reader.array(fields, 7) || fields != 7 || !reader.text(output.id, 89) || !regionInNamespace(output.id, name_space) || + !decodeRegionOptionalId(reader, raw, output.parent) || (!output.parent.empty() && !regionInNamespace(output.parent, name_space)) || output.id == output.parent || + !reader.unsignedInteger(value) || value > depth || (output.parent.empty() != (value == 0))) return false; + output.level = uint8_t(value); + if (!reader.text(name, 128) || !geocaching::protocol::validRecordText(name, false, true) || !reader.array(names, 32)) return false; + if (elements) + { + const size_t count = 11 + 3 * names; + if (*elements > 2048 || count > size_t(2048 - *elements)) return false; + *elements += uint16_t(count); + } + const size_t names_start = reader.position(); + for (size_t i = 0; i < names; ++i) + { + const size_t prefix_end = reader.position(); + std::string_view locale; + if (!reader.array(fields, 2) || fields != 2 || !reader.text(locale, 21) || !regionLocale(locale) || + !reader.text(name, 128) || !geocaching::protocol::validRecordText(name, false, true)) return false; + // Re-scan the bounded prior translation prefix rather than retain an + // array of locale spans on the ESP stack or in permanent storage. + geocaching::protocol::CmpReader previous({raw.data + names_start, prefix_end - names_start}); + for (size_t j = 0; j < i; ++j) + { + std::string_view old_locale, old_name; + if (!previous.array(fields, 2) || fields != 2 || !previous.text(old_locale, 21) || !previous.text(old_name, 128) || old_locale == locale) return false; + } + if (!previous.finished()) return false; + } + if (!reader.array(fields, 4) || fields != 4) return false; + int64_t coordinate = 0; + for (auto* target : {&output.west, &output.south, &output.east, &output.north}) + { + if (!reader.signedInteger(coordinate) || coordinate < INT32_MIN || coordinate > INT32_MAX) return false; + *target = int32_t(coordinate); + } + if (output.west < -1800000000 || output.west > 1799999999 || output.east < -1800000000 || output.east > 1799999999 || + output.south < -900000000 || output.north > 900000000 || output.south > output.north || + !reader.unsignedInteger(value) || value > 10000 || (output.level == depth && value)) return false; + output.children = uint16_t(value); + return true; +} +inline bool decodeRegionChildren(geocaching::ByteView raw, geocaching::ByteView request, geocaching::ByteView version, + std::string_view name_space, std::string_view parent, std::string_view after, + int8_t parent_level, uint8_t depth, uint8_t limit, RegionNodeView& row, RegionChildrenView& output) +{ + if (!regionReplyInput(raw, request, &output, sizeof(output)) || !regionReplyInput(raw, request, &row, sizeof(row)) || !regionVersion(version) || + !regionNamespace(name_space) || depth > 8 || !limit || limit > 32 || + (parent.empty() ? parent_level != -1 : parent_level < 0 || parent_level > depth || !regionInNamespace(parent, name_space)) || + (!after.empty() && !regionInNamespace(after, name_space)) || + !networkInputDisjoint(version, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(version, reinterpret_cast(&row), sizeof(row)) || + !networkInputDisjoint({reinterpret_cast(&row),sizeof(row)}, reinterpret_cast(&output), sizeof(output))) return false; + for (const auto value : {name_space,parent,after}) + if (!readOptionalDisjoint(regionBytes(value),reinterpret_cast(&row),sizeof(row)) || + !readOptionalDisjoint(regionBytes(value),reinterpret_cast(&output),sizeof(output))) return false; + output = {}; + geocaching::protocol::CmpReader reader(raw); + geocaching::ByteView received; + std::string_view previous = after; + size_t rows = 0; + if (!uploadResponseHeader(reader, 10, request, 3) || !reader.binary(received, 32) || received.size != 32 || std::memcmp(received.data, version.data, 32) || + !decodeRegionOptionalId(reader, raw, output.cursor) || !reader.array(rows, limit)) return false; + uint16_t elements = uint16_t(9 + rows); + for (size_t i = 0; i < rows; ++i) + { + if (!decodeRegionNode(reader, raw, name_space, depth, row, &elements) || row.parent != parent || row.level != parent_level + 1 || + (!previous.empty() && row.id <= previous)) return false; + previous = row.id; + } + if ((!output.cursor.empty() && (!rows || output.cursor != previous || !regionInNamespace(output.cursor, name_space))) || !reader.finished()) return false; + output.rows = uint8_t(rows); + return true; +} +inline bool decodeRegionDescriptor(geocaching::ByteView raw, geocaching::ByteView request, std::string_view id, geocaching::ByteView version, + std::string_view name_space, uint8_t depth, RegionDescriptorView& output) +{ + if (!regionReplyInput(raw, request, &output, sizeof(output)) || !regionVersion(version) || !regionInNamespace(id, name_space) || + !networkInputDisjoint(version, reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(regionBytes(id), reinterpret_cast(&output), sizeof(output)) || + !networkInputDisjoint(regionBytes(name_space), reinterpret_cast(&output), sizeof(output))) return false; + output = {}; + geocaching::protocol::CmpReader reader(raw); + geocaching::ByteView received; + std::string_view type; + size_t fields = 0; + uint64_t bytes = 0; + if (!uploadResponseHeader(reader, 11, request, 3) || !reader.binary(received, 32) || received.size != 32 || std::memcmp(received.data, version.data, 32) || + !decodeRegionNode(reader, raw, name_space, depth, output.node) || output.node.id != id || + !reader.array(fields, 3) || fields != 3 || !reader.text(type, 24) || type != "region-e7-json-v1" || + !reader.binary(output.digest, 32) || output.digest.size != 32 || !reader.unsignedInteger(bytes) || !bytes || bytes > 8388608 || !reader.finished()) return false; + output.bytes = uint32_t(bytes); + return true; +} +inline bool decodeRegionBoundaryResponse(geocaching::ByteView raw, geocaching::ByteView request, geocaching::ByteView digest, + uint32_t bytes, uint32_t offset, uint16_t length, ReadFileResponse& output) +{ + return decodeTrackReadFileResponse(raw,request,digest,bytes,offset,length,output,12); +} +// The caller owns the immutable region ID/version scope and derives binding +// with deriveRegionQueryBinding. The box is a prefilter only; exact geometry is +// a directory claim, not an author signature or a device-side GIS computation. +inline bool decodeRegionQueryResponse(geocaching::ByteView raw, geocaching::ByteView request, const ReadQuery& query, + geocaching::ByteView binding, ReadQueryRow& row, ReadQueryResponse& output) +{ + return decodeTrackReadQueryResponse(raw,request,query,binding,row,output,13); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/request_retry_window.h b/modules/core_track_sharing/include/track_sharing/request_retry_window.h new file mode 100644 index 00000000..d8ab5302 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/request_retry_window.h @@ -0,0 +1,127 @@ +#pragma once +#include "track_sharing/network_request.h" + +namespace track_sharing +{ +enum class ExchangePoll : uint8_t +{ + Ready, + Waiting, + Completed, + Exhausted, + Paused, + Invalid +}; +// One in-flight network operation, metadata only. Caller retains immutable wire +// inputs and exclusive receiver ownership. Rebuild retries with request(), never +// generate a replacement ID while the same exchange is unresolved. Receiver +// clears pending only after authenticated durable acceptance (or this window's +// timeout/pause cancellation). A successful send is not an acknowledgement. +class RequestRetryWindow +{ + public: + bool begin(geocaching::ByteView request, uint64_t now, uint32_t response_ms, uint32_t backoff_ms, uint16_t attempts) + { + if (active_ || !networkRequestId(request) || !response_ms || !backoff_ms || !attempts || + !networkInputDisjoint(request, reinterpret_cast(this), sizeof(*this))) return false; + std::memcpy(request_, request.data, 16); + deadline_ = last_ = now; + response_ms_ = response_ms; + backoff_ms_ = backoff_ms; + attempts_ = 0; + limit_ = attempts; + active_ = true; + queued_ = paused_ = ready_ = false; + return true; + } + geocaching::ByteView request() const { return {request_, active_ ? 16U : 0U}; } + uint16_t attempts() const { return attempts_; } + template + ExchangePoll poll(Receiver& receiver, uint64_t now) + { + if (!active_ || now < last_) return ExchangePoll::Invalid; + last_ = now; + if (paused_) return ExchangePoll::Paused; + if (queued_) + { + if (!receiver.pending()) + { + active_ = ready_ = false; + return ExchangePoll::Completed; + } + if (now < deadline_) return ExchangePoll::Waiting; + receiver.cancelPending(); + queued_ = false; + deadline_ = after(now, backoff_ms_); + } + if (attempts_ >= limit_) + { + ready_ = false; + return ExchangePoll::Exhausted; + } + if (now < deadline_) return ExchangePoll::Waiting; + ready_ = true; + return ExchangePoll::Ready; + } + bool sent(bool accepted, uint64_t now) + { + if (!active_ || paused_ || !ready_ || now < last_ || attempts_ >= limit_) return false; + last_ = now; + ++attempts_; + ready_ = false; + queued_ = accepted; + deadline_ = after(now, accepted ? response_ms_ : backoff_ms_); + return true; + } + template + void pause(Receiver& receiver) + { + if (!active_) return; + // The durable acknowledgement may arrive before the next scheduler + // poll. Retire that exchange before pausing; its ID must not be reused + // for the next chunk or protocol operation after resume. + if (queued_ && !receiver.pending()) + { + active_ = queued_ = ready_ = paused_ = false; + return; + } + if (queued_) receiver.cancelPending(); + queued_ = ready_ = false; + paused_ = true; + } + // Runtime staged a packet under the job lock, but actual dispatch outside + // that lock was rejected. Keep the exact exchange ID and attempt count. + template + bool dispatchRejected(Receiver& receiver, uint64_t now) + { + if (!active_ || !queued_ || paused_ || now < last_) return false; + last_ = now; + if (!receiver.pending()) + { + active_ = queued_ = ready_ = false; + return true; + } + receiver.cancelPending(); + queued_ = ready_ = false; + deadline_ = after(now,backoff_ms_); + return true; + } + bool resume(uint64_t now) + { + if (!active_ || queued_ || now < last_ || (!paused_ && attempts_ < limit_)) return false; + last_ = deadline_ = now; + attempts_ = 0; + paused_ = ready_ = false; + return true; + } + + private: + static uint64_t after(uint64_t now, uint32_t period) { return UINT64_MAX - now < period ? UINT64_MAX : now + period; } + uint8_t request_[16]{}; + uint64_t deadline_ = 0, last_ = 0; + uint32_t response_ms_ = 0, backoff_ms_ = 0; + uint16_t attempts_ = 0, limit_ = 0; + bool active_ = false, queued_ = false, paused_ = false, ready_ = false; +}; +static_assert(sizeof(RequestRetryWindow) <= 56, "Retry window must not retain wire payloads"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/reservation_candidate.h b/modules/core_track_sharing/include/track_sharing/reservation_candidate.h new file mode 100644 index 00000000..1dc9ccc1 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/reservation_candidate.h @@ -0,0 +1,25 @@ +#pragma once +#include "track_sharing/manifest_hash.h" +#include "track_sharing/revision_reservation.h" +namespace track_sharing +{ +// Binds immutable candidate bytes to a local reservation, not file/parent +// authenticity or authorization. Identity fields must be from one device read. +template +bool reservationMatchesCandidate(geocaching::ByteView raw, geocaching::ByteView device_author, uint32_t generation, + geocaching::ByteView author_hash, const RevisionReservation& reservation, + Hash& hash, ManifestView& view, uint8_t* scratch, size_t capacity) +{ + if (!scratch || capacity < 64 || !device_author.data || device_author.size != 64 || !author_hash.data || author_hash.size != 32 || + !reservationValid(reservation) || (reservation.phase != ReservationPhase::Reserved && reservation.phase != ReservationPhase::Checked) || + generation != reservation.generation || std::memcmp(author_hash.data, reservation.author, 32) || + signingSpansOverlap(raw.data, raw.size, reinterpret_cast(&view), sizeof(view)) || + signingSpansOverlap(scratch, 64, reinterpret_cast(&reservation), sizeof(reservation)) || + signingSpansOverlap(scratch, 64, reinterpret_cast(&view), sizeof(view)) || + signingSpansOverlap(scratch, 64, device_author.data, 64) || signingSpansOverlap(scratch, 64, author_hash.data, 32)) return false; + if (!deriveManifestHashes(raw, view, hash, scratch, scratch + 32)) return false; + return view.revision == reservation.revision && !std::memcmp(view.author.data, device_author.data, 64) && + !std::memcmp(scratch, reservation.object, 32) && !std::memcmp(scratch + 32, reservation.candidate, 32) && + (view.revision == 1 ? !view.previous.size : view.previous.size == 32 && !std::memcmp(view.previous.data, reservation.parent, 32)); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/reservation_store.h b/modules/core_track_sharing/include/track_sharing/reservation_store.h new file mode 100644 index 00000000..cf5fff01 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/reservation_store.h @@ -0,0 +1,136 @@ +#pragma once +#include "track_sharing/revision_reservation.h" +namespace track_sharing +{ +enum class ReservationStoreResult : uint8_t +{ + Ready, + Missing, + Corrupt, + StorageFailure, + Conflict, + Invalid +}; +// Exclusive lease and canonical distinct paths are caller-owned. Two borrowed +// records/scratch only; phase proof and current-head arbitration remain external. +template +class ReservationStore +{ + public: + using Result = ReservationStoreResult; + ReservationStore(Io& io, const char* a, const char* b) : io_(io), a_(a), b_(b) {} + Result load(RevisionReservation& current, RevisionReservation& other, uint8_t* scratch, size_t capacity) + { + if (!inputs(current, other, scratch, capacity)) return Result::Invalid; + const auto first = read(a_, current, scratch), second = read(b_, other, scratch); + if (first == Result::StorageFailure || second == Result::StorageFailure) + { + current = {}; + return Result::StorageFailure; + } + const bool good_a = first == Result::Ready && (current.sequence & 1), good_b = second == Result::Ready && !(other.sequence & 1); + if (good_a && good_b) + { + const auto& newer = current.sequence > other.sequence ? current : other; + const auto& older = current.sequence > other.sequence ? other : current; + if (!reservationAdvanceValid(older, newer)) + { + current = {}; + return Result::Conflict; + } + if (other.sequence > current.sequence) current = other; + return Result::Ready; + } + if (good_b) + { + current = other; + return Result::Ready; + } + if (good_a) return Result::Ready; + current = {}; + return first == Result::Missing && second == Result::Missing ? Result::Missing : Result::Corrupt; + } + Result initialize(const RevisionReservation& initial, RevisionReservation& verify, uint8_t* scratch, size_t capacity) + { + if (!inputs(initial, verify, scratch, capacity) || !reservationValid(initial) || initial.sequence != 1 || initial.phase != ReservationPhase::Reserved) return Result::Invalid; + const auto first = read(a_, verify, scratch); + if (first == Result::StorageFailure) return first; + if (first != Result::Missing) return Result::Conflict; + const auto second = read(b_, verify, scratch); + if (second == Result::StorageFailure) return second; + if (second != Result::Missing) return Result::Conflict; + if (!encodeReservation(initial, scratch, capacity)) return Result::Invalid; + const auto result = persist(1, verify, scratch); + return result == Result::Ready && !same(initial, verify) ? Result::StorageFailure : result; + } + Result validateCurrent(const RevisionReservation& current, RevisionReservation& verify, uint8_t* scratch, size_t capacity) + { + if (!inputs(current, verify, scratch, capacity) || !reservationValid(current)) return Result::Invalid; + const auto existing = read(current.sequence & 1 ? a_ : b_, verify, scratch); + if (existing == Result::StorageFailure) return existing; + if (existing != Result::Ready || !same(current, verify)) return Result::Conflict; + const auto other = read(current.sequence & 1 ? b_ : a_, verify, scratch); + if (other == Result::StorageFailure) return other; + return other == Result::Ready && (!binding(current, verify) || verify.sequence >= current.sequence) ? Result::Conflict : Result::Ready; + } + Result advance(const RevisionReservation& current, ReservationPhase phase, RevisionReservation& verify, uint8_t* scratch, size_t capacity) + { + if (!inputs(current, verify, scratch, capacity) || !reservationValid(current) || current.sequence == UINT64_MAX) return Result::Invalid; + verify = current; + verify.sequence++; + verify.phase = phase; + if (!reservationAdvanceValid(current, verify)) return Result::Conflict; + const auto checked = validateCurrent(current, verify, scratch, capacity); + if (checked != Result::Ready) return checked; + verify = current; + verify.sequence++; + verify.phase = phase; + if (!encodeReservation(verify, scratch, capacity)) return Result::Invalid; + const auto result = persist(current.sequence + 1, verify, scratch); + if (result != Result::Ready) return result; + return verify.phase == phase && reservationAdvanceValid(current, verify) ? Result::Ready : Result::StorageFailure; + } + + private: + bool inputs(const RevisionReservation& a, const RevisionReservation& b, uint8_t* scratch, size_t capacity) const + { + if (&a == &b || !scratch || capacity < kReservationBytes || !a_ || !b_ || !*a_ || !*b_) return false; + for (size_t i = 0;; ++i) + { + const auto lower = [](char c) + { return c >= 'A' && c <= 'Z' ? char(c + 32) : c; }; + if (lower(a_[i]) != lower(b_[i])) return true; + if (!a_[i]) return false; + } + } + static bool binding(const RevisionReservation& a, const RevisionReservation& b) { return a.generation == b.generation && a.revision == b.revision && !std::memcmp(a.operation, b.operation, 16) && !std::memcmp(a.object, b.object, 32) && !std::memcmp(a.author, b.author, 32) && !std::memcmp(a.parent, b.parent, 32) && !std::memcmp(a.candidate, b.candidate, 32); } + static bool same(const RevisionReservation& a, const RevisionReservation& b) { return binding(a, b) && a.sequence == b.sequence && a.phase == b.phase; } + Result read(const char* path, RevisionReservation& output, uint8_t* scratch) + { + output = {}; + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? Result::Missing : Result::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool read = sized && size == kReservationBytes && io_.seek(opened.handle, 0) && io_.read(opened.handle, scratch, kReservationBytes) == kReservationBytes; + const bool closed = io_.close(opened.handle); + if (!sized || !closed) return Result::StorageFailure; + if (size != kReservationBytes) return Result::Corrupt; + if (!read) return Result::StorageFailure; + return decodeReservation(scratch, kReservationBytes, output) ? Result::Ready : Result::Corrupt; + } + Result persist(uint64_t sequence, RevisionReservation& verify, uint8_t* scratch) + { + const char* path = sequence & 1 ? a_ : b_; + const auto opened = io_.open(path, Io::Mode::CreateTemporary); + if (!opened.handle) return Result::StorageFailure; + const bool written = io_.write(opened.handle, scratch, kReservationBytes) == kReservationBytes && io_.sync(opened.handle), closed = io_.close(opened.handle); + if (!written || !closed) return Result::StorageFailure; + const auto result = read(path, verify, scratch); + return result == Result::Ready && verify.sequence == sequence ? Result::Ready : Result::StorageFailure; + } + Io& io_; + const char* a_; + const char* b_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/restore_ip_publication_head.h b/modules/core_track_sharing/include/track_sharing/restore_ip_publication_head.h new file mode 100644 index 00000000..ef710886 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/restore_ip_publication_head.h @@ -0,0 +1,94 @@ +#pragma once +#include "track_sharing/confirm_ip_publication_head.h" +namespace track_sharing +{ +// Recover the same author's confirmed asset on a replacement device. Immutable +// signed manifest and directory receipt must both persist BEFORE head creation. +// Only the exact verified signed head may be completed; different heads and +// corrupt journals never authorize recovery. +template +HeadStoreResult restoreIpPublicationHead(Heads& heads, Blobs& blobs, Receipts& receipts, + const VerifiedManifestView& manifest, const VerifiedReceiptView& proof, + const PublicationJournal& expected, geocaching::ByteView request, + geocaching::ByteView directory_public, uint32_t generation, + geocaching::ByteView author, geocaching::ByteView author_hash, + ObjectHead& current, ObjectHead& next, ObjectHead& verify, + uint8_t* scratch, size_t capacity) +{ + if (!scratch || capacity < kObjectHeadBytes) return HeadStoreResult::Invalid; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&manifest), sizeof(manifest)}, + {reinterpret_cast(&proof), sizeof(proof)}, + {reinterpret_cast(&expected), sizeof(expected)}, + manifest.manifest.raw, + manifest.manifest.author, + manifest.manifest.previous, + manifest.manifest.file_hash, + manifest.signature, + proof.receipt.raw, + proof.signature, + request, + directory_public, + author, + author_hash}) + for (const auto output : {geocaching::ByteView{reinterpret_cast(¤t), sizeof(current)}, + {reinterpret_cast(&next), sizeof(next)}, + {reinterpret_cast(&verify), sizeof(verify)}, + {scratch, capacity}}) + if (signingSpansOverlap(input.data, input.size, output.data, output.size)) return HeadStoreResult::Invalid; + if (¤t == &next || ¤t == &verify || &next == &verify || !generation || expected.generation != generation || + !manifest.verified || !proof.verified || !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || + !manifest.manifest.author.data || manifest.manifest.author.size != 64 || std::memcmp(author.data, manifest.manifest.author.data, 64) || + !manifest.manifest.raw.data || !manifest.manifest.raw.size || !manifest.signature.data || manifest.signature.size != 64 || + !proof.receipt.raw.data || !proof.receipt.raw.size || !proof.signature.data || proof.signature.size != 64 || + !manifest.manifest.file_hash.data || manifest.manifest.file_hash.size != 32 || + !receiptMatchesJob(proof.receipt, expected, request, directory_public, manifest.manifest.state) || + expected.revision != manifest.manifest.revision || expected.file_bytes != manifest.manifest.file_bytes || + std::memcmp(expected.author_hash, author_hash.data, 32) || std::memcmp(expected.object, manifest.object_id, 32) || + std::memcmp(expected.revision_hash, manifest.revision_hash, 32) || std::memcmp(expected.file_hash, manifest.manifest.file_hash.data, 32)) + return HeadStoreResult::Invalid; + next = {}; + std::memcpy(next.object, manifest.object_id, 32); + std::memcpy(next.hash, manifest.revision_hash, 32); + std::memcpy(next.operation, expected.operation, 16); + if (manifest.manifest.revision > 1) + { + if (!manifest.manifest.previous.data || manifest.manifest.previous.size != 32) return HeadStoreResult::Invalid; + std::memcpy(next.parent, manifest.manifest.previous.data, 32); + } + next.sequence = 1; + next.generation = generation; + next.revision = manifest.manifest.revision; + next.phase = HeadPhase::Committed; + next.archived = manifest.manifest.state == 1; + if (!objectHeadValid(next)) return HeadStoreResult::Invalid; + const auto loaded = heads.load(current, verify, scratch, capacity); + if (loaded != HeadStoreResult::Ready && loaded != HeadStoreResult::Missing) return loaded; + if (loaded == HeadStoreResult::Ready && (current.revision != next.revision || + current.archived != next.archived || std::memcmp(current.object, next.object, 32) || std::memcmp(current.hash, next.hash, 32) || + std::memcmp(current.parent, next.parent, 32))) return HeadStoreResult::Conflict; + const auto stored = blobs.persist(manifest.manifest.raw, manifest.signature, scratch, capacity); + if (stored != SignedStoreResult::Ready) return stored == SignedStoreResult::StorageFailure ? HeadStoreResult::StorageFailure : HeadStoreResult::Conflict; + const auto receipt = receipts.persist(proof.receipt.raw, proof.signature, scratch, capacity); + if (receipt != SignedStoreResult::Ready) return receipt == SignedStoreResult::StorageFailure ? HeadStoreResult::StorageFailure : HeadStoreResult::Conflict; + if (loaded == HeadStoreResult::Ready) + { + if (current.phase == HeadPhase::Signed) + { + // Preserve the original signing operation and generation. Persist + // the ordinary commit first; a retry after interruption can then + // adopt that verified committed head at the current generation. + if (current.sequence == UINT64_MAX) return HeadStoreResult::Invalid; + next = current; + ++next.sequence; + next.phase = HeadPhase::Committed; + const auto committed = heads.advance(current, next, verify, scratch, capacity); + if (committed != HeadStoreResult::Ready) return committed; + current = next; + } + return heads.rebindCommittedGeneration(current, verify, generation, scratch, capacity); + } + const auto initialized = heads.initializeCommitted(next, verify, scratch, capacity); + if (initialized == HeadStoreResult::Ready) current = next; + return initialized; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/restore_prepared_signature.h b/modules/core_track_sharing/include/track_sharing/restore_prepared_signature.h new file mode 100644 index 00000000..e6cb085e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/restore_prepared_signature.h @@ -0,0 +1,41 @@ +#pragma once +#include "track_sharing/prepared_signature.h" +#include "track_sharing/verify_manifest.h" +namespace track_sharing +{ +// Caller holds immutable verified blob/file/current-parent lease. No signer +// participates: recovery validates durable evidence before returning signature. +template +PreparedSignatureResult restorePreparedSignature(Check& check, const VerifiedManifestView& signed_view, + geocaching::ByteView author, uint32_t generation, geocaching::ByteView author_hash, + RevisionReservation& current, RevisionReservation& verify, JournalStore& journal, + uint8_t* scratch, size_t capacity, uint8_t* signature, size_t signature_capacity) +{ + using Result = PreparedSignatureResult; + if (!scratch || capacity < kReservationBytes || !signature || signature_capacity < 64 || !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || !generation || ¤t == &verify || signingSpansOverlap(scratch, capacity, signature, 64)) return Result::Invalid; + const auto overlaps = [&](const uint8_t* source, size_t size) + { return signingSpansOverlap(scratch, capacity, source, size) || signingSpansOverlap(signature, 64, source, size); }; + if (overlaps(signed_view.manifest.raw.data, signed_view.manifest.raw.size) || overlaps(signed_view.signature.data, signed_view.signature.size) || + overlaps(author.data, author.size) || overlaps(author_hash.data, author_hash.size) || overlaps(reinterpret_cast(&signed_view), sizeof(signed_view)) || + overlaps(reinterpret_cast(¤t), sizeof(current)) || overlaps(reinterpret_cast(&verify), sizeof(verify)) || + overlaps(reinterpret_cast(&check), sizeof(check)) || overlaps(reinterpret_cast(&journal), sizeof(journal))) return Result::Invalid; + std::memset(signature, 0, 64); + if (!check.ready()) return Result::NotChecked; + if (!reservationValid(current) || (current.phase != ReservationPhase::Checked && current.phase != ReservationPhase::Signed) || !signed_view.verified || !signed_view.signature.data || signed_view.signature.size != 64) return Result::Conflict; + if (current.generation != generation || std::memcmp(current.author, author_hash.data, 32)) return Result::StaleIdentity; + const auto& manifest = signed_view.manifest; + if (!manifest.author.data || manifest.author.size != 64 || std::memcmp(manifest.author.data, author.data, 64) || manifest.revision != current.revision || + std::memcmp(signed_view.object_id, current.object, 32) || std::memcmp(signed_view.revision_hash, current.candidate, 32) || !check.matchesCandidate(manifest) || + (manifest.revision > 1 && (!manifest.previous.data || manifest.previous.size != 32 || std::memcmp(manifest.previous.data, current.parent, 32)))) return Result::Conflict; + const auto selected = journal.validateCurrent(current, verify, scratch, capacity); + if (selected != ReservationStoreResult::Ready) return selected == ReservationStoreResult::Conflict ? Result::Conflict : Result::StorageFailure; + if (current.phase == ReservationPhase::Checked) + { + const auto advanced = journal.advance(current, ReservationPhase::Signed, verify, scratch, capacity); + if (advanced != ReservationStoreResult::Ready) return advanced == ReservationStoreResult::Conflict ? Result::Conflict : Result::StorageFailure; + current = verify; + } + std::memcpy(signature, signed_view.signature.data, 64); + return Result::Ready; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/revision_reservation.h b/modules/core_track_sharing/include/track_sharing/revision_reservation.h new file mode 100644 index 00000000..29c9675c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/revision_reservation.h @@ -0,0 +1,108 @@ +#pragma once +#include "track_sharing/upload_journal.h" +namespace track_sharing +{ +enum class ReservationPhase : uint8_t +{ + Reserved, + Checked, + Signed, + Committed, + Cancelled +}; +struct RevisionReservation +{ + uint8_t operation[16]{}, object[32]{}, author[32]{}, parent[32]{}, candidate[32]{}; + uint64_t sequence = 0; + uint32_t generation = 0, revision = 0; + ReservationPhase phase = ReservationPhase::Reserved; +}; +static_assert(sizeof(RevisionReservation) <= 176, "Reservation must not own manifest/file bytes"); +constexpr size_t kReservationBytes = 168; +inline bool reservationValid(const RevisionReservation& value) +{ + uint8_t operation = 0, object = 0, author = 0, parent = 0, candidate = 0; + for (auto byte : value.operation) operation |= byte; + for (unsigned i = 0; i < 32; ++i) + { + object |= value.object[i]; + author |= value.author[i]; + parent |= value.parent[i]; + candidate |= value.candidate[i]; + } + return operation && object && author && candidate && value.sequence && value.generation && value.revision && + (value.revision == 1 ? !parent : parent != 0) && static_cast(value.phase) <= 4; +} +// TMRV/v1/op16/object32/author-hash32/parent-revision-hash32/candidate-hash32/ +// Phase progression only. Durable evidence for Checked/Signed/Committed is +// established by the platform before persistence, never by this predicate. +inline bool reservationAdvanceValid(const RevisionReservation& current, const RevisionReservation& next) +{ + if (!reservationValid(current) || !reservationValid(next) || current.sequence == UINT64_MAX || next.sequence != current.sequence + 1 || + current.generation != next.generation || current.revision != next.revision || + std::memcmp(current.operation, next.operation, 16) || std::memcmp(current.object, next.object, 32) || + std::memcmp(current.author, next.author, 32) || std::memcmp(current.parent, next.parent, 32) || std::memcmp(current.candidate, next.candidate, 32)) return false; + if (current.phase == next.phase) return true; + if (current.phase == ReservationPhase::Reserved) return next.phase == ReservationPhase::Checked || next.phase == ReservationPhase::Cancelled; + if (current.phase == ReservationPhase::Checked) return next.phase == ReservationPhase::Signed || next.phase == ReservationPhase::Cancelled; + if (current.phase == ReservationPhase::Signed) return next.phase == ReservationPhase::Committed; + return false; +} +// TMRV/v1/op16/object32/author-hash32/parent-revision-hash32/candidate-hash32/ +// generationLE32/revisionLE32/phase8/sequenceLE64/CRC16. Caller supplies storage. +// CRC is not author proof, reservation arbitration, or phase completion proof. +inline bool encodeReservation(const RevisionReservation& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kReservationBytes || !reservationValid(value)) return false; + const auto a = reinterpret_cast(&value), b = reinterpret_cast(output); + if (a <= b ? b - a < sizeof(value) : a - b < kReservationBytes) return false; + std::memcpy(output, "TMRV", 4); + output[4] = 1; + std::memcpy(output + 5, value.operation, 16); + std::memcpy(output + 21, value.object, 32); + std::memcpy(output + 53, value.author, 32); + std::memcpy(output + 85, value.parent, 32); + std::memcpy(output + 117, value.candidate, 32); + for (unsigned i = 0; i < 4; ++i) + { + output[149 + i] = uint8_t(value.generation >> (8 * i)); + output[153 + i] = uint8_t(value.revision >> (8 * i)); + } + output[157] = static_cast(value.phase); + for (unsigned i = 0; i < 8; ++i) output[158 + i] = uint8_t(value.sequence >> (8 * i)); + const auto crc = uploadJournalCrc(output, 166); + output[166] = uint8_t(crc); + output[167] = uint8_t(crc >> 8); + return true; +} +inline bool decodeReservation(const uint8_t* input, size_t size, RevisionReservation& value) +{ + if (!input || size != kReservationBytes) + { + value = {}; + return false; + } + const auto a = reinterpret_cast(input), b = reinterpret_cast(&value); + if (a <= b ? b - a < size : a - b < sizeof(value)) return false; + value = {}; + if (std::memcmp(input, "TMRV", 4) || input[4] != 1 || uploadJournalCrc(input, 166) != (uint16_t(input[166]) | (uint16_t(input[167]) << 8))) return false; + std::memcpy(value.operation, input + 5, 16); + std::memcpy(value.object, input + 21, 32); + std::memcpy(value.author, input + 53, 32); + std::memcpy(value.parent, input + 85, 32); + std::memcpy(value.candidate, input + 117, 32); + for (unsigned i = 0; i < 4; ++i) + { + value.generation |= uint32_t(input[149 + i]) << (8 * i); + value.revision |= uint32_t(input[153 + i]) << (8 * i); + } + value.phase = static_cast(input[157]); + for (unsigned i = 0; i < 8; ++i) value.sequence |= uint64_t(input[158 + i]) << (8 * i); + if (!reservationValid(value)) + { + value = {}; + return false; + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/route_file_check.h b/modules/core_track_sharing/include/track_sharing/route_file_check.h new file mode 100644 index 00000000..aa981336 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/route_file_check.h @@ -0,0 +1,66 @@ +#pragma once +#include "track_sharing/file_integrity.h" +#include "track_sharing/kml_xml_stream.h" +namespace track_sharing +{ +// Single-use validation job. The platform must retain an immutable source/SD +// lease and current identity binding through the eventual atomic install. +// On ESP this object belongs to an admitted PSRAM owner, never a task stack. +// Caller lends the XML name stack, hash context and shared per-step scratch. +template +class RouteFileCheck +{ + public: + RouteFileCheck(Io& io, Hash& hash, void* names, size_t capacity) + : integrity_(io, hash), xml_(names, capacity) {} + FileIntegrityResult begin(const char* path, uint32_t total, const uint8_t hash[32]) + { + if (result_ != FileIntegrityResult::Idle) return FileIntegrityResult::Busy; + result_ = integrity_.begin(path, total, hash); + if (result_ == FileIntegrityResult::Pending) path_ = path; + return result_; + } + FileIntegrityResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != FileIntegrityResult::Pending) return result_; + const auto before = integrity_.checkedBytes(); + const auto status = integrity_.step(scratch, capacity); + const auto count = integrity_.checkedBytes() - before; + if (status == FileIntegrityResult::Pending) + { + if (count && !xml_.feed(scratch, count)) + { + integrity_.cancel(); + return result_ = FileIntegrityResult::Invalid; + } + return result_; + } + if (status == FileIntegrityResult::Ready && !xml_.finish(extent_)) + return result_ = FileIntegrityResult::Invalid; + return result_ = status; + } + FileIntegrityResult cancel() + { + if (result_ == FileIntegrityResult::Pending) + { + integrity_.cancel(); + result_ = FileIntegrityResult::Cancelled; + } + return result_; + } + bool ready() const { return result_ == FileIntegrityResult::Ready; } + bool matchesSource(const char* path, uint32_t bytes, const uint8_t* hash) const + { + return ready() && path && path_ && !std::strcmp(path, path_) && integrity_.matchesExpected(bytes, hash); + } + uint32_t checkedBytes() const { return integrity_.checkedBytes(); } + const GeometryExtent* geometry() const { return ready() ? &extent_ : nullptr; } + + private: + FileIntegrityCheck integrity_; + KmlXmlStream xml_; + GeometryExtent extent_{}; + const char* path_ = nullptr; + FileIntegrityResult result_ = FileIntegrityResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/route_match.h b/modules/core_track_sharing/include/track_sharing/route_match.h new file mode 100644 index 00000000..6a6b6b61 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/route_match.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include +#include +namespace track_sharing +{ +struct RouteMatchPoint +{ + double lat = 0, lon = 0; +}; +inline constexpr double routeEarthRadius = 6371000.0; +inline double routeRadians(double value) { return value * 0.017453292519943295; } +inline double routeAngularDistance(RouteMatchPoint a, RouteMatchPoint b) +{ + const auto x = std::sin(routeRadians(b.lat - a.lat) / 2), y = std::sin(routeRadians(b.lon - a.lon) / 2); + const double h = std::clamp(x * x + std::cos(routeRadians(a.lat)) * std::cos(routeRadians(b.lat)) * y * y, 0.0, 1.0); + return 2 * std::atan2(std::sqrt(h), std::sqrt(1 - h)); +} +inline double routeBearing(RouteMatchPoint a, RouteMatchPoint b) +{ + const auto delta = routeRadians(std::remainder(b.lon - a.lon, 360.0)); + const auto la = routeRadians(a.lat), lb = routeRadians(b.lat); + return std::atan2(std::sin(delta) * std::cos(lb), std::cos(la) * std::sin(lb) - std::sin(la) * std::cos(lb) * std::cos(delta)); +} +struct RouteMatchResult +{ + double distance_m = std::numeric_limits::infinity(); + double progress_m = 0, total_m = 0, endpoint_distance_m = 0; + bool valid = false, ambiguous = false; +}; +// One preceding raw point, never a retained path. Minor great-circle arcs; +// an antipodal pair cannot define a unique arc and is explicitly rejected. +class RouteMatchAccumulator +{ + public: + explicit RouteMatchAccumulator(RouteMatchPoint fix = {}) : fix_(fix) {} + bool point(RouteMatchPoint current, bool broken) + { + if (!std::isfinite(current.lat) || !std::isfinite(current.lon) || std::abs(current.lat) > 90 || std::abs(current.lon) > 180 || + !std::isfinite(fix_.lat) || !std::isfinite(fix_.lon) || std::abs(fix_.lat) > 90 || std::abs(fix_.lon) > 180) return false; + const double distance = routeAngularDistance(fix_, current) * routeEarthRadius; + if (have_previous_ && !broken) + { + const double length = routeAngularDistance(previous_, current); + if (length > 3.141592653589793 - 1e-8) return false; + if (length > 1e-12) + { + const double from_start = routeAngularDistance(previous_, fix_); + const double heading = routeBearing(previous_, fix_) - routeBearing(previous_, current); + const double along = std::atan2(std::sin(from_start) * std::cos(heading), std::cos(from_start)); + if (along >= 0 && along <= length) + { + const double cross = std::abs(std::asin(std::clamp(std::sin(from_start) * std::sin(heading), -1.0, 1.0))); + consider(cross * routeEarthRadius, result_.total_m + along * routeEarthRadius); + } + } + result_.total_m += length * routeEarthRadius; + } + consider(distance, result_.total_m); + result_.endpoint_distance_m = distance; + previous_ = current; + have_previous_ = true; + result_.valid = true; + return true; + } + const RouteMatchResult& result() const { return result_; } + + private: + void consider(double distance, double progress) + { + if (distance + 0.01 < result_.distance_m) + { + result_.distance_m = distance; + result_.progress_m = progress; + result_.ambiguous = false; + } + else if (std::abs(distance - result_.distance_m) <= 0.01 && std::abs(progress - result_.progress_m) > 20) result_.ambiguous = true; + } + RouteMatchPoint fix_, previous_{}; + RouteMatchResult result_{}; + bool have_previous_ = false; +}; +static_assert(sizeof(RouteMatchAccumulator) <= 96, "Matcher retains scalar state and one point"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/route_navigation_state.h b/modules/core_track_sharing/include/track_sharing/route_navigation_state.h new file mode 100644 index 00000000..d37263f2 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/route_navigation_state.h @@ -0,0 +1,82 @@ +#pragma once +#include "track_sharing/route_match.h" +namespace track_sharing +{ +enum class RouteNavigationStatus : uint8_t +{ + Unavailable, + Ambiguous, + Position, + Arrived +}; +struct RouteNavigationView +{ + double remaining_m = 0; + unsigned percent = 0; + RouteNavigationStatus status = RouteNavigationStatus::Unavailable; +}; +// Position on the recorded path is not distance walked. No extrapolation, +// bridging of disconnected legs, or arrival inference at a loop's start. +class RouteNavigationState +{ + public: + void reset() { *this = {}; } + void observe(const RouteMatchResult& result, uint32_t completed, double movement) + { + if (observed_ && completed == last_observed_) return; + if (observed_ && completed - last_observed_ > 6000) near_ = arrived_ = false; + observed_ = true; + last_observed_ = completed; + if (!usable(result, movement) || result.ambiguous) + { + near_ = arrived_ = false; + return; + } + const double remaining = std::max(0.0, result.total_m - result.progress_m); + if (remaining >= 20) armed_ = true; + if (!armed_ || !near_end(result, movement)) + { + near_ = arrived_ = false; + return; + } + if (!near_) + { + near_ = true; + near_since_ = completed; + } + // Only independent completed scans count toward the five-second dwell. + arrived_ = completed - near_since_ >= 5000; + } + RouteNavigationView project(const RouteMatchResult& result, uint32_t age, double movement) const + { + RouteNavigationView view; + if (age > 5000 || !usable(result, movement)) return view; + if (result.ambiguous) + { + view.status = RouteNavigationStatus::Ambiguous; + return view; + } + view.remaining_m = std::max(0.0, result.total_m - result.progress_m); + view.percent = static_cast(std::clamp(result.progress_m / result.total_m * 100.0, 0.0, 100.0)); + view.status = arrived_ && near_end(result, movement) ? RouteNavigationStatus::Arrived : RouteNavigationStatus::Position; + return view; + } + + private: + static bool usable(const RouteMatchResult& result, double movement) + { + return result.valid && std::isfinite(result.total_m) && result.total_m > 0 && + std::isfinite(result.progress_m) && result.progress_m >= 0 && result.progress_m <= result.total_m && + std::isfinite(result.distance_m) && result.distance_m >= 0 && + std::isfinite(result.endpoint_distance_m) && result.endpoint_distance_m >= 0 && + std::isfinite(movement) && movement >= 0 && movement <= 3 && result.distance_m + movement <= 7; + } + static bool near_end(const RouteMatchResult& result, double movement) + { + return result.total_m - result.progress_m <= 10 && result.endpoint_distance_m + movement <= 10; + } + uint32_t last_observed_ = 0, near_since_ = 0; + bool observed_ = false, armed_ = false, near_ = false, arrived_ = false; +}; +static_assert(sizeof(RouteNavigationState) <= 16, "Arrival keeps only scalar state in the route owner"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/sealed_attachment_uploads.h b/modules/core_track_sharing/include/track_sharing/sealed_attachment_uploads.h new file mode 100644 index 00000000..dc5dcb29 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/sealed_attachment_uploads.h @@ -0,0 +1,60 @@ +#pragma once +#include "track_sharing/attachment_package_file_check.h" +#include "track_sharing/upload_journal_store.h" +#include "track_sharing/upload_paths.h" + +namespace track_sharing +{ +// Packed upload IDs follow canonical index order, 16 bytes per entry. A zero +// ID explicitly omits a resource; it does not authorize a path lookup by URL. +// Platform freezes this mapping and identity in admitted PSRAM under a lease. +template +class SealedAttachmentUploads +{ + public: + SealedAttachmentUploads(Io& io, geocaching::ByteView ids, uint32_t generation, const uint8_t* author_hash) + : io_(io), ids_(ids), generation_(generation), author_hash_(author_hash) {} + AttachmentResolveResult index(const uint8_t* id, const AttachmentIndexBinding& binding, char* path, size_t capacity) + { + if (!binding.hash.data || binding.hash.size != 32 || !binding.count || binding.count > 64 || ids_.size != size_t(binding.count) * 16) + return AttachmentResolveResult::Invalid; + return load(id, UploadKind::AttachmentIndex, binding.bytes, binding.hash.data, path, capacity); + } + AttachmentResolveResult resolve(uint8_t ordinal, const AttachmentEntryView& entry, char* path, size_t capacity) + { + if (!ids_.data || ordinal >= ids_.size / 16 || ids_.size % 16 || ids_.size > 64 * 16 || !entry.hash.data || entry.hash.size != 32) + return AttachmentResolveResult::Invalid; + const auto* id = ids_.data + size_t(ordinal) * 16; + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= id[i]; + if (!bits) return AttachmentResolveResult::Missing; + return load(id, UploadKind::AttachmentResource, entry.bytes, entry.hash.data, path, capacity); + } + + private: + AttachmentResolveResult load(const uint8_t* id, UploadKind kind, uint32_t bytes, const uint8_t* hash, char* path, size_t capacity) + { + if (!id || !hash || !author_hash_ || !generation_ || !ids_.data || !path || capacity < 64 || !bytes || bytes > uploadByteLimit(kind)) + return AttachmentResolveResult::Invalid; + if (!resolveUploadPath(id, 16, UploadFile::Candidate, path, capacity, kind) || + !resolveUploadPath(id, 16, UploadFile::JournalA, a_, sizeof(a_), kind) || + !resolveUploadPath(id, 16, UploadFile::JournalB, b_, sizeof(b_), kind)) return AttachmentResolveResult::Invalid; + UploadJournalStore store(io_, a_, b_); + const auto loaded = store.load(current_, verify_, scratch_, sizeof(scratch_)); + if (loaded == JournalStoreResult::StorageFailure) return AttachmentResolveResult::StorageFailure; + // A supplied ID with missing/corrupt journals is invalid even when + // optional. Only a zero ID means intentional absence. + if (loaded != JournalStoreResult::Ready || !uploadJournalValid(current_) || current_.phase != UploadPhase::Sealed || + current_.generation != generation_ || current_.total != bytes || std::memcmp(current_.id, id, 16) || + std::memcmp(current_.author_hash, author_hash_, 32) || std::memcmp(current_.file_hash, hash, 32)) return AttachmentResolveResult::Invalid; + return AttachmentResolveResult::Ready; + } + Io& io_; + geocaching::ByteView ids_; + uint32_t generation_; + const uint8_t* author_hash_; + UploadJournal current_, verify_; + char a_[64]{}, b_[64]{}; + uint8_t scratch_[108]{}; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/sign_manifest.h b/modules/core_track_sharing/include/track_sharing/sign_manifest.h new file mode 100644 index 00000000..fa3e9eef --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/sign_manifest.h @@ -0,0 +1,46 @@ +#pragma once +#include "track_sharing/manifest.h" +#include +namespace track_sharing +{ +inline bool signingSpansOverlap(const uint8_t* a, size_t a_size, const uint8_t* b, size_t b_size) +{ + const auto first = reinterpret_cast(a), second = reinterpret_cast(b); + return a_size && b_size && (first <= second ? second - first < a_size : first - second < b_size); +} +// Cryptographic primitive, not publication authorization. Caller must first +// verify uploaded file/hash/geometry and reserve the author revision. Identity +// retains private keys. All workspace/view/signature storage is caller-owned. +template +bool signManifest(geocaching::ByteView raw, Signer& identity, ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) +{ + constexpr char domain[] = "TrailMate.Track.Manifest.v1"; + if (!workspace || !signature || signature_capacity < 64 || raw.size > 4096 || capacity < sizeof(domain) + raw.size || capacity < 64 || + signingSpansOverlap(raw.data, raw.size, workspace, capacity) || signingSpansOverlap(raw.data, raw.size, signature, 64) || + signingSpansOverlap(workspace, capacity, signature, 64) || !identity.isReady() || !decodeManifest(raw, view)) return false; + identity.combinedPublicKey(workspace); + if (std::memcmp(workspace, view.author.data, 64)) return false; + std::memcpy(workspace, domain, sizeof(domain)); + if (view.version == 2) workspace[sizeof(domain) - 2] = '2'; + std::memcpy(workspace + sizeof(domain), raw.data, raw.size); + return identity.sign(workspace, sizeof(domain) + raw.size, signature); +} +// Caller has completed file/revision authorization under an immutable lease. +// Generation binds even a restored identical key to a distinct author epoch. +template +bool signManifestForGeneration(geocaching::ByteView raw, Signer& identity, uint32_t generation, ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) +{ + if (!signature || signature_capacity < 64 || signingSpansOverlap(raw.data, raw.size, signature, 64) || + signingSpansOverlap(workspace, capacity, signature, 64)) return false; + std::memset(signature, 0, 64); + if (!generation || identity.authorGeneration() != generation || !identity.isReady()) return false; + if (!signManifest(raw, identity, view, workspace, capacity, signature, signature_capacity) || identity.authorGeneration() != generation || !identity.isReady()) + { + std::memset(signature, 0, 64); + return false; + } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/signed_manifest_reader.h b/modules/core_track_sharing/include/track_sharing/signed_manifest_reader.h new file mode 100644 index 00000000..b76a3298 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/signed_manifest_reader.h @@ -0,0 +1,53 @@ +#pragma once +#include "track_sharing/file_integrity.h" +#include "geocaching/domain/record.h" +namespace track_sharing +{ +// Read exact bounded signed CMP bytes into caller-admitted PSRAM. Caller must +// subsequently verifyManifest and match the current head; Ready is only I/O. +template +class SignedManifestReader +{ + public: + explicit SignedManifestReader(Io& io) : io_(io) {} + FileIntegrityResult begin(const char* path, uint8_t* output, size_t capacity) + { + if (result_ != FileIntegrityResult::Idle) return FileIntegrityResult::Busy; + if (!path || !*path || !output || capacity < 4166) return FileIntegrityResult::Invalid; + const auto a = reinterpret_cast(output); + const auto overlaps = [&](const void* input, size_t size) + { const auto b = reinterpret_cast(input); return a <= b ? b - a < 4166 : a - b < size; }; + if (overlaps(this, sizeof(*this)) || overlaps(&io_, sizeof(io_)) || overlaps(path, std::strlen(path) + 1)) return FileIntegrityResult::Invalid; + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return result_ = FileIntegrityResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size), closed = io_.close(opened.handle); + if (!sized || !closed) return result_ = FileIntegrityResult::StorageFailure; + if (size < 69 || size > 4166) return result_ = FileIntegrityResult::Invalid; + output_ = output; path_ = path; size_ = uint32_t(size); + return result_ = FileIntegrityResult::Pending; + } + FileIntegrityResult step() + { + if (result_ != FileIntegrityResult::Pending) return result_; + const auto opened = io_.open(path_, Io::Mode::Read); + if (!opened.handle) return stop(FileIntegrityResult::StorageFailure); + uint64_t size = 0; + const size_t count = std::min(320, size_ - offset_); + const bool read = io_.size(opened.handle, size) && size == size_ && io_.seek(opened.handle, offset_) && io_.read(opened.handle, output_ + offset_, count) == count; + const bool closed = io_.close(opened.handle); + if (!read || !closed) return stop(FileIntegrityResult::StorageFailure); + offset_ += uint32_t(count); + return result_ = offset_ == size_ ? FileIntegrityResult::Ready : FileIntegrityResult::Pending; + } + FileIntegrityResult cancel() { return result_ == FileIntegrityResult::Pending ? stop(FileIntegrityResult::Cancelled) : result_; } + geocaching::ByteView bytes() const { return result_ == FileIntegrityResult::Ready ? geocaching::ByteView{output_, size_} : geocaching::ByteView{}; } + private: + FileIntegrityResult stop(FileIntegrityResult result) { if (output_) std::memset(output_, 0, size_); return result_ = result; } + Io& io_; + const char* path_ = nullptr; + uint8_t* output_ = nullptr; + uint32_t size_ = 0, offset_ = 0; + FileIntegrityResult result_ = FileIntegrityResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/signed_manifest_store.h b/modules/core_track_sharing/include/track_sharing/signed_manifest_store.h new file mode 100644 index 00000000..b8682637 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/signed_manifest_store.h @@ -0,0 +1,100 @@ +#pragma once +#include "track_sharing/sign_manifest.h" +#include +namespace track_sharing +{ +enum class SignedStoreResult : uint8_t +{ + Ready, + Missing, + Conflict, + StorageFailure, + Invalid +}; +// Byte persistence only: caller has authorized/signature-verified immutable +// inputs, exclusive canonical paths and media-bound I/O. No owned byte buffers. +template +class SignedManifestStore +{ + public: + using Result = SignedStoreResult; + SignedManifestStore(Io& io, const char* temporary, const char* destination) : io_(io), temporary_(temporary), destination_(destination) {} + Result persist(geocaching::ByteView raw, geocaching::ByteView signature, uint8_t* scratch, size_t capacity) + { + if (!raw.data || !raw.size || raw.size > 4096 || !signature.data || signature.size != 64 || !scratch || capacity < 128 || !temporary_ || !destination_ || !*temporary_ || !*destination_ || !std::strcmp(temporary_, destination_) || + signingSpansOverlap(scratch, 128, raw.data, raw.size) || signingSpansOverlap(scratch, 128, signature.data, signature.size)) return Result::Invalid; + const auto final = compare(destination_, raw, signature, scratch); + if (final != Result::Missing) return final; + const auto existing = compare(temporary_, raw, signature, scratch); + if (existing != Result::Missing && existing != Result::Ready) return existing; + if (existing == Result::Missing) + { + const auto opened = io_.open(temporary_, Io::Mode::CreateTemporary); + if (!opened.handle) return Result::StorageFailure; + const size_t prefix = raw.size <= 255 ? 3 : 4; + scratch[0] = 0x92; + scratch[1] = raw.size <= 255 ? 0xc4 : 0xc5; + if (prefix == 3) scratch[2] = uint8_t(raw.size); + else + { + scratch[2] = uint8_t(raw.size >> 8); + scratch[3] = uint8_t(raw.size); + } + bool ok = io_.write(opened.handle, scratch, prefix) == prefix; + for (size_t offset = 0; ok && offset < raw.size;) + { + const size_t count = std::min(size_t(320), raw.size - offset); + ok = io_.write(opened.handle, raw.data + offset, count) == count; + offset += count; + } + scratch[0] = 0xc4; + scratch[1] = 64; + ok = ok && io_.write(opened.handle, scratch, 2) == 2 && io_.write(opened.handle, signature.data, 64) == 64 && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!ok || !closed) return Result::StorageFailure; + if (compare(temporary_, raw, signature, scratch) != Result::Ready) return Result::StorageFailure; + } + if (!io_.publish(temporary_, destination_)) return Result::StorageFailure; + return compare(destination_, raw, signature, scratch) == Result::Ready ? Result::Ready : Result::StorageFailure; + } + + private: + Result compare(const char* path, geocaching::ByteView raw, geocaching::ByteView signature, uint8_t* scratch) + { + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? Result::Missing : Result::StorageFailure; + const size_t prefix = raw.size <= 255 ? 3 : 4; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + bool io_ok = sized && io_.seek(opened.handle, 0), equal = sized && size == raw.size + prefix + 66; + if (io_ok && equal) + { + io_ok = io_.read(opened.handle, scratch, prefix) == prefix; + equal = io_ok && scratch[0] == 0x92 && scratch[1] == (prefix == 3 ? 0xc4 : 0xc5) && (prefix == 3 ? scratch[2] == raw.size : ((size_t(scratch[2]) << 8) | scratch[3]) == raw.size); + } + for (size_t offset = 0; io_ok && equal && offset < raw.size;) + { + const size_t count = std::min(size_t(128), raw.size - offset); + io_ok = io_.read(opened.handle, scratch, count) == count; + equal = io_ok && !std::memcmp(scratch, raw.data + offset, count); + offset += count; + } + if (io_ok && equal) + { + io_ok = io_.read(opened.handle, scratch, 2) == 2; + equal = io_ok && scratch[0] == 0xc4 && scratch[1] == 64; + } + if (io_ok && equal) + { + io_ok = io_.read(opened.handle, scratch, 64) == 64; + equal = io_ok && !std::memcmp(scratch, signature.data, 64); + } + const bool closed = io_.close(opened.handle); + return !io_ok || !closed ? Result::StorageFailure : equal ? Result::Ready + : Result::Conflict; + } + Io& io_; + const char* temporary_; + const char* destination_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/signed_object_head.h b/modules/core_track_sharing/include/track_sharing/signed_object_head.h new file mode 100644 index 00000000..6e256606 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/signed_object_head.h @@ -0,0 +1,34 @@ +#pragma once +#include "track_sharing/object_head.h" +#include "track_sharing/revision_reservation.h" +#include "track_sharing/verify_manifest.h" +namespace track_sharing +{ +// Caller holds current-head exclusive lease; null current requires verified +// absence. Verified view must come from immutable stored signature bytes. +// This creates Signed only; Committed requires separate durable task evidence. +inline bool createSignedObjectHead(const VerifiedManifestView& signed_view, const RevisionReservation& reservation, + uint32_t generation, geocaching::ByteView author, geocaching::ByteView author_hash, + const ObjectHead* current, ObjectHead& output) +{ + const auto overlaps = [&](const void* input, size_t size) + { const auto a = reinterpret_cast(input), b = reinterpret_cast(&output); return input && (a <= b ? b - a < size : a - b < sizeof(output)); }; + if (overlaps(&signed_view, sizeof(signed_view)) || overlaps(&reservation, sizeof(reservation)) || + overlaps(signed_view.manifest.raw.data, signed_view.manifest.raw.size) || overlaps(signed_view.signature.data, signed_view.signature.size) || + overlaps(author.data, author.size) || overlaps(author_hash.data, author_hash.size) || (current && overlaps(current, sizeof(*current)))) return false; + output = {}; + if (!signed_view.verified || !reservationValid(reservation) || reservation.phase != ReservationPhase::Signed || !generation || reservation.generation != generation || + !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || signed_view.manifest.author.size != 64 || !signed_view.manifest.author.data || + std::memcmp(signed_view.manifest.author.data, author.data, 64) || std::memcmp(reservation.author, author_hash.data, 32) || + std::memcmp(signed_view.object_id, reservation.object, 32) || std::memcmp(signed_view.revision_hash, reservation.candidate, 32) || + signed_view.manifest.revision != reservation.revision || signed_view.manifest.state > 1) return false; + if (reservation.revision != 1 && (!signed_view.manifest.previous.data || signed_view.manifest.previous.size != 32 || std::memcmp(reservation.parent, signed_view.manifest.previous.data, 32))) return false; + if (current && (!objectHeadValid(*current) || current->sequence == UINT64_MAX || current->revision == UINT32_MAX || reservation.revision != current->revision + 1)) return false; + std::memcpy(output.object, reservation.object, 32); std::memcpy(output.hash, reservation.candidate, 32); + std::memcpy(output.parent, reservation.parent, 32); std::memcpy(output.operation, reservation.operation, 16); + output.generation = generation; output.revision = reservation.revision; output.sequence = current ? current->sequence + 1 : 1; + output.archived = signed_view.manifest.state == 1; + if (!objectHeadValid(output) || (current ? !objectHeadAdvanceValid(*current, output) : output.revision != 1)) { output = {}; return false; } + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/signed_package_file_check.h b/modules/core_track_sharing/include/track_sharing/signed_package_file_check.h new file mode 100644 index 00000000..bacf257d --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/signed_package_file_check.h @@ -0,0 +1,97 @@ +#pragma once +#include "track_sharing/manifest_file_check.h" +#include "track_sharing/uploaded_manifest_reader.h" +namespace track_sharing +{ +// Public shared package: upload journals belong to the current local USB actor, +// while the manifest signature belongs to its original (possibly remote) +// author. Never re-sign or substitute the local author. Caller owns immutable +// leases, buffers, paths, expected object/revision, metadata and hash context. +template +class SignedPackageFileCheck +{ + public: + SignedPackageFileCheck(Io& io, Hash& hash, Crypto& crypto, const UploadJournal& signed_upload, const UploadJournal& kml_upload, + const uint32_t& generation, const uint8_t* actor_hash, void* names, size_t names_capacity, + VerifiedManifestView& verified, uint8_t* workspace, size_t workspace_capacity) + : reader_(io, hash, signed_upload, generation, actor_hash), file_(io, hash, names, names_capacity), + hash_(hash), crypto_(crypto), kml_upload_(kml_upload), generation_(generation), actor_hash_(actor_hash), + verified_(verified), workspace_(workspace), workspace_capacity_(workspace_capacity) {} + ManifestFileResult begin(const char* signed_path, const char* kml_path, geocaching::ByteView expected_object, geocaching::ByteView expected_revision, + uint8_t* raw, size_t capacity) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!kml_path || !expected_object.data || expected_object.size != 32 || !expected_revision.data || expected_revision.size != 32 || !workspace_ || workspace_capacity_ < 4200 || + !uploadJournalValid(kml_upload_) || kml_upload_.phase != UploadPhase::Sealed || !identityMatches()) return result_ = ManifestFileResult::Invalid; + if (signingSpansOverlap(expected_object.data, 32, raw, capacity) || signingSpansOverlap(expected_revision.data, 32, raw, capacity) || + signingSpansOverlap(expected_object.data, 32, workspace_, workspace_capacity_) || signingSpansOverlap(expected_revision.data, 32, workspace_, workspace_capacity_)) return result_ = ManifestFileResult::Invalid; + expected_object_ = expected_object; + expected_revision_ = expected_revision; + kml_path_ = kml_path; + const auto read = reader_.begin(signed_path, raw, capacity); + return result_ = read == FileIntegrityResult::Pending ? ManifestFileResult::Pending : map(read); + } + template + ManifestFileResult step(Package& package, uint8_t* scratch, size_t capacity) + { + if (result_ != ManifestFileResult::Pending) return result_; + if (!identityMatches()) + { + cancel(); + return result_ = ManifestFileResult::Invalid; + } + if (checking_kml_) return result_ = file_.step(scratch, capacity); + const auto read = reader_.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return result_ = map(read); + if (!authenticated_) + { + if (verifyManifest(reader_.bytes(), crypto_, hash_, workspace_, workspace_capacity_, verified_) != ManifestVerification::Valid || + std::memcmp(verified_.object_id, expected_object_.data, 32) || std::memcmp(verified_.revision_hash, expected_revision_.data, 32) || + verified_.manifest.file_bytes != kml_upload_.total || std::memcmp(verified_.manifest.file_hash.data, kml_upload_.file_hash, 32)) return result_ = ManifestFileResult::Invalid; + authenticated_ = true; + } + if (verified_.manifest.version == 2) + { + const auto checked = package.checkAttachments(verified_.manifest, scratch, capacity); + if (checked == ManifestFileResult::Pending) return result_; + if (checked != ManifestFileResult::Ready) return result_ = checked; + result_ = file_.beginVerifiedPackage(kml_path_, verified_, package); + } + else result_ = package.matchesAttachments(verified_.manifest) ? file_.begin(kml_path_, verified_) : ManifestFileResult::Invalid; + checking_kml_ = result_ == ManifestFileResult::Pending; + return result_; + } + ManifestFileResult cancel() + { + reader_.cancel(); + file_.cancel(); + if (result_ == ManifestFileResult::Pending) result_ = ManifestFileResult::Cancelled; + return result_; + } + bool ready() const { return result_ == ManifestFileResult::Ready && authenticated_ && identityMatches() && file_.ready(); } + geocaching::ByteView signedBytes() const { return ready() ? reader_.bytes() : geocaching::ByteView{}; } + + private: + bool identityMatches() const { return actor_hash_ && generation_ && generation_ == kml_upload_.generation && !std::memcmp(actor_hash_, kml_upload_.author_hash, 32); } + static ManifestFileResult map(FileIntegrityResult result) + { + return result == FileIntegrityResult::HashMismatch ? ManifestFileResult::HashMismatch : result == FileIntegrityResult::StorageFailure ? ManifestFileResult::StorageFailure + : ManifestFileResult::Invalid; + } + UploadedManifestReader reader_; + ManifestFileCheck file_; + Hash& hash_; + Crypto& crypto_; + const UploadJournal& kml_upload_; + const uint32_t& generation_; + const uint8_t* actor_hash_; + VerifiedManifestView& verified_; + uint8_t* workspace_; + size_t workspace_capacity_; + geocaching::ByteView expected_object_, expected_revision_; + const char* kml_path_ = nullptr; + ManifestFileResult result_ = ManifestFileResult::Idle; + bool authenticated_ = false, checking_kml_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/track_datetime.h b/modules/core_track_sharing/include/track_sharing/track_datetime.h new file mode 100644 index 00000000..e55618d1 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/track_datetime.h @@ -0,0 +1,58 @@ +#pragma once +#include +namespace track_sharing +{ +// Bounded sharing profile: XSD dateTime calendar/timezone syntax, seconds +// 00..59 (no leap-second conversion); no timezone means unspecified, not UTC. +inline bool validTrackDateTime(std::string_view text, bool allow_unknown = false) +{ + const auto whitespace = [](char ch) + { return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; }; + while (!text.empty() && whitespace(text.front())) text.remove_prefix(1); + while (!text.empty() && whitespace(text.back())) text.remove_suffix(1); + if (text.empty()) return allow_unknown; + if (text.size() > 63) return false; + size_t i = 0; + bool negative = false; + if (text[i] == '-') + { + negative = true; + ++i; + } + const auto start = i; + unsigned year = 0; + bool nonzero = false; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') + { + const unsigned digit = text[i++] - '0'; + year = (year * 10 + digit) % 400; + nonzero |= digit != 0; + } + if (i - start < 4 || !nonzero || (i - start > 4 && text[start] == '0')) return false; + const auto symbol = [&](char ch) + {if(i>=text.size()||text[i]!=ch)return false;++i;return true; }; + const auto two = [&](unsigned& value) + {if(text.size()-i<2||text[i]<'0'||text[i]>'9'||text[i+1]<'0'||text[i+1]>'9')return false;value=(text[i]-'0')*10+text[i+1]-'0';i+=2;return true; }; + unsigned month, day, hour, minute, second; + if (!symbol('-') || !two(month) || !symbol('-') || !two(day) || !symbol('T') || !two(hour) || !symbol(':') || !two(minute) || !symbol(':') || !two(second) || month < 1 || month > 12 || hour > 24 || minute > 59 || second > 59) return false; + const unsigned calendar_year = negative ? (401 - year) % 400 : year; + const bool leap = calendar_year % 4 == 0 && (calendar_year % 100 != 0 || calendar_year % 400 == 0); + const unsigned days = month == 2 ? (leap ? 29 : 28) : (month == 4 || month == 6 || month == 9 || month == 11) ? 30 + : 31; + if (!day || day > days) return false; + bool fraction_nonzero = false; + if (i < text.size() && text[i] == '.') + { + const auto first = ++i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') fraction_nonzero |= text[i++] != '0'; + if (i == first) return false; + } + if (hour == 24 && (minute || second || fraction_nonzero)) return false; + if (i == text.size()) return true; + if (text[i] == 'Z') return i + 1 == text.size(); + if (text[i] != '+' && text[i] != '-') return false; + ++i; + unsigned zone_hour, zone_minute; + return two(zone_hour) && symbol(':') && two(zone_minute) && i == text.size() && zone_hour <= 14 && zone_minute <= 59 && (zone_hour != 14 || !zone_minute); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_chunk_writer.h b/modules/core_track_sharing/include/track_sharing/upload_chunk_writer.h new file mode 100644 index 00000000..ce64334f --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_chunk_writer.h @@ -0,0 +1,66 @@ +#pragma once +#include "track_sharing/upload_journal_store.h" +#include +namespace track_sharing +{ +// Caller authenticates the device identity, owns the exclusive transaction +// lease, and supplies stable confined paths and media-bound I/O. Candidate +// already exists. No file handle or byte buffer survives append(). +template +class UploadChunkWriter +{ + public: + // Live identity fields are supplied from current device state, never from + // the recovered journal. Caller refreshes them before every serialized call. + UploadChunkWriter(Io& io, Store& store, UploadJournal& current, UploadJournal& verify, const char* path, + const uint32_t& generation, const uint8_t* author_hash) + : io_(io), store_(store), current_(current), verify_(verify), path_(path), generation_(generation), author_hash_(author_hash) {} + JournalStoreResult append(const uint8_t* id, uint32_t offset, const uint8_t* bytes, size_t length, + uint8_t* scratch, size_t capacity) + { + if (!id || !bytes || !scratch || capacity < 128 || !length || length > 320 || !path_ || !*path_ || + ¤t_ == &verify_ || !uploadJournalValid(current_)) return JournalStoreResult::Invalid; + const auto input = reinterpret_cast(bytes), workspace = reinterpret_cast(scratch); + if (input <= workspace ? workspace - input < length : input - workspace < 128) return JournalStoreResult::Invalid; + if (!author_hash_ || !generation_ || generation_ != current_.generation || std::memcmp(author_hash_, current_.author_hash, 32)) return JournalStoreResult::Conflict; + if (std::memcmp(id, current_.id, 16) || current_.phase != UploadPhase::Receiving || + offset > current_.durable_offset || offset > current_.total || length > current_.total - offset) return JournalStoreResult::Conflict; + const bool duplicate = offset < current_.durable_offset; + if (duplicate && length > current_.durable_offset - offset) return JournalStoreResult::Conflict; + const auto checked = store_.validateCurrent(current_, verify_, scratch, capacity); + if (checked != JournalStoreResult::Ready) return checked; + const auto opened = io_.open(path_, duplicate ? Io::Mode::Read : Io::Mode::Update); + if (!opened.handle) return JournalStoreResult::StorageFailure; + uint64_t size = 0; + bool ok = io_.size(opened.handle, size) && size >= current_.durable_offset && size <= current_.total && io_.seek(opened.handle, offset); + bool equal = true; + if (duplicate) + { + for (size_t consumed = 0; ok && equal && consumed < length;) + { + const size_t count = std::min(size_t(128), length - consumed); + ok = io_.read(opened.handle, scratch, count) == count; + if (ok) equal = !std::memcmp(scratch, bytes + consumed, count); + consumed += count; + } + } + else if (ok) ok = io_.write(opened.handle, bytes, length) == length && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!ok || !closed) return JournalStoreResult::StorageFailure; + if (!equal) return JournalStoreResult::Conflict; + if (duplicate) return JournalStoreResult::Ready; + const auto result = store_.advance(current_, offset + uint32_t(length), UploadPhase::Receiving, verify_, scratch, capacity); + if (result == JournalStoreResult::Ready) current_ = verify_; + return result; + } + + private: + Io& io_; + Store& store_; + UploadJournal& current_; + UploadJournal& verify_; + const char* path_; + const uint32_t& generation_; + const uint8_t* author_hash_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_journal.h b/modules/core_track_sharing/include/track_sharing/upload_journal.h new file mode 100644 index 00000000..91192472 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_journal.h @@ -0,0 +1,81 @@ +#pragma once +#include +#include +namespace track_sharing +{ +enum class UploadPhase : uint8_t +{ + Receiving, + Checking, + Sealed, + Failed +}; +struct UploadJournal +{ + uint8_t id[16]{}, author_hash[32]{}, file_hash[32]{}; + uint64_t sequence = 0; + uint32_t generation = 0, total = 0, durable_offset = 0; + UploadPhase phase = UploadPhase::Receiving; +}; +static_assert(sizeof(UploadJournal) <= 112, "Upload journal must not contain file storage"); +constexpr size_t kUploadJournalBytes = 108; +inline bool uploadJournalValid(const UploadJournal& value) +{ + uint8_t id = 0, author = 0; + for (auto byte : value.id) id |= byte; + for (auto byte : value.author_hash) author |= byte; + const auto phase = static_cast(value.phase); + return id && author && value.sequence && value.generation && value.total && value.total <= 8388608 && value.durable_offset <= value.total && phase <= 3 && + ((value.phase != UploadPhase::Checking && value.phase != UploadPhase::Sealed) || value.durable_offset == value.total); +} +inline uint16_t uploadJournalCrc(const uint8_t* bytes, size_t length) +{ + uint16_t crc = 0xffff; + for (size_t i = 0; i < length; ++i) + { + crc ^= uint16_t(bytes[i]) << 8; + for (unsigned bit = 0; bit < 8; ++bit) crc = uint16_t((crc << 1) ^ ((crc & 0x8000) ? 0x1021 : 0)); + } + return crc; +} +// TMKU/version1/id16/author-generationLE32/author-hash32/file-hash32/ +// totalLE32/durable-offsetLE32/phase8/sequenceLE64/CRC16-CCITTLE16. +// CRC detects corruption, not authentication. Platform must check current +// device identity, admitted paths/media, and actual file hash before Sealed. +inline bool encodeUploadJournal(const UploadJournal& value, uint8_t* output, size_t capacity) +{ + if (!output || capacity < kUploadJournalBytes || !uploadJournalValid(value)) return false; + std::memcpy(output, "TMKU", 4); + output[4] = 1; + std::memcpy(output + 5, value.id, 16); + const auto u32 = [&](size_t offset, uint32_t number) + {for(unsigned i=0;i<4;++i)output[offset+i]=uint8_t(number>>(i*8)); }; + u32(21, value.generation); + std::memcpy(output + 25, value.author_hash, 32); + std::memcpy(output + 57, value.file_hash, 32); + u32(89, value.total); + u32(93, value.durable_offset); + output[97] = static_cast(value.phase); + for (unsigned i = 0; i < 8; ++i) output[98 + i] = uint8_t(value.sequence >> (i * 8)); + const auto crc = uploadJournalCrc(output, 106); + output[106] = uint8_t(crc); + output[107] = uint8_t(crc >> 8); + return true; +} +inline bool decodeUploadJournal(const uint8_t* input, size_t size, UploadJournal& value) +{ + value = {}; + if (!input || size != kUploadJournalBytes || std::memcmp(input, "TMKU", 4) || input[4] != 1 || uploadJournalCrc(input, 106) != (uint16_t(input[106]) | (uint16_t(input[107]) << 8))) return false; + const auto u32 = [&](size_t offset) + {uint32_t number=0;for(unsigned i=0;i<4;++i)number|=uint32_t(input[offset+i])<<(i*8);return number; }; + std::memcpy(value.id, input + 5, 16); + value.generation = u32(21); + std::memcpy(value.author_hash, input + 25, 32); + std::memcpy(value.file_hash, input + 57, 32); + value.total = u32(89); + value.durable_offset = u32(93); + value.phase = static_cast(input[97]); + for (unsigned i = 0; i < 8; ++i) value.sequence |= uint64_t(input[98 + i]) << (i * 8); + return uploadJournalValid(value); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_journal_store.h b/modules/core_track_sharing/include/track_sharing/upload_journal_store.h new file mode 100644 index 00000000..386a4a28 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_journal_store.h @@ -0,0 +1,164 @@ +#pragma once +#include "track_sharing/upload_journal.h" +#include +namespace track_sharing +{ +enum class JournalStoreResult : uint8_t +{ + Ready, + Missing, + Corrupt, + StorageFailure, + Conflict, + Invalid +}; +// Caller owns exclusive transaction lease, immutable canonical distinct paths, +// media-bound I/O, two metadata objects and a 108-byte scratch buffer. No file +// handles or automatic metadata/buffers survive a call. CRC is not file proof. +template +class UploadJournalStore +{ + public: + UploadJournalStore(Io& io, const char* a, const char* b) : io_(io), a_(a), b_(b) {} + JournalStoreResult initialize(const UploadJournal& initial, UploadJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!paths() || !scratch || capacity < 108 || &initial == &verify || !uploadJournalValid(initial) || + initial.sequence != 1 || initial.durable_offset || initial.phase != UploadPhase::Receiving) return JournalStoreResult::Invalid; + for (const auto path : {a_, b_}) + { + const auto existing = read(path, verify, scratch); + if (existing == JournalStoreResult::StorageFailure) return existing; + if (existing != JournalStoreResult::Missing) return JournalStoreResult::Conflict; + } + if (!encodeUploadJournal(initial, scratch, capacity)) return JournalStoreResult::Invalid; + const auto opened = io_.open(a_, Io::Mode::CreateTemporary); + if (!opened.handle) return JournalStoreResult::StorageFailure; + const bool written = io_.write(opened.handle, scratch, 108) == 108 && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return JournalStoreResult::StorageFailure; + if (read(a_, verify, scratch) != JournalStoreResult::Ready || !same(initial, verify)) return JournalStoreResult::StorageFailure; + return JournalStoreResult::Ready; + } + // Check before modifying candidate bytes; checking only at commit is too + // late when a stale offset would overwrite already acknowledged content. + JournalStoreResult validateCurrent(const UploadJournal& current, UploadJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!paths() || !scratch || capacity < 108 || ¤t == &verify || !uploadJournalValid(current)) return JournalStoreResult::Invalid; + const auto existing = read(current.sequence & 1 ? a_ : b_, verify, scratch); + if (existing == JournalStoreResult::StorageFailure) return existing; + if (existing != JournalStoreResult::Ready || !same(current, verify)) return JournalStoreResult::Conflict; + const auto other = read(current.sequence & 1 ? b_ : a_, verify, scratch); + if (other == JournalStoreResult::StorageFailure) return other; + if (other == JournalStoreResult::Ready && (!binding(current, verify) || verify.sequence >= current.sequence)) return JournalStoreResult::Conflict; + return JournalStoreResult::Ready; + } + // Advance without allocating a third metadata object. Current remains + // unchanged; caller adopts verify only after Ready. Exclusive lease required. + JournalStoreResult advance(const UploadJournal& current, uint32_t offset, UploadPhase phase, + UploadJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!paths() || !scratch || capacity < 108 || ¤t == &verify || !uploadJournalValid(current)) return JournalStoreResult::Invalid; + if (current.sequence == UINT64_MAX || offset < current.durable_offset || offset > current.total || + !transition(current.phase, phase) || ((phase == UploadPhase::Checking || phase == UploadPhase::Sealed) && offset != current.total)) return JournalStoreResult::Conflict; + const auto checked = validateCurrent(current, verify, scratch, capacity); + if (checked != JournalStoreResult::Ready) return checked; + if (!encodeUploadJournal(current, scratch, capacity)) return JournalStoreResult::Invalid; + for (unsigned i = 0; i < 4; ++i) scratch[93 + i] = uint8_t(offset >> (8 * i)); + scratch[97] = static_cast(phase); + const uint64_t sequence = current.sequence + 1; + for (unsigned i = 0; i < 8; ++i) scratch[98 + i] = uint8_t(sequence >> (8 * i)); + const auto crc = uploadJournalCrc(scratch, 106); + scratch[106] = uint8_t(crc); + scratch[107] = uint8_t(crc >> 8); + const char* target = sequence & 1 ? a_ : b_; + const auto opened = io_.open(target, Io::Mode::CreateTemporary); + if (!opened.handle) return JournalStoreResult::StorageFailure; + const bool written = io_.write(opened.handle, scratch, 108) == 108 && io_.sync(opened.handle); + const bool closed = io_.close(opened.handle); + if (!written || !closed) return JournalStoreResult::StorageFailure; + if (read(target, verify, scratch) != JournalStoreResult::Ready || !binding(current, verify) || + verify.sequence != sequence || verify.durable_offset != offset || verify.phase != phase) return JournalStoreResult::StorageFailure; + return JournalStoreResult::Ready; + } + JournalStoreResult load(UploadJournal& output, UploadJournal& candidate, uint8_t* scratch, size_t capacity) + { + output = {}; + if (&output == &candidate || !paths() || !scratch || capacity < 108) return JournalStoreResult::Invalid; + auto first = read(a_, output, scratch), second = read(b_, candidate, scratch); + if (first == JournalStoreResult::Ready && !(output.sequence & 1)) first = JournalStoreResult::Corrupt; + if (second == JournalStoreResult::Ready && (candidate.sequence & 1)) second = JournalStoreResult::Corrupt; + if (first == JournalStoreResult::StorageFailure || second == JournalStoreResult::StorageFailure) + { + output = {}; + return JournalStoreResult::StorageFailure; + } + if (first == JournalStoreResult::Ready && second == JournalStoreResult::Ready) + { + const auto& newer = output.sequence > candidate.sequence ? output : candidate; + const auto& older = output.sequence > candidate.sequence ? candidate : output; + if (!binding(newer, older) || newer.durable_offset < older.durable_offset || static_cast(newer.phase) < static_cast(older.phase)) + { + output = {}; + return JournalStoreResult::Conflict; + } + if (candidate.sequence > output.sequence) output = candidate; + return JournalStoreResult::Ready; + } + if (second == JournalStoreResult::Ready) + { + output = candidate; + return JournalStoreResult::Ready; + } + if (first == JournalStoreResult::Ready) return JournalStoreResult::Ready; + output = {}; + return first == JournalStoreResult::Missing && second == JournalStoreResult::Missing ? JournalStoreResult::Missing : JournalStoreResult::Corrupt; + } + JournalStoreResult commit(const UploadJournal& current, const UploadJournal& desired, UploadJournal& verify, uint8_t* scratch, size_t capacity) + { + if (!paths() || !scratch || capacity < 108 || &verify == ¤t || &verify == &desired || !uploadJournalValid(desired)) return JournalStoreResult::Invalid; + if (current.sequence) + { + if (!uploadJournalValid(current) || current.sequence == UINT64_MAX || desired.sequence != current.sequence + 1 || !binding(current, desired) || desired.durable_offset < current.durable_offset || !transition(current.phase, desired.phase)) return JournalStoreResult::Conflict; + return advance(current, desired.durable_offset, desired.phase, verify, scratch, capacity); + } + else + { + return initialize(desired, verify, scratch, capacity); + } + } + + private: + bool paths() const + { + if (!a_ || !b_ || !*a_ || !*b_) return false; + for (size_t i = 0;; ++i) + { + const auto lower = [](char c) + { return c >= 'A' && c <= 'Z' ? char(c + 32) : c; }; + if (lower(a_[i]) != lower(b_[i])) return true; + if (!a_[i]) return false; + } + } + JournalStoreResult read(const char* path, UploadJournal& output, uint8_t* scratch) + { + output = {}; + const auto opened = io_.open(path, Io::Mode::Read); + if (!opened.handle) return opened.status == Io::OpenStatus::Missing ? JournalStoreResult::Missing : JournalStoreResult::StorageFailure; + uint64_t size = 0; + const bool sized = io_.size(opened.handle, size); + const bool read = sized && size == 108 && io_.seek(opened.handle, 0) && io_.read(opened.handle, scratch, 108) == 108; + const bool closed = io_.close(opened.handle); + if (!closed || !sized) return JournalStoreResult::StorageFailure; + if (size != 108) return JournalStoreResult::Corrupt; + if (!read) return JournalStoreResult::StorageFailure; + return decodeUploadJournal(scratch, 108, output) ? JournalStoreResult::Ready : JournalStoreResult::Corrupt; + } + static bool binding(const UploadJournal& a, const UploadJournal& b) { return a.generation == b.generation && a.total == b.total && !std::memcmp(a.id, b.id, 16) && !std::memcmp(a.author_hash, b.author_hash, 32) && !std::memcmp(a.file_hash, b.file_hash, 32); } + static bool same(const UploadJournal& a, const UploadJournal& b) { return binding(a, b) && a.sequence == b.sequence && a.durable_offset == b.durable_offset && a.phase == b.phase; } + static bool transition(UploadPhase a, UploadPhase b) { return a == UploadPhase::Receiving ? (b == a || b == UploadPhase::Checking || b == UploadPhase::Failed) : a == UploadPhase::Checking ? (b == a || b == UploadPhase::Sealed || b == UploadPhase::Failed) + : b == a; } + Io& io_; + const char* a_; + const char* b_; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_paths.h b/modules/core_track_sharing/include/track_sharing/upload_paths.h new file mode 100644 index 00000000..f39e400a --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_paths.h @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include +namespace track_sharing +{ +enum class UploadKind : uint8_t +{ + Kml = 3, + Manifest = 4, + IpReceipt = 7, + AttachmentIndex = 8, + AttachmentResource = 9, + AttachmentBindings = 10, + SignedManifest = 11 +}; +constexpr uint32_t uploadByteLimit(UploadKind kind) +{ + return kind == UploadKind::Kml ? 8388608 : kind == UploadKind::Manifest ? 4096 + : kind == UploadKind::IpReceipt ? 1100 + : kind == UploadKind::AttachmentIndex ? 294912 + : kind == UploadKind::AttachmentResource ? 8388608 + : kind == UploadKind::AttachmentBindings ? 1100 + : kind == UploadKind::SignedManifest ? 4166 + : 0; +} +enum class UploadFile : uint8_t +{ + Candidate, + JournalA, + JournalB +}; +constexpr const char* kUploadDirectory = "/trailmate/web-upload"; +constexpr const char* kManifestUploadDirectory = "/trailmate/web-manifest"; +constexpr const char* kReceiptUploadDirectory = "/trailmate/web-receipt"; +constexpr const char* kAttachmentIndexUploadDirectory = "/trailmate/web-index"; +constexpr const char* kAttachmentResourceUploadDirectory = "/trailmate/web-asset"; +constexpr const char* kAttachmentBindingsUploadDirectory = "/trailmate/web-package"; +constexpr const char* kSignedManifestUploadDirectory = "/trailmate/web-signed"; +constexpr const char* uploadDirectory(UploadKind kind) +{ + return kind == UploadKind::Kml ? kUploadDirectory : kind == UploadKind::Manifest ? kManifestUploadDirectory + : kind == UploadKind::IpReceipt ? kReceiptUploadDirectory + : kind == UploadKind::AttachmentIndex ? kAttachmentIndexUploadDirectory + : kind == UploadKind::AttachmentResource ? kAttachmentResourceUploadDirectory + : kind == UploadKind::AttachmentBindings ? kAttachmentBindingsUploadDirectory + : kind == UploadKind::SignedManifest ? kSignedManifestUploadDirectory + : nullptr; +} +constexpr size_t kUploadPathCapacity = 64; +// No user path/name enters this resolver. Caller borrows a command tail or +// admitted task storage; exclusive lease and media authorization are separate. +inline bool resolveUploadPath(const uint8_t* id, size_t size, UploadFile file, char* output, size_t capacity, UploadKind kind = UploadKind::Kml) +{ + if (!id || size != 16 || !output || capacity < kUploadPathCapacity || static_cast(file) > 2 || !uploadByteLimit(kind)) return false; + const auto source = reinterpret_cast(id), destination = reinterpret_cast(output); + if (source <= destination ? destination - source < 16 : source - destination < kUploadPathCapacity) return false; + uint8_t nonzero = 0; + for (size_t i = 0; i < 16; ++i) nonzero |= id[i]; + if (!nonzero) return false; + constexpr char hex[] = "0123456789abcdef"; + const char* directory = uploadDirectory(kind); + size_t position = std::strlen(directory); + std::memcpy(output, directory, position); + output[position++] = '/'; + for (size_t i = 0; i < 16; ++i) + { + output[position++] = hex[id[i] >> 4]; + output[position++] = hex[id[i] & 15]; + } + const char* suffix = file == UploadFile::JournalA ? ".uja" : file == UploadFile::JournalB ? ".ujb" + : kind == UploadKind::Kml ? ".kml" + : ".cmp"; + std::memcpy(output + position, suffix, 5); + static_assert(sizeof("/trailmate/web-manifest/") - 1 + 32 + 5 <= kUploadPathCapacity, "Upload path exceeds borrowed tail"); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_protocol.h b/modules/core_track_sharing/include/track_sharing/upload_protocol.h new file mode 100644 index 00000000..9a18c19c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_protocol.h @@ -0,0 +1,61 @@ +#pragma once +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include "track_sharing/upload_journal.h" +#include "track_sharing/upload_paths.h" +namespace track_sharing +{ +enum class UploadCommand : uint8_t +{ + Begin, + Chunk, + Finish, + Status +}; +struct UploadRequest +{ + geocaching::ByteView id, hash, chunk; + uint32_t total = 0, offset = 0; + UploadKind kind = UploadKind::Kml; +}; +// Views borrow the command body. Platform must retain/copy scalar bindings and +// move chunk bytes out of the scratch prefix before journal I/O overwrites it. +inline bool decodeUploadRequest(UploadCommand command, geocaching::ByteView body, UploadRequest& output) +{ + output = {}; + if (!body.data || !body.size || body.size > 471 || static_cast(command) > 3) return false; + geocaching::protocol::CmpReader reader(body); + size_t fields = 0; + const size_t count = command == UploadCommand::Begin ? 4 : command == UploadCommand::Chunk ? 3 + : 1; + if (!reader.array(fields, count) || fields != count || !reader.binary(output.id, 16) || output.id.size != 16) return false; + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= output.id.data[i]; + if (!bits) return false; + uint64_t value = 0; + if (command == UploadCommand::Begin) + { + if (!reader.unsignedInteger(value) || value > UINT8_MAX || !uploadByteLimit(static_cast(value))) return false; + output.kind = static_cast(value); + if (!reader.unsignedInteger(value) || !value || value > uploadByteLimit(output.kind)) return false; + output.total = uint32_t(value); + if (!reader.binary(output.hash, 32) || output.hash.size != 32) return false; + } + else if (command == UploadCommand::Chunk) + { + if (!reader.unsignedInteger(value) || value >= 8388608 || !reader.binary(output.chunk, 320) || !output.chunk.size || output.chunk.size > 8388608 - value) return false; + output.offset = uint32_t(value); + } + return reader.finished(); +} +// Failed is returned as an error status by dispatcher, never as sealed=0. +inline bool encodeUploadProgress(const UploadJournal& journal, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!uploadJournalValid(journal) || journal.phase == UploadPhase::Failed || !output) return false; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(4) || !writer.binary({journal.id, 16}) || !writer.unsignedInteger(320) || !writer.unsignedInteger(journal.durable_offset) || !writer.unsignedInteger(journal.phase == UploadPhase::Sealed ? 1 : 0)) return false; + written = writer.size(); + return true; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_reply_receiver.h b/modules/core_track_sharing/include/track_sharing/upload_reply_receiver.h new file mode 100644 index 00000000..b6f9ab0e --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_reply_receiver.h @@ -0,0 +1,106 @@ +#pragma once +#include "chat/ports/track_delivery_binding.h" +#include "track_sharing/persist_upload_response.h" + +namespace track_sharing +{ +// Owned by the admitted job, normally in PSRAM. The router serializes callbacks; +// lease.holds() must check author/media/directory leases without reentering it. +// Remove binding from the router before destroying this object or its records. +template +class UploadReplyReceiver +{ + public: + UploadReplyReceiver(Store& store, Lease& lease, PublicationJournal& current, + PublicationJournal& next, PublicationJournal& verify, + const uint8_t local[16], uint8_t* scratch, size_t capacity) + : store_(store), lease_(lease), current_(current), next_(next), verify_(verify), + scratch_(scratch), capacity_(capacity), binding_{dispatch, this} + { + if (local) std::memcpy(local_, local, 16); + } + const chat::lxmf::TrackDeliveryBinding& binding() const { return binding_; } + bool pending() const { return pending_; } + geocaching::ByteView token() const { return {token_, has_token_ ? 16U : 0U}; } + uint16_t chunkLimit() const { return chunk_limit_; } + + // Arm before dispatch; a retry reuses the same outstanding request bytes. + // A send rejection can cancelPending(), then arm a new request. + bool arm(uint8_t operation, geocaching::ByteView request, size_t chunk_bytes = 0) + { + if (pending_ || !networkRequestId(request) || !networkRequestId({local_, 16}) || + signingSpansOverlap(request.data, request.size, reinterpret_cast(this), sizeof(*this)) || + !publicationJournalValid(current_) || !lease_.holds(current_)) return false; + if (operation == 4) + { + if (current_.phase != PublicationPhase::Accepted && current_.phase != PublicationPhase::Uploading) return false; + } + else if (operation == 5) + { + if (current_.phase != PublicationPhase::Uploading || !has_token_ || !chunk_bytes || + chunk_bytes > chunk_limit_ || current_.offset > current_.file_bytes || + chunk_bytes > current_.file_bytes - current_.offset) return false; + } + else if (operation == 6) + { + if (current_.phase != PublicationPhase::Uploading || !has_token_ || current_.offset != current_.file_bytes) return false; + } + else return false; + std::memcpy(request_, request.data, 16); + operation_ = operation; + offset_ = current_.offset; + sent_bytes_ = uint16_t(chunk_bytes); + pending_ = true; + return true; + } + void cancelPending() { pending_ = false; } + + private: + static bool dispatch(const chat::lxmf::CustomDeliveryView& view, void* context) + { + return context && static_cast(context)->receive(view); + } + bool receive(const chat::lxmf::CustomDeliveryView& view) + { + if (!pending_ || !lease_.holds(current_) || !view.source.data || view.source.size != 16 || + std::memcmp(view.source.data, current_.directory, 16) || !view.destination.data || view.destination.size != 16 || + std::memcmp(view.destination.data, local_, 16) || !view.message_hash.data || view.message_hash.size != 32 || + !view.data.data || !view.data.size || view.data.size > 8192 || + signingSpansOverlap(view.data.data, view.data.size, reinterpret_cast(this), sizeof(*this))) return false; + const geocaching::ByteView response{view.data.data, view.data.size}, request{request_, 16}; + auto result = PublicationStoreResult::Invalid; + if (operation_ == 4) + { + NetworkUploadBegin accepted; + result = persistTrackUploadBegin(store_, current_, next_, verify_, response, request, accepted, scratch_, capacity_); + if (result == PublicationStoreResult::Ready) + { + has_token_ = !accepted.sealed; + if (has_token_) std::memcpy(token_, accepted.token.data, 16); + else std::memset(token_, 0, 16); + chunk_limit_ = accepted.chunk_bytes; + } + } + else if (operation_ == 5) + result = persistTrackUploadAck(store_, current_, next_, verify_, response, request, {token_, 16}, offset_, sent_bytes_, scratch_, capacity_); + else if (operation_ == 6) + result = persistTrackUploadSealed(store_, current_, next_, verify_, response, request, scratch_, capacity_); + if (result != PublicationStoreResult::Ready) return false; + pending_ = false; + return true; + } + Store& store_; + Lease& lease_; + PublicationJournal& current_; + PublicationJournal& next_; + PublicationJournal& verify_; + uint8_t* scratch_; + size_t capacity_; + chat::lxmf::TrackDeliveryBinding binding_; + uint8_t local_[16]{}, request_[16]{}, token_[16]{}; + uint32_t offset_ = 0; + uint16_t sent_bytes_ = 0, chunk_limit_ = 0; + uint8_t operation_ = 0; + bool pending_ = false, has_token_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_resources.h b/modules/core_track_sharing/include/track_sharing/upload_resources.h new file mode 100644 index 00000000..f261ec59 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_resources.h @@ -0,0 +1,97 @@ +#pragma once +#include "track_sharing/upload_chunk_writer.h" +#include "track_sharing/upload_paths.h" +#include "track_sharing/upload_seal_task.h" +namespace track_sharing +{ +// Platform admits this object in transient PSRAM, plus the borrowed I/O driver +// and hash context. No allocation here. Candidate is created exclusively by +// platform after quota/absence checks, before initialize(). A new instance is +// required after cancellation/failure; persisted Checking is revalidated. +template +class UploadResources +{ + public: + UploadResources(Io& io, Hash& hash, const uint8_t* id, const uint32_t& generation, const uint8_t* author, UploadKind kind = UploadKind::Kml) + : io_(io), generation_(generation), author_(author), store_(io, a_, b_), writer_(io, store_, current_, verify_, candidate_, generation, author), + check_(io, hash), seal_(check_, store_, current_, verify_, generation, author), kind_(kind) + { + paths_ = resolveUploadPath(id, 16, UploadFile::Candidate, candidate_, sizeof(candidate_), kind_) && + resolveUploadPath(id, 16, UploadFile::JournalA, a_, sizeof(a_), kind_) && resolveUploadPath(id, 16, UploadFile::JournalB, b_, sizeof(b_), kind_); + if (paths_) std::memcpy(id_, id, 16); + } + UploadResources(const UploadResources&) = delete; + UploadResources& operator=(const UploadResources&) = delete; + const char* candidatePath() const { return paths_ ? candidate_ : nullptr; } + const UploadJournal& metadata() const { return current_; } + JournalStoreResult recover(uint8_t* scratch, size_t capacity) + { + if (!paths_ || started_) return JournalStoreResult::Invalid; + const auto result = store_.load(current_, verify_, scratch, capacity); + if (result != JournalStoreResult::Ready) + { + bound_ = false; + return result; + } + bound_ = current_.total <= uploadByteLimit(kind_) && author_ && generation_ && generation_ == current_.generation && !std::memcmp(author_, current_.author_hash, 32) && !std::memcmp(id_, current_.id, 16); + if (!bound_) + { + current_ = {}; + return JournalStoreResult::Conflict; + } + return JournalStoreResult::Ready; + } + JournalStoreResult initialize(uint32_t total, const uint8_t* hash, uint8_t* scratch, size_t capacity) + { + if (!paths_ || bound_ || started_ || !author_ || !generation_ || !hash || !total || total > uploadByteLimit(kind_) || !scratch || capacity < 108) return JournalStoreResult::Invalid; + // Existing candidate must be empty; do not truncate files implicitly. + const auto opened = io_.open(candidate_, Io::Mode::Read); + if (!opened.handle) return JournalStoreResult::StorageFailure; + uint64_t bytes = 0; + const bool sized = io_.size(opened.handle, bytes), closed = io_.close(opened.handle); + if (!sized || !closed) return JournalStoreResult::StorageFailure; + if (bytes) return JournalStoreResult::Conflict; + current_ = {}; + std::memcpy(current_.id, id_, 16); + std::memcpy(current_.author_hash, author_, 32); + std::memcpy(current_.file_hash, hash, 32); + current_.sequence = 1; + current_.generation = generation_; + current_.total = total; + const auto result = store_.initialize(current_, verify_, scratch, capacity); + bound_ = result == JournalStoreResult::Ready; + if (!bound_) current_ = {}; + return result; + } + JournalStoreResult append(const uint8_t* id, uint32_t offset, const uint8_t* bytes, size_t length, uint8_t* scratch, size_t capacity) + { + if (!bound_ || started_) return JournalStoreResult::Conflict; + return writer_.append(id, offset, bytes, length, scratch, capacity); + } + UploadSealResult beginSeal(uint8_t* scratch, size_t capacity) + { + if (!bound_ || started_) return UploadSealResult::Conflict; + const auto result = seal_.begin(candidate_, scratch, capacity); + started_ = result == UploadSealResult::Pending; + return result; + } + UploadSealResult step(uint8_t* scratch, size_t capacity) { return seal_.step(scratch, capacity); } + UploadSealResult cancel() { return seal_.cancel(); } + bool sealingStarted() const { return started_; } + UploadSealResult sealResult() const { return seal_.result(); } + + private: + Io& io_; + const uint32_t& generation_; + const uint8_t* author_; + char candidate_[64]{}, a_[64]{}, b_[64]{}; + uint8_t id_[16]{}; + UploadJournal current_, verify_; + UploadJournalStore store_; + UploadChunkWriter> writer_; + FileIntegrityCheck check_; + UploadSealTask, UploadJournalStore> seal_; + UploadKind kind_; + bool paths_ = false, bound_ = false, started_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_seal_task.h b/modules/core_track_sharing/include/track_sharing/upload_seal_task.h new file mode 100644 index 00000000..ccd3ecee --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_seal_task.h @@ -0,0 +1,79 @@ +#pragma once +#include "track_sharing/file_integrity.h" +#include "track_sharing/upload_journal_store.h" +namespace track_sharing +{ +enum class UploadSealResult : uint8_t +{ + Idle, + Pending, + Ready, + Invalid, + Conflict, + StorageFailure, + HashMismatch, + Cancelled +}; +// Borrowed, single-use task. Platform retains exclusive immutable candidate +// lease, media-bound I/O and authenticated current identity throughout. Sealed +// proves file bytes only; XML/manifest/revision authorization still follows. +template +class UploadSealTask +{ + public: + UploadSealTask(Check& check, Store& store, UploadJournal& current, UploadJournal& verify, + const uint32_t& generation, const uint8_t* author) + : check_(check), store_(store), current_(current), verify_(verify), generation_(generation), author_(author) {} + UploadSealResult begin(const char* path, uint8_t* scratch, size_t capacity) + { + if (result_ != UploadSealResult::Idle) return UploadSealResult::Conflict; + if (!path || !*path || !scratch || capacity < 108 || ¤t_ == &verify_ || !uploadJournalValid(current_)) return UploadSealResult::Invalid; + if (!identity() || current_.durable_offset != current_.total || + (current_.phase != UploadPhase::Receiving && current_.phase != UploadPhase::Checking)) return UploadSealResult::Conflict; + const auto journal = current_.phase == UploadPhase::Receiving ? store_.advance(current_, current_.total, UploadPhase::Checking, verify_, scratch, capacity) + : store_.validateCurrent(current_, verify_, scratch, capacity); + if (journal != JournalStoreResult::Ready) return map(journal); + if (current_.phase == UploadPhase::Receiving) current_ = verify_; + if (check_.begin(path, current_.total, current_.file_hash) != FileIntegrityResult::Pending) return result_ = UploadSealResult::StorageFailure; + return result_ = UploadSealResult::Pending; + } + UploadSealResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != UploadSealResult::Pending) return result_; + if (!identity()) + { + check_.cancel(); + return result_ = UploadSealResult::Conflict; + } + const auto status = check_.step(scratch, capacity); + if (status == FileIntegrityResult::Pending) return result_; + if (status != FileIntegrityResult::Ready && status != FileIntegrityResult::HashMismatch) return result_ = UploadSealResult::StorageFailure; + const auto journal = store_.advance(current_, current_.total, status == FileIntegrityResult::Ready ? UploadPhase::Sealed : UploadPhase::Failed, verify_, scratch, capacity); + if (journal != JournalStoreResult::Ready) return result_ = map(journal); + current_ = verify_; + return result_ = status == FileIntegrityResult::Ready ? UploadSealResult::Ready : UploadSealResult::HashMismatch; + } + UploadSealResult cancel() + { + if (result_ == UploadSealResult::Pending) + { + check_.cancel(); + result_ = UploadSealResult::Cancelled; + } + return result_; + } + UploadSealResult result() const { return result_; } + + private: + bool identity() const { return author_ && generation_ && generation_ == current_.generation && !std::memcmp(author_, current_.author_hash, 32); } + static UploadSealResult map(JournalStoreResult value) { return value == JournalStoreResult::Conflict ? UploadSealResult::Conflict : value == JournalStoreResult::Invalid ? UploadSealResult::Invalid + : UploadSealResult::StorageFailure; } + Check& check_; + Store& store_; + UploadJournal& current_; + UploadJournal& verify_; + const uint32_t& generation_; + const uint8_t* author_; + UploadSealResult result_ = UploadSealResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_send_step.h b/modules/core_track_sharing/include/track_sharing/upload_send_step.h new file mode 100644 index 00000000..b478d2af --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_send_step.h @@ -0,0 +1,73 @@ +#pragma once +#include "track_sharing/upload_reply_receiver.h" +#include + +namespace track_sharing +{ +enum class UploadSendResult : uint8_t +{ + Queued, + Waiting, + FileStored, + Rejected, + StorageFailure, + Conflict, + Invalid +}; + +// Caller holds immutable retained-KML and author/media/directory leases. Output +// (320 bytes) and file scratch (256 bytes) are borrowed job workspace. No file +// handle remains open while entering the router. Sending never advances offset. +template +UploadSendResult sendTrackUploadStep(Io& io, Receiver& receiver, Sender& sender, + const PublicationJournal& current, const char* retained_kml, + geocaching::ByteView request, const uint8_t author[64], + uint8_t* output, size_t capacity, uint8_t* file_scratch, size_t file_capacity) +{ + if (receiver.pending()) return UploadSendResult::Waiting; + if (!publicationJournalValid(current)) return UploadSendResult::Invalid; + if (current.phase == PublicationPhase::FileStored) return UploadSendResult::FileStored; + if (!retained_kml || !*retained_kml || !author || !output || capacity < 320 || !file_scratch || !file_capacity || + !networkRequestId(request) || signingSpansOverlap(output, capacity, file_scratch, file_capacity)) return UploadSendResult::Invalid; + const geocaching::ByteView inputs[] = { + request, {author, 64}, {reinterpret_cast(¤t), sizeof(current)}, {reinterpret_cast(&receiver), sizeof(receiver)}, {reinterpret_cast(&io), sizeof(io)}, {reinterpret_cast(&sender), sizeof(sender)}, {reinterpret_cast(retained_kml), std::strlen(retained_kml) + 1}}; + for (const auto input : inputs) + if (!networkInputDisjoint(input, output, capacity) || !networkInputDisjoint(input, file_scratch, file_capacity)) return UploadSendResult::Invalid; + uint8_t operation = 4; + size_t written = 0, count = 0; + bool encoded = false; + if (current.phase == PublicationPhase::Accepted || + (current.phase == PublicationPhase::Uploading && receiver.token().size != 16)) + encoded = encodeTrackUploadBegin(request, {current.file_hash, 32}, current.file_bytes, 8192, output, capacity, written); + else if (current.phase == PublicationPhase::Uploading && current.offset < current.file_bytes) + { + operation = 5; + count = std::min({size_t(256), file_capacity, size_t(receiver.chunkLimit()), size_t(current.file_bytes - current.offset)}); + if (!count) return UploadSendResult::Invalid; + const auto input = io.open(retained_kml, Io::Mode::Read); + if (!input.handle) return UploadSendResult::StorageFailure; + uint64_t bytes = 0; + const bool sized = io.size(input.handle, bytes); + const bool read = sized && bytes == current.file_bytes && io.seek(input.handle, current.offset) && + io.read(input.handle, file_scratch, count) == count; + const bool closed = io.close(input.handle); + if (!sized || !closed) return UploadSendResult::StorageFailure; + if (bytes != current.file_bytes) return UploadSendResult::Conflict; + if (!read) return UploadSendResult::StorageFailure; + encoded = encodeTrackUploadChunk(request, receiver.token(), current.offset, {file_scratch, count}, 8192, output, capacity, written); + } + else if (current.phase == PublicationPhase::Uploading && current.offset == current.file_bytes) + { + operation = 6; + encoded = encodeTrackUploadFinish(request, receiver.token(), 8192, output, capacity, written); + } + if (!encoded || !receiver.arm(operation, request, count)) return UploadSendResult::Invalid; + const auto result = sender.sendTrackData(current.directory, {output, written}, author, current.generation, false, nullptr); + if (!result.ok) + { + receiver.cancelPending(); + return UploadSendResult::Rejected; + } + return UploadSendResult::Queued; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_session.h b/modules/core_track_sharing/include/track_sharing/upload_session.h new file mode 100644 index 00000000..8d1beacc --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_session.h @@ -0,0 +1,83 @@ +#pragma once +#include "track_sharing/upload_seal_task.h" +namespace track_sharing +{ +// Serialized platform management session. Stopping cancels hashing but retains +// durable files; recovery requires a new owner and authenticated session. +template +class UploadSession +{ + public: + UploadSession(Resources& owner, const uint8_t* session, uint32_t media, uint64_t now) + : owner_(owner), media_(media), touched_(now) + { + uint8_t bits = 0; + if (session) + for (unsigned i = 0; i < 16; ++i) bits |= session[i]; + active_ = bits != 0; + if (active_) std::memcpy(session_, session, 16); + } + bool matches(const uint8_t* session, uint32_t media, uint64_t now) + { + if (!alive(media, now, true)) return false; + if (!session || std::memcmp(session_, session, 16)) return false; + return true; + } + bool authorize(const uint8_t* session, uint32_t media, uint64_t now) + { + if (!matches(session,media,now)) return false; + touched_ = now; + return true; + } + JournalStoreResult append(const uint8_t* session, uint32_t media, uint64_t now, const uint8_t* id, + uint32_t offset, const uint8_t* bytes, size_t length, uint8_t* scratch, size_t capacity) + { + if (!matches(session, media, now)) return JournalStoreResult::Conflict; + const auto result = owner_.append(id, offset, bytes, length, scratch, capacity); + if (result == JournalStoreResult::Ready) touched_ = now; + return result; + } + UploadSealResult beginSeal(const uint8_t* session, uint32_t media, uint64_t now, uint8_t* scratch, size_t capacity) + { + if (!matches(session, media, now)) return UploadSealResult::Conflict; + const auto result = owner_.beginSeal(scratch, capacity); + if (result == UploadSealResult::Pending) touched_ = now; + return result; + } + UploadSealResult advance(uint32_t media, uint64_t now, bool connected, uint8_t* scratch, size_t capacity) + { + if (!alive(media, now, connected)) return UploadSealResult::Cancelled; + return owner_.step(scratch, capacity); + } + bool close(const uint8_t* session) + { + if (!active_ || !session || std::memcmp(session_, session, 16)) return false; + stop(); + return true; + } + bool releasable() const { return !active_; } + static constexpr uint64_t kIdleTimeoutMs = 30000; + + private: + void stop() + { + owner_.cancel(); + active_ = false; + } + bool alive(uint32_t media, uint64_t now, bool connected) + { + if (!active_) return false; + if (!connected || media != media_ || now < touched_ || now - touched_ >= kIdleTimeoutMs) + { + stop(); + return false; + } + return true; + } + Resources& owner_; + uint32_t media_; + uint64_t touched_; + uint8_t session_[16]{}; + bool active_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/upload_space_budget.h b/modules/core_track_sharing/include/track_sharing/upload_space_budget.h new file mode 100644 index 00000000..e75e817b --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/upload_space_budget.h @@ -0,0 +1,41 @@ +#pragma once +#include "track_sharing/upload_paths.h" +#include +namespace track_sharing +{ +class UploadSpaceBudget +{ + public: + explicit UploadSpaceBudget(uint32_t bytes, uint32_t maximum = 8388608) : valid_((maximum == 8388608 || maximum == 4096 || maximum == 4166 || maximum == 1100 || maximum == 294912) && bytes && bytes <= maximum) + { + if (valid_) remaining_ = 2ULL * (maximum + 216) - bytes - 216; + } + UploadSpaceBudget(uint32_t bytes, UploadKind kind) : UploadSpaceBudget(bytes, uploadByteLimit(kind)) + { + if (valid_ && kind == UploadKind::AttachmentResource) + { + // One admitted 64-resource package; every unknown/orphan file and + // journal counts. Does not enlarge the original two-KML quota. + remaining_ = 64ULL * 1024 * 1024 + 64 * 216 - bytes - 216; + file_limit_ = 64 * 3; + } + } + bool add(uint64_t bytes, bool directory) + { + if (!valid_ || directory || files_ >= file_limit_ - 3 || bytes > remaining_) return valid_ = false; + ++files_; + remaining_ -= bytes; + return true; + } + bool complete() const { return valid_; } + static constexpr uint64_t kBytes = 2ULL * (8388608 + 216); + static constexpr unsigned kFiles = 6; + + private: + uint64_t remaining_ = 0; + uint16_t files_ = 0; + uint16_t file_limit_ = kFiles; + bool valid_; +}; +static_assert(sizeof(UploadSpaceBudget) <= 16, "Upload quota cannot hold a directory list"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/uploaded_candidate_check.h b/modules/core_track_sharing/include/track_sharing/uploaded_candidate_check.h new file mode 100644 index 00000000..5d09a7d6 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/uploaded_candidate_check.h @@ -0,0 +1,115 @@ +#pragma once +#include "track_sharing/manifest_file_check.h" +#include "track_sharing/prepare_request.h" +#include "track_sharing/uploaded_manifest_reader.h" +namespace track_sharing +{ +// One admitted preparation job borrows paths, identity, journals, manifest +// output and XML name stack. No second hash context or owned manifest buffer. +template +class UploadedCandidateCheck +{ + public: + UploadedCandidateCheck(Io& io, Hash& hash, const UploadJournal& manifest_upload, const UploadJournal& kml_upload, + const uint32_t& generation, const uint8_t* author_hash, void* names, size_t capacity, ManifestView& view) + : reader_(io, hash, manifest_upload, generation, author_hash), file_(io, hash, names, capacity), + manifest_upload_(manifest_upload), kml_upload_(kml_upload), generation_(generation), author_hash_(author_hash), view_(view) {} + ManifestFileResult begin(const PrepareRequest& request, const char* manifest_path, const char* kml_path, uint8_t* output, size_t capacity, geocaching::ByteView author) + { + if (result_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!kml_path || !author.data || author.size != 64 || !uploadJournalValid(kml_upload_) || kml_upload_.phase != UploadPhase::Sealed || + !std::memcmp(manifest_upload_.id, kml_upload_.id, 16)) return ManifestFileResult::Invalid; + if (!request.operation.data || request.operation.size != 16 || !request.kml_upload.data || request.kml_upload.size != 16 || + !request.manifest_upload.data || request.manifest_upload.size != 16 || request.generation != generation_ || + std::memcmp(request.kml_upload.data, kml_upload_.id, 16) || std::memcmp(request.manifest_upload.data, manifest_upload_.id, 16) || + (request.expected_parent.size && (!request.expected_parent.data || request.expected_parent.size != 32))) return ManifestFileResult::Invalid; + uint8_t operation_bits = 0; + for (size_t i = 0; i < 16; ++i) operation_bits |= request.operation.data[i]; + if (!operation_bits) return ManifestFileResult::Invalid; + const auto read = reader_.begin(manifest_path, output, capacity); + if (read != FileIntegrityResult::Pending) return result_ = map(read); + kml_path_ = kml_path; + author_ = author; + expected_parent_ = request.expected_parent; + return result_ = ManifestFileResult::Pending; + } + ManifestFileResult step(uint8_t* scratch, size_t capacity) + { + KmlOnlyPackage denied; + return step(scratch, capacity, denied); + } + template + ManifestFileResult step(uint8_t* scratch, size_t capacity, Package& package) + { + if (result_ != ManifestFileResult::Pending) return result_; + if (!author_hash_ || generation_ != kml_upload_.generation || std::memcmp(author_hash_, kml_upload_.author_hash, 32)) + { + reader_.cancel(); + file_.cancel(); + return result_ = ManifestFileResult::Invalid; + } + if (!checking_file_) + { + const auto read = reader_.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return result_; + if (read != FileIntegrityResult::Ready) return result_ = map(read); + if (!decodeManifest(reader_.bytes(), view_) || std::memcmp(view_.author.data, author_.data, 64) || + view_.file_bytes != kml_upload_.total || std::memcmp(view_.file_hash.data, kml_upload_.file_hash, 32)) return result_ = ManifestFileResult::Invalid; + if (view_.version == 2) + { + const auto checked = package.checkAttachments(view_, scratch, capacity); + if (checked == ManifestFileResult::Pending) return result_; + if (checked != ManifestFileResult::Ready) return result_ = checked; + result_ = file_.beginUploadedPackageCandidate(kml_path_, reader_.bytes(), author_, kml_upload_, generation_, {author_hash_, 32}, view_, package); + } + else result_ = file_.beginUploadedCandidate(kml_path_, reader_.bytes(), author_, kml_upload_, generation_, {author_hash_, 32}, view_); + if (result_ == ManifestFileResult::Pending && + (view_.revision == 1 ? expected_parent_.size != 0 : expected_parent_.size != 32 || std::memcmp(expected_parent_.data, view_.previous.data, 32))) + { + file_.cancel(); + return result_ = ManifestFileResult::Invalid; + } + checking_file_ = result_ == ManifestFileResult::Pending; + return result_; + } + return result_ = file_.step(scratch, capacity); + } + ManifestFileResult cancel() + { + if (result_ == ManifestFileResult::Pending) + { + reader_.cancel(); + file_.cancel(); + result_ = ManifestFileResult::Cancelled; + } + return result_; + } + bool ready() const { return result_ == ManifestFileResult::Ready && file_.ready(); } + bool matchesCandidate(const ManifestView& candidate) const { return ready() && file_.matchesCandidate(candidate); } + geocaching::ByteView manifest() const { return ready() ? reader_.bytes() : geocaching::ByteView{}; } + + private: + struct KmlOnlyPackage + { + ManifestFileResult checkAttachments(const ManifestView&, uint8_t*, size_t) { return ManifestFileResult::Invalid; } + bool matchesAttachments(const ManifestView&) const { return false; } + }; + static ManifestFileResult map(FileIntegrityResult result) + { + return result == FileIntegrityResult::HashMismatch ? ManifestFileResult::HashMismatch : result == FileIntegrityResult::StorageFailure ? ManifestFileResult::StorageFailure + : result == FileIntegrityResult::Cancelled ? ManifestFileResult::Cancelled + : ManifestFileResult::Invalid; + } + UploadedManifestReader reader_; + ManifestFileCheck file_; + const UploadJournal& manifest_upload_; + const UploadJournal& kml_upload_; + const uint32_t& generation_; + const uint8_t* author_hash_; + ManifestView& view_; + const char* kml_path_ = nullptr; + geocaching::ByteView author_, expected_parent_; + ManifestFileResult result_ = ManifestFileResult::Idle; + bool checking_file_ = false; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/uploaded_manifest_reader.h b/modules/core_track_sharing/include/track_sharing/uploaded_manifest_reader.h new file mode 100644 index 00000000..607b4e06 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/uploaded_manifest_reader.h @@ -0,0 +1,69 @@ +#pragma once +#include "geocaching/domain/record.h" +#include "track_sharing/file_integrity.h" +#include "track_sharing/upload_journal.h" +namespace track_sharing +{ +// Caller owns an immutable structured-upload path/journal lease and admitted PSRAM +// output. Copy exactly the bytes hashed, never reread after hash verification. +template +class UploadedManifestReader +{ + static_assert(MaximumBytes > 0 && MaximumBytes <= 4166, "Structured upload reader must remain bounded"); + + public: + UploadedManifestReader(Io& io, Hash& hash, const UploadJournal& journal, const uint32_t& generation, const uint8_t* author) + : check_(io, hash), journal_(journal), generation_(generation), author_(author) {} + FileIntegrityResult begin(const char* path, uint8_t* output, size_t capacity) + { + if (output_) return FileIntegrityResult::Busy; + if (!output || !uploadJournalValid(journal_) || journal_.phase != UploadPhase::Sealed || journal_.total > MaximumBytes || capacity < journal_.total || !identityMatches()) return FileIntegrityResult::Invalid; + const auto target = reinterpret_cast(output); + const auto overlaps = [&](const void* source, size_t length) + { const auto a = reinterpret_cast(source); return a <= target ? target - a < length : a - target < journal_.total; }; + if (!path || overlaps(this, sizeof(*this)) || overlaps(&journal_, sizeof(journal_)) || overlaps(author_, 32) || overlaps(&generation_, sizeof(generation_)) || overlaps(path, std::strlen(path) + 1)) return FileIntegrityResult::Invalid; + const auto result = check_.begin(path, journal_.total, journal_.file_hash); + if (result == FileIntegrityResult::Pending) + { + output_ = output; + bytes_ = journal_.total; + } + return result_ = result; + } + FileIntegrityResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != FileIntegrityResult::Pending) return result_; + if (!identityMatches()) return stop(FileIntegrityResult::Invalid); + const auto a = reinterpret_cast(scratch); + const auto overlaps = [&](const void* source, size_t length) + { const auto b = reinterpret_cast(source); return a <= b ? b - a < capacity : a - b < length; }; + if (!scratch || overlaps(output_, bytes_) || overlaps(this, sizeof(*this)) || overlaps(&journal_, sizeof(journal_)) || + overlaps(author_, 32) || overlaps(&generation_, sizeof(generation_))) return stop(FileIntegrityResult::Invalid); + const auto before = check_.checkedBytes(); + const auto status = check_.step(scratch, capacity); + const auto count = check_.checkedBytes() - before; + if (count) std::memcpy(output_ + before, scratch, count); + if (status == FileIntegrityResult::Pending) return result_; + if (status != FileIntegrityResult::Ready) return stop(status); + return result_ = status; + } + FileIntegrityResult cancel() { return result_ == FileIntegrityResult::Pending ? stop(FileIntegrityResult::Cancelled) : result_; } + geocaching::ByteView bytes() const { return result_ == FileIntegrityResult::Ready && identityMatches() ? geocaching::ByteView{output_, bytes_} : geocaching::ByteView{}; } + + private: + bool identityMatches() const { return author_ && generation_ && generation_ == journal_.generation && !std::memcmp(author_, journal_.author_hash, 32); } + FileIntegrityResult stop(FileIntegrityResult status) + { + check_.cancel(); + if (output_) std::memset(output_, 0, bytes_); + return result_ = status; + } + FileIntegrityCheck check_; + const UploadJournal& journal_; + const uint32_t& generation_; + const uint8_t* author_; + uint8_t* output_ = nullptr; + uint32_t bytes_ = 0; + FileIntegrityResult result_ = FileIntegrityResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/verified_file_publish.h b/modules/core_track_sharing/include/track_sharing/verified_file_publish.h new file mode 100644 index 00000000..c89012b6 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/verified_file_publish.h @@ -0,0 +1,55 @@ +#pragma once +#include "track_sharing/file_integrity.h" +#include "track_sharing/sign_manifest.h" +namespace track_sharing +{ +// Requires exclusive canonical paths/media and stable expected hash supplied by +// the admitted owner. Ready includes a complete read-back after rename. +template +class VerifiedFilePublish +{ + public: + VerifiedFilePublish(Io& io, Hash& hash) : io_(io), check_(io, hash) {} + FileIntegrityResult begin(const char* temporary, const char* destination, uint32_t bytes, const uint8_t* expected) + { + if (check_.active()) return FileIntegrityResult::Busy; + result_ = FileIntegrityResult::Invalid; + if (!temporary || !destination || !expected || !*temporary || !*destination || !std::strcmp(temporary, destination) || + !bytes || bytes > 8388608 || signingSpansOverlap(expected, 32, reinterpret_cast(this), sizeof(*this))) return FileIntegrityResult::Invalid; + temporary_ = temporary; destination_ = destination; expected_ = expected; bytes_ = bytes; final_ = false; + const auto existing = io_.open(destination, Io::Mode::Read); + if (existing.handle) + { + if (!io_.close(existing.handle)) return result_ = FileIntegrityResult::StorageFailure; + final_ = true; + } + else if (existing.status != Io::OpenStatus::Missing) return result_ = FileIntegrityResult::StorageFailure; + return result_ = check_.begin(final_ ? destination_ : temporary_, bytes_, expected_); + } + FileIntegrityResult step(uint8_t* scratch, size_t capacity) + { + if (result_ != FileIntegrityResult::Pending) return result_; + if (!scratch || signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this)) || + signingSpansOverlap(scratch, capacity, expected_, 32) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(temporary_), std::strlen(temporary_) + 1) || + signingSpansOverlap(scratch, capacity, reinterpret_cast(destination_), std::strlen(destination_) + 1)) + { check_.cancel(); return result_ = FileIntegrityResult::Invalid; } + const auto checked = check_.step(scratch, capacity); + if (checked != FileIntegrityResult::Ready) return result_ = checked; + if (final_) return result_ = FileIntegrityResult::Ready; + if (!io_.publish(temporary_, destination_)) return result_ = FileIntegrityResult::StorageFailure; + final_ = true; + return result_ = check_.begin(destination_, bytes_, expected_); + } + FileIntegrityResult cancel() { check_.cancel(); return result_ = FileIntegrityResult::Cancelled; } + private: + Io& io_; + FileIntegrityCheck check_; + const char* temporary_ = nullptr; + const char* destination_ = nullptr; + const uint8_t* expected_ = nullptr; + uint32_t bytes_ = 0; + bool final_ = false; + FileIntegrityResult result_ = FileIntegrityResult::Idle; +}; +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/verify_ip_confirmation.h b/modules/core_track_sharing/include/track_sharing/verify_ip_confirmation.h new file mode 100644 index 00000000..512deced --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/verify_ip_confirmation.h @@ -0,0 +1,94 @@ +#pragma once +#include "track_sharing/ip_confirmation_request.h" +#include "track_sharing/verify_manifest.h" +#include "track_sharing/verify_receipt.h" +namespace track_sharing +{ +// Caller supplies authenticated CURRENT identity and an immutable verified local +// signed manifest. No RF service, owned payload buffer or heap allocation. +// Successful outputs lend the unchanged signed receipt until persistence ends. +template +ReceiptVerification verifyIpConfirmation(const IpConfirmationRequest& request, geocaching::ByteView signed_receipt, + const VerifiedManifestView& manifest, uint32_t generation, + geocaching::ByteView author, geocaching::ByteView author_hash, + Crypto& crypto, Hash& hash, uint8_t* workspace, size_t capacity, + PublicationJournal& expected, VerifiedReceiptView& proof) +{ + const auto job_bytes = reinterpret_cast(&expected); + const auto proof_bytes = reinterpret_cast(&proof); + if (signingSpansOverlap(job_bytes, sizeof(expected), proof_bytes, sizeof(proof))) return ReceiptVerification::InvalidReceipt; + for (const auto input : {geocaching::ByteView{reinterpret_cast(&request), sizeof(request)}, + {reinterpret_cast(&manifest), sizeof(manifest)}, + request.operation, + request.transfer, + request.proof_hash, + request.directory_public, + request.directory, + request.epoch, + request.network_request, + request.object, + request.revision_hash, + signed_receipt, + manifest.manifest.raw, + manifest.signature, + author, + author_hash}) + if (signingSpansOverlap(input.data, input.size, job_bytes, sizeof(expected)) || + signingSpansOverlap(input.data, input.size, proof_bytes, sizeof(proof))) return ReceiptVerification::InvalidReceipt; + expected = {}; + proof = {}; + if (!manifest.verified || !generation || request.generation != generation || + !author.data || author.size != 64 || !author_hash.data || author_hash.size != 32 || + !manifest.manifest.author.data || manifest.manifest.author.size != 64 || + std::memcmp(author.data, manifest.manifest.author.data, 64) || + !signed_receipt.data || !signed_receipt.size || signed_receipt.size > 1100 || + !manifest.manifest.file_hash.data || manifest.manifest.file_hash.size != 32 || + request.revision != manifest.manifest.revision) return ReceiptVerification::BindingMismatch; + const auto span = [](geocaching::ByteView field, size_t size) + { return field.data && field.size == size; }; + if (!span(request.operation, 16) || !span(request.transfer, 16) || !span(request.proof_hash, 32) || + !span(request.directory_public, 64) || !span(request.directory, 16) || !span(request.epoch, 16) || + !span(request.network_request, 16) || !span(request.object, 32) || !span(request.revision_hash, 32)) return ReceiptVerification::BindingMismatch; + if (std::memcmp(request.object.data, manifest.object_id, 32) || + std::memcmp(request.revision_hash.data, manifest.revision_hash, 32)) return ReceiptVerification::BindingMismatch; + if (!workspace || capacity < sizeof("TrailMate.Track.Receipt.v1") + 1024) return ReceiptVerification::WorkspaceTooSmall; + for (const auto input : {signed_receipt, author, author_hash, request.operation, request.transfer, request.proof_hash, request.directory_public, request.directory, request.epoch, request.network_request, request.object, request.revision_hash, manifest.manifest.raw, manifest.signature, geocaching::ByteView{reinterpret_cast(&request), sizeof(request)}, {reinterpret_cast(&manifest), sizeof(manifest)}, {job_bytes, sizeof(expected)}, {proof_bytes, sizeof(proof)}}) + if (signingSpansOverlap(input.data, input.size, workspace, capacity)) return ReceiptVerification::WorkspaceTooSmall; + const auto digest = [&](geocaching::ByteView input, uint8_t* output) + { + const bool ok = hash.start() && hash.update(input.data, input.size) && hash.finish(output); + hash.reset(); + return ok; + }; + if (!digest(author, workspace) || std::memcmp(workspace, author_hash.data, 32)) return ReceiptVerification::BindingMismatch; + if (!digest(signed_receipt, workspace) || std::memcmp(workspace, request.proof_hash.data, 32)) return ReceiptVerification::BindingMismatch; + // Reticulum LXMF delivery derives from the combined public key, not from + // an arbitrary USB-supplied destination sharing a valid signing key. + if (!digest(request.directory_public, workspace)) return ReceiptVerification::InvalidReceipt; + std::memmove(workspace + 10, workspace, 16); + constexpr char service[] = "lxmf.delivery"; + if (!digest({reinterpret_cast(service), sizeof(service) - 1}, workspace + 32)) return ReceiptVerification::InvalidReceipt; + std::memcpy(workspace, workspace + 32, 10); + if (!digest({workspace, 26}, workspace + 32) || std::memcmp(workspace + 32, request.directory.data, 16)) return ReceiptVerification::BindingMismatch; + std::memcpy(expected.operation, request.operation.data, 16); + std::memcpy(expected.object, manifest.object_id, 32); + std::memcpy(expected.revision_hash, manifest.revision_hash, 32); + std::memcpy(expected.directory, request.directory.data, 16); + std::memcpy(expected.directory_epoch, request.epoch.data, 16); + std::memcpy(expected.file_hash, manifest.manifest.file_hash.data, 32); + std::memcpy(expected.author_hash, author_hash.data, 32); + expected.sequence = 1; + expected.generation = generation; + expected.revision = request.revision; + expected.file_bytes = expected.offset = manifest.manifest.file_bytes; + expected.phase = PublicationPhase::Publishing; + const auto result = verifyReceipt(signed_receipt, expected, request.network_request, request.directory_public, + manifest.manifest.state, crypto, workspace, capacity, proof); + if (result != ReceiptVerification::Valid) + { + expected = {}; + proof = {}; + } + return result; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/verify_manifest.h b/modules/core_track_sharing/include/track_sharing/verify_manifest.h new file mode 100644 index 00000000..51022f15 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/verify_manifest.h @@ -0,0 +1,55 @@ +#pragma once +#include "track_sharing/manifest_hash.h" +namespace track_sharing +{ +enum class ManifestVerification : uint8_t +{ + Valid, + InvalidManifest, + InvalidSignature, + IdentityMismatch, + WorkspaceTooSmall, + CryptoUnavailable +}; +struct VerifiedManifestView +{ + ManifestView manifest; + geocaching::ByteView signature; + uint8_t object_id[32]{}, revision_hash[32]{}; + bool verified = false; +}; +static_assert(sizeof(VerifiedManifestView) <= 304, "Verified manifest must borrow all content"); +// Caller admits output and domain workspace separately; none is an automatic +// protocol object here. Crypto verifies Ed25519, Hash lends its stream context. +// Successful verification establishes author integrity, not file/currentness. +template +ManifestVerification verifyManifest(geocaching::ByteView signed_manifest, Crypto& crypto, Hash& hash, + uint8_t* workspace, size_t capacity, VerifiedManifestView& output, + geocaching::ByteView expected_author = {}) +{ + if (signingSpansOverlap(signed_manifest.data, signed_manifest.size, reinterpret_cast(&output), sizeof(output))) + { + output.verified = false; + return ManifestVerification::InvalidManifest; + } + output = {}; + if (!signed_manifest.data || !signed_manifest.size || signed_manifest.size > 4166) return ManifestVerification::InvalidManifest; + geocaching::protocol::CmpReader reader(signed_manifest); + size_t fields = 0; + geocaching::ByteView raw, signature; + if (!reader.array(fields, 2) || fields != 2 || !reader.binary(raw, 4096) || !reader.binary(signature, 64) || signature.size != 64 || !reader.finished() || !decodeManifest(raw, output.manifest)) return ManifestVerification::InvalidManifest; + if (expected_author.size && (expected_author.size != 64 || !expected_author.data || std::memcmp(expected_author.data, output.manifest.author.data, 64))) return ManifestVerification::IdentityMismatch; + constexpr char domain[] = "TrailMate.Track.Manifest.v1"; + if (!workspace || capacity < sizeof(domain) + raw.size || signingSpansOverlap(signed_manifest.data, signed_manifest.size, workspace, capacity) || + signingSpansOverlap(workspace, capacity, reinterpret_cast(&output), sizeof(output))) return ManifestVerification::WorkspaceTooSmall; + std::memcpy(workspace, domain, sizeof(domain)); + if (output.manifest.version == 2) workspace[sizeof(domain) - 2] = '2'; + std::memcpy(workspace + sizeof(domain), raw.data, raw.size); + const auto verified = crypto.verify({output.manifest.author.data + 32, 32}, signature, {workspace, sizeof(domain) + raw.size}); + if (verified != ManifestVerification::Valid) return verified; + if (!deriveManifestHashes(raw, output.manifest, hash, output.object_id, output.revision_hash)) return ManifestVerification::CryptoUnavailable; + output.signature = signature; + output.verified = true; + return ManifestVerification::Valid; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/verify_read_manifest.h b/modules/core_track_sharing/include/track_sharing/verify_read_manifest.h new file mode 100644 index 00000000..c5c9066c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/verify_read_manifest.h @@ -0,0 +1,51 @@ +#pragma once +#include "track_sharing/network_read_response.h" +#include "track_sharing/verify_manifest.h" + +namespace track_sharing +{ +enum class ReadManifestVerification : uint8_t +{ + Valid, + InvalidResponse, + InvalidSignature, + ReferenceMismatch, + CryptoUnavailable, + WorkspaceTooSmall +}; + +// Borrows the canonical signed element from the original response. The owner +// provides verification output/domain workspace/hash context in admitted memory. +template +ReadManifestVerification verifyReadManifest(geocaching::ByteView response, geocaching::ByteView request, + geocaching::ByteView object, geocaching::ByteView revision, + Crypto& crypto, Hash& hash, uint8_t* workspace, size_t capacity, + VerifiedManifestView& output) +{ + output.verified = false; + if (!object.data || object.size != 32 || (revision.size && (!revision.data || revision.size != 32)) || + signingSpansOverlap(object.data, object.size, reinterpret_cast(&output), sizeof(output)) || + signingSpansOverlap(revision.data, revision.size, reinterpret_cast(&output), sizeof(output)) || + signingSpansOverlap(object.data, object.size, workspace, capacity) || signingSpansOverlap(revision.data, revision.size, workspace, capacity)) return ReadManifestVerification::InvalidResponse; + ReadManifestResponse manifest; + ReadResponseView envelope; + if (!decodeTrackReadManifestResponse(response, request, manifest) || !decodeTrackReadEnvelope(response, request, 2, envelope) || + envelope.body.size < 3 || envelope.body.data[0] != 0x92 || envelope.body.data[envelope.body.size - 1] > 1) return ReadManifestVerification::InvalidResponse; + // Canonical array(2) and boolean-as-uint(0/1) each occupy one byte. + const geocaching::ByteView signed_manifest{envelope.body.data + 1, envelope.body.size - 2}; + const auto result = verifyManifest(signed_manifest, crypto, hash, workspace, capacity, output); + if (result != ManifestVerification::Valid) + { + if (result == ManifestVerification::InvalidSignature) return ReadManifestVerification::InvalidSignature; + if (result == ManifestVerification::WorkspaceTooSmall) return ReadManifestVerification::WorkspaceTooSmall; + if (result == ManifestVerification::CryptoUnavailable) return ReadManifestVerification::CryptoUnavailable; + return ReadManifestVerification::InvalidResponse; + } + if (std::memcmp(object.data, output.object_id, 32) || (revision.size && std::memcmp(revision.data, output.revision_hash, 32))) + { + output.verified = false; + return ReadManifestVerification::ReferenceMismatch; + } + return ReadManifestVerification::Valid; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/verify_receipt.h b/modules/core_track_sharing/include/track_sharing/verify_receipt.h new file mode 100644 index 00000000..a1d2c4e4 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/verify_receipt.h @@ -0,0 +1,35 @@ +#pragma once +#include "track_sharing/receipt.h" +namespace track_sharing +{ +enum class ReceiptVerification : uint8_t { Valid, InvalidReceipt, BindingMismatch, InvalidSignature, WorkspaceTooSmall }; +struct VerifiedReceiptView +{ + ReceiptView receipt; + geocaching::ByteView signature; + bool verified = false; +}; +static_assert(sizeof(VerifiedReceiptView)<=192,"Verified receipt must borrow signed bytes"); +template +ReceiptVerification verifyReceipt(geocaching::ByteView signed_receipt,const PublicationJournal& job,geocaching::ByteView request, + geocaching::ByteView directory_public,uint8_t state,Crypto& crypto,uint8_t* workspace,size_t capacity, + VerifiedReceiptView& output) +{ + const auto* target=reinterpret_cast(&output); + for(const auto field:{signed_receipt,request,directory_public,geocaching::ByteView{reinterpret_cast(&job),sizeof(job)}}) + if(signingSpansOverlap(field.data,field.size,target,sizeof(output)))return ReceiptVerification::InvalidReceipt; + output={}; + if(!signed_receipt.data||!signed_receipt.size||signed_receipt.size>1100)return ReceiptVerification::InvalidReceipt; + geocaching::protocol::CmpReader reader(signed_receipt);size_t fields=0;geocaching::ByteView raw,signature; + if(!reader.array(fields,2)||fields!=2||!reader.binary(raw,1024)||!reader.binary(signature,64)||signature.size!=64|| + !reader.finished()||!decodeReceipt(raw,output.receipt))return ReceiptVerification::InvalidReceipt; + if(!receiptMatchesJob(output.receipt,job,request,directory_public,state))return ReceiptVerification::BindingMismatch; + constexpr char domain[]="TrailMate.Track.Receipt.v1"; + if(!workspace||capacity(&job),sizeof(job)}}) + if(signingSpansOverlap(field.data,field.size,workspace,capacity))return ReceiptVerification::WorkspaceTooSmall; + std::memcpy(workspace,domain,sizeof(domain));std::memcpy(workspace+sizeof(domain),raw.data,raw.size); + if(crypto.verify({directory_public.data+32,32},signature,{workspace,sizeof(domain)+raw.size})!=ReceiptVerification::Valid)return ReceiptVerification::InvalidSignature; + output.signature=signature;output.verified=true;return ReceiptVerification::Valid; +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/xml_declaration_guard.h b/modules/core_track_sharing/include/track_sharing/xml_declaration_guard.h new file mode 100644 index 00000000..c046a31c --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/xml_declaration_guard.h @@ -0,0 +1,176 @@ +#pragma once +#include +#include +namespace track_sharing +{ +class XmlDeclarationGuard +{ + public: + bool feed(uint8_t byte) + { + if (state_ == State::Failed) return false; + if (state_ == State::Done) return true; + if (state_ == State::Initial) + { + state_ = byte == 0xef ? State::BomSecond : State::Prefix; + if (byte == 0xef) return true; + } + if (state_ == State::BomSecond) + { + if (byte != 0xbb) return fail(); + state_ = State::BomThird; + return true; + } + if (state_ == State::BomThird) + { + if (byte != 0xbf) return fail(); + state_ = State::Prefix; + return true; + } + if (state_ == State::Prefix) + { + constexpr char prefix[] = " 'z' || position_ >= 10) return fail(); + name_[position_++] = char(byte); + return true; + } + if (state_ == State::Equals) + { + if (space(byte)) return true; + if (byte != '=') return fail(); + state_ = State::Quote; + return true; + } + if (state_ == State::Quote) + { + if (space(byte)) return true; + if (byte != '\'' && byte != '"') return fail(); + quote_ = byte; + position_ = 0; + state_ = State::Value; + return true; + } + if (state_ == State::Value) + { + if (byte == quote_) + { + if (!value()) return fail(); + state_ = State::Separator; + return true; + } + if (position_ >= 16 || byte >= 128) return fail(); + value_[position_++] = char(byte); + return true; + } + if (state_ == State::Separator) + { + if (space(byte)) + { + position_ = 0; + state_ = State::Name; + return true; + } + if (byte == '?') + { + state_ = State::End; + return true; + } + return fail(); + } + if (state_ == State::End) + { + if (byte != '>') return fail(); + state_ = State::Done; + return true; + } + return fail(); + } + bool complete() const { return state_ == State::Done; } + + private: + enum class State : uint8_t + { + Initial, + BomSecond, + BomThird, + Prefix, + AfterPrefix, + Name, + Equals, + Quote, + Value, + Separator, + End, + Done, + Failed + }; + static bool space(uint8_t byte) { return byte == 9 || byte == 10 || byte == 13 || byte == 32; } + bool fail() + { + state_ = State::Failed; + return false; + } + bool field() + { + const std::string_view name(name_, position_); + field_ = name == "version" ? 1 : name == "encoding" ? 2 + : name == "standalone" ? 4 + : 0; + if (!field_ || (seen_ & field_) || (!seen_ && field_ != 1) || (seen_ & 4)) return false; + seen_ |= field_; + return true; + } + bool value() + { + const std::string_view text(value_, position_); + if (field_ == 1) return text == "1.0"; + if (field_ == 4) return text == "yes" || text == "no"; + if (text.size() != 5) return false; + constexpr char utf[] = "UTF-8"; + for (unsigned i = 0; i < 5; ++i) + { + const auto ch = text[i] >= 'a' && text[i] <= 'z' ? char(text[i] - 32) : text[i]; + if (ch != utf[i]) return false; + } + return true; + } + char name_[10]{}, value_[16]{}; + uint8_t position_ = 0, seen_ = 0, field_ = 0, quote_ = 0; + State state_ = State::Initial; +}; +static_assert(sizeof(XmlDeclarationGuard) <= 32, "XML declaration guard must not buffer a prolog"); +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/xml_name.h b/modules/core_track_sharing/include/track_sharing/xml_name.h new file mode 100644 index 00000000..8dbaf3f5 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/xml_name.h @@ -0,0 +1,61 @@ +#pragma once +#include +#include +namespace track_sharing +{ +inline bool xmlNameStart(uint32_t cp, bool nc) +{ + return (!nc && cp == ':') || cp == '_' || (cp >= 'A' && cp <= 'Z') || (cp >= 'a' && cp <= 'z') || + (cp >= 0xc0 && cp <= 0xd6) || (cp >= 0xd8 && cp <= 0xf6) || (cp >= 0xf8 && cp <= 0x2ff) || + (cp >= 0x370 && cp <= 0x37d) || (cp >= 0x37f && cp <= 0x1fff) || (cp >= 0x200c && cp <= 0x200d) || + (cp >= 0x2070 && cp <= 0x218f) || (cp >= 0x2c00 && cp <= 0x2fef) || (cp >= 0x3001 && cp <= 0xd7ff) || + (cp >= 0xf900 && cp <= 0xfdcf) || (cp >= 0xfdf0 && cp <= 0xfffd) || (cp >= 0x10000 && cp <= 0xeffff); +} +inline bool validXmlName(std::string_view name, bool nc = true) +{ + if (name.empty()) return false; + size_t i = 0; + bool first = true; + while (i < name.size()) + { + uint32_t cp = static_cast(name[i++]), minimum = 0; + unsigned remaining = 0; + if (cp >= 0xc2 && cp <= 0xdf) + { + cp &= 31; + remaining = 1; + minimum = 0x80; + } + else if (cp >= 0xe0 && cp <= 0xef) + { + cp &= 15; + remaining = 2; + minimum = 0x800; + } + else if (cp >= 0xf0 && cp <= 0xf4) + { + cp &= 7; + remaining = 3; + minimum = 0x10000; + } + else if (cp >= 128) return false; + if (remaining > name.size() - i) return false; + while (remaining--) + { + const auto byte = static_cast(name[i++]); + if ((byte & 0xc0) != 0x80) return false; + cp = (cp << 6) | (byte & 63); + } + if (cp < minimum) return false; + const bool start = xmlNameStart(cp, nc), extra = cp == '-' || cp == '.' || (cp >= '0' && cp <= '9') || cp == 0xb7 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x203f && cp <= 0x2040); + if (!start && (first || !extra)) return false; + first = false; + } + return true; +} +inline bool validXmlQName(std::string_view name) +{ + const auto colon = name.find(':'); + return colon == name.npos ? validXmlName(name) : name.find(':', colon + 1) == name.npos && validXmlName(name.substr(0, colon)) && validXmlName(name.substr(colon + 1)); +} +} // namespace track_sharing diff --git a/modules/core_track_sharing/include/track_sharing/xml_namespace_scope.h b/modules/core_track_sharing/include/track_sharing/xml_namespace_scope.h new file mode 100644 index 00000000..1974a878 --- /dev/null +++ b/modules/core_track_sharing/include/track_sharing/xml_namespace_scope.h @@ -0,0 +1,128 @@ +#pragma once +#include +#include +#include +namespace track_sharing +{ +enum class XmlNamespace : uint8_t +{ + None, + Kml, + Gx, + Other +}; +// Classifies element namespaces only. Does not identify arbitrary namespaces +// for expanded-name duplicate-attribute checks; the XML wrapper owns that. +class XmlNamespaceScope +{ + public: + bool enter() + { + if (failed_ || depth_ >= 64) return fail(); + ++depth_; + return true; + } + bool leave() + { + if (failed_ || !depth_) return fail(); + while (count_ && bindings_[count_ - 1].depth == depth_) --count_; + --depth_; + return true; + } + bool declare(std::string_view prefix, std::string_view uri) + { + constexpr std::string_view xml = "http://www.w3.org/XML/1998/namespace", xmlns = "http://www.w3.org/2000/xmlns/"; + if (failed_ || !depth_ || count_ >= 16 || prefix.size() > 31 || prefix.find('\0') != prefix.npos || prefix.find(':') != prefix.npos || prefix == "xmlns" || uri == xmlns || + (prefix == "xml" ? uri != xml : uri == xml) || (!prefix.empty() && uri.empty())) return fail(); + for (size_t i = count_; i > 0 && bindings_[i - 1].depth == depth_; --i) + if (std::string_view(bindings_[i - 1].prefix) == prefix) return fail(); + auto& binding = bindings_[count_++]; + if (!prefix.empty()) std::memcpy(binding.prefix, prefix.data(), prefix.size()); + binding.prefix[prefix.size()] = '\0'; + binding.depth = depth_; + binding.identity = uri.empty() ? 0 : fingerprint(uri); + binding.value = uri.empty() ? XmlNamespace::None : uri == "http://www.opengis.net/kml/2.2" ? XmlNamespace::Kml + : uri == "http://www.google.com/kml/ext/2.2" ? XmlNamespace::Gx + : XmlNamespace::Other; + return true; + } + bool element(std::string_view qname, XmlNamespace& space, std::string_view& local) const + { + if (failed_ || !depth_ || qname.empty()) return false; + const auto colon = qname.find(':'); + std::string_view prefix; + if (colon != qname.npos) + { + if (!colon || colon + 1 == qname.size() || qname.find(':', colon + 1) != qname.npos) return false; + prefix = qname.substr(0, colon); + local = qname.substr(colon + 1); + } + else local = qname; + if (prefix == "xmlns") return false; + if (prefix == "xml") + { + space = XmlNamespace::Other; + return true; + } + for (size_t i = count_; i > 0; --i) + if (std::string_view(bindings_[i - 1].prefix) == prefix) + { + space = bindings_[i - 1].value; + return true; + } + space = XmlNamespace::None; + return prefix.empty(); + } + + static uint64_t fingerprint(std::string_view value) + { + uint64_t result = 14695981039346656037ULL; + for (const unsigned char byte : value) + { + result ^= byte; + result *= 1099511628211ULL; + } + return result; + } + bool attributeNamespace(std::string_view prefix, uint64_t& identity) const + { + if (failed_ || !depth_) return false; + if (prefix.empty()) + { + identity = 0; + return true; + } + if (prefix == "xmlns") return false; + if (prefix == "xml") + { + identity = fingerprint("http://www.w3.org/XML/1998/namespace"); + return true; + } + for (size_t i = count_; i > 0; --i) + if (std::string_view(bindings_[i - 1].prefix) == prefix) + { + identity = bindings_[i - 1].identity; + return true; + } + return false; + } + + private: + bool fail() + { + failed_ = true; + return false; + } + struct Binding + { + char prefix[32]{}; + uint64_t identity = 0; + XmlNamespace value = XmlNamespace::None; + uint8_t depth = 0; + }; + Binding bindings_[16]{}; + uint8_t count_ = 0, depth_ = 0; + bool failed_ = false; +}; +static_assert(sizeof(XmlNamespaceScope) <= 784, "Namespace scopes must stay bounded"); +} // namespace track_sharing diff --git a/modules/ui_presentation/include/ui_presentation/map/map_overlay_snapshot.h b/modules/ui_presentation/include/ui_presentation/map/map_overlay_snapshot.h index 948ec4a5..f100e917 100644 --- a/modules/ui_presentation/include/ui_presentation/map/map_overlay_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/map/map_overlay_snapshot.h @@ -56,6 +56,9 @@ struct MapOverlayItem uint8_t icon = 0; bool selected = false; bool visible = true; + // Bit0: ordered path sample; bit1: disconnected from preceding sample. + // Uses the existing trailing padding rather than an additional point list. + uint8_t path_flags = 0; }; struct MapOverlaySnapshot diff --git a/modules/ui_shared/include/ui/support/disconnected_route_line.h b/modules/ui_shared/include/ui/support/disconnected_route_line.h new file mode 100644 index 00000000..f5246bfa --- /dev/null +++ b/modules/ui_shared/include/ui/support/disconnected_route_line.h @@ -0,0 +1,41 @@ +#pragma once +#include "lvgl.h" +#include +#include +#include + +namespace ui::support +{ +// Marker is read only by our draw callback, never passed to lv_line_set_points. +// It fits both integer and floating lv_value_precise_t exactly. +constexpr int32_t kRouteLineBreak = std::numeric_limits::min(); +inline lv_point_precise_t routeLineBreak() { return {kRouteLineBreak, kRouteLineBreak}; } +inline void drawDisconnectedRouteLine(lv_event_t* event, const lv_point_precise_t* points, size_t count) +{ + if (!points || count < 2) return; + auto* object = static_cast(lv_event_get_current_target(event)); + auto* layer = lv_event_get_layer(event); + if (!object || !layer) return; + lv_area_t area; + lv_obj_get_coords(object, &area); + const auto x = area.x1 - lv_obj_get_scroll_x(object), y = area.y1 - lv_obj_get_scroll_y(object); + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.base.layer = layer; + lv_obj_init_draw_line_dsc(object, LV_PART_MAIN, &line); + bool previous = false; + for (size_t i = 0; i < count; ++i) + { + const auto& point = points[i]; + if (point.x == kRouteLineBreak && point.y == kRouteLineBreak) + { + previous = false; + continue; + } + line.p2 = {point.x + x, point.y + y}; + if (previous) lv_draw_line(layer, &line); + line.p1 = line.p2; + previous = true; + } +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/kml_navigation_match.h b/modules/ui_shared/include/ui/support/kml_navigation_match.h new file mode 100644 index 00000000..e9a6f3ef --- /dev/null +++ b/modules/ui_shared/include/ui/support/kml_navigation_match.h @@ -0,0 +1,202 @@ +#pragma once +#include "track_sharing/route_match.h" +#include "track_sharing/route_navigation_state.h" +#include "ui/support/kml_route_identity.h" +#include "ui/support/route_image_kml_reader.h" +namespace ui::support +{ +// Owned by the active route, allocated externally. XML work exists only while +// scanning; each poll has a byte budget and an elapsed-time budget between reads. +class KmlNavigationMatch +{ + public: + KmlRouteIdentity expected; + KmlNavigationMatch() = default; + ~KmlNavigationMatch() { cancel(); } + KmlNavigationMatch(const KmlNavigationMatch&) = delete; + KmlNavigationMatch& operator=(const KmlNavigationMatch&) = delete; + static KmlNavigationMatch* create() + { +#if defined(ESP_PLATFORM) + const auto caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + void* memory = heap_caps_malloc(sizeof(KmlNavigationMatch), caps | MALLOC_CAP_8BIT); +#else + void* memory = std::malloc(sizeof(KmlNavigationMatch)); +#endif + return memory ? new (memory) KmlNavigationMatch : nullptr; + } + static void destroy(KmlNavigationMatch*& owner) + { + if (!owner) return; + owner->~KmlNavigationMatch(); +#if defined(ESP_PLATFORM) + heap_caps_free(owner); +#else + std::free(owner); +#endif + owner = nullptr; + } + void cancel() + { + release(); + ready_ = false; + last_start_ = 0; + started_ = false; + navigation_.reset(); + } + bool pending() const { return work_ != nullptr; } + const track_sharing::RouteMatchResult* result() const { return ready_ ? &result_ : nullptr; } + track_sharing::RouteNavigationView navigation(double lat, double lon, uint32_t now) const + { + if (!ready_ || !valid_fix(lat, lon)) return {}; + const double movement = track_sharing::routeAngularDistance(result_fix_, {lat, lon}) * track_sharing::routeEarthRadius; + return navigation_.project(result_, now - completed_at_, movement); + } + // Failed parses invalidate earlier results. GPS movement produces rigorous + // distance bounds: distance-to-a-set is 1-Lipschitz in surface distance. + bool bounds(double lat, double lon, uint32_t now, double& lower, double& upper) const + { + if (!ready_ || now - completed_at_ > 15000 || !valid_fix(lat, lon)) return false; + const double movement = track_sharing::routeAngularDistance(result_fix_, {lat, lon}) * track_sharing::routeEarthRadius; + lower = std::max(0.0, result_.distance_m - movement); + upper = result_.distance_m + movement; + return true; + } + void poll(const char* path, double lat, double lon, uint32_t now, uint32_t (*clock)(), size_t chunks = 64, uint32_t milliseconds = 4) + { + if (!valid_fix(lat, lon)) + { + cancel(); + return; + } + if (!work_) + { + if (ready_ && now - completed_at_ < 5000 && track_sharing::routeAngularDistance(result_fix_, {lat, lon}) * track_sharing::routeEarthRadius < 1) return; + if (started_ && now - last_start_ < 1000) return; + started_ = true; + last_start_ = now; + if (!begin(path, {lat, lon})) + { + ready_ = false; + navigation_.reset(); + return; + } + } + const uint32_t start = clock ? clock() : now; + for (size_t i = 0; i < chunks && work_; ++i) + { + uint32_t count = 0; + if (lv_fs_read(&file_, work_->chunk, sizeof(work_->chunk), &count) != LV_FS_RES_OK || count > sizeof(work_->chunk)) + { + fail(); + break; + } + if (!count) + { + const unsigned selected = expected.counts[1] ? 1 : 0; + const bool valid = work_->parser.finish() && !geometry_failed_[selected] && seen_ == expected; + const auto candidate = accumulators_[selected].result(); + const bool closed = release(); + if (valid && closed && candidate.valid) + { + result_ = candidate; + result_fix_ = scan_fix_; + completed_at_ = clock ? clock() : now; + ready_ = true; + const double movement = track_sharing::routeAngularDistance(result_fix_, {lat, lon}) * track_sharing::routeEarthRadius; + navigation_.observe(result_, completed_at_, movement); + } + else + { + ready_ = false; + navigation_.reset(); + } + break; + } + if (!work_->parser.feed(reinterpret_cast(work_->chunk), count)) + { + fail(); + break; + } + if (clock && clock() - start >= milliseconds) break; + } + } + + private: + static bool valid_fix(double lat, double lon) + { + return std::isfinite(lat) && std::isfinite(lon) && std::abs(lat) <= 90 && std::abs(lon) <= 180; + } + bool begin(const char* path, track_sharing::RouteMatchPoint fix) + { + const auto normalized = fs::normalize_path(path); + if (normalized.empty() || (!expected.counts[0] && !expected.counts[1])) return false; +#if defined(ESP_PLATFORM) + const auto caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + void* memory = heap_caps_malloc(sizeof(RouteImageKmlWork), caps | MALLOC_CAP_8BIT); +#else + void* memory = std::malloc(sizeof(RouteImageKmlWork)); +#endif + if (!memory) return false; + seen_ = {}; + geometry_failed_[0] = geometry_failed_[1] = false; + scan_fix_ = fix; + for (auto& accumulator : accumulators_) accumulator = track_sharing::RouteMatchAccumulator(fix); + track_sharing::KmlImageCallbacks callbacks{}; + callbacks.context = this; + callbacks.coordinate = [](void* context, bool gx, double lat, double lon, double altitude, bool height, bool broken) + { + auto& self = *static_cast(context); + self.seen_.coordinate(gx, lat, lon, altitude, height, broken); + if (!self.accumulators_[gx ? 1 : 0].point({lat, lon}, broken)) self.geometry_failed_[gx ? 1 : 0] = true; + }; + callbacks.geometry = [](void* context, bool gx, bool begin, track_sharing::KmlAltitudeMode mode) + { static_cast(context)->seen_.geometry(gx, begin, mode); }; + work_ = new (memory) RouteImageKmlWork(callbacks); + file_open_ = lv_fs_open(&file_, normalized.c_str(), LV_FS_MODE_RD) == LV_FS_RES_OK; + if (!file_open_) + { + release(); + return false; + } + return true; + } + bool release() + { + bool ok = true; + if (file_open_) + { + ok = lv_fs_close(&file_) == LV_FS_RES_OK; + file_open_ = false; + } + if (work_) + { + work_->~RouteImageKmlWork(); +#if defined(ESP_PLATFORM) + heap_caps_free(work_); +#else + std::free(work_); +#endif + work_ = nullptr; + } + return ok; + } + void fail() + { + release(); + ready_ = false; + navigation_.reset(); + } + RouteImageKmlWork* work_ = nullptr; + lv_fs_file_t file_{}; + KmlRouteIdentity seen_; + track_sharing::RouteMatchAccumulator accumulators_[2]; + track_sharing::RouteMatchPoint scan_fix_{}, result_fix_{}; + track_sharing::RouteMatchResult result_; + track_sharing::RouteNavigationState navigation_; + uint32_t last_start_ = 0, completed_at_ = 0; + bool geometry_failed_[2]{}; + bool file_open_ = false, ready_ = false, started_ = false; +}; +static_assert(sizeof(KmlNavigationMatch) <= 384, "Navigation owner has no retained coordinates or parser"); +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/kml_route_identity.h b/modules/ui_shared/include/ui/support/kml_route_identity.h new file mode 100644 index 00000000..96ef210a --- /dev/null +++ b/modules/ui_shared/include/ui/support/kml_route_identity.h @@ -0,0 +1,40 @@ +#pragma once +#include "track_sharing/kml_image_events.h" +#include +#include +namespace ui::support +{ +// Geometry change detection only, never an authenticity proof. +struct KmlRouteIdentity +{ + uint32_t counts[2]{}; + uint64_t hashes[2]{14695981039346656037ULL, 14695981039346656037ULL}; + static void mix(uint64_t& hash, const void* bytes, size_t count) + { + const auto* data = static_cast(bytes); + for (size_t i = 0; i < count; ++i) + { + hash ^= data[i]; + hash *= 1099511628211ULL; + } + } + void coordinate(bool gx, double lat, double lon, double altitude, bool has_altitude, bool broken) + { + auto& hash = hashes[gx ? 1 : 0]; + mix(hash, &lat, sizeof(lat)); + mix(hash, &lon, sizeof(lon)); + mix(hash, &altitude, sizeof(altitude)); + mix(hash, &has_altitude, sizeof(has_altitude)); + mix(hash, &broken, sizeof(broken)); + ++counts[gx ? 1 : 0]; + } + void geometry(bool gx, bool begin, track_sharing::KmlAltitudeMode mode) + { + if (!begin) mix(hashes[gx ? 1 : 0], &mode, sizeof(mode)); + } + bool operator==(const KmlRouteIdentity& other) const + { + return counts[0] == other.counts[0] && counts[1] == other.counts[1] && hashes[0] == other.hashes[0] && hashes[1] == other.hashes[1]; + } +}; +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/kml_route_projection.h b/modules/ui_shared/include/ui/support/kml_route_projection.h new file mode 100644 index 00000000..a0c5ab07 --- /dev/null +++ b/modules/ui_shared/include/ui/support/kml_route_projection.h @@ -0,0 +1,180 @@ +#pragma once +#include "ui/support/kml_route_identity.h" +#include "ui/support/route_image_kml_reader.h" +#include +#include +#include +#include +namespace ui::support +{ +struct KmlElevationMetrics +{ + size_t altitude_count = 0; + double min_altitude_m = 0, max_altitude_m = 0, ascent_m = 0, descent_m = 0, previous_altitude_m = 0; + bool has_previous_altitude = false; +}; +template +class KmlRouteProjection +{ + public: + using Point = std::remove_reference_t()[0])>; + using Sample = std::remove_reference_t()[0])>; + KmlRouteProjection(Points& points, Samples& samples, Metrics& metrics, size_t budget) + : points_(points), samples_(samples), metrics_(metrics), budget_(budget) {} + KmlRouteIdentity source_identity() const + { + KmlRouteIdentity result; + for (unsigned i = 0; i < 2; ++i) + { + result.counts[i] = counts_[i]; + result.hashes[i] = hashes_[i]; + } + return result; + } + bool load(const char* path) + { + points_.clear(); + samples_.clear(); + metrics_ = {}; + track_sharing::KmlImageCallbacks callbacks{}; + callbacks.context = this; + callbacks.coordinate = [](void* context, bool gx, double lat, double lon, double altitude, bool has_altitude, bool broken) + { static_cast(context)->coordinate(gx, lat, lon, altitude, has_altitude, broken); }; + callbacks.geometry = [](void* context, bool gx, bool begin, track_sharing::KmlAltitudeMode mode) + { static_cast(context)->geometry(gx, begin, mode); }; + if (!path || !budget_ || budget_ > 96 || !readRouteImageKml(path, callbacks)) return false; + selected_ = counts_[1] ? 1 : 0; + total_ = counts_[selected_]; + wanted_ = std::min(budget_, total_); + if (!wanted_) return true; // valid image/point feature, no walking path + if (!points_.reserve(wanted_) || !samples_.reserve(wanted_)) return clear(); + second_ = true; + if (!readRouteImageKml(path, callbacks) || failed_ || ordinal_ != total_ || second_hash_ != hashes_[selected_] || points_.size() != wanted_) return clear(); + return true; + } + + private: + void geometry(bool gx, bool begin, track_sharing::KmlAltitudeMode mode) + { + const unsigned kind = gx ? 1 : 0; + if (!second_) + { + if (!begin) mix(hashes_[kind], &mode, sizeof(mode)); + return; + } + if (kind != selected_) return; + if (begin) + { + metrics_before_ = metrics_; + geometry_start_ = points_.size(); + return; + } + mix(second_hash_, &mode, sizeof(mode)); + if (mode != track_sharing::KmlAltitudeMode::Absolute) + { + metrics_ = metrics_before_; + metrics_.has_previous_altitude = false; + for (size_t i = geometry_start_; i < points_.size(); ++i) points_[i].has_altitude = false, samples_[i].has_altitude = false; + pending_profile_break_ = true; + } + } + bool clear() + { + points_.clear(); + samples_.clear(); + metrics_ = {}; + return false; + } + static void mix(uint64_t& hash, const void* bytes, size_t count) + { + const auto* data = static_cast(bytes); + for (size_t i = 0; i < count; ++i) + { + hash ^= data[i]; + hash *= 1099511628211ULL; + } + } + static double distance(const Point& a, const Point& b) + { + constexpr double radians = 0.017453292519943295; + const auto dlat = (b.lat - a.lat) * radians, dlon = (b.lon - a.lon) * radians; + const auto x = std::sin(dlat / 2), y = std::sin(dlon / 2); + const double h = std::clamp(x * x + std::cos(a.lat * radians) * std::cos(b.lat * radians) * y * y, 0.0, 1.0); + return 12742000.0 * std::atan2(std::sqrt(h), std::sqrt(1 - h)); + } + void coordinate(bool gx, double lat, double lon, double altitude, bool has_altitude, bool broken) + { + const unsigned kind = gx ? 1 : 0; + uint64_t& fingerprint = second_ ? second_hash_ : hashes_[kind]; + if (second_ && kind != selected_) return; + mix(fingerprint, &lat, sizeof(lat)); + mix(fingerprint, &lon, sizeof(lon)); + mix(fingerprint, &altitude, sizeof(altitude)); + mix(fingerprint, &has_altitude, sizeof(has_altitude)); + mix(fingerprint, &broken, sizeof(broken)); + if (!second_) + { + ++counts_[kind]; + return; + } + if (broken) have_previous_ = false, metrics_.has_previous_altitude = false, pending_geo_break_ = pending_profile_break_ = true; + Point point{}; + point.lat = lat; + point.lon = lon; + point.altitude_m = altitude; + point.has_altitude = has_altitude; + if (have_previous_) distance_ += distance(previous_, point); + previous_ = point; + have_previous_ = true; + if (!has_altitude) metrics_.has_previous_altitude = false, pending_profile_break_ = true; + else + { + if (!metrics_.altitude_count) metrics_.min_altitude_m = metrics_.max_altitude_m = altitude; + else metrics_.min_altitude_m = std::min(metrics_.min_altitude_m, altitude), metrics_.max_altitude_m = std::max(metrics_.max_altitude_m, altitude); + if (metrics_.has_previous_altitude) + { + const auto delta = altitude - metrics_.previous_altitude_m; + if (delta > 0) metrics_.ascent_m += delta; + else metrics_.descent_m -= delta; + } + metrics_.previous_altitude_m = altitude; + metrics_.has_previous_altitude = true; + ++metrics_.altitude_count; + } + const size_t target = wanted_ <= 1 ? 0 : (points_.size() * (total_ - 1)) / (wanted_ - 1); + if (ordinal_++ != target) return; + point.break_before = pending_geo_break_; + Sample sample{}; + sample.altitude_m = altitude; + sample.has_altitude = has_altitude; + sample.distance_m = distance_; + sample.break_before = pending_profile_break_; + if (!points_.push_back(std::move(point)) || !samples_.push_back(std::move(sample))) failed_ = true; + pending_geo_break_ = false; + pending_profile_break_ = !has_altitude; + } + Points& points_; + Samples& samples_; + Metrics& metrics_; + Metrics metrics_before_{}; + size_t geometry_start_ = 0; + size_t budget_, wanted_ = 0, total_ = 0, ordinal_ = 0; + uint32_t counts_[2]{}; + uint64_t hashes_[2]{14695981039346656037ULL, 14695981039346656037ULL}, second_hash_ = 14695981039346656037ULL; + Point previous_{}; + double distance_ = 0; + unsigned selected_ = 0; + bool second_ = false, failed_ = false, have_previous_ = false, pending_geo_break_ = true, pending_profile_break_ = true; +}; +// Two validated passes, fixed output budget. Fingerprint catches changed +// geometry between passes; it is not an author signature or file hash proof. +template +bool projectKmlRoute(const char* path, Points& points, Samples& samples, Metrics& metrics, size_t budget, KmlRouteIdentity* identity = nullptr) +{ + KmlRouteProjection projection(points, samples, metrics, budget); + static_assert(sizeof(projection) <= 256, "Route projection owns only scalar state, never a point list"); + const bool ok = projection.load(path); + if (identity) *identity = ok ? projection.source_identity() : KmlRouteIdentity{}; + return ok; +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/overlay_path_segments.h b/modules/ui_shared/include/ui/support/overlay_path_segments.h new file mode 100644 index 00000000..fc5a2860 --- /dev/null +++ b/modules/ui_shared/include/ui/support/overlay_path_segments.h @@ -0,0 +1,35 @@ +#pragma once +#include "lvgl.h" +#include "ui_presentation/map/map_overlay_snapshot.h" +namespace ui::support +{ +// Stream projected segments directly from the existing snapshot. No retained +// vertex list, per-segment LVGL objects or connection across failed projections. +template +void visitOverlayPathSegments(const ui::map::MapOverlaySnapshot& snapshot, Project&& project, Emit&& emit) +{ + bool previous = false; + lv_point_t last{}; + ui::map::MapOverlayStyle style = ui::map::MapOverlayStyle::Default; + for (size_t i = 0; i < snapshot.item_count && i < ui::map::MapOverlaySnapshot::kMaxItems; ++i) + { + const auto& item = snapshot.items[i]; + if (!(item.path_flags & 1)) + { + previous = false; + continue; + } + if (item.path_flags & 2) previous = false; + lv_point_t current{}; + if (!item.visible || !item.point.valid || !project(item.point, current)) + { + previous = false; + continue; + } + if (previous && style == item.style) emit(last, current, item.style); + last = current; + style = item.style; + previous = true; + } +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/route_image_kml_reader.h b/modules/ui_shared/include/ui/support/route_image_kml_reader.h new file mode 100644 index 00000000..85ef4bd3 --- /dev/null +++ b/modules/ui_shared/include/ui/support/route_image_kml_reader.h @@ -0,0 +1,60 @@ +#pragma once +#include "track_sharing/kml_image_xml_stream.h" +#include "ui/support/lvgl_fs_utils.h" +#include +#include +#if defined(ESP_PLATFORM) +#include +#endif + +namespace ui::support +{ +struct RouteImageKmlWork +{ + char names[4096], source[4096], chunk[256]; + track_sharing::KmlImageXmlStream parser; + explicit RouteImageKmlWork(track_sharing::KmlImageCallbacks callbacks) + : parser(names, sizeof(names), source, sizeof(source), callbacks) {} +}; +static_assert(sizeof(RouteImageKmlWork) <= 12544, "KML workspace is bounded and transient"); + +// No parser, name stack or source array on the ESP task stack. External RAM +// admission never falls back internally if the board has PSRAM. +inline bool readRouteImageKml(const char* path, track_sharing::KmlImageCallbacks callbacks) +{ + const auto normalized = fs::normalize_path(path); + if (normalized.empty()) return false; +#if defined(ESP_PLATFORM) + const auto caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + void* memory = heap_caps_malloc(sizeof(RouteImageKmlWork), caps | MALLOC_CAP_8BIT); +#else + void* memory = std::malloc(sizeof(RouteImageKmlWork)); +#endif + if (!memory) return false; + auto* work = new (memory) RouteImageKmlWork(callbacks); + lv_fs_file_t file; + bool ok = lv_fs_open(&file, normalized.c_str(), LV_FS_MODE_RD) == LV_FS_RES_OK; + if (ok) + { + while (ok) + { + uint32_t count = 0; + if (lv_fs_read(&file, work->chunk, sizeof(work->chunk), &count) != LV_FS_RES_OK || count > sizeof(work->chunk)) ok = false; + else if (!count) + { + ok = work->parser.finish(); + break; + } + else ok = work->parser.feed(reinterpret_cast(work->chunk), count); + } + if (lv_fs_close(&file) != LV_FS_RES_OK) ok = false; + } + work->~RouteImageKmlWork(); +#if defined(ESP_PLATFORM) + heap_caps_free(memory); +#else + std::free(memory); +#endif + return ok; +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/route_image_memory.h b/modules/ui_shared/include/ui/support/route_image_memory.h new file mode 100644 index 00000000..d90744c8 --- /dev/null +++ b/modules/ui_shared/include/ui/support/route_image_memory.h @@ -0,0 +1,113 @@ +#pragma once +#include +#include +#include +#include +#if defined(ESP_PLATFORM) +#include +#endif +namespace ui::support +{ +// Optional, bounded image storage. Failed admission returns false, never +// throws/aborts or falls back internally when external RAM is present. +template +class RouteImageVector +{ + public: + RouteImageVector() = default; + ~RouteImageVector() { clear(); } + RouteImageVector(const RouteImageVector&) = delete; + RouteImageVector& operator=(const RouteImageVector&) = delete; + RouteImageVector(RouteImageVector&& other) noexcept { swap(other); } + RouteImageVector& operator=(RouteImageVector&& other) noexcept + { + if (this != &other) + { + clear(); + swap(other); + } + return *this; + } + bool reserve(size_t wanted) + { + if (wanted > Capacity) return false; + if (!wanted || data_) return true; + return allocate(); + } + bool push_back(T&& value) + { + static_assert(sizeof(T) <= 256, "Image object exceeds admission budget"); + static_assert(Capacity > 0 && Capacity <= 255, "Route buffer has a fixed byte-sized count"); + if (size_ == Capacity) return false; + if (!data_ && !allocate()) return false; + new (data_ + size_) T(std::move(value)); + ++size_; + return true; + } + bool push_back(const T& value) + { + T copy(value); + return push_back(std::move(copy)); + } + void resize(size_t wanted) + { + while (size_ > wanted) data_[--size_].~T(); // bounded buffer only shrinks + } + T& front() { return data_[0]; } + const T& front() const { return data_[0]; } + T& back() { return data_[size_ - 1]; } + const T& back() const { return data_[size_ - 1]; } + + private: + bool allocate() + { +#if defined(ESP_PLATFORM) + const auto caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + data_ = static_cast(heap_caps_malloc(Capacity * sizeof(T), caps | MALLOC_CAP_8BIT)); +#else + data_ = static_cast(std::malloc(Capacity * sizeof(T))); +#endif + if (!data_) + { + failed_ = true; + return false; + } + return true; + } + + public: + void clear() noexcept + { + while (size_) data_[--size_].~T(); +#if defined(ESP_PLATFORM) + heap_caps_free(data_); +#else + std::free(data_); +#endif + data_ = nullptr; + failed_ = false; + } + void swap(RouteImageVector& other) noexcept + { + std::swap(data_, other.data_); + std::swap(size_, other.size_); + std::swap(failed_, other.failed_); + } + size_t size() const { return size_; } + bool empty() const { return !size_; } + bool failed() const { return failed_; } + T* data() { return data_; } + const T* data() const { return data_; } + T* begin() { return data_; } + const T* begin() const { return data_; } + T* end() { return data_ ? data_ + size_ : nullptr; } + const T* end() const { return data_ ? data_ + size_ : nullptr; } + T& operator[](size_t i) { return data_[i]; } + const T& operator[](size_t i) const { return data_[i]; } + + private: + T* data_ = nullptr; + uint8_t size_ = 0; + bool failed_ = false; +}; +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/route_image_source_state.h b/modules/ui_shared/include/ui/support/route_image_source_state.h new file mode 100644 index 00000000..b10d2a0c --- /dev/null +++ b/modules/ui_shared/include/ui/support/route_image_source_state.h @@ -0,0 +1,21 @@ +#pragma once +#include "platform/ui/route_storage.h" +namespace ui::support +{ +// Both route screens use the same rule. A legacy file's presence is useful for +// compatibility guidance but is not a source match. No URL is retained here. +template +void refreshRouteImageSourceState(Image& image, bool include_cache) +{ + image.downloaded = image.source_hash_valid && platform::ui::route_storage::route_image_source_matches(image.local_path, image.source_hash); + image.preview_ready = include_cache && image.downloaded && platform::ui::route_storage::route_asset_file_exists(image.preview_path); + image.view_ready = include_cache && image.downloaded && platform::ui::route_storage::route_asset_file_exists(image.view_path); +} +template +bool routeImagesNeedSourceReview(const Images& images) +{ + for (const auto& image : images) + if (!image.downloaded && platform::ui::route_storage::route_asset_file_exists(image.local_path)) return true; + return false; +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/support/route_segment_sampling.h b/modules/ui_shared/include/ui/support/route_segment_sampling.h new file mode 100644 index 00000000..7c05c276 --- /dev/null +++ b/modules/ui_shared/include/ui/support/route_segment_sampling.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include + +namespace ui::support +{ +// Two linear passes; no ordinal table or allocations. Skipped source samples +// still contribute break/missing markers so decimation cannot bridge a gap. +template +size_t sampleRouteSegments(const Point* points, size_t count, size_t budget, Valid&& valid, Emit&& emit) +{ + if (!points || !count || !budget) return 0; + size_t total = 0; + for (size_t i = 0; i < count; ++i) + if (valid(points[i])) ++total; + const size_t wanted = std::min(total, budget); + if (!wanted) return 0; + size_t ordinal = 0, emitted = 0, target = 0; + bool broken = true; + for (size_t i = 0; i < count && emitted < wanted; ++i) + { + const auto& point = points[i]; + if (point.break_before) broken = true; + if (!valid(point)) + { + broken = true; + continue; + } + if (ordinal++ != target) continue; + if (!emit(point, emitted != 0 && !broken)) break; + broken = false; + ++emitted; + target = wanted <= 1 ? 0 : (emitted * (total - 1)) / (wanted - 1); + } + return emitted; +} +} // namespace ui::support diff --git a/modules/ui_shared/include/ui/widgets/route_elevation_profile.h b/modules/ui_shared/include/ui/widgets/route_elevation_profile.h index 74e648a1..6dcf6a7f 100644 --- a/modules/ui_shared/include/ui/widgets/route_elevation_profile.h +++ b/modules/ui_shared/include/ui/widgets/route_elevation_profile.h @@ -1,9 +1,9 @@ #pragma once #include "lvgl.h" +#include "ui/support/route_image_memory.h" #include -#include namespace ui::widgets::route_elevation_profile { @@ -13,7 +13,9 @@ struct Sample double altitude_m = 0.0; double distance_m = 0.0; bool has_altitude = false; + bool break_before = false; }; +static_assert(sizeof(Sample) <= 24, "Segment flag must fit existing sample padding"); struct Metrics { @@ -40,7 +42,7 @@ struct Widget lv_obj_t* panel = nullptr; lv_obj_t* label = nullptr; lv_obj_t* line = nullptr; - std::vector points{}; + ::ui::support::RouteImageVector points{}; }; void reset(Widget& widget); diff --git a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp index acc11844..680762cc 100644 --- a/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp @@ -8,12 +8,14 @@ using Projection = gps::ui::shell::Projection; #include "app/app_facade_access.h" #include "gps/domain/gps_diagnostics.h" #include "gps/gpx/attribute_reader.h" +#include "gps/kml/image_sources.h" #include "platform/ui/device_runtime.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/route_storage.h" #include "platform/ui/team_ui_store_runtime.h" #include "platform/ui/tracker_runtime.h" #include "sys/clock.h" +#include "track_sharing/installed_package_path.h" #include "ui/app_runtime.h" #include "ui/components/map_poi_search.h" #include "ui/formatters.h" @@ -24,7 +26,13 @@ using Projection = gps::ui::shell::Projection; #include "ui/presentation_sources/team_map_overlay_source.h" #include "ui/runtime/ui_feedback.h" #include "ui/screens/gps/gps_constants.h" +#include "ui/support/kml_navigation_match.h" +#include "ui/support/kml_route_projection.h" #include "ui/support/lvgl_fs_utils.h" +#include "ui/support/route_image_kml_reader.h" +#include "ui/support/route_image_memory.h" +#include "ui/support/route_image_source_state.h" +#include "ui/support/route_segment_sampling.h" #include "ui/team_presentation/team_member_label.h" #include "ui/ui_common.h" #include "ui/widgets/map/map_viewport.h" @@ -117,7 +125,11 @@ struct TrackOverlayPoint double lon = 0.0; double altitude_m = 0.0; bool has_altitude = false; + bool break_before = false; }; +static_assert(sizeof(TrackOverlayPoint) <= 32, "Route segment flag uses existing point padding"); +using TrackPointBuffer = ::ui::support::RouteImageVector; +using ElevationSampleBuffer = ::ui::support::RouteImageVector<::ui::widgets::route_elevation_profile::Sample, kMaxTrackOverlayPoints>; struct TrackElevationMetrics { @@ -132,6 +144,8 @@ struct TrackElevationMetrics struct RouteImagePoint { + uint8_t source_hash[32]{}; + bool source_hash_valid = false; double lat = 0.0; double lon = 0.0; bool has_position = false; @@ -223,11 +237,11 @@ char s_map_notice_text[64]{}; uint32_t s_map_notice_until_ms = 0; int s_map_drag_start_pan_x = 0; int s_map_drag_start_pan_y = 0; -std::vector s_track_points; -std::vector s_route_images; +TrackPointBuffer s_track_points; +::ui::support::RouteImageVector s_route_images; std::vector<::ui::widgets::route_image_strip::Item> s_route_image_strip_items; -std::vector<::ui::widgets::route_elevation_profile::Sample> s_route_elevation_samples; -std::vector<::ui::widgets::route_elevation_profile::Sample> s_route_elevation_work_samples; +ElevationSampleBuffer s_route_elevation_samples; +ElevationSampleBuffer s_route_elevation_work_samples; TrackElevationMetrics s_track_elevation_metrics; std::vector s_track_modal_names; std::string s_track_file; @@ -241,6 +255,7 @@ bool s_route_image_cache_state_known = false; bool s_route_image_cache_build_running = false; bool s_route_deviation_active = false; double s_route_deviation_distance_m = 0.0; +::ui::support::KmlNavigationMatch* s_route_navigation = nullptr; std::size_t s_route_selected_image = 0; uint32_t s_route_image_strip_items_hash = 0; std::vector s_member_buttons; @@ -312,20 +327,20 @@ void release_overlay_snapshot() s_overlay_snapshot = nullptr; } -void downsample_track_points(std::vector& points); -void append_track_point_raw(std::vector& out, +void downsample_track_points(TrackPointBuffer& points); +void append_track_point_raw(TrackPointBuffer& out, double lat, double lon, double altitude_m = 0.0, bool has_altitude = false); -void append_track_point(std::vector& out, +void append_track_point(TrackPointBuffer& out, double lat, double lon, double altitude_m = 0.0, bool has_altitude = false); void build_route_elevation_samples( - const std::vector& points, - std::vector<::ui::widgets::route_elevation_profile::Sample>& out); + const TrackPointBuffer& points, + ElevationSampleBuffer& out); ::ui::presentation_sources::TeamMapOverlaySource& team_map_overlay_source(); void apply_map_drag_preview(); lv_obj_t* create_map_control_button(lv_obj_t* parent, @@ -621,6 +636,7 @@ void set_button_label(lv_obj_t* btn, const char* text) void clear_map_controls() { + if (s_route_navigation) s_route_navigation->cancel(); ::ui::components::map_poi_search::close(); s_map_search_btn = nullptr; s_map_pick_btn = s_map_cancel_btn = nullptr; @@ -887,6 +903,7 @@ std::string route_path_basename(const std::string& path) std::string route_asset_id_for_path(const std::string& path) { + if (track_sharing::isInstalledPackageRoute(path)) return path.substr(8, 66); const std::string name = route_path_basename(path); std::uint32_t hash = 2166136261U; for (unsigned char ch : name) @@ -1016,7 +1033,7 @@ void project_route_point_to_local_m(double ref_lat, double& y_m) { const double ref_lat_rad = degrees_to_radians(ref_lat); - x_m = degrees_to_radians(point.lon - ref_lon) * + x_m = degrees_to_radians(std::remainder(point.lon - ref_lon, 360.0)) * kEarthRadiusM * std::cos(ref_lat_rad); y_m = degrees_to_radians(point.lat - ref_lat) * kEarthRadiusM; @@ -1067,7 +1084,7 @@ bool nearest_route_distance_m(double self_lat, double next_x = 0.0; double next_y = 0.0; project_route_point_to_local_m(self_lat, self_lon, s_track_points[index], next_x, next_y); - const double distance = distance_to_segment_m(prev_x, prev_y, next_x, next_y); + const double distance = s_track_points[index].break_before ? std::hypot(next_x, next_y) : distance_to_segment_m(prev_x, prev_y, next_x, next_y); if (std::isfinite(distance)) { out_distance_m = std::min(out_distance_m, distance); @@ -1078,9 +1095,22 @@ bool nearest_route_distance_m(double self_lat, return std::isfinite(out_distance_m); } +void advance_route_navigation() +{ + if (!s_route_navigation) return; + double lat = 0, lon = 0; + if (!s_root || s_projection != Projection::Map || s_location_request || s_map_target || + !route_deviation_mode_active() || !platform::ui::device::sd_ready() || !current_fix_lat_lon(lat, lon)) + { + s_route_navigation->cancel(); + return; + } + s_route_navigation->poll(s_track_file.c_str(), lat, lon, sys::millis_now(), sys::millis_now); +} + void update_route_deviation_state() { - if (!route_deviation_mode_active()) + if (!route_deviation_mode_active() || !s_route_navigation || !platform::ui::device::sd_ready()) { s_route_deviation_active = false; s_route_deviation_distance_m = 0.0; @@ -1089,23 +1119,25 @@ void update_route_deviation_state() double self_lat = 0.0; double self_lon = 0.0; - double distance_m = 0.0; + double lower = 0.0, upper = 0.0; if (!current_fix_lat_lon(self_lat, self_lon) || - !nearest_route_distance_m(self_lat, self_lon, distance_m)) + !s_route_navigation->bounds(self_lat, self_lon, sys::millis_now(), lower, upper)) { s_route_deviation_active = false; s_route_deviation_distance_m = 0.0; return; } - s_route_deviation_distance_m = distance_m; + // Conservative distance at the current fix; movement while reading must + // neither invent an off-route alert nor prematurely clear one. + s_route_deviation_distance_m = lower > kRouteDeviationClearThresholdM ? lower : NAN; if (s_route_deviation_active) { - s_route_deviation_active = distance_m > kRouteDeviationClearThresholdM; + if (upper <= kRouteDeviationClearThresholdM) s_route_deviation_active = false; } else { - s_route_deviation_active = distance_m > kRouteDeviationThresholdM; + s_route_deviation_active = lower > kRouteDeviationThresholdM; } } @@ -1115,6 +1147,7 @@ void update_track_elevation_metrics(TrackElevationMetrics& metrics, { if (!has_altitude || !std::isfinite(altitude_m)) { + metrics.has_previous_altitude = false; return; } @@ -1375,11 +1408,14 @@ void ensure_map_route_image_cache_build() return; } + const bool package = track_sharing::isInstalledPackageRoute(s_track_file); + if (package && status.asset_id == s_route_asset_id && status.phase == platform::ui::route_storage::RouteImageDownloadPhase::Failed) return; + std::vector items; items.reserve(s_route_images.size()); for (const auto& image : s_route_images) { - if (!image.downloaded || (image.preview_ready && image.view_ready)) + if ((!image.downloaded && !package) || (image.preview_ready && image.view_ready)) { continue; } @@ -1387,6 +1423,8 @@ void ensure_map_route_image_cache_build() item.source_path = image.local_path; item.preview_path = image.preview_path; item.view_path = image.view_path; + std::memcpy(item.source_hash, image.source_hash, 32); + item.source_hash_valid = image.source_hash_valid; items.push_back(std::move(item)); } if (items.empty()) @@ -1397,9 +1435,10 @@ void ensure_map_route_image_cache_build() std::string error; if (platform::ui::route_storage::start_route_image_cache_build( s_route_asset_id, - items, + std::move(items), error, - platform::ui::route_storage::RouteImageTaskPresentation::PageOnly)) + platform::ui::route_storage::RouteImageTaskPresentation::PageOnly, + package ? s_track_file : std::string{})) { s_route_image_cache_build_running = true; } @@ -1468,7 +1507,7 @@ void toggle_map_route_image_strip() } if (s_route_images.empty()) { - set_map_notice("No route images", 1200); + set_map_notice(s_route_images.failed() ? ::ui::i18n::tr("Image memory unavailable") : "No route images", 1200); s_route_image_strip_visible = false; sync_map_route_image_strip(); request_refresh_view(); @@ -1481,7 +1520,11 @@ void toggle_map_route_image_strip() refresh_route_image_storage_state(true); ensure_map_route_image_cache_build(); } - set_map_notice(s_route_image_strip_visible ? "Images shown" : "Images hidden", 900); + set_map_notice(s_route_image_strip_visible && ::ui::support::routeImagesNeedSourceReview(s_route_images) + ? ::ui::i18n::tr("Image cache needs checking. Download again.") + : s_route_image_strip_visible ? "Images shown" + : "Images hidden", + 1800); sync_map_route_image_strip(); request_refresh_view(); } @@ -1720,9 +1763,9 @@ void sync_map_notice_overlay() char notice[40]{}; std::snprintf(notice, sizeof(notice), - "Off route %.0fm", + ::ui::i18n::tr("Off route %.0fm"), s_route_deviation_distance_m); - set_compact_label(s_map_notice_label, notice); + ::ui::i18n::set_content_label_text_raw(s_map_notice_label, notice); lv_obj_set_style_bg_color(s_map_notice_panel, lv_color_hex(0x8F2E1D), 0); lv_obj_clear_flag(s_map_notice_panel, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(s_map_notice_panel); @@ -1751,6 +1794,33 @@ void sync_map_notice_overlay() lv_obj_move_foreground(s_map_notice_panel); return; } + if (route_deviation_mode_active() && s_route_navigation) + { + char notice[96]{}; + double lat = 0, lon = 0; + const char* message = ::ui::i18n::tr("Route position unavailable"); + if (!platform::ui::device::sd_ready()) message = ::ui::i18n::tr("Route storage unavailable"); + else if (!current_fix_lat_lon(lat, lon)) message = ::ui::i18n::tr("Route: waiting for GPS"); + else + { + const auto view = s_route_navigation->navigation(lat, lon, now); + if (view.status == track_sharing::RouteNavigationStatus::Ambiguous) message = ::ui::i18n::tr("Route position ambiguous"); + else if (view.status == track_sharing::RouteNavigationStatus::Arrived) message = ::ui::i18n::tr("Route end (estimated)"); + else if (view.status == track_sharing::RouteNavigationStatus::Position) + { + char distance[20]{}; + ::ui::menu::dashboard::format_distance(view.remaining_m, distance, sizeof(distance)); + std::snprintf(notice, sizeof(notice), ::ui::i18n::tr("Route %u%% | %s left"), view.percent, distance); + message = notice; + } + else if (s_route_navigation->pending()) message = ::ui::i18n::tr("Matching route..."); + } + ::ui::i18n::set_content_label_text_raw(s_map_notice_label, message); + lv_obj_set_style_bg_color(s_map_notice_panel, lv_color_hex(0x25170D), 0); + lv_obj_clear_flag(s_map_notice_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_map_notice_panel); + return; + } lv_obj_set_style_bg_color(s_map_notice_panel, lv_color_hex(0x25170D), 0); lv_obj_add_flag(s_map_notice_panel, LV_OBJ_FLAG_HIDDEN); } @@ -2223,82 +2293,13 @@ std::size_t find_case_insensitive(const std::string& text, return std::string::npos; } -std::string html_decode_attr(std::string value) -{ - auto replace_all = [&value](const char* from, const char* to) - { - std::size_t pos = 0; - const std::size_t from_len = std::strlen(from); - while ((pos = value.find(from, pos)) != std::string::npos) - { - value.replace(pos, from_len, to); - pos += std::strlen(to); - } - }; - replace_all("&", "&"); - replace_all(""", "\""); - replace_all(""", "\""); - replace_all("&", "&"); - return value; -} - void extract_img_srcs(const std::string& text, std::vector& out_urls) { - std::size_t pos = 0; - while (out_urls.size() < kMaxRouteImagePoints) - { - const std::size_t img_pos = find_case_insensitive(text, "', img_pos); - const std::size_t tag_len = - tag_end == std::string::npos ? text.size() - img_pos : tag_end - img_pos + 1; - const std::string tag = text.substr(img_pos, tag_len); - const std::size_t src_pos = find_case_insensitive(tag, "src"); - if (src_pos != std::string::npos) - { - std::size_t eq_pos = tag.find('=', src_pos + 3); - if (eq_pos != std::string::npos) - { - ++eq_pos; - while (eq_pos < tag.size() && - std::isspace(static_cast(tag[eq_pos]))) - { - ++eq_pos; - } - if (eq_pos < tag.size()) - { - const char quote = - tag[eq_pos] == '\'' || tag[eq_pos] == '"' ? tag[eq_pos] : '\0'; - const std::size_t value_start = quote ? eq_pos + 1 : eq_pos; - std::size_t value_end = value_start; - while (value_end < tag.size()) - { - const char ch = tag[value_end]; - if ((quote && ch == quote) || - (!quote && - (std::isspace(static_cast(ch)) || ch == '>'))) - { - break; - } - ++value_end; - } - if (value_end > value_start) - { - out_urls.push_back( - html_decode_attr(tag.substr(value_start, value_end - value_start))); - } - } - } - } - if (tag_end == std::string::npos) - { - break; - } - pos = tag_end + 1; - } + if (out_urls.size() >= kMaxRouteImagePoints) return; + gps::kml::forEachImageSource(text, [&](std::string_view source) + { + out_urls.push_back(gps::kml::decodeImageSource(source)); + return out_urls.size() < kMaxRouteImagePoints; }); } bool extract_tag_text(const std::string& line, const char* tag, std::string& out_text) @@ -2328,27 +2329,19 @@ bool extract_tag_text(const std::string& line, const char* tag, std::string& out return true; } -void append_route_images(const std::vector& urls, +bool append_route_images(std::string_view url, double lat, double lon, bool has_position) { - for (const auto& url : urls) - { - if (s_route_images.size() >= kMaxRouteImagePoints) - { - return; - } - if (url.empty()) - { - continue; - } - RouteImagePoint image{}; - image.lat = lat; - image.lon = lon; - image.has_position = has_position && std::isfinite(lat) && std::isfinite(lon); - s_route_images.push_back(std::move(image)); - } + if (s_route_images.size() >= kMaxRouteImagePoints) return false; + if (url.empty()) return true; + RouteImagePoint image{}; + image.source_hash_valid = platform::ui::route_storage::route_image_reference_hash(url, image.source_hash); + image.lat = lat; + image.lon = lon; + image.has_position = has_position && std::isfinite(lat) && std::isfinite(lon); + return s_route_images.push_back(std::move(image)); } void assign_route_image_paths() @@ -2378,16 +2371,7 @@ void refresh_route_image_storage_state(bool include_cache_state) assign_route_image_paths(); for (RouteImagePoint& image : s_route_images) { - image.downloaded = - platform::ui::route_storage::route_asset_file_exists(image.local_path); - image.preview_ready = - include_cache_state && - image.downloaded && - platform::ui::route_storage::route_asset_file_exists(image.preview_path); - image.view_ready = - include_cache_state && - image.downloaded && - platform::ui::route_storage::route_asset_file_exists(image.view_path); + ::ui::support::refreshRouteImageSourceState(image, include_cache_state); } s_route_image_saved_state_known = true; s_route_image_cache_state_known = include_cache_state; @@ -2488,100 +2472,48 @@ bool load_kml_route_images(const char* path) s_route_image_saved_state_known = false; s_route_image_cache_state_known = false; s_route_image_cache_build_running = false; - if (!path || path[0] == '\0' || !platform::ui::device::sd_ready()) + if (!path || path[0] == '\0' || !platform::ui::device::sd_ready()) return false; + + size_t feature_start = 0; + const track_sharing::KmlImageCallbacks callbacks{ + &feature_start, + [](void* context) + { *static_cast(context) = s_route_images.size(); }, + [](void*, std::string_view source) + { + if (s_route_images.size() >= kMaxRouteImagePoints || s_route_images.failed()) return false; + return append_route_images(gps::kml::decodeImageSource(source), 0, 0, false); + }, + [](void* context, bool located, double lat, double lon) + { + for (size_t i = *static_cast(context); i < s_route_images.size(); ++i) + { + s_route_images[i].has_position = located; + s_route_images[i].lat = lat; + s_route_images[i].lon = lon; + } + }}; + const bool read_ok = ::ui::support::readRouteImageKml(path, callbacks); + if (!read_ok) { + s_route_images.clear(); // incomplete XML cannot expose a partial list return false; } - - std::vector pending_image_urls; - const bool read_ok = read_track_file_lines( - path, - [&](const std::string& line) - { - if (find_case_insensitive(line, "(text[token_start]))) - { - ++token_start; - } - if (token_start >= text.size()) - { - break; - } - std::size_t token_end = token_start; - while (token_end < text.size() && - !std::isspace(static_cast(text[token_end]))) - { - ++token_end; - } - - double lat = 0.0; - double lon = 0.0; - double altitude_m = 0.0; - bool has_altitude = false; - if (parse_kml_coordinate_token( - text.substr(token_start, token_end - token_start), - lat, - lon, - altitude_m, - has_altitude)) - { - append_route_images(pending_image_urls, lat, lon, true); - pending_image_urls.clear(); - break; - } - token_start = token_end; - } - } - if (!pending_image_urls.empty() && extract_tag_text(line, "gx:coord", text)) - { - double lat = 0.0; - double lon = 0.0; - double altitude_m = 0.0; - bool has_altitude = false; - if (parse_kml_gx_coord_token(text, lat, lon, altitude_m, has_altitude)) - { - append_route_images(pending_image_urls, lat, lon, true); - pending_image_urls.clear(); - } - } - - if (find_case_insensitive(line, "& points, - std::vector<::ui::widgets::route_elevation_profile::Sample>& out) + const TrackPointBuffer& points, + ElevationSampleBuffer& out) { out.clear(); - out.reserve(points.size()); + if (!out.reserve(points.size())) return; double distance_m = 0.0; const TrackOverlayPoint* previous = nullptr; for (const auto& point : points) { + if (point.break_before) previous = nullptr; if (previous) { const double step = route_point_distance_m(*previous, point); @@ -2595,222 +2527,12 @@ void build_route_elevation_samples( sample.altitude_m = point.altitude_m; sample.distance_m = distance_m; sample.has_altitude = point.has_altitude; + sample.break_before = point.break_before; out.push_back(sample); } } -enum class KmlCoordinateMode : uint8_t -{ - None, - Coordinates, - GxCoord, -}; - -struct KmlCoordinateStreamParser -{ - std::vector coordinate_points; - std::vector gx_points; - TrackElevationMetrics coordinate_metrics; - TrackElevationMetrics gx_metrics; - bool in_tag = false; - bool tag_truncated = false; - KmlCoordinateMode mode = KmlCoordinateMode::None; - bool discard_token = false; - std::string tag; - std::string token; - - KmlCoordinateStreamParser() - { - coordinate_points.reserve(kMaxTrackOverlayPoints); - gx_points.reserve(kMaxTrackOverlayPoints); - tag.reserve(kMaxKmlTagBytes); - token.reserve(kMaxKmlCoordinateTokenBytes); - } - - std::vector& active_points() - { - return mode == KmlCoordinateMode::GxCoord ? gx_points : coordinate_points; - } - - TrackElevationMetrics& active_metrics() - { - return mode == KmlCoordinateMode::GxCoord ? gx_metrics : coordinate_metrics; - } - - void flush_token() - { - if (discard_token) - { - token.clear(); - discard_token = false; - return; - } - if (token.empty()) - { - return; - } - - double lat = 0.0; - double lon = 0.0; - double altitude_m = 0.0; - bool has_altitude = false; - const bool parsed = mode == KmlCoordinateMode::GxCoord - ? parse_kml_gx_coord_token(token, lat, lon, altitude_m, has_altitude) - : parse_kml_coordinate_token(token, lat, lon, altitude_m, has_altitude); - if (parsed) - { - update_track_elevation_metrics(active_metrics(), altitude_m, has_altitude); - append_track_point_raw(active_points(), lat, lon, altitude_m, has_altitude); - } - token.clear(); - } - - void begin_mode(KmlCoordinateMode next_mode) - { - mode = next_mode; - token.clear(); - discard_token = false; - } - - void end_mode(KmlCoordinateMode closing_mode) - { - if (mode == closing_mode) - { - flush_token(); - mode = KmlCoordinateMode::None; - } - } - - void handle_tag() - { - if (tag_truncated) - { - tag.clear(); - tag_truncated = false; - return; - } - - bool closing = false; - if (kml_tag_name_matches(tag, "coordinates", closing)) - { - if (closing) - { - end_mode(KmlCoordinateMode::Coordinates); - } - else - { - begin_mode(KmlCoordinateMode::Coordinates); - } - } - else if (kml_tag_name_matches(tag, "coord", closing)) - { - if (closing) - { - end_mode(KmlCoordinateMode::GxCoord); - } - else - { - begin_mode(KmlCoordinateMode::GxCoord); - } - } - tag.clear(); - } - - void consume_char(char ch) - { - if (in_tag) - { - if (ch == '>') - { - in_tag = false; - handle_tag(); - return; - } - if (tag.size() < kMaxKmlTagBytes) - { - tag.push_back(ch); - } - else - { - tag_truncated = true; - } - return; - } - - if (ch == '<') - { - if (mode != KmlCoordinateMode::None) - { - flush_token(); - } - in_tag = true; - tag.clear(); - tag_truncated = false; - return; - } - - if (mode == KmlCoordinateMode::None) - { - return; - } - if (mode == KmlCoordinateMode::Coordinates && - std::isspace(static_cast(ch))) - { - flush_token(); - return; - } - if (discard_token) - { - return; - } - if (token.size() >= kMaxKmlCoordinateTokenBytes) - { - token.clear(); - discard_token = true; - return; - } - token.push_back(ch); - } - - void consume(const char* data, uint32_t size) - { - if (!data) - { - return; - } - for (uint32_t index = 0; index < size; ++index) - { - consume_char(data[index]); - } - } - - void finish() - { - if (mode != KmlCoordinateMode::None) - { - flush_token(); - } - } - - void take_points( - std::vector& out, - TrackElevationMetrics& metrics, - std::vector<::ui::widgets::route_elevation_profile::Sample>& profile_samples) - { - if (!gx_points.empty()) - { - build_route_elevation_samples(gx_points, profile_samples); - metrics = gx_metrics; - out = std::move(gx_points); - return; - } - build_route_elevation_samples(coordinate_points, profile_samples); - metrics = coordinate_metrics; - out = std::move(coordinate_points); - } -}; - -void downsample_track_points(std::vector& points) +void downsample_track_points(TrackPointBuffer& points) { if (points.size() <= kMaxTrackOverlayPoints) { @@ -2826,7 +2548,7 @@ void downsample_track_points(std::vector& points) points.resize(kMaxTrackOverlayPoints); } -void append_track_point_raw(std::vector& out, +void append_track_point_raw(TrackPointBuffer& out, double lat, double lon, double altitude_m, @@ -2840,17 +2562,24 @@ void append_track_point_raw(std::vector& out, out.push_back(point); } -void append_track_point(std::vector& out, +void append_track_point(TrackPointBuffer& out, double lat, double lon, double altitude_m, bool has_altitude) { + if (out.size() == kMaxTrackOverlayPoints) + { + // Legacy GPX/CSV readers also remain bounded, retaining both endpoints. + for (size_t i = 1; i < kMaxTrackOverlayPoints / 2; ++i) out[i] = out[i * 2]; + out[kMaxTrackOverlayPoints / 2] = out.back(); + out.resize(kMaxTrackOverlayPoints / 2 + 1); + } append_track_point_raw(out, lat, lon, altitude_m, has_altitude); downsample_track_points(out); } -bool load_gpx_track_points(const char* path, std::vector& out) +bool load_gpx_track_points(const char* path, TrackPointBuffer& out) { out.clear(); if (!platform::ui::device::sd_ready()) @@ -2858,7 +2587,7 @@ bool load_gpx_track_points(const char* path, std::vector& out return false; } - out.reserve(kMaxTrackOverlayPoints); + if (!out.reserve(kMaxTrackOverlayPoints)) return false; const bool read_ok = read_track_file_lines(path, [&out](const std::string& line) { if (line.find("& out return read_ok && !out.empty(); } -bool load_csv_track_points(const char* path, std::vector& out) +bool load_csv_track_points(const char* path, TrackPointBuffer& out) { out.clear(); if (!platform::ui::device::sd_ready()) @@ -2881,7 +2610,7 @@ bool load_csv_track_points(const char* path, std::vector& out return false; } - out.reserve(kMaxTrackOverlayPoints); + if (!out.reserve(kMaxTrackOverlayPoints)) return false; const bool read_ok = read_track_file_lines(path, [&out](std::string line) { line = trim_copy(std::move(line)); @@ -2912,9 +2641,9 @@ bool load_csv_track_points(const char* path, std::vector& out } bool load_kml_track_points(const char* path, - std::vector& out, + TrackPointBuffer& out, TrackElevationMetrics& metrics, - std::vector<::ui::widgets::route_elevation_profile::Sample>& profile_samples) + ElevationSampleBuffer& profile_samples) { out.clear(); metrics = TrackElevationMetrics{}; @@ -2924,14 +2653,7 @@ bool load_kml_track_points(const char* path, return false; } - out.reserve(kMaxTrackOverlayPoints); - KmlCoordinateStreamParser parser; - const bool read_ok = read_track_file_chunks(path, [&](const char* data, uint32_t size) - { parser.consume(data, size); }); - parser.finish(); - parser.take_points(out, metrics, profile_samples); - downsample_track_points(out); - return read_ok && !out.empty(); + return ::ui::support::projectKmlRoute(path, out, profile_samples, metrics, kMaxTrackOverlayPoints) && !out.empty(); } void append_route_image_overlay(::ui::map::MapOverlaySnapshot& snapshot) @@ -2998,25 +2720,27 @@ void append_track_overlay(::ui::map::MapOverlaySnapshot& snapshot) } const std::size_t total = s_track_points.size(); - const std::size_t count = std::min(available, total); - for (std::size_t index = 0; index < count; ++index) - { - const std::size_t src = count == total - ? index - : (index * (total - 1)) / (count - 1 == 0 ? 1 : count - 1); - const auto& point = s_track_points[src]; - auto& item = snapshot.items[snapshot.item_count++]; - const bool is_route = s_track_overlay_kind == TrackOverlayFileKind::Route; - item.kind = is_route ? ::ui::map::MapOverlayKind::RoutePoint - : ::ui::map::MapOverlayKind::TrackPoint; - item.style = is_route ? ::ui::map::MapOverlayStyle::Route - : ::ui::map::MapOverlayStyle::Track; - item.point.valid = true; - item.point.lat = point.lat; - item.point.lon = point.lon; - item.stable_id = static_cast((is_route ? 0x52540000U : 0x54520000U) + index); - item.visible = true; - } + const auto count = ::ui::support::sampleRouteSegments( + s_track_points.data(), total, available, + [](const TrackOverlayPoint& p) + { return std::isfinite(p.lat) && std::isfinite(p.lon); }, + [&](const TrackOverlayPoint& point, bool connected) + { + auto& item = snapshot.items[snapshot.item_count++]; + item = ::ui::map::MapOverlayItem{}; + const bool is_route = s_track_overlay_kind == TrackOverlayFileKind::Route; + item.kind = is_route ? ::ui::map::MapOverlayKind::RoutePoint + : ::ui::map::MapOverlayKind::TrackPoint; + item.style = is_route ? ::ui::map::MapOverlayStyle::Route + : ::ui::map::MapOverlayStyle::Track; + item.point.valid = true; + item.point.lat = point.lat; + item.point.lon = point.lon; + item.stable_id = static_cast((is_route ? 0x52540000U : 0x54520000U) + snapshot.item_count); + item.visible = true; + item.path_flags = uint8_t(1 | (connected ? 0 : 2)); + return true; + }); if (count < total) { @@ -3066,11 +2790,13 @@ bool load_map_track_file_impl(const char* path, bool show_fail_toast) return false; } - std::vector points; + TrackPointBuffer points; TrackElevationMetrics elevation_metrics; - std::vector<::ui::widgets::route_elevation_profile::Sample> elevation_samples; + ElevationSampleBuffer elevation_samples; const std::string normalized = ::ui::fs::normalize_path(path); TrackOverlayFileKind file_kind = TrackOverlayFileKind::Track; + ::ui::support::KmlNavigationMatch::destroy(s_route_navigation); + ::ui::support::KmlNavigationMatch* navigation = nullptr; bool loaded = false; if (ends_with_ignore_case(normalized, ".csv")) { @@ -3078,7 +2804,9 @@ bool load_map_track_file_impl(const char* path, bool show_fail_toast) } else if (ends_with_ignore_case(normalized, ".kml")) { - loaded = load_kml_track_points(path, points, elevation_metrics, elevation_samples); + navigation = ::ui::support::KmlNavigationMatch::create(); + loaded = navigation && platform::ui::device::sd_ready() && + ::ui::support::projectKmlRoute(path, points, elevation_samples, elevation_metrics, kMaxTrackOverlayPoints, &navigation->expected) && !points.empty(); file_kind = TrackOverlayFileKind::Route; } else @@ -3087,6 +2815,7 @@ bool load_map_track_file_impl(const char* path, bool show_fail_toast) } if (!loaded) { + ::ui::support::KmlNavigationMatch::destroy(navigation); if (show_fail_toast) { set_map_notice("No track yet", 1500); @@ -3116,6 +2845,7 @@ bool load_map_track_file_impl(const char* path, bool show_fail_toast) } s_track_file = path; + s_route_navigation = navigation; s_track_points = std::move(points); s_track_overlay_active = true; s_track_overlay_kind = file_kind; @@ -3424,6 +3154,7 @@ void refresh_timer_cb(lv_timer_t* timer) { (void)timer; gps_runtime_pump().update(sys::millis_now()); + advance_route_navigation(); if (s_root && s_projection == Projection::Map && !s_map_target) { const auto snapshot = map_workspace_model().snapshot(); diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index bcb9f54d..21f29a92 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -1,10 +1,12 @@ #include "ui/screens/tracker/tracker_page_components.h" #include "app/app_config.h" #include "app/app_facade_access.h" +#include "gps/kml/image_sources.h" #include "platform/ui/device_runtime.h" #include "platform/ui/route_storage.h" #include "platform/ui/tracker_runtime.h" #include "platform/ui/wifi_runtime.h" +#include "track_sharing/installed_package_path.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" @@ -13,7 +15,13 @@ #include "ui/screens/tracker/tracker_page_input.h" #include "ui/screens/tracker/tracker_page_layout.h" #include "ui/screens/tracker/tracker_state.h" +#include "ui/support/disconnected_route_line.h" +#include "ui/support/kml_route_projection.h" #include "ui/support/lvgl_fs_utils.h" +#include "ui/support/route_image_kml_reader.h" +#include "ui/support/route_image_memory.h" +#include "ui/support/route_image_source_state.h" +#include "ui/support/route_segment_sampling.h" #include "ui/ui_common.h" #include "ui/widgets/map/map_viewport.h" #include "ui/widgets/route_elevation_profile.h" @@ -71,10 +79,15 @@ struct RoutePreviewPoint double lon = 0.0; double altitude_m = 0.0; bool has_altitude = false; + bool break_before = true; }; +static_assert(sizeof(RoutePreviewPoint) <= 32, "Segment flag must fit existing point padding"); + struct RoutePreviewImage { + uint8_t source_hash[32]{}; + bool source_hash_valid = false; std::string url{}; std::string local_path{}; std::string preview_path{}; @@ -105,10 +118,10 @@ enum class RoutePreviewDownloadState : uint8_t Failed, }; -std::vector s_preview_route_points; -std::vector s_preview_images; -std::vector s_preview_map_line_points; -std::vector<::ui::widgets::route_elevation_profile::Sample> s_preview_elevation_samples; +::ui::support::RouteImageVector s_preview_route_points; +::ui::support::RouteImageVector s_preview_images; +::ui::support::RouteImageVector s_preview_map_line_points; +::ui::support::RouteImageVector<::ui::widgets::route_elevation_profile::Sample, 96> s_preview_elevation_samples; ::ui::widgets::route_elevation_profile::Widget s_preview_elevation_profile; ::ui::widgets::route_image_strip::Widget s_preview_image_strip; std::vector<::ui::widgets::route_image_strip::Item> s_preview_image_strip_items; @@ -178,6 +191,7 @@ void close_route_preview_page(); void render_route_preview_page(); void refresh_route_preview_map(); void update_route_preview_status(); +void show_route_preview_notice(const char* message, uint32_t duration_ms); void update_route_preview_load_button(); void update_route_preview_buttons(); bool preview_selected_route_is_active(); @@ -594,6 +608,7 @@ bool repair_degraded_active_route_from_list() std::string route_asset_id_for_name(const std::string& route_name) { + if (route_name.size() == 70 && route_name.substr(66) == ".kml" && track_sharing::isInstalledPackageAssetId(std::string_view(route_name).substr(0, 66))) return route_name.substr(0, 66); std::uint32_t hash = 2166136261U; for (unsigned char ch : route_name) { @@ -644,79 +659,13 @@ std::size_t find_case_insensitive(const std::string& text, return std::string::npos; } -std::string html_decode_attr(std::string value) -{ - auto replace_all = [&value](const char* from, const char* to) - { - std::size_t pos = 0; - const std::size_t from_len = std::strlen(from); - while ((pos = value.find(from, pos)) != std::string::npos) - { - value.replace(pos, from_len, to); - pos += std::strlen(to); - } - }; - replace_all("&", "&"); - replace_all(""", "\""); - replace_all(""", "\""); - replace_all("&", "&"); - return value; -} - void extract_img_srcs(const std::string& text, std::vector& out_urls) { - std::size_t pos = 0; - while (out_urls.size() < kPreviewMaxImages) - { - const std::size_t img_pos = find_case_insensitive(text, "', img_pos); - const std::size_t tag_len = - (tag_end == std::string::npos) ? (text.size() - img_pos) : (tag_end - img_pos + 1); - const std::string tag = text.substr(img_pos, tag_len); - const std::size_t src_pos = find_case_insensitive(tag, "src"); - if (src_pos != std::string::npos) - { - std::size_t eq_pos = tag.find('=', src_pos + 3); - if (eq_pos != std::string::npos) - { - ++eq_pos; - while (eq_pos < tag.size() && - std::isspace(static_cast(tag[eq_pos]))) - { - ++eq_pos; - } - if (eq_pos < tag.size()) - { - const char quote = (tag[eq_pos] == '\'' || tag[eq_pos] == '"') ? tag[eq_pos] : '\0'; - const std::size_t value_start = quote ? eq_pos + 1 : eq_pos; - std::size_t value_end = value_start; - while (value_end < tag.size()) - { - const char ch = tag[value_end]; - if ((quote && ch == quote) || - (!quote && (std::isspace(static_cast(ch)) || ch == '>'))) - { - break; - } - ++value_end; - } - if (value_end > value_start) - { - out_urls.push_back(html_decode_attr(tag.substr(value_start, value_end - value_start))); - } - } - } - } - if (tag_end == std::string::npos) - { - break; - } - pos = tag_end + 1; - } + if (out_urls.size() >= kPreviewMaxImages) return; + gps::kml::forEachImageSource(text, [&](std::string_view source) + { + out_urls.push_back(gps::kml::decodeImageSource(source)); + return out_urls.size() < kPreviewMaxImages; }); } bool extract_tag_text(const std::string& line, const char* tag, std::string& out_text) @@ -818,7 +767,7 @@ bool parse_space_coordinate_text(const std::string& text, return true; } -void append_preview_route_point(double lat, double lon, double altitude_m, bool has_altitude) +void append_preview_route_point(double lat, double lon, double altitude_m, bool has_altitude, bool break_before) { if (!std::isfinite(lat) || !std::isfinite(lon)) { @@ -829,31 +778,24 @@ void append_preview_route_point(double lat, double lon, double altitude_m, bool point.lon = lon; point.altitude_m = altitude_m; point.has_altitude = has_altitude && std::isfinite(altitude_m); + point.break_before = break_before; s_preview_route_points.push_back(point); } -void append_pending_images(const std::vector& urls, +bool append_pending_images(std::string url, double lat, double lon, bool has_position) { - for (const auto& url : urls) - { - if (s_preview_images.size() >= kPreviewMaxImages) - { - return; - } - if (url.empty()) - { - continue; - } - RoutePreviewImage image{}; - image.url = url; - image.lat = lat; - image.lon = lon; - image.has_position = has_position && std::isfinite(lat) && std::isfinite(lon); - s_preview_images.push_back(image); - } + if (s_preview_images.size() >= kPreviewMaxImages) return false; + if (url.empty()) return true; + RoutePreviewImage image{}; + image.source_hash_valid = platform::ui::route_storage::route_image_reference_hash(url, image.source_hash); + image.url = std::move(url); + image.lat = lat; + image.lon = lon; + image.has_position = has_position && std::isfinite(lat) && std::isfinite(lon); + return s_preview_images.push_back(std::move(image)); } double preview_degrees_to_radians(double degrees) @@ -884,6 +826,7 @@ void refresh_preview_metrics() const RoutePreviewPoint* previous_point = nullptr; for (const auto& point : s_preview_route_points) { + if (point.break_before) previous_point = nullptr, have_previous = false; if (previous_point) { const double distance = preview_distance_m(*previous_point, point); @@ -896,6 +839,7 @@ void refresh_preview_metrics() if (!point.has_altitude || !std::isfinite(point.altitude_m)) { + have_previous = false; continue; } if (!have_altitude) @@ -957,16 +901,12 @@ void refresh_preview_saved_image_count_hint() return; } - std::size_t saved_count = 0; - if (platform::ui::route_storage::count_route_saved_images( - s_preview_asset_id, - s_preview_images.size(), - saved_count)) + for (auto& image : s_preview_images) { - s_preview_saved_image_count_hint = - std::min(saved_count, s_preview_images.size()); - s_preview_image_saved_state_known = true; + ::ui::support::refreshRouteImageSourceState(image, false); + if (image.downloaded) ++s_preview_saved_image_count_hint; } + s_preview_image_saved_state_known = true; } void refresh_preview_image_storage_state(bool include_cache_state) @@ -975,22 +915,11 @@ void refresh_preview_image_storage_state(bool include_cache_state) std::size_t saved_count = 0; for (RoutePreviewImage& image : s_preview_images) { - image.downloaded = - platform::ui::route_storage::route_asset_file_exists(image.local_path); + ::ui::support::refreshRouteImageSourceState(image, include_cache_state); if (image.downloaded) { ++saved_count; } - if (include_cache_state) - { - image.preview_ready = - image.downloaded && - platform::ui::route_storage::route_asset_file_exists(image.preview_path); - } - else - { - image.preview_ready = false; - } image.view_ready = false; } s_preview_saved_image_count_hint = saved_count; @@ -1016,58 +945,10 @@ bool apply_route_preview_download_status_to_images( return true; } - bool changed = false; - if (status.busy && - status.phase == platform::ui::route_storage::RouteImageDownloadPhase::Downloading && - status.failed == 0 && - status.saved > 0) - { - const std::size_t confirmed = - std::min(status.saved, s_preview_images.size()); - for (std::size_t index = 0; index < confirmed; ++index) - { - if (!s_preview_images[index].downloaded) - { - s_preview_images[index].downloaded = true; - changed = true; - } - } - } - else if (status.busy && - status.phase == platform::ui::route_storage::RouteImageDownloadPhase::Caching && - status.failed == 0 && - status.saved > 0) - { - const std::size_t confirmed = - std::min(status.saved, s_preview_images.size()); - for (std::size_t index = 0; index < confirmed; ++index) - { - RoutePreviewImage& image = s_preview_images[index]; - if (!image.downloaded) - { - image.downloaded = true; - changed = true; - } - if (!image.preview_ready) - { - image.preview_ready = true; - changed = true; - } - } - } - if (changed || (status.busy && status.saved > 0)) - { - s_preview_saved_image_count_hint = 0; - for (const RoutePreviewImage& image : s_preview_images) - { - if (image.downloaded) - { - ++s_preview_saved_image_count_hint; - } - } - s_preview_image_saved_state_known = true; - } - return changed; + // A batch may contain only missing images. Its first N successes are not + // the first N global image slots, and counters are not source proofs. + // Keep progress display separate; inspect actual records at termination. + return false; } void clear_preview_model() @@ -1094,126 +975,49 @@ void clear_preview_model() bool load_route_preview_model(const std::string& route_name) { clear_preview_model(); - if (route_name.empty()) - { - return false; - } - + if (route_name.empty()) return false; s_preview_route_name = route_name; s_preview_asset_id = route_asset_id_for_name(route_name); - std::vector fallback_line_points; - std::vector pending_image_urls; - bool in_line_string = false; - const std::string path = route_path_for_name(route_name); - const bool read_ok = ::ui::fs::read_text_file_lines( - path.c_str(), - [&](std::string& line) - { - if (find_case_insensitive(line, "(text[token_start]))) - { - ++token_start; - } - if (token_start >= text.size()) - { - break; - } - std::size_t token_end = token_start; - while (token_end < text.size() && - !std::isspace(static_cast(text[token_end]))) - { - ++token_end; - } - const std::string token = text.substr(token_start, token_end - token_start); - double lat = 0.0; - double lon = 0.0; - double altitude_m = 0.0; - bool has_altitude = false; - if (parse_comma_coordinate_token(token, lat, lon, altitude_m, has_altitude)) - { - if (first_token && !pending_image_urls.empty()) - { - append_pending_images(pending_image_urls, lat, lon, true); - pending_image_urls.clear(); - } - if (in_line_string) - { - RoutePreviewPoint point{}; - point.lat = lat; - point.lon = lon; - point.altitude_m = altitude_m; - point.has_altitude = has_altitude; - fallback_line_points.push_back(point); - } - } - first_token = false; - token_start = token_end; - } - } - - if (find_case_insensitive(line, "(p) = s_preview_images.size(); }, + [](void*, std::string_view source) + { + if (s_preview_images.size() >= kPreviewMaxImages || s_preview_images.failed()) return false; + return append_pending_images(gps::kml::decodeImageSource(source), 0, 0, false); + }, + [](void* p, bool located, double lat, double lon) + { + for (size_t i = *static_cast(p); i < s_preview_images.size(); ++i) + { + s_preview_images[i].has_position = located; + s_preview_images[i].lat = lat; + s_preview_images[i].lon = lon; + } + }}; + if (!::ui::support::readRouteImageKml(path.c_str(), callbacks)) { - s_preview_route_points = fallback_line_points; + clear_preview_model(); + return false; } - refresh_preview_metrics(); + s_preview_metrics.altitude_count = metrics.altitude_count; + s_preview_metrics.min_altitude_m = metrics.min_altitude_m; + s_preview_metrics.max_altitude_m = metrics.max_altitude_m; + s_preview_metrics.ascent_m = metrics.ascent_m; + s_preview_metrics.descent_m = metrics.descent_m; + s_preview_metrics.distance_m = s_preview_elevation_samples.empty() ? 0 : s_preview_elevation_samples.back().distance_m; assign_preview_image_paths(); refresh_preview_saved_image_count_hint(); - if (!s_preview_images.empty()) - { - s_preview_selected_image = 0; - } + if (!s_preview_images.empty()) s_preview_selected_image = 0; return !s_preview_route_points.empty() || !s_preview_images.empty(); } @@ -1333,6 +1137,39 @@ bool preview_model_fits_bounds(lv_obj_t* viewport_root, return model; } +bool build_preview_map_line_points() +{ + s_preview_map_line_points.clear(); + bool disconnected = true; + bool admitted = true; + ::ui::support::sampleRouteSegments( + s_preview_route_points.data(), s_preview_route_points.size(), kPreviewMaxDrawPoints, + [](const RoutePreviewPoint& point) + { return std::isfinite(point.lat) && std::isfinite(point.lon); }, + [&](const RoutePreviewPoint& point, bool connect) + { + lv_point_t projected{}; + if (!::ui::widgets::map::project_point(s_preview_map_runtime, {true, point.lat, point.lon}, projected)) + { + disconnected = true; + return true; + } + if ((!connect || disconnected) && !s_preview_map_line_points.empty()) + admitted = s_preview_map_line_points.push_back(::ui::support::routeLineBreak()); + if (admitted) + { + lv_point_precise_t precise{}; + precise.x = static_cast(projected.x); + precise.y = static_cast(projected.y); + admitted = s_preview_map_line_points.push_back(std::move(precise)); + } + disconnected = false; + return admitted; + }); + if (!admitted) s_preview_map_line_points.clear(); + return s_preview_map_line_points.size() >= 2; +} + void draw_preview_map_overlay() { const auto& widgets = ::ui::widgets::map::widgets(s_preview_map_runtime); @@ -1342,40 +1179,17 @@ void draw_preview_map_overlay() } lv_obj_clean(widgets.overlay_layer); - if (!s_preview_route_points.empty()) + if (build_preview_map_line_points()) { - const std::size_t draw_count = - std::min(kPreviewMaxDrawPoints, s_preview_route_points.size()); - s_preview_map_line_points.clear(); - s_preview_map_line_points.reserve(draw_count); - for (std::size_t index = 0; index < draw_count; ++index) - { - const std::size_t src_index = draw_count <= 1 - ? 0 - : (index * (s_preview_route_points.size() - 1)) / (draw_count - 1); - const auto& point = s_preview_route_points[src_index]; - lv_point_t projected{}; - if (::ui::widgets::map::project_point( - s_preview_map_runtime, - {true, point.lat, point.lon}, - projected)) - { - lv_point_precise_t precise{}; - precise.x = static_cast(projected.x); - precise.y = static_cast(projected.y); - s_preview_map_line_points.push_back(precise); - } - } - if (s_preview_map_line_points.size() >= 2) - { - lv_obj_t* line = lv_line_create(widgets.overlay_layer); - lv_line_set_points(line, - s_preview_map_line_points.data(), - static_cast(s_preview_map_line_points.size())); - lv_obj_set_style_line_width(line, 3, LV_PART_MAIN); - lv_obj_set_style_line_color(line, lv_color_hex(0xE94F2E), LV_PART_MAIN); - lv_obj_set_style_line_rounded(line, true, LV_PART_MAIN); - } + lv_obj_t* line = lv_line_create(widgets.overlay_layer); + lv_obj_set_size(line, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_line_width(line, 3, LV_PART_MAIN); + lv_obj_set_style_line_color(line, lv_color_hex(0xE94F2E), LV_PART_MAIN); + lv_obj_set_style_line_rounded(line, true, LV_PART_MAIN); + lv_obj_add_event_cb( + line, [](lv_event_t* event) + { ::ui::support::drawDisconnectedRouteLine(event, s_preview_map_line_points.data(), s_preview_map_line_points.size()); }, + LV_EVENT_DRAW_MAIN, nullptr); } for (std::size_t index = 0; index < s_preview_images.size(); ++index) @@ -1607,7 +1421,7 @@ void toggle_route_preview_image_strip() assign_preview_image_paths(); if (s_preview_images.empty()) { - s_preview_status_text = "No route images"; + s_preview_status_text = s_preview_images.failed() ? ::ui::i18n::tr("Image memory unavailable") : "No route images"; s_preview_image_strip_visible = false; sync_route_preview_image_strip(); update_route_preview_status(); @@ -1620,6 +1434,8 @@ void toggle_route_preview_image_strip() if (s_preview_image_strip_visible) { ensure_route_preview_image_cache_build(); + if (::ui::support::routeImagesNeedSourceReview(s_preview_images)) + show_route_preview_notice(::ui::i18n::tr("Image cache needs checking. Download again."), 2200); } sync_route_preview_image_strip(); update_route_preview_status(); @@ -1677,9 +1493,10 @@ std::vector route_preview_cach { std::vector items; items.reserve(s_preview_images.size()); + const bool package = track_sharing::isInstalledPackageRoute(route_path_for_name(s_preview_route_name)); for (const auto& image : s_preview_images) { - if (!image.downloaded) + if (!image.downloaded && !package) { continue; } @@ -1687,6 +1504,8 @@ std::vector route_preview_cach item.source_path = image.local_path; item.preview_path = image.preview_path; item.view_path = image.view_path; + std::memcpy(item.source_hash, image.source_hash, 32); + item.source_hash_valid = image.source_hash_valid; items.push_back(std::move(item)); } return items; @@ -1923,12 +1742,14 @@ void pause_route_preview_download_poll_timer() void ensure_route_preview_image_cache_build(bool refresh_state) { + const auto path = route_path_for_name(s_preview_route_name); + const bool package = track_sharing::isInstalledPackageRoute(path); if (refresh_state) { refresh_preview_image_storage_state(true); } if (s_preview_images.empty() || - !route_preview_all_images_saved() || + (!route_preview_all_images_saved() && !package) || route_preview_all_image_caches_ready()) { return; @@ -1970,9 +1791,10 @@ void ensure_route_preview_image_cache_build(bool refresh_state) std::string error; if (!platform::ui::route_storage::start_route_image_cache_build( s_preview_asset_id, - items, + std::move(items), error, - platform::ui::route_storage::RouteImageTaskPresentation::UserVisible)) + platform::ui::route_storage::RouteImageTaskPresentation::UserVisible, + package ? path : std::string{})) { s_preview_download_state = RoutePreviewDownloadState::Failed; s_preview_status_text = error.empty() ? "Start cache failed" : error; @@ -2190,13 +2012,14 @@ void ensure_route_preview_elevation_profile() void rebuild_preview_elevation_samples() { + if (!s_preview_elevation_samples.empty()) return; // full-stream distances already projected at load s_preview_elevation_samples.clear(); - s_preview_elevation_samples.reserve(s_preview_route_points.size()); + if (!s_preview_elevation_samples.reserve(s_preview_route_points.size())) return; double distance_m = 0.0; const RoutePreviewPoint* previous = nullptr; for (const auto& point : s_preview_route_points) { - if (previous) + if (previous && !point.break_before) { const double step = preview_distance_m(*previous, point); if (std::isfinite(step)) @@ -2209,6 +2032,7 @@ void rebuild_preview_elevation_samples() sample.altitude_m = point.altitude_m; sample.distance_m = distance_m; sample.has_altitude = point.has_altitude; + sample.break_before = point.break_before; s_preview_elevation_samples.push_back(sample); } } @@ -2659,6 +2483,11 @@ void on_route_preview_download_clicked(lv_event_t*) return; } + if (track_sharing::isInstalledPackageRoute(route_path_for_name(s_preview_route_name))) + { + ensure_route_preview_image_cache_build(false); + return; + } const auto wifi = platform::ui::wifi::status(); if (!wifi.supported || !wifi.connected) { diff --git a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp index 0ea94dfb..a4014d05 100644 --- a/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp +++ b/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp @@ -12,6 +12,7 @@ #include "platform/ui/device_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" +#include "ui/support/overlay_path_segments.h" #include "ui/widgets/map/map_tiles.h" #include "ui_map_runtime/map_geo_coordinates.h" #include "ui_presentation/map/map_marker_cache.h" @@ -691,9 +692,35 @@ void refresh_markers(RuntimeImpl& impl) lv_obj_invalidate(impl.widgets.overlay_layer); } +void draw_overlay_paths(lv_event_t* event, const RuntimeImpl& impl) +{ + if (!impl.overlay.header.valid) return; + auto* layer = lv_event_get_layer(event); + auto* object = static_cast(lv_event_get_current_target(event)); + if (!layer || !object) return; + lv_area_t origin; + lv_obj_get_coords(object, &origin); + lv_draw_line_dsc_t line; + lv_draw_line_dsc_init(&line); + line.width = 2; + line.opa = LV_OPA_COVER; + ::ui::support::visitOverlayPathSegments( + impl.overlay, + [&](const ::ui::map::MapGeoPoint& point, lv_point_t& output) + { return project_geo_point(impl, {point.valid, point.lat, point.lon}, output); }, + [&](const lv_point_t& a, const lv_point_t& b, ::ui::map::MapOverlayStyle style) + { + line.p1 = {static_cast(a.x + origin.x1), static_cast(a.y + origin.y1)}; + line.p2 = {static_cast(b.x + origin.x1), static_cast(b.y + origin.y1)}; + line.color = overlay_color(style); + lv_draw_line(layer, &line); + }); +} + void draw_markers(lv_event_t* event) { const auto& impl = *static_cast(lv_event_get_user_data(event)); + draw_overlay_paths(event, impl); if (!impl.markers || !impl.markers->cache.valid || !impl.marker_binding) return; const auto status = impl.marker_binding->source->markerStatus(); if (!status.ready || status.revision != impl.markers->cache.seen.revision) return; @@ -742,6 +769,8 @@ void render_overlay(RuntimeImpl& impl) impl.marker_drag_dx = 0; impl.marker_drag_dy = 0; refresh_markers(impl); + // Paths have no child objects to invalidate their old or new pixels. + lv_obj_invalidate(impl.widgets.overlay_layer); if (!impl.overlay.header.valid) { return; @@ -750,6 +779,7 @@ void render_overlay(RuntimeImpl& impl) for (std::size_t i = 0; i < impl.overlay.item_count; ++i) { const auto& item = impl.overlay.items[i]; + if (item.path_flags & 1) continue; // existing draw callback renders paths if (!item.visible || !item.point.valid) { continue; @@ -1020,6 +1050,7 @@ Widgets create(Runtime& runtime, lv_obj_t* parent, uint32_t loader_interval_ms) impl->poi_overlay.create(impl->widgets.root); #endif impl->widgets.overlay_layer = lv_obj_create(impl->widgets.root); + lv_obj_add_event_cb(impl->widgets.overlay_layer, draw_markers, LV_EVENT_DRAW_MAIN, impl); lv_obj_set_size(impl->widgets.overlay_layer, LV_PCT(100), LV_PCT(100)); lv_obj_set_pos(impl->widgets.overlay_layer, 0, 0); make_plain(impl->widgets.overlay_layer); @@ -1222,9 +1253,9 @@ void set_marker_binding(Runtime& runtime, const ui::map::MapMarkerBinding* bindi lv_obj_remove_event_cb(impl->widgets.overlay_layer, draw_markers); impl->marker_binding = binding; impl->marker_retry_at = 0; + lv_obj_add_event_cb(impl->widgets.overlay_layer, draw_markers, LV_EVENT_DRAW_MAIN, impl); if (binding) { - lv_obj_add_event_cb(impl->widgets.overlay_layer, draw_markers, LV_EVENT_DRAW_MAIN, impl); refresh_markers(*impl); } lv_obj_invalidate(impl->widgets.overlay_layer); diff --git a/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp b/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp index 5cdbb773..931173c3 100644 --- a/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp +++ b/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp @@ -1,4 +1,6 @@ #include "ui/widgets/route_elevation_profile.h" +#include "ui/support/disconnected_route_line.h" +#include "ui/support/route_segment_sampling.h" #include #include @@ -33,27 +35,6 @@ void set_label_text(lv_obj_t* label, const char* text) lv_label_set_text(label, text ? text : ""); } -const Sample* altitude_sample_at_ordinal(const Sample* samples, - std::size_t sample_count, - std::size_t target_ordinal) -{ - std::size_t ordinal = 0; - for (std::size_t index = 0; index < sample_count; ++index) - { - const auto& sample = samples[index]; - if (!sample.has_altitude || !std::isfinite(sample.altitude_m)) - { - continue; - } - if (ordinal == target_ordinal) - { - return &sample; - } - ++ordinal; - } - return nullptr; -} - } // namespace void reset(Widget& widget) @@ -98,9 +79,15 @@ void create(lv_obj_t* parent, Widget& widget, const Config& config) widget.line = lv_line_create(widget.panel); lv_obj_set_pos(widget.line, 0, 0); + lv_obj_set_size(widget.line, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_line_color(widget.line, lv_color_hex(0x7FD8BE), 0); lv_obj_set_style_line_width(widget.line, 2, 0); lv_obj_set_style_line_rounded(widget.line, true, 0); + lv_obj_add_event_cb( + widget.line, [](lv_event_t* event) + { const auto* state = static_cast(lv_event_get_user_data(event)); + if (state) ::ui::support::drawDisconnectedRouteLine(event, state->points.data(), state->points.size()); }, + LV_EVENT_DRAW_MAIN, &widget); } void set_hidden(Widget& widget, bool hidden) @@ -189,42 +176,37 @@ bool update(Widget& widget, const double range = std::max(1.0, metrics.max_altitude_m - metrics.min_altitude_m); const std::size_t draw_count = - std::max(2, std::min(config.max_points, altitude_count)); + std::max(2, std::min({96, config.max_points, altitude_count})); widget.points.clear(); - widget.points.reserve(draw_count); const double total_distance = std::max(1.0, std::isfinite(metrics.distance_m) ? metrics.distance_m : 0.0); - for (std::size_t index = 0; index < draw_count; ++index) - { - const std::size_t src_ordinal = - draw_count <= 1 ? 0 : (index * (altitude_count - 1)) / (draw_count - 1); - const Sample* sample = altitude_sample_at_ordinal(samples, sample_count, src_ordinal); - if (!sample) + bool admitted = true; + ::ui::support::sampleRouteSegments( + samples, sample_count, draw_count, + [](const Sample& sample) + { return sample.has_altitude && std::isfinite(sample.altitude_m) && std::isfinite(sample.distance_m); }, + [&](const Sample& sample, bool connect) { - continue; - } - double x_ratio = sample->distance_m / total_distance; - if (!std::isfinite(x_ratio)) - { - x_ratio = 0.0; - } - x_ratio = std::max(0.0, std::min(1.0, x_ratio)); - const double y_ratio = (metrics.max_altitude_m - sample->altitude_m) / range; - lv_point_precise_t point{}; - point.x = static_cast(config.plot_pad + std::lround(x_ratio * plot_width)); - point.y = static_cast(config.plot_top + std::lround(y_ratio * plot_height)); - widget.points.push_back(point); - } + double x_ratio = sample.distance_m / total_distance; + x_ratio = std::max(0.0, std::min(1.0, x_ratio)); + const double y_ratio = (metrics.max_altitude_m - sample.altitude_m) / range; + if (!connect && !widget.points.empty()) admitted = widget.points.push_back(::ui::support::routeLineBreak()); + if (admitted) + { + lv_point_precise_t point{}; + point.x = static_cast(config.plot_pad + std::lround(x_ratio * plot_width)); + point.y = static_cast(config.plot_top + std::lround(y_ratio * plot_height)); + admitted = widget.points.push_back(std::move(point)); + } + return admitted; + }); - if (widget.points.size() < 2) + if (!admitted || widget.points.size() < 2) { + widget.points.clear(); set_hidden(widget, true); return false; } - - lv_line_set_points(widget.line, - widget.points.data(), - static_cast(widget.points.size())); set_hidden(widget, false); lv_obj_move_foreground(widget.panel); return true; diff --git a/packs/ar/locales/ar/strings.tsv b/packs/ar/locales/ar/strings.tsv index 52e84c29..5c51be28 100644 --- a/packs/ar/locales/ar/strings.tsv +++ b/packs/ar/locales/ar/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints لا توجد نقاط طريق محفوظة Saved waypoint نقطة طريق محفوظة Waypoint unavailable نقطة الطريق غير متاحة Reticulum Reticulum +Image memory unavailable ذاكرة الصور غير كافية +Image cache needs checking. Download again. تحتاج ذاكرة الصور إلى التحقق. نزّل الصور مجددًا. +Off route %.0fm خارج المسار %.0fm +Route position unavailable موضع المسار غير متاح +Route storage unavailable تخزين المسار غير متاح +Route: waiting for GPS المسار: انتظار GPS +Route position ambiguous موضع المسار متعدد الاحتمالات +Route end (estimated) نهاية المسار (تقدير) +Route %u%% | %s left المسار %u%% | المتبقي %s +Matching route... جارٍ مطابقة المسار… diff --git a/packs/ar/package.ini b/packs/ar/package.ini index c4b04394..4275593b 100644 --- a/packs/ar/package.ini +++ b/packs/ar/package.ini @@ -1,7 +1,7 @@ kind=package id=ar package_type=locale-bundle -version=1.3.0 +version=1.3.2 display_name=Arabic summary=Arabic review locale bundle with Naskh font coverage for RTL UI text and mixed-script content rendering; display-only until Arabic input is implemented. description=DESCRIPTION.txt diff --git a/packs/europe-cyrillic-ext/locales/ru/strings.tsv b/packs/europe-cyrillic-ext/locales/ru/strings.tsv index f9bc907e..08afb8d9 100644 --- a/packs/europe-cyrillic-ext/locales/ru/strings.tsv +++ b/packs/europe-cyrillic-ext/locales/ru/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Нет сохранённых точек Saved waypoint Сохранённая точка Waypoint unavailable Точка недоступна Reticulum Reticulum +Image memory unavailable Недостаточно памяти для изображений +Image cache needs checking. Download again. Кэш изображений требует проверки. Скачайте изображения снова. +Off route %.0fm Вне маршрута %.0fm +Route position unavailable Положение на маршруте недоступно +Route storage unavailable Хранилище маршрута недоступно +Route: waiting for GPS Маршрут: ожидание GPS +Route position ambiguous Положение неоднозначно +Route end (estimated) Конец маршрута (оценка) +Route %u%% | %s left Маршрут %u%% | осталось %s +Matching route... Сопоставление маршрута… diff --git a/packs/europe-cyrillic-ext/package.ini b/packs/europe-cyrillic-ext/package.ini index b2273d28..de30bc68 100644 --- a/packs/europe-cyrillic-ext/package.ini +++ b/packs/europe-cyrillic-ext/package.ini @@ -1,7 +1,7 @@ kind=package id=europe-cyrillic-ext package_type=locale-bundle -version=1.4.0 +version=1.4.2 display_name=European Cyrillic Extended summary=Shared Cyrillic font coverage, a Russian review locale pack, and a Russian Cyrillic keyboard layout. description=DESCRIPTION.txt diff --git a/packs/europe-latin-ext/locales/de/strings.tsv b/packs/europe-latin-ext/locales/de/strings.tsv index da94937d..2d6fb365 100644 --- a/packs/europe-latin-ext/locales/de/strings.tsv +++ b/packs/europe-latin-ext/locales/de/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Keine gespeicherten Wegpunkte Saved waypoint Gespeicherter Wegpunkt Waypoint unavailable Wegpunkt nicht verfügbar Reticulum Reticulum +Image memory unavailable Bildspeicher nicht verfügbar +Image cache needs checking. Download again. Bildcache muss geprüft werden. Bilder erneut herunterladen. +Off route %.0fm Abseits der Route %.0fm +Route position unavailable Routenposition nicht verfügbar +Route storage unavailable Routenspeicher nicht verfügbar +Route: waiting for GPS Route: GPS wird erwartet +Route position ambiguous Routenposition mehrdeutig +Route end (estimated) Routenende (geschätzt) +Route %u%% | %s left Route %u%% | noch %s +Matching route... Route wird abgeglichen… diff --git a/packs/europe-latin-ext/locales/es/strings.tsv b/packs/europe-latin-ext/locales/es/strings.tsv index cb926fcb..ec2e6323 100644 --- a/packs/europe-latin-ext/locales/es/strings.tsv +++ b/packs/europe-latin-ext/locales/es/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints No hay puntos guardados Saved waypoint Punto guardado Waypoint unavailable Punto no disponible Reticulum Reticulum +Image memory unavailable Memoria de imágenes insuficiente +Image cache needs checking. Download again. Hay que verificar la caché de imágenes. Descárgalas de nuevo. +Off route %.0fm Fuera de ruta %.0fm +Route position unavailable Posición de ruta no disponible +Route storage unavailable Almacenamiento de ruta no disponible +Route: waiting for GPS Ruta: esperando GPS +Route position ambiguous Posición de ruta ambigua +Route end (estimated) Fin de ruta (estimado) +Route %u%% | %s left Ruta %u%% | quedan %s +Matching route... Comprobando ruta… diff --git a/packs/europe-latin-ext/locales/fr/strings.tsv b/packs/europe-latin-ext/locales/fr/strings.tsv index bd995dd1..da34f53f 100644 --- a/packs/europe-latin-ext/locales/fr/strings.tsv +++ b/packs/europe-latin-ext/locales/fr/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Aucun point enregistré Saved waypoint Point enregistré Waypoint unavailable Point indisponible Reticulum Reticulum +Image memory unavailable Mémoire insuffisante pour les images +Image cache needs checking. Download again. Le cache des images doit être vérifié. Téléchargez-les à nouveau. +Off route %.0fm Hors itinéraire %.0fm +Route position unavailable Position sur l’itinéraire indisponible +Route storage unavailable Stockage de l’itinéraire indisponible +Route: waiting for GPS Itinéraire : attente GPS +Route position ambiguous Position sur l’itinéraire ambiguë +Route end (estimated) Fin d’itinéraire (estimée) +Route %u%% | %s left Itinéraire %u%% | reste %s +Matching route... Correspondance de l’itinéraire… diff --git a/packs/europe-latin-ext/locales/it/strings.tsv b/packs/europe-latin-ext/locales/it/strings.tsv index 3a674d31..9d90e137 100644 --- a/packs/europe-latin-ext/locales/it/strings.tsv +++ b/packs/europe-latin-ext/locales/it/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Nessun punto salvato Saved waypoint Punto salvato Waypoint unavailable Punto non disponibile Reticulum Reticulum +Image memory unavailable Memoria per le immagini insufficiente +Image cache needs checking. Download again. La cache delle immagini va verificata. Scaricale di nuovo. +Off route %.0fm Fuori percorso %.0fm +Route position unavailable Posizione sul percorso non disponibile +Route storage unavailable Memoria del percorso non disponibile +Route: waiting for GPS Percorso: attesa GPS +Route position ambiguous Posizione sul percorso ambigua +Route end (estimated) Fine percorso (stimata) +Route %u%% | %s left Percorso %u%% | restano %s +Matching route... Confronto del percorso… diff --git a/packs/europe-latin-ext/locales/nl/strings.tsv b/packs/europe-latin-ext/locales/nl/strings.tsv index b81e8b42..ae5c4d1a 100644 --- a/packs/europe-latin-ext/locales/nl/strings.tsv +++ b/packs/europe-latin-ext/locales/nl/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Geen opgeslagen routepunten Saved waypoint Opgeslagen routepunt Waypoint unavailable Routepunt niet beschikbaar Reticulum Reticulum +Image memory unavailable Onvoldoende geheugen voor afbeeldingen +Image cache needs checking. Download again. De afbeeldingscache moet worden gecontroleerd. Download opnieuw. +Off route %.0fm Buiten route %.0fm +Route position unavailable Routepositie niet beschikbaar +Route storage unavailable Routeopslag niet beschikbaar +Route: waiting for GPS Route: wachten op GPS +Route position ambiguous Routepositie is dubbelzinnig +Route end (estimated) Route-einde (geschat) +Route %u%% | %s left Route %u%% | nog %s +Matching route... Route vergelijken… diff --git a/packs/europe-latin-ext/locales/pl/strings.tsv b/packs/europe-latin-ext/locales/pl/strings.tsv index 11b0434a..a231a1ef 100644 --- a/packs/europe-latin-ext/locales/pl/strings.tsv +++ b/packs/europe-latin-ext/locales/pl/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Brak zapisanych punktów Saved waypoint Zapisany punkt Waypoint unavailable Punkt niedostępny Reticulum Reticulum +Image memory unavailable Za mało pamięci na obrazy +Image cache needs checking. Download again. Pamięć podręczna obrazów wymaga sprawdzenia. Pobierz je ponownie. +Off route %.0fm Poza trasą %.0fm +Route position unavailable Pozycja na trasie niedostępna +Route storage unavailable Pamięć trasy niedostępna +Route: waiting for GPS Trasa: oczekiwanie na GPS +Route position ambiguous Pozycja na trasie niejednoznaczna +Route end (estimated) Koniec trasy (szacowany) +Route %u%% | %s left Trasa %u%% | zostało %s +Matching route... Dopasowywanie trasy… diff --git a/packs/europe-latin-ext/locales/pt-PT/strings.tsv b/packs/europe-latin-ext/locales/pt-PT/strings.tsv index a2efe3cb..697e9db4 100644 --- a/packs/europe-latin-ext/locales/pt-PT/strings.tsv +++ b/packs/europe-latin-ext/locales/pt-PT/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints Sem pontos guardados Saved waypoint Ponto guardado Waypoint unavailable Ponto indisponível Reticulum Reticulum +Image memory unavailable Memória insuficiente para imagens +Image cache needs checking. Download again. A cache de imagens precisa de verificação. Transfira-as novamente. +Off route %.0fm Fora do percurso %.0fm +Route position unavailable Posição no percurso indisponível +Route storage unavailable Armazenamento do percurso indisponível +Route: waiting for GPS Percurso: a aguardar GPS +Route position ambiguous Posição no percurso ambígua +Route end (estimated) Fim do percurso (estimado) +Route %u%% | %s left Percurso %u%% | faltam %s +Matching route... A verificar o percurso… diff --git a/packs/europe-latin-ext/package.ini b/packs/europe-latin-ext/package.ini index 18f2d58c..b704b95e 100644 --- a/packs/europe-latin-ext/package.ini +++ b/packs/europe-latin-ext/package.ini @@ -1,7 +1,7 @@ kind=package id=europe-latin-ext package_type=locale-bundle -version=1.3.0 +version=1.3.2 display_name=European Latin Extended summary=Shared Latin-Extended font coverage plus German, Spanish, French, Italian, Dutch, Polish, and Portuguese review locale packs. description=DESCRIPTION.txt diff --git a/packs/ja/locales/ja/strings.tsv b/packs/ja/locales/ja/strings.tsv index 4333269f..f810e6d5 100644 --- a/packs/ja/locales/ja/strings.tsv +++ b/packs/ja/locales/ja/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints 保存済みのウェイポイントはありません Saved waypoint 保存済みウェイポイント Waypoint unavailable ウェイポイントを利用できません Reticulum Reticulum +Image memory unavailable 画像用メモリが不足しています +Image cache needs checking. Download again. 画像キャッシュの確認が必要です。再ダウンロードしてください。 +Off route %.0fm ルート外 %.0fm +Route position unavailable ルート位置を取得できません +Route storage unavailable ルート保存領域が使用できません +Route: waiting for GPS ルート:GPS待機中 +Route position ambiguous ルート位置に複数の候補 +Route end (estimated) ルート終点(推定) +Route %u%% | %s left ルート %u%% | 残り %s +Matching route... ルートを照合中… diff --git a/packs/ja/package.ini b/packs/ja/package.ini index 37944a99..6bacfcb9 100644 --- a/packs/ja/package.ini +++ b/packs/ja/package.ini @@ -1,7 +1,7 @@ kind=package id=ja package_type=locale-bundle -version=1.3.0 +version=1.3.2 display_name=Japanese summary=Japanese review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Japanese input is implemented. description=DESCRIPTION.txt diff --git a/packs/ko/locales/ko/strings.tsv b/packs/ko/locales/ko/strings.tsv index d38c0b19..c78a8dca 100644 --- a/packs/ko/locales/ko/strings.tsv +++ b/packs/ko/locales/ko/strings.tsv @@ -828,3 +828,13 @@ No saved waypoints 저장된 지점이 없습니다 Saved waypoint 저장된 지점 Waypoint unavailable 지점을 사용할 수 없습니다 Reticulum Reticulum +Image memory unavailable 이미지 메모리가 부족합니다 +Image cache needs checking. Download again. 이미지 캐시를 확인해야 합니다. 다시 다운로드하세요. +Off route %.0fm 경로 이탈 %.0fm +Route position unavailable 경로 위치를 확인할 수 없음 +Route storage unavailable 경로 저장소를 사용할 수 없음 +Route: waiting for GPS 경로: GPS 대기 중 +Route position ambiguous 경로 위치 후보가 여러 개임 +Route end (estimated) 경로 끝 도착 추정 +Route %u%% | %s left 경로 %u%% | 남은 거리 %s +Matching route... 경로 확인 중… diff --git a/packs/ko/package.ini b/packs/ko/package.ini index 2bc40cc1..765d5957 100644 --- a/packs/ko/package.ini +++ b/packs/ko/package.ini @@ -1,7 +1,7 @@ kind=package id=ko package_type=locale-bundle -version=1.3.0 +version=1.3.2 display_name=Korean summary=Korean review locale bundle with external CJK font coverage for UI text and mixed-language content; display-only until Korean input is implemented. description=DESCRIPTION.txt diff --git a/packs/zh-Hans/locales/zh-Hans/strings.tsv b/packs/zh-Hans/locales/zh-Hans/strings.tsv index 2744077f..271a3b43 100644 --- a/packs/zh-Hans/locales/zh-Hans/strings.tsv +++ b/packs/zh-Hans/locales/zh-Hans/strings.tsv @@ -828,6 +828,16 @@ No saved waypoints 没有已保存的航点 Saved waypoint 已保存航点 Waypoint unavailable 航点不可用 Reticulum Reticulum 网络 +Image memory unavailable 图片内存不足 +Image cache needs checking. Download again. 图片缓存需要核对,请重新下载。 +Off route %.0fm 偏离线路 %.0fm +Route position unavailable 线路位置不可用 +Route storage unavailable 线路存储不可用 +Route: waiting for GPS 线路:等待定位 +Route position ambiguous 线路位置有多解 +Route end (estimated) 估计到达线路终点 +Route %u%% | %s left 线路 %u%% | 剩余 %s +Matching route... 正在匹配线路… No nodes 暂无节点 Region unknown 行政区未知 Position unknown 暂无位置信息 diff --git a/packs/zh-Hans/package.ini b/packs/zh-Hans/package.ini index b7f5097f..bd6b2e88 100644 --- a/packs/zh-Hans/package.ini +++ b/packs/zh-Hans/package.ini @@ -1,7 +1,7 @@ kind=package id=zh-Hans package_type=locale-bundle -version=1.4.0 +version=1.4.2 display_name=Simplified Chinese summary=Tiered Simplified Chinese locale bundle with standard-display fonts and T-Deck Pro 16px 1bpp GNU Unifont variants, plus the built-in Pinyin IME backend. description=DESCRIPTION.txt diff --git a/packs/zh-Hant/locales/zh-Hant/strings.tsv b/packs/zh-Hant/locales/zh-Hant/strings.tsv index 89bc481f..1a1cab81 100644 --- a/packs/zh-Hant/locales/zh-Hant/strings.tsv +++ b/packs/zh-Hant/locales/zh-Hant/strings.tsv @@ -828,6 +828,16 @@ No saved waypoints 沒有已儲存的航點 Saved waypoint 已儲存航點 Waypoint unavailable 航點無法使用 Reticulum Reticulum 網路 +Image memory unavailable 圖片記憶體不足 +Image cache needs checking. Download again. 圖片快取需要核對,請重新下載。 +Off route %.0fm 偏離路線 %.0fm +Route position unavailable 路線位置無法取得 +Route storage unavailable 路線儲存無法使用 +Route: waiting for GPS 路線:等待定位 +Route position ambiguous 路線位置有多解 +Route end (estimated) 估計抵達路線終點 +Route %u%% | %s left 路線 %u%% | 剩餘 %s +Matching route... 正在比對路線… No nodes 暫無節點 Region unknown 行政區未知 Position unknown 暫無位置資訊 diff --git a/packs/zh-Hant/package.ini b/packs/zh-Hant/package.ini index 05499174..d596f8a6 100644 --- a/packs/zh-Hant/package.ini +++ b/packs/zh-Hant/package.ini @@ -1,7 +1,7 @@ kind=package id=zh-Hant package_type=locale-bundle -version=1.3.0 +version=1.3.2 display_name=Traditional Chinese (Taiwan) summary=Traditional Chinese (Taiwan) review locale bundle with CJK font coverage; display-only until native review and a real Zhuyin-focused IME are available. description=DESCRIPTION.txt diff --git a/platform/esp/arduino_common/configure_sources.py b/platform/esp/arduino_common/configure_sources.py new file mode 100644 index 00000000..2ce78365 --- /dev/null +++ b/platform/esp/arduino_common/configure_sources.py @@ -0,0 +1,13 @@ +# PlatformIO library hook. Preserve existing exclusions for all other targets. +Import("env") +source_filter = ["+<*>", "-", "-", + "-", "-"] +if env.subst("$PIOENV") != "wio_tracker_l2": + source_filter.append("-") +else: + env.AppendUnique(CPPPATH=[ + env.subst("$PROJECT_DIR") + "/modules/core_track_sharing/include", + env.subst("$PROJECT_DIR") + "/platform/esp/idf_common/include", + env.subst("$PROJECT_DIR") + "/third_party/yxml", + ]) +env.Replace(SRC_FILTER=source_filter) diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h index 8ccdecef..690b1486 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h @@ -35,6 +35,7 @@ void requireWorkingConfigRepair(); // Binds the one live AppConfig instance. Independent Settings owners use the // observer only to schedule a repository write; it never makes NVS authoritative. void bindWorkingConfig(const AppConfig& config); +bool workingConfigIsBound(); void requestWorkingConfigSync(); void serviceWorkingConfig(); @@ -61,6 +62,17 @@ bool syncWorkingConfig(const AppConfig& config); bool backupWorkingConfig(const AppConfig& config); bool restoreWorkingConfig(); +// Web editing stages the existing canonical format on device storage without +// an AppConfig clone. These APIs must run on the application command consumer. +const char* webWorkingCandidatePath(); +bool validateWebWorkingCandidate(); +// Successful promotion is durable; the regular service tick restarts the +// device after allowing the USB reply to drain. No old-state sync may replace it. +bool commitWebWorkingCandidate(); +bool webConfigurationRestartPending(); +// Reuse the existing USB reply drain delay for an identity maintenance reboot. +void scheduleWebManagementRestart(); + void beginWorkingConfigReset(); void endWorkingConfigReset(); bool resetWorkingConfig(); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/geocaching_discovery_budget.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/geocaching_discovery_budget.h index 3678d5ad..064051ff 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/geocaching_discovery_budget.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/geocaching_discovery_budget.h @@ -8,14 +8,21 @@ namespace chat::lxmf::runtime { // Admission only: an admitted announcement must still pass the normal -// destination/identity/signature validation before reaching Geocaching. +// destination/identity/signature validation before reaching an application. class GeocachingDiscoveryBudget { public: static bool matches(const reticulum::ParsedPacket& packet, bool listening, const RuntimeBudget& budget) { - if (!listening || !packet.valid || !packet.destination_hash || + return matchesApplications(packet, listening, false, budget); + } + // Both applications share the existing four-announcement window. Track + // uses read-only service hashes instead of a second mutable resident cache. + static bool matchesApplications(const reticulum::ParsedPacket& packet, bool geocaching, bool tracks, + const RuntimeBudget& budget) + { + if ((!geocaching && !tracks) || !packet.valid || !packet.destination_hash || packet.packet_type != reticulum::PacketType::Announce || packet.destination_type != reticulum::DestinationType::Single || !budget.phase || std::strcmp(budget.phase, "call") == 0 || @@ -24,15 +31,17 @@ class GeocachingDiscoveryBudget return false; reticulum::ParsedAnnounce announce{}; if (!reticulum::parseAnnounce(packet, &announce) || - !announce.name_hash || !announce.app_data_len || announce.app_data_len > 96) + !announce.name_hash || !announce.app_data_len || announce.app_data_len > 128) return false; - static const auto name = [] - { - std::array value{}; - reticulum::computeNameHash("trailmate", "geocache.directory", value.data()); - return value; - }(); - return std::memcmp(name.data(), announce.name_hash, name.size()) == 0; + // SHA-256(full service name)[:10], verified against the actual + // Reticulum computeNameHash implementation in discovery admission tests. + static constexpr std::array name{ + 0xe9, 0xef, 0xf7, 0x7f, 0x95, 0xaf, 0xde, 0xba, 0x4e, 0x0c}; + if (geocaching && announce.app_data_len <= 96 && std::memcmp(name.data(), announce.name_hash, name.size()) == 0) return true; + if (!tracks) return false; + static constexpr std::array track_name{ + 0x68, 0xd6, 0x34, 0x81, 0xa7, 0x36, 0xf9, 0xc3, 0x18, 0xad}; + return std::memcmp(track_name.data(), announce.name_hash, track_name.size()) == 0; } bool consume(uint32_t now_ms) diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/local_identity_access.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/local_identity_access.h new file mode 100644 index 00000000..35730a53 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/local_identity_access.h @@ -0,0 +1,14 @@ +#pragma once +#include "chat/ports/stored_identity_lease.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include + +namespace chat::lxmf +{ +struct LocalIdentityMemory +{ + static void* allocate(size_t size) { return heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } + static void release(void* pointer) { heap_caps_free(pointer); } +}; +using LocalIdentityLease = StoredIdentityLease; +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index cbc6db47..ddd7540d 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -9,6 +9,7 @@ #include "chat/infra/lxmf/lxmf_wire.h" #include "chat/infra/mesh_incoming_queue.h" #include "chat/ports/i_geocaching_transport.h" +#include "chat/ports/track_delivery_binding.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_mesh_peer_directory.h" #include "platform/esp/arduino_common/chat/infra/lxmf/geocaching_discovery_budget.h" @@ -59,6 +60,14 @@ class LxmfAdapter : public IMeshAdapter, private runtime::IPeerProjectionSink static void operator delete(void* ptr, std::size_t size) noexcept; MeshCapabilities getCapabilities() const override; + DeviceAuthorResult readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const override; + DeviceAuthorResult exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const override; + DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) override; + DeviceAuthorResult signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const override; + DeviceAuthorResult proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, uint32_t expected_generation, + uint8_t* workspace, size_t capacity, uint8_t* public_key, + uint32_t& generation, uint8_t* signature) const override; bool getGeocachingAuthorKey(uint8_t out[64]) const; bool signGeocachingRecord(ByteSpan record, uint8_t* workspace, size_t workspace_capacity, uint8_t* output, size_t output_capacity, size_t& written); @@ -74,6 +83,8 @@ class LxmfAdapter : public IMeshAdapter, private runtime::IPeerProjectionSink geocaching_handler_ = handler; geocaching_handler_context_ = context; } + void setTrackDeliveryBinding(const TrackDeliveryBinding* binding) { track_delivery_binding_ = binding; } + bool requestTrackDirectoryPath(const uint8_t* destination); MeshSendResult sendCustomDataToDestination(const uint8_t destination_hash[16], const char* custom_type, ByteSpan data, bool response, std::array* accepted_lxmf_hash = nullptr); @@ -130,6 +141,7 @@ class LxmfAdapter : public IMeshAdapter, private runtime::IPeerProjectionSink void processSendQueue() override; private: + const TrackDeliveryBinding* track_delivery_binding_ = nullptr; CustomDeliveryHandler geocaching_handler_ = nullptr; GeocachingAnnouncementHandler geocaching_announcement_handler_ = nullptr; void* geocaching_announcement_context_ = nullptr; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h index a38a071e..9fef8b71 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h @@ -6,11 +6,14 @@ #pragma once #include "chat/infra/reticulum/reticulum_wire.h" +#include "chat/ports/device_identity_restore.h" #include #include #include +#include +namespace hostlink::management { struct IdentityRestoreView; } namespace chat::lxmf { @@ -24,13 +27,42 @@ class LxmfIdentity static constexpr size_t kSignatureSize = reticulum::kSignatureSize; bool init(); + // Local USB account access may load an existing identity without starting + // Reticulum or creating replacement keys. Epoch migration remains shared. + bool initExisting(); bool isReady() const { return ready_; } const uint8_t* encryptionPublicKey() const { return enc_pub_.data(); } const uint8_t* signingPublicKey() const { return sig_pub_.data(); } const uint8_t* identityHash() const { return identity_hash_.data(); } const uint8_t* destinationHash() const { return destination_hash_.data(); } - uint32_t nodeId() const { return node_id_; } + uint32_t nodeId() const { return reticulum::nodeIdFromDestinationHash(destination_hash_.data()); } + uint32_t authorGeneration() const { return author_generation_; } + bool identityStorageError() const { return identity_storage_error_; } + // Identity restore must advance even when restoring the same public key. + bool advanceAuthorGeneration(); + // Caller holds the identity owner lock, excludes active publication/storage, + // and owns a work area separate from the request. Successful or uncertain + // writes disable this identity until reboot; no live key replacement. + DeviceIdentityRestoreResult restoreNativeIdentity(const hostlink::management::IdentityRestoreView& request, + uint8_t* work, size_t capacity); + // Internal export primitive. Caller owns serialization and authorization + // under its identity/router lock; no private copy is retained here. + bool copyNativePrivateKey(uint32_t expected_generation, uint8_t* output, size_t capacity) const + { + constexpr size_t size = kEncPrivKeySize + kSigPrivKeySize; + if (!output) return false; + const auto out = reinterpret_cast(output), self = reinterpret_cast(this); + if (out <= self ? self - out < size : out - self < sizeof(*this)) return false; + if (capacity < size || !ready_ || identity_storage_error_ || !expected_generation || author_generation_ != expected_generation) + { + std::memset(output, 0, capacity < size ? capacity : size); + return false; + } + std::memcpy(output, enc_priv_.data(), kEncPrivKeySize); + std::memcpy(output + kEncPrivKeySize, sig_priv_.data(), kSigPrivKeySize); + return true; + } void combinedPublicKey(uint8_t out_key[reticulum::kCombinedPublicKeySize]) const; @@ -51,7 +83,8 @@ class LxmfIdentity void recomputeDerivedFields(); bool ready_ = false; - uint32_t node_id_ = 0; + bool identity_storage_error_ = false; // Uses existing alignment padding. + uint32_t author_generation_ = 0; // Replaces the derivable node-ID cache; no object growth. std::array enc_pub_ = {}; std::array enc_priv_ = {}; std::array sig_pub_ = {}; @@ -59,5 +92,6 @@ class LxmfIdentity std::array identity_hash_ = {}; std::array destination_hash_ = {}; }; +static_assert(sizeof(LxmfIdentity) == 200, "Device author support must not grow the identity object"); } // namespace chat::lxmf diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h index e44f5411..eb897e2a 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h @@ -10,6 +10,7 @@ #include "chat/infra/mesh_adapter_router_core.h" #include "chat/ports/i_incoming_delivery_commit_port.h" #include "chat/ports/i_mesh_adapter.h" +#include "chat/ports/i_track_transport.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include @@ -28,6 +29,17 @@ class MeshAdapterRouter : public IMeshAdapter, public: MeshAdapterRouter(); ~MeshAdapterRouter() override; + DeviceAuthorResult readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const override; + DeviceAuthorResult exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const override; + DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) override; + DeviceIdentityRestoreResult restoreRadioIdentity(const hostlink::management::RadioIdentityRestoreView& request, uint8_t* work, size_t capacity) override; + DeviceAuthorResult readRadioIdentityInfo(uint8_t kind, uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const override; + DeviceAuthorResult exportRadioIdentityBackup(uint8_t kind, uint32_t generation, uint8_t* output, size_t capacity, size_t& written, const uint8_t* session, const uint8_t* request) const override; + DeviceAuthorResult signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const override; + DeviceAuthorResult proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, uint32_t expected_generation, + uint8_t* workspace, size_t capacity, uint8_t* public_key, + uint32_t& generation, uint8_t* signature) const override; bool installBackend(MeshProtocol protocol, std::unique_ptr backend) override; bool installServiceBackend(MeshProtocol protocol, std::unique_ptr backend); @@ -35,6 +47,16 @@ class MeshAdapterRouter : public IMeshAdapter, // Retire a cached former chat instance, never an active/shared service. std::unique_ptr takeInactiveReticulumCache(); bool processServiceQueue(MeshProtocol protocol); + // Resolve and validate under the same lock as dispatch. No backend pointer + // escapes; expected identity is mandatory for durable publication jobs. + MeshSendResult sendTrackData(const uint8_t destination[16], lxmf::ByteSpan data, + const uint8_t expected_author[64], uint32_t expected_generation, + bool response = false, std::array* accepted_hash = nullptr) override; + bool trackLocalDestination(uint8_t* output) override; + bool requestTrackDirectoryPath(const uint8_t* destination) override; + bool bindTrackDelivery(const lxmf::TrackDeliveryBinding* binding) override; + TrackBindingChange replaceTrackDelivery(const lxmf::TrackDeliveryBinding* expected, const lxmf::TrackDeliveryBinding* replacement) override; + TrackBindingChange visitTrackDelivery(const lxmf::TrackDeliveryBinding* expected, uint8_t* author, uint32_t& generation, TrackBindingVisit visit, void* context) override; // Worker-thread call. Resolves the current instance while holding the // router lock; callers must not retain a backend pointer across switches. MeshSendResult sendGeocachingData(const uint8_t destination_hash[16], @@ -127,7 +149,9 @@ class MeshAdapterRouter : public IMeshAdapter, }; mutable SemaphoreHandle_t mutex_ = nullptr; + const lxmf::TrackDeliveryBinding* track_delivery_binding_ = nullptr; chat::MeshAdapterRouterCore core_; + const IMeshAdapter* deviceAuthorBackendLocked() const; IGeocachingTransport* geocachingTransportLocked(); bool geocachingDestinationLocked(uint8_t out[16]); void applyGeocachingHandlers(MeshProtocol protocol, IMeshAdapter* backend); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h index 6976b573..6974508a 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h @@ -40,7 +40,7 @@ class MeshCoreIdentity bool importPrivateKey(const uint8_t in_priv[kPrivKeySize]); private: - bool loadFromPrefs(); + bool loadFromPrefs(bool& absent); bool saveToPrefs() const; bool generateAndPersist(); static bool isValidPublicHash(uint8_t hash); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h index e1ab30f9..9d7c068e 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h @@ -12,6 +12,7 @@ #include "chat/infra/lxmf/lxmf_wire.h" #include "chat/ports/i_geocaching_transport.h" +#include "chat/ports/i_track_transport.h" #include "chat/ports/i_incoming_delivery_commit_port.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_mesh_peer_directory.h" @@ -40,6 +41,7 @@ enum class ReticulumUsage : uint8_t }; class ReticulumAdapter final : public IMeshAdapter, + public ITrackTransport, public IGeocachingTransport, public IIncomingDeliveryCommitPort { @@ -58,7 +60,20 @@ class ReticulumAdapter final : public IMeshAdapter, ReticulumAdapter& operator=(const ReticulumAdapter&) = delete; MeshCapabilities getCapabilities() const override; + DeviceAuthorResult readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const override; + DeviceAuthorResult exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const override; + DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) override; + DeviceAuthorResult signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const override; + DeviceAuthorResult proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, uint32_t expected_generation, + uint8_t* workspace, size_t capacity, uint8_t* public_key, + uint32_t& generation, uint8_t* signature) const override; IGeocachingTransport* geocachingTransport() override { return this; } + ITrackTransport* trackTransport() override { return this; } + bool requestDirectoryPath(const uint8_t* destination) override; + MeshSendResult sendTrackData(const uint8_t destination[16], lxmf::ByteSpan data, + bool response, std::array* accepted_hash) override; + void setTrackDeliveryBinding(const lxmf::TrackDeliveryBinding* binding) override; bool getGeocachingAuthorKey(uint8_t out[64]) override; bool signGeocachingRecord(lxmf::ByteSpan record, uint8_t* workspace, size_t workspace_capacity, uint8_t* output, size_t output_capacity, size_t& written) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/device_identity.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/device_identity.h index 6dd4cda2..b1249cf8 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/device_identity.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/device_identity.h @@ -1,6 +1,6 @@ #pragma once -#include +#include "platform/esp/common/config_preferences.h" #include #include #include @@ -13,6 +13,9 @@ #if __has_include("esp_efuse.h") #include "esp_efuse.h" #endif +#if __has_include("esp_mac.h") +#include "esp_mac.h" +#endif namespace platform::esp::arduino_common::device_identity { @@ -46,7 +49,7 @@ inline chat::NodeId getSelfNodeId() node_id = 1; } - Preferences prefs; + ::platform::esp::common::ConfigPreferences prefs; if (prefs.begin("chat", false)) { if (prefs.getUInt("node_id", 0) != node_id) diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/browse_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/browse_runtime.h index 16c13251..aaffe6b6 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/browse_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/browse_runtime.h @@ -10,11 +10,108 @@ namespace platform::esp::arduino_common::geocaching::browse_runtime { void configure(chat::MeshAdapterRouter& router, LoraBoard& board); bool workPending(); +enum class WebReadResult : uint8_t +{ + Pending, + Ready, + Invalid, + Conflict, + Unavailable, + NotFound, + Forbidden, + AlreadySaved, + HashMismatch, + StaleIdentity, + Absent // Authenticated selected directory explicitly returned cache not_found. +}; +// A metadata-only lease; UI and browser share the one storage runtime. +bool webLease(bool enabled, bool require_storage = true); +// Runs under the existing session mutex only when persistence/network work is +// idle. Callback returns true after an identity write requiring reboot, causing +// the idle browse session to be discarded. False leaves its cached state intact. +bool withIdentityMaintenance(bool (*action)(void*), void* context); +// Copies the existing boot identifier and 64-bit monotonic clock; no new state. +bool publicationContext(uint8_t boot_id[16], uint64_t& monotonic_ms); +// One row from the existing authenticated directory discovery cache. +WebReadResult readWebDirectories(uint32_t offset, uint8_t* output, size_t capacity, size_t& written); +// Requests current signed record from the selected authenticated directory. +// Reuses the sole detail slot. Output: revision LE32 followed by revision hash[32]. +// Ready releases the detail; explicit not_found returns Absent and zero output. +// Timeout/failure/unknown directory never qualifies as cache absence. +WebReadResult readWebLatestRevision(const uint8_t cache_id[32], const uint8_t directory[16], uint8_t* output, size_t capacity); +inline constexpr const char* kWebDraftCandidatePath = "/trailmate/geocaching.web.draft"; +// Loads the candidate through the storage owner into the existing save arena. +WebReadResult queueWebDraftFile(const uint8_t id[16], uint32_t total, uint64_t expected_generation, + const uint8_t hash[32], const uint8_t author[64], uint32_t author_generation); +// Releases only a web job proven not to have entered a storage transaction. +bool releaseWebDraftSave(); +// Checks the committed row with the shared read job. Caller supplies the +// authoritative device key; a missing row is allowed only for generation zero. +// Optional edit hash recognises a retry at expected+1 after a committed save. +WebReadResult checkWebDraftEditOwner(const uint8_t id[16], uint64_t expected_generation, const uint8_t author[64], + const uint8_t* edit_hash = nullptr); +// One borrowed catalog row per response [catalog_generation, offset, known_count, row|null]. +// Reuses the existing four-row UI projection; known_count is a lower bound. +WebReadResult readWebDraftCatalog(uint32_t offset, bool has_expected, uint64_t expected_generation, + uint8_t* output, size_t capacity, size_t& written); +// Response [id16, generation64, total, offset, chunk]. No retained caller buffer. +WebReadResult readWebDraftChunk(const uint8_t id[16], uint32_t offset, uint16_t count, uint64_t expected_generation, + uint8_t* output, size_t capacity, size_t& written); // Called only after explicit publication confirmation. Copies exact SignedCache // bytes; verification and persistence run on the storage maintenance owner. bool queuePublication(const uint8_t* signed_cache, size_t size); +// Local publication preview only; it does not prove the latest network revision. +// The selected directory must belong to the authenticated, capability-verified cache. +bool webPublicationPlan(const uint8_t draft_id[16], uint64_t generation, const uint8_t directory[16], + uint8_t author[64], uint32_t& from_revision, uint32_t& to_revision); +// Current in-memory operation only; NotFound does not prove absence from SD history. +// [operation_id, phase, cache|null, hash|null, revision, durable|null, transported|null, error|null]. +// phase: 0 preparing,1 persisting,2 waiting,3 confirmed,4 stopped,5 failed,6 stopping. +WebReadResult readWebPublicationJob(const uint8_t operation_id[16], const uint8_t expected_author[64], + uint8_t* output, size_t capacity, size_t& written); +// Verified, read-only SD projection; may return Pending while the shared +// reader advances. NotFound is not permission to repeat a prepare/commit. +WebReadResult readWebPublicationHistory(const uint8_t operation_id[16], const uint8_t expected_author[64], + uint8_t* output, size_t capacity, size_t& written); +// Command-consumer stop of a matching current or verified saved attempt. Pending +// means the existing maintenance owner must finish submit/stop persistence. +// Ready preserves an already confirmed result or proves local stopping done. +WebReadResult requestWebPublicationStop(const uint8_t operation_id[16], const uint8_t expected_author[64]); +// Confirmation binding from the committed draft through the shared reader. +// Output: SHA256(draft bytes)[32], directory epoch[16], from/to revision LE32. +// Requires the selected draft's current catalog window; versions are local only. +WebReadResult readWebPublicationBinding(const uint8_t draft_id[16], uint64_t generation, + const uint8_t directory[16], const uint8_t expected_author[64], + uint8_t* output, size_t capacity); +// Internal commit primitive: HostLink must first validate its durable confirmation ticket. +// Reuses the sole publication job and signs on the existing maintenance owner. +// A supplied 56-byte binding is rechecked before admission; false can mean the +// shared draft reader is still pending. HostLink must always supply its binding. +// A nonzero operation_id pins both durable task and request identifiers; it +// occupies 16 bytes only in the existing on-demand PSRAM publication job. +bool queueWebDraftPublication(const uint8_t draft_id[16], uint64_t generation, const uint8_t directory[16], + const uint8_t expected_author[64], uint32_t expected_revision, + const uint8_t* expected_binding = nullptr, const uint8_t* operation_id = nullptr); // Local draft transaction; expected_generation is zero for a new draft. -bool queueDraftSave(const uint8_t draft_id[16], const uint8_t* encoded, size_t size, uint64_t expected_generation); +// editor_fields excludes caller-supplied author/base and restores both through editDraft. +bool queueDraftSave(const uint8_t draft_id[16], const uint8_t* encoded, size_t size, uint64_t expected_generation, + bool editor_fields = false); // Exactly one operation slice, called by the existing storage maintenance owner. void step(); +WebReadResult queueWebSignature(const uint8_t operation[16], const uint8_t owner[16], const uint8_t transfer[16], + const uint8_t hash[32], uint32_t total, const uint8_t author[64], uint32_t generation, + const uint8_t* draft = nullptr, uint64_t draft_generation = 0); +WebReadResult readWebSignature(const uint8_t operation[16], const uint8_t owner[16], bool status_only, + uint32_t offset, uint16_t maximum, uint8_t* output, size_t capacity, size_t& written); +WebReadResult releaseWebSignature(const uint8_t owner[16], const uint8_t* operation = nullptr); +// Read-only recovery may expose correlation metadata only while no signing or +// confirmation job is using the shared input slot. +WebReadResult sharedSignatureUploadIdle(); +// Runs under the existing storage mutex with signing/confirmation excluded; +// never applies identity-restoration side effects. +bool withSignatureInputRecovery(bool (*action)(void*), void* context); +WebReadResult queueWebConfirmation(const uint8_t transfer[16], const uint8_t owner[16], const uint8_t hash[32], uint32_t total, + const uint8_t author[64], uint32_t generation, const uint8_t* proof, size_t proof_size); +WebReadResult readWebConfirmation(const uint8_t transfer[16], const uint8_t owner[16], uint8_t* output, size_t capacity, size_t& written); +WebReadResult releaseWebConfirmation(const uint8_t owner[16], const uint8_t* transfer = nullptr); } // namespace platform::esp::arduino_common::geocaching::browse_runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h index 44fb54fe..262373d0 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/cache_detail.h @@ -12,16 +12,22 @@ namespace platform::esp::arduino_common::geocaching { -// One open detail, allocated off the ESP stack. Text is copied only after -// author verification against the selected cache ID and revision hash. +// One open detail, allocated off the ESP stack. Verified text borrows the +// retained immutable response and is consumed synchronously under the source lock. struct CacheDetail { + // Latest GET uses less than 112 bytes; its unsent tail binds directory epoch. + static constexpr size_t kLatestEpochOffset = 112; enum class State { Network, Saved, Ready, - Failed + Failed, + LatestNetwork, + LatestReady, + LatestFailed, + LatestNotFound }; ::geocaching::GeocacheId id; ::geocaching::RevisionHash hash; @@ -29,8 +35,6 @@ struct CacheDetail ::geocaching::RequestId request; std::array request_bytes{}; size_t request_size = 0; - std::array description{}; - std::array hint{}; std::array receipt{}; bool has_receipt = false; uint8_t* response = nullptr; @@ -66,16 +70,13 @@ struct CacheDetail auto* scratch = static_cast(::platform::esp::common::memory::allocatePreferred("geocaching.detail.verify", capacity, false)); if (!scratch) return false; ::geocaching::protocol::VerifiedRecordView verified; - const bool valid = ::geocaching::protocol::verifyGeocache(bytes, crypto, scratch, capacity, verified, &id, &hash) == + const bool latest = state == State::LatestNetwork; + const bool valid = ::geocaching::protocol::verifyGeocache(bytes, crypto, scratch, capacity, verified, &id, latest ? nullptr : &hash) == ::geocaching::protocol::VerificationResult::Valid; if (valid) { verified_record = verified; - description.fill(0); - hint.fill(0); - std::memcpy(description.data(), verified.record.description.data(), verified.record.description.size()); - std::memcpy(hint.data(), verified.record.hint.data(), verified.record.hint.size()); - state = State::Ready; + state = latest ? State::LatestReady : State::Ready; } heap_caps_free(scratch); return valid; @@ -84,12 +85,12 @@ struct CacheDetail ::geocaching::ByteView bytes, ::geocaching::protocol::RecordCrypto& crypto, uint8_t** owned) { if (!matches(source, incoming)) return false; - if (state == State::Ready && has_receipt) + if ((state == State::Ready || state == State::LatestReady || state == State::LatestNotFound) && has_receipt) { std::array digest{}; return crypto.sha256(bytes, digest.data()) && digest == receipt; } - if (state != State::Network || response) return false; + if ((state != State::Network && state != State::LatestNetwork) || response) return false; response = owned ? *owned : static_cast(::platform::esp::common::memory::allocatePreferred("geocaching.detail.rx", bytes.size, false)); if (!response) return false; if (owned) *owned = nullptr; @@ -100,23 +101,32 @@ struct CacheDetail void advanceNetwork(chat::MeshAdapterRouter& router, const ::geocaching::Destination& local, ::geocaching::protocol::RecordCrypto& crypto, uint64_t now) { - if (state != State::Network) return; + if (state != State::Network && state != State::LatestNetwork) return; + const bool latest = state == State::LatestNetwork; if (response) { + if (latest && ::geocaching::protocol::decodeGetNotFound({response, response_size}, request, 8192) && + crypto.sha256({response, response_size}, receipt.data())) + { + has_receipt = true; + state = State::LatestNotFound; + return; + } ::geocaching::protocol::GetResponseView parsed; if (::geocaching::protocol::decodeGetResponse({response, response_size}, request, 8192, parsed) && !parsed.has_conflict && + (!latest || parsed.is_current) && verify(parsed.signed_cache, crypto)) has_receipt = crypto.sha256({response, response_size}, receipt.data()); // Keep only the open, verified response so Save can transfer it to // the durable installation without another network request. - if (state == State::Ready) return; + if (state == State::Ready || state == State::LatestReady) return; heap_caps_free(response); response = nullptr; response_size = 0; } if (now - started >= 120000) { - state = State::Failed; + state = latest ? State::LatestFailed : State::Failed; error = "Directory did not return verified details. Go back and reopen to retry."; return; } @@ -126,5 +136,5 @@ struct CacheDetail next_send = now + (sent.ok ? 15000 : 1000); } }; -static_assert(sizeof(CacheDetail) < 4096, "Only the open detail owns full text"); +static_assert(sizeof(CacheDetail) < 1536, "Detail metadata must borrow text from its retained response"); } // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_download_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_download_store.h index 46a54a28..25e41d84 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_download_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_download_store.h @@ -24,8 +24,8 @@ class IndexedDownloadStore final : public DownloadStore uint8_t* frame, size_t capacity, uint8_t* response, size_t response_capacity, uint8_t* verification, size_t verification_capacity, ::geocaching::protocol::RecordCrypto& crypto) : volume_(volume), root_(root), copy_(copy), roots_{&first, &second}, owner_(owner), workspace_(workspace), - frame_(frame), capacity_(capacity), response_(response), response_capacity_(response_capacity), - verification_(verification), verification_capacity_(verification_capacity), crypto_(crypto) + frame_(frame), response_(response), verification_(verification), capacity_(capacity), + response_capacity_(response_capacity), verification_capacity_(verification_capacity), crypto_(crypto) { valid_ = copy < 2 && ::geocaching::storage::validIndexRoot(root) && root.shards.data == roots_[copy]->data() + 48 && frame && capacity >= 24 && response_capacity && verification_capacity && workspace.outgoing; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_publication_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_publication_store.h index 82699cf0..60b070a9 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_publication_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/indexed_publication_store.h @@ -110,7 +110,7 @@ class IndexedPublicationStore final : public PublicationStore if (!catalog_) { if (!owner_.acquire(this)) return DraftReadResult::Busy; - catalog_.reset(::platform::memory::createPsram(volume_, crypto_)); + catalog_.reset(::platform::memory::createPsram(volume_, crypto_, verification_, verification_capacity_)); if (!catalog_) { owner_.release(this); @@ -161,7 +161,7 @@ class IndexedPublicationStore final : public PublicationStore if (!history_) { if (!owner_.acquire(this)) return DraftReadResult::Busy; - history_.reset(::platform::memory::createPsram(volume_, crypto_)); + history_.reset(::platform::memory::createPsram(volume_, crypto_, verification_, verification_capacity_)); if (!history_) { owner_.release(this); @@ -287,6 +287,15 @@ class IndexedPublicationStore final : public PublicationStore if (acquired != JournalWriteResult::Verified) return acquired; return started(io_->emplace(volume_, crypto_).beginReserve(root_, copy_, record, time, workspace, capacity, frame_, capacity_, *roots_[1 - copy_], key, generation)); } + JournalWriteResult reserveWebDraftUnsignedRecord(::geocaching::ByteView key, uint64_t generation, ::geocaching::ByteView record, + ::geocaching::protocol::RecordCrypto& crypto, uint8_t* workspace, size_t capacity, + const ::geocaching::storage::StoredTime& time) override + { + if (&crypto != &crypto_ || !key.data || key.size != 16 || !generation) return JournalWriteResult::Invalid; + const auto acquired = acquire(); + if (acquired != JournalWriteResult::Verified) return acquired; + return started(io_->emplace(volume_, crypto_).beginReserve(root_, copy_, record, time, workspace, capacity, frame_, capacity_, *roots_[1 - copy_], key, generation, true)); + } JournalWriteResult persistNewTask(const ::geocaching::Destination& local, const ::geocaching::Destination& remote, const ::geocaching::RequestId& request, const std::array& task, uint8_t kind, ::geocaching::ByteView bytes, const ::geocaching::storage::StoredTime& time, @@ -313,6 +322,18 @@ class IndexedPublicationStore final : public PublicationStore return started(io_->emplace(volume_).begin(root_, copy_, {key_.data(), key_.size()}, 1, {response_, response.size}, workspace_, frame_, capacity_, *roots_[1 - copy_], &crypto_, verification_, verification_capacity_)); } + JournalWriteResult commitWebPublication(const ::geocaching::Destination& local, const ::geocaching::Destination& remote, + const ::geocaching::RequestId& request, const std::array& operation, + ::geocaching::ByteView request_bytes, ::geocaching::ByteView confirmation, + const ::geocaching::storage::StoredTime& time, ::geocaching::protocol::RecordCrypto& crypto) override + { + if (&crypto != &crypto_ || !request_bytes.data || request_bytes.size > 8192 || !confirmation.data || confirmation.size > 384) + return JournalWriteResult::Invalid; + const auto acquired = acquire(); + if (acquired != JournalWriteResult::Verified) return acquired; + return started(io_->emplace(volume_).beginConfirmed(root_,copy_,local,remote,request,operation,request_bytes,confirmation,time, + workspace_,frame_,capacity_,*roots_[1-copy_],crypto_,verification_,verification_capacity_)); + } JournalWriteResult stopTask(const std::array& task) override { const auto acquired = acquire(); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/publication_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/publication_store.h index 5d46cb5e..8ea6d320 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/publication_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/publication_store.h @@ -58,6 +58,12 @@ class PublicationStore virtual JournalWriteResult reserveDraftUnsignedRecord(::geocaching::ByteView, uint64_t, ::geocaching::ByteView, ::geocaching::protocol::RecordCrypto&, uint8_t*, size_t, const ::geocaching::storage::StoredTime&) = 0; + // USB author maintenance: fixed record time, with first-author binding in + // the same durable draft/issuance transaction. Unsupported stores fail shut. + virtual JournalWriteResult reserveWebDraftUnsignedRecord(::geocaching::ByteView, uint64_t, ::geocaching::ByteView, + ::geocaching::protocol::RecordCrypto&, uint8_t*, size_t, + const ::geocaching::storage::StoredTime&) + { return JournalWriteResult::Invalid; } virtual JournalWriteResult persistNewTask(const ::geocaching::Destination&, const ::geocaching::Destination&, const ::geocaching::RequestId&, const std::array&, uint8_t, ::geocaching::ByteView, const ::geocaching::storage::StoredTime&, @@ -65,6 +71,11 @@ class PublicationStore virtual JournalWriteResult commitPublishResult(const ::geocaching::Destination&, const ::geocaching::Destination&, const ::geocaching::RequestId&, ::geocaching::ByteView, ::geocaching::protocol::RecordCrypto&) = 0; + virtual JournalWriteResult commitWebPublication(const ::geocaching::Destination&, const ::geocaching::Destination&, + const ::geocaching::RequestId&, const std::array&, + ::geocaching::ByteView, ::geocaching::ByteView, + const ::geocaching::storage::StoredTime&, ::geocaching::protocol::RecordCrypto&) + { return JournalWriteResult::Invalid; } virtual JournalWriteResult stopTask(const std::array&) = 0; }; } // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_author_issue_port.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_author_issue_port.h index 646763fa..01eefac7 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_author_issue_port.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_author_issue_port.h @@ -12,8 +12,8 @@ class SdAuthorIssuePort final : public ::geocaching::AuthorIssuePort public: SdAuthorIssuePort(Transport& transport, PublicationStore& store, ::geocaching::protocol::RecordCrypto& crypto, - const ::geocaching::storage::StoredTime& issued, ::geocaching::ByteView draft = {}, uint64_t generation = 0) - : store_(store), transport_(transport), crypto_(crypto), issued_(issued), draft_(draft), generation_(generation) {} + const ::geocaching::storage::StoredTime& issued, ::geocaching::ByteView draft = {}, uint64_t generation = 0, bool web_author = false) + : store_(store), transport_(transport), crypto_(crypto), issued_(issued), draft_(draft), generation_(generation), web_author_(web_author) {} bool getAuthorKey(uint8_t out[64]) override { return transport_.getGeocachingAuthorKey(out); } @@ -22,7 +22,8 @@ class SdAuthorIssuePort final : public ::geocaching::AuthorIssuePort using Result = ::geocaching::AuthorReservationResult; if (failed_) return Result::Failed; if (reserved_) return Result::Reserved; - const auto result = committing_ ? store_.stepCommit() : draft_.size ? store_.reserveDraftUnsignedRecord(draft_, generation_, record, crypto_, workspace, capacity, issued_) + const auto result = committing_ ? store_.stepCommit() : draft_.size ? web_author_ ? store_.reserveWebDraftUnsignedRecord(draft_, generation_, record, crypto_, workspace, capacity, issued_) + : store_.reserveDraftUnsignedRecord(draft_, generation_, record, crypto_, workspace, capacity, issued_) : store_.reserveUnsignedRecord(record, crypto_, workspace, capacity, issued_); if (result == JournalWriteResult::Busy) return Result::Pending; if (result == JournalWriteResult::InProgress) @@ -58,6 +59,6 @@ class SdAuthorIssuePort final : public ::geocaching::AuthorIssuePort ::geocaching::storage::StoredTime issued_; ::geocaching::ByteView draft_; uint64_t generation_ = 0; - bool committing_ = false, reserved_ = false, failed_ = false; + bool committing_ = false, reserved_ = false, failed_ = false, web_author_ = false; }; } // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_author_reservation.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_author_reservation.h index ce850e88..f8139c4d 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_author_reservation.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_author_reservation.h @@ -30,7 +30,7 @@ class SdIndexedAuthorReservation bool beginReserve(const ::geocaching::storage::IndexRootView& root, unsigned copy, ::geocaching::ByteView encoded, const ::geocaching::storage::StoredTime& issued, uint8_t* workspace, size_t workspace_capacity, uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate, - ::geocaching::ByteView draft = {}, uint64_t generation = 0) + ::geocaching::ByteView draft = {}, uint64_t generation = 0, bool web_author = false) { using namespace ::geocaching; RecordView record; @@ -46,10 +46,13 @@ class SdIndexedAuthorReservation for (unsigned i = 0; i < 4; ++i) key_[32 + i] = static_cast(record.revision >> ((3 - i) * 8)); std::memcpy(author_.data(), record.author_public_key.data, author_.size()); has_draft_ = draft.size != 0; + web_author_ = web_author; + if (web_author_ && !has_draft_) return false; if (has_draft_) { - if (!issued.has_utc || issued.utc_seconds != record.updated_at || + if ((!web_author_ && (!issued.has_utc || issued.utc_seconds != record.updated_at)) || (record.revision == 1 && record.created_at != record.updated_at)) return false; + if (web_author_) { issued_.has_utc = true; issued_.utc_seconds = record.updated_at; } std::memcpy(draft_.data(), draft.data, draft_.size()); if (!io_.emplace(volume_).begin(root_, 4, {draft_.data(), draft_.size()}, frame_, capacity_)) return false; phase_ = Phase::Draft; @@ -104,7 +107,16 @@ class SdIndexedAuthorReservation } DraftView draft; const ByteView draft_key{draft_.data(), draft_.size()}; - if (!decodeDraft(draft_key, get.value(), draft) || draft.generation != generation_) return fail(IndexedCommitStep::Invalid); + if (!decodeDraft(draft_key, get.value(), draft)) return fail(IndexedCommitStep::Invalid); + if (draft.generation != generation_) + { + // A lost signature reply may follow a completed reservation. Only + // that exact retained version can replay the prior draft generation. + if (!web_author_ || phase_ != Phase::Draft || generation_ == UINT64_MAX || draft.generation != generation_ + 1 || + draft.author.size != 64 || std::memcmp(draft.author.data, author_.data(), 64) || draft.base_hash.size != 32 || + std::memcmp(draft.base_hash.data, hash_.bytes.data(), 32)) return fail(IndexedCommitStep::Invalid); + generation_ = draft.generation; + } if (phase_ == Phase::Bind) { if (draft.author.size) @@ -118,6 +130,11 @@ class SdIndexedAuthorReservation return result_; } RecordView record; + if (web_author_ && !draft.author.size) + { + if (!protocol::decodeGeocacheRecord(encoded_, record) || record.revision != 1 || draft.base_hash.size) return fail(IndexedCommitStep::Invalid); + draft.author = {author_.data(), author_.size()}; + } if (!protocol::decodeGeocacheRecord(encoded_, record) || draft.author.size != 64 || !draft.has_coordinates || std::memcmp(draft.author.data, author_.data(), 64) || std::memcmp(record.creation_nonce.data, draft_.data(), 16) || record.state != static_cast(draft.state) || record.latitude_e7 != draft.latitude_e7 || record.longitude_e7 != draft.longitude_e7 || @@ -191,6 +208,11 @@ class SdIndexedAuthorReservation { if (std::memcmp(issued.author_public_key.data, author_.data(), 64)) return fail(IndexedCommitStep::Invalid); if (issued.revision > highest_) highest_ = issued.revision; + if(issued.has_recovered_creation) + { + if(issued.recovered_created_at!=record.created_at)return fail(IndexedCommitStep::Invalid); + first_valid_=true; + } if (issued.revision == record.revision) { if (std::memcmp(issued.revision_hash.data, hash_.bytes.data(), 32)) return fail(IndexedCommitStep::Invalid); @@ -251,7 +273,7 @@ class SdIndexedAuthorReservation uint64_t generation_ = 0; uint32_t highest_ = 0; unsigned copy_ = 0; - bool has_draft_ = false, existed_ = false, base_matches_ = false, parent_valid_ = false, first_valid_ = false, duplicate_ = false; + bool has_draft_ = false, existed_ = false, base_matches_ = false, parent_valid_ = false, first_valid_ = false, duplicate_ = false, web_author_ = false; Phase phase_ = Phase::Issued; IndexedCommitStep result_ = IndexedCommitStep::Idle; std::variant io_; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_draft_catalog.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_draft_catalog.h index ad6b76a7..b6817bee 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_draft_catalog.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_draft_catalog.h @@ -1,5 +1,6 @@ #pragma once #include "geocaching/storage/draft_catalog.h" +#include "geocaching/storage/confirmed_publication.h" #include "platform/esp/arduino_common/geocaching/sd_index_get.h" #include "platform/esp/arduino_common/geocaching/sd_index_scan.h" #include "platform/memory/psram_ptr.h" @@ -14,8 +15,9 @@ namespace platform::esp::arduino_common::geocaching class SdIndexedDraftCatalog { public: - SdIndexedDraftCatalog(const ::geocaching::storage::VolumeInstance& volume, ::geocaching::protocol::RecordCrypto& crypto) - : volume_(volume), crypto_(crypto) {} + SdIndexedDraftCatalog(const ::geocaching::storage::VolumeInstance& volume, ::geocaching::protocol::RecordCrypto& crypto, + uint8_t* verification = nullptr, size_t verification_capacity = 0) + : volume_(volume), crypto_(crypto), verification_(verification), verification_capacity_(verification_capacity) {} bool begin(const ::geocaching::storage::IndexRootView& root, size_t offset, ::geocaching::storage::DraftCatalogPage& page, uint8_t* frame, size_t capacity) { @@ -166,6 +168,23 @@ class SdIndexedDraftCatalog acknowledged_ = false; if (state_ == 4) { + protocol::CmpReader marker(outgoing.terminal_data); + size_t terminal_fields = 0; + if (marker.array(terminal_fields, 6) && terminal_fields == 4) + { + auto verified = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!verified || !verification_ || verifyConfirmedPublication(row.key, outgoing.request, outgoing.terminal_data, + crypto_, verification_, verification_capacity_, *verified) != protocol::VerificationResult::Valid) + { + unavailable_ = true; + return IndexGetStep::Invalid; + } + ack_id_ = verified->id; + ack_hash_ = verified->hash; + acknowledged_ = true; + } + else + { // Decode the response identity before reusing the frame // for its task, then compare it with that task below. protocol::CmpReader ack(outgoing.terminal_data); @@ -182,6 +201,7 @@ class SdIndexedDraftCatalog acknowledged_ = protocol::decodePublishResponse(outgoing.terminal_data, request, ack_id_, ack_hash_, record.revision, record.state, disposition); } } + } } std::memcpy(request_key_.data(), row.key.data, 48); std::memcpy(task_key_.data(), outgoing.task_id.data, 16); @@ -235,6 +255,8 @@ class SdIndexedDraftCatalog uint8_t state_ = 0; bool requests_ = false, changed_ = false, intent_ = false, acknowledged_ = false; bool unavailable_ = false; + uint8_t* verification_ = nullptr; + size_t verification_capacity_ = 0; }; static_assert(sizeof(SdIndexedDraftCatalog) <= (sizeof(void*) == 4 ? 256 : 320), "Catalog scan owns metadata only"); } // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_new_task.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_new_task.h index 5bd5a6f3..76de51ce 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_new_task.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_new_task.h @@ -2,7 +2,12 @@ #include "geocaching/protocol/get_request.h" #include "geocaching/storage/object_ref.h" #include "geocaching/storage/queued_request.h" +#include "geocaching/storage/confirmed_publication.h" +#include "geocaching/storage/author_issued.h" +#include "geocaching/storage/draft_record.h" +#include "platform/memory/psram_ptr.h" #include "platform/esp/arduino_common/geocaching/sd_indexed_commit.h" +#include "platform/esp/arduino_common/geocaching/sd_index_scan.h" namespace platform::esp::arduino_common::geocaching { @@ -12,13 +17,33 @@ class SdIndexedNewTask { public: explicit SdIndexedNewTask(const ::geocaching::storage::VolumeInstance& volume) : volume_(volume) {} + bool beginConfirmed(const ::geocaching::storage::IndexRootView& root, unsigned copy, + const ::geocaching::Destination& local, const ::geocaching::Destination& remote, + const ::geocaching::RequestId& request_id, const std::array& task_id, + ::geocaching::ByteView request, ::geocaching::ByteView confirmation, + const ::geocaching::storage::StoredTime& time, ::geocaching::storage::QueuedRequestWorkspace& workspace, + uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate, + ::geocaching::protocol::RecordCrypto& crypto, uint8_t* verification, size_t verification_capacity) + { + using namespace ::geocaching; + if(result_!=IndexedCommitStep::Idle)return false; + verified_.reset(::platform::memory::createPsram()); + std::memcpy(key_.data(),local.bytes.data(),16);std::memcpy(key_.data()+16,remote.bytes.data(),16);std::memcpy(key_.data()+32,request_id.bytes.data(),16); + ::geocaching::storage::PublicationConfirmationView proof; + if(!verified_||!::geocaching::storage::decodePublicationConfirmation(confirmation,proof)|| + ::geocaching::storage::verifyConfirmedPublication({key_.data(),key_.size()},request,confirmation,crypto,verification,verification_capacity,*verified_)!=protocol::VerificationResult::Valid)return false; + ::geocaching::storage::RequestTaskTarget target; + target.cache_id={verified_->id.bytes.data(),32};target.revision_hash={verified_->hash.bytes.data(),32}; + return begin(root,copy,local,remote,request_id,task_id,1,request,time,target,workspace,frame,capacity,candidate,confirmation); + } bool begin(const ::geocaching::storage::IndexRootView& root, unsigned copy, const ::geocaching::Destination& local, const ::geocaching::Destination& remote, const ::geocaching::RequestId& request_id, const std::array& task_id, uint8_t kind, ::geocaching::ByteView request, const ::geocaching::storage::StoredTime& time, const ::geocaching::storage::RequestTaskTarget& target, ::geocaching::storage::QueuedRequestWorkspace& workspace, - uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate) + uint8_t* frame, size_t capacity, ::geocaching::storage::IndexRootBytes& candidate, + ::geocaching::ByteView confirmation = {}) { using namespace ::geocaching; using namespace ::geocaching::storage; @@ -48,6 +73,30 @@ class SdIndexedNewTask candidate_ = &candidate; count_ = 2; result_ = IndexedCommitStep::Working; + if(confirmation.size) + { + if(!verified_||kind!=1||overlaps(confirmation,outgoing)||overlaps(confirmation,task))return false; + OutgoingView completed;completed.request=request;completed.state=4;completed.task_id={task_id_.data(),16};completed.created=time;completed.terminal_data=confirmation; + TaskView done;done.kind=1;done.state=3;done.cache_id=target.cache_id;done.revision_hash=target.revision_hash;done.requests[0]={key_.data(),key_.size()};done.request_count=1; + size_t outgoing_size=0,task_size=0; + if(!encodeOutgoing({key_.data(),key_.size()},completed,workspace.outgoing,workspace.outgoing_capacity,outgoing_size)|| + !encodeTask({task_id_.data(),16},done,workspace.task.data(),workspace.task.size(),task_size))return false; + mutations_[0].value={workspace.outgoing,outgoing_size};mutations_[1].value={workspace.task.data(),task_size}; + // Stage optional history metadata in the existing output lease. + // No recovery buffer is allocated; absence needs enough tail room. + recovery_work_={workspace.outgoing+outgoing_size,workspace.outgoing_capacity-outgoing_size}; + size_t issued_size=0; + auto history_time=time; + history_time.has_utc=true;history_time.utc_seconds=verified_->record.updated_at;history_time.utc_trusted=false; + if(encodeAuthorIssued(verified_->hash,verified_->record.author_public_key,history_time, + workspace.outgoing+outgoing_size,recovery_work_.size,issued_size,verified_->record.created_at)) + { + mutations_[2]={3,{head_.data(),36},{recovery_work_.data,issued_size},false}; + recovery_work_={recovery_work_.data+issued_size,recovery_work_.size-issued_size}; + } + confirmed_request_=request;confirmation_=confirmation;confirming_=true;confirmation_phase_=0; + return operation_.emplace(volume_).begin(root_,5,{key_.data(),key_.size()},frame_,capacity_); + } if (kind != 2) return startCommit() == IndexedCommitStep::Working; protocol::GetRequestView get; if (!protocol::decodeGetRequest(request, request_id, get) || get.wanted_hash.size != 32 || @@ -72,6 +121,7 @@ class SdIndexedNewTask bool committed(::geocaching::storage::IndexRootView& out) const { out = {}; + if(result_==IndexedCommitStep::Verified&&duplicate_){out=root_;return true;} return result_ == IndexedCommitStep::Verified && std::get(operation_).committed(out); } IndexedCommitStep step() @@ -79,12 +129,87 @@ class SdIndexedNewTask using namespace ::geocaching::storage; if (result_ != IndexedCommitStep::Working) return result_; if (committing_) return result_ = std::get(operation_).step(); + if (confirming_ && confirmation_phase_ == 4) return confirmHistory(); auto& read = std::get(operation_); const auto status = read.step(); if (status == IndexGetStep::Working) return result_; if (status != IndexGetStep::Ready && status != IndexGetStep::NotFound) return fail(status == IndexGetStep::VolumeChanged ? IndexedCommitStep::VolumeChanged : status == IndexGetStep::IoError ? IndexedCommitStep::IoError : IndexedCommitStep::Invalid); + if(confirming_) + { + if(confirmation_phase_==0) + { + if(status==IndexGetStep::Ready) + { + OutgoingView saved; + if(!decodeOutgoing({key_.data(),key_.size()},read.value(),saved)||saved.state!=4||saved.continue_intent|| + std::memcmp(saved.task_id.data,task_id_.data(),16)||saved.request.size!=confirmed_request_.size|| + std::memcmp(saved.request.data,confirmed_request_.data,confirmed_request_.size)||saved.terminal_data.size!=confirmation_.size|| + std::memcmp(saved.terminal_data.data,confirmation_.data,confirmation_.size))return fail(IndexedCommitStep::Invalid); + duplicate_=true;confirmation_phase_=2; + if(!operation_.emplace(volume_).begin(root_,10,{task_id_.data(),16},frame_,capacity_))return fail(IndexedCommitStep::Invalid); + return result_; + } + } + else if(confirmation_phase_==2) + { + TaskView saved; + if(status!=IndexGetStep::Ready||!decodeTask({task_id_.data(),16},read.value(),saved)||saved.kind!=1||saved.state!=3||saved.continue_intent|| + saved.request_count!=1||std::memcmp(saved.requests[0].data,key_.data(),48)||saved.cache_id.size!=32||saved.revision_hash.size!=32|| + std::memcmp(saved.cache_id.data,verified_->id.bytes.data(),32)||std::memcmp(saved.revision_hash.data,verified_->hash.bytes.data(),32))return fail(IndexedCommitStep::Invalid); + } + else if(confirmation_phase_==1) + { + AuthorIssuedView issued; + if(status==IndexGetStep::Ready) + { + if(!decodeAuthorIssued({head_.data(),36},read.value(),issued)|| + std::memcmp(issued.revision_hash.data,verified_->hash.bytes.data(),32)||std::memcmp(issued.author_public_key.data,verified_->record.author_public_key.data,64))return fail(IndexedCommitStep::Invalid); + if(duplicate_)return result_=IndexedCommitStep::Verified; + } + else + { + if(duplicate_||status!=IndexGetStep::NotFound||!mutations_[2].value.size)return fail(IndexedCommitStep::Invalid); + count_=3; + } + // Validate existing origin evidence before adding recovery + // metadata. Reuse the same frame and pinned immutable root. + confirmation_phase_=4; + if(!operation_.emplace(volume_).begin(root_,3,frame_,capacity_))return fail(IndexedCommitStep::Invalid); + return result_; + } + else if(confirmation_phase_==3) + { + if(status==IndexGetStep::Ready) + { + DraftView existing; + if(!decodeDraft({cache_id_.data(),16},read.value(),existing)||existing.author.size!=64|| + std::memcmp(existing.author.data,verified_->record.author_public_key.data,64))return fail(IndexedCommitStep::Invalid); + // Retain later edits, generations and predecessors as-is. + } + else if(status==IndexGetStep::NotFound) + { + const auto& record=verified_->record; + DraftView restored; + restored.author=record.author_public_key;restored.base_hash={verified_->hash.bytes.data(),32}; + restored.has_coordinates=true;restored.latitude_e7=record.latitude_e7;restored.longitude_e7=record.longitude_e7; + restored.name=record.name;restored.description=record.description;restored.hint=record.hint; + restored.state=static_cast(record.state);restored.difficulty_x2=record.difficulty_x2; + restored.terrain_x2=record.terrain_x2;restored.container_size=static_cast(record.container_size); + size_t draft_size=0; + if(!encodeDraft({cache_id_.data(),16},restored,const_cast(recovery_work_.data),recovery_work_.size,draft_size))return fail(IndexedCommitStep::Invalid); + mutations_[count_++]={4,{cache_id_.data(),16},{recovery_work_.data,draft_size},false}; + } + else return fail(IndexedCommitStep::Invalid); + return startCommit(); + } + std::memcpy(head_.data(),verified_->id.bytes.data(),32); + for(unsigned i=0;i<4;++i)head_[32+i]=static_cast(verified_->record.revision>>((3-i)*8)); + confirmation_phase_=1; + if(!operation_.emplace(volume_).begin(root_,3,{head_.data(),36},frame_,capacity_))return fail(IndexedCommitStep::Invalid); + return result_; + } CacheHeadView head; const ::geocaching::ByteView key{cache_id_.data(), cache_id_.size()}; if (status == IndexGetStep::Ready && !decodeCacheHead(key, read.value(), head)) return fail(IndexedCommitStep::Invalid); @@ -98,11 +223,43 @@ class SdIndexedNewTask } private: + IndexedCommitStep confirmHistory() + { + using namespace ::geocaching::storage; + auto& scan = std::get(operation_); + const auto status = scan.step(); + if (status == IndexScanStep::Working) return result_; + if (status == IndexScanStep::End) + { + std::memcpy(cache_id_.data(),verified_->record.creation_nonce.data,16); + confirmation_phase_=3; + if(!operation_.emplace(volume_).begin(root_,4,{cache_id_.data(),16},frame_,capacity_))return fail(IndexedCommitStep::Invalid); + return result_; + } + if (status != IndexScanStep::Item) + return fail(status == IndexScanStep::VolumeChanged ? IndexedCommitStep::VolumeChanged : + status == IndexScanStep::IoError ? IndexedCommitStep::IoError : IndexedCommitStep::Invalid); + MutationView row; + AuthorIssuedView issued; + if (!scan.item(row) || !decodeAuthorIssued(row.key,row.value,issued)) return fail(IndexedCommitStep::Invalid); + if (!std::memcmp(issued.cache_id.data,verified_->id.bytes.data(),32)) + { + const auto& record=verified_->record; + if (std::memcmp(issued.author_public_key.data,record.author_public_key.data,64) || + (issued.has_recovered_creation && issued.recovered_created_at != record.created_at) || + (issued.revision == 1 && (!issued.issued_at.has_utc || issued.issued_at.utc_seconds != record.created_at))) + return fail(IndexedCommitStep::Invalid); + } + if (!scan.advance()) return fail(IndexedCommitStep::Invalid); + return result_; + } IndexedCommitStep startCommit() { // The encoder creates one matching task/request pair. The commit - // requires both keys absent, so no existing relationship is replaced. - if (!operation_.emplace(volume_).begin(root_, copy_, mutations_.data(), count_, frame_, capacity_, *candidate_, false, 2, true)) + // History additions are absent-only and share this one transaction. + // No existing request, task, issuance or draft is replaced. + const size_t absent=confirming_?count_:2; + if (!operation_.emplace(volume_).begin(root_, copy_, mutations_.data(), count_, frame_, capacity_, *candidate_, false, absent, true)) return fail(IndexedCommitStep::Invalid); committing_ = true; return result_; @@ -114,7 +271,7 @@ class SdIndexedNewTask } ::geocaching::storage::VolumeInstance volume_; ::geocaching::storage::IndexRootView root_; - std::array<::geocaching::storage::MutationView, 3> mutations_{}; + std::array<::geocaching::storage::MutationView, 4> mutations_{}; std::array key_{}; std::array task_id_{}; std::array cache_id_{}; @@ -125,7 +282,11 @@ class SdIndexedNewTask uint64_t generation_ = 0; unsigned copy_ = 0; bool committing_ = false; - std::variant operation_; + bool confirming_ = false, duplicate_ = false; + uint8_t confirmation_phase_ = 0; + ::geocaching::ByteView confirmed_request_,confirmation_,recovery_work_; + ::platform::memory::PsramPtr<::geocaching::protocol::VerifiedRecordView> verified_; + std::variant operation_; IndexedCommitStep result_ = IndexedCommitStep::Idle; }; static_assert(sizeof(SdIndexedNewTask) <= 1536, "Task creation owns metadata only"); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_history.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_history.h index b118574c..00c40f0b 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_history.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_history.h @@ -10,8 +10,9 @@ namespace platform::esp::arduino_common::geocaching class SdIndexedPublicationHistory { public: - SdIndexedPublicationHistory(const ::geocaching::storage::VolumeInstance& volume, ::geocaching::protocol::RecordCrypto& crypto) - : volume_(volume), crypto_(crypto) {} + SdIndexedPublicationHistory(const ::geocaching::storage::VolumeInstance& volume, ::geocaching::protocol::RecordCrypto& crypto, + uint8_t* verification = nullptr, size_t verification_capacity = 0) + : volume_(volume), crypto_(crypto), verification_(verification), verification_capacity_(verification_capacity) {} bool begin(const ::geocaching::storage::IndexRootView& root, ::geocaching::ByteView key, uint64_t generation, const ::geocaching::GeocacheId& cache, ::geocaching::ByteView author, ::geocaching::storage::PublicationHistory& out, uint8_t* frame, size_t capacity) @@ -63,7 +64,7 @@ class SdIndexedPublicationHistory return IndexGetStep::Invalid; } draft_.reset(); - catalog_.reset(::platform::memory::createPsram(volume_, crypto_)); + catalog_.reset(::platform::memory::createPsram(volume_, crypto_, verification_, verification_capacity_)); if (!catalog_) { unavailable_ = true; @@ -96,7 +97,20 @@ class SdIndexedPublicationHistory if (!scan_) return IndexGetStep::Invalid; const auto status = scan_->step(); if (status == IndexScanStep::Working) return IndexGetStep::Working; - if (status == IndexScanStep::End) return history_.finish(confirmed_, *out_) ? IndexGetStep::Ready : IndexGetStep::Invalid; + if (status == IndexScanStep::End) + { + if(history_.needsRecoveryValidation()) + { + if(!history_.beginRecoveryValidation())return IndexGetStep::Invalid; + // Drop before allocating so a second scan never owns another + // simultaneous frame or scanner. The same snapshot stays pinned. + scan_.reset();scan_.reset(::platform::memory::createPsram(volume_)); + if(!scan_){unavailable_=true;return IndexGetStep::Invalid;} + if(!scan_->begin(root_,3,frame_,capacity_))return IndexGetStep::Invalid; + return IndexGetStep::Working; + } + return history_.finish(confirmed_, *out_) ? IndexGetStep::Ready : IndexGetStep::Invalid; + } if (status != IndexScanStep::Item) return status == IndexScanStep::IoError ? IndexGetStep::IoError : status == IndexScanStep::VolumeChanged ? IndexGetStep::VolumeChanged : status == IndexScanStep::WorkspaceTooSmall ? IndexGetStep::WorkspaceTooSmall @@ -124,6 +138,8 @@ class SdIndexedPublicationHistory uint64_t generation_ = 0; uint32_t confirmed_ = 0; bool unavailable_ = false; + uint8_t* verification_ = nullptr; + size_t verification_capacity_ = 0; }; static_assert(sizeof(SdIndexedPublicationHistory) <= (sizeof(void*) == 4 ? 384 : 448), "History read retains metadata, never record payloads"); } // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_recovery.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_recovery.h index 66e7ae30..d617154f 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_recovery.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/sd_indexed_publication_recovery.h @@ -17,7 +17,17 @@ class SdIndexedPublicationRecovery bool begin(const ::geocaching::storage::IndexRootView& root, const ::geocaching::storage::PublicationRecoveryFilter& filter, uint8_t* frame, size_t capacity) { - if (result_ != IndexGetStep::Idle) return false; + if (result_ != IndexGetStep::Idle || (filter.include_terminal && !filter.has_operation)) return false; + root_ = root; + filter_ = filter; + frame_ = frame; + capacity_ = capacity; + result_ = IndexGetStep::Working; + if (filter.has_operation) + { + task_key_ = filter.operation; + return startGet(10, {task_key_.data(), task_key_.size()}) == IndexGetStep::Working; + } scan_.reset(::platform::memory::createPsram(volume_)); if (!scan_) { @@ -25,11 +35,6 @@ class SdIndexedPublicationRecovery return false; } if (!scan_->begin(root, 5, frame, capacity)) return false; - root_ = root; - filter_ = filter; - frame_ = frame; - capacity_ = capacity; - result_ = IndexGetStep::Working; return true; } bool matches(const ::geocaching::storage::PublicationRecoveryFilter& filter) const { return filter_.same(filter); } @@ -49,12 +54,58 @@ class SdIndexedPublicationRecovery { const auto status = get_->step(); if (status == IndexGetStep::Working) return status; + if (filter_.has_operation) + { + if (status != IndexGetStep::Ready) + return result_ = status == IndexGetStep::NotFound && reading_selected_ ? IndexGetStep::Invalid : status; + if (!reading_selected_) + { + TaskView task; + if (!decodeTask({task_key_.data(), task_key_.size()}, get_->value(), task) || task.kind != 1 || + task.cache_id.size != 32 || task.revision_hash.size != 32) return result_ = IndexGetStep::Invalid; + if ((filter_.has_cache && std::memcmp(task.cache_id.data, filter_.cache.bytes.data(), 32)) || + (filter_.has_hash && std::memcmp(task.revision_hash.data, filter_.hash.bytes.data(), 32))) return result_ = IndexGetStep::NotFound; + bool matched = false; + for (size_t i = 0; i < task.request_count; ++i) + { + if (!filter_.matchesRequest(task.requests[i])) continue; + // One web operation names one destination. An ambiguous + // group cannot be projected as one confirmed directory. + if (matched) return result_ = IndexGetStep::Invalid; + std::memcpy(selected_.key.data(), task.requests[i].data, selected_.key.size()); + matched = true; + } + if (!matched) return result_ = IndexGetStep::NotFound; + selected_.task = task_key_; + std::memcpy(selected_.cache.bytes.data(), task.cache_id.data, 32); + std::memcpy(selected_.hash.bytes.data(), task.revision_hash.data, 32); + selected_.task_state = task.state; + selected_.continue_intent = task.continue_intent; + reading_selected_ = true; + return startGet(5, {selected_.key.data(), selected_.key.size()}); + } + OutgoingView outgoing; + if (!decodeOutgoing({selected_.key.data(), selected_.key.size()}, get_->value(), outgoing) || + std::memcmp(outgoing.task_id.data, selected_.task.data(), 16)) return result_ = IndexGetStep::Invalid; + if (!publicationRecoveryRequestEligible(filter_, outgoing) || + (!filter_.include_terminal && outgoing.state < 4 && + (!selected_.continue_intent || selected_.task_state == 3 || selected_.task_state == 5))) + return result_ = IndexGetStep::NotFound; + selected_.outgoing_state = outgoing.state; + selected_.confirmed = outgoing.state == 4; + selected_.continue_intent &= outgoing.continue_intent; + selected_.created = outgoing.created; + selected_.request = outgoing.request; + selected_.response = selected_.confirmed ? outgoing.terminal_data : ByteView{}; + return result_ = IndexGetStep::Ready; + } if (status != IndexGetStep::Ready) return result_ = status == IndexGetStep::NotFound ? IndexGetStep::Invalid : status; if (reading_selected_) { OutgoingView outgoing; if (!decodeOutgoing({selected_.key.data(), selected_.key.size()}, get_->value(), outgoing) || - std::memcmp(outgoing.task_id.data, selected_.task.data(), 16) || (outgoing.state == 4) != selected_.confirmed) + std::memcmp(outgoing.task_id.data, selected_.task.data(), 16) || outgoing.state != selected_.outgoing_state || + (outgoing.state == 4) != selected_.confirmed) return result_ = IndexGetStep::Invalid; selected_.request = outgoing.request; selected_.response = selected_.confirmed ? outgoing.terminal_data : ByteView{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/web_confirmation_job.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/web_confirmation_job.h new file mode 100644 index 00000000..67ddd6ef --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/geocaching/web_confirmation_job.h @@ -0,0 +1,84 @@ +#pragma once +#include "geocaching/storage/confirmed_publication.h" +#include "platform/esp/arduino_common/geocaching/publication_store.h" +#include "platform/memory/psram_ptr.h" + +namespace platform::esp::arduino_common::geocaching +{ +enum class WebConfirmationPhase : uint8_t { Idle, Prepare, Admit, Commit, Confirmed, Failed, Cancelled }; +// The serialized owner pins immutable inputs and lends max(320, source-size + 128) bytes. +// This job owns metadata only; it never opens a file or a network connection. +template +class WebConfirmationJob +{ + public: + WebConfirmationJob(Store& store, ::geocaching::protocol::RecordCrypto& crypto) : store_(store), crypto_(crypto) {} + bool begin(const std::array& operation, ::geocaching::ByteView signed_cache, ::geocaching::ByteView proof, + ::geocaching::ByteView author, const ::geocaching::storage::StoredTime& time, uint8_t* work, size_t capacity) + { + if(phase_!=WebConfirmationPhase::Idle||!signed_cache.data||!signed_cache.size||signed_cache.size>4166||!proof.data||!proof.size||proof.size>384|| + !author.data||author.size!=64||!work||capacity<320||capacity(work),b=reinterpret_cast(input.data);if(a<=b?b-a(::platform::memory::createPsram()); + if(!content||store_.needsRecovery()||protocol::verifyGeocache(signed_,crypto_,work_,capacity_,*content)!=protocol::VerificationResult::Valid|| + std::memcmp(content->record.author_public_key.data,author_.data,64)){phase_=WebConfirmationPhase::Failed;return;} + protocol::PublicationReceiptView receipt; + ::geocaching::storage::PublicationConfirmationView proof; + protocol::CmpReader raw(proof_); // Decode first to obtain its signed directory destination. + if(!::geocaching::storage::decodePublicationConfirmation(proof_,proof)){phase_=WebConfirmationPhase::Failed;return;} + raw=protocol::CmpReader(proof.raw);size_t count=0;uint64_t version=0;ByteView directory; + if(!raw.array(count,8)||count!=8||!raw.unsignedInteger(version)||version!=1||!raw.binary(directory,16)||directory.size!=16|| + ::geocaching::storage::verifyPublicationConfirmation(proof_,directory,*content,crypto_,work_,capacity_,receipt)!=protocol::VerificationResult::Valid){phase_=WebConfirmationPhase::Failed;return;} + cache_=content->id;hash_=content->hash;revision_=content->record.revision;std::memcpy(remote_.bytes.data(),directory.data,16); + if(!crypto_.sha256(author_,work_+64)){phase_=WebConfirmationPhase::Failed;return;} + std::memcpy(work_+10,work_+64,16);constexpr char name[]="lxmf.delivery"; + if(!crypto_.sha256({reinterpret_cast(name),sizeof(name)-1},work_+32)){phase_=WebConfirmationPhase::Failed;return;} + std::memcpy(work_,work_+32,10); + if(!crypto_.sha256({work_,26},work_+64)){phase_=WebConfirmationPhase::Failed;return;} + std::memcpy(local_.bytes.data(),work_+64,16);request_.bytes=operation_; + if(!protocol::encodePublishRequest(request_,content->record.encoded,content->signature,8192,work_,capacity_,request_size_)){phase_=WebConfirmationPhase::Failed;return;} + phase_=WebConfirmationPhase::Admit; + } + else if(phase_==WebConfirmationPhase::Admit||phase_==WebConfirmationPhase::Commit) + { + const auto result=phase_==WebConfirmationPhase::Commit?store_.stepCommit(): + store_.commitWebPublication(local_,remote_,request_,operation_,{work_,request_size_},proof_,time_,crypto_); + if(result==JournalWriteResult::Busy)return; + if(result==JournalWriteResult::InProgress){phase_=WebConfirmationPhase::Commit;return;} + phase_=result==JournalWriteResult::Verified?WebConfirmationPhase::Confirmed:WebConfirmationPhase::Failed; + } + } + private: + Store& store_; + ::geocaching::protocol::RecordCrypto& crypto_; + ::geocaching::ByteView signed_,proof_,author_; + ::geocaching::storage::StoredTime time_; + ::geocaching::Destination local_,remote_; + ::geocaching::RequestId request_; + ::geocaching::GeocacheId cache_; + ::geocaching::RevisionHash hash_; + std::array operation_{}; + uint8_t* work_=nullptr; + size_t capacity_=0,request_size_=0; + uint32_t revision_=0; + WebConfirmationPhase phase_=WebConfirmationPhase::Idle; +}; +} // namespace platform::esp::arduino_common::geocaching diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h index acc2c493..b048bc14 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/gps/track_recorder.h @@ -5,10 +5,14 @@ #include +#include #include namespace gps { +static_assert(sizeof(std::atomic_bool) == sizeof(bool), "Recorder flags must retain their byte budget"); +static_assert(sizeof(std::atomic) == sizeof(uint32_t), "Recorder interval must retain its word budget"); +static_assert(sizeof(std::atomic) == 1, "Deferred recorder settings require one byte each"); struct TrackPoint { @@ -41,6 +45,7 @@ class TrackRecorder void setIntervalSeconds(uint32_t seconds); void setDistanceOnly(bool enabled); void setFormat(TrackFormat format); + void applyPendingSettings(); // Append a single point if recording is active and SD is ready. void appendPoint(const TrackPoint& pt); @@ -48,6 +53,13 @@ class TrackRecorder // List GPX files under /trackers; returns count. size_t listTracks(String* out_names, size_t max_names) const; + // Nonblocking; shares the recording mutex with writers and future snapshots. + // Only a direct child of /trackers may be removed, never the live recording. + bool removeTrack(const char* path); + // Borrow the existing mutex; caller must release on the same task exactly + // once. Catalog reads allow recording; snapshots require it to be stopped. + bool tryAcquireRead(bool require_stopped); + void releaseRead(); static constexpr const char* kTrackDir = "/trackers"; @@ -67,11 +79,14 @@ class TrackRecorder bool shouldAcceptPointLocked(const TrackPoint& pt, uint32_t now_ms) const; void markPointAcceptedLocked(const TrackPoint& pt, uint32_t now_ms); bool writePendingPointsLocked(bool force); + void applyPendingSettingsLocked(); + void applyAutoRecordingLocked(bool enabled); + void applyFormatLocked(TrackFormat format); static constexpr size_t kPendingPointCapacity = 16; SemaphoreHandle_t mutex_ = xSemaphoreCreateMutex(); - bool recording_ = false; + std::atomic_bool recording_{false}; bool auto_recording_ = false; bool manual_recording_ = false; String current_path_{}; @@ -79,9 +94,13 @@ class TrackRecorder TrackPoint last_point_{}; time_t last_point_time_ = 0; uint32_t last_point_ms_ = 0; - uint32_t min_interval_ms_ = 0; - bool distance_only_ = false; + std::atomic min_interval_ms_{0}; + std::atomic_bool distance_only_{false}; TrackFormat format_ = TrackFormat::GPX; + // Latest requested settings survive a busy snapshot/writer. Load/store + // only: no read/modify/write atomic operations on the PSRAM recorder. + std::atomic requested_format_{0xff}; + std::atomic requested_auto_recording_{0xff}; TrackPoint pending_points_[kPendingPointCapacity]{}; size_t pending_point_count_ = 0; uint32_t last_flush_ms_ = 0; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_identity_restore.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_identity_restore.h new file mode 100644 index 00000000..c116cb48 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_identity_restore.h @@ -0,0 +1,73 @@ +#pragma once +#include "chat/ports/i_mesh_adapter.h" +#include "hostlink/identity_restore_service.h" +#include "hostlink/radio_identity_backup.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" +#include "platform/esp/arduino_common/hostlink/web_config_upload.h" +#include +#include +namespace hostlink::management +{ +constexpr size_t kIdentityRestoreLargestWork = sizeof(IdentityRestoreWork) > sizeof(RadioIdentityRestoreWork) ? sizeof(IdentityRestoreWork) : sizeof(RadioIdentityRestoreWork); +constexpr size_t kRestoreRuntimeWorkBytes = kIdentityRestoreLargestWork > sizeof(web_config::Metadata) + 111 ? kIdentityRestoreLargestWork : sizeof(web_config::Metadata) + 111; +static_assert(kRestoreRuntimeWorkBytes <= 512); +inline Status restoreIdentityOperation(chat::IMeshAdapter& adapter, const EnvelopeView& request, + IdentityRestoreActivity activity, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (activity.busy()) return Status::Busy; + void* memory = heap_caps_malloc(kRestoreRuntimeWorkBytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + struct Release + { + void* memory; + ~Release() + { + volatile uint8_t* data = static_cast(memory); + for (size_t i = 0; i < kRestoreRuntimeWorkBytes; ++i) data[i] = 0; + heap_caps_free(memory); + } + } release{memory}; + // The journal and request phases reuse the same raw allocation sequentially. + auto* metadata = new (memory) web_config::Metadata; + const bool loaded = web_config::load(*metadata, static_cast(memory) + sizeof(web_config::Metadata)); + activity.configuration = loaded && metadata->phase < (metadata->kind == 2 ? 4 : 2); + metadata->~Metadata(); + if (!loaded && (platform::esp::arduino_common::storage::sd_exists(web_config::kMetaPath) || + platform::esp::arduino_common::storage::sd_exists(web_config::kMetaNew) || + platform::esp::arduino_common::storage::sd_exists(web_config::kMetaBackup))) return Status::StorageUnavailable; + struct Context + { + chat::IMeshAdapter& adapter; + const EnvelopeView& request; + const IdentityRestoreActivity& activity; + void* work; + uint8_t* output; + size_t capacity; + size_t& written; + Status status = Status::Busy; + } context{adapter, request, activity, memory, output, capacity, written}; + const bool admitted = platform::esp::arduino_common::geocaching::browse_runtime::withIdentityMaintenance( + [](void* opaque) + { + auto& state = *static_cast(opaque); + if (state.request.code == uint16_t(Operation::RadioIdentityBackupRestore)) + { + auto* work = new (state.work) RadioIdentityRestoreWork; + state.status = processRadioIdentityRestore(state.adapter, state.request, state.activity, *work, + state.output, state.capacity, state.written); + work->~RadioIdentityRestoreWork(); + } + else + { + auto* work = new (state.work) IdentityRestoreWork; + state.status = processIdentityRestore(state.adapter, state.request, state.activity, *work, + state.output, state.capacity, state.written); + work->~IdentityRestoreWork(); + } + return state.status == Status::Ok; + }, + &context); + return admitted ? context.status : Status::Busy; +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_management_service.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_management_service.h new file mode 100644 index 00000000..a9bb4bc9 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_management_service.h @@ -0,0 +1,838 @@ +#pragma once + +#include "app/app_facade_access.h" +#include "chat/infra/reticulum/reticulum_wire.h" +#include "chat/ports/i_mesh_adapter.h" +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/hostlink_service_codec.h" +#include "hostlink/identity_backup_request.h" +#include "hostlink/management_envelope.h" +#include "hostlink/sensitive_memory.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" +#include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "platform/esp/arduino_common/hostlink/web_config_upload.h" +#include "platform/esp/arduino_common/hostlink/web_publication_commit.h" +#include "platform/esp/arduino_common/hostlink/web_publication_prepare.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/mbedtls_sha256_compat.h" +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "hostlink/route_install_directory.h" +#include "hostlink/route_install_storage_policy.h" +#include "hostlink/route_storage_lease.h" +#include "platform/esp/arduino_common/hostlink/hostlink_identity_restore.h" +#include "platform/esp/arduino_common/hostlink/web_geocache_confirmation.h" +#include "platform/esp/arduino_common/hostlink/web_geocache_signature.h" +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/track_snapshot_service.h" +#include "platform/esp/idf_common/hostlink_route_install_runtime.h" +#include "platform/esp/idf_common/hostlink_track_catalog.h" +#include "platform/esp/idf_common/hostlink_track_directories_runtime.h" +#include "platform/esp/idf_common/hostlink_track_ip_confirmation_runtime.h" +#include "platform/esp/idf_common/hostlink_track_prepare_owner.h" +#include "platform/esp/idf_common/hostlink_track_prepare_persistence.h" +#include "platform/esp/idf_common/hostlink_track_prepare_runtime.h" +#include "platform/esp/idf_common/hostlink_track_publication_runtime.h" +#include "platform/esp/idf_common/hostlink_track_read_service.h" +#include "platform/esp/idf_common/hostlink_track_snapshot_owner.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include "platform/esp/idf_common/hostlink_track_upload_maintenance.h" +#include "platform/esp/idf_common/hostlink_track_upload_runtime.h" +#endif + +namespace hostlink::management +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +inline route_install_runtime::RouteInstallOwner* route_install_owner = nullptr; +inline void release_route_install() +{ + if (!route_install_owner) return; + route_install_runtime::releaseRouteInstall(route_install_owner); + hostlink::route_storage_lease::release(); +} +struct RouteInstallDirectoryPort +{ + uint32_t session() const { return platform::esp::arduino_common::storage::sd_media_session(); } + bool ready() const { return platform::esp::arduino_common::storage::sd_card_ready(); } + bool externalOwner() const { return platform::esp::arduino_common::storage::sd_external_block_owner_active(); } + bool directory(const char* path, uint32_t expected) const { return session() == expected && platform::esp::arduino_common::storage::sd_is_directory(path) && session() == expected; } + bool mkdir(const char* path, uint32_t expected) const { return platform::esp::arduino_common::storage::sd_mkdir(path, expected); } +}; +inline Status route_install_policy(const RouteInstallView& request, bool install, uint8_t* scratch, size_t capacity, uint32_t expected_media) +{ + if (expected_media != platform::esp::arduino_common::storage::sd_media_session()) return Status::StorageUnavailable; + RouteInstallStorageActivity activity; + activity.restart = app::sd_tms::webConfigurationRestartPending(); + activity.snapshot = snapshot_runtime::owner != nullptr; + activity.prepare = prepare_runtime::prepareHoldsStorage(); + activity.publication = publication_runtime::holdsStorage(); + if (install && app::hasAppFacade()) + { + const auto& config = app::configFacade().readConfig(); + activity.active_route = routeInstallPathMatches(request.hash, config.route_path, sizeof(config.route_path)); + } + const auto initial = routeInstallStorageAdmission(activity); + if (initial != Status::Ok) return initial; + if (!scratch || capacity < 448) return Status::Internal; + const auto a = reinterpret_cast(request.hash.data), b = reinterpret_cast(scratch); + // Admission still borrows the incoming command. Recheck its persistent + // config journal only after the route owner has copied the binding. + if (a <= b ? b - a < request.hash.size : a - b < capacity) return Status::Ok; + const auto aligned = (b + 128 + alignof(web_config::Metadata) - 1) & ~uintptr_t(alignof(web_config::Metadata) - 1); + auto* metadata = new (reinterpret_cast(aligned)) web_config::Metadata; + const bool loaded = web_config::load(*metadata, scratch); + activity.configuration = loaded && metadata->phase < (metadata->kind == 2 ? 4 : 2); + metadata->~Metadata(); + namespace sd = platform::esp::arduino_common::storage; + if (!loaded && (sd::sd_exists(web_config::kMetaPath) || sd::sd_exists(web_config::kMetaNew) || sd::sd_exists(web_config::kMetaBackup))) return Status::StorageUnavailable; + const auto admitted = routeInstallStorageAdmission(activity); + if (admitted != Status::Ok || !install) return admitted; + RouteInstallDirectoryPort directory; + return ensureRouteInstallDirectory(directory, expected_media); +} +template +inline Status with_route_storage_gate(Action&& action, bool signature_recovery = false) +{ + struct Context + { + Action& action; + Status status = Status::Busy; + } context{action}; + const auto gate = signature_recovery + ? platform::esp::arduino_common::geocaching::browse_runtime::withSignatureInputRecovery + : platform::esp::arduino_common::geocaching::browse_runtime::withIdentityMaintenance; + gate( + [](void* opaque) + {auto& state=*static_cast(opaque);state.status=state.action();return false; }, + &context); + // False deliberately avoids the identity-restoration side effect. Status + // stays Busy when the storage gate did not invoke our action. + return context.status; +} +inline Status package_route_policy(const char* target, uint8_t* scratch, size_t capacity) +{ + if (!target || !scratch || capacity < 448) return Status::InvalidField; + RouteInstallStorageActivity activity; + activity.restart = app::sd_tms::webConfigurationRestartPending(); + activity.snapshot = snapshot_runtime::owner != nullptr; + activity.publication = publication_runtime::holdsStorage(); + if (app::hasAppFacade()) activity.active_route = !std::strcmp(app::configFacade().readConfig().route_path, target); + const auto initial = routeInstallStorageAdmission(activity); + if (initial != Status::Ok) return initial; + const auto aligned = (reinterpret_cast(scratch) + 128 + alignof(web_config::Metadata) - 1) & ~uintptr_t(alignof(web_config::Metadata) - 1); + auto* metadata = new (reinterpret_cast(aligned)) web_config::Metadata; + const bool loaded = web_config::load(*metadata, scratch); + activity.configuration = loaded && metadata->phase < (metadata->kind == 2 ? 4 : 2); + metadata->~Metadata(); + namespace sd = platform::esp::arduino_common::storage; + if (!loaded && (sd::sd_exists(web_config::kMetaPath) || sd::sd_exists(web_config::kMetaNew) || sd::sd_exists(web_config::kMetaBackup))) return Status::StorageUnavailable; + return routeInstallStorageAdmission(activity); +} +inline void advance_route_install(uint8_t* scratch, size_t capacity, uint64_t now, bool connected) +{ + if (!route_install_owner) return; + if (!connected || !route_install_owner->alive(now, connected)) + { + release_route_install(); + return; + } + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + const auto expected_media = route_install_owner->mediaSession(); + with_route_storage_gate([&] + { return route_install_runtime::advanceRouteInstallSlot(route_install_owner, adapter, scratch, capacity, now, true, + [&](const RouteInstallView& view, bool install) + { return route_install_policy(view, install, scratch, capacity, expected_media); }); }); + if (!route_install_owner) hostlink::route_storage_lease::release(); +} +inline Status process_route_install(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + const auto expected_media = route_install_owner ? route_install_owner->mediaSession() : platform::esp::arduino_common::storage::sd_media_session(); + return with_route_storage_gate([&] + { + if(!route_install_owner&&!hostlink::route_storage_lease::acquire())return Status::Busy; + const auto result=route_install_runtime::processRouteInstallRequest(route_install_owner,*adapter,request,snapshot_runtime::nowMs(),output,capacity,written, + [&](const RouteInstallView& view,bool install){return route_install_policy(view,install,output,capacity,expected_media);}); + if(result==Status::Ok)route_install_runtime::retireDeliveredRouteInstall(route_install_owner); + if(!route_install_owner)hostlink::route_storage_lease::release(); + return result; }); +} +#endif +inline Status publication_job_get(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written, bool stop = false) +{ + if (capacity < 448) return Status::Internal; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t generation = 0; + geocaching::ByteView operation; + if (!reader.array(fields, 3) || (fields != 2 && fields != 3) || !reader.binary(operation, 16) || operation.size != 16 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX) return Status::InvalidField; + uint64_t kind = 0; + if ((fields == 3 && (!reader.unsignedInteger(kind) || kind != 1)) || !reader.finished()) return Status::InvalidField; + uint8_t id[16], bits = 0; + std::memcpy(id, operation.data, sizeof(id)); + for (const auto byte : id) bits |= byte; + if (!bits) return Status::InvalidField; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (kind == 1) + { + if (snapshot_runtime::owner || prepare_runtime::prepareHoldsStorage() || upload_runtime::owner) return Status::Busy; + if (stop) return publication_runtime::cancelStatus(*adapter, operation, uint32_t(generation), output, capacity, written); + return publication_runtime::getStatus(*adapter, operation, uint32_t(generation), output, capacity, written, request.session, uint64_t(esp_timer_get_time() / 1000)); + } +#else + if (kind == 1) return Status::Unsupported; +#endif + uint32_t actual = 0; + const auto identity = adapter->readDeviceAuthorKey(output + 384, actual); + if (identity == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (identity != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (generation != actual) return Status::StaleIdentity; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + auto result = stop ? runtime::requestWebPublicationStop(id, output + 384) : runtime::WebReadResult::Ready; + if (result == runtime::WebReadResult::Ready) + { + result = runtime::readWebPublicationJob(id, output + 384, output, 384, written); + if (result == runtime::WebReadResult::NotFound) + result = runtime::readWebPublicationHistory(id, output + 384, output, 384, written); + } + switch (result) + { + case runtime::WebReadResult::Ready: + return Status::Ok; + case runtime::WebReadResult::Pending: + return Status::Busy; + case runtime::WebReadResult::Forbidden: + return Status::Unauthorized; + case runtime::WebReadResult::StaleIdentity: + return Status::StaleIdentity; + case runtime::WebReadResult::NotFound: + // Missing retained evidence does not authorize a repeated publication. + return Status::Expired; + default: + return Status::StorageUnavailable; + } +} +inline Status readDirectoryList(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t offset = 0; + if (!reader.array(fields, 1) || fields != 1 || !reader.unsignedInteger(offset) || offset > 4 || !reader.finished()) return Status::InvalidField; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + switch (runtime::readWebDirectories(static_cast(offset), output, capacity, written)) + { + case runtime::WebReadResult::Ready: + return Status::Ok; + case runtime::WebReadResult::Pending: + return Status::Busy; + case runtime::WebReadResult::Invalid: + return Status::InvalidField; + default: + return Status::StorageUnavailable; + } +} +inline Status draft_put(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + if (capacity < 448) return Status::Internal; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + geocaching::ByteView transfer, draft; + uint64_t expected = 0, author_generation = 0; + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(transfer, 16) || transfer.size != 16 || + !reader.binary(draft, 16) || draft.size != 16 || !reader.unsignedInteger(expected) || expected == UINT64_MAX || + !reader.unsignedInteger(author_generation) || !author_generation || author_generation > UINT32_MAX || !reader.finished()) return Status::InvalidField; + uint8_t ids[32]; + std::memcpy(ids, transfer.data, 16); + std::memcpy(ids + 16, draft.data, 16); + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t actual = 0; + const auto author = adapter->readDeviceAuthorKey(output + 384, actual); + if (author == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (author != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != author_generation) return Status::StaleIdentity; + web_config::Metadata meta; + if (!web_config::load(meta, output)) return Status::Expired; + if (std::memcmp(meta.owner, request.session, 16)) return Status::Unauthorized; + if (meta.kind != 2 || !meta.phase || std::memcmp(meta.id, ids, 16)) return Status::Conflict; + if (meta.phase == 1) + { + std::memcpy(meta.base_hash, ids + 16, 16); + for (unsigned i = 0; i < 8; ++i) meta.base_hash[16 + i] = static_cast(expected >> (8 * i)); + web_config::put32(meta.base_hash + 24, actual); + meta.phase = 2; + if (!web_config::save(meta, output)) return Status::StorageUnavailable; + } + else + { + if (std::memcmp(meta.base_hash, ids + 16, 16) || web_config::u32(meta.base_hash + 24) != actual) return Status::Conflict; + for (unsigned i = 0; i < 8; ++i) + if (meta.base_hash[16 + i] != static_cast(expected >> (8 * i))) return Status::Conflict; + } + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + if (meta.phase != 4) + { + auto result = runtime::checkWebDraftEditOwner(ids + 16, expected, output + 384, meta.hash); + bool from_queue = false; + if (result == runtime::WebReadResult::Ready) + { + uint8_t hash[32]; + if (!web_config::hash_file(runtime::kWebDraftCandidatePath, output, hash, meta.total)) return Status::StorageUnavailable; + if (std::memcmp(hash, meta.hash, 32)) return Status::HashMismatch; + if (meta.phase != 3) + { + meta.phase = 3; + if (!web_config::save(meta, output)) return Status::StorageUnavailable; + } + result = runtime::queueWebDraftFile(ids + 16, meta.total, expected, meta.hash, output + 384, actual); + from_queue = true; + // Even a finished in-memory job is re-read on the next request; + // only the committed row or a durable receipt proves success. + if (result == runtime::WebReadResult::AlreadySaved) return Status::Busy; + } + else if (result == runtime::WebReadResult::AlreadySaved) + { + meta.phase = 4; + if (!web_config::save(meta, output)) return Status::StorageUnavailable; + } + if (meta.phase != 4) + { + if (from_queue && (result == runtime::WebReadResult::Invalid || result == runtime::WebReadResult::HashMismatch || result == runtime::WebReadResult::StaleIdentity || result == runtime::WebReadResult::Unavailable) && + runtime::releaseWebDraftSave()) + { + meta.phase = 2; + if (!web_config::save(meta, output)) return Status::StorageUnavailable; + } + switch (result) + { + case runtime::WebReadResult::Pending: + return Status::Busy; + case runtime::WebReadResult::Forbidden: + return Status::Unauthorized; + case runtime::WebReadResult::Invalid: + return Status::InvalidField; + case runtime::WebReadResult::HashMismatch: + return Status::HashMismatch; + case runtime::WebReadResult::StaleIdentity: + return Status::StaleIdentity; + case runtime::WebReadResult::Unavailable: + return Status::StorageUnavailable; + default: + return Status::Conflict; + } + } + } + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(3) || !writer.binary({meta.id, 16}) || !writer.binary({meta.base_hash, 16}) || !writer.unsignedInteger(expected + 1)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +inline Status cache_list_owned(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + if (capacity < 448) return Status::Internal; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t offset = 0, expected = 0, author_generation = 0; + if (!reader.array(fields, 3) || fields != 3 || !reader.unsignedInteger(offset) || offset > UINT32_MAX || + reader.position() >= request.body_size) return Status::InvalidField; + const bool bound = request.body[reader.position()] != 0xc0; + if (!(bound ? reader.unsignedInteger(expected) : reader.nil()) || (offset && !bound) || + !reader.unsignedInteger(author_generation) || !author_generation || author_generation > UINT32_MAX || !reader.finished()) + return Status::InvalidField; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t actual = 0; + const auto author = adapter->readDeviceAuthorKey(output + 384, actual); + if (author == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (author != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != author_generation) return Status::StaleIdentity; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + switch (runtime::readWebDraftCatalog(static_cast(offset), bound, expected, output, 384, written)) + { + case runtime::WebReadResult::Ready: + return Status::Ok; + case runtime::WebReadResult::Pending: + return Status::Busy; + case runtime::WebReadResult::Conflict: + case runtime::WebReadResult::AlreadySaved: + case runtime::WebReadResult::NotFound: + return Status::Conflict; + case runtime::WebReadResult::Invalid: + return Status::InvalidField; + case runtime::WebReadResult::Unavailable: + return Status::StorageUnavailable; + case runtime::WebReadResult::Forbidden: + return Status::Unauthorized; + case runtime::WebReadResult::HashMismatch: + return Status::HashMismatch; + case runtime::WebReadResult::StaleIdentity: + return Status::StaleIdentity; + case runtime::WebReadResult::Absent: + return Status::Internal; + } + return Status::Internal; +} +inline Status cache_draft_get(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + if (capacity < 448) return Status::Internal; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t offset = 0, count = 0, expected = 0, author_generation = 0; + geocaching::ByteView id; + if (!reader.array(fields, 5) || fields != 5 || !reader.binary(id, 16) || id.size != 16 || + !reader.unsignedInteger(offset) || offset > UINT32_MAX || !reader.unsignedInteger(count) || !count || count > 320 || + !reader.unsignedInteger(expected) || (offset && !expected) || + !reader.unsignedInteger(author_generation) || !author_generation || author_generation > UINT32_MAX || !reader.finished()) + return Status::InvalidField; + uint8_t key[16]; + std::memcpy(key, id.data, 16); + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t actual = 0; + const auto author = adapter->readDeviceAuthorKey(output + 384, actual); + if (author == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (author != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != author_generation) return Status::StaleIdentity; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + switch (runtime::readWebDraftChunk(key, static_cast(offset), static_cast(count), expected, output, 384, written)) + { + case runtime::WebReadResult::Ready: + return Status::Ok; + case runtime::WebReadResult::Pending: + return Status::Busy; + case runtime::WebReadResult::Conflict: + case runtime::WebReadResult::AlreadySaved: + case runtime::WebReadResult::NotFound: + return Status::Conflict; + case runtime::WebReadResult::Invalid: + return Status::InvalidField; + case runtime::WebReadResult::Unavailable: + return Status::StorageUnavailable; + case runtime::WebReadResult::Forbidden: + return Status::Unauthorized; + case runtime::WebReadResult::HashMismatch: + return Status::HashMismatch; + case runtime::WebReadResult::StaleIdentity: + return Status::StaleIdentity; + case runtime::WebReadResult::Absent: + return Status::Internal; + } + return Status::Internal; +} +inline Status author_operation(const EnvelopeView& request, uint8_t* scratch, size_t capacity, size_t& body_size) +{ + if (capacity < 448) return Status::Internal; + const bool proof = request.code == static_cast(Operation::AuthorProve); + uint32_t expected = 0; + if (proof) + { + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t generation = 0; + geocaching::ByteView challenge; + if (!reader.array(fields, 2) || fields != 2 || !reader.binary(challenge, 32) || challenge.size != 32 || + !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || !reader.finished()) + return Status::InvalidField; + expected = static_cast(generation); + // The request nonce can overlap the output arena. Move it out of the + // fixed message and response regions before any encoding begins. + std::memmove(scratch + 416, challenge.data, 32); + } + else if (request.body_size != 1 || request.body[0] != 0x90) return Status::InvalidField; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + auto* public_key = scratch + 256; + auto* signature = scratch + 320; + uint32_t generation = 0; + const auto result = proof + ? adapter->proveDeviceAuthor(request.session, scratch + 416, expected, + scratch, 160, public_key, generation, signature) + : adapter->readDeviceAuthorKey(public_key, generation); + switch (result) + { + case chat::DeviceAuthorResult::Ok: + break; + case chat::DeviceAuthorResult::Unsupported: + return Status::Unsupported; + case chat::DeviceAuthorResult::Unavailable: + return Status::Busy; + case chat::DeviceAuthorResult::StaleIdentity: + return Status::StaleIdentity; + case chat::DeviceAuthorResult::InvalidInput: + return Status::InvalidField; + case chat::DeviceAuthorResult::StorageError: + return Status::StorageUnavailable; + } + if (!generation) return Status::Internal; + auto* identity_hash = scratch + 384; + auto* destination = scratch + 400; + uint8_t name_hash[chat::reticulum::kNameHashSize]; + chat::reticulum::computeIdentityHash(public_key, identity_hash); + chat::reticulum::computeNameHash("lxmf", "delivery", name_hash); + chat::reticulum::computeDestinationHash(name_hash, identity_hash, destination); + geocaching::protocol::CmpWriter writer(scratch, 256); + if (!writer.array(proof ? 5 : 4) || !writer.binary({public_key, 64}) || !writer.binary({identity_hash, 16}) || + !writer.binary({destination, 16}) || !writer.unsignedInteger(generation) || + (proof && !writer.binary({signature, 64}))) return Status::Internal; + body_size = writer.size(); + return Status::Ok; +} + +// Runs on the existing application command consumer, not the CDC task. Reuses +// PendingCommand::payload as hash/read scratch and then response storage. +inline Status read_config_snapshot(const EnvelopeView& request, uint8_t* scratch, + size_t capacity, size_t& body_size) +{ + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields; + uint64_t offset, count; + uint8_t expected[32]; + bool has_expected = false; + if (!reader.array(fields, 3) || fields != 3 || !reader.unsignedInteger(offset) || + !reader.unsignedInteger(count) || count == 0 || count > 320 || offset > UINT32_MAX) + return Status::InvalidField; + if (!reader.nil()) + { + // A failed cursor is poisoned; decode the expected hash using a fresh + // cursor rather than treating a failed nil parse as a valid alternative. + geocaching::protocol::CmpReader again({request.body, request.body_size}); + geocaching::ByteView hash; + if (!again.array(fields, 3) || !again.unsignedInteger(offset) || !again.unsignedInteger(count) || + !again.binary(hash, 32) || hash.size != 32 || !again.finished()) return Status::InvalidField; + std::memcpy(expected, hash.data, 32); + has_expected = true; + } + else if (!reader.finished()) return Status::InvalidField; + if (offset && !has_expected) return Status::InvalidField; + if (app::sd_tms::syncPendingWorkingConfig() != app::sd_tms::WorkingConfigSyncResult::Synchronized) + return Status::StorageUnavailable; + using namespace platform::esp::arduino_common::storage; + SdRuntimeFile file; + if (!file.open("/trailmate/config.tms", "r")) return Status::StorageUnavailable; + const uint64_t total = file.size(); + if (!total || total > 32768 || offset > total || capacity < 384) return Status::InvalidField; + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + uint8_t hash[32]; + bool ok = platform::esp::common::crypto::sha256_starts(&context, 0) == 0; + uint64_t consumed = 0; + while (ok && consumed < total) + { + const size_t wanted = static_cast(total - consumed > 128 ? 128 : total - consumed); + const int n = file.read(scratch, wanted); + ok = n == static_cast(wanted) && + platform::esp::common::crypto::sha256_update(&context, scratch, wanted) == 0; + consumed += wanted; + } + ok = ok && platform::esp::common::crypto::sha256_finish(&context, hash) == 0; + mbedtls_sha256_free(&context); + if (!ok) return Status::StorageUnavailable; + if (has_expected && std::memcmp(hash, expected, 32)) return Status::Conflict; + const size_t length = static_cast(total - offset < count ? total - offset : count); + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(4) || !writer.unsignedInteger(offset) || !writer.unsignedInteger(total) || + !writer.binary({hash, 32}) || !writer.binaryHeader(length)) return Status::Internal; + const size_t header = writer.size(); + if (length > capacity - header || !file.seek(offset) || + file.read(scratch + header, length) != static_cast(length)) return Status::StorageUnavailable; + body_size = header + length; + return Status::Ok; +} + +inline ErrorCode execute_management(PendingCommand& command) +{ + EnvelopeView request; + if (!decodeEnvelope(command.payload, command.payload_len, request)) return ErrorCode::InvalidParam; + // Copy only identifiers before overwriting the borrowed request arena. + uint8_t ids[32]; + std::memcpy(ids, request.session, 16); + std::memcpy(ids + 16, request.request, 16); + uint8_t* body = command.payload + 41; + size_t body_size = 1; + Status status = Status::Unsupported; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (route_install_owner && routeInstallBlocksOperation(request.code) && route_install_owner->status() != Status::Busy && route_install_owner->status() != Status::Internal) + release_route_install(); + if (route_install_owner && routeInstallBlocksOperation(request.code)) status = Status::Busy; + else if (ip_confirmation_runtime::holdsStorage() && ip_confirmation_runtime::blocksOperation(request.code)) status = Status::Busy; + else +#endif + if (request.code == static_cast(Operation::Capabilities)) + { + if (request.body_size != 1 || request.body[0] != 0x90) status = Status::InvalidField; + else + { + // [version, frame bytes, read chunk bytes, pending window, operations, TMS schema] +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + const uint8_t capabilities[] = {0x96, 1, 0xcd, 2, 0, 0xcd, 1, 0x40, 1, 0xdc, 0, 56, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 17, 20, 21, 32, 33, 34, 35, 36, 37, 38, 48, 50, 64, 65, 66, 67, 68, 70, 71, 72, 73, 74, 75, 76, 77, 80, 81, 82, 83, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 7}; +#else + const uint8_t capabilities[] = {0x96, 1, 0xcd, 2, 0, 0xcd, 1, 0x40, 1, 0xdc, 0, 20, 0, 1, 2, 3, 17, 20, 21, 32, 33, 34, 35, 36, 48, 50, 64, 65, 66, 67, 68, 70, 7}; +#endif + std::memcpy(body, capabilities, sizeof(capabilities)); + body_size = sizeof(capabilities); + status = Status::Ok; + } + } + else if (request.code == static_cast(Operation::SessionClose)) + { + if (request.body_size != 1 || request.body[0] != 0x90) status = Status::InvalidField; + else + { + platform::esp::arduino_common::geocaching::browse_runtime::webLease(false); +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + platform::esp::arduino_common::geocaching::browse_runtime::releaseWebSignature(request.session); + platform::esp::arduino_common::geocaching::browse_runtime::releaseWebConfirmation(request.session); + upload_runtime::closeUploadSession(request.session); + prepare_runtime::closePrepareSession(request.session); + ip_confirmation_runtime::closeSession(request.session); + if (route_install_owner && route_install_owner->matchesSession(request.session)) release_route_install(); + publication_runtime::closeSession(request.session); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + snapshot_runtime::closeSession(request.session); + directory_runtime::closeSession(request.session); +#endif + body[0] = 0x90; + body_size = 1; + status = Status::Ok; + } + } +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + else if (request.code >= static_cast(Operation::CacheSignBegin) && request.code <= static_cast(Operation::CacheSignRelease)) + status = cache_signature(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code >= static_cast(Operation::CacheConfirmBegin) && request.code <= static_cast(Operation::CacheConfirmRelease)) + status = cache_confirmation(request, body, sizeof(command.payload) - 41, body_size); +#endif + else if (request.code == static_cast(Operation::AuthorInfo) || + request.code == static_cast(Operation::AuthorProve)) + status = author_operation(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == uint16_t(Operation::IdentityBackupExport)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter ? processIdentityBackupExport(*adapter, request, body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + else if (request.code == uint16_t(Operation::RadioIdentityInfo) || request.code == uint16_t(Operation::RadioIdentityBackupExport)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter ? radioIdentityReadOperation(*adapter, request, body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + else if (request.code == uint16_t(Operation::IdentityRestoreFormats)) + { + if (request.body_size != 1 || request.body[0] != 0x90) status = Status::InvalidField; + else + { + body[0] = 0x92; + body[1] = 0; + body[2] = 1; + body_size = 3; + status = Status::Ok; + } + } + else if (request.code == uint16_t(Operation::IdentityBackupRestore) || request.code == uint16_t(Operation::RadioIdentityBackupRestore)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + IdentityRestoreActivity activity; + activity.restart = app::sd_tms::webConfigurationRestartPending(); + activity.snapshot = snapshot_runtime::owner != nullptr || route_install_owner != nullptr; + activity.upload = upload_runtime::owner != nullptr; + activity.prepare = prepare_runtime::prepareHoldsStorage(); + activity.publication = publication_runtime::holdsStorage(); + // Directory callbacks can receive responses while the command consumer + // runs. Require their own atomic lock before inspecting pending state. + if (directory_runtime::owner) + { + auto& directory = *directory_runtime::owner; + if (directory.locked.test_and_set(std::memory_order_acquire)) activity.network = true; + else + { + activity.network = directory.pending; + directory.locked.clear(std::memory_order_release); + } + } + status = adapter ? restoreIdentityOperation(*adapter, request, activity, body, sizeof(command.payload) - 41, body_size) : Status::Busy; + if (status == Status::Ok) + { + prepare_runtime::releasePrepare(); + publication_runtime::destroyController(); + directory_runtime::closeSession(ids); + app::sd_tms::scheduleWebManagementRestart(); + } + } +#endif + else if (request.code == static_cast(Operation::CacheDraftGet)) + status = cache_draft_get(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::CacheDraftPut)) + status = draft_put(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::CachePrepare)) + status = cache_prepare(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::JobGet)) + status = publication_job_get(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::JobCancel)) + status = publication_job_get(request, body, sizeof(command.payload) - 41, body_size, true); + else if (request.code == static_cast(Operation::CacheCommit)) + status = cache_commit(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::CacheDirectoryList)) + status = readDirectoryList(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::CacheListOwned)) + status = cache_list_owned(request, body, sizeof(command.payload) - 41, body_size); +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + else if (request.code == uint16_t(Operation::RouteInstall) || request.code == uint16_t(Operation::RouteInstallStatus)) + { + status = process_route_install(request, body, sizeof(command.payload) - 41, body_size); + } + else if (request.code == static_cast(Operation::TrackList)) + status = readTrackCatalog(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == uint16_t(Operation::TransferCleanup) || request.code == uint16_t(Operation::TransferListOwned)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = !snapshot_runtime::owner && !prepare_runtime::prepareHoldsStorage() && !publication_runtime::holdsStorage() && !route_install_owner && adapter + ? with_route_storage_gate([&] + { + const size_t capacity = sizeof(command.payload) - 41; + if (request.code == uint16_t(Operation::TransferListOwned) && request.body_size == 3 && + request.body[0] == 0x92 && (request.body[1] == 5 || request.body[1] == 6) && request.body[2] == 0xc0) + { + namespace storage = platform::esp::arduino_common::storage; + uint8_t author_hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(*adapter, body + capacity - 64, 64, author_hash, generation); + if (identity != Status::Ok) return identity; + const auto media = storage::sd_media_session(); + if (!storage::sd_card_ready() || storage::sd_external_block_owner_active()) return Status::StorageUnavailable; + const auto result = web_config::listSharedSignatureUpload(request, body + capacity - 64, generation, media, body, capacity, body_size); + if (media != storage::sd_media_session() || !storage::sd_card_ready() || storage::sd_external_block_owner_active()) + { + body_size = 0; + return Status::StorageUnavailable; + } + return result; + } + return upload_runtime::processUploadMaintenance(*adapter, request, snapshot_runtime::nowMs(), body, capacity, body_size); }, + request.code == uint16_t(Operation::TransferListOwned) && request.body_size == 3 && + request.body[0] == 0x92 && (request.body[1] == 5 || request.body[1] == 6) && request.body[2] == 0xc0) + : Status::Busy; + } + else if (request.code == static_cast(Operation::TrackDirectoryList)) + { + platform::esp::arduino_common::geocaching::browse_runtime::webLease(true, false); + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter && !publication_runtime::holdsStorage() ? directory_runtime::list(*adapter, request, uint64_t(esp_timer_get_time() / 1000), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + else if (request.code == static_cast(Operation::TrackDirectoryDiscover)) + { + platform::esp::arduino_common::geocaching::browse_runtime::webLease(true, false); + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter && !publication_runtime::holdsStorage() ? directory_runtime::discover(*adapter, request, uint64_t(esp_timer_get_time() / 1000), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + else if (request.code == uint16_t(Operation::TrackReadBegin)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter && !snapshot_runtime::owner && !upload_runtime::owner && !prepare_runtime::prepareHoldsStorage() ? track_read_runtime::processReadBegin(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + else if (request.code == uint16_t(Operation::TrackReadStatus) || request.code == uint16_t(Operation::TrackReadChunk) || request.code == uint16_t(Operation::TrackReadRelease)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = adapter ? track_read_runtime::processReadResult(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + else if (request.code == static_cast(Operation::TrackConfirmPublication) || request.code == static_cast(Operation::TrackImportHistory)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter || snapshot_runtime::owner || prepare_runtime::prepareHoldsStorage() || publication_runtime::holdsStorage() || app::sd_tms::webConfigurationRestartPending()) status = Status::Busy; + else status = with_route_storage_gate([&] + { return ip_confirmation_runtime::processConfirmation(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size); }); + } + else if (request.code == static_cast(Operation::TrackPrepare) || request.code == static_cast(Operation::TrackSignPrepare) || request.code == uint16_t(Operation::TrackPackageSignPrepare) || request.code == uint16_t(Operation::TrackPackageInstall) || request.code == uint16_t(Operation::TrackPackageInstallStatus)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (snapshot_runtime::owner || publication_runtime::holdsStorage() || route_install_owner || !adapter) status = Status::Busy; + else if (request.code == uint16_t(Operation::TrackPackageInstall) || request.code == uint16_t(Operation::TrackPackageInstallStatus)) + status = with_route_storage_gate([&] + { return prepare_runtime::processPrepareRequest(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size, package_route_policy); }); + else status = prepare_runtime::processPrepareRequest(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size); + } + else if (request.code == static_cast(Operation::TrackCommit)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = !snapshot_runtime::owner && !prepare_runtime::prepareHoldsStorage() && !upload_runtime::owner && adapter ? publication_runtime::processCommit(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + else if (request.code == static_cast(Operation::TrackSnapshotBuild)) + { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (upload_runtime::owner || prepare_runtime::prepareHoldsStorage() || publication_runtime::holdsStorage()) status = Status::Busy; + else +#endif + status = snapshot_runtime::build(request, body, sizeof(command.payload) - 41, body_size); + } + else if (request.code == static_cast(Operation::TrackSnapshotCancel)) + status = snapshot_runtime::cancel(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::TrackSnapshotRelease)) + { + namespace sd = platform::esp::arduino_common::storage; + if (snapshot_runtime::owner) + status = snapshot_runtime::releaseTrackSnapshot(request, snapshot_runtime::owner->io, snapshot_runtime::owner->media, body, sizeof(command.payload) - 41, body_size); + else + { + const auto media = sd::sd_media_session(); + sd::SdRuntimeFile file(sd::SdRuntimeMemoryPolicy::PsramOnly); + sd::SdBorrowedRecordFileIo io(file, media); + status = file.has_backing_storage() ? snapshot_runtime::releaseTrackSnapshot(request, io, media, body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + } + else if (request.code == static_cast(Operation::TrackSnapshot)) + { + namespace sd = platform::esp::arduino_common::storage; + sd::SdRuntimeFile file(sd::SdRuntimeMemoryPolicy::PsramOnly); + sd::SdBorrowedRecordFileIo io(file, sd::sd_media_session()); + status = file.has_backing_storage() ? readTrackSnapshotService(request, io, body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } +#endif + else if (request.code == static_cast(Operation::ConfigSnapshot)) + status = read_config_snapshot(request, body, sizeof(command.payload) - 41, body_size); + else if (request.code == static_cast(Operation::ConfigValidate) || + request.code == static_cast(Operation::ConfigCommit) || + (request.code >= 32 && request.code <= 36)) + { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (upload_runtime::routeTrackUpload(request)) + { + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + status = !snapshot_runtime::owner && !prepare_runtime::prepareHoldsStorage() && !publication_runtime::holdsStorage() && adapter ? upload_runtime::processAuthenticatedUpload(*adapter, request, snapshot_runtime::nowMs(), body, sizeof(command.payload) - 41, body_size) : Status::Busy; + } + else +#endif + status = web_config::process(request, body, sizeof(command.payload) - 41, body_size); + } + if (status != Status::Ok) + { + body[0] = 0x90; + body_size = 1; + } + size_t written; + if (request.code == uint16_t(Operation::IdentityBackupRestore) || request.code == uint16_t(Operation::RadioIdentityBackupRestore)) + std::memset(body + body_size, 0, sizeof(command.payload) - 41 - body_size); + const bool sensitive = request.code == uint16_t(Operation::IdentityBackupExport) || request.code == uint16_t(Operation::IdentityBackupRestore) || + request.code == uint16_t(Operation::RadioIdentityBackupExport) || request.code == uint16_t(Operation::RadioIdentityBackupRestore); + if (!encodeEnvelope(ids, ids + 16, static_cast(status), body, body_size, + command.payload, sizeof(command.payload), written)) + { + if (sensitive) clearSensitiveBytes(command.payload, sizeof(command.payload)); + return ErrorCode::Internal; + } + const auto result = enqueue_event(static_cast(FrameType::ManagementResponse), command.payload, + written, false, command.sequence) + ? ErrorCode::Ok + : ErrorCode::Busy; + if (sensitive) clearSensitiveBytes(command.payload, sizeof(command.payload)); + return result; +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_service.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_service.h index 69907d8b..034ca238 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_service.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/hostlink_service.h @@ -12,7 +12,9 @@ void start(); void stop(); bool is_active(); Status get_status(); -bool enqueue_event(uint8_t type, const uint8_t* payload, size_t len, bool high_priority = false); +bool enqueue_event(uint8_t type, const uint8_t* payload, size_t len, bool high_priority = false, + uint16_t reply_sequence = 0); void process_pending_commands(); +void tick_publication_tasks(); } // namespace hostlink diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/publication_ticket_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/publication_ticket_store.h new file mode 100644 index 00000000..4f37b068 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/publication_ticket_store.h @@ -0,0 +1,33 @@ +#pragma once + +#include "hostlink/publication_ticket.h" +#include "platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" + +namespace hostlink::management +{ +constexpr const char* kPublicationTicketPath = "/trailmate/geocaching.web.publish"; +constexpr const char* kPublicationTicketNew = "/trailmate/geocaching.web.publish.new"; +constexpr const char* kPublicationTicketBackup = "/trailmate/geocaching.web.publish.bak"; + +// Storage primitives only: PREPARE/COMMIT owns admission and phase transitions. +// No retained RAM; scratch belongs to the command owner and must be at least 231 bytes. +inline bool loadPublicationTicket(uint8_t* scratch, size_t capacity) +{ + if (!scratch || capacity < kPublicationTicketBytes || + !chat::storage::v2::recoverAtomicFile(kPublicationTicketPath, kPublicationTicketNew, kPublicationTicketBackup)) return false; + platform::esp::arduino_common::storage::SdRuntimeFile file; + return file.open(kPublicationTicketPath, "r") && file.size() == kPublicationTicketBytes && + file.read(scratch, kPublicationTicketBytes) == static_cast(kPublicationTicketBytes) && + validatePublicationTicket(scratch, kPublicationTicketBytes); +} + +inline bool savePublicationTicket(const uint8_t* bytes, size_t size) +{ + if (!validatePublicationTicket(bytes, size)) return false; + platform::esp::arduino_common::storage::SdRuntimeFile file; + if (!file.open(kPublicationTicketNew, "w") || file.write(bytes, size) != size || !file.flush()) return false; + file.close(); + return chat::storage::v2::replaceFileAtomically(kPublicationTicketNew, kPublicationTicketPath, kPublicationTicketBackup); +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_config_upload.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_config_upload.h new file mode 100644 index 00000000..28e312de --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_config_upload.h @@ -0,0 +1,415 @@ +#pragma once + +#include "app/app_facade_access.h" +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/hostlink_codec.h" +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" +#include "platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/mbedtls_sha256_compat.h" + +namespace hostlink::management::web_config +{ +using platform::esp::arduino_common::storage::SdRuntimeFile; +constexpr const char* kMetaPath = "/trailmate/config.web.meta"; +constexpr const char* kMetaNew = "/trailmate/config.web.meta.new"; +constexpr const char* kMetaBackup = "/trailmate/config.web.meta.bak"; +constexpr size_t kMetaBytes = 111; +struct Metadata +{ + uint8_t owner[16]{}, id[16]{}, hash[32]{}, base_hash[32]{}; + uint32_t total = 0, offset = 0; + uint8_t phase = 0; // config: 0/1/2; draft: 0 receive,1 seal,2 bind,3 intent,4 committed + uint8_t kind = 1; // 1 config, 2 draft, 5 unsigned record, 6 SignedCache confirmation +}; +static_assert(sizeof(Metadata) == 108, "Shared transfer metadata grew its RAM budget"); +inline const char* candidate_path(uint8_t kind) +{ + if (kind == 5) return "/trailmate/cache.sign.upload"; + if (kind == 6) return "/trailmate/cache.confirm.upload"; + return kind == 2 ? platform::esp::arduino_common::geocaching::browse_runtime::kWebDraftCandidatePath + : app::sd_tms::webWorkingCandidatePath(); +} +inline uint32_t transfer_limit(uint8_t kind) +{ + return kind == 1 ? 32768 : kind == 6 ? 4166 + : (kind == 2 || kind == 5) ? 4096 + : 0; +} +inline uint32_t u32(const uint8_t* p) +{ + return uint32_t(p[0]) | uint32_t(p[1]) << 8 | uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; +} +inline void put32(uint8_t* p, uint32_t value) +{ + for (unsigned i = 0; i < 4; ++i) p[i] = static_cast(value >> (8 * i)); +} +inline bool load(Metadata& meta, uint8_t* scratch) +{ + if (!chat::storage::v2::recoverAtomicFile(kMetaPath, kMetaNew, kMetaBackup)) return false; + SdRuntimeFile file; + if (!file.open(kMetaPath, "r") || file.size() != kMetaBytes || file.read(scratch, kMetaBytes) != static_cast(kMetaBytes) || + std::memcmp(scratch, "TMU1", 4) || crc16_ccitt(scratch, 109) != (uint16_t(scratch[109]) | uint16_t(scratch[110]) << 8)) return false; + std::memcpy(meta.owner, scratch + 4, 16); + std::memcpy(meta.id, scratch + 20, 16); + std::memcpy(meta.hash, scratch + 36, 32); + std::memcpy(meta.base_hash, scratch + 77, 32); + meta.total = u32(scratch + 68); + meta.offset = u32(scratch + 72); + const auto tagged_phase = scratch[76]; + if (tagged_phase <= 2) meta.kind = 1; + else if (tagged_phase >= 0x80 && tagged_phase <= 0x84) meta.kind = 2; + else if (tagged_phase >= 0x40 && tagged_phase <= 0x42) meta.kind = 5; + else if (tagged_phase >= 0xc0 && tagged_phase <= 0xc2) meta.kind = 6; + else return false; + meta.phase = tagged_phase & 0x3f; + return meta.total && meta.total <= transfer_limit(meta.kind) && meta.offset <= meta.total && + meta.phase <= (meta.kind == 2 ? 4 : 2) && (!meta.phase || meta.offset == meta.total); +} +inline bool save(const Metadata& meta, uint8_t* scratch) +{ + if (!scratch || !transfer_limit(meta.kind) || !meta.total || + meta.total > transfer_limit(meta.kind) || meta.offset > meta.total || + meta.phase > (meta.kind == 2 ? 4 : 2) || (meta.phase && meta.offset != meta.total)) return false; + std::memcpy(scratch, "TMU1", 4); + std::memcpy(scratch + 4, meta.owner, 16); + std::memcpy(scratch + 20, meta.id, 16); + std::memcpy(scratch + 36, meta.hash, 32); + put32(scratch + 68, meta.total); + put32(scratch + 72, meta.offset); + scratch[76] = meta.phase | (meta.kind == 2 ? 0x80 : meta.kind == 5 ? 0x40 + : meta.kind == 6 ? 0xc0 + : 0); + std::memcpy(scratch + 77, meta.base_hash, 32); + const uint16_t crc = crc16_ccitt(scratch, 109); + scratch[109] = static_cast(crc); + scratch[110] = static_cast(crc >> 8); + SdRuntimeFile file; + if (!file.open(kMetaNew, "w") || file.write(scratch, kMetaBytes) != kMetaBytes || !file.flush()) return false; + file.close(); + return chat::storage::v2::replaceFileAtomically(kMetaNew, kMetaPath, kMetaBackup); +} +inline bool hash_file(const char* path, uint8_t* scratch, uint8_t hash[32], uint32_t required = 0) +{ + SdRuntimeFile file; + if (!file.open(path, "r")) return false; + const uint64_t size = file.size(); + if (!size || size > 32768 || (required && size != required)) return false; + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + bool ok = platform::esp::common::crypto::sha256_starts(&context, 0) == 0; + uint64_t offset = 0; + while (ok && offset < size) + { + const size_t count = static_cast(size - offset > 128 ? 128 : size - offset); + ok = file.read(scratch, count) == static_cast(count) && + platform::esp::common::crypto::sha256_update(&context, scratch, count) == 0; + offset += count; + } + ok = ok && platform::esp::common::crypto::sha256_finish(&context, hash) == 0; + mbedtls_sha256_free(&context); + return ok; +} +inline bool reply(const Metadata& meta, uint8_t* scratch, size_t capacity, size_t& size) +{ + geocaching::protocol::CmpWriter writer(scratch, capacity); + const bool ok = writer.array(4) && writer.binary({meta.id, 16}) && writer.unsignedInteger(320) && + writer.unsignedInteger(meta.offset) && writer.unsignedInteger(meta.phase ? 1 : 0); + size = writer.size(); + return ok; +} + +// A lost draft-put acknowledgement must not permanently occupy the one slot. +// Reconcile only a committed row belonging to the unchanged device author; +// pending/failed intents and candidate bytes remain untouched. +inline Status reconcileCommittedDraft(Metadata& meta, const uint8_t* author, uint32_t generation, uint8_t* scratch) +{ + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + if (meta.kind != 2 || meta.phase != 3 || !author || !scratch) return Status::InvalidField; + if (!generation || generation != u32(meta.base_hash + 24)) return Status::StaleIdentity; + uint64_t expected = 0; + for (unsigned i = 0; i < 8; ++i) expected |= static_cast(meta.base_hash[16 + i]) << (8 * i); + if (expected == UINT64_MAX) return Status::InvalidField; + const auto result = runtime::checkWebDraftEditOwner(meta.base_hash, expected, author, meta.hash); + if (result == runtime::WebReadResult::AlreadySaved) + { + meta.phase = 4; + return save(meta, scratch) ? Status::Ok : Status::StorageUnavailable; + } + if (result == runtime::WebReadResult::Pending || result == runtime::WebReadResult::Ready) return Status::Busy; + if (result == runtime::WebReadResult::Forbidden) return Status::Unauthorized; + if (result == runtime::WebReadResult::Unavailable) return Status::StorageUnavailable; + return Status::Conflict; +} + +// Opt-in operation 38 [5,null]. The generation below is the current proved +// author generation, not an inferred generation of legacy upload metadata. +// Returns only sealed unsigned input belonging to that public author. Does not +// sign, abort, advance a phase, or return candidate/private-key bytes. +// Caller holds withSignatureInputRecovery throughout. Never acquire another +// runtime mutex from inside that non-recursive storage gate. +inline Status listSharedSignatureUpload(const EnvelopeView& request, const uint8_t* author, uint32_t generation, + uint32_t media, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!request.session || !author || !generation || !scratch || capacity < 448 || request.body_size != 3 || + request.body[0] != 0x92 || (request.body[1] != 5 && request.body[1] != 6) || request.body[2] != 0xc0) return Status::InvalidField; + const uint8_t kind = request.body[1]; + Metadata meta; + const bool loaded = load(meta, scratch); + if (!loaded && (platform::esp::arduino_common::storage::sd_exists(kMetaPath) || + platform::esp::arduino_common::storage::sd_exists(kMetaNew) || + platform::esp::arduino_common::storage::sd_exists(kMetaBackup))) return Status::StorageUnavailable; + bool found = loaded && meta.kind == kind && meta.phase == 1; + if (found) + { + SdRuntimeFile file; + if (!file.open(candidate_path(kind), "r") || file.size() != meta.total) return Status::StorageUnavailable; + const auto count = meta.total < 128 ? meta.total : 128; + if (file.read(scratch, count) != static_cast(count)) return Status::StorageUnavailable; + file.close(); + size_t start = 0; + if (kind == 6) + { + // SignedCache is [bin(unsigned_record),bin(signature64)]. Inspect + // only the small public record prefix; never return either body. + if (count < 4 || scratch[0] != 0x92 || (scratch[1] != 0xc4 && scratch[1] != 0xc5)) return Status::Conflict; + const uint32_t record_size = scratch[1] == 0xc4 ? scratch[2] : (uint32_t(scratch[2]) << 8 | scratch[3]); + start = scratch[1] == 0xc4 ? 3 : 4; + if (!record_size || record_size > 4096 || (start == 4 && record_size < 256) || start + record_size + 66 != meta.total) return Status::Conflict; + } + geocaching::protocol::CmpReader prefix({scratch + start, count - start}); + size_t fields = 0; + uint64_t version = 0; + geocaching::ByteView key, nonce; + if (!prefix.array(fields, 16) || fields != 16 || !prefix.unsignedInteger(version) || version != 1 || + !prefix.binary(key, 64) || key.size != 64 || !prefix.binary(nonce, 16) || nonce.size != 16) return Status::Conflict; + found = !std::memcmp(author, key.data, 64); + if (found) + { + std::memcpy(scratch + 256, nonce.data, 16); + if (!hash_file(candidate_path(kind), scratch, scratch + 288, meta.total)) return Status::StorageUnavailable; + if (std::memcmp(meta.hash, scratch + 288, 32)) return Status::HashMismatch; + } + } + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(3) || !writer.unsignedInteger(media) || !writer.nil()) return Status::Internal; + if (found) + { + if (!writer.array(9) || !writer.binary({meta.id, 16}) || !writer.unsignedInteger(kind) || + !writer.unsignedInteger(meta.total) || !writer.binary({meta.hash, 32}) || !writer.unsignedInteger(generation) || + !writer.unsignedInteger(meta.phase) || !writer.unsignedInteger(meta.offset) || + !writer.binary({meta.owner, 16}) || !writer.binary({scratch + 256, 16})) return Status::Internal; + } + else if (!writer.nil()) return Status::Internal; + written = writer.size(); + return Status::Ok; +} + +// A durable confirmation releases the single upload slot before its ACK. Keep +// the candidate for exact replay after a lost reply/reboot; new IDs may reuse +// terminal metadata. Response is saved in the existing command arena. +inline Status finishConfirmedUpload(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + if (!request.session || !output || capacity < 448 || !written || written > 160) return Status::Internal; + const auto reply_size = written; + written = 0; + auto* reply = output + capacity - 160; + std::memmove(reply, output, reply_size); + geocaching::protocol::CmpReader reader({reply, reply_size}); + size_t fields = 0; + uint64_t generation = 0, phase = 0, revision = 0, durable = 0; + geocaching::ByteView id, hash, cache, version_hash; + if (!reader.array(fields, 8) || fields != 8 || !reader.binary(id, 16) || id.size != 16 || + !reader.binary(hash, 32) || hash.size != 32 || !reader.unsignedInteger(generation) || !generation || generation > UINT32_MAX || + !reader.unsignedInteger(phase) || phase > 2) return Status::Internal; + if (phase == 1) + { + if (!reader.binary(cache, 32) || cache.size != 32 || !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX || + !reader.binary(version_hash, 32) || version_hash.size != 32 || !reader.unsignedInteger(durable) || durable != 1 || !reader.finished()) return Status::Internal; + Metadata meta; + if (!load(meta, output)) return Status::StorageUnavailable; + if (meta.kind != 6 || !meta.phase || std::memcmp(meta.owner, request.session, 16) || + std::memcmp(meta.id, id.data, 16) || std::memcmp(meta.hash, hash.data, 32)) return Status::Conflict; + if (meta.phase != 2) + { + meta.phase = 2; + if (!save(meta, output)) return Status::StorageUnavailable; + } + } + std::memmove(output, reply, reply_size); + written = reply_size; + return Status::Ok; +} + +// All persistent transfer state lives on device storage; the command payload is +// reused as scratch. No static payload arena, task, queue or heap document. +inline Status process(const EnvelopeView& request, uint8_t* scratch, size_t capacity, size_t& body_size) +{ + if (capacity < 384) return Status::Internal; + using namespace platform::esp::arduino_common::storage; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields; + geocaching::ByteView id, data, expected; + Metadata requested; + std::memcpy(requested.owner, request.session, 16); + uint64_t offset = 0, kind = 0, total = 0; + const auto op = static_cast(request.code); + if (op == Operation::TransferBegin) + { + if (!reader.array(fields, 4) || fields != 4 || !reader.binary(id, 16) || id.size != 16 || + !reader.unsignedInteger(kind) || kind > UINT8_MAX || !transfer_limit(static_cast(kind)) || !reader.unsignedInteger(total) || !total || + total > transfer_limit(static_cast(kind)) || + !reader.binary(expected, 32) || expected.size != 32 || !reader.finished()) return Status::InvalidField; + std::memcpy(requested.id, id.data, 16); + std::memcpy(requested.hash, expected.data, 32); + requested.total = static_cast(total); + requested.kind = static_cast(kind); + } + else + { + const size_t count = op == Operation::TransferChunk ? 3 : op == Operation::ConfigCommit ? 2 + : 1; + if (!reader.array(fields, count) || fields != count || !reader.binary(id, 16) || id.size != 16) return Status::InvalidField; + std::memcpy(requested.id, id.data, 16); + if (op == Operation::TransferChunk && (!reader.unsignedInteger(offset) || offset > UINT32_MAX || + !reader.binary(data, 320) || !data.size)) return Status::InvalidField; + if (op == Operation::ConfigCommit && (!reader.binary(expected, 32) || expected.size != 32)) return Status::InvalidField; + if (expected.data) std::memcpy(requested.base_hash, expected.data, 32); + if (!reader.finished()) return Status::InvalidField; + } + // CHUNK bytes may overlap scratch. Move them to the far end before journal + // reads overwrite the beginning; 128-byte journal/hash scratch stays clear. + uint8_t* chunk = scratch + capacity - data.size; + if (data.size) std::memmove(chunk, data.data, data.size); + Metadata current; + const bool exists = load(current, scratch); + if (op == Operation::TransferBegin) + { + if (exists && current.kind == 2 && current.phase == 3 && std::memcmp(current.id, requested.id, 16)) + { + if (capacity < 448) return Status::Internal; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t generation = 0; + const auto identity = adapter->readDeviceAuthorKey(scratch + 384, generation); + if (identity == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (identity != chat::DeviceAuthorResult::Ok) return Status::Busy; + const auto reconciled = reconcileCommittedDraft(current, scratch + 384, generation, scratch); + if (reconciled != Status::Ok) return reconciled; + } + if (exists && (current.phase < (current.kind == 2 ? 4 : 2) || !std::memcmp(current.id, requested.id, 16))) + { + if (std::memcmp(current.owner, requested.owner, 16)) return Status::Busy; + if (std::memcmp(current.id, requested.id, 16) || current.kind != requested.kind || current.total != requested.total || + std::memcmp(current.hash, requested.hash, 32)) return Status::Conflict; + } + else + { + if (app::sd_tms::webConfigurationRestartPending()) return Status::Busy; + if (sd_exists(kMetaPath) && !exists) return Status::StorageUnavailable; + if (requested.kind == 1 && (app::sd_tms::syncPendingWorkingConfig() != app::sd_tms::WorkingConfigSyncResult::Synchronized || + !hash_file("/trailmate/config.tms", scratch, requested.base_hash))) return Status::StorageUnavailable; + SdRuntimeFile file; + if (!file.open(candidate_path(requested.kind), "w") || !file.flush()) return Status::StorageUnavailable; + file.close(); + current = requested; + if (!save(current, scratch)) return Status::StorageUnavailable; + } + } + else + { + if (!exists) return Status::Expired; + if (std::memcmp(current.owner, requested.owner, 16)) return Status::Unauthorized; + if (std::memcmp(current.id, requested.id, 16)) return Status::Conflict; + if (current.kind != 1 && (op == Operation::ConfigValidate || op == Operation::ConfigCommit)) return Status::InvalidField; + if (op == Operation::TransferAbort) + { + if (current.phase >= (current.kind == 2 ? 3 : 2)) return Status::Conflict; + if (current.kind == 2 && !platform::esp::arduino_common::geocaching::browse_runtime::releaseWebDraftSave()) return Status::Busy; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (current.kind == 5 || current.kind == 6) + { + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + const auto released = current.kind == 5 ? runtime::releaseWebSignature(request.session) : runtime::releaseWebConfirmation(request.session); + if (released == runtime::WebReadResult::Pending) return Status::Busy; + if (released == runtime::WebReadResult::Forbidden) return Status::Unauthorized; + if (released != runtime::WebReadResult::Ready && released != runtime::WebReadResult::NotFound) return Status::Internal; + } +#endif + if ((sd_exists(candidate_path(current.kind)) && !sd_remove(candidate_path(current.kind))) || + (sd_exists(kMetaNew) && !sd_remove(kMetaNew)) || + (sd_exists(kMetaBackup) && !sd_remove(kMetaBackup)) || + !sd_remove(kMetaPath)) return Status::StorageUnavailable; + scratch[0] = 0x90; + body_size = 1; + return Status::Ok; + } + if (op == Operation::TransferChunk) + { + if (offset > current.total || data.size > current.total - offset || offset > current.offset) return Status::Conflict; + SdRuntimeFile file; + if (!file.open(candidate_path(current.kind), "r+") || !file.seek(offset)) return Status::StorageUnavailable; + if (offset < current.offset) + { + if (data.size > current.offset - offset) return Status::Conflict; + for (size_t i = 0; i < data.size; ++i) + if (file.read_byte() != chunk[i]) return Status::Conflict; + } + else + { + if (current.phase) return Status::Conflict; + if (file.write(chunk, data.size) != data.size || !file.flush()) return Status::StorageUnavailable; + file.close(); + current.offset += static_cast(data.size); + if (!save(current, scratch)) return Status::StorageUnavailable; + } + } + else if (op == Operation::TransferFinish || op == Operation::ConfigValidate) + { + uint8_t hash[32]; + if (current.offset != current.total) return Status::Conflict; + if (!current.phase) + { + if (!hash_file(candidate_path(current.kind), scratch, hash, current.total)) return Status::StorageUnavailable; + if (std::memcmp(current.hash, hash, 32)) return Status::HashMismatch; + if (current.kind == 1 && !app::sd_tms::validateWebWorkingCandidate()) return Status::InvalidField; + current.phase = 1; + if (!save(current, scratch)) return Status::StorageUnavailable; + } + } + else if (op == Operation::ConfigCommit) + { + uint8_t hash[32]; + if (!current.phase || std::memcmp(current.base_hash, requested.base_hash, 32)) return Status::Conflict; + if (current.phase != 2) + { + // A retry after power loss between promotion and journal update + // recognises the exact already-promoted file, not a new save. + if (!hash_file("/trailmate/config.tms", scratch, hash)) return Status::StorageUnavailable; + if (std::memcmp(hash, current.hash, 32)) + { + if (app::sd_tms::syncPendingWorkingConfig() != app::sd_tms::WorkingConfigSyncResult::Synchronized || + !hash_file("/trailmate/config.tms", scratch, hash)) return Status::StorageUnavailable; + if (std::memcmp(hash, current.base_hash, 32)) return Status::Conflict; + if (!hash_file(app::sd_tms::webWorkingCandidatePath(), scratch, hash, current.total) || + std::memcmp(hash, current.hash, 32)) return Status::HashMismatch; + if (!app::sd_tms::commitWebWorkingCandidate()) return Status::StorageUnavailable; + } + current.phase = 2; + if (!save(current, scratch)) return Status::StorageUnavailable; + } + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(3) || !writer.binary({current.id, 16}) || !writer.binary({current.hash, 32}) || + !writer.unsignedInteger(app::sd_tms::webConfigurationRestartPending() ? 1 : 0)) return Status::Internal; + body_size = writer.size(); + return Status::Ok; + } + else if (op != Operation::TransferStatus) return Status::Unsupported; + } + return reply(current, scratch, capacity, body_size) ? Status::Ok : Status::Internal; +} +} // namespace hostlink::management::web_config diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_confirmation.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_confirmation.h new file mode 100644 index 00000000..f6fa1cb6 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_confirmation.h @@ -0,0 +1,68 @@ +#pragma once +#include "geocaching/storage/publication_confirmation.h" +#include "hostlink/geocache_confirmation_request.h" +#include "platform/esp/arduino_common/hostlink/web_config_upload.h" +#include "platform/esp/arduino_common/hostlink/web_publication_prepare.h" +#include "platform/memory/psram_ptr.h" +namespace hostlink::management +{ +inline Status cache_confirmation(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || capacity < 448) return Status::Internal; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + if (request.code == static_cast(Operation::CacheConfirmBegin)) + { + GeocacheConfirmationRequest decoded; + const auto parsed = parseGeocacheConfirmationRequest(request.body, request.body_size, decoded); + if (parsed != Status::Ok) return parsed; + // Preserve proof and bindings before shared response scratch is reused. + auto proof = ::platform::memory::PsramPtr>(::platform::memory::createPsram>()); + size_t proof_size = 0; + if (!proof) return Status::StorageUnavailable; + ::geocaching::protocol::CmpReader raw(decoded.proof); + size_t count = 0; + uint64_t version = 0; + ::geocaching::ByteView directory, epoch; + if (!raw.array(count, 8) || count != 8 || !raw.unsignedInteger(version) || version != 1 || !raw.binary(directory, 16) || directory.size != 16 || + !raw.binary(epoch, 16) || epoch.size != 16 || std::memcmp(epoch.data, decoded.epoch.data, 16)) return Status::InvalidField; + if (!::geocaching::storage::encodePublicationConfirmation({decoded.proof, decoded.signature, decoded.directory_public}, proof->data(), proof->size(), proof_size)) return Status::InvalidField; + std::memmove(output + 320, decoded.transfer.data, 16); + std::memmove(output + 336, decoded.hash.data, 32); + const auto expected_generation = decoded.author_generation; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + uint32_t actual = 0; + if (!adapter) return Status::Busy; + const auto identity = adapter->readDeviceAuthorKey(output + 384, actual); + if (identity != chat::DeviceAuthorResult::Ok) return identity == chat::DeviceAuthorResult::StorageError ? Status::StorageUnavailable : Status::Busy; + if (actual != expected_generation) return Status::StaleIdentity; + web_config::Metadata metadata; + if (!web_config::load(metadata, output)) return Status::Expired; + if (std::memcmp(metadata.owner, request.session, 16)) return Status::Unauthorized; + if (metadata.kind != 6 || !metadata.phase || std::memcmp(metadata.id, output + 320, 16) || std::memcmp(metadata.hash, output + 336, 32)) return Status::Conflict; + const auto queued = runtime::queueWebConfirmation(output + 320, request.session, output + 336, metadata.total, output + 384, actual, proof->data(), proof_size); + if (queued != runtime::WebReadResult::Ready) return publication_result(queued); + const auto result = runtime::readWebConfirmation(output + 320, request.session, output, 320, written); + return result == runtime::WebReadResult::Ready ? web_config::finishConfirmedUpload(request, output, capacity, written) : publication_result(result); + } + ::geocaching::protocol::CmpReader body({request.body, request.body_size}); + size_t count = 0; + ::geocaching::ByteView transfer; + if (!body.array(count, 1) || count != 1 || !body.binary(transfer, 16) || transfer.size != 16 || !body.finished()) return Status::InvalidField; + std::memmove(output + 320, transfer.data, 16); + if (request.code == static_cast(Operation::CacheConfirmStatus)) + { + const auto result = runtime::readWebConfirmation(output + 320, request.session, output, 320, written); + return result == runtime::WebReadResult::Ready ? web_config::finishConfirmedUpload(request, output, capacity, written) : publication_result(result); + } + if (request.code == static_cast(Operation::CacheConfirmRelease)) + { + const auto released = runtime::releaseWebConfirmation(request.session, output + 320); + if (released != runtime::WebReadResult::Ready && released != runtime::WebReadResult::NotFound) return publication_result(released); + output[0] = 0x90; + written = 1; + return Status::Ok; + } + return Status::Unsupported; +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_signature.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_signature.h new file mode 100644 index 00000000..1c130e27 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_geocache_signature.h @@ -0,0 +1,67 @@ +#pragma once +#include "hostlink/geocache_sign_request.h" +#include "platform/esp/arduino_common/hostlink/web_config_upload.h" +#include "platform/esp/arduino_common/hostlink/web_publication_prepare.h" + +namespace hostlink::management +{ +inline Status cache_signature(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || capacity < 448) return Status::Internal; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + uint32_t offset = 0; + uint16_t maximum = 0; + if (request.code == static_cast(Operation::CacheSignBegin)) + { + GeocacheSignRequest decoded; + const auto parsed = parseGeocacheSignRequest(request.body, request.body_size, decoded); + if (parsed != Status::Ok) return parsed; + uint8_t draft[16]; + const bool bound = decoded.draft.size == 16; + const auto draft_generation = decoded.draft_generation; + if (bound) std::memcpy(draft, decoded.draft.data, 16); + // Copy borrowed bindings before metadata/hash scratch overwrites input. + std::memmove(output + 320, decoded.transfer.data, 16); + std::memmove(output + 336, decoded.hash.data, 32); + std::memmove(output + 368, decoded.operation.data, 16); + const auto total = decoded.bytes, expected_generation = decoded.author_generation; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + uint32_t actual = 0; + if (!adapter) return Status::Busy; + const auto identity = adapter->readDeviceAuthorKey(output + 384, actual); + if (identity != chat::DeviceAuthorResult::Ok) return identity == chat::DeviceAuthorResult::StorageError ? Status::StorageUnavailable : Status::Busy; + if (actual != expected_generation) return Status::StaleIdentity; + web_config::Metadata metadata; + if (!web_config::load(metadata, output)) return Status::Expired; + if (std::memcmp(metadata.owner, request.session, 16)) return Status::Unauthorized; + if (metadata.kind != 5 || metadata.phase != 1 || metadata.total != total || + std::memcmp(metadata.id, output + 320, 16) || std::memcmp(metadata.hash, output + 336, 32)) return Status::Conflict; + const auto queued = runtime::queueWebSignature(output + 368, request.session, output + 320, output + 336, total, output + 384, actual, + bound ? draft : nullptr, draft_generation); + if (queued != runtime::WebReadResult::Ready) return publication_result(queued); + return publication_result(runtime::readWebSignature(output + 368, request.session, true, 0, 0, output, 320, written)); + } + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t position = 0, limit = 0; + geocaching::ByteView operation; + const bool read = request.code == static_cast(Operation::CacheSignRead); + if (!reader.array(fields, read ? 3 : 1) || fields != (read ? 3 : 1) || !reader.binary(operation, 16) || operation.size != 16 || + (read && (!reader.unsignedInteger(position) || position > 4165 || !reader.unsignedInteger(limit) || !limit || limit > 320)) || !reader.finished()) return Status::InvalidField; + std::memmove(output + 368, operation.data, 16); + offset = static_cast(position); + maximum = static_cast(limit); + if (request.code == static_cast(Operation::CacheSignRelease)) + { + const auto released = runtime::releaseWebSignature(request.session, output + 368); + if (released != runtime::WebReadResult::Ready && released != runtime::WebReadResult::NotFound) return publication_result(released); + output[0] = 0x90; + written = 1; + return Status::Ok; + } + if (request.code != static_cast(Operation::CacheSignStatus) && !read) return Status::Unsupported; + // The runtime captures operation before writing the response arena. + return publication_result(runtime::readWebSignature(output + 368, request.session, !read, offset, maximum, output, capacity, written)); +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_commit.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_commit.h new file mode 100644 index 00000000..65825b08 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_commit.h @@ -0,0 +1,138 @@ +#pragma once +#include "platform/esp/arduino_common/hostlink/web_publication_prepare.h" + +namespace hostlink::management +{ +// [prepare/operation_id16, committed_draft_hash32, author_generation32]. +// The prepared ID is the durable operation ID; never generate another on retry. +inline Status cache_commit(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (capacity < 471) return Status::Internal; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + namespace sd = platform::esp::arduino_common::storage; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t expected_generation = 0; + geocaching::ByteView operation, hash; + if (!reader.array(fields, 3) || fields != 3 || !reader.binary(operation, 16) || operation.size != 16 || + !reader.binary(hash, 32) || hash.size != 32 || !reader.unsignedInteger(expected_generation) || + !expected_generation || expected_generation > UINT32_MAX || !reader.finished()) return Status::InvalidField; + uint8_t retained[48], boot_id[16], bits = 0; + std::memcpy(retained, operation.data, 16); + std::memcpy(retained + 16, hash.data, 32); + for (unsigned i = 0; i < 16; ++i) bits |= retained[i]; + if (!bits) return Status::InvalidField; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t actual = 0; + const auto identity = adapter->readDeviceAuthorKey(output + 384, actual); + if (identity == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (identity != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != expected_generation) return Status::StaleIdentity; + uint64_t clock = 0; + if (!runtime::publicationContext(boot_id, clock)) return Status::Busy; + if (!loadPublicationTicket(output, capacity)) + return sd::sd_exists(kPublicationTicketPath) ? Status::StorageUnavailable : Status::Expired; + const bool prepared = output[4] == static_cast(PublicationTicketPhase::Prepared); + const auto match = matchPublicationTicket(output, kPublicationTicketBytes, request.session, retained, + prepared ? boot_id : output + 37, output + 384, actual, clock, prepared); + if (match != Status::Ok) return match; + if (std::memcmp(output + 177, retained + 16, 32)) return Status::Conflict; + if (output[4] == static_cast(PublicationTicketPhase::Checking)) return Status::Conflict; + bool existing = false; + if (prepared) + { + // A new ticket must not reuse an ID from any retained operation, even + // if it is no longer the single current in-memory publication. + std::memmove(output + 240, output, kPublicationTicketBytes); + const auto* ticket = output + 240; + auto prior = runtime::readWebPublicationJob(retained, ticket + 81, output, 240, written); + if (prior == runtime::WebReadResult::NotFound) + prior = runtime::readWebPublicationHistory(retained, ticket + 81, output, 240, written); + if (prior == runtime::WebReadResult::Ready) return Status::Conflict; + if (prior != runtime::WebReadResult::NotFound) return publication_result(prior); + std::memmove(output, ticket, kPublicationTicketBytes); + } + if (!prepared) + { + // Preserve the private ticket while the queried task uses the reply arena. + std::memmove(output + 240, output, kPublicationTicketBytes); + auto* ticket = output + 240; + auto result = runtime::readWebPublicationJob(retained, ticket + 81, output, 240, written); + if (result == runtime::WebReadResult::NotFound) + result = runtime::readWebPublicationHistory(retained, ticket + 81, output, 240, written); + if (result == runtime::WebReadResult::Ready) + { + if (ticket[4] == static_cast(PublicationTicketPhase::Intent) && + (!advancePublicationTicket(ticket, kPublicationTicketBytes, PublicationTicketPhase::Accepted) || + !savePublicationTicket(ticket, kPublicationTicketBytes))) return Status::StorageUnavailable; + } + else + { + if (result != runtime::WebReadResult::NotFound) return publication_result(result); + // Accepted-but-missing or old-boot evidence is unresolved. Do not + // infer absence of signing/transport or create a replacement task. + uint64_t created = 0; + uint32_t lifetime = 0; + for (unsigned i = 0; i < 8; ++i) created |= uint64_t(ticket[217 + i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) lifetime |= uint32_t(ticket[225 + i]) << (8 * i); + if (ticket[4] != static_cast(PublicationTicketPhase::Intent) || std::memcmp(ticket + 37, boot_id, 16) || + clock < created || clock - created >= lifetime) return Status::Busy; + std::memmove(output, ticket, kPublicationTicketBytes); + } + if (result == runtime::WebReadResult::Ready) + { + // The record is staged already; jump to the shared receipt encoding. + existing = true; + } + } + // A staged Accepted ticket means the existing operation supplied evidence. + if (!existing) + { + uint64_t generation = 0; + for (unsigned i = 0; i < 8; ++i) generation |= uint64_t(output[69 + i]) << (8 * i); + const auto binding = runtime::readWebPublicationBinding(output + 53, generation, output + 145, output + 81, output + 240, 56); + if (binding != runtime::WebReadResult::Ready) return publication_result(binding); + if (std::memcmp(output + 177, output + 240, 32) || std::memcmp(output + 161, output + 272, 16) || + std::memcmp(output + 209, output + 288, 8)) return Status::Conflict; + const auto refreshed_identity = adapter->readDeviceAuthorKey(output + 384, actual); + if (refreshed_identity == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (refreshed_identity != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != expected_generation || std::memcmp(output + 81, output + 384, 64)) return Status::StaleIdentity; + if (!runtime::publicationContext(boot_id, clock)) return Status::Busy; + if (prepared) + { + const auto refreshed = matchPublicationTicket(output, kPublicationTicketBytes, request.session, retained, boot_id, + output + 384, actual, clock, true); + if (refreshed != Status::Ok) return refreshed; + } + else + { + uint64_t created = 0; + uint32_t lifetime = 0; + for (unsigned i = 0; i < 8; ++i) created |= uint64_t(output[217 + i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) lifetime |= uint32_t(output[225 + i]) << (8 * i); + if (std::memcmp(output + 37, boot_id, 16) || clock < created || clock - created >= lifetime) return Status::Busy; + } + const uint32_t revision = uint32_t(output[213]) | uint32_t(output[214]) << 8 | uint32_t(output[215]) << 16 | uint32_t(output[216]) << 24; + if (!advancePublicationTicket(output, kPublicationTicketBytes, PublicationTicketPhase::Intent) || + !savePublicationTicket(output, kPublicationTicketBytes)) return Status::StorageUnavailable; + if (!runtime::queueWebDraftPublication(output + 53, generation, output + 145, output + 81, revision, output + 240, retained)) + return Status::Busy; + if (!advancePublicationTicket(output, kPublicationTicketBytes, PublicationTicketPhase::Accepted) || + !savePublicationTicket(output, kPublicationTicketBytes)) return Status::StorageUnavailable; + std::memmove(output + 240, output, kPublicationTicketBytes); + } + const auto* ticket = output + 240; + uint64_t generation = 0; + for (unsigned i = 0; i < 8; ++i) generation |= uint64_t(ticket[69 + i]) << (8 * i); + const uint32_t revision = uint32_t(ticket[213]) | uint32_t(ticket[214]) << 8 | uint32_t(ticket[215]) << 16 | uint32_t(ticket[216]) << 24; + geocaching::protocol::CmpWriter writer(output, 240); + if (!writer.array(7) || !writer.binary({ticket + 21, 16}) || !writer.binary({ticket + 53, 16}) || + !writer.unsignedInteger(generation) || !writer.binary({ticket + 145, 16}) || !writer.binary({ticket + 177, 32}) || + !writer.unsignedInteger(revision) || !writer.unsignedInteger(1)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_prepare.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_prepare.h new file mode 100644 index 00000000..34286ed7 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/hostlink/web_publication_prepare.h @@ -0,0 +1,198 @@ +#pragma once +#include "app/app_facade_access.h" +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" +#include "platform/esp/arduino_common/hostlink/publication_ticket_store.h" +#include "platform/esp/common/mbedtls_sha256_compat.h" + +namespace hostlink::management +{ +inline bool terminal_publication_result(geocaching::ByteView reply, const uint8_t operation[16]) +{ + geocaching::protocol::CmpReader reader(reply); + size_t fields = 0; + uint64_t phase = 0, revision = 0, durable = 0, transported = 0; + geocaching::ByteView id, cache, hash; + if (!reader.array(fields, 8) || fields != 8 || !reader.binary(id, 16) || id.size != 16 || std::memcmp(id.data, operation, 16) || + !reader.unsignedInteger(phase) || (phase != 3 && phase != 4) || !reader.binary(cache, 32) || cache.size != 32 || + !reader.binary(hash, 32) || hash.size != 32 || !reader.unsignedInteger(revision) || !revision || revision > UINT32_MAX || + !reader.unsignedInteger(durable) || durable != 1) return false; + auto nullable = reader; + if (nullable.nil()) reader = nullable; + else if (!reader.unsignedInteger(transported) || transported != 1) return false; + return (phase != 3 || transported == 1) && reader.nil() && reader.finished(); +} +inline Status publication_result(platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult result) +{ + using Result = platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult; + switch (result) + { + case Result::Ready: + return Status::Ok; + case Result::Pending: + return Status::Busy; + case Result::Invalid: + return Status::InvalidField; + case Result::Forbidden: + return Status::Unauthorized; + case Result::StaleIdentity: + return Status::StaleIdentity; + case Result::Unavailable: + return Status::StorageUnavailable; + case Result::HashMismatch: + return Status::HashMismatch; + default: + return Status::Conflict; + } +} +inline bool derive_prepare_cache_id(const uint8_t author[64], const uint8_t draft[16], uint8_t output[32]) +{ + constexpr char domain[] = "trailmate.geocache/id/v1"; + mbedtls_sha256_context context; + mbedtls_sha256_init(&context); + namespace crypto = platform::esp::common::crypto; + const bool ok = crypto::sha256_starts(&context, 0) == 0 && + crypto::sha256_update(&context, reinterpret_cast(domain), sizeof(domain)) == 0 && + crypto::sha256_update(&context, author, 64) == 0 && crypto::sha256_update(&context, draft, 16) == 0 && + crypto::sha256_finish(&context, output) == 0; + mbedtls_sha256_free(&context); + return ok; +} +// [draft16, generation64, directory16, author_generation32, prepare_id16]. +// Checking is persisted before network polling; COMMIT must reject it. +inline Status cache_prepare(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (capacity < 471) return Status::Internal; + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + namespace sd = platform::esp::arduino_common::storage; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + geocaching::ByteView draft, directory, token; + uint64_t generation = 0, expected_author_generation = 0; + if (!reader.array(fields, 5) || fields != 5 || !reader.binary(draft, 16) || draft.size != 16 || + !reader.unsignedInteger(generation) || !generation || !reader.binary(directory, 16) || directory.size != 16 || + !reader.unsignedInteger(expected_author_generation) || !expected_author_generation || expected_author_generation > UINT32_MAX || + !reader.binary(token, 16) || token.size != 16 || !reader.finished()) return Status::InvalidField; + uint8_t ids[48], boot_id[16]; + std::memcpy(ids, draft.data, 16); + std::memcpy(ids + 16, directory.data, 16); + std::memcpy(ids + 32, token.data, 16); + uint8_t token_bits = 0; + for (unsigned i = 32; i < 48; ++i) token_bits |= ids[i]; + if (!token_bits) return Status::InvalidField; + auto* adapter = app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr; + if (!adapter) return Status::Busy; + uint32_t actual = 0; + const auto author_result = adapter->readDeviceAuthorKey(output + 384, actual); + if (author_result == chat::DeviceAuthorResult::StorageError) return Status::StorageUnavailable; + if (author_result != chat::DeviceAuthorResult::Ok) return Status::Busy; + if (actual != expected_author_generation) return Status::StaleIdentity; + uint64_t clock = 0; + if (!runtime::publicationContext(boot_id, clock)) return Status::Busy; + bool exists = loadPublicationTicket(output, capacity); + if (!exists && sd::sd_exists(kPublicationTicketPath)) return Status::StorageUnavailable; + if (exists) + { + uint64_t created = 0; + uint32_t lifetime = 0; + for (unsigned i = 0; i < 8; ++i) created |= uint64_t(output[217 + i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) lifetime |= uint32_t(output[225 + i]) << (8 * i); + const bool same_token = !std::memcmp(output + 21, ids + 32, 16); + if (output[4] == 1) return Status::Conflict; + if (output[4] == 2) + { + if (same_token) return Status::Conflict; + if (std::memcmp(output + 5, request.session, 16)) return Status::Unauthorized; + if (std::memcmp(output + 81, output + 384, 64)) return Status::StaleIdentity; + uint32_t prior_generation = 0; + for (unsigned i = 0; i < 4; ++i) prior_generation |= uint32_t(output[77 + i]) << (8 * i); + if (prior_generation != actual) return Status::StaleIdentity; + std::memmove(output + 240, output, kPublicationTicketBytes); + auto* prior = output + 240; + size_t result_size = 0; + auto result = runtime::readWebPublicationJob(prior + 21, prior + 81, output, 240, result_size); + if (result == runtime::WebReadResult::NotFound) + result = runtime::readWebPublicationHistory(prior + 21, prior + 81, output, 240, result_size); + if (result != runtime::WebReadResult::Ready) return publication_result(result); + if (!terminal_publication_result({output, result_size}, prior + 21)) return Status::Conflict; + std::memmove(output, prior, kPublicationTicketBytes); + std::memcpy(output + 384, output + 81, 64); + exists = false; + } + if (exists) + { + const bool old_boot = std::memcmp(output + 37, boot_id, 16) != 0; + const bool expired = clock < created || clock - created >= lifetime; + uint32_t prior_generation = 0; + for (unsigned i = 0; i < 4; ++i) prior_generation |= uint32_t(output[77 + i]) << (8 * i); + const bool same_owner = !std::memcmp(output + 5, request.session, 16) && !std::memcmp(output + 81, output + 384, 64) && prior_generation == actual; + if (!same_token && (old_boot || expired || same_owner)) exists = false; + else + { + const auto match = matchPublicationTicket(output, kPublicationTicketBytes, request.session, ids + 32, + boot_id, output + 384, actual, clock, false); + if (match != Status::Ok) return match; + if (std::memcmp(output + 53, ids, 16) || std::memcmp(output + 145, ids + 16, 16)) return Status::Conflict; + for (unsigned i = 0; i < 8; ++i) + if (output[69 + i] != static_cast(generation >> (8 * i))) return Status::Conflict; + } + } + } + if (!exists || output[4] == 3) + { + const auto binding = runtime::readWebPublicationBinding(ids, generation, ids + 16, output + 384, output + 240, 88); + if (binding != runtime::WebReadResult::Ready) return publication_result(binding); + if (!exists) + { + uint32_t from = 0, to = 0; + for (unsigned i = 0; i < 4; ++i) + { + from |= uint32_t(output[288 + i]) << (8 * i); + to |= uint32_t(output[292 + i]) << (8 * i); + } + PublicationTicketFields ticket{request.session, ids + 32, boot_id, ids, output + 384, ids + 16, + output + 272, output + 240, generation, actual, from, to, clock, kPublicationTicketLifetimeMs}; + if (!encodePublicationTicket(ticket, output, capacity, PublicationTicketPhase::Checking) || + !savePublicationTicket(output, kPublicationTicketBytes)) return Status::StorageUnavailable; + } + else if (std::memcmp(output + 177, output + 240, 32) || std::memcmp(output + 161, output + 272, 16) || + std::memcmp(output + 209, output + 288, 8)) return Status::Conflict; + if (!derive_prepare_cache_id(output + 384, ids, output + 328)) return Status::Internal; + const auto latest = runtime::readWebLatestRevision(output + 328, ids + 16, output + 240, 36); + const uint32_t from = uint32_t(output[209]) | uint32_t(output[210]) << 8 | uint32_t(output[211]) << 16 | uint32_t(output[212]) << 24; + if (latest == runtime::WebReadResult::Absent) + { + if (from) return Status::Conflict; + } + else if (latest == runtime::WebReadResult::Ready) + { + const uint32_t revision = uint32_t(output[240]) | uint32_t(output[241]) << 8 | uint32_t(output[242]) << 16 | uint32_t(output[243]) << 24; + if (!from || revision != from || std::memcmp(output + 244, output + 296, 32)) return Status::Conflict; + } + else return publication_result(latest); + if (!runtime::publicationContext(boot_id, clock) || std::memcmp(output + 37, boot_id, 16)) return Status::Conflict; + const auto current = matchPublicationTicket(output, kPublicationTicketBytes, request.session, ids + 32, + boot_id, output + 384, actual, clock, false); + if (current != Status::Ok) return current; + if (!completePublicationCheck(output, kPublicationTicketBytes, clock) || !savePublicationTicket(output, kPublicationTicketBytes)) return Status::StorageUnavailable; + } + uint64_t created = 0; + for (unsigned i = 0; i < 8; ++i) created |= uint64_t(output[217 + i]) << (8 * i); + const auto lifetime = uint32_t(output[225]) | uint32_t(output[226]) << 8 | uint32_t(output[227]) << 16 | uint32_t(output[228]) << 24; + const auto remaining = lifetime - static_cast(clock - created); + // Stage immutable ticket above the response area, avoiding overlapping CMP copies. + std::memmove(output + 240, output, kPublicationTicketBytes); + const auto* ticket = output + 240; + const uint32_t from = uint32_t(ticket[209]) | uint32_t(ticket[210]) << 8 | uint32_t(ticket[211]) << 16 | uint32_t(ticket[212]) << 24; + const uint32_t to = uint32_t(ticket[213]) | uint32_t(ticket[214]) << 8 | uint32_t(ticket[215]) << 16 | uint32_t(ticket[216]) << 24; + geocaching::protocol::CmpWriter writer(output, 240); + if (!writer.array(10) || !writer.binary({ticket + 21, 16}) || !writer.binary({ticket + 53, 16}) || + !writer.unsignedInteger(generation) || !writer.binary({ticket + 81, 64}) || !writer.binary({ticket + 145, 16}) || + !writer.binary({ticket + 161, 16}) || !writer.binary({ticket + 177, 32}) || !writer.unsignedInteger(from) || + !writer.unsignedInteger(to) || !writer.unsignedInteger(remaining)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/route_image_source_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_image_source_runtime.h new file mode 100644 index 00000000..914d7857 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_image_source_runtime.h @@ -0,0 +1,242 @@ +#pragma once + +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/file_integrity.h" +#include "track_sharing/image_source_binding_store.h" +#include "track_sharing/installed_package_path.h" +#include +#include +#include +#if defined(ESP_PLATFORM) +#include +#endif + +namespace platform::esp::route_images +{ +namespace sd = platform::esp::arduino_common::storage; +// Hash-only owner avoids admitting any SD backing/path buffers for UI hashes. +class ImageReferenceHashWork +{ + public: + ImageReferenceHashWork() : hash_(context_) {} +#if defined(ESP_PLATFORM) + static void* operator new(size_t size, const std::nothrow_t&) noexcept + { + const auto caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + return heap_caps_malloc(size, caps | MALLOC_CAP_8BIT); + } + static void operator delete(void* value) noexcept { heap_caps_free(value); } +#endif + bool hash(std::string_view reference, uint8_t* output) + { + return output && !reference.empty() && reference.size() <= 4096 && hash_.start() && hash_.update(reinterpret_cast(reference.data()), reference.size()) && hash_.finish(output); + } + + private: + mbedtls_sha256_context context_; + platform::esp::storage::SnapshotStreamSha256 hash_; +}; +static_assert(sizeof(ImageReferenceHashWork) <= 256, "Reference-only hashing must not include file buffers"); +class SourceIo +{ + public: + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + explicit SourceIo(sd::SdRuntimeMemoryPolicy policy = memoryPolicy()) : media_(sd::sd_media_session()), file_(policy) {} + bool current() const { return file_.has_backing_storage() && media_ && media_ == sd::sd_media_session() && sd::sd_card_ready(); } + OpenResult open(const char* path, Mode mode) + { + if (!current() || file_.is_open()) return {nullptr, OpenStatus::Unavailable}; + if (file_.open(path, mode == Mode::Read ? "r" : "w", media_)) return {this, OpenStatus::Ready}; + if (mode == Mode::Read) + { + // A bool open/exists failure cannot distinguish contention from + // absence. A successful open needs no probe; after a failure only + // an explicit Missing result is safe to treat as absence. The + // semantic API reads whole files, so oversized/empty results are + // NOT Missing and must not be interpreted as a prefix read. + uint8_t first = 0; + const auto probe = sd::sd_read_file(path, &first, 1); + if (!current()) return {nullptr, OpenStatus::Unavailable}; + if (probe.status == sd::SdFileReadStatus::Missing) return {nullptr, OpenStatus::Missing}; + } + return {nullptr, OpenStatus::Unavailable}; + } + bool close(void*) + { + file_.close(); + return current(); + } + bool size(void*, uint64_t& value) + { + if (!current()) return false; + value = file_.size(); + return current() && !file_.read_busy(); + } + bool seek(void*, uint64_t at) { return current() && file_.seek(at) && current(); } + size_t read(void*, uint8_t* output, size_t length) + { + if (!current()) return 0; + const auto count = file_.read(output, length); + return current() && count >= 0 ? size_t(count) : 0; + } + size_t write(void*, const uint8_t* input, size_t length) { return current() ? file_.write(input, length) : 0; } + bool sync(void*) { return current() && file_.flush() && current(); } + bool remove(const char* path) { return current() && sd::sd_remove(path, media_); } + bool rename(const char* from, const char* to) { return current() && sd::sd_rename(from, to, media_); } + + private: + static sd::SdRuntimeMemoryPolicy memoryPolicy() + { +#if defined(ESP_PLATFORM) + if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM)) return sd::SdRuntimeMemoryPolicy::PsramOnly; +#endif + return sd::SdRuntimeMemoryPolicy::Default; + } + uint32_t media_; + sd::SdRuntimeFile file_; +}; + +// Short-lived owner, never an ESP automatic local. The SD driver backing, +// SHA context, verification scratch and path storage all belong to this owner. +// Caller holds the route-storage lease through download, verify and commit. +class RouteImageSourceWork +{ + public: + RouteImageSourceWork() : hash_(context_), check_(io_, hash_), bindings_(io_) {} + explicit RouteImageSourceWork(sd::SdRuntimeMemoryPolicy policy) : io_(policy), hash_(context_), check_(io_, hash_), bindings_(io_) {} +#if defined(ESP_PLATFORM) + static void* operator new(size_t size, const std::nothrow_t&) noexcept + { + const auto memory = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL; + return heap_caps_malloc(size, memory | MALLOC_CAP_8BIT); + } + static void operator delete(void* value) noexcept { heap_caps_free(value); } +#endif + bool referenceHash(std::string_view source, uint8_t* output) + { + return !started_ && output && !source.empty() && source.size() <= 4096 && hash_.start() && + hash_.update(reinterpret_cast(source.data()), source.size()) && hash_.finish(output); + } + bool begin(std::string_view source, std::string_view image) + { + if (started_ || !resolve(image) || !referenceHash(source, source_hash_) || !hash_.start()) return false; + const auto opened = io_.open(candidate_, SourceIo::Mode::CreateTemporary); + if (!opened.handle) return false; + started_ = true; + writing_ = true; + return true; + } + // Offline callers already own the exact decoded reference digest. The + // original signed package proof must be established independently. + bool beginHashed(std::string_view image, const uint8_t source_hash[32]) + { + if (started_ || !source_hash || !resolve(image) || !hash_.start()) return false; + std::memcpy(source_hash_, source_hash, 32); + const auto opened = io_.open(candidate_, SourceIo::Mode::CreateTemporary); + if (!opened.handle) return false; + started_ = writing_ = true; + return true; + } + bool feed(const uint8_t* input, size_t length) + { + if (!writing_ || failed_ || (!input && length) || length > track_sharing::kImageSourceByteLimit - bytes_) return failed_ = true, false; + if (!hash_.update(input, length) || io_.write(&io_, input, length) != length) return failed_ = true, false; + bytes_ += uint32_t(length); + return true; + } + bool seal() + { + if (!writing_) return false; + const bool flushed = io_.sync(&io_), closed = io_.close(&io_); + writing_ = false; + if (failed_ || !bytes_ || !flushed || !closed || !hash_.finish(file_hash_)) return false; + return check_.begin(candidate_, bytes_, file_hash_) == track_sharing::FileIntegrityResult::Pending; + } + track_sharing::FileIntegrityResult verifyStep() { return check_.step(scratch_, sizeof(scratch_)); } + bool verifying() const { return check_.active(); } + uint32_t bytes() const { return bytes_; } + bool contentMatches(uint32_t bytes, const uint8_t hash[32]) const + { + return hash && !writing_ && !failed_ && bytes_ == bytes && !std::memcmp(file_hash_, hash, 32); + } + bool commit() + { + const track_sharing::ImageSourceBindingView expected = {{source_hash_, 32}, {file_hash_, 32}, bytes_}; + const track_sharing::ImageBindingPaths paths = {candidate_, image_, candidate_binding_, binding_, preview_, view_}; + return bindings_.commit(paths, expected, check_, scratch_, sizeof(scratch_)) == track_sharing::ImageBindingResult::Ready; + } + // Fast provenance/length query, not an independent full-file hash proof. + // Signed attachment admission must also verify the actual content digest. + bool matchesExisting(std::string_view image, const uint8_t* source_hash) + { + if (started_ || !source_hash || !resolve(image)) return false; + track_sharing::ImageSourceBindingView record; + if (bindings_.read(binding_, scratch_, sizeof(scratch_), record) != track_sharing::ImageBindingResult::Ready || + std::memcmp(record.source_hash.data, source_hash, 32)) return false; + const auto file = io_.open(image_, SourceIo::Mode::Read); + if (!file.handle) return false; + uint64_t size = 0; + const bool sized = io_.size(file.handle, size), closed = io_.close(file.handle); + return sized && closed && size == record.file_bytes; + } + bool matchesExistingReference(std::string_view image, std::string_view source) + { + return referenceHash(source, source_hash_) && matchesExisting(image, source_hash_); + } + + private: + bool resolve(std::string_view image) + { + constexpr std::string_view root = "/routes/.trailmate/"; + if (image.substr(0, root.size()) != root) return false; + const auto separator = image.find('/', root.size()); + if (separator == image.npos) return false; + const auto id = image.substr(root.size(), separator - root.size()); + if (id.empty() || (id.size() > 48 && !track_sharing::isInstalledPackageAssetId(id)) || id == "." || id == "..") return false; + for (const char c : id) + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.')) return false; + const auto suffix = image.substr(separator); + if (suffix.size() != 20 || suffix.substr(0, 12) != "/images/img-" || suffix.substr(16) != ".jpg") return false; + unsigned index = 0; + for (size_t i = 12; i < 16; ++i) + { + if (suffix[i] < '0' || suffix[i] > '9') return false; + index = index * 10 + unsigned(suffix[i] - '0'); + } + if (!index || index > 64 || image.size() + 12 >= sizeof(image_)) return false; + std::memcpy(image_, image.data(), image.size()); + image_[image.size()] = '\0'; + const auto set = [&](char* output, const char* format) + { return std::snprintf(output, sizeof(image_), format, image_) > 0; }; + if (!set(candidate_, "%s.tmp") || !set(binding_, "%s.source") || !set(candidate_binding_, "%s.source.new")) return false; + const auto base = image.substr(0, separator); + return std::snprintf(preview_, sizeof(preview_), "%.*s/thumbs/thumb-%04u.bmp", int(base.size()), base.data(), index) > 0 && + std::snprintf(view_, sizeof(view_), "%.*s/views/view-%04u.bmp", int(base.size()), base.data(), index) > 0; + } + SourceIo io_; + mbedtls_sha256_context context_; + platform::esp::storage::SnapshotStreamSha256 hash_; + track_sharing::FileIntegrityCheck check_; + track_sharing::ImageSourceBindingStore bindings_; + uint8_t source_hash_[32]{}, file_hash_[32]{}, scratch_[320]{}; + char candidate_[128]{}, image_[128]{}, candidate_binding_[128]{}, binding_[128]{}, preview_[128]{}, view_[128]{}; + uint32_t bytes_ = 0; + bool started_ = false, writing_ = false, failed_ = false; +}; +} // namespace platform::esp::route_images diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/route_package_image_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_package_image_runtime.h new file mode 100644 index 00000000..005f17f3 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_package_image_runtime.h @@ -0,0 +1,162 @@ +#pragma once +#include "hostlink/route_storage_lease.h" +#include "platform/esp/arduino_common/route_image_source_runtime.h" +#include "platform/esp/idf_common/hostlink_track_stored_package_input.h" +#include "track_sharing/installed_package_path.h" +#include +namespace platform::esp::route_images +{ +namespace package = hostlink::management::package_install_runtime; +// One transient worker per existing image batch, no global slot. Candidate +// reference/resource mappings are unusable until the whole stored package is +// verified. The large input owner is released before copying/decoding images. +class RoutePackageImageWork +{ + public: + explicit RoutePackageImageWork(uint32_t media) + : media_(media), file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media), hash_(context_) {} + ~RoutePackageImageWork() { package::destroyStoredPackageInputOwner(owner_); } + bool hasBacking() const { return lease_.acquired() && file_.has_backing_storage() && current(); } + track_sharing::ManifestFileResult begin(std::string_view path) + { + using namespace track_sharing; + if (state_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!hasBacking() || !installedPackageRevision(path, revision_) || + !routePackageTarget({revision_, 32}, PackageRouteFile::Binding, resource_path_, sizeof(resource_path_))) return state_ = ManifestFileResult::Invalid; + const auto opened = io_.open(resource_path_, sd::SdBorrowedRecordFileIo::Mode::Read); + if (!opened.handle) return state_ = ManifestFileResult::StorageFailure; + uint64_t bytes = 0; + const bool loaded = io_.size(opened.handle, bytes) && bytes == kInstalledPackageBindingBytes && io_.read(opened.handle, scratch_, bytes) == bytes; + const bool closed = io_.close(opened.handle); + if (!loaded || !closed || !decodeInstalledPackageBinding(scratch_, kInstalledPackageBindingBytes, binding_) || std::memcmp(binding_.revision, revision_, 32)) return state_ = ManifestFileResult::Invalid; + std::snprintf(destination_root_, sizeof(destination_root_), "/routes/.trailmate/%.*s/images/", 66, path.data() + 8); + owner_ = package::createStoredPackageInputOwner(media_, {binding_.object, 32}, {revision_, 32}); + if (!owner_) return state_ = ManifestFileResult::StorageFailure; + if (!owner_->observeAttachments(&collect, this)) return state_ = ManifestFileResult::Invalid; + state_ = owner_->begin(scratch_, sizeof(scratch_)); + return state_ == ManifestFileResult::Pending ? state_ : fail(state_ == ManifestFileResult::Ready ? ManifestFileResult::Invalid : state_); + } + track_sharing::ManifestFileResult step() + { + using namespace track_sharing; + if (!current()) return fail(ManifestFileResult::StorageFailure); + if (state_ != ManifestFileResult::Pending) return state_; + state_ = owner_->step(scratch_, sizeof(scratch_)); + if (state_ == ManifestFileResult::Ready && !owner_->ready()) state_ = ManifestFileResult::Invalid; + if (state_ != ManifestFileResult::Pending) + { + package::destroyStoredPackageInputOwner(owner_); + owner_ = nullptr; + if (state_ != ManifestFileResult::Ready) count_ = 0; + } + return state_; + } + bool ready() const { return state_ == track_sharing::ManifestFileResult::Ready && current(); } + // The caller supplies an existing UI image path and an exact decoded KML + // reference digest. Relative paths/URLs never turn into filesystem paths. + // yield is called between small reads and readback hashes on the worker. + bool materialize(const uint8_t reference[32], std::string_view destination, void (*yield)() = nullptr) + { + if (!ready() || !reference || destination.substr(0, std::strlen(destination_root_)) != destination_root_) return false; + const Entry* matched = nullptr; + for (unsigned i = 0; i < count_; ++i) + if (!std::memcmp(entries_[i].reference, reference, 32)) + { + matched = &entries_[i]; + break; + } + if (!matched || !resolvePackageCachePath({revision_, 32}, track_sharing::ObjectFile::AttachmentResource, {matched->hash, 32}, resource_path_, sizeof(resource_path_))) return false; + void* memory = heap_caps_malloc(sizeof(RouteImageSourceWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return false; + auto* output = ::new (memory) RouteImageSourceWork(sd::SdRuntimeMemoryPolicy::PsramOnly); + bool ok = output->beginHashed(destination, reference); + const auto input = ok ? io_.open(resource_path_, sd::SdBorrowedRecordFileIo::Mode::Read) : sd::SdBorrowedRecordFileIo::OpenResult{nullptr, sd::SdBorrowedRecordFileIo::OpenStatus::Unavailable}; + uint64_t bytes = 0; + ok = ok && input.handle && io_.size(input.handle, bytes) && bytes == matched->bytes; + uint32_t consumed = 0; + while (ok && consumed < matched->bytes) + { + const auto wanted = std::min(sizeof(scratch_), matched->bytes - consumed); + const auto read = current() ? io_.read(input.handle, scratch_, wanted) : 0; + ok = read == wanted && output->feed(scratch_, read); + consumed += uint32_t(read); + if (yield) yield(); + } + if (input.handle && !io_.close(input.handle)) ok = false; + ok = ok && current() && output->seal() && output->contentMatches(matched->bytes, matched->hash); + auto checked = track_sharing::FileIntegrityResult::Pending; + while (ok && checked == track_sharing::FileIntegrityResult::Pending) + { + if (!current()) + { + ok = false; + break; + } + checked = output->verifyStep(); + if (yield) yield(); + } + ok = ok && checked == track_sharing::FileIntegrityResult::Ready && current() && output->commit(); + output->~RouteImageSourceWork(); + heap_caps_free(output); + return ok; + } + + private: + struct Entry + { + uint8_t reference[32]{}, hash[32]{}; + uint32_t bytes = 0; + }; + static bool collect(void* context, uint8_t, const track_sharing::AttachmentEntryView& entry) + { + auto& self = *static_cast(context); + if (!track_sharing::attachmentIdsEqual(entry.mime, "image/jpeg") && !track_sharing::attachmentIdsEqual(entry.mime, "image/png")) return true; + if (self.count_ >= 64 || !entry.hash.data || entry.hash.size != 32 || entry.bytes > track_sharing::kImageSourceByteLimit) return false; + auto& output = self.entries_[self.count_++]; + if (!self.hash_.start() || !self.hash_.update(reinterpret_cast(entry.reference.data()), entry.reference.size()) || !self.hash_.finish(output.reference)) return false; + std::memcpy(output.hash, entry.hash.data, 32); + output.bytes = entry.bytes; + return true; + } + bool current() const { return media_ && media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + track_sharing::ManifestFileResult fail(track_sharing::ManifestFileResult result) + { + package::destroyStoredPackageInputOwner(owner_); + owner_ = nullptr; + count_ = 0; + return state_ = result; + } + hostlink::route_storage_lease::Guard lease_; + uint32_t media_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + mbedtls_sha256_context context_; + platform::esp::storage::SnapshotStreamSha256 hash_; + package::StoredPackageInputOwner* owner_ = nullptr; + track_sharing::InstalledPackageBinding binding_; + Entry entries_[64]{}; + uint8_t revision_[32]{}, scratch_[512]{}; + char resource_path_[160]{}, destination_root_[96]{}; + uint8_t count_ = 0; + track_sharing::ManifestFileResult state_ = track_sharing::ManifestFileResult::Idle; +}; +static_assert(sizeof(RoutePackageImageWork) <= 6144, "Package image mapping must stay bounded transient PSRAM"); +inline RoutePackageImageWork* createRoutePackageImageWork() +{ + void* memory = heap_caps_malloc(sizeof(RoutePackageImageWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* work = new (memory) RoutePackageImageWork(sd::sd_media_session()); + if (work->hasBacking()) return work; + work->~RoutePackageImageWork(); + heap_caps_free(work); + return nullptr; +} +inline void destroyRoutePackageImageWork(RoutePackageImageWork* work) +{ + if (work) + { + work->~RoutePackageImageWork(); + heap_caps_free(work); + } +} +} // namespace platform::esp::route_images diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/route_png_cache.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_png_cache.h new file mode 100644 index 00000000..c3994d93 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/route_png_cache.h @@ -0,0 +1,11 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include +#include +namespace platform::esp::route_images +{ +// Caller owns source-file and route-storage lease. Output is caller-provided +// BGR24 pixels; no full decoded source image, no internal-heap fallback. +bool decodeRoutePng(arduino_common::storage::SdRuntimeFile& input, + uint8_t* bgr, size_t capacity, uint16_t width, uint16_t height); +} // namespace platform::esp::route_images diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h new file mode 100644 index 00000000..f967291d --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h @@ -0,0 +1,81 @@ +#pragma once + +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/storage/record_file_io.h" + +namespace platform::esp::arduino_common::storage +{ +// Operation-scoped adapter for an exclusive caller-owned file. The caller +// includes SdRuntimeFile::Impl in resource admission; this adapter allocates +// no wrapper/Impl and never binds itself to a replacement card. Only one +// handle can be open; it is closed between copy slices. No shared backend is +// changed and no internal-flash fallback is permitted. +class SdBorrowedRecordFileIo final : public platform::esp::storage::RecordFileIo +{ + public: + SdBorrowedRecordFileIo(SdRuntimeFile& file, uint32_t session) : file_(file), session_(session) {} + ~SdBorrowedRecordFileIo() override + { + if (active_) close(&file_); + } + SdBorrowedRecordFileIo(const SdBorrowedRecordFileIo&) = delete; + SdBorrowedRecordFileIo& operator=(const SdBorrowedRecordFileIo&) = delete; + OpenResult open(const char* path, Mode mode) override + { + if (!path || !*path) return {}; + if (!available()) return {nullptr, OpenStatus::Unavailable}; + if (active_ || file_.is_open()) return {}; + const char* flags = mode == Mode::Read ? "rb" : mode == Mode::Update ? "r+b" + : "wb"; + if (file_.open(path, flags, session_)) + { + active_ = true; + return {&file_, OpenStatus::Ready}; + } + if (!available()) return {nullptr, OpenStatus::Unavailable}; + uint8_t probe = 0; + const bool missing = mode == Mode::Read && sd_read_file(path, &probe, 1).status == SdFileReadStatus::Missing; + if (!available()) return {nullptr, OpenStatus::Unavailable}; + return {nullptr, missing ? OpenStatus::Missing : OpenStatus::IoError}; + } + bool close(Handle handle) override + { + if (!active_ || handle != &file_) return false; + const bool ready = usable(handle); + file_.close(); + active_ = false; + // As with SdRecordFileIo, sync establishes durability, not close. + return ready; + } + bool size(Handle handle, uint64_t& bytes) override + { + bytes = 0; + if (!usable(handle)) return false; + bytes = file_.size(); + return true; + } + bool seek(Handle handle, uint64_t offset) override { return usable(handle) && file_.seek(offset); } + size_t read(Handle handle, void* buffer, size_t bytes) override + { + if (!usable(handle)) return 0; + const int count = file_.read(buffer, bytes); + return count > 0 ? static_cast(count) : 0; + } + size_t write(Handle handle, const void* buffer, size_t bytes) override { return usable(handle) ? file_.write(buffer, bytes) : 0; } + bool sync(Handle handle) override { return usable(handle) && file_.flush(); } + bool publish(const char* temporary, const char* destination) override + { + if (!temporary || !*temporary || !destination || !*destination || active_ || !available()) return false; + uint8_t probe = 0; + return sd_read_file(destination, &probe, 1).status == SdFileReadStatus::Missing && available() && sd_rename(temporary, destination, session_); + } + + private: + bool available() const { return session_ == sd_media_session() && sd_card_ready() && !sd_external_block_owner_active(); } + bool usable(Handle handle) const { return active_ && handle == &file_ && available() && file_.is_open(); } + SdRuntimeFile& file_; + uint32_t session_; + bool active_ = false; +}; +static_assert(sizeof(SdBorrowedRecordFileIo) <= 24, "Borrowed SD adapter must not own a driver/buffer"); +} // namespace platform::esp::arduino_common::storage diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h index 166c87bf..b9ca317b 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -108,8 +108,10 @@ bool sd_set_external_block_owner_active(bool active); bool sd_exists(const char* path); bool sd_is_directory(const char* path); bool sd_mkdir(const char* path); +bool sd_mkdir(const char* path, uint32_t expected_session); bool sd_rmdir(const char* path); bool sd_remove(const char* path); +bool sd_remove(const char* path, uint32_t expected_session); bool sd_rename(const char* old_path, const char* new_path); bool sd_rename(const char* old_path, const char* new_path, uint32_t expected_session); @@ -145,10 +147,18 @@ SdFileReadResult sd_read_file(const char* path, uint8_t* buffer, std::size_t capacity); +enum class SdRuntimeMemoryPolicy : uint8_t +{ + Default, + PsramOnly, +}; + class SdRuntimeFile { public: SdRuntimeFile(); + explicit SdRuntimeFile(SdRuntimeMemoryPolicy policy); + bool has_backing_storage() const { return impl_ != nullptr; } ~SdRuntimeFile(); SdRuntimeFile(const SdRuntimeFile&) = delete; @@ -196,6 +206,8 @@ class SdRuntimeDir { public: SdRuntimeDir(); + explicit SdRuntimeDir(SdRuntimeMemoryPolicy policy); + bool has_backing_storage() const { return impl_ != nullptr; } ~SdRuntimeDir(); SdRuntimeDir(const SdRuntimeDir&) = delete; @@ -208,6 +220,11 @@ class SdRuntimeDir bool is_open() const; bool read_next(char* name, std::size_t name_size, bool* is_dir); SdDirReadStatus read_next_status(char* name, std::size_t name_size, bool* is_dir); + // SdFat metadata cursor path; positions are native directory-entry offsets. + SdDirReadStatus read_next_info(char* name, std::size_t name_size, bool* is_dir, + uint64_t* file_size, uint64_t* next_position); + bool seek_read_position(uint64_t position); + SdFileReadStatus seek_read_status(uint64_t position); private: class Impl; diff --git a/platform/esp/arduino_common/library.json b/platform/esp/arduino_common/library.json index 78fe4e3a..66730097 100644 --- a/platform/esp/arduino_common/library.json +++ b/platform/esp/arduino_common/library.json @@ -7,14 +7,7 @@ "build": { "includeDir": "include", "srcDir": "src", - "srcFilter": [ - "+<*>", - "-", - "-", - "-", - "-", - "-" - ], + "extraScript": "configure_sources.py", "flags": [ "-I../../../platform/shared/include", "-I../../../modules/ui_shared/include", diff --git a/platform/esp/arduino_common/src/LV_Helper_v9.cpp b/platform/esp/arduino_common/src/LV_Helper_v9.cpp index 147714cc..14f7faf6 100644 --- a/platform/esp/arduino_common/src/LV_Helper_v9.cpp +++ b/platform/esp/arduino_common/src/LV_Helper_v9.cpp @@ -1678,6 +1678,14 @@ bool lv_external_font_load_fs_was_busy() namespace { constexpr size_t kLvglLargeAllocThresholdBytes = 2048; +#if defined(ARDUINO_WIO_TRACKER_L2) +// Wio L2 has 8 MiB PSRAM. Its many small persistent UI objects must not +// compete with task stacks, radio and DMA for the internal heap. Display DMA +// buffers are allocated separately in beginLvglHelper with explicit caps. +constexpr bool kLvglPreferPsramForSmallAllocations = true; +#else +constexpr bool kLvglPreferPsramForSmallAllocations = false; +#endif bool lvgl_has_psram() { @@ -1700,7 +1708,7 @@ bool lvgl_external_font_load_uses_strict_psram() uint32_t lvgl_primary_caps(size_t size) { - if (lvgl_has_psram() && size >= kLvglLargeAllocThresholdBytes) + if (lvgl_has_psram() && (kLvglPreferPsramForSmallAllocations || size >= kLvglLargeAllocThresholdBytes)) { return MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; } @@ -1709,7 +1717,7 @@ uint32_t lvgl_primary_caps(size_t size) uint32_t lvgl_secondary_caps(size_t size) { - if (lvgl_has_psram() && size >= kLvglLargeAllocThresholdBytes) + if (lvgl_has_psram() && (kLvglPreferPsramForSmallAllocations || size >= kLvglLargeAllocThresholdBytes)) { return MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; } diff --git a/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp b/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp index 6fefbe3d..c69ed709 100644 --- a/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp +++ b/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp @@ -4,15 +4,21 @@ #include "platform/esp/arduino_common/app_config_tms_settings_extension.h" #include "platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/ui/device_runtime.h" #include "platform/ui/reticulum_group_config_runtime.h" #include "platform/ui/reticulum_network_config_runtime.h" #include "platform/ui/settings_store.h" -#include -#include +#include "platform/esp/common/config_preferences.h" +#include "platform/esp/common/reticulum_runtime_compat.h" #include +#include #include +#include +#if defined(ESP_PLATFORM) +#include +#endif namespace app::sd_tms { @@ -25,6 +31,7 @@ constexpr const char* kConfigDir = "/trailmate"; constexpr const char* kConfigPath = "/trailmate/config.tms"; constexpr const char* kConfigTempPath = "/trailmate/config.tms.new"; constexpr const char* kConfigRecoveryPath = "/trailmate/config.tms.bak"; +constexpr const char* kWebCandidatePath = "/trailmate/config.web.tms"; constexpr const char* kBackupDir = "/trailmate/backup"; constexpr const char* kBackupPath = "/trailmate/backup/settings.tms"; constexpr const char* kBackupTempPath = "/trailmate/backup/settings.tms.new"; @@ -37,7 +44,7 @@ constexpr const char* kResetPendingKey = "working"; // without an SD card cannot be undone by inserting an old card later. bool reset_pending() { - Preferences preferences; + ::platform::esp::common::ConfigPreferences preferences; if (!preferences.begin(kResetNamespace, true)) { return false; @@ -49,7 +56,7 @@ bool reset_pending() bool set_reset_pending(bool pending) { - Preferences preferences; + ::platform::esp::common::ConfigPreferences preferences; if (!preferences.begin(kResetNamespace, false)) { return false; @@ -80,12 +87,50 @@ bool remove_if_present(const char* path) ::platform::esp::arduino_common::storage::sd_remove(path); } -tms::LineScratch s_line_scratch{}; +// Operations are serialized by the application owner. Nested candidate +// validation borrows the emission buffer; only the outer lease frees it. +tms::LineScratch* s_line_scratch_storage = nullptr; +class ScopedLineScratch +{ + public: + ScopedLineScratch() + { + if (s_line_scratch_storage) return; +#if defined(ESP_PLATFORM) + void* raw = heap_caps_malloc(sizeof(tms::LineScratch), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + void* raw = std::malloc(sizeof(tms::LineScratch)); +#endif + if (raw) + { + s_line_scratch_storage = new (raw) tms::LineScratch{}; + owner_ = true; + } + } + ~ScopedLineScratch() + { + if (!owner_) return; +#if defined(ESP_PLATFORM) + heap_caps_free(s_line_scratch_storage); +#else + std::free(s_line_scratch_storage); +#endif + s_line_scratch_storage = nullptr; + } + ScopedLineScratch(const ScopedLineScratch&) = delete; + ScopedLineScratch& operator=(const ScopedLineScratch&) = delete; + bool ready() const { return s_line_scratch_storage != nullptr; } + + private: + bool owner_ = false; +}; +#define s_line_scratch (*s_line_scratch_storage) const AppConfig* s_working_config = nullptr; bool s_sync_pending = false; bool s_reset_in_progress = false; bool s_repair_required = false; uint32_t s_next_sync_attempt_ms = 0U; +uint32_t s_web_restart_at = 0U; bool tracks_working_settings(const char* ns) { @@ -103,7 +148,7 @@ void on_settings_store_changed(void*, const char* ns, const char*) bool consume_physical_line(tms::Decoder& decoder, std::size_t length) { - if (length >= sizeof(s_line_scratch.bytes)) + if (!s_line_scratch_storage || length >= sizeof(s_line_scratch.bytes)) { return false; } @@ -147,6 +192,8 @@ bool read_document(const char* path, return false; } + ScopedLineScratch scratch; + if (!scratch.ready()) return false; settings_extension::beginRead(target != nullptr); tms::Decoder decoder(target, kind, @@ -217,6 +264,8 @@ bool write_document_candidate(const AppConfig& config, const char* directory, const char* temporary_path) { + ScopedLineScratch scratch; + if (!scratch.ready()) return false; if (!ensure_directory(directory) || !remove_if_present(temporary_path)) { return false; @@ -276,6 +325,8 @@ bool recover_transaction(const char* final_path, bool copy_backup_as_working_candidate() { + ScopedLineScratch scratch; + if (!scratch.ready()) return false; SdRuntimeFile source; SdRuntimeFile destination; if (!source.open(kBackupPath, "r") || @@ -459,6 +510,11 @@ void bindWorkingConfig(const AppConfig& config) nullptr); } +bool workingConfigIsBound() +{ + return s_working_config != nullptr; +} + void requestWorkingConfigSync() { if (!s_reset_in_progress) @@ -470,6 +526,10 @@ void requestWorkingConfigSync() WorkingConfigSyncResult syncPendingWorkingConfig() { + if (s_web_restart_at != 0U || !s_working_config || s_repair_required) + { + return WorkingConfigSyncResult::Failed; + } if (!s_sync_pending) { return WorkingConfigSyncResult::Synchronized; @@ -494,6 +554,14 @@ WorkingConfigSyncResult syncPendingWorkingConfig() void serviceWorkingConfig() { + if (s_web_restart_at != 0U) + { + if (static_cast(millis() - s_web_restart_at) >= 0) + { + ::platform::ui::device::restart(); + } + return; + } if (!s_sync_pending || !s_working_config || s_repair_required) { return; @@ -513,7 +581,7 @@ void serviceWorkingConfig() bool syncWorkingConfig(const AppConfig& config) { - if (s_repair_required || !sd_available() || + if (s_web_restart_at != 0U || s_repair_required || !sd_available() || !write_document_candidate(config, tms::DocumentKind::Working, kConfigDir, @@ -539,7 +607,7 @@ bool backupWorkingConfig(const AppConfig& config) bool restoreWorkingConfig() { - if (!sd_available() || + if (s_web_restart_at != 0U || !sd_available() || !recover_transaction(kBackupPath, kBackupTempPath, kBackupRecoveryPath) || !::platform::esp::arduino_common::storage::sd_exists(kBackupPath)) { @@ -577,8 +645,46 @@ void endWorkingConfigReset() s_next_sync_attempt_ms = 0U; } +const char* webWorkingCandidatePath() +{ + return kWebCandidatePath; +} + +bool webConfigurationRestartPending() +{ + return s_web_restart_at != 0U; +} +void scheduleWebManagementRestart() +{ + s_sync_pending = false; + if (s_web_restart_at != 0U) return; + s_web_restart_at = millis() + 2000U; + if (s_web_restart_at == 0U) s_web_restart_at = 1U; +} + +bool validateWebWorkingCandidate() +{ + if (s_web_restart_at != 0U || s_repair_required || !sd_available()) return false; + uint16_t schema = 0; + tms::DocumentInfo info{}; + return read_document(kWebCandidatePath, tms::DocumentKind::Working, nullptr, + &schema, &info) && + schema == tms::kSchemaVersion; +} + +bool commitWebWorkingCandidate() +{ + if (!validateWebWorkingCandidate() || + !promote_candidate(kWebCandidatePath, kConfigPath, kConfigRecoveryPath)) return false; + s_sync_pending = false; + s_web_restart_at = millis() + 2000U; + if (s_web_restart_at == 0U) s_web_restart_at = 1U; + return true; +} + bool resetWorkingConfig() { + if (s_web_restart_at != 0U) return false; s_sync_pending = false; s_next_sync_attempt_ms = 0U; s_repair_required = false; @@ -610,4 +716,5 @@ const char* loadResultName(LoadResult result) return "unknown"; } +#undef s_line_scratch } // namespace app::sd_tms diff --git a/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp b/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp index 36066356..56d6a465 100644 --- a/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp +++ b/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp @@ -4,8 +4,8 @@ #include "chat/ble/meshtastic_phone_config_bridge.h" #include "platform/esp/arduino_common/device_identity.h" -#include -#include +#include "platform/esp/common/config_preferences.h" +#include "platform/ui/settings_store.h" #include #include @@ -200,7 +200,8 @@ void initialize_default_module(meshtastic_LocalModuleConfig* module) void reset_state() { - std::memset(s_state_storage, 0, sizeof(State)); + s_state_storage->~State(); + new (s_state_storage) State(); s_state.mt_ble.enabled = true; s_state.mt_ble.mode = meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN; initialize_default_module(&s_state.module); @@ -294,53 +295,65 @@ bool valid_state() bool load_preferences() { reset_state(); - Preferences prefs; - if (prefs.begin(kMtBleNamespace, true)) + namespace store = ::platform::ui::settings_store; + const auto boolean = [](const char* ns, const char* key, bool fallback, bool& out) { - s_state.mt_ble.enabled = prefs.getBool("enabled", true); - const uint8_t mode = prefs.getUChar( - "mode", - static_cast(meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN)); - if (ble::meshtastic_config_bridge::isValidBluetoothMode(mode)) + const auto result = store::read_bool_into(ns, key, &out); + if (result == store::ScalarReadResult::Missing) { - s_state.mt_ble.mode = static_cast(mode); + out = fallback; + return true; } - const uint32_t pin = prefs.getUInt("pin", 0U); - if (ble::meshtastic_config_bridge::isValidBlePin(pin)) + return result == store::ScalarReadResult::Ok; + }; + const auto byte = [](const char* ns, const char* key, uint8_t fallback, uint8_t& out) + { + const auto result = store::read_u8_into(ns, key, &out); + if (result == store::ScalarReadResult::Missing) { - s_state.mt_ble.fixed_pin = pin; + out = fallback; + return true; } - prefs.end(); - } - ble::meshtastic_config_bridge::normalizeBluetoothConfig(&s_state.mt_ble); + return result == store::ScalarReadResult::Ok; + }; + const auto integer = [](const char* ns, const char* key, uint32_t fallback, uint32_t& out) + { + const auto result = store::read_uint_into(ns, key, &out); + if (result == store::ScalarReadResult::Missing) + { + out = fallback; + return true; + } + return result == store::ScalarReadResult::Ok; + }; + uint8_t mode = 0; + if (!boolean(kMtBleNamespace, "enabled", true, s_state.mt_ble.enabled) || + !byte(kMtBleNamespace, "mode", static_cast(meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN), mode) || + !integer(kMtBleNamespace, "pin", 0U, s_state.mt_ble.fixed_pin) || + !ble::meshtastic_config_bridge::isValidBluetoothMode(mode) || + !ble::meshtastic_config_bridge::isValidBlePin(s_state.mt_ble.fixed_pin) || + (mode == meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN && s_state.mt_ble.fixed_pin != 0U)) return false; + s_state.mt_ble.mode = static_cast(mode); + if (!integer(kMcBleNamespace, "pin", 0U, s_state.mc_pin) || !valid_mc_pin(s_state.mc_pin) || + !boolean(kMcBleNamespace, "manual_add", false, s_state.mc_manual_add) || + !byte(kMcBleNamespace, "telem_base", 0U, s_state.mc_telemetry_base) || + !byte(kMcBleNamespace, "telem_loc", 0U, s_state.mc_telemetry_loc) || + !byte(kMcBleNamespace, "telem_env", 0U, s_state.mc_telemetry_env) || + !byte(kMcBleNamespace, "advert_loc", 0U, s_state.mc_advert_location) || + s_state.mc_telemetry_base > 3U || s_state.mc_telemetry_loc > 3U || s_state.mc_telemetry_env > 3U) return false; - if (prefs.begin(kMcBleNamespace, true)) - { - const uint32_t pin = prefs.getUInt("pin", 0U); - s_state.mc_pin = valid_mc_pin(pin) ? pin : 0U; - s_state.mc_manual_add = prefs.getBool("manual_add", false); - s_state.mc_telemetry_base = std::min(prefs.getUChar("telem_base", 0U), 3U); - s_state.mc_telemetry_loc = std::min(prefs.getUChar("telem_loc", 0U), 3U); - s_state.mc_telemetry_env = std::min(prefs.getUChar("telem_env", 0U), 3U); - s_state.mc_advert_location = prefs.getUChar("advert_loc", 0U); - prefs.end(); - } - - if (prefs.begin(kMtModuleNamespace, true)) - { - const std::size_t size = prefs.getBytes(kModuleBlobKey, &s_state.module, sizeof(s_state.module)); - prefs.end(); - if (size != sizeof(s_state.module) || - s_state.module.version != ble::meshtastic_defaults::kModuleConfigVersion) - { - initialize_default_module(&s_state.module); - } - else - { - ble::meshtastic_config_bridge::normalizeModuleConfig(&s_state.module); - } - } - return true; + // Defaults were staged by reset_state. Preserve them only for an absent + // module; a failed/truncated/future-version read must not replace user data. + std::size_t size = 0; + const auto result = store::read_blob_into(kMtModuleNamespace, kModuleBlobKey, + &s_state.module, sizeof(s_state.module), &size); + if (result == store::BlobReadResult::Missing) return true; + if (result != store::BlobReadResult::Ok || size != sizeof(s_state.module) || + (s_state.module.version != 0U && s_state.module.version != ble::meshtastic_defaults::kModuleConfigVersion)) return false; + // Version zero is the explicitly supported legacy form. The existing + // normalizer upgrades it without discarding unrelated module settings. + ble::meshtastic_config_bridge::normalizeModuleConfig(&s_state.module); + return s_state.module.version == ble::meshtastic_defaults::kModuleConfigVersion; } bool encode_module() @@ -369,7 +382,7 @@ bool encode_module() bool write_preferences() { - Preferences prefs; + ::platform::esp::common::ConfigPreferences prefs; if (!prefs.begin(kMtBleNamespace, false)) { return false; @@ -378,6 +391,10 @@ bool write_preferences() prefs.putUChar("mode", static_cast(s_state.mt_ble.mode)) && prefs.putUInt("pin", s_state.mt_ble.fixed_pin); prefs.end(); + if (!ok) + { + return false; + } if (!prefs.begin(kMcBleNamespace, false)) { return false; @@ -387,15 +404,18 @@ bool write_preferences() prefs.putUChar("telem_base", s_state.mc_telemetry_base) && prefs.putUChar("telem_loc", s_state.mc_telemetry_loc) && prefs.putUChar("telem_env", s_state.mc_telemetry_env) && - prefs.putUChar("advert_loc", s_state.mc_advert_location) && ok; + prefs.putUChar("advert_loc", s_state.mc_advert_location); prefs.end(); + if (!ok) + { + return false; + } if (!prefs.begin(kMtModuleNamespace, false)) { return false; } ok = prefs.putBytes(kModuleBlobKey, &s_state.module, sizeof(s_state.module)) == - sizeof(s_state.module) && - ok; + sizeof(s_state.module); prefs.end(); return ok; } diff --git a/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp index 01d9929d..84d3523b 100644 --- a/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp +++ b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp @@ -1,5 +1,9 @@ #include "platform/esp/arduino_common/app_config_tms_settings_extension.h" +#include "app/tms_c6_config_extension.h" #include "platform/esp/arduino_common/app_config_tms_ble_extension.h" +#if defined(ESP_PLATFORM) && !defined(ARDUINO) +#include +#endif #include "platform/ui/auto_reply_settings.h" #include "platform/ui/device_runtime.h" @@ -103,6 +107,9 @@ struct ParseState uint64_t network_seen = 0U; uint32_t access_seen = 0U; ::chat::reticulum::ReticulumNetworkConfig network{}; +#if defined(CONFIG_IDF_TARGET_ESP32P4) + tms::c6::Config companion{}; +#endif #if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) uint32_t cellular_seen = 0U; ::platform::ui::a7682e::Config cellular{}; @@ -121,10 +128,6 @@ static_assert(sizeof(ParseState) <= 4U * 1024U, ParseState* s_state_storage = nullptr; bool s_state_allocation_failed_logged = false; -// Serial emission is sequential. Locale, base64 strings, and timezone bytes -// can share one small 192-byte buffer rather than reserving two at all times. -uint8_t s_write_scratch[kTimezoneTzdefBytes]{}; - #define s_state (*s_state_storage) bool ensure_parse_state() @@ -562,6 +565,36 @@ bool valid_text_blob(const char* value, std::size_t length) return value != nullptr && std::memchr(value, '\0', length) == nullptr; } +bool write_i32_setting(tms::RecordWriter& writer, const char* document_key, + const char* ns, const char* store_key, int32_t fallback) +{ + int32_t value = 0; + const auto result = ::platform::ui::settings_store::read_int_into(ns, store_key, &value); + if (result == ::platform::ui::settings_store::ScalarReadResult::Missing) value = fallback; + else if (result != ::platform::ui::settings_store::ScalarReadResult::Ok) return false; + return writer.i32(document_key, value); +} + +bool write_u32_setting(tms::RecordWriter& writer, const char* document_key, + const char* ns, const char* store_key, uint32_t fallback) +{ + uint32_t value = 0; + const auto result = ::platform::ui::settings_store::read_uint_into(ns, store_key, &value); + if (result == ::platform::ui::settings_store::ScalarReadResult::Missing) value = fallback; + else if (result != ::platform::ui::settings_store::ScalarReadResult::Ok) return false; + return writer.u32(document_key, value); +} + +bool write_bool_setting(tms::RecordWriter& writer, const char* document_key, + const char* ns, const char* store_key, bool fallback) +{ + bool value = false; + const auto result = ::platform::ui::settings_store::read_bool_into(ns, store_key, &value); + if (result == ::platform::ui::settings_store::ScalarReadResult::Missing) value = fallback; + else if (result != ::platform::ui::settings_store::ScalarReadResult::Ok) return false; + return writer.boolean(document_key, value); +} + bool write_text_setting(tms::RecordWriter& writer, const char* document_key, const char* ns, @@ -570,11 +603,16 @@ bool write_text_setting(tms::RecordWriter& writer, std::size_t scratch_size) { std::size_t length = 0U; - if (!::platform::ui::settings_store::get_string_into( - ns, store_key, scratch, scratch_size, &length)) + const auto result = ::platform::ui::settings_store::read_string_into( + ns, store_key, scratch, scratch_size, &length); + if (result == ::platform::ui::settings_store::StringReadResult::Missing) { scratch[0] = '\0'; } + else if (result != ::platform::ui::settings_store::StringReadResult::Ok) + { + return false; + } return writer.text(document_key, scratch); } @@ -583,13 +621,21 @@ bool write_blob_setting(tms::RecordWriter& writer, const char* ns, const char* store_key) { + if (!s_state_storage) return false; + // Emission owns the transient ParseState. Its timezone field can serve as + // scratch for one value at a time; no permanent 192-byte buffer is needed. std::size_t length = 0U; - if (!::platform::ui::settings_store::get_blob_into( - ns, store_key, s_write_scratch, sizeof(s_write_scratch), &length)) + const auto result = ::platform::ui::settings_store::read_blob_into( + ns, store_key, s_state.timezone_tzdef, sizeof(s_state.timezone_tzdef), &length); + if (result == ::platform::ui::settings_store::BlobReadResult::Missing) { length = 0U; } - return writer.blob(document_key, s_write_scratch, length); + else if (result != ::platform::ui::settings_store::BlobReadResult::Ok) + { + return false; + } + return writer.blob(document_key, s_state.timezone_tzdef, length); } bool write_string_as_blob(tms::RecordWriter& writer, @@ -598,35 +644,26 @@ bool write_string_as_blob(tms::RecordWriter& writer, const char* store_key, std::size_t maximum_length) { - if (maximum_length + 1U > sizeof(s_write_scratch)) + if (!s_state_storage || maximum_length + 1U > sizeof(s_state.timezone_tzdef)) { return false; } std::size_t length = 0U; - if (!::platform::ui::settings_store::get_string_into( - ns, - store_key, - reinterpret_cast(s_write_scratch), - maximum_length + 1U, - &length)) + const auto result = ::platform::ui::settings_store::read_string_into( + ns, store_key, reinterpret_cast(s_state.timezone_tzdef), + maximum_length + 1U, &length); + if (result == ::platform::ui::settings_store::StringReadResult::Missing) { length = 0U; } - return writer.blob(document_key, s_write_scratch, length); -} - -bool snapshot_wifi_profile(void*, - std::size_t index, - const ::platform::ui::wifi::Config& profile) -{ - if (index >= ::platform::ui::wifi::kMaxSavedProfileCount) + else if (result != ::platform::ui::settings_store::StringReadResult::Ok) { return false; } - s_state.wifi_profiles[index] = profile; - return true; + return writer.blob(document_key, s_state.timezone_tzdef, length); } +// Board-specific configuration projection. #if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) enum CellularSeen : uint32_t { @@ -691,6 +728,102 @@ bool write_cellular_records(tms::RecordWriter& writer) } #endif +#if defined(CONFIG_IDF_TARGET_ESP32P4) +bool write_c6_records(tms::RecordWriter& writer) +{ + namespace store = ::platform::ui::settings_store; + auto& c = s_state.companion; + constexpr const char* ns = "c6_companion"; + c.seen = tms::c6::kComplete; + const auto boolean = [&](const char* key, bool fallback, bool& out) + { + const auto result = store::read_bool_into(ns, key, &out); + if (result == store::ScalarReadResult::Missing) + { + out = fallback; + return true; + } + return result == store::ScalarReadResult::Ok; + }; + const auto integer = [&](const char* key, int32_t fallback, int32_t& out) + { + const auto result = store::read_int_into(ns, key, &out); + if (result == store::ScalarReadResult::Missing) + { + out = fallback; + return true; + } + return result == store::ScalarReadResult::Ok; + }; + int32_t channel = 0, interval = 0, sta = 0, ap = 0; + if (!boolean("espnow_enabled", false, c.espnow_enabled) || + !boolean("espnow_team_discovery", false, c.espnow_team_discovery) || + !integer("espnow_channel", 0, channel) || !integer("espnow_beacon_interval_ms", 1000, interval) || + !integer("wifi_sta_channel", 0, sta) || !integer("wifi_ap_channel", 1, ap)) return false; + if (channel < 0 || channel > 14 || interval < 1 || interval > 60000 || sta < 0 || sta > 14 || ap < 1 || ap > 14) return false; + c.espnow_channel = uint8_t(channel); + c.espnow_beacon_interval_ms = uint16_t(interval); + c.wifi_sta_channel = uint8_t(sta); + c.wifi_ap_channel = uint8_t(ap); + // Reuse an already-owned profile slot after the shared profiles emitted. + auto& shared = s_state.wifi_profiles[0]; + if (!::platform::ui::wifi::load_config(shared)) return false; + if (!boolean("wifi_enabled", shared.enabled, c.wifi_enabled) || + !boolean("wifi_sta_enabled", c.wifi_enabled, c.wifi_sta_enabled) || + !boolean("wifi_ap_enabled", false, c.wifi_ap_enabled) || + !boolean("wifi_persist_credentials", false, c.wifi_persist_credentials)) return false; + const auto text = [&](const char* key, char* out, size_t capacity, const char* fallback) + { + size_t length = 0; + const auto result = store::read_string_into(ns, key, out, capacity, &length); + if (result == store::StringReadResult::Ok) return true; + if (result != store::StringReadResult::Missing) return false; + if (std::strlen(fallback) >= capacity) return false; + std::memcpy(out, fallback, std::strlen(fallback) + 1); + return true; + }; + return text("wifi_sta_ssid", c.wifi_sta_ssid, sizeof(c.wifi_sta_ssid), shared.ssid) && + text("wifi_sta_password", c.wifi_sta_password, sizeof(c.wifi_sta_password), shared.password) && + text("wifi_ap_ssid", c.wifi_ap_ssid, sizeof(c.wifi_ap_ssid), "TrailMate-C6") && + text("wifi_ap_password", c.wifi_ap_password, sizeof(c.wifi_ap_password), "") && tms::c6::valid(c, shared.enabled) && tms::c6::write(c, writer); +} +bool apply_c6_records() +{ + namespace store = ::platform::ui::settings_store; + constexpr const char* ns = "c6_companion"; + const auto& c = s_state.companion; + if (!c.seen) return true; // Existing TMSET7 documents migrate on next projection. + if (!tms::c6::valid(c, s_state.wifi_enabled)) return false; + store::ScopedChangeBatch batch; +#define C6_PUT_BOOL(name) \ + store::put_bool(ns, #name, c.name); \ + if (store::get_bool(ns, #name, !c.name) != c.name) return false +#define C6_PUT_INT(name) \ + store::put_int(ns, #name, c.name); \ + if (store::get_int(ns, #name, -1) != c.name) return false + C6_PUT_BOOL(espnow_enabled); + C6_PUT_BOOL(espnow_team_discovery); + C6_PUT_INT(espnow_channel); + C6_PUT_INT(espnow_beacon_interval_ms); + C6_PUT_BOOL(wifi_enabled); + C6_PUT_BOOL(wifi_sta_enabled); + C6_PUT_BOOL(wifi_ap_enabled); + C6_PUT_BOOL(wifi_persist_credentials); + C6_PUT_INT(wifi_sta_channel); + C6_PUT_INT(wifi_ap_channel); +#undef C6_PUT_BOOL +#undef C6_PUT_INT + // Caller-owned text scratch; no automatic credential array. + const auto text = [&](const char* key, const char* value) + { + size_t length = 0; + auto* scratch = reinterpret_cast(s_state.timezone_tzdef); + return store::put_string(ns, key, value) && store::get_string_into(ns, key, scratch, sizeof(s_state.timezone_tzdef), &length) && !std::strcmp(scratch, value); + }; + return text("wifi_sta_ssid", c.wifi_sta_ssid) && text("wifi_sta_password", c.wifi_sta_password) && + text("wifi_ap_ssid", c.wifi_ap_ssid) && text("wifi_ap_password", c.wifi_ap_password); +} +#endif } // namespace void beginRead(bool applying) @@ -716,6 +849,10 @@ tms::RecordConsumeResult consumeRecord(void*, const tms::RecordReader& reader) { return tms::RecordConsumeResult::Invalid; } +#if defined(CONFIG_IDF_TARGET_ESP32P4) + const auto companion = tms::c6::consume(s_state.companion, reader); + if (companion != tms::RecordConsumeResult::Unhandled) return companion; +#endif if (key_equals(reader, "ui.screen.timeout_ms")) { return mark_once(SeenScreenTimeout) && reader.u32(&s_state.screen_timeout_ms) @@ -918,14 +1055,56 @@ tms::RecordConsumeResult consumeRecord(void*, const tms::RecordReader& reader) return tms::RecordConsumeResult::Unhandled; } +bool write_base_scalar_settings() +{ + namespace store = ::platform::ui::settings_store; + // The scalar API is void. Compare read-back, with an unequal fallback, + // so a failed write/open cannot be reported as successful application. + // Equal existing values are sufficient; no extra retained buffers. + const auto integer = [](const char* ns, const char* key, int value) + { + store::put_int(ns, key, value); + return store::get_int(ns, key, value == 0 ? 1 : 0) == value; + }; + const auto boolean = [](const char* ns, const char* key, bool value) + { + store::put_bool(ns, key, value); + return store::get_bool(ns, key, !value) == value; + }; + const auto unsigned_integer = [](const char* ns, const char* key, uint32_t value) + { + store::put_uint(ns, key, value); + return store::get_uint(ns, key, value ^ UINT32_C(1)) == value; + }; + return unsigned_integer(kSettingsNs, "screen_timeout", s_state.screen_timeout_ms) && + integer(kSettingsNs, "screen_brightness", s_state.screen_brightness) && + integer(kSettingsNs, "speaker_volume", s_state.speaker_volume) && + boolean(kSettingsNs, "vibration_enabled", s_state.vibration_enabled) && + integer(kSettingsNs, "timezone_profile", s_state.timezone_profile) && + integer(kSettingsNs, "timezone_offset", s_state.timezone_offset_minutes) && + integer(kSettingsNs, "chat_message_alerts", s_state.message_alerts) && + integer(kSettingsNs, "chat_contact_alerts", s_state.contact_alerts) && + boolean(kSettingsNs, ::platform::ui::auto_reply::kEnabledKey, s_state.auto_reply_enabled) && + boolean(kSettingsNs, "adv_debug", s_state.debug_logs_enabled) && + unsigned_integer(kPowerNs, "gauge_design_mah", s_state.gauge_design_mah) && + unsigned_integer(kPowerNs, "gauge_full_mah", s_state.gauge_full_mah); +} + bool finishDocument(void*, bool applying, uint16_t schema_version) { if (!s_state_storage) { return false; } +#if defined(CONFIG_IDF_TARGET_ESP32P4) + if (s_state.companion.seen && !tms::c6::valid(s_state.companion, s_state.wifi_enabled)) return false; +#endif const bool contains_extension_records = - s_state.seen != 0U || s_state.network_seen != 0U || s_state.cellular_seen != 0U; + s_state.seen != 0U || s_state.network_seen != 0U || s_state.cellular_seen != 0U +#if defined(CONFIG_IDF_TARGET_ESP32P4) + || s_state.companion.seen != 0U +#endif + ; if (schema_version == 2U && !contains_extension_records) { // TMSET2 predates the full Settings projection. Its AppConfig is @@ -970,69 +1149,38 @@ bool finishDocument(void*, bool applying, uint16_t schema_version) s_state.enabled_imes[s_state.enabled_imes_len] = '\0'; s_state.auto_reply_text[s_state.auto_reply_text_len] = '\0'; - ::platform::ui::settings_store::put_uint(kSettingsNs, - "screen_timeout", - s_state.screen_timeout_ms); - ::platform::ui::settings_store::put_int(kSettingsNs, - "screen_brightness", - static_cast(s_state.screen_brightness)); - ::platform::ui::settings_store::put_int(kSettingsNs, - "speaker_volume", - static_cast(s_state.speaker_volume)); - ::platform::ui::settings_store::put_bool(kSettingsNs, - "vibration_enabled", - s_state.vibration_enabled); - bool ok = ::platform::ui::settings_store::put_string( - kSettingsNs, "display_locale", s_state.locale) && - ::platform::ui::settings_store::put_string( - kSettingsNs, "enabled_imes", s_state.enabled_imes) && - ::platform::ui::settings_store::put_blob(kSettingsNs, - "timezone_tzdef", - s_state.timezone_tzdef, - s_state.timezone_tzdef_len); - ::platform::ui::settings_store::put_int(kSettingsNs, - "timezone_profile", - static_cast(s_state.timezone_profile)); - ::platform::ui::settings_store::put_int(kSettingsNs, - "timezone_offset", - static_cast(s_state.timezone_offset_minutes)); - ::platform::ui::settings_store::put_int(kSettingsNs, - "chat_message_alerts", - static_cast(s_state.message_alerts)); - ::platform::ui::settings_store::put_int(kSettingsNs, - "chat_contact_alerts", - static_cast(s_state.contact_alerts)); - ::platform::ui::settings_store::put_bool(kSettingsNs, - ::platform::ui::auto_reply::kEnabledKey, - s_state.auto_reply_enabled); - ok = ::platform::ui::settings_store::put_string( - kSettingsNs, - ::platform::ui::auto_reply::kTextKey, - s_state.auto_reply_text) && - ok; - ::platform::ui::settings_store::put_bool(kSettingsNs, - "adv_debug", - s_state.debug_logs_enabled); - ::platform::ui::settings_store::put_uint(kPowerNs, - "gauge_design_mah", - s_state.gauge_design_mah); - ::platform::ui::settings_store::put_uint(kPowerNs, - "gauge_full_mah", - s_state.gauge_full_mah); - ok = ::platform::ui::wifi::replace_saved_profiles( - s_state.wifi_enabled, s_state.wifi_profiles, s_state.wifi_profile_count) && - ok; - if (s_state.network_seen != 0U) + if (!write_base_scalar_settings()) { - ok = ::platform::ui::reticulum_network_config::setFromTms(s_state.network) && ok; + return false; + } + if (!::platform::ui::settings_store::put_string(kSettingsNs, "display_locale", s_state.locale) || + !::platform::ui::settings_store::put_string(kSettingsNs, "enabled_imes", s_state.enabled_imes) || + !::platform::ui::settings_store::put_blob(kSettingsNs, "timezone_tzdef", + s_state.timezone_tzdef, s_state.timezone_tzdef_len) || + !::platform::ui::settings_store::put_string(kSettingsNs, ::platform::ui::auto_reply::kTextKey, + s_state.auto_reply_text) || + !::platform::ui::wifi::replace_saved_profiles(s_state.wifi_enabled, s_state.wifi_profiles, + s_state.wifi_profile_count)) + { + return false; + } + // Stop at the first failed owner. Do not apply later network/radio settings + // after a failed cache write; the original SD document can replay on reboot. + if (s_state.network_seen != 0U && + !::platform::ui::reticulum_network_config::setFromTms(s_state.network)) + { + return false; } #if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) - if (s_state.cellular_seen != 0U) + if (s_state.cellular_seen != 0U && !::platform::ui::a7682e::save_config(s_state.cellular)) { - ok = ::platform::ui::a7682e::save_config(s_state.cellular) && ok; + return false; } #endif - return ok; +#if defined(CONFIG_IDF_TARGET_ESP32P4) + if (!apply_c6_records()) return false; +#endif + return true; } bool writeRecords(void* context, tms::RecordWriter& writer) @@ -1047,54 +1195,49 @@ bool writeRecords(void* context, tms::RecordWriter& writer) { return false; } - if (!writer.u32("ui.screen.timeout_ms", ::platform::ui::screen::timeout_ms()) || - !writer.i32("ui.display.brightness", - ::platform::ui::settings_store::get_int( - kSettingsNs, - "screen_brightness", - static_cast(::platform::ui::device::screen_brightness()))) || - !writer.i32("ui.audio.speaker_volume", - ::platform::ui::settings_store::get_int(kSettingsNs, "speaker_volume", 45)) || - !writer.boolean("ui.haptics.vibration_enabled", - ::platform::ui::settings_store::get_bool( - kSettingsNs, "vibration_enabled", true)) || + if (!write_u32_setting(writer, "ui.screen.timeout_ms", kSettingsNs, + "screen_timeout", ::platform::ui::screen::timeout_ms()) || + !write_i32_setting(writer, "ui.display.brightness", kSettingsNs, + "screen_brightness", ::platform::ui::device::screen_brightness()) || + !write_i32_setting(writer, "ui.audio.speaker_volume", kSettingsNs, + "speaker_volume", 45) || + !write_bool_setting(writer, "ui.haptics.vibration_enabled", kSettingsNs, + "vibration_enabled", true) || !write_text_setting(writer, "ui.locale", kSettingsNs, "display_locale", - reinterpret_cast(s_write_scratch), + reinterpret_cast(s_state.timezone_tzdef), kLocaleBytes) || !write_string_as_blob(writer, "ui.ime.enabled", kSettingsNs, "enabled_imes", kEnabledImesBytes - 1U) || - !writer.i32("ui.timezone.profile", ::platform::ui::time::timezone_profile_id()) || - !writer.i32("ui.timezone.offset_minutes", ::platform::ui::time::timezone_offset_min()) || + !write_i32_setting(writer, "ui.timezone.profile", kSettingsNs, + "timezone_profile", ::platform::ui::time::timezone_profile_id()) || + // Snapshot the saved offset, not a date-dependent DST result. City + // profiles keep their effective runtime calculation independently. + !write_i32_setting(writer, "ui.timezone.offset_minutes", kSettingsNs, + "timezone_offset", ::platform::ui::time::timezone_offset_min()) || !write_blob_setting(writer, "ui.timezone.tzdef", kSettingsNs, "timezone_tzdef") || - !writer.i32("chat.alerts.message", - ::platform::ui::settings_store::get_int(kSettingsNs, "chat_message_alerts", 1)) || - !writer.i32("chat.alerts.contact", - ::platform::ui::settings_store::get_int(kSettingsNs, "chat_contact_alerts", 1)) || - !writer.boolean("chat.auto_reply.enabled", - ::platform::ui::settings_store::get_bool( - kSettingsNs, - ::platform::ui::auto_reply::kEnabledKey, - false)) || + !write_i32_setting(writer, "chat.alerts.message", kSettingsNs, + "chat_message_alerts", 1) || + !write_i32_setting(writer, "chat.alerts.contact", kSettingsNs, + "chat_contact_alerts", 1) || + !write_bool_setting(writer, "chat.auto_reply.enabled", kSettingsNs, + ::platform::ui::auto_reply::kEnabledKey, false) || !write_string_as_blob(writer, "chat.auto_reply.text", kSettingsNs, ::platform::ui::auto_reply::kTextKey, kAutoReplyBytes) || - !writer.boolean("debug.sd_logs_enabled", - ::platform::ui::settings_store::get_bool(kSettingsNs, "adv_debug", false)) || - !writer.u32("power.gauge.design_mah", - ::platform::ui::settings_store::get_uint(kPowerNs, "gauge_design_mah", 1500U)) || - !writer.u32("power.gauge.full_mah", - ::platform::ui::settings_store::get_uint(kPowerNs, "gauge_full_mah", 1500U)) || + !write_bool_setting(writer, "debug.sd_logs_enabled", kSettingsNs, "adv_debug", false) || + !write_u32_setting(writer, "power.gauge.design_mah", kPowerNs, "gauge_design_mah", 1500U) || + !write_u32_setting(writer, "power.gauge.full_mah", kPowerNs, "gauge_full_mah", 1500U) || !write_network_records(writer, *config)) { return false; @@ -1102,8 +1245,9 @@ bool writeRecords(void* context, tms::RecordWriter& writer) bool wifi_enabled = false; std::size_t wifi_count = 0U; - if (!::platform::ui::wifi::visit_saved_profiles( - &wifi_enabled, &wifi_count, snapshot_wifi_profile, nullptr) || + if (!::platform::ui::wifi::read_saved_profiles_into( + &wifi_enabled, &wifi_count, s_state.wifi_profiles, + ::platform::ui::wifi::kMaxSavedProfileCount) || wifi_count > ::platform::ui::wifi::kMaxSavedProfileCount || !writer.boolean("wifi.enabled", wifi_enabled) || !writer.u8("wifi.profile_count", static_cast(wifi_count))) @@ -1129,6 +1273,9 @@ bool writeRecords(void* context, tms::RecordWriter& writer) { return false; } +#endif +#if defined(CONFIG_IDF_TARGET_ESP32P4) + if (!write_c6_records(writer)) return false; #endif return ble_extension::writeRecords(writer); } diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 3ec915c8..20bb6224 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -381,7 +381,6 @@ BackgroundTaskStartResult startBackgroundTasks(LoraBoard* board, chat::IMeshAdap void tickBoundLifecycle(std::size_t max_events) { - app::sd_tms::serviceWorkingConfig(); app::IAppLifecycleFacade& lifecycle = app::lifecycleFacade(); lifecycle.updateCoreServices(); lifecycle.tickEventRuntime(); diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 6b206f91..7a1c9410 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -5,6 +5,10 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" #include "geocaching/protocol/sign_record.h" +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "track_sharing/device_signer.h" +#include "hostlink/identity_backup_export.h" +#endif #include "platform/esp/arduino_common/voice/vmp_pager_session.h" @@ -2115,6 +2119,77 @@ bool LxmfAdapter::respondToSidebandTelemetryRequest( return sent; } +DeviceAuthorResult LxmfAdapter::readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const +{ + generation = 0; + if (!public_key) return DeviceAuthorResult::InvalidInput; + if (!identity_.isReady()) return identity_.identityStorageError() ? DeviceAuthorResult::StorageError : DeviceAuthorResult::Unavailable; + if (!identity_.authorGeneration()) return DeviceAuthorResult::StorageError; + identity_.combinedPublicKey(public_key); + generation = identity_.authorGeneration(); + return DeviceAuthorResult::Ok; +} +DeviceAuthorResult LxmfAdapter::exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + return hostlink::management::exportNativeIdentityBackup(identity_, generation, output, capacity, written); +#else + (void)generation; + (void)output; + (void)capacity; + written = 0; + return DeviceAuthorResult::Unsupported; +#endif +} +DeviceIdentityRestoreResult LxmfAdapter::restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + bool network_busy = delivery_attempt_ledger_.size() != 0 || !network_page_client_.empty() || + propagation_client_.hasPendingUploads() || propagation_client_.pendingDeliveryCount() != 0 || + propagation_client_.state().sync_stage != runtime::PropagationSyncStage::Idle; + link_manager_.forEachSession([&network_busy](const LinkSession& session) + { network_busy |= session.state != LinkState::Closed; }); + if (network_busy) return DeviceIdentityRestoreResult::Unavailable; + return identity_.restoreNativeIdentity(request, work, capacity); +#else + (void)request; + (void)work; + (void)capacity; + return DeviceIdentityRestoreResult::Unsupported; +#endif +} +DeviceAuthorResult LxmfAdapter::signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + return track_sharing::signDeviceTrackManifest(identity_, raw, size, generation, view, workspace, capacity, signature, signature_capacity); +#else + (void)raw; + (void)size; + (void)generation; + (void)view; + (void)workspace; + (void)capacity; + (void)signature; + (void)signature_capacity; + return DeviceAuthorResult::Unsupported; +#endif +} + +DeviceAuthorResult LxmfAdapter::proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, + uint32_t expected_generation, uint8_t* workspace, size_t capacity, + uint8_t* public_key, uint32_t& generation, uint8_t* signature) const +{ + if (!signature || !session || !challenge || !workspace || !expected_generation) return DeviceAuthorResult::InvalidInput; + const auto result = readDeviceAuthorKey(public_key, generation); + if (result != DeviceAuthorResult::Ok) return result; + if (generation != expected_generation) return DeviceAuthorResult::StaleIdentity; + size_t size = 0; + if (!deviceAuthorProofMessage(session, challenge, public_key, generation, workspace, capacity, size)) + return DeviceAuthorResult::InvalidInput; + return identity_.sign(workspace, size, signature) ? DeviceAuthorResult::Ok : DeviceAuthorResult::Unavailable; +} + bool LxmfAdapter::getGeocachingAuthorKey(uint8_t out[64]) const { if (!out) return false; @@ -2124,6 +2199,14 @@ bool LxmfAdapter::getGeocachingAuthorKey(uint8_t out[64]) const return true; } +bool LxmfAdapter::requestTrackDirectoryPath(const uint8_t* destination) +{ + if (!destination || !track_delivery_binding_ || !track_delivery_binding_->announcement) return false; + const auto budget=makeRuntimeBudget(); + if (!budget.phase || !std::strcmp(budget.phase,"call") || !std::strcmp(budget.phase,"nomad") || !std::strcmp(budget.phase,"saver")) return false; + return sendPathRequestForDestination(destination); +} + bool LxmfAdapter::signGeocachingRecord(ByteSpan record, uint8_t* workspace, size_t workspace_capacity, uint8_t* output, size_t output_capacity, size_t& written) { @@ -3409,6 +3492,7 @@ bool LxmfAdapter::pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size void LxmfAdapter::handleRawPacket(const uint8_t* data, size_t size) { + if (!identity_.isReady()) return; interfaces_.handleRawPacket(data, size); } @@ -3464,6 +3548,7 @@ void LxmfAdapter::maintainGatewayDiscovery() void LxmfAdapter::processRuntime() { + if (!identity_.isReady()) return; rtnet::poll(config_); const auto network_status = rtnet::status(); if (network_status.generation != network_config_generation_) @@ -3672,8 +3757,9 @@ bool LxmfAdapter::processOneRadioPacket( return false; } } - const bool geocaching_discovery = runtime::GeocachingDiscoveryBudget::matches( - parsed, geocaching_announcement_handler_ != nullptr, budget); + const bool geocaching_discovery = runtime::GeocachingDiscoveryBudget::matchesApplications( + parsed, geocaching_announcement_handler_ != nullptr, + track_delivery_binding_ && track_delivery_binding_->announcement, budget); const bool gateway_discovery = (budget.allow_propagation_client || budget.allow_public_discovery) && reticulum::NativeGatewayDiscovery::matches(parsed); if (budget.drop_public_discovery && !geocaching_discovery && !gateway_discovery && @@ -4199,6 +4285,25 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len } } + if ((ingest.status == runtime::AnnounceIngestResult::Status::Accepted || cached_discovery) && + track_delivery_binding_ && track_delivery_binding_->announcement && !ingest.local_destination && + ingest.announce.name_hash && ingest.announce.public_key && ingest.announce.app_data && ingest.announce.app_data_len <= 128) + { + uint8_t name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("trailmate", "track.directory", name_hash); + if (hashesEqual(name_hash, ingest.announce.name_hash, sizeof(name_hash))) + { + uint8_t delivery_hash[reticulum::kTruncatedHashSize] = {}; + reticulum::computeNameHash("lxmf", "delivery", name_hash); + reticulum::computeDestinationHash(name_hash, ingest.identity_hash, delivery_hash); + const TrackDirectoryAnnouncementView view{ + {packet.destination_hash, reticulum::kTruncatedHashSize}, {delivery_hash, sizeof(delivery_hash)}, + {ingest.announce.public_key, reticulum::kCombinedPublicKeySize}, {ingest.announce.app_data, ingest.announce.app_data_len}}; + if (deliverVerifiedTrackDirectory(track_delivery_binding_, view)) + rememberPeerIdentity(ingest.announce.public_key, nullptr, false); + } + } + if (ingest.status == runtime::AnnounceIngestResult::Status::Ignored) { if (ingest.local_destination) @@ -9871,6 +9976,20 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( return false; } constexpr char geocaching_type[] = "trailmate.geocache"; + if (custom_result == CustomDataResult::Valid && isTrackCustomType(custom_type)) + { + const CustomDeliveryView view{ + {envelope.source_hash, sizeof(envelope.source_hash)}, + {expected_destination_hash, reticulum::kTruncatedHashSize}, + {delivery_context.message_hash, sizeof(delivery_context.message_hash)}, + custom_data}; + if (!deliverVerifiedTrackForAuthor(track_delivery_binding_, delivery_context.source_unverified, destination_is_group, view, identity_)) + { + release_packet_for_retry(); + return false; + } + return true; + } if (custom_result == CustomDataResult::Valid && custom_type.size == sizeof(geocaching_type) - 1 && std::memcmp(custom_type.data, geocaching_type, custom_type.size) == 0) { diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp index 66e97bd7..26e31ba3 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp @@ -7,11 +7,18 @@ #include "../../internal/blob_store_io.h" #include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "platform/esp/common/config_preferences.h" +#include "platform/esp/common/identity_generation_store.h" +#include "platform/esp/common/identity_generation_recovery.h" +#include "platform/esp/common/identity_key_state.h" +#include "platform/esp/common/identity_native_key_validation.h" +#include "platform/esp/common/identity_seed_key_conversion.h" #include "platform/esp/common/reticulum_crypto_compat.h" #include "platform/esp/common/reticulum_runtime_compat.h" +#include "hostlink/identity_restore_request.h" #include -#include +#include namespace chat::lxmf { @@ -60,18 +67,49 @@ bool isAllZero(const uint8_t* data, size_t len) bool LxmfIdentity::init() { + if (!ready_ && identity_storage_error_ && author_generation_ == UINT32_MAX) return false; if (ready_) { return true; } + identity_storage_error_ = false; + author_generation_ = 0; if (loadFromPrefs()) { + if (!author_generation_ && !platform::esp::common::syncIdentityGeneration(identity_hash_.data(), author_generation_)) + { + identity_storage_error_ = true; + return false; + } ready_ = true; return true; } + if (!platform::esp::common::lxmfIdentityKeysAbsent()) + { + identity_storage_error_ = true; + author_generation_ = 0; + Serial.println("[LXMF][Identity] existing identity storage requires repair; refusing regeneration"); + return false; + } return generateAndPersist(); } +bool LxmfIdentity::initExisting() +{ + if (!ready_ && identity_storage_error_ && author_generation_ == UINT32_MAX) return false; + if (ready_) return true; + identity_storage_error_ = false; + author_generation_ = 0; + if (!loadFromPrefs()) return false; + if (!author_generation_ && !platform::esp::common::syncIdentityGeneration(identity_hash_.data(), author_generation_)) + { + identity_storage_error_ = true; + return false; + } + ready_ = true; + return true; +} + void LxmfIdentity::combinedPublicKey(uint8_t out_key[reticulum::kCombinedPublicKeySize]) const { if (!out_key) @@ -121,34 +159,44 @@ bool LxmfIdentity::deriveSharedSecret(const uint8_t peer_public_key[kEncPubKeySi bool LxmfIdentity::loadFromPrefs() { - std::vector blob; - if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kEncPubKey, blob) || - blob.size() != enc_pub_.size()) + using namespace platform::esp::common; + // Probe first: legacy-only devices incur no new key-buffer allocation. + nvs_handle_t record_handle = 0; + const auto opened = nvs_open(kPrefsNs, NVS_READONLY, &record_handle); + if (opened != ESP_OK && opened != ESP_ERR_NVS_NOT_FOUND) return false; + size_t record_size = 0; + const auto record_status = opened == ESP_OK ? nvs_get_blob(record_handle, "native_v1", nullptr, &record_size) : ESP_ERR_NVS_NOT_FOUND; + if (opened == ESP_OK) nvs_close(record_handle); + if (record_status != ESP_ERR_NVS_NOT_FOUND) + { + if (record_status != ESP_OK || record_size != kIdentityRecordBytes) return false; + // Startup-only transient ownership; never a large automatic stack object. + auto* record = static_cast(std::malloc(kIdentityRecordWorkBytes)); + if (!record) return false; + struct ReleaseRecord + { + uint8_t* record; + ~ReleaseRecord() { clearIdentityRecordWork(record, kIdentityRecordWorkBytes); std::free(record); } + } release_record{record}; + if (readIdentityRecord(record, kIdentityRecordWorkBytes) != IdentityRecordResult::Ready || + !validateNativeIdentityKeys(record + 12, record + 76, record + kIdentityRecordBytes, 32)) return false; + std::memcpy(enc_pub_.data(), record + 12, enc_pub_.size()); + std::memcpy(sig_pub_.data(), record + 44, sig_pub_.size()); + std::memcpy(enc_priv_.data(), record + 76, enc_priv_.size()); + std::memcpy(sig_priv_.data(), record + 108, sig_priv_.size()); + recomputeDerivedFields(); + return recoverIdentityGeneration(identity_hash_.data(), identityRecordU32(record + 8), author_generation_); + } + platform::esp::common::ConfigPreferences preferences; + if (!preferences.begin(kPrefsNs, true) || + preferences.getBytes(kEncPubKey, enc_pub_.data(), enc_pub_.size()) != enc_pub_.size() || + preferences.getBytes(kEncPrivKey, enc_priv_.data(), enc_priv_.size()) != enc_priv_.size() || + preferences.getBytes(kSigPubKey, sig_pub_.data(), sig_pub_.size()) != sig_pub_.size() || + preferences.getBytes(kSigPrivKey, sig_priv_.data(), sig_priv_.size()) != sig_priv_.size()) { return false; } - memcpy(enc_pub_.data(), blob.data(), enc_pub_.size()); - - if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kEncPrivKey, blob) || - blob.size() != enc_priv_.size()) - { - return false; - } - memcpy(enc_priv_.data(), blob.data(), enc_priv_.size()); - - if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kSigPubKey, blob) || - blob.size() != sig_pub_.size()) - { - return false; - } - memcpy(sig_pub_.data(), blob.data(), sig_pub_.size()); - - if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kSigPrivKey, blob) || - blob.size() != sig_priv_.size()) - { - return false; - } - memcpy(sig_priv_.data(), blob.data(), sig_priv_.size()); + preferences.end(); if (isAllZero(enc_pub_.data(), enc_pub_.size()) || isAllZero(enc_priv_.data(), enc_priv_.size()) || @@ -158,14 +206,25 @@ bool LxmfIdentity::loadFromPrefs() return false; } - uint8_t derived_sig_pub[kSigPubKeySize] = {}; - ed25519_derive_pub(derived_sig_pub, sig_priv_.data()); - if (memcmp(derived_sig_pub, sig_pub_.data(), sizeof(derived_sig_pub)) != 0) + uint8_t key_work[kSigPubKeySize] = {}; + // Keep the existing small work area. No combined key buffers on task stack. + // The combined validator is used by restore; storage has separate arrays. + if (!hostlink::validateExpandedSigningKey(sig_pub_.data(), sig_priv_.data(), key_work, sizeof(key_work))) { - memcpy(sig_pub_.data(), derived_sig_pub, sizeof(derived_sig_pub)); - saveToPrefs(); + return false; } + // dh1() stores an already-clamped native scalar; eval() does not clamp it. + if ((enc_priv_[0] & 7) != 0 || (enc_priv_[31] & 0xc0) != 0x40 || + !Curve25519::eval(key_work, enc_priv_.data(), nullptr)) + { + memset(key_work, 0, sizeof(key_work)); + return false; + } + const bool enc_matches = memcmp(key_work, enc_pub_.data(), sizeof(key_work)) == 0; + memset(key_work, 0, sizeof(key_work)); + if (!enc_matches) return false; + recomputeDerivedFields(); return true; } @@ -215,6 +274,8 @@ bool LxmfIdentity::generateAndPersist() recomputeDerivedFields(); ready_ = saveToPrefs(); + identity_storage_error_ = !ready_; + if (ready_) (void)platform::esp::common::syncIdentityGeneration(identity_hash_.data(), author_generation_); return ready_; } @@ -227,7 +288,68 @@ void LxmfIdentity::recomputeDerivedFields() uint8_t name_hash[reticulum::kNameHashSize] = {}; reticulum::computeNameHash("lxmf", "delivery", name_hash); reticulum::computeDestinationHash(name_hash, identity_hash_.data(), destination_hash_.data()); - node_id_ = reticulum::nodeIdFromDestinationHash(destination_hash_.data()); +} + +DeviceIdentityRestoreResult LxmfIdentity::restoreNativeIdentity(const hostlink::management::IdentityRestoreView& request, + uint8_t* work, size_t capacity) +{ + using Result = DeviceIdentityRestoreResult; + using namespace platform::esp::common; + constexpr size_t work_size = 352; + if (!ready_ || identity_storage_error_ || !author_generation_) return Result::Unavailable; + if (!work || capacity < work_size || request.expected_public.size != 64 || !request.expected_public.data || + request.public_key.size != 64 || !request.public_key.data || request.format > 1 || request.private_key.size != (request.format == 0 ? 96U : 64U) || !request.private_key.data || + request.identity_hash.size != 16 || !request.identity_hash.data || request.delivery.size != 16 || !request.delivery.data) return Result::InvalidInput; + const auto start = reinterpret_cast(work), self = reinterpret_cast(this); + if (start >= self ? start - self < sizeof(*this) : self - start < work_size) return Result::InvalidInput; + const auto overlaps = [start](const uint8_t* input, size_t size) + { + const auto source = reinterpret_cast(input); + return start >= source ? start - source < size : source - start < work_size; + }; + if (overlaps(request.expected_public.data, 64) || overlaps(request.payload.data, request.payload.size) || + overlaps(request.signature.data, request.signature.size) || overlaps(request.public_key.data, 64) || + overlaps(request.private_key.data, request.private_key.size) || overlaps(request.identity_hash.data, 16) || overlaps(request.delivery.data, 16)) return Result::InvalidInput; + struct Clear { uint8_t* work; ~Clear() { clearIdentityRecordWork(work, work_size); } } clear{work}; + if (request.expected_generation != author_generation_ || + std::memcmp(request.expected_public.data, enc_pub_.data(), 32) || + std::memcmp(request.expected_public.data + 32, sig_pub_.data(), 32)) return Result::StaleIdentity; + if (!hostlink::management::verifyIdentityRestoreSignature(request, work, work_size)) return Result::InvalidInput; + const uint8_t* private_key = request.private_key.data; + uint8_t* crypto_work = work; + size_t crypto_capacity = work_size; + if (request.format == 1) + { + if (!convertSeedIdentityKeys(request.public_key.data, request.private_key.data, work, 96, work + 96, 32)) return Result::InvalidInput; + private_key = work; + crypto_work = work + 96; + crypto_capacity = work_size - 96; + } + if (!validateNativeIdentityKeys(request.public_key.data, private_key, crypto_work, 32)) return Result::InvalidInput; + reticulum::computeIdentityHash(request.public_key.data, crypto_work); + if (std::memcmp(crypto_work, request.identity_hash.data, 16)) return Result::InvalidInput; + reticulum::computeNameHash("lxmf", "delivery", crypto_work + 16); + reticulum::computeDestinationHash(crypto_work + 16, crypto_work, crypto_work + 32); + if (std::memcmp(crypto_work + 32, request.delivery.data, 16)) return Result::InvalidInput; + const auto stored = commitIdentityRecord(request.expected_generation, author_generation_, request.public_key.data, + private_key, crypto_work, crypto_capacity, validateNativeIdentityKeys); + if (stored == IdentityRecordResult::Committed || stored == IdentityRecordResult::Indeterminate) + { + ready_ = false; + identity_storage_error_ = true; + author_generation_ = UINT32_MAX; // Existing field: reboot-required latch. + clearIdentityRecordWork(enc_priv_.data(), enc_priv_.size()); + clearIdentityRecordWork(sig_priv_.data(), sig_priv_.size()); + return stored == IdentityRecordResult::Committed ? Result::CommittedRebootRequired : Result::IndeterminateRebootRequired; + } + return stored == IdentityRecordResult::TargetChanged ? Result::StaleIdentity : + stored == IdentityRecordResult::Invalid ? Result::InvalidInput : Result::StorageError; +} + +bool LxmfIdentity::advanceAuthorGeneration() +{ + if (!ready_) return false; + return platform::esp::common::syncIdentityGeneration(identity_hash_.data(), author_generation_, true); } } // namespace chat::lxmf diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp index ab3e0139..a896022b 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp @@ -9,6 +9,13 @@ #include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h" #include "platform/ui/reticulum_call_runtime.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/local_identity_access.h" +#include "geocaching/protocol/sign_record.h" +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "hostlink/identity_backup_export.h" +#include "platform/esp/common/radio_identity_management.h" +#include "track_sharing/device_signer.h" +#endif namespace chat { @@ -70,6 +77,7 @@ bool MeshAdapterRouter::installServiceBackend(MeshProtocol protocol, std::unique void MeshAdapterRouter::applyGeocachingHandlers(MeshProtocol protocol, IMeshAdapter* backend) { if (!backend || (protocol != MeshProtocol::Reticulum && protocol != MeshProtocol::RNode)) return; + if (auto* tracks = backend->trackTransport()) tracks->setTrackDeliveryBinding(track_delivery_binding_); auto* service = backend->geocachingTransport(); if (!service) return; service->setGeocachingAnnouncementHandler(geocaching_announcement_, geocaching_context_); @@ -95,6 +103,84 @@ bool MeshAdapterRouter::processServiceQueue(MeshProtocol protocol) return lock.locked() && core_.processServiceQueue(protocol); } +bool MeshAdapterRouter::bindTrackDelivery(const lxmf::TrackDeliveryBinding* binding) +{ + // Legacy entry is acquire-only: removal/replacement must name its owner. + return replaceTrackDelivery(nullptr,binding) == TrackBindingChange::Applied; +} + +TrackBindingChange MeshAdapterRouter::replaceTrackDelivery(const lxmf::TrackDeliveryBinding* expected, const lxmf::TrackDeliveryBinding* replacement) +{ + LockGuard lock(mutex_, 0); + if (!lock.locked()) return TrackBindingChange::Busy; + if (track_delivery_binding_ != expected) return TrackBindingChange::Conflict; + if (expected == replacement) return TrackBindingChange::Applied; + track_delivery_binding_ = replacement; + auto* backend = core_.backendForProtocol(MeshProtocol::Reticulum); + if (backend && backend->trackTransport()) backend->trackTransport()->setTrackDeliveryBinding(replacement); + return TrackBindingChange::Applied; +} + +TrackBindingChange MeshAdapterRouter::visitTrackDelivery(const lxmf::TrackDeliveryBinding* expected, uint8_t* author, uint32_t& generation, + TrackBindingVisit visit, void* context) +{ + if (!expected || !author || !visit || !expected->expected_local_author || !expected->expected_author_generation) return TrackBindingChange::Conflict; + const auto a = reinterpret_cast(author), b = reinterpret_cast(expected->expected_local_author); + if (a <= b ? b - a < 64 : a - b < 64) return TrackBindingChange::Conflict; + LockGuard lock(mutex_, 0); + if (!lock.locked()) return TrackBindingChange::Busy; + if (track_delivery_binding_ != expected) return TrackBindingChange::Conflict; + const auto* backend = deviceAuthorBackendLocked(); + if (!backend) return TrackBindingChange::Busy; + const auto read = backend->readDeviceAuthorKey(author, generation); + if (read != DeviceAuthorResult::Ok) return read == DeviceAuthorResult::Unavailable ? TrackBindingChange::Busy : TrackBindingChange::Conflict; + if (generation != expected->expected_author_generation || std::memcmp(author, expected->expected_local_author, 64)) return TrackBindingChange::Conflict; + visit(context); + return TrackBindingChange::Applied; +} + +bool MeshAdapterRouter::trackLocalDestination(uint8_t* output) +{ + if (!output) return false; + std::memset(output,0,16); + LockGuard lock(mutex_,0); + if (!lock.locked()) return false; + const auto* backend=deviceAuthorBackendLocked(); + return backend&&backend->isReady()&&geocachingDestinationLocked(output); +} + +bool MeshAdapterRouter::requestTrackDirectoryPath(const uint8_t* destination) +{ + if(!destination)return false; + uint8_t bits=0;for(size_t i=0;i<16;++i)bits|=destination[i];if(!bits)return false; + LockGuard lock(mutex_,0); + if(!lock.locked()||!track_delivery_binding_||!deviceAuthorBackendLocked())return false; + auto* backend=core_.backendForProtocol(MeshProtocol::Reticulum); + auto* transport=backend&&backend->isReady()?backend->trackTransport():nullptr; + return transport&&transport->requestDirectoryPath(destination); +} + +MeshSendResult MeshAdapterRouter::sendTrackData(const uint8_t destination[16], lxmf::ByteSpan data, + const uint8_t expected_author[64], uint32_t expected_generation, + bool response, std::array* accepted_hash) +{ + if (accepted_hash) accepted_hash->fill(0); + if (!destination || !data.data || !data.size || data.size > 8192 || !expected_author || !expected_generation) + return MeshSendResult::fail(MeshOperationFailure::InvalidInput); + LockGuard lock(mutex_, 0); + if (!lock.locked()) return MeshSendResult::fail(MeshOperationFailure::Busy); + if (!deviceAuthorBackendLocked()) return MeshSendResult::fail(MeshOperationFailure::NotReady); + auto* backend = core_.backendForProtocol(MeshProtocol::Reticulum); + auto* transport = backend ? backend->trackTransport() : nullptr; + if (!backend || !backend->isReady() || !transport) return MeshSendResult::fail(MeshOperationFailure::NotReady); + uint8_t current[64]{}; + uint32_t generation = 0; + if (backend->readDeviceAuthorKey(current, generation) != DeviceAuthorResult::Ok || + generation != expected_generation || std::memcmp(current, expected_author, sizeof(current))) + return MeshSendResult::fail(MeshOperationFailure::LocalIdentityMissing); + return transport->sendTrackData(destination, data, response, accepted_hash); +} + std::unique_ptr MeshAdapterRouter::takeServiceBackend(MeshProtocol protocol, const IMeshAdapter* expected) { LockGuard lock(mutex_, 0); @@ -159,14 +245,139 @@ IGeocachingTransport* MeshAdapterRouter::geocachingTransportLocked() return backend ? backend->geocachingTransport() : nullptr; } +const IMeshAdapter* MeshAdapterRouter::deviceAuthorBackendLocked() const +{ + if (lxmf::LocalIdentityLease::rebootRequired()) return nullptr; + const auto active = core_.backendProtocol(); + if (active != MeshProtocol::Reticulum && active != MeshProtocol::RNode && + !core_.isServiceBackend(MeshProtocol::Reticulum)) return nullptr; + return core_.backendForProtocol(MeshProtocol::Reticulum); +} + +DeviceAuthorResult MeshAdapterRouter::readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const +{ + generation = 0; + if (lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + LockGuard lock(mutex_, 0); + if (!lock.locked()) return DeviceAuthorResult::Unavailable; + const auto* backend = deviceAuthorBackendLocked(); + if (backend) return backend->readDeviceAuthorKey(public_key, generation); + // Establish the device account on first USB use without starting RF. + // init() only generates when all identity key storage is absent; partial + // or unreadable material is refused by the existing persistence policy. + lxmf::LocalIdentityLease identity([](lxmf::LxmfIdentity& value) { return value.init(); }); + return identity.read(public_key, generation); +} +DeviceAuthorResult MeshAdapterRouter::exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const +{ + written = 0; + if (lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + LockGuard lock(mutex_, 0); + if (!lock.locked()) return DeviceAuthorResult::Unavailable; + const auto* backend = deviceAuthorBackendLocked(); + if (backend) return backend->exportDeviceIdentityBackup(generation, output, capacity, written); +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + lxmf::LocalIdentityLease identity; + return identity.get() ? hostlink::management::exportNativeIdentityBackup(*identity.get(), generation, output, capacity, written) : identity.result(); +#else + return DeviceAuthorResult::Unsupported; +#endif +} +DeviceIdentityRestoreResult MeshAdapterRouter::restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) +{ + LockGuard lock(mutex_, 0); + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return DeviceIdentityRestoreResult::Unavailable; + auto* backend = deviceAuthorBackendLocked() ? core_.backendForProtocol(MeshProtocol::Reticulum) : nullptr; + DeviceIdentityRestoreResult result; + if (backend) result = backend->restoreDeviceIdentity(request, work, capacity); + else + { + lxmf::LocalIdentityLease identity; + result = identity.get() ? identity.get()->restoreNativeIdentity(request, work, capacity) : DeviceIdentityRestoreResult::Unavailable; + } + lxmf::LocalIdentityLease::noteRestore(result); + return result; +} +DeviceAuthorResult MeshAdapterRouter::readRadioIdentityInfo(uint8_t kind, uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const +{ + written = 0; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + LockGuard lock(mutex_, 0); + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + lxmf::LocalIdentityLease identity; + return identity.get() ? platform::esp::common::readRadioIdentityInfo(*identity.get(), kind, generation, output, capacity, written) : identity.result(); +#else + return DeviceAuthorResult::Unsupported; +#endif +} +DeviceAuthorResult MeshAdapterRouter::exportRadioIdentityBackup(uint8_t kind, uint32_t generation, uint8_t* output, size_t capacity, size_t& written, const uint8_t* session, const uint8_t* request) const +{ + written = 0; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + LockGuard lock(mutex_, 0); + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + lxmf::LocalIdentityLease identity; + return identity.get() ? platform::esp::common::exportRadioIdentityBackup(*identity.get(), kind, generation, output, capacity, written, session, request) : identity.result(); +#else + return DeviceAuthorResult::Unsupported; +#endif +} +DeviceIdentityRestoreResult MeshAdapterRouter::restoreRadioIdentity(const hostlink::management::RadioIdentityRestoreView& request, uint8_t* work, size_t capacity) +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + LockGuard lock(mutex_, 0); + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return DeviceIdentityRestoreResult::Unavailable; + lxmf::LocalIdentityLease identity; + const auto result = identity.get() ? platform::esp::common::restoreRadioIdentity(*identity.get(), request, work, capacity) : DeviceIdentityRestoreResult::Unavailable; + lxmf::LocalIdentityLease::noteRestore(result); + return result; +#else + return DeviceIdentityRestoreResult::Unsupported; +#endif +} +DeviceAuthorResult MeshAdapterRouter::signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const +{ + if (lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + LockGuard lock(mutex_, 0); + if (!lock.locked()) return DeviceAuthorResult::Unavailable; + const auto* backend = deviceAuthorBackendLocked(); + if (backend) return backend->signTrackManifest(raw, size, generation, view, workspace, capacity, signature, signature_capacity); +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + lxmf::LocalIdentityLease identity; + return identity.get() ? track_sharing::signDeviceTrackManifest(*identity.get(), raw, size, generation, view, workspace, capacity, signature, signature_capacity) : identity.result(); +#else + return DeviceAuthorResult::Unsupported; +#endif +} + +DeviceAuthorResult MeshAdapterRouter::proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, + uint32_t expected_generation, uint8_t* workspace, size_t capacity, + uint8_t* public_key, uint32_t& generation, uint8_t* signature) const +{ + generation = 0; + if (lxmf::LocalIdentityLease::rebootRequired()) return DeviceAuthorResult::Unavailable; + LockGuard lock(mutex_, 0); + if (!lock.locked()) return DeviceAuthorResult::Unavailable; + const auto* backend = deviceAuthorBackendLocked(); + // Resolve and sign under one lock; no backend pointer survives a switch. + if (backend) return backend->proveDeviceAuthor(session, challenge, expected_generation, workspace, capacity, + public_key, generation, signature); + lxmf::LocalIdentityLease identity; + return identity.prove(session, challenge, expected_generation, workspace, capacity, public_key, generation, signature); +} + bool MeshAdapterRouter::getGeocachingAuthorKey(uint8_t out[64]) { if (!out) return false; std::memset(out, 0, 64); LockGuard lock(mutex_, 0); - if (!lock.locked()) return false; + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return false; auto* service = geocachingTransportLocked(); - return service && service->getGeocachingAuthorKey(out); + if (service) return service->getGeocachingAuthorKey(out); + lxmf::LocalIdentityLease identity; + uint32_t generation = 0; + return identity.read(out, generation) == DeviceAuthorResult::Ok; } bool MeshAdapterRouter::signGeocachingRecord(lxmf::ByteSpan record, uint8_t* workspace, size_t workspace_capacity, @@ -174,9 +385,11 @@ bool MeshAdapterRouter::signGeocachingRecord(lxmf::ByteSpan record, uint8_t* wor { written = 0; LockGuard lock(mutex_, 0); - if (!lock.locked()) return false; + if (!lock.locked() || lxmf::LocalIdentityLease::rebootRequired()) return false; auto* service = geocachingTransportLocked(); - return service && service->signGeocachingRecord(record, workspace, workspace_capacity, output, output_capacity, written); + if (service) return service->signGeocachingRecord(record, workspace, workspace_capacity, output, output_capacity, written); + lxmf::LocalIdentityLease identity; + return identity.get() && ::geocaching::protocol::signGeocacheRecord({record.data, record.size}, *identity.get(), workspace, workspace_capacity, output, output_capacity, written); } MeshProtocol MeshAdapterRouter::backendProtocol() const diff --git a/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_identity.cpp b/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_identity.cpp index bd5ce6a1..afca5893 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_identity.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_identity.cpp @@ -4,8 +4,8 @@ */ #include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h" -#include "../../internal/blob_store_io.h" #include "chat/infra/meshcore/meshcore_identity_crypto.h" +#include "platform/esp/common/radio_identity_work.h" #include "esp_random.h" @@ -22,7 +22,6 @@ constexpr const char* kIdentityPrefsNs = "mc_ident"; constexpr const char* kIdentityPrefsPriv = "priv64"; constexpr const char* kIdentityPrefsPub = "pub32"; constexpr const char* kIdentityPrefsVer = "ver"; -constexpr uint8_t kIdentityPrefsVersion = 1; void fillRandomBytes(uint8_t* out, size_t len) { @@ -54,83 +53,78 @@ bool MeshCoreIdentity::init() { return true; } - if (loadFromPrefs()) + bool absent = false; + if (loadFromPrefs(absent)) { ready_ = true; return true; } - return generateAndPersist(); + memset(priv_.data(), 0, priv_.size()); + memset(pub_.data(), 0, pub_.size()); + return absent && generateAndPersist(); } -bool MeshCoreIdentity::loadFromPrefs() +bool MeshCoreIdentity::loadFromPrefs(bool& absent) { - std::vector priv_blob; - chat::infra::PreferencesBlobMetadata meta; - if (!chat::infra::loadRawBlobFromPreferencesWithMetadata(kIdentityPrefsNs, - kIdentityPrefsPriv, - kIdentityPrefsVer, - nullptr, - priv_blob, - &meta) || - priv_blob.size() != priv_.size()) + using namespace platform::esp::common; + absent = false; + RadioIdentityWork work; + if (!work.data()) return false; + const auto loaded = readRadioIdentityRecord(kIdentityPrefsNs, RadioIdentityKind::MeshCore, work.data(), work.size()); + if (loaded == IdentityRecordResult::Ready) { - return false; - } - - memcpy(priv_.data(), priv_blob.data(), priv_.size()); - - std::vector pub_blob; - const bool have_pub_blob = chat::infra::loadRawBlobFromPreferences(kIdentityPrefsNs, - kIdentityPrefsPub, - pub_blob) && - (pub_blob.size() == pub_.size()); - if (have_pub_blob) - { - memcpy(pub_.data(), pub_blob.data(), pub_.size()); + if (work.data()[5]) + { + absent = true; + return false; + } + if (meshcoreIsZeroBytes(work.data() + 44, 32) || !meshcoreDerivePublicKey(work.data() + 44, work.data() + kRadioIdentityRecordBytes) || + std::memcmp(work.data() + 12, work.data() + kRadioIdentityRecordBytes, 32)) return false; + std::memcpy(pub_.data(), work.data() + 12, pub_.size()); + std::memcpy(priv_.data(), work.data() + 44, priv_.size()); + return true; } + if (loaded != IdentityRecordResult::Absent) return false; + const auto legacy = readLegacyRadioIdentity(kIdentityPrefsNs, kIdentityPrefsPub, kIdentityPrefsPriv, + RadioIdentityKind::MeshCore, work.data(), work.size(), kIdentityPrefsVer); + absent = legacy == IdentityRecordResult::Absent; + if (legacy != IdentityRecordResult::Ready) return false; + memcpy(priv_.data(), work.data() + 44, priv_.size()); + memcpy(pub_.data(), work.data() + 12, pub_.size()); uint8_t derived_pub[kPubKeySize] = {}; - if (!meshcoreDerivePublicKey(priv_.data(), derived_pub)) + if (meshcoreIsZeroBytes(priv_.data(), 32) || !meshcoreDerivePublicKey(priv_.data(), derived_pub)) { memset(priv_.data(), 0, priv_.size()); return false; } - bool needs_save = !have_pub_blob; - if (!needs_save && memcmp(pub_.data(), derived_pub, sizeof(derived_pub)) != 0) - { - needs_save = true; - } + const bool needs_save = memcmp(pub_.data(), derived_pub, sizeof(derived_pub)) != 0; memcpy(pub_.data(), derived_pub, sizeof(derived_pub)); if (needs_save) { - saveToPrefs(); + if (!saveToPrefs()) + { + memset(priv_.data(), 0, priv_.size()); + memset(pub_.data(), 0, pub_.size()); + return false; + } } return true; } bool MeshCoreIdentity::saveToPrefs() const { - chat::infra::PreferencesBlobMetadata meta; - meta.len = priv_.size(); - meta.has_version = true; - meta.version = kIdentityPrefsVersion; - - const bool priv_ok = chat::infra::saveRawBlobToPreferencesWithMetadata( - kIdentityPrefsNs, - kIdentityPrefsPriv, - kIdentityPrefsVer, - nullptr, - priv_.data(), - priv_.size(), - &meta, - false); - const bool pub_ok = chat::infra::saveRawBlobToPreferences(kIdentityPrefsNs, - kIdentityPrefsPub, - pub_.data(), - pub_.size()); - return priv_ok && pub_ok; + using namespace platform::esp::common; + RadioIdentityWork work; + if (!work.data()) return false; + const auto prior = readRadioIdentityRecord(kIdentityPrefsNs, RadioIdentityKind::MeshCore, work.data(), work.size()); + if (prior != IdentityRecordResult::Ready && prior != IdentityRecordResult::Absent) return false; + const auto generation = prior == IdentityRecordResult::Ready ? identityRecordU32(work.data() + 8) : 0; + return commitRadioIdentityRecord(kIdentityPrefsNs, RadioIdentityKind::MeshCore, generation, pub_.data(), priv_.data(), work.data(), work.size(), + [](const uint8_t* pub, const uint8_t* priv, uint8_t* scratch, size_t) + { return !meshcoreIsZeroBytes(priv, 32) && meshcoreDerivePublicKey(priv, scratch) && !std::memcmp(pub, scratch, 32); }) == IdentityRecordResult::Committed; } bool MeshCoreIdentity::generateAndPersist() @@ -214,16 +208,35 @@ bool MeshCoreIdentity::importPrivateKey(const uint8_t in_priv[kPrivKeySize]) } uint8_t derived_pub[kPubKeySize] = {}; - if (!meshcoreDerivePublicKey(in_priv, derived_pub)) + if (meshcoreIsZeroBytes(in_priv, 32) || !meshcoreDerivePublicKey(in_priv, derived_pub)) { return false; } + using namespace platform::esp::common; + RadioIdentityWork work; + if (!work.data()) return false; + const auto prior = readRadioIdentityRecord(kIdentityPrefsNs, RadioIdentityKind::MeshCore, work.data(), work.size()); + if (prior != IdentityRecordResult::Ready && prior != IdentityRecordResult::Absent) return false; + const auto generation = prior == IdentityRecordResult::Ready ? identityRecordU32(work.data() + 8) : 0; + const auto saved = commitRadioIdentityRecord(kIdentityPrefsNs, RadioIdentityKind::MeshCore, generation, + derived_pub, in_priv, work.data(), work.size(), + [](const uint8_t* pub, const uint8_t* priv, uint8_t* scratch, size_t) + { return !meshcoreIsZeroBytes(priv, 32) && meshcoreDerivePublicKey(priv, scratch) && !std::memcmp(pub, scratch, 32); }); + if (saved != IdentityRecordResult::Committed) + { + if (saved == IdentityRecordResult::Indeterminate) + { + ready_ = false; + memset(priv_.data(), 0, priv_.size()); + memset(pub_.data(), 0, pub_.size()); + } + return false; + } memcpy(priv_.data(), in_priv, priv_.size()); memcpy(pub_.data(), derived_pub, sizeof(derived_pub)); ready_ = true; - - return saveToPrefs(); + return true; } } // namespace meshcore diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index 86174154..0361eb28 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -575,6 +575,33 @@ bool MtAdapter::sendAppDataNow(ChannelId channel, uint32_t portnum, return false; } + if (portnum == meshtastic_PortNum_POSITION_APP) + { + const auto& app_config = app::configFacade().readConfig(); + const bool secondary_position = out_channel == ChannelId::SECONDARY; + if (!runtime::MeshtasticPositionCore::applyChannelPrecision( + scratch.decoded, + secondary_position ? app_config.secondary_channel_has_module_settings + : app_config.primary_channel_has_module_settings, + secondary_position ? app_config.secondary_channel_position_precision + : app_config.primary_channel_position_precision)) + { + last_send_error_ = meshtastic_Routing_Error_BAD_REQUEST; + return false; + } + // All routes (broadcast, channel direct, PKI and MQTT mirror) consume + // this same normalized payload, never the original coordinates. + payload = scratch.decoded.payload.bytes; + len = scratch.decoded.payload.size; + pb_ostream_t normalized = pb_ostream_from_buffer(data_buffer.data(), data_buffer.size()); + if (!pb_encode(&normalized, meshtastic_Data_fields, &scratch.decoded)) + { + last_send_error_ = meshtastic_Routing_Error_TOO_LARGE; + return false; + } + data_size = normalized.bytes_written; + } + size_t wire_size = wire_buffer.size(); uint8_t channel_hash = @@ -743,17 +770,9 @@ bool MtAdapter::sendAppDataNow(ChannelId channel, uint32_t portnum, trackPendingAck(msg_id, dest_node, out_channel, channel_hash, wire_buffer.data(), wire_size); } auto& mqtt_data = scratch.decoded; - std::memset(&mqtt_data, 0, sizeof(mqtt_data)); - mqtt_data.portnum = static_cast(portnum); - mqtt_data.want_response = effective_want_response; mqtt_data.dest = dest_node; mqtt_data.source = node_id_; mqtt_data.has_bitfield = true; - mqtt_data.payload.size = std::min(len, sizeof(mqtt_data.payload.bytes)); - if (mqtt_data.payload.size > 0) - { - memcpy(mqtt_data.payload.bytes, payload, mqtt_data.payload.size); - } queueMqttProxyPublishFromWire(wire_buffer.data(), wire_size, use_pki ? nullptr : &mqtt_data, out_channel); @@ -808,6 +827,19 @@ bool MtAdapter::sendMeshPacket(const meshtastic_MeshPacket& packet) data.source = node_id_; data.has_bitfield = true; + const auto& app_config = app::configFacade().readConfig(); + const bool secondary_position = packet.channel == 1; + if (!runtime::MeshtasticPositionCore::applyChannelPrecision( + data, + secondary_position ? app_config.secondary_channel_has_module_settings + : app_config.primary_channel_has_module_settings, + secondary_position ? app_config.secondary_channel_position_precision + : app_config.primary_channel_position_precision)) + { + last_send_error_ = meshtastic_Routing_Error_BAD_REQUEST; + return false; + } + pb_ostream_t dstream = pb_ostream_from_buffer(data_buf.data(), data_buf.size()); if (!pb_encode(&dstream, meshtastic_Data_fields, &data)) { @@ -4032,6 +4064,13 @@ bool MtAdapter::initPkiKeys() { LORA_LOG("[LORA] PKI stored identity unavailable status=%u\n", static_cast(loaded.failure)); + if (loaded.failure != ::mesh::StoreFailure::NotFound) + { + memset(pki_public_key_.data(), 0, pki_public_key_.size()); + memset(pki_private_key_.data(), 0, pki_private_key_.size()); + pki_ready_ = false; + return false; // Corrupt or unreadable identity must not silently become a new account. + } } if (!have_keys) @@ -4056,6 +4095,9 @@ bool MtAdapter::initPkiKeys() { LORA_LOG("[LORA] PKI key persist failed status=%u\n", static_cast(saved.failure)); + memset(pki_public_key_.data(), 0, pki_public_key_.size()); + memset(pki_private_key_.data(), 0, pki_private_key_.size()); + have_keys = false; } } } diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp index 289a47b0..5d6d35ce 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp @@ -154,11 +154,57 @@ void ReticulumAdapter::setGeocachingAnnouncementHandler( service_->setGeocachingAnnouncementHandler(handler, context); } +DeviceAuthorResult ReticulumAdapter::readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const +{ + return service_ ? service_->readDeviceAuthorKey(public_key, generation) : DeviceAuthorResult::Unavailable; +} +DeviceAuthorResult ReticulumAdapter::exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t capacity, size_t& written) const +{ + written = 0; + return service_ ? service_->exportDeviceIdentityBackup(generation, output, capacity, written) : DeviceAuthorResult::Unavailable; +} +DeviceIdentityRestoreResult ReticulumAdapter::restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& request, uint8_t* work, size_t capacity) +{ + return service_ ? service_->restoreDeviceIdentity(request, work, capacity) : DeviceIdentityRestoreResult::Unavailable; +} +DeviceAuthorResult ReticulumAdapter::signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, + uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const +{ + return service_ ? service_->signTrackManifest(raw, size, generation, view, workspace, capacity, signature, signature_capacity) : DeviceAuthorResult::Unavailable; +} + +DeviceAuthorResult ReticulumAdapter::proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, + uint32_t expected_generation, uint8_t* workspace, size_t capacity, + uint8_t* public_key, uint32_t& generation, uint8_t* signature) const +{ + return service_ ? service_->proveDeviceAuthor(session, challenge, expected_generation, workspace, capacity, + public_key, generation, signature) + : DeviceAuthorResult::Unavailable; +} + bool ReticulumAdapter::getGeocachingAuthorKey(uint8_t out[64]) { return service_->getGeocachingAuthorKey(out); } +MeshSendResult ReticulumAdapter::sendTrackData(const uint8_t destination[16], lxmf::ByteSpan data, + bool response, std::array* accepted_hash) +{ + if (accepted_hash) accepted_hash->fill(0); + if (!service_) return MeshSendResult::fail(MeshOperationFailure::NotReady); + return service_->sendCustomDataToDestination(destination, "trailmate.track.v1", data, response, accepted_hash); +} + +void ReticulumAdapter::setTrackDeliveryBinding(const lxmf::TrackDeliveryBinding* binding) +{ + if (service_) service_->setTrackDeliveryBinding(binding); +} + +bool ReticulumAdapter::requestDirectoryPath(const uint8_t* destination) +{ + return service_&&service_->requestTrackDirectoryPath(destination); +} + bool ReticulumAdapter::signGeocachingRecord(lxmf::ByteSpan record, uint8_t* workspace, size_t workspace_capacity, uint8_t* output, size_t output_capacity, size_t& written) { diff --git a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp index 7657fadd..5b46da61 100644 --- a/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp +++ b/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp @@ -1,8 +1,9 @@ #include "platform/esp/arduino_common/geocaching/browse_runtime.h" -#include "app/app_context.h" +#include "app/app_facade_access.h" #include "geocaching/protocol/record_encoder.h" #include "geocaching/storage/download_recovery.h" #include "geocaching/storage/draft_publication.h" +#include "geocaching/storage/publication_history_job.h" #include "geocaching/storage/record_shape.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h" @@ -22,12 +23,13 @@ #include "platform/esp/arduino_common/geocaching/sd_indexed_stop_task.h" #include "platform/esp/arduino_common/geocaching/sd_publish_port.h" #include "platform/esp/arduino_common/geocaching/stored_reply_receipt.h" +#include "platform/esp/arduino_common/geocaching/web_confirmation_job.h" #include "platform/esp/common/geocaching_crypto.h" #include "platform/esp/common/memory_budget.h" #include "platform/esp/common/meshcore_runtime_compat.h" +#include "platform/esp/common/reticulum_runtime_compat.h" #include "platform/memory/psram_ptr.h" #include "ui/screens/geocaching/geocaching_page_shell.h" -#include #include #include #include @@ -124,6 +126,9 @@ struct Session uint64_t started = 0; uint32_t wait_ms = gc::QueryClient::kReplyTimeoutMs; bool submitted = false; + bool web_operation = false; + bool stop_requested = false; // Existing tail padding; size assertion below forbids growth. + std::array operation_id{}; // Only the existing on-demand PSRAM job grows. ~Publication() { heap_caps_free(bytes); @@ -131,6 +136,8 @@ struct Session heap_caps_free(draft_bytes); } }; + static_assert(sizeof(Publication) == ((offsetof(Publication, submitted) + 1 + alignof(Publication) - 1) / alignof(Publication)) * alignof(Publication) + 16, + "Web operation linkage must cost exactly 16 bytes in the on-demand job"); ::platform::memory::PsramPtr publication; chat::MeshOperationFailure last_dispatch_failure = chat::MeshOperationFailure::None; // Recovery discovers work left by a previous session. New work in this @@ -146,10 +153,12 @@ struct Session bool started = false, done = false, saved = false, editor_fields = true; bool erase = false; bool erase_download = false; + bool web_file = false, web_job = false; std::array cache{}, hash{}; ::platform::memory::PsramPtr removal; ~DraftSave() { heap_caps_free(bytes); } }; + static_assert(sizeof(DraftSave) == (sizeof(void*) == 4 ? 120 : 128), "Web staging grew the existing save job"); ::platform::memory::PsramPtr draft_save; struct DraftRead { @@ -157,8 +166,11 @@ struct Session uint8_t* bytes = nullptr; size_t size = 0; ::ui::geocaching::DraftReadStatus status = ::ui::geocaching::DraftReadStatus::Pending; + uint8_t users = 0; // 1 UI, 2 browser; occupies existing tail padding. + DraftReadResult result = DraftReadResult::Pending; ~DraftRead() { heap_caps_free(bytes); } }; + static_assert(sizeof(DraftRead) == (sizeof(void*) == 4 ? 28 : 40), "Shared draft reading grew its memory budget"); ::platform::memory::PsramPtr draft_read; bool draft_io_reset = false; struct DraftCatalog @@ -211,6 +223,88 @@ struct Session uint64_t current_index_upgrade_deferred_revision = 0; bool checkpoint_recovery_required = false; ::platform::memory::PsramPtr store; + struct WebSignature + { + std::array draft{}; + uint64_t draft_generation = 0; + DeviceAuthorIssuePort port; + gc::AuthorIssue issue; + std::array operation{}, owner{}, transfer{}; + std::array author{}; + std::array hash{}; + uint32_t generation = 0, media = 0, total = 0, offset = 0; + uint64_t started = 0; + uint8_t* record = nullptr; + uint8_t* work = nullptr; + size_t capacity = 0; + storage::SdRuntimeFile file; + Digest digest; + bool issuing = false, failed = false; + WebSignature(chat::MeshAdapterRouter& router, PublicationStore& store, gc::protocol::RecordCrypto& crypto, + const gc::storage::StoredTime& issued, const uint8_t* draft_id = nullptr, uint64_t draft_version = 0) + : draft_generation(draft_version), port(router, store, crypto, issued, + draft_id ? gc::ByteView{draft.data(), draft.size()} : gc::ByteView{}, draft_version, draft_id != nullptr), + issue(port) + { + if (draft_id) std::memcpy(draft.data(), draft_id, draft.size()); + } + void fail() + { + issue.cancel(); + file.close(); + failed = true; + heap_caps_free(record); + record = nullptr; + heap_caps_free(work); + work = nullptr; + } + ~WebSignature() + { + issue.cancel(); + file.close(); + heap_caps_free(record); + heap_caps_free(work); + } + }; + ::platform::memory::PsramPtr web_signature; + struct WebConfirmation + { + WebConfirmationJob job; + std::array transfer{}, owner{}; + std::array hash{}, proof_hash{}; + std::array author{}; + uint32_t generation = 0, media = 0, total = 0, offset = 0; + uint64_t started = 0; + uint8_t *source = nullptr, *work = nullptr, *proof = nullptr; + size_t capacity = 0, proof_size = 0; + storage::SdRuntimeFile file; + Digest digest; + bool begun = false, failed = false, released = false; + WebConfirmation(PublicationStore& store, gc::protocol::RecordCrypto& crypto) : job(store, crypto) {} + void releasePayload() + { + file.close(); + heap_caps_free(source); + source = nullptr; + heap_caps_free(work); + work = nullptr; + heap_caps_free(proof); + proof = nullptr; + released = true; + } + void fail() + { + job.cancel(); + failed = true; + releasePayload(); + } + ~WebConfirmation() + { + job.cancel(); + releasePayload(); + } + }; + ::platform::memory::PsramPtr web_confirmation; ::platform::memory::PsramPtr dispatch_store; ::platform::memory::PsramPtr port; ::platform::memory::PsramPtr detail, pending_detail; @@ -293,6 +387,8 @@ struct Session } ~Session() { + web_signature.reset(); // Its reservation port borrows store. + web_confirmation.reset(); workspace_owner.release(detail.get()); detail.reset(); workspace_owner.release(checkpoint.get()); @@ -317,13 +413,15 @@ struct Session }; chat::MeshAdapterRouter* router = nullptr; LoraBoard* board = nullptr; -SemaphoreHandle_t mutex = nullptr; +std::atomic mutex{nullptr}; +static_assert(sizeof(mutex) == sizeof(SemaphoreHandle_t), "Lazy Geocaching lock must not grow static state"); ::platform::memory::PsramPtr session; std::array boot{}; -std::atomic wanted{false}, active{false}, restart{false}; +std::atomic wanted{0}; // 1 UI, 2 browser, same size as the former bool. +std::atomic active{false}, restart{false}; std::atomic network_requested{false}; std::atomic local_storage_requested{false}; -std::atomic cancel_draft_read{false}; +std::atomic cancel_draft_read{0}; std::atomic cancel_detail{false}; std::atomic next_step{0}; std::atomic replies_seen{0}, replies_busy{0}, replies_queued{0}; @@ -391,6 +489,7 @@ bool downloadActive() } bool publicationActive() { + if (session && (session->web_signature || session->web_confirmation)) return true; if (!session || !session->publication) return false; const auto& job = *session->publication; if (job.draft_stage != Session::Publication::DraftStage::None) return true; @@ -457,12 +556,34 @@ bool advanceDraftCatalog(Session& s) } struct Guard { - bool locked = mutex && xSemaphoreTake(mutex, 0) == pdTRUE; + SemaphoreHandle_t handle = mutex.load(std::memory_order_acquire); + bool locked = handle && xSemaphoreTake(handle, 0) == pdTRUE; ~Guard() { - if (locked) xSemaphoreGive(mutex); + if (locked) xSemaphoreGive(handle); } }; +bool ensureMutex() +{ + if (mutex.load(std::memory_order_acquire)) return true; + const auto created = xSemaphoreCreateMutex(); + if (!created) return false; + SemaphoreHandle_t expected = nullptr; + if (!mutex.compare_exchange_strong(expected, created, std::memory_order_acq_rel)) + vSemaphoreDelete(created); + return true; +} +void releaseDraftReaders(Session& s, uint8_t mask) +{ + if (!s.draft_read) return; + s.draft_read->users &= static_cast(~mask); + if (!s.draft_read->users) + { + const bool pending = s.draft_read->status == ::ui::geocaching::DraftReadStatus::Pending; + s.draft_read.reset(); + if (pending) s.draft_io_reset = true; + } +} gc::storage::StoredTime now(void*) { gc::storage::StoredTime time; @@ -878,7 +999,7 @@ void advanceBrowse(Session& s) auto backend = std::unique_ptr(new (std::nothrow) chat::reticulum::ReticulumAdapter(*board, nullptr, chat::reticulum::ReticulumUsage::BackgroundIpService)); if (!backend) return; - backend->applyConfig(app::AppContext::getInstance().readConfig().reticulumConfig()); + backend->applyConfig(app::appFacade().readConfig().reticulumConfig()); auto* created = backend.get(); if (!router->installServiceBackend(chat::MeshProtocol::Reticulum, std::move(backend))) return; s.created_backend = created; @@ -1064,12 +1185,14 @@ enum class PublicationRestore : uint8_t None, Pending, Restored, - Invalid + Invalid, + Forbidden }; PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache = nullptr, - const gc::RevisionHash* hash = nullptr, const gc::Destination* remote = nullptr) + const gc::RevisionHash* hash = nullptr, const gc::Destination* remote = nullptr, + const std::array* expected_task = nullptr, const uint8_t* stop_author = nullptr) { - const bool background = !cache && !hash && !remote; + const bool background = !cache && !hash && !remote && !expected_task; if (background && s.publication_recovery_complete) return PublicationRestore::None; gc::storage::PublicationRecoveryFilter filter; filter.local = s.local; @@ -1079,6 +1202,9 @@ PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache = if (cache) filter.cache = *cache; if (hash) filter.hash = *hash; if (remote) filter.remote = *remote; + filter.has_operation = expected_task != nullptr; + filter.include_terminal = stop_author != nullptr; + if (expected_task) filter.operation = *expected_task; gc::storage::PublicationRecoveryView selected; const auto result = s.store->readPublicationRecovery(filter, selected); s.publication_restore_pending = result == DraftReadResult::Pending; @@ -1105,6 +1231,7 @@ PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache = return PublicationRestore::Invalid; gc::RequestId request; std::memcpy(request.bytes.data(), selected.key.data() + 32, 16); + if (expected_task && (selected.task != *expected_task || request.bytes != *expected_task)) return PublicationRestore::Invalid; auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); if (!job) { @@ -1121,19 +1248,30 @@ PublicationRestore restorePublication(Session& s, const gc::GeocacheId* cache = return PublicationRestore::Pending; } if (!job->port->attachRestoredRequest(job->remote, request)) return PublicationRestore::Invalid; - const bool reuse = s.publication && s.publication->bytes; - const size_t capacity = reuse ? s.publication->size + 26 : selected.request.size + 26; - auto* scratch = reuse ? s.publication->bytes + s.publication->size - : static_cast(mem::allocatePreferred("geocaching.publish.restore", capacity, false)); + // The read lease already owns this disjoint verification arena. Resume + // needs record verification space, not a second complete request buffer. + auto* scratch = s.verification; if (!scratch) { next_step.store(millis() + 1000); return PublicationRestore::Pending; } - const bool restored = job->attempt->resume(job->remote, request, selected.request, scratch, capacity, + const bool restored = job->attempt->resume(job->remote, request, selected.request, scratch, kVerificationCapacity, selected.cache, selected.hash, selected.response); - if (!reuse) heap_caps_free(scratch); if (!restored) return PublicationRestore::Invalid; + // resume verified this exact immutable request. Borrow its author only + // while the existing read lease owns the response frame. + gc::protocol::PublishRequestView published; + if (!gc::protocol::decodePublishRequest(selected.request, request, published)) return PublicationRestore::Invalid; + gc::protocol::CmpReader signed_reader(published.signed_cache); + size_t fields = 0; + gc::ByteView encoded, signature; + gc::RecordView original; + if (!signed_reader.array(fields, 2) || fields != 2 || !signed_reader.binary(encoded, gc::kMaxRecordBytes) || + !signed_reader.binary(signature, 64) || signature.size != 64 || !signed_reader.finished() || + !gc::protocol::decodeGeocacheRecord(encoded, original)) return PublicationRestore::Invalid; + if (stop_author && std::memcmp(original.author_public_key.data, stop_author, 64)) return PublicationRestore::Forbidden; + std::memcpy(job->author.data(), original.author_public_key.data, job->author.size()); job->started = now(nullptr).monotonic_ms; job->wait_ms = 2 * gc::QueryClient::kReplyTimeoutMs + 5000; s.publication = std::move(job); @@ -1518,7 +1656,7 @@ void advanceDraftPublication(Session& s) } bool publicationDraftReady(const std::array& id, uint64_t generation, std::array& author, - uint32_t* from = nullptr, uint32_t* to = nullptr) + uint32_t* from = nullptr, uint32_t* to = nullptr, const gc::Destination* selected = nullptr) { if (!session) return false; network_requested.store(true); @@ -1540,11 +1678,25 @@ bool publicationDraftReady(const std::array& id, uint64_t generatio } if (!draft) return false; gc::Destination remote; + bool has_remote = false; + if (selected && session->client) + { + for (size_t i = 0; i < session->client->directoryCount(); ++i) + { + const auto* entry = session->client->directory(i); + if (entry && entry->delivery.bytes == selected->bytes && entry->capabilities_verified) + { + has_remote = true; + break; + } + } + } + else if (!selected && session->port) has_remote = session->port->pageSource(remote); const auto utc = std::time(nullptr); const bool ready = session && session->phase == Phase::Ready && session->port && session->store && !session->needsRecovery() && !session->store->commitPending() && !downloadActive() && !publicationActive() && !draftSaveActive() && utc >= 946684800 && - static_cast(utc) <= 253402300799ULL && session->port->pageSource(remote) && + static_cast(utc) <= 253402300799ULL && has_remote && draft->has_coordinates && gc::protocol::validRecordText(draft->name.data(), false, true) && router->getGeocachingAuthorKey(author.data()) && (!draft->has_author || draft->author == author); @@ -1568,12 +1720,14 @@ class Facade final : public ::ui::geocaching::Source } void activate(bool open) override { + if (open && !ensureMutex()) return; if (open && !wanted.load()) { network_requested.store(false); local_storage_requested.store(false); } - wanted.store(open); + if (open) wanted.fetch_or(1); + else wanted.fetch_and(static_cast(~1)); if (!open) cancel_detail.store(true); next_step.store(0); } @@ -1586,7 +1740,7 @@ class Facade final : public ::ui::geocaching::Source if (!guard.locked) { out.busy = true; - std::snprintf(out.status.data(), out.status.size(), "Updating..."); + std::snprintf(out.status.data(), out.status.size(), "%s", mutex.load() ? "Updating..." : "Geocaching lock memory unavailable"); return; } if (session && section != ::ui::geocaching::Section::Discover) @@ -1855,8 +2009,8 @@ class Facade final : public ::ui::geocaching::Source if (job->state == CacheDetail::State::Ready) { view.status = ::ui::geocaching::DetailStatus::Ready; - view.description = job->description.data(); - view.hint = job->hint.data(); + view.description = job->verified_record.record.description; + view.hint = job->verified_record.record.hint; std::array author{}; view.can_archive = job->verified_record.record.state != gc::CacheState::Archived && router->getGeocachingAuthorKey(author.data()) && @@ -1955,31 +2109,30 @@ class Facade final : public ::ui::geocaching::Source using Status = ::ui::geocaching::DraftReadStatus; if (!guard.locked) return Status::Pending; if (!sink || !session || !session->store || session->needsRecovery()) return Status::Failed; - if (cancel_draft_read.exchange(false)) - { - session->draft_read.reset(); - session->draft_io_reset = true; - } + releaseDraftReaders(*session, cancel_draft_read.exchange(0)); if (!session->draft_read || session->draft_read->id != id) { + if (session->draft_read && (session->draft_read->users & 2)) return Status::Pending; session->draft_io_reset = true; session->draft_read.reset(::platform::memory::createPsram()); if (!session->draft_read) return Status::Failed; session->draft_read->id = id; + session->draft_read->users = 1; next_step.store(0); return Status::Pending; } + session->draft_read->users |= 1; if (session->draft_read->status == Status::Pending) return Status::Pending; if (session->draft_read->status == Status::Failed) { - session->draft_read.reset(); + releaseDraftReaders(*session, 1); return Status::Failed; } gc::storage::DraftView draft; const auto& result = *session->draft_read; if (!gc::storage::decodeDraft({id.data(), id.size()}, {result.bytes, result.size}, draft)) { - session->draft_read.reset(); + releaseDraftReaders(*session, 1); return Status::Failed; } ::ui::geocaching::DraftInput out; @@ -1996,7 +2149,7 @@ class Facade final : public ::ui::geocaching::Source out.terrain_x2 = draft.terrain_x2; out.container_size = draft.container_size; sink(out, context); - session->draft_read.reset(); + releaseDraftReaders(*session, 1); return Status::Ready; } void cancelDraftRead(const std::array& id) override @@ -2006,11 +2159,10 @@ class Facade final : public ::ui::geocaching::Source { if (session && session->draft_read && session->draft_read->id == id) { - session->draft_read.reset(); - session->draft_io_reset = true; + releaseDraftReaders(*session, 1); } } - else cancel_draft_read.store(true); + else cancel_draft_read.fetch_or(1); next_step.store(0); } bool saveDraft(::ui::geocaching::DraftInput& input) override @@ -2293,32 +2445,831 @@ void configure(chat::MeshAdapterRouter& value, LoraBoard& radio) { router = &value; board = &radio; - if (!mutex) mutex = xSemaphoreCreateMutex(); esp_fill_random(boot.data(), boot.size()); ::geocaching::ui::shell::bind(&facade); } -bool queueDraftSave(const uint8_t id[16], const uint8_t* bytes, size_t size, uint64_t expected) +bool publicationContext(uint8_t boot_id[16], uint64_t& monotonic_ms) +{ + if (!boot_id || !router || !board) return false; + std::memcpy(boot_id, boot.data(), boot.size()); + monotonic_ms = static_cast(esp_timer_get_time()) / 1000; + return true; +} +bool withIdentityMaintenance(bool (*action)(void*), void* context) +{ + if (!action || !ensureMutex()) return false; + Guard guard; + if (!guard.locked || pending_reply.load(std::memory_order_acquire) || pending_announcement.load(std::memory_order_acquire)) return false; + if (session) + { + const auto& s = *session; + if ((s.phase != Phase::Ready && s.phase != Phase::Failed) || s.needsRecovery() || s.recovery || s.checkpoint || + s.workspace_owner.holder() || s.response || s.publication_restore_pending || s.download_restore_pending || + downloadActive() || publicationActive() || draftSaveActive() || + (s.store && s.store->commitPending()) || (s.dispatch_store && s.dispatch_store->busy()) || + (s.receipts && s.receipts->pending()) || (s.saved && s.saved->pending()) || + (s.draft_catalog && s.draft_catalog->reading) || + (s.draft_read && s.draft_read->status == ::ui::geocaching::DraftReadStatus::Pending)) return false; + if (s.client) + { + const auto phase = s.client->phase(); + if (phase != gc::QueryClientPhase::Idle && phase != gc::QueryClientPhase::PageReady && phase != gc::QueryClientPhase::Failed) return false; + } + } + if (action(context)) + { + wanted.store(0); + network_requested.store(false); + local_storage_requested.store(false); + active.store(false); + session.reset(); + ++epoch; + } + return true; +} + +bool webLease(bool enabled, bool require_storage) +{ + if (enabled) + { + if (!router || !board || !ensureMutex()) return false; + const auto previous = wanted.fetch_or(2); + if (require_storage) local_storage_requested.store(true); + if (previous & 2) return true; + } + else + { + if (!(wanted.fetch_and(static_cast(~2)) & 2)) return true; + cancel_draft_read.fetch_or(2); + } + next_step.store(0); + return true; +} + +WebReadResult readWebDraftCatalog(uint32_t offset, bool has_expected, uint64_t expected, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || capacity < 384) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery()) return WebReadResult::Pending; + if (has_expected && expected != s.store->catalogGeneration()) return WebReadResult::Conflict; + s.draft_catalog_wanted = true; + s.map_metadata_only = false; + if (!s.draft_catalog) + { + next_step.store(0); + return WebReadResult::Pending; + } + auto& catalog = *s.draft_catalog; + const size_t page_offset = (static_cast(offset) / 4) * 4; + if (catalog.requested_offset != page_offset) + { + catalog.requested_offset = page_offset; + catalog.failed = catalog.ready = false; + if (catalog.reading) s.draft_io_reset = true; + ++catalog.generation; + next_step.store(0); + return WebReadResult::Pending; + } + if (catalog.failed && catalog.sequence == s.store->catalogGeneration()) return WebReadResult::Unavailable; + if (!draftCatalogReady(s)) return WebReadResult::Pending; + gc::protocol::CmpWriter writer(output, capacity); + if (!writer.array(4) || !writer.unsignedInteger(catalog.sequence) || !writer.unsignedInteger(offset) || + !writer.unsignedInteger(catalog.total)) return WebReadResult::Invalid; + const size_t index = static_cast(offset) - page_offset; + if (index >= catalog.page.count) + { + if (!writer.nil()) return WebReadResult::Invalid; + } + else + { + const auto& row = catalog.page.rows[index]; + const auto& publication = row.publication; + const uint8_t flags = (publication.pending ? 1 : 0) | (publication.stopped ? 2 : 0) | + (publication.local_changes ? 4 : 0) | (publication.base_retained ? 8 : 0); + if (!writer.array(9) || !writer.binary({row.id.data(), 16}) || !writer.unsignedInteger(row.generation) || + !(row.has_author ? writer.binary({row.author.data(), 64}) : writer.nil()) || !writer.text(row.name.data()) || + !writer.unsignedInteger(row.state) || + !(row.has_coordinates ? writer.signedInteger(row.latitude_e7) : writer.nil()) || + !(row.has_coordinates ? writer.signedInteger(row.longitude_e7) : writer.nil()) || + !(row.has_base ? writer.binary({row.base_hash.data(), 32}) : writer.nil()) || + !writer.array(3) || !writer.unsignedInteger(publication.latest_revision) || + !writer.unsignedInteger(publication.confirmed_revision) || !writer.unsignedInteger(flags)) return WebReadResult::Invalid; + } + written = writer.size(); + return WebReadResult::Ready; +} +WebReadResult readWebLatestRevision(const uint8_t id[32], const uint8_t directory[16], uint8_t* output, size_t capacity) +{ + if (!id || !directory || !output || capacity < 36) return WebReadResult::Invalid; + if (!webLease(true, false)) return WebReadResult::Unavailable; + if (!network_requested.exchange(true)) next_step.store(0); + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (!s.client || !s.port) return WebReadResult::Pending; + const gc::DirectoryEntry* target = nullptr; + for (size_t i = 0; i < s.client->directoryCount(); ++i) + { + const auto* entry = s.client->directory(i); + if (entry && !std::memcmp(entry->delivery.bytes.data(), directory, 16)) + { + target = entry; + break; + } + } + if (!target) return s.client->directoryCount() ? WebReadResult::NotFound : WebReadResult::Pending; + if (!target->capabilities_verified) return WebReadResult::Pending; + if (s.pending_detail || cancel_detail.load() || publicationActive() || downloadActive()) return WebReadResult::Pending; + if (!s.detail) + { + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!job) return WebReadResult::Unavailable; + std::memcpy(job->id.bytes.data(), id, 32); + job->remote = target->delivery; + job->state = CacheDetail::State::LatestNetwork; + job->started = now(nullptr).monotonic_ms; + if (!randomId(nullptr, job->request.bytes.data()) || + !gc::protocol::encodeGetRequest(job->request, job->id, nullptr, nullptr, 8192, + job->request_bytes.data(), job->request_bytes.size(), job->request_size) || + job->request_size > CacheDetail::kLatestEpochOffset) return WebReadResult::Unavailable; + std::memcpy(job->request_bytes.data() + CacheDetail::kLatestEpochOffset, target->epoch.data(), 16); + s.detail = std::move(job); + next_step.store(0); + return WebReadResult::Pending; + } + auto& job = *s.detail; + if (std::memcmp(job.id.bytes.data(), id, 32) || std::memcmp(job.remote.bytes.data(), directory, 16)) return WebReadResult::Conflict; + const bool latest_job = job.state == CacheDetail::State::LatestNetwork || job.state == CacheDetail::State::LatestReady || + job.state == CacheDetail::State::LatestFailed || job.state == CacheDetail::State::LatestNotFound; + if (latest_job && std::memcmp(job.request_bytes.data() + CacheDetail::kLatestEpochOffset, target->epoch.data(), 16)) + { + s.detail.reset(); + return WebReadResult::Conflict; + } + if (job.state == CacheDetail::State::LatestNetwork) return WebReadResult::Pending; + if (job.state == CacheDetail::State::LatestNotFound) + { + std::memset(output, 0, 36); + s.detail.reset(); + return WebReadResult::Absent; + } + if (job.state == CacheDetail::State::LatestFailed) + { + s.detail.reset(); + return WebReadResult::Unavailable; + } + if (job.state != CacheDetail::State::LatestReady) return WebReadResult::Conflict; + const auto revision = job.verified_record.record.revision; + for (unsigned i = 0; i < 4; ++i) output[i] = static_cast(revision >> (8 * i)); + std::memcpy(output + 4, job.verified_record.hash.bytes.data(), 32); + s.detail.reset(); + return WebReadResult::Ready; +} +WebReadResult readWebDirectories(uint32_t offset, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || capacity < 320 || offset > gc::QueryClient::kDirectoryCapacity) return WebReadResult::Invalid; + if (!webLease(true, false)) return WebReadResult::Unavailable; + if (!network_requested.exchange(true)) next_step.store(0); + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + if (session->phase == Phase::Failed || session->workspace_unavailable) return WebReadResult::Unavailable; + if (!session->client) return WebReadResult::Pending; + const auto& client = *session->client; + const auto* entry = client.directory(offset); + gc::protocol::CmpWriter writer(output, capacity); + if (!writer.array(3) || !writer.unsignedInteger(offset) || !writer.unsignedInteger(client.directoryCount())) return WebReadResult::Invalid; + if (!entry) + { + if (!writer.nil()) return WebReadResult::Invalid; + } + else + { + const auto current = now(nullptr).monotonic_ms; + const auto age = current >= entry->last_seen ? current - entry->last_seen : 0; + const uint8_t flags = (entry->capabilities_verified ? 1 : 0) | (entry->in_flight ? 2 : 0); + if (!writer.array(9) || !writer.binary({entry->delivery.bytes.data(), 16}) || !writer.binary({entry->discovery.bytes.data(), 16}) || + !writer.binary({entry->public_key.data(), 64}) || !writer.binary({entry->epoch.data(), 16}) || !writer.unsignedInteger(entry->sequence) || + !writer.text(entry->name.data()) || !writer.unsignedInteger(flags) || !writer.unsignedInteger(entry->max_query_items) || + !writer.unsignedInteger(age)) return WebReadResult::Invalid; + } + written = writer.size(); + return WebReadResult::Ready; +} + +static WebReadResult prepareWebDraftRead(Session& s, const uint8_t id[16]) +{ + releaseDraftReaders(s, cancel_draft_read.exchange(0)); + if (!s.draft_read || std::memcmp(s.draft_read->id.data(), id, 16)) + { + if (s.draft_read && (s.draft_read->users & 1)) return WebReadResult::Pending; + s.draft_io_reset = true; + s.draft_read.reset(::platform::memory::createPsram()); + if (!s.draft_read) return WebReadResult::Unavailable; + std::memcpy(s.draft_read->id.data(), id, 16); + s.draft_read->users = 2; + next_step.store(0); + return WebReadResult::Pending; + } + auto& job = *s.draft_read; + job.users |= 2; + if (job.status == ::ui::geocaching::DraftReadStatus::Pending) return WebReadResult::Pending; + if (job.status != ::ui::geocaching::DraftReadStatus::Ready) + { + const auto result = job.result == DraftReadResult::NotFound ? WebReadResult::NotFound : WebReadResult::Unavailable; + releaseDraftReaders(s, 2); + return result; + } + return WebReadResult::Ready; +} +WebReadResult readWebPublicationBinding(const uint8_t id[16], uint64_t generation, const uint8_t directory[16], + const uint8_t expected_author[64], uint8_t* output, size_t capacity) +{ + if (!id || !generation || !directory || !expected_author || !output || capacity < 56) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + if (!network_requested.exchange(true)) next_step.store(0); + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed || s.workspace_unavailable) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery() || !s.client) return WebReadResult::Pending; + const gc::DirectoryEntry* target = nullptr; + for (size_t i = 0; i < s.client->directoryCount(); ++i) + { + const auto* entry = s.client->directory(i); + if (entry && !std::memcmp(entry->delivery.bytes.data(), directory, 16)) + { + target = entry; + break; + } + } + if (!target) return WebReadResult::NotFound; + if (!target->capabilities_verified) return WebReadResult::Pending; + if (s.store->commitPending() || downloadActive() || publicationActive() || draftSaveActive()) return WebReadResult::Pending; + const auto ready = prepareWebDraftRead(s, id); + if (ready != WebReadResult::Ready) return ready; + const auto& job = *s.draft_read; + gc::storage::DraftView draft; + auto result = WebReadResult::Unavailable; + if (gc::storage::decodeDraft({id, 16}, {job.bytes, job.size}, draft)) + { + if (draft.generation != generation) result = WebReadResult::Conflict; + else if (draft.author.size && std::memcmp(draft.author.data, expected_author, 64)) result = WebReadResult::Forbidden; + else if (!draft.has_coordinates || !gc::protocol::validRecordText(draft.name, false, true)) result = WebReadResult::Invalid; + else + { + s.draft_catalog_wanted = true; + s.map_metadata_only = false; + if (!draftCatalogReady(s)) + { + s.draft_catalog_wanted = true; + next_step.store(0); + return WebReadResult::Pending; + } + bool in_window = false; + for (size_t i = 0; i < s.draft_catalog->page.count; ++i) + in_window |= !std::memcmp(s.draft_catalog->page.rows[i].id.data(), id, 16); + if (!in_window) + { + // The committed row already proved the ID exists. Seek it in + // the existing four-row window, including after session reset. + auto& catalog = *s.draft_catalog; + const auto next = catalog.page.offset + catalog.page.count; + catalog.requested_offset = next < catalog.page.total ? next : 0; + catalog.ready = catalog.failed = false; + releaseDraftReaders(s, 2); + next_step.store(0); + return WebReadResult::Pending; + } + std::array draft_id; + std::memcpy(draft_id.data(), id, draft_id.size()); + std::array author; + uint32_t from = 0, to = 0; + if (!router->getGeocachingAuthorKey(author.data())) result = WebReadResult::Pending; + else if (std::memcmp(author.data(), expected_author, author.size())) result = WebReadResult::StaleIdentity; + else if (!publicationDraftReady(draft_id, generation, author, &from, &to, &target->delivery)) result = WebReadResult::Conflict; + else + { + Digest digest; + digest.update(job.bytes, job.size); + if (digest.finalize(output, 32)) + { + std::memcpy(output + 32, target->epoch.data(), target->epoch.size()); + if (capacity >= 88) + { + std::memset(output + 56, 0, 32); + if (draft.base_hash.size) std::memcpy(output + 56, draft.base_hash.data, 32); + } + for (unsigned i = 0; i < 4; ++i) + { + output[48 + i] = static_cast(from >> (8 * i)); + output[52 + i] = static_cast(to >> (8 * i)); + } + result = WebReadResult::Ready; + } + } + } + } + releaseDraftReaders(s, 2); + return result; +} +WebReadResult checkWebDraftEditOwner(const uint8_t id[16], uint64_t expected, const uint8_t author[64], const uint8_t* edit_hash) +{ + if (!id || !author || expected == UINT64_MAX) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery()) return WebReadResult::Pending; + const auto ready = prepareWebDraftRead(s, id); + if (ready == WebReadResult::NotFound) return expected ? WebReadResult::Conflict : WebReadResult::Ready; + if (ready != WebReadResult::Ready) return ready; + gc::storage::DraftView draft; + const auto& job = *s.draft_read; + auto result = WebReadResult::Unavailable; + if (gc::storage::decodeDraft({id, 16}, {job.bytes, job.size}, draft)) + { + if (draft.author.size && std::memcmp(draft.author.data, author, 64)) result = WebReadResult::Forbidden; + else if (draft.generation == expected) result = WebReadResult::Ready; + else if (edit_hash && draft.generation == expected + 1) + { + // Canonical fixed-width author/base values are the only bytes + // inserted by editDraft. Hash nil metadata plus the borrowed suffix, + // exactly matching the uploaded edit, without allocating a clone. + const size_t suffix = 1 + (draft.author.size ? 66 : 1) + (draft.base_hash.size ? 34 : 1); + constexpr uint8_t prefix[] = {0x94, 0xc0, 0xc0}; + uint8_t hash[32]; + Digest digest; + digest.update(prefix, sizeof(prefix)); + digest.update(job.bytes + suffix, job.size - suffix); + result = !digest.finalize(hash, sizeof(hash)) ? WebReadResult::Unavailable + : std::memcmp(hash, edit_hash, 32) ? WebReadResult::Conflict + : WebReadResult::AlreadySaved; + } + else result = WebReadResult::Conflict; + } + releaseDraftReaders(s, 2); + return result; +} +WebReadResult readWebDraftChunk(const uint8_t id[16], uint32_t offset, uint16_t count, uint64_t expected, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!id || !output || !count || count > 320 || capacity < 384 || (offset && !expected)) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery()) return WebReadResult::Pending; + const auto ready = prepareWebDraftRead(s, id); + if (ready != WebReadResult::Ready) return ready; + auto& job = *s.draft_read; + gc::storage::DraftView draft; + if (!gc::storage::decodeDraft({id, 16}, {job.bytes, job.size}, draft)) + { + releaseDraftReaders(s, 2); + return WebReadResult::Unavailable; + } + if ((expected && draft.generation != expected) || offset > job.size) + { + releaseDraftReaders(s, 2); + return WebReadResult::Conflict; + } + const size_t length = std::min(static_cast(count), job.size - static_cast(offset)); + gc::protocol::CmpWriter writer(output, capacity); + if (!writer.array(5) || !writer.binary({job.id.data(), 16}) || !writer.unsignedInteger(draft.generation) || + !writer.unsignedInteger(job.size) || !writer.unsignedInteger(offset) || !writer.binary({job.bytes + offset, length})) + return WebReadResult::Invalid; + written = writer.size(); + if (offset + length == job.size) releaseDraftReaders(s, 2); + return WebReadResult::Ready; +} +bool queueDraftSave(const uint8_t id[16], const uint8_t* bytes, size_t size, uint64_t expected, bool editor_fields) { Guard guard; gc::storage::DraftView draft; if (!guard.locked || !id || !session || !session->store || session->store->commitPending() || session->needsRecovery() || downloadActive() || publicationActive() || draftSaveActive() || session->phase == Phase::ResumeDownloads || - !gc::storage::decodeDraft({id, 16}, {bytes, size}, draft) || expected == UINT64_MAX || draft.generation != expected + 1) return false; + !gc::storage::decodeDraft({id, 16}, {bytes, size}, draft) || expected == UINT64_MAX || draft.generation != expected + 1 || + (editor_fields && (draft.author.size || draft.base_hash.size))) return false; auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); if (!job) return false; - job->bytes = static_cast(mem::allocatePreferred("geocaching.draft", size, false)); + // editDraft prepends retained author/base metadata in this same allocation. + // Two MessagePack binary values replace two nils: at most 100 extra bytes. + const size_t capacity = size + (editor_fields ? 100 : 0); + job->bytes = static_cast(mem::allocatePreferred("geocaching.draft", capacity, false)); if (!job->bytes) return false; std::memcpy(job->id.data(), id, 16); std::memcpy(job->bytes, bytes, size); job->size = size; - job->capacity = size; - job->editor_fields = false; + job->capacity = capacity; + job->editor_fields = editor_fields; job->expected = expected; session->draft_save = std::move(job); next_step.store(0); ++epoch; return true; } +WebReadResult queueWebDraftFile(const uint8_t id[16], uint32_t total, uint64_t expected, + const uint8_t hash[32], const uint8_t author[64], uint32_t author_generation) +{ + if (!id || !hash || !author || !total || total > 4096 || expected == UINT64_MAX || !author_generation) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (s.draft_save && s.draft_save->web_job && s.draft_save->expected == expected && + !std::memcmp(s.draft_save->id.data(), id, 16)) + { + const auto& job = *s.draft_save; + if (job.capacity != static_cast(total) + 100 || std::memcmp(job.hash.data(), hash, 32)) return WebReadResult::Conflict; + return !job.done ? WebReadResult::Pending : job.saved ? WebReadResult::AlreadySaved + : static_cast(job.cache[0]); + } + if (s.phase != Phase::Ready || !s.store || s.needsRecovery() || s.store->commitPending() || + s.draft_read || downloadActive() || publicationActive() || draftSaveActive()) return WebReadResult::Pending; + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!job) return WebReadResult::Unavailable; + job->capacity = total + 100; + job->bytes = static_cast(mem::allocatePreferred("geocaching.draft", job->capacity, false)); + if (!job->bytes) return WebReadResult::Unavailable; + std::memcpy(job->id.data(), id, 16); + std::memcpy(job->hash.data(), hash, 32); + // These checks fit in the existing identity-restoration headroom. They are + // consumed before editDraft can move the encoded suffix into that space. + std::memcpy(job->bytes + total, author, 64); + std::memcpy(job->bytes + total + 64, &author_generation, sizeof(author_generation)); + job->expected = expected; + job->web_job = job->web_file = true; + s.draft_save = std::move(job); + next_step.store(0); + ++epoch; + return WebReadResult::Pending; +} +bool releaseWebDraftSave() +{ + if (!mutex.load(std::memory_order_acquire)) return true; + Guard guard; + if (!guard.locked) return false; + if (!session || !session->draft_save || !session->draft_save->web_job) return true; + const auto& job = *session->draft_save; + if (!job.done || job.started || job.saved) return false; + session->draft_save.reset(); + ++epoch; + next_step.store(0); + return true; +} +static void failWebDraftSave(Session::DraftSave& job, WebReadResult result) +{ + job.done = true; + job.cache[0] = static_cast(result); + heap_caps_free(job.bytes); + job.bytes = nullptr; + ++epoch; +} +static bool advanceWebDraftFile(Session& s) +{ + auto& job = *s.draft_save; + const size_t total = job.capacity - 100; + storage::SdRuntimeFile file; + const size_t count = std::min(size_t(320), total - job.size); + if (!file.open(kWebDraftCandidatePath, "r") || file.size() != total || !file.seek(job.size) || + file.read(job.bytes + job.size, count) != static_cast(count)) + { + failWebDraftSave(job, WebReadResult::Unavailable); + return true; + } + job.size += count; + if (job.size != total) return true; + if (!s.crypto.sha256({job.bytes, total}, job.cache.data())) failWebDraftSave(job, WebReadResult::Unavailable); + else if (std::memcmp(job.cache.data(), job.hash.data(), 32)) failWebDraftSave(job, WebReadResult::HashMismatch); + else + { + gc::storage::DraftView draft; + if (!gc::storage::decodeDraft({job.id.data(), 16}, {job.bytes, total}, draft) || + draft.author.size || draft.base_hash.size || draft.generation != job.expected + 1) + failWebDraftSave(job, WebReadResult::Invalid); + else + { + job.web_file = false; + job.cache[0] = static_cast(WebReadResult::Unavailable); + } + } + return true; +} +WebReadResult requestWebPublicationStop(const uint8_t operation_id[16], const uint8_t expected_author[64]) +{ + if (!operation_id || !expected_author) return WebReadResult::Invalid; + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) bits |= operation_id[i]; + if (!bits) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery() || s.phase != Phase::Ready) return WebReadResult::Pending; + const auto* current_key = s.publication && s.publication->attempt ? s.publication->attempt->requestId().bytes.data() + : s.publication && s.publication->web_operation ? s.publication->operation_id.data() + : nullptr; + const bool matches_current = current_key && !std::memcmp(current_key, operation_id, 16); + const bool failed_current = matches_current && s.publication->attempt && s.publication->attempt->phase() == gc::PublishAttemptPhase::Failed; + if (!matches_current || failed_current) + { + if (publicationActive() || downloadActive() || draftSaveActive() || s.store->commitPending()) return WebReadResult::Pending; + std::array actual; + if (!router->getGeocachingAuthorKey(actual.data())) return WebReadResult::Pending; + if (std::memcmp(actual.data(), expected_author, actual.size())) return WebReadResult::StaleIdentity; + if (s.local_read_only) + { + // A readable startup snapshot has not finished the journal audit. + // Finish it before restoring a writer bound to this root/store. + s.workspace_owner.release(s.detail.get()); + if (s.detail && s.detail->state == CacheDetail::State::Ready) s.detail->saved_read.reset(); + else s.detail.reset(); + s.pending_detail.reset(); + s.store->releaseDraftRead(); + s.saved.reset(); + if (s.draft_catalog) s.draft_catalog->reading = false; + s.draft_read.reset(); + s.store.reset(); + s.download_store.reset(); + s.local_read_only = false; + startRecovery(); + next_step.store(0); + return WebReadResult::Pending; + } + std::array task; + std::memcpy(task.data(), operation_id, task.size()); + const auto restored = restorePublication(s, nullptr, nullptr, nullptr, &task, expected_author); + if (restored == PublicationRestore::Pending) return WebReadResult::Pending; + if (restored == PublicationRestore::None) return WebReadResult::NotFound; + if (restored == PublicationRestore::Forbidden) return WebReadResult::Forbidden; + if (restored != PublicationRestore::Restored) return WebReadResult::Unavailable; + } + auto& job = *session->publication; + const auto* key = job.attempt ? job.attempt->requestId().bytes.data() : job.web_operation ? job.operation_id.data() + : nullptr; + if (!key || std::memcmp(key, operation_id, 16)) return WebReadResult::NotFound; + if (std::memcmp(job.author.data(), expected_author, job.author.size())) return WebReadResult::Forbidden; + std::array actual; + if (!router->getGeocachingAuthorKey(actual.data())) return WebReadResult::Pending; + if (std::memcmp(actual.data(), expected_author, actual.size())) return WebReadResult::StaleIdentity; + // A signed record still needs a durable attempt before a stopped receipt. + // Preserve the request across asynchronous historical lookup/admission so + // the maintenance owner cancels it before transport can dispatch it. + if (!job.attempt) + { + if (job.bytes && job.draft_stage == Session::Publication::DraftStage::None) + { + job.stop_requested = true; + next_step.store(0); + } + return WebReadResult::Pending; + } + const auto phase = job.attempt->phase(); + if (phase == gc::PublishAttemptPhase::Confirmed || phase == gc::PublishAttemptPhase::Cancelled) return WebReadResult::Ready; + if (phase == gc::PublishAttemptPhase::Committing) return WebReadResult::Pending; + if (phase == gc::PublishAttemptPhase::Idle || phase == gc::PublishAttemptPhase::Failed) return WebReadResult::Unavailable; + if (!job.attempt->cancel()) return WebReadResult::Unavailable; + ++epoch; + next_step.store(0); + return job.attempt->phase() == gc::PublishAttemptPhase::Cancelled ? WebReadResult::Ready : WebReadResult::Pending; +} +WebReadResult readWebPublicationHistory(const uint8_t operation_id[16], const uint8_t expected_author[64], + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!operation_id || !expected_author || !output || capacity < 192) return WebReadResult::Invalid; + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) bits |= operation_id[i]; + if (!bits) return WebReadResult::Invalid; + if (!webLease(true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + auto& s = *session; + if (s.phase == Phase::Failed) return WebReadResult::Unavailable; + if (!s.store || s.needsRecovery()) return WebReadResult::Pending; + std::array actual; + if (!router->getGeocachingAuthorKey(actual.data())) return WebReadResult::Pending; + if (std::memcmp(actual.data(), expected_author, actual.size())) return WebReadResult::StaleIdentity; + gc::storage::PublicationRecoveryFilter filter; + filter.local = s.local; + filter.has_operation = filter.include_terminal = true; + std::memcpy(filter.operation.data(), operation_id, 16); + gc::storage::PublicationRecoveryView selected; + auto read = DraftReadResult::Pending; + // Bounded slices on the existing command consumer. No new worker or queue. + for (unsigned i = 0; i < 8 && read == DraftReadResult::Pending; ++i) + read = s.store->readPublicationRecovery(filter, selected); + if (read == DraftReadResult::Pending || read == DraftReadResult::Busy) return WebReadResult::Pending; + if (read == DraftReadResult::NotFound) return WebReadResult::NotFound; + if (read != DraftReadResult::Ready) return WebReadResult::Unavailable; + struct ReadLease + { + PublicationStore& store; + ~ReadLease() { store.releaseDraftRead(); } + } lease{*s.store}; + auto verified = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!verified) return WebReadResult::Unavailable; + const auto result = gc::storage::encodePublicationHistoryJob(selected, {operation_id, 16}, {expected_author, 64}, + s.crypto, s.verification, kVerificationCapacity, *verified, + output, capacity, written); + switch (result) + { + case gc::storage::PublicationHistoryJobResult::Ready: + return WebReadResult::Ready; + case gc::storage::PublicationHistoryJobResult::Forbidden: + return WebReadResult::Forbidden; + case gc::storage::PublicationHistoryJobResult::Invalid: + return WebReadResult::Invalid; + default: + return WebReadResult::Unavailable; + } +} +WebReadResult readWebPublicationJob(const uint8_t operation_id[16], const uint8_t expected_author[64], + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!operation_id || !expected_author || !output || capacity < 192) return WebReadResult::Invalid; + if (!webLease(true, false)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session) return WebReadResult::Pending; + if (!session->publication) return WebReadResult::NotFound; + const auto& job = *session->publication; + const auto* attempt = job.attempt.get(); + const auto* key = attempt ? attempt->requestId().bytes.data() : job.web_operation ? job.operation_id.data() + : nullptr; + if (!key || std::memcmp(key, operation_id, 16)) return WebReadResult::NotFound; + if (std::memcmp(job.author.data(), expected_author, job.author.size())) return WebReadResult::Forbidden; + std::array actual_author; + if (!router->getGeocachingAuthorKey(actual_author.data())) return WebReadResult::Pending; + if (std::memcmp(actual_author.data(), expected_author, actual_author.size())) return WebReadResult::StaleIdentity; + uint8_t phase = 0; + int durable = 0, transported = 0; // -1 means not known from this projection. + if (attempt) + { + using Phase = gc::PublishAttemptPhase; + switch (attempt->phase()) + { + case Phase::Idle: + phase = 0; + break; + case Phase::Submitting: + phase = 1; + break; + case Phase::Waiting: + phase = 2; + durable = 1; + transported = job.submitted ? 1 : -1; + break; + case Phase::Committing: + phase = 2; + durable = 1; + transported = 1; + break; + case Phase::Confirmed: + phase = 3; + durable = transported = 1; + break; + case Phase::Cancelled: + phase = 4; + durable = 1; + transported = job.submitted ? 1 : -1; + break; + case Phase::Failed: + phase = 5; + durable = transported = -1; + break; + case Phase::Cancelling: + phase = 6; + durable = -1; + transported = job.submitted ? 1 : -1; + break; + } + } + else if (job.stop_requested) + { + phase = 6; + durable = transported = -1; + } + const char* error = job.error; + if (!attempt && (job.error || (job.draft_stage == Session::Publication::DraftStage::None && !job.bytes))) + { + phase = 5; + durable = transported = -1; + if (!error) error = "Publication could not be admitted"; + } + gc::protocol::CmpWriter writer(output, capacity); + if (!writer.array(8) || !writer.binary({operation_id, 16}) || !writer.unsignedInteger(phase) || + !(attempt ? writer.binary({attempt->cacheId().bytes.data(), 32}) : writer.nil()) || + !(attempt ? writer.binary({attempt->revisionHash().bytes.data(), 32}) : writer.nil()) || + !writer.unsignedInteger(attempt ? attempt->revision() : job.expected_revision) || + !(durable < 0 ? writer.nil() : writer.unsignedInteger(static_cast(durable))) || + !(transported < 0 ? writer.nil() : writer.unsignedInteger(static_cast(transported))) || + !(error ? writer.text(error) : writer.nil())) return WebReadResult::Invalid; + written = writer.size(); + return WebReadResult::Ready; +} + +bool webPublicationPlan(const uint8_t id[16], uint64_t generation, const uint8_t directory[16], + uint8_t author[64], uint32_t& from, uint32_t& to) +{ + Guard guard; + if (!guard.locked || !id || !directory || !author) return false; + std::array draft_id; + gc::Destination remote; + std::array key; + std::memcpy(draft_id.data(), id, draft_id.size()); + std::memcpy(remote.bytes.data(), directory, remote.bytes.size()); + uint32_t previous = 0, next = 0; + if (!publicationDraftReady(draft_id, generation, key, &previous, &next, &remote)) return false; + std::memcpy(author, key.data(), key.size()); + from = previous; + to = next; + return true; +} + +bool queueWebDraftPublication(const uint8_t id[16], uint64_t generation, const uint8_t directory[16], + const uint8_t expected_author[64], uint32_t expected_revision, const uint8_t* expected_binding, + const uint8_t* operation_id) +{ + Guard guard; + if (!guard.locked || !id || !directory || !expected_author) return false; + if (operation_id) + { + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) bits |= operation_id[i]; + if (!bits) return false; + } + std::array draft_id; + gc::Destination remote; + std::array author; + std::memcpy(draft_id.data(), id, draft_id.size()); + std::memcpy(remote.bytes.data(), directory, remote.bytes.size()); + uint32_t previous = 0, revision = 0; + if (!publicationDraftReady(draft_id, generation, author, &previous, &revision, &remote) || + std::memcmp(author.data(), expected_author, author.size()) || revision != expected_revision) return false; + if (expected_binding) + { + const gc::DirectoryEntry* target = nullptr; + for (size_t i = 0; i < session->client->directoryCount(); ++i) + { + const auto* entry = session->client->directory(i); + if (entry && entry->delivery.bytes == remote.bytes) + { + target = entry; + break; + } + } + if (!target || std::memcmp(target->epoch.data(), expected_binding + 32, target->epoch.size())) return false; + for (unsigned i = 0; i < 4; ++i) + if (expected_binding[48 + i] != static_cast(previous >> (8 * i)) || + expected_binding[52 + i] != static_cast(revision >> (8 * i))) return false; + if (prepareWebDraftRead(*session, id) != WebReadResult::Ready) return false; + const auto& read = *session->draft_read; + gc::storage::DraftView draft; + uint8_t hash[32]; + Digest digest; + digest.update(read.bytes, read.size); + const bool matches = gc::storage::decodeDraft({id, 16}, {read.bytes, read.size}, draft) && + draft.generation == generation && digest.finalize(hash, sizeof(hash)) && + !std::memcmp(hash, expected_binding, sizeof(hash)); + releaseDraftReaders(*session, 2); + if (!matches) return false; + } + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram()); + if (!job) return false; + job->remote = remote; + job->draft_id = draft_id; + job->draft_generation = generation; + job->author = author; + job->expected_revision = revision; + if (operation_id) + { + job->web_operation = true; + std::memcpy(job->operation_id.data(), operation_id, 16); + } + job->issued = now(nullptr); + job->issued.has_utc = true; + job->issued.utc_seconds = static_cast(std::time(nullptr)); + job->draft_stage = Session::Publication::DraftStage::Bind; + session->draft_save.reset(); + session->publication = std::move(job); + next_step.store(0); + ++epoch; + return true; +} + bool queuePublication(const uint8_t* bytes, size_t size) { Guard guard; @@ -2340,6 +3291,308 @@ bool queuePublication(const uint8_t* bytes, size_t size) ++epoch; return true; } +WebReadResult queueWebSignature(const uint8_t operation[16], const uint8_t owner[16], const uint8_t transfer[16], + const uint8_t hash[32], uint32_t total, const uint8_t author[64], uint32_t generation, + const uint8_t* draft, uint64_t draft_generation) +{ + if (!operation || !owner || !transfer || !hash || !author || !generation || !total || total > 4096) return WebReadResult::Invalid; + if (bool(draft) != bool(draft_generation)) return WebReadResult::Invalid; + if (!webLease(true, true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session || !router) return WebReadResult::Pending; + auto& s = *session; + if (s.web_signature) + { + const auto& job = *s.web_signature; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + return !std::memcmp(job.operation.data(), operation, 16) && !std::memcmp(job.transfer.data(), transfer, 16) && + !std::memcmp(job.hash.data(), hash, 32) && !std::memcmp(job.author.data(), author, 64) && + job.generation == generation && job.total == total && job.draft_generation == draft_generation && + (!draft || !std::memcmp(job.draft.data(), draft, 16)) + ? WebReadResult::Ready + : WebReadResult::Conflict; + } + if (s.phase != Phase::Ready || !s.store || s.needsRecovery() || s.store->commitPending() || + s.workspace_owner.holder() || downloadActive() || publicationActive() || draftSaveActive()) return WebReadResult::Pending; + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram(*router, *s.store, s.crypto, now(nullptr), draft, draft_generation)); + if (!job) return WebReadResult::Unavailable; + job->record = static_cast(heap_caps_malloc(total, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + job->capacity = total + 70; + job->work = static_cast(heap_caps_malloc(job->capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!job->record || !job->work) return WebReadResult::Unavailable; + std::memcpy(job->operation.data(), operation, 16); + std::memcpy(job->owner.data(), owner, 16); + std::memcpy(job->transfer.data(), transfer, 16); + std::memcpy(job->hash.data(), hash, 32); + std::memcpy(job->author.data(), author, 64); + job->generation = generation; + job->total = total; + job->media = storage::sd_media_session(); + job->started = now(nullptr).monotonic_ms; + s.web_signature = std::move(job); + next_step.store(0); + return WebReadResult::Ready; +} + +bool validWebSignature(Session::WebSignature& job) +{ + if (job.failed || !router || job.media != storage::sd_media_session() || !storage::sd_card_ready() || storage::sd_external_block_owner_active() || + now(nullptr).monotonic_ms - job.started >= 120000) return false; + std::array author{}; + uint32_t generation = 0; + return router->readDeviceAuthorKey(author.data(), generation) == chat::DeviceAuthorResult::Ok && + generation == job.generation && !std::memcmp(author.data(), job.author.data(), 64); +} +void advanceWebSignature(Session& s) +{ + if (!(wanted.load() & 2)) + { + s.web_signature.reset(); + return; + } + auto& job = *s.web_signature; + if (!validWebSignature(job)) + { + job.fail(); + return; + } + if (job.offset < job.total) + { + if (!job.file.is_open() && (!job.file.open("/trailmate/cache.sign.upload", "r", job.media) || job.file.size() != job.total)) + { + job.fail(); + return; + } + const size_t count = std::min(size_t(320), size_t(job.total - job.offset)); + if (job.file.read(job.record + job.offset, count) != static_cast(count)) + { + job.fail(); + return; + } + job.digest.update(job.record + job.offset, count); + job.offset += count; + return; + } + if (!job.issuing) + { + job.file.close(); + if (!job.digest.finalize(job.work, 32) || std::memcmp(job.work, job.hash.data(), 32) || + !job.issue.begin({job.record, job.total}, job.work, job.capacity)) + { + job.fail(); + return; + } + job.issuing = true; + } + job.issue.advance(); + if (job.issue.phase() == gc::AuthorIssuePhase::Failed) job.fail(); + else if (job.issue.phase() == gc::AuthorIssuePhase::Signed && job.record) + { + heap_caps_free(job.record); + job.record = nullptr; + } +} +WebReadResult readWebSignature(const uint8_t operation[16], const uint8_t owner[16], bool status_only, + uint32_t offset, uint16_t maximum, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + Guard guard; + if (!guard.locked) return WebReadResult::Pending; + if (!operation || !owner || !output || !session || !session->web_signature) return WebReadResult::NotFound; + auto& job = *session->web_signature; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + if (std::memcmp(job.operation.data(), operation, 16)) return WebReadResult::NotFound; + if (!validWebSignature(job)) job.fail(); + const auto signed_record = job.issue.signedRecord(); + const uint8_t phase = job.failed ? 2 : signed_record.size ? 1 + : 0; + gc::protocol::CmpWriter writer(output, capacity); + if (status_only) + { + if (!writer.array(5) || !writer.binary({job.operation.data(), 16}) || !writer.binary({job.hash.data(), 32}) || + !writer.unsignedInteger(job.generation) || !writer.unsignedInteger(phase) || !writer.unsignedInteger(phase == 1 ? signed_record.size : 0)) return WebReadResult::Invalid; + } + else + { + if (phase != 1) return WebReadResult::Pending; + if (!maximum || maximum > 320 || offset >= signed_record.size) return WebReadResult::Invalid; + const size_t count = std::min(size_t(maximum), signed_record.size - offset); + if (!writer.array(5) || !writer.binary({job.operation.data(), 16}) || !writer.unsignedInteger(offset) || !writer.unsignedInteger(signed_record.size) || + !writer.binary({job.hash.data(), 32}) || !writer.binary({signed_record.data + offset, count})) return WebReadResult::Invalid; + } + written = writer.size(); + return WebReadResult::Ready; +} +WebReadResult releaseWebSignature(const uint8_t owner[16], const uint8_t* operation) +{ + Guard guard; + if (!guard.locked) return WebReadResult::Pending; + if (!owner || !session || !session->web_signature) return WebReadResult::NotFound; + const auto& job = *session->web_signature; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + if (operation && std::memcmp(job.operation.data(), operation, 16)) return WebReadResult::NotFound; + session->web_signature.reset(); + return WebReadResult::Ready; +} +WebReadResult sharedSignatureUploadIdle() +{ + Guard guard; + if (!guard.locked || (session && (session->web_signature || session->web_confirmation))) return WebReadResult::Pending; + return WebReadResult::Ready; +} +bool withSignatureInputRecovery(bool (*action)(void*), void* context) +{ + if (!action || !ensureMutex()) return false; + Guard guard; + if (!guard.locked || (session && (session->web_signature || session->web_confirmation))) return false; + // Staging is separate from the Geocaching index. Serialize read-only input + // with maintenance steps without requiring RF/network discovery to be idle. + action(context); + return true; +} +WebReadResult validWebConfirmation(Session::WebConfirmation& job) +{ + if (!router || job.media != storage::sd_media_session() || !storage::sd_card_ready() || + now(nullptr).monotonic_ms - job.started >= 120000) return WebReadResult::StaleIdentity; + if (storage::sd_external_block_owner_active()) return WebReadResult::Pending; + std::array author{}; + uint32_t generation = 0; + const auto result = router->readDeviceAuthorKey(author.data(), generation); + // The router uses a nonblocking lock. Unavailable cannot prove that the + // account changed: retain the original task and retry within its deadline. + if (result == chat::DeviceAuthorResult::Unavailable) return WebReadResult::Pending; + if (result == chat::DeviceAuthorResult::StorageError) return WebReadResult::Unavailable; + return result == chat::DeviceAuthorResult::Ok && generation == job.generation && + !std::memcmp(author.data(), job.author.data(), 64) + ? WebReadResult::Ready + : WebReadResult::StaleIdentity; +} +WebReadResult queueWebConfirmation(const uint8_t transfer[16], const uint8_t owner[16], const uint8_t hash[32], uint32_t total, + const uint8_t author[64], uint32_t generation, const uint8_t* proof, size_t proof_size) +{ + if (!transfer || !owner || !hash || !author || !generation || !total || total > 4166 || !proof || !proof_size || proof_size > 384) return WebReadResult::Invalid; + gc::storage::PublicationConfirmationView decoded; + if (!gc::storage::decodePublicationConfirmation({proof, proof_size}, decoded)) return WebReadResult::Invalid; + if (!webLease(true, true)) return WebReadResult::Unavailable; + Guard guard; + if (!guard.locked || !session || !router) return WebReadResult::Pending; + auto& s = *session; + std::array proof_hash{}; + if (!s.crypto.sha256({proof, proof_size}, proof_hash.data())) return WebReadResult::Unavailable; + if (s.web_confirmation) + { + const auto& job = *s.web_confirmation; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + return job.total == total && job.generation == generation && !std::memcmp(job.transfer.data(), transfer, 16) && !std::memcmp(job.hash.data(), hash, 32) && + !std::memcmp(job.author.data(), author, 64) && job.proof_hash == proof_hash + ? WebReadResult::Ready + : WebReadResult::Conflict; + } + if (s.phase != Phase::Ready || !s.store || s.needsRecovery() || s.store->commitPending() || s.workspace_owner.holder() || downloadActive() || publicationActive() || draftSaveActive()) return WebReadResult::Pending; + auto job = ::platform::memory::PsramPtr(::platform::memory::createPsram(*s.store, s.crypto)); + if (!job) return WebReadResult::Unavailable; + job->source = static_cast(heap_caps_malloc(total, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + job->capacity = std::max(size_t(320), size_t(total) + 128); + job->work = static_cast(heap_caps_malloc(job->capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + job->proof = static_cast(heap_caps_malloc(proof_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!job->source || !job->work || !job->proof) return WebReadResult::Unavailable; + std::memcpy(job->transfer.data(), transfer, 16); + std::memcpy(job->owner.data(), owner, 16); + std::memcpy(job->hash.data(), hash, 32); + std::memcpy(job->author.data(), author, 64); + std::memcpy(job->proof, proof, proof_size); + job->proof_hash = proof_hash; + job->proof_size = proof_size; + job->generation = generation; + job->total = total; + job->media = storage::sd_media_session(); + job->started = now(nullptr).monotonic_ms; + s.web_confirmation = std::move(job); + next_step.store(0); + return WebReadResult::Ready; +} +void advanceWebConfirmation(Session& s) +{ + if (!(wanted.load() & 2)) + { + s.web_confirmation.reset(); + return; + } + auto& job = *s.web_confirmation; + const auto validity = validWebConfirmation(job); + if (validity == WebReadResult::Pending) return; + if (validity != WebReadResult::Ready) + { + job.fail(); + return; + } + if (job.released) return; + if (job.offset < job.total) + { + if (!job.file.is_open() && (!job.file.open("/trailmate/cache.confirm.upload", "r", job.media) || job.file.size() != job.total)) + { + job.fail(); + return; + } + const size_t count = std::min(size_t(320), size_t(job.total - job.offset)); + if (job.file.read(job.source + job.offset, count) != static_cast(count)) + { + job.fail(); + return; + } + job.digest.update(job.source + job.offset, count); + job.offset += count; + return; + } + if (!job.begun) + { + job.file.close(); + if (!job.digest.finalize(job.work, 32) || std::memcmp(job.work, job.hash.data(), 32) || + !job.job.begin(job.transfer, {job.source, job.total}, {job.proof, job.proof_size}, {job.author.data(), 64}, now(nullptr), job.work, job.capacity)) + { + job.fail(); + return; + } + job.begun = true; + } + job.job.advance(); + if (job.job.phase() == WebConfirmationPhase::Failed || job.job.phase() == WebConfirmationPhase::Cancelled) job.fail(); + else if (job.job.phase() == WebConfirmationPhase::Confirmed) job.releasePayload(); +} +WebReadResult readWebConfirmation(const uint8_t transfer[16], const uint8_t owner[16], uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!transfer || !owner || !output || capacity < 192) return WebReadResult::Invalid; + Guard guard; + if (!guard.locked || !session || !session->web_confirmation) return WebReadResult::Pending; + auto& job = *session->web_confirmation; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + if (std::memcmp(job.transfer.data(), transfer, 16)) return WebReadResult::NotFound; + const auto validity = validWebConfirmation(job); + if (validity != WebReadResult::Ready) return validity; + const uint8_t phase = job.failed ? 2 : job.job.phase() == WebConfirmationPhase::Confirmed ? 1 + : 0; + gc::protocol::CmpWriter writer(output, capacity); + if (!writer.array(8) || !writer.binary({job.transfer.data(), 16}) || !writer.binary({job.hash.data(), 32}) || !writer.unsignedInteger(job.generation) || !writer.unsignedInteger(phase)) return WebReadResult::Invalid; + if (phase == 1) + { + if (!writer.binary({job.job.cacheId().bytes.data(), 32}) || !writer.unsignedInteger(job.job.revision()) || !writer.binary({job.job.hash().bytes.data(), 32}) || !writer.unsignedInteger(1)) return WebReadResult::Invalid; + } + else if (!writer.nil() || !writer.nil() || !writer.nil() || !writer.unsignedInteger(0)) return WebReadResult::Invalid; + written = writer.size(); + return WebReadResult::Ready; +} +WebReadResult releaseWebConfirmation(const uint8_t owner[16], const uint8_t* transfer) +{ + Guard guard; + if (!guard.locked) return WebReadResult::Pending; + if (!owner || !session || !session->web_confirmation) return WebReadResult::NotFound; + const auto& job = *session->web_confirmation; + if (std::memcmp(job.owner.data(), owner, 16)) return WebReadResult::Forbidden; + if (transfer && std::memcmp(job.transfer.data(), transfer, 16)) return WebReadResult::NotFound; + session->web_confirmation.reset(); + return WebReadResult::Ready; +} bool workPending() { if (!router || !board || !mutex) return false; @@ -2387,6 +3640,9 @@ void step() else s.detail = std::move(s.pending_detail); } advanceBrowse(s); + if (!(wanted.load() & 2) && s.detail && + (s.detail->state == CacheDetail::State::LatestNetwork || s.detail->state == CacheDetail::State::LatestReady || + s.detail->state == CacheDetail::State::LatestFailed || s.detail->state == CacheDetail::State::LatestNotFound)) s.detail.reset(); if (s.detail) s.detail->advanceNetwork(*router, s.local, s.crypto, now(nullptr).monotonic_ms); if (!s.storage_requested) { @@ -2394,6 +3650,16 @@ void step() return; } WorkspaceSlice workspace_slice{s}; + if (s.phase == Phase::Ready && s.web_confirmation) + { + advanceWebConfirmation(s); + return; + } + if (s.phase == Phase::Ready && s.web_signature) + { + advanceWebSignature(s); + return; + } if (s.phase == Phase::Ready && s.download_store && s.download_store->missingSavedProjection(s.legacy_projection_cache)) { s.legacy_projection_pending = true; @@ -2404,7 +3670,7 @@ void step() s.phase = Phase::ResumeDownloads; } } - if (s.phase == Phase::Ready && s.draft_catalog_wanted && !ensurePublicationStore(s)) + if (s.phase == Phase::Ready && (s.draft_catalog_wanted || (wanted.load() & 2)) && !ensurePublicationStore(s)) { next_step.store(millis() + 1000); return; @@ -2464,11 +3730,7 @@ void step() return; } drainReply(s); - if (cancel_draft_read.exchange(false)) - { - s.draft_read.reset(); - s.draft_io_reset = true; - } + releaseDraftReaders(s, cancel_draft_read.exchange(0)); if (s.draft_io_reset) { if (s.store) s.store->releaseDraftRead(); @@ -2572,6 +3834,7 @@ void step() if (result == DraftReadResult::Pending) return; if (result != DraftReadResult::Busy) { + job.result = result; const bool valid = result == DraftReadResult::Ready && gc::storage::decodeDraft({job.id.data(), job.id.size()}, bytes, draft); if (valid) { @@ -2591,6 +3854,21 @@ void step() if (s.phase == Phase::Ready && draftSaveActive()) { auto& job = *s.draft_save; + if (job.web_file && advanceWebDraftFile(s)) return; + if (job.web_job && !job.started) + { + uint8_t author[64]; + uint32_t generation = 0, expected_generation = 0; + const auto result = router->readDeviceAuthorKey(author, generation); + if (result == chat::DeviceAuthorResult::Unavailable) return; + std::memcpy(&expected_generation, job.bytes + job.capacity - 100 + 64, sizeof(expected_generation)); + if (result != chat::DeviceAuthorResult::Ok || generation != expected_generation || + std::memcmp(author, job.bytes + job.capacity - 100, 64)) + { + failWebDraftSave(job, result == chat::DeviceAuthorResult::StorageError ? WebReadResult::Unavailable : WebReadResult::StaleIdentity); + return; + } + } if (job.erase_download) { if (advanceOfflineRemoval(s)) return; @@ -2871,8 +4149,21 @@ void step() if (gc::protocol::verifyGeocache({job.bytes, job.size}, s.crypto, scratch, job.size + 26, record) == gc::protocol::VerificationResult::Valid && router->getGeocachingAuthorKey(author.data()) && !std::memcmp(author.data(), record.record.author_public_key.data, 64)) { - const auto restored = restorePublication(s, &record.id, &record.hash, &job.remote); - if (restored == PublicationRestore::Restored || restored == PublicationRestore::Pending) return; + job.author = author; + std::array task; + if (job.web_operation) task = job.operation_id; + const bool stop_requested = job.stop_requested; + const auto restored = restorePublication(s, &record.id, &record.hash, &job.remote, job.web_operation ? &task : nullptr); + if (restored == PublicationRestore::Restored) + { + if (stop_requested) + { + s.publication->stop_requested = true; + s.publication->attempt->cancel(); + } + return; + } + if (restored == PublicationRestore::Pending) return; if (restored == PublicationRestore::Invalid) { job.error = "Saved publication could not be recovered"; @@ -2881,13 +4172,17 @@ void step() ++epoch; return; } - std::array task; gc::RequestId request; - esp_fill_random(task.data(), task.size()); - esp_fill_random(request.bytes.data(), request.bytes.size()); + if (job.web_operation) request.bytes = task; + else + { + esp_fill_random(task.data(), task.size()); + esp_fill_random(request.bytes.data(), request.bytes.size()); + } job.port.reset(::platform::memory::createPsram(*s.store, s.crypto, s.local, record.id, record.hash, task, now(nullptr))); if (job.port) job.attempt.reset(::platform::memory::createPsram(*job.port, s.crypto)); if (job.attempt && !job.attempt->begin(job.remote, request, {job.bytes, job.size}, scratch, job.size + 26)) job.attempt.reset(); + if (job.attempt && stop_requested) job.attempt->cancel(); } heap_caps_free(job.bytes); job.bytes = nullptr; diff --git a/platform/esp/arduino_common/src/gps/track_recorder.cpp b/platform/esp/arduino_common/src/gps/track_recorder.cpp index 09997c2b..d921d8f8 100644 --- a/platform/esp/arduino_common/src/gps/track_recorder.cpp +++ b/platform/esp/arduino_common/src/gps/track_recorder.cpp @@ -4,9 +4,11 @@ #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include +#include #include #include #include +#include namespace gps { @@ -315,11 +317,12 @@ void TrackRecorder::stop() void TrackRecorder::setAutoRecording(bool enabled) { - if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(200)) != pdTRUE) - { - return; - } + requested_auto_recording_.store(enabled ? 1 : 0, std::memory_order_release); + applyPendingSettings(); +} +void TrackRecorder::applyAutoRecordingLocked(bool enabled) +{ auto_recording_ = enabled; if (auto_recording_ && !recording_) { @@ -352,11 +355,6 @@ void TrackRecorder::setAutoRecording(bool enabled) pending_point_count_ = 0; } updateActiveStateLocked(); - - if (mutex_) - { - xSemaphoreGive(mutex_); - } } void TrackRecorder::setIntervalSeconds(uint32_t seconds) @@ -375,17 +373,16 @@ void TrackRecorder::setDistanceOnly(bool enabled) void TrackRecorder::setFormat(TrackFormat format) { - if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(400)) != pdTRUE) - { - return; - } + const auto value = static_cast(format); + if (value > static_cast(TrackFormat::Binary)) return; + requested_format_.store(value, std::memory_order_release); + applyPendingSettings(); +} +void TrackRecorder::applyFormatLocked(TrackFormat format) +{ if (format_ == format) { - if (mutex_) - { - xSemaphoreGive(mutex_); - } return; } @@ -394,15 +391,9 @@ void TrackRecorder::setFormat(TrackFormat format) if (!recording_) { format_ = format; - if (mutex_) - { - xSemaphoreGive(mutex_); - } return; } - format_ = format; - if (current_path_.length() > 0) { writePendingPointsLocked(true); @@ -417,6 +408,7 @@ void TrackRecorder::setFormat(TrackFormat format) f.close(); } } + format_ = format; recording_ = false; current_path_ = ""; last_point_valid_ = false; @@ -431,24 +423,41 @@ void TrackRecorder::setFormat(TrackFormat format) beginNewFile(); } } +} - if (mutex_) +void TrackRecorder::applyPendingSettingsLocked() +{ + const auto automatic = requested_auto_recording_.load(std::memory_order_acquire); + const auto format = requested_format_.load(std::memory_order_acquire); + // Stop automatic recording before a format change; enabling comes last. + // Requests remain available for another poll if a newer value arrives + // during application. Never clear a newer request with an old completion. + if (automatic == 0 && auto_recording_) applyAutoRecordingLocked(false); + if (format <= static_cast(TrackFormat::Binary) && format != static_cast(format_)) + applyFormatLocked(static_cast(format)); + const uint32_t now = millis(); + if (automatic == 1 && (!auto_recording_ || (!recording_ && now - last_flush_ms_ >= 1000U))) { - xSemaphoreGive(mutex_); + // While stopped there are no pending points; reuse the flush clock to + // bound automatic-start retries if the card is not ready yet. + last_flush_ms_ = now; + applyAutoRecordingLocked(true); } } +void TrackRecorder::applyPendingSettings() +{ + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return; + applyPendingSettingsLocked(); + xSemaphoreGive(mutex_); +} + void TrackRecorder::appendPoint(const TrackPoint& pt) { - if (!recording_ || current_path_.isEmpty()) - { - return; - } - bool force_flush_before_enqueue = false; bool should_flush_after_enqueue = false; - if (mutex_ && xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) + if (!mutex_ || xSemaphoreTake(mutex_, pdMS_TO_TICKS(20)) != pdTRUE) { return; } @@ -512,7 +521,7 @@ void TrackRecorder::appendPoint(const TrackPoint& pt) void TrackRecorder::flushPending(bool force) { const TickType_t mutex_wait = force ? pdMS_TO_TICKS(400) : pdMS_TO_TICKS(20); - if (mutex_ && xSemaphoreTake(mutex_, mutex_wait) != pdTRUE) + if (!mutex_ || xSemaphoreTake(mutex_, mutex_wait) != pdTRUE) { return; } @@ -766,6 +775,46 @@ void TrackRecorder::clearActiveStateLocked() const } } +bool TrackRecorder::tryAcquireRead(bool require_stopped) +{ + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return false; + if (require_stopped && recording_) + { + xSemaphoreGive(mutex_); + return false; + } + return true; +} + +void TrackRecorder::releaseRead() +{ + if (mutex_) + { + applyPendingSettingsLocked(); + xSemaphoreGive(mutex_); + } +} + +bool TrackRecorder::removeTrack(const char* path) +{ + if (!path || std::strncmp(path, "/trackers/", 10) || !path[10]) return false; + const char* name = path + 10; + if (!std::strcmp(name, ".") || !std::strcmp(name, "..")) return false; + // Recovery metadata is not a user track, including FAT case aliases. + if (!strcasecmp(path, kActivePath)) return false; + for (const char* cursor = name; *cursor; ++cursor) + { + if (*cursor == '/' || *cursor == '\\' || *cursor == ':' || + static_cast(*cursor) < 32) return false; + } + if (!mutex_ || xSemaphoreTake(mutex_, 0) != pdTRUE) return false; + // FAT path comparisons must reject case variants of the active filename. + const bool active = recording_ && !strcasecmp(path, current_path_.c_str()); + const bool removed = !active && sd_card_ready() && sd_remove(path); + xSemaphoreGive(mutex_); + return removed; +} + size_t TrackRecorder::listTracks(String* out_names, size_t max_names) const { if (max_names == 0 || out_names == nullptr) diff --git a/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp b/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp index 007e4a5a..49af2ad7 100644 --- a/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp +++ b/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp @@ -4,8 +4,10 @@ #include "hostlink/hostlink_frame_router.h" #include "hostlink/hostlink_service_codec.h" #include "hostlink/hostlink_types.h" +#include "hostlink/sensitive_memory.h" #include "platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h" #include "platform/esp/arduino_common/hostlink/hostlink_config_service.h" +#include "platform/esp/arduino_common/hostlink/hostlink_management_service.h" #include "usb/usb_cdc_transport.h" #include "app/app_config.h" @@ -25,7 +27,9 @@ #include "sys/clock.h" +#include #include +#include #include #include @@ -37,8 +41,13 @@ namespace constexpr uint32_t kHandshakeTimeoutMs = 5000; constexpr uint32_t kStatusIntervalMs = 1500; constexpr uint32_t kGpsIntervalMs = 1000; +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +constexpr size_t kTxQueueSize = 4; +constexpr size_t kCmdQueueSize = 2; +#else constexpr size_t kTxQueueSize = 12; constexpr size_t kCmdQueueSize = 12; +#endif constexpr uint8_t kCmdTxAppFlagWantResponse = 1 << 0; constexpr uint8_t kCmdTxAppFlagTeamMgmtPlain = 1 << 1; @@ -58,10 +67,24 @@ struct TxItem size_t len = 0; }; -TaskHandle_t s_task = nullptr; +void release_tx_item(TxItem& item) +{ + clearSensitiveBytes(item.data, item.len); + free(item.data); + item = {}; +} + +void drain_tx_queue(QueueHandle_t queue) +{ + if (!queue) return; + TxItem item; + while (xQueueReceive(queue, &item, 0) == pdPASS) release_tx_item(item); +} + +std::atomic s_task{nullptr}; QueueHandle_t s_tx_queue = nullptr; QueueHandle_t s_cmd_queue = nullptr; -volatile bool s_stop = false; +std::atomic s_stop{false}; SessionRuntime s_session{}; Decoder s_decoder(kMaxFrameLen); @@ -103,7 +126,8 @@ void send_hello_ack(uint16_t seq) { const uint32_t caps = CapTxMsg | CapConfig | CapSetTime | CapStatus | CapLogs | CapGps | - CapAppData | CapTeamState | CapAprsGateway | CapTxAppData; + CapAppData | CapTeamState | CapAprsGateway | CapTxAppData | CapManagement | + CapChannelPositionPrecision | CapSharedSignatureRecovery | CapSharedConfirmationRecovery; HelloAckPayloadInfo info; info.protocol_version = kProtocolVersion; @@ -289,10 +313,12 @@ bool enqueue_pending_command(const PendingCommand& command) { if (!s_cmd_queue) { + platform::esp::arduino_common::geocaching::browse_runtime::webLease(false); return false; } - PendingCommand copy = command; - return xQueueSend(s_cmd_queue, ©, 0) == pdPASS; + // FreeRTOS copies the item before returning; do not duplicate the 512-byte + // payload on the task stack just to enqueue it. + return xQueueSend(s_cmd_queue, &command, 0) == pdPASS; } ErrorCode execute_cmd_tx_msg(const PendingCommand& command) @@ -488,6 +514,25 @@ ErrorCode execute_frame_command(HostlinkCommandId command, const Frame& frame) } } +ErrorCode handle_cmd_management(const Frame& frame) +{ + if (frame.payload.empty() || frame.payload.size() > kMaxFrameLen) + { + return ErrorCode::InvalidParam; + } + management::EnvelopeView request; + if (!management::decodeEnvelope(frame.payload.data(), frame.payload.size(), request)) + { + return ErrorCode::InvalidParam; + } + PendingCommand command{}; + command.type = PendingCommandType::Management; + command.sequence = frame.seq; + command.payload_len = static_cast(frame.payload.size()); + std::memcpy(command.payload, frame.payload.data(), frame.payload.size()); + return enqueue_pending_command(command) ? ErrorCode::Ok : ErrorCode::Busy; +} + void handle_frame(const Frame& frame) { const HostlinkFrameDecision decision = route_frame(frame, is_ready(s_session)); @@ -497,6 +542,7 @@ void handle_frame(const Frame& frame) send_ack(frame.seq, decision.error); return; case HostlinkFrameDecisionType::CompleteHello: + drain_tx_queue(s_tx_queue); send_hello_ack(frame.seq); mark_handshake_complete(s_session, sys::millis_now()); send_status_event(true); @@ -510,7 +556,12 @@ void handle_frame(const Frame& frame) return; } - ErrorCode result = execute_frame_command(decision.command, frame); + ErrorCode result; + if (decision.command == HostlinkCommandId::Management) + { + result = handle_cmd_management(frame); + } + else result = execute_frame_command(decision.command, frame); if (result != ErrorCode::Ok) { note_error(s_session, static_cast(result)); @@ -532,6 +583,7 @@ void hostlink_task(void* /*arg*/) if (!connected) { mark_disconnected(s_session); + drain_tx_queue(s_tx_queue); s_decoder.reset(); if (s_cmd_queue) { @@ -585,10 +637,7 @@ void hostlink_task(void* /*arg*/) usb_cdc::write(item.data, item.len); note_tx(s_session); } - if (item.data) - { - free(item.data); - } + release_tx_item(item); } vTaskDelay(pdMS_TO_TICKS(10)); @@ -596,6 +645,7 @@ void hostlink_task(void* /*arg*/) usb_cdc::stop(); stop_session(s_session); + s_task.store(nullptr, std::memory_order_release); vTaskDelete(nullptr); } @@ -616,27 +666,53 @@ void start() { s_cmd_queue = xQueueCreate(kCmdQueueSize, sizeof(PendingCommand)); } - xTaskCreate(hostlink_task, "hostlink", 6 * 1024, nullptr, 5, &s_task); + if (!s_tx_queue || !s_cmd_queue) + { + stop(); + return; + } + TaskHandle_t created_task = nullptr; + if (xTaskCreate(hostlink_task, "hostlink", 6 * 1024, nullptr, 5, &created_task) != pdPASS) + { + // A failed start must not retain queues or leave a half-live session. + s_task = nullptr; + stop(); + } + else s_task.store(created_task, std::memory_order_release); } void stop() { - s_stop = true; - if (s_task) +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::publication_runtime::disconnect(); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + // All snapshot operations run on this application scheduler. Retire before + // deleting the queue that would otherwise be needed for cleanup visits. + management::snapshot_runtime::retireForStop(); + management::upload_runtime::advanceUploads(nullptr, 0, management::snapshot_runtime::nowMs(), false); + management::prepare_runtime::releasePrepare(); + management::release_route_install(); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + // Conditional removal is nonblocking while an inbound callback owns the + // router lock. Retire this context before the stopped scheduler loses its + // cleanup visits; handover already clears owner and never removes a job. + while (!management::directory_runtime::release()) { - vTaskDelete(s_task); - s_task = nullptr; + vTaskDelay(1); + } +#endif + s_stop = true; + while (s_task.load(std::memory_order_acquire)) + { + // Let the worker leave its loop and destroy its local frame buffers. + // Queues remain valid until the worker has acknowledged completion. + vTaskDelay(1); } if (s_tx_queue) { - TxItem item; - while (xQueueReceive(s_tx_queue, &item, 0) == pdPASS) - { - if (item.data) - { - free(item.data); - } - } + drain_tx_queue(s_tx_queue); vQueueDelete(s_tx_queue); s_tx_queue = nullptr; } @@ -652,22 +728,43 @@ void stop() void process_pending_commands() { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::ip_confirmation_runtime::tick(management::snapshot_runtime::nowMs(), usb_cdc::is_connected()); +#endif if (!s_cmd_queue) { return; } - if (!is_ready(s_session)) + PendingCommand command{}; + if (!usb_cdc::is_connected() || !is_ready(s_session)) { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::advance_route_install(command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), false); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::snapshot_runtime::advance(command.payload, sizeof(command.payload), false); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::upload_runtime::advanceUploads(command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), false); + management::prepare_runtime::advancePrepare(nullptr, command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), false); + management::publication_runtime::advancePending(nullptr, command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), false); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::directory_runtime::advance(uint64_t(esp_timer_get_time() / 1000), false); +#endif + platform::esp::arduino_common::geocaching::browse_runtime::webLease(false); xQueueReset(s_cmd_queue); return; } - PendingCommand command{}; - while (xQueueReceive(s_cmd_queue, &command, 0) == pdPASS) + if (xQueueReceive(s_cmd_queue, &command, 0) == pdPASS) { ErrorCode result = ErrorCode::Unsupported; switch (command.type) { + case PendingCommandType::Management: + result = management::execute_management(command); + break; case PendingCommandType::TxMsg: result = execute_cmd_tx_msg(command); break; @@ -683,6 +780,33 @@ void process_pending_commands() note_error(s_session, static_cast(result)); } } +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::snapshot_runtime::advance(command.payload, sizeof(command.payload), true); +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::upload_runtime::advanceUploads(command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), true); + management::publication_runtime::advancePending(app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr, + command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), true); + if (management::prepare_runtime::work) + { + const auto advance = [&] + { return management::prepare_runtime::advancePrepare(app::hasAppFacade() ? app::messagingFacade().getMeshAdapter() : nullptr, + command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), true); }; + if (management::prepare_runtime::work->packageInstallation()) management::with_route_storage_gate(advance); + else advance(); + } +#endif +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::directory_runtime::advance(uint64_t(esp_timer_get_time() / 1000), true, command.payload, sizeof(command.payload)); + management::advance_route_install(command.payload, sizeof(command.payload), management::snapshot_runtime::nowMs(), true); +#endif +} + +void tick_publication_tasks() +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + management::publication_runtime::tick(management::snapshot_runtime::nowMs()); +#endif } bool is_active() @@ -695,47 +819,56 @@ Status get_status() return s_session.status; } -bool enqueue_event(uint8_t type, const uint8_t* payload, size_t len, bool high_priority) +bool enqueue_event(uint8_t type, const uint8_t* payload, size_t len, bool high_priority, + uint16_t reply_sequence) { if (!s_tx_queue || !is_ready(s_session)) { return false; } - std::vector frame; - if (!encode_frame(type, next_seq(), payload, len, frame)) + if (len > kMaxFrameLen || (len && !payload)) { return false; } TxItem item; - item.len = frame.size(); + item.len = kHeaderSize + len + kCrcSize; +#if HAS_PSRAM + // These task-owned USB frames do not require DMA-capable storage. Keep + // queued payloads out of the internal radio/task-stack reserve when possible. + item.data = static_cast(heap_caps_malloc_prefer(item.len, 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); +#else item.data = static_cast(malloc(item.len)); +#endif if (!item.data) { return false; } - memcpy(item.data, frame.data(), item.len); + size_t written = 0; + if (!encode_frame_into(type, reply_sequence ? reply_sequence : next_seq(), payload, len, item.data, item.len, written)) + { + release_tx_item(item); + return false; + } if (xQueueSend(s_tx_queue, &item, 0) != pdPASS) { if (!high_priority) { - free(item.data); + release_tx_item(item); return false; } TxItem drop; if (xQueueReceive(s_tx_queue, &drop, 0) == pdPASS) { - if (drop.data) - { - free(drop.data); - } + release_tx_item(drop); if (xQueueSend(s_tx_queue, &item, 0) == pdPASS) { return true; } } - free(item.data); + release_tx_item(item); return false; } return true; diff --git a/platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp b/platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp index dc7b58a1..b45cc753 100644 --- a/platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp +++ b/platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp @@ -1,6 +1,8 @@ #include "platform/esp/arduino_common/mesh/esp_preferences_mesh_identity_store.h" #include "../chat/internal/blob_store_io.h" +#include "platform/esp/common/radio_identity_work.h" +#include #include #include @@ -26,10 +28,19 @@ bool isZero(const uint8_t* data, size_t size) return true; } -bool validIdentity(const ::mesh::LocalIdentity& identity) +bool validIdentity(const ::mesh::LocalIdentity& identity, uint8_t* scratch) { return identity.valid && - !isZero(identity.private_key, sizeof(identity.private_key)); + scratch && !isZero(identity.private_key, sizeof(identity.private_key)) && + (identity.private_key[0] & 7) == 0 && (identity.private_key[31] & 0xc0) == 0x40 && + Curve25519::eval(scratch, identity.private_key, nullptr) && + !std::memcmp(scratch, identity.public_key, sizeof(identity.public_key)); +} +bool isDefaultIdentityOptions(const EspPreferencesLocalIdentityStore::Options& options) +{ + return options.ns && options.public_key && options.private_key && + !std::strcmp(options.ns, "chat") && !std::strcmp(options.public_key, "pki_pub") && + !std::strcmp(options.private_key, "pki_priv"); } } // namespace @@ -47,6 +58,39 @@ EspPreferencesLocalIdentityStore::EspPreferencesLocalIdentityStore(const Options ::mesh::StoreResult EspPreferencesLocalIdentityStore::load(::mesh::LocalIdentity& out) { out = ::mesh::LocalIdentity{}; + using namespace platform::esp::common; + RadioIdentityWork work; + if (!work.data()) return ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); + const auto loaded = isDefaultIdentityOptions(options_) + ? readRadioIdentityRecord(options_.ns, RadioIdentityKind::Meshtastic, work.data(), work.size()) + : IdentityRecordResult::Absent; + if (loaded == IdentityRecordResult::Ready) + { + if (work.data()[5]) return ::mesh::StoreResult::fail(::mesh::StoreFailure::NotFound); + std::memcpy(out.public_key, work.data() + 12, sizeof(out.public_key)); + std::memcpy(out.private_key, work.data() + 44, sizeof(out.private_key)); + out.valid = true; + if (validIdentity(out, work.data() + kRadioIdentityRecordBytes)) return ::mesh::StoreResult::success(); + out = {}; + return ::mesh::StoreResult::fail(::mesh::StoreFailure::Corrupt); + } + if (loaded != IdentityRecordResult::Absent) + return ::mesh::StoreResult::fail(loaded == IdentityRecordResult::Invalid ? ::mesh::StoreFailure::Corrupt : ::mesh::StoreFailure::IoError); + if (isDefaultIdentityOptions(options_)) + { + const auto legacy = readLegacyRadioIdentity(options_.ns, options_.public_key, options_.private_key, + RadioIdentityKind::Meshtastic, work.data(), work.size()); + if (legacy != IdentityRecordResult::Ready) + return ::mesh::StoreResult::fail(legacy == IdentityRecordResult::Absent ? ::mesh::StoreFailure::NotFound + : legacy == IdentityRecordResult::Invalid ? ::mesh::StoreFailure::Corrupt + : ::mesh::StoreFailure::IoError); + std::memcpy(out.public_key, work.data() + 12, sizeof(out.public_key)); + std::memcpy(out.private_key, work.data() + 44, sizeof(out.private_key)); + out.valid = true; + if (validIdentity(out, work.data() + kRadioIdentityRecordBytes)) return ::mesh::StoreResult::success(); + out = {}; + return ::mesh::StoreResult::fail(::mesh::StoreFailure::Corrupt); + } std::vector public_blob; std::vector private_blob; @@ -56,7 +100,9 @@ EspPreferencesLocalIdentityStore::EspPreferencesLocalIdentityStore(const Options ::chat::infra::loadRawBlobFromPreferences(options_.ns, options_.private_key, private_blob); if (!have_public && !have_private) { - return ::mesh::StoreResult::fail(::mesh::StoreFailure::NotFound); + return ::mesh::StoreResult::fail(radioIdentityLegacyAbsent(options_.ns, options_.public_key, options_.private_key) + ? ::mesh::StoreFailure::NotFound + : ::mesh::StoreFailure::IoError); } if (!have_public || !have_private || public_blob.size() != sizeof(out.public_key) || @@ -67,9 +113,10 @@ EspPreferencesLocalIdentityStore::EspPreferencesLocalIdentityStore(const Options std::memcpy(out.public_key, public_blob.data(), sizeof(out.public_key)); std::memcpy(out.private_key, private_blob.data(), sizeof(out.private_key)); - out.valid = !isZero(out.private_key, sizeof(out.private_key)); - if (!out.valid) + out.valid = true; + if (!validIdentity(out, work.data() + kRadioIdentityRecordBytes)) { + out = {}; return ::mesh::StoreResult::fail(::mesh::StoreFailure::Corrupt); } return ::mesh::StoreResult::success(); @@ -78,32 +125,57 @@ EspPreferencesLocalIdentityStore::EspPreferencesLocalIdentityStore(const Options ::mesh::StoreResult EspPreferencesLocalIdentityStore::save( const ::mesh::LocalIdentity& identity) { - if (!validIdentity(identity)) + using namespace platform::esp::common; + RadioIdentityWork work; + if (!work.data()) return ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); + if (!validIdentity(identity, work.data() + kRadioIdentityRecordBytes)) { return ::mesh::StoreResult::fail(::mesh::StoreFailure::InvalidArgument); } - - const bool public_ok = ::chat::infra::saveRawBlobToPreferences( - options_.ns, - options_.public_key, - identity.public_key, - sizeof(identity.public_key)); - const bool private_ok = ::chat::infra::saveRawBlobToPreferences( - options_.ns, - options_.private_key, - identity.private_key, - sizeof(identity.private_key)); - if (!public_ok || !private_ok) + // Preserve explicitly configured legacy storage; native_rf1 belongs only + // to the default PKI owner, not arbitrary key pairs sharing a namespace. + if (!isDefaultIdentityOptions(options_)) { - return ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); + const bool public_ok = ::chat::infra::saveRawBlobToPreferences(options_.ns, options_.public_key, + identity.public_key, sizeof(identity.public_key)); + const bool private_ok = ::chat::infra::saveRawBlobToPreferences(options_.ns, options_.private_key, + identity.private_key, sizeof(identity.private_key)); + return public_ok && private_ok ? ::mesh::StoreResult::success() : ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); } - return ::mesh::StoreResult::success(); + + const auto prior = readRadioIdentityRecord(options_.ns, RadioIdentityKind::Meshtastic, work.data(), work.size()); + if (prior != IdentityRecordResult::Ready && prior != IdentityRecordResult::Absent) + return ::mesh::StoreResult::fail(prior == IdentityRecordResult::Invalid ? ::mesh::StoreFailure::Corrupt : ::mesh::StoreFailure::IoError); + const auto generation = prior == IdentityRecordResult::Ready ? identityRecordU32(work.data() + 8) : 0; + const auto saved = commitRadioIdentityRecord(options_.ns, RadioIdentityKind::Meshtastic, generation, + identity.public_key, identity.private_key, work.data(), work.size(), + [](const uint8_t* pub, const uint8_t* priv, uint8_t* scratch, size_t) + { return Curve25519::eval(scratch, priv, nullptr) && !std::memcmp(pub, scratch, 32); }); + return saved == IdentityRecordResult::Committed ? ::mesh::StoreResult::success() + : ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); } ::mesh::StoreResult EspPreferencesLocalIdentityStore::clear() { - ::chat::infra::clearPreferencesKeys(options_.ns, options_.public_key, options_.private_key); - return ::mesh::StoreResult::success(); + if (!isDefaultIdentityOptions(options_)) + { + ::chat::infra::clearPreferencesKeys(options_.ns, options_.public_key, options_.private_key); + return ::mesh::StoreResult::success(); + } + using namespace platform::esp::common; + RadioIdentityWork work; + if (!work.data()) return ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); + const auto prior = readRadioIdentityRecord(options_.ns, RadioIdentityKind::Meshtastic, work.data(), work.size()); + if (prior != IdentityRecordResult::Ready && prior != IdentityRecordResult::Absent) + return ::mesh::StoreResult::fail(::mesh::StoreFailure::Corrupt); + const auto generation = prior == IdentityRecordResult::Ready ? identityRecordU32(work.data() + 8) : 0; + const auto cleared = commitRadioIdentityRecord( + options_.ns, RadioIdentityKind::Meshtastic, generation, nullptr, nullptr, + work.data(), work.size(), [](const uint8_t*, const uint8_t*, uint8_t*, size_t) + { return false; }, + true); + return cleared == IdentityRecordResult::Committed ? ::mesh::StoreResult::success() + : ::mesh::StoreResult::fail(::mesh::StoreFailure::IoError); } } // namespace platform::esp::arduino_common::mesh diff --git a/platform/esp/arduino_common/src/platform_ui_route_storage.cpp b/platform/esp/arduino_common/src/platform_ui_route_storage.cpp index 2e076ad5..601fdc8f 100644 --- a/platform/esp/arduino_common/src/platform_ui_route_storage.cpp +++ b/platform/esp/arduino_common/src/platform_ui_route_storage.cpp @@ -1,9 +1,15 @@ #include "platform/ui/route_storage.h" +#include "platform/esp/arduino_common/route_image_source_runtime.h" +#include "platform/esp/arduino_common/route_png_cache.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/ui/device_runtime.h" #include "platform/ui/http_client_runtime.h" #include "platform/ui/wifi_access_runtime.h" +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "hostlink/route_storage_lease.h" +#include "platform/esp/arduino_common/route_package_image_runtime.h" +#endif #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -21,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -103,6 +110,7 @@ struct RouteImageBatchContext RouteImageBatchKind kind = RouteImageBatchKind::Download; std::string asset_id{}; + std::string package_route_path{}; std::vector download_items{}; std::vector cache_items{}; }; @@ -125,6 +133,16 @@ RouteImageDownloadResult download_route_image_with_progress( platform::ui::http_client::ProgressCallback progress, void* progress_context); +bool route_image_source_matches_reference(const std::string& image, const std::string& source) +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::route_storage_lease::Guard lease; + if (!lease.acquired()) return false; +#endif + std::unique_ptr work(new (std::nothrow) platform::esp::route_images::RouteImageSourceWork); + return work && work->matchesExistingReference(image, source); +} + bool has_kml_extension(const std::string& name) { if (name.size() < 4) @@ -139,7 +157,7 @@ bool has_kml_extension(const std::string& name) bool is_safe_asset_id(const std::string& asset_id) { - if (asset_id.empty() || asset_id.size() > 48) + if (asset_id.empty() || (asset_id.size() > 48 && !track_sharing::isInstalledPackageAssetId(asset_id))) { return false; } @@ -286,6 +304,9 @@ class HeapCapsBuffer heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); if (!data_) { + // PSRAM-equipped devices never spill optional image work into the + // scarce internal heap when external admission fails. + if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM)) return false; const std::size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); const std::size_t internal_largest = @@ -471,7 +492,8 @@ void log_route_worker_stack(const char* stage) struct RouteJpegCacheContext { - ::platform::esp::arduino_common::storage::SdRuntimeFile input{}; + ::platform::esp::arduino_common::storage::SdRuntimeFile input{ + heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? ::platform::esp::arduino_common::storage::SdRuntimeMemoryPolicy::PsramOnly : ::platform::esp::arduino_common::storage::SdRuntimeMemoryPolicy::Default}; uint8_t* pixels = nullptr; std::size_t pixel_bytes = 0; double crop_x = 0.0; @@ -600,9 +622,16 @@ RouteJpegOutputResult jpeg_output_cb(RouteJpegDecoder* dec, { continue; } + // LVGL's P4 tiny JPEG backend emits BGR; ESP ROM emits RGB. +#if defined(CONFIG_IDF_TARGET_ESP32P4) + ctx->pixels[dst_offset + 0] = rgb[src_offset + 0]; + ctx->pixels[dst_offset + 1] = rgb[src_offset + 1]; + ctx->pixels[dst_offset + 2] = rgb[src_offset + 2]; +#else ctx->pixels[dst_offset + 0] = rgb[src_offset + 2]; ctx->pixels[dst_offset + 1] = rgb[src_offset + 1]; ctx->pixels[dst_offset + 2] = rgb[src_offset + 0]; +#endif } } return 1; @@ -643,7 +672,8 @@ bool write_bmp_24(const std::string& path, return false; } - ::platform::esp::arduino_common::storage::SdRuntimeFile out; + ::platform::esp::arduino_common::storage::SdRuntimeFile out( + heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? ::platform::esp::arduino_common::storage::SdRuntimeMemoryPolicy::PsramOnly : ::platform::esp::arduino_common::storage::SdRuntimeMemoryPolicy::Default); if (!out.open(path.c_str(), "w")) { return false; @@ -676,7 +706,8 @@ bool write_bmp_24(const std::string& path, return false; } - std::vector row(row_bytes, 0); + HeapCapsBuffer row; + if (!row.allocate(row_bytes)) return false; const std::size_t src_row_bytes = static_cast(width) * 3U; for (uint16_t row_index = 0; row_index < height; ++row_index) { @@ -717,6 +748,13 @@ bool generate_route_image_bmp_cache(const std::string& source_path, RouteJpegCacheContext ctx{}; ctx.target_w = width; ctx.target_h = height; + if (width > kRouteViewWidth || height > kRouteViewHeight || !ctx.input.open(source_path.c_str(), "r")) return false; + uint8_t magic[8]{}; + const int magic_bytes = ctx.input.read(magic, sizeof(magic)); + if (magic_bytes != sizeof(magic) || !ctx.input.seek(0)) return false; + constexpr uint8_t png_magic[] = {137, 80, 78, 71, 13, 10, 26, 10}; + const bool png = std::memcmp(magic, png_magic, sizeof(magic)) == 0; + if (png && !heap_caps_get_total_size(MALLOC_CAP_SPIRAM)) return false; HeapCapsBuffer pixels; const std::size_t pixel_bytes = static_cast(width) * height * 3U; if (!pixels.allocate(pixel_bytes)) @@ -726,59 +764,60 @@ bool generate_route_image_bmp_cache(const std::string& source_path, } ctx.pixels = pixels.data(); ctx.pixel_bytes = pixels.size(); - if (!ctx.input.open(source_path.c_str(), "r")) + bool decoded = false; + if (png) { - return false; + decoded = platform::esp::route_images::decodeRoutePng(ctx.input, pixels.data(), pixels.size(), width, height); } + else + { +#if defined(CONFIG_IDF_TARGET_ESP32P4) + constexpr uint8_t kDecodeScale = 0; +#else + constexpr uint8_t kDecodeScale = 1; +#endif + HeapCapsBuffer decoder_work; + if (!decoder_work.allocate(kRouteJpegDecoderWorkBytes)) + { + ctx.input.close(); + log_cache_memory_skip("decoder_work", kRouteJpegDecoderWorkBytes); + return false; + } + RouteJpegDecoder decoder{}; +#if defined(CONFIG_IDF_TARGET_ESP32P4) + JRESULT result = jd_prepare(&decoder, + jpeg_input_cb, + decoder_work.data(), + decoder_work.size(), + &ctx); +#else + esp_rom_tjpgd_result_t result = + esp_rom_tjpgd_prepare(&decoder, + jpeg_input_cb, + decoder_work.data(), + static_cast(decoder_work.size()), + &ctx); +#endif + if (result != JDR_OK || decoder.width == 0 || decoder.height == 0) + { + ctx.input.close(); + return false; + } + ctx.source_w = static_cast( + std::max(1U, decoder.width >> kDecodeScale)); + ctx.source_h = static_cast( + std::max(1U, decoder.height >> kDecodeScale)); + configure_cover_crop(ctx); #if defined(CONFIG_IDF_TARGET_ESP32P4) - constexpr uint8_t kDecodeScale = 0; + result = jd_decomp(&decoder, jpeg_output_cb, kDecodeScale); #else - constexpr uint8_t kDecodeScale = 1; + result = esp_rom_tjpgd_decomp(&decoder, jpeg_output_cb, kDecodeScale); #endif - HeapCapsBuffer decoder_work; - if (!decoder_work.allocate(kRouteJpegDecoderWorkBytes)) - { - ctx.input.close(); - log_cache_memory_skip("decoder_work", kRouteJpegDecoderWorkBytes); - return false; + decoded = result == JDR_OK; } - RouteJpegDecoder decoder{}; -#if defined(CONFIG_IDF_TARGET_ESP32P4) - JRESULT result = jd_prepare(&decoder, - jpeg_input_cb, - decoder_work.data(), - decoder_work.size(), - &ctx); -#else - esp_rom_tjpgd_result_t result = - esp_rom_tjpgd_prepare(&decoder, - jpeg_input_cb, - decoder_work.data(), - static_cast(decoder_work.size()), - &ctx); -#endif - if (result != JDR_OK || decoder.width == 0 || decoder.height == 0) - { - ctx.input.close(); - return false; - } - ctx.source_w = static_cast( - std::max(1U, decoder.width >> kDecodeScale)); - ctx.source_h = static_cast( - std::max(1U, decoder.height >> kDecodeScale)); - configure_cover_crop(ctx); - -#if defined(CONFIG_IDF_TARGET_ESP32P4) - result = jd_decomp(&decoder, jpeg_output_cb, kDecodeScale); -#else - result = esp_rom_tjpgd_decomp(&decoder, jpeg_output_cb, kDecodeScale); -#endif ctx.input.close(); - if (result != JDR_OK) - { - return false; - } + if (!decoded) return false; const std::string temp_path = output_path + ".tmp"; (void)::platform::esp::arduino_common::storage::sd_remove(temp_path.c_str()); @@ -808,6 +847,10 @@ bool route_image_cache_ready(const RouteImageCacheItem& item) bool ensure_route_image_cache(const RouteImageCacheItem& item) { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::route_storage_lease::Guard lease; + if (!lease.acquired()) return false; +#endif if (route_image_cache_ready(item)) { return true; @@ -1043,6 +1086,7 @@ void route_image_worker_task(void* param) { auto* ctx = static_cast(param); std::string asset_id; + std::string package_route_path; RouteImageBatchKind kind = RouteImageBatchKind::Download; std::vector download_items; std::vector cache_items; @@ -1050,6 +1094,7 @@ void route_image_worker_task(void* param) { kind = ctx->kind; asset_id = std::move(ctx->asset_id); + package_route_path = std::move(ctx->package_route_path); download_items = std::move(ctx->download_items); cache_items = std::move(ctx->cache_items); delete ctx; @@ -1099,6 +1144,30 @@ void route_image_worker_task(void* param) } else if (cache_only) { + uint64_t package_sources = 0; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if (!package_route_path.empty()) + { + namespace images = platform::esp::route_images; + auto* work = images::createRoutePackageImageWork(); + if (work) + { + auto checked = work->begin(package_route_path); + while (checked == track_sharing::ManifestFileResult::Pending) + { + checked = work->step(); + taskYIELD(); + } + if (work->ready()) + for (size_t i = 0; i < cache_items.size() && i < 64; ++i) + if (cache_items[i].source_hash_valid && work->materialize(cache_items[i].source_hash, cache_items[i].source_path, [] + { taskYIELD(); })) package_sources |= uint64_t(1) << i; + // Release the package lease and proof/mapping work before the + // existing decoder acquires its own lease and pixel workspace. + images::destroyRoutePackageImageWork(work); + } + } +#endif for (std::size_t index = 0; index < total; ++index) { const RouteImageCacheItem& item = cache_items[index]; @@ -1119,7 +1188,7 @@ void route_image_worker_task(void* param) detail); ++processed; - if (ensure_route_image_cache(item)) + if ((package_route_path.empty() || (index < 64 && (package_sources & (uint64_t(1) << index)))) && ensure_route_image_cache(item)) { ++saved; std::snprintf(detail, @@ -1212,7 +1281,7 @@ void route_image_worker_task(void* param) "Image %u missing URL", static_cast(index + 1)); } - else if (route_asset_file_exists(item.output_path)) + else if (route_image_source_matches_reference(item.output_path, item.url)) { original_available = true; consecutive_connection_failures = 0; @@ -1464,6 +1533,10 @@ bool list_routes(std::vector& out_routes, std::size_t max_count) bool remove_route(const std::string& path) { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::route_storage_lease::Guard lease; + if (!lease.acquired()) return false; +#endif if (!platform::ui::device::sd_ready() || path.empty()) { return false; @@ -1555,6 +1628,22 @@ bool route_asset_file_exists(const std::string& path) !::platform::esp::arduino_common::storage::sd_is_directory(path.c_str()); } +bool route_image_source_matches(const std::string& path, const uint8_t expected_source_hash[32]) +{ +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::route_storage_lease::Guard lease; + if (!lease.acquired()) return false; +#endif + std::unique_ptr work(new (std::nothrow) platform::esp::route_images::RouteImageSourceWork); + return work && work->matchesExisting(path, expected_source_hash); +} + +bool route_image_reference_hash(std::string_view reference, uint8_t output[32]) +{ + std::unique_ptr work(new (std::nothrow) platform::esp::route_images::ImageReferenceHashWork); + return work && work->hash(reference, output); +} + namespace { @@ -1566,6 +1655,14 @@ RouteImageDownloadResult download_route_image_with_progress( void* progress_context) { RouteImageDownloadResult result{}; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::route_storage_lease::Guard lease; + if (!lease.acquired()) + { + result.error = "Route storage busy"; + return result; + } +#endif if (url.empty() || output_path.empty()) { result.error = "Missing URL"; @@ -1587,13 +1684,20 @@ RouteImageDownloadResult download_route_image_with_progress( return result; } - const std::string temp_path = output_path + ".tmp"; - (void)::platform::esp::arduino_common::storage::sd_remove(temp_path.c_str()); - - ::platform::esp::arduino_common::storage::SdRuntimeFile out; - if (!out.open(temp_path.c_str(), "w")) + if (!max_bytes || max_bytes > track_sharing::kImageSourceByteLimit) { - result.error = "Open image file failed"; + result.error = "Invalid image size limit"; + return result; + } + std::unique_ptr work(new (std::nothrow) platform::esp::route_images::RouteImageSourceWork); + if (!work) + { + result.error = "Low image verification memory"; + return result; + } + if (!work->begin(url, output_path)) + { + result.error = "Invalid image path or unavailable storage"; return result; } @@ -1616,33 +1720,30 @@ RouteImageDownloadResult download_route_image_with_progress( request, [](const std::uint8_t* data, std::size_t len, void* context) { - auto* file = static_cast<::platform::esp::arduino_common::storage::SdRuntimeFile*>(context); - return file && file->write(data, len) == len; + auto* work = static_cast(context); + return work && work->feed(data, len); }, - &out, + work.get(), result.error, &stats); result.http_status = stats.http_status; result.bytes = stats.bytes; - (void)out.flush(); - out.close(); - - if (!ok) + if (!ok) return result; + if (stats.bytes != work->bytes() || !work->seal()) { - (void)::platform::esp::arduino_common::storage::sd_remove(temp_path.c_str()); + result.error = "Image write or hash failed"; return result; } - - if (::platform::esp::arduino_common::storage::sd_exists(output_path.c_str())) + while (work->verifying()) { - (void)::platform::esp::arduino_common::storage::sd_remove(output_path.c_str()); + work->verifyStep(); + taskYIELD(); } - if (!::platform::esp::arduino_common::storage::sd_rename(temp_path.c_str(), output_path.c_str())) + if (!work->commit()) { - (void)::platform::esp::arduino_common::storage::sd_remove(temp_path.c_str()); result.ok = false; - result.error = "Finalize image failed"; + result.error = "Image verification or source binding failed"; return result; } @@ -1786,11 +1887,13 @@ bool start_route_image_download(const std::string& asset_id, } bool start_route_image_cache_build(const std::string& asset_id, - const std::vector& items, + std::vector items, std::string& out_error, - RouteImageTaskPresentation presentation) + RouteImageTaskPresentation presentation, + const std::string& package_route_path) { out_error.clear(); + const auto total = items.size(); if (!ensure_batch_mutex()) { out_error = "Create image cache mutex failed"; @@ -1801,7 +1904,7 @@ bool start_route_image_cache_build(const std::string& asset_id, out_error = "Invalid route asset id"; return false; } - if (items.empty()) + if (items.empty() || items.size() > 64) { out_error = "No route image cache work"; return false; @@ -1815,7 +1918,8 @@ bool start_route_image_cache_build(const std::string& asset_id, } ctx->kind = RouteImageBatchKind::Cache; ctx->asset_id = asset_id; - ctx->cache_items = items; + ctx->package_route_path = package_route_path; + ctx->cache_items = std::move(items); { BatchStateLock lock(s_image_batch.mutex); @@ -1839,7 +1943,7 @@ bool start_route_image_cache_build(const std::string& asset_id, set_batch_status_locked(RouteImageDownloadPhase::Caching, true, asset_id, - items.size(), + total, 0, 0, 0, diff --git a/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp index 30d5c578..bbb332dc 100644 --- a/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_tracker_runtime.cpp @@ -9,6 +9,8 @@ #include "freertos/task.h" #include +#include +#include namespace platform::ui::tracker { @@ -137,13 +139,21 @@ void stop_recording() bool current_path(std::string& out_path) { - const String& path = ::gps::TrackRecorder::getInstance().currentPath(); - if (path.isEmpty()) + auto& recorder = ::gps::TrackRecorder::getInstance(); + if (!recorder.tryAcquireRead(false)) { out_path.clear(); return false; } + const String& path = recorder.currentPath(); + if (path.isEmpty()) + { + out_path.clear(); + recorder.releaseRead(); + return false; + } out_path = path.c_str(); + recorder.releaseRead(); return true; } @@ -168,6 +178,7 @@ bool list_tracks(std::vector& out_tracks, std::size_t max_count) void poll() { (void)ensure_worker(); + ::gps::TrackRecorder::getInstance().applyPendingSettings(); if (!s_start_pending.load() && !s_stop_pending.load()) { s_desired_recording.store(::gps::TrackRecorder::getInstance().isRecording()); @@ -180,7 +191,7 @@ bool remove_track(const std::string& path) { return false; } - return ::platform::esp::arduino_common::storage::sd_remove(path.c_str()); + return ::gps::TrackRecorder::getInstance().removeTrack(path.c_str()); } const char* track_dir() @@ -188,6 +199,38 @@ const char* track_dir() return ::gps::TrackRecorder::kTrackDir; } +bool try_acquire_snapshot_read() +{ + if (s_start_pending.load() || s_stop_pending.load()) return false; + auto& recorder = ::gps::TrackRecorder::getInstance(); + if (!recorder.tryAcquireRead(true)) return false; + if (s_start_pending.load() || s_stop_pending.load()) + { + recorder.releaseRead(); + return false; + } + return true; +} + +bool try_acquire_catalog_read() +{ + return ::gps::TrackRecorder::getInstance().tryAcquireRead(false); +} + +void release_snapshot_read() +{ + ::gps::TrackRecorder::getInstance().releaseRead(); +} + +bool is_recording_filename(const char* filename) +{ + // Caller holds the catalog/snapshot mutex, so the borrowed path is stable. + auto& recorder = ::gps::TrackRecorder::getInstance(); + const String& path = recorder.currentPath(); + return filename && recorder.isRecording() && !std::strncmp(path.c_str(), "/trackers/", 10) && + !strcasecmp(path.c_str() + 10, filename); +} + void set_auto_recording(bool enabled) { ::gps::TrackRecorder::getInstance().setAutoRecording(enabled); diff --git a/platform/esp/arduino_common/src/route_png_cache.cpp b/platform/esp/arduino_common/src/route_png_cache.cpp new file mode 100644 index 00000000..0b52eeaf --- /dev/null +++ b/platform/esp/arduino_common/src/route_png_cache.cpp @@ -0,0 +1,232 @@ +#include "platform/esp/arduino_common/route_png_cache.h" +#include +#include +#include +#include +#include +#if defined(ESP_PLATFORM) +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#endif +#if __has_include() +#include "../../../../third_party/route_png/route_pngle.h" +#include + +extern "C" void* trail_mate_png_alloc(size_t bytes) +{ + if (!bytes || bytes > 65552) return nullptr; +#if defined(ESP_PLATFORM) + if (!heap_caps_get_total_size(MALLOC_CAP_SPIRAM)) return nullptr; + return heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + return std::malloc(bytes); +#endif +} +extern "C" void trail_mate_png_free(void* memory) +{ +#if defined(ESP_PLATFORM) + heap_caps_free(memory); +#else + std::free(memory); +#endif +} +namespace platform::esp::route_images +{ +namespace +{ +namespace sd = arduino_common::storage; +constexpr uint32_t kPngLimit = 8U * 1024U * 1024U; +constexpr uint32_t kIhdr = 0x49484452, kPlte = 0x504c5445, kTrns = 0x74524e53, kIdat = 0x49444154, kIend = 0x49454e44; +class PngCacheWork +{ + public: + PngCacheWork(sd::SdRuntimeFile& input, uint8_t* output, uint16_t width, uint16_t height) + : input_(input), pixels_(output), media_(sd::sd_media_session()), width_(width), height_(height) {} + bool current() const { return media_ && media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + static uint32_t big(const uint8_t* p) { return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3]; } + static void yield() + { +#if defined(ESP_PLATFORM) + taskYIELD(); +#endif + } + bool exact(uint8_t* output, size_t bytes) + { + size_t read = 0; + while (read < bytes) + { + if (!current()) return false; + const int count = input_.read(output + read, bytes - read); + if (count <= 0 || size_t(count) > bytes - read) return false; + read += size_t(count); + yield(); + } + return true; + } + bool validate() + { + // Complete CRC/order/length proof before the allocating decoder. No + // ancillary compression or caller-controlled allocation sizes. + if (!current() || input_.size() > kPngLimit || input_.size() < 45 || !input_.seek(0) || !exact(scratch_, 8)) return false; + constexpr uint8_t signature[] = {137, 80, 78, 71, 13, 10, 26, 10}; + if (std::memcmp(scratch_, signature, 8)) return false; + bool header = false, palette = false, transparency = false, data = false, data_ended = false; + uint64_t data_bytes = 0; + unsigned palette_count = 0; + for (unsigned chunk = 0; chunk < 65536; ++chunk) + { + if (!current() || input_.position() > input_.size() || input_.size() - input_.position() < 12 || !exact(scratch_, 8)) return false; + const uint32_t size = big(scratch_), type = big(scratch_ + 4); + if (size > input_.size() - input_.position() - 4 || (!header && type != kIhdr)) return false; + for (unsigned i = 4; i < 8; ++i) + if (!((scratch_[i] >= 'A' && scratch_[i] <= 'Z') || (scratch_[i] >= 'a' && scratch_[i] <= 'z'))) return false; + if (scratch_[6] & 32) return false; // PNG reserved bit + if (!(scratch_[4] & 32) && type != kIhdr && type != kPlte && type != kIdat && type != kIend) return false; + if (type == kIhdr && (header || size != 13)) return false; + if (type == kPlte && (palette || data || !size || size > 768 || size % 3 || color_ == 0 || color_ == 4)) return false; + if (type == kTrns && (transparency || data || !size || + (color_ == 0 ? size != 2 : color_ == 2 ? size != 6 + : color_ == 3 ? !palette || size > palette_count + : true))) return false; + if (type == kIdat && (data_ended || (color_ == 3 && !palette))) return false; + if (type == kIend && (size || !data || !data_bytes)) return false; + uint32_t crc = uint32_t(lgfx_mz_crc32(0, scratch_ + 4, 4)); + uint32_t remaining = size; + while (remaining) + { + const auto count = std::min(remaining, sizeof(scratch_)); + if (!exact(scratch_, count)) return false; + crc = uint32_t(lgfx_mz_crc32(crc, scratch_, count)); + if (type == kIhdr) + { + source_w_ = big(scratch_); + source_h_ = big(scratch_ + 4); + depth_ = scratch_[8]; + color_ = scratch_[9]; + const bool depth_valid = color_ == 0 ? (depth_ == 1 || depth_ == 2 || depth_ == 4 || depth_ == 8 || depth_ == 16) + : color_ == 3 ? (depth_ == 1 || depth_ == 2 || depth_ == 4 || depth_ == 8) + : (color_ == 2 || color_ == 4 || color_ == 6) && (depth_ == 8 || depth_ == 16); + if (!source_w_ || !source_h_ || source_w_ > 8192 || source_h_ > 8192 || uint64_t(source_w_) * source_h_ > 32000000 || + !depth_valid || scratch_[10] || scratch_[11] || scratch_[12] > 1) return false; + } + remaining -= uint32_t(count); + } + if (!exact(scratch_, 4) || big(scratch_) != crc) return false; + if (type == kIhdr) header = true; + else if (type == kPlte) + { + palette_count = size / 3; + if (color_ == 3 && palette_count > (1U << depth_)) return false; + palette = true; + } + else if (type == kTrns) transparency = true; + else if (type == kIdat) + { + data = true; + data_bytes += size; + } + else if (data) data_ended = true; + if (type == kIend) return current() && input_.position() == input_.size() && input_.seek(0); + } + return false; + } + void crop() + { + const double source = double(source_w_) / source_h_, target = double(width_) / height_; + if (source > target) + { + crop_h_ = source_h_; + crop_w_ = crop_h_ * target; + crop_x_ = (source_w_ - crop_w_) / 2; + crop_y_ = 0; + } + else + { + crop_w_ = source_w_; + crop_h_ = crop_w_ / target; + crop_x_ = 0; + crop_y_ = (source_h_ - crop_h_) / 2; + } + } + bool dimensionsMatch(uint32_t width, uint32_t height) const { return width == source_w_ && height == source_h_; } + bool drawable() const { return current() && !draw_error_; } + static uint32_t read(void* context, uint8_t* buffer, uint32_t bytes) + { + auto& self = *static_cast(context); + if (!self.current() || self.input_.position() > self.input_.size() || bytes > self.input_.size() - self.input_.position()) return 0; + const bool ok = buffer ? self.exact(buffer, bytes) : self.input_.seek(self.input_.position() + bytes); + yield(); + return ok ? bytes : 0; + } + static void draw(void* context, uint32_t x, uint32_t y, uint_fast8_t step, size_t count, const uint8_t* argb) + { + auto& s = *static_cast(context); + if (!step || !count || !argb || y >= s.source_h_ || x >= s.source_w_ || count > (s.source_w_ - x + step - 1) / step) + { + s.draw_error_ = true; + return; + } + const uint32_t last = x + uint32_t(count - 1) * step; + const int dy0 = std::max(0, int(std::floor((double(y) - s.crop_y_) * s.height_ / s.crop_h_)) - 1); + const int dy1 = std::min(int(s.height_) - 1, int(std::ceil((double(y + 1) - s.crop_y_) * s.height_ / s.crop_h_)) + 1); + const int dx0 = std::max(0, int(std::floor((double(x) - s.crop_x_) * s.width_ / s.crop_w_)) - 1); + const int dx1 = std::min(int(s.width_) - 1, int(std::ceil((double(last + 1) - s.crop_x_) * s.width_ / s.crop_w_)) + 1); + for (int dy = dy0; dy <= dy1; ++dy) + { + const bool wide = uint64_t(s.source_w_) * s.height_ > uint64_t(s.source_h_) * s.width_; + const uint32_t sy = wide ? uint64_t(2 * dy + 1) * s.source_h_ / (2 * s.height_) + : (uint64_t(s.source_h_) * s.width_ - uint64_t(s.source_w_) * s.height_ + uint64_t(2 * dy + 1) * s.source_w_) / (2 * s.width_); + if (sy != y) continue; + for (int dx = dx0; dx <= dx1; ++dx) + { + const uint32_t sx = wide ? (uint64_t(s.source_w_) * s.height_ - uint64_t(s.source_h_) * s.width_ + uint64_t(2 * dx + 1) * s.source_h_) / (2 * s.height_) + : uint64_t(2 * dx + 1) * s.source_w_ / (2 * s.width_); + if (sx < x || sx > last || (sx - x) % step) continue; + const auto* pixel = argb + ((sx - x) / step) * 4; + auto* target = s.pixels_ + (size_t(dy) * s.width_ + dx) * 3; + const unsigned alpha = pixel[0]; + for (unsigned channel = 0; channel < 3; ++channel) + target[channel] = uint8_t((unsigned(pixel[3 - channel]) * alpha + 255 * (255 - alpha) + 127) / 255); + } + } + yield(); + } + + private: + sd::SdRuntimeFile& input_; + uint8_t* pixels_; + uint32_t media_, source_w_ = 0, source_h_ = 0; + uint16_t width_, height_; + uint8_t depth_ = 0, color_ = 0, scratch_[512]{}; + bool draw_error_ = false; + double crop_x_ = 0, crop_y_ = 0, crop_w_ = 0, crop_h_ = 0; +}; +static_assert(sizeof(PngCacheWork) <= 768, "PNG transport/CRC/crop scratch is transient PSRAM"); +} // namespace +bool decodeRoutePng(sd::SdRuntimeFile& input, uint8_t* bgr, size_t capacity, uint16_t width, uint16_t height) +{ + if (!bgr || !width || !height || width > 320 || height > 180 || capacity < size_t(width) * height * 3) return false; + void* memory = trail_mate_png_alloc(sizeof(PngCacheWork)); + if (!memory) return false; + auto* work = new (memory) PngCacheWork(input, bgr, width, height); + bool ok = work->validate(); + pngle_t* decoder = ok ? trail_mate_pngle_new() : nullptr; + ok = decoder && trail_mate_pngle_prepare(decoder, &PngCacheWork::read, work) == 0 && work->dimensionsMatch(trail_mate_pngle_get_width(decoder), trail_mate_pngle_get_height(decoder)); + if (ok) + { + work->crop(); + ok = trail_mate_pngle_decomp(decoder, &PngCacheWork::draw) == 0 && work->drawable() && input.position() == input.size(); + } + trail_mate_pngle_destroy(decoder); + work->~PngCacheWork(); + trail_mate_png_free(memory); + return ok; +} +} // namespace platform::esp::route_images +#else +namespace platform::esp::route_images +{ +bool decodeRoutePng(arduino_common::storage::SdRuntimeFile&, uint8_t*, size_t, uint16_t, uint16_t) { return false; } +} // namespace platform::esp::route_images +#endif diff --git a/platform/esp/arduino_common/src/route_png_codec.c b/platform/esp/arduino_common/src/route_png_codec.c new file mode 100644 index 00000000..70d36028 --- /dev/null +++ b/platform/esp/arduino_common/src/route_png_codec.c @@ -0,0 +1,5 @@ +// Route decoder private instance. Original LovyanGFX API is left intact. +#if __has_include() +#include "../../../../third_party/route_png/route_inflate.c" +#include "../../../../third_party/route_png/route_pngle.c" +#endif diff --git a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp index d87c42d4..c95e61d2 100644 --- a/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp +++ b/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp @@ -1265,6 +1265,11 @@ bool sd_is_directory(const char* path) } bool sd_mkdir(const char* path) +{ + return sd_mkdir(path, sd_media_session()); +} + +bool sd_mkdir(const char* path, uint32_t expected_session) { const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("mkdir", normalized); @@ -1282,6 +1287,11 @@ bool sd_mkdir(const char* path) sd_io_end("mkdir", normalized, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || expected_session == s_faulted_media_session.load() || sd_external_block_owner_active()) + { + sd_io_end("mkdir", normalized, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { result = s_sdfat.mkdir(normalized, true); @@ -1321,6 +1331,11 @@ bool sd_rmdir(const char* path) } bool sd_remove(const char* path) +{ + return sd_remove(path, sd_media_session()); +} + +bool sd_remove(const char* path, uint32_t expected_session) { const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("remove", normalized); @@ -1338,6 +1353,11 @@ bool sd_remove(const char* path) sd_io_end("remove", normalized, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || s_external_block_owner_active) + { + sd_io_end("remove", normalized, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { result = s_sdfat.remove(normalized); @@ -1419,6 +1439,17 @@ SdRuntimeFile::SdRuntimeFile() { } +SdRuntimeFile::SdRuntimeFile(SdRuntimeMemoryPolicy policy) : impl_(nullptr) +{ + if (policy == SdRuntimeMemoryPolicy::Default) + { + impl_ = psram_preferred_object(); + return; + } + void* memory = heap_caps_malloc(sizeof(Impl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (memory) impl_ = new (memory) Impl(); +} + SdRuntimeFile::~SdRuntimeFile() { close(); @@ -2018,6 +2049,17 @@ SdRuntimeDir::SdRuntimeDir() { } +SdRuntimeDir::SdRuntimeDir(SdRuntimeMemoryPolicy policy) : impl_(nullptr) +{ + if (policy == SdRuntimeMemoryPolicy::Default) + { + impl_ = psram_preferred_object(); + return; + } + void* memory = heap_caps_malloc(sizeof(Impl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (memory) impl_ = new (memory) Impl(); +} + SdRuntimeDir::~SdRuntimeDir() { close(); @@ -2114,6 +2156,14 @@ bool SdRuntimeDir::read_next(char* name, std::size_t name_size, bool* is_dir) SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size, bool* is_dir) { + return read_next_info(name, name_size, is_dir, nullptr, nullptr); +} + +SdDirReadStatus SdRuntimeDir::read_next_info(char* name, std::size_t name_size, bool* is_dir, + uint64_t* file_size, uint64_t* next_position) +{ + if (file_size) *file_size = 0; + if (next_position) *next_position = 0; if (name == nullptr || name_size == 0) { return SdDirReadStatus::Invalid; @@ -2123,7 +2173,7 @@ SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size { *is_dir = false; } - if (!is_open()) return SdDirReadStatus::Unavailable; + if (!is_open() || sd_external_block_owner_active()) return SdDirReadStatus::Unavailable; if (impl_->backend == SdCardBackend::SdFat) { @@ -2134,6 +2184,7 @@ SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size sd_io_end("dir_read", impl_->path, start_ms, false, 0, -2); return SdDirReadStatus::Busy; } + const SdIoErrorScope io_error; if (impl_->retry_pending) { // Reopen to clear SdFat's sticky read-error bit, then restore the @@ -2164,7 +2215,7 @@ SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size sd_io_end("dir_read", impl_->path, start_ms, !failed, 0, failed ? -1 : 0); return failed ? SdDirReadStatus::IoError : SdDirReadStatus::End; } - entry.getName(name, name_size); + const bool named = entry.getName(name, name_size); if (guard.busStatus() == sys::runtime::BusAcquireStatus::Busy || guard.busStatus() == sys::runtime::BusAcquireStatus::TimedOut) { impl_->retry_position = position; @@ -2176,7 +2227,17 @@ SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size { *is_dir = entry.isDir(); } - entry.close(); + const uint64_t bytes = entry.isDir() ? 0 : entry.fileSize(); + const uint64_t next = impl_->sdfat_dir.curPosition(); + const bool closed = entry.close(); + if (!named || !closed || io_error.error()) + { + name[0] = 0; + sd_io_end("dir_read", impl_->path, start_ms, false, 0, -1); + return SdDirReadStatus::IoError; + } + if (file_size) *file_size = bytes; + if (next_position) *next_position = next; sd_io_end("dir_read", impl_->path, start_ms, true, 0, name[0] != '\0' ? 1 : 0); return name[0] != '\0' ? SdDirReadStatus::Entry : SdDirReadStatus::IoError; } @@ -2184,6 +2245,27 @@ SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size return SdDirReadStatus::Unavailable; } +bool SdRuntimeDir::seek_read_position(uint64_t position) +{ + return seek_read_status(position) == SdFileReadStatus::Ready; +} + +SdFileReadStatus SdRuntimeDir::seek_read_status(uint64_t position) +{ + if (!impl_ || position % 32) return SdFileReadStatus::Invalid; + SdRuntimeOperationGuard guard("sd_dir_seek"); + if (!guard.locked()) return SdFileReadStatus::Busy; + if (!is_open() || sd_external_block_owner_active()) return SdFileReadStatus::Unavailable; + const SdIoErrorScope io_error; + const bool sought = impl_->sdfat_dir.seekSet(position); + if (guard.busStatus() == sys::runtime::BusAcquireStatus::Busy || + guard.busStatus() == sys::runtime::BusAcquireStatus::TimedOut) return SdFileReadStatus::Busy; + if (io_error.error()) return SdFileReadStatus::IoError; + if (!sought) return SdFileReadStatus::Invalid; + impl_->retry_pending = false; + return SdFileReadStatus::Ready; +} + bool sd_read_raw(uint32_t lba, uint8_t* buffer) { char path[32]; diff --git a/platform/esp/arduino_common/src/usb/usb_cdc_transport.cpp b/platform/esp/arduino_common/src/usb/usb_cdc_transport.cpp index e70d41d0..debfffed 100644 --- a/platform/esp/arduino_common/src/usb/usb_cdc_transport.cpp +++ b/platform/esp/arduino_common/src/usb/usb_cdc_transport.cpp @@ -53,7 +53,7 @@ bool start() s_status.dtr = false; s_cdc.enableReboot(false); - s_cdc.setRxBufferSize(2048); + s_cdc.setRxBufferSize(1024); s_cdc.setTxTimeoutMs(10); s_cdc.onEvent(cdc_event_cb); s_cdc.begin(); @@ -67,6 +67,11 @@ bool start() void stop() { +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + // The app owns enumeration for reconnect. HostLink may release its task + // and queues without re-registering callbacks or rebuilding descriptors. + return; +#endif #if CONFIG_TINYUSB_CDC_ENABLED && defined(TRAIL_MATE_ENABLE_USB_CDC_TRANSPORT) if (!s_status.started) { diff --git a/platform/esp/common/include/platform/esp/common/config_preferences.h b/platform/esp/common/include/platform/esp/common/config_preferences.h new file mode 100644 index 00000000..8973ff1f --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/config_preferences.h @@ -0,0 +1,73 @@ +#pragma once + +// Preserve Arduino's existing typed NVS representation. Native IDF uses the +// same namespaces, keys and scalar types without an Arduino runtime dependency. +#if defined(ARDUINO) +#include +namespace platform::esp::common +{ +using ConfigPreferences = ::Preferences; +} +#else +#include +#include +#include + +namespace platform::esp::common +{ +class ConfigPreferences +{ + public: + ~ConfigPreferences() { end(); } + ConfigPreferences() = default; + ConfigPreferences(const ConfigPreferences&) = delete; + ConfigPreferences& operator=(const ConfigPreferences&) = delete; + bool begin(const char* ns, bool read_only) + { + end(); + read_only_ = read_only; + return ns && nvs_open(ns, read_only ? NVS_READONLY : NVS_READWRITE, &handle_) == ESP_OK; + } + void end() + { + if (handle_) nvs_close(handle_); + handle_ = 0; + } + uint8_t getUChar(const char* key, uint8_t fallback = 0) const + { + uint8_t value; + return handle_ && nvs_get_u8(handle_, key, &value) == ESP_OK ? value : fallback; + } + bool getBool(const char* key, bool fallback = false) const { return getUChar(key, fallback ? 1 : 0) != 0; } + uint32_t getUInt(const char* key, uint32_t fallback = 0) const + { + uint32_t value; + return handle_ && nvs_get_u32(handle_, key, &value) == ESP_OK ? value : fallback; + } + std::size_t getBytes(const char* key, void* output, std::size_t capacity) const + { + std::size_t size = capacity; + return handle_ && nvs_get_blob(handle_, key, output, &size) == ESP_OK ? size : 0; + } + std::size_t putUChar(const char* key, uint8_t value) { return writable() && committed(nvs_set_u8(handle_, key, value)) ? 1 : 0; } + std::size_t putBool(const char* key, bool value) { return putUChar(key, value ? 1 : 0); } + std::size_t putUInt(const char* key, uint32_t value) { return writable() && committed(nvs_set_u32(handle_, key, value)) ? sizeof(value) : 0; } + std::size_t putBytes(const char* key, const void* value, std::size_t size) + { + return writable() && committed(nvs_set_blob(handle_, key, value, size)) ? size : 0; + } + bool remove(const char* key) + { + if (!writable()) return false; + const esp_err_t result = nvs_erase_key(handle_, key); + return result == ESP_ERR_NVS_NOT_FOUND || committed(result); + } + + private: + bool writable() const { return handle_ && !read_only_; } + bool committed(esp_err_t result) { return result == ESP_OK && nvs_commit(handle_) == ESP_OK; } + nvs_handle_t handle_ = 0; + bool read_only_ = true; +}; +} // namespace platform::esp::common +#endif diff --git a/platform/esp/common/include/platform/esp/common/identity_atomic_record.h b/platform/esp/common/include/platform/esp/common/identity_atomic_record.h new file mode 100644 index 00000000..6c5a4b6a --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_atomic_record.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#include +#include + +namespace platform::esp::common +{ +constexpr size_t kIdentityRecordBytes = 176; +constexpr size_t kIdentityRecordWorkBytes = kIdentityRecordBytes + 32; +enum class IdentityRecordResult +{ + Absent, + Ready, + Invalid, + StorageError, + TargetChanged, + Committed, + Indeterminate +}; +inline uint32_t identityRecordU32(const uint8_t* data) +{ + return uint32_t(data[0]) | (uint32_t(data[1]) << 8) | (uint32_t(data[2]) << 16) | (uint32_t(data[3]) << 24); +} +inline void identityRecordPutU32(uint8_t* data, uint32_t value) +{ + for (unsigned i = 0; i < 4; ++i) data[i] = uint8_t(value >> (8 * i)); +} +inline uint32_t identityRecordCrc(const uint8_t* data, size_t size) +{ + uint32_t crc = UINT32_MAX; + for (size_t i = 0; i < size; ++i) + { + crc ^= data[i]; + for (unsigned bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1))); + } + return ~crc; +} +inline void clearIdentityRecordWork(uint8_t* work, size_t size) +{ + volatile uint8_t* target = work; + if (target) + for (size_t i = 0; i < size; ++i) target[i] = 0; +} +// CRC detects damaged record metadata. It is not authentication; key pairing +// and signed USB import authorization are separate requirements. +inline bool identityRecordHeaderValid(const uint8_t* record, size_t size) +{ + return record && size == kIdentityRecordBytes && !std::memcmp(record, "TMID", 4) && + record[4] == 1 && record[5] == 0 && record[6] == 0 && record[7] == 0 && + identityRecordU32(record + 8) != 0 && + identityRecordU32(record + 172) == identityRecordCrc(record, 172); +} +inline IdentityRecordResult readIdentityRecord(uint8_t* output, size_t capacity) +{ + if (!output || capacity < kIdentityRecordBytes) return IdentityRecordResult::Invalid; + nvs_handle_t handle = 0; + auto status = nvs_open("lxmf_ident", NVS_READONLY, &handle); + if (status == ESP_ERR_NVS_NOT_FOUND) + { + clearIdentityRecordWork(output, kIdentityRecordBytes); + return IdentityRecordResult::Absent; + } + if (status != ESP_OK) + { + clearIdentityRecordWork(output, kIdentityRecordBytes); + return IdentityRecordResult::StorageError; + } + size_t size = kIdentityRecordBytes; + status = nvs_get_blob(handle, "native_v1", output, &size); + nvs_close(handle); + if (status == ESP_ERR_NVS_NOT_FOUND) + { + clearIdentityRecordWork(output, kIdentityRecordBytes); + return IdentityRecordResult::Absent; + } + if (status != ESP_OK || !identityRecordHeaderValid(output, size)) + { + clearIdentityRecordWork(output, kIdentityRecordBytes); + return status == ESP_OK ? IdentityRecordResult::Invalid : IdentityRecordResult::StorageError; + } + return IdentityRecordResult::Ready; +} +// Caller serializes this with the identity owner lock and passes its live +// generation. Never activate keys after Indeterminate; reconcile on reboot. +// One blob contains generation, public64 and native private96; legacy keys are +// neither overwritten nor removed. Work is caller-owned and wiped on exit. +template +IdentityRecordResult commitIdentityRecord(uint32_t expected_generation, uint32_t live_generation, + const uint8_t* public_key, const uint8_t* private_key, + uint8_t* work, size_t capacity, Validate validate) +{ + if (!work || capacity < kIdentityRecordWorkBytes || !public_key || !private_key) return IdentityRecordResult::Invalid; + const auto start = reinterpret_cast(work); + const auto pub = reinterpret_cast(public_key), priv = reinterpret_cast(private_key); + if ((start >= pub ? start - pub < 64 : pub - start < kIdentityRecordWorkBytes) || + (start >= priv ? start - priv < 96 : priv - start < kIdentityRecordWorkBytes)) return IdentityRecordResult::Invalid; + struct Clear + { + uint8_t* work; + ~Clear() { clearIdentityRecordWork(work, kIdentityRecordWorkBytes); } + } clear{work}; + if (!expected_generation || expected_generation != live_generation) return IdentityRecordResult::TargetChanged; + if (live_generation == UINT32_MAX || !validate(public_key, private_key, work + kIdentityRecordBytes, 32)) return IdentityRecordResult::Invalid; + const auto stored = readIdentityRecord(work, capacity); + if (stored != IdentityRecordResult::Absent && stored != IdentityRecordResult::Ready) return stored; + if (stored == IdentityRecordResult::Ready && identityRecordU32(work + 8) > live_generation) return IdentityRecordResult::TargetChanged; + std::memcpy(work, "TMID", 4); + work[4] = 1; + work[5] = work[6] = work[7] = 0; + identityRecordPutU32(work + 8, live_generation + 1); + std::memcpy(work + 12, public_key, 64); + std::memcpy(work + 76, private_key, 96); + identityRecordPutU32(work + 172, identityRecordCrc(work, 172)); + nvs_handle_t handle = 0; + if (nvs_open("lxmf_ident", NVS_READWRITE, &handle) != ESP_OK) return IdentityRecordResult::StorageError; + const auto set = nvs_set_blob(handle, "native_v1", work, kIdentityRecordBytes); + const auto commit = set == ESP_OK ? nvs_commit(handle) : set; + nvs_close(handle); + // A reported write error does not prove that the old record survived. + return set == ESP_OK && commit == ESP_OK ? IdentityRecordResult::Committed : IdentityRecordResult::Indeterminate; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/identity_generation_recovery.h b/platform/esp/common/include/platform/esp/common/identity_generation_recovery.h new file mode 100644 index 00000000..ceaea4a1 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_generation_recovery.h @@ -0,0 +1,42 @@ +#pragma once +#include "platform/esp/common/identity_atomic_record.h" + +namespace platform::esp::common +{ +// Called only after validating a complete committed identity record and deriving +// its identity hash. Recover a missed epoch update without ever rolling back a +// newer epoch or accepting two identities with the same generation. +inline bool recoverIdentityGeneration(const uint8_t identity_hash[16], uint32_t record_generation, uint32_t& generation) +{ + generation = 0; + if (!identity_hash || !record_generation) return false; + nvs_handle_t handle = 0; + if (nvs_open("tm_author", NVS_READWRITE, &handle) != ESP_OK) return false; + struct Close + { + nvs_handle_t handle; + ~Close() { nvs_close(handle); } + } close{handle}; + uint8_t epoch[20]{}; + size_t size = sizeof(epoch); + const auto read = nvs_get_blob(handle, "epoch", epoch, &size); + if (read == ESP_OK) + { + if (size != sizeof(epoch)) return false; + const auto current = identityRecordU32(epoch); + if (!current) return false; + if (current >= record_generation) + { + if (std::memcmp(epoch + 4, identity_hash, 16)) return false; + generation = current; + return true; + } + } + else if (read != ESP_ERR_NVS_NOT_FOUND) return false; + identityRecordPutU32(epoch, record_generation); + std::memcpy(epoch + 4, identity_hash, 16); + if (nvs_set_blob(handle, "epoch", epoch, sizeof(epoch)) != ESP_OK || nvs_commit(handle) != ESP_OK) return false; + generation = record_generation; + return true; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/identity_generation_store.h b/platform/esp/common/include/platform/esp/common/identity_generation_store.h new file mode 100644 index 00000000..d0068ff5 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_generation_store.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include + +namespace platform::esp::common +{ +// One atomic NVS blob: LE generation32 + public identity hash16. No private +// material or heap allocation. A malformed record never resets the counter. +inline bool syncIdentityGeneration(const uint8_t identity_hash[16], uint32_t& generation, + bool force_advance = false) +{ + generation = 0; + if (!identity_hash) return false; + nvs_handle_t handle = 0; + if (nvs_open("tm_author", NVS_READWRITE, &handle) != ESP_OK) return false; + struct Close + { + nvs_handle_t handle; + ~Close() { nvs_close(handle); } + } close{handle}; + uint8_t record[20]{}; + size_t size = sizeof(record); + const auto result = nvs_get_blob(handle, "epoch", record, &size); + uint32_t next = 1; + if (result == ESP_OK) + { + if (size != sizeof(record)) return false; + next = static_cast(record[0]) | (static_cast(record[1]) << 8) | + (static_cast(record[2]) << 16) | (static_cast(record[3]) << 24); + if (!next) return false; + if (!force_advance && !std::memcmp(record + 4, identity_hash, 16)) + { + generation = next; + return true; + } + if (next == UINT32_MAX) return false; + ++next; + } + else if (result != ESP_ERR_NVS_NOT_FOUND) return false; + for (unsigned index = 0; index < 4; ++index) record[index] = static_cast(next >> (index * 8)); + std::memcpy(record + 4, identity_hash, 16); + if (nvs_set_blob(handle, "epoch", record, sizeof(record)) != ESP_OK || nvs_commit(handle) != ESP_OK) return false; + generation = next; + return true; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/identity_key_state.h b/platform/esp/common/include/platform/esp/common/identity_key_state.h new file mode 100644 index 00000000..8c4f76e0 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_key_state.h @@ -0,0 +1,27 @@ +#pragma once +#include + +namespace platform::esp::common +{ +// Creation is permitted only for an absent identity. Corrupt, partial, empty, +// differently typed or unreadable keys must never silently re-key an account. +inline bool lxmfIdentityKeysAbsent() +{ + nvs_handle_t handle = 0; + const auto result = nvs_open("lxmf_ident", NVS_READONLY, &handle); + if (result == ESP_ERR_NVS_NOT_FOUND) return true; + if (result != ESP_OK) return false; + struct Close + { + nvs_handle_t handle; + ~Close() { nvs_close(handle); } + } close{handle}; + constexpr const char* keys[] = {"native_v1", "enc_pub", "enc_priv", "sig_pub", "sig_priv"}; + for (const auto* key : keys) + { + size_t length = 0; + if (nvs_get_blob(handle, key, nullptr, &length) != ESP_ERR_NVS_NOT_FOUND) return false; + } + return true; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/identity_native_key_validation.h b/platform/esp/common/include/platform/esp/common/identity_native_key_validation.h new file mode 100644 index 00000000..f7a0f29d --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_native_key_validation.h @@ -0,0 +1,36 @@ +#pragma once +#include "hostlink/identity_signing_key_validation.h" +#include "platform/esp/common/reticulum_crypto_compat.h" + +namespace platform::esp::common +{ +// Caller provides one 32-byte work area. The existing curve implementation has +// its own arithmetic stack usage; invoke on the identity-management owner task. +inline bool validateNativeIdentityKeys(const uint8_t* public_key, const uint8_t* private_key, + uint8_t* scratch, size_t capacity) +{ + if (!public_key || !private_key || !scratch || capacity < 32) return false; + const uintptr_t work = reinterpret_cast(scratch); + const uintptr_t pub = reinterpret_cast(public_key); + const uintptr_t priv = reinterpret_cast(private_key); + if ((work >= pub ? work - pub < 64 : pub - work < 32) || + (work >= priv ? work - priv < 96 : priv - work < 32)) return false; + bool valid = hostlink::validateExpandedSigningKey(public_key + 32, private_key + 32, scratch, capacity); + uint8_t nonzero = 0; + for (size_t i = 0; i < 32; ++i) nonzero |= private_key[i]; + // Native Arduino storage comes from dh1(), which clamps before eval(). + // Do not silently reinterpret a seed-format key as native expanded data. + const bool canonical = (private_key[0] & 7) == 0 && (private_key[31] & 0xc0) == 0x40; + if (valid && nonzero && canonical) + { + valid = Curve25519::eval(scratch, private_key, nullptr); + uint8_t difference = 0; + for (size_t i = 0; i < 32; ++i) difference |= scratch[i] ^ public_key[i]; + valid = valid && difference == 0; + } + else valid = false; + volatile uint8_t* clear = scratch; + for (size_t i = 0; i < 32; ++i) clear[i] = 0; + return valid; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/identity_seed_key_conversion.h b/platform/esp/common/include/platform/esp/common/identity_seed_key_conversion.h new file mode 100644 index 00000000..a66d6b8a --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/identity_seed_key_conversion.h @@ -0,0 +1,35 @@ +#pragma once +#include "platform/esp/common/reticulum_crypto_compat.h" +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include +#include +#include + +namespace platform::esp::common +{ +// Reticulum input is X25519 private32 + Ed25519 seed32. Native output is +// clamped X25519 private32 + expanded Ed25519 private64. All arenas are caller +// owned; restore can reuse its existing PSRAM work after signature verification. +inline bool convertSeedIdentityKeys(const uint8_t* public_key, const uint8_t* seeds, + uint8_t* native, size_t native_capacity, + uint8_t* scratch, size_t scratch_capacity) +{ + if (!public_key || !seeds || !native || native_capacity < 96 || !scratch || scratch_capacity < 32) return false; + const auto overlap=[](const void* a,size_t na,const void* b,size_t nb){const auto x=reinterpret_cast(a),y=reinterpret_cast(b);return x<=y?y-x + +namespace platform::esp::common +{ +inline const char* radioIdentityNamespace(RadioIdentityKind kind) { return kind == RadioIdentityKind::MeshCore ? "mc_ident" : kind == RadioIdentityKind::Meshtastic ? "chat" + : nullptr; } +inline bool validateRadioIdentityPair(RadioIdentityKind kind, const uint8_t* pub, const uint8_t* priv, uint8_t* scratch, size_t capacity) +{ + if (!pub || !priv || !scratch || capacity < 32) return false; + if (kind == RadioIdentityKind::MeshCore) return !chat::meshcore::meshcoreIsZeroBytes(priv, 32) && !chat::meshcore::meshcoreIsZeroBytes(priv + 32, 32) && + chat::meshcore::meshcoreDerivePublicKey(priv, scratch) && !std::memcmp(pub, scratch, 32); + return kind == RadioIdentityKind::Meshtastic && (priv[0] & 7) == 0 && (priv[31] & 0xc0) == 0x40 && + Curve25519::eval(scratch, priv, nullptr) && !std::memcmp(pub, scratch, 32); +} +inline IdentityRecordResult readManagedRadioIdentity(RadioIdentityKind kind, RadioIdentityWork& work, uint32_t& generation, bool& present) +{ + generation = 0; + present = false; + if (!work.data()) return IdentityRecordResult::StorageError; + const auto* ns = radioIdentityNamespace(kind); + if (!ns) return IdentityRecordResult::Invalid; + auto loaded = readRadioIdentityRecord(ns, kind, work.data(), work.size()); + if (loaded == IdentityRecordResult::Ready) + { + generation = identityRecordU32(work.data() + 8); + if (work.data()[5]) return IdentityRecordResult::Ready; + } + else if (loaded == IdentityRecordResult::Absent) + { + loaded = readLegacyRadioIdentity(ns, kind == RadioIdentityKind::MeshCore ? "pub32" : "pki_pub", kind == RadioIdentityKind::MeshCore ? "priv64" : "pki_priv", kind, + work.data(), work.size(), kind == RadioIdentityKind::MeshCore ? "ver" : nullptr); + if (loaded == IdentityRecordResult::Absent) return IdentityRecordResult::Ready; + // Match runtime's legacy MC public reconstruction, without writing NVS. + if (loaded == IdentityRecordResult::Ready && kind == RadioIdentityKind::MeshCore) + { + if (!chat::meshcore::meshcoreDerivePublicKey(work.data() + 44, work.data() + 12)) return IdentityRecordResult::Invalid; + } + } + if (loaded != IdentityRecordResult::Ready) return loaded; + if (!validateRadioIdentityPair(kind, work.data() + 12, work.data() + 44, work.data() + 112, 32)) return IdentityRecordResult::Invalid; + present = true; + return IdentityRecordResult::Ready; +} +template +chat::DeviceAuthorResult readRadioIdentityInfo(const Identity& identity, uint8_t kind_value, uint32_t author_generation, uint8_t* output, size_t capacity, size_t& written) +{ + using Result = chat::DeviceAuthorResult; + written = 0; + if (!output || capacity < 448 || kind_value < 1 || kind_value > 2 || !author_generation) return Result::InvalidInput; + const auto start = reinterpret_cast(output), object = reinterpret_cast(&identity); + if (start <= object ? object - start < capacity : start - object < sizeof(identity)) return Result::InvalidInput; + if (!identity.isReady()) return identity.identityStorageError() ? Result::StorageError : Result::Unavailable; + if (identity.authorGeneration() != author_generation) return Result::StaleIdentity; + RadioIdentityWork work; + uint32_t generation = 0; + bool present = false; + if (readManagedRadioIdentity(RadioIdentityKind(kind_value), work, generation, present) != IdentityRecordResult::Ready) return Result::StorageError; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(4) || !writer.unsignedInteger(kind_value) || !writer.unsignedInteger(present ? 1 : 0) || !writer.unsignedInteger(generation) || + !(present ? writer.binary({work.data() + 12, 32}) : writer.nil())) return Result::InvalidInput; + written = writer.size(); + return Result::Ok; +} +template +chat::DeviceAuthorResult exportRadioIdentityBackup(const Identity& identity, uint8_t kind_value, uint32_t author_generation, uint8_t* output, size_t capacity, size_t& written, const uint8_t* session, const uint8_t* request) +{ + using Result = chat::DeviceAuthorResult; + written = 0; + if (!output || capacity < 448 || kind_value < 1 || kind_value > 2 || !author_generation || !session || !request) return Result::InvalidInput; + const auto start = reinterpret_cast(output), object = reinterpret_cast(&identity); + if (start <= object ? object - start < capacity : start - object < sizeof(identity)) return Result::InvalidInput; + const auto fail = [&](Result result) + {clearIdentityRecordWork(output,capacity);return result; }; + if (!identity.isReady()) return fail(identity.identityStorageError() ? Result::StorageError : Result::Unavailable); + if (identity.authorGeneration() != author_generation) return fail(Result::StaleIdentity); + RadioIdentityWork work; + uint32_t generation = 0; + bool present = false; + if (readManagedRadioIdentity(RadioIdentityKind(kind_value), work, generation, present) != IdentityRecordResult::Ready) return fail(Result::StorageError); + if (!present) return fail(Result::Unavailable); + using namespace hostlink::management; + std::memcpy(output, kRadioIdentityBackupDomain, sizeof(kRadioIdentityBackupDomain)); + const size_t limit = capacity - 64, begin = sizeof(kRadioIdentityBackupDomain); + geocaching::protocol::CmpWriter writer(output + begin, limit - begin); + if (!writer.array(10) || !writer.unsignedInteger(1) || !writer.text(kRadioIdentityBackupType) || !writer.unsignedInteger(kind_value) || + !writer.binary({work.data() + 12, 32}) || !writer.binary({work.data() + 44, radioIdentityPrivateBytes(RadioIdentityKind(kind_value))}) || + !writer.binaryHeader(64)) return fail(Result::InvalidInput); + size_t cursor = begin + writer.size(); + if (64 > limit - cursor) return fail(Result::InvalidInput); + identity.combinedPublicKey(output + cursor); + cursor += 64; + geocaching::protocol::CmpWriter tail(output + cursor, limit - cursor); + if (!tail.unsignedInteger(author_generation) || !tail.unsignedInteger(generation) || !tail.binary({session, 16}) || !tail.binary({request, 16})) return fail(Result::InvalidInput); + cursor += tail.size(); + const size_t payload_size = cursor - begin; + if (payload_size > 320 || !identity.sign(output, cursor, output + limit)) return fail(Result::Unavailable); + if (!identity.isReady() || identity.authorGeneration() != author_generation) return fail(Result::StaleIdentity); + const size_t header = payload_size <= 255 ? 3 : 4; + std::memmove(output + header, output + begin, payload_size); + output[0] = 0x92; + geocaching::protocol::CmpWriter binary(output + 1, header - 1); + if (!binary.binaryHeader(payload_size)) return fail(Result::InvalidInput); + geocaching::protocol::CmpWriter signature(output + header + payload_size, capacity - header - payload_size); + if (!signature.binary({output + limit, 64})) return fail(Result::InvalidInput); + written = header + payload_size + signature.size(); + clearIdentityRecordWork(output + written, capacity - written); + return Result::Ok; +} +template +chat::DeviceIdentityRestoreResult restoreRadioIdentity(const Identity& identity, const hostlink::management::RadioIdentityRestoreView& request, uint8_t* scratch, size_t capacity) +{ + using Result = chat::DeviceIdentityRestoreResult; + if (!scratch || capacity < 336 || request.kind < 1 || request.kind > 2 || !request.expected_author.data || request.expected_author.size != 64 || + request.expected_radio_generation == UINT32_MAX || (!request.expected_radio_public.data && request.expected_radio_public.size) || + (request.expected_radio_public.size != 0 && request.expected_radio_public.size != 32)) return Result::InvalidInput; + const auto start = reinterpret_cast(scratch); + for (const auto view : {request.expected_author, request.expected_radio_public, request.payload, request.signature}) + { + if (!view.size) continue; + const auto input = reinterpret_cast(view.data); + if (start >= input ? start - input < view.size : input - start < capacity) return Result::InvalidInput; + } + struct Clear + { + uint8_t* work; + size_t size; + ~Clear() { clearIdentityRecordWork(work, size); } + } clear{scratch, capacity}; + if (!identity.isReady()) return identity.identityStorageError() ? Result::StorageError : Result::Unavailable; + if (identity.authorGeneration() != request.expected_author_generation) return Result::StaleIdentity; + identity.combinedPublicKey(scratch); + if (request.expected_author.size != 64 || std::memcmp(scratch, request.expected_author.data, 64)) return Result::StaleIdentity; + if (!hostlink::management::verifyRadioIdentityRestoreSignature(request, scratch, capacity) || + !validateRadioIdentityPair(RadioIdentityKind(request.kind), request.public_key.data, request.private_key.data, scratch, capacity)) return Result::InvalidInput; + RadioIdentityWork stored; + uint32_t generation = 0; + bool present = false; + if (readManagedRadioIdentity(RadioIdentityKind(request.kind), stored, generation, present) != IdentityRecordResult::Ready) return Result::StorageError; + if (generation != request.expected_radio_generation || present != (request.expected_radio_public.size == 32) || + (present && std::memcmp(stored.data() + 12, request.expected_radio_public.data, 32))) return Result::StaleRadioIdentity; + const auto kind = RadioIdentityKind(request.kind); + const auto saved = commitRadioIdentityRecord( + radioIdentityNamespace(kind), kind, generation, request.public_key.data, request.private_key.data, scratch, capacity, + [kind](const uint8_t* pub, const uint8_t* priv, uint8_t* work, size_t size) + { return validateRadioIdentityPair(kind, pub, priv, work, size); }, + false, true); + return saved == IdentityRecordResult::Committed ? Result::CommittedRebootRequired : saved == IdentityRecordResult::Indeterminate ? Result::IndeterminateRebootRequired + : saved == IdentityRecordResult::TargetChanged ? Result::StaleRadioIdentity + : saved == IdentityRecordResult::Invalid ? Result::InvalidInput + : Result::StorageError; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/radio_identity_record.h b/platform/esp/common/include/platform/esp/common/radio_identity_record.h new file mode 100644 index 00000000..a158175f --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/radio_identity_record.h @@ -0,0 +1,187 @@ +#pragma once +#include "platform/esp/common/identity_atomic_record.h" +#include + +namespace platform::esp::common +{ +enum class RadioIdentityKind : uint8_t +{ + MeshCore = 1, + Meshtastic = 2 +}; +constexpr size_t kRadioIdentityRecordBytes = 112; +constexpr size_t kRadioIdentityWorkBytes = kRadioIdentityRecordBytes + 32; +constexpr const char* kRadioIdentityRecordKey = "native_rf1"; +// One byte, shared by both native RF writers. Restoration leaves state=2 until +// reboot, closing the gap between NVS commit and the scheduled restart. +inline std::atomic radio_identity_write_state{0}; +static_assert(sizeof(radio_identity_write_state) == 1); +class RadioIdentityWriteGuard +{ + public: + RadioIdentityWriteGuard() + { + uint8_t expected = 0; + locked_ = radio_identity_write_state.compare_exchange_strong(expected, 1, std::memory_order_acquire); + } + ~RadioIdentityWriteGuard() + { + if (!locked_) return; + uint8_t expected = 1; + radio_identity_write_state.compare_exchange_strong(expected, 0, std::memory_order_release); + } + bool locked() const { return locked_; } + void requireReboot() + { + if (locked_) radio_identity_write_state.store(2, std::memory_order_release); + } + RadioIdentityWriteGuard(const RadioIdentityWriteGuard&) = delete; + RadioIdentityWriteGuard& operator=(const RadioIdentityWriteGuard&) = delete; + + private: + bool locked_ = false; +}; +inline size_t radioIdentityPrivateBytes(RadioIdentityKind kind) +{ + return kind == RadioIdentityKind::MeshCore ? 64 : kind == RadioIdentityKind::Meshtastic ? 32 + : 0; +} +inline bool radioIdentityRecordValid(const uint8_t* record, size_t size, RadioIdentityKind kind) +{ + const auto private_size = radioIdentityPrivateBytes(kind); + if (!record || size != kRadioIdentityRecordBytes || !private_size || std::memcmp(record, "TMRF", 4) || + record[4] != 1 || record[5] > 1 || record[6] != uint8_t(kind) || record[7] != private_size || + !identityRecordU32(record + 8) || identityRecordU32(record + 108) != identityRecordCrc(record, 108)) return false; + // A cleared record is a durable tombstone. It suppresses stale legacy keys + // without deleting them in a separate transaction or resurrecting them. + const size_t first_zero = record[5] ? 12 : 44 + private_size; + for (size_t index = first_zero; index < 108; ++index) + if (record[index]) return false; + return true; +} +inline IdentityRecordResult readRadioIdentityRecord(const char* ns, RadioIdentityKind kind, uint8_t* output, size_t capacity) +{ + if (!ns || !output || capacity < kRadioIdentityRecordBytes || !radioIdentityPrivateBytes(kind)) return IdentityRecordResult::Invalid; + clearIdentityRecordWork(output, kRadioIdentityRecordBytes); + nvs_handle_t handle = 0; + auto status = nvs_open(ns, NVS_READONLY, &handle); + if (status == ESP_ERR_NVS_NOT_FOUND) return IdentityRecordResult::Absent; + if (status != ESP_OK) return IdentityRecordResult::StorageError; + size_t size = kRadioIdentityRecordBytes; + status = nvs_get_blob(handle, kRadioIdentityRecordKey, output, &size); + nvs_close(handle); + if (status == ESP_ERR_NVS_NOT_FOUND) return IdentityRecordResult::Absent; + if (status != ESP_OK || !radioIdentityRecordValid(output, size, kind)) + { + clearIdentityRecordWork(output, kRadioIdentityRecordBytes); + return status == ESP_OK ? IdentityRecordResult::Invalid : IdentityRecordResult::StorageError; + } + return IdentityRecordResult::Ready; +} +inline bool radioIdentityLegacyAbsent(const char* ns, const char* public_key, const char* private_key) +{ + if (!ns || !public_key || !private_key) return false; + nvs_handle_t handle = 0; + const auto opened = nvs_open(ns, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return true; + if (opened != ESP_OK) return false; + size_t size = 0; + const auto pub = nvs_get_blob(handle, public_key, nullptr, &size); + size = 0; + const auto priv = nvs_get_blob(handle, private_key, nullptr, &size); + nvs_close(handle); + return pub == ESP_ERR_NVS_NOT_FOUND && priv == ESP_ERR_NVS_NOT_FOUND; +} +// Read old pairs directly into caller PSRAM; no vector or temporary secret copy. +// MeshCore can reconstruct a missing legacy public key from the expanded key; +// its caller validates/reconstructs before use. Meshtastic requires both keys. +inline IdentityRecordResult readLegacyRadioIdentity(const char* ns, const char* public_key, const char* private_key, + RadioIdentityKind kind, uint8_t* output, size_t capacity, + const char* version_key = nullptr) +{ + const auto private_size = radioIdentityPrivateBytes(kind); + if (!ns || !public_key || !private_key || !output || capacity < kRadioIdentityRecordBytes || !private_size) + return IdentityRecordResult::Invalid; + clearIdentityRecordWork(output, kRadioIdentityRecordBytes); + nvs_handle_t handle = 0; + const auto opened = nvs_open(ns, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return IdentityRecordResult::Absent; + if (opened != ESP_OK) return IdentityRecordResult::StorageError; + struct Close + { + nvs_handle_t handle; + ~Close() { nvs_close(handle); } + } close{handle}; + size_t pub_size = 0, priv_size = 0; + const auto pub = nvs_get_blob(handle, public_key, nullptr, &pub_size); + const auto priv = nvs_get_blob(handle, private_key, nullptr, &priv_size); + if (pub == ESP_ERR_NVS_NOT_FOUND && priv == ESP_ERR_NVS_NOT_FOUND) return IdentityRecordResult::Absent; + if (priv != ESP_OK || priv_size != private_size || (pub == ESP_OK && pub_size != 32) || + (pub != ESP_OK && (pub != ESP_ERR_NVS_NOT_FOUND || kind != RadioIdentityKind::MeshCore))) return IdentityRecordResult::Invalid; + if (version_key) + { + uint8_t version = 0; + const auto read = nvs_get_u8(handle, version_key, &version); + if ((read == ESP_OK && version != 1) || (read != ESP_OK && read != ESP_ERR_NVS_NOT_FOUND)) return IdentityRecordResult::Invalid; + } + if (nvs_get_blob(handle, private_key, output + 44, &priv_size) != ESP_OK || priv_size != private_size || + (pub == ESP_OK && (nvs_get_blob(handle, public_key, output + 12, &pub_size) != ESP_OK || pub_size != 32))) + { + clearIdentityRecordWork(output, kRadioIdentityRecordBytes); + return IdentityRecordResult::StorageError; + } + return IdentityRecordResult::Ready; +} +// Expected generation is RF-local; it never changes the Reticulum account. +// No automatic large buffer. The owner serializes access and provides scratch. +// Validate must prove the protocol-specific public/private correspondence. +template +IdentityRecordResult commitRadioIdentityRecord(const char* ns, RadioIdentityKind kind, uint32_t expected_generation, + const uint8_t* public_key, const uint8_t* private_key, + uint8_t* work, size_t capacity, Validate validate, bool clear = false, + bool require_reboot = false) +{ + const auto private_size = radioIdentityPrivateBytes(kind); + if (!ns || !private_size || !work || capacity < kRadioIdentityWorkBytes || + (!clear && (!public_key || !private_key)) || expected_generation == UINT32_MAX) return IdentityRecordResult::Invalid; + const auto start = reinterpret_cast(work); + if (!clear) + { + const auto pub = reinterpret_cast(public_key), priv = reinterpret_cast(private_key); + if ((start >= pub ? start - pub < 32 : pub - start < kRadioIdentityWorkBytes) || + (start >= priv ? start - priv < private_size : priv - start < kRadioIdentityWorkBytes)) return IdentityRecordResult::Invalid; + } + struct Clear + { + uint8_t* work; + ~Clear() { clearIdentityRecordWork(work, kRadioIdentityWorkBytes); } + } wipe{work}; + RadioIdentityWriteGuard guard; + if (!guard.locked()) return IdentityRecordResult::TargetChanged; + if (!clear && !validate(public_key, private_key, work + kRadioIdentityRecordBytes, 32)) return IdentityRecordResult::Invalid; + const auto prior = readRadioIdentityRecord(ns, kind, work, capacity); + if (prior != IdentityRecordResult::Ready && prior != IdentityRecordResult::Absent) return prior; + const auto generation = prior == IdentityRecordResult::Ready ? identityRecordU32(work + 8) : 0; + if (generation != expected_generation) return IdentityRecordResult::TargetChanged; + clearIdentityRecordWork(work, kRadioIdentityRecordBytes); + std::memcpy(work, "TMRF", 4); + work[4] = 1; + work[5] = clear ? 1 : 0; + work[6] = uint8_t(kind); + work[7] = uint8_t(private_size); + identityRecordPutU32(work + 8, generation + 1); + if (!clear) + { + std::memcpy(work + 12, public_key, 32); + std::memcpy(work + 44, private_key, private_size); + } + identityRecordPutU32(work + 108, identityRecordCrc(work, 108)); + nvs_handle_t handle = 0; + if (nvs_open(ns, NVS_READWRITE, &handle) != ESP_OK) return IdentityRecordResult::StorageError; + const auto set = nvs_set_blob(handle, kRadioIdentityRecordKey, work, kRadioIdentityRecordBytes); + const auto committed = set == ESP_OK ? nvs_commit(handle) : set; + nvs_close(handle); + if (require_reboot) guard.requireReboot(); // Any attempted write may have committed. + return set == ESP_OK && committed == ESP_OK ? IdentityRecordResult::Committed : IdentityRecordResult::Indeterminate; +} +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/radio_identity_work.h b/platform/esp/common/include/platform/esp/common/radio_identity_work.h new file mode 100644 index 00000000..029a4d6b --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/radio_identity_work.h @@ -0,0 +1,41 @@ +#pragma once +#include "platform/esp/common/radio_identity_record.h" +#include +#if defined(ESP_PLATFORM) +#include +#endif +namespace platform::esp::common +{ +// Only the pointer is automatic. On PSRAM boards allocation failure never +// spills into internal heap. Boards with no PSRAM can use a bounded 144-byte +// internal allocation; every allocation is erased on exit. +class RadioIdentityWork +{ + public: + RadioIdentityWork() + { +#if defined(ESP_PLATFORM) + const uint32_t caps = heap_caps_get_total_size(MALLOC_CAP_SPIRAM) ? MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT : MALLOC_CAP_8BIT; + data_ = static_cast(heap_caps_malloc(kRadioIdentityWorkBytes, caps)); +#else + data_ = static_cast(std::malloc(kRadioIdentityWorkBytes)); +#endif + } + ~RadioIdentityWork() + { + clearIdentityRecordWork(data_, kRadioIdentityWorkBytes); +#if defined(ESP_PLATFORM) + heap_caps_free(data_); +#else + std::free(data_); +#endif + } + RadioIdentityWork(const RadioIdentityWork&) = delete; + RadioIdentityWork& operator=(const RadioIdentityWork&) = delete; + uint8_t* data() const { return data_; } + size_t size() const { return kRadioIdentityWorkBytes; } + + private: + uint8_t* data_ = nullptr; +}; +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/settings_store_impl.h b/platform/esp/common/include/platform/esp/common/settings_store_impl.h index 70c06277..4f3f72f0 100644 --- a/platform/esp/common/include/platform/esp/common/settings_store_impl.h +++ b/platform/esp/common/include/platform/esp/common/settings_store_impl.h @@ -52,6 +52,13 @@ constexpr StorageKeyAlias kStorageKeyAliases[] = { {"timezone_profile", "timezone_prof"}, {"gauge_design_mah", "gauge_dsgn"}, {"wifi_profile_count", "wifi_prof_count"}, + {"espnow_team_discovery", "en_team_disc"}, + {"espnow_beacon_interval_ms", "en_beacon_ms"}, + {"wifi_sta_enabled", "wifi_sta_en"}, + {"wifi_sta_channel", "wifi_sta_ch"}, + {"wifi_persist_credentials", "wifi_keep_cred"}, + {"wifi_sta_password", "wifi_sta_pwd"}, + {"wifi_ap_password", "wifi_ap_pwd"}, }; inline const char* safe_label(const char* value) @@ -700,6 +707,130 @@ bool get_string_into(const char* ns, return true; } +// Strict scalar reads use the caller's storage, not a retained settings cache. +namespace +{ +template +ScalarReadResult read_scalar_into(const char* ns, const char* key, Value* out, Reader reader) +{ + using namespace ::platform::esp::common::settings_store_detail; + if (!out) return ScalarReadResult::Invalid; + *out = Value{}; + if (!ns || !key) return ScalarReadResult::Invalid; + const char* storage_key = resolve_storage_key(key); + if (!validate_storage_key("READ", ns, key, storage_key)) return ScalarReadResult::Invalid; + nvs_handle_t handle = 0; + const auto opened = nvs_open(ns, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return ScalarReadResult::Missing; + if (opened != ESP_OK) return ScalarReadResult::Unavailable; + const auto result = reader(handle, storage_key, out); + nvs_close(handle); + if (result == ESP_OK) return ScalarReadResult::Ok; + *out = Value{}; + return result == ESP_ERR_NVS_NOT_FOUND ? ScalarReadResult::Missing : result == ESP_ERR_NVS_TYPE_MISMATCH ? ScalarReadResult::Invalid + : ScalarReadResult::Unavailable; +} +} // namespace + +ScalarReadResult read_int_into(const char* ns, const char* key, int32_t* out) +{ + return read_scalar_into(ns, key, out, nvs_get_i32); +} + +ScalarReadResult read_uint_into(const char* ns, const char* key, uint32_t* out) +{ + return read_scalar_into(ns, key, out, nvs_get_u32); +} + +ScalarReadResult read_u8_into(const char* ns, const char* key, uint8_t* out) +{ + return read_scalar_into(ns, key, out, nvs_get_u8); +} + +ScalarReadResult read_bool_into(const char* ns, const char* key, bool* out) +{ + if (!out) return ScalarReadResult::Invalid; + *out = false; + uint8_t value = 0; + const auto result = read_scalar_into(ns, key, &value, nvs_get_u8); + if (result != ScalarReadResult::Ok) return result; + if (value > 1U) return ScalarReadResult::Invalid; + *out = value != 0; + return ScalarReadResult::Ok; +} + +StringReadResult read_string_into(const char* ns, const char* key, char* out, + std::size_t capacity, std::size_t* out_len) +{ + using namespace ::platform::esp::common::settings_store_detail; + if (out_len) *out_len = 0; + if (!ns || !key || !out || !capacity) return StringReadResult::Invalid; + out[0] = '\0'; + const char* storage_key = resolve_storage_key(key); + if (!validate_storage_key("READ", ns, key, storage_key)) return StringReadResult::Invalid; + nvs_handle_t handle = 0; + const auto opened = nvs_open(ns, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return StringReadResult::Missing; + if (opened != ESP_OK) return StringReadResult::Unavailable; + std::size_t size = 0; + const auto measured = nvs_get_str(handle, storage_key, nullptr, &size); + if (measured != ESP_OK || !size || size > capacity) + { + nvs_close(handle); + return measured == ESP_ERR_NVS_NOT_FOUND ? StringReadResult::Missing : measured == ESP_OK || measured == ESP_ERR_NVS_TYPE_MISMATCH ? StringReadResult::Invalid + : StringReadResult::Unavailable; + } + std::size_t actual = size; + const auto read = nvs_get_str(handle, storage_key, out, &actual); + nvs_close(handle); + if (read != ESP_OK || actual != size || out[size - 1] || std::strlen(out) != size - 1) + { + out[0] = '\0'; + return read == ESP_OK ? StringReadResult::Invalid : StringReadResult::Unavailable; + } + if (out_len) *out_len = size - 1; + return StringReadResult::Ok; +} + +BlobReadResult read_blob_into(const char* ns, const char* key, void* out, + std::size_t capacity, std::size_t* out_len) +{ + using namespace ::platform::esp::common::settings_store_detail; + if (out_len) *out_len = 0; + if (!ns || !key || (!out && capacity != 0)) return BlobReadResult::Invalid; + const char* storage_key = resolve_storage_key(key); + if (!validate_storage_key("READ", ns, key, storage_key)) return BlobReadResult::Invalid; + nvs_handle_t handle = 0; + const auto opened = nvs_open(ns, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return BlobReadResult::Missing; + if (opened != ESP_OK) return BlobReadResult::Unavailable; + std::size_t size = 0; + const auto measured = nvs_get_blob(handle, storage_key, nullptr, &size); + if (measured != ESP_OK || size > capacity) + { + nvs_close(handle); + return measured == ESP_ERR_NVS_NOT_FOUND ? BlobReadResult::Missing : measured == ESP_OK || measured == ESP_ERR_NVS_TYPE_MISMATCH ? BlobReadResult::Invalid + : BlobReadResult::Unavailable; + } + if (size == 0) + { + nvs_close(handle); + return BlobReadResult::Ok; + } + std::size_t actual = size; + const auto read = nvs_get_blob(handle, storage_key, out, &actual); + nvs_close(handle); + if (read != ESP_OK || actual != size) + { + std::memset(out, 0, size); + return read == ESP_OK || read == ESP_ERR_NVS_TYPE_MISMATCH || read == ESP_ERR_NVS_INVALID_LENGTH + ? BlobReadResult::Invalid + : BlobReadResult::Unavailable; + } + if (out_len) *out_len = size; + return BlobReadResult::Ok; +} + bool get_blob(const char* ns, const char* key, std::vector& out) { using namespace ::platform::esp::common::settings_store_detail; diff --git a/platform/esp/common/include/platform/esp/common/storage/mount_io_state.h b/platform/esp/common/include/platform/esp/common/storage/mount_io_state.h new file mode 100644 index 00000000..7906f115 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/mount_io_state.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include + +namespace platform::esp::storage +{ +// Replace a mounted boolean, preserving its one-byte footprint. Failure is +// separate from mounted status and only observed by an operation-scoped reader. +class MountIoState +{ + public: + operator bool() const { return (flags_.load(std::memory_order_acquire) & 1) != 0; } + MountIoState& operator=(bool mounted) + { + flags_.store(mounted ? 1 : 0, std::memory_order_release); + return *this; + } + void markFailure() { flags_.fetch_or(2, std::memory_order_relaxed); } + bool failed() const { return (flags_.load(std::memory_order_relaxed) & 2) != 0; } + void clearFailure() { flags_.fetch_and(1, std::memory_order_relaxed); } + + private: + std::atomic flags_{0}; +}; +static_assert(sizeof(MountIoState) == sizeof(bool), "Mount I/O state must reuse the existing byte"); +class MountIoErrorScope +{ + public: + explicit MountIoErrorScope(MountIoState& state) : state_(state), previous_(state.failed()) { state_.clearFailure(); } + ~MountIoErrorScope() + { + if (previous_) state_.markFailure(); + } + MountIoErrorScope(const MountIoErrorScope&) = delete; + MountIoErrorScope& operator=(const MountIoErrorScope&) = delete; + bool error() const { return state_.failed(); } + + private: + MountIoState& state_; + bool previous_; +}; +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/snapshot_container.h b/platform/esp/common/include/platform/esp/common/storage/snapshot_container.h new file mode 100644 index 00000000..ccfe96f3 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/snapshot_container.h @@ -0,0 +1,75 @@ +#pragma once + +#include "hostlink/track_snapshot.h" +#include "platform/esp/common/storage/record_file_io.h" + +namespace platform::esp::storage +{ +constexpr uint32_t kSnapshotContainerHeaderBytes = 64; +inline constexpr uint8_t kSnapshotContainerMagic[4] = {'T', 'S', 'N', '1'}; +inline constexpr uint8_t kSnapshotContainerReserved[5] = {}; +inline constexpr uint8_t kEmptySnapshotHeader[kSnapshotContainerHeaderBytes] = {}; + +inline uint16_t snapshotHeaderCrc(uint16_t crc, const uint8_t* data, size_t bytes) +{ + for (size_t i = 0; i < bytes; ++i) + { + crc ^= uint16_t(data[i]) << 8; + for (unsigned bit = 0; bit < 8; ++bit) crc = uint16_t((crc << 1) ^ ((crc & 0x8000) ? 0x1021 : 0)); + } + return crc; +} +inline bool snapshotMetadataValid(const hostlink::management::TrackSnapshotMetadata& metadata) +{ + uint8_t bits = 0; + for (auto byte : metadata.id) bits |= byte; + return bits && metadata.bytes && metadata.bytes <= hostlink::management::kTrackSnapshotMaxBytes && metadata.format <= 3; +} + +// Fixed container: TSN1 / id16 / sha25632 / length LE32 / format8 / +// reserved-zero5 / CRC16-CCITT LE16, then exact raw recording bytes. +// CRC covers bytes 0..61. This header is not a signature or a content hash. +// Write small fields directly: no automatic 64-byte protocol buffer. +inline bool writeSnapshotContainerHeader(RecordFileIo& io, RecordFileIo::Handle file, + const hostlink::management::TrackSnapshotMetadata& metadata) +{ + if (!snapshotMetadataValid(metadata) || !io.seek(file, 0)) return false; + uint16_t crc = 0xffff; + const auto part = [&](const uint8_t* data, size_t bytes) + { + if (io.write(file, data, bytes) != bytes) return false; + crc = snapshotHeaderCrc(crc, data, bytes); + return true; + }; + const uint8_t length[4] = {uint8_t(metadata.bytes), uint8_t(metadata.bytes >> 8), uint8_t(metadata.bytes >> 16), uint8_t(metadata.bytes >> 24)}; + if (!part(kSnapshotContainerMagic, 4) || !part(metadata.id, 16) || !part(metadata.hash, 32) || + !part(length, 4) || !part(&metadata.format, 1) || !part(kSnapshotContainerReserved, 5)) return false; + const uint8_t checksum[2] = {uint8_t(crc), uint8_t(crc >> 8)}; + return io.write(file, checksum, 2) == 2; +} + +inline bool readSnapshotContainerHeader(RecordFileIo& io, RecordFileIo::Handle file, + hostlink::management::TrackSnapshotMetadata& metadata) +{ + metadata = {}; + uint64_t total = 0; + if (!io.size(file, total) || total <= kSnapshotContainerHeaderBytes || + total > kSnapshotContainerHeaderBytes + hostlink::management::kTrackSnapshotMaxBytes || !io.seek(file, 0)) return false; + uint16_t crc = 0xffff; + const auto part = [&](uint8_t* data, size_t bytes) + { + if (io.read(file, data, bytes) != bytes) return false; + crc = snapshotHeaderCrc(crc, data, bytes); + return true; + }; + uint8_t magic[4], length[4], reserved[5], checksum[2]; + if (!part(magic, 4) || std::memcmp(magic, kSnapshotContainerMagic, 4) || !part(metadata.id, 16) || + !part(metadata.hash, 32) || !part(length, 4) || !part(&metadata.format, 1) || !part(reserved, 5) || + std::memcmp(reserved, kSnapshotContainerReserved, 5) || io.read(file, checksum, 2) != 2 || + crc != (uint16_t(checksum[0]) | uint16_t(checksum[1]) << 8)) return false; + metadata.bytes = uint32_t(length[0]) | uint32_t(length[1]) << 8 | uint32_t(length[2]) << 16 | uint32_t(length[3]) << 24; + if (!snapshotMetadataValid(metadata) || total != kSnapshotContainerHeaderBytes + metadata.bytes) return false; + metadata.sealed = true; + return true; +} +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/snapshot_file_response.h b/platform/esp/common/include/platform/esp/common/storage/snapshot_file_response.h new file mode 100644 index 00000000..3cef6f12 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/snapshot_file_response.h @@ -0,0 +1,44 @@ +#pragma once + +#include "platform/esp/common/storage/snapshot_container.h" + +namespace platform::esp::storage +{ +enum class SnapshotReadResult : uint8_t +{ + Ok, + Missing, + InvalidRequest, + Conflict, + Capacity, + StorageFailure +}; + +// The platform resolves id to an immutable device-owned container path and +// its RecordFileIo must check the captured media session. Never accept a path +// supplied directly by the browser. Metadata/request cannot alias output. +// File bytes go directly into the existing response payload after its prefix. +inline SnapshotReadResult readSnapshotFileResponse(RecordFileIo& io, const char* path, + const hostlink::management::TrackSnapshotRequest& request, + hostlink::management::TrackSnapshotMetadata& metadata, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!path || !*path || !output || !request.maximum || request.maximum > hostlink::management::kTrackSnapshotChunkBytes || + (request.offset && !request.has_hash)) return SnapshotReadResult::InvalidRequest; + const auto opened = io.open(path, RecordFileIo::Mode::Read); + if (!opened.handle) return opened.status == RecordFileIo::OpenStatus::Missing ? SnapshotReadResult::Missing : SnapshotReadResult::StorageFailure; + SnapshotReadResult result = SnapshotReadResult::StorageFailure; + size_t chunk_offset = 0, chunk_length = 0, encoded = 0; + if (readSnapshotContainerHeader(io, opened.handle, metadata)) + { + if (std::memcmp(request.id, metadata.id, 16) || (request.has_hash && std::memcmp(request.expected_hash, metadata.hash, 32))) result = SnapshotReadResult::Conflict; + else if (request.offset >= metadata.bytes) result = SnapshotReadResult::InvalidRequest; + else if (!hostlink::management::prepareTrackSnapshotResponse(request, metadata, output, capacity, chunk_offset, chunk_length, encoded)) result = SnapshotReadResult::Capacity; + else if (io.seek(opened.handle, kSnapshotContainerHeaderBytes + request.offset) && io.read(opened.handle, output + chunk_offset, chunk_length) == chunk_length) result = SnapshotReadResult::Ok; + } + if (!io.close(opened.handle)) result = SnapshotReadResult::StorageFailure; + if (result == SnapshotReadResult::Ok) written = encoded; + return result; +} +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/snapshot_stream_sha256.h b/platform/esp/common/include/platform/esp/common/storage/snapshot_stream_sha256.h new file mode 100644 index 00000000..a0876e7b --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/snapshot_stream_sha256.h @@ -0,0 +1,60 @@ +#pragma once + +#include "platform/esp/common/mbedtls_sha256_compat.h" +#include "platform/esp/common/storage/track_snapshot_file_storage.h" + +namespace platform::esp::storage +{ +// Caller lends an exclusive, uninitialized context for this object's lifetime. +// No context copy/allocation or internal-RAM fallback occurs here. The owner +// must include the real context size in its short-lived resource admission. +class SnapshotStreamSha256 final : public SnapshotStreamHash +{ + public: + explicit SnapshotStreamSha256(mbedtls_sha256_context& context) : context_(context) { mbedtls_sha256_init(&context_); } + ~SnapshotStreamSha256() override { mbedtls_sha256_free(&context_); } + SnapshotStreamSha256(const SnapshotStreamSha256&) = delete; + SnapshotStreamSha256& operator=(const SnapshotStreamSha256&) = delete; + bool start() override + { + reset(); + active_ = platform::esp::common::crypto::sha256_starts(&context_, 0) == 0; + return active_; + } + bool update(const uint8_t* bytes, size_t length) override + { + if (!active_ || (!bytes && length)) + { + reset(); + return false; + } + if (platform::esp::common::crypto::sha256_update(&context_, bytes, length) != 0) + { + reset(); + return false; + } + return true; + } + bool finish(uint8_t output[32]) override + { + if (!active_ || !output) + { + reset(); + return false; + } + const bool result = platform::esp::common::crypto::sha256_finish(&context_, output) == 0; + reset(); + return result; + } + void reset() override + { + mbedtls_sha256_free(&context_); + mbedtls_sha256_init(&context_); + active_ = false; + } + + private: + mbedtls_sha256_context& context_; + bool active_ = false; +}; +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/track_manifest_crypto.h b/platform/esp/common/include/platform/esp/common/storage/track_manifest_crypto.h new file mode 100644 index 00000000..5762bfda --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/track_manifest_crypto.h @@ -0,0 +1,23 @@ +#pragma once +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "track_sharing/verify_manifest.h" +namespace platform::esp::storage +{ +// Public-key verification only. Uses the same Ed25519 implementation as device +// identity; no identity object, private key copy or persistent scratch exists. +struct TrackManifestCrypto +{ + track_sharing::ManifestVerification verify(geocaching::ByteView key, geocaching::ByteView signature, geocaching::ByteView message) const + { + constexpr char domain[] = "TrailMate.Track.Manifest.v1"; + if (!key.data || key.size != 32 || !signature.data || signature.size != 64 || !message.data || + message.size <= sizeof(domain) || message.size > sizeof(domain) + 4096 || + std::memcmp(message.data, domain, sizeof(domain) - 2) || + (message.data[sizeof(domain) - 2] != '1' && message.data[sizeof(domain) - 2] != '2') || message.data[sizeof(domain) - 1] != 0) + return track_sharing::ManifestVerification::InvalidSignature; + return ed25519_verify(signature.data, message.data, message.size, key.data) != 0 + ? track_sharing::ManifestVerification::Valid + : track_sharing::ManifestVerification::InvalidSignature; + } +}; +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/track_receipt_crypto.h b/platform/esp/common/include/platform/esp/common/storage/track_receipt_crypto.h new file mode 100644 index 00000000..c65bce06 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/track_receipt_crypto.h @@ -0,0 +1,16 @@ +#pragma once +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "track_sharing/verify_receipt.h" +namespace platform::esp::storage +{ +struct TrackReceiptCrypto +{ + track_sharing::ReceiptVerification verify(geocaching::ByteView key,geocaching::ByteView signature,geocaching::ByteView message) const + { + constexpr char domain[]="TrailMate.Track.Receipt.v1"; + if(!key.data||key.size!=32||!signature.data||signature.size!=64||!message.data||message.size<=sizeof(domain)|| + message.size>sizeof(domain)+1024||std::memcmp(message.data,domain,sizeof(domain)))return track_sharing::ReceiptVerification::InvalidSignature; + return ed25519_verify(signature.data,message.data,message.size,key.data)?track_sharing::ReceiptVerification::Valid:track_sharing::ReceiptVerification::InvalidSignature; + } +}; +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/track_snapshot_file_storage.h b/platform/esp/common/include/platform/esp/common/storage/track_snapshot_file_storage.h new file mode 100644 index 00000000..e08917a3 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/track_snapshot_file_storage.h @@ -0,0 +1,164 @@ +#pragma once + +#include "hostlink/track_snapshot_builder.h" +#include "platform/esp/common/storage/record_file_io.h" +#include "platform/esp/common/storage/snapshot_container.h" + +namespace platform::esp::storage +{ +class SnapshotStreamHash +{ + public: + virtual ~SnapshotStreamHash() = default; + virtual bool start() = 0; + virtual bool update(const uint8_t* bytes, size_t length) = 0; + virtual bool finish(uint8_t hash[32]) = 0; + virtual void reset() = 0; +}; + +// Paths belong to this lease and stay valid until release. acquire must reject +// live recordings and block source modification/removal. valid checks the +// captured media session as well as source ownership. Busy acquire owns nothing. +// acquire only obtains ownership; it must not create/truncate candidate files. +// Paths must be canonical and refer to distinct files (including aliases). +// commit publishes the closed single-file TSN1 container to an immutable +// destination. Data and metadata move together; no sidecar is needed. discard only removes this +// lease's uncommitted temporary files, never a previously committed snapshot. +class SnapshotFileLease +{ + public: + virtual ~SnapshotFileLease() = default; + virtual hostlink::management::SnapshotStorageResult acquire(const uint8_t id[16], uint32_t bytes, uint8_t format) = 0; + virtual bool valid() const = 0; + virtual const char* sourcePath() const = 0; + virtual const char* temporaryPath() const = 0; + virtual bool commit(const hostlink::management::TrackSnapshotMetadata& metadata) = 0; + virtual bool discard() = 0; + virtual void release() = 0; +}; + +// File handles are operation-scoped and never overlap: one reader closes +// before the writer opens. Hash context and scratch are caller-owned. Partial +// I/O is fatal to this candidate; the builder enters abort and never retries +// a chunk against a partially advanced stream/hash. +class TrackSnapshotFileStorage final : public hostlink::management::TrackSnapshotStorage +{ + public: + TrackSnapshotFileStorage(RecordFileIo& io, SnapshotStreamHash& hash, SnapshotFileLease& lease) + : io_(io), hash_(hash), lease_(lease) {} + + hostlink::management::SnapshotStorageResult begin(const uint8_t id[16], uint32_t bytes, uint8_t format) override + { + using Result = hostlink::management::SnapshotStorageResult; + if (active_) return Result::Busy; + uint8_t bits = 0; + if (id) + for (size_t i = 0; i < 16; ++i) bits |= id[i]; + if (!bits || !bytes || bytes > hostlink::management::kTrackSnapshotMaxBytes || format > 3) return Result::Failed; + active_ = true; + candidate_owned_ = false; + const auto result = lease_.acquire(id, bytes, format); + if (result == Result::Busy) + { + active_ = false; + return result; + } + if (result != Result::Ok || !lease_.valid() || !lease_.sourcePath() || !*lease_.sourcePath() || + !lease_.temporaryPath() || !*lease_.temporaryPath() || + !std::strcmp(lease_.sourcePath(), lease_.temporaryPath()) || !sourceMatches(bytes)) return Result::Failed; + candidate_owned_ = true; + const auto file = io_.open(lease_.temporaryPath(), RecordFileIo::Mode::CreateTemporary); + if (!file.handle) return Result::Failed; + const bool initialized = io_.write(file.handle, kEmptySnapshotHeader, kSnapshotContainerHeaderBytes) == kSnapshotContainerHeaderBytes; + const bool closed = io_.close(file.handle); + if (!initialized || !closed || !hash_.start()) return Result::Failed; + metadata_ = {}; + std::memcpy(metadata_.id, id, 16); + metadata_.bytes = bytes; + metadata_.format = format; + offset_ = 0; + return Result::Ok; + } + + hostlink::management::SnapshotStorageResult copy(uint32_t offset, uint8_t* scratch, size_t length) override + { + using Result = hostlink::management::SnapshotStorageResult; + if (!active_ || !lease_.valid() || offset != offset_ || !scratch || !length || + length > hostlink::management::kTrackSnapshotChunkBytes || offset > metadata_.bytes || length > metadata_.bytes - offset) return Result::Failed; + const auto source = io_.open(lease_.sourcePath(), RecordFileIo::Mode::Read); + if (!source.handle) return Result::Failed; + uint64_t bytes = 0; + bool ok = io_.size(source.handle, bytes) && bytes == metadata_.bytes && io_.seek(source.handle, offset) && io_.read(source.handle, scratch, length) == length; + const bool source_closed = io_.close(source.handle); + if (!ok || !source_closed || !lease_.valid()) return Result::Failed; + const auto destination = io_.open(lease_.temporaryPath(), RecordFileIo::Mode::Update); + if (!destination.handle) return Result::Failed; + ok = io_.size(destination.handle, bytes) && bytes == kSnapshotContainerHeaderBytes + offset && io_.seek(destination.handle, kSnapshotContainerHeaderBytes + offset) && io_.write(destination.handle, scratch, length) == length; + const bool destination_closed = io_.close(destination.handle); + if (!ok || !destination_closed || !lease_.valid() || !hash_.update(scratch, length)) return Result::Failed; + offset_ += static_cast(length); + return Result::Ok; + } + + hostlink::management::SnapshotStorageResult finish(hostlink::management::TrackSnapshotMetadata& metadata) override + { + using Result = hostlink::management::SnapshotStorageResult; + metadata.sealed = false; + if (!active_ || offset_ != metadata_.bytes || !lease_.valid() || !sourceMatches(metadata_.bytes)) return Result::Failed; + const auto file = io_.open(lease_.temporaryPath(), RecordFileIo::Mode::Update); + if (!file.handle) return Result::Failed; + uint64_t bytes = 0; + const bool ok = io_.size(file.handle, bytes) && bytes == kSnapshotContainerHeaderBytes + metadata_.bytes && + hash_.finish(metadata_.hash) && writeSnapshotContainerHeader(io_, file.handle, metadata_) && io_.sync(file.handle); + const bool closed = io_.close(file.handle); + if (!ok || !closed || !lease_.valid() || !lease_.commit(metadata_)) return Result::Failed; + metadata = metadata_; + metadata.sealed = true; + hash_.reset(); + lease_.release(); + active_ = false; + candidate_owned_ = false; + return Result::Ok; + } + + hostlink::management::SnapshotStorageResult abort() override + { + using Result = hostlink::management::SnapshotStorageResult; + if (!active_) return Result::Ok; + if (candidate_owned_ && !lease_.discard()) return Result::Failed; + hash_.reset(); + lease_.release(); + active_ = false; + candidate_owned_ = false; + return Result::Ok; + } + + bool abandonForStop() override + { + // The scheduler invokes this between slices, when all operation-scoped + // handles are closed. Never publish or delete anything on this path. + hash_.reset(); + lease_.release(); + active_ = false; + candidate_owned_ = false; + return true; + } + + private: + bool sourceMatches(uint32_t required) + { + const auto file = io_.open(lease_.sourcePath(), RecordFileIo::Mode::Read); + if (!file.handle) return false; + uint64_t bytes = 0; + const bool matches = io_.size(file.handle, bytes) && bytes == required; + return io_.close(file.handle) && matches; + } + RecordFileIo& io_; + SnapshotStreamHash& hash_; + SnapshotFileLease& lease_; + hostlink::management::TrackSnapshotMetadata metadata_; + uint32_t offset_ = 0; + bool active_ = false, candidate_owned_ = false; +}; +static_assert(sizeof(TrackSnapshotFileStorage) <= 104, "File snapshot adapter must not own payload/hash storage"); +} // namespace platform::esp::storage diff --git a/platform/esp/common/include/platform/esp/common/storage/track_snapshot_service.h b/platform/esp/common/include/platform/esp/common/storage/track_snapshot_service.h new file mode 100644 index 00000000..9683f9cc --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/storage/track_snapshot_service.h @@ -0,0 +1,48 @@ +#pragma once + +#include "hostlink/management_envelope.h" +#include "platform/esp/common/storage/snapshot_file_response.h" + +namespace hostlink::management +{ +// Read only sealed, device-owned containers. The source recording pathname is +// never supplied by a client. Scratch reuses the response tail and cannot +// overlap the bounded 320-byte chunk response. The caller owns the file I/O. +inline Status readTrackSnapshotService(const EnvelopeView& envelope, + platform::esp::storage::RecordFileIo& io, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + TrackSnapshotRequest request; + if (!decodeTrackSnapshotRequest({envelope.body, envelope.body_size}, request)) return Status::InvalidField; + if (!output || capacity < 464) return Status::Internal; + char* path = reinterpret_cast(output + capacity - 64); + constexpr char prefix[] = "/trailmate/web-tracks/"; + constexpr char hex[] = "0123456789abcdef"; + std::memcpy(path, prefix, sizeof(prefix) - 1); + size_t position = sizeof(prefix) - 1; + for (const auto byte : request.id) + { + path[position++] = hex[byte >> 4]; + path[position++] = hex[byte & 15]; + } + std::memcpy(path + position, ".tsn", 5); + TrackSnapshotMetadata metadata; + using platform::esp::storage::SnapshotReadResult; + switch (platform::esp::storage::readSnapshotFileResponse(io, path, request, metadata, output, capacity - 64, written)) + { + case SnapshotReadResult::Ok: + return Status::Ok; + case SnapshotReadResult::Missing: + return Status::Expired; + case SnapshotReadResult::InvalidRequest: + return Status::InvalidField; + case SnapshotReadResult::Conflict: + return Status::Conflict; + case SnapshotReadResult::Capacity: + return Status::Internal; + default: + return Status::StorageUnavailable; + } +} +} // namespace hostlink::management diff --git a/platform/esp/common/include/platform/esp/common/wifi_profile_snapshot.h b/platform/esp/common/include/platform/esp/common/wifi_profile_snapshot.h new file mode 100644 index 00000000..29ff11b7 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/wifi_profile_snapshot.h @@ -0,0 +1,111 @@ +#pragma once + +#include "platform/ui/settings_store.h" +#include "platform/ui/wifi_runtime.h" +#include +#include + +namespace platform::esp::common +{ + +// Read the persistent set into caller-owned staging, independently of the +// connection cache. There is no migration write and no credential-sized heap +// allocation. A failed candidate must not be used as a configuration snapshot. +inline bool read_persisted_wifi_profiles(bool* enabled, std::size_t* count, + ::platform::ui::wifi::Config* profiles, + std::size_t capacity) +{ + namespace store = ::platform::ui::settings_store; + using Scalar = store::ScalarReadResult; + using Text = store::StringReadResult; + constexpr auto max_count = ::platform::ui::wifi::kMaxSavedProfileCount; + static_assert(sizeof(::platform::ui::wifi::Config) <= 100U, + "Wi-Fi snapshot primary scratch must stay small"); + if (enabled) *enabled = false; + if (count) *count = 0; + if (profiles) + for (std::size_t i = 0; i < capacity && i < max_count; ++i) profiles[i] = {}; + if (!enabled || !count || !profiles || capacity < max_count) return false; + const auto fail = [&]() + { + *enabled = false; + *count = 0; + for (std::size_t i = 0; i < max_count; ++i) profiles[i] = {}; + return false; + }; + bool stored_enabled = false; + auto scalar_result = store::read_bool_into("settings", "wifi_enabled", &stored_enabled); + if (scalar_result != Scalar::Ok && scalar_result != Scalar::Missing) return fail(); + int32_t stored_count = 0; + scalar_result = store::read_int_into("settings", "wifi_prof_count", &stored_count); + if ((scalar_result != Scalar::Ok && scalar_result != Scalar::Missing) || + stored_count < 0 || stored_count > static_cast(max_count)) return fail(); + + const auto read_text = [](const char* key, char* out, std::size_t bytes) + { + std::size_t size = 0; + return store::read_string_into("settings", key, out, bytes, &size); + }; + const bool recovering = stored_count == 0; + const auto slots = recovering ? max_count : static_cast(stored_count); + std::size_t loaded = 0; + for (std::size_t slot = 0; slot < slots; ++slot) + { + auto& profile = profiles[loaded]; + profile = {}; + char key[24]{}; + std::snprintf(key, sizeof(key), "wifi_ssid_%u", static_cast(slot)); + const auto ssid_result = read_text(key, profile.ssid, sizeof(profile.ssid)); + if (ssid_result != Text::Ok && ssid_result != Text::Missing) return fail(); + if (ssid_result == Text::Missing || profile.ssid[0] == '\0') + { + if (!recovering) return fail(); + continue; + } + bool duplicate = false; + for (std::size_t previous = 0; previous < loaded; ++previous) + { + // An explicit count promises a complete set. Duplicate slots cannot + // silently disappear into a shorter exported list. + if (!std::strcmp(profiles[previous].ssid, profile.ssid)) duplicate = true; + } + if (duplicate && !recovering) return fail(); + std::snprintf(key, sizeof(key), "wifi_password_%u", static_cast(slot)); + auto password_result = read_text(key, profile.password, sizeof(profile.password)); + if (password_result == Text::Missing) + { + std::snprintf(key, sizeof(key), "wifi_pass_%u", static_cast(slot)); + password_result = read_text(key, profile.password, sizeof(profile.password)); + } + if (password_result != Text::Ok && password_result != Text::Missing) return fail(); + if (duplicate) continue; // Legacy recovery retains the first priority. + profile.enabled = true; + ++loaded; + } + + // Older firmware also used one primary pair. Preserve its fallback and + // appended-profile semantics without letting its mirror override a slot. + ::platform::ui::wifi::Config primary{}; + const auto ssid_result = read_text("wifi_ssid", primary.ssid, sizeof(primary.ssid)); + const auto password_result = read_text("wifi_password", primary.password, sizeof(primary.password)); + if ((ssid_result != Text::Ok && ssid_result != Text::Missing) || + (password_result != Text::Ok && password_result != Text::Missing)) return fail(); + if (primary.ssid[0] != '\0') + { + bool found = false; + for (std::size_t i = 0; i < loaded; ++i) + if (!std::strcmp(profiles[i].ssid, primary.ssid)) found = true; + if (!found) + { + if (loaded == max_count) return fail(); + primary.enabled = true; + profiles[loaded++] = primary; + } + } + *enabled = stored_enabled; + *count = loaded; + for (std::size_t i = loaded; i < max_count; ++i) profiles[i] = {}; + return true; +} + +} // namespace platform::esp::common diff --git a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h index 64cf0233..10e7a5b5 100644 --- a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h +++ b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h @@ -3,6 +3,7 @@ #include "app/app_facade_access.h" #include "platform/esp/boards/board_runtime.h" #include "platform/esp/common/memory_budget.h" +#include "platform/esp/common/wifi_profile_snapshot.h" #include "platform/ui/settings_store.h" #include "platform/ui/wifi_access_runtime.h" #include "platform/ui/wifi_runtime.h" @@ -603,10 +604,6 @@ void upsert_profile_front(const Config& profile) bool persist_profiles() { - ::platform::ui::settings_store::put_int(kSettingsNs, - kWifiProfileCountKey, - static_cast(s_runtime.profile_count)); - bool ok = true; for (std::size_t i = 0; i < kWifiProfileCapacity; ++i) { char ssid_key[24] = {}; @@ -614,18 +611,21 @@ bool persist_profiles() profile_key(ssid_key, sizeof(ssid_key), "ssid", i); profile_key(password_key, sizeof(password_key), "password", i); const Config& profile = s_runtime.profiles[i]; - ok = ::platform::ui::settings_store::put_string( - kSettingsNs, - ssid_key, - i < s_runtime.profile_count ? profile.ssid : "") && - ok; - ok = ::platform::ui::settings_store::put_string( - kSettingsNs, - password_key, - i < s_runtime.profile_count ? profile.password : "") && - ok; + if (!::platform::ui::settings_store::put_string( + kSettingsNs, + ssid_key, + i < s_runtime.profile_count ? profile.ssid : "") || + !::platform::ui::settings_store::put_string( + kSettingsNs, + password_key, + i < s_runtime.profile_count ? profile.password : "")) return false; } - return ok; + // Publish the count only after all slots were committed. The SD working + // document remains the recovery authority if a multi-key write is partial. + ::platform::ui::settings_store::put_int(kSettingsNs, kWifiProfileCountKey, + static_cast(s_runtime.profile_count)); + return ::platform::ui::settings_store::get_int(kSettingsNs, kWifiProfileCountKey, -1) == + static_cast(s_runtime.profile_count); } bool read_profile_from_store(std::size_t index, Config& profile) @@ -662,12 +662,12 @@ bool read_profile_from_store(std::size_t index, Config& profile) if (::platform::ui::settings_store::get_string(kSettingsNs, legacy_password_key, value)) { copy_bounded(profile.password, sizeof(profile.password), value.c_str()); - (void)::platform::ui::settings_store::put_string( + const bool migrated = ::platform::ui::settings_store::put_string( kSettingsNs, password_key, profile.password); const char* legacy_keys[] = {legacy_password_key}; - ::platform::ui::settings_store::remove_keys(kSettingsNs, legacy_keys, 1); + if (migrated) ::platform::ui::settings_store::remove_keys(kSettingsNs, legacy_keys, 1); } } @@ -1485,9 +1485,17 @@ bool save_config(const Config& config) const bool password_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, config.password); ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, config.enabled); + const bool enabled_ok = ::platform::ui::settings_store::get_bool( + kSettingsNs, kWifiEnabledKey, !config.enabled) == config.enabled; + if (!profiles_ok || !ssid_ok || !password_ok || !enabled_ok) + { + s_runtime.profiles_cached = false; + s_runtime.config_cached = false; + return false; + } cache_config(config); refresh_runtime_status_message(); - return profiles_ok && ssid_ok && password_ok; + return true; } bool find_saved_config(const char* ssid, Config& out) @@ -1540,6 +1548,12 @@ bool visit_saved_profiles(bool* enabled, return true; } +bool read_saved_profiles_into(bool* enabled, std::size_t* count, + Config* profiles, std::size_t capacity) +{ + return ::platform::esp::common::read_persisted_wifi_profiles(enabled, count, profiles, capacity); +} + bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count) { ::platform::ui::settings_store::ScopedChangeBatch changes; @@ -1549,7 +1563,9 @@ bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t co } for (std::size_t index = 0U; index < count; ++index) { - if (!has_saved_credentials(profiles[index])) + if (!std::memchr(profiles[index].ssid, '\0', sizeof(profiles[index].ssid)) || + !std::memchr(profiles[index].password, '\0', sizeof(profiles[index].password)) || + !has_saved_credentials(profiles[index])) { return false; } @@ -1585,9 +1601,17 @@ bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t co const bool password_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, primary.password); ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, enabled); + const bool enabled_ok = ::platform::ui::settings_store::get_bool( + kSettingsNs, kWifiEnabledKey, !enabled) == enabled; + if (!profiles_ok || !ssid_ok || !password_ok || !enabled_ok) + { + s_runtime.profiles_cached = false; + s_runtime.config_cached = false; + return false; + } cache_config(primary); refresh_runtime_status_message(); - return profiles_ok && ssid_ok && password_ok; + return true; } bool apply_enabled(bool enabled) diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_prepare_scheduler_work.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_prepare_scheduler_work.h new file mode 100644 index 00000000..83ccbcba --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_prepare_scheduler_work.h @@ -0,0 +1,42 @@ +#pragma once +#include "chat/ports/i_mesh_adapter.h" +#include "hostlink/management_envelope.h" +#include "track_sharing/prepare_request.h" +#include +namespace hostlink::management::prepare_runtime +{ +struct TrackPreparedResult +{ + uint8_t object[32]{}, hash[32]{}, parent[32]{}, operation[16]{}; + uint32_t revision = 0; +}; +// One existing application scheduler pointer, no extra global slot/queue. +// Virtual dispatch and concrete state live only in transient PSRAM. +class PrepareSchedulerWork +{ + public: + virtual ~PrepareSchedulerWork() = default; + virtual bool alive(uint32_t, uint64_t, bool) const = 0; + virtual bool matchesSession(const uint8_t*) const = 0; + virtual bool holdsStages() const = 0; + virtual void suspendIdentity() = 0; + virtual geocaching::ByteView operationId() const = 0; + virtual bool renewMatched(uint64_t) = 0; + virtual bool matches(const uint8_t*, const track_sharing::PrepareRequest&) const = 0; + virtual Status advance(chat::IMeshAdapter&, uint8_t*, size_t) = 0; + virtual const TrackPreparedResult* result() const = 0; + virtual geocaching::ByteView preparedSignature() const = 0; + virtual bool ready() const = 0; + virtual Status status() const = 0; + virtual bool packageInstallation() const { return false; } + virtual uint8_t installationState() const { return ready() ? 1 : 0; } +}; +inline void destroySchedulerWork(PrepareSchedulerWork* work) +{ + if (work) + { + work->~PrepareSchedulerWork(); + heap_caps_free(work); + } +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_identity.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_identity.h new file mode 100644 index 00000000..2229abfb --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_identity.h @@ -0,0 +1,35 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_route_install_owner.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +namespace hostlink::management::route_install_runtime +{ +template +Status withCurrentRouteIdentity(RouteInstallOwner& owner, Adapter& adapter, uint8_t* scratch, size_t capacity, Guard&& policy, Action&& action) +{ + if (!scratch || capacity < 448 || capacity > 512) return Status::InvalidField; + const auto a = reinterpret_cast(&owner), b = reinterpret_cast(scratch); + if (a <= b ? b - a < sizeof(owner) : a - b < capacity) return Status::InvalidField; + const auto live = [&] + { + uint8_t hash[32]; + uint32_t generation = 0; + const auto result = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, hash, generation); + if (result != Status::Ok) return result; + if (!owner.identityMatches(generation, {hash, 32})) return Status::StaleIdentity; + return policy(); + }; + return action(live); +} +template +Status startAuthenticatedRouteInstall(RouteInstallOwner& owner, Adapter& adapter, bool install, uint8_t* scratch, size_t capacity, Guard&& policy) +{ + return withCurrentRouteIdentity(owner, adapter, scratch, capacity, policy, [&](auto& live) + { return owner.start(install, scratch, capacity, live); }); +} +template +Status advanceAuthenticatedRouteInstall(RouteInstallOwner& owner, Adapter& adapter, uint8_t* scratch, size_t capacity, Guard&& policy) +{ + return withCurrentRouteIdentity(owner, adapter, scratch, capacity, policy, [&](auto& live) + { return owner.advance(scratch, capacity, live); }); +} +} // namespace hostlink::management::route_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_owner.h new file mode 100644 index 00000000..f6d1e6e0 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_owner.h @@ -0,0 +1,133 @@ +#pragma once +#include "hostlink/route_install_authorization.h" +#include "hostlink/route_install_job.h" +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/upload_journal_store.h" +#include +#include +namespace hostlink::management::route_install_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +class RouteInstallOwner +{ + public: + RouteInstallOwner(const RouteInstallView& request, const uint8_t* session, const uint8_t* author, uint32_t media, uint64_t now) + : request_{{bindings_, 16}, {bindings_ + 16, 32}, request.bytes, request.generation}, media_(media), deadline_(now + 120000), + file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media), hash_(context_), + job_(io_, hash_, names_, sizeof(names_), request_, source_, target_) + { + std::memcpy(bindings_, request.transfer.data, 16); + std::memcpy(bindings_ + 16, request.hash.data, 32); + std::memcpy(session_, session, 16); + std::memcpy(author_, author, 32); + } + RouteInstallOwner(const RouteInstallOwner&) = delete; + RouteInstallOwner& operator=(const RouteInstallOwner&) = delete; + template + Status start(bool install, uint8_t* scratch, size_t capacity, Guard&& live) + { + if (job_.phase() != RouteInstallPhase::Idle) return Status::Busy; + query_only_ = !install; + if (!scratch || capacity < 256 || !mediaReady()) return Status::StorageUnavailable; + if (!track_sharing::resolveUploadPath(bindings_, 16, track_sharing::UploadFile::Candidate, source_, sizeof(source_)) || + !track_sharing::resolveUploadPath(bindings_, 16, track_sharing::UploadFile::JournalA, a_, sizeof(a_)) || + !track_sharing::resolveUploadPath(bindings_, 16, track_sharing::UploadFile::JournalB, b_, sizeof(b_))) return Status::InvalidField; + // Local route confirmation proves public target bytes, not a new write. + // Its upload journal may legitimately have been retired after install. + if (query_only_) return job_.begin(false, [&] + {const auto current=live();return current==Status::Ok&&!mediaReady()?Status::StorageUnavailable:current; }); + track_sharing::UploadJournalStore store(io_, a_, b_); + const auto loaded = store.load(journal_, verify_, scratch, capacity); + if (loaded != track_sharing::JournalStoreResult::Ready) return loaded == track_sharing::JournalStoreResult::StorageFailure ? Status::StorageUnavailable : loaded == track_sharing::JournalStoreResult::Missing ? Status::Expired + : Status::InvalidField; + const auto guard = [&] + {const auto current=live();return current==Status::Ok?authorizeRouteInstallUpload(request_,journal_,track_sharing::UploadKind::Kml,request_.generation,{author_,32},true,mediaReady()):current; }; + return job_.begin(install, guard); + } + template + Status advance(uint8_t* scratch, size_t capacity, Guard&& live) + { + if (!scratch || capacity < 256) return Status::InvalidField; + const auto guard = [&] + { + const auto current = live(); + if (current != Status::Ok) return current; + if (query_only_) return mediaReady() ? Status::Ok : Status::StorageUnavailable; + const auto authorized = authorizeRouteInstallUpload(request_, journal_, track_sharing::UploadKind::Kml, request_.generation, {author_, 32}, true, mediaReady()); + if (authorized != Status::Ok) return authorized; + track_sharing::UploadJournalStore store(io_, a_, b_); + const auto valid = store.validateCurrent(journal_, verify_, scratch, capacity); + return valid == track_sharing::JournalStoreResult::Ready ? Status::Ok : valid == track_sharing::JournalStoreResult::StorageFailure ? Status::StorageUnavailable + : Status::Conflict; + }; + return job_.step(scratch, capacity, guard); + } + bool identityMatches(uint32_t generation, geocaching::ByteView hash) const + { + return generation == request_.generation && hash.data && hash.size == 32 && !std::memcmp(hash.data, author_, 32); + } + bool matches(const uint8_t* session, const RouteInstallView& request) const + { + return session && request.transfer.data && request.transfer.size == 16 && request.hash.data && request.hash.size == 32 && + !std::memcmp(session, session_, 16) && !std::memcmp(request.transfer.data, bindings_, 16) && !std::memcmp(request.hash.data, bindings_ + 16, 32) && + request.bytes == request_.bytes && request.generation == request_.generation; + } + bool matchesSession(const uint8_t* session) const { return session && !std::memcmp(session, session_, 16); } + bool alive(uint64_t now, bool connected) const { return connected && now < deadline_ && mediaReady(); } + void renew(uint64_t now) { deadline_ = now + 120000; } + bool hasBacking() const { return file_.has_backing_storage(); } + bool queryOnly() const { return query_only_; } + uint32_t mediaSession() const { return media_; } + bool resultDelivered() const { return result_delivered_; } + void markResultDelivered() + { + if (job_.status() == Status::Ok) result_delivered_ = true; + } + Status status() const { return job_.status(); } + uint8_t wireState() const { return job_.wireState(); } + void cancel() { job_.cancel(); } + const RouteInstallView& binding() const { return request_; } + const char* target() const { return target_; } + + private: + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + uint8_t bindings_[48]{}, session_[16]{}, author_[32]{}; + RouteInstallView request_; + uint32_t media_; + uint64_t deadline_; + bool query_only_ = true; + bool result_delivered_ = false; + track_sharing::UploadJournal journal_{}, verify_{}; + char source_[64]{}, target_[77]{}, a_[64]{}, b_[64]{}, names_[2048]{}; + mbedtls_sha256_context context_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + platform::esp::storage::SnapshotStreamSha256 hash_; + RouteInstallJob job_; +}; +static_assert(sizeof(RouteInstallOwner) <= 12288, "Route owner exceeds transient PSRAM admission"); +inline RouteInstallOwner* createRouteInstallOwner(const RouteInstallView& request, const uint8_t* session, const uint8_t* author, uint32_t media, uint64_t now) +{ + if (!session || !author || !request.transfer.data || request.transfer.size != 16 || !request.hash.data || request.hash.size != 32 || + !request.bytes || request.bytes > 8388608 || !request.generation || request.generation >= UINT32_MAX || + media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + void* memory = heap_caps_malloc(sizeof(RouteInstallOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* owner = new (memory) RouteInstallOwner(request, session, author, media, now); + if (!owner->hasBacking()) + { + owner->~RouteInstallOwner(); + heap_caps_free(memory); + return nullptr; + } + return owner; +} +inline void destroyRouteInstallOwner(RouteInstallOwner* owner) +{ + if (!owner) return; + owner->cancel(); + owner->~RouteInstallOwner(); + heap_caps_free(owner); +} +} // namespace hostlink::management::route_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_runtime.h new file mode 100644 index 00000000..f18b13db --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_route_install_runtime.h @@ -0,0 +1,106 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/idf_common/hostlink_route_install_identity.h" +namespace hostlink::management::route_install_runtime +{ +// Slot belongs to the application scheduler. No static owner/queue is created +// here. Policy supplies storage mutual exclusion and active-route restrictions. +inline void releaseRouteInstall(RouteInstallOwner*& slot) +{ + destroyRouteInstallOwner(slot); + slot = nullptr; +} +inline void closeRouteInstallSession(RouteInstallOwner*& slot, const uint8_t* session) +{ + if (slot && slot->matchesSession(session)) releaseRouteInstall(slot); +} +// A terminal receipt has already been copied into caller-owned response bytes. +// Recovery reads the durable target again; it does not need this PSRAM owner. +inline bool retireDeliveredRouteInstall(RouteInstallOwner*& slot) +{ + if (!slot || !slot->resultDelivered() || (slot->wireState() != 1 && slot->wireState() != 2)) return false; + releaseRouteInstall(slot); + return true; +} +template +Status advanceRouteInstallSlot(RouteInstallOwner*& slot, Adapter* adapter, uint8_t* scratch, size_t capacity, uint64_t now, bool connected, Policy&& policy) +{ + if (!slot) return Status::Expired; + if (!slot->alive(now, connected)) + { + releaseRouteInstall(slot); + return Status::Expired; + } + if (!adapter) return Status::Busy; + return advanceAuthenticatedRouteInstall(*slot, *adapter, scratch, capacity, [&] + { return policy(slot->binding(), !slot->queryOnly()); }); +} +template +Status processRouteInstallRequest(RouteInstallOwner*& slot, Adapter& adapter, const EnvelopeView& request, uint64_t now, + uint8_t* scratch, size_t capacity, size_t& written, Policy&& policy) +{ + written = 0; + const bool install = request.code == uint16_t(Operation::RouteInstall); + if (!install && request.code != uint16_t(Operation::RouteInstallStatus)) return Status::Unsupported; + if (!request.session || !scratch || capacity < 448 || capacity > 512) return Status::InvalidField; + RouteInstallView decoded; + const auto parsed = parseRouteInstallRequest({request.body, request.body_size}, decoded); + if (parsed != Status::Ok) return parsed; + auto* public_key = scratch + capacity - 64; + const auto overlap = [&](const void* input, size_t size) + {const auto a=reinterpret_cast(input),b=reinterpret_cast(public_key);return a<=b?b-aalive(now, true)) releaseRouteInstall(slot); + if (slot && !slot->matches(request.session, decoded)) return slot->matchesSession(request.session) ? Status::Conflict : Status::Unauthorized; + // A finished query is always revalidated from disk on the next query. + // Retire only transient state; journals/files remain for recovery. + if (slot && slot->status() != Status::Busy && (slot->status() != Status::Ok || slot->resultDelivered())) releaseRouteInstall(slot); + if (!slot) + { + const auto admitted = policy(decoded, install); + if (admitted != Status::Ok) return admitted; + if (upload_runtime::owner) + { + auto& upload = *upload_runtime::owner; + const auto& journal = upload.resources.metadata(); + if (upload.kind != track_sharing::UploadKind::Kml || upload.discarding || journal.phase != track_sharing::UploadPhase::Sealed || + std::memcmp(journal.id, decoded.transfer.data, 16) || !upload.session.matches(request.session, sd::sd_media_session(), now)) return Status::Busy; + const auto authorized = authorizeRouteInstallUpload(decoded, journal, upload.kind, generation, {author, 32}, true, true); + if (authorized != Status::Ok) return authorized; + } + auto* created = createRouteInstallOwner(decoded, request.session, author, sd::sd_media_session(), now); + if (!created) return Status::Busy; + const auto started = startAuthenticatedRouteInstall(*created, adapter, install, scratch, capacity, [&] + { return policy(created->binding(), install); }); + if ((started != Status::Ok && started != Status::Busy) || created->wireState() > 2) + { + destroyRouteInstallOwner(created); + return started == Status::Ok ? Status::Internal : started; + } + // The route owner now holds the immutable upload lease. Platform policy + // must block all upload mutations while this slot is occupied. + if (upload_runtime::owner) + { + upload_runtime::destroyOwner(upload_runtime::owner); + upload_runtime::owner = nullptr; + } + slot = created; + } + if (!slot->identityMatches(generation, {author, 32})) return Status::StaleIdentity; + slot->renew(now); + const auto state = slot->wireState(); + if (state > 2 || (install && state == 2)) return Status::Internal; + const auto& binding = slot->binding(); + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(5) || !writer.binary(binding.transfer) || !writer.binary(binding.hash) || !writer.unsignedInteger(binding.bytes) || + !writer.text(slot->target()) || !writer.unsignedInteger(state)) return Status::Internal; + written = writer.size(); + slot->markResultDelivered(); + return Status::Ok; +} +} // namespace hostlink::management::route_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h new file mode 100644 index 00000000..e01aa669 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h @@ -0,0 +1,129 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/sealed_attachment_uploads.h" +#include +#include + +namespace hostlink::management::prepare_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// Transient PSRAM only. The scheduler retains the immutable upload lease and +// authenticates identity before every step. No static slot/table or task stack +// buffers. This proof is a prerequisite of v2 preparation, not its completion. +class TrackAttachmentPrepareOwner +{ + public: + TrackAttachmentPrepareOwner(uint32_t media, uint32_t generation, const uint8_t* author_hash, + const uint8_t* index_id, geocaching::ByteView ids, const track_sharing::AttachmentIndexBinding& binding, + geocaching::ByteView kml_hash, uint32_t kml_bytes) + : media_(media), generation_(generation), count_(binding.count), index_bytes_(binding.bytes), kml_bytes_(kml_bytes), + file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media_), index_hash_(index_context_), resource_hash_(resource_context_), + resolver_(io_, {ids_, ids.size}, generation_, author_), + check_(io_, index_hash_, resource_hash_, resolver_, window_, sizeof(window_), fingerprints_, 64, + {{index_digest_, 32}, index_bytes_, count_}, {kml_digest_, 32}, kml_bytes) + { + std::memcpy(author_, author_hash, 32); + std::memcpy(index_id_, index_id, 16); + std::memcpy(ids_, ids.data, ids.size); + std::memcpy(index_digest_, binding.hash.data, 32); + std::memcpy(kml_digest_, kml_hash.data, 32); + } + TrackAttachmentPrepareOwner(const TrackAttachmentPrepareOwner&) = delete; + TrackAttachmentPrepareOwner& operator=(const TrackAttachmentPrepareOwner&) = delete; + track_sharing::AttachmentPackageResult begin() + { + using R = track_sharing::AttachmentPackageResult; + if (started_) return R::Busy; + started_ = true; + if (!mediaReady()) return state_ = R::StorageFailure; + const auto loaded = resolver_.index(index_id_, {{index_digest_, 32}, index_bytes_, count_}, path_, sizeof(path_)); + if (loaded != track_sharing::AttachmentResolveResult::Ready) + return state_ = loaded == track_sharing::AttachmentResolveResult::StorageFailure ? R::StorageFailure : R::Invalid; + return state_ = check_.begin(path_); + } + track_sharing::AttachmentPackageResult step(uint32_t generation, const uint8_t* author_hash) + { + using R = track_sharing::AttachmentPackageResult; + if (!mediaReady()) + { + check_.cancel(); + return state_ = R::StorageFailure; + } + if (generation != generation_ || !author_hash || std::memcmp(author_hash, author_, 32)) + { + check_.cancel(); + return state_ = R::Invalid; + } + if (state_ != R::Pending) return state_; + return state_ = check_.step(); + } + void cancel() + { + check_.cancel(); + state_ = track_sharing::AttachmentPackageResult::Cancelled; + } + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + bool ready() const { return state_ == track_sharing::AttachmentPackageResult::Ready && mediaReady() && check_.ready(); } + bool matches(const track_sharing::AttachmentIndexBinding& binding, geocaching::ByteView kml_hash, uint32_t kml_bytes) const + { + return ready() && kml_bytes == kml_bytes_ && kml_hash.data && kml_hash.size == 32 && !std::memcmp(kml_hash.data, kml_digest_, 32) && check_.matchesIndex(path_, binding); + } + uint64_t availability() const { return ready() ? check_.availability() : 0; } + bool allAvailable() const { return ready() && check_.allAvailable(); } + bool hasBacking() const { return file_.has_backing_storage(); } + const char* indexPath() const { return ready() ? path_ : nullptr; } + track_sharing::AttachmentIndexBinding indexBinding() const { return {{index_digest_, 32}, index_bytes_, count_}; } + geocaching::ByteView kmlHash() const { return {kml_digest_, 32}; } + uint32_t kmlBytes() const { return kml_bytes_; } + geocaching::ByteView uploadIds() const { return {ids_, size_t(count_) * 16}; } + sd::SdBorrowedRecordFileIo& io() { return io_; } + platform::esp::storage::SnapshotStreamSha256& indexHash() { return index_hash_; } + platform::esp::storage::SnapshotStreamSha256& resourceHash() { return resource_hash_; } + track_sharing::SealedAttachmentUploads& resolver() { return resolver_; } + + private: + uint32_t media_, generation_; + uint8_t count_; + uint32_t index_bytes_, kml_bytes_; + uint8_t author_[32]{}, index_id_[16]{}, ids_[1024]{}, index_digest_[32]{}, kml_digest_[32]{}; + char path_[64]{}; + uint8_t window_[4352]{}; + track_sharing::AttachmentFingerprint fingerprints_[64]{}; + mbedtls_sha256_context index_context_, resource_context_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + platform::esp::storage::SnapshotStreamSha256 index_hash_, resource_hash_; + track_sharing::SealedAttachmentUploads resolver_; + track_sharing::AttachmentPackageFileCheck check_; + track_sharing::AttachmentPackageResult state_ = track_sharing::AttachmentPackageResult::Idle; + bool started_ = false; +}; +static_assert(sizeof(TrackAttachmentPrepareOwner) <= 9216, "Attachment preparation exceeds transient PSRAM budget"); +inline TrackAttachmentPrepareOwner* createAttachmentPrepareOwner(uint32_t media, uint32_t generation, const uint8_t* author_hash, + const uint8_t* index_id, geocaching::ByteView ids, + const track_sharing::AttachmentIndexBinding& binding, geocaching::ByteView kml_hash, uint32_t kml_bytes) +{ + if (!generation || !author_hash || !index_id || !ids.data || !binding.count || binding.count > 64 || ids.size != size_t(binding.count) * 16 || + !binding.hash.data || binding.hash.size != 32 || !binding.bytes || binding.bytes > track_sharing::kMaxAttachmentIndexBytes || + !kml_hash.data || kml_hash.size != 32 || !kml_bytes || kml_bytes > 8388608 || media != sd::sd_media_session() || + !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= index_id[i]; + if (!bits) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackAttachmentPrepareOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* owner = new (memory) TrackAttachmentPrepareOwner(media, generation, author_hash, index_id, ids, binding, kml_hash, kml_bytes); + if (owner->hasBacking()) return owner; + owner->~TrackAttachmentPrepareOwner(); + heap_caps_free(memory); + return nullptr; +} +inline void destroyAttachmentPrepareOwner(TrackAttachmentPrepareOwner* owner) +{ + if (!owner) return; + owner->cancel(); + owner->~TrackAttachmentPrepareOwner(); + heap_caps_free(owner); +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_retention.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_retention.h new file mode 100644 index 00000000..20ac7919 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_attachment_retention.h @@ -0,0 +1,120 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h" +#include "platform/esp/idf_common/hostlink_track_package_cache_admission.h" +#include "platform/esp/idf_common/hostlink_track_package_retention_admission.h" +#include "track_sharing/immutable_file_copy.h" +namespace hostlink::management::prepare_runtime +{ +// Copies verified index first, then streams that retained index and copies +// supplied resources one at a time. All target paths derive from object/hash. +// Existing/partial destinations are rechecked by ImmutableFileCopy on retry. +class TrackAttachmentRetention +{ + public: + TrackAttachmentRetention(TrackAttachmentPrepareOwner& source, geocaching::ByteView object, uint32_t media, bool public_cache = false) + : source_(source), media_(media), index_(source.io(), source.indexHash(), window_, sizeof(window_), fingerprints_, 64, + source.indexBinding(), source.kmlHash(), source.kmlBytes()), + copy_(source.io(), source.resourceHash()), public_cache_(public_cache) + { + if (object.data && object.size == 32) std::memcpy(object_, object.data, 32); + } + Status step(uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (!source_.ready() || !source_.mediaReady() || media_ != sd::sd_media_session()) return stop(Status::StorageUnavailable); + if (terminal_) return status_; + if (!index_retained_) + { + if (!copying_) + { + const auto binding = source_.indexBinding(); + const auto admitted = beginCopy(source_.indexPath(), binding.hash, binding.bytes, ObjectFile::AttachmentIndex, ObjectFile::AttachmentIndexTemporary, scratch, capacity); + if (admitted != Status::Ok) return admitted == Status::Busy ? admitted : stop(admitted); + } + const auto copied = copy_.step(scratch, capacity); + if (copied == ImmutableCopyResult::Pending) return Status::Busy; + if (copied != ImmutableCopyResult::Ready) return stop(map(copied)); + copying_ = false; + index_retained_ = true; + std::memcpy(index_path_, destination_, sizeof(index_path_)); + if (index_.begin(index_path_) != AttachmentIndexFileResult::Pending) return stop(Status::InvalidField); + return Status::Busy; + } + if (copying_) + { + const auto copied = copy_.step(scratch, capacity); + if (copied == ImmutableCopyResult::Pending) return Status::Busy; + if (copied != ImmutableCopyResult::Ready) return stop(map(copied)); + copying_ = false; + return next(); + } + const auto parsed = index_.step(); + if (parsed == AttachmentIndexFileResult::Pending) return Status::Busy; + if (parsed == AttachmentIndexFileResult::Ready) + { + terminal_ = true; + return status_ = Status::Ok; + } + if (parsed != AttachmentIndexFileResult::Entry) return stop(parsed == AttachmentIndexFileResult::HashMismatch ? Status::HashMismatch : Status::StorageUnavailable); + const auto* entry = index_.entry(); + if (!entry || ordinal_ >= 64) return stop(Status::InvalidField); + const auto resolved = source_.resolver().resolve(ordinal_, *entry, upload_path_, sizeof(upload_path_)); + if (resolved == AttachmentResolveResult::Missing) + return entry->required ? stop(Status::Conflict) : next(); + if (resolved != AttachmentResolveResult::Ready) return stop(resolved == AttachmentResolveResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + const auto admitted = beginCopy(upload_path_, entry->hash, entry->bytes, ObjectFile::AttachmentResource, ObjectFile::AttachmentResourceTemporary, scratch, capacity); + return admitted == Status::Ok || admitted == Status::Busy ? Status::Busy : stop(admitted); + } + + private: + Status beginCopy(const char* source, geocaching::ByteView hash, uint32_t bytes, track_sharing::ObjectFile final_kind, + track_sharing::ObjectFile temporary_kind, uint8_t* scratch, size_t capacity) + { + const auto admitted = public_cache_ ? admitPackageCacheFile(media_, {object_, 32}, hash, bytes, final_kind, temporary_kind, scratch, capacity) : admitRetainedPackageFile(media_, {object_, 32}, hash, bytes, final_kind, temporary_kind, scratch, capacity); + if (admitted != Status::Ok) return admitted; + const auto resolve = [&](track_sharing::ObjectFile kind, char* path, size_t capacity) + { + if (public_cache_) + { + const bool resource = kind == track_sharing::ObjectFile::AttachmentResource || kind == track_sharing::ObjectFile::AttachmentResourceTemporary; + return track_sharing::resolvePackageCachePath({object_, 32}, kind, resource ? hash : geocaching::ByteView{}, path, capacity); + } + return track_sharing::resolveObjectPath({object_, 32}, kind, hash, path, capacity); + }; + if (!resolve(final_kind, destination_, sizeof(destination_)) || !resolve(temporary_kind, temporary_, sizeof(temporary_))) return Status::InvalidField; + const auto begun = copy_.begin(source, temporary_, destination_, bytes, hash.data); + copying_ = begun == track_sharing::ImmutableCopyResult::Pending; + return copying_ ? Status::Ok : map(begun); + } + Status next() + { + if (!index_.advance()) return stop(Status::InvalidField); + ++ordinal_; + return Status::Busy; + } + static Status map(track_sharing::ImmutableCopyResult result) + { + return result == track_sharing::ImmutableCopyResult::HashMismatch ? Status::HashMismatch : result == track_sharing::ImmutableCopyResult::StorageFailure ? Status::StorageUnavailable + : Status::Conflict; + } + Status stop(Status result) + { + index_.cancel(); + copy_.cancel(); + terminal_ = true; + return status_ = result; + } + TrackAttachmentPrepareOwner& source_; + uint32_t media_; + uint8_t object_[32]{}, window_[4352]{}; + track_sharing::AttachmentFingerprint fingerprints_[64]{}; + char temporary_[160]{}, destination_[160]{}, index_path_[160]{}, upload_path_[64]{}; + track_sharing::AttachmentIndexFileCheck index_; + track_sharing::ImmutableFileCopy copy_; + uint8_t ordinal_ = 0; + Status status_ = Status::Busy; + bool index_retained_ = false, copying_ = false, terminal_ = false; + bool public_cache_; +}; +static_assert(sizeof(TrackAttachmentRetention) <= 7168, "Attachment retention must fit transient PSRAM admission"); +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_catalog.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_catalog.h new file mode 100644 index 00000000..93e2b399 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_catalog.h @@ -0,0 +1,150 @@ +#pragma once + +#include "geocaching/protocol/cmp_reader.h" +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/idf_common/tracker_snapshot_runtime.h" + +namespace hostlink::management +{ +inline bool trackCatalogNameValid(std::string_view name) +{ + if (name.empty() || name.size() > 127) return false; + for (size_t i = 0; i < name.size();) + { + const uint8_t first = static_cast(name[i++]); + if (first < 0x80) + { + if (first < 32 || first == '/' || first == '\\' || first == ':') return false; + continue; + } + unsigned continuation = 0; + uint32_t value = 0, minimum = 0; + if (first >= 0xc2 && first <= 0xdf) + { + continuation = 1; + value = first & 31; + minimum = 0x80; + } + else if (first >= 0xe0 && first <= 0xef) + { + continuation = 2; + value = first & 15; + minimum = 0x800; + } + else if (first >= 0xf0 && first <= 0xf4) + { + continuation = 3; + value = first & 7; + minimum = 0x10000; + } + else return false; + if (continuation > name.size() - i) return false; + while (continuation--) + { + const uint8_t next = static_cast(name[i++]); + if ((next & 0xc0) != 0x80) return false; + value = value << 6 | (next & 63); + } + if (value < minimum || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return false; + } + return true; +} +inline uint8_t trackCatalogFormat(std::string_view name) +{ + if (name.size() < 4 || name[name.size() - 4] != '.') return 255; + const auto lower = [](char value) + { return value >= 'A' && value <= 'Z' ? char(value + 32) : value; }; + const auto a = lower(name[name.size() - 3]), b = lower(name[name.size() - 2]), c = lower(name.back()); + if (a == 'k' && b == 'm' && c == 'l') return 0; + if (a == 'g' && b == 'p' && c == 'x') return 1; + if (a == 'c' && b == 's' && c == 'v') return 2; + if (a == 'b' && b == 'i' && c == 'n') return 3; + return 255; +} + +// Op 80 request [null | [media_session32, directory_position]]; +// response [media_session32, next_position|null, [name,format,bytes,active]|null]. +// Live directory, not a frozen catalogue: refresh after recording mutations. +// Four entry scans per request, one returned row; scratch borrows the tail of +// PendingCommand. No vector, pathname copy or second file handle is needed. +inline Status readTrackCatalog(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!output || capacity < 384) return Status::Internal; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t wanted_session = 0, position = 0; + if (!reader.array(fields, 1) || fields != 1) return Status::InvalidField; + const bool first = reader.position() < request.body_size && request.body[reader.position()] == 0xc0; + if (first) + { + if (!reader.nil()) return Status::InvalidField; + } + else if (!reader.array(fields, 2) || fields != 2 || !reader.unsignedInteger(wanted_session) || wanted_session > UINT32_MAX || + !reader.unsignedInteger(position) || position > 128U * 1024U * 1024U || position % 32) return Status::InvalidField; + if (!reader.finished()) return Status::InvalidField; + if (!platform::ui::tracker::try_acquire_catalog_read()) return Status::Busy; + struct Release + { + ~Release() { platform::ui::tracker::release_snapshot_read(); } + } release; + namespace sd = platform::esp::arduino_common::storage; + const uint32_t session = sd::sd_media_session(); + if (!first && wanted_session != session) return Status::Conflict; + sd::SdRuntimeDir directory; + const auto opened = directory.open_read_status("/trackers", session); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready && opened != sd::SdFileReadStatus::Missing) return Status::StorageUnavailable; + bool ended = opened == sd::SdFileReadStatus::Missing, found = false, active = false; + uint8_t format = 255; + uint64_t bytes = 0; + char* name = reinterpret_cast(output + capacity - 128); + if (!ended && position) + { + const auto seek = directory.seek_read_status(position); + if (seek == sd::SdFileReadStatus::Busy) return Status::Busy; + if (seek == sd::SdFileReadStatus::Invalid) return Status::Conflict; + if (seek != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + } + for (unsigned scan = 0; !ended && !found && scan < 4; ++scan) + { + bool is_dir = false; + uint64_t next = 0; + const auto result = directory.read_next_info(name, 128, &is_dir, &bytes, &next); + if (result == sd::SdDirReadStatus::Busy) return Status::Busy; + if (result == sd::SdDirReadStatus::End) + { + ended = true; + break; + } + if (result != sd::SdDirReadStatus::Entry || next <= position || next > 128U * 1024U * 1024U || next % 32) return Status::StorageUnavailable; + position = next; + if (is_dir) continue; + bool control = std::strlen(name) == 10; + constexpr char reserved[] = "active.bin"; + for (unsigned i = 0; control && i < 10; ++i) + { + const char character = name[i] >= 'A' && name[i] <= 'Z' ? char(name[i] + 32) : name[i]; + control = character == reserved[i]; + } + if (control) continue; + format = trackCatalogFormat(name); + if (format == 255) continue; + if (!trackCatalogNameValid(name)) return Status::StorageUnavailable; + active = platform::ui::tracker::is_recording_filename(name); + found = true; + } + if (session != sd::sd_media_session() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + geocaching::protocol::CmpWriter writer(output, capacity - 128); + if (!writer.array(3) || !writer.unsignedInteger(session) || !(ended ? writer.nil() : writer.unsignedInteger(position))) return Status::Internal; + if (found) + { + if (!writer.array(4) || !writer.text(name) || !writer.unsignedInteger(format) || !writer.unsignedInteger(bytes) || !writer.unsignedInteger(active ? 1 : 0)) return Status::Internal; + } + else if (!writer.nil()) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_directories_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_directories_runtime.h new file mode 100644 index 00000000..e4b11e48 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_directories_runtime.h @@ -0,0 +1,256 @@ +#pragma once +#include "chat/ports/i_mesh_adapter.h" +#include "hostlink/management_envelope.h" +#include "track_sharing/directory_list.h" +#include "track_sharing/publication_directory.h" +#include +#include +#include +#include +#include + +namespace hostlink::management::directory_runtime +{ +class Owner +{ + public: + Owner(chat::IMeshAdapter& mesh, const uint8_t* id, uint64_t now) + : adapter(mesh), pool(entries, track_sharing::TrackDirectoryPool::kCapacity), last_used(now), binding{nullptr, this, announce, control} + { + std::memcpy(session, id, 16); + } + chat::IMeshAdapter& adapter; + track_sharing::TrackDirectoryEntry entries[track_sharing::TrackDirectoryPool::kCapacity]{}; + track_sharing::TrackDirectoryPool pool; + uint8_t session[16]{}; + uint64_t last_used; + chat::lxmf::TrackDeliveryBinding binding; + std::atomic_flag locked = ATOMIC_FLAG_INIT; + uint8_t local_delivery[16]{}, network_request[16]{}, target_delivery[16]{}, target_epoch[16]{}; + uint64_t retry_after[track_sharing::TrackDirectoryPool::kCapacity]{}, deadline = 0; + size_t selected_index = 0, next_index = 0; + bool pending = false; + static bool announce(const chat::lxmf::TrackDirectoryAnnouncementView& view, void* context) + { + auto& owner = *static_cast(context); + if (owner.locked.test_and_set(std::memory_order_acquire)) return false; + const bool accepted = owner.pool.observe(view, uint64_t(esp_timer_get_time() / 1000)); + if (accepted && owner.pending && !owner.pool.find({owner.target_delivery, 16}, {owner.target_epoch, 16})) + { + owner.pending = false; + owner.retry_after[owner.selected_index] = 0; + } + owner.locked.clear(std::memory_order_release); + return accepted; + } + static bool control(const chat::lxmf::CustomDeliveryView& view, void* context) + { + auto& owner = *static_cast(context); + if (owner.locked.test_and_set(std::memory_order_acquire)) return false; + const bool accepted = owner.pending && uint64_t(esp_timer_get_time() / 1000) <= owner.deadline && view.source.data && view.source.size == 16 && !std::memcmp(view.source.data, owner.target_delivery, 16) && + view.destination.data && view.destination.size == 16 && !std::memcmp(view.destination.data, owner.local_delivery, 16) && + owner.pool.confirmCapabilities({owner.target_delivery, 16}, {owner.target_epoch, 16}, {view.data.data, view.data.size}, {owner.network_request, 16}); + if (accepted) owner.pending = false; + owner.locked.clear(std::memory_order_release); + return accepted; + } + void negotiate(uint64_t now, uint8_t* scratch, size_t capacity) + { + if (!scratch || capacity < 128 || locked.test_and_set(std::memory_order_acquire)) return; + if (pending && now >= deadline) + { + pending = false; + retry_after[selected_index] = now + 30000; + } + if (pending) + { + locked.clear(std::memory_order_release); + return; + } + bool found = false; + for (size_t visited = 0; visited < pool.size(); ++visited) + { + const auto index = next_index++ % pool.size(); + const auto* entry = pool.at(index); + if (entry->capabilities_verified || now < retry_after[index]) continue; + selected_index = index; + std::memcpy(target_delivery, entry->delivery, 16); + std::memcpy(target_epoch, entry->epoch, 16); + found = true; + break; + } + locked.clear(std::memory_order_release); + if (!found) return; + auto* local_author = scratch + capacity - 64; + uint32_t generation = 0; + if (adapter.readDeviceAuthorKey(local_author, generation) != chat::DeviceAuthorResult::Ok || !adapter.trackLocalDestination(local_delivery)) return; + esp_fill_random(network_request, 16); + geocaching::protocol::CmpWriter writer(scratch, capacity - 64); + if (!track_sharing::networkRequestHeader(writer, 0, {network_request, 16}, 8192, 0)) return; + if (locked.test_and_set(std::memory_order_acquire)) return; + if (!pool.find({target_delivery, 16}, {target_epoch, 16})) + { + locked.clear(std::memory_order_release); + return; + } + pending = true; + deadline = now + 15000; + locked.clear(std::memory_order_release); + // No owner flag is held while entering the router; callbacks only + // borrow their LXMF frame and synchronously finish capability validation. + const auto result = adapter.sendTrackData(target_delivery, {scratch, writer.size()}, local_author, generation, false, nullptr); + if (!result.ok && !locked.test_and_set(std::memory_order_acquire)) + { + pending = false; + retry_after[selected_index] = now + 3000; + locked.clear(std::memory_order_release); + } + } +}; +static_assert(sizeof(Owner) <= 1792, "Directory session is bounded transient PSRAM metadata"); +inline Owner* owner = nullptr; +using PinnedDirectory = track_sharing::PublicationDirectory; +static_assert(sizeof(PinnedDirectory) <= 104, "Pinned directory contains only publication metadata"); +inline Status pin(chat::IMeshAdapter& adapter, const uint8_t* session, geocaching::ByteView delivery, + geocaching::ByteView epoch, uint64_t now, PinnedDirectory& output) +{ + if (!session || !delivery.data || delivery.size != 16 || !epoch.data || epoch.size != 16) return Status::InvalidField; + const auto target = reinterpret_cast(&output); + const auto overlaps = [target](const void* pointer, size_t size) + { + const auto source = reinterpret_cast(pointer); + return source <= target ? target - source < size : source - target < sizeof(PinnedDirectory); + }; + if (overlaps(session, 16) || overlaps(delivery.data, 16) || overlaps(epoch.data, 16) || + (owner && overlaps(owner, sizeof(Owner)))) return Status::InvalidField; + if (!owner || now < owner->last_used || now - owner->last_used >= 30000) return Status::Expired; + if (&owner->adapter != &adapter || std::memcmp(owner->session, session, 16)) return Status::Unauthorized; + if (owner->locked.test_and_set(std::memory_order_acquire)) return Status::Busy; + const auto* entry = owner->pool.find(delivery, epoch); + if (!entry || !entry->capabilities_verified) + { + owner->locked.clear(std::memory_order_release); + return entry ? Status::Unauthorized : Status::Expired; + } + std::memcpy(output.delivery, entry->delivery, 16); + std::memcpy(output.public_key, entry->public_key, 64); + std::memcpy(output.epoch, entry->epoch, 16); + output.file_limit = entry->file_limit; + output.chunk_limit = entry->chunk_limit; + output.query_limit = entry->query_limit; + owner->last_used = now; + owner->locked.clear(std::memory_order_release); + return Status::Ok; +} +inline bool release() +{ + if (!owner) return true; + // Do not hold the pool flag while taking the router lock. Successful unbind + // waits for the current callback before its PSRAM context can be destroyed. + const auto removed = owner->adapter.replaceTrackDelivery(&owner->binding,nullptr); + if (removed != chat::TrackBindingChange::Applied && removed != chat::TrackBindingChange::Conflict) return false; + owner->~Owner(); + heap_caps_free(owner); + owner = nullptr; + return true; +} +// Job context is fully constructed and its identity guard fixed before this +// scheduler-only call. Atomic replacement retires directory callbacks before +// releasing PSRAM; later directory cleanup cannot clear the job's binding. +template +chat::TrackBindingChange handoverRead(chat::IMeshAdapter& adapter, ReadWork& work, uint64_t now) +{ + if (owner && &owner->adapter != &adapter) return chat::TrackBindingChange::Conflict; + const auto changed = work.attach(now, owner ? &owner->binding : nullptr); + if (changed == chat::TrackBindingChange::Applied && owner) + { + owner->~Owner(); + heap_caps_free(owner); + owner = nullptr; + } + return changed; +} +inline chat::TrackBindingChange handover(chat::IMeshAdapter& adapter, const chat::lxmf::TrackDeliveryBinding* replacement) +{ + if (!replacement || !replacement->handler || !replacement->expected_local_author || !replacement->expected_author_generation || + (owner && &owner->adapter != &adapter)) return chat::TrackBindingChange::Conflict; + const auto changed = adapter.replaceTrackDelivery(owner ? &owner->binding : nullptr,replacement); + if (changed != chat::TrackBindingChange::Applied) return changed; + if (owner) + { + owner->~Owner();heap_caps_free(owner);owner=nullptr; + } + return changed; +} +inline void advance(uint64_t now, bool connected, uint8_t* scratch = nullptr, size_t capacity = 0) +{ + if (owner && (!connected || now < owner->last_used || now - owner->last_used >= 30000)) + { + release(); + return; + } + if (owner && connected) owner->negotiate(now, scratch, capacity); +} +inline void closeSession(const uint8_t* id) +{ + if (owner && id && !std::memcmp(owner->session, id, 16)) release(); +} +inline Status list(chat::IMeshAdapter& adapter, const EnvelopeView& request, uint64_t now, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t cursor = 0; + if (!request.session || !reader.array(fields, 1) || fields != 1 || !reader.unsignedInteger(cursor) || cursor > 8 || !reader.finished()) return Status::InvalidField; + advance(now, true); + if (owner && (&owner->adapter != &adapter || std::memcmp(owner->session, request.session, 16))) return Status::Conflict; + if (!owner) + { + if (cursor) return Status::Expired; + constexpr uint32_t caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + if (heap_caps_get_free_size(caps) < sizeof(Owner) + 8192 || heap_caps_get_largest_free_block(caps) < sizeof(Owner)) return Status::Busy; + void* memory = heap_caps_malloc(sizeof(Owner), caps); + if (!memory) return Status::Busy; + auto* created = new (memory) Owner(adapter, request.session, now); + const auto bound = adapter.replaceTrackDelivery(nullptr,&created->binding); + if (bound != chat::TrackBindingChange::Applied) + { + created->~Owner(); + heap_caps_free(created); + return bound == chat::TrackBindingChange::Unsupported ? Status::Unsupported : Status::Busy; + } + owner = created; + } + if (owner->locked.test_and_set(std::memory_order_acquire)) return Status::Busy; + const bool encoded = track_sharing::encodeTrackDirectoryPage(owner->pool, size_t(cursor), output, capacity, written); + owner->locked.clear(std::memory_order_release); + if (!encoded) return Status::InvalidField; + owner->last_used = now; + return Status::Ok; +} +inline Status discover(chat::IMeshAdapter& adapter, const EnvelopeView& request, uint64_t now, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + geocaching::ByteView destination; + if (!output || !capacity || !reader.array(fields, 1) || fields != 1 || !reader.binary(destination, 16) || !track_sharing::networkRequestId(destination) || !reader.finished()) return Status::InvalidField; + uint8_t target[16]; + std::memcpy(target, destination.data, 16); + const uint8_t empty_page[] = {0x91, 0}; + auto opening = request; + opening.body = empty_page; + opening.body_size = sizeof(empty_page); + const auto opened = list(adapter, opening, now, output, capacity, written); + if (opened != Status::Ok) + { + written = 0; + return opened; + } + written = 0; + if (!adapter.requestTrackDirectoryPath(target)) return Status::Busy; + output[0] = 0x90; + written = 1; + return Status::Ok; +} +} // namespace hostlink::management::directory_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_admission.h new file mode 100644 index 00000000..b22d449d --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_admission.h @@ -0,0 +1,82 @@ +#pragma once +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "track_sharing/ip_confirmation_request.h" +#include "track_sharing/object_head.h" +#include "track_sharing/object_paths.h" +namespace hostlink::management::ip_confirmation_runtime +{ +// Authenticated evidence has already been checked by work. For head absence, +// only this exact import's immutable/temporary evidence may exist. Unknown +// files and unfinished signing block adoption, never get removed or ignored. +inline Status admitImportedObjectDirectory(uint32_t media, const track_sharing::IpConfirmationRequest& request, + uint8_t* scratch, size_t capacity) +{ + namespace storage = platform::esp::arduino_common::storage; + using namespace track_sharing; + if (!scratch || capacity < 160 + kObjectHeadBytes) return Status::InvalidField; + const auto ready = [&]() + { return media == storage::sd_media_session() && storage::sd_card_ready() && !storage::sd_external_block_owner_active(); }; + if (!ready()) return Status::StorageUnavailable; + auto* path = reinterpret_cast(scratch); + for (const auto slot : {ObjectFile::HeadA, ObjectFile::HeadB}) + { + if (!resolveObjectPath(request.object, slot, {}, path, 160)) return Status::InvalidField; + // sd_read_file reads the entire file and rejects a smaller capacity. + // Borrow the existing command workspace for the fixed 136-byte head; + // an existence probe of one byte rejects every legitimate saved head. + const auto read = storage::sd_read_file(path, scratch + 160, kObjectHeadBytes); + if (!ready()) return Status::StorageUnavailable; + if (read.status == storage::SdFileReadStatus::Ready) return Status::Ok; // Work validates both slots before mutation. + if (read.status != storage::SdFileReadStatus::Missing) return Status::StorageUnavailable; + } + if (!resolveObjectPath(request.object, ObjectFile::Directory, {}, path, 160)) return Status::InvalidField; + { + storage::SdRuntimeDir directory(storage::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status(path, media); + if (opened == storage::SdFileReadStatus::Busy) return Status::Busy; + if (opened != storage::SdFileReadStatus::Missing && opened != storage::SdFileReadStatus::Ready) return Status::StorageUnavailable; + if (opened == storage::SdFileReadStatus::Ready) + { + constexpr char hex[] = "0123456789abcdef"; + bool ended = false; + for (unsigned count = 0; count < 5; ++count) + { + bool is_directory = false; + uint64_t bytes = 0, next = 0; + auto* name = reinterpret_cast(scratch + 160); + const auto entry = directory.read_next_info(name, capacity - 160, &is_directory, &bytes, &next); + if (entry == storage::SdDirReadStatus::End) + { + ended = true; + break; + } + if (entry == storage::SdDirReadStatus::Busy) return Status::Busy; + if (entry != storage::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_directory) return Status::Conflict; + const size_t length = std::strlen(name); + const auto key = length == 68 ? request.revision_hash : length == 36 ? request.network_request + : geocaching::ByteView{}; + if (!key.size) return Status::Conflict; + for (size_t i = 0; i < key.size; ++i) + if (name[i * 2] != hex[key.data[i] >> 4] || name[i * 2 + 1] != hex[key.data[i] & 15]) return Status::Conflict; + const auto* suffix = name + key.size * 2; + if (key.size == 32 ? std::strcmp(suffix, ".cmp") && std::strcmp(suffix, ".tmp") : std::strcmp(suffix, ".rcp") && std::strcmp(suffix, ".rct")) return Status::Conflict; + } + if (!ended) return Status::Conflict; + } + } + // Directory driver storage above is released before creating paths. + for (const auto* item : {"/trailmate", kObjectDirectory, static_cast(path)}) + { + storage::SdRuntimeDir directory(storage::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto status = directory.open_read_status(item, media); + if (status == storage::SdFileReadStatus::Busy) return Status::Busy; + if (status == storage::SdFileReadStatus::Ready) continue; + if (status != storage::SdFileReadStatus::Missing || !ready() || !storage::sd_mkdir(item) || !ready()) return Status::StorageUnavailable; + } + return ready() ? Status::Ok : Status::StorageUnavailable; +} +} // namespace hostlink::management::ip_confirmation_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_authentication.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_authentication.h new file mode 100644 index 00000000..12c5d7fe --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_authentication.h @@ -0,0 +1,28 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_publication_admission.h" +namespace hostlink::management::publication_runtime +{ +// Candidate metadata is untrusted. This only starts sliced authentication; +// Busy plus non-null output means work admitted, never authenticated success. +inline Status beginHistoryAuthentication(chat::IMeshAdapter& adapter, + const track_sharing::PublicationJournal& candidate, const track_sharing::PublicationDirectory& directory, + uint32_t media, uint8_t* scratch, size_t capacity, TrackPublicationAdmission*& output) +{ + using namespace track_sharing; + if(output)return Status::Conflict; + if(!scratch || capacity<448 || !publicationDirectoryMatches(directory,candidate) || + signingSpansOverlap(scratch,capacity,reinterpret_cast(&candidate),sizeof(candidate)) || + signingSpansOverlap(scratch,capacity,reinterpret_cast(&directory),sizeof(directory)))return Status::InvalidField; + if(media!=sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active())return Status::StorageUnavailable; + uint8_t hash[32];uint32_t generation=0; + auto* author=scratch+capacity-64; + const auto identity=upload_runtime::readUploadAuthor(adapter,author,64,hash,generation); + if(identity!=Status::Ok)return identity; + if(generation!=candidate.generation || std::memcmp(hash,candidate.author_hash,32))return Status::StaleIdentity; + // Small borrowed views, immediately copied by the admission factory. + const CommitRequest request={{candidate.operation,16},{candidate.object,32},{candidate.revision_hash,32}, + {candidate.directory,16},{candidate.directory_epoch,16},candidate.generation,candidate.revision}; + output=createPublicationAdmission(request,directory,media,author,hash,true); + return Status::Busy; +} +} diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_owner.h new file mode 100644 index 00000000..c7ff8307 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_owner.h @@ -0,0 +1,44 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_history_query.h" +#include "track_sharing/publication_candidate_reader.h" +namespace hostlink::management::publication_runtime +{ +class HistoryOwner +{ + public: + HistoryOwner(geocaching::ByteView operation,uint32_t generation,const uint8_t* session,uint32_t media) + :file_(sd::SdRuntimeMemoryPolicy::PsramOnly),directory_(sd::SdRuntimeMemoryPolicy::PsramOnly),io_(file_,media), + reader_(io_),scan_(directory_,media),query_(scan_,reader_,operation,generation,media),generation_(generation) + {std::memcpy(operation_,operation.data,16);std::memcpy(session_,session,16);} + bool backed()const{return file_.has_backing_storage() && directory_.has_backing_storage();} + bool matches(geocaching::ByteView operation,uint32_t generation,const uint8_t* session)const + {return session && operation.data && operation.size==16 && generation==generation_ && !std::memcmp(operation_,operation.data,16) && !std::memcmp(session_,session,16);} + bool matchesSession(const uint8_t* session)const{return session && !std::memcmp(session_,session,16);} + Status step(chat::IMeshAdapter& adapter,uint8_t* scratch,size_t capacity){return status_=query_.step(adapter,scratch,capacity);} + void advance(chat::IMeshAdapter& adapter,uint8_t* scratch,size_t capacity) + {if(status_==Status::Busy)step(adapter,scratch,capacity);} + const track_sharing::PublicationJournal* result()const{return query_.result();} + private: + sd::SdRuntimeFile file_; + sd::SdRuntimeDir directory_; + sd::SdBorrowedRecordFileIo io_; + track_sharing::PublicationCandidateReader reader_; + HistoryScan scan_; + HistoryQuery query_; + uint8_t operation_[16]{},session_[16]{}; + uint32_t generation_; + Status status_=Status::Busy; +}; +static_assert(sizeof(HistoryOwner)<=2048,"History lookup cannot retain a file list"); +inline void releaseHistory(HistoryOwner*& owner) +{if(owner){owner->~HistoryOwner();heap_caps_free(owner);owner=nullptr;}} +inline HistoryOwner* createHistory(geocaching::ByteView operation,uint32_t generation,const uint8_t* session) +{ + if(!operation.data || operation.size!=16 || !generation || !session || !sd::sd_card_ready() || sd::sd_external_block_owner_active())return nullptr; + void* memory=heap_caps_malloc(sizeof(HistoryOwner),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!memory)return nullptr; + auto* result=new(memory) HistoryOwner(operation,generation,session,sd::sd_media_session()); + if(result->backed())return result; + releaseHistory(result);return nullptr; +} +} diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_query.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_query.h new file mode 100644 index 00000000..c98ad8a4 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_query.h @@ -0,0 +1,69 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_history_scan.h" +#include "platform/esp/idf_common/hostlink_track_history_authentication.h" +namespace hostlink::management::publication_runtime +{ +// Borrowed scanner/reader and owned transient authentication. Caller owns the +// exclusive SD lease and allocates all backing objects in admitted PSRAM. +template +class HistoryQuery +{ + public: + HistoryQuery(Scan& scan,Reader& reader,geocaching::ByteView operation,uint32_t generation,uint32_t media) + :scan_(scan),reader_(reader),generation_(generation),media_(media) + { + if(operation.data && operation.size==16 && generation) + { + uint8_t bits=0;for(size_t i=0;i<16;++i)bits|=operation.data[i]; + if(bits){std::memcpy(operation_,operation.data,16);status_=Status::Busy;} + } + } + ~HistoryQuery(){destroyPublicationAdmission(authentication_);} + HistoryQuery(const HistoryQuery&)=delete; + HistoryQuery& operator=(const HistoryQuery&)=delete; + Status step(chat::IMeshAdapter& adapter,uint8_t* scratch,size_t capacity) + { + if(status_!=Status::Busy && status_!=Status::Ok)return status_; + if(!scratch || capacity<448)return fail(Status::InvalidField); + if(media_!=sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active())return fail(Status::StorageUnavailable); + if(authentication_) + { + const auto result=authentication_->step(adapter,scratch,capacity); + if(result==Status::Busy)return result; + if(result!=Status::Ok)return fail(result); + return status_=Status::Ok; + } + if(!selected_) + { + const auto scanned=scan_.step(key_); + if(scanned==HistoryScanResult::Pending || scanned==HistoryScanResult::Busy)return Status::Busy; + if(scanned==HistoryScanResult::End)return fail(Status::Expired); + if(scanned!=HistoryScanResult::Candidate)return fail(scanned==HistoryScanResult::Unavailable ? Status::StorageUnavailable : Status::Conflict); + if(std::memcmp(operation_,key_.operation,16))return Status::Busy; + selected_=true; + } + if(!candidate_loaded_) + { + const auto loaded=reader_.load(key_,scratch,capacity); + if(loaded!=track_sharing::PublicationStoreResult::Ready)return fail(loaded==track_sharing::PublicationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + candidate_loaded_=true; + } + const auto* job=reader_.unverifiedJournal();const auto* directory=reader_.unverifiedDirectory(); + if(!job || !directory)return fail(Status::Conflict); + if(job->generation!=generation_)return fail(Status::StaleIdentity); + const auto started=beginHistoryAuthentication(adapter,*job,*directory,media_,scratch,capacity,authentication_); + if(started!=Status::Busy)return fail(started); + return Status::Busy; + } + const track_sharing::PublicationJournal* result() const { return status_==Status::Ok && authentication_ ? authentication_->result() : nullptr; } + private: + Status fail(Status result){destroyPublicationAdmission(authentication_);authentication_=nullptr;return status_=result;} + Scan& scan_;Reader& reader_; + TrackPublicationAdmission* authentication_=nullptr; + track_sharing::PublicationSlotKey key_; + uint8_t operation_[16]{}; + uint32_t generation_,media_; + Status status_=Status::InvalidField; + bool selected_=false,candidate_loaded_=false; +}; +} diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_scan.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_scan.h new file mode 100644 index 00000000..8b16a6c9 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_history_scan.h @@ -0,0 +1,77 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "track_sharing/publication_slot_path.h" +namespace hostlink::management::publication_runtime +{ +enum class HistoryScanResult : uint8_t { Pending, Candidate, End, Busy, Unavailable, Invalid, StorageFailure }; +// Caller admits scanner and one PsramOnly directory in PSRAM. No list or open +// handle survives a step. Candidates still require journal/signature validation. +class HistoryScan +{ + using Dir = platform::esp::arduino_common::storage::SdRuntimeDir; + static constexpr char prefix_[]="/trailmate/tracks/"; + static constexpr size_t prefix_bytes_=sizeof(prefix_)-1, object_bytes_=prefix_bytes_+64; + public: + HistoryScan(Dir& directory, uint32_t media) : directory_(directory), media_(media) {} + HistoryScanResult step(track_sharing::PublicationSlotKey& output) + { + namespace sd = platform::esp::arduino_common::storage; + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return HistoryScanResult::Unavailable; + if (!directory_.has_backing_storage()) return HistoryScanResult::Unavailable; + if (ended_) return HistoryScanResult::End; + const bool inside = object_path_[0] != 0; + const auto opened = directory_.open_read_status(inside ? object_path_ : track_sharing::kObjectDirectory, media_); + if (opened != sd::SdFileReadStatus::Ready) + { + directory_.close(); + if (opened == sd::SdFileReadStatus::Busy) return HistoryScanResult::Busy; + if (opened == sd::SdFileReadStatus::Missing) + { + if (inside) { object_path_[0]=0; file_position_=0; return HistoryScanResult::Pending; } + ended_=true;return HistoryScanResult::End; + } + return opened == sd::SdFileReadStatus::Unavailable ? HistoryScanResult::Unavailable : HistoryScanResult::StorageFailure; + } + auto& position = inside ? file_position_ : root_position_; + const auto sought = directory_.seek_read_status(position); + if (sought != sd::SdFileReadStatus::Ready) + { + directory_.close(); + return sought == sd::SdFileReadStatus::Busy ? HistoryScanResult::Busy : sought == sd::SdFileReadStatus::Unavailable ? HistoryScanResult::Unavailable : HistoryScanResult::StorageFailure; + } + bool is_directory=false; + uint64_t bytes=0,next=position; + const auto read=directory_.read_next_info(name_,sizeof(name_),&is_directory,&bytes,&next); + directory_.close(); + if (media_ != sd::sd_media_session()) return HistoryScanResult::Unavailable; + if (read == sd::SdDirReadStatus::Busy) return HistoryScanResult::Busy; + if (read == sd::SdDirReadStatus::End) + { + if(inside){object_path_[0]=0;file_position_=0;return HistoryScanResult::Pending;} + ended_=true;return HistoryScanResult::End; + } + if(read != sd::SdDirReadStatus::Entry) return read == sd::SdDirReadStatus::Unavailable ? HistoryScanResult::Unavailable : HistoryScanResult::StorageFailure; + if(next<=position)return HistoryScanResult::Invalid; + position=next; + if(!inside) + { + if(!is_directory || std::strlen(name_)!=64)return HistoryScanResult::Pending; + unsigned bits=0; + for(unsigned i=0;i<64;++i){const char c=name_[i];if(!((c>='0'&&c<='9')||(c>='a'&&c<='f')))return HistoryScanResult::Pending;bits|=c!='0';} + if(!bits)return HistoryScanResult::Pending; + std::memcpy(object_path_,prefix_,prefix_bytes_);std::memcpy(object_path_+prefix_bytes_,name_,65); + return HistoryScanResult::Pending; + } + if(is_directory || std::strlen(name_)!=36)return HistoryScanResult::Pending; + std::memcpy(path_,object_path_,object_bytes_);path_[object_bytes_]='/';std::memcpy(path_+object_bytes_+1,name_,37); + return track_sharing::parsePublicationSlotPath(path_,object_bytes_+37,output) ? HistoryScanResult::Candidate : HistoryScanResult::Pending; + } + private: + Dir& directory_; + uint32_t media_; + uint64_t root_position_=0,file_position_=0; + char object_path_[object_bytes_+1]{},path_[object_bytes_+38]{},name_[65]{}; + bool ended_=false; +}; +static_assert(sizeof(HistoryScan)<=320,"History scanner cannot own a file list"); +} diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_owner.h new file mode 100644 index 00000000..199a2ed9 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_owner.h @@ -0,0 +1,115 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "platform/esp/common/storage/track_receipt_crypto.h" +#include "platform/esp/idf_common/hostlink_track_history_admission.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include "track_sharing/ip_confirmation_work.h" +#include +#include +namespace hostlink::management::ip_confirmation_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// One transient PSRAM owner. Caller serializes storage against other HostLink +// work, owns session/media leases and destroys this after polling/release. +class TrackIpConfirmationOwner +{ + public: + TrackIpConfirmationOwner(const track_sharing::IpConfirmationRequest& request, uint32_t media, + const uint8_t* author, const uint8_t* author_hash) + : media_(media), file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media_), hash_(context_), + work_(io_, hash_, manifest_crypto_, receipt_crypto_, request, {author, 64}, {author_hash, 32}, + signed_storage_, sizeof(signed_storage_), receipt_storage_, sizeof(receipt_storage_), domain_storage_, sizeof(domain_storage_)) {} + TrackIpConfirmationOwner(const TrackIpConfirmationOwner&) = delete; + TrackIpConfirmationOwner& operator=(const TrackIpConfirmationOwner&) = delete; + bool backingReady() const { return file_.has_backing_storage(); } + Status advance(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) + { + if (!scratch || capacity < 448) return Status::InvalidField; + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) + { + work_.cancel(); + return Status::StorageUnavailable; + } + uint8_t author_hash[32]; + uint32_t generation = 0; + auto* author = scratch + capacity - 64; + const auto identity = upload_runtime::readUploadAuthor(adapter, author, 64, author_hash, generation); + if (identity != Status::Ok) + { + if (identity != Status::Busy) work_.cancel(); + return identity; + } + if (const auto* evidence = work_.importEvidence()) + { + const auto admitted = admitImportedObjectDirectory(media_, *evidence, scratch, capacity); + if (admitted != Status::Ok) + { + if (admitted != Status::Busy) work_.cancel(); + return admitted; + } + // Admission borrowed scratch. Restore current author before step. + const auto refreshed = upload_runtime::readUploadAuthor(adapter, author, 64, author_hash, generation); + if (refreshed != Status::Ok) + { + if (refreshed != Status::Busy) work_.cancel(); + return refreshed; + } + } + switch (work_.step(generation, {author, 64}, {author_hash, 32}, true, scratch, capacity)) + { + case track_sharing::IpConfirmationResult::Pending: + return Status::Busy; + case track_sharing::IpConfirmationResult::Ready: + return Status::Ok; + case track_sharing::IpConfirmationResult::StorageFailure: + return Status::StorageUnavailable; + case track_sharing::IpConfirmationResult::StaleIdentity: + return Status::StaleIdentity; + case track_sharing::IpConfirmationResult::HashMismatch: + return Status::HashMismatch; + case track_sharing::IpConfirmationResult::Cancelled: + return Status::Expired; + case track_sharing::IpConfirmationResult::Conflict: + return Status::Conflict; + default: + return Status::InvalidField; + } + } + const track_sharing::ObjectHead* result() const { return work_.result(); } + + private: + uint32_t media_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + mbedtls_sha256_context context_; + platform::esp::storage::SnapshotStreamSha256 hash_; + platform::esp::storage::TrackManifestCrypto manifest_crypto_; + platform::esp::storage::TrackReceiptCrypto receipt_crypto_; + uint8_t signed_storage_[4166]{}, receipt_storage_[1100]{}, domain_storage_[4166]{}; + track_sharing::IpConfirmationWork work_; +}; +static_assert(sizeof(TrackIpConfirmationOwner) <= 16384, "Confirmation must fit one bounded transient PSRAM owner"); +inline TrackIpConfirmationOwner* createOwner(const track_sharing::IpConfirmationRequest& request, uint32_t media, + const uint8_t* author, const uint8_t* author_hash) +{ + if (!author || !author_hash) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackIpConfirmationOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* owner = new (memory) TrackIpConfirmationOwner(request, media, author, author_hash); + if (!owner->backingReady()) + { + owner->~TrackIpConfirmationOwner(); + heap_caps_free(owner); + return nullptr; + } + return owner; +} +inline void destroyOwner(TrackIpConfirmationOwner* owner) +{ + if (!owner) return; + owner->~TrackIpConfirmationOwner(); + heap_caps_free(owner); +} +} // namespace hostlink::management::ip_confirmation_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_runtime.h new file mode 100644 index 00000000..e813c60e --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_ip_confirmation_runtime.h @@ -0,0 +1,157 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/idf_common/hostlink_track_ip_confirmation_owner.h" +namespace hostlink::management::ip_confirmation_runtime +{ +// One resident pointer. Request copy, session/timer and work live in PSRAM. +// Repeating the SAME request advances one bounded step. SessionClose releases; +// a new session can recover persisted evidence after a lost reply/disconnect. +struct Controller +{ + TrackIpConfirmationOwner* work = nullptr; + track_sharing::IpConfirmationRequest decoded; + uint8_t raw[416]{}, session[16]{}; + size_t size = 0; + uint32_t media = 0; + uint64_t last = 0; + Status status = Status::Busy; + uint16_t operation = 0; +}; +static_assert(sizeof(Controller) <= 768, "Confirmation controller must remain bounded PSRAM metadata"); +inline Controller* controller = nullptr; +inline void release() +{ + if (!controller) return; + destroyOwner(controller->work); + controller->~Controller(); + heap_caps_free(controller); + controller = nullptr; +} +inline void closeSession(const uint8_t* session) +{ + if (controller && session && !std::memcmp(session, controller->session, 16)) release(); +} +inline void tick(uint64_t now, bool connected) +{ + if (controller && (!connected || controller->media != sd::sd_media_session() || now < controller->last || now - controller->last >= 30000)) release(); +} +inline bool holdsStorage() { return controller && controller->work && controller->status == Status::Busy; } +constexpr bool blocksOperation(uint16_t operation) +{ + switch (static_cast(operation)) + { + case Operation::Capabilities: + case Operation::AuthorInfo: + case Operation::AuthorProve: + case Operation::SessionClose: + case Operation::TrackConfirmPublication: + case Operation::TrackImportHistory: + return false; + default: + return true; + } +} +inline Status processConfirmation(chat::IMeshAdapter& adapter, const EnvelopeView& request, uint64_t now, + uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + const bool import_history = request.code == uint16_t(Operation::TrackImportHistory); + if (!request.session || !request.body || !request.body_size || request.body_size > (import_history ? 416 : 288) || !scratch || capacity < 448) return Status::InvalidField; + tick(now, true); + // A completed response stays available for exact retries. The owning + // session may start its next publication without waiting for the TTL. + if (controller && controller->status == Status::Ok && !std::memcmp(controller->session, request.session, 16) && + (controller->operation != request.code || controller->size != request.body_size || std::memcmp(controller->raw, request.body, request.body_size))) release(); + if (controller && (controller->operation != request.code || std::memcmp(controller->session, request.session, 16) || controller->size != request.body_size || + std::memcmp(controller->raw, request.body, request.body_size))) return Status::Conflict; + if (!controller) + { + void* memory = heap_caps_malloc(sizeof(Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + controller = new (memory) Controller; + std::memcpy(controller->session, request.session, 16); + std::memcpy(controller->raw, request.body, request.body_size); + controller->size = request.body_size; + controller->media = sd::sd_media_session(); + controller->last = now; + controller->operation = request.code; + if (!track_sharing::decodeIpConfirmationRequest({controller->raw, controller->size}, controller->decoded, import_history)) + { + release(); + return Status::InvalidField; + } + } + if (!controller->work) + { + auto* key = scratch + capacity - 64; + uint8_t author_hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, key, 64, author_hash, generation); + if (identity != Status::Ok) + { + controller->status = identity; + return identity; + } + if (generation != controller->decoded.generation) + { + controller->status = Status::StaleIdentity; + return controller->status; + } + if (upload_runtime::owner) + { + const auto& upload = upload_runtime::owner->resources.metadata(); + if (upload_runtime::owner->kind != track_sharing::UploadKind::IpReceipt || upload.phase != track_sharing::UploadPhase::Sealed || + upload.generation != generation || std::memcmp(upload.id, controller->decoded.transfer.data, 16) || + std::memcmp(upload.file_hash, controller->decoded.proof_hash.data, 32) || std::memcmp(upload.author_hash, author_hash, 32) || + !upload_runtime::owner->session.matches(controller->session, controller->media, now)) + { + release(); + return Status::Busy; + } + upload_runtime::destroyOwner(upload_runtime::owner); + upload_runtime::owner = nullptr; + } + controller->work = createOwner(controller->decoded, controller->media, key, author_hash); + if (!controller->work) return Status::Busy; + } + // Identity/media is rechecked even when returning a completed result. + controller->status = controller->work->advance(adapter, scratch, capacity); + if (controller->status != Status::Ok && controller->status != Status::Busy) return controller->status; + controller->last = now; + const auto* result = controller->work->result(); + // A completed retry can follow a newly resealed proof upload. Release only + // the exact proof owned by this current identity/session so bound cleanup + // can finish; keep unrelated, receiving, or mismatched transfers intact. + if (result && upload_runtime::owner) + { + auto* key = scratch + capacity - 64; + uint8_t author_hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, key, 64, author_hash, generation); + if (identity != Status::Ok) return identity; + if (generation != controller->decoded.generation) return Status::StaleIdentity; + const auto& upload = upload_runtime::owner->resources.metadata(); + const bool proof_matches = + (upload_runtime::owner->kind == track_sharing::UploadKind::IpReceipt && + !std::memcmp(upload.id, controller->decoded.transfer.data, 16) && + !std::memcmp(upload.file_hash, controller->decoded.proof_hash.data, 32)) || + (import_history && upload_runtime::owner->kind == track_sharing::UploadKind::Manifest && + !std::memcmp(upload.id, controller->decoded.manifest_transfer.data, 16) && + !std::memcmp(upload.file_hash, controller->decoded.manifest_hash.data, 32)); + if (proof_matches && !upload_runtime::owner->discarding && upload.phase == track_sharing::UploadPhase::Sealed && + upload.generation == generation && !std::memcmp(upload.author_hash, author_hash, 32) && + upload_runtime::owner->session.matches(request.session, controller->media, now)) + { + upload_runtime::destroyOwner(upload_runtime::owner); + upload_runtime::owner = nullptr; + } + } + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(8) || !writer.binary(controller->decoded.operation) || !writer.binary(controller->decoded.transfer) || + !writer.unsignedInteger(controller->decoded.generation) || !writer.unsignedInteger(result ? 1 : 0) || + !(result ? writer.binary({result->object, 32}) : writer.nil()) || !(result ? writer.binary({result->hash, 32}) : writer.nil()) || + !writer.unsignedInteger(result ? result->revision : 0) || !writer.unsignedInteger(result ? 1 : 0)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management::ip_confirmation_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_kml_retention.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_kml_retention.h new file mode 100644 index 00000000..17996997 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_kml_retention.h @@ -0,0 +1,32 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_prepare_owner.h" +#include "track_sharing/immutable_file_copy.h" +#include "track_sharing/object_paths.h" +namespace hostlink::management::prepare_runtime +{ +// Embedded in an admitted transient stage; borrows input's exclusive Io/hash. +// Caller reserves directory space and retains input/media/identity until done. +class TrackKmlRetention +{ + public: + explicit TrackKmlRetention(TrackPrepareOwner& input) : input_(input), copy_(input.io, input.hash) {} + track_sharing::ImmutableCopyResult begin(geocaching::ByteView object) + { + using namespace track_sharing; + if (!input_.ready() || !resolveObjectPath(object, ObjectFile::KmlTemporary, input_.view.file_hash, temporary_, sizeof(temporary_)) || + !resolveObjectPath(object, ObjectFile::KmlFile, input_.view.file_hash, destination_, sizeof(destination_))) return ImmutableCopyResult::Invalid; + return copy_.begin(input_.kml_path, temporary_, destination_, input_.view.file_bytes, input_.view.file_hash.data); + } + track_sharing::ImmutableCopyResult step(uint8_t* scratch, size_t capacity) + { + if (!input_.ready()) { copy_.cancel(); return track_sharing::ImmutableCopyResult::StorageFailure; } + return copy_.step(scratch, capacity); + } + const char* destination() const { return destination_; } + private: + TrackPrepareOwner& input_; + char temporary_[160]{}, destination_[160]{}; + track_sharing::ImmutableFileCopy copy_; +}; +static_assert(sizeof(TrackKmlRetention) <= 560, "Retention must not duplicate KML or hash context"); +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_object_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_object_admission.h new file mode 100644 index 00000000..351ccab2 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_object_admission.h @@ -0,0 +1,101 @@ +#pragma once +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "track_sharing/object_paths.h" +#include "track_sharing/prepare_request.h" +#include "track_sharing/reservation_store.h" +#include +namespace hostlink::management::prepare_runtime +{ +// Caller has admitted this object and holds exclusive media/object lease. +// Unknown I/O is never treated as absence; probe handles close before mkdir. +inline Status ensureObjectDirectory(uint32_t media, geocaching::ByteView object, uint8_t* scratch, size_t capacity) +{ + namespace storage = platform::esp::arduino_common::storage; + if (!scratch || capacity < track_sharing::kObjectPathCapacity) return Status::Internal; + const auto ready = [&]() { return media == storage::sd_media_session() && storage::sd_card_ready() && !storage::sd_external_block_owner_active(); }; + if (!ready()) return Status::StorageUnavailable; + auto* object_path = reinterpret_cast(scratch); + if (!track_sharing::resolveObjectPath(object, track_sharing::ObjectFile::Directory, {}, object_path, capacity)) return Status::InvalidField; + const auto probe = [&](const char* path) { storage::SdRuntimeDir directory; return directory.open_read_status(path, media); }; + for (const auto* path : {"/trailmate", track_sharing::kObjectDirectory, static_cast(object_path)}) + { + const auto status = probe(path); + if (!ready()) return Status::StorageUnavailable; + if (status == storage::SdFileReadStatus::Busy) return Status::Busy; + if (status == storage::SdFileReadStatus::Ready) continue; + if (status != storage::SdFileReadStatus::Missing || !storage::sd_mkdir(path) || !ready()) return Status::StorageUnavailable; + const auto created = probe(path); + if (created == storage::SdFileReadStatus::Busy) return Status::Busy; + if (created != storage::SdFileReadStatus::Ready || !ready()) return Status::StorageUnavailable; + } + return Status::Ok; +} +// Read-only first-version absence proof under caller's exclusive media/object +// lease. Any orphan/unknown entry blocks initialization; never deletes files. +inline Status requireEmptyObjectDirectory(uint32_t media, geocaching::ByteView object, uint8_t* scratch, size_t capacity) +{ + namespace storage = platform::esp::arduino_common::storage; + if (!scratch || capacity < 288) return Status::Internal; + if (media != storage::sd_media_session() || !storage::sd_card_ready() || storage::sd_external_block_owner_active()) return Status::StorageUnavailable; + auto* path = reinterpret_cast(scratch); + if (!track_sharing::resolveObjectPath(object, track_sharing::ObjectFile::Directory, {}, path, 160)) return Status::InvalidField; + storage::SdRuntimeDir directory; + const auto opened = directory.open_read_status(path, media); + if (opened == storage::SdFileReadStatus::Busy) return Status::Busy; + if (opened == storage::SdFileReadStatus::Ready) + { + bool is_directory = false; + uint64_t bytes = 0, next = 0; + const auto entry = directory.read_next_info(reinterpret_cast(scratch + capacity - 128), 128, &is_directory, &bytes, &next); + if (entry == storage::SdDirReadStatus::Busy) return Status::Busy; + if (entry == storage::SdDirReadStatus::Entry) return Status::Conflict; + if (entry != storage::SdDirReadStatus::End) return Status::StorageUnavailable; + } + else if (opened != storage::SdFileReadStatus::Missing) return Status::StorageUnavailable; + return media == storage::sd_media_session() && storage::sd_card_ready() && !storage::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; +} +// Recovery admission only. Caller has already matched candidate domain hashes +// and verified head absence under the same exclusive object/media lease. +template +Status requireOwnedFirstRevisionDirectory(uint32_t media, const track_sharing::PrepareRequest& request, + const uint8_t* author_hash, const track_sharing::RevisionReservation& current, + Store& store, track_sharing::RevisionReservation& verify, uint8_t* scratch, size_t capacity) +{ + namespace storage = platform::esp::arduino_common::storage; + if (!scratch || capacity < 288) return Status::Internal; + if (!author_hash || !request.operation.data || request.operation.size != 16 || request.expected_parent.size || + !track_sharing::reservationValid(current) || current.revision != 1 || current.generation != request.generation || + static_cast(current.phase) > static_cast(track_sharing::ReservationPhase::Signed) || + std::memcmp(current.author, author_hash, 32) || std::memcmp(current.operation, request.operation.data, 16)) return Status::Conflict; + if (media != storage::sd_media_session() || !storage::sd_card_ready() || storage::sd_external_block_owner_active()) return Status::StorageUnavailable; + const auto validated = store.validateCurrent(current, verify, scratch, capacity); + if (validated != track_sharing::ReservationStoreResult::Ready) return validated == track_sharing::ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + auto* path = reinterpret_cast(scratch); + if (!track_sharing::resolveObjectPath({current.object, 32}, track_sharing::ObjectFile::Directory, {}, path, 160)) return Status::InvalidField; + storage::SdRuntimeDir directory; + const auto opened = directory.open_read_status(path, media); + if (opened == storage::SdFileReadStatus::Busy) return Status::Busy; + if (opened != storage::SdFileReadStatus::Ready) return Status::StorageUnavailable; + constexpr char hex[] = "0123456789abcdef"; + for (unsigned count = 0; count < 5; ++count) + { + bool is_directory = false; + uint64_t bytes = 0, next = 0; + auto* name = reinterpret_cast(scratch + capacity - 128); + const auto entry = directory.read_next_info(name, 128, &is_directory, &bytes, &next); + if (entry == storage::SdDirReadStatus::Busy) return Status::Busy; + if (entry == storage::SdDirReadStatus::End) return media == storage::sd_media_session() && storage::sd_card_ready() && !storage::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + if (entry != storage::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_directory) return Status::Conflict; + const size_t length = std::strlen(name), key_size = length == 36 ? 16 : length == 68 ? 32 : 0; + if (!key_size) return Status::Conflict; + const auto* key = key_size == 16 ? current.operation : current.candidate; + for (size_t i = 0; i < key_size; ++i) + if (name[i * 2] != hex[key[i] >> 4] || name[i * 2 + 1] != hex[key[i] & 15]) return Status::Conflict; + const auto* suffix = name + key_size * 2; + if (key_size == 16 ? std::strcmp(suffix, ".rva") && std::strcmp(suffix, ".rvb") : std::strcmp(suffix, ".tmp") && std::strcmp(suffix, ".cmp")) return Status::Conflict; + } + return Status::Conflict; +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_cache_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_cache_admission.h new file mode 100644 index 00000000..d0276027 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_cache_admission.h @@ -0,0 +1,104 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_object_admission.h" +#include "track_sharing/package_cache_paths.h" +namespace hostlink::management::prepare_runtime +{ +inline Status ensurePackageCacheDirectory(uint32_t media, geocaching::ByteView revision, uint8_t* scratch, size_t capacity) +{ + namespace sd = platform::esp::arduino_common::storage; + if (!scratch || capacity < 288 || track_sharing::signingSpansOverlap(revision.data, revision.size, scratch, capacity) || + !track_sharing::resolvePackageCachePath(revision, track_sharing::ObjectFile::Directory, {}, reinterpret_cast(scratch), 160)) return Status::InvalidField; + for (const char* path : {"/trailmate", track_sharing::kPackageCacheDirectory, static_cast(reinterpret_cast(scratch))}) + { + if (media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + if (path == reinterpret_cast(scratch)) + { + sd::SdRuntimeDir root(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!root.has_backing_storage()) return Status::Busy; + const auto root_status = root.open_read_status(track_sharing::kPackageCacheDirectory, media); + if (root_status == sd::SdFileReadStatus::Busy) return Status::Busy; + if (root_status != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + auto* name = reinterpret_cast(scratch + capacity - 128); + unsigned count = 0; + bool matched = false, ended = false; + const auto* wanted = std::strrchr(path, '/') + 1; + for (unsigned scan = 0; scan <= 64; ++scan) + { + bool is_dir = false; + uint64_t length = 0, next = 0; + const auto row = root.read_next_info(name, 128, &is_dir, &length, &next); + if (row == sd::SdDirReadStatus::Busy) return Status::Busy; + if (row == sd::SdDirReadStatus::End) + { + ended = true; + break; + } + if (row != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (++count > 64) return Status::LimitExceeded; + if (is_dir && !std::strcmp(name, wanted)) matched = true; + } + if (!ended || (!matched && count == 64)) return Status::LimitExceeded; + } + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status(path, media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened == sd::SdFileReadStatus::Ready) continue; + if (opened != sd::SdFileReadStatus::Missing || !sd::sd_mkdir(path) || media != sd::sd_media_session()) return Status::StorageUnavailable; + } + return Status::Ok; +} +inline Status admitPackageCacheFile(uint32_t media, geocaching::ByteView revision, geocaching::ByteView hash, uint32_t bytes, + track_sharing::ObjectFile final_kind, track_sharing::ObjectFile temporary_kind, uint8_t* scratch, size_t capacity) +{ + namespace sd = platform::esp::arduino_common::storage; + using track_sharing::ObjectFile; + constexpr uint64_t limit = 64ULL * 1024 * 1024 + 65536; + constexpr unsigned file_limit = 134; + const bool resource = final_kind == ObjectFile::AttachmentResource && temporary_kind == ObjectFile::AttachmentResourceTemporary; + const bool kinds = resource || (final_kind == ObjectFile::SignedManifest && temporary_kind == ObjectFile::SignedTemporary) || + (final_kind == ObjectFile::KmlFile && temporary_kind == ObjectFile::KmlTemporary) || (final_kind == ObjectFile::AttachmentIndex && temporary_kind == ObjectFile::AttachmentIndexTemporary); + if (!kinds || !scratch || capacity < 448 || !bytes || bytes > 8388608 || !hash.data || hash.size != 32 || + track_sharing::signingSpansOverlap(scratch, capacity, revision.data, revision.size) || track_sharing::signingSpansOverlap(scratch, capacity, hash.data, hash.size)) return Status::InvalidField; + const geocaching::ByteView key = resource ? hash : geocaching::ByteView{}; + if (!track_sharing::resolvePackageCachePath(revision, final_kind, key, reinterpret_cast(scratch), 160) || + !track_sharing::resolvePackageCachePath(revision, temporary_kind, key, reinterpret_cast(scratch + 160), 160)) return Status::InvalidField; + // Caller arena supplies canonical names for comparison, no path locals. + const char* final_name = std::strrchr(reinterpret_cast(scratch), '/') + 1; + const char* temporary_name = std::strrchr(reinterpret_cast(scratch + 160), '/') + 1; + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + // Terminate the directory prefix in place without touching either name. + *const_cast(final_name - 1) = 0; + const auto opened = directory.open_read_status(reinterpret_cast(scratch), media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + uint64_t total = 0, partial = 0; + unsigned files = 0; + bool has_final = false, has_temporary = false; + auto* name = reinterpret_cast(scratch + capacity - 128); + for (unsigned scan = 0; scan <= file_limit; ++scan) + { + bool is_dir = false; + uint64_t length = 0, next = 0; + const auto row = directory.read_next_info(name, 128, &is_dir, &length, &next); + if (row == sd::SdDirReadStatus::Busy) return Status::Busy; + if (row == sd::SdDirReadStatus::End) + { + if ((!has_final && !has_temporary && files == file_limit) || (has_final ? 0 : bytes - partial) > limit - total) return Status::LimitExceeded; + return media == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + } + if (row != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_dir || ++files > file_limit || length > limit - total) return Status::LimitExceeded; + total += length; + if (!std::strcmp(name, final_name)) has_final = true; + if (!std::strcmp(name, temporary_name)) + { + if (length > bytes) return Status::Conflict; + has_temporary = true; + partial = length; + } + } + return Status::LimitExceeded; +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_input_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_input_owner.h new file mode 100644 index 00000000..13de9c67 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_input_owner.h @@ -0,0 +1,177 @@ +#pragma once +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h" +#include "track_sharing/attachment_upload_bindings.h" +#include "track_sharing/bound_sealed_upload.h" +#include "track_sharing/signed_package_file_check.h" +#include +#include +namespace hostlink::management::package_install_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// Public package input stage. Original signed bytes remain immutable. Local +// actor hash/generation authorizes uploads; remote author signature authorizes +// content. This does not publish a route, create an owned head or sign content. +class TrackPackageInputOwner +{ + public: + TrackPackageInputOwner(uint32_t media, uint32_t generation, const uint8_t* actor, + geocaching::ByteView kml_id, geocaching::ByteView signed_id, geocaching::ByteView mapping_id, + geocaching::ByteView object, geocaching::ByteView revision) + : media_(media), generation_(generation), has_mapping_(mapping_id.size == 16), + file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media_), hash_(context_), + check_(io_, hash_, crypto_, signed_upload_, kml_upload_, generation_, actor_, names_, sizeof(names_), verified_, workspace_, sizeof(workspace_)), + mapping_reader_(io_, hash_, mapping_upload_, generation_, actor_) + { + std::memcpy(actor_, actor, 32); + std::memcpy(ids_, kml_id.data, 16); + std::memcpy(ids_ + 16, signed_id.data, 16); + if (has_mapping_) std::memcpy(ids_ + 32, mapping_id.data, 16); + std::memcpy(object_, object.data, 32); + std::memcpy(revision_, revision.data, 32); + } + ~TrackPackageInputOwner() { prepare_runtime::destroyAttachmentPrepareOwner(attachments_); } + TrackPackageInputOwner(const TrackPackageInputOwner&) = delete; + TrackPackageInputOwner& operator=(const TrackPackageInputOwner&) = delete; + track_sharing::ManifestFileResult begin(uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (state_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!mediaReady()) return state_ = ManifestFileResult::StorageFailure; + auto loaded = loadBoundSealedUpload(io_, {ids_, 16}, UploadKind::Kml, generation_, actor_, kml_upload_, verify_, kml_path_, scratch, capacity); + if (loaded == JournalStoreResult::Ready) loaded = loadBoundSealedUpload(io_, {ids_ + 16, 16}, UploadKind::SignedManifest, generation_, actor_, signed_upload_, verify_, signed_path_, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return state_ = loaded == JournalStoreResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + return state_ = check_.begin(signed_path_, kml_path_, {object_, 32}, {revision_, 32}, raw_, sizeof(raw_)); + } + track_sharing::ManifestFileResult step(uint32_t generation, const uint8_t* actor, uint8_t* scratch, size_t capacity) + { + using track_sharing::ManifestFileResult; + if (!mediaReady()) + { + cancel(); + return state_ = ManifestFileResult::StorageFailure; + } + if (!actor || generation != generation_ || std::memcmp(actor, actor_, 32)) + { + cancel(); + return state_ = ManifestFileResult::Invalid; + } + return state_ == ManifestFileResult::Pending ? state_ = check_.step(*this, scratch, capacity) : state_; + } + track_sharing::ManifestFileResult checkAttachments(const track_sharing::ManifestView& candidate, uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (candidate.version == 1) return has_mapping_ ? ManifestFileResult::Invalid : ManifestFileResult::Ready; + if (!getManifestExtensions(candidate, extensions_)) return ManifestFileResult::Invalid; + if (!extensions_.assets.count) return has_mapping_ ? ManifestFileResult::Invalid : ManifestFileResult::Ready; + if (!has_mapping_) return ManifestFileResult::Invalid; + if (!mapping_started_) + { + const auto loaded = loadBoundSealedUpload(io_, {ids_ + 32, 16}, UploadKind::AttachmentBindings, generation_, actor_, mapping_upload_, verify_, mapping_path_, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return loaded == JournalStoreResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + if (mapping_reader_.begin(mapping_path_, mapping_raw_, sizeof(mapping_raw_)) != FileIntegrityResult::Pending) return ManifestFileResult::Invalid; + mapping_started_ = true; + return ManifestFileResult::Pending; + } + if (!attachments_) + { + const auto read = mapping_reader_.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return ManifestFileResult::Pending; + if (read != FileIntegrityResult::Ready) return read == FileIntegrityResult::HashMismatch ? ManifestFileResult::HashMismatch : ManifestFileResult::StorageFailure; + if (!decodeAttachmentUploadBindings(mapping_reader_.bytes(), extensions_.assets.count, mapping_)) return ManifestFileResult::Invalid; + attachments_ = prepare_runtime::createAttachmentPrepareOwner(media_, generation_, actor_, mapping_.index_id.data, mapping_.ids, extensions_.assets, candidate.file_hash, candidate.file_bytes); + if (!attachments_) return ManifestFileResult::Pending; + const auto begun = attachments_->begin(); + if (begun != AttachmentPackageResult::Pending) return begun == AttachmentPackageResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + } + const auto checked = attachments_->step(generation_, actor_); + return checked == AttachmentPackageResult::Pending ? ManifestFileResult::Pending : checked == AttachmentPackageResult::Ready ? ManifestFileResult::Ready + : checked == AttachmentPackageResult::HashMismatch ? ManifestFileResult::HashMismatch + : checked == AttachmentPackageResult::StorageFailure ? ManifestFileResult::StorageFailure + : ManifestFileResult::Invalid; + } + bool matchesAttachments(const track_sharing::ManifestView& candidate) const + { + if (candidate.version == 1) return !has_mapping_ && !attachments_; + if (!track_sharing::getManifestExtensions(candidate, extensions_)) return false; + return extensions_.assets.count ? attachments_ && attachments_->matches(extensions_.assets, candidate.file_hash, candidate.file_bytes) : !has_mapping_ && !attachments_; + } + void cancel() + { + check_.cancel(); + mapping_reader_.cancel(); + if (attachments_) attachments_->cancel(); + state_ = track_sharing::ManifestFileResult::Cancelled; + } + bool ready() const { return state_ == track_sharing::ManifestFileResult::Ready && mediaReady() && check_.ready() && + (verified_.manifest.version == 1 ? !has_mapping_ : matchesAttachments(verified_.manifest)); } + bool hasBacking() const { return file_.has_backing_storage(); } + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + const track_sharing::VerifiedManifestView* verified() const { return ready() ? &verified_ : nullptr; } + geocaching::ByteView signedBytes() const { return ready() ? check_.signedBytes() : geocaching::ByteView{}; } + prepare_runtime::TrackAttachmentPrepareOwner* attachments() const { return ready() ? attachments_ : nullptr; } + const char* kmlPath() const { return ready() ? kml_path_ : nullptr; } + const char* signedPath() const { return ready() ? signed_path_ : nullptr; } + const track_sharing::UploadJournal& signedUpload() const { return signed_upload_; } + sd::SdBorrowedRecordFileIo& io() { return io_; } + platform::esp::storage::SnapshotStreamSha256& hash() { return hash_; } + + private: + uint32_t media_, generation_; + bool has_mapping_; + uint8_t actor_[32]{}, ids_[48]{}, object_[32]{}, revision_[32]{}; + char kml_path_[64]{}, signed_path_[64]{}, mapping_path_[64]{}, names_[2048]{}; + uint8_t raw_[4166]{}, workspace_[4200]{}, mapping_raw_[1100]{}; + track_sharing::UploadJournal kml_upload_, signed_upload_, mapping_upload_, verify_; + track_sharing::VerifiedManifestView verified_; + mutable track_sharing::ManifestExtensionsView extensions_; + track_sharing::AttachmentUploadBindingsView mapping_; + mbedtls_sha256_context context_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + platform::esp::storage::SnapshotStreamSha256 hash_; + platform::esp::storage::TrackManifestCrypto crypto_; + track_sharing::SignedPackageFileCheck check_; + track_sharing::UploadedManifestReader mapping_reader_; + prepare_runtime::TrackAttachmentPrepareOwner* attachments_ = nullptr; + track_sharing::ManifestFileResult state_ = track_sharing::ManifestFileResult::Idle; + bool mapping_started_ = false; +}; +static_assert(sizeof(TrackPackageInputOwner) <= 18432, "Shared package input must remain bounded transient PSRAM"); +inline TrackPackageInputOwner* createPackageInputOwner(uint32_t media, uint32_t generation, const uint8_t* actor, + geocaching::ByteView kml_id, geocaching::ByteView signed_id, geocaching::ByteView mapping_id, + geocaching::ByteView object, geocaching::ByteView revision) +{ + if (!actor || !generation || !kml_id.data || kml_id.size != 16 || !signed_id.data || signed_id.size != 16 || + (mapping_id.size && (!mapping_id.data || mapping_id.size != 16)) || !object.data || object.size != 32 || !revision.data || revision.size != 32 || + media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + for (const auto id : {kml_id, signed_id, object, revision}) + { + uint8_t bits = 0; + for (size_t i = 0; i < id.size; ++i) bits |= id.data[i]; + if (!bits) return nullptr; + } + if (mapping_id.size) + { + uint8_t bits = 0; + for (size_t i = 0; i < 16; ++i) bits |= mapping_id.data[i]; + if (!bits) return nullptr; + } + void* memory = heap_caps_malloc(sizeof(TrackPackageInputOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* owner = new (memory) TrackPackageInputOwner(media, generation, actor, kml_id, signed_id, mapping_id, object, revision); + if (owner->hasBacking()) return owner; + owner->~TrackPackageInputOwner(); + heap_caps_free(memory); + return nullptr; +} +inline void destroyPackageInputOwner(TrackPackageInputOwner* owner) +{ + if (owner) + { + owner->cancel(); + owner->~TrackPackageInputOwner(); + heap_caps_free(owner); + } +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_install_work.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_install_work.h new file mode 100644 index 00000000..4846551c --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_install_work.h @@ -0,0 +1,160 @@ +#pragma once +#include "hostlink/route_storage_lease.h" +#include "platform/esp/idf_common/hostlink_prepare_scheduler_work.h" +#include "platform/esp/idf_common/hostlink_track_package_route_publication.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include +namespace hostlink::management::package_install_runtime +{ +using PackageInstallPolicy = Status (*)(const char*, uint8_t*, size_t); +class PackageInstallWork final : public prepare_runtime::PrepareSchedulerWork +{ + public: + PackageInstallWork(const track_sharing::PrepareRequest& request, const uint8_t* session, const uint8_t* actor, uint32_t media, uint64_t now, PackageInstallPolicy policy) + : media_(media), generation_(request.generation), last_(now), policy_(policy), has_mapping_(request.package_upload.size == 16) + { + std::memcpy(session_, session, 16); + std::memcpy(actor_, actor, 32); + std::memcpy(ids_, request.kml_upload.data, 16); + std::memcpy(ids_ + 16, request.manifest_upload.data, 16); + if (has_mapping_) std::memcpy(ids_ + 32, request.package_upload.data, 16); + std::memcpy(result_.operation, request.operation.data, 16); + std::memcpy(result_.object, request.expected_object.data, 32); + std::memcpy(result_.hash, request.expected_revision.data, 32); + } + ~PackageInstallWork() override { releaseStages(); } + bool alive(uint32_t media, uint64_t now, bool connected) const override { return connected && media == media_ && now >= last_ && now - last_ < 30000; } + bool matchesSession(const uint8_t* session) const override { return session && !std::memcmp(session, session_, 16); } + bool holdsStages() const override { return lease_; } + void suspendIdentity() override { identity_ = false; } + geocaching::ByteView operationId() const override { return {result_.operation, 16}; } + bool renewMatched(uint64_t now) override + { + if (!identity_ || !alive(sd::sd_media_session(), now, true)) return false; + last_ = now; + return true; + } + bool matches(const uint8_t* session, const track_sharing::PrepareRequest& request) const override + { + return request.install_request && request.package_request && matchesSession(session) && request.generation == generation_ && !request.expected_parent.size && + request.operation.data && request.operation.size == 16 && !std::memcmp(request.operation.data, result_.operation, 16) && + request.kml_upload.data && request.kml_upload.size == 16 && !std::memcmp(request.kml_upload.data, ids_, 16) && + request.manifest_upload.data && request.manifest_upload.size == 16 && !std::memcmp(request.manifest_upload.data, ids_ + 16, 16) && + (has_mapping_ ? request.package_upload.data && request.package_upload.size == 16 && !std::memcmp(request.package_upload.data, ids_ + 32, 16) : !request.package_upload.size) && + request.expected_object.data && request.expected_object.size == 32 && !std::memcmp(request.expected_object.data, result_.object, 32) && + request.expected_revision.data && request.expected_revision.size == 32 && !std::memcmp(request.expected_revision.data, result_.hash, 32); + } + Status advance(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) override + { + identity_ = false; + if (!scratch || capacity < 448) return fail(Status::Internal); + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return fail(Status::StorageUnavailable); + uint8_t actor[32]; + uint32_t generation = 0; + const auto current = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, actor, generation); + if (current == Status::Busy) return current; + if (current != Status::Ok) return fail(current); + if (generation != generation_ || std::memcmp(actor, actor_, 32)) return fail(Status::StaleIdentity); + identity_ = true; + if (terminal_) return status_; + auto* target = reinterpret_cast(scratch + capacity - 80); + if (!track_sharing::routePackageTarget({result_.hash, 32}, track_sharing::PackageRouteFile::Kml, target, 80)) return fail(Status::InvalidField); + const auto allowed = policy_(target, scratch, capacity); + if (allowed != Status::Ok) return allowed == Status::Busy ? allowed : fail(allowed); + if (!input_) + { + input_ = createPackageInputOwner(media_, generation_, actor_, {ids_, 16}, {ids_ + 16, 16}, has_mapping_ ? geocaching::ByteView{ids_ + 32, 16} : geocaching::ByteView{}, {result_.object, 32}, {result_.hash, 32}); + if (!input_) return Status::Busy; + const auto begun = input_->begin(scratch, capacity); + if (begun != track_sharing::ManifestFileResult::Pending) return fail(map(begun)); + } + if (!input_->ready()) + { + const auto checked = input_->step(generation_, actor_, scratch, capacity); + if (checked == track_sharing::ManifestFileResult::Pending) return Status::Busy; + if (checked != track_sharing::ManifestFileResult::Ready) return fail(map(checked)); + result_.revision = input_->verified()->manifest.revision; + } + if (!cache_) + { + cache_ = createPublicPackageRetention(*input_, media_); + if (!cache_) return Status::Busy; + } + if (!cache_->ready()) + { + const auto cached = cache_->step(generation_, actor_, scratch, capacity); + if (cached == Status::Busy) return cached; + if (cached != Status::Ok) return fail(cached); + } + if (!publication_) + { + publication_ = createPackageRoutePublication(*input_, *cache_, media_); + if (!publication_) return Status::Busy; + } + const auto published = publication_->step(generation_, actor_, scratch, capacity, [&](const char* path) + { return policy_(path, scratch, capacity); }); + if (published == Status::Busy) return published; + if (published != Status::Ok) return fail(published); + terminal_ = true; + status_ = Status::Ok; + releaseStages(); + return status_; + } + const prepare_runtime::TrackPreparedResult* result() const override { return ready() ? &result_ : nullptr; } + geocaching::ByteView preparedSignature() const override { return {}; } + bool ready() const override { return identity_ && terminal_ && status_ == Status::Ok && media_ == sd::sd_media_session() && sd::sd_card_ready(); } + Status status() const override { return terminal_ ? status_ : Status::Busy; } + bool packageInstallation() const override { return true; } + + private: + static Status map(track_sharing::ManifestFileResult value) { return value == track_sharing::ManifestFileResult::StorageFailure ? Status::StorageUnavailable : value == track_sharing::ManifestFileResult::HashMismatch ? Status::HashMismatch + : Status::InvalidField; } + Status fail(Status value) + { + terminal_ = true; + status_ = value; + releaseStages(); + return value; + } + void releaseStages() + { + destroyPackageRoutePublication(publication_); + publication_ = nullptr; + destroyPublicPackageRetention(cache_); + cache_ = nullptr; + destroyPackageInputOwner(input_); + input_ = nullptr; + if (lease_) + { + hostlink::route_storage_lease::release(); + lease_ = false; + } + } + uint8_t session_[16]{}, actor_[32]{}, ids_[48]{}; + prepare_runtime::TrackPreparedResult result_; + uint32_t media_, generation_; + uint64_t last_; + PackageInstallPolicy policy_; + TrackPackageInputOwner* input_ = nullptr; + TrackPublicPackageRetention* cache_ = nullptr; + TrackPackageRoutePublication* publication_ = nullptr; + Status status_ = Status::Busy; + bool has_mapping_, identity_ = false, terminal_ = false, lease_ = true; +}; +static_assert(sizeof(PackageInstallWork) <= 288, "Installed result metadata must stay small and transient"); +inline PackageInstallWork* createPackageInstallWork(const track_sharing::PrepareRequest& request, const uint8_t* session, const uint8_t* actor, uint32_t media, uint64_t now, PackageInstallPolicy policy) +{ + if (!policy || !session || !actor || request.query_request || !request.install_request || !request.package_request || request.expected_parent.size || !request.generation || + !request.operation.data || request.operation.size != 16 || !request.kml_upload.data || request.kml_upload.size != 16 || !request.manifest_upload.data || request.manifest_upload.size != 16 || + (request.package_upload.size && (!request.package_upload.data || request.package_upload.size != 16)) || !request.expected_object.data || request.expected_object.size != 32 || !request.expected_revision.data || request.expected_revision.size != 32 || + media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + if (!hostlink::route_storage_lease::acquire()) return nullptr; + void* memory = heap_caps_malloc(sizeof(PackageInstallWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) + { + hostlink::route_storage_lease::release(); + return nullptr; + } + return new (memory) PackageInstallWork(request, session, actor, media, now, policy); +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_query_work.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_query_work.h new file mode 100644 index 00000000..bc717547 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_query_work.h @@ -0,0 +1,114 @@ +#pragma once +#include "hostlink/route_storage_lease.h" +#include "platform/esp/idf_common/hostlink_prepare_scheduler_work.h" +#include "platform/esp/idf_common/hostlink_track_stored_package_input.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +namespace hostlink::management::package_install_runtime +{ +class PackageQueryWork final : public prepare_runtime::PrepareSchedulerWork +{ + public: + PackageQueryWork(const track_sharing::PrepareRequest& request, const uint8_t* session, const uint8_t* actor, uint32_t media, uint64_t now) + : media_(media), generation_(request.generation), last_(now) + { + std::memcpy(session_, session, 16); + std::memcpy(actor_, actor, 32); + std::memcpy(result_.operation, request.operation.data, 16); + std::memcpy(result_.object, request.expected_object.data, 32); + std::memcpy(result_.hash, request.expected_revision.data, 32); + } + ~PackageQueryWork() override { release(); } + bool alive(uint32_t media, uint64_t now, bool connected) const override { return connected && media == media_ && now >= last_ && now - last_ < 30000; } + bool matchesSession(const uint8_t* session) const override { return session && !std::memcmp(session, session_, 16); } + bool holdsStages() const override { return lease_; } + void suspendIdentity() override { identity_ = false; } + geocaching::ByteView operationId() const override { return {result_.operation, 16}; } + bool renewMatched(uint64_t now) override + { + if (!identity_ || !alive(sd::sd_media_session(), now, true)) return false; + last_ = now; + return true; + } + bool matches(const uint8_t* session, const track_sharing::PrepareRequest& request) const override + { + return request.query_request && matchesSession(session) && request.generation == generation_ && request.operation.data && request.operation.size == 16 && !std::memcmp(request.operation.data, result_.operation, 16) && + request.expected_object.data && request.expected_object.size == 32 && !std::memcmp(request.expected_object.data, result_.object, 32) && request.expected_revision.data && request.expected_revision.size == 32 && !std::memcmp(request.expected_revision.data, result_.hash, 32); + } + Status advance(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) override + { + identity_ = false; + if (!scratch || capacity < 448) return fail(Status::Internal); + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return fail(Status::StorageUnavailable); + uint8_t actor[32]; + uint32_t generation = 0; + const auto current = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, actor, generation); + if (current == Status::Busy) return current; + if (current != Status::Ok) return fail(current); + if (generation != generation_ || std::memcmp(actor, actor_, 32)) return fail(Status::StaleIdentity); + identity_ = true; + if (terminal_) return status_; + track_sharing::ManifestFileResult checked; + if (!input_) + { + input_ = createStoredPackageInputOwner(media_, {result_.object, 32}, {result_.hash, 32}); + if (!input_) return Status::Busy; + checked = input_->begin(scratch, capacity); + } + else checked = input_->step(scratch, capacity); + if (checked == track_sharing::ManifestFileResult::Pending) return Status::Busy; + if (checked != track_sharing::ManifestFileResult::Ready) return fail(checked == track_sharing::ManifestFileResult::StorageFailure ? Status::StorageUnavailable : checked == track_sharing::ManifestFileResult::HashMismatch ? Status::HashMismatch + : Status::InvalidField); + if (!input_->ready() && !input_->missing()) return fail(Status::InvalidField); + missing_ = input_->missing(); + terminal_ = true; + status_ = Status::Ok; + release(); + return status_; + } + const prepare_runtime::TrackPreparedResult* result() const override { return ready() ? &result_ : nullptr; } + geocaching::ByteView preparedSignature() const override { return {}; } + bool ready() const override { return identity_ && terminal_ && status_ == Status::Ok && media_ == sd::sd_media_session() && sd::sd_card_ready(); } + Status status() const override { return terminal_ ? status_ : Status::Busy; } + bool packageInstallation() const override { return true; } + uint8_t installationState() const override { return ready() ? (missing_ ? 2 : 1) : 0; } + + private: + Status fail(Status result) + { + terminal_ = true; + status_ = result; + release(); + return result; + } + void release() + { + destroyStoredPackageInputOwner(input_); + input_ = nullptr; + if (lease_) + { + hostlink::route_storage_lease::release(); + lease_ = false; + } + } + uint8_t session_[16]{}, actor_[32]{}; + prepare_runtime::TrackPreparedResult result_; + uint32_t media_, generation_; + uint64_t last_; + StoredPackageInputOwner* input_ = nullptr; + Status status_ = Status::Busy; + bool identity_ = false, terminal_ = false, missing_ = false, lease_ = true; +}; +static_assert(sizeof(PackageQueryWork) <= 224, "Fresh query result must not retain parser buffers"); +inline PackageQueryWork* createPackageQueryWork(const track_sharing::PrepareRequest& request, const uint8_t* session, const uint8_t* actor, uint32_t media, uint64_t now) +{ + if (!session || !actor || !request.query_request || !request.generation || !request.operation.data || request.operation.size != 16 || !request.expected_object.data || request.expected_object.size != 32 || !request.expected_revision.data || request.expected_revision.size != 32 || media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + if (!hostlink::route_storage_lease::acquire()) return nullptr; + void* memory = heap_caps_malloc(sizeof(PackageQueryWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) + { + hostlink::route_storage_lease::release(); + return nullptr; + } + return new (memory) PackageQueryWork(request, session, actor, media, now); +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_retention_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_retention_admission.h new file mode 100644 index 00000000..2077f02a --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_retention_admission.h @@ -0,0 +1,70 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_object_admission.h" +namespace hostlink::management::prepare_runtime +{ +// v2 object history budget: 128MiB + bounded metadata room, <=512 files. +// Counts unknown/orphan files. Source leases remain held through copying. +inline Status admitRetainedPackageFile(uint32_t media, geocaching::ByteView object, geocaching::ByteView hash, uint32_t bytes, + track_sharing::ObjectFile final_kind, track_sharing::ObjectFile temporary_kind, uint8_t* scratch, size_t capacity) +{ + using track_sharing::ObjectFile; + const bool kinds = (final_kind == ObjectFile::KmlFile && temporary_kind == ObjectFile::KmlTemporary) || + (final_kind == ObjectFile::AttachmentIndex && temporary_kind == ObjectFile::AttachmentIndexTemporary) || + (final_kind == ObjectFile::AttachmentResource && temporary_kind == ObjectFile::AttachmentResourceTemporary); + constexpr uint64_t limit = 128ULL * 1024 * 1024 + 1024 * 1024; + if (!kinds || !bytes || bytes > 8388608 || !scratch || capacity < 448 || !hash.data || hash.size != 32 || + track_sharing::signingSpansOverlap(scratch, capacity, hash.data, hash.size) || + track_sharing::signingSpansOverlap(scratch, capacity, object.data, object.size)) return Status::InvalidField; + // Two paths are borrowed only until directory enumeration starts. + if (!track_sharing::resolveObjectPath(object, final_kind, hash, reinterpret_cast(scratch), 160) || + !track_sharing::resolveObjectPath(object, temporary_kind, hash, reinterpret_cast(scratch + 160), 160)) return Status::InvalidField; + // Only suffixes are needed during the scan; no path arrays on the task stack. + const auto* suffix = reinterpret_cast(scratch + 160 + std::strlen(reinterpret_cast(scratch + 160)) - 4); + uint32_t temporary_suffix = 0; + std::memcpy(&temporary_suffix, suffix, 4); + suffix = reinterpret_cast(scratch + std::strlen(reinterpret_cast(scratch)) - 4); + uint32_t final_suffix = 0; + std::memcpy(&final_suffix, suffix, 4); + if (!track_sharing::resolveObjectPath(object, ObjectFile::Directory, {}, reinterpret_cast(scratch), 160)) return Status::InvalidField; + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status(reinterpret_cast(scratch), media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + uint64_t total = 0, partial = 0; + unsigned files = 0; + bool has_final = false, has_temporary = false; + constexpr char hex[] = "0123456789abcdef"; + auto* name = reinterpret_cast(scratch + capacity - 128); + for (unsigned scan = 0; scan <= 512; ++scan) + { + bool is_directory = false; + uint64_t length = 0, next = 0; + const auto row = directory.read_next_info(name, 128, &is_directory, &length, &next); + if (row == sd::SdDirReadStatus::Busy) return Status::Busy; + if (row == sd::SdDirReadStatus::End) + { + const uint64_t additional = has_final ? 0 : bytes - partial; + if ((!has_final && !has_temporary && files == 512) || additional > limit - total) return Status::LimitExceeded; + return media == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + } + if (row != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_directory || ++files > 512 || length > limit - total) return Status::LimitExceeded; + total += length; + if (std::strlen(name) != 68) continue; + bool matches = true; + for (size_t i = 0; i < 32; ++i) matches = matches && name[2 * i] == hex[hash.data[i] >> 4] && name[2 * i + 1] == hex[hash.data[i] & 15]; + if (!matches) continue; + uint32_t actual_suffix = 0; + std::memcpy(&actual_suffix, name + 64, 4); + if (actual_suffix == final_suffix) has_final = true; + if (actual_suffix == temporary_suffix) + { + if (length > bytes) return Status::Conflict; + has_temporary = true; + partial = length; + } + } + return Status::LimitExceeded; +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_route_publication.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_route_publication.h new file mode 100644 index 00000000..acd9e2fb --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_package_route_publication.h @@ -0,0 +1,141 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_public_package_retention.h" +#include "track_sharing/package_route_publication.h" +namespace hostlink::management::package_install_runtime +{ +inline Status admitPackageRouteStorage(uint32_t media, geocaching::ByteView revision, uint32_t bytes, uint8_t* scratch, size_t capacity) +{ + constexpr uint64_t limit = 32ULL * 1024 * 1024 + 65536; + constexpr unsigned file_limit = 256; + if (!scratch || capacity < 448 || !bytes || bytes > 8388608 || track_sharing::signingSpansOverlap(revision.data, revision.size, scratch, capacity)) return Status::InvalidField; + if (!track_sharing::routePackageTarget(revision, track_sharing::PackageRouteFile::Kml, reinterpret_cast(scratch), 80) || + !track_sharing::routePackageTarget(revision, track_sharing::PackageRouteFile::Temporary, reinterpret_cast(scratch + 80), 80) || + !track_sharing::routePackageTarget(revision, track_sharing::PackageRouteFile::Binding, reinterpret_cast(scratch + 160), 80) || + !track_sharing::routePackageTarget(revision, track_sharing::PackageRouteFile::BindingTemporary, reinterpret_cast(scratch + 240), 80)) return Status::InvalidField; + const char* final_name = std::strrchr(reinterpret_cast(scratch), '/') + 1; + const char* temporary_name = std::strrchr(reinterpret_cast(scratch + 80), '/') + 1; + const char* record_name = std::strrchr(reinterpret_cast(scratch + 160), '/') + 1; + const char* record_temporary_name = std::strrchr(reinterpret_cast(scratch + 240), '/') + 1; + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status("/routes", media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + uint64_t total = 0, partial = 0, record_partial = 0; + unsigned files = 0; + bool has_final = false, has_temporary = false; + bool has_record = false, has_record_temporary = false; + auto* name = reinterpret_cast(scratch + capacity - 128); + for (unsigned scan = 0; scan <= file_limit; ++scan) + { + bool is_dir = false; + uint64_t length = 0, next = 0; + const auto row = directory.read_next_info(name, 128, &is_dir, &length, &next); + if (row == sd::SdDirReadStatus::Busy) return Status::Busy; + if (row == sd::SdDirReadStatus::End) + { + const uint64_t additional = (has_final ? 0 : bytes - partial) + (has_record ? 0 : track_sharing::kInstalledPackageBindingBytes - record_partial); + const unsigned needed = unsigned(!has_final && !has_temporary) + unsigned(!has_record && !has_record_temporary); + if (files > file_limit - needed || additional > limit - total) return Status::LimitExceeded; + return media == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + } + if (row != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_dir || ++files > file_limit || length > limit - total) return Status::LimitExceeded; + total += length; + if (!std::strcmp(name, final_name)) has_final = true; + if (!std::strcmp(name, record_name)) has_record = true; + if (!std::strcmp(name, record_temporary_name)) + { + if (length > track_sharing::kInstalledPackageBindingBytes) return Status::Conflict; + has_record_temporary = true; + record_partial = length; + } + if (!std::strcmp(name, temporary_name)) + { + if (length > bytes) return Status::Conflict; + has_temporary = true; + partial = length; + } + } + return Status::LimitExceeded; +} +// Final visibility stage, allocated only after the public cache is Ready. +// Caller retains scheduler/storage/active-route exclusion through destruction. +class TrackPackageRoutePublication +{ + public: + TrackPackageRoutePublication(TrackPackageInputOwner& input, TrackPublicPackageRetention& cache, uint32_t media) + : input_(input), cache_(cache), media_(media), publish_(input.io(), input.hash(), binding_, source_) + { + const auto* verified = input.verified(); + if (!verified) return; + std::memcpy(binding_.object, verified->object_id, 32); + std::memcpy(binding_.revision, verified->revision_hash, 32); + std::memcpy(binding_.kml_hash, verified->manifest.file_hash.data, 32); + binding_.kml_bytes = verified->manifest.file_bytes; + if (verified->manifest.version == 2 && track_sharing::getManifestExtensions(verified->manifest, extensions_) && extensions_.assets.count) + { + std::memcpy(binding_.index_hash, extensions_.assets.hash.data, 32); + binding_.index_bytes = extensions_.assets.bytes; + binding_.count = extensions_.assets.count; + if (input.attachments()) binding_.availability = input.attachments()->availability(); + } + initialized_ = track_sharing::resolvePackageCachePath({binding_.revision, 32}, track_sharing::ObjectFile::KmlFile, {}, source_, sizeof(source_)) && + publish_.begin() == track_sharing::PackageRouteResult::Pending; + } + template + Status step(uint32_t generation, const uint8_t* actor, uint8_t* scratch, size_t capacity, Policy&& policy) + { + if (!initialized_ || !scratch || capacity < 448) return Status::InvalidField; + const auto current = input_.step(generation, actor, scratch, capacity); + if (current != track_sharing::ManifestFileResult::Ready || !cache_.ready() || media_ != sd::sd_media_session()) + { + publish_.cancel(); + return current == track_sharing::ManifestFileResult::StorageFailure || media_ != sd::sd_media_session() ? Status::StorageUnavailable : Status::StaleIdentity; + } + const auto allowed = policy(publish_.target()); + if (allowed != Status::Ok) return allowed; + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status("/routes", media_); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready && (opened != sd::SdFileReadStatus::Missing || !sd::sd_mkdir("/routes") || media_ != sd::sd_media_session())) return Status::StorageUnavailable; + const auto admitted = admitPackageRouteStorage(media_, {binding_.revision, 32}, binding_.kml_bytes, scratch, capacity); + if (admitted != Status::Ok) return admitted; + const auto result = publish_.step(scratch, capacity, [&] + { return input_.ready() && cache_.ready() && media_ == sd::sd_media_session() && policy(publish_.target()) == Status::Ok; }); + return result == track_sharing::PackageRouteResult::Pending ? Status::Busy : result == track_sharing::PackageRouteResult::Ready ? Status::Ok + : result == track_sharing::PackageRouteResult::HashMismatch ? Status::HashMismatch + : result == track_sharing::PackageRouteResult::StorageFailure ? Status::StorageUnavailable + : result == track_sharing::PackageRouteResult::Cancelled ? Status::Expired + : Status::Conflict; + } + bool ready() const { return publish_.ready() && input_.ready() && cache_.ready() && media_ == sd::sd_media_session(); } + const char* target() const { return publish_.target(); } + + private: + TrackPackageInputOwner& input_; + TrackPublicPackageRetention& cache_; + uint32_t media_; + track_sharing::InstalledPackageBinding binding_; + track_sharing::ManifestExtensionsView extensions_; + char source_[160]{}; + track_sharing::PackageRoutePublication publish_; + bool initialized_ = false; +}; +static_assert(sizeof(TrackPackageRoutePublication) <= 1280, "Package publication must not own payload/parser/hash workspaces"); +inline TrackPackageRoutePublication* createPackageRoutePublication(TrackPackageInputOwner& input, TrackPublicPackageRetention& cache, uint32_t media) +{ + if (!input.ready() || !cache.ready() || media != sd::sd_media_session()) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPackageRoutePublication), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return memory ? new (memory) TrackPackageRoutePublication(input, cache, media) : nullptr; +} +inline void destroyPackageRoutePublication(TrackPackageRoutePublication* owner) +{ + if (owner) + { + owner->~TrackPackageRoutePublication(); + heap_caps_free(owner); + } +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_owner.h new file mode 100644 index 00000000..25d208c9 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_owner.h @@ -0,0 +1,177 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h" +#include "track_sharing/attachment_upload_bindings.h" +#include "track_sharing/bound_sealed_upload.h" +#include "track_sharing/prepare_upload_inputs.h" +#include "track_sharing/uploaded_candidate_check.h" +#include +#include +namespace hostlink::management::prepare_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// Input-validation stage only. Scheduler admits one PSRAM owner under an +// immutable upload/media lease; Ready is not signature/publication completion. +class TrackPrepareOwner +{ + public: + TrackPrepareOwner(const track_sharing::PrepareRequest& source, uint32_t media_id, const uint8_t* public_key, const uint8_t* identity_hash) + : media(media_id), generation(source.generation), file(sd::SdRuntimeMemoryPolicy::PsramOnly), io(file, media), hash(context), + check(io, hash, manifest_upload, kml_upload, generation, author_hash, names, sizeof(names), view), + binding_reader(io, hash, binding_upload, generation, author_hash) + { + std::memcpy(bindings, source.operation.data, 16); + std::memcpy(bindings + 16, source.kml_upload.data, 16); + std::memcpy(bindings + 32, source.manifest_upload.data, 16); + request.operation = {bindings, 16}; + request.kml_upload = {bindings + 16, 16}; + request.manifest_upload = {bindings + 32, 16}; + request.generation = generation; + request.package_request = source.package_request; + if (source.package_upload.size) + { + std::memcpy(bindings + 80, source.package_upload.data, 16); + request.package_upload = {bindings + 80, 16}; + } + if (source.expected_parent.size) + { + std::memcpy(bindings + 48, source.expected_parent.data, 32); + request.expected_parent = {bindings + 48, 32}; + } + std::memcpy(author, public_key, 64); + std::memcpy(author_hash, identity_hash, 32); + } + TrackPrepareOwner(const TrackPrepareOwner&) = delete; + TrackPrepareOwner& operator=(const TrackPrepareOwner&) = delete; + ~TrackPrepareOwner() { destroyAttachmentPrepareOwner(attachments); } + track_sharing::ManifestFileResult start(uint8_t* scratch, size_t capacity) + { + if (state != track_sharing::ManifestFileResult::Idle) return track_sharing::ManifestFileResult::Busy; + if (!mediaReady()) return state = track_sharing::ManifestFileResult::StorageFailure; + const auto loaded = track_sharing::loadPrepareUploadInputs(io, request, generation, author_hash, kml_upload, manifest_upload, verify, kml_path, manifest_path, scratch, capacity); + if (loaded != track_sharing::JournalStoreResult::Ready) return state = loaded == track_sharing::JournalStoreResult::StorageFailure ? track_sharing::ManifestFileResult::StorageFailure : track_sharing::ManifestFileResult::Invalid; + const auto result = check.begin(request, manifest_path, kml_path, raw, sizeof(raw), {author, 64}); + return state = result; + } + track_sharing::ManifestFileResult step(uint32_t current_generation, const uint8_t* current_hash, uint8_t* scratch, size_t capacity) + { + if (!mediaReady()) + { + cancelJobs(); + return state = track_sharing::ManifestFileResult::StorageFailure; + } + if (!current_hash || current_generation != generation || std::memcmp(current_hash, author_hash, 32)) + { + cancelJobs(); + return state = track_sharing::ManifestFileResult::Invalid; + } + if (state != track_sharing::ManifestFileResult::Pending) return state; + state = check.step(scratch, capacity, *this); + if (state == track_sharing::ManifestFileResult::Ready && request.package_request && view.version != 2) + state = track_sharing::ManifestFileResult::Invalid; + return state; + } + track_sharing::ManifestFileResult checkAttachments(const track_sharing::ManifestView& candidate, uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (!request.package_request || !getManifestExtensions(candidate, extensions)) return ManifestFileResult::Invalid; + if (!extensions.assets.count) return request.package_upload.size ? ManifestFileResult::Invalid : ManifestFileResult::Ready; + if (!request.package_upload.data || request.package_upload.size != 16) return ManifestFileResult::Invalid; + if (!binding_started) + { + const auto loaded = loadBoundSealedUpload(io, request.package_upload, UploadKind::AttachmentBindings, generation, author_hash, + binding_upload, binding_verify, binding_path, scratch, capacity); + if (loaded != JournalStoreResult::Ready) return loaded == JournalStoreResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + if (binding_reader.begin(binding_path, binding_raw, sizeof(binding_raw)) != FileIntegrityResult::Pending) return ManifestFileResult::Invalid; + binding_started = true; + return ManifestFileResult::Pending; + } + if (!attachments) + { + const auto read = binding_reader.step(scratch, capacity); + if (read == FileIntegrityResult::Pending) return ManifestFileResult::Pending; + if (read != FileIntegrityResult::Ready) return read == FileIntegrityResult::HashMismatch ? ManifestFileResult::HashMismatch : ManifestFileResult::StorageFailure; + if (!decodeAttachmentUploadBindings(binding_reader.bytes(), extensions.assets.count, attachment_bindings)) return ManifestFileResult::Invalid; + attachments = createAttachmentPrepareOwner(media, generation, author_hash, attachment_bindings.index_id.data, attachment_bindings.ids, + extensions.assets, candidate.file_hash, candidate.file_bytes); + if (!attachments) return ManifestFileResult::Pending; + const auto begun = attachments->begin(); + if (begun != AttachmentPackageResult::Pending) return begun == AttachmentPackageResult::StorageFailure ? ManifestFileResult::StorageFailure : ManifestFileResult::Invalid; + } + const auto result = attachments->step(generation, author_hash); + return result == AttachmentPackageResult::Pending ? ManifestFileResult::Pending : result == AttachmentPackageResult::Ready ? ManifestFileResult::Ready + : result == AttachmentPackageResult::HashMismatch ? ManifestFileResult::HashMismatch + : result == AttachmentPackageResult::StorageFailure ? ManifestFileResult::StorageFailure + : ManifestFileResult::Invalid; + } + bool matchesAttachments(const track_sharing::ManifestView& candidate) const + { + if (!request.package_request || candidate.version != 2 || !track_sharing::getManifestExtensions(candidate, extensions)) return false; + return extensions.assets.count ? attachments && attachments->matches(extensions.assets, candidate.file_hash, candidate.file_bytes) : !request.package_upload.size && !attachments; + } + void cancel() + { + cancelJobs(); + state = track_sharing::ManifestFileResult::Cancelled; + } + bool ready() const { return state == track_sharing::ManifestFileResult::Ready && mediaReady() && check.ready(); } + bool matchesCandidate(const track_sharing::ManifestView& candidate) const { return ready() && check.matchesCandidate(candidate) && (candidate.version == 1 || matchesAttachments(candidate)); } + geocaching::ByteView manifest() const { return ready() ? check.manifest() : geocaching::ByteView{}; } + bool mediaReady() const { return media == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + uint32_t media, generation; + uint8_t bindings[96]{}, author[64]{}, author_hash[32]{}; + track_sharing::PrepareRequest request; + track_sharing::UploadJournal kml_upload, manifest_upload, verify; + track_sharing::ManifestView view; + char kml_path[64]{}, manifest_path[64]{}, names[2048]{}; + uint8_t raw[4096]{}; + mbedtls_sha256_context context; + sd::SdRuntimeFile file; + sd::SdBorrowedRecordFileIo io; + platform::esp::storage::SnapshotStreamSha256 hash; + TrackAttachmentPrepareOwner* attachments = nullptr; + mutable track_sharing::ManifestExtensionsView extensions; + + private: + track_sharing::UploadedCandidateCheck check; + track_sharing::UploadJournal binding_upload, binding_verify; + uint8_t binding_raw[1100]{}; + char binding_path[64]{}; + track_sharing::AttachmentUploadBindingsView attachment_bindings; + track_sharing::UploadedManifestReader binding_reader; + bool binding_started = false; + void cancelJobs() + { + check.cancel(); + binding_reader.cancel(); + if (attachments) attachments->cancel(); + } + track_sharing::ManifestFileResult state = track_sharing::ManifestFileResult::Idle; +}; +static_assert(sizeof(TrackPrepareOwner) <= 12288, "Preparation input owner exceeds PSRAM admission"); +inline TrackPrepareOwner* createPrepareOwner(const track_sharing::PrepareRequest& request, uint32_t media, const uint8_t* author, const uint8_t* author_hash) +{ + if (request.install_request || request.query_request || !author || !author_hash || !request.generation || !request.operation.data || request.operation.size != 16 || !request.kml_upload.data || request.kml_upload.size != 16 || + !request.manifest_upload.data || request.manifest_upload.size != 16 || (request.expected_parent.size && (!request.expected_parent.data || request.expected_parent.size != 32)) || + (request.package_upload.size && (!request.package_request || !request.package_upload.data || request.package_upload.size != 16)) || + media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPrepareOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* created = new (memory) TrackPrepareOwner(request, media, author, author_hash); + if (!created->file.has_backing_storage()) + { + created->~TrackPrepareOwner(); + heap_caps_free(memory); + return nullptr; + } + return created; +} +inline void destroyPrepareOwner(TrackPrepareOwner* owner) +{ + if (!owner) return; + owner->cancel(); + owner->~TrackPrepareOwner(); + heap_caps_free(owner); +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_persistence.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_persistence.h new file mode 100644 index 00000000..9120b521 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_persistence.h @@ -0,0 +1,177 @@ +#pragma once +#include "chat/ports/i_mesh_adapter.h" +#include "platform/esp/idf_common/hostlink_track_prepare_owner.h" +#include "platform/esp/idf_common/hostlink_track_object_admission.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "track_sharing/checked_reservation.h" +#include "track_sharing/prepared_signature.h" +#include "track_sharing/persist_signed_head.h" +#include "track_sharing/signed_manifest_reader.h" +#include "track_sharing/restore_prepared_signature.h" +#include "track_sharing/current_parent_loader.h" +namespace hostlink::management::prepare_runtime +{ +// Scheduler lends a Ready input owner and holds exclusive object/media/identity +// leases throughout this stage. No public capability is enabled by this class. +class TrackPreparePersistence +{ + public: + explicit TrackPreparePersistence(TrackPrepareOwner& input) + : input_(input), heads_(input.io, head_a_, head_b_), reservations_(input.io, reservation_a_, reservation_b_), + blobs_(input.io, signed_temporary_, signed_path_), + parent_loader_(input.io, input.hash, crypto_, heads_, head_, head_verify_, verified_, parent_path_, signed_bytes_, sizeof(signed_bytes_)) {} + bool initialize() + { + if (!input_.ready() || !track_sharing::deriveManifestHashes(input_.manifest(), input_.view, input_.hash, object_, candidate_)) return false; + using track_sharing::ObjectFile; + const geocaching::ByteView object{object_, 32}, operation = input_.request.operation, revision{candidate_, 32}; + initialized_ = track_sharing::resolveObjectPath(object, ObjectFile::HeadA, {}, head_a_, sizeof(head_a_)) && + track_sharing::resolveObjectPath(object, ObjectFile::HeadB, {}, head_b_, sizeof(head_b_)) && + track_sharing::resolveObjectPath(object, ObjectFile::ReservationA, operation, reservation_a_, sizeof(reservation_a_)) && + track_sharing::resolveObjectPath(object, ObjectFile::ReservationB, operation, reservation_b_, sizeof(reservation_b_)) && + track_sharing::resolveObjectPath(object, ObjectFile::SignedTemporary, revision, signed_temporary_, sizeof(signed_temporary_)) && + track_sharing::resolveObjectPath(object, ObjectFile::SignedManifest, revision, signed_path_, sizeof(signed_path_)); + return initialized_; + } + Status finish(chat::IMeshAdapter& adapter, const track_sharing::VerifiedManifestView* parent, uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + complete_ = false; + if (!initialized_ || !input_.ready() || !scratch || capacity < 448) return Status::InvalidField; + uint8_t current_hash[32]; uint32_t current_generation = 0; + auto* key = scratch + capacity - 64; + const auto identity = upload_runtime::readUploadAuthor(adapter, key, 64, current_hash, current_generation); + if (identity != Status::Ok) return identity; + if (current_generation != input_.generation || std::memcmp(key, input_.author, 64) || std::memcmp(current_hash, input_.author_hash, 32)) return Status::StaleIdentity; + if (parent_loading_) + { + const auto loaded_parent = parent_loader_.step(workspace_, sizeof(workspace_)); + if (loaded_parent == ParentLoadResult::Pending) return Status::Busy; + parent_loading_ = false; + if (loaded_parent != ParentLoadResult::Ready) return loaded_parent == ParentLoadResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + parent_ready_ = true; + } + const auto loaded_head = heads_.load(head_, head_verify_, scratch, capacity); + if (loaded_head != HeadStoreResult::Ready && loaded_head != HeadStoreResult::Missing) return loaded_head == HeadStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + const auto loaded_reservation = reservations_.load(reservation_, reservation_verify_, scratch, capacity); + if (loaded_reservation != ReservationStoreResult::Ready && loaded_reservation != ReservationStoreResult::Missing) return loaded_reservation == ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + const geocaching::ByteView author{input_.author, 64}, author_hash{input_.author_hash, 32}; + if (loaded_reservation == ReservationStoreResult::Ready) + { + if (!reservationMatchesInput()) return Status::Conflict; + if (reservation_.phase == ReservationPhase::Checked || reservation_.phase == ReservationPhase::Signed) + { + if (loaded_head == HeadStoreResult::Missing) + { + const auto admitted = requireOwnedFirstRevisionDirectory(input_.media, input_.request, input_.author_hash, reservation_, reservations_, reservation_verify_, scratch, capacity); + if (admitted != Status::Ok) return admitted; + } + const auto existing = readVerifiedSignature(); + if (existing == Status::Ok) + { + const auto restored = restorePreparedSignature(input_, verified_, author, input_.generation, author_hash, reservation_, reservation_verify_, reservations_, workspace_, sizeof(workspace_), signature_, sizeof(signature_)); + if (restored != PreparedSignatureResult::Ready) return restored == PreparedSignatureResult::StorageFailure ? Status::StorageUnavailable : restored == PreparedSignatureResult::StaleIdentity ? Status::StaleIdentity : Status::Conflict; + return persistHead(scratch, capacity); + } + if (existing != Status::Expired) return existing; + if (reservation_.phase == ReservationPhase::Signed) return Status::StorageUnavailable; + } + } + if (loaded_head == HeadStoreResult::Missing) + { + if (parent || input_.request.expected_parent.size) return Status::Conflict; + Status admitted; + if (loaded_reservation == ReservationStoreResult::Missing) + admitted = requireEmptyObjectDirectory(input_.media, {object_, 32}, scratch, capacity); + else if (loaded_reservation == ReservationStoreResult::Ready && !std::memcmp(reservation_.object, object_, 32) && !std::memcmp(reservation_.candidate, candidate_, 32)) + admitted = requireOwnedFirstRevisionDirectory(input_.media, input_.request, input_.author_hash, reservation_, reservations_, reservation_verify_, scratch, capacity); + else return loaded_reservation == ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + if (admitted != Status::Ok) return admitted; + } + else if (!parent) + { + if (parent_ready_) parent = &verified_; + else + { + const auto started = parent_loader_.begin({object_, 32}, input_.request.expected_parent, {input_.author, 64}, input_.generation, scratch, capacity); + if (started != ParentLoadResult::Pending) return started == ParentLoadResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + parent_loading_ = true; + return Status::Busy; + } + } + const auto directory = ensureObjectDirectory(input_.media, {object_, 32}, scratch, capacity); + if (directory != Status::Ok) return directory; + const auto checked = persistCheckedReservation(input_, input_.request, input_.manifest(), author, author_hash, input_.generation, + loaded_head == HeadStoreResult::Missing ? nullptr : &head_, parent, input_.hash, reservations_, input_.view, + desired_, reservation_, reservation_verify_, scratch, capacity); + if (checked != ReservationStoreResult::Ready) return checked == ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + const auto signed_result = persistPreparedSignature(input_, input_.manifest(), author, input_.generation, author_hash, reservation_, reservation_verify_, + input_.hash, adapter, blobs_, reservations_, input_.view, workspace_, sizeof(workspace_), signature_, sizeof(signature_)); + if (signed_result != PreparedSignatureResult::Ready) return signed_result == PreparedSignatureResult::StaleIdentity ? Status::StaleIdentity : signed_result == PreparedSignatureResult::StorageFailure ? Status::StorageUnavailable : signed_result == PreparedSignatureResult::Unavailable ? Status::Busy : Status::Conflict; + const auto verified = readVerifiedSignature(); + if (verified != Status::Ok) return verified == Status::Expired ? Status::StorageUnavailable : verified; + return persistHead(scratch, capacity); + } + bool ready() const { return complete_ && input_.ready(); } + const track_sharing::ObjectHead& head() const { return head_; } + geocaching::ByteView signature() const { return complete_ ? geocaching::ByteView{signature_, sizeof(signature_)} : geocaching::ByteView{}; } + private: + bool reservationMatchesInput() const + { + if (reservation_.generation != input_.generation || reservation_.revision != input_.view.revision || + std::memcmp(reservation_.operation, input_.request.operation.data, 16) || std::memcmp(reservation_.object, object_, 32) || + std::memcmp(reservation_.candidate, candidate_, 32) || std::memcmp(reservation_.author, input_.author_hash, 32)) return false; + return reservation_.revision == 1 ? input_.view.previous.size == 0 : input_.view.previous.size == 32 && !std::memcmp(reservation_.parent, input_.view.previous.data, 32); + } + Status readVerifiedSignature() + { + using namespace track_sharing; + const auto probe = input_.io.open(signed_path_, sd::SdBorrowedRecordFileIo::Mode::Read); + if (!probe.handle) return probe.status == sd::SdBorrowedRecordFileIo::OpenStatus::Missing ? Status::Expired : Status::StorageUnavailable; + if (!input_.io.close(probe.handle)) return Status::StorageUnavailable; + SignedManifestReader reader(input_.io); + if (reader.begin(signed_path_, signed_bytes_, sizeof(signed_bytes_)) != FileIntegrityResult::Pending) return Status::StorageUnavailable; + FileIntegrityResult read; + do { read = reader.step(); } while (read == FileIntegrityResult::Pending); + if (read != FileIntegrityResult::Ready) return Status::StorageUnavailable; + return verifyManifest(reader.bytes(), crypto_, input_.hash, workspace_, sizeof(workspace_), verified_, {input_.author, 64}) == ManifestVerification::Valid ? Status::Ok : Status::Conflict; + } + Status persistHead(uint8_t* scratch, size_t capacity) + { + const auto persisted = track_sharing::persistSignedObjectHead(heads_, verified_, reservation_, input_.generation, {input_.author, 64}, {input_.author_hash, 32}, + head_, head_next_, head_verify_, scratch, capacity); + if (persisted != track_sharing::HeadStoreResult::Ready) return persisted == track_sharing::HeadStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + complete_ = true; + return Status::Ok; + } + TrackPrepareOwner& input_; + uint8_t object_[32]{}, candidate_[32]{}, signature_[64]{}, workspace_[4200]{}, signed_bytes_[4166]{}; + char head_a_[160]{}, head_b_[160]{}, reservation_a_[160]{}, reservation_b_[160]{}, signed_temporary_[160]{}, signed_path_[160]{}, parent_path_[160]{}; + track_sharing::ObjectHead head_, head_next_, head_verify_; + track_sharing::RevisionReservation desired_, reservation_, reservation_verify_; + track_sharing::VerifiedManifestView verified_; + platform::esp::storage::TrackManifestCrypto crypto_; + track_sharing::ObjectHeadStore heads_; + track_sharing::ReservationStore reservations_; + track_sharing::SignedManifestStore blobs_; + track_sharing::CurrentParentLoader> parent_loader_; + bool initialized_ = false, complete_ = false, parent_loading_ = false, parent_ready_ = false; +}; +static_assert(sizeof(TrackPreparePersistence) <= 12288, "Persistent preparation must fit transient PSRAM admission"); +inline TrackPreparePersistence* createPreparePersistence(TrackPrepareOwner& input) +{ + if (!input.ready()) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPreparePersistence), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* task = new (memory) TrackPreparePersistence(input); + if (task->initialize()) return task; + task->~TrackPreparePersistence(); heap_caps_free(task); return nullptr; +} +inline void destroyPreparePersistence(TrackPreparePersistence* task) +{ + if (!task) return; + task->~TrackPreparePersistence(); heap_caps_free(task); +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_runtime.h new file mode 100644 index 00000000..49fe309f --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_runtime.h @@ -0,0 +1,148 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/idf_common/hostlink_track_package_install_work.h" +#include "platform/esp/idf_common/hostlink_track_package_query_work.h" +#include "platform/esp/idf_common/hostlink_track_prepare_work.h" +#include "track_sharing/package_query_request.h" +namespace hostlink::management::prepare_runtime +{ +// Single application-scheduler slot. All task state lives in transient PSRAM; +// no separate CDC task, queue, response cache or resident protocol buffer. +inline PrepareSchedulerWork* work = nullptr; +inline void releasePrepare() +{ + destroySchedulerWork(work); + work = nullptr; +} +inline Status advancePrepare(chat::IMeshAdapter* adapter, uint8_t* scratch, size_t capacity, uint64_t now, bool connected) +{ + if (!work) return Status::Expired; + if (!work->alive(sd::sd_media_session(), now, connected)) + { + releasePrepare(); + return Status::Expired; + } + if (!adapter) + { + work->suspendIdentity(); + return Status::Busy; + } + return work->advance(*adapter, scratch, capacity); +} +inline void closePrepareSession(const uint8_t* session) +{ + if (work && work->matchesSession(session)) releasePrepare(); +} +inline bool prepareHoldsStorage() { return work && work->holdsStages(); } +inline Status processPrepareRequest(chat::IMeshAdapter& adapter, const EnvelopeView& request, uint64_t now, uint8_t* scratch, size_t capacity, size_t& written, package_install_runtime::PackageInstallPolicy install_policy = nullptr) +{ + written = 0; + if (!request.session || !scratch || capacity < 448) return Status::InvalidField; + track_sharing::PrepareRequest decoded; + const bool querying = request.code == uint16_t(Operation::TrackPackageInstallStatus); + if (!(querying ? track_sharing::decodePackageQueryRequest({request.body, request.body_size}, decoded) : track_sharing::decodePrepareRequest({request.body, request.body_size}, decoded))) return Status::InvalidField; + const bool installing = request.code == uint16_t(Operation::TrackPackageInstall) || querying; + if (installing != decoded.install_request || ((request.code == uint16_t(Operation::TrackPackageSignPrepare) || installing) != decoded.package_request) || + (installing && !querying && !install_policy)) return Status::InvalidField; + auto* public_key = scratch + capacity - 64; + for (const auto field : {decoded.operation, decoded.kml_upload, decoded.manifest_upload, decoded.expected_parent, decoded.package_upload, decoded.expected_object, decoded.expected_revision, geocaching::ByteView{request.session, 16}}) + if (track_sharing::signingSpansOverlap(field.data, field.size, public_key, 64)) return Status::InvalidField; + uint8_t author_hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, public_key, 64, author_hash, generation); + if (identity != Status::Ok) return identity; + if (generation != decoded.generation) return Status::StaleIdentity; + if (work && !work->alive(sd::sd_media_session(), now, true)) releasePrepare(); + // A fresh query always rereads disk, including after a delivered success. + if (querying && work && !work->holdsStages()) releasePrepare(); + if (work && !work->matches(request.session, decoded)) + { + if (work->holdsStages() || !work->matchesSession(request.session)) return Status::Conflict; + releasePrepare(); + } + if (!work) + { + if (upload_runtime::owner) + { + if (querying) return Status::Busy; + const auto& metadata = upload_runtime::owner->resources.metadata(); + if (upload_runtime::owner->discarding || metadata.phase != track_sharing::UploadPhase::Sealed || + !upload_runtime::owner->session.matches(request.session, sd::sd_media_session(), now) || metadata.generation != generation || + std::memcmp(metadata.author_hash, author_hash, 32) || + (std::memcmp(metadata.id, decoded.kml_upload.data, 16) && std::memcmp(metadata.id, decoded.manifest_upload.data, 16) && + (!decoded.package_upload.data || std::memcmp(metadata.id, decoded.package_upload.data, 16)))) return Status::Busy; + upload_runtime::destroyOwner(upload_runtime::owner); + upload_runtime::owner = nullptr; + } + if (installing) + { + if (querying) work = package_install_runtime::createPackageQueryWork(decoded, request.session, author_hash, sd::sd_media_session(), now); + else work = package_install_runtime::createPackageInstallWork(decoded, request.session, author_hash, sd::sd_media_session(), now, install_policy); + if (!work) return Status::Busy; + } + else + { + auto* input = createPrepareOwner(decoded, sd::sd_media_session(), public_key, author_hash); + if (!input) return Status::Busy; + auto* created = createPrepareWork(input, request.session, now); + if (!created) + { + destroyPrepareOwner(input); + return Status::Busy; + } + const auto started = input->start(scratch, capacity); + if (started != track_sharing::ManifestFileResult::Pending) + { + destroyPrepareWork(created); + return started == track_sharing::ManifestFileResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + } + work = created; + } + } + // A retry can reseal uploads while this slot already holds a completed + // result. Drop only this request's sealed owner; leave unrelated or active + // transfers intact. The retained files and immutable result remain usable. + if (!querying && work->result() && !work->holdsStages() && upload_runtime::owner) + { + const auto& metadata = upload_runtime::owner->resources.metadata(); + if (!upload_runtime::owner->discarding && metadata.phase == track_sharing::UploadPhase::Sealed && + upload_runtime::owner->session.matches(request.session, sd::sd_media_session(), now) && metadata.generation == generation && + !std::memcmp(metadata.author_hash, author_hash, 32) && + (!std::memcmp(metadata.id, decoded.kml_upload.data, 16) || !std::memcmp(metadata.id, decoded.manifest_upload.data, 16) || + (decoded.package_upload.data && !std::memcmp(metadata.id, decoded.package_upload.data, 16)))) + { + upload_runtime::destroyOwner(upload_runtime::owner); + upload_runtime::owner = nullptr; + } + } + const auto advanced = work->advance(adapter, scratch, capacity); + if (advanced != Status::Ok && advanced != Status::Busy) return advanced; + if (!work->renewMatched(now)) return Status::Busy; + const auto* result = work->result(); + if (installing) + { + auto* target = reinterpret_cast(scratch + capacity - 80); + if (result && !track_sharing::routePackageTarget({result->hash, 32}, track_sharing::PackageRouteFile::Kml, target, 80)) return Status::Internal; + geocaching::protocol::CmpWriter writer(scratch, capacity - 80); + if (!writer.array(5) || !writer.binary(work->operationId()) || !writer.unsignedInteger(work->installationState())) return Status::Internal; + if (result) + { + if (!writer.binary({result->object, 32}) || !writer.binary({result->hash, 32}) || !writer.text(target)) return Status::Internal; + } + else if (!writer.nil() || !writer.nil() || !writer.nil()) return Status::Internal; + written = writer.size(); + return Status::Ok; + } + geocaching::protocol::CmpWriter writer(scratch, capacity); + const bool signing = request.code == static_cast(Operation::TrackSignPrepare) || request.code == uint16_t(Operation::TrackPackageSignPrepare); + if (!writer.array(signing ? 6 : 5) || !writer.binary(work->operationId()) || !writer.unsignedInteger(result ? 1 : 0)) return Status::Internal; + if (result) + { + if (!writer.binary({result->object, 32}) || !writer.binary({result->hash, 32}) || !writer.unsignedInteger(result->revision)) return Status::Internal; + } + else if (!writer.nil() || !writer.nil() || !writer.unsignedInteger(0)) return Status::Internal; + if (signing && !(result ? writer.binary(work->preparedSignature()) : writer.nil())) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_work.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_work.h new file mode 100644 index 00000000..472c1027 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_prepare_work.h @@ -0,0 +1,200 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_prepare_scheduler_work.h" +#include "platform/esp/idf_common/hostlink_track_attachment_retention.h" +#include "platform/esp/idf_common/hostlink_track_kml_retention.h" +#include "platform/esp/idf_common/hostlink_track_prepare_persistence.h" +#include "platform/esp/idf_common/hostlink_track_retention_admission.h" +namespace hostlink::management::prepare_runtime +{ +// One scheduler-owned transient PSRAM job. Owns input after successful creation; +// big stages release immediately on terminal outcome, metadata expires in 30s. +class TrackPrepareWork : public PrepareSchedulerWork +{ + public: + TrackPrepareWork(TrackPrepareOwner* input, const uint8_t* session, uint64_t now) + : input_(input), media_(input->media), generation_(input->generation), last_(now) + { + std::memcpy(session_, session, 16); + std::memcpy(author_hash_, input->author_hash, 32); + std::memcpy(upload_ids_, input->request.kml_upload.data, 16); + std::memcpy(upload_ids_ + 16, input->request.manifest_upload.data, 16); + package_request_ = input->request.package_request; + package_present_ = input->request.package_upload.size == 16; + if (package_present_) std::memcpy(upload_ids_ + 32, input->request.package_upload.data, 16); + std::memcpy(result_.operation, input->request.operation.data, 16); + parent_present_ = input->request.expected_parent.size == 32; + if (parent_present_) std::memcpy(result_.parent, input->request.expected_parent.data, 32); + } + ~TrackPrepareWork() { releaseStages(); } + TrackPrepareWork(const TrackPrepareWork&) = delete; + TrackPrepareWork& operator=(const TrackPrepareWork&) = delete; + bool alive(uint32_t media, uint64_t now, bool connected) const { return connected && media == media_ && now >= last_ && now - last_ < 30000; } + bool matchesSession(const uint8_t* session) const { return session && !std::memcmp(session, session_, 16); } + bool holdsStages() const { return input_ || persistence_; } + void suspendIdentity() { identity_verified_ = false; } + geocaching::ByteView operationId() const { return {result_.operation, 16}; } + bool renewMatched(uint64_t now) + { + if (!identity_verified_ || !alive(sd::sd_media_session(), now, true)) return false; + last_ = now; + return true; + } + bool matches(const uint8_t* session, const track_sharing::PrepareRequest& request) const + { + return session && !std::memcmp(session, session_, 16) && request.generation == generation_ && + !request.install_request && + request.operation.data && request.operation.size == 16 && !std::memcmp(request.operation.data, result_.operation, 16) && + request.kml_upload.data && request.kml_upload.size == 16 && !std::memcmp(request.kml_upload.data, upload_ids_, 16) && + request.manifest_upload.data && request.manifest_upload.size == 16 && !std::memcmp(request.manifest_upload.data, upload_ids_ + 16, 16) && + request.package_request == package_request_ && + (package_present_ ? request.package_upload.data && request.package_upload.size == 16 && !std::memcmp(request.package_upload.data, upload_ids_ + 32, 16) : !request.package_upload.size) && + (parent_present_ ? request.expected_parent.data && request.expected_parent.size == 32 && !std::memcmp(request.expected_parent.data, result_.parent, 32) : request.expected_parent.size == 0); + } + bool authorize(const uint8_t* session, const track_sharing::PrepareRequest& request, uint64_t now) + { + if (!alive(sd::sd_media_session(), now, true) || !matches(session, request)) return false; + last_ = now; + return true; + } + Status advance(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) + { + identity_verified_ = false; + if (!scratch || capacity < 448) return fail(Status::Internal); + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return fail(Status::StorageUnavailable); + uint8_t hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, hash, generation); + if (identity == Status::Busy) return Status::Busy; + if (identity != Status::Ok) return fail(identity); + if (generation != generation_ || std::memcmp(hash, author_hash_, 32)) return fail(Status::StaleIdentity); + identity_verified_ = true; + if (terminal_) return status_; + if (attachment_retention_) return advanceAttachments(scratch, capacity); + if (retention_) return advanceRetention(scratch, capacity); + if (!input_->ready()) + { + const auto checked = input_->step(generation, hash, scratch, capacity); + if (checked == track_sharing::ManifestFileResult::Pending) return Status::Busy; + if (checked != track_sharing::ManifestFileResult::Ready) return fail(checked == track_sharing::ManifestFileResult::StorageFailure ? Status::StorageUnavailable : checked == track_sharing::ManifestFileResult::HashMismatch ? Status::HashMismatch + : Status::InvalidField); + } + if (!persistence_) + { + persistence_ = createPreparePersistence(*input_); + if (!persistence_) return Status::Busy; + } + const auto finished = persistence_->finish(adapter, nullptr, scratch, capacity); + if (finished == Status::Busy) return finished; + if (finished != Status::Ok) return fail(finished); + const auto& head = persistence_->head(); + std::memcpy(result_.object, head.object, 32); + std::memcpy(result_.hash, head.hash, 32); + std::memcpy(result_.parent, head.parent, 32); + std::memcpy(result_.operation, head.operation, 16); + result_.revision = head.revision; + const auto signed_bytes = persistence_->signature(); + if (signed_bytes.size != sizeof(signature_)) return fail(Status::Internal); + std::memcpy(signature_, signed_bytes.data, sizeof(signature_)); + const auto admitted = input_->view.version == 2 ? admitRetainedPackageFile(media_, {result_.object, 32}, input_->view.file_hash, input_->view.file_bytes, + track_sharing::ObjectFile::KmlFile, track_sharing::ObjectFile::KmlTemporary, scratch, capacity) + : admitRetainedKml(media_, {result_.object, 32}, input_->view.file_hash, input_->view.file_bytes, scratch, capacity); + if (admitted == Status::Busy) return Status::Busy; + if (admitted != Status::Ok) return fail(admitted); + void* memory = heap_caps_malloc(sizeof(TrackKmlRetention), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + retention_ = new (memory) TrackKmlRetention(*input_); + const auto started = retention_->begin({result_.object, 32}); + if (started != track_sharing::ImmutableCopyResult::Pending) return fail(started == track_sharing::ImmutableCopyResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + return Status::Busy; + } + bool ready() const { return identity_verified_ && terminal_ && status_ == Status::Ok && media_ == sd::sd_media_session() && sd::sd_card_ready(); } + const TrackPreparedResult* result() const { return ready() ? &result_ : nullptr; } + geocaching::ByteView preparedSignature() const { return ready() ? geocaching::ByteView{signature_, sizeof(signature_)} : geocaching::ByteView{}; } + Status status() const { return terminal_ ? status_ : Status::Busy; } + + private: + Status advanceRetention(uint8_t* scratch, size_t capacity) + { + const auto retained = retention_->step(scratch, capacity); + if (retained == track_sharing::ImmutableCopyResult::Pending) return Status::Busy; + if (retained != track_sharing::ImmutableCopyResult::Ready) + return fail(retained == track_sharing::ImmutableCopyResult::HashMismatch ? Status::HashMismatch : retained == track_sharing::ImmutableCopyResult::StorageFailure ? Status::StorageUnavailable + : Status::Conflict); + if (input_->attachments) + { + void* memory = heap_caps_malloc(sizeof(TrackAttachmentRetention), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + attachment_retention_ = new (memory) TrackAttachmentRetention(*input_->attachments, {result_.object, 32}, media_); + return Status::Busy; + } + terminal_ = true; + status_ = Status::Ok; + releaseStages(); + return status_; + } + Status advanceAttachments(uint8_t* scratch, size_t capacity) + { + const auto retained = attachment_retention_->step(scratch, capacity); + if (retained == Status::Busy) return retained; + if (retained != Status::Ok) return fail(retained); + terminal_ = true; + status_ = Status::Ok; + releaseStages(); + return status_; + } + Status fail(Status status) + { + terminal_ = true; + status_ = status; + releaseStages(); + return status; + } + void releaseStages() + { + if (attachment_retention_) + { + attachment_retention_->~TrackAttachmentRetention(); + heap_caps_free(attachment_retention_); + attachment_retention_ = nullptr; + } + if (retention_) + { + retention_->~TrackKmlRetention(); + heap_caps_free(retention_); + retention_ = nullptr; + } + destroyPreparePersistence(persistence_); + persistence_ = nullptr; + destroyPrepareOwner(input_); + input_ = nullptr; + } + TrackPrepareOwner* input_; + TrackPreparePersistence* persistence_ = nullptr; + TrackKmlRetention* retention_ = nullptr; + TrackAttachmentRetention* attachment_retention_ = nullptr; + uint8_t session_[16]{}, author_hash_[32]{}, upload_ids_[48]{}; + TrackPreparedResult result_; + uint8_t signature_[64]{}; + uint32_t media_, generation_; + uint64_t last_; + Status status_ = Status::Busy; + bool parent_present_ = false, terminal_ = false, identity_verified_ = false; + bool package_request_ = false, package_present_ = false; +}; +static_assert(sizeof(TrackPrepareWork) <= 320, "Prepare result metadata cannot retain big buffers"); +inline TrackPrepareWork* createPrepareWork(TrackPrepareOwner* input, const uint8_t* session, uint64_t now) +{ + if (!input || !session) return nullptr; + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) bits |= session[i]; + if (!bits) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPrepareWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return memory ? new (memory) TrackPrepareWork(input, session, now) : nullptr; +} +inline void destroyPrepareWork(TrackPrepareWork* work) +{ + if (!work) return; + work->~TrackPrepareWork(); + heap_caps_free(work); +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_public_package_retention.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_public_package_retention.h new file mode 100644 index 00000000..1dd80bc0 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_public_package_retention.h @@ -0,0 +1,106 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_attachment_retention.h" +#include "platform/esp/idf_common/hostlink_track_package_input_owner.h" +namespace hostlink::management::package_install_runtime +{ +// Retains original signed bytes and full supplied package in the public cache. +// This creates no owned publication head and exposes no /routes entry yet. +class TrackPublicPackageRetention +{ + public: + TrackPublicPackageRetention(TrackPackageInputOwner& input, uint32_t media) : input_(input), media_(media), copy_(input.io(), input.hash()) {} + ~TrackPublicPackageRetention() + { + if (attachments_) + { + attachments_->~TrackAttachmentRetention(); + heap_caps_free(attachments_); + } + } + Status step(uint32_t generation, const uint8_t* actor, uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + const auto current = input_.step(generation, actor, scratch, capacity); + if (current != ManifestFileResult::Ready || !input_.ready() || media_ != sd::sd_media_session()) return fail(current == ManifestFileResult::StorageFailure || media_ != sd::sd_media_session() ? Status::StorageUnavailable : Status::StaleIdentity); + if (terminal_) return status_; + const auto* verified = input_.verified(); + const geocaching::ByteView revision{verified->revision_hash, 32}; + if (attachments_) + { + const auto result = attachments_->step(scratch, capacity); + if (result == Status::Busy) return result; + return result == Status::Ok ? finish() : fail(result); + } + if (stage_ == 2) + { + if (!input_.attachments()) return finish(); + void* memory = heap_caps_malloc(sizeof(TrackAttachmentRetention), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + attachments_ = new (memory) TrackAttachmentRetention(*input_.attachments(), revision, media_, true); + return Status::Busy; + } + if (!copying_) + { + auto admitted = prepare_runtime::ensurePackageCacheDirectory(media_, revision, scratch, capacity); + if (admitted != Status::Ok) return admitted == Status::Busy ? admitted : fail(admitted); + const bool signed_stage = stage_ == 0; + const auto final_kind = signed_stage ? ObjectFile::SignedManifest : ObjectFile::KmlFile; + const auto temporary_kind = signed_stage ? ObjectFile::SignedTemporary : ObjectFile::KmlTemporary; + const auto bytes = signed_stage ? input_.signedUpload().total : verified->manifest.file_bytes; + const geocaching::ByteView hash = signed_stage ? geocaching::ByteView{input_.signedUpload().file_hash, 32} : verified->manifest.file_hash; + admitted = prepare_runtime::admitPackageCacheFile(media_, revision, hash, bytes, final_kind, temporary_kind, scratch, capacity); + if (admitted != Status::Ok) return admitted == Status::Busy ? admitted : fail(admitted); + if (!resolvePackageCachePath(revision, final_kind, {}, destination_, sizeof(destination_)) || !resolvePackageCachePath(revision, temporary_kind, {}, temporary_, sizeof(temporary_))) return fail(Status::InvalidField); + const auto begin = copy_.begin(signed_stage ? input_.signedPath() : input_.kmlPath(), temporary_, destination_, bytes, hash.data); + if (begin != ImmutableCopyResult::Pending) return fail(map(begin)); + copying_ = true; + } + const auto copied = copy_.step(scratch, capacity); + if (copied == ImmutableCopyResult::Pending) return Status::Busy; + if (copied != ImmutableCopyResult::Ready) return fail(map(copied)); + copying_ = false; + ++stage_; + return Status::Busy; + } + bool ready() const { return terminal_ && status_ == Status::Ok && input_.ready(); } + + private: + using TrackAttachmentRetention = prepare_runtime::TrackAttachmentRetention; + static Status map(track_sharing::ImmutableCopyResult result) { return result == track_sharing::ImmutableCopyResult::HashMismatch ? Status::HashMismatch : result == track_sharing::ImmutableCopyResult::StorageFailure ? Status::StorageUnavailable + : Status::Conflict; } + Status fail(Status result) + { + copy_.cancel(); + terminal_ = true; + return status_ = result; + } + Status finish() + { + terminal_ = true; + return status_ = Status::Ok; + } + TrackPackageInputOwner& input_; + uint32_t media_; + track_sharing::ImmutableFileCopy copy_; + char temporary_[160]{}, destination_[160]{}; + TrackAttachmentRetention* attachments_ = nullptr; + Status status_ = Status::Busy; + uint8_t stage_ = 0; + bool copying_ = false, terminal_ = false; +}; +static_assert(sizeof(TrackPublicPackageRetention) <= 768, "Public retention owns no payload/context/index buffers"); +inline TrackPublicPackageRetention* createPublicPackageRetention(TrackPackageInputOwner& input, uint32_t media) +{ + if (!input.ready() || media != sd::sd_media_session()) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPublicPackageRetention), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + return memory ? new (memory) TrackPublicPackageRetention(input, media) : nullptr; +} +inline void destroyPublicPackageRetention(TrackPublicPackageRetention* work) +{ + if (work) + { + work->~TrackPublicPackageRetention(); + heap_caps_free(work); + } +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_admission.h new file mode 100644 index 00000000..a36f5e61 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_admission.h @@ -0,0 +1,268 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "platform/esp/common/storage/track_receipt_crypto.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include "track_sharing/admit_publication_intent.h" +#include "track_sharing/commit_publication_head.h" +#include "track_sharing/object_paths.h" +#include "track_sharing/publication_intent.h" +#include "track_sharing/load_publication_history.h" +#include "track_sharing/publication_receipt_loader.h" +#include "track_sharing/reservation_store.h" +#include "track_sharing/signed_manifest_reader.h" +#include +#include + +namespace hostlink::management::publication_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// Scheduler owns an exclusive object/media lease. All request bytes are copied; +// no CDC frame, discovery entry or file handle survives a scheduling boundary. +// Ready is durable LOCAL admission, never a remote publication confirmation. +class TrackPublicationAdmission +{ + friend class TrackPublicationExecution; + public: + TrackPublicationAdmission(const track_sharing::CommitRequest& request, const track_sharing::PublicationDirectory& directory, + uint32_t media, const uint8_t* author, const uint8_t* author_hash, bool read_only = false) + : directory_(directory), media_(media), file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media_), hash_(context_), + reader_(io_), file_check_(io_, hash_), heads_(io_, head_a_, head_b_), reservations_(io_, reservation_a_, reservation_b_), + directories_(io_, directory_path_), jobs_(io_, job_a_, job_b_), receipt_reader_(io_, receipt_crypto_, job_, directory_, receipt_, 0), read_only_(read_only) + { + std::memcpy(bindings_, request.operation.data, 16); + std::memcpy(bindings_ + 16, request.object.data, 32); + std::memcpy(bindings_ + 48, request.revision_hash.data, 32); + std::memcpy(bindings_ + 80, request.directory.data, 16); + std::memcpy(bindings_ + 96, request.directory_epoch.data, 16); + request_ = {{bindings_, 16}, {bindings_ + 16, 32}, {bindings_ + 48, 32}, {bindings_ + 80, 16}, {bindings_ + 96, 16}, request.generation, request.revision}; + std::memcpy(author_, author, 64); + std::memcpy(author_hash_, author_hash, 32); + } + TrackPublicationAdmission(const TrackPublicationAdmission&) = delete; + TrackPublicationAdmission& operator=(const TrackPublicationAdmission&) = delete; + bool hasBackingStorage() const { return file_.has_backing_storage(); } + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + bool ready() const { return stage_ == Stage::Ready && identity_verified_ && mediaReady(); } + bool readOnly() const { return read_only_; } + const track_sharing::PublicationJournal* result() const { return ready() ? &job_ : nullptr; } + const track_sharing::VerifiedManifestView* manifest() const { return ready() ? &manifest_ : nullptr; } + Status step(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + identity_verified_ = false; + if (!scratch || capacity < 448 || signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this))) return fail(Status::InvalidField); + if (!mediaReady()) return fail(Status::StorageUnavailable); + uint8_t current_hash[32]; + uint32_t generation = 0; + auto* key = scratch + capacity - 64; + const auto identity = upload_runtime::readUploadAuthor(adapter, key, 64, current_hash, generation); + if (identity == Status::Busy) return identity; + if (identity != Status::Ok) return fail(identity); + if (generation != request_.generation || std::memcmp(key, author_, 64) || std::memcmp(current_hash, author_hash_, 32)) return fail(Status::StaleIdentity); + identity_verified_ = true; + if (stage_ == Stage::Failed) return status_; + if (stage_ == Stage::Ready) return Status::Ok; + if (stage_ == Stage::Idle) return begin(scratch, capacity); + if (stage_ == Stage::Reading) + { + const auto read = reader_.step(); + if (read == FileIntegrityResult::Pending) return Status::Busy; + if (read != FileIntegrityResult::Ready) return fail(Status::StorageUnavailable); + if (verifyManifest(reader_.bytes(), crypto_, hash_, workspace_, sizeof(workspace_), manifest_, {author_, 64}) != ManifestVerification::Valid) return fail(Status::Conflict); + if(read_only_)historicalHead(); + if(!requestMatchesHead() || !signedHeadMatches(head_, manifest_, reservation_, request_.generation, {author_, 64}, {author_hash_, 32})) return fail(Status::Conflict); + if (manifest_.manifest.file_bytes > directory_.file_limit) return fail(Status::LimitExceeded); + if (!resolveObjectPath(request_.object, ObjectFile::KmlFile, manifest_.manifest.file_hash, kml_path_, sizeof(kml_path_))) return fail(Status::InvalidField); + if (file_check_.begin(kml_path_, manifest_.manifest.file_bytes, manifest_.manifest.file_hash.data) != FileIntegrityResult::Pending) return fail(Status::StorageUnavailable); + stage_ = Stage::Checking; + return Status::Busy; + } + if (stage_ == Stage::Checking) + { + const auto checked = file_check_.step(scratch, capacity); + if (checked == FileIntegrityResult::Pending) return Status::Busy; + if (checked != FileIntegrityResult::Ready) return fail(checked == FileIntegrityResult::HashMismatch ? Status::HashMismatch : Status::StorageUnavailable); + stage_ = Stage::Admitting; + return Status::Busy; + } + if (stage_ == Stage::ReceiptReading) + { + const auto recovered = receipt_reader_.step(workspace_ + 1100, sizeof(workspace_) - 1100); + if (recovered == ReceiptLoadResult::Pending) return Status::Busy; + if (recovered != ReceiptLoadResult::Ready || !receipt_reader_.ready()) return fail(recovered == ReceiptLoadResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + stage_ = Stage::Ready; + return status_ = Status::Ok; + } + // Re-read both durable evidence sets after all sliced reads. Runtime + // holds exclusive object paths; replacement media is rejected above. + if(!read_only_) + { + const auto loaded = heads_.load(head_, head_verify_, scratch, capacity); + if (loaded != HeadStoreResult::Ready) return fail(loaded == HeadStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + } + const auto reserved = reservations_.load(reservation_, reservation_verify_, scratch, capacity); + if (reserved != ReservationStoreResult::Ready) return fail(reserved == ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + if(read_only_)historicalHead(); + if (!requestMatchesHead() || !signedHeadMatches(head_, manifest_, reservation_, request_.generation, {author_, 64}, {author_hash_, 32})) return fail(Status::Conflict); + // A Committed head is legal only with an already durable matching job. + // Construct identical desired evidence using a shadow Signed head; no + // persisted head is rolled back or rewritten by this shadow. + head_next_ = head_; + head_next_.phase = HeadPhase::Signed; + if (!createPublicationIntent(request_, head_next_, manifest_, reservation_, {author_, 64}, {author_hash_, 32}, file_check_, desired_)) return fail(Status::Conflict); + if (read_only_ || head_.phase == HeadPhase::Committed) + { + const auto existing = loadPublicationHistory(directories_, jobs_, directory_, desired_, directory_verify_, + job_, job_verify_, scratch, capacity); + if (existing != PublicationStoreResult::Ready) return fail(existing == PublicationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + } + else + { + const auto admitted = admitPublicationIntent(directories_, jobs_, directory_, directory_verify_, desired_, job_, job_verify_, scratch, capacity); + if (admitted != PublicationStoreResult::Ready) return fail(admitted == PublicationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + } + if (!read_only_) + { + const auto committed = commitPublicationHead(heads_, jobs_, job_, job_verify_, manifest_, reservation_, request_.generation, + {author_, 64}, {author_hash_, 32}, head_, head_next_, head_verify_, scratch, capacity); + if (committed != HeadStoreResult::Ready) return fail(committed == HeadStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + } + if (job_.phase == PublicationPhase::Confirmed) + { + if (!resolveObjectPath(request_.object, ObjectFile::Receipt, request_.operation, receipt_path_, sizeof(receipt_path_)) || + !receipt_reader_.setExpectedState(head_.archived ? 1 : 0)) return fail(Status::InvalidField); + // Reuse the former manifest-domain workspace: first 1100 bytes + // hold the receipt, remaining bytes hold its verification domain. + const auto started = receipt_reader_.begin(receipt_path_, workspace_, 1100); + if (started != ReceiptLoadResult::Pending) return fail(started == ReceiptLoadResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + stage_ = Stage::ReceiptReading; + return Status::Busy; + } + stage_ = Stage::Ready; + status_ = Status::Ok; + return status_; + } + + private: + // Never persisted. The immutable signed manifest and this operation's + // reservation authenticate historical bindings independently of latest head. + void historicalHead() + { + head_={};head_.sequence=1;head_.generation=reservation_.generation;head_.revision=reservation_.revision; + head_.phase=track_sharing::HeadPhase::Signed;head_.archived=manifest_.manifest.state==1; + std::memcpy(head_.object,reservation_.object,32);std::memcpy(head_.hash,reservation_.candidate,32); + std::memcpy(head_.parent,reservation_.parent,32);std::memcpy(head_.operation,reservation_.operation,16); + } + enum class Stage : uint8_t + { + Idle, + Reading, + Checking, + Admitting, + ReceiptReading, + Ready, + Failed + }; + bool requestMatchesHead() const + { + return head_.generation == request_.generation && head_.revision == request_.revision && + !std::memcmp(head_.operation, request_.operation.data, 16) && !std::memcmp(head_.object, request_.object.data, 32) && + !std::memcmp(head_.hash, request_.revision_hash.data, 32); + } + Status begin(uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (!resolveObjectPath(request_.object, ObjectFile::HeadA, {}, head_a_, sizeof(head_a_)) || + !resolveObjectPath(request_.object, ObjectFile::HeadB, {}, head_b_, sizeof(head_b_)) || + !resolveObjectPath(request_.object, ObjectFile::ReservationA, request_.operation, reservation_a_, sizeof(reservation_a_)) || + !resolveObjectPath(request_.object, ObjectFile::ReservationB, request_.operation, reservation_b_, sizeof(reservation_b_)) || + !resolveObjectPath(request_.object, ObjectFile::SignedManifest, request_.revision_hash, manifest_path_, sizeof(manifest_path_)) || + !resolveObjectPath(request_.object, ObjectFile::PublicationDirectory, request_.operation, directory_path_, sizeof(directory_path_)) || + !resolveObjectPath(request_.object, ObjectFile::PublicationA, request_.operation, job_a_, sizeof(job_a_)) || + !resolveObjectPath(request_.object, ObjectFile::PublicationB, request_.operation, job_b_, sizeof(job_b_))) return fail(Status::InvalidField); + if(!read_only_) + { + const auto loaded = heads_.load(head_, head_verify_, scratch, capacity); + if (loaded != HeadStoreResult::Ready) return fail(loaded == HeadStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + if (!requestMatchesHead()) return fail(Status::Conflict); + } + const auto reserved = reservations_.load(reservation_, reservation_verify_, scratch, capacity); + if (reserved != ReservationStoreResult::Ready) return fail(reserved == ReservationStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict); + if (reader_.begin(manifest_path_, signed_bytes_, sizeof(signed_bytes_)) != FileIntegrityResult::Pending) return fail(Status::StorageUnavailable); + stage_ = Stage::Reading; + return Status::Busy; + } + Status fail(Status status) + { + reader_.cancel(); + file_check_.cancel(); + receipt_reader_.cancel(); + manifest_.verified = false; + stage_ = Stage::Failed; + status_ = status; + return status; + } + track_sharing::PublicationDirectory directory_, directory_verify_; + uint8_t bindings_[112]{}, author_[64]{}, author_hash_[32]{}; + track_sharing::CommitRequest request_; + uint32_t media_; + char head_a_[160]{}, head_b_[160]{}, reservation_a_[160]{}, reservation_b_[160]{}, manifest_path_[160]{}, + directory_path_[160]{}, job_a_[160]{}, job_b_[160]{}, kml_path_[160]{}, receipt_path_[160]{}; + uint8_t signed_bytes_[4166]{}, workspace_[4200]{}; + track_sharing::ObjectHead head_, head_next_, head_verify_; + track_sharing::RevisionReservation reservation_, reservation_verify_; + track_sharing::PublicationJournal desired_, job_, job_verify_; + track_sharing::VerifiedManifestView manifest_; + track_sharing::VerifiedReceiptView receipt_; + mbedtls_sha256_context context_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + platform::esp::storage::SnapshotStreamSha256 hash_; + platform::esp::storage::TrackManifestCrypto crypto_; + track_sharing::SignedManifestReader reader_; + track_sharing::FileIntegrityCheck file_check_; + track_sharing::ObjectHeadStore heads_; + track_sharing::ReservationStore reservations_; + track_sharing::PublicationDirectoryStore directories_; + track_sharing::PublicationJournalStore jobs_; + platform::esp::storage::TrackReceiptCrypto receipt_crypto_; + track_sharing::PublicationReceiptLoader receipt_reader_; + Stage stage_ = Stage::Idle; + Status status_ = Status::Busy; + bool identity_verified_ = false; + bool read_only_ = false; +}; +static_assert(sizeof(TrackPublicationAdmission) <= 14336, "Publication admission must fit transient PSRAM"); +inline TrackPublicationAdmission* createPublicationAdmission(const track_sharing::CommitRequest& request, const track_sharing::PublicationDirectory& directory, + uint32_t media, const uint8_t* author, const uint8_t* author_hash, bool read_only = false) +{ + if (!author || !author_hash || !request.generation || !request.revision || !track_sharing::publicationDirectoryValid(directory) || + media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + const geocaching::ByteView fields[] = {request.operation, request.object, request.revision_hash, request.directory, request.directory_epoch}; + const size_t lengths[] = {16, 32, 32, 16, 16}; + for (size_t index = 0; index < 5; ++index) + { + const auto field = fields[index]; + if (!field.data || field.size != lengths[index]) return nullptr; + uint8_t bits = 0; + for (size_t i = 0; i < field.size; ++i) bits |= field.data[i]; + if (!bits) return nullptr; + } + if (std::memcmp(directory.delivery, request.directory.data, 16) || std::memcmp(directory.epoch, request.directory_epoch.data, 16)) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackPublicationAdmission), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* created = new (memory) TrackPublicationAdmission(request, directory, media, author, author_hash, read_only); + if (created->hasBackingStorage()) return created; + created->~TrackPublicationAdmission(); + heap_caps_free(memory); + return nullptr; +} +inline void destroyPublicationAdmission(TrackPublicationAdmission* owner) +{ + if (!owner) return; + owner->~TrackPublicationAdmission(); + heap_caps_free(owner); +} +} // namespace hostlink::management::publication_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_execution.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_execution.h new file mode 100644 index 00000000..8d09a767 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_execution.h @@ -0,0 +1,192 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_directories_runtime.h" +#include "platform/esp/idf_common/hostlink_track_publication_admission.h" +#include "track_sharing/publication_network_session.h" + +namespace hostlink::management::publication_runtime +{ +// Owns a validated SD job after successful binding handover. Scheduler visits +// and callbacks share the existing router lock. Sending occurs outside it, +// using immutable staged bytes and copied destination/current author fields. +// No USB session dependency, separate task, queue or internal-RAM fallback. +class TrackPublicationExecution +{ + public: + using Jobs = track_sharing::PublicationJournalStore; + using Receipts = track_sharing::SignedManifestStore; + struct Lease + { + TrackPublicationExecution& owner; + bool holds(const track_sharing::PublicationJournal& job) const { return owner.holds(job); } + }; + struct Sender + { + TrackPublicationExecution& owner; + chat::MeshSendResult sendTrackData(const uint8_t* destination, chat::lxmf::ByteSpan bytes, const uint8_t* author, uint32_t generation, bool response, std::array* hash) + { + return owner.sendTrackData(destination, bytes, author, generation, response, hash); + } + }; + struct Nonce + { + TrackPublicationExecution& owner; + bool fill(uint8_t* output, size_t size) { return owner.fill(output, size); } + }; + using Session = track_sharing::PublicationNetworkSession; + static TrackPublicationExecution* create(TrackPublicationAdmission*& input, chat::IMeshAdapter& adapter, const uint8_t* local, + chat::TrackBindingChange& changed) + { + changed = chat::TrackBindingChange::Conflict; + if (!input || input->readOnly() || !input->ready() || !track_sharing::networkRequestId({local, 16})) return nullptr; + constexpr auto caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + if (heap_caps_get_free_size(caps) < sizeof(TrackPublicationExecution) + 8192 || heap_caps_get_largest_free_block(caps) < sizeof(TrackPublicationExecution)) + { + changed = chat::TrackBindingChange::Busy; + return nullptr; + } + void* memory = heap_caps_malloc(sizeof(TrackPublicationExecution), caps); + if (!memory) + { + changed = chat::TrackBindingChange::Busy; + return nullptr; + } + auto* created = new (memory) TrackPublicationExecution(*input, adapter, local); + if (!created->initialize()) + { + created->~TrackPublicationExecution(); + heap_caps_free(memory); + return nullptr; + } + changed = directory_runtime::handover(adapter, &created->network_.binding()); + if (changed != chat::TrackBindingChange::Applied) + { + created->~TrackPublicationExecution(); + heap_caps_free(memory); + return nullptr; + } + created->owns_ = true; + input = nullptr; + return created; + } + static bool release(TrackPublicationExecution*& work) + { + if (!work) return true; + const auto changed = work->adapter_.replaceTrackDelivery(&work->network_.binding(), nullptr); + if (changed != chat::TrackBindingChange::Applied && changed != chat::TrackBindingChange::Conflict) return false; + work->~TrackPublicationExecution(); + heap_caps_free(work); + work = nullptr; + return true; + } + track_sharing::NetworkSessionResult advance(uint64_t now) + { + now_ = now; + const auto visited = adapter_.visitTrackDelivery(&network_.binding(), author_, generation_, drive, this); + if (visited != chat::TrackBindingChange::Applied) return visited == chat::TrackBindingChange::Busy ? track_sharing::NetworkSessionResult::Waiting : track_sharing::NetworkSessionResult::Conflict; + if (!bytes_) return result_; + const auto sent = adapter_.sendTrackData(destination_, {wire_, bytes_}, author_, generation_, false, nullptr); + if (sent.ok) return track_sharing::NetworkSessionResult::Queued; + rejected_ = true; + // Busy leaves the rejection marker for the next serialized visit. + adapter_.visitTrackDelivery(&network_.binding(), author_, generation_, reject, this); + return track_sharing::NetworkSessionResult::Rejected; + } + chat::TrackBindingChange snapshot(track_sharing::PublicationJournal& output) + { + struct Copy {TrackPublicationExecution& work;track_sharing::PublicationJournal& output;} copy{*this,output}; + return adapter_.visitTrackDelivery(&network_.binding(),author_,generation_,[](void* value) + { + auto& data=*static_cast(value);data.output=data.work.input_.job_; + },©); + } + Status cancel(geocaching::ByteView operation,uint32_t generation,track_sharing::PublicationJournal& output) + { + if(!operation.data || operation.size!=16 || !generation)return Status::InvalidField; + if(generation!=input_.request_.generation)return Status::StaleIdentity; + struct Cancel + { + TrackPublicationExecution& work;geocaching::ByteView operation;track_sharing::PublicationJournal& output; + track_sharing::PublicationStoreResult result=track_sharing::PublicationStoreResult::Conflict; + } call{*this,operation,output}; + const auto visited=adapter_.visitTrackDelivery(&network_.binding(),author_,generation_,[](void* value) + { + auto& data=*static_cast(value);auto& work=data.work; + if(std::memcmp(data.operation.data,work.input_.job_.operation,16))return; + data.result=work.network_.cancel(work.generation_,{work.input_.author_hash_,32}); + if(data.result==track_sharing::PublicationStoreResult::Ready || data.result==track_sharing::PublicationStoreResult::StorageFailure) + {work.bytes_=0;work.rejected_=false;} + if(data.result==track_sharing::PublicationStoreResult::Ready)data.output=work.input_.job_; + },&call); + if(visited!=chat::TrackBindingChange::Applied)return visited==chat::TrackBindingChange::Busy?Status::Busy:Status::StaleIdentity; + return call.result==track_sharing::PublicationStoreResult::Ready?Status::Ok: + call.result==track_sharing::PublicationStoreResult::StorageFailure?Status::StorageUnavailable:Status::Conflict; + } + bool holds(const track_sharing::PublicationJournal& job) const + { + return &job == &input_.job_ && input_.ready() && track_sharing::publicationDirectoryMatches(input_.directory_, job) && + job.generation == input_.request_.generation && !std::memcmp(job.author_hash, input_.author_hash_, 32); + } + bool fill(uint8_t* output, size_t size) + { + if (!output || size != 16) return false; + esp_fill_random(output, size); + return track_sharing::networkRequestId({output, size}); + } + chat::MeshSendResult sendTrackData(const uint8_t* destination, chat::lxmf::ByteSpan bytes, const uint8_t* author, uint32_t generation, + bool response, std::array* hash) + { + if (hash) hash->fill(0); + if (!destination || !author || bytes.data != wire_ || !bytes.size || bytes.size > sizeof(wire_) || response || + generation != generation_ || std::memcmp(author, author_, 64)) return chat::MeshSendResult::fail(chat::MeshOperationFailure::InvalidInput); + std::memcpy(destination_, destination, 16); + bytes_ = bytes.size; + return chat::MeshSendResult::success(0); // Staging only; dispatch follows outside router lock. + } + + private: + TrackPublicationExecution(TrackPublicationAdmission& input, chat::IMeshAdapter& adapter, const uint8_t* local) + : input_(input), adapter_(adapter), receipts_(input.io_, receipt_temporary_, receipt_path_), lease_{*this}, sender_{*this}, nonce_{*this}, + network_(input.io_, input.jobs_, receipts_, input.receipt_crypto_, lease_, sender_, nonce_, input.job_, input.desired_, input.job_verify_, input.manifest_, input.receipt_, + {input.directory_.public_key, 64}, local, input.author_, input.kml_path_, wire_, sizeof(wire_), input.workspace_, sizeof(input.workspace_), file_, sizeof(file_), 30000, 3000, 8) {} + ~TrackPublicationExecution() + { + if (owns_) destroyPublicationAdmission(&input_); + } + bool initialize() + { + return track_sharing::resolveObjectPath(input_.request_.object, track_sharing::ObjectFile::ReceiptTemporary, input_.request_.operation, receipt_temporary_, sizeof(receipt_temporary_)) && + track_sharing::resolveObjectPath(input_.request_.object, track_sharing::ObjectFile::Receipt, input_.request_.operation, receipt_path_, sizeof(receipt_path_)); + } + static void reject(void* context) + { + auto& work = *static_cast(context); + if (work.rejected_) + { + work.network_.dispatchRejected(work.now_); + work.rejected_ = false; + } + } + static void drive(void* context) + { + auto& work = *static_cast(context); + reject(context); + work.bytes_ = 0; + work.result_ = work.network_.advance(work.now_); + } + TrackPublicationAdmission& input_; + chat::IMeshAdapter& adapter_; + char receipt_temporary_[160]{}, receipt_path_[160]{}; + uint8_t wire_[4300]{}, file_[256]{}, author_[64]{}, destination_[16]{}; + uint32_t generation_ = 0; + size_t bytes_ = 0; + uint64_t now_ = 0; + Receipts receipts_; + Lease lease_; + Sender sender_; + Nonce nonce_; + Session network_; + track_sharing::NetworkSessionResult result_ = track_sharing::NetworkSessionResult::Waiting; + bool owns_ = false, rejected_ = false; +}; +static_assert(sizeof(TrackPublicationExecution) <= 6144, "Execution context must remain transient PSRAM only"); +} // namespace hostlink::management::publication_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_request.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_request.h new file mode 100644 index 00000000..f7c7989e --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_request.h @@ -0,0 +1,183 @@ +#pragma once +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/idf_common/hostlink_track_publication_admission.h" +#include "platform/esp/idf_common/hostlink_track_publication_execution.h" +#include "track_sharing/network_request.h" + +namespace hostlink::management::publication_runtime +{ +// Caller owns one scheduler slot, outside CDC transport lifetime. Large +// admission buffers retire immediately at completion; only metadata remains. +class TrackPublicationRequestWork +{ + public: + TrackPublicationRequestWork(TrackPublicationAdmission* admission, const track_sharing::CommitRequest& request, + const track_sharing::PublicationDirectory& directory, const uint8_t* session, + const uint8_t* author_hash, uint32_t media, uint64_t now, bool retain = false) + : admission_(admission), directory_(directory), media_(media), last_(now), retain_(retain) + { + std::memcpy(session_, session, 16); + std::memcpy(job_.operation, request.operation.data, 16); + std::memcpy(job_.object, request.object.data, 32); + std::memcpy(job_.revision_hash, request.revision_hash.data, 32); + std::memcpy(job_.directory, request.directory.data, 16); + std::memcpy(job_.directory_epoch, request.directory_epoch.data, 16); + std::memcpy(job_.author_hash, author_hash, 32); + job_.generation = request.generation; + job_.revision = request.revision; + } + ~TrackPublicationRequestWork() { destroyPublicationAdmission(admission_); } + TrackPublicationRequestWork(const TrackPublicationRequestWork&) = delete; + TrackPublicationRequestWork& operator=(const TrackPublicationRequestWork&) = delete; + bool alive(uint64_t now, bool connected) const { return connected && media_ == sd::sd_media_session() && now >= last_ && now - last_ < 30000; } + bool matchesSession(const uint8_t* session) const { return session && !std::memcmp(session, session_, 16); } + bool matches(const uint8_t* session, const track_sharing::CommitRequest& request, const track_sharing::PublicationDirectory& directory) const + { + if (!matchesSession(session) || request.generation != job_.generation || request.revision != job_.revision || + !track_sharing::publicationDirectoriesEqual(directory_, directory)) return false; + const geocaching::ByteView source[] = {request.operation, request.object, request.revision_hash, request.directory, request.directory_epoch}; + const geocaching::ByteView expected[] = {{job_.operation, 16}, {job_.object, 32}, {job_.revision_hash, 32}, {job_.directory, 16}, {job_.directory_epoch, 16}}; + for (size_t i = 0; i < 5; ++i) + if (!source[i].data || source[i].size != expected[i].size || std::memcmp(source[i].data, expected[i].data, expected[i].size)) return false; + return true; + } + bool renew(uint64_t now) + { + if (!alive(now, true)) return false; + last_ = now; + return true; + } + bool holdsAdmission() const { return admission_ != nullptr; } + TrackPublicationExecution* startExecution(chat::IMeshAdapter& adapter, const uint8_t* local, chat::TrackBindingChange& changed) + { + if (status_ != Status::Ok || !identity_verified_ || !admission_ || !admission_->ready()) + { + changed = chat::TrackBindingChange::Conflict; + return nullptr; + } + return TrackPublicationExecution::create(admission_, adapter, local, changed); + } + void suspendIdentity() { identity_verified_ = false; } + const track_sharing::PublicationJournal* result() const { return status_ == Status::Ok && identity_verified_ && media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() ? &job_ : nullptr; } + Status step(chat::IMeshAdapter& adapter, uint8_t* scratch, size_t capacity) + { + identity_verified_ = false; + if (!scratch || capacity < 448 || track_sharing::signingSpansOverlap(scratch, capacity, reinterpret_cast(this), sizeof(*this))) return Status::InvalidField; + if (media_ != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return retire(Status::StorageUnavailable); + if (!admission_) + { + if (status_ != Status::Ok) return status_; + if (!scratch || capacity < 448) return Status::InvalidField; + uint8_t hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, hash, generation); + if (identity != Status::Ok) return identity; + if (generation != job_.generation || std::memcmp(hash, job_.author_hash, 32)) return retire(Status::StaleIdentity); + identity_verified_ = true; + return Status::Ok; + } + const auto advanced = admission_->step(adapter, scratch, capacity); + if (advanced == Status::Busy) return advanced; + if (advanced == Status::Ok && admission_->result()) + { + job_ = *admission_->result(); + identity_verified_ = true; + if (retain_) return status_ = Status::Ok; + } + return retire(advanced); + } + + private: + Status retire(Status status) + { + destroyPublicationAdmission(admission_); + admission_ = nullptr; + return status_ = status; + } + TrackPublicationAdmission* admission_; + track_sharing::PublicationDirectory directory_; + track_sharing::PublicationJournal job_; + uint8_t session_[16]{}; + uint32_t media_; + uint64_t last_; + Status status_ = Status::Busy; + bool identity_verified_ = false; + bool retain_ = false; +}; +static_assert(sizeof(TrackPublicationRequestWork) <= 384, "Completed admission retains metadata only"); +inline void releasePublicationRequest(TrackPublicationRequestWork*& work) +{ + if (!work) return; + work->~TrackPublicationRequestWork(); + heap_caps_free(work); + work = nullptr; +} +inline Status advancePublicationRequest(TrackPublicationRequestWork*& work, chat::IMeshAdapter* adapter, + uint8_t* scratch, size_t capacity, uint64_t now, bool connected) +{ + if (!work) return Status::Expired; + if (!work->alive(now, connected)) + { + releasePublicationRequest(work); + return Status::Expired; + } + if (!adapter) + { + work->suspendIdentity(); + return Status::Busy; + } + return work->step(*adapter, scratch, capacity); +} +// Internal local-admission handler. Caller must obtain directory from pin(), +// hold exclusive storage and register durable network work before advertising +// operation 83. This function does not enable capabilities or send radio data. +inline Status processPublicationRequest(chat::IMeshAdapter& adapter, TrackPublicationRequestWork*& work, + const EnvelopeView& request, const track_sharing::PublicationDirectory& directory, + uint64_t now, uint8_t* scratch, size_t capacity, size_t& written, bool retain = false) +{ + written = 0; + if (request.code != uint16_t(Operation::TrackCommit) || !request.session || !scratch || capacity < 448 || + !track_sharing::networkRequestId({request.session, 16})) return Status::InvalidField; + track_sharing::CommitRequest decoded; + if (!track_sharing::decodeCommitRequest({request.body, request.body_size}, decoded)) return Status::InvalidField; + if (!track_sharing::publicationDirectoryValid(directory) || std::memcmp(directory.delivery, decoded.directory.data, 16) || + std::memcmp(directory.epoch, decoded.directory_epoch.data, 16)) return Status::InvalidField; + auto* key = scratch + capacity - 64; + if (track_sharing::signingSpansOverlap(request.body, request.body_size, key, 64) || + track_sharing::signingSpansOverlap(request.session, 16, key, 64) || + track_sharing::signingSpansOverlap(reinterpret_cast(&directory), sizeof(directory), scratch, capacity)) return Status::InvalidField; + if (work && track_sharing::signingSpansOverlap(scratch, capacity, reinterpret_cast(work), sizeof(*work))) return Status::InvalidField; + if (work) work->suspendIdentity(); + uint8_t author_hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, key, 64, author_hash, generation); + if (identity != Status::Ok) return identity; + if (generation != decoded.generation) return Status::StaleIdentity; + if (work && !work->alive(now, true)) releasePublicationRequest(work); + if (work && !work->matches(request.session, decoded, directory)) return Status::Conflict; + if (!work) + { + auto* admission = createPublicationAdmission(decoded, directory, sd::sd_media_session(), key, author_hash); + if (!admission) return Status::Busy; + void* memory = heap_caps_malloc(sizeof(TrackPublicationRequestWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) + { + destroyPublicationAdmission(admission); + return Status::Busy; + } + work = new (memory) TrackPublicationRequestWork(admission, decoded, directory, request.session, author_hash, sd::sd_media_session(), now, retain); + } + const auto advanced = work->step(adapter, scratch, capacity); + if (advanced != Status::Ok && advanced != Status::Busy) return advanced; + if (!work->renew(now)) return Status::Expired; + if (advanced == Status::Busy) return advanced; + const auto* result = work->result(); + if (!result) return Status::Internal; + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(8) || !writer.binary({result->operation, 16}) || !writer.binary({result->object, 32}) || + !writer.binary({result->revision_hash, 32}) || !writer.unsignedInteger(result->generation) || !writer.unsignedInteger(result->revision) || + !writer.binary({result->directory, 16}) || !writer.binary({result->directory_epoch, 16}) || !writer.unsignedInteger(1)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +} // namespace hostlink::management::publication_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_runtime.h new file mode 100644 index 00000000..da0bbdfe --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_publication_runtime.h @@ -0,0 +1,344 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_publication_request.h" +#include "platform/esp/idf_common/hostlink_track_read_owner.h" +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +#include "platform/esp/idf_common/hostlink_track_history_owner.h" +#endif +namespace hostlink::management::publication_runtime +{ +// Only one resident pointer (4 bytes on ESP). All scheduler/control records are +// admitted PSRAM, and accepted execution outlives its USB request metadata. +struct Controller +{ + chat::IMeshAdapter* adapter = nullptr; + TrackPublicationRequestWork* request = nullptr; + TrackPublicationExecution* execution = nullptr; + track_read_runtime::TrackReadOwner* read = nullptr; + track_sharing::ReadExchangeState read_state = track_sharing::ReadExchangeState::Idle; + bool read_retiring = false; + bool read_directory_cached = false; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + HistoryOwner* history = nullptr; +#endif + track_sharing::PublicationDirectory directory; + track_sharing::PublicationJournal latest; + track_sharing::CommitRequest decoded; + uint8_t local[16]{}; + uint64_t last = 0; + bool accepted = false, retiring = false; + bool history_cached = false; +}; +static_assert(sizeof(Controller) <= 512, "Publication controller must retain metadata only"); +inline Controller* controller = nullptr; +inline bool holdsStorage() +{ + return controller && (controller->read || controller->execution || (controller->request && controller->request->holdsAdmission()) +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + || controller->history +#endif + ); +} +inline bool destroyController() +{ + if (!controller) return true; + if (!track_read_runtime::TrackReadOwner::release(controller->read)) return false; + if (!TrackPublicationExecution::release(controller->execution)) return false; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + releaseHistory(controller->history); +#endif + releasePublicationRequest(controller->request); + controller->~Controller(); + heap_caps_free(controller); + controller = nullptr; + return true; +} +inline void disconnect() +{ + if (!controller) return; + releasePublicationRequest(controller->request); + if (!controller->execution) destroyController(); +} +inline void closeSession(const uint8_t* session) +{ + if (controller && controller->read_directory_cached && session && !std::memcmp(controller->local,session,16)) + { + if (controller->read) controller->read_retiring = true; + disconnect(); + return; + } + if (controller && controller->read && controller->read->matchesSession(session)) + { + controller->read_retiring = true; + disconnect(); + return; + } +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if(controller && controller->history && controller->history->matchesSession(session))releaseHistory(controller->history); +#endif + if (controller && controller->request && controller->request->matchesSession(session)) disconnect(); +} +inline void advancePending(chat::IMeshAdapter* adapter, uint8_t* scratch, size_t capacity, uint64_t now, bool connected) +{ + if (!connected) + { + disconnect(); + return; + } + if (controller && controller->request && !controller->execution) + advancePublicationRequest(controller->request, adapter, scratch, capacity, now, true); +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if(controller && controller->history && adapter==controller->adapter) + controller->history->advance(*adapter,scratch,capacity); +#endif +} +inline void tick(uint64_t now) +{ + if (!controller) return; + if (controller->read) + { + if (!controller->read_retiring) + { + auto state = controller->read_state; + const auto changed = controller->read->advance(now, state); + if (changed == chat::TrackBindingChange::Applied) + { + if (state != controller->read_state) controller->last = now; + controller->read_state = state; + if (state != track_sharing::ReadExchangeState::Waiting && state != track_sharing::ReadExchangeState::Ready) + controller->read_retiring = true; + } + else if (changed != chat::TrackBindingChange::Busy) controller->read_retiring = true; + if (state == track_sharing::ReadExchangeState::Ready && (now < controller->last || now - controller->last >= 30000)) + controller->read_retiring = true; + } + if (controller->read_retiring && track_read_runtime::TrackReadOwner::release(controller->read)) + destroyController(); + return; + } + if (controller->execution) + { + if (!controller->retiring) + { + const auto result = controller->execution->advance(now); + controller->execution->snapshot(controller->latest); + if (result == track_sharing::NetworkSessionResult::Confirmed || result == track_sharing::NetworkSessionResult::Conflict || + result == track_sharing::NetworkSessionResult::StorageFailure || result == track_sharing::NetworkSessionResult::Paused) controller->retiring = true; + } + if (controller->retiring && TrackPublicationExecution::release(controller->execution)) + { + releasePublicationRequest(controller->request); + controller->last = now; + } + } + else if (!controller->request && (now < controller->last || now - controller->last >= 30000)) destroyController(); +} +inline Status encodeAdmission(const track_sharing::PublicationJournal& result, uint8_t* output, size_t capacity, size_t& written) +{ + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(8) || !writer.binary({result.operation, 16}) || !writer.binary({result.object, 32}) || !writer.binary({result.revision_hash, 32}) || + !writer.unsignedInteger(result.generation) || !writer.unsignedInteger(result.revision) || !writer.binary({result.directory, 16}) || + !writer.binary({result.directory_epoch, 16}) || !writer.unsignedInteger(1)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +// JobGet [operation, generation, 1] selects tracks. Retained memory is evidence +// from the authenticated executor only; missing history must not imply success. +inline Status getStatus(chat::IMeshAdapter& adapter, geocaching::ByteView operation, uint32_t generation, + uint8_t* output, size_t capacity, size_t& written, const uint8_t* session=nullptr, uint64_t now=0) +{ + written = 0; + if (!output || capacity < 448 || !operation.data || operation.size != 16 || !generation) return Status::InvalidField; + if (controller && controller->read) return Status::Busy; + if (!controller || (!controller->accepted && !controller->history_cached) || controller->adapter != &adapter || + std::memcmp(operation.data, controller->latest.operation, 16)) + { +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if(!session)return Status::InvalidField; + if(controller && (controller->adapter!=&adapter || controller->execution || controller->request))return Status::Busy; + if(!controller) + { + void* memory=heap_caps_malloc(sizeof(Controller),MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT); + if(!memory)return Status::Busy; + controller=new(memory) Controller;controller->adapter=&adapter; + } + controller->last=now; + if(controller->history && !controller->history->matches(operation,generation,session))releaseHistory(controller->history); + if(!controller->history)controller->history=createHistory(operation,generation,session); + if(!controller->history)return Status::Busy; + const auto status=controller->history->step(adapter,output,capacity); + if(status!=Status::Ok){if(status!=Status::Busy)releaseHistory(controller->history);return status;} + if(!controller->history->result())return Status::Busy; + controller->latest=*controller->history->result(); + controller->history_cached=true; + releaseHistory(controller->history); +#else + (void)session;(void)now; + return Status::Expired; +#endif + } + if(controller->history_cached && now)controller->last=now; + if (generation != controller->latest.generation) return Status::StaleIdentity; + if (controller->execution) + { + const auto result = controller->execution->snapshot(controller->latest); + if (result != chat::TrackBindingChange::Applied) return result == chat::TrackBindingChange::Busy ? Status::Busy : Status::StaleIdentity; + } + else + { + uint8_t hash[32]; + uint32_t actual = 0; + const auto result = upload_runtime::readUploadAuthor(adapter, output + capacity - 64, 64, hash, actual); + if (result != Status::Ok) return result; + if (actual != generation || std::memcmp(hash, controller->latest.author_hash, 32)) return Status::StaleIdentity; + } + const auto& job = controller->latest; + geocaching::protocol::CmpWriter writer(output, capacity - 64); + if (!writer.array(14) || !writer.binary({job.operation, 16}) || !writer.binary({job.object, 32}) || + !writer.binary({job.revision_hash, 32}) || !writer.unsignedInteger(job.generation) || !writer.unsignedInteger(job.revision) || + !writer.binary({job.directory, 16}) || !writer.binary({job.directory_epoch, 16}) || !writer.binary({job.file_hash, 32}) || + !writer.unsignedInteger(job.file_bytes) || !writer.unsignedInteger(job.offset) || !writer.unsignedInteger(uint8_t(job.phase)) || + !writer.unsignedInteger(job.sequence) || !writer.binary({job.author_hash, 32}) || + !writer.unsignedInteger(controller->execution && !controller->retiring ? 1 : 0)) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +inline Status cancelStatus(chat::IMeshAdapter& adapter,geocaching::ByteView operation,uint32_t generation, + uint8_t* output,size_t capacity,size_t& written) +{ + written=0; + if(!operation.data || operation.size!=16 || !generation || !output || capacity<448)return Status::InvalidField; + if(!controller)return Status::Expired; + if(controller->adapter!=&adapter)return Status::Conflict; + if(controller->execution) + { + const auto status=controller->execution->cancel(operation,generation,controller->latest); + if(status!=Status::Ok)return status; + controller->retiring=true; + } + else + { + if(std::memcmp(operation.data,controller->latest.operation,16))return Status::Expired; + if(generation!=controller->latest.generation)return Status::StaleIdentity; + if(controller->latest.phase!=track_sharing::PublicationPhase::Cancelled) + return controller->latest.phase==track_sharing::PublicationPhase::Publishing || controller->latest.phase==track_sharing::PublicationPhase::Confirmed? + Status::Conflict:Status::Unsupported; + } + return getStatus(adapter,operation,generation,output,capacity,written); +} +inline Status processCommit(chat::IMeshAdapter& adapter, const EnvelopeView& request, uint64_t now, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!request.session || request.code != uint16_t(Operation::TrackCommit) || !scratch || capacity < 448) return Status::InvalidField; + if (!controller) + { + void* memory = heap_caps_malloc(sizeof(Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + controller = new (memory) Controller; + controller->adapter = &adapter; + controller->last = now; + } + auto& state = *controller; + if (state.read) return Status::Busy; +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + if(state.history)return Status::Busy; +#endif + if (state.adapter != &adapter) return Status::Conflict; + if (!track_sharing::decodeCommitRequest({request.body, request.body_size}, state.decoded)) return Status::InvalidField; + if (state.accepted) + { + const auto& decoded = state.decoded; + if (decoded.generation != state.latest.generation || decoded.revision != state.latest.revision || + std::memcmp(decoded.operation.data, state.latest.operation, 16) || std::memcmp(decoded.object.data, state.latest.object, 32) || + std::memcmp(decoded.revision_hash.data, state.latest.revision_hash, 32) || std::memcmp(decoded.directory.data, state.latest.directory, 16) || + std::memcmp(decoded.directory_epoch.data, state.latest.directory_epoch, 16)) return Status::Conflict; + if (state.execution) + { + const auto snapshot = state.execution->snapshot(state.latest); + if (snapshot != chat::TrackBindingChange::Applied) return snapshot == chat::TrackBindingChange::Busy ? Status::Busy : Status::StaleIdentity; + } + else + { + uint8_t hash[32]; + uint32_t generation = 0; + const auto identity = upload_runtime::readUploadAuthor(adapter, scratch + capacity - 64, 64, hash, generation); + if (identity != Status::Ok) return identity; + if (generation != state.latest.generation || std::memcmp(hash, state.latest.author_hash, 32)) return Status::StaleIdentity; + } + state.last = now; + return encodeAdmission(state.latest, scratch, capacity, written); + } + const auto pinned = directory_runtime::pin(adapter, request.session, state.decoded.directory, state.decoded.directory_epoch, now, state.directory); + if (pinned != Status::Ok) return pinned; + const auto prepared = processPublicationRequest(adapter, state.request, request, state.directory, now, scratch, capacity, written, true); + if (prepared != Status::Ok) return prepared; + state.latest = *state.request->result(); + written = 0; + if (!adapter.trackLocalDestination(state.local)) return Status::Busy; + chat::TrackBindingChange changed; + state.execution = state.request->startExecution(adapter, state.local, changed); + if (!state.execution) return changed == chat::TrackBindingChange::Busy ? Status::Busy : Status::Conflict; + state.accepted = true; + state.last = now; + return encodeAdmission(state.latest, scratch, capacity, written); +} +// Typed entry point for USB decoding. The caller checks snapshot/upload/prepare +// exclusion first; this controller enforces publication/history/read exclusion. +// Pin the directory again here: a browser-supplied public key is never trusted. +inline Status beginRead(chat::IMeshAdapter& adapter, uint8_t operation, geocaching::ByteView request, + geocaching::ByteView session, geocaching::ByteView delivery, geocaching::ByteView epoch, + geocaching::ByteView author, uint32_t generation, + const track_sharing::ReadValidationContext& expected, uint64_t now) +{ + if (!track_sharing::networkRequestId(request) || !track_sharing::networkRequestId(session)) return Status::InvalidField; + if (controller && (controller->read || controller->execution || controller->request +#if (defined(ESP_PLATFORM) && !defined(ARDUINO)) || defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + || controller->history +#endif + )) return Status::Busy; + const bool cached = controller && controller->read_directory_cached && controller->adapter == &adapter && + now >= controller->last && now-controller->last < 30000 && !std::memcmp(controller->local,session.data,16) && + track_sharing::readSpan(delivery,16) && track_sharing::readSpan(epoch,16) && + !std::memcmp(controller->directory.delivery,delivery.data,16) && !std::memcmp(controller->directory.epoch,epoch.data,16); + if (!cached) + { + if (controller && !destroyController()) return Status::Busy; + void* memory = heap_caps_malloc(sizeof(Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + controller = new (memory) Controller; + controller->adapter = &adapter; + const auto pinned = directory_runtime::pin(adapter, session.data, delivery, epoch, now, controller->directory); + if (pinned != Status::Ok) { destroyController(); return pinned; } + } + controller->last = now; + controller->read = track_read_runtime::TrackReadOwner::create(adapter, operation, request, session, controller->directory, author, generation, expected); + if (!controller->read) { destroyController(); return Status::Busy; } + const auto changed = directory_runtime::handoverRead(adapter, *controller->read, now); + if (changed != chat::TrackBindingChange::Applied) + { + destroyController(); + return changed == chat::TrackBindingChange::Busy ? Status::Busy : changed == chat::TrackBindingChange::Unsupported ? Status::Unsupported : Status::Conflict; + } + controller->read_state = track_sharing::ReadExchangeState::Waiting; + controller->read_retiring = false; + controller->read_directory_cached = true; + std::memcpy(controller->local,session.data,16); + return Status::Ok; +} +inline Status readResult(chat::IMeshAdapter& adapter, geocaching::ByteView session, geocaching::ByteView request, + uint32_t offset, uint16_t count, uint8_t* output, size_t capacity, size_t& written, + track_sharing::StoredReadResponse& metadata, uint64_t now) +{ + written = 0; + metadata = {}; + if (!track_sharing::networkRequestId(session) || !track_sharing::networkRequestId(request)) return Status::InvalidField; + if (!controller || !controller->read || controller->read_retiring) return Status::Expired; + if (controller->adapter != &adapter || !controller->read->matchesSession(session.data)) return Status::Unauthorized; + if (!controller->read->matchesRequest(request)) return Status::Conflict; + track_sharing::ReadStoreResult result; + const auto changed = controller->read->read(offset,count,output,capacity,written,metadata,result); + if (changed != chat::TrackBindingChange::Applied) return changed == chat::TrackBindingChange::Busy ? Status::Busy : Status::StaleIdentity; + if (result == track_sharing::ReadStoreResult::Ready) { controller->last = now; return Status::Ok; } + if (result == track_sharing::ReadStoreResult::Invalid) return metadata.ready ? Status::InvalidField : Status::Busy; + return result == track_sharing::ReadStoreResult::Conflict ? Status::Conflict : Status::StorageUnavailable; +} +} // namespace hostlink::management::publication_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_owner.h new file mode 100644 index 00000000..a8d07041 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_owner.h @@ -0,0 +1,281 @@ +#pragma once +#include "chat/ports/i_mesh_adapter.h" +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "track_sharing/publication_directory.h" +#include "track_sharing/read_network_exchange.h" +#include "track_sharing/read_query_binding.h" +#include "track_sharing/read_response_sink.h" +#include "track_sharing/read_response_validator.h" +#include +#include +#include + +namespace hostlink::management::track_read_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// Scheduler lends exclusive storage and a directory authenticated by discovery. +// All input views are copied before registration. USB disconnect must release +// this owner; Busy removal keeps it alive until callbacks have retired. +class TrackReadOwner +{ + public: + using Hash = platform::esp::storage::SnapshotStreamSha256; + using Crypto = platform::esp::storage::TrackManifestCrypto; + using Store = track_sharing::ReadResponseStore; + using Validator = track_sharing::ReadResponseValidator; + using Sink = track_sharing::ReadResponseSink; + struct Lease + { + TrackReadOwner& owner; + bool holds() const { return owner.live_.load(std::memory_order_acquire) && owner.mediaReady(); } + }; + using Exchange = track_sharing::ReadNetworkExchange; + + static TrackReadOwner* create(chat::IMeshAdapter& adapter, uint8_t operation, geocaching::ByteView request, + geocaching::ByteView session, const track_sharing::PublicationDirectory& directory, + geocaching::ByteView author, uint32_t generation, + const track_sharing::ReadValidationContext& expected) + { + using namespace track_sharing; + if (!readResponseOperation(operation) || !networkRequestId(request) || !networkRequestId(session) || + !readSpan(author, 64) || !generation || !publicationDirectoryValid(directory) || + !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + constexpr auto caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; + // Leave the existing transport's headroom; never spill into internal RAM. + if (heap_caps_get_free_size(caps) < sizeof(TrackReadOwner) + 8192 || heap_caps_get_largest_free_block(caps) < sizeof(TrackReadOwner)) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackReadOwner), caps); + if (!memory) return nullptr; + auto* result = new (memory) TrackReadOwner(adapter, operation, request, session, directory, author, generation, sd::sd_media_session()); + if (result->file_.has_backing_storage() && result->initialize(expected)) return result; + result->~TrackReadOwner(); + heap_caps_free(memory); + return nullptr; + } + static bool release(TrackReadOwner*& owner) + { + if (!owner) return true; + owner->live_.store(false, std::memory_order_release); + const auto changed = owner->exchange_.detach(); + if (changed != chat::TrackBindingChange::Applied && changed != chat::TrackBindingChange::Conflict) return false; + if (owner->paths_owned_ && owner->mediaReady()) + { + sd::sd_remove(owner->temporary_, owner->media_); + sd::sd_remove(owner->destination_, owner->media_); + } + owner->~TrackReadOwner(); + heap_caps_free(owner); + owner = nullptr; + return true; + } + bool matchesSession(const uint8_t* session) const { return session && !std::memcmp(session_, session, 16); } + bool matchesRequest(geocaching::ByteView request) const { return request.data && request.size == 16 && !std::memcmp(request_, request.data, 16); } + // Caller retires the previous directory owner only after Applied. Its + // binding must still be registered; no independently invoked bind/unbind. + chat::TrackBindingChange attach(uint64_t now, const chat::lxmf::TrackDeliveryBinding* previous) + { + if (!lease_.holds()) return chat::TrackBindingChange::Conflict; + size_t bytes = 0; + bool encoded = false; + if (operation_ == 0) + { + geocaching::protocol::CmpWriter writer(workspace_, sizeof(workspace_)); + encoded = track_sharing::networkRequestHeader(writer, 0, {request_, 16}, 8192, 0); + bytes = writer.size(); + } + else if (operation_ == 1) encoded = track_sharing::encodeTrackReadQuery({request_, 16}, query_, 8192, workspace_, sizeof(workspace_), bytes); + else if (operation_ == 2) encoded = track_sharing::encodeTrackReadManifest({request_, 16}, expected_.object, expected_.revision, 8192, workspace_, sizeof(workspace_), bytes); + else if (operation_ == 8) encoded = track_sharing::encodeTrackReadFile({request_, 16}, expected_.file_hash, expected_.offset, expected_.length, 8192, workspace_, sizeof(workspace_), bytes); + else if (operation_ == 9) encoded = track_sharing::encodeRegionCapabilities({request_,16},8192,workspace_,sizeof(workspace_),bytes); + else if (operation_ == 10) encoded = track_sharing::encodeRegionChildren({request_,16},regional_scope_.version,track_sharing::regionBytes(regional_scope_.parent),track_sharing::regionBytes(regional_scope_.after),query_.limit,8192,workspace_,sizeof(workspace_),bytes); + else if (operation_ == 11) encoded = track_sharing::encodeRegionDescriptor({request_,16},track_sharing::regionBytes(regional_scope_.id),regional_scope_.version,8192,workspace_,sizeof(workspace_),bytes); + else if (operation_ == 12) encoded = track_sharing::encodeRegionBoundaryChunk({request_,16},track_sharing::regionBytes(regional_scope_.id),regional_scope_.version,expected_.offset,expected_.length,8192,workspace_,sizeof(workspace_),bytes); + else if (operation_ == 13) encoded = track_sharing::encodeRegionQuery({request_,16},track_sharing::regionBytes(regional_scope_.id),regional_scope_.version,query_,8192,workspace_,sizeof(workspace_),bytes); + if (!encoded || bytes > 320) return chat::TrackBindingChange::Conflict; + return exchange_.attach(operation_, {request_, 16}, {directory_.delivery, 16}, {local_, 16}, {workspace_, bytes}, 8192, now, previous); + } + chat::TrackBindingChange advance(uint64_t now, track_sharing::ReadExchangeState& output) { return exchange_.advance(now, output); } + // Metadata and SD reads are serialized with delivery persistence. The caller + // must check Applied and Result::Ready before exposing bytes to USB. + chat::TrackBindingChange read(uint32_t offset, uint16_t count, uint8_t* output, size_t capacity, + size_t& written, track_sharing::StoredReadResponse& metadata, track_sharing::ReadStoreResult& result) + { + if (!output || !capacity || !track_sharing::networkInputDisjoint({output, capacity}, reinterpret_cast(this), sizeof(*this)) || + !track_sharing::networkInputDisjoint({reinterpret_cast(&metadata), sizeof(metadata)}, reinterpret_cast(this), sizeof(*this))) return chat::TrackBindingChange::Conflict; + written = 0; + metadata = {}; + result = track_sharing::ReadStoreResult::Invalid; + struct Call + { + TrackReadOwner& owner; + uint32_t offset; + uint16_t count; + uint8_t* output; + size_t capacity; + size_t& written; + track_sharing::StoredReadResponse& metadata; + track_sharing::ReadStoreResult& result; + } call{*this, offset, count, output, capacity, written, metadata, result}; + return adapter_.visitTrackDelivery( + exchange_.binding(), current_author_, current_generation_, [](void* value) + { + auto& data = *static_cast(value); + auto& self = data.owner; + if (!self.lease_.holds() || self.current_generation_ != self.generation_ || std::memcmp(self.current_author_, self.author_, 64) || !self.stored_.ready) return; + data.metadata = self.stored_; + if (!data.count) { data.result = track_sharing::ReadStoreResult::Ready; return; } + data.result = self.store_.read(self.stored_.bytes, data.offset, data.count, data.output, data.capacity, data.written); }, + &call); + } + + private: + TrackReadOwner(chat::IMeshAdapter& adapter, uint8_t operation, geocaching::ByteView request, geocaching::ByteView session, + const track_sharing::PublicationDirectory& directory, geocaching::ByteView author, uint32_t generation, uint32_t media) + : adapter_(adapter), directory_(directory), file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media), hash_(hash_context_), + store_(io_, temporary_, destination_), validator_(expected_, crypto_, hash_, manifest_, row_, page_, workspace_, sizeof(workspace_), ®ional_replies_), + sink_(store_, validator_, hash_, stored_, workspace_, sizeof(workspace_)), lease_{*this}, exchange_(adapter, sink_, lease_, author, generation), + generation_(generation), media_(media), operation_(operation) + { + std::memcpy(request_, request.data, 16); + std::memcpy(session_, session.data, 16); + std::memcpy(author_, author.data, 64); + expected_.directory_public = {directory_.public_key, 64}; + expected_.directory_epoch = {directory_.epoch, 16}; + } + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + bool copy(geocaching::ByteView source, uint8_t* target, size_t size, geocaching::ByteView& output, bool optional = false) + { + if (optional && !source.size) + { + output = {}; + return true; + } + if (!source.data || source.size != size) return false; + std::memcpy(target, source.data, size); + output = {target, size}; + return true; + } + bool initialize(const track_sharing::ReadValidationContext& input) + { + using namespace track_sharing; + if (adapter_.readDeviceAuthorKey(current_author_, current_generation_) != chat::DeviceAuthorResult::Ok || + current_generation_ != generation_ || std::memcmp(current_author_, author_, 64)) return false; + if (!adapter_.trackLocalDestination(local_) || !networkRequestId({local_, 16}) || !mediaReady()) return false; + if (operation_ >= 10) + { + if (!input.region || !copyRegionScope(*input.region)) return false; + expected_.region = ®ional_scope_; + } + if (operation_ == 1 || operation_ == 13) + { + if (!input.query || input.query->limit > (operation_ == 1 ? directory_.query_limit : 32)) return false; + query_ = *input.query; + if (!copy(input.query->author, query_author_, 64, query_.author, true) || + !copy(input.query->cursor_object, cursor_, 32, query_.cursor_object, true) || + !copy(input.query->cursor_binding, cursor_ + 32, 32, query_.cursor_binding, true)) return false; + if (operation_ == 1) + { + if (!deriveReadQueryBinding(query_, hash_, workspace_, sizeof(workspace_), query_binding_)) return false; + } + else if (query_.west < -1800000000 || query_.west > 1799999999 || query_.east < -1800000000 || query_.east > 1799999999 || + query_.south < -900000000 || query_.north > 900000000 || query_.south > query_.north || + !deriveRegionQueryBinding(regionBytes(regional_scope_.id),regional_scope_.version,query_,hash_,workspace_,sizeof(workspace_),query_binding_)) return false; + if (query_.has_cursor && std::memcmp(query_.cursor_binding.data,query_binding_,32)) return false; + expected_.query = &query_; + expected_.query_binding = {query_binding_, 32}; + } + else if (operation_ == 2) + { + if (!copy(input.object, reference_, 32, expected_.object) || !copy(input.revision, reference_ + 32, 32, expected_.revision, true)) return false; + } + else if (operation_ == 8 || operation_ == 12) + { + if (!input.file_bytes || input.file_bytes > (operation_ == 8 ? directory_.file_limit : 8388608) || !input.length || input.length > (operation_ == 8 ? directory_.chunk_limit : 1024) || + input.offset >= input.file_bytes || input.length > input.file_bytes - input.offset || !copy(input.file_hash, reference_, 32, expected_.file_hash)) return false; + expected_.file_bytes = input.file_bytes; + expected_.offset = input.offset; + expected_.length = input.length; + } + else if (operation_ == 10) + { + if (!input.query || !input.query->limit || input.query->limit > 32 || regional_scope_.parent_level < -1 || regional_scope_.parent_level > regional_scope_.depth || + (regional_scope_.parent.empty() != (regional_scope_.parent_level == -1))) return false; + query_.limit = input.query->limit; + expected_.query = &query_; + } + constexpr char root[] = "/trailmate/track-read"; + if ((!sd::sd_is_directory("/trailmate") && !sd::sd_mkdir("/trailmate")) || + (!sd::sd_is_directory(root) && !sd::sd_mkdir(root)) || !mediaReady()) return false; + constexpr char hex[] = "0123456789abcdef"; + size_t position = sizeof(root) - 1; + std::memcpy(temporary_, root, position); + temporary_[position++] = '/'; + for (const auto input_id : {geocaching::ByteView{session_, 16}, {request_, 16}}) + for (size_t i = 0; i < 16; ++i) + { + temporary_[position++] = hex[input_id.data[i] >> 4]; + temporary_[position++] = hex[input_id.data[i] & 15]; + } + std::memcpy(destination_, temporary_, position); + std::memcpy(temporary_ + position, ".tmp", 5); + std::memcpy(destination_ + position, ".cmp", 5); + uint8_t probe = 0; + if (sd::sd_read_file(temporary_, &probe, 1).status != sd::SdFileReadStatus::Missing || + sd::sd_read_file(destination_, &probe, 1).status != sd::SdFileReadStatus::Missing || !mediaReady()) return false; + paths_owned_ = true; + return true; + } + bool copyRegionScope(const track_sharing::RegionReadScope& input) + { + using namespace track_sharing; + if (!regionNamespace(input.name_space) || input.depth > 8 || !copy(input.version,reference_+32,32,regional_scope_.version)) return false; + const auto text = [](std::string_view source,char* target,size_t capacity,std::string_view& output) + { + if (source.size() > capacity || (source.size() && !source.data())) return false; + if (source.size()) std::memcpy(target,source.data(),source.size()); + output = {target,source.size()}; + return true; + }; + if (!text(input.name_space,regional_text_,24,regional_scope_.name_space) || !text(input.id,regional_text_+24,89,regional_scope_.id) || + !text(input.parent,regional_text_+113,89,regional_scope_.parent) || !text(input.after,regional_text_+202,89,regional_scope_.after) || + !regionVersion(regional_scope_.version) || + (!regional_scope_.id.empty() && !regionInNamespace(regional_scope_.id,regional_scope_.name_space)) || + (!regional_scope_.parent.empty() && !regionInNamespace(regional_scope_.parent,regional_scope_.name_space)) || + (!regional_scope_.after.empty() && !regionInNamespace(regional_scope_.after,regional_scope_.name_space)) || + (operation_ != 10 && regional_scope_.id.empty())) return false; + regional_scope_.depth = input.depth; + regional_scope_.parent_level = input.parent_level; + return true; + } + chat::IMeshAdapter& adapter_; + track_sharing::PublicationDirectory directory_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + mbedtls_sha256_context hash_context_; + Hash hash_; + Crypto crypto_; + char temporary_[96]{}, destination_[96]{}; + uint8_t workspace_[4200]{}, reference_[64]{}, query_author_[64]{}, cursor_[64]{}, query_binding_[32]{}; + uint8_t request_[16]{}, session_[16]{}, author_[64]{}, local_[16]{}, current_author_[64]{}; + track_sharing::ReadQuery query_; + track_sharing::ReadValidationContext expected_; + char regional_text_[291]{}; + track_sharing::RegionReadScope regional_scope_; + track_sharing::RegionReplyWorkspace regional_replies_; + track_sharing::VerifiedManifestView manifest_; + track_sharing::ReadQueryRow row_; + track_sharing::ReadQueryResponse page_; + track_sharing::StoredReadResponse stored_; + Store store_; + Validator validator_; + Sink sink_; + Lease lease_; + Exchange exchange_; + uint32_t generation_, media_, current_generation_ = 0; + uint8_t operation_; + bool paths_owned_ = false; + std::atomic live_{true}; +}; +static_assert(sizeof(TrackReadOwner) <= 8192, "Read owner is a transient PSRAM workspace, never an internal-RAM response queue"); +} // namespace hostlink::management::track_read_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_service.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_service.h new file mode 100644 index 00000000..b5f2d52d --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_read_service.h @@ -0,0 +1,100 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_publication_runtime.h" +#include "track_sharing/read_usb_request.h" +namespace hostlink::management::track_read_runtime +{ +struct ReadDecodeWork +{ + track_sharing::ReadUsbRequest decoded; + uint8_t author[64]{}, request[16]{}; +}; +static_assert(sizeof(ReadDecodeWork) <= 512, "Read command decoder is transient PSRAM metadata"); +inline Status processReadBegin(chat::IMeshAdapter& adapter, const EnvelopeView& input, uint64_t now, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!input.session || input.code != uint16_t(Operation::TrackReadBegin) || !output || capacity < 32) return Status::InvalidField; + void* memory = heap_caps_malloc(sizeof(ReadDecodeWork), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + auto* work = new (memory) ReadDecodeWork; + Status status = Status::InvalidField; + if (track_sharing::decodeReadUsbRequest({input.body, input.body_size}, work->decoded)) + { + auto& decoded = work->decoded; + std::memcpy(work->request, decoded.request.data, 16); + uint32_t generation = 0; + const auto author = adapter.readDeviceAuthorKey(work->author, generation); + if (author != chat::DeviceAuthorResult::Ok) + status = author == chat::DeviceAuthorResult::Unsupported ? Status::Unsupported : author == chat::DeviceAuthorResult::StorageError ? Status::StorageUnavailable + : Status::Busy; + else if (generation != decoded.generation) status = Status::StaleIdentity; + else status = publication_runtime::beginRead(adapter, decoded.operation, decoded.request, {input.session, 16}, decoded.directory, decoded.epoch, + {work->author, 64}, generation, decoded.expected, now); + if (status == Status::Ok) + { + // Owner has copied every frame view before the response overwrites + // command.payload. ACK uses the independent decode workspace. + geocaching::protocol::CmpWriter writer(output, capacity); + if (!writer.array(2) || !writer.binary({work->request, 16}) || !writer.unsignedInteger(decoded.operation)) status = Status::Internal; + else written = writer.size(); + } + } + work->~ReadDecodeWork(); + heap_caps_free(work); + return status; +} +inline Status processReadResult(chat::IMeshAdapter& adapter, const EnvelopeView& input, uint64_t now, + uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + if (!input.session || !output || capacity < 64) return Status::InvalidField; + const bool chunk = input.code == uint16_t(Operation::TrackReadChunk); + const bool release = input.code == uint16_t(Operation::TrackReadRelease); + if (!chunk && !release && input.code != uint16_t(Operation::TrackReadStatus)) return Status::InvalidField; + geocaching::protocol::CmpReader reader({input.body, input.body_size}); + geocaching::ByteView request; + size_t fields = 0; + uint64_t offset = 0, count = 0; + if (!reader.array(fields, 3) || fields != (chunk ? 3u : 1u) || !reader.binary(request, 16) || !track_sharing::networkRequestId(request) || + (chunk && (!reader.unsignedInteger(offset) || offset > 8191 || !reader.unsignedInteger(count) || !count || count > 320)) || !reader.finished()) return Status::InvalidField; + uint8_t id[16]; + std::memcpy(id, request.data, 16); + namespace runtime = publication_runtime; + if (release) + { + if (!runtime::controller || !runtime::controller->read) return Status::Expired; + if (runtime::controller->adapter != &adapter || !runtime::controller->read->matchesSession(input.session)) return Status::Unauthorized; + if (!runtime::controller->read->matchesRequest({id, 16})) return Status::Conflict; + runtime::controller->read_retiring = true; + if (!TrackReadOwner::release(runtime::controller->read)) return Status::Busy; + runtime::controller->read_retiring = false; + runtime::controller->read_state = track_sharing::ReadExchangeState::Idle; + runtime::controller->last = now; + output[0] = 0x90; + written = 1; + return Status::Ok; + } + track_sharing::StoredReadResponse metadata; + size_t ignored = 0; + auto status = runtime::readResult(adapter, {input.session, 16}, {id, 16}, 0, 0, output, capacity, ignored, metadata, now); + if (status != Status::Ok) return status; + geocaching::protocol::CmpWriter writer(output, capacity); + if (!chunk) + { + if (!writer.array(5) || !writer.binary({id, 16}) || !writer.unsignedInteger(metadata.operation) || !writer.unsignedInteger(metadata.status) || + !writer.unsignedInteger(metadata.bytes) || !writer.binary({metadata.digest, 32})) return Status::Internal; + written = writer.size(); + return Status::Ok; + } + if (offset >= metadata.bytes || count > metadata.bytes - offset) return Status::InvalidField; + if (!writer.array(4) || !writer.binary({metadata.digest, 32}) || !writer.unsignedInteger(metadata.bytes) || + !writer.unsignedInteger(offset) || !writer.binaryHeader(count)) return Status::Internal; + const size_t header = writer.size(); + if (count > capacity - header) return Status::LimitExceeded; + size_t copied = 0; + status = runtime::readResult(adapter, {input.session, 16}, {id, 16}, uint32_t(offset), uint16_t(count), output + header, capacity - header, copied, metadata, now); + if (status != Status::Ok || copied != count) return status == Status::Ok ? Status::StorageUnavailable : status; + written = header + copied; + return Status::Ok; +} +} // namespace hostlink::management::track_read_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_retention_admission.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_retention_admission.h new file mode 100644 index 00000000..980377fa --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_retention_admission.h @@ -0,0 +1,50 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_object_admission.h" +namespace hostlink::management::prepare_runtime +{ +// Bounded per-object history: 32 MiB and 128 files, including unknown files. +// Caller holds storage exclusion through the subsequent copy. No directory list. +inline Status admitRetainedKml(uint32_t media, geocaching::ByteView object, geocaching::ByteView file_hash, + uint32_t bytes, uint8_t* scratch, size_t capacity) +{ + constexpr uint64_t byte_limit = 33554432; + if (!scratch || capacity < 288 || !file_hash.data || file_hash.size != 32 || !bytes || bytes > 8388608) return Status::InvalidField; + if (track_sharing::signingSpansOverlap(scratch, capacity, file_hash.data, file_hash.size) || + track_sharing::signingSpansOverlap(scratch, capacity, object.data, object.size)) return Status::InvalidField; + if (!track_sharing::resolveObjectPath(object, track_sharing::ObjectFile::Directory, {}, reinterpret_cast(scratch), 160)) return Status::InvalidField; + sd::SdRuntimeDir directory; + const auto opened = directory.open_read_status(reinterpret_cast(scratch), media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + uint64_t total = 0, temporary_bytes = 0; + unsigned files = 0;bool has_temporary = false, has_final = false; + constexpr char hex[] = "0123456789abcdef"; + auto* name = reinterpret_cast(scratch + capacity - 128); + for (unsigned scan = 0; scan <= 128; ++scan) + { + bool is_directory = false;uint64_t length = 0, next = 0; + const auto entry = directory.read_next_info(name, 128, &is_directory, &length, &next); + if (entry == sd::SdDirReadStatus::Busy) return Status::Busy; + if (entry == sd::SdDirReadStatus::End) + { + const uint64_t additional = has_final ? 0 : bytes - temporary_bytes; + if ((!has_final && !has_temporary && files == 128) || additional > byte_limit - total) return Status::LimitExceeded; + return media == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + } + if (entry != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (is_directory || ++files > 128 || length > byte_limit - total) return Status::LimitExceeded; + total += length; + if (std::strlen(name) != 68) continue; + bool matches = true; + for (size_t i = 0; i < 32; ++i) matches = matches && name[2 * i] == hex[file_hash.data[i] >> 4] && name[2 * i + 1] == hex[file_hash.data[i] & 15]; + if (!matches) continue; + if (!std::strcmp(name + 64, ".kml")) has_final = true; + if (!std::strcmp(name + 64, ".ktm")) + { + if (length > bytes) return Status::Conflict; + has_temporary = true;temporary_bytes = length; + } + } + return Status::LimitExceeded; +} +} // namespace hostlink::management::prepare_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_snapshot_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_snapshot_owner.h new file mode 100644 index 00000000..4890d328 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_snapshot_owner.h @@ -0,0 +1,236 @@ +#pragma once + +#include "hostlink/management_envelope.h" +#include "hostlink/snapshot_space_budget.h" + +#include "hostlink/track_snapshot_selection.h" +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/common/storage/track_snapshot_service.h" +#include "platform/esp/idf_common/tracker_snapshot_lease.h" +#include +#include +#include + +namespace hostlink::management +{ +namespace snapshot_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +class TrackerSnapshotOwner +{ + public: + TrackerSnapshotOwner(const TrackSnapshotSelection& selection, uint64_t now) + : media(selection.media_session), last_touch(now), file(sd::SdRuntimeMemoryPolicy::PsramOnly), io(file, media), hash(context), + lease(io, source, temporary, destination, media), storage(io, hash, lease), builder(storage), task(builder) + { + constexpr char prefix[] = "/trackers/"; + std::memcpy(source, prefix, sizeof(prefix) - 1); + std::memcpy(source + sizeof(prefix) - 1, selection.filename.data(), selection.filename.size()); + source[sizeof(prefix) - 1 + selection.filename.size()] = '\0'; + constexpr char folder[] = "/trailmate/web-tracks/"; + constexpr char hex[] = "0123456789abcdef"; + std::memcpy(temporary, folder, sizeof(folder) - 1); + size_t position = sizeof(folder) - 1; + for (const auto byte : selection.id) + { + temporary[position++] = hex[byte >> 4]; + temporary[position++] = hex[byte & 15]; + } + std::memcpy(destination, temporary, position); + std::memcpy(temporary + position, ".new", 5); + std::memcpy(destination + position, ".tsn", 5); + } + bool sameSelection(const TrackSnapshotSelection& selection) const + { + const auto& metadata = task.metadata(); + return media == selection.media_session && metadata.bytes == selection.bytes && metadata.format == selection.format && + std::string_view(source + 10) == selection.filename; + } + uint32_t media; + uint64_t last_touch; + char source[128]{}, temporary[64]{}, destination[64]{}; + mbedtls_sha256_context context; + sd::SdRuntimeFile file; + sd::SdBorrowedRecordFileIo io; + platform::esp::storage::SnapshotStreamSha256 hash; + platform::esp::idf_common::TrackerSnapshotLease lease; + platform::esp::storage::TrackSnapshotFileStorage storage; + TrackSnapshotBuilder builder; + SnapshotBuildSession task; +}; +static_assert(sizeof(TrackerSnapshotOwner) <= 1024, "Snapshot owner must fit bounded transient PSRAM admission"); +// All access is serialized by the existing application command scheduler. +inline TrackerSnapshotOwner* owner = nullptr; +inline uint64_t nowMs() { return static_cast(esp_timer_get_time() / 1000); } +inline void destroyOwner() +{ + if (!owner || !owner->task.releasable()) return; + owner->~TrackerSnapshotOwner(); + heap_caps_free(owner); + owner = nullptr; +} +inline void advance(uint8_t* scratch, size_t capacity, bool connected) +{ + if (!owner) return; + const auto now = nowMs(); + owner->task.advance(scratch, capacity, now, connected); + if (owner->task.releasable() && (!connected || now < owner->last_touch || now - owner->last_touch >= SnapshotBuildSession::kIdleTimeoutMs)) destroyOwner(); +} +inline void closeSession(const uint8_t session[16]) +{ + if (!owner || !owner->task.cancel(session, owner->task.metadata().id)) return; + // Force result retirement after cleanup; a closed management session + // cannot keep the transient owner admitted while HostLink stays ready. + owner->last_touch = UINT64_MAX; +} +inline bool retireForStop() +{ + advance(nullptr, 0, false); + if (!owner) return true; + if (!owner->task.abandonForStop()) return false; + destroyOwner(); + return owner == nullptr; +} +inline bool uncommittedSnapshotName(const char* name) +{ + if (!name || std::strlen(name) != 36) return false; + for (unsigned i = 0; i < 32; ++i) + if (!((name[i] >= '0' && name[i] <= '9') || (name[i] >= 'a' && name[i] <= 'f') || + (name[i] >= 'A' && name[i] <= 'F'))) return false; + constexpr char suffix[] = ".new"; + for (unsigned i = 0; i < 4; ++i) + { + const char value = name[32 + i] >= 'A' && name[32 + i] <= 'Z' ? char(name[32 + i] + 32) : name[32 + i]; + if (value != suffix[i]) return false; + } + return true; +} +inline Status admitSpace(uint32_t media, uint32_t bytes, uint8_t* output, size_t capacity) +{ + if (owner) return Status::Busy; + if (!output || capacity < 384) return Status::Internal; + sd::SdRuntimeDir directory; + const auto opened = directory.open_read_status("/trailmate/web-tracks", media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + SnapshotSpaceBudget budget(bytes); + for (unsigned scan = 0; scan < 64; ++scan) + { + bool is_dir = false; + uint64_t size = 0, next = 0; + const auto result = directory.read_next_info(reinterpret_cast(output + capacity - 128), 128, &is_dir, &size, &next); + if (result == sd::SdDirReadStatus::Busy) return Status::Busy; + if (result == sd::SdDirReadStatus::End) + return media == sd::sd_media_session() && !sd::sd_external_block_owner_active() && budget.complete() ? Status::Ok : Status::StorageUnavailable; + if (result != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + auto* name = reinterpret_cast(output + capacity - 128); + if (!is_dir && uncommittedSnapshotName(name)) + { + // Admission runs only after the previous owner was retired. Keep + // the path in the same borrowed tail, away from request views. + constexpr char prefix[] = "/trailmate/web-tracks/"; + std::memmove(name + sizeof(prefix) - 1, name, 37); + std::memcpy(name, prefix, sizeof(prefix) - 1); + if (media != sd::sd_media_session() || sd::sd_external_block_owner_active() || !sd::sd_remove(name, media)) + return Status::StorageUnavailable; + continue; + } + if (!budget.add(size, is_dir)) return Status::LimitExceeded; + } + return Status::LimitExceeded; +} +inline Status build(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + TrackSnapshotSelection selection; + if (!request.session || !decodeTrackSnapshotSelection({request.body, request.body_size}, selection)) return Status::InvalidField; + const auto now = nowMs(); + if (owner && owner->task.matches(request.session, selection.id)) + { + if (!owner->sameSelection(selection)) return Status::Conflict; + owner->last_touch = now; + owner->task.touch(request.session, selection.id, now); + return encodeTrackSnapshotBuildState(owner->task, output, capacity, written) ? Status::Ok : Status::Internal; + } + if (owner) + { + if (!owner->task.releasable()) return Status::Busy; + destroyOwner(); + } + if (selection.media_session != sd::sd_media_session()) return Status::Conflict; + if (!sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + // Create only private directories; no internal-flash allocation fallback. + if ((!sd::sd_is_directory("/trailmate") && !sd::sd_mkdir("/trailmate")) || + (!sd::sd_is_directory("/trailmate/web-tracks") && !sd::sd_mkdir("/trailmate/web-tracks"))) return Status::StorageUnavailable; + if (selection.media_session != sd::sd_media_session() || sd::sd_external_block_owner_active()) return Status::Conflict; + const auto admission = admitSpace(selection.media_session, selection.bytes, output, capacity); + if (admission != Status::Ok) return admission; + void* memory = heap_caps_malloc(sizeof(TrackerSnapshotOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return Status::Busy; + owner = new (memory) TrackerSnapshotOwner(selection, now); + if (!owner->file.has_backing_storage()) + { + destroyOwner(); + return Status::Busy; + } + // Do not truncate an orphaned candidate or an existing immutable snapshot. + uint8_t probe = 0; + const auto temporary_status = sd::sd_read_file(owner->temporary, &probe, 1).status; + const auto destination_status = sd::sd_read_file(owner->destination, &probe, 1).status; + if (temporary_status != sd::SdFileReadStatus::Missing || destination_status != sd::SdFileReadStatus::Missing) + { + destroyOwner(); + return (temporary_status == sd::SdFileReadStatus::Ready || destination_status == sd::SdFileReadStatus::Ready) ? Status::Conflict : Status::StorageUnavailable; + } + const auto started = owner->task.begin(request.session, selection.id, selection.bytes, selection.format, now); + if (started != SnapshotBuildResult::Pending) + { + destroyOwner(); + return started == SnapshotBuildResult::Busy ? Status::Busy : Status::InvalidField; + } + return encodeTrackSnapshotBuildState(owner->task, output, capacity, written) ? Status::Ok : Status::Internal; +} +inline Status releaseTrackSnapshot(const EnvelopeView& request, platform::esp::storage::RecordFileIo& io, + uint32_t media, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + geocaching::ByteView raw; + if (!request.session || !reader.array(fields, 1) || fields != 1 || !reader.binary(raw, 16) || raw.size != 16 || !reader.finished()) return Status::InvalidField; + uint8_t id[16]; + std::memcpy(id, raw.data, 16); + if (owner && !owner->task.releasable()) return Status::Busy; + if (owner && !std::memcmp(owner->task.metadata().id, id, 16) && !owner->task.matches(request.session, id)) return Status::Unauthorized; + if (!output || capacity < 464) return Status::Internal; + // Reuse operation 81 validation to check the exact device-owned container. + // Its path remains in output's last 64 bytes after the one-byte read. + geocaching::protocol::CmpWriter writer(output, capacity - 64); + if (!writer.array(4) || !writer.binary({id, 16}) || !writer.unsignedInteger(0) || !writer.unsignedInteger(1) || !writer.nil()) return Status::Internal; + EnvelopeView read; + read.body = output; + read.body_size = writer.size(); + size_t ignored = 0; + const auto status = readTrackSnapshotService(read, io, output, capacity, ignored); + if (status != Status::Ok && status != Status::Expired) return status; + if (media != sd::sd_media_session() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + if (status == Status::Ok && !sd::sd_remove(reinterpret_cast(output + capacity - 64), media)) return Status::StorageUnavailable; + if (owner && !std::memcmp(owner->task.metadata().id, id, 16)) destroyOwner(); + output[0] = 0x90; + written = 1; + return Status::Ok; +} +inline Status cancel(const EnvelopeView& request, uint8_t* output, size_t capacity, size_t& written) +{ + written = 0; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + geocaching::ByteView id; + if (!request.session || !reader.array(fields, 1) || fields != 1 || !reader.binary(id, 16) || id.size != 16 || !reader.finished()) return Status::InvalidField; + if (!owner) return Status::Expired; + if (!owner->task.cancel(request.session, id.data)) return Status::Unauthorized; + return encodeTrackSnapshotBuildState(owner->task, output, capacity, written) ? Status::Ok : Status::Internal; +} +} // namespace snapshot_runtime +} // namespace hostlink::management diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_stored_package_input.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_stored_package_input.h new file mode 100644 index 00000000..dae8ca39 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_stored_package_input.h @@ -0,0 +1,201 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_package_input_owner.h" +#include "track_sharing/installed_package_binding.h" +#include "track_sharing/package_cache_paths.h" +#include "track_sharing/signed_manifest_reader.h" +namespace hostlink::management::package_install_runtime +{ +class CachedPackageResolver +{ + public: + using Observer = bool (*)(void*, uint8_t, const track_sharing::AttachmentEntryView&); + explicit CachedPackageResolver(const track_sharing::InstalledPackageBinding& binding) : binding_(binding) {} + void observe(Observer callback, void* context) + { + observer_ = callback; + context_ = context; + } + track_sharing::AttachmentResolveResult resolve(uint8_t ordinal, const track_sharing::AttachmentEntryView& entry, char* output, size_t capacity) + { + using namespace track_sharing; + if (ordinal >= binding_.count) return AttachmentResolveResult::Invalid; + if (!(binding_.availability & (uint64_t(1) << ordinal))) return AttachmentResolveResult::Missing; + // Candidate notification only; consumers expose results only after the + // entire owner is Ready, including original signature and actual KML. + if (observer_ && !observer_(context_, ordinal, entry)) return AttachmentResolveResult::Invalid; + return resolvePackageCachePath({binding_.revision, 32}, ObjectFile::AttachmentResource, entry.hash, output, capacity) ? AttachmentResolveResult::Ready : AttachmentResolveResult::Invalid; + } + + private: + const track_sharing::InstalledPackageBinding& binding_; + Observer observer_ = nullptr; + void* context_ = nullptr; +}; +// Read-only fresh disk proof, independent of upload journals and creator's +// local identity generation. CRC record is a locator, never author proof. +class StoredPackageInputOwner +{ + public: + StoredPackageInputOwner(uint32_t media, geocaching::ByteView object, geocaching::ByteView revision) + : media_(media), file_(sd::SdRuntimeMemoryPolicy::PsramOnly), io_(file_, media), hash_(context_), asset_hash_(asset_context_), + reader_(io_), kml_(io_, hash_, names_, sizeof(names_)), resolver_(binding_) + { + std::memcpy(object_, object.data, 32); + std::memcpy(revision_, revision.data, 32); + } + ~StoredPackageInputOwner() + { + if (package_) package_->~Package(); + } + bool hasBacking() const { return file_.has_backing_storage(); } + bool observeAttachments(CachedPackageResolver::Observer callback, void* context) + { + if (state_ != track_sharing::ManifestFileResult::Idle) return false; + resolver_.observe(callback, context); + return true; + } + track_sharing::ManifestFileResult begin(uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (state_ != ManifestFileResult::Idle) return ManifestFileResult::Busy; + if (!scratch || capacity < 448 || !mediaReady()) return state_ = ManifestFileResult::StorageFailure; + if (!routePackageTarget({revision_, 32}, PackageRouteFile::Kml, target_, sizeof(target_)) || + !routePackageTarget({revision_, 32}, PackageRouteFile::Binding, reinterpret_cast(scratch + capacity - 80), 80)) return state_ = ManifestFileResult::Invalid; + const auto marker = io_.open(reinterpret_cast(scratch + capacity - 80), sd::SdBorrowedRecordFileIo::Mode::Read); + if (!marker.handle) + { + if (marker.status != sd::SdBorrowedRecordFileIo::OpenStatus::Missing) return state_ = ManifestFileResult::StorageFailure; + const auto route = io_.open(target_, sd::SdBorrowedRecordFileIo::Mode::Read); + if (route.handle) + { + io_.close(route.handle); + return state_ = ManifestFileResult::Invalid; + } + if (route.status != sd::SdBorrowedRecordFileIo::OpenStatus::Missing) return state_ = ManifestFileResult::StorageFailure; + missing_ = true; + return state_ = ManifestFileResult::Ready; + } + uint64_t size = 0; + const bool read = io_.size(marker.handle, size) && size == kInstalledPackageBindingBytes && io_.read(marker.handle, scratch, kInstalledPackageBindingBytes) == kInstalledPackageBindingBytes; + const bool closed = io_.close(marker.handle); + if (!read || !closed) return state_ = ManifestFileResult::StorageFailure; + if (!decodeInstalledPackageBinding(scratch, kInstalledPackageBindingBytes, binding_) || std::memcmp(binding_.object, object_, 32) || std::memcmp(binding_.revision, revision_, 32)) return state_ = ManifestFileResult::Invalid; + const auto route = io_.open(target_, sd::SdBorrowedRecordFileIo::Mode::Read); + if (!route.handle) + { + if (route.status != sd::SdBorrowedRecordFileIo::OpenStatus::Missing) return state_ = ManifestFileResult::StorageFailure; + missing_ = true; + return state_ = ManifestFileResult::Ready; + } + if (!io_.close(route.handle)) return state_ = ManifestFileResult::StorageFailure; + if (!resolvePackageCachePath({revision_, 32}, ObjectFile::SignedManifest, {}, signed_path_, sizeof(signed_path_))) return state_ = ManifestFileResult::Invalid; + return state_ = reader_.begin(signed_path_, raw_, sizeof(raw_)) == FileIntegrityResult::Pending ? ManifestFileResult::Pending : ManifestFileResult::StorageFailure; + } + track_sharing::ManifestFileResult step(uint8_t* scratch, size_t capacity) + { + using namespace track_sharing; + if (!mediaReady()) + { + cancel(); + return state_ = ManifestFileResult::StorageFailure; + } + if (state_ != ManifestFileResult::Pending) return state_; + if (checking_kml_) return state_ = kml_.step(scratch, capacity); + if (!authenticated_) + { + const auto read = reader_.step(); + if (read == FileIntegrityResult::Pending) return state_; + if (read != FileIntegrityResult::Ready) return state_ = ManifestFileResult::StorageFailure; + if (verifyManifest(reader_.bytes(), crypto_, hash_, workspace_, sizeof(workspace_), verified_) != ManifestVerification::Valid || + std::memcmp(verified_.object_id, object_, 32) || std::memcmp(verified_.revision_hash, revision_, 32) || + verified_.manifest.file_bytes != binding_.kml_bytes || std::memcmp(verified_.manifest.file_hash.data, binding_.kml_hash, 32)) return state_ = ManifestFileResult::Invalid; + if (verified_.manifest.version == 1) + { + if (binding_.count) return state_ = ManifestFileResult::Invalid; + } + else + { + if (!getManifestExtensions(verified_.manifest, extensions_) || extensions_.assets.count != binding_.count || extensions_.assets.bytes != binding_.index_bytes || + (binding_.count && std::memcmp(extensions_.assets.hash.data, binding_.index_hash, 32))) return state_ = ManifestFileResult::Invalid; + } + authenticated_ = true; + if (binding_.count) + { + package_ = new (package_storage_) Package(io_, hash_, asset_hash_, resolver_, window_, sizeof(window_), fingerprints_, 64, + extensions_.assets, verified_.manifest.file_hash, verified_.manifest.file_bytes); + if (!resolvePackageCachePath({revision_, 32}, ObjectFile::AttachmentIndex, {}, index_path_, sizeof(index_path_)) || + package_->begin(index_path_) != AttachmentPackageResult::Pending) return state_ = ManifestFileResult::Invalid; + } + } + if (package_ && !package_->ready()) + { + const auto checked = package_->step(); + if (checked == AttachmentPackageResult::Pending) return state_; + if (checked != AttachmentPackageResult::Ready) return state_ = checked == AttachmentPackageResult::HashMismatch ? ManifestFileResult::HashMismatch : checked == AttachmentPackageResult::StorageFailure ? ManifestFileResult::StorageFailure + : ManifestFileResult::Invalid; + } + const auto begun = verified_.manifest.version == 2 ? kml_.beginVerifiedPackage(target_, verified_, *this) : kml_.begin(target_, verified_); + checking_kml_ = begun == ManifestFileResult::Pending; + return state_ = begun; + } + bool matchesAttachments(const track_sharing::ManifestView& candidate) const + { + return authenticated_ && candidate.raw.data == verified_.manifest.raw.data && candidate.raw.size == verified_.manifest.raw.size && + (!binding_.count || (package_ && package_->ready())); + } + void cancel() + { + reader_.cancel(); + kml_.cancel(); + if (package_) package_->cancel(); + state_ = track_sharing::ManifestFileResult::Cancelled; + } + bool ready() const { return state_ == track_sharing::ManifestFileResult::Ready && !missing_ && authenticated_ && kml_.ready() && mediaReady(); } + bool missing() const { return state_ == track_sharing::ManifestFileResult::Ready && missing_ && mediaReady(); } + bool mediaReady() const { return media_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); } + + private: + using Package = track_sharing::AttachmentPackageFileCheck; + uint32_t media_; + uint8_t object_[32]{}, revision_[32]{}; + char target_[80]{}, signed_path_[160]{}, index_path_[160]{}, names_[2048]{}; + uint8_t raw_[4166]{}, workspace_[4200]{}, window_[4352]{}; + track_sharing::AttachmentFingerprint fingerprints_[64]{}; + track_sharing::InstalledPackageBinding binding_; + track_sharing::VerifiedManifestView verified_; + track_sharing::ManifestExtensionsView extensions_; + mbedtls_sha256_context context_, asset_context_; + sd::SdRuntimeFile file_; + sd::SdBorrowedRecordFileIo io_; + platform::esp::storage::SnapshotStreamSha256 hash_, asset_hash_; + platform::esp::storage::TrackManifestCrypto crypto_; + track_sharing::SignedManifestReader reader_; + track_sharing::ManifestFileCheck kml_; + CachedPackageResolver resolver_; + alignas(Package) uint8_t package_storage_[sizeof(Package)]; + Package* package_ = nullptr; + track_sharing::ManifestFileResult state_ = track_sharing::ManifestFileResult::Idle; + bool authenticated_ = false, checking_kml_ = false, missing_ = false; +}; +static_assert(sizeof(StoredPackageInputOwner) <= 24576, "Stored package query must remain bounded transient PSRAM"); +inline StoredPackageInputOwner* createStoredPackageInputOwner(uint32_t media, geocaching::ByteView object, geocaching::ByteView revision) +{ + if (!object.data || object.size != 32 || !revision.data || revision.size != 32 || media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + void* memory = heap_caps_malloc(sizeof(StoredPackageInputOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* owner = new (memory) StoredPackageInputOwner(media, object, revision); + if (owner->hasBacking()) return owner; + owner->~StoredPackageInputOwner(); + heap_caps_free(memory); + return nullptr; +} +inline void destroyStoredPackageInputOwner(StoredPackageInputOwner* owner) +{ + if (owner) + { + owner->cancel(); + owner->~StoredPackageInputOwner(); + heap_caps_free(owner); + } +} +} // namespace hostlink::management::package_install_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_identity.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_identity.h new file mode 100644 index 00000000..ede06891 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_identity.h @@ -0,0 +1,54 @@ +#pragma once +#include "chat/ports/device_author.h" +#include "hostlink/management_envelope.h" +#include "platform/esp/common/mbedtls_sha256_compat.h" +#include "platform/esp/idf_common/hostlink_track_upload_runtime.h" +#include +namespace hostlink::management::upload_runtime +{ +template +Status readUploadAuthor(Adapter& adapter, uint8_t* public_key, size_t capacity, uint8_t* author_hash, uint32_t& generation) +{ + generation = 0; + if (!public_key || capacity < 64 || !author_hash) return Status::Internal; + const auto a = reinterpret_cast(public_key), b = reinterpret_cast(author_hash); + if (a <= b ? b - a < 64 : a - b < 32) return Status::Internal; + std::memset(author_hash, 0, 32); + std::memset(public_key, 0, 64); + uint32_t actual = 0; + const auto result = adapter.readDeviceAuthorKey(public_key, actual); + if (result != chat::DeviceAuthorResult::Ok) + return result == chat::DeviceAuthorResult::StorageError ? Status::StorageUnavailable : result == chat::DeviceAuthorResult::Unsupported ? Status::Unsupported + : Status::Busy; + uint8_t bits = 0; + for (unsigned i = 0; i < 64; ++i) bits |= public_key[i]; + if (!actual || !bits) return Status::StaleIdentity; +#if MBEDTLS_VERSION_NUMBER < 0x03000000 + const int hashed = mbedtls_sha256_ret(public_key, 64, author_hash, 0); +#else + const int hashed = mbedtls_sha256(public_key, 64, author_hash, 0); +#endif + if (hashed) + { + std::memset(author_hash, 0, 32); + return Status::StorageUnavailable; + } + generation = actual; + return Status::Ok; +} +template +Status processAuthenticatedUpload(Adapter& adapter, const EnvelopeView& request, uint64_t now, + uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!scratch || capacity < 448 || !routeTrackUpload(request)) return Status::InvalidField; + auto* public_key = scratch + capacity - 64; + const auto a = reinterpret_cast(request.body), b = reinterpret_cast(public_key); + if (a <= b ? b - a < request.body_size : a - b < 64) return Status::Internal; + uint8_t author[32]; + uint32_t generation = 0; + const auto identity = readUploadAuthor(adapter, public_key, 64, author, generation); + if (identity != Status::Ok) return identity; + return processUploadRequest(request, generation, author, now, scratch, capacity, written); +} +} // namespace hostlink::management::upload_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_maintenance.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_maintenance.h new file mode 100644 index 00000000..a3defd6f --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_maintenance.h @@ -0,0 +1,187 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +namespace hostlink::management::upload_runtime +{ +// Exact binding cleanup does not require an intact candidate. The two durable +// journals prove ownership even after an interrupted deletion or disconnect. +inline Status cleanupBoundUpload(const EnvelopeView& request, uint32_t generation, const uint8_t* author, + uint64_t now, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + track_sharing::UploadRequest decoded; + if (!request.session || !author || !generation || !scratch || capacity < 448 || + !track_sharing::decodeUploadRequest(track_sharing::UploadCommand::Begin, {request.body, request.body_size}, decoded)) return Status::InvalidField; + uint8_t binding[48]; + std::memcpy(binding, decoded.id.data, 16); + std::memcpy(binding + 16, decoded.hash.data, 32); + const auto kind = decoded.kind; + const auto total = decoded.total; + const auto media = sd::sd_media_session(); + bool created = false; + if (!sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + if (owner && std::memcmp(binding, owner->resources.metadata().id, 16)) return Status::Busy; + if (!owner) + { + owner = createOwner(binding, request.session, media, generation, author, now, kind); + if (!owner) return Status::Busy; + created = true; + const auto loaded = owner->resources.recover(scratch, capacity); + if (loaded != track_sharing::JournalStoreResult::Ready) + { + Status result = Status::Conflict; + if (loaded == track_sharing::JournalStoreResult::Missing) + { + // Missing journals alone are not permission to erase an orphan. + const auto candidate = owner->io.open(owner->resources.candidatePath(), sd::SdBorrowedRecordFileIo::Mode::Read); + if (candidate.handle) + { + if (!owner->io.close(candidate.handle)) result = Status::StorageUnavailable; + } + else result = candidate.status == sd::SdBorrowedRecordFileIo::OpenStatus::Missing ? Status::Ok : Status::StorageUnavailable; + } + else if (loaded == track_sharing::JournalStoreResult::StorageFailure) result = Status::StorageUnavailable; + destroyOwner(owner); + owner = nullptr; + if (media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) result = Status::StorageUnavailable; + if (result == Status::Ok) + { + scratch[0] = 0x90; + written = 1; + } + return result; + } + } + const auto& metadata = owner->resources.metadata(); + const auto reject = [&](Status status) + { if (created) { destroyOwner(owner); owner = nullptr; } return status; }; + if (kind != owner->kind || total != metadata.total || std::memcmp(binding + 16, metadata.file_hash, 32)) return reject(Status::Conflict); + if (generation != metadata.generation || std::memcmp(author, metadata.author_hash, 32)) return reject(Status::StaleIdentity); + if (!owner->session.matches(request.session, media, now)) return reject(Status::Unauthorized); + owner->generation = generation; + std::memcpy(owner->author, author, 32); + const auto result = abortOwnedUpload(*owner, request.session, binding, now, scratch, capacity); + if (result != Status::Ok) return result; + destroyOwner(owner); + owner = nullptr; + if (media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + scratch[0] = 0x90; + written = 1; + return Status::Ok; +} +inline bool parseUploadJournalName(const char* name, uint8_t* id) +{ + if (!name || !id || std::strlen(name) != 36 || (std::strcmp(name + 32, ".uja") && std::strcmp(name + 32, ".ujb"))) return false; + uint8_t bits = 0; + for (unsigned i = 0; i < 16; ++i) + { + const auto digit = [](char value) + { return value >= '0' && value <= '9' ? value - '0' : value >= 'a' && value <= 'f' ? value - 'a' + 10 + : -1; }; + const int high = digit(name[i * 2]), low = digit(name[i * 2 + 1]); + if (high < 0 || low < 0) return false; + id[i] = uint8_t(high * 16 + low); + bits |= id[i]; + } + return bits != 0; +} +// [kind,null|[media,position]] -> [media,next|null,row|null]. Live catalogue, +// four directory entries per request, at most one row. Both journal names may +// yield the same id; callers deduplicate. No candidate bytes are read or sealed. +inline Status listOwnedUploads(const EnvelopeView& request, uint32_t generation, const uint8_t* author, + uint64_t now, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!request.session || !author || !generation || !scratch || capacity < 448 || owner) return owner ? Status::Busy : Status::InvalidField; + geocaching::protocol::CmpReader reader({request.body, request.body_size}); + size_t fields = 0; + uint64_t raw_kind = 0, wanted = 0, position = 0; + if (!reader.array(fields, 2) || fields != 2 || !reader.unsignedInteger(raw_kind) || raw_kind > UINT8_MAX || !track_sharing::uploadByteLimit(static_cast(raw_kind))) return Status::InvalidField; + const bool first = reader.position() < request.body_size && request.body[reader.position()] == 0xc0; + if (first) + { + if (!reader.nil()) return Status::InvalidField; + } + else if (!reader.array(fields, 2) || fields != 2 || !reader.unsignedInteger(wanted) || wanted > UINT32_MAX || + !reader.unsignedInteger(position) || position > 128U * 1024U * 1024U || position % 32) return Status::InvalidField; + if (!reader.finished()) return Status::InvalidField; + const auto kind = static_cast(raw_kind); + const auto media = sd::sd_media_session(); + if (!first && wanted != media) return Status::Conflict; + if (!sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + bool ended = false, found = false; + uint8_t id[16]; + { + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status(track_sharing::uploadDirectory(kind), media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened == sd::SdFileReadStatus::Missing) ended = true; + else if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + if (!ended && position) + { + const auto seek = directory.seek_read_status(position); + if (seek != sd::SdFileReadStatus::Ready) return seek == sd::SdFileReadStatus::Busy ? Status::Busy : seek == sd::SdFileReadStatus::Invalid ? Status::Conflict + : Status::StorageUnavailable; + } + for (unsigned scan = 0; !ended && !found && scan < 4; ++scan) + { + bool is_directory = false; + uint64_t bytes = 0, next = 0; + auto* name = reinterpret_cast(scratch + capacity - 128); + const auto entry = directory.read_next_info(name, 128, &is_directory, &bytes, &next); + if (entry == sd::SdDirReadStatus::End) + { + ended = true; + break; + } + if (entry != sd::SdDirReadStatus::Entry) return entry == sd::SdDirReadStatus::Busy ? Status::Busy : Status::StorageUnavailable; + if (next <= position || next % 32 || next > 128U * 1024U * 1024U) return Status::Conflict; + position = next; + found = !is_directory && parseUploadJournalName(name, id); + } + } + TrackUploadOwner* temporary = nullptr; + struct Release + { + TrackUploadOwner*& value; + ~Release() { destroyOwner(value); } + } release{temporary}; + if (found) + { + temporary = createOwner(id, request.session, media, generation, author, now, kind); + if (!temporary) return Status::Busy; + const auto loaded = temporary->resources.recover(scratch, capacity); + if (loaded == track_sharing::JournalStoreResult::Conflict) found = false; // Another identity/generation remains untouched. + else if (loaded != track_sharing::JournalStoreResult::Ready) return loaded == track_sharing::JournalStoreResult::StorageFailure ? Status::StorageUnavailable : Status::Conflict; + } + if (media != sd::sd_media_session() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + geocaching::protocol::CmpWriter writer(scratch, capacity); + if (!writer.array(3) || !writer.unsignedInteger(media) || !(ended ? writer.nil() : writer.unsignedInteger(position))) return Status::Internal; + if (found) + { + const auto& metadata = temporary->resources.metadata(); + if (!writer.array(7) || !writer.binary({metadata.id, 16}) || !writer.unsignedInteger(raw_kind) || !writer.unsignedInteger(metadata.total) || + !writer.binary({metadata.file_hash, 32}) || !writer.unsignedInteger(metadata.generation) || !writer.unsignedInteger(uint8_t(metadata.phase)) || + !writer.unsignedInteger(metadata.durable_offset)) return Status::Internal; + } + else if (!writer.nil()) return Status::Internal; + written = writer.size(); + return Status::Ok; +} +template +Status processUploadMaintenance(Adapter& adapter, const EnvelopeView& request, uint64_t now, + uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!scratch || capacity < 448 || request.body_size > 128) return Status::InvalidField; + const auto input = reinterpret_cast(request.body), key = reinterpret_cast(scratch + capacity - 64); + if (input <= key ? key - input < request.body_size : input - key < 64) return Status::Internal; + uint8_t author[32]; + uint32_t generation = 0; + const auto identity = readUploadAuthor(adapter, scratch + capacity - 64, 64, author, generation); + if (identity != Status::Ok) return identity; + if (request.code == uint16_t(Operation::TransferCleanup)) return cleanupBoundUpload(request, generation, author, now, scratch, capacity, written); + if (request.code == uint16_t(Operation::TransferListOwned)) return listOwnedUploads(request, generation, author, now, scratch, capacity, written); + return Status::Unsupported; +} +} // namespace hostlink::management::upload_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_owner.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_owner.h new file mode 100644 index 00000000..4e0cdc56 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_owner.h @@ -0,0 +1,267 @@ +#pragma once +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/upload_protocol.h" +#include "track_sharing/upload_resources.h" +#include "track_sharing/upload_session.h" +#include "track_sharing/upload_space_budget.h" +#include +#include +namespace hostlink::management::upload_runtime +{ +namespace sd = platform::esp::arduino_common::storage; +// For new transactions only. Recovery reserves no additional files. Every +// unknown/orphan entry counts; only a verified End completes admission. +inline Status admitUploadSpace(uint32_t media, uint32_t bytes, uint8_t* scratch, size_t capacity, track_sharing::UploadKind kind = track_sharing::UploadKind::Kml) +{ + if (!scratch || capacity < 128) return Status::Internal; + if (!bytes || bytes > track_sharing::uploadByteLimit(kind)) return Status::InvalidField; + sd::SdRuntimeDir directory(sd::SdRuntimeMemoryPolicy::PsramOnly); + if (!directory.has_backing_storage()) return Status::Busy; + const auto opened = directory.open_read_status(track_sharing::uploadDirectory(kind), media); + if (opened == sd::SdFileReadStatus::Busy) return Status::Busy; + if (opened != sd::SdFileReadStatus::Ready) return Status::StorageUnavailable; + track_sharing::UploadSpaceBudget budget(bytes, kind); + for (unsigned scan = 0; scan < 193; ++scan) + { + bool is_dir = false; + uint64_t size = 0, next = 0; + const auto result = directory.read_next_info(reinterpret_cast(scratch + capacity - 128), 128, &is_dir, &size, &next); + if (result == sd::SdDirReadStatus::Busy) return Status::Busy; + if (result == sd::SdDirReadStatus::End) + return media == sd::sd_media_session() && !sd::sd_external_block_owner_active() ? Status::Ok : Status::StorageUnavailable; + if (result != sd::SdDirReadStatus::Entry) return Status::StorageUnavailable; + if (!budget.add(size, is_dir)) return Status::LimitExceeded; + } + return Status::LimitExceeded; +} +class TrackUploadOwner +{ + public: + using Resources = track_sharing::UploadResources; + TrackUploadOwner(const uint8_t* id, const uint8_t* session_id, uint32_t media_id, uint32_t identity_generation, const uint8_t* identity_hash, uint64_t now, track_sharing::UploadKind upload_kind = track_sharing::UploadKind::Kml) + : media(media_id), generation(identity_generation), kind(upload_kind), file(sd::SdRuntimeMemoryPolicy::PsramOnly), io(file, media), hash(context), resources(io, hash, id, generation, author, kind), session(resources, session_id, media, now) + { + std::memcpy(author, identity_hash, 32); + } + TrackUploadOwner(const TrackUploadOwner&) = delete; + TrackUploadOwner& operator=(const TrackUploadOwner&) = delete; + // Platform refreshes these from authenticated current identity before use. + uint32_t media, generation; + const track_sharing::UploadKind kind; + bool discarding = false; + uint8_t author[32]{}; + mbedtls_sha256_context context; + sd::SdRuntimeFile file; + sd::SdBorrowedRecordFileIo io; + platform::esp::storage::SnapshotStreamSha256 hash; + Resources resources; + track_sharing::UploadSession session; +}; +static_assert(sizeof(TrackUploadOwner) <= 1024, "Upload owner exceeds transient PSRAM admission"); +// Scheduler owns the returned pointer; no global slot/queue is added here. +// SD driver's separately allocated Impl must also be counted in admission. +inline TrackUploadOwner* createOwner(const uint8_t* id, const uint8_t* session, uint32_t media, uint32_t generation, const uint8_t* author, uint64_t now, track_sharing::UploadKind kind = track_sharing::UploadKind::Kml) +{ + if (!track_sharing::uploadByteLimit(kind) || !id || !session || !author || !generation || media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return nullptr; + uint8_t id_bits = 0, session_bits = 0, author_bits = 0; + for (unsigned i = 0; i < 16; ++i) + { + id_bits |= id[i]; + session_bits |= session[i]; + } + for (unsigned i = 0; i < 32; ++i) author_bits |= author[i]; + if (!id_bits || !session_bits || !author_bits) return nullptr; + void* memory = heap_caps_malloc(sizeof(TrackUploadOwner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!memory) return nullptr; + auto* created = new (memory) TrackUploadOwner(id, session, media, generation, author, now, kind); + if (!created->file.has_backing_storage()) + { + created->~TrackUploadOwner(); + heap_caps_free(memory); + return nullptr; + } + return created; +} +inline void destroyOwner(TrackUploadOwner* owner) +{ + if (!owner) return; + owner->resources.cancel(); + owner->~TrackUploadOwner(); + heap_caps_free(owner); +} +// Caller holds exclusive scheduler/storage lease. Request fields must live +// outside borrowed scratch. Recovery is a distinct path, never truncation. +inline Status beginNewUpload(TrackUploadOwner*& output, const uint8_t* id, const uint8_t* session, uint32_t media, + uint32_t generation, const uint8_t* author, uint32_t bytes, const uint8_t* hash, + uint64_t now, uint8_t* scratch, size_t capacity, track_sharing::UploadKind kind = track_sharing::UploadKind::Kml) +{ + if (output) return Status::Busy; + if (!id || !session || !author || !hash || !generation || !bytes || bytes > track_sharing::uploadByteLimit(kind) || !scratch || capacity < 128) return Status::InvalidField; + const auto overlaps = [&](const uint8_t* input, size_t length) + { const auto a = reinterpret_cast(input), b = reinterpret_cast(scratch);return a <= b ? b - a < length : a - b < capacity; }; + if (overlaps(id, 16) || overlaps(session, 16) || overlaps(author, 32) || overlaps(hash, 32)) return Status::InvalidField; + uint8_t id_bits = 0, session_bits = 0, author_bits = 0; + for (unsigned i = 0; i < 16; ++i) + { + id_bits |= id[i]; + session_bits |= session[i]; + } + for (unsigned i = 0; i < 32; ++i) author_bits |= author[i]; + if (!id_bits || !session_bits || !author_bits) return Status::InvalidField; + if (media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + const char* directory = track_sharing::uploadDirectory(kind); + if ((!sd::sd_is_directory("/trailmate") && !sd::sd_mkdir("/trailmate")) || + (!sd::sd_is_directory(directory) && !sd::sd_mkdir(directory))) return Status::StorageUnavailable; + const auto admission = admitUploadSpace(media, bytes, scratch, capacity, kind); + if (admission != Status::Ok) return admission; + auto* owner = createOwner(id, session, media, generation, author, now, kind); + if (!owner) return Status::Busy; + const auto fail = [&](Status status) + { destroyOwner(owner);return status; }; + const auto recovered = owner->resources.recover(scratch, capacity); + if (recovered != track_sharing::JournalStoreResult::Missing) + return fail(recovered == track_sharing::JournalStoreResult::Ready || recovered == track_sharing::JournalStoreResult::Conflict ? Status::Conflict : Status::StorageUnavailable); + const auto existing = owner->io.open(owner->resources.candidatePath(), sd::SdBorrowedRecordFileIo::Mode::Read); + if (existing.handle) + { + const bool closed = owner->io.close(existing.handle); + return fail(closed ? Status::Conflict : Status::StorageUnavailable); + } + if (existing.status != sd::SdBorrowedRecordFileIo::OpenStatus::Missing) return fail(Status::StorageUnavailable); + const auto created = owner->io.open(owner->resources.candidatePath(), sd::SdBorrowedRecordFileIo::Mode::CreateTemporary); + if (!created.handle) return fail(Status::StorageUnavailable); + const bool synced = owner->io.sync(created.handle), closed = owner->io.close(created.handle); + if (!synced || !closed) return fail(Status::StorageUnavailable); + const auto initialized = owner->resources.initialize(bytes, hash, scratch, capacity); + if (initialized != track_sharing::JournalStoreResult::Ready) return fail(initialized == track_sharing::JournalStoreResult::Conflict ? Status::Conflict : Status::StorageUnavailable); + output = owner; + return Status::Ok; +} +// Recovery reads only; restored Sealed metadata is not fresh file proof. +inline Status recoverUpload(TrackUploadOwner*& output, const uint8_t* id, const uint8_t* session, uint32_t media, + uint32_t generation, const uint8_t* author, uint32_t bytes, const uint8_t* hash, + uint64_t now, uint8_t* scratch, size_t capacity, track_sharing::UploadKind kind = track_sharing::UploadKind::Kml) +{ + if (output) return Status::Busy; + if (!id || !session || !author || !hash || !generation || !bytes || bytes > track_sharing::uploadByteLimit(kind) || !scratch || capacity < 128) return Status::InvalidField; + const auto overlaps = [&](const uint8_t* input, size_t length) + { const auto a = reinterpret_cast(input), b = reinterpret_cast(scratch);return a <= b ? b - a < length : a - b < capacity; }; + if (overlaps(id, 16) || overlaps(session, 16) || overlaps(author, 32) || overlaps(hash, 32)) return Status::InvalidField; + uint8_t id_bits = 0, session_bits = 0, author_bits = 0; + for (unsigned i = 0; i < 16; ++i) + { + id_bits |= id[i]; + session_bits |= session[i]; + } + for (unsigned i = 0; i < 32; ++i) author_bits |= author[i]; + if (!id_bits || !session_bits || !author_bits) return Status::InvalidField; + if (media != sd::sd_media_session() || !sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + auto* owner = createOwner(id, session, media, generation, author, now, kind); + if (!owner) return Status::Busy; + const auto fail = [&](Status status) + { destroyOwner(owner);return status; }; + const auto loaded = owner->resources.recover(scratch, capacity); + if (loaded != track_sharing::JournalStoreResult::Ready) + return fail(loaded == track_sharing::JournalStoreResult::Missing ? Status::Expired : loaded == track_sharing::JournalStoreResult::Conflict ? Status::Conflict + : Status::StorageUnavailable); + const auto& metadata = owner->resources.metadata(); + if (metadata.total != bytes || std::memcmp(metadata.file_hash, hash, 32)) return fail(Status::Conflict); + if (metadata.phase == track_sharing::UploadPhase::Failed) + { + owner->discarding = true; + output = owner; + return Status::HashMismatch; + } + const auto opened = owner->io.open(owner->resources.candidatePath(), sd::SdBorrowedRecordFileIo::Mode::Read); + if (!opened.handle) + { + if (opened.status == sd::SdBorrowedRecordFileIo::OpenStatus::Missing) + { + owner->discarding = true; + output = owner; + return Status::StorageUnavailable; + } + return fail(Status::StorageUnavailable); + } + uint64_t size = 0; + const bool sized = owner->io.size(opened.handle, size), closed = owner->io.close(opened.handle); + if (!sized || !closed) return fail(Status::StorageUnavailable); + if (size < metadata.durable_offset || size > bytes) + { + owner->discarding = true; + output = owner; + return Status::StorageUnavailable; + } + output = owner; + return Status::Ok; +} +inline Status abortOwnedUpload(TrackUploadOwner& owner, const uint8_t* session, const uint8_t* id, uint64_t now, uint8_t* scratch, size_t capacity) +{ + if (!id || !scratch || capacity < 128) return Status::InvalidField; + const auto& metadata = owner.resources.metadata(); + if (std::memcmp(id, metadata.id, 16)) return Status::Conflict; + if (owner.generation != metadata.generation || std::memcmp(owner.author, metadata.author_hash, 32)) return Status::StaleIdentity; + if (!owner.session.authorize(session, sd::sd_media_session(), now)) return Status::Unauthorized; + owner.discarding = true; + owner.resources.cancel(); + for (const auto file : {track_sharing::UploadFile::Candidate, track_sharing::UploadFile::JournalA, track_sharing::UploadFile::JournalB}) + { + auto* path = reinterpret_cast(scratch); + if (!track_sharing::resolveUploadPath(metadata.id, 16, file, path, capacity, owner.kind)) return Status::Internal; + const auto existing = owner.io.open(path, sd::SdBorrowedRecordFileIo::Mode::Read); + if (!existing.handle) + { + if (existing.status == sd::SdBorrowedRecordFileIo::OpenStatus::Missing) continue; + return Status::StorageUnavailable; + } + if (!owner.io.close(existing.handle) || !sd::sd_remove(path, owner.media)) return Status::StorageUnavailable; + } + return Status::Ok; +} +inline Status processExistingUpload(TrackUploadOwner& owner, track_sharing::UploadCommand command, + geocaching::ByteView body, const uint8_t* session, uint64_t now, + track_sharing::UploadRequest& decoded, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (owner.discarding) return Status::Conflict; + if (!scratch || capacity < 448) return Status::Internal; + if (!track_sharing::decodeUploadRequest(command, body, decoded)) return Status::InvalidField; + const auto& metadata = owner.resources.metadata(); + if (std::memcmp(decoded.id.data, metadata.id, 16)) return Status::Conflict; + if (owner.generation != metadata.generation || std::memcmp(owner.author, metadata.author_hash, 32)) return Status::StaleIdentity; + if (!owner.session.matches(session, sd::sd_media_session(), now)) return Status::Unauthorized; + if (!sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + if (metadata.phase == track_sharing::UploadPhase::Failed) return Status::HashMismatch; + const auto state = owner.resources.sealResult(); + if (state == track_sharing::UploadSealResult::StorageFailure) return Status::StorageUnavailable; + if (state == track_sharing::UploadSealResult::Cancelled) return Status::Expired; + if (state == track_sharing::UploadSealResult::Conflict) return Status::StaleIdentity; + if (command == track_sharing::UploadCommand::Begin) + { + if (decoded.kind != owner.kind || decoded.total != metadata.total || std::memcmp(decoded.hash.data, metadata.file_hash, 32)) return Status::Conflict; + } + else if (command == track_sharing::UploadCommand::Chunk) + { + uint8_t id[16]; + std::memcpy(id, decoded.id.data, 16); + const auto count = decoded.chunk.size; + auto* chunk = scratch + capacity - count; + std::memmove(chunk, decoded.chunk.data, count); + const auto result = owner.session.append(session, owner.media, now, id, decoded.offset, chunk, count, scratch, capacity); + if (result != track_sharing::JournalStoreResult::Ready) + return result == track_sharing::JournalStoreResult::Conflict ? Status::Conflict : result == track_sharing::JournalStoreResult::Invalid ? Status::InvalidField + : Status::StorageUnavailable; + } + else if (command == track_sharing::UploadCommand::Finish && metadata.phase != track_sharing::UploadPhase::Sealed && !owner.resources.sealingStarted()) + { + const auto result = owner.session.beginSeal(session, owner.media, now, scratch, capacity); + if (result != track_sharing::UploadSealResult::Pending) + return result == track_sharing::UploadSealResult::Conflict ? Status::Conflict : result == track_sharing::UploadSealResult::Invalid ? Status::InvalidField + : Status::StorageUnavailable; + } + if (!owner.session.authorize(session, owner.media, now)) return Status::Unauthorized; + return track_sharing::encodeUploadProgress(metadata, scratch, capacity, written) ? Status::Ok : Status::Internal; +} +} // namespace hostlink::management::upload_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_runtime.h new file mode 100644 index 00000000..d6a5daa3 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/hostlink_track_upload_runtime.h @@ -0,0 +1,95 @@ +#pragma once +#include "platform/esp/idf_common/hostlink_track_upload_owner.h" +namespace hostlink::management::upload_runtime +{ +// Single application-scheduler owner, not a CDC task/queue. Caller supplies +// authenticated current author hash outside command scratch before each use. +inline TrackUploadOwner* owner = nullptr; +inline bool routeTrackUpload(const EnvelopeView& request) +{ + if (request.code == static_cast(Operation::TransferBegin)) + { + track_sharing::UploadRequest decoded; + return track_sharing::decodeUploadRequest(track_sharing::UploadCommand::Begin, {request.body, request.body_size}, decoded); + } + if (!owner || (request.code != static_cast(Operation::TransferChunk) && request.code != static_cast(Operation::TransferFinish) && request.code != static_cast(Operation::TransferAbort) && request.code != static_cast(Operation::TransferStatus))) return false; + track_sharing::UploadRequest decoded; + const auto command = request.code == static_cast(Operation::TransferChunk) ? track_sharing::UploadCommand::Chunk : request.code == static_cast(Operation::TransferStatus) ? track_sharing::UploadCommand::Status + : track_sharing::UploadCommand::Finish; + return track_sharing::decodeUploadRequest(command, {request.body, request.body_size}, decoded) && !std::memcmp(decoded.id.data, owner->resources.metadata().id, 16); +} +inline void advanceUploads(uint8_t* scratch, size_t capacity, uint64_t now, bool connected) +{ + if (!owner) return; + owner->session.advance(sd::sd_media_session(), now, connected, scratch, capacity); + if (owner->session.releasable()) + { + destroyOwner(owner); + owner = nullptr; + } +} +inline void closeUploadSession(const uint8_t* session) +{ + if (!owner || !owner->session.close(session)) return; + destroyOwner(owner); + owner = nullptr; +} +inline Status processUploadRequest(const EnvelopeView& request, uint32_t generation, const uint8_t* author, + uint64_t now, uint8_t* scratch, size_t capacity, size_t& written) +{ + written = 0; + if (!request.session || !author || !generation) return Status::Unauthorized; + if (!scratch || capacity < 448) return Status::Internal; + track_sharing::UploadCommand command; + if (request.code == static_cast(Operation::TransferBegin)) command = track_sharing::UploadCommand::Begin; + else if (request.code == static_cast(Operation::TransferChunk)) command = track_sharing::UploadCommand::Chunk; + else if (request.code == static_cast(Operation::TransferStatus)) command = track_sharing::UploadCommand::Status; + else if (request.code == static_cast(Operation::TransferFinish) || request.code == static_cast(Operation::TransferAbort)) command = track_sharing::UploadCommand::Finish; + else return Status::Unsupported; + track_sharing::UploadRequest decoded; + if (!track_sharing::decodeUploadRequest(command, {request.body, request.body_size}, decoded)) return Status::InvalidField; + if (owner && command == track_sharing::UploadCommand::Begin && std::memcmp(decoded.id.data, owner->resources.metadata().id, 16)) + { + const auto& metadata = owner->resources.metadata(); + if (owner->discarding || metadata.phase != track_sharing::UploadPhase::Sealed) return Status::Conflict; + if (generation != metadata.generation || std::memcmp(author, metadata.author_hash, 32)) return Status::StaleIdentity; + if (!owner->session.matches(request.session, sd::sd_media_session(), now)) return Status::Unauthorized; + if (!sd::sd_card_ready() || sd::sd_external_block_owner_active()) return Status::StorageUnavailable; + // Retire only transient resources. Sealed bytes and journals remain + // available for preparation or explicit recovery/cleanup. + destroyOwner(owner); + owner = nullptr; + } + if (owner) + { + // Refresh borrowed identity fields before every operation. A changed + // identity cannot keep uploading bytes under the journal's old binding. + owner->generation = generation; + std::memcpy(owner->author, author, 32); + if (request.code == static_cast(Operation::TransferAbort)) + { + const auto result = abortOwnedUpload(*owner, request.session, decoded.id.data, now, scratch, capacity); + if (result != Status::Ok) return result; + destroyOwner(owner); + owner = nullptr; + scratch[0] = 0x90; + written = 1; + return Status::Ok; + } + return processExistingUpload(*owner, command, {request.body, request.body_size}, request.session, now, decoded, scratch, capacity, written); + } + if (command != track_sharing::UploadCommand::Begin) return Status::Expired; + // Small binding copy only; file data always remains in the command arena. + uint8_t bindings[48]; + std::memcpy(bindings, decoded.id.data, 16); + std::memcpy(bindings + 16, decoded.hash.data, 32); + const auto recovered = recoverUpload(owner, bindings, request.session, sd::sd_media_session(), generation, author, decoded.total, bindings + 16, now, scratch, capacity, decoded.kind); + if (recovered != Status::Ok && recovered != Status::Expired) return recovered; + if (recovered == Status::Expired) + { + const auto created = beginNewUpload(owner, bindings, request.session, sd::sd_media_session(), generation, author, decoded.total, bindings + 16, now, scratch, capacity, decoded.kind); + if (created != Status::Ok) return created; + } + return track_sharing::encodeUploadProgress(owner->resources.metadata(), scratch, capacity, written) ? Status::Ok : Status::Internal; +} +} // namespace hostlink::management::upload_runtime diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_lease.h b/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_lease.h new file mode 100644 index 00000000..bb527b1a --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_lease.h @@ -0,0 +1,114 @@ +#pragma once + +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/common/storage/track_snapshot_file_storage.h" +#include "platform/esp/idf_common/tracker_snapshot_runtime.h" + +namespace platform::esp::idf_common +{ +// Owner lends canonical paths and a RecordFileIo bound to this same media +// session. Paths live through copy/cleanup; no pathname arrays are stored here. +// Snapshot paths are private id-based .new/.tsn names, never browser paths. +class TrackerSnapshotLease final : public platform::esp::storage::SnapshotFileLease +{ + public: + TrackerSnapshotLease(platform::esp::storage::RecordFileIo& io, const char* source, + const char* temporary, const char* destination, uint32_t session) + : io_(io), source_(source), temporary_(temporary), destination_(destination), session_(session) {} + ~TrackerSnapshotLease() override { release(); } + TrackerSnapshotLease(const TrackerSnapshotLease&) = delete; + TrackerSnapshotLease& operator=(const TrackerSnapshotLease&) = delete; + + hostlink::management::SnapshotStorageResult acquire(const uint8_t id[16], uint32_t bytes, uint8_t format) override + { + using Result = hostlink::management::SnapshotStorageResult; + if (owned_) return Result::Busy; + uint8_t bits = 0; + if (id) + for (unsigned i = 0; i < 16; ++i) bits |= id[i]; + if (!bits || !bytes || bytes > hostlink::management::kTrackSnapshotMaxBytes || format > 3 || + !sourcePathValid(source_) || !snapshotPathValid(temporary_, id, ".new") || !snapshotPathValid(destination_, id, ".tsn")) return Result::Failed; + if (!mediaAvailable()) return Result::Failed; + if (!platform::ui::tracker::try_acquire_snapshot_read()) return Result::Busy; + owned_ = true; + if (!mediaAvailable()) + { + release(); + return Result::Failed; + } + std::memcpy(id_, id, 16); + bytes_ = bytes; + format_ = format; + published_ = false; + return Result::Ok; + } + bool valid() const override { return owned_ && mediaAvailable(); } + const char* sourcePath() const override { return source_; } + const char* temporaryPath() const override { return temporary_; } + bool commit(const hostlink::management::TrackSnapshotMetadata& metadata) override + { + if (!valid() || published_ || metadata.bytes != bytes_ || metadata.format != format_ || std::memcmp(metadata.id, id_, 16)) return false; + if (!io_.publish(temporary_, destination_)) return false; + published_ = true; + return true; + } + bool discard() override + { + namespace sd = platform::esp::arduino_common::storage; + if (!owned_ || published_) return true; + // Replacement/USB-owned media cannot be touched. Abandon the old + // private candidate for owner-controlled cleanup on that media later. + if (!mediaAvailable()) return true; + uint8_t probe = 0; + const auto status = sd::sd_read_file(temporary_, &probe, 1).status; + if (!mediaAvailable()) return true; + if (status == sd::SdFileReadStatus::Missing) return true; + if (status != sd::SdFileReadStatus::Ready && status != sd::SdFileReadStatus::Invalid) return false; + return sd::sd_remove(temporary_, session_); + } + void release() override + { + if (owned_) + { + platform::ui::tracker::release_snapshot_read(); + owned_ = false; + } + } + + private: + bool mediaAvailable() const + { + namespace sd = platform::esp::arduino_common::storage; + return session_ == sd::sd_media_session() && sd::sd_card_ready() && !sd::sd_external_block_owner_active(); + } + static bool sourcePathValid(const char* path) + { + if (!path || std::strncmp(path, "/trackers/", 10)) return false; + size_t length = 0; + while (length < 128 && path[length]) ++length; + if (length <= 10 || length == 128 || !std::strcmp(path + 10, ".") || !std::strcmp(path + 10, "..")) return false; + for (size_t i = 10; i < length; ++i) + if (static_cast(path[i]) < 32 || path[i] == '/' || path[i] == '\\' || path[i] == ':') return false; + return true; + } + static bool snapshotPathValid(const char* path, const uint8_t id[16], const char* suffix) + { + constexpr const char* prefix = "/trailmate/web-tracks/"; + constexpr const char* hex = "0123456789abcdef"; + if (!path || std::strncmp(path, prefix, std::strlen(prefix))) return false; + const char* name = path + std::strlen(prefix); + for (unsigned i = 0; i < 16; ++i) + if (!name[i * 2] || name[i * 2] != hex[id[i] >> 4] || !name[i * 2 + 1] || name[i * 2 + 1] != hex[id[i] & 15]) return false; + return std::strcmp(name + 32, suffix) == 0; + } + platform::esp::storage::RecordFileIo& io_; + const char* source_; + const char* temporary_; + const char* destination_; + uint8_t id_[16]{}; + uint32_t session_, bytes_ = 0; + uint8_t format_ = 0; + bool owned_ = false, published_ = false; +}; +static_assert(sizeof(TrackerSnapshotLease) <= 80, "Snapshot lease must not own path/file buffers"); +} // namespace platform::esp::idf_common diff --git a/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_runtime.h b/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_runtime.h new file mode 100644 index 00000000..f32468f7 --- /dev/null +++ b/platform/esp/idf_common/include/platform/esp/idf_common/tracker_snapshot_runtime.h @@ -0,0 +1,12 @@ +#pragma once +namespace platform::ui::tracker +{ +// ESP implementations: succeeds with no live recording or in-flight mutation. +// Owner must release exactly once after copy or cleanup. While held, start, +// sampling and deletion yield; this does not lock external SD block owners. +bool try_acquire_snapshot_read(); +void release_snapshot_read(); +// Short-lived metadata listing may run during recording, with sampling yielded. +bool try_acquire_catalog_read(); +bool is_recording_filename(const char* filename); // Caller holds catalog gate. +} // namespace platform::ui::tracker diff --git a/platform/esp/idf_common/src/app_runtime_support.cpp b/platform/esp/idf_common/src/app_runtime_support.cpp index 5437c4c0..93e45c38 100644 --- a/platform/esp/idf_common/src/app_runtime_support.cpp +++ b/platform/esp/idf_common/src/app_runtime_support.cpp @@ -2,6 +2,8 @@ #include "app/app_facade_access.h" #include "esp_log.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" +#include "platform/esp/arduino_common/hostlink/hostlink_service.h" #include "platform/esp/boards/board_runtime.h" #include @@ -54,6 +56,16 @@ void tickBoundLifecycle(std::size_t max_events) // task on ESP-IDF targets. Only the actual UI projection below needs the // display lock. lifecycle.updateCoreServices(); + ::hostlink::tick_publication_tasks(); + // Configuration emission reads the live object and platform settings. + // Serialize it with UI edits without allocating a second AppConfig. The + // network pump above remains outside this mutex. Bound work to one command. + if (platform::esp::boards::lockDisplay(kDisplayLockTimeoutMs)) + { + ::hostlink::process_pending_commands(); + app::sd_tms::serviceWorkingConfig(); + platform::esp::boards::unlockDisplay(); + } if (s_lvgl_task_owned_ui_dispatch.load(std::memory_order_acquire)) { diff --git a/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp b/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp index a23decb7..dbeb5d58 100644 --- a/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp @@ -327,6 +327,15 @@ Status flushPending() return out; } +bool discardLegacySource() +{ + using namespace platform::esp::arduino_common::storage; + if (!sd_card_ready()) return false; + s_pending = false; + return (!sd_exists(kConfigTempPath) || sd_remove(kConfigTempPath)) && + (!sd_exists(kConfigPath) || sd_remove(kConfigPath)); +} + bool hasPending() { return s_pending; diff --git a/platform/esp/idf_common/src/platform_ui_tracker_runtime.cpp b/platform/esp/idf_common/src/platform_ui_tracker_runtime.cpp index a3a25de8..78d2d98d 100644 --- a/platform/esp/idf_common/src/platform_ui_tracker_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_tracker_runtime.cpp @@ -1,5 +1,7 @@ #include "gps/gpx/track_writer.h" #include "platform/esp/arduino_common/gps/sd_gpx_output.h" +#include "platform/esp/idf_common/tracker_snapshot_runtime.h" +#include "platform/ui/tracker_access_gate.h" #include "platform/ui/tracker_runtime.h" #include "esp_timer.h" @@ -22,6 +24,7 @@ constexpr const char* kTrackDir = "/trackers"; constexpr uint32_t kDefaultIntervalSeconds = 5; constexpr double kDistanceOnlyThresholdMeters = 5.0; constexpr uint32_t kMinValidEpoch = 1577836800U; +TrackerControlState tracker_control; struct TrackerRuntimeState { @@ -295,6 +298,9 @@ bool is_recording() bool start_recording() { + TrackerGateScope access(tracker_control.access); + if (!access) return false; + if (tracker_control.stop_requested.load(std::memory_order_acquire)) return false; TrackerRuntimeState& runtime = state(); if (runtime.recording) { @@ -317,12 +323,17 @@ bool start_recording() runtime.last_sample_ms = 0; runtime.has_last_point = false; write_header(runtime); + access.release(); poll(); return true; } void stop_recording() { + tracker_control.stop_requested.store(true, std::memory_order_release); + TrackerGateScope access(tracker_control.access); + if (!access) return; + tracker_control.stop_requested.exchange(false, std::memory_order_acq_rel); TrackerRuntimeState& runtime = state(); if (!runtime.recording) { @@ -337,17 +348,27 @@ void stop_recording() void poll() { + TrackerGateScope access(tracker_control.access); + if (!access) return; + if (tracker_control.stop_requested.load(std::memory_order_acquire)) + { + access.release(); + stop_recording(); + return; + } TrackerRuntimeState& runtime = state(); if (!runtime.recording) { if (runtime.auto_recording) { + access.release(); (void)start_recording(); } return; } if (!runtime.file.is_open()) { + access.release(); stop_recording(); return; } @@ -430,6 +451,8 @@ bool list_tracks(std::vector& out_tracks, std::size_t max_count) bool remove_track(const std::string& path) { + TrackerGateScope access(tracker_control.access); + if (!access) return false; if (!platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready() || path.empty()) { return false; @@ -443,6 +466,34 @@ const char* track_dir() return kTrackDir; } +bool try_acquire_snapshot_read() +{ + if (!tracker_control.access.tryAcquire()) return false; + if (state().recording || tracker_control.stop_requested.load(std::memory_order_acquire)) + { + tracker_control.access.release(); + return false; + } + return true; +} + +void release_snapshot_read() +{ + tracker_control.access.release(); +} + +bool try_acquire_catalog_read() +{ + return tracker_control.access.tryAcquire(); +} + +bool is_recording_filename(const char* filename) +{ + return filename && state().recording && + state().current_rel_path.compare(0, 10, "/trackers/") == 0 && + std::strcmp(state().current_rel_path.c_str() + 10, filename) == 0; +} + void set_auto_recording(bool enabled) { state().auto_recording = enabled; diff --git a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp index 4b80d795..05741153 100644 --- a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp @@ -1,5 +1,6 @@ #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || defined(TRAIL_MATE_ESP_BOARD_TAB5) +#include "platform/esp/common/wifi_profile_snapshot.h" #include "platform/ui/wifi_runtime.h" #include @@ -183,10 +184,6 @@ void upsert_profile_front(const Config& profile) bool persist_profiles() { - ::platform::ui::settings_store::put_int(kSettingsNs, - kWifiProfileCountKey, - static_cast(s_runtime.profile_count)); - bool ok = true; for (std::size_t i = 0; i < kWifiProfileCapacity; ++i) { char ssid_key[24] = {}; @@ -194,18 +191,21 @@ bool persist_profiles() profile_key(ssid_key, sizeof(ssid_key), "ssid", i); profile_key(password_key, sizeof(password_key), "password", i); const Config& profile = s_runtime.profiles[i]; - ok = ::platform::ui::settings_store::put_string( - kSettingsNs, - ssid_key, - i < s_runtime.profile_count ? profile.ssid : "") && - ok; - ok = ::platform::ui::settings_store::put_string( - kSettingsNs, - password_key, - i < s_runtime.profile_count ? profile.password : "") && - ok; + if (!::platform::ui::settings_store::put_string( + kSettingsNs, + ssid_key, + i < s_runtime.profile_count ? profile.ssid : "") || + !::platform::ui::settings_store::put_string( + kSettingsNs, + password_key, + i < s_runtime.profile_count ? profile.password : "")) return false; } - return ok; + // Publish the count only after all slots were committed. The SD working + // document remains the recovery authority if a multi-key write is partial. + ::platform::ui::settings_store::put_int(kSettingsNs, kWifiProfileCountKey, + static_cast(s_runtime.profile_count)); + return ::platform::ui::settings_store::get_int(kSettingsNs, kWifiProfileCountKey, -1) == + static_cast(s_runtime.profile_count); } bool read_profile_from_store(std::size_t index, Config& profile) @@ -242,12 +242,12 @@ bool read_profile_from_store(std::size_t index, Config& profile) if (::platform::ui::settings_store::get_string(kSettingsNs, legacy_password_key, value)) { copy_text(profile.password, sizeof(profile.password), value.c_str()); - (void)::platform::ui::settings_store::put_string( + const bool migrated = ::platform::ui::settings_store::put_string( kSettingsNs, password_key, profile.password); const char* legacy_keys[] = {legacy_password_key}; - ::platform::ui::settings_store::remove_keys(kSettingsNs, legacy_keys, 1); + if (migrated) ::platform::ui::settings_store::remove_keys(kSettingsNs, legacy_keys, 1); } } @@ -350,12 +350,19 @@ bool save_saved_config(const Config& config) const bool password_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, config.password); ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, config.enabled); - const bool saved = profiles_ok && ssid_ok && password_ok; + const bool enabled_ok = ::platform::ui::settings_store::get_bool( + kSettingsNs, kWifiEnabledKey, !config.enabled) == config.enabled; + const bool saved = profiles_ok && ssid_ok && password_ok && enabled_ok; if (saved) { s_runtime.saved_config = config; s_runtime.config_cached = true; } + else + { + s_runtime.profiles_cached = false; + s_runtime.config_cached = false; + } return saved; } @@ -517,6 +524,68 @@ bool find_saved_config(const char* ssid, Config& out) return false; } +bool visit_saved_profiles(bool* enabled, std::size_t* count, SavedProfileVisitor visitor, void* context) +{ + if (!enabled || !count || !visitor) return false; + if (!s_runtime.profiles_cached || !s_runtime.config_cached) (void)load_saved_config(); + *enabled = s_runtime.saved_config.enabled; + *count = s_runtime.profile_count; + for (std::size_t index = 0; index < s_runtime.profile_count; ++index) + { + if (!visitor(context, index, s_runtime.profiles[index])) return false; + } + return true; +} + +bool read_saved_profiles_into(bool* enabled, std::size_t* count, + Config* profiles, std::size_t capacity) +{ + return ::platform::esp::common::read_persisted_wifi_profiles(enabled, count, profiles, capacity); +} + +bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count) +{ + if (count > kWifiProfileCapacity || (count && !profiles)) return false; + for (std::size_t index = 0; index < count; ++index) + { + if (!std::memchr(profiles[index].ssid, '\0', sizeof(profiles[index].ssid)) || + !std::memchr(profiles[index].password, '\0', sizeof(profiles[index].password)) || + !has_saved_credentials(profiles[index])) return false; + for (std::size_t previous = 0; previous < index; ++previous) + { + if (same_ssid(profiles[index], profiles[previous])) return false; + } + } + ::platform::ui::settings_store::ScopedChangeBatch changes; + // Reuse the existing runtime profile array; the validated TMS staging set + // belongs to the caller and is never copied into an automatic array. + clear_profiles(); + for (std::size_t index = 0; index < count; ++index) + { + s_runtime.profiles[index] = profiles[index]; + s_runtime.profiles[index].enabled = true; + } + s_runtime.profile_count = count; + s_runtime.next_profile_index = 0; + s_runtime.automatic_profile_attempts = 0; + s_runtime.saved_config = count ? s_runtime.profiles[0] : Config{}; + s_runtime.saved_config.enabled = enabled; + s_runtime.config_cached = true; + const bool profiles_ok = persist_profiles(); + const bool ssid_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiSsidKey, s_runtime.saved_config.ssid); + const bool password_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, s_runtime.saved_config.password); + ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, enabled); + const bool enabled_ok = ::platform::ui::settings_store::get_bool( + kSettingsNs, kWifiEnabledKey, !enabled) == enabled; + const bool saved = profiles_ok && ssid_ok && password_ok && enabled_ok; + if (!saved) + { + s_runtime.profiles_cached = false; + s_runtime.config_cached = false; + } + return saved; +} + bool apply_enabled(bool enabled) { reset_automatic_profile_attempts(); diff --git a/platform/esp/idf_common/src/reticulum_crypto_compat.cpp b/platform/esp/idf_common/src/reticulum_crypto_compat.cpp index 1f34b62a..0a84c4dc 100644 --- a/platform/esp/idf_common/src/reticulum_crypto_compat.cpp +++ b/platform/esp/idf_common/src/reticulum_crypto_compat.cpp @@ -97,6 +97,15 @@ bool multiply_x25519(const std::uint8_t scalar[kX25519KeySize], ReticulumRngCompat RNG{}; +bool Curve25519::eval(std::uint8_t output[32], const std::uint8_t private_key[32], const std::uint8_t point[32]) +{ + static constexpr std::uint8_t base_point[kX25519KeySize] = {9}; + if (!output || !private_key) return false; + const bool ok = multiply_x25519(private_key, point ? point : base_point, output) && !all_zero(output, kX25519KeySize); + if (!ok) std::memset(output, 0, kX25519KeySize); + return ok; +} + void ReticulumRngCompat::begin(const char*) const { // ESP-IDF entropy is provided directly by esp_fill_random(). diff --git a/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp b/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp index 383ce060..04a78230 100644 --- a/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp +++ b/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp @@ -1,9 +1,12 @@ #include "platform/esp/idf_common/sd_card_runtime_sdfat_adapter.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/arduino_common/storage/sd_file_lifetime.h" +#include "platform/esp/common/storage/mount_io_state.h" #include "esp_attr.h" #include "esp_err.h" +#include "esp_heap_caps.h" #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" @@ -65,7 +68,8 @@ SdCardInfo s_info{}; sdmmc_card_t* s_card = nullptr; sdmmc_host_t s_host = SDMMC_HOST_DEFAULT(); sdmmc_host_runtime::SlotOwner s_owner = sdmmc_host_runtime::SlotOwner::None; -bool s_mounted = false; +platform::esp::storage::MountIoState s_mounted; +using SdIoErrorScope = platform::esp::storage::MountIoErrorScope; std::atomic s_media_session{1}; volatile bool s_external_block_owner_active = false; SemaphoreHandle_t s_storage_mutex = nullptr; @@ -299,11 +303,13 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface { if (!card_ || !dst) { + s_mounted.markFailure(); return false; } const esp_err_t err = sdmmc_read_sectors(card_, s_dma_sector, sector, 1); if (err != ESP_OK) { + s_mounted.markFailure(); ESP_LOGW(kTag, "raw read sector=%lu failed: %s", static_cast(sector), @@ -318,6 +324,7 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface { if (!card_ || (!dst && ns > 0)) { + s_mounted.markFailure(); return false; } #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ @@ -327,6 +334,7 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface const esp_err_t err = sdmmc_read_sectors(card_, dst, sector, ns); if (err != ESP_OK) { + s_mounted.markFailure(); ESP_LOGW(kTag, "raw multi-read sector=%lu count=%u failed: %s", static_cast(sector), @@ -353,18 +361,24 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface return card_ ? static_cast(card_->csd.capacity) : 0; } - bool syncDevice() override { return card_ != nullptr; } + bool syncDevice() override + { + if (!card_) s_mounted.markFailure(); + return card_ != nullptr; + } bool writeSector(Sector_t sector, const uint8_t* src) override { if (!card_ || !src) { + s_mounted.markFailure(); return false; } std::memcpy(s_dma_sector, src, kSdSectorSize); const esp_err_t err = sdmmc_write_sectors(card_, s_dma_sector, sector, 1); if (err != ESP_OK) { + s_mounted.markFailure(); ESP_LOGW(kTag, "raw write sector=%lu failed: %s", static_cast(sector), @@ -378,6 +392,7 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface { if (!card_ || (!src && ns > 0)) { + s_mounted.markFailure(); return false; } #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) || \ @@ -387,6 +402,7 @@ class SdmmcBlockDevice final : public FsBlockDeviceInterface const esp_err_t err = sdmmc_write_sectors(card_, src, sector, ns); if (err != ESP_OK) { + s_mounted.markFailure(); ESP_LOGW(kTag, "raw multi-write sector=%lu count=%u failed: %s", static_cast(sector), @@ -709,6 +725,7 @@ SdFileReadResult sd_read_file(const char* path, const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("map_file_read", normalized, capacity); SdFileReadResult result{}; + const uint32_t session = sd_media_session(); auto finish = [&](SdFileReadStatus status, std::size_t bytes_read, @@ -745,17 +762,22 @@ SdFileReadResult sd_read_file(const char* path, { return finish(SdFileReadStatus::Busy, 0, 0, -2); } + if (session != sd_media_session() || s_external_block_owner_active || s_info.backend != SdCardBackend::SdFat) + return finish(SdFileReadStatus::Unavailable, 0, 0, -3); + const SdIoErrorScope io_error(s_mounted); file = s_volume.open(normalized, O_RDONLY); + if (io_error.error()) return finish(SdFileReadStatus::IoError, 0, 0, -6); if (!file) { return finish(SdFileReadStatus::Missing, 0, 0, -1); } file_size = file.fileSize(); + if (io_error.error()) return finish(SdFileReadStatus::IoError, 0, 0, -6); if (file_size == 0 || file_size > capacity) { - file.close(); + if (!file.close() || io_error.error()) return finish(SdFileReadStatus::IoError, 0, file_size, -6); return finish(SdFileReadStatus::Invalid, 0, file_size, -5); } } @@ -773,8 +795,11 @@ SdFileReadResult sd_read_file(const char* path, { return finish(SdFileReadStatus::Busy, total_read, file_size, -2); } + if (session != sd_media_session() || s_external_block_owner_active) + return finish(SdFileReadStatus::Unavailable, total_read, file_size, -3); + const SdIoErrorScope io_error(s_mounted); bytes_read = file.read(buffer + total_read, chunk_size); - if (bytes_read <= 0) + if (bytes_read <= 0 || io_error.error() || static_cast(bytes_read) > chunk_size) { file.close(); return finish(SdFileReadStatus::IoError, total_read, file_size, -6); @@ -790,7 +815,10 @@ SdFileReadResult sd_read_file(const char* path, { return finish(SdFileReadStatus::Busy, total_read, file_size, -2); } - file.close(); + if (session != sd_media_session() || s_external_block_owner_active) + return finish(SdFileReadStatus::Unavailable, total_read, file_size, -3); + const SdIoErrorScope io_error(s_mounted); + if (!file.close() || io_error.error()) return finish(SdFileReadStatus::IoError, total_read, file_size, -6); } return finish(SdFileReadStatus::Ready, total_read, file_size, 0); @@ -820,6 +848,11 @@ bool sd_is_directory(const char* path) } bool sd_mkdir(const char* path) +{ + return sd_mkdir(path, sd_media_session()); +} + +bool sd_mkdir(const char* path, uint32_t expected_session) { const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("mkdir", normalized); @@ -834,9 +867,15 @@ bool sd_mkdir(const char* path) sd_io_end("mkdir", normalized, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || !s_mounted || sd_external_block_owner_active()) + { + sd_io_end("mkdir", normalized, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { - result = s_volume.mkdir(normalized, true) || s_volume.exists(normalized); + const SdIoErrorScope io_error(s_mounted); + result = (s_volume.mkdir(normalized, true) || s_volume.exists(normalized)) && !io_error.error(); sd_io_end("mkdir", normalized, start_ms, result); return result; } @@ -870,6 +909,11 @@ bool sd_rmdir(const char* path) } bool sd_remove(const char* path) +{ + return sd_remove(path, sd_media_session()); +} + +bool sd_remove(const char* path, uint32_t expected_session) { const char* normalized = normalize_sd_path(path); const uint32_t start_ms = sd_io_begin("remove", normalized); @@ -884,6 +928,11 @@ bool sd_remove(const char* path) sd_io_end("remove", normalized, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || s_external_block_owner_active) + { + sd_io_end("remove", normalized, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { result = s_volume.remove(normalized); @@ -895,6 +944,11 @@ bool sd_remove(const char* path) } bool sd_rename(const char* old_path, const char* new_path) +{ + return sd_rename(old_path, new_path, sd_media_session()); +} + +bool sd_rename(const char* old_path, const char* new_path, uint32_t expected_session) { const char* normalized_old = normalize_sd_path(old_path); const char* normalized_new = normalize_sd_path(new_path); @@ -910,6 +964,11 @@ bool sd_rename(const char* old_path, const char* new_path) sd_io_end("rename", normalized_old, start_ms, false, 0, -2); return false; } + if (expected_session != sd_media_session() || s_external_block_owner_active) + { + sd_io_end("rename", normalized_old, start_ms, false, 0, -1); + return false; + } if (s_info.backend == SdCardBackend::SdFat) { result = s_volume.rename(normalized_old, normalized_new); @@ -923,15 +982,34 @@ bool sd_rename(const char* old_path, const char* new_path) class SdRuntimeFile::Impl { public: + struct PreviousLayoutBudget + { + FsFile file; + SdCardBackend backend; + char path[128]; + char mode[8]; + }; FsFile sdfat_file; + uint32_t session = 0; SdCardBackend backend = SdCardBackend::None; char path[128]{}; - char mode[8]{}; }; SdRuntimeFile::SdRuntimeFile() : impl_(new (std::nothrow) Impl()) { + static_assert(sizeof(Impl) <= sizeof(Impl::PreviousLayoutBudget), "IDF file session must not grow its heap object"); +} + +SdRuntimeFile::SdRuntimeFile(SdRuntimeMemoryPolicy policy) : impl_(nullptr) +{ + if (policy == SdRuntimeMemoryPolicy::Default) + { + impl_ = new (std::nothrow) Impl(); + return; + } + void* memory = heap_caps_malloc(sizeof(Impl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (memory) impl_ = new (memory) Impl(); } SdRuntimeFile::~SdRuntimeFile() @@ -956,7 +1034,6 @@ bool SdRuntimeFile::open(const char* path, const char* mode, uint32_t expected_s const char* normalized = normalize_sd_path(path); copy_path(impl_->path, sizeof(impl_->path), normalized); - copy_path(impl_->mode, sizeof(impl_->mode), mode ? mode : "r"); const uint32_t start_ms = sd_io_begin("file_open", impl_->path); if (open_mode_mutates(mode) && sd_mutation_blocked_by_external_owner("file_open", impl_->path, start_ms)) @@ -979,6 +1056,7 @@ bool SdRuntimeFile::open(const char* path, const char* mode, uint32_t expected_s { impl_->sdfat_file = s_volume.open(normalized, sdfat_open_flags(mode)); impl_->backend = impl_->sdfat_file ? SdCardBackend::SdFat : SdCardBackend::None; + impl_->session = expected_session; sd_io_end("file_open", impl_->path, start_ms, impl_->backend == SdCardBackend::SdFat); return impl_->backend == SdCardBackend::SdFat; } @@ -997,7 +1075,7 @@ void SdRuntimeFile::close() { const uint32_t start_ms = sd_io_begin("file_close", impl_->path); SdRuntimeBusGuard guard("sd_file_close"); - if (guard.locked()) + if (guard.locked() && is_open()) { impl_->sdfat_file.close(); sd_io_end("file_close", impl_->path, start_ms, true); @@ -1007,14 +1085,16 @@ void SdRuntimeFile::close() sd_io_end("file_close", impl_->path, start_ms, false, 0, -2); } } + abandon_sd_file(impl_->sdfat_file); impl_->backend = SdCardBackend::None; + impl_->session = 0; impl_->path[0] = '\0'; - impl_->mode[0] = '\0'; } bool SdRuntimeFile::is_open() const { - return impl_ != nullptr && impl_->backend != SdCardBackend::None; + return impl_ != nullptr && impl_->backend != SdCardBackend::None && + impl_->session == sd_media_session() && !s_external_block_owner_active; } int SdRuntimeFile::available() const @@ -1026,7 +1106,7 @@ int SdRuntimeFile::available() const if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeBusGuard guard("sd_file_available"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1052,6 +1132,7 @@ int SdRuntimeFile::read(void* buffer, std::size_t bytes_to_read) sd_io_end("file_read", impl_->path, start_ms, false, bytes_to_read, -2); return -1; } + if (!is_open()) return -1; const int result = impl_->sdfat_file.read(buffer, bytes_to_read); sd_io_end("file_read", impl_->path, start_ms, result >= 0, bytes_to_read, result); return result; @@ -1068,7 +1149,7 @@ int SdRuntimeFile::read_byte() if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeBusGuard guard("sd_file_read_byte"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return -1; } @@ -1092,6 +1173,7 @@ std::size_t SdRuntimeFile::read_bytes(char* buffer, std::size_t bytes_to_read) sd_io_end("file_read_bytes", impl_->path, start_ms, false, bytes_to_read, -2); return 0; } + if (!is_open()) return 0; int result = impl_->sdfat_file.read(buffer, bytes_to_read); sd_io_end("file_read_bytes", impl_->path, start_ms, result >= 0, bytes_to_read, result); return result > 0 ? static_cast(result) : 0; @@ -1119,6 +1201,7 @@ std::size_t SdRuntimeFile::write(const void* buffer, std::size_t bytes_to_write) sd_io_end("file_write", impl_->path, start_ms, false, bytes_to_write, -2); return 0; } + if (!is_open()) return 0; const std::size_t result = impl_->sdfat_file.write(buffer, bytes_to_write); sd_io_end("file_write", impl_->path, start_ms, result == bytes_to_write, bytes_to_write, result); return result; @@ -1183,6 +1266,7 @@ bool SdRuntimeFile::seek(uint64_t offset) read_busy_ = s_storage_mutex != nullptr; return false; } + if (!is_open()) return false; return impl_->sdfat_file.seekSet(offset); } return false; @@ -1197,7 +1281,7 @@ uint64_t SdRuntimeFile::position() const if (impl_->backend == SdCardBackend::SdFat) { SdRuntimeBusGuard guard("sd_file_position"); - if (!guard.locked()) + if (!guard.locked() || !is_open()) { return 0; } @@ -1221,6 +1305,7 @@ uint64_t SdRuntimeFile::size() const read_busy_ = s_storage_mutex != nullptr; return 0; } + if (!is_open()) return 0; return impl_->sdfat_file.fileSize(); } return 0; @@ -1245,6 +1330,7 @@ bool SdRuntimeFile::flush() sd_io_end("file_flush", impl_->path, start_ms, false, 0, -2); return false; } + if (!is_open()) return false; const bool result = impl_->sdfat_file.sync(); sd_io_end("file_flush", impl_->path, start_ms, result); return result; @@ -1255,14 +1341,32 @@ bool SdRuntimeFile::flush() class SdRuntimeDir::Impl { public: + struct PreviousLayoutBudget + { + FsFile dir; + SdCardBackend backend; + char path[128]; + }; FsFile sdfat_dir; - SdCardBackend backend = SdCardBackend::None; + uint32_t session = 0; char path[128]{}; }; SdRuntimeDir::SdRuntimeDir() : impl_(new (std::nothrow) Impl()) { + static_assert(sizeof(Impl) <= sizeof(Impl::PreviousLayoutBudget), "Directory session must reuse cached backend/padding"); +} + +SdRuntimeDir::SdRuntimeDir(SdRuntimeMemoryPolicy policy) : impl_(nullptr) +{ + if (policy == SdRuntimeMemoryPolicy::Default) + { + impl_ = new (std::nothrow) Impl(); + return; + } + void* memory = heap_caps_malloc(sizeof(Impl), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (memory) impl_ = new (memory) Impl(); } SdRuntimeDir::~SdRuntimeDir() @@ -1273,31 +1377,7 @@ SdRuntimeDir::~SdRuntimeDir() bool SdRuntimeDir::open(const char* path) { - close(); - if (impl_ == nullptr) - { - return false; - } - const char* normalized = normalize_sd_path(path); - copy_path(impl_->path, sizeof(impl_->path), normalized); - const uint32_t start_ms = sd_io_begin("dir_open", impl_->path); - SdRuntimeBusGuard guard("sd_dir_open"); - if (!guard.locked()) - { - sd_io_end("dir_open", impl_->path, start_ms, false, 0, -2); - return false; - } - if (s_info.backend == SdCardBackend::SdFat) - { - impl_->sdfat_dir = s_volume.open(normalized, O_RDONLY); - impl_->backend = - (impl_->sdfat_dir && impl_->sdfat_dir.isDir()) ? SdCardBackend::SdFat - : SdCardBackend::None; - sd_io_end("dir_open", impl_->path, start_ms, impl_->backend == SdCardBackend::SdFat); - return impl_->backend == SdCardBackend::SdFat; - } - sd_io_end("dir_open", impl_->path, start_ms, false, 0, -1); - return false; + return open_read_status(path, sd_media_session()) == SdFileReadStatus::Ready; } SdFileReadStatus SdRuntimeDir::open_read_status(const char* path, uint32_t expected_session) @@ -1310,10 +1390,12 @@ SdFileReadStatus SdRuntimeDir::open_read_status(const char* path, uint32_t expec if (!guard.locked()) return SdFileReadStatus::Busy; if (expected_session != sd_media_session() || sd_external_block_owner_active() || s_info.backend != SdCardBackend::SdFat) return SdFileReadStatus::Unavailable; + const SdIoErrorScope io_error(s_mounted); impl_->sdfat_dir = s_volume.open(normalized, O_RDONLY); + if (io_error.error()) return SdFileReadStatus::IoError; if (!impl_->sdfat_dir) return SdFileReadStatus::Missing; if (!impl_->sdfat_dir.isDir()) return SdFileReadStatus::Invalid; - impl_->backend = SdCardBackend::SdFat; + impl_->session = expected_session; return SdFileReadStatus::Ready; } @@ -1323,11 +1405,11 @@ void SdRuntimeDir::close() { return; } - if (impl_->backend == SdCardBackend::SdFat) + if (impl_->sdfat_dir) { const uint32_t start_ms = sd_io_begin("dir_close", impl_->path); SdRuntimeBusGuard guard("sd_dir_close"); - if (guard.locked()) + if (guard.locked() && is_open()) { impl_->sdfat_dir.close(); sd_io_end("dir_close", impl_->path, start_ms, true); @@ -1337,71 +1419,74 @@ void SdRuntimeDir::close() sd_io_end("dir_close", impl_->path, start_ms, false, 0, -2); } } - impl_->backend = SdCardBackend::None; + abandon_sd_file(impl_->sdfat_dir); + impl_->session = 0; impl_->path[0] = '\0'; } bool SdRuntimeDir::is_open() const { - return impl_ != nullptr && impl_->backend != SdCardBackend::None; + return impl_ && impl_->sdfat_dir && impl_->session == sd_media_session() && + s_info.backend == SdCardBackend::SdFat && !s_external_block_owner_active; } bool SdRuntimeDir::read_next(char* name, std::size_t name_size, bool* is_dir) { - if (!is_open() || name == nullptr || name_size == 0) - { - return false; - } - name[0] = '\0'; - if (is_dir != nullptr) - { - *is_dir = false; - } - - if (impl_->backend == SdCardBackend::SdFat) - { - const uint32_t start_ms = sd_io_begin("dir_read", impl_->path); - SdRuntimeBusGuard guard("sd_dir_read"); - if (!guard.locked()) - { - sd_io_end("dir_read", impl_->path, start_ms, false, 0, -2); - return false; - } - FsFile entry = impl_->sdfat_dir.openNextFile(O_RDONLY); - if (!entry) - { - sd_io_end("dir_read", impl_->path, start_ms, true, 0, 0); - return false; - } - entry.getName(name, name_size); - if (is_dir != nullptr) - { - *is_dir = entry.isDir(); - } - entry.close(); - sd_io_end("dir_read", impl_->path, start_ms, true, 0, name[0] != '\0' ? 1 : 0); - return name[0] != '\0'; - } - - return false; + return read_next_status(name, name_size, is_dir) == SdDirReadStatus::Entry; } SdDirReadStatus SdRuntimeDir::read_next_status(char* name, std::size_t name_size, bool* is_dir) { + return read_next_info(name, name_size, is_dir, nullptr, nullptr); +} + +SdDirReadStatus SdRuntimeDir::read_next_info(char* name, std::size_t name_size, bool* is_dir, + uint64_t* file_size, uint64_t* next_position) +{ + if (file_size) *file_size = 0; + if (next_position) *next_position = 0; if (!name || !name_size) return SdDirReadStatus::Invalid; name[0] = 0; if (is_dir) *is_dir = false; SdRuntimeBusGuard guard("sd_tmap_dir_read"); if (!guard.locked()) return SdDirReadStatus::Busy; if (!is_open() || sd_external_block_owner_active()) return SdDirReadStatus::Unavailable; + const SdIoErrorScope io_error(s_mounted); FsFile entry = impl_->sdfat_dir.openNextFile(O_RDONLY); - if (!entry) return impl_->sdfat_dir.getError() ? SdDirReadStatus::IoError : SdDirReadStatus::End; - if (!entry.getName(name, name_size)) return SdDirReadStatus::IoError; - if (is_dir) *is_dir = entry.isDir(); - entry.close(); + if (!entry) return io_error.error() || impl_->sdfat_dir.getError() ? SdDirReadStatus::IoError : SdDirReadStatus::End; + const bool named = entry.getName(name, name_size); + const bool directory = entry.isDir(); + const uint64_t bytes = directory ? 0 : entry.fileSize(); + const uint64_t position = impl_->sdfat_dir.curPosition(); + const bool closed = entry.close(); + if (!named || !closed || io_error.error()) + { + name[0] = 0; + return SdDirReadStatus::IoError; + } + if (is_dir) *is_dir = directory; + if (file_size) *file_size = bytes; + if (next_position) *next_position = position; return name[0] ? SdDirReadStatus::Entry : SdDirReadStatus::IoError; } +bool SdRuntimeDir::seek_read_position(uint64_t position) +{ + return seek_read_status(position) == SdFileReadStatus::Ready; +} + +SdFileReadStatus SdRuntimeDir::seek_read_status(uint64_t position) +{ + if (position % 32 || !impl_) return SdFileReadStatus::Invalid; + SdRuntimeBusGuard guard("sd_dir_seek"); + if (!guard.locked()) return SdFileReadStatus::Busy; + if (!is_open()) return SdFileReadStatus::Unavailable; + const SdIoErrorScope io_error(s_mounted); + const bool result = impl_->sdfat_dir.seekSet(position); + if (io_error.error()) return SdFileReadStatus::IoError; + return result ? SdFileReadStatus::Ready : SdFileReadStatus::Invalid; +} + bool sd_read_raw(uint32_t lba, uint8_t* buffer) { char path[32]; diff --git a/platform/esp/idf_common/src/storage_runtime.cpp b/platform/esp/idf_common/src/storage_runtime.cpp index e21f91be..c9c4e6a9 100644 --- a/platform/esp/idf_common/src/storage_runtime.cpp +++ b/platform/esp/idf_common/src/storage_runtime.cpp @@ -5,6 +5,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "platform/esp/arduino_common/chat/infra/store/sd_store.h" +#include "platform/esp/arduino_common/geocaching/browse_runtime.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/boards/board_runtime.h" #include "platform/esp/common/memory_budget.h" @@ -35,6 +36,7 @@ using OperationGeneration = using Result = platform::esp::common::storage::StorageOperationResult; using ResultKind = platform::esp::common::storage::StorageOperationResultKind; +namespace geo = platform::esp::arduino_common::geocaching::browse_runtime; class PsramBlobSink final : public chat::IMeshPeerDirectoryBlobSink { @@ -183,6 +185,19 @@ class SdMaintenanceAdapter final : public Adapter } if (operation == Operation::Persist) { + // A retry continues its selected owner. Reuse the existing + // persistence generation field while the peer payload is empty. + if (geocaching_steps_ && peer_payload_generation_ == generation) + return Result::inProgressResult(operation, generation); + const bool peer_pending = peer_directory_ && peer_directory_->persistencePending(); + if (geo::workPending() && (geocaching_turn_ || !peer_pending)) + { + peer_payload_generation_ = generation; + geocaching_steps_ = 8; + geocaching_turn_ = false; + return Result::inProgressResult(operation, generation); + } + geocaching_turn_ = true; return beginPeerPersistence(generation); } return execute(operation, generation); @@ -199,6 +214,15 @@ class SdMaintenanceAdapter final : public Adapter } if (operation == Operation::Persist) { + if (geocaching_steps_ && peer_payload_generation_ == generation) + { + if (geo::workPending()) geo::step(); + if (--geocaching_steps_ && geo::workPending()) + return Result::inProgressResult(operation, generation); + geocaching_steps_ = 0; + peer_payload_generation_ = 0; + return Result::completedResult(operation, generation); + } return stepPeerPersistence(generation); } if (!store_) @@ -219,6 +243,7 @@ class SdMaintenanceAdapter final : public Adapter } if (operation == Operation::Persist) { + geocaching_steps_ = 0; peer_payload_.release(); peer_payload_generation_ = 0U; peer_payload_revision_ = 0U; @@ -501,6 +526,9 @@ class SdMaintenanceAdapter final : public Adapter OperationGeneration peer_hydration_generation_ = 0U; bool peer_hydration_store_in_progress_ = false; bool peer_hydration_pending_ = false; + // Occupy the existing tail alignment padding (target object remains 44 B). + uint8_t geocaching_steps_ = 0; + bool geocaching_turn_ = false; void releasePeerHydrationPayload() { @@ -622,7 +650,7 @@ void tick_deferred_storage() platform::esp::common::storage::StorageMaintenanceDemand demand{}; demand.persistence_pending = - s_peer_directory && s_peer_directory->persistencePending(); + (s_peer_directory && s_peer_directory->persistencePending()) || geo::workPending(); demand.compaction_pending = s_store && s_store->compactionPending(); (void)s_owner.submitTick(ownerNow(nullptr), diff --git a/platform/linux/common/src/platform/ui/route_storage.cpp b/platform/linux/common/src/platform/ui/route_storage.cpp index 07dffe4f..2d70a86c 100644 --- a/platform/linux/common/src/platform/ui/route_storage.cpp +++ b/platform/linux/common/src/platform/ui/route_storage.cpp @@ -1,16 +1,21 @@ #include "platform/ui/route_storage.h" +#include "track_sharing/image_source_binding.h" #include #include #include #include #include +#include #include #include #include #include #include #include +#if defined(TRAIL_MATE_HAS_OPENSSL) +#include +#endif #include "platform/linux/runtime_paths.h" @@ -364,6 +369,27 @@ bool route_asset_file_exists(const std::string& path) !ec; } +bool route_image_source_matches(const std::string& path, const uint8_t expected_source_hash[32]) +{ + if (path.empty() || !expected_source_hash) return false; + const auto root = route_dir_path(); + const auto input = std::filesystem::path(path); + const auto relative = input.is_absolute() ? input.lexically_relative(root) : input; + std::filesystem::path resolved; + if (!::platform::linux_runtime::resolve_child_under_root(root, relative.generic_string(), resolved)) return false; + std::error_code error; + std::filesystem::path binding; + const auto binding_relative = relative.generic_string() + ".source"; + if (!::platform::linux_runtime::resolve_child_under_root(root, binding_relative, binding)) return false; + if (std::filesystem::file_size(binding, error) != track_sharing::kImageSourceBindingBytes || error) return false; + uint8_t bytes[track_sharing::kImageSourceBindingBytes]; + std::ifstream file(binding, std::ios::binary); + if (!file.read(reinterpret_cast(bytes), sizeof(bytes))) return false; + track_sharing::ImageSourceBindingView record; + if (!track_sharing::decodeImageSourceBinding(bytes, sizeof(bytes), record) || std::memcmp(record.source_hash.data, expected_source_hash, 32)) return false; + return std::filesystem::file_size(resolved, error) == record.file_bytes && !error; +} + RouteImageDownloadResult download_route_image(const std::string& url, const std::string& output_path, std::uint32_t max_bytes) @@ -376,6 +402,17 @@ RouteImageDownloadResult download_route_image(const std::string& url, return result; } +bool route_image_reference_hash(std::string_view reference, uint8_t output[32]) +{ + if (!output || reference.empty() || reference.size() > 4096) return false; +#if defined(TRAIL_MATE_HAS_OPENSSL) + unsigned length = 0; + return EVP_Digest(reference.data(), reference.size(), output, &length, EVP_sha256(), nullptr) == 1 && length == 32; +#else + return false; +#endif +} + bool start_route_image_download(const std::string& asset_id, std::vector items, std::string& out_error, @@ -447,13 +484,15 @@ bool start_route_image_download(const std::string& asset_id, } bool start_route_image_cache_build(const std::string& asset_id, - const std::vector& items, + std::vector items, std::string& out_error, - RouteImageTaskPresentation presentation) + RouteImageTaskPresentation presentation, + const std::string& package_route_path) { (void)asset_id; (void)items; (void)presentation; + (void)package_route_path; out_error = "Route image cache unsupported on host"; return false; } diff --git a/scripts/audit_app_config_preferences.mjs b/scripts/audit_app_config_preferences.mjs new file mode 100644 index 00000000..d92a479c --- /dev/null +++ b/scripts/audit_app_config_preferences.mjs @@ -0,0 +1,86 @@ +import {readFile,writeFile} from 'node:fs/promises'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import path from 'node:path'; +import {maskStorageSource,storageArguments} from './audit_preferences_access.mjs'; + +const storeFile='platform/esp/arduino_common/src/app_config_store.cpp',codecFile='modules/core_sys/src/app/tms_config_codec.cpp'; +const aliases={chat_policy:'chat_policy',meshtastic_config:'meshtastic_config',meshcore_config:'meshcore_config',rnode_config:'rnode_config',motion_config:'motion_config',aprs:'aprs'}; +function body(source,name){ + const masked=maskStorageSource(source),start=masked.lastIndexOf('bool '+name+'('); + if(start<0)throw Error('Missing production function '+name); + const open=masked.indexOf('{',start);let depth=1,end=open+1; + while(end{if(!owner)return;const set=fields.get(owner)??new Set();set.add(key);fields.set(owner,set);}; + for(const [name,mesh] of [['writeGeneral',null],['writeMeshCore','meshcore_config'],['writeReticulum','rnode_config']]){ + const {source}=body(codec,name),masked=maskStorageSource(source); + for(const call of masked.matchAll(/\bencoder\.(?:boolean|u8|u16|u32|i8|f32|text|blob)\s*\(/g)){ + const args=storageArguments(source,masked,call.index+call[0].length),key=args[0]?.match(/^"([\w.]+)"$/)?.[1];if(key)add(key,member(args[1]??'',mesh)); + } + } + const base=body(codec,'writeMeshBase'),masked=maskStorageSource(base.source); + for(const call of masked.matchAll(/\bTMS_MESH_VALUE\s*\(/g)){ + const args=storageArguments(base.source,masked,call.index+call[0].length),suffix=args[1]?.match(/^"([\w.]+)"$/)?.[1],owner=member(args[2]??''); + if(suffix&&owner)for(const [prefix,profile] of [['mt.','meshtastic_config'],['mc.base.','meshcore_config'],['rt.','rnode_config']])add(prefix+suffix,profile+'.'+owner); + } + for(const [prefix,profile] of [['mt.','meshtastic_config'],['mc.base.','meshcore_config'],['rt.','rnode_config']])for(const side of ['primary','secondary']){ + // These two explicit blob writes use snprintf rather than the value macro. + if(!base.source.includes('"%s'+side+'_psk"'))throw Error('PSK projection changed');add(prefix+side+'_psk',profile+'.'+side+'_key'); + } + const allMasked=maskStorageSource(codec),suffixes=['enabled','uplink','downlink','host','port','root','username','password']; + for(const call of allMasked.matchAll(/\bwriteMqtt\s*\(/g)){ + const args=storageArguments(codec,allMasked,call.index+call[0].length),prefix=args[1]?.match(/^"(mt\.mqtt\.|mc\.mqtt\.)"$/)?.[1];if(!prefix)continue; + if(args.length!==10)throw Error('MQTT projection changed');for(let i=0;i<8;i++)add(prefix+suffixes[i],member(args[i+2],prefix==='mc.mqtt.'?'meshcore_config':null)); + } + return fields; +} +export function collectLegacyWrites(store,codec){ + const projection=collectCoreProjection(codec),constants=new Map(),withStrings=maskStorageSource(store,{preserveStrings:true}); + for(const match of withStrings.matchAll(/constexpr\s+const\s+char\s*\*\s*(k\w+)\s*=\s*"([\w.-]+)"\s*;/g)){ + if(constants.has(match[1]))throw Error('Ambiguous storage key constant');constants.set(match[1],match[2]); + } + const fn=body(store,'saveAppConfigToPreferences'),masked=maskStorageSource(fn.source),rows=[]; + for(const section of masked.matchAll(/\bif\s*\(save_(chat|gps|settings|aprs)\)\s*\{/g)){ + const namespace=section[1],begin=section.index+section[0].length;let depth=1,end=begin; + while(end!row.fields.length),escape=value=>String(value).replaceAll('|','\\|').replaceAll('\n',' '); + const report=['# AppConfig 传统保存与核心 TMS 投影对账','', + '由 scripts/audit_app_config_preferences.mjs 读取实际 saveAppConfigToPreferences 与核心 TMS writer。展开四个保存域中的包装器实参,解析同文件常量、成员投影与明确列出的动态槽/镜像/长度元数据;不记录配置值、默认值、秘密或缓冲内容,不读取设备。','', + `找到 ${rows.length} 个保存调用;${missing.length} 个没有投影关系,必须继续追踪。调用数不是配置数;一个字段可对应多个元数据键。`,'', + '范围:此表解决旧包装器实参不可见的缺口,只有保存→导出源码关系。不是编译器分析,不证明加载/TMS消费/校验/硬件生效或所有独立配置。前缀/MQTT固定参数位置、活动槽与长度元数据使用当前生产结构,改变时必须复核。Reticulum接口/组的专用持久化、独立BLE/模块/设置和账号密钥仍按23/24/26/44/46核对。删除的旧键不是新增可维护配置。','', + '| Namespace | 存储键或模板 | 方法 | 核心TMS投影 | 关系 | 保存来源 |','|---|---|---|---|---|---|', + ...rows.map(row=>`| ${row.namespace} | ${escape(row.key)} | ${row.method} | ${row.fields.join(', ')||'待追踪'} | ${row.classification} | [${storeFile}:${row.line}](../../../../${storeFile}#L${row.line}) |`),''].join('\n'); + const output=path.join(root,'docs/locales/zh-Hans/web-outdoor-management/47-AppConfig传统保存投影对账.md'); + if(check){if((await readFile(output,'utf8')).replaceAll('\r\n','\n')!==report)throw Error('AppConfig inventory stale');}else await writeFile(output,report); + return {calls:rows.length,unresolved:missing.map(row=>({namespace:row.namespace,key:row.key,line:row.line}))}; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href)console.log(JSON.stringify(await runAppConfigAudit(fileURLToPath(new URL('../',import.meta.url)),{check:process.argv.includes('--check')}))); diff --git a/scripts/audit_nvs_access.mjs b/scripts/audit_nvs_access.mjs new file mode 100644 index 00000000..7b02c133 --- /dev/null +++ b/scripts/audit_nvs_access.mjs @@ -0,0 +1,52 @@ +import {readdir,readFile,writeFile} from 'node:fs/promises'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import path from 'node:path'; +import {maskStorageSource,storageArguments} from './audit_preferences_access.mjs'; + +export function scanNvsAccesses(source,file){ + const masked=maskStorageSource(source),rows=[]; + for(const match of masked.matchAll(/\b(nvs_(?:open(?:_from_partition)?|(?:get|set)_(?:u8|i8|u16|i16|u32|i32|u64|i64|str|blob)|erase_key|erase_all|commit|close))\s*\(/g)){ + const method=match[1],args=storageArguments(source,masked,match.index+match[0].length),opened=method==='nvs_open'||method==='nvs_open_from_partition',partitioned=method==='nvs_open_from_partition'; + const kind=opened?'namespace':/^nvs_(?:get_|set_|erase_key)/.test(method)?'key':method==='nvs_erase_all'?'clear':method==='nvs_commit'?'commit':'close'; + const expression=opened?args[partitioned?1:0]:kind==='key'?args[1]:''; + const handle=opened?args[partitioned?3:2]:args[0]; + if(expression===undefined||handle===undefined)continue; + // Only namespace/key identifiers and handle expressions. Never defaults, + // payloads, output buffers, values or partition names. + rows.push({file,line:source.slice(0,match.index).split('\n').length,method,kind,handle,expression,literal:expression.match(/^"([A-Za-z0-9_.-]+)"$/)?.[1]??null}); + } + return rows; +} +export function scanNvsKeyAliases(source){ + const masked=maskStorageSource(source),table=masked.match(/constexpr\s+StorageKeyAlias\s+kStorageKeyAliases\[\]\s*=\s*\{([\s\S]*?)\};/);if(!table)throw Error('Missing authoritative NVS key alias table'); + const start=table.index+table[0].indexOf('{')+1,body=maskStorageSource(source,{preserveStrings:true}).slice(start,start+table[1].length); + const pattern=/\{\s*"([A-Za-z0-9_.-]+)"\s*,\s*"([A-Za-z0-9_.-]+)"\s*\}/g; + const entries=[...body.matchAll(pattern)].map(row=>({logical:row[1],stored:row[2]})); + if(body.replace(pattern,'').replace(/[\s,]/g,''))throw Error('Unsupported NVS key alias entry'); + const limit=Number(masked.match(/kMaxNvsKeyLength\s*=\s*(\d+)/)?.[1]); + if(!limit||!entries.length||new Set(entries.map(row=>row.logical)).size!==entries.length||entries.some(row=>row.stored.length>limit)||new Set(entries.map(row=>row.stored)).size!==entries.length)throw Error('Invalid NVS key alias table'); + return {limit,entries}; +} +export async function collectNvsAccesses(root,roots=['platform/esp','modules/ui_shared','third_party']){ + const pending=[...roots],files=[],rows=[]; + while(pending.length){const folder=pending.pop();for(const entry of await readdir(path.join(root,folder),{withFileTypes:true})){const file=folder+'/'+entry.name;if(entry.isDirectory())pending.push(file);else if(/\.(?:c|cpp|h|hpp)$/.test(entry.name))files.push(file);}} + files.sort();for(const file of files)rows.push(...scanNvsAccesses(await readFile(path.join(root,file),'utf8'),file));return {files,rows}; +} +export async function runNvsAudit(root,{check=false}={}){ + const {files,rows}=await collectNvsAccesses(root),aliasFile='platform/esp/common/include/platform/esp/common/settings_store_impl.h',aliases=scanNvsKeyAliases(await readFile(path.join(root,aliasFile),'utf8')); + const escape=value=>String(value).replaceAll('|','\\|').replaceAll('\n',' ').replaceAll('`',''); + const reference=row=>`[${row.file}:${row.line}](../../../../${row.file}#L${row.line})`; + const document=['# 裸 NVS 入口与存储别名清单','', + '由 scripts/audit_nvs_access.mjs 扫描 ESP、共享 UI 和仓库 third_party 的 C/C++ 文件。只记录 NVS 函数、namespace/key/handle 实参和位置,不读取设备、不记录值/默认值/缓冲/分区名。不把最近的 nvs_open 自动推断为后续调用的 namespace;跨作用域句柄、宏和条件编译仍需人工核对。','', + `扫描 ${files.length} 个源文件,找到 ${rows.length} 个候选调用,分布于 ${new Set(rows.map(row=>row.file)).size} 个文件。显式打开 ${rows.filter(row=>row.kind==='namespace').length},键访问 ${rows.filter(row=>row.kind==='key').length},其余为提交/关闭/清域。调用数不是独立配置数。`,'', + '这是源码入口清单,不是全部配置或硬件生效证明。系统 SDK、构建环境依赖、其他平台、函数指针/包装器实参以及复杂原始字符串不由此表自动覆盖。依赖库可单独调用 collectNvsAccesses 指定根目录审计;输出应单独记录其版本,不能写进与机器无关的仓库清单。','', + '## NVS 候选调用','', '| 方法 | 类别 | 句柄表达式 | namespace/key 表达式 | 字面量 | 来源 |','|---|---|---|---|---|---|', + ...rows.map(row=>`| ${row.method} | ${row.kind} | ${escape(row.handle)} | ${escape(row.expression)} | ${row.literal??'动态/不适用'} | ${reference(row)} |`),'', + '## 通用存储键映射','', + `实际 resolve_storage_key 在通用 settings_store 的读写入口使用这些别名;最大 NVS 键名为 ${aliases.limit} 字节。映射不是额外可编辑配置,也不能假设直接 Preferences 或其它库自动调用它。原逻辑键的 TMS/网页覆盖应与实际调用方继续对账。`,'', + `来源:[${aliasFile}](../../../../${aliasFile})`,'', '| 逻辑键 | 实际 NVS 键 |','|---|---|',...aliases.entries.map(row=>`| ${row.logical} | ${row.stored} |`),''].join('\n'); + const output=path.join(root,'docs/locales/zh-Hans/web-outdoor-management/43-裸NVS入口与存储别名清单.md'); + if(check){if((await readFile(output,'utf8')).replaceAll('\r\n','\n')!==document)throw Error('NVS inventory stale; regenerate and review.');}else await writeFile(output,document); + return {files:files.length,calls:rows.length,sources:[...new Set(rows.map(row=>row.file))],aliases:aliases.entries.length}; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){const result=await runNvsAudit(fileURLToPath(new URL('../',import.meta.url)),{check:process.argv.includes('--check')});console.log('NVS source inventory: '+JSON.stringify(result)+'; no device values read.');} diff --git a/scripts/audit_preferences_access.mjs b/scripts/audit_preferences_access.mjs new file mode 100644 index 00000000..4791d07d --- /dev/null +++ b/scripts/audit_preferences_access.mjs @@ -0,0 +1,69 @@ +import {readdir,readFile,writeFile} from 'node:fs/promises'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import path from 'node:path'; + +// Keep positions and newlines while excluding comments and quoted content +// from receiver/call discovery. Arguments are read from the original source. +export function maskStorageSource(source,{preserveStrings=false}={}){ + const out=source.split('');let state='code',quote='',escaped=false; + for(let i=0;imatch[1])); + const rows=[]; + for(const match of masked.matchAll(/\b(\w+)\s*\.\s*(begin|get\w+|put\w+|isKey|remove|clear)\s*\(/g)){ + if(!receivers.has(match[1]))continue; + const args=storageArguments(source,masked,match.index+match[0].length),method=match[2]; + const kind=method==='begin'?'namespace':method==='clear'?'clear':'key'; + // Never include defaults, values, payloads, read buffers or credentials. + const expression=kind==='clear'?'':args[0];if(expression===undefined)continue; + const literal=expression.match(/^"([a-zA-Z0-9_.-]+)"$/)?.[1]??null; + rows.push({file,line:source.slice(0,match.index).split('\n').length,receiver:match[1],method,kind,literal,expression}); + } + return rows; +} +export async function runPreferencesAudit(root,{check=false}={}){ + const pending=['platform/esp','modules/ui_shared'],files=[],rows=[]; + while(pending.length){const folder=pending.pop();for(const entry of await readdir(path.join(root,folder),{withFileTypes:true})){const file=folder+'/'+entry.name;if(entry.isDirectory())pending.push(file);else if(/\.(?:cpp|c|h|hpp)$/.test(entry.name))files.push(file);}} + files.sort();for(const file of files)rows.push(...scanPreferencesAccesses(await readFile(path.join(root,file),'utf8'),file)); + const opens=rows.filter(row=>row.kind==='namespace'),keys=rows.filter(row=>row.kind==='key'),clears=rows.filter(row=>row.kind==='clear'); + const escape=value=>String(value).replaceAll('|','\\|').replaceAll('\n',' ').replaceAll('`',''); + const reference=row=>`[${row.file}:${row.line}](../../../../${row.file}#L${row.line})`; + const document=['# 直接 Preferences 持久化入口清单','', + '由 scripts/audit_preferences_access.mjs 扫描 ESP 与共享 UI 源码中声明为 Preferences / ConfigPreferences 的对象及引用参数,提取显式成员调用。排除普通注释和字符串中的伪调用,只记录 namespace/key 实参表达式、方法和位置,不输出默认值、配置值、凭据或字节内容。','', + '这是候选入口清单,不是完整编译器调用图或配置覆盖证明。对象作用域、条件编译、宏、跨文件别名、箭头访问、裸 NVS API、第三方库及其他平台仍需核对;尤其不把同名对象上最近一次 begin 自动当作后续键的 namespace,以免作用域或分支造成错误归类。原始字符串、复杂模板和预处理器结构亦须人工复核。动态实参全部保留。','', + `扫描 ${files.length} 个源文件,发现 ${rows.length} 个调用:namespace 开启 ${opens.length},键访问 ${keys.length},整域清理 ${clears.length}。键访问中 ${keys.filter(row=>row.literal).length} 个为直接字面量,${keys.filter(row=>!row.literal).length} 个仍需追踪常量或动态表达式。`,'', + '## Namespace 开启','', '| 接收对象 | Namespace 实参 | 来源 |','|---|---|---|', + ...opens.map(row=>`| ${row.receiver} | ${escape(row.expression)} | ${reference(row)} |`),'', + '## 键访问','', '| 接收对象 | 方法 | 键实参 | 字面量键 | 来源 |','|---|---|---|---|---|', + ...keys.map(row=>`| ${row.receiver} | ${row.method} | ${escape(row.expression)} | ${row.literal??'待追踪'} | ${reference(row)} |`),'', + '## 整域清理','', '| 接收对象 | 来源 |','|---|---|',...clears.map(row=>`| ${row.receiver} | ${reference(row)} |`),''].join('\n'); + const output=path.join(root,'docs/locales/zh-Hans/web-outdoor-management/25-直接Preferences入口清单.md'); + if(check){if((await readFile(output,'utf8')).replaceAll('\r\n','\n')!==document)throw Error('Preferences access inventory stale; regenerate and review.');} + else await writeFile(output,document); + return {files:files.length,calls:rows.length,namespaces:opens.length,keys:keys.length,clears:clears.length}; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){ + const result=await runPreferencesAudit(fileURLToPath(new URL('../',import.meta.url)),{check:process.argv.includes('--check')}); + console.log(`Preferences access inventory: ${JSON.stringify(result)}; no device values read.`); +} diff --git a/scripts/audit_settings_store_access.mjs b/scripts/audit_settings_store_access.mjs new file mode 100644 index 00000000..0e5da8f3 --- /dev/null +++ b/scripts/audit_settings_store_access.mjs @@ -0,0 +1,58 @@ +import {readdir,readFile,writeFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +const root=fileURLToPath(new URL('../',import.meta.url)),roots=['platform/esp','modules/ui_shared']; +async function sourceFiles(folder){ + const entries=await readdir(path.join(root,folder),{withFileTypes:true}),result=[]; + for(const entry of entries){const file=folder+'/'+entry.name;if(entry.isDirectory())result.push(...await sourceFiles(file));else if(/\.(?:cpp|c|h|hpp)$/.test(entry.name))result.push(file);} + return result; +} +function argumentsOf(source,start){ + const args=[];let begin=start,depth=0,quote=null,escaped=false; + for(let position=start;positionmatch[1])]; + const calls=new RegExp('\\b(?:'+aliases.join('|')+')\\s*::\\s*(get_int|get_bool|get_uint|get_string|get_string_into|get_blob|get_blob_into|read_string_into|read_blob_into|read_int_into|read_uint_into|read_u8_into|read_bool_into|put_int|put_bool|put_uint|put_string|put_blob)\\s*\\(','g'),rows=[]; + for(const match of source.matchAll(calls)){ + const args=argumentsOf(source,match.index+match[0].length);if(args.length<2)continue; + rows.push({file,line:source.slice(0,match.index).split('\n').length,method:match[1],namespace:resolveSymbol(args[0],constants),key:resolveSymbol(args[1],constants),namespaceExpression:args[0],keyExpression:args[1]}); + } + return rows; +} +const files=(await Promise.all(roots.map(sourceFiles))).flat().sort(),rows=[]; +for(const file of files)rows.push(...scanSettingsAccesses(await readFile(path.join(root,file),'utf8'),file)); +const known=rows.filter(row=>row.namespace&&row.key),dynamic=rows.filter(row=>!row.namespace||!row.key),groups=new Map(); +for(const row of known){const key=row.namespace+'.'+row.key;if(!groups.has(key))groups.set(key,[]);groups.get(key).push(row);} +const escape=value=>String(value).replaceAll('|','\\|').replaceAll('\n',' ').replaceAll('`',''); +const reference=row=>`[${row.file}:${row.line}](../../../../${row.file}#L${row.line})`; +const document=['# ESP 与共享 UI 持久化入口清单','', + '由 scripts/audit_settings_store_access.mjs 从 platform/esp 和 modules/ui_shared 的 C/C++ 源文件提取 settings_store 及其同文件 namespace 别名的显式读写调用。仅解析字面量及同文件 k 开头的唯一字符串常量;不会展开宏、跨文件常量或动态键,不按文件名推断覆盖状态。所有动态入口保留在下表,必须人工追到实参。','', + '这是一份源码候选入口清单,不是配置覆盖或实机验收证明。未包含直接 Preferences/NVS API、无 namespace 限定调用、其他平台、第三方库的存储;注释/条件编译及作用域仍需结合源码人工核对。默认值及调用的第三个以后参数不写入报告,且不读取设备、配置文件或私钥。不能仅凭某键有 TMS 同义项就认定导出、校验、写入及生效均已完成。','', + `扫描 ${files.length} 个源文件,发现 ${rows.length} 个显式调用;解析 ${known.length} 个调用、${groups.size} 个 namespace/key 组合;${dynamic.length} 个动态入口保留待核对。`,'', + '## 静态键与实际调用来源','', '| Namespace | 存储键 | 读写方法 | 来源 |','|---|---|---|---|', + ...[...groups].sort(([a],[b])=>a.localeCompare(b)).map(([,items])=>`| ${items[0].namespace} | ${items[0].key} | ${[...new Set(items.map(row=>row.method))].sort().join(', ')} | ${items.map(reference).join(', ')} |`),'', + '## 动态入口:不得忽略','', '| Namespace 表达式 | 键表达式 | 方法 | 来源 |','|---|---|---|---|', + ...dynamic.map(row=>`| ${escape(row.namespaceExpression)} | ${escape(row.keyExpression)} | ${row.method} | ${reference(row)} |`),''].join('\n'); +const output=path.join(root,'docs/locales/zh-Hans/web-outdoor-management/22-持久化入口清单.md'); +if(process.argv.includes('--check')){if((await readFile(output,'utf8')).replaceAll('\r\n','\n')!==document)throw Error('Settings access inventory stale; regenerate and review.');} +else await writeFile(output,document); +console.log(`Settings access inventory: ${rows.length} calls, ${groups.size} static keys, ${dynamic.length} dynamic entries; no device values read.`); diff --git a/scripts/audit_web_config_fields.mjs b/scripts/audit_web_config_fields.mjs new file mode 100644 index 00000000..73e6982d --- /dev/null +++ b/scripts/audit_web_config_fields.mjs @@ -0,0 +1,55 @@ +import {readFile,writeFile} from 'node:fs/promises'; +import {configurationField} from '../site/device/src/config-schema.js'; +import {configurationPresentation} from '../site/device/src/config-field-presentation.js'; +const sources=['modules/core_sys/src/app/tms_config_codec.cpp','platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp','platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp','modules/core_sys/include/app/tms_c6_config_extension.h']; +const contents=await Promise.all(sources.map(file=>readFile(new URL('../'+file,import.meta.url),'utf8'))); +const fields=new Map(); +const add=(key,source)=>{if(!fields.has(key))fields.set(key,new Set());fields.get(key).add(source);}; +const table=(name,prefix)=>{ + const matched=contents[0].match(new RegExp('constexpr const char\\* '+name+'\\[\\] = \\{([\\s\\S]*?)\\};')); + if(!matched)throw Error('Missing authoritative schema table: '+name); + for(const value of matched[1].matchAll(/"([a-z0-9_.]+)"/g))add(prefix+value[1],sources[0]); +}; +table('kGeneralFields',''); +const meshPrefixes=new Set([...contents[0].matchAll(/\bwriteMeshBase\(\s*encoder,\s*"([a-z0-9_.]+)"/g)].map(match=>match[1])); +const mqttPrefixes=new Set([...contents[0].matchAll(/\bwriteMqtt\(\s*encoder,\s*"([a-z0-9_.]+)"/g)].map(match=>match[1])); +if(!meshPrefixes.size||!mqttPrefixes.size)throw Error('Missing production mesh/MQTT prefix calls'); +for(const prefix of meshPrefixes)table('kMeshBaseFields',prefix); +for(const prefix of mqttPrefixes)table('kMqttFields',prefix); +table('kMeshCoreFields','mc.');table('kMeshCoreChannelFields','mc.channel.{slot}.');table('kReticulumFields','rt.');table('kReticulumGroupFields','rt.group.{slot}.'); +// C6 keys are generated by stringifying macro arguments, not literal writers. +// Cross-check write and consume declarations so an omission fails this audit. +const c6Writes=[...contents[3].matchAll(/C6_WRITE\(\s*([a-z_]+)\s*,\s*(?:boolean|u8|u16|text)\s*\)/g)].map(match=>match[1]); +const c6Consumes=[...contents[3].matchAll(/C6_(?:FIELD|TEXT)\(\s*([a-z_]+)\s*,(?:\s*[a-z0-9_]+\s*,)?\s*(\d+)\s*\)/g)]; +const complete=Number(contents[3].match(/kComplete\s*=\s*\(1U\s*<<\s*(\d+)U\)/)?.[1]); +if(!complete||new Set(c6Writes).size!==complete||c6Consumes.length!==complete||new Set(c6Consumes.map(match=>match[1])).size!==complete||new Set(c6Consumes.map(match=>Number(match[2]))).size!==complete|| + c6Consumes.some(match=>Number(match[2])>=complete||!c6Writes.includes(match[1])))throw Error('C6 write/consume inventory mismatch'); +for(const field of c6Writes)add('c6_companion.'+field,sources[3]); +for(let index=0;indexa.localeCompare(b)).map(([key,source])=>{ + const actual=key.replace('{slot}','0'),schema=configurationField({key:actual,type:'u8'},configurationPresentation(actual,'en')); + return {key,label:schema.label,localized:!/[\u3400-\u9fff]/.test(schema.label),source:[...source]}; +}); +const missing=rows.filter(row=>!row.localized); +const escape=value=>value.replaceAll('|','\\|').replaceAll('\n',' '); +const markdown=['# 当前 TMS 字段展示覆盖核对','', + '此表由 scripts/audit_web_config_fields.mjs 从当前核心与配置扩展的字段表、字面量 writer 调用、带索引的键模板和 C6 宏生成字段提取,按字段族去重。C6 写入/消费名称及 presence 索引交叉检查;其字段只在相应板型能力下导出。槽位用 {slot} 表示,不表示只支持一个槽位。表中不读取设备或配置文件中的实际值。','', + '范围限制:这是一份已知 TMS 字段族和英文展示缺口清单,不证明所有 NVS/SD 独立设置都已纳入 TMS,不证明条件编译分支或每个字段的录入约束、硬件能力和读写行为已经验收。未检测到中文仅是必要条件,不能代替其他十种语言的完整性检查或术语质量审阅。字段类型与结构化控件仍需要逐项对照 writer 和设备验证器;尤其不能将 protobuf 模块 chunk 的原始编辑视为完整模块交互。','', + `当前提取 **${rows.length} 个字段族**;其中 **${missing.length} 个**英文展示仍包含中文回退,必须补齐。`,'', + '## 已确认的展示缺口','',...missing.map(row=>`- ${row.key}`),'', + '## 字段族清单','', '| 字段族 | 当前英文展示 | 中文回退 | 源码 |','|---|---|---|---|', + ...rows.map(row=>`| ${row.key} | ${escape(row.label)} | ${row.localized?'无':'有'} | ${row.source.map(file=>`[${file}](../../../../${file})`).join(', ')} |`),''].join('\n'); +const output=new URL('../docs/locales/zh-Hans/web-outdoor-management/15-配置字段覆盖核对.md',import.meta.url); +if(process.argv.includes('--check')){ + const current=await readFile(output,'utf8'); + if(current.replaceAll('\r\n','\n')!==markdown)throw Error('Configuration field inventory is stale; run node scripts/audit_web_config_fields.mjs and review the changes.'); + console.log(`Known TMS families: ${rows.length}; English labels with Chinese fallback: ${missing.length}. Source inventory verified.`); +}else{ + await writeFile(output,markdown); + console.log(`Known TMS families: ${rows.length}; English labels with Chinese fallback: ${missing.length}. Report updated.`); +} diff --git a/scripts/audit_web_configuration_presentation.mjs b/scripts/audit_web_configuration_presentation.mjs new file mode 100644 index 00000000..7123a59d --- /dev/null +++ b/scripts/audit_web_configuration_presentation.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs/promises';import path from 'node:path';import {spawnSync} from 'node:child_process'; +import {TmsDocument} from '../site/device/src/tms-document.js'; +import {configurationField} from '../site/device/src/config-schema.js'; +import {configurationPresentation} from '../site/device/src/config-field-presentation.js'; +import {isWebConfigurationField} from '../site/device/src/config-web-scope.js'; +import {isWebModuleField} from '../site/device/src/config-module-web-scope.js'; +import {configurationMaskOptions} from '../site/device/src/config-mask.js'; +import {MODULE_SCHEMA} from '../site/device/src/meshtastic-module-schema.js'; +import {configModuleFieldLabel} from '../site/i18n/config-module-labels.js'; +import {moduleDuration} from '../site/device/src/config-module-duration.js'; +import {isModulePositionPrecision} from '../site/device/src/config-position-precision.js'; +const executable=path.resolve('.codex-build/hostlink-codec/native/tms_complete_interop_cli'+(process.platform==='win32'?'.exe':'')); +// This fixed CLI emits a synthetic fixture. Never accept device dumps as input; +// report schema/presentation metadata only, without configuration values. +const emitted=spawnSync(executable,['--emit'],{windowsHide:true,maxBuffer:1024*1024});if(emitted.error)throw emitted.error;if(emitted.status!==0)throw Error('Synthetic fixture CLI failed'); +const document=new TmsDocument(new Uint8Array(emitted.stdout)),totals={},rows=[]; +for(const record of document.entries()){ + if(!isWebConfigurationField(record.key))continue;const schema=configurationField(record,configurationPresentation(record.key,'zh-Hans'),document); + const control=record.key.startsWith('mt_module.')?'module container':schema.readOnly?'read-only':schema.duration?'duration with unit':configurationMaskOptions(record.key)?'bit choices':schema.character?'character':schema.percentScale?'percent slider':schema.options?(schema.allowCustom?'choice with custom':'choice'):record.type==='bool'?'toggle':record.type==='b64'?'binary/text adapter':/^(u8|u16|u32|i8|i32|f32)$/.test(record.type)?'number':'text'; + totals[control]=(totals[control]??0)+1;rows.push({key:record.key,type:record.type,label:schema.label,control,range:schema.range??null,help:schema.help??null,unit:schema.duration?.nativeUnit??null}); +} +const modules=[],excludedModules=[];function walk(message,names=[]){for(const field of MODULE_SCHEMA.messages[message].fields){const namesNext=[...names,field.name],key=namesNext.join('.');if(!isWebModuleField(namesNext)){excludedModules.push(key);continue;}const duration=moduleDuration(namesNext,field);modules.push({key,type:field.type,label:configModuleFieldLabel(namesNext,'zh-Hans'),readOnly:Boolean(field.readOnly),durationUnit:duration?.nativeUnit??null,positionPrecision:isModulePositionPrecision(namesNext,field)});if(field.type==='MESSAGE')walk(field.message,namesNext);}}walk(MODULE_SCHEMA.root); +const report={source:'synthetic complete production codec fixture; no hardware data and no configuration values',fields:document.size,webRecords:rows.length,excludedRecords:document.size-rows.length,totals,moduleDescriptors:modules.length,excludedModules,rows,modules}; +await fs.writeFile('.codex-build/hostlink-codec/config-presentation-audit.json',JSON.stringify(report,null,2)+'\n'); +console.log(JSON.stringify({fields:report.fields,webRecords:report.webRecords,totals,moduleDescriptors:modules.length,excludedModules})); diff --git a/scripts/check_hostlink_firmware.py b/scripts/check_hostlink_firmware.py new file mode 100644 index 00000000..9d16098f --- /dev/null +++ b/scripts/check_hostlink_firmware.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Reject a hardware test image that does not link the HostLink service. + +This is an artifact admission check, not USB or protocol acceptance. Run it +against an unstripped ELF with the matching toolchain's nm executable. +""" +import argparse +from pathlib import Path +import subprocess +import sys + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--elf", type=Path, required=True) +parser.add_argument("--nm", type=Path, required=True) +args = parser.parse_args() +if not args.elf.is_file() or not args.nm.is_file(): + parser.error("ELF and nm must both be existing files") +result = subprocess.run( + [str(args.nm.resolve()), "--defined-only", "--demangle", str(args.elf.resolve())], + capture_output=True, text=True, check=False, +) +if result.returncode: + print(result.stderr, file=sys.stderr) + sys.exit(result.returncode) +required = ("hostlink::start()", "hostlink::process_pending_commands()", + "hostlink::management::execute_management(", "usb_cdc::start()") +missing = [symbol for symbol in required if symbol not in result.stdout] +if missing: + print("HostLink test image rejected: missing linked symbols:", file=sys.stderr) + for symbol in missing: + print(f" {symbol}", file=sys.stderr) + sys.exit(1) +print("HostLink service symbols linked. USB readiness and hardware behavior remain unverified.") diff --git a/scripts/generate_meshtastic_module_schema.mjs b/scripts/generate_meshtastic_module_schema.mjs new file mode 100644 index 00000000..9a79d0bf --- /dev/null +++ b/scripts/generate_meshtastic_module_schema.mjs @@ -0,0 +1,46 @@ +import {readFile,writeFile} from 'node:fs/promises'; +const files=['localonly.pb.h','module_config.pb.h']; +const root='meshtastic_LocalModuleConfig'; +const source=(await Promise.all(files.map(file=>readFile(new URL('../modules/core_chat/generated/meshtastic/'+file,import.meta.url),'utf8')))).join('\n').replace(/\/\*[\s\S]*?\*\//g,''); +const enums={}; +for(const matched of source.matchAll(/typedef enum _(\w+)\s*\{([\s\S]*?)\}\s*\w+;/g)){ + const name=matched[1],values=[]; + for(const row of matched[2].matchAll(/(\w+)\s*=\s*(-?(?:0x[0-9a-fA-F]+|\d+))/g))values.push({name:row[1].replace(name+'_',''),value:Number(row[2])}); + enums[name]=values; +} +const messages={}; +function visit(name){ + if(messages[name])return; + const list=source.match(new RegExp('#define '+name+'_FIELDLIST\\(X, a\\)([\\s\\S]*?)(?=\\n#define)')); + const struct=source.match(new RegExp('typedef struct _'+name+'\\s*\\{([\\s\\S]*?)\\}\\s*'+name+';')); + const size=source.match(new RegExp('#define '+name+'_size\\s+(\\d+)')); + if(!list||!struct||!size)throw Error('Missing nanopb definition: '+name); + const message={maxBytes:Number(size[1]),fields:[]};messages[name]=message; + for(const row of list[1].matchAll(/X\(a,\s*(\w+),\s*(\w+),\s*(\w+),\s*(\w+),\s*(\d+)\)/g)){ + const [,allocation,cardinality,type,field,number]=row; + if(allocation!=='STATIC'||!['SINGULAR','OPTIONAL','REPEATED'].includes(cardinality)||!['BOOL','UINT32','INT32','STRING','MESSAGE','UENUM'].includes(type))throw Error('Unsupported field descriptor: '+name+'.'+field); + const declaration=struct[1].match(new RegExp('(?:^|\\n)\\s*(\\w+)\\s+'+field+'(?:\\[(\\d+)\\])?\\s*;')); + if(!declaration)throw Error('Missing storage definition: '+name+'.'+field); + const item={name:field,number:Number(number),type,cardinality}; + if(type==='STRING'){if(declaration[1]!=='char'||!declaration[2])throw Error('Unbounded string: '+field);item.maxBytes=Number(declaration[2])-1;} + if(type==='UINT32')item.max=declaration[1]==='uint8_t'?255:declaration[1]==='uint16_t'?65535:4294967295; + if(type==='INT32'){item.min=-2147483648;item.max=2147483647;} + if(type==='UENUM'){if(!enums[declaration[1]]?.length)throw Error('Missing enum: '+field);item.options=enums[declaration[1]];} + if(type==='MESSAGE'){ + const nested=source.match(new RegExp('#define '+name+'_'+field+'_MSGTYPE\\s+(\\w+)')); + if(!nested)throw Error('Missing nested message: '+field);item.message=nested[1]; + } + if(cardinality==='REPEATED'){if(!declaration[2])throw Error('Unbounded repeated field: '+field);item.maxCount=Number(declaration[2]);} + if(name===root&&field==='version')item.readOnly=true; + message.fields.push(item); + } + if(!message.fields.length)throw Error('No fields extracted: '+name); + for(const field of message.fields)if(field.message)visit(field.message); +} +visit(root); +const result={root,messages}; +const output='// Generated from checked-in nanopb headers; run scripts/generate_meshtastic_module_schema.mjs.\nexport const MODULE_SCHEMA='+JSON.stringify(result,null,2)+';\n'; +const destination=new URL('../site/device/src/meshtastic-module-schema.js',import.meta.url); +if(process.argv.includes('--check')){if(await readFile(destination,'utf8')!==output)throw Error('Module schema is out of date; regenerate it.');} +else await writeFile(destination,output); +console.log(`Module schema: ${Object.keys(messages).length} messages, ${Object.values(messages).reduce((sum,message)=>sum+message.fields.length,0)} fields; ${process.argv.includes('--check')?'checked':'generated'}.`); diff --git a/tests/deployment/test_directory_image.py b/tests/deployment/test_directory_image.py new file mode 100644 index 00000000..09f61e8f --- /dev/null +++ b/tests/deployment/test_directory_image.py @@ -0,0 +1,63 @@ +"""Import the exact Docker COPY source closure outside the repository.""" +import json +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +DEPLOY = ROOT / 'tools/geocaching/deploy' + + +class DirectoryImageTests(unittest.TestCase): + def test_actual_runtime_modules_import_from_staged_image_sources(self): + dockerfile = (DEPLOY / 'Dockerfile').read_text() + allowed = {line[1:] for line in (DEPLOY / 'Dockerfile.dockerignore').read_text().splitlines() + if line.startswith('!') and not line.endswith('/')} + with tempfile.TemporaryDirectory() as work: + stage = Path(work) + copied = [] + for line in dockerfile.splitlines(): + if not line.startswith('COPY '): + continue + fields = shlex.split(line)[1:] + destination = fields[-1] + self.assertTrue(destination.startswith('/app/')) + directory = stage / destination.removeprefix('/app/') + directory.mkdir(parents=True, exist_ok=True) + for source in fields[:-1]: + self.assertIn(source, allowed, f'{source} excluded from Docker context') + shutil.copyfile(ROOT / source, directory / Path(source).name) + copied.append(source) + script = """import sys,json +sys.path.insert(0, sys.argv[1]) +import directory_service, packet_bridge, reply_queue +from tools.tracks.directory_service import TrackDirectoryService +from tools.tracks.application import TrackApplication +from tools.tracks.region_catalog import RegionCatalog +from tools.tracks.attachment_index import match_index +print(json.dumps({'geocaching':directory_service.__file__,'tracks':sys.modules['tools.tracks.directory_service'].__file__,'reply':reply_queue.__file__})) +""" + result = subprocess.run([sys.executable, '-I', '-c', script, str(stage)], + cwd=stage, capture_output=True, text=True, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + self.assertEqual(result.returncode, 0, result.stderr) + modules = json.loads(result.stdout) + for file in modules.values(): + self.assertTrue(Path(file).is_relative_to(stage), file) + self.assertIn('tools/geocaching/reply_queue.py', copied) + self.assertIn('tools/tracks/directory_service.py', copied) + for arguments in ([str(stage/'directory_service.py'),'--help'], + ['-m','tools.tracks.directory_service','--help']): + check = subprocess.run([sys.executable, *arguments], cwd=stage, + capture_output=True, text=True, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + self.assertEqual(check.returncode, 0, check.stderr) + self.assertIn('--rns-config', check.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/deployment/test_shared_directory_network.py b/tests/deployment/test_shared_directory_network.py new file mode 100644 index 00000000..f4cb6f27 --- /dev/null +++ b/tests/deployment/test_shared_directory_network.py @@ -0,0 +1,139 @@ +"""Real native services share one RNS instance and one browser packet gateway. + +Creates isolated local records only. No physical USB, RF or public publication. +Requires site dependencies and a Playwright browser (optional channel environment). +""" +import http.server +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT/'tools/geocaching')) +import RNS +from directory_service import load_identity +from directory_store import DirectoryStore, packed + + +def free_port(): + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + return listener.getsockname()[1] + + +class SharedDirectoryNetworkTests(unittest.TestCase): + def test_both_protocols_and_author_signatures_through_one_shared_gateway(self): + retained = ROOT/'.codex-build/hostlink-codec/shared-directory-network' + retained.mkdir(parents=True, exist_ok=True) + (retained/'NOTES.txt').write_text('Local shared RNS owner + track client + packet gateway acceptance. Current production Python services and browser clients. Isolated signatures, no USB/RF/public publication. Retain compact evidence, remove reproducible build after acceptance.') + children = [] + flags = getattr(subprocess, 'CREATE_NO_WINDOW', 0) + with tempfile.TemporaryDirectory() as work: + state = Path(work) + rns = state/'rns' + rns.mkdir() + port, shared, control = free_port(), free_port(), free_port() + config = ('[reticulum]\nshare_instance = Yes\nshared_instance_type = tcp\n' + f'shared_instance_port = {shared}\ninstance_control_port = {control}\n' + 'enable_transport = Yes\n[interfaces]\n[[Browser gateway]]\n' + f'enabled = Yes\ntype = TCPServerInterface\nlisten_ip = 127.0.0.1\nlisten_port = {port}\n') + (rns/'config').write_text(config) + geo_state = state/'geocaching' + geo_state.mkdir() + identity = load_identity(geo_state) + destination = RNS.Destination.hash_from_name_and_identity('lxmf.delivery', identity) + author = RNS.Identity() + record = [1, author.get_public_key(), os.urandom(16), 1, None, 0, + 300000000, 1200000000, 'Shared gateway local cache', '', '', 2, 2, 0, 100, 100] + raw = packed(record) + store = DirectoryStore(geo_state/'directory.sqlite', destination) + try: + reply = store.handle(os.urandom(16), packed([1,0,1,os.urandom(16),8192, + [[raw,author.sign(b'trailmate.geocache/sign/v1\0'+raw)]]])) + self.assertIsNotNone(reply) + finally: + store.close() + + class Page(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type','text/html') + self.end_headers() + self.wfile.write(b'Shared gateway acceptance') + def log_message(self, *_): + pass + + web = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Page) + web_thread = threading.Thread(target=web.serve_forever, daemon=True) + web_thread.start() + origin = f'http://127.0.0.1:{web.server_port}' + + def start(label, arguments): + log_path = retained/(label+'.log') + handle = log_path.open('w', encoding='utf-8') + child = subprocess.Popen([sys.executable, *arguments], cwd=ROOT, + stdout=handle, stderr=subprocess.STDOUT, creationflags=flags) + children.append((child,handle)) + deadline = time.monotonic()+25 + while child.poll() is None and time.monotonic()") +endif() + +add_executable(tms_c6_extension test_tms_c6_extension.cpp + "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(tms_c6_extension PRIVATE cxx_std_17) +target_compile_definitions(tms_c6_extension PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(tms_c6_extension PRIVATE "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" "${REPO}/modules/core_gps/include") +if(MSVC) + target_compile_options(tms_c6_extension PRIVATE /UNDEBUG) +else() + target_compile_options(tms_c6_extension PRIVATE -UNDEBUG) +endif() +add_test(NAME tms_c6_extension COMMAND tms_c6_extension) +add_executable(tms_config_interop_cli tms_config_interop_cli.cpp + "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(tms_config_interop_cli PRIVATE cxx_std_17) +target_compile_definitions(tms_config_interop_cli PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(tms_config_interop_cli PRIVATE + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" "${REPO}/modules/core_gps/include") +set(C6_SETTINGS_SOURCE "${REPO}/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${C6_SETTINGS_SOURCE}") +file(READ "${C6_SETTINGS_SOURCE}" C6_SETTINGS_TEXT) +string(FIND "${C6_SETTINGS_TEXT}" "bool write_base_scalar_settings()" SETTINGS_SCALAR_START) +string(FIND "${C6_SETTINGS_TEXT}" "bool finishDocument(void*" SETTINGS_SCALAR_END) +math(EXPR SETTINGS_SCALAR_LENGTH "${SETTINGS_SCALAR_END}-${SETTINGS_SCALAR_START}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${SETTINGS_SCALAR_START} ${SETTINGS_SCALAR_LENGTH} SETTINGS_SCALAR_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/tms_scalar_storage_actual.inc" "${SETTINGS_SCALAR_HELPER}") +string(FIND "${C6_SETTINGS_TEXT}" "bool writeRecords(void*" SETTINGS_FINISH_END) +math(EXPR SETTINGS_FINISH_LENGTH "${SETTINGS_FINISH_END}-${SETTINGS_SCALAR_END}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${SETTINGS_SCALAR_END} ${SETTINGS_FINISH_LENGTH} SETTINGS_FINISH_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/tms_finish_actual.inc" "${SETTINGS_FINISH_HELPER}") +add_executable(tms_scalar_storage test_tms_scalar_storage.cpp) +target_compile_features(tms_scalar_storage PRIVATE cxx_std_17) +target_include_directories(tms_scalar_storage PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include") +if(MSVC) + target_compile_options(tms_scalar_storage PRIVATE /UNDEBUG) +else() + target_compile_options(tms_scalar_storage PRIVATE -UNDEBUG -Wall -Wextra) +endif() +add_test(NAME tms_scalar_storage COMMAND tms_scalar_storage) +foreach(WIFI_BACKEND common idf) + if(WIFI_BACKEND STREQUAL "common") + set(WIFI_STORAGE_SOURCE "${REPO}/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h") + set(WIFI_READ_END "bool read_config_from_store(") + set(WIFI_SAVE_START "bool save_config(") + set(WIFI_SAVE_END "bool find_saved_config(") + else() + set(WIFI_STORAGE_SOURCE "${REPO}/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp") + set(WIFI_READ_END "void format_ipv4(") + set(WIFI_SAVE_START "bool save_saved_config(") + set(WIFI_SAVE_END "c6::WifiCompanionConfig make_companion_wifi_config(") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${WIFI_STORAGE_SOURCE}") + file(READ "${WIFI_STORAGE_SOURCE}" WIFI_STORAGE_TEXT) + foreach(WIFI_PART persist read save replace) + if(WIFI_PART STREQUAL "persist") + set(WIFI_START_TEXT "bool persist_profiles()") + set(WIFI_END_TEXT "bool read_profile_from_store(") + elseif(WIFI_PART STREQUAL "read") + set(WIFI_START_TEXT "bool read_profile_from_store(") + set(WIFI_END_TEXT "${WIFI_READ_END}") + elseif(WIFI_PART STREQUAL "save") + set(WIFI_START_TEXT "${WIFI_SAVE_START}") + set(WIFI_END_TEXT "${WIFI_SAVE_END}") + else() + set(WIFI_START_TEXT "bool replace_saved_profiles(") + set(WIFI_END_TEXT "bool apply_enabled(") + endif() + string(FIND "${WIFI_STORAGE_TEXT}" "${WIFI_START_TEXT}" WIFI_START) + if(WIFI_START LESS 0) + message(FATAL_ERROR "Missing production Wi-Fi ${WIFI_BACKEND}/${WIFI_PART} start") + endif() + string(SUBSTRING "${WIFI_STORAGE_TEXT}" ${WIFI_START} -1 WIFI_TAIL) + string(FIND "${WIFI_TAIL}" "${WIFI_END_TEXT}" WIFI_END) + if(WIFI_END LESS 0) + message(FATAL_ERROR "Missing production Wi-Fi ${WIFI_BACKEND}/${WIFI_PART} end") + endif() + string(SUBSTRING "${WIFI_TAIL}" 0 ${WIFI_END} WIFI_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/wifi_${WIFI_BACKEND}_${WIFI_PART}_actual.inc" "${WIFI_ACTUAL}") + endforeach() + add_executable(wifi_${WIFI_BACKEND}_storage test_wifi_profile_storage.cpp) + target_compile_features(wifi_${WIFI_BACKEND}_storage PRIVATE cxx_std_17) + target_include_directories(wifi_${WIFI_BACKEND}_storage PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include") + if(WIFI_BACKEND STREQUAL "idf") + target_compile_definitions(wifi_${WIFI_BACKEND}_storage PRIVATE WIFI_STORAGE_IDF=1) + endif() + if(MSVC) + target_compile_options(wifi_${WIFI_BACKEND}_storage PRIVATE /UNDEBUG) + else() + target_compile_options(wifi_${WIFI_BACKEND}_storage PRIVATE -UNDEBUG -Wall -Wextra) + endif() + add_test(NAME wifi_${WIFI_BACKEND}_storage COMMAND wifi_${WIFI_BACKEND}_storage) +endforeach() +set(COMPLETE_NETWORK_SOURCE "${REPO}/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${COMPLETE_NETWORK_SOURCE}") +file(READ "${COMPLETE_NETWORK_SOURCE}" COMPLETE_NETWORK_TEXT) +foreach(COMPLETE_NETWORK_PART helpers validate) + if(COMPLETE_NETWORK_PART STREQUAL "helpers") + set(COMPLETE_START_TEXT "bool bounded_text(") + set(COMPLETE_END_TEXT "void reset_config(") + else() + set(COMPLETE_START_TEXT "bool validate_config(") + set(COMPLETE_END_TEXT "void activate(") + endif() + string(FIND "${COMPLETE_NETWORK_TEXT}" "${COMPLETE_START_TEXT}" COMPLETE_START) + string(FIND "${COMPLETE_NETWORK_TEXT}" "${COMPLETE_END_TEXT}" COMPLETE_END) + if(COMPLETE_START LESS 0 OR COMPLETE_END LESS COMPLETE_START) + message(FATAL_ERROR "Missing actual Reticulum network validation code") + endif() + math(EXPR COMPLETE_LENGTH "${COMPLETE_END}-${COMPLETE_START}") + string(SUBSTRING "${COMPLETE_NETWORK_TEXT}" ${COMPLETE_START} ${COMPLETE_LENGTH} COMPLETE_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/complete_network_${COMPLETE_NETWORK_PART}_actual.inc" "${COMPLETE_ACTUAL}") +endforeach() +add_executable(tms_complete_interop_cli tms_config_interop_cli.cpp tms_complete_config_ports.cpp + "${REPO}/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp" + "${REPO}/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp" + "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp" + "${REPO}/modules/core_chat/third_party/nanopb/pb_encode.c" + "${REPO}/modules/core_chat/third_party/nanopb/pb_decode.c" + "${REPO}/modules/core_chat/third_party/nanopb/pb_common.c" + "${REPO}/modules/core_chat/generated/meshtastic/localonly.pb.cpp" + "${REPO}/modules/core_chat/generated/meshtastic/module_config.pb.cpp" + "${REPO}/modules/core_chat/generated/meshtastic/config.pb.cpp" + "${REPO}/modules/core_chat/generated/meshtastic/device_ui.pb.cpp") +target_compile_features(tms_complete_interop_cli PRIVATE cxx_std_17) +target_compile_definitions(tms_complete_interop_cli PRIVATE TMS_COMPLETE_INTEROP=1 TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(tms_complete_interop_cli PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" + "${REPO}/modules/core_phone/include" "${REPO}/modules/core_gps/include" + "${REPO}/platform/esp/arduino_common/include" "${REPO}/platform/esp/common/include" + "${REPO}/modules/core_chat/generated" "${REPO}/modules/core_chat/third_party/nanopb") +string(FIND "${C6_SETTINGS_TEXT}" "bool write_c6_records(" C6_SETTINGS_START) +string(FIND "${C6_SETTINGS_TEXT}" "#endif\n} // namespace\n\nvoid beginRead" C6_SETTINGS_END) +math(EXPR C6_SETTINGS_LENGTH "${C6_SETTINGS_END}-${C6_SETTINGS_START}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${C6_SETTINGS_START} ${C6_SETTINGS_LENGTH} C6_SETTINGS_HELPERS) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/tms_c6_storage_actual.inc" "${C6_SETTINGS_HELPERS}") +add_executable(tms_c6_storage test_tms_c6_storage.cpp "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(tms_c6_storage PRIVATE cxx_std_17) +target_compile_definitions(tms_c6_storage PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(tms_c6_storage PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" "${REPO}/modules/core_gps/include") +if(MSVC) + target_compile_options(tms_c6_storage PRIVATE /UNDEBUG) +else() + target_compile_options(tms_c6_storage PRIVATE -UNDEBUG) +endif() +add_test(NAME tms_c6_storage COMMAND tms_c6_storage) +set(BLE_PREFERENCES_SOURCE "${REPO}/platform/esp/arduino_common/src/app_config_tms_ble_extension.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${BLE_PREFERENCES_SOURCE}") +file(READ "${BLE_PREFERENCES_SOURCE}" BLE_PREFERENCES_TEXT) +string(FIND "${BLE_PREFERENCES_TEXT}" "bool write_preferences()" BLE_PREFERENCES_START) +string(FIND "${BLE_PREFERENCES_TEXT}" "} // namespace\n\nbool beginRead()" BLE_PREFERENCES_END) +if(BLE_PREFERENCES_START LESS 0 OR BLE_PREFERENCES_END LESS BLE_PREFERENCES_START) + message(FATAL_ERROR "Cannot locate production BLE preferences writer") +endif() +math(EXPR BLE_PREFERENCES_LENGTH "${BLE_PREFERENCES_END}-${BLE_PREFERENCES_START}") +string(SUBSTRING "${BLE_PREFERENCES_TEXT}" ${BLE_PREFERENCES_START} ${BLE_PREFERENCES_LENGTH} BLE_PREFERENCES_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/ble_preferences_actual.inc" "${BLE_PREFERENCES_ACTUAL}") +add_executable(ble_preferences_storage test_ble_preferences_storage.cpp) +target_compile_features(ble_preferences_storage PRIVATE cxx_std_17) +target_include_directories(ble_preferences_storage PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + "${REPO}/modules/core_chat/generated" "${REPO}/modules/core_chat/third_party/nanopb") +if(MSVC) + target_compile_options(ble_preferences_storage PRIVATE /UNDEBUG) +else() + target_compile_options(ble_preferences_storage PRIVATE -UNDEBUG) +endif() +add_test(NAME ble_preferences_storage COMMAND ble_preferences_storage) +string(FIND "${BLE_PREFERENCES_TEXT}" "void initialize_default_module(" BLE_DEFAULTS_START) +string(FIND "${BLE_PREFERENCES_TEXT}" "bool mark_once(" BLE_DEFAULTS_END) +string(FIND "${BLE_PREFERENCES_TEXT}" "bool load_preferences()" BLE_LOAD_START) +string(FIND "${BLE_PREFERENCES_TEXT}" "bool encode_module()" BLE_LOAD_END) +if(BLE_DEFAULTS_START LESS 0 OR BLE_DEFAULTS_END LESS BLE_DEFAULTS_START + OR BLE_LOAD_START LESS 0 OR BLE_LOAD_END LESS BLE_LOAD_START) + message(FATAL_ERROR "Cannot locate production BLE defaults/load") +endif() +math(EXPR BLE_DEFAULTS_LENGTH "${BLE_DEFAULTS_END}-${BLE_DEFAULTS_START}") +string(SUBSTRING "${BLE_PREFERENCES_TEXT}" ${BLE_DEFAULTS_START} ${BLE_DEFAULTS_LENGTH} BLE_DEFAULTS_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/ble_defaults_actual.inc" "${BLE_DEFAULTS_ACTUAL}") +math(EXPR BLE_LOAD_LENGTH "${BLE_LOAD_END}-${BLE_LOAD_START}") +string(SUBSTRING "${BLE_PREFERENCES_TEXT}" ${BLE_LOAD_START} ${BLE_LOAD_LENGTH} BLE_LOAD_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/ble_load_actual.inc" "${BLE_LOAD_ACTUAL}") +add_executable(ble_preferences_read test_ble_preferences_read.cpp + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(ble_preferences_read PRIVATE cxx_std_17) +target_compile_definitions(ble_preferences_read PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(ble_preferences_read PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" + "${REPO}/modules/core_phone/include" "${REPO}/modules/core_gps/include" + "${REPO}/modules/core_chat/generated" "${REPO}/modules/core_chat/third_party/nanopb") +if(MSVC) + target_compile_options(ble_preferences_read PRIVATE /UNDEBUG) +else() + target_compile_options(ble_preferences_read PRIVATE -UNDEBUG) +endif() +add_test(NAME ble_preferences_read COMMAND ble_preferences_read) +set(SETTINGS_STRING_SOURCE "${REPO}/platform/esp/common/include/platform/esp/common/settings_store_impl.h") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SETTINGS_STRING_SOURCE}") +file(READ "${SETTINGS_STRING_SOURCE}" SETTINGS_STRING_TEXT) +string(FIND "${SETTINGS_STRING_TEXT}" "StringReadResult read_string_into(" SETTINGS_STRING_START) +string(FIND "${SETTINGS_STRING_TEXT}" "BlobReadResult read_blob_into(" SETTINGS_STRING_END) +math(EXPR SETTINGS_STRING_LENGTH "${SETTINGS_STRING_END}-${SETTINGS_STRING_START}") +string(SUBSTRING "${SETTINGS_STRING_TEXT}" ${SETTINGS_STRING_START} ${SETTINGS_STRING_LENGTH} SETTINGS_STRING_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_string_read_actual.inc" "${SETTINGS_STRING_HELPER}") +add_executable(settings_string_read test_settings_string_read.cpp) +target_compile_features(settings_string_read PRIVATE cxx_std_17) +target_include_directories(settings_string_read PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include") +if(MSVC) + target_compile_options(settings_string_read PRIVATE /UNDEBUG) +else() + target_compile_options(settings_string_read PRIVATE -UNDEBUG) +endif() +add_test(NAME settings_string_read COMMAND settings_string_read) +string(FIND "${SETTINGS_STRING_TEXT}" "// Strict scalar reads use" SETTINGS_SCALAR_READ_START) +string(FIND "${SETTINGS_STRING_TEXT}" "StringReadResult read_string_into(" SETTINGS_SCALAR_READ_END) +if(SETTINGS_SCALAR_READ_START LESS 0 OR SETTINGS_SCALAR_READ_END LESS SETTINGS_SCALAR_READ_START) + message(FATAL_ERROR "Cannot locate strict ESP scalar readers") +endif() +math(EXPR SETTINGS_SCALAR_READ_LENGTH "${SETTINGS_SCALAR_READ_END}-${SETTINGS_SCALAR_READ_START}") +string(SUBSTRING "${SETTINGS_STRING_TEXT}" ${SETTINGS_SCALAR_READ_START} ${SETTINGS_SCALAR_READ_LENGTH} SETTINGS_SCALAR_READ_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_scalar_read_actual.inc" "${SETTINGS_SCALAR_READ_HELPER}") +add_executable(settings_scalar_read test_settings_scalar_read.cpp) +target_compile_features(settings_scalar_read PRIVATE cxx_std_17) +target_include_directories(settings_scalar_read PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include") +if(MSVC) + target_compile_options(settings_scalar_read PRIVATE /UNDEBUG) +else() + target_compile_options(settings_scalar_read PRIVATE -UNDEBUG) +endif() +add_test(NAME settings_scalar_read COMMAND settings_scalar_read) +string(FIND "${SETTINGS_STRING_TEXT}" "BlobReadResult read_blob_into(" SETTINGS_BLOB_START) +string(FIND "${SETTINGS_STRING_TEXT}" "bool get_blob(" SETTINGS_BLOB_END) +if(SETTINGS_BLOB_START LESS 0 OR SETTINGS_BLOB_END LESS SETTINGS_BLOB_START) + message(FATAL_ERROR "Cannot locate strict ESP blob reader") +endif() +math(EXPR SETTINGS_BLOB_LENGTH "${SETTINGS_BLOB_END}-${SETTINGS_BLOB_START}") +string(SUBSTRING "${SETTINGS_STRING_TEXT}" ${SETTINGS_BLOB_START} ${SETTINGS_BLOB_LENGTH} SETTINGS_BLOB_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_blob_read_actual.inc" "${SETTINGS_BLOB_HELPER}") +add_executable(settings_blob_read test_settings_blob_read.cpp) +target_compile_features(settings_blob_read PRIVATE cxx_std_17) +target_include_directories(settings_blob_read PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/core_sys/include") +if(MSVC) + target_compile_options(settings_blob_read PRIVATE /UNDEBUG) +else() + target_compile_options(settings_blob_read PRIVATE -UNDEBUG) +endif() +add_test(NAME settings_blob_read COMMAND settings_blob_read) +string(FIND "${C6_SETTINGS_TEXT}" "bool write_text_setting(" SETTINGS_PROJECTION_START) +string(FIND "${C6_SETTINGS_TEXT}" "// Board-specific configuration projection." SETTINGS_PROJECTION_END) +if(SETTINGS_PROJECTION_START LESS 0 OR SETTINGS_PROJECTION_END LESS SETTINGS_PROJECTION_START) + message(FATAL_ERROR "Cannot locate settings projection readers") +endif() +math(EXPR SETTINGS_PROJECTION_LENGTH "${SETTINGS_PROJECTION_END}-${SETTINGS_PROJECTION_START}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${SETTINGS_PROJECTION_START} ${SETTINGS_PROJECTION_LENGTH} SETTINGS_PROJECTION_HELPER) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_projection_actual.inc" "${SETTINGS_PROJECTION_HELPER}") +add_executable(settings_projection test_settings_projection.cpp + "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(settings_projection PRIVATE cxx_std_17) +target_compile_definitions(settings_projection PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(settings_projection PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" "${REPO}/modules/core_gps/include") +if(MSVC) + target_compile_options(settings_projection PRIVATE /UNDEBUG) +else() + target_compile_options(settings_projection PRIVATE -UNDEBUG) +endif() +add_test(NAME settings_projection COMMAND settings_projection) +string(FIND "${C6_SETTINGS_TEXT}" "bool write_i32_setting(" SETTINGS_SCALAR_PROJECTION_START) +string(FIND "${C6_SETTINGS_TEXT}" "bool write_text_setting(" SETTINGS_SCALAR_PROJECTION_END) +string(FIND "${C6_SETTINGS_TEXT}" "bool writeRecords(void* context" SETTINGS_RECORDS_START) +string(FIND "${C6_SETTINGS_TEXT}" "#undef s_state" SETTINGS_RECORDS_END) +if(SETTINGS_SCALAR_PROJECTION_START LESS 0 OR SETTINGS_SCALAR_PROJECTION_END LESS SETTINGS_SCALAR_PROJECTION_START + OR SETTINGS_RECORDS_START LESS 0 OR SETTINGS_RECORDS_END LESS SETTINGS_RECORDS_START) + message(FATAL_ERROR "Cannot locate production scalar helpers/settings writer") +endif() +math(EXPR SETTINGS_SCALAR_PROJECTION_LENGTH "${SETTINGS_SCALAR_PROJECTION_END}-${SETTINGS_SCALAR_PROJECTION_START}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${SETTINGS_SCALAR_PROJECTION_START} ${SETTINGS_SCALAR_PROJECTION_LENGTH} SETTINGS_SCALAR_PROJECTION_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_scalar_projection_actual.inc" "${SETTINGS_SCALAR_PROJECTION_ACTUAL}") +math(EXPR SETTINGS_RECORDS_LENGTH "${SETTINGS_RECORDS_END}-${SETTINGS_RECORDS_START}") +string(SUBSTRING "${C6_SETTINGS_TEXT}" ${SETTINGS_RECORDS_START} ${SETTINGS_RECORDS_LENGTH} SETTINGS_RECORDS_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/settings_records_actual.inc" "${SETTINGS_RECORDS_ACTUAL}") +add_executable(settings_scalar_projection test_settings_scalar_projection.cpp + "${REPO}/modules/core_sys/src/app/tms_config_codec.cpp" + "${REPO}/modules/core_sys/src/platform/ui/timezone_profile.cpp" + "${REPO}/modules/core_chat/src/infra/mesh_protocol_utils.cpp") +target_compile_features(settings_scalar_projection PRIVATE cxx_std_17) +target_compile_definitions(settings_scalar_projection PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) +target_include_directories(settings_scalar_projection PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" + "${REPO}/modules/core_sys/include" "${REPO}/modules/core_chat/include" "${REPO}/modules/core_gps/include") +if(MSVC) + target_compile_options(settings_scalar_projection PRIVATE /UNDEBUG) +else() + target_compile_options(settings_scalar_projection PRIVATE -UNDEBUG) +endif() +add_test(NAME settings_scalar_projection COMMAND settings_scalar_projection) +set(LXMF_ANNOUNCE_SOURCE "${REPO}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${LXMF_ANNOUNCE_SOURCE}") +file(READ "${LXMF_ANNOUNCE_SOURCE}" LXMF_ANNOUNCE_TEXT) +string(FIND "${LXMF_ANNOUNCE_TEXT}" "bool appendMsgpackByte(" LXMF_ANNOUNCE_START) +string(FIND "${LXMF_ANNOUNCE_TEXT}" "void destinationHashForServiceAspect(" LXMF_ANNOUNCE_END) +math(EXPR LXMF_ANNOUNCE_LENGTH "${LXMF_ANNOUNCE_END}-${LXMF_ANNOUNCE_START}") +string(SUBSTRING "${LXMF_ANNOUNCE_TEXT}" ${LXMF_ANNOUNCE_START} ${LXMF_ANNOUNCE_LENGTH} LXMF_ANNOUNCE_HELPERS) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/lxmf_announce_actual.inc" "${LXMF_ANNOUNCE_HELPERS}") +add_executable(lxmf_announce_bounds test_lxmf_announce_bounds.cpp) +target_compile_features(lxmf_announce_bounds PRIVATE cxx_std_17) +target_include_directories(lxmf_announce_bounds PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +if(MSVC) + target_compile_options(lxmf_announce_bounds PRIVATE /UNDEBUG) +else() + target_compile_options(lxmf_announce_bounds PRIVATE -UNDEBUG -O2 -Wall -Wextra) +endif() +add_test(NAME lxmf_announce_bounds COMMAND lxmf_announce_bounds) + +function(hostlink_test name source) + add_executable(${name} ${source} ${ARGN}) + target_compile_features(${name} PRIVATE cxx_std_17) + target_include_directories(${name} PRIVATE + "${REPO}/modules/core_hostlink/include" + "${REPO}/modules/core_track_sharing/include" + "${REPO}/modules/core_geocaching/include" + "${REPO}/modules/core_sys/include" + "${REPO}/modules/core_chat/include" + "${REPO}/modules/core_gps/include" + "${REPO}/platform/esp/arduino_common/include") + if(MSVC) + target_compile_options(${name} PRIVATE /UNDEBUG) + else() + target_compile_options(${name} PRIVATE -UNDEBUG -Wall -Wextra) + endif() + add_test(NAME ${name} COMMAND ${name}) +endfunction() +set(LVGL_HEAP_SOURCE "${REPO}/platform/esp/arduino_common/src/LV_Helper_v9.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${LVGL_HEAP_SOURCE}") +file(READ "${LVGL_HEAP_SOURCE}" LVGL_HEAP_TEXT) +string(FIND "${LVGL_HEAP_TEXT}" "constexpr size_t kLvglLargeAllocThresholdBytes" LVGL_POLICY_START) +string(FIND "${LVGL_HEAP_TEXT}" "} // namespace\n\n// LVGL 9.x custom" LVGL_POLICY_END) +math(EXPR LVGL_POLICY_LENGTH "${LVGL_POLICY_END}-${LVGL_POLICY_START}") +string(SUBSTRING "${LVGL_HEAP_TEXT}" ${LVGL_POLICY_START} ${LVGL_POLICY_LENGTH} LVGL_POLICY_ACTUAL) +string(FIND "${LVGL_HEAP_TEXT}" "extern \"C\" void* lv_malloc_core" LVGL_ALLOC_START) +string(FIND "${LVGL_HEAP_TEXT}" "extern \"C\" void lv_mem_monitor_core" LVGL_ALLOC_END) +math(EXPR LVGL_ALLOC_LENGTH "${LVGL_ALLOC_END}-${LVGL_ALLOC_START}") +string(SUBSTRING "${LVGL_HEAP_TEXT}" ${LVGL_ALLOC_START} ${LVGL_ALLOC_LENGTH} LVGL_ALLOC_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/lvgl_heap_actual.inc" "${LVGL_POLICY_ACTUAL}\n${LVGL_ALLOC_ACTUAL}") +foreach(policy wio legacy) + hostlink_test(lvgl_heap_${policy} test_lvgl_heap_policy.cpp) + target_include_directories(lvgl_heap_${policy} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(lvgl_heap_${policy} PRIVATE HAS_PSRAM=1) +endforeach() +target_compile_definitions(lvgl_heap_wio PRIVATE ARDUINO_WIO_TRACKER_L2=1) +set(HOSTLINK_TX_SOURCE "${REPO}/platform/esp/arduino_common/src/hostlink/hostlink_service.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${HOSTLINK_TX_SOURCE}") +file(READ "${HOSTLINK_TX_SOURCE}" HOSTLINK_TX_TEXT) +string(FIND "${HOSTLINK_TX_TEXT}" "bool enqueue_event(uint8_t type" HOSTLINK_TX_START) +string(FIND "${HOSTLINK_TX_TEXT}" "} // namespace hostlink" HOSTLINK_TX_END) +math(EXPR HOSTLINK_TX_LENGTH "${HOSTLINK_TX_END}-${HOSTLINK_TX_START}") +string(SUBSTRING "${HOSTLINK_TX_TEXT}" ${HOSTLINK_TX_START} ${HOSTLINK_TX_LENGTH} HOSTLINK_TX_ACTUAL) +string(FIND "${HOSTLINK_TX_TEXT}" "void release_tx_item(" HOSTLINK_TX_RELEASE_START) +string(FIND "${HOSTLINK_TX_TEXT}" "std::atomic" HOSTLINK_TX_RELEASE_END) +math(EXPR HOSTLINK_TX_RELEASE_LENGTH "${HOSTLINK_TX_RELEASE_END}-${HOSTLINK_TX_RELEASE_START}") +string(SUBSTRING "${HOSTLINK_TX_TEXT}" ${HOSTLINK_TX_RELEASE_START} ${HOSTLINK_TX_RELEASE_LENGTH} HOSTLINK_TX_RELEASE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/hostlink_tx_actual.inc" "${HOSTLINK_TX_RELEASE}\n${HOSTLINK_TX_ACTUAL}") +hostlink_test(hostlink_tx_memory test_hostlink_tx_memory.cpp "${REPO}/modules/core_hostlink/src/hostlink_codec.cpp") +target_include_directories(hostlink_tx_memory PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_compile_definitions(hostlink_tx_memory PRIVATE HAS_PSRAM=1) +hostlink_test(stored_identity_lease test_stored_identity_lease.cpp) +hostlink_test(confirm_ip_publication_head test_confirm_ip_publication_head.cpp) +hostlink_test(track_ip_confirmation_request test_track_ip_confirmation_request.cpp) +hostlink_test(geocache_signature_job test_geocache_signature_job.cpp) +hostlink_test(geocache_sign_request test_geocache_sign_request.cpp) +hostlink_test(geocache_confirmation_request test_geocache_confirmation_request.cpp) + +hostlink_test(frame_into "${REPO}/modules/core_hostlink/tests/test_frame_into.cpp" + "${REPO}/modules/core_hostlink/src/hostlink_codec.cpp") +hostlink_test(management_envelope "${REPO}/modules/core_hostlink/tests/test_management_envelope.cpp") +hostlink_test(management_routing "${REPO}/modules/core_hostlink/tests/test_management_routing.cpp" + "${REPO}/modules/core_hostlink/src/hostlink_frame_router.cpp") +hostlink_test(object_transfer "${REPO}/modules/core_hostlink/tests/test_object_transfer.cpp") +hostlink_test(track_snapshot "${REPO}/modules/core_hostlink/tests/test_track_snapshot.cpp") +hostlink_test(track_snapshot_builder "${REPO}/modules/core_hostlink/tests/test_track_snapshot_builder.cpp") +hostlink_test(track_snapshot_session test_track_snapshot_session.cpp) +hostlink_test(track_snapshot_selection test_track_snapshot_selection.cpp) +hostlink_test(route_install_request test_route_install_request.cpp) +foreach(IMAGE_PAGE gps tracker) + if(IMAGE_PAGE STREQUAL "gps") + set(IMAGE_SOURCE "${REPO}/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp") + else() + set(IMAGE_SOURCE "${REPO}/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IMAGE_SOURCE}") + file(READ "${IMAGE_SOURCE}" IMAGE_TEXT) + string(FIND "${IMAGE_TEXT}" "void extract_img_srcs(" IMAGE_START) + string(FIND "${IMAGE_TEXT}" "bool extract_tag_text(" IMAGE_END) + if(IMAGE_START LESS 0 OR IMAGE_END LESS IMAGE_START) + message(FATAL_ERROR "Cannot locate production ${IMAGE_PAGE} image source extraction") + endif() + math(EXPR IMAGE_LENGTH "${IMAGE_END}-${IMAGE_START}") + string(SUBSTRING "${IMAGE_TEXT}" ${IMAGE_START} ${IMAGE_LENGTH} IMAGE_ACTUAL) + string(REGEX MATCH "constexpr std::size_t k(MaxRouteImagePoints|PreviewMaxImages) = [0-9]+;" IMAGE_LIMIT "${IMAGE_TEXT}") + if(NOT IMAGE_LIMIT) + message(FATAL_ERROR "Cannot locate production ${IMAGE_PAGE} image count limit") + endif() + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/${IMAGE_PAGE}_image_sources_actual.inc" "${IMAGE_LIMIT}\n${IMAGE_ACTUAL}") +endforeach() +hostlink_test(kml_image_sources test_kml_image_sources.cpp) +target_include_directories(kml_image_sources PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +hostlink_test(kml_image_stream test_kml_image_stream.cpp "${REPO}/third_party/yxml/yxml.c") +target_include_directories(kml_image_stream PRIVATE "${REPO}/third_party/yxml") +hostlink_test(route_segment_render test_route_segment_render.cpp "${REPO}/modules/ui_shared/src/ui/widgets/route_elevation_profile.cpp") +target_include_directories(route_segment_render BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/kml-reader-fakes" "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes") +target_include_directories(route_segment_render PRIVATE "${REPO}/modules/ui_shared/include") +target_compile_definitions(route_segment_render PRIVATE ESP_PLATFORM=1) +if(NOT MSVC) + target_compile_options(route_segment_render PRIVATE -fno-exceptions) +endif() +set(IMAGE_LINUX_SOURCE "${REPO}/platform/linux/common/src/platform/ui/route_storage.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IMAGE_LINUX_SOURCE}") +file(READ "${IMAGE_LINUX_SOURCE}" IMAGE_LINUX_TEXT) +string(FIND "${IMAGE_LINUX_TEXT}" "bool route_image_source_matches(" IMAGE_LINUX_START) +if(IMAGE_LINUX_START LESS 0) + message(FATAL_ERROR "Cannot locate production Linux image source query") +endif() +string(SUBSTRING "${IMAGE_LINUX_TEXT}" ${IMAGE_LINUX_START} -1 IMAGE_LINUX_TAIL) +string(FIND "${IMAGE_LINUX_TAIL}" "RouteImageDownloadResult download_route_image(" IMAGE_LINUX_END) +if(IMAGE_LINUX_END LESS 0) + message(FATAL_ERROR "Cannot locate production Linux image source query end") +endif() +string(SUBSTRING "${IMAGE_LINUX_TAIL}" 0 ${IMAGE_LINUX_END} IMAGE_LINUX_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/image_source_linux_actual.inc" "${IMAGE_LINUX_ACTUAL}") +hostlink_test(image_source_linux test_image_source_linux.cpp "${REPO}/platform/linux/common/src/platform/linux/runtime_paths.cpp") +target_include_directories(image_source_linux PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/platform/linux/common/include") +set_tests_properties(image_source_linux PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") +hostlink_test(route_install_authorization test_route_install_authorization.cpp) +hostlink_test(route_install_commit test_route_install_commit.cpp) +hostlink_test(route_install_storage_policy test_route_install_storage_policy.cpp) +hostlink_test(route_storage_lease test_route_storage_lease.cpp) +hostlink_test(route_install_directory test_route_install_directory.cpp) +hostlink_test(snapshot_space_budget test_snapshot_space_budget.cpp) +hostlink_test(track_manifest test_track_manifest.cpp) +hostlink_test(track_network_request test_track_network_request.cpp) +hostlink_test(track_network_read test_track_network_read.cpp) +hostlink_test(track_read_reply test_track_read_reply.cpp) +hostlink_test(track_read_exchange test_track_read_exchange.cpp) +hostlink_test(track_read_usb test_track_read_usb.cpp) +hostlink_test(track_read_store test_track_read_store.cpp) +hostlink_test(track_read_sink test_track_read_sink.cpp) +hostlink_test(track_query_response test_track_query_response.cpp) +hostlink_test(track_read_validator test_track_read_validator.cpp) +hostlink_test(track_region_response test_track_region_response.cpp) +hostlink_test(request_retry_window test_request_retry_window.cpp) +add_executable(track_upload_response test_track_upload_response.cpp) +target_compile_features(track_upload_response PRIVATE cxx_std_17) +target_include_directories(track_upload_response PRIVATE "${REPO}/modules/core_track_sharing/include" "${REPO}/modules/core_geocaching/include") +target_include_directories(track_upload_response PRIVATE "${REPO}/modules/core_chat/include") +if(MSVC) + target_compile_options(track_upload_response PRIVATE /UNDEBUG) +else() + target_compile_options(track_upload_response PRIVATE -UNDEBUG -Wall -Wextra) +endif() +hostlink_test(track_delivery_binding test_track_delivery_binding.cpp) +hostlink_test(track_directory_pool test_track_directory_pool.cpp) +hostlink_test(publication_directory test_publication_directory.cpp) +hostlink_test(track_directories_runtime test_track_directories_runtime.cpp) +target_include_directories(track_directories_runtime BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/directory-runtime-fakes" "${REPO}/platform/esp/idf_common/include") +set(TRACK_ROUTER_SOURCE "${REPO}/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${TRACK_ROUTER_SOURCE}") +file(READ "${TRACK_ROUTER_SOURCE}" TRACK_ROUTER_CONTENT) +string(FIND "${TRACK_ROUTER_CONTENT}" "MeshSendResult MeshAdapterRouter::sendTrackData(" TRACK_DISPATCH_START) +string(FIND "${TRACK_ROUTER_CONTENT}" "std::unique_ptr MeshAdapterRouter::takeServiceBackend(" TRACK_DISPATCH_END) +if(TRACK_DISPATCH_START LESS 0 OR TRACK_DISPATCH_END LESS TRACK_DISPATCH_START) + message(FATAL_ERROR "Cannot locate production track dispatch") +endif() +math(EXPR TRACK_DISPATCH_LENGTH "${TRACK_DISPATCH_END}-${TRACK_DISPATCH_START}") +string(SUBSTRING "${TRACK_ROUTER_CONTENT}" ${TRACK_DISPATCH_START} ${TRACK_DISPATCH_LENGTH} TRACK_DISPATCH_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/track_router_dispatch_actual.inc" "${TRACK_DISPATCH_ACTUAL}") +string(FIND "${TRACK_ROUTER_CONTENT}" "bool MeshAdapterRouter::bindTrackDelivery(" TRACK_BIND_START) +string(FIND "${TRACK_ROUTER_CONTENT}" "bool MeshAdapterRouter::trackLocalDestination(" TRACK_BIND_END) +if(TRACK_BIND_START LESS 0 OR TRACK_BIND_END LESS TRACK_BIND_START) + message(FATAL_ERROR "Cannot locate production track binding registration") +endif() +math(EXPR TRACK_BIND_LENGTH "${TRACK_BIND_END}-${TRACK_BIND_START}") +string(SUBSTRING "${TRACK_ROUTER_CONTENT}" ${TRACK_BIND_START} ${TRACK_BIND_LENGTH} TRACK_BIND_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/track_router_binding_actual.inc" "${TRACK_BIND_ACTUAL}") +hostlink_test(track_router_dispatch test_track_router_dispatch.cpp) +target_include_directories(track_router_dispatch PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_include_directories(track_manifest PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(track_manifest_chain test_track_manifest_chain.cpp) +target_include_directories(track_manifest_chain PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(candidate_revision test_candidate_revision.cpp) +target_include_directories(candidate_revision PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(revision_reservation test_revision_reservation.cpp) +hostlink_test(object_head test_object_head.cpp) +hostlink_test(object_head_store test_object_head_store.cpp) +hostlink_test(object_paths test_object_paths.cpp) +target_include_directories(revision_reservation PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(reservation_store test_reservation_store.cpp) +target_include_directories(reservation_store PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(signed_manifest_store test_signed_manifest_store.cpp) +target_include_directories(signed_manifest_store PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(track_upload_journal test_track_upload_journal.cpp) +target_include_directories(track_upload_journal PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_journal_store test_upload_journal_store.cpp) +target_include_directories(upload_journal_store PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_chunk_writer test_upload_chunk_writer.cpp) +target_include_directories(upload_chunk_writer PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_paths test_upload_paths.cpp) +target_include_directories(upload_paths PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_session test_upload_session.cpp) +target_include_directories(upload_session PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_protocol test_upload_protocol.cpp) +target_include_directories(upload_protocol PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(prepare_request test_prepare_request.cpp) +target_include_directories(prepare_request PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(upload_space_budget test_upload_space_budget.cpp) +target_include_directories(upload_space_budget PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(track_geometry_bounds test_track_geometry_bounds.cpp) +target_include_directories(track_geometry_bounds PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(track_coordinate_stream test_track_coordinate_stream.cpp) +target_include_directories(track_coordinate_stream PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(track_datetime test_track_datetime.cpp) +target_include_directories(track_datetime PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(kml_geometry_events test_kml_geometry_events.cpp) +target_include_directories(kml_geometry_events PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(yxml_stream test_yxml_stream.cpp "${REPO}/third_party/yxml/yxml.c") +target_include_directories(yxml_stream PRIVATE "${REPO}/third_party/yxml") +hostlink_test(xml_namespace_scope test_xml_namespace_scope.cpp) +target_include_directories(xml_namespace_scope PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(xml_name test_xml_name.cpp) +target_include_directories(xml_name PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(kml_xml_stream test_kml_xml_stream.cpp "${REPO}/third_party/yxml/yxml.c") +target_include_directories(kml_xml_stream PRIVATE "${REPO}/modules/core_track_sharing/include" "${REPO}/third_party/yxml") +hostlink_test(track_snapshot_files test_track_snapshot_files.cpp) +hostlink_test(track_snapshot_service test_track_snapshot_service.cpp) +target_include_directories(track_snapshot_service PRIVATE "${REPO}/platform/esp/common/include") +target_include_directories(track_snapshot_files PRIVATE "${REPO}/platform/esp/common/include") +hostlink_test(management_dispatch test_management_dispatch.cpp + "${REPO}/modules/core_hostlink/src/hostlink_codec.cpp") +if(WIN32) + # Avoid "patch" in the CLI filename (disPATCH); legacy Windows installer + # detection can request elevation when CTest uses CreateProcess directly. + set_target_properties(management_dispatch PROPERTIES OUTPUT_NAME management_response) +endif() +hostlink_test(web_config_upload test_web_config_upload.cpp + "${REPO}/modules/core_hostlink/src/hostlink_codec.cpp") +hostlink_test(publication_ticket test_publication_ticket.cpp + "${REPO}/modules/core_hostlink/src/hostlink_codec.cpp") +foreach(name management_dispatch web_config_upload publication_ticket) + target_include_directories(${name} BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/fakes") +endforeach() +hostlink_test(config_preferences test_config_preferences.cpp) +target_include_directories(config_preferences BEFORE PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" + "${REPO}/platform/esp/common/include") + +set(SD_TMS_SOURCE "${REPO}/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SD_TMS_SOURCE}") +file(READ "${SD_TMS_SOURCE}" SD_TMS_CONTENT) +string(FIND "${SD_TMS_CONTENT}" "tms::LineScratch* s_line_scratch_storage" SCRATCH_START) +string(FIND "${SD_TMS_CONTENT}" "#define s_line_scratch" SCRATCH_END) +if(SCRATCH_START LESS 0 OR SCRATCH_END LESS SCRATCH_START) + message(FATAL_ERROR "Cannot locate production TMS scratch lease") +endif() +math(EXPR SCRATCH_LENGTH "${SCRATCH_END}-${SCRATCH_START}") +string(SUBSTRING "${SD_TMS_CONTENT}" ${SCRATCH_START} ${SCRATCH_LENGTH} SCRATCH_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/tms_scratch_actual.inc" "${SCRATCH_SOURCE}") +hostlink_test(tms_scratch test_tms_scratch.cpp) +target_include_directories(tms_scratch PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_compile_definitions(tms_scratch PRIVATE ESP_PLATFORM TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) + +hostlink_test(identity_generation test_identity_generation.cpp) +hostlink_test(identity_export_material test_identity_export_material.cpp) +hostlink_test(identity_backup_export test_identity_backup_export.cpp) +hostlink_test(identity_backup_request test_identity_backup_request.cpp) +hostlink_test(identity_restore_service test_identity_restore_service.cpp) +set(IDENTITY_RESTART_SOURCE "${REPO}/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDENTITY_RESTART_SOURCE}") +file(READ "${IDENTITY_RESTART_SOURCE}" IDENTITY_RESTART_CONTENT) +string(FIND "${IDENTITY_RESTART_CONTENT}" "void scheduleWebManagementRestart()" RESTART_START) +string(FIND "${IDENTITY_RESTART_CONTENT}" "bool validateWebWorkingCandidate()" RESTART_END) +if(RESTART_START LESS 0 OR RESTART_END LESS RESTART_START) + message(FATAL_ERROR "Cannot locate identity restart scheduler") +endif() +math(EXPR RESTART_LENGTH "${RESTART_END}-${RESTART_START}") +string(SUBSTRING "${IDENTITY_RESTART_CONTENT}" ${RESTART_START} ${RESTART_LENGTH} RESTART_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_restart_schedule_actual.inc" "${RESTART_SOURCE}") +hostlink_test(identity_restart_schedule test_identity_restart_schedule.cpp) +target_include_directories(identity_restart_schedule PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +set(ARDUINO_LOOP_SOURCE "${REPO}/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp") +set(ARDUINO_APP_TICK_SOURCE "${REPO}/apps/esp32_lvgl/src/esp32_lvgl_arduino_app_runtime_access.cpp") +set(ARDUINO_LIFECYCLE_SOURCE "${REPO}/platform/esp/arduino_common/src/app_runtime_support.cpp") +foreach(LOOP_PART loop app lifecycle service) + if(LOOP_PART STREQUAL "loop") + set(LOOP_SOURCE "${ARDUINO_LOOP_SOURCE}") + set(LOOP_START "void tick()") + set(LOOP_END "} // namespace trailmate::apps::esp32_lvgl::arduino_loop_runtime") + elseif(LOOP_PART STREQUAL "app") + set(LOOP_SOURCE "${ARDUINO_APP_TICK_SOURCE}") + set(LOOP_START "void tick()") + set(LOOP_END "const Status& status()") + elseif(LOOP_PART STREQUAL "lifecycle") + set(LOOP_SOURCE "${ARDUINO_LIFECYCLE_SOURCE}") + set(LOOP_START "void tickBoundLifecycle(") + set(LOOP_END "void tickRuntime(") + else() + set(LOOP_SOURCE "${IDENTITY_RESTART_SOURCE}") + set(LOOP_START "void serviceWorkingConfig()") + set(LOOP_END "bool syncWorkingConfig(") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${LOOP_SOURCE}") + file(READ "${LOOP_SOURCE}" LOOP_TEXT) + string(FIND "${LOOP_TEXT}" "${LOOP_START}" LOOP_BEGIN) + string(FIND "${LOOP_TEXT}" "${LOOP_END}" LOOP_FINISH) + if(LOOP_BEGIN LESS 0 OR LOOP_FINISH LESS LOOP_BEGIN) + message(FATAL_ERROR "Cannot locate production Arduino ${LOOP_PART} service") + endif() + math(EXPR LOOP_LENGTH "${LOOP_FINISH}-${LOOP_BEGIN}") + string(SUBSTRING "${LOOP_TEXT}" ${LOOP_BEGIN} ${LOOP_LENGTH} LOOP_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/arduino_${LOOP_PART}_actual.inc" "${LOOP_ACTUAL}") +endforeach() +foreach(LOOP_VARIANT cdc no_cdc) + hostlink_test(arduino_config_loop_${LOOP_VARIANT} test_arduino_config_loop.cpp + "${REPO}/modules/ui_shared/src/ui/loop_shell.cpp") + target_include_directories(arduino_config_loop_${LOOP_VARIANT} PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/modules/ui_shared/include") +endforeach() +target_compile_definitions(arduino_config_loop_cdc PRIVATE TRAIL_MATE_USB_HOSTLINK_AUTOSTART=1) +hostlink_test(identity_restore_runtime test_identity_restore_runtime.cpp) +target_include_directories(identity_restore_runtime BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/identity-restore-fakes") +set(IDENTITY_GC_SOURCE "${REPO}/platform/esp/arduino_common/src/geocaching/browse_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDENTITY_GC_SOURCE}") +file(READ "${IDENTITY_GC_SOURCE}" IDENTITY_GC_CONTENT) +string(FIND "${IDENTITY_GC_CONTENT}" "bool withIdentityMaintenance(" GC_START) +string(FIND "${IDENTITY_GC_CONTENT}" "bool webLease(bool enabled" GC_END) +if(GC_START LESS 0 OR GC_END LESS GC_START) + message(FATAL_ERROR "Cannot locate Geocaching identity admission") +endif() +math(EXPR GC_LENGTH "${GC_END}-${GC_START}") +string(SUBSTRING "${IDENTITY_GC_CONTENT}" ${GC_START} ${GC_LENGTH} GC_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_geocaching_admission_actual.inc" "${GC_SOURCE}") +hostlink_test(identity_geocaching_admission test_identity_geocaching_admission.cpp) +target_include_directories(identity_geocaching_admission PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +set(IDENTITY_NETWORK_SOURCE "${REPO}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDENTITY_NETWORK_SOURCE}") +file(READ "${IDENTITY_NETWORK_SOURCE}" IDENTITY_NETWORK_CONTENT) +string(FIND "${IDENTITY_NETWORK_CONTENT}" "DeviceIdentityRestoreResult LxmfAdapter::restoreDeviceIdentity" NETWORK_START) +string(FIND "${IDENTITY_NETWORK_CONTENT}" "DeviceAuthorResult LxmfAdapter::signTrackManifest" NETWORK_END) +if(NETWORK_START LESS 0 OR NETWORK_END LESS NETWORK_START) + message(FATAL_ERROR "Cannot locate identity network admission") +endif() +math(EXPR NETWORK_LENGTH "${NETWORK_END}-${NETWORK_START}") +string(SUBSTRING "${IDENTITY_NETWORK_CONTENT}" ${NETWORK_START} ${NETWORK_LENGTH} NETWORK_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_restore_network_actual.inc" "${NETWORK_SOURCE}") +hostlink_test(identity_restore_network test_identity_restore_network.cpp) +target_include_directories(identity_restore_network PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +target_compile_definitions(identity_restore_network PRIVATE TRAIL_MATE_USB_HOSTLINK_AUTOSTART) +hostlink_test(identity_atomic_record test_identity_atomic_record.cpp) +hostlink_test(radio_identity_record test_radio_identity_record.cpp) +target_include_directories(radio_identity_record BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" "${REPO}/platform/esp/common/include") +hostlink_test(identity_generation_recovery test_identity_generation_recovery.cpp) +target_include_directories(identity_generation_recovery BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" "${REPO}/platform/esp/common/include") +target_include_directories(identity_atomic_record BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" "${REPO}/platform/esp/common/include") +target_include_directories(identity_generation BEFORE PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" + "${REPO}/platform/esp/common/include") +set(IDENTITY_SOURCE "${REPO}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDENTITY_SOURCE}") +file(READ "${IDENTITY_SOURCE}" IDENTITY_CONTENT) +string(FIND "${IDENTITY_CONTENT}" "bool LxmfIdentity::init()" INIT_START) +string(FIND "${IDENTITY_CONTENT}" "void LxmfIdentity::combinedPublicKey" INIT_END) +if(INIT_START LESS 0 OR INIT_END LESS INIT_START) + message(FATAL_ERROR "Cannot locate production identity initialization") +endif() +math(EXPR INIT_LENGTH "${INIT_END}-${INIT_START}") +string(SUBSTRING "${IDENTITY_CONTENT}" ${INIT_START} ${INIT_LENGTH} INIT_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_init_actual.inc" "${INIT_SOURCE}") +hostlink_test(identity_init test_identity_init.cpp) +target_include_directories(identity_init BEFORE PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" + "${REPO}/platform/esp/common/include" + "${CMAKE_CURRENT_BINARY_DIR}") +set(ED25519_DIR "${REPO}/modules/core_chat/src/infra/meshcore/crypto/ed25519") +hostlink_test(identity_restore_request test_identity_restore_request.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" + "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c") +set(TRAIL_MATE_ARDUINO_CRYPTO_DIR "${REPO}/.pio/libdeps/wio_tracker_l2/Crypto" CACHE PATH "Arduino Crypto source for native identity validation") +option(TRAIL_MATE_REQUIRE_RADIO_IDENTITY_TESTS "Require real RF identity crypto/storage tests rather than silently omitting them" OFF) +if(TRAIL_MATE_REQUIRE_RADIO_IDENTITY_TESTS AND (NOT EXISTS "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Curve25519.cpp" OR MSVC)) + message(FATAL_ERROR "Required RF identity tests need the pinned Arduino Crypto source and a supported native compiler") +endif() +if(EXISTS "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Curve25519.cpp" AND NOT MSVC) + set(RADIO_PKI_SOURCE "${REPO}/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${RADIO_PKI_SOURCE}") + file(READ "${RADIO_PKI_SOURCE}" RADIO_PKI_TEXT) + string(FIND "${RADIO_PKI_TEXT}" "bool MtAdapter::initPkiKeys()" RADIO_PKI_START) + string(FIND "${RADIO_PKI_TEXT}" "MtAdapter::PkiNodeKeyEntry* MtAdapter::findCachedPkiNodeKey" RADIO_PKI_END) + if(RADIO_PKI_START LESS 0 OR RADIO_PKI_END LESS RADIO_PKI_START) + message(FATAL_ERROR "Missing production PKI initialization") + endif() + math(EXPR RADIO_PKI_LENGTH "${RADIO_PKI_END}-${RADIO_PKI_START}") + string(SUBSTRING "${RADIO_PKI_TEXT}" ${RADIO_PKI_START} ${RADIO_PKI_LENGTH} RADIO_PKI_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/radio_pki_init_actual.inc" "${RADIO_PKI_ACTUAL}") + hostlink_test(radio_identity_storage test_radio_identity_storage.cpp test_radio_pki_init.cpp identity_validation_unused_rng.cpp + "${REPO}/platform/esp/arduino_common/src/chat/infra/meshcore/meshcore_identity.cpp" + "${REPO}/platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp" + "${REPO}/modules/core_chat/src/infra/meshcore/meshcore_identity_crypto.cpp" + "${ED25519_DIR}/key_exchange.c" + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" + "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Curve25519.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/BigNumberUtil.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Crypto.cpp") + target_include_directories(radio_identity_storage BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/radio-identity-fakes" "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes") + target_include_directories(radio_identity_storage PRIVATE "${REPO}/platform/esp/common/include" "${REPO}/modules/core_mesh/include" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}") + target_include_directories(radio_identity_storage PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(radio_identity_storage PRIVATE ESP_PLATFORM=1) + hostlink_test(radio_identity_management test_radio_identity_management.cpp identity_validation_unused_rng.cpp + "${REPO}/modules/core_chat/src/infra/meshcore/meshcore_identity_crypto.cpp" + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" + "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" "${ED25519_DIR}/key_exchange.c" + "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Curve25519.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/BigNumberUtil.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Crypto.cpp") + target_include_directories(radio_identity_management BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes") + target_include_directories(radio_identity_management PRIVATE "${REPO}/platform/esp/common/include" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}") + string(FIND "${IDENTITY_CONTENT}" "bool LxmfIdentity::loadFromPrefs()" LOAD_START) + string(FIND "${IDENTITY_CONTENT}" "bool LxmfIdentity::saveToPrefs()" LOAD_END) + if(LOAD_START LESS 0 OR LOAD_END LESS LOAD_START) + message(FATAL_ERROR "Cannot locate production identity loading") + endif() + math(EXPR LOAD_LENGTH "${LOAD_END}-${LOAD_START}") + string(SUBSTRING "${IDENTITY_CONTENT}" ${LOAD_START} ${LOAD_LENGTH} LOAD_SOURCE) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_load_actual.inc" "${LOAD_SOURCE}") + string(FIND "${IDENTITY_CONTENT}" "DeviceIdentityRestoreResult LxmfIdentity::restoreNativeIdentity" RESTORE_START) + string(FIND "${IDENTITY_CONTENT}" "bool LxmfIdentity::advanceAuthorGeneration()" RESTORE_END) + if(RESTORE_START LESS 0 OR RESTORE_END LESS RESTORE_START) + message(FATAL_ERROR "Cannot locate production identity restore") + endif() + math(EXPR RESTORE_LENGTH "${RESTORE_END}-${RESTORE_START}") + string(SUBSTRING "${IDENTITY_CONTENT}" ${RESTORE_START} ${RESTORE_LENGTH} RESTORE_SOURCE) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/identity_restore_actual.inc" "${RESTORE_SOURCE}") + hostlink_test(identity_native_key_validation test_identity_native_key_validation.cpp identity_validation_unused_rng.cpp test_identity_load_validation.cpp test_identity_restore_actual.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Curve25519.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/BigNumberUtil.cpp" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}/Crypto.cpp") + target_include_directories(identity_native_key_validation PRIVATE "${REPO}/platform/esp/common/include" "${TRAIL_MATE_ARDUINO_CRYPTO_DIR}") + target_include_directories(identity_native_key_validation BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/config-preferences-fakes" "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_options(identity_native_key_validation PRIVATE -ffunction-sections -fdata-sections) + target_link_options(identity_native_key_validation PRIVATE -Wl,--gc-sections) +endif() +hostlink_test(identity_signing_key_validation test_identity_signing_key_validation.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sha512.c" + "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c") +hostlink_test(track_manifest_sign test_track_manifest_sign.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" + "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c") +target_include_directories(track_manifest_sign PRIVATE "${REPO}/modules/core_track_sharing/include") +hostlink_test(device_author test_device_author.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" + "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c") + +set(IDF_STORAGE_SOURCE "${REPO}/platform/esp/idf_common/src/storage_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDF_STORAGE_SOURCE}") +file(READ "${IDF_STORAGE_SOURCE}" IDF_STORAGE_CONTENT) +string(FIND "${IDF_STORAGE_CONTENT}" " Result begin(Operation operation" STORAGE_BEGIN) +string(FIND "${IDF_STORAGE_CONTENT}" " void cancelAtStepBoundary" STORAGE_END) +if(STORAGE_BEGIN LESS 0 OR STORAGE_END LESS STORAGE_BEGIN) + message(FATAL_ERROR "Cannot locate IDF storage scheduling methods") +endif() +math(EXPR STORAGE_LENGTH "${STORAGE_END}-${STORAGE_BEGIN}") +string(SUBSTRING "${IDF_STORAGE_CONTENT}" ${STORAGE_BEGIN} ${STORAGE_LENGTH} STORAGE_SOURCE) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/idf_storage_scheduling_actual.inc" "${STORAGE_SOURCE}") +hostlink_test(idf_geo_scheduling test_idf_geo_scheduling.cpp) +target_include_directories(idf_geo_scheduling PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/platform/esp/common/include") + +# Optional real-crypto native verification against an existing SDK checkout. +# Machine paths belong in the CMake cache, never in repository configuration. +set(TRAIL_MATE_MBEDTLS_DIR "" CACHE PATH "Existing Mbed TLS source tree for snapshot crypto verification") +set(TRACKER_SOURCE "${REPO}/platform/esp/idf_common/src/platform_ui_tracker_runtime.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${TRACKER_SOURCE}") +file(READ "${TRACKER_SOURCE}" TRACKER_CONTENT) +set(TRACKER_GATE_SOURCE "") +foreach(fragment start remove snapshot) + if(fragment STREQUAL "start") + set(first "bool start_recording()") + set(last "bool current_path(") + elseif(fragment STREQUAL "remove") + set(first "bool remove_track(") + set(last "const char* track_dir()") + else() + set(first "bool try_acquire_snapshot_read()") + set(last "void set_auto_recording(") + endif() + string(FIND "${TRACKER_CONTENT}" "${first}" TRACKER_START) + string(FIND "${TRACKER_CONTENT}" "${last}" TRACKER_END) + if(TRACKER_START LESS 0 OR TRACKER_END LESS TRACKER_START) + message(FATAL_ERROR "Cannot locate production Tracker gate functions") + endif() + math(EXPR TRACKER_LENGTH "${TRACKER_END}-${TRACKER_START}") + string(SUBSTRING "${TRACKER_CONTENT}" ${TRACKER_START} ${TRACKER_LENGTH} TRACKER_FRAGMENT) + string(APPEND TRACKER_GATE_SOURCE "${TRACKER_FRAGMENT}\n") +endforeach() +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/idf_tracker_gate_actual.inc" "${TRACKER_GATE_SOURCE}") +hostlink_test(idf_tracker_gate test_idf_tracker_gate.cpp) +target_include_directories(idf_tracker_gate PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +foreach(backend idf arduino) + if(backend STREQUAL "idf") + set(SD_MUTATION_SOURCE "${REPO}/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp") + set(SD_MUTATION_END "class SdRuntimeFile::Impl") + set(SD_MUTATION_INCLUDE "sd_idf_mutations_actual.inc") + else() + set(SD_MUTATION_SOURCE "${REPO}/platform/esp/arduino_common/src/storage/sd_card_runtime.cpp") + set(SD_MUTATION_END "bool sd_rename(") + set(SD_MUTATION_INCLUDE "sd_arduino_remove_actual.inc") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SD_MUTATION_SOURCE}") + file(READ "${SD_MUTATION_SOURCE}" SD_MUTATION_CONTENT) + string(FIND "${SD_MUTATION_CONTENT}" "bool sd_remove(" SD_MUTATION_START) + string(FIND "${SD_MUTATION_CONTENT}" "${SD_MUTATION_END}" SD_MUTATION_STOP) + if(SD_MUTATION_START LESS 0 OR SD_MUTATION_STOP LESS SD_MUTATION_START) + message(FATAL_ERROR "Cannot locate production SD snapshot mutation functions") + endif() + math(EXPR SD_MUTATION_LENGTH "${SD_MUTATION_STOP}-${SD_MUTATION_START}") + string(SUBSTRING "${SD_MUTATION_CONTENT}" ${SD_MUTATION_START} ${SD_MUTATION_LENGTH} SD_MUTATION_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/${SD_MUTATION_INCLUDE}" "${SD_MUTATION_ACTUAL}") + hostlink_test(${backend}_sd_mutations test_sd_snapshot_mutations.cpp) + target_include_directories(${backend}_sd_mutations PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") +endforeach() +target_compile_definitions(arduino_sd_mutations PRIVATE TRAIL_MATE_SD_MUTATION_ARDUINO) +hostlink_test(idf_snapshot_lease test_idf_snapshot_lease.cpp) +target_include_directories(idf_snapshot_lease PRIVATE "${REPO}/platform/esp/common/include" "${REPO}/platform/esp/idf_common/include") +set(IDF_SD_FILE_SOURCE "${REPO}/platform/esp/idf_common/src/sd_card_runtime_sdfat_adapter.cpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IDF_SD_FILE_SOURCE}") +file(READ "${IDF_SD_FILE_SOURCE}" IDF_SD_FILE_CONTENT) +string(FIND "${IDF_SD_FILE_CONTENT}" "class SdRuntimeFile::Impl" IDF_SD_FILE_START) +string(FIND "${IDF_SD_FILE_CONTENT}" "class SdRuntimeDir::Impl" IDF_SD_FILE_END) +if(IDF_SD_FILE_START LESS 0 OR IDF_SD_FILE_END LESS IDF_SD_FILE_START) + message(FATAL_ERROR "Cannot locate production IDF SD file session methods") +endif() +math(EXPR IDF_SD_FILE_LENGTH "${IDF_SD_FILE_END}-${IDF_SD_FILE_START}") +string(SUBSTRING "${IDF_SD_FILE_CONTENT}" ${IDF_SD_FILE_START} ${IDF_SD_FILE_LENGTH} IDF_SD_FILE_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/idf_sd_file_session_actual.inc" "${IDF_SD_FILE_ACTUAL}") +hostlink_test(idf_sd_file_session test_idf_sd_file_session.cpp) +target_include_directories(idf_sd_file_session BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/sd-handle-fakes" "${CMAKE_CURRENT_BINARY_DIR}") +foreach(fragment directory probe) + if(fragment STREQUAL "directory") + set(first "class SdRuntimeDir::Impl") + set(last "bool sd_read_raw(") + else() + set(first "SdFileReadResult sd_read_file(") + set(last "bool sd_is_directory(") + endif() + string(FIND "${IDF_SD_FILE_CONTENT}" "${first}" SD_DIR_START) + string(FIND "${IDF_SD_FILE_CONTENT}" "${last}" SD_DIR_END) + if(SD_DIR_START LESS 0 OR SD_DIR_END LESS SD_DIR_START) + message(FATAL_ERROR "Cannot locate production IDF directory/probe methods") + endif() + math(EXPR SD_DIR_LENGTH "${SD_DIR_END}-${SD_DIR_START}") + string(SUBSTRING "${IDF_SD_FILE_CONTENT}" ${SD_DIR_START} ${SD_DIR_LENGTH} SD_DIR_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/idf_sd_${fragment}_actual.inc" "${SD_DIR_ACTUAL}") +endforeach() +hostlink_test(idf_sd_directory test_idf_sd_directory.cpp) +target_include_directories(idf_sd_directory PRIVATE "${REPO}/platform/esp/idf_common/include" "${REPO}/modules/core_track_sharing/include") +target_include_directories(idf_sd_directory BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/sd-handle-fakes" "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/platform/esp/common/include") +string(FIND "${IDF_SD_FILE_CONTENT}" "class SdmmcBlockDevice final" SD_BLOCK_START) +string(FIND "${IDF_SD_FILE_CONTENT}" "SdmmcBlockDevice s_block_device;" SD_BLOCK_END) +if(SD_BLOCK_START LESS 0 OR SD_BLOCK_END LESS SD_BLOCK_START) + message(FATAL_ERROR "Cannot locate production IDF block error methods") +endif() +math(EXPR SD_BLOCK_LENGTH "${SD_BLOCK_END}-${SD_BLOCK_START}") +string(SUBSTRING "${IDF_SD_FILE_CONTENT}" ${SD_BLOCK_START} ${SD_BLOCK_LENGTH} SD_BLOCK_ACTUAL) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/idf_sd_block_error_actual.inc" "${SD_BLOCK_ACTUAL}") +hostlink_test(idf_sd_block_error test_idf_sd_block_error.cpp) +target_include_directories(idf_sd_block_error PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" "${REPO}/platform/esp/common/include") +target_compile_definitions(idf_sd_block_error PRIVATE TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) +hostlink_test(track_catalog test_track_catalog.cpp) +target_include_directories(track_catalog BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/catalog-fakes" "${REPO}/platform/esp/idf_common/include") +if(TRAIL_MATE_MBEDTLS_DIR) + hostlink_test(verify_ip_confirmation test_verify_ip_confirmation.cpp + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" + "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c") + target_include_directories(verify_ip_confirmation PRIVATE "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}") + target_include_directories(verify_ip_confirmation BEFORE PRIVATE "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(verify_ip_confirmation PRIVATE MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + hostlink_test(track_read_manifest test_track_read_manifest.cpp + "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + hostlink_test(track_query_binding test_track_query_binding.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + hostlink_test(track_region_request test_track_region_request.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + hostlink_test(prepared_signature test_prepared_signature.cpp "${REPO}/third_party/yxml/yxml.c" + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(prepared_signature PRIVATE "${REPO}/modules/core_track_sharing/include" "${REPO}/third_party/yxml") + hostlink_test(upload_seal_sha256 test_upload_seal_sha256.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(upload_seal_sha256 PRIVATE "${REPO}/modules/core_track_sharing/include") + hostlink_test(manifest_file_check test_manifest_file_check.cpp "${REPO}/third_party/yxml/yxml.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(manifest_file_check PRIVATE "${REPO}/modules/core_track_sharing/include" "${REPO}/third_party/yxml") + hostlink_test(route_file_check test_route_file_check.cpp "${REPO}/third_party/yxml/yxml.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(route_file_check PRIVATE "${REPO}/third_party/yxml") + hostlink_test(route_install_job test_route_install_job.cpp "${REPO}/third_party/yxml/yxml.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(route_install_job PRIVATE "${REPO}/third_party/yxml") + hostlink_test(track_file_integrity test_track_file_integrity.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(track_file_integrity PRIVATE "${REPO}/modules/core_track_sharing/include") + hostlink_test(image_source_binding test_image_source_binding.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(image_source_binding PRIVATE "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}" "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(image_source_binding PRIVATE MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + set(IMAGE_DOWNLOAD_SOURCE "${REPO}/platform/esp/arduino_common/src/platform_ui_route_storage.cpp") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${IMAGE_DOWNLOAD_SOURCE}") + file(READ "${IMAGE_DOWNLOAD_SOURCE}" IMAGE_DOWNLOAD_TEXT) + string(FIND "${IMAGE_DOWNLOAD_TEXT}" "RouteImageDownloadResult download_route_image_with_progress(" IMAGE_DOWNLOAD_START REVERSE) + if(IMAGE_DOWNLOAD_START LESS 0) + message(FATAL_ERROR "Cannot locate production image download") + endif() + string(SUBSTRING "${IMAGE_DOWNLOAD_TEXT}" ${IMAGE_DOWNLOAD_START} -1 IMAGE_DOWNLOAD_TAIL) + string(FIND "${IMAGE_DOWNLOAD_TAIL}" "\n} // namespace" IMAGE_DOWNLOAD_END) + if(IMAGE_DOWNLOAD_END LESS 0) + message(FATAL_ERROR "Cannot locate production image download end") + endif() + string(SUBSTRING "${IMAGE_DOWNLOAD_TAIL}" 0 ${IMAGE_DOWNLOAD_END} IMAGE_DOWNLOAD_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/image_download_actual.inc" "${IMAGE_DOWNLOAD_ACTUAL}") + foreach(IMAGE_QUERY_KIND reference public) + if(IMAGE_QUERY_KIND STREQUAL "reference") + set(IMAGE_QUERY_START_TEXT "bool route_image_source_matches_reference(") + set(IMAGE_QUERY_END_TEXT "bool has_kml_extension(") + else() + set(IMAGE_QUERY_START_TEXT "bool route_image_source_matches(") + set(IMAGE_QUERY_END_TEXT "\nnamespace") + endif() + string(FIND "${IMAGE_DOWNLOAD_TEXT}" "${IMAGE_QUERY_START_TEXT}" IMAGE_QUERY_START) + if(IMAGE_QUERY_START LESS 0) + message(FATAL_ERROR "Cannot locate production image query") + endif() + string(SUBSTRING "${IMAGE_DOWNLOAD_TEXT}" ${IMAGE_QUERY_START} -1 IMAGE_QUERY_TAIL) + string(FIND "${IMAGE_QUERY_TAIL}" "${IMAGE_QUERY_END_TEXT}" IMAGE_QUERY_END) + if(IMAGE_QUERY_END LESS 0) + message(FATAL_ERROR "Cannot locate production image query end") + endif() + string(SUBSTRING "${IMAGE_QUERY_TAIL}" 0 ${IMAGE_QUERY_END} IMAGE_QUERY_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/image_query_${IMAGE_QUERY_KIND}_actual.inc" "${IMAGE_QUERY_ACTUAL}") + endforeach() + hostlink_test(image_source_runtime test_image_source_runtime.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(image_source_runtime BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes") + target_include_directories(image_source_runtime PRIVATE "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}" "${CMAKE_CURRENT_BINARY_DIR}" "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(image_source_runtime PRIVATE ESP_PLATFORM=1 MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + function(image_ui_extract source start_text end_text name) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${source}") + file(READ "${source}" text) + string(FIND "${text}" "${start_text}" start REVERSE) + if(start LESS 0) + message(FATAL_ERROR "Cannot locate production image UI ${name}") + endif() + string(SUBSTRING "${text}" ${start} -1 tail) + string(FIND "${tail}" "${end_text}" finish) + if(finish LESS 0) + message(FATAL_ERROR "Cannot locate production image UI ${name} end") + endif() + string(SUBSTRING "${tail}" 0 ${finish} actual) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/image_ui_${name}_actual.inc" "${actual}") + endfunction() + if(EXISTS "${TRAIL_MATE_ROUTE_PNG_LGFX}/src/lgfx/utility/lgfx_miniz.c" AND EXISTS "${TRAIL_MATE_ROUTE_PNG_LVGL}/src/libs/tjpgd/tjpgd.c") + image_ui_extract("${IMAGE_DOWNLOAD_SOURCE}" "void put_le16(" "void log_cache_memory_skip(" png_heap) + image_ui_extract("${IMAGE_DOWNLOAD_SOURCE}" "struct RouteJpegCacheContext" "bool route_image_cache_ready(" png_cache) + hostlink_test(route_png_cache test_route_png_cache.cpp + "${REPO}/platform/esp/arduino_common/src/route_png_cache.cpp" + "${REPO}/platform/esp/arduino_common/src/route_png_codec.c" + "${TRAIL_MATE_ROUTE_PNG_LGFX}/src/lgfx/utility/lgfx_miniz.c" + "${TRAIL_MATE_ROUTE_PNG_LVGL}/src/libs/tjpgd/tjpgd.c") + target_include_directories(route_png_cache BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/png-cache-fakes" "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes") + target_include_directories(route_png_cache PRIVATE "${TRAIL_MATE_ROUTE_PNG_LGFX}/src" "${TRAIL_MATE_ROUTE_PNG_LVGL}" "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(route_png_cache PRIVATE ESP_PLATFORM=1 CONFIG_IDF_TARGET_ESP32P4=1 LV_CONF_SKIP=1 LV_KCONFIG_IGNORE=1 LV_USE_TJPGD=1 "TRAIL_MATE_PNG_FIXTURE_DIR=\"${CMAKE_CURRENT_LIST_DIR}/fixtures/png-cache\"" "TRAIL_MATE_JPEG_FIXTURE=\"${CMAKE_CURRENT_LIST_DIR}/fixtures/png-cache/red-16x8.jpg\"") + if(NOT MSVC) + target_compile_options(route_png_cache PRIVATE -fno-exceptions) + endif() + endif() + set(IMAGE_NAV_SOURCE "${REPO}/modules/ui_shared/src/ui/screens/gps/gps_page_runtime.cpp") + set(IMAGE_PREVIEW_SOURCE "${REPO}/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp") + image_ui_extract("${IMAGE_NAV_SOURCE}" "struct RouteImagePoint" "enum class TrackOverlayFileKind" navigation_struct) + image_ui_extract("${IMAGE_NAV_SOURCE}" "bool append_route_images(" "void assign_route_image_paths(" navigation_append) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void assign_route_image_paths(" "void refresh_route_image_storage_state(" navigation_paths) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void refresh_route_image_storage_state(" "template " navigation_refresh) + image_ui_extract("${IMAGE_NAV_SOURCE}" "struct TrackOverlayPoint" "struct RouteImagePoint" navigation_route_structs) + image_ui_extract("${IMAGE_NAV_SOURCE}" "bool load_kml_track_points(" "void append_route_image_overlay(" navigation_route_load) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void build_route_elevation_samples(" "void downsample_track_points(" navigation_route_samples) + image_ui_extract("${IMAGE_NAV_SOURCE}" "bool nearest_route_distance_m(" "void advance_route_navigation(" navigation_route_nearest) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void advance_route_navigation(" "void update_track_elevation_metrics(" navigation_match_state) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void project_route_point_to_local_m(" "bool nearest_route_distance_m(" navigation_route_projection) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void append_track_overlay(" "void keep_only_current_position_overlay(" navigation_route_overlay) + image_ui_extract("${IMAGE_NAV_SOURCE}" "void ensure_map_route_image_cache_build(" "void sync_map_route_image_strip(" navigation_package_cache) + image_ui_extract("${IMAGE_NAV_SOURCE}" "std::string route_path_basename(" "std::string route_asset_root_for_id(" navigation_asset_id) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "struct RoutePreviewImage" "struct RoutePreviewMetrics" preview_struct) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "bool append_pending_images(" "double preview_degrees_to_radians(" preview_append) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "void assign_preview_image_paths(" "void refresh_preview_saved_image_count_hint(" preview_paths) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "void refresh_preview_saved_image_count_hint(" "void refresh_preview_image_storage_state(" preview_hint) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "void refresh_preview_image_storage_state(" "bool apply_route_preview_download_status_to_images(" preview_refresh) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "bool apply_route_preview_download_status_to_images(" "void clear_preview_model(" preview_status) + image_ui_extract("${IMAGE_NAV_SOURCE}" "bool load_kml_route_images(" "void build_route_elevation_samples(" navigation_load) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "struct RoutePreviewPoint" "struct RoutePreviewImage" preview_point_struct) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "void append_preview_route_point(" "bool append_pending_images(" preview_point_append) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "bool load_route_preview_model(" "struct PreviewBounds" preview_load) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "struct RoutePreviewMetrics" "enum class RoutePreviewDownloadState" preview_metrics_struct) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "double preview_degrees_to_radians(" "void assign_preview_image_paths(" preview_metrics) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "void rebuild_preview_elevation_samples(" "void sync_route_preview_elevation_visibility(" preview_samples) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "bool build_preview_map_line_points(" "void draw_preview_map_overlay(" preview_draw_points) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "std::vector route_preview_cache_items(" "bool route_preview_download_status_matches(" preview_package_cache_items) + image_ui_extract("${IMAGE_PREVIEW_SOURCE}" "std::string route_asset_id_for_name(" "std::string route_asset_root_for_id(" preview_asset_id) + image_ui_extract("${IMAGE_DOWNLOAD_SOURCE}" "bool route_image_reference_hash(" "\nnamespace" hash) + file(READ "${CMAKE_CURRENT_BINARY_DIR}/image_ui_hash_actual.inc" IMAGE_HASH_ACTUAL) + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/image_reference_hash_actual.inc" "${IMAGE_HASH_ACTUAL}") + hostlink_test(image_source_ui test_image_source_ui.cpp + "${REPO}/third_party/yxml/yxml.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(image_source_ui BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes") + target_include_directories(image_source_ui BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/kml-reader-fakes") + target_include_directories(image_source_ui PRIVATE "${REPO}/third_party/yxml") + target_include_directories(image_source_ui PRIVATE "${REPO}/modules/ui_shared/include" "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}" "${CMAKE_CURRENT_BINARY_DIR}" "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(image_source_ui PRIVATE ESP_PLATFORM=1 MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + if(NOT MSVC) + target_compile_options(image_source_ui PRIVATE -fno-exceptions) + endif() + hostlink_test(kml_navigation test_kml_navigation.cpp "${REPO}/third_party/yxml/yxml.c") + target_include_directories(kml_navigation BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes" "${CMAKE_CURRENT_LIST_DIR}/kml-reader-fakes") + target_include_directories(kml_navigation PRIVATE "${REPO}/third_party/yxml" "${REPO}/modules/ui_shared/include" "${REPO}/modules/ui_presentation/include" "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(kml_navigation PRIVATE ESP_PLATFORM=1) + hostlink_test(kml_navigation_match test_kml_navigation_match.cpp "${REPO}/third_party/yxml/yxml.c") + target_include_directories(kml_navigation_match BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/image-source-fakes" "${CMAKE_CURRENT_LIST_DIR}/kml-reader-fakes") + target_include_directories(kml_navigation_match PRIVATE "${REPO}/third_party/yxml" "${REPO}/modules/ui_shared/include" "${REPO}/modules/ui_presentation/include" "${CMAKE_CURRENT_BINARY_DIR}") + target_compile_definitions(kml_navigation_match PRIVATE ESP_PLATFORM=1) + if(NOT MSVC) + target_compile_options(kml_navigation PRIVATE -fno-exceptions) + target_compile_options(kml_navigation_match PRIVATE -fno-exceptions) + endif() + hostlink_test(track_manifest_verify test_track_manifest_verify.cpp + "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(track_manifest_verify PRIVATE "${REPO}/modules/core_track_sharing/include") + hostlink_test(track_manifest_hash test_track_manifest_hash.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + hostlink_test(track_manifest_extensions test_track_manifest_extensions.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(track_manifest_extensions PRIVATE "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}" "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(track_manifest_extensions PRIVATE MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + hostlink_test(attachment_index_stream test_attachment_index_stream.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(attachment_index_stream PRIVATE "${REPO}/platform/esp/common/include" "${CMAKE_CURRENT_LIST_DIR}" "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(attachment_index_stream PRIVATE MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + target_include_directories(track_manifest_hash PRIVATE "${REPO}/modules/core_track_sharing/include") + hostlink_test(track_snapshot_owner test_track_snapshot_owner.cpp "${REPO}/third_party/yxml/yxml.c" + "${ED25519_DIR}/keypair.c" "${ED25519_DIR}/sign.c" "${ED25519_DIR}/verify.c" "${ED25519_DIR}/sha512.c" "${ED25519_DIR}/ge.c" "${ED25519_DIR}/fe.c" "${ED25519_DIR}/sc.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + target_include_directories(track_snapshot_owner BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/owner-fakes" "${REPO}/platform/esp/idf_common/include") + target_include_directories(track_snapshot_owner PRIVATE "${REPO}/modules/core_track_sharing/include" "${REPO}/third_party/yxml") + hostlink_test(snapshot_stream_hash test_snapshot_stream_hash.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + hostlink_test(snapshot_files_sha256 test_track_snapshot_files.cpp + "${TRAIL_MATE_MBEDTLS_DIR}/library/sha256.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform.c" + "${TRAIL_MATE_MBEDTLS_DIR}/library/platform_util.c") + # Both variants intentionally share the same on-disk fixture. Serialize + # them so one variant cannot delete another variant's active snapshot. + set_tests_properties(track_snapshot_files snapshot_files_sha256 PROPERTIES RESOURCE_LOCK snapshot_file_fixture) + foreach(target snapshot_stream_hash snapshot_files_sha256 track_snapshot_owner track_manifest_hash track_manifest_verify track_file_integrity manifest_file_check route_file_check route_install_job upload_seal_sha256 prepared_signature track_query_binding track_read_manifest track_region_request) + target_include_directories(${target} PRIVATE + "${REPO}/platform/esp/common/include" + "${CMAKE_CURRENT_LIST_DIR}" + "${TRAIL_MATE_MBEDTLS_DIR}/include") + target_compile_definitions(${target} PRIVATE MBEDTLS_CONFIG_FILE="snapshot_hash_config.h") + endforeach() + target_compile_definitions(snapshot_files_sha256 PRIVATE TRAIL_MATE_REAL_SNAPSHOT_HASH) +endif() diff --git a/tests/hostlink/README.md b/tests/hostlink/README.md new file mode 100644 index 00000000..f2a3de84 --- /dev/null +++ b/tests/hostlink/README.md @@ -0,0 +1,34 @@ +# HostLink management verification + +Build with CMake and run CTest. Assertions stay enabled in all build types. + +```sh +cmake -S tests/hostlink -B .codex-build/hostlink-codec/native +cmake --build .codex-build/hostlink-codec/native +ctest --test-dir .codex-build/hostlink-codec/native --output-on-failure +``` + +Tests cover framing parity, borrowed management envelopes, legacy/management +routing, queue-item size, transfer ownership/idempotency, snapshot consistency, +SD upload/commit control flow, and native IDF typed NVS compatibility. +The TMS scratch test compiles the production lease extracted from its source, +checks nested reuse and outer ownership, and injects PSRAM allocation failure. +Identity tests verify persistent generation changes, refusal to overwrite an +existing damaged account, and the unchanged 200-byte identity object. The native +proof executable uses the firmware's Ed25519 library and fixed test keys. +The IDF scheduling test compiles the production begin/step methods and verifies +peer/Geocaching fairness, bounded batches, and continuation without restarting a batch. + +After the native build, run the cross-language check from the repository root: + +```sh +node --test tests/site/author-native-interop.test.mjs +``` + +The dispatch/upload tests use fake storage and a deliberately noncryptographic +hash to exercise failures and state transitions. They do not prove SHA-256, +physical SD persistence, power-loss recovery, USB operation, or target stack +budgets. The NVS compatibility test checks key/type handling and error reporting, +not flash durability. Target builds and hardware verification remain separate. +The identity-init policy test compiles the actual production policy with simulated +key loading; it does not replace real NVS/crypto loading or hardware validation. diff --git a/tests/hostlink/bzip2_compat_cli.c b/tests/hostlink/bzip2_compat_cli.c new file mode 100644 index 00000000..6233f521 --- /dev/null +++ b/tests/hostlink/bzip2_compat_cli.c @@ -0,0 +1,36 @@ +#include "bzlib.h" +#include +#include +#ifdef _WIN32 +#include +#include +#endif +int main(int argc, char** argv) +{ + const unsigned capacity = 4 * 1024 * 1024; + char* input = (char*)malloc(capacity); + char* output = (char*)malloc(capacity); + unsigned size, output_size = capacity; + int status; + if (argc != 3 || !input || !output) return 2; +#ifdef _WIN32 + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); +#endif + size = (unsigned)fread(input, 1, capacity, stdin); + if (ferror(stdin) || size == capacity) return 2; + if (argv[1][0] == 'c') + status = BZ2_bzBuffToBuffCompress(output, &output_size, input, size, atoi(argv[2]), 0, 30); + else + status = BZ2_bzBuffToBuffDecompress(output, &output_size, input, size, atoi(argv[2]), 0); + if (status != BZ_OK) + { + free(input); + free(output); + return 3; + } + status = fwrite(output, 1, output_size, stdout) == output_size ? 0 : 4; + free(input); + free(output); + return status; +} diff --git a/tests/hostlink/catalog-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/hostlink/catalog-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..76fb323f --- /dev/null +++ b/tests/hostlink/catalog-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +namespace platform::esp::arduino_common::storage +{ +enum class SdFileReadStatus +{ + Ready, + Missing, + Busy, + Unavailable, + Invalid, + IoError +}; +enum class SdDirReadStatus +{ + Entry, + End, + Busy, + Unavailable, + Invalid, + IoError +}; +struct Row +{ + std::string name; + bool directory = false; + uint64_t bytes = 100; +}; +inline std::vector rows; +inline uint32_t media = 1; +inline bool busy = false, unavailable = false, external = false; +inline unsigned scans = 0; +inline uint32_t sd_media_session() { return media; } +inline bool sd_external_block_owner_active() { return external; } +class SdRuntimeDir +{ + size_t index = 0; + + public: + SdFileReadStatus open_read_status(const char*, uint32_t expected) + { + if (busy) return SdFileReadStatus::Busy; + if (unavailable || expected != media) return SdFileReadStatus::Unavailable; + return SdFileReadStatus::Ready; + } + bool seek_read_position(uint64_t position) + { + index = position / 32; + return index <= rows.size(); + } + SdFileReadStatus seek_read_status(uint64_t position) { return seek_read_position(position) ? SdFileReadStatus::Ready : SdFileReadStatus::Invalid; } + SdDirReadStatus read_next_info(char* name, size_t capacity, bool* directory, uint64_t* bytes, uint64_t* next) + { + ++scans; + if (index == rows.size()) return SdDirReadStatus::End; + const auto& row = rows[index++]; + if (row.name.size() >= capacity) return SdDirReadStatus::IoError; + std::memcpy(name, row.name.c_str(), row.name.size() + 1); + *directory = row.directory; + *bytes = row.bytes; + *next = index * 32; + return SdDirReadStatus::Entry; + } +}; +} // namespace platform::esp::arduino_common::storage diff --git a/tests/hostlink/check_image_notice_catalogs.py b/tests/hostlink/check_image_notice_catalogs.py new file mode 100644 index 00000000..85ac8a91 --- /dev/null +++ b/tests/hostlink/check_image_notice_catalogs.py @@ -0,0 +1,12 @@ +from pathlib import Path + +root = Path(__file__).resolve().parents[2] +keys = {"Image cache needs checking. Download again.", "Image memory unavailable"} +files = list((root / "packs").rglob("strings.tsv")) +assert len(files) == 13 +for file in files: + rows = [line.split("\t", 1) for line in file.read_text(encoding="utf-8-sig").splitlines()] + for key in keys: + matches = [row for row in rows if row and row[0] == key] + assert len(matches) == 1 and len(matches[0]) == 2 and matches[0][1].strip(), (file, key) +print("Image compatibility and memory notices: 13 device locale catalogs passed") diff --git a/tests/hostlink/config-preferences-fakes/esp_efuse.h b/tests/hostlink/config-preferences-fakes/esp_efuse.h new file mode 100644 index 00000000..f740e01f --- /dev/null +++ b/tests/hostlink/config-preferences-fakes/esp_efuse.h @@ -0,0 +1,8 @@ +#pragma once +#include +inline int esp_efuse_mac_get_default(uint8_t* out) +{ + const uint8_t fixture[] = {2, 3, 4, 5, 6, 7}; + for (unsigned i = 0; i < 6; ++i) out[i] = fixture[i]; + return 0; +} diff --git a/tests/hostlink/config-preferences-fakes/esp_err.h b/tests/hostlink/config-preferences-fakes/esp_err.h new file mode 100644 index 00000000..8b369f75 --- /dev/null +++ b/tests/hostlink/config-preferences-fakes/esp_err.h @@ -0,0 +1,2 @@ +#pragma once +#include "nvs.h" diff --git a/tests/hostlink/config-preferences-fakes/nvs.h b/tests/hostlink/config-preferences-fakes/nvs.h new file mode 100644 index 00000000..4af69742 --- /dev/null +++ b/tests/hostlink/config-preferences-fakes/nvs.h @@ -0,0 +1,108 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +using esp_err_t = int; +using nvs_handle_t = uint32_t; +constexpr int ESP_OK = 0, ESP_ERR_NVS_NOT_FOUND = 1, NVS_READONLY = 0, NVS_READWRITE = 1; +constexpr int ESP_ERR_NVS_TYPE_MISMATCH = 3, ESP_ERR_NVS_INVALID_LENGTH = 5; +namespace fake_nvs +{ +using Value = std::variant>; +inline std::map> values; +inline std::map> handles; +inline nvs_handle_t next = 1; +inline bool fail_commit = false; +inline int open_error = 0; +inline unsigned commits = 0; +template +int get(nvs_handle_t handle, const char* key, T* output) +{ + if (!handles.count(handle)) return 2; + const auto& records = values[handles[handle].first]; + const auto record = records.find(key); + if (record == records.end()) return ESP_ERR_NVS_NOT_FOUND; + const auto* value = std::get_if(&record->second); + if (!value) return 3; + *output = *value; + return ESP_OK; +} +template +int put(nvs_handle_t handle, const char* key, T value) +{ + if (!handles.count(handle) || !handles[handle].second) return 2; + values[handles[handle].first][key] = value; + return ESP_OK; +} +} // namespace fake_nvs +inline int nvs_open(const char* ns, int mode, nvs_handle_t* output) +{ + if (fake_nvs::open_error) return fake_nvs::open_error; + if (mode == NVS_READONLY && !fake_nvs::values.count(ns)) return ESP_ERR_NVS_NOT_FOUND; + fake_nvs::values[ns]; + *output = fake_nvs::next++; + fake_nvs::handles[*output] = {ns, mode == NVS_READWRITE}; + return ESP_OK; +} +inline void nvs_close(nvs_handle_t handle) { fake_nvs::handles.erase(handle); } +inline int nvs_get_u8(nvs_handle_t handle, const char* key, uint8_t* output) { return fake_nvs::get(handle, key, output); } +inline int nvs_get_u32(nvs_handle_t handle, const char* key, uint32_t* output) { return fake_nvs::get(handle, key, output); } +inline int nvs_set_u8(nvs_handle_t handle, const char* key, uint8_t value) { return fake_nvs::put(handle, key, value); } +inline int nvs_set_u32(nvs_handle_t handle, const char* key, uint32_t value) { return fake_nvs::put(handle, key, value); } +inline int nvs_get_i32(nvs_handle_t handle, const char* key, int32_t* output) { return fake_nvs::get(handle, key, output); } +inline int nvs_set_i32(nvs_handle_t handle, const char* key, int32_t value) { return fake_nvs::put(handle, key, value); } +inline int nvs_set_str(nvs_handle_t handle, const char* key, const char* value) { return fake_nvs::put(handle, key, std::string(value)); } +inline int nvs_get_str(nvs_handle_t handle, const char* key, char* output, std::size_t* size) +{ + std::string value; + const auto result = fake_nvs::get(handle, key, &value); + if (result != ESP_OK) return result; + const auto needed = value.size() + 1; + if (output && *size < needed) + { + *size = needed; + return ESP_ERR_NVS_INVALID_LENGTH; + } + if (output) std::memcpy(output, value.c_str(), needed); + *size = needed; + return ESP_OK; +} +inline int nvs_erase_all(nvs_handle_t handle) +{ + if (!fake_nvs::handles.count(handle) || !fake_nvs::handles[handle].second) return 2; + fake_nvs::values[fake_nvs::handles[handle].first].clear(); + return ESP_OK; +} +inline int nvs_commit(nvs_handle_t) +{ + ++fake_nvs::commits; + return fake_nvs::fail_commit ? 4 : ESP_OK; +} +inline int nvs_get_blob(nvs_handle_t handle, const char* key, void* output, std::size_t* size) +{ + std::vector value; + const auto result = fake_nvs::get(handle, key, &value); + if (result != ESP_OK) return result; + if (output && *size < value.size()) + { + *size = value.size(); + return 5; + } + if (output) std::memcpy(output, value.data(), value.size()); + *size = value.size(); + return ESP_OK; +} +inline int nvs_set_blob(nvs_handle_t handle, const char* key, const void* value, std::size_t size) +{ + const auto* raw = static_cast(value); + return fake_nvs::put(handle, key, std::vector(raw, raw + size)); +} +inline int nvs_erase_key(nvs_handle_t handle, const char* key) +{ + if (!fake_nvs::handles.count(handle) || !fake_nvs::handles[handle].second) return 2; + return fake_nvs::values[fake_nvs::handles[handle].first].erase(key) ? ESP_OK : ESP_ERR_NVS_NOT_FOUND; +} diff --git a/tests/hostlink/directory-runtime-fakes/esp_heap_caps.h b/tests/hostlink/directory-runtime-fakes/esp_heap_caps.h new file mode 100644 index 00000000..cab8120b --- /dev/null +++ b/tests/hostlink/directory-runtime-fakes/esp_heap_caps.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +constexpr uint32_t MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 2; +inline size_t directory_free_bytes = 65536, directory_largest_block = 65536, directory_live_allocations = 0; +inline bool directory_allocation_fail = false; +inline size_t heap_caps_get_free_size(uint32_t) { return directory_free_bytes; } +inline size_t heap_caps_get_largest_free_block(uint32_t) { return directory_largest_block; } +inline void* heap_caps_malloc(size_t bytes, uint32_t caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (directory_allocation_fail) return nullptr; + void* value = std::malloc(bytes); + if (value) ++directory_live_allocations; + return value; +} +inline void heap_caps_free(void* value) +{ + if (value) + { + assert(directory_live_allocations); + --directory_live_allocations; + std::free(value); + } +} diff --git a/tests/hostlink/directory-runtime-fakes/esp_random.h b/tests/hostlink/directory-runtime-fakes/esp_random.h new file mode 100644 index 00000000..30f7c183 --- /dev/null +++ b/tests/hostlink/directory-runtime-fakes/esp_random.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +inline void esp_fill_random(void* output, size_t bytes) +{ + static uint8_t value = 1; + std::memset(output, value++, bytes); +} diff --git a/tests/hostlink/directory-runtime-fakes/esp_timer.h b/tests/hostlink/directory-runtime-fakes/esp_timer.h new file mode 100644 index 00000000..fe0fc908 --- /dev/null +++ b/tests/hostlink/directory-runtime-fakes/esp_timer.h @@ -0,0 +1,4 @@ +#pragma once +#include +inline int64_t directory_clock_us = 1000000; +inline int64_t esp_timer_get_time() { return directory_clock_us; } diff --git a/tests/hostlink/fakes/app/app_facade_access.h b/tests/hostlink/fakes/app/app_facade_access.h new file mode 100644 index 00000000..1f16159c --- /dev/null +++ b/tests/hostlink/fakes/app/app_facade_access.h @@ -0,0 +1,22 @@ +#pragma once +#include "chat/infra/reticulum/reticulum_wire.h" +#include "chat/ports/i_mesh_adapter.h" +namespace app +{ +struct FakeMessagingFacade +{ + chat::IMeshAdapter* adapter = nullptr; + chat::IMeshAdapter* getMeshAdapter() { return adapter; } +}; +inline FakeMessagingFacade fake_messaging; +inline bool hasAppFacade() { return true; } +inline FakeMessagingFacade& messagingFacade() { return fake_messaging; } +} // namespace app +// These deterministic public hash stubs exercise dispatch and buffer ownership. +// Real SHA-256/Ed25519 interoperability is verified by separate vector tests. +namespace chat::reticulum +{ +inline void computeIdentityHash(const uint8_t public_key[64], uint8_t output[16]) { std::memcpy(output, public_key, 16); } +inline void computeNameHash(const char*, const char*, uint8_t output[10]) { std::memset(output, 0x41, 10); } +inline void computeDestinationHash(const uint8_t*, const uint8_t identity_hash[16], uint8_t output[16]) { std::memcpy(output, identity_hash, 16); } +} // namespace chat::reticulum diff --git a/tests/hostlink/fakes/platform/esp/arduino_common/app_config_sd_tms_runtime.h b/tests/hostlink/fakes/platform/esp/arduino_common/app_config_sd_tms_runtime.h new file mode 100644 index 00000000..913d4637 --- /dev/null +++ b/tests/hostlink/fakes/platform/esp/arduino_common/app_config_sd_tms_runtime.h @@ -0,0 +1,32 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +namespace app::sd_tms +{ +enum class WorkingConfigSyncResult +{ + Synchronized, + Deferred, + Failed +}; +inline WorkingConfigSyncResult fake_sync = WorkingConfigSyncResult::Synchronized; +inline bool fake_restart = false; +inline unsigned fake_commits = 0; +inline WorkingConfigSyncResult syncPendingWorkingConfig() { return fake_sync; } +inline const char* webWorkingCandidatePath() { return "/trailmate/config.web.tms"; } +inline bool webConfigurationRestartPending() { return fake_restart; } +inline bool validateWebWorkingCandidate() +{ + const auto& text = platform::esp::arduino_common::storage::fake_files[webWorkingCandidatePath()]; + return text.find("TMSET7\n") == 0 && text.find("END\n") != std::string::npos; +} +inline bool commitWebWorkingCandidate() +{ + using namespace platform::esp::arduino_common::storage; + if (!validateWebWorkingCandidate() || fake_write_failure) return false; + fake_file = fake_files[webWorkingCandidatePath()]; + fake_files.erase(webWorkingCandidatePath()); + fake_restart = true; + ++fake_commits; + return true; +} +} // namespace app::sd_tms diff --git a/tests/hostlink/fakes/platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h b/tests/hostlink/fakes/platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h new file mode 100644 index 00000000..69511726 --- /dev/null +++ b/tests/hostlink/fakes/platform/esp/arduino_common/chat/infra/store/fixed_slot_journal.h @@ -0,0 +1,24 @@ +#pragma once +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +namespace chat::storage::v2 +{ +inline bool recoverAtomicFile(const char* final, const char* temporary, const char*) +{ + using namespace platform::esp::arduino_common::storage; + if (sd_exists(final)) return true; + if (sd_exists(temporary)) + { + fake_files[final] = fake_files[temporary]; + fake_files.erase(temporary); + } + return fake_available; +} +inline bool replaceFileAtomically(const char* temporary, const char* final, const char*) +{ + using namespace platform::esp::arduino_common::storage; + if (!sd_exists(temporary) || fake_write_failure) return false; + fake_files[final] = fake_files[temporary]; + fake_files.erase(temporary); + return true; +} +} // namespace chat::storage::v2 diff --git a/tests/hostlink/fakes/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/hostlink/fakes/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..91a195cf --- /dev/null +++ b/tests/hostlink/fakes/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include +namespace platform::esp::arduino_common::storage +{ +inline std::string fake_file = "TMSET7\nschema.version=u16:7\ndocument.kind=enum:working\nEND\n"; +inline std::unordered_map fake_files; +inline bool fake_available = true, fake_read_failure = false, fake_write_failure = false; +inline bool sd_exists(const char* path) { return fake_available && (std::string(path) == "/trailmate/config.tms" || fake_files.count(path)); } +inline bool sd_remove(const char* path) { return fake_available && fake_files.erase(path) != 0; } +class SdRuntimeFile +{ + size_t offset_ = 0; + std::string path_; + std::string& contents() const { return path_ == "/trailmate/config.tms" ? fake_file : fake_files[path_]; } + + public: + bool open(const char* path, const char* mode) + { + if (!fake_available) return false; + path_ = path; + offset_ = 0; + if (mode[0] == 'w') contents().clear(); + else if (!sd_exists(path)) return false; + return true; + } + void close() {} + uint64_t size() const { return contents().size(); } + bool seek(uint64_t offset) + { + offset_ = static_cast(offset); + return offset <= contents().size(); + } + int read(void* output, size_t length) + { + if (fake_read_failure || offset_ > contents().size() || length > contents().size() - offset_) return -1; + std::memcpy(output, contents().data() + offset_, length); + offset_ += length; + return static_cast(length); + } + int read_byte() + { + uint8_t byte; + return read(&byte, 1) == 1 ? byte : -1; + } + size_t write(const void* bytes, size_t length) + { + if (fake_write_failure) return 0; + if (offset_ + length > contents().size()) contents().resize(offset_ + length); + std::memcpy(contents().data() + offset_, bytes, length); + offset_ += length; + return length; + } + bool flush() { return !fake_write_failure; } +}; +} // namespace platform::esp::arduino_common::storage diff --git a/tests/hostlink/fakes/platform/esp/common/mbedtls_sha256_compat.h b/tests/hostlink/fakes/platform/esp/common/mbedtls_sha256_compat.h new file mode 100644 index 00000000..4043bea2 --- /dev/null +++ b/tests/hostlink/fakes/platform/esp/common/mbedtls_sha256_compat.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +// Deliberately NOT cryptography. Tests control storage/dispatch and hash-binding +// branches; SHA-256 interoperability requires the real target crypto backend. +struct mbedtls_sha256_context +{ + uint8_t state[32]{}; + size_t bytes = 0; +}; +inline void mbedtls_sha256_init(mbedtls_sha256_context*) {} +inline void mbedtls_sha256_free(mbedtls_sha256_context*) {} +namespace platform::esp::common::crypto +{ +inline int sha256_starts(mbedtls_sha256_context*, int) { return 0; } +inline int sha256_update(mbedtls_sha256_context* context, const uint8_t* bytes, size_t length) +{ + for (size_t i = 0; i < length; ++i) context->state[(context->bytes++) % 32] ^= bytes[i]; + return 0; +} +inline int sha256_finish(mbedtls_sha256_context* context, uint8_t out[32]) +{ + for (size_t i = 0; i < 32; ++i) out[i] = context->state[i]; + return 0; +} +} // namespace platform::esp::common::crypto diff --git a/tests/hostlink/fixtures/png-cache/README.md b/tests/hostlink/fixtures/png-cache/README.md new file mode 100644 index 00000000..e0a1359c --- /dev/null +++ b/tests/hostlink/fixtures/png-cache/README.md @@ -0,0 +1,16 @@ +# Image cache fixtures + +These fixtures contain original, programmatically generated pixels, with no +external image content. `generate.py` reproduces them using Pillow and Python +zlib; regeneration is not required to run native tests. + +- `red-16x8.jpg`: solid red RGB, JPEG quality 95, no chroma subsampling, for + the actual tiny JPEG decoder and BGR cache regression. +- `rgba-96x96.png`: Pillow PNG encoder, adaptive row filters and compressed IDAT. +- `fixed-96x96.png`: the same pixels with unfiltered rows and fixed-Huffman zlib. + +For both PNG files, pixel `(x,y)` is +`((x*5)%256, (y*7)%256, ((x+y)*13)%256, [0,128,255][(x+y)%3])`. +Native tests compute expected colors from this definition before comparing every +BMP pixel. Additional fixtures, including all legal color/depth/Adam7/filter +combinations and malformed streams, are generated in memory by the native test. diff --git a/tests/hostlink/fixtures/png-cache/fixed-96x96.png b/tests/hostlink/fixtures/png-cache/fixed-96x96.png new file mode 100644 index 00000000..d26cc606 Binary files /dev/null and b/tests/hostlink/fixtures/png-cache/fixed-96x96.png differ diff --git a/tests/hostlink/fixtures/png-cache/generate.py b/tests/hostlink/fixtures/png-cache/generate.py new file mode 100644 index 00000000..ae14eb67 --- /dev/null +++ b/tests/hostlink/fixtures/png-cache/generate.py @@ -0,0 +1,23 @@ +"""Generate original solid JPEG and compressed PNG regression fixtures.""" +from pathlib import Path +import struct +import zlib +from PIL import Image + +root = Path(__file__).resolve().parent +Image.new("RGB", (16, 8), (255, 0, 0)).save(root / "red-16x8.jpg", quality=95, subsampling=0) +width = height = 96 +pixels = bytes(channel for y in range(height) for x in range(width) + for channel in ((x * 5) & 255, (y * 7) & 255, ((x + y) * 13) & 255, + (0, 128, 255)[(x + y) % 3])) +Image.frombytes("RGBA", (width, height), pixels).save(root / "rgba-96x96.png", compress_level=6) + +def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + +raw = b"".join(b"\0" + pixels[y * width * 4:(y + 1) * width * 4] for y in range(height)) +compressor = zlib.compressobj(level=6, strategy=zlib.Z_FIXED) +compressed = compressor.compress(raw) + compressor.flush() +png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")) +(root / "fixed-96x96.png").write_bytes(png) diff --git a/tests/hostlink/fixtures/png-cache/red-16x8.jpg b/tests/hostlink/fixtures/png-cache/red-16x8.jpg new file mode 100644 index 00000000..418e415d Binary files /dev/null and b/tests/hostlink/fixtures/png-cache/red-16x8.jpg differ diff --git a/tests/hostlink/fixtures/png-cache/rgba-96x96.png b/tests/hostlink/fixtures/png-cache/rgba-96x96.png new file mode 100644 index 00000000..c45fe3ce Binary files /dev/null and b/tests/hostlink/fixtures/png-cache/rgba-96x96.png differ diff --git a/tests/hostlink/identity-restore-fakes/chat/ports/i_mesh_adapter.h b/tests/hostlink/identity-restore-fakes/chat/ports/i_mesh_adapter.h new file mode 100644 index 00000000..02d903b1 --- /dev/null +++ b/tests/hostlink/identity-restore-fakes/chat/ports/i_mesh_adapter.h @@ -0,0 +1,22 @@ +#pragma once +#include "chat/ports/device_identity_restore.h" +#include "hostlink/identity_restore_request.h" +#include "hostlink/radio_identity_backup.h" +namespace chat +{ +struct IMeshAdapter +{ + unsigned calls = 0; + DeviceIdentityRestoreResult result = DeviceIdentityRestoreResult::CommittedRebootRequired; + DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView&, uint8_t*, size_t) + { + ++calls; + return result; + } + DeviceIdentityRestoreResult restoreRadioIdentity(const hostlink::management::RadioIdentityRestoreView&, uint8_t*, size_t) + { + ++calls; + return result; + } +}; +} // namespace chat diff --git a/tests/hostlink/identity-restore-fakes/esp_heap_caps.h b/tests/hostlink/identity-restore-fakes/esp_heap_caps.h new file mode 100644 index 00000000..f1daf323 --- /dev/null +++ b/tests/hostlink/identity-restore-fakes/esp_heap_caps.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +#include +constexpr unsigned MALLOC_CAP_SPIRAM=1,MALLOC_CAP_8BIT=2; +namespace restore_fake { inline bool allocation_fails=false;inline size_t allocation_size=0;inline unsigned allocations=0,frees=0; } +inline void* heap_caps_malloc(size_t size,unsigned flags){assert(flags==3);if(restore_fake::allocation_fails)return nullptr;restore_fake::allocation_size=size;++restore_fake::allocations;return std::malloc(size);} +inline void heap_caps_free(void* ptr){for(size_t i=0;i(ptr)[i]==0);++restore_fake::frees;std::free(ptr);} diff --git a/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/geocaching/browse_runtime.h b/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/geocaching/browse_runtime.h new file mode 100644 index 00000000..24596b24 --- /dev/null +++ b/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/geocaching/browse_runtime.h @@ -0,0 +1,6 @@ +#pragma once +namespace restore_fake { inline bool admitted=true,dropped=false; } +namespace platform::esp::arduino_common::geocaching::browse_runtime +{ +inline bool withIdentityMaintenance(bool(*action)(void*),void* context){if(!restore_fake::admitted)return false;restore_fake::dropped=action(context);return true;} +} diff --git a/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/hostlink/web_config_upload.h b/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/hostlink/web_config_upload.h new file mode 100644 index 00000000..a08684c8 --- /dev/null +++ b/tests/hostlink/identity-restore-fakes/platform/esp/arduino_common/hostlink/web_config_upload.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include +namespace restore_fake { inline bool loaded=false,journal_exists=false;inline uint8_t phase=0,kind=1; } +namespace platform::esp::arduino_common::storage { inline bool sd_exists(const char*){return restore_fake::journal_exists;} } +namespace hostlink::management::web_config +{ +constexpr const char *kMetaPath="meta",*kMetaNew="new",*kMetaBackup="backup"; +struct Metadata{uint8_t owner[16]{},id[16]{},hash[32]{},base_hash[32]{};uint32_t total=0,offset=0;uint8_t phase=0,kind=1;}; +inline bool load(Metadata& meta,uint8_t* work){std::memset(work,89,111);meta.phase=restore_fake::phase;meta.kind=restore_fake::kind;return restore_fake::loaded;} +} diff --git a/tests/hostlink/identity_validation_unused_rng.cpp b/tests/hostlink/identity_validation_unused_rng.cpp new file mode 100644 index 00000000..032a181b --- /dev/null +++ b/tests/hostlink/identity_validation_unused_rng.cpp @@ -0,0 +1,9 @@ +// Curve25519.cpp also defines dh1(), whose hardware RNG is not used by eval(). +// Windows linkers still require those definitions. Fail if the test ever calls +// that path; do not substitute fake random bytes into a cryptographic test. +#include +#include +RNGClass::RNGClass() {} +RNGClass::~RNGClass() {} +void RNGClass::rand(uint8_t*, size_t) { std::abort(); } +RNGClass RNG; diff --git a/tests/hostlink/image-source-fakes/esp_heap_caps.h b/tests/hostlink/image-source-fakes/esp_heap_caps.h new file mode 100644 index 00000000..e139a96f --- /dev/null +++ b/tests/hostlink/image-source-fakes/esp_heap_caps.h @@ -0,0 +1,28 @@ +#pragma once +#include +constexpr unsigned MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_INTERNAL = 2, MALLOC_CAP_8BIT = 4; +namespace image_fake +{ +inline bool psram = true, allocation_fail = false; +inline unsigned allocations = 0, live = 0, last_caps = 0; +inline unsigned internal_allocations = 0, fail_at = 0; +} // namespace image_fake +inline size_t heap_caps_get_total_size(unsigned caps) { return caps == MALLOC_CAP_SPIRAM && image_fake::psram ? 8192 : 0; } +inline void* heap_caps_malloc(size_t bytes, unsigned caps) +{ + ++image_fake::allocations; + if (caps & MALLOC_CAP_INTERNAL) ++image_fake::internal_allocations; + image_fake::last_caps = caps; + if (image_fake::allocation_fail || image_fake::allocations == image_fake::fail_at) return nullptr; + auto* p = std::malloc(bytes); + if (p) ++image_fake::live; + return p; +} +inline void heap_caps_free(void* p) +{ + if (p) + { + --image_fake::live; + std::free(p); + } +} diff --git a/tests/hostlink/image-source-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/hostlink/image-source-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h new file mode 100644 index 00000000..e0f0753b --- /dev/null +++ b/tests/hostlink/image-source-fakes/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -0,0 +1,134 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +namespace image_fake +{ +inline uint32_t media = 1; +inline bool ready = true, busy = false, flush_fail = false, write_fail = false, rename_fail = false, open_fail = false; +inline std::map> files; +inline unsigned probes = 0, reads = 0, writes = 0, removes = 0, renames = 0; +inline void reset() +{ + media = 1; + ready = true; + busy = false; + flush_fail = false; + write_fail = false; + rename_fail = false; + open_fail = false; + files.clear(); + probes = reads = writes = removes = renames = 0; +} +} // namespace image_fake +namespace platform::esp::arduino_common::storage +{ +enum class SdRuntimeMemoryPolicy +{ + Default, + PsramOnly +}; +enum class SdFileReadStatus +{ + Ready, + Missing, + Busy, + Unavailable, + IoError, + Invalid +}; +struct SdFileReadResult +{ + SdFileReadStatus status; +}; +inline bool sd_card_ready() { return image_fake::ready; } +inline uint32_t sd_media_session() { return image_fake::media; } +inline bool sd_external_block_owner_active() { return image_fake::busy; } +inline SdFileReadResult sd_read_file(const char* path, uint8_t* output, size_t capacity) +{ + ++image_fake::probes; + if (!image_fake::ready) return {SdFileReadStatus::Unavailable}; + if (image_fake::busy) return {SdFileReadStatus::Busy}; + auto it = image_fake::files.find(path); + if (it == image_fake::files.end()) return {SdFileReadStatus::Missing}; + if (it->second.empty() || it->second.size() > capacity) return {SdFileReadStatus::Invalid}; + if (capacity && !it->second.empty()) *output = it->second[0]; + return {SdFileReadStatus::Ready}; +} +inline bool sd_remove(const char* path, uint32_t expected) +{ + ++image_fake::removes; + return image_fake::ready && expected == image_fake::media && image_fake::files.erase(path) == 1; +} +inline bool sd_rename(const char* from, const char* to, uint32_t expected) +{ + ++image_fake::renames; + if (image_fake::rename_fail || !image_fake::ready || expected != image_fake::media || !image_fake::files.count(from) || image_fake::files.count(to)) return false; + image_fake::files[to] = std::move(image_fake::files[from]); + image_fake::files.erase(from); + return true; +} +class SdRuntimeFile +{ + public: + explicit SdRuntimeFile(SdRuntimeMemoryPolicy policy = SdRuntimeMemoryPolicy::Default) : policy_(policy) {} + ~SdRuntimeFile() { close(); } + bool has_backing_storage() const { return true; } + bool open(const char* path, const char* mode, uint32_t expected = 0) + { + if (!expected) expected = image_fake::media; + if (opened_ || !image_fake::ready || image_fake::busy || image_fake::open_fail || expected != image_fake::media) return false; + assert(policy_ == (image_fake::psram ? SdRuntimeMemoryPolicy::PsramOnly : SdRuntimeMemoryPolicy::Default)); + path_ = path; + media_ = expected; + offset_ = 0; + if (*mode == 'r' && !image_fake::files.count(path_)) return false; + if (*mode == 'w') image_fake::files[path_].clear(); + opened_ = true; + return true; + } + void close() { opened_ = false; } + bool is_open() const { return opened_; } + bool read_busy() const { return image_fake::busy; } + uint64_t size() const { return opened_ && media_ == image_fake::media ? image_fake::files.at(path_).size() : 0; } + uint64_t position() const { return opened_ ? offset_ : 0; } + bool seek(uint64_t at) + { + if (!opened_ || at > size()) return false; + offset_ = size_t(at); + return true; + } + int read(void* output, size_t count) + { + ++image_fake::reads; + if (!opened_ || image_fake::busy || media_ != image_fake::media) return -1; + const auto& bytes = image_fake::files.at(path_); + count = std::min(count, bytes.size() - offset_); + std::memcpy(output, bytes.data() + offset_, count); + offset_ += count; + return int(count); + } + size_t write(const void* input, size_t count) + { + ++image_fake::writes; + if (!opened_ || image_fake::write_fail || media_ != image_fake::media) return 0; + auto& bytes = image_fake::files.at(path_); + const auto* data = static_cast(input); + bytes.insert(bytes.end(), data, data + count); + return count; + } + bool flush() { return opened_ && !image_fake::flush_fail && media_ == image_fake::media; } + + private: + SdRuntimeMemoryPolicy policy_; + std::string path_; + size_t offset_ = 0; + uint32_t media_ = 0; + bool opened_ = false; +}; +} // namespace platform::esp::arduino_common::storage diff --git a/tests/hostlink/kml-reader-fakes/lvgl.h b/tests/hostlink/kml-reader-fakes/lvgl.h new file mode 100644 index 00000000..ba48c729 --- /dev/null +++ b/tests/hostlink/kml-reader-fakes/lvgl.h @@ -0,0 +1,53 @@ +#pragma once +#include "lvgl_draw.h" +#include +#include +#include +#include +#include +using lv_fs_res_t = unsigned; +constexpr lv_fs_res_t LV_FS_RES_OK = 0, LV_FS_RES_UNKNOWN = 1; +constexpr unsigned LV_FS_MODE_RD = 1; +struct lv_fs_file_t +{ + const std::string* content = nullptr; + size_t offset = 0; + bool open = false; +}; +struct lv_fs_dir_t +{ +}; +namespace kml_reader_fake +{ +inline std::map files; +inline unsigned open_handles = 0, reads = 0; +inline int fail_read = -1; +inline bool close_fail = false; +} // namespace kml_reader_fake +inline lv_fs_res_t lv_fs_open(lv_fs_file_t* file, const char* path, unsigned) +{ + auto found = kml_reader_fake::files.find(path); + if (found == kml_reader_fake::files.end()) return LV_FS_RES_UNKNOWN; + *file = {&found->second, 0, true}; + ++kml_reader_fake::open_handles; + return LV_FS_RES_OK; +} +inline lv_fs_res_t lv_fs_read(lv_fs_file_t* file, void* output, uint32_t capacity, uint32_t* count) +{ + ++kml_reader_fake::reads; + *count = 0; + if (!file->open || static_cast(kml_reader_fake::reads) == kml_reader_fake::fail_read) return LV_FS_RES_UNKNOWN; + *count = static_cast(std::min(capacity, file->content->size() - file->offset)); + std::memcpy(output, file->content->data() + file->offset, *count); + file->offset += *count; + return LV_FS_RES_OK; +} +inline lv_fs_res_t lv_fs_close(lv_fs_file_t* file) +{ + if (!file->open) return LV_FS_RES_UNKNOWN; + file->open = false; + --kml_reader_fake::open_handles; + return kml_reader_fake::close_fail ? LV_FS_RES_UNKNOWN : LV_FS_RES_OK; +} +inline lv_fs_res_t lv_fs_dir_open(lv_fs_dir_t*, const char*) { return LV_FS_RES_UNKNOWN; } +inline lv_fs_res_t lv_fs_dir_close(lv_fs_dir_t*) { return LV_FS_RES_OK; } diff --git a/tests/hostlink/kml-reader-fakes/lvgl_draw.h b/tests/hostlink/kml-reader-fakes/lvgl_draw.h new file mode 100644 index 00000000..adfe5328 --- /dev/null +++ b/tests/hostlink/kml-reader-fakes/lvgl_draw.h @@ -0,0 +1,126 @@ +#pragma once +#include +#include +#include +#include +using lv_coord_t = int32_t; +using lv_color_t = uint32_t; +struct lv_point_t +{ + int32_t x = 0, y = 0; +}; +struct lv_point_precise_t +{ + float x = 0, y = 0; +}; +struct lv_area_t +{ + int32_t x1 = 0, y1 = 0, x2 = 0, y2 = 0; +}; +struct lv_layer_t +{ +}; +struct lv_obj_t; +struct lv_event_t +{ + lv_obj_t* object; + lv_layer_t* layer; + void* user; +}; +using lv_event_cb_t = void (*)(lv_event_t*); +struct lv_obj_t +{ + lv_obj_t* parent = nullptr; + int width = 320, height = 100, x = 0, y = 0, scroll_x = 0, scroll_y = 0; + unsigned flags = 0; + lv_event_cb_t callback = nullptr; + void* user = nullptr; + std::string label; +}; +struct lv_draw_line_dsc_t +{ + struct Base + { + lv_layer_t* layer = nullptr; + } base; + lv_point_precise_t p1, p2; +}; +struct lv_font_t +{ +}; +inline lv_font_t lv_font_montserrat_14; +constexpr unsigned LV_PART_MAIN = 0, LV_EVENT_DRAW_MAIN = 1, LV_OPA_80 = 204; +constexpr unsigned LV_OBJ_FLAG_IGNORE_LAYOUT = 1, LV_OBJ_FLAG_HIDDEN = 2, LV_OBJ_FLAG_SCROLLABLE = 4; +constexpr unsigned LV_LABEL_LONG_DOT = 0, LV_ALIGN_BOTTOM_MID = 0; +inline int LV_PCT(int value) { return -value; } +namespace draw_fake +{ +inline std::vector> objects; +inline std::vector lines; +inline unsigned builtin_points = 0; +inline lv_layer_t layer; +inline void draw(lv_obj_t* object) +{ + lines.clear(); + lv_event_t event{object, &layer, object->user}; + if (object->callback) object->callback(&event); +} +} // namespace draw_fake +inline lv_obj_t* lv_obj_create(lv_obj_t* parent) +{ + auto object = std::make_unique(); + object->parent = parent; + auto* result = object.get(); + draw_fake::objects.push_back(std::move(object)); + return result; +} +inline lv_obj_t* lv_label_create(lv_obj_t* parent) { return lv_obj_create(parent); } +inline lv_obj_t* lv_line_create(lv_obj_t* parent) { return lv_obj_create(parent); } +inline bool lv_obj_is_valid(lv_obj_t* object) { return object != nullptr; } +inline void lv_obj_set_size(lv_obj_t* object, int width, int height) +{ + object->width = width < 0 && object->parent ? -width * object->parent->width / 100 : width; + object->height = height < 0 && object->parent ? -height * object->parent->height / 100 : height; +} +inline void lv_obj_set_pos(lv_obj_t* object, int x, int y) +{ + object->x = x; + object->y = y; +} +inline void lv_obj_set_width(lv_obj_t* object, int width) { object->width = width; } +inline void lv_obj_add_flag(lv_obj_t* object, unsigned flag) { object->flags |= flag; } +inline void lv_obj_clear_flag(lv_obj_t* object, unsigned flag) { object->flags &= ~flag; } +inline lv_color_t lv_color_hex(uint32_t value) { return value; } +inline void lv_obj_set_style_bg_color(lv_obj_t*, lv_color_t, unsigned) {} +inline void lv_obj_set_style_bg_opa(lv_obj_t*, unsigned, unsigned) {} +inline void lv_obj_set_style_border_width(lv_obj_t*, int, unsigned) {} +inline void lv_obj_set_style_border_color(lv_obj_t*, lv_color_t, unsigned) {} +inline void lv_obj_set_style_radius(lv_obj_t*, int, unsigned) {} +inline void lv_obj_set_style_pad_all(lv_obj_t*, int, unsigned) {} +inline void lv_obj_set_style_text_font(lv_obj_t*, const lv_font_t*, unsigned) {} +inline void lv_obj_set_style_text_color(lv_obj_t*, lv_color_t, unsigned) {} +inline void lv_obj_set_style_line_color(lv_obj_t*, lv_color_t, unsigned) {} +inline void lv_obj_set_style_line_width(lv_obj_t*, int, unsigned) {} +inline void lv_obj_set_style_line_rounded(lv_obj_t*, bool, unsigned) {} +inline void lv_label_set_long_mode(lv_obj_t*, unsigned) {} +inline void lv_label_set_text(lv_obj_t* object, const char* text) { object->label = text; } +inline void lv_obj_update_layout(lv_obj_t*) {} +inline lv_coord_t lv_obj_get_content_width(lv_obj_t* object) { return object->width; } +inline lv_obj_t* lv_obj_get_parent(lv_obj_t* object) { return object->parent; } +inline void lv_obj_align(lv_obj_t*, unsigned, int, int) {} +inline void lv_obj_move_foreground(lv_obj_t*) {} +inline void lv_obj_add_event_cb(lv_obj_t* object, lv_event_cb_t callback, unsigned, void* user) +{ + object->callback = callback; + object->user = user; +} +inline void* lv_event_get_current_target(lv_event_t* event) { return event->object; } +inline lv_layer_t* lv_event_get_layer(lv_event_t* event) { return event->layer; } +inline void* lv_event_get_user_data(lv_event_t* event) { return event->user; } +inline void lv_obj_get_coords(lv_obj_t* object, lv_area_t* area) { *area = {object->x, object->y, object->x + object->width - 1, object->y + object->height - 1}; } +inline int lv_obj_get_scroll_x(lv_obj_t* object) { return object->scroll_x; } +inline int lv_obj_get_scroll_y(lv_obj_t* object) { return object->scroll_y; } +inline void lv_draw_line_dsc_init(lv_draw_line_dsc_t* descriptor) { *descriptor = {}; } +inline void lv_obj_init_draw_line_dsc(lv_obj_t*, unsigned, lv_draw_line_dsc_t*) {} +inline void lv_draw_line(lv_layer_t*, const lv_draw_line_dsc_t* descriptor) { draw_fake::lines.push_back(*descriptor); } +inline void lv_line_set_points(lv_obj_t*, const lv_point_precise_t*, uint16_t) { ++draw_fake::builtin_points; } diff --git a/tests/hostlink/meshtastic_module_native_decode.cpp b/tests/hostlink/meshtastic_module_native_decode.cpp new file mode 100644 index 00000000..6767c82d --- /dev/null +++ b/tests/hostlink/meshtastic_module_native_decode.cpp @@ -0,0 +1,32 @@ +#include "meshtastic/localonly.pb.h" +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#endif +int main() +{ +#ifdef _WIN32 + _setmode(_fileno(stdin), _O_BINARY); +#endif + const std::vector input(std::istreambuf_iterator(std::cin), {}); + if (input.size() > meshtastic_LocalModuleConfig_size) return 1; + meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; + auto stream = pb_istream_from_buffer(input.data(), input.size()); + if (!pb_decode(&stream, meshtastic_LocalModuleConfig_fields, &module)) + { + std::cerr << PB_GET_ERROR(&stream); + return 2; + } + // Only synthetic fixture flags/counts. Never print credentials or raw data. + std::cout << "{\"version\":" << module.version + << ",\"mqtt\":" << (module.has_mqtt && module.mqtt.enabled ? 1 : 0) + << ",\"precision\":" << (module.has_mqtt && module.mqtt.has_map_report_settings ? module.mqtt.map_report_settings.position_precision : 0) + << ",\"threshold\":" << (module.has_paxcounter ? module.paxcounter.wifi_threshold : 0) + << ",\"rows\":" << (module.has_remote_hardware ? module.remote_hardware.available_pins_count : 0) + << ",\"gpio\":" << (module.has_remote_hardware && module.remote_hardware.available_pins_count ? unsigned(module.remote_hardware.available_pins[0].gpio_pin) : 0) + << "}"; +} diff --git a/tests/hostlink/owner-fakes/esp_heap_caps.h b/tests/hostlink/owner-fakes/esp_heap_caps.h new file mode 100644 index 00000000..c1cdec73 --- /dev/null +++ b/tests/hostlink/owner-fakes/esp_heap_caps.h @@ -0,0 +1,7 @@ +#pragma once +#include +constexpr unsigned MALLOC_CAP_SPIRAM=1, MALLOC_CAP_8BIT=2; +void* heap_caps_malloc(size_t,unsigned); +void heap_caps_free(void*); +inline size_t heap_caps_get_free_size(unsigned) {return 1048576;} +inline size_t heap_caps_get_largest_free_block(unsigned) {return 1048576;} diff --git a/tests/hostlink/owner-fakes/esp_random.h b/tests/hostlink/owner-fakes/esp_random.h new file mode 100644 index 00000000..9bb58306 --- /dev/null +++ b/tests/hostlink/owner-fakes/esp_random.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include +#include +inline void esp_fill_random(void* output,size_t size) +{ + static uint8_t value=20; + std::memset(output,value++,size); +} diff --git a/tests/hostlink/owner-fakes/esp_timer.h b/tests/hostlink/owner-fakes/esp_timer.h new file mode 100644 index 00000000..3c95bba5 --- /dev/null +++ b/tests/hostlink/owner-fakes/esp_timer.h @@ -0,0 +1,3 @@ +#pragma once +#include +int64_t esp_timer_get_time(); diff --git a/tests/hostlink/png-cache-fakes/freertos/FreeRTOS.h b/tests/hostlink/png-cache-fakes/freertos/FreeRTOS.h new file mode 100644 index 00000000..d92f36ed --- /dev/null +++ b/tests/hostlink/png-cache-fakes/freertos/FreeRTOS.h @@ -0,0 +1,2 @@ +#pragma once +// Native tests do not simulate task scheduling; task.h exposes a yield probe. diff --git a/tests/hostlink/png-cache-fakes/freertos/task.h b/tests/hostlink/png-cache-fakes/freertos/task.h new file mode 100644 index 00000000..e4b0a843 --- /dev/null +++ b/tests/hostlink/png-cache-fakes/freertos/task.h @@ -0,0 +1,3 @@ +#pragma once +void route_png_test_yield(); +inline void taskYIELD() { route_png_test_yield(); } diff --git a/tests/hostlink/png-cache-fakes/pgmspace.h b/tests/hostlink/png-cache-fakes/pgmspace.h new file mode 100644 index 00000000..bcd47f14 --- /dev/null +++ b/tests/hostlink/png-cache-fakes/pgmspace.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include +#define PROGMEM +#define pgm_read_byte(p) (*(const uint8_t*)(p)) +#define pgm_read_word(p) (*(const uint16_t*)(p)) +#define pgm_read_dword(p) (*(const uint32_t*)(p)) +#define memcpy_P memcpy +#define memcmp_P memcmp diff --git a/tests/hostlink/radio-identity-fakes/esp_heap_caps.h b/tests/hostlink/radio-identity-fakes/esp_heap_caps.h new file mode 100644 index 00000000..3dc6c14e --- /dev/null +++ b/tests/hostlink/radio-identity-fakes/esp_heap_caps.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include +#include +#include +constexpr uint32_t MALLOC_CAP_8BIT = 1, MALLOC_CAP_SPIRAM = 2; +namespace radio_heap_fixture +{ +inline size_t external_size = 8 * 1024 * 1024; +inline bool fail = false; +inline unsigned attempts = 0, allocations = 0, frees = 0; +inline uint32_t last_caps = 0; +inline std::map blocks; +} // namespace radio_heap_fixture +inline size_t heap_caps_get_total_size(uint32_t) { return radio_heap_fixture::external_size; } +inline void* heap_caps_malloc(size_t size, uint32_t caps) +{ + using namespace radio_heap_fixture; + ++attempts; + last_caps = caps; + if (fail) return nullptr; + auto* memory = std::malloc(size); + if (memory) + { + blocks[memory] = size; + ++allocations; + } + return memory; +} +inline void heap_caps_free(void* memory) +{ + if (!memory) return; + using namespace radio_heap_fixture; + const auto size = blocks.at(memory); + for (size_t i = 0; i < size; ++i) assert(!static_cast(memory)[i]); + blocks.erase(memory); + ++frees; + std::free(memory); +} diff --git a/tests/hostlink/radio-identity-fakes/esp_random.h b/tests/hostlink/radio-identity-fakes/esp_random.h new file mode 100644 index 00000000..48f488b9 --- /dev/null +++ b/tests/hostlink/radio-identity-fakes/esp_random.h @@ -0,0 +1,12 @@ +#pragma once +#include +namespace radio_random_fixture +{ +inline unsigned calls = 0; +} +inline uint32_t esp_random() +{ + // Deterministic source only for production initialization control-flow tests. + // Never evidence of hardware entropy or a real secret-generation guarantee. + return (++radio_random_fixture::calls * 0x9e3779b9u) ^ 0x7f4a7c15u; +} diff --git a/tests/hostlink/sd-handle-fakes/SdFatConfig.h b/tests/hostlink/sd-handle-fakes/SdFatConfig.h new file mode 100644 index 00000000..89ff7eda --- /dev/null +++ b/tests/hostlink/sd-handle-fakes/SdFatConfig.h @@ -0,0 +1,2 @@ +#pragma once +#define DESTRUCTOR_CLOSES_FILE 0 diff --git a/tests/hostlink/snapshot_hash_config.h b/tests/hostlink/snapshot_hash_config.h new file mode 100644 index 00000000..40c57e42 --- /dev/null +++ b/tests/hostlink/snapshot_hash_config.h @@ -0,0 +1,8 @@ +#pragma once +// Native verification uses the real SDK Mbed TLS implementation with a +// minimal software SHA-256 configuration, not the fake dispatch hash. +#define MBEDTLS_SHA256_C +#define MBEDTLS_PLATFORM_C +#if defined(_WIN32) +#define MBEDTLS_PLATFORM_ZEROIZE_ALT +#endif diff --git a/tests/hostlink/test_arduino_config_loop.cpp b/tests/hostlink/test_arduino_config_loop.cpp new file mode 100644 index 00000000..172f2a4d --- /dev/null +++ b/tests/hostlink/test_arduino_config_loop.cpp @@ -0,0 +1,295 @@ +#include "ui/loop_shell.h" + +#include +#include +#include +#include + +namespace +{ +uint32_t clock_now = 100; +bool overlay_active = false, saver_active = false; +std::vector trace; +unsigned sync_count = 0; +bool sync_success = true; +struct Restarted +{ +}; +} // namespace + +uint32_t millis() { return clock_now; } +bool ui_is_overlay_active() { return overlay_active; } +void yield() { trace.push_back('y'); } +void delay(uint32_t) {} + +namespace platform::ui::device +{ +void restart() +{ + trace.push_back('r'); + throw Restarted{}; +} +} // namespace platform::ui::device + +namespace app::sd_tms +{ +uint32_t s_web_restart_at = 0, s_next_sync_attempt_ms = 0; +bool s_sync_pending = false, s_repair_required = false; +int config = 0; +int* s_working_config = &config; +enum class WorkingConfigSyncResult +{ + Synchronized, + Failed +}; +WorkingConfigSyncResult syncPendingWorkingConfig() +{ + ++sync_count; + trace.push_back('s'); + if (sync_success) + { + s_sync_pending = false; + return WorkingConfigSyncResult::Synchronized; + } + return WorkingConfigSyncResult::Failed; +} +#include "arduino_service_actual.inc" +#include "identity_restart_schedule_actual.inc" +} // namespace app::sd_tms + +namespace app +{ +struct IAppLifecycleFacade +{ + void updateCoreServices() { trace.push_back('c'); } + void tickEventRuntime() { trace.push_back('e'); } + void dispatchPendingEvents(std::size_t) { trace.push_back('d'); } +}; +IAppLifecycleFacade& lifecycleFacade() +{ + static IAppLifecycleFacade facade; + return facade; +} +} // namespace app + +namespace platform::esp::arduino_common +{ +void tickBoundLifecycle(std::size_t max_events = 4); +// Compile the actual lifecycle too, so moving service delivery cannot leave a +// duplicate call that advances SD synchronization twice in one main-loop turn. +#include "arduino_lifecycle_actual.inc" +namespace storage +{ +void tick_deferred_storage() { trace.push_back('t'); } +} // namespace storage +namespace display_runtime +{ +void tickIfDue(uint32_t) { trace.push_back('v'); } +} // namespace display_runtime +} // namespace platform::esp::arduino_common + +namespace platform::ui::screen +{ +bool is_saver_active() { return saver_active; } +} // namespace platform::ui::screen +namespace platform::esp::boards +{ +struct Board +{ + void handlePowerButton() {} +}; +struct Handles +{ + Board* board; +}; +Handles resolveAppContextInitHandles() { return {nullptr}; } +} // namespace platform::esp::boards + +namespace usb_cdc +{ +bool connected = true; +bool start() { return true; } +bool is_connected() { return connected; } +} // namespace usb_cdc +namespace hostlink +{ +bool active = true, schedule_next = false; +void tick_publication_tasks() { trace.push_back('p'); } +bool is_active() { return active; } +void start() { active = true; } +void stop() { active = false; } +void process_pending_commands() +{ + trace.push_back('h'); + if (schedule_next) + { + schedule_next = false; + app::sd_tms::scheduleWebManagementRestart(); + } +} +} // namespace hostlink + +namespace ui::widgets::reticulum_call_overlay +{ +void tick() {} +} // namespace ui::widgets::reticulum_call_overlay +namespace trailmate::apps::esp32_lvgl::arduino_agenda +{ +void tick() { trace.push_back('a'); } +} // namespace trailmate::apps::esp32_lvgl::arduino_agenda +namespace trailmate::apps::esp32_lvgl::arduino_app_runtime_access +{ +struct Status +{ + bool app_context_bound = true; +} s_status; +bool s_was_screen_saver_active = false; +uint32_t s_wake_gate_until_ms = 0; +bool wake_gate_active(uint32_t now_ms) +{ + return s_wake_gate_until_ms != 0 && + static_cast(s_wake_gate_until_ms - now_ms) > 0; +} +#include "arduino_app_actual.inc" +} // namespace trailmate::apps::esp32_lvgl::arduino_app_runtime_access +namespace trailmate::apps::esp32_lvgl::arduino_loop_runtime +{ +void log_loop_interval(uint32_t) {} +#include "arduino_loop_actual.inc" +} // namespace trailmate::apps::esp32_lvgl::arduino_loop_runtime + +namespace +{ +void reset() +{ + clock_now = 100; + overlay_active = saver_active = false; + trace.clear(); + sync_count = 0; + sync_success = true; + app::sd_tms::s_web_restart_at = app::sd_tms::s_next_sync_attempt_ms = 0; + app::sd_tms::s_sync_pending = app::sd_tms::s_repair_required = false; + app::sd_tms::s_working_config = &app::sd_tms::config; + using namespace trailmate::apps::esp32_lvgl::arduino_app_runtime_access; + s_status.app_context_bound = true; + s_was_screen_saver_active = false; + s_wake_gate_until_ms = 0; + usb_cdc::connected = hostlink::active = true; + hostlink::schedule_next = false; +} +void tick() { trailmate::apps::esp32_lvgl::arduino_loop_runtime::tick(); } +bool has(char event) +{ + for (char value : trace) + if (value == event) return true; + return false; +} +void expect_restart() +{ + trace.clear(); + bool restarted = false; + try + { + tick(); + } + catch (const Restarted&) + { + restarted = true; + } + assert(restarted && has('r') && !has('a')); +} +} // namespace + +int main() +{ + // A save pending before the screen saver must still be synchronized. A + // failing storage attempt has its existing 1 s retry budget while sleeping. + reset(); + saver_active = true; + app::sd_tms::s_sync_pending = true; + sync_success = false; + tick(); + assert(sync_count == 1 && !has('c') && has('v')); + clock_now = 1099; + tick(); + assert(sync_count == 1); + clock_now = 1100; + sync_success = true; + tick(); + assert(sync_count == 2 && !app::sd_tms::s_sync_pending); + + // A commit received while sleeping retains the complete 2 s ACK window, + // then restarts without a physical screen wake or another serial request. + reset(); + saver_active = true; +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + hostlink::schedule_next = true; + tick(); + assert(has('h')); +#else + app::sd_tms::scheduleWebManagementRestart(); + tick(); +#endif + assert(app::sd_tms::s_web_restart_at == 2100 && !has('r')); + clock_now = 2099; + tick(); + assert(!has('r')); + clock_now = 2100; + expect_restart(); + assert(!has('c') && sync_count == 0); + + // An overlay bypasses the app runtime; neither pending synchronization nor + // an already accepted restart may depend on dismissing that overlay. + reset(); + overlay_active = true; + app::sd_tms::s_sync_pending = true; + tick(); + assert(sync_count == 1 && !has('t') && !has('c') && has('v')); + app::sd_tms::scheduleWebManagementRestart(); + clock_now = 2100; + expect_restart(); + + // The post-saver 750 ms gate still pauses normal core/UI work, but does + // not postpone configuration service or a due management restart. + reset(); + saver_active = true; + tick(); + saver_active = false; + clock_now = 200; + tick(); + app::sd_tms::s_sync_pending = true; + clock_now = 300; + trace.clear(); + tick(); + assert(sync_count == 1 && !has('c')); + app::sd_tms::s_web_restart_at = 301; + clock_now = 301; + expect_restart(); + + reset(); + saver_active = true; + app::sd_tms::scheduleWebManagementRestart(); + usb_cdc::connected = false; + clock_now = 2100; + expect_restart(); +#if defined(TRAIL_MATE_USB_HOSTLINK_AUTOSTART) + assert(!hostlink::active); +#endif + + reset(); + app::sd_tms::s_sync_pending = true; + tick(); + assert(sync_count == 1 && has('c') && has('e') && has('d')); + + // Deadline arithmetic remains correct across the 32-bit millisecond wrap. + reset(); + saver_active = true; + clock_now = UINT32_MAX - 1999; + app::sd_tms::scheduleWebManagementRestart(); + assert(app::sd_tms::s_web_restart_at == 1); + clock_now = 0; + tick(); + assert(!has('r')); + clock_now = 1; + expect_restart(); +} diff --git a/tests/hostlink/test_attachment_index_stream.cpp b/tests/hostlink/test_attachment_index_stream.cpp new file mode 100644 index 00000000..af704367 --- /dev/null +++ b/tests/hostlink/test_attachment_index_stream.cpp @@ -0,0 +1,181 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/attachment_index_file_check.h" +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +static uint8_t window[4352], kml_hash[32] = {1}; +static track_sharing::AttachmentFingerprint fingerprints[64]; +static std::vector fixture(unsigned count = 3, size_t reference_bytes = 30, bool duplicate = false) +{ + std::vector raw(track_sharing::kMaxAttachmentIndexBytes); + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(4) && writer.unsignedInteger(1) && writer.binary({kml_hash, 32}) && writer.unsignedInteger(300) && writer.array(count)); + uint8_t hash[32] = {7}; + for (unsigned i = 0; i < count; ++i) + { + const auto id = duplicate && i == 1 ? "IMG0" : "img" + std::to_string(i); + auto reference = "photos/" + std::to_string(i) + "/"; + reference.append(reference_bytes > reference.size() ? reference_bytes - reference.size() : 0, 'x'); + assert(writer.array(7) && writer.text(id) && writer.binary({hash, 32}) && writer.unsignedInteger(100) && + writer.text("image/png") && writer.text(reference) && writer.unsignedInteger(0) && writer.unsignedInteger(i % 2)); + } + raw.resize(writer.size()); + return raw; +} +static bool stream(const std::vector& raw, size_t chunk, unsigned count, unsigned& entries) +{ + track_sharing::AttachmentIndexStream parser(window, sizeof(window), fingerprints, 64, {kml_hash, 32}, 300, static_cast(raw.size()), static_cast(count)); + size_t offset = 0; + entries = 0; + while (offset < raw.size()) + { + size_t consumed = 0; + const auto result = parser.feed(raw.data() + offset, std::min(chunk, raw.size() - offset), consumed); + offset += consumed; + if (result == track_sharing::AttachmentStreamResult::Invalid) return false; + if (result == track_sharing::AttachmentStreamResult::Entry) + { + const auto* entry = parser.entry(); + assert(entry && entry->id == "img" + std::to_string(entries)); + size_t paused = 99; + assert(parser.feed(nullptr, 0, paused) == result && paused == 0 && parser.entry() == entry); + ++entries; + assert(parser.advance() && !parser.entry()); + } + else assert(consumed > 0); + } + assert(parser.packageBytes() == raw.size() + 300 + entries * 100); + return parser.finish(); +} +struct Io +{ + enum class Mode + { + Read + }; + struct OpenResult + { + void* handle; + }; + std::vector bytes; + bool opened = false, fail_open = false, fail_close = false, fail_read = false, fail_size = false; + size_t offset = 0, opens = 0, reads = 0; + OpenResult open(const char*, Mode) + { + assert(!opened); + ++opens; + if (fail_open) return {nullptr}; + opened = true; + return {this}; + } + bool close(void*) + { + assert(opened); + opened = false; + return !fail_close; + } + bool size(void*, uint64_t& value) + { + value = bytes.size() + (fail_size ? 1 : 0); + return true; + } + bool seek(void*, uint64_t value) + { + offset = static_cast(value); + return offset <= bytes.size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + assert(opened && length <= 320 && offset + length <= bytes.size()); + ++reads; + if (fail_read) return 0; + std::memcpy(output, bytes.data() + offset, length); + return length; + } +}; +int main() +{ + unsigned entries = 0; + const auto small = fixture(); + for (size_t chunk = 1; chunk <= small.size(); ++chunk) assert(stream(small, chunk, 3, entries) && entries == 3); + const auto large = fixture(64, 4096); + for (size_t chunk : {size_t(1), size_t(17), size_t(320), size_t(4096), large.size()}) assert(stream(large, chunk, 64, entries) && entries == 64); + assert(!stream(fixture(3, 30, true), 1, 3, entries)); + auto trailing = small; + trailing.push_back(0); + assert(!stream(trailing, 320, 3, entries)); + auto truncated = small; + truncated.pop_back(); + assert(!stream(truncated, 1, 3, entries)); + auto wrong_header = small; + wrong_header[4] ^= 1; + assert(!stream(wrong_header, 1, 3, entries)); + assert(!stream(small, 1, 2, entries)); + auto wrong_scalar = small; + wrong_scalar[wrong_scalar.size() - 1] = 0xc3; + assert(!stream(wrong_scalar, 1, 3, entries)); + track_sharing::AttachmentIndexStream no_window(nullptr, 0, fingerprints, 64, {kml_hash, 32}, 300, small.size(), 3); + assert(!no_window.admitted() && !no_window.finish()); + track_sharing::AttachmentIndexStream alias(window, sizeof(window), reinterpret_cast(window), 64, {kml_hash, 32}, 300, small.size(), 3); + assert(!alias.admitted()); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t digest[32]; + assert(hash.start() && hash.update(large.data(), large.size()) && hash.finish(digest)); + hash.reset(); + const track_sharing::AttachmentIndexBinding binding{{digest, 32}, static_cast(large.size()), 64}; + using Check = track_sharing::AttachmentIndexFileCheck; + using Result = track_sharing::AttachmentIndexFileResult; + static_assert(sizeof(Check) <= 768, "Index job must remain a small admitted workspace"); + for (unsigned fault = 0; fault < 6; ++fault) + { + Io io; + io.bytes = large; + io.fail_open = fault == 1; + io.fail_close = fault == 2; + io.fail_read = fault == 3; + io.fail_size = fault == 4; + if (fault == 5) io.bytes[io.bytes.size() - 2] = 1; // role still valid, wrong digest + Check check(io, hash, window, sizeof(window), fingerprints, 64, binding, {kml_hash, 32}, 300); + assert(check.begin("index") == Result::Pending && check.begin("index") == Result::Busy); + Result result; + unsigned seen = 0; + do + { + result = check.step(); + assert(!io.opened); + if (result == Result::Entry) + { + assert(!check.ready() && check.entry()); + const auto reads = io.reads; + assert(check.step() == Result::Entry && io.reads == reads); + ++seen; + assert(check.advance()); + } + } while (result == Result::Pending || result == Result::Entry); + assert(result == (fault == 0 ? Result::Ready : fault == 5 ? Result::HashMismatch + : Result::StorageFailure)); + if (!fault) + { + assert(seen == 64 && check.ready() && check.matchesSource("index", binding)); + assert(!check.matchesSource("other", binding)); + } + else assert(!check.ready()); + } + Io io; + io.bytes = large; + Check cancelled(io, hash, window, sizeof(window), fingerprints, 64, binding, {kml_hash, 32}, 300); + assert(cancelled.begin("index") == Result::Pending && cancelled.step() == Result::Pending); + assert(cancelled.cancel() == Result::Cancelled && !cancelled.ready() && !io.opened); + Check invalid(io, hash, nullptr, 0, fingerprints, 64, binding, {kml_hash, 32}, 300); + const auto before = io.opens; + assert(invalid.begin("index") != Result::Pending && invalid.step() == Result::Invalid && io.opens == before); +} diff --git a/tests/hostlink/test_ble_preferences_read.cpp b/tests/hostlink/test_ble_preferences_read.cpp new file mode 100644 index 00000000..0b688472 --- /dev/null +++ b/tests/hostlink/test_ble_preferences_read.cpp @@ -0,0 +1,192 @@ +#include "chat/ble/meshtastic_defaults.h" +#include "chat/ble/meshtastic_phone_config_bridge.h" +#include "platform/ui/settings_store.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using Result = platform::ui::settings_store::ScalarReadResult; +std::map values; +std::string failed; +Result failure = Result::Ok; +platform::ui::settings_store::BlobReadResult blob_result = platform::ui::settings_store::BlobReadResult::Missing; +std::size_t module_size = 0; +meshtastic_LocalModuleConfig stored_module = meshtastic_LocalModuleConfig_init_zero; +unsigned default_initializations = 0, normalizations = 0; +std::string key_for(const char* ns, const char* key) { return std::string(ns) + "/" + key; } +Result fetch(const char* ns, const char* key, uint32_t& value) +{ + value = 0; + const auto id = key_for(ns, key); + if (id == failed) return failure; + const auto found = values.find(id); + if (found == values.end()) return Result::Missing; + value = found->second; + return Result::Ok; +} +} // namespace +namespace platform::ui::settings_store +{ +ScalarReadResult read_uint_into(const char* ns, const char* key, uint32_t* out) { return fetch(ns, key, *out); } +ScalarReadResult read_u8_into(const char* ns, const char* key, uint8_t* out) +{ + uint32_t value; + auto result = fetch(ns, key, value); + *out = 0; + if (result != Result::Ok) return result; + if (value > 255) return Result::Invalid; + *out = static_cast(value); + return Result::Ok; +} +ScalarReadResult read_bool_into(const char* ns, const char* key, bool* out) +{ + uint32_t value; + auto result = fetch(ns, key, value); + *out = false; + if (result != Result::Ok) return result; + if (value > 1) return Result::Invalid; + *out = value != 0; + return Result::Ok; +} +BlobReadResult read_blob_into(const char*, const char*, void* out, std::size_t capacity, std::size_t* length) +{ + *length = 0; + if (blob_result != BlobReadResult::Ok) return blob_result; + if (module_size > capacity) return BlobReadResult::Invalid; + std::memcpy(out, &stored_module, module_size); + *length = module_size; + return BlobReadResult::Ok; +} +} // namespace platform::ui::settings_store +namespace platform::esp::common +{ +class ConfigPreferences +{ + const char* owner_ = nullptr; + + public: + bool begin(const char* owner, bool) + { + owner_ = owner; + return true; + } + void end() { owner_ = nullptr; } + uint32_t getUInt(const char* key, uint32_t fallback = 0) + { + uint32_t value; + return fetch(owner_, key, value) == Result::Ok ? value : fallback; + } + uint8_t getUChar(const char* key, uint8_t fallback = 0) { return static_cast(getUInt(key, fallback)); } + bool getBool(const char* key, bool fallback = false) { return getUInt(key, fallback) != 0; } + std::size_t getBytes(const char*, void* out, std::size_t capacity) + { + std::size_t size = 0; + return ::platform::ui::settings_store::read_blob_into(owner_, "cfg", out, capacity, &size) == ::platform::ui::settings_store::BlobReadResult::Ok ? size : 0; + } +}; +} // namespace platform::esp::common +namespace platform::esp::arduino_common::device_identity +{ +uint32_t getSelfNodeId() +{ + ++default_initializations; + return 0x102030; +} +} // namespace platform::esp::arduino_common::device_identity +namespace +{ +constexpr const char* kMtBleNamespace = "mt_ble"; +constexpr const char* kMcBleNamespace = "mc_ble"; +constexpr const char* kMtModuleNamespace = "mt_mod"; +constexpr const char* kModuleBlobKey = "cfg"; +struct State +{ + meshtastic_Config_BluetoothConfig mt_ble = meshtastic_Config_BluetoothConfig_init_zero; + uint32_t mc_pin = 0; + bool mc_manual_add = false; + uint8_t mc_telemetry_base = 0, mc_telemetry_loc = 0, mc_telemetry_env = 0, mc_advert_location = 0; + meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; +} state; +State* s_state_storage = &state; +#define s_state (*s_state_storage) +#include "ble_defaults_actual.inc" + +bool valid_mc_pin(uint32_t pin) +{ + return pin == 0 || (pin >= 100000 && pin <= 999999); +} +#include "ble_load_actual.inc" +#undef s_state +void reset_fixture() +{ + values = {{"mt_ble/enabled", 0}, {"mt_ble/mode", 1}, {"mt_ble/pin", 123456}, {"mc_ble/pin", 654321}, {"mc_ble/manual_add", 0}, {"mc_ble/telem_base", 1}, {"mc_ble/telem_loc", 2}, {"mc_ble/telem_env", 3}, {"mc_ble/advert_loc", 255}}; + failed.clear(); + failure = Result::Ok; + blob_result = platform::ui::settings_store::BlobReadResult::Ok; + module_size = sizeof(stored_module); + std::memset(&stored_module, 0, sizeof(stored_module)); + stored_module.version = ble::meshtastic_defaults::kModuleConfigVersion; + stored_module.has_serial = true; + stored_module.serial.enabled = true; + stored_module.serial.baud = meshtastic_ModuleConfig_SerialConfig_Serial_Baud_BAUD_9600; + default_initializations = 0; +} +} // namespace +int main() +{ + reset_fixture(); + assert(load_preferences()); + assert(!state.mt_ble.enabled && state.mt_ble.fixed_pin == 123456 && state.mc_pin == 654321); + assert(state.mc_telemetry_env == 3 && state.mc_advert_location == 255 && state.module.serial.enabled); + const auto baseline = values; + for (const auto& item : baseline) + for (auto result : {Result::Invalid, Result::Unavailable}) + { + reset_fixture(); + failed = item.first; + failure = result; + assert(!load_preferences()); + } + for (auto result : {platform::ui::settings_store::BlobReadResult::Invalid, + platform::ui::settings_store::BlobReadResult::Unavailable}) + { + reset_fixture(); + blob_result = result; + assert(!load_preferences()); + assert(default_initializations == 1); // No replacement after failed read. + } + for (auto size : {std::size_t(0), sizeof(stored_module) - 1, sizeof(stored_module) + 1}) + { + reset_fixture(); + module_size = size; + assert(!load_preferences()); + } + reset_fixture(); + stored_module.version = ble::meshtastic_defaults::kModuleConfigVersion + 1; + assert(!load_preferences()); + reset_fixture(); + stored_module.version = 0; // Explicitly supported legacy normalization. + assert(load_preferences()); + assert(state.module.version == ble::meshtastic_defaults::kModuleConfigVersion && state.module.serial.enabled); + for (const auto& item : std::map{{"mt_ble/mode", 255}, {"mt_ble/pin", 1}, {"mc_ble/pin", 999}, {"mc_ble/telem_base", 4}, {"mc_ble/telem_loc", 255}, {"mc_ble/telem_env", 4}}) + { + reset_fixture(); + values[item.first] = item.second; + assert(!load_preferences()); + } + reset_fixture(); + values["mt_ble/mode"] = meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN; + assert(!load_preferences()); + reset_fixture(); + values.clear(); + blob_result = platform::ui::settings_store::BlobReadResult::Missing; + assert(load_preferences()); + assert(state.mt_ble.enabled && state.mt_ble.fixed_pin == 0 && state.mc_pin == 0); + assert(default_initializations == 1); +} diff --git a/tests/hostlink/test_ble_preferences_storage.cpp b/tests/hostlink/test_ble_preferences_storage.cpp new file mode 100644 index 00000000..a9761401 --- /dev/null +++ b/tests/hostlink/test_ble_preferences_storage.cpp @@ -0,0 +1,111 @@ +#include "meshtastic/config.pb.h" +#include "meshtastic/localonly.pb.h" + +#include +#include +#include +#include +#include + +namespace +{ +struct Action +{ + std::string owner; + std::string key; + std::size_t size; +}; +std::vector writes; +std::vector opens; +unsigned closes = 0, fail_write = 0, fail_open = 0; +} // namespace + +namespace platform::esp::common +{ +// The NVS adapter is simulated; compile the production writer and real +// generated BLE/module types so stop/ownership and byte counts are exercised. +class ConfigPreferences +{ + std::string owner_; + + public: + bool begin(const char* owner, bool) + { + opens.emplace_back(owner); + if (opens.size() == fail_open) return false; + owner_ = owner; + return true; + } + void end() + { + assert(!owner_.empty()); + owner_.clear(); + ++closes; + } + std::size_t put(const char* key, std::size_t size) + { + assert(!owner_.empty()); + writes.push_back({owner_, key, size}); + return writes.size() == fail_write ? 0 : size; + } + std::size_t putBool(const char* key, bool) { return put(key, sizeof(uint8_t)); } + std::size_t putUChar(const char* key, uint8_t) { return put(key, sizeof(uint8_t)); } + std::size_t putUInt(const char* key, uint32_t) { return put(key, sizeof(uint32_t)); } + std::size_t putBytes(const char* key, const void*, std::size_t size) { return put(key, size); } +}; +} // namespace platform::esp::common + +namespace +{ +constexpr const char* kMtBleNamespace = "mt_ble"; +constexpr const char* kMcBleNamespace = "mc_ble"; +constexpr const char* kMtModuleNamespace = "mt_mod"; +constexpr const char* kModuleBlobKey = "cfg"; +struct State +{ + meshtastic_Config_BluetoothConfig mt_ble = meshtastic_Config_BluetoothConfig_init_zero; + uint32_t mc_pin = 0; + bool mc_manual_add = false; + uint8_t mc_telemetry_base = 0, mc_telemetry_loc = 0, mc_telemetry_env = 0, mc_advert_location = 0; + meshtastic_LocalModuleConfig module = meshtastic_LocalModuleConfig_init_zero; +} s_state; +#include "ble_preferences_actual.inc" + +void reset() +{ + writes.clear(); + opens.clear(); + closes = fail_write = fail_open = 0; +} +} // namespace + +int main() +{ + reset(); + assert(write_preferences()); + assert(writes.size() == 10 && opens.size() == 3 && closes == 3); + assert(writes.back().owner == "mt_mod" && writes.back().key == "cfg"); + assert(writes.back().size == sizeof(meshtastic_LocalModuleConfig)); + for (unsigned failure = 1; failure <= 10; ++failure) + { + reset(); + fail_write = failure; + assert(!write_preferences()); + assert(writes.size() == failure); + const unsigned owners = failure <= 3 ? 1 : failure <= 9 ? 2 + : 3; + assert(opens.size() == owners && closes == owners); + } + for (unsigned failure = 1; failure <= 3; ++failure) + { + reset(); + fail_open = failure; + assert(!write_preferences()); + assert(opens.size() == failure && closes == failure - 1); + assert(writes.size() == (failure == 1 ? 0 : failure == 2 ? 3 + : 9)); + } + reset(); + assert(write_preferences()); + assert(writes.size() == 10 && closes == 3); +} diff --git a/tests/hostlink/test_bzip2_compat.py b/tests/hostlink/test_bzip2_compat.py new file mode 100644 index 00000000..baa661c3 --- /dev/null +++ b/tests/hostlink/test_bzip2_compat.py @@ -0,0 +1,48 @@ +"""Cross-check vendored bzip2 against Python libbz2 via the native CLI. + +Pass bzip2_compat_cli built with the sources in third_party/bzip2/src. +The process boundary supports a 32-bit native tool with 64-bit Python. +""" +import argparse +import bz2 +import random +import subprocess + + +def run(executable): + rng = random.Random(20261012) + cases = [b"", b"a", bytes(range(256)), b"trailmate\x00" * 12000, + bytes(rng.randrange(256) for _ in range(32768)), b"a" * 1000000] + checks = 0 + for original in cases: + for level in (1, 9): + encoded = subprocess.run([executable, "c", str(level)], input=original, + capture_output=True, check=True).stdout + assert bz2.decompress(encoded) == original + checks += 1 + external = bz2.compress(original, compresslevel=level) + for small in (0, 1): + decoded = subprocess.run([executable, "d", str(small)], input=external, + capture_output=True, check=True).stdout + assert decoded == original + checks += 1 + damaged = bytearray(external) + damaged[0] ^= 1 + result = subprocess.run([executable, "d", str(small)], input=damaged, + capture_output=True) + assert result.returncode != 0 + checks += 1 + # Valid header with a corrupted combined CRC must also fail. + damaged = bytearray(external) + damaged[-2] ^= 0x40 + result = subprocess.run([executable, "d", str(small)], input=damaged, + capture_output=True) + assert result.returncode != 0 + checks += 1 + print(f"bzip2 cross-implementation checks passed: {checks}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("executable") + run(parser.parse_args().executable) diff --git a/tests/hostlink/test_candidate_revision.cpp b/tests/hostlink/test_candidate_revision.cpp new file mode 100644 index 00000000..344fee84 --- /dev/null +++ b/tests/hostlink/test_candidate_revision.cpp @@ -0,0 +1,49 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "track_sharing/candidate_revision.h" +#include +int main() +{ + uint8_t raw[512], author[64] = {1}, nonce[16] = {2}, hash[32] = {3}, file[32] = {4}; + track_sharing::VerifiedManifestView parent; + parent.verified = true; + parent.manifest.raw = {file, 1}; + parent.manifest.author = {author, 64}; + parent.manifest.nonce = {nonce, 16}; + parent.manifest.revision = 1; + parent.manifest.created = 100; + parent.manifest.updated = 100; + parent.manifest.file_hash = {file, 32}; + parent.manifest.file_bytes = 200; + std::memcpy(parent.revision_hash, hash, 32); + track_sharing::ManifestView candidate; + for (unsigned mode = 0; mode < 8; ++mode) + { + const unsigned revision = mode < 3 ? mode + 1 : 2; + const unsigned state = mode >= 3 && mode <= 5 ? 1 : 0; + parent.manifest.state = mode == 6 ? 1 : 0; + uint8_t child_file[32], previous[32]; + std::memcpy(child_file, file, 32); + std::memcpy(previous, hash, 32); + if (mode == 4) child_file[31] ^= 1; + if (mode == 7) previous[31] ^= 1; + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(14) && writer.unsignedInteger(1) && writer.binary({author, 64}) && writer.binary({nonce, 16}) && writer.unsignedInteger(0) && writer.unsignedInteger(revision)); + assert(revision == 1 ? writer.nil() : writer.binary({previous, 32})); + assert(writer.unsignedInteger(state) && writer.unsignedInteger(100) && writer.unsignedInteger(101) && writer.text("Track") && writer.text("")); + assert(writer.array(4)); + for (unsigned i = 0; i < 4; ++i) assert(writer.unsignedInteger(0)); + assert(writer.array(4) && writer.unsignedInteger(0) && writer.nil() && writer.nil() && writer.unsignedInteger(1)); + assert(writer.array(4) && writer.binary({child_file, 32}) && writer.unsignedInteger(mode == 5 ? 201 : 200) && writer.text("application/vnd.google-earth.kml+xml") && writer.unsignedInteger(1)); + const geocaching::ByteView bytes{raw, writer.size()}; + assert(track_sharing::validateCandidateRevision(bytes, {author, 64}, nullptr, candidate) == (mode == 0)); + assert(track_sharing::validateCandidateRevision(bytes, {author, 64}, &parent, candidate) == (mode == 1 || mode == 3)); + assert(!track_sharing::validateCandidateRevision(bytes, {author, 64}, &parent, parent.manifest) && parent.manifest.revision == 1); + parent.verified = false; + assert(!track_sharing::validateCandidateRevision(bytes, {author, 64}, &parent, candidate)); + parent.verified = true; + author[63] ^= 1; + assert(!track_sharing::validateCandidateRevision(bytes, {author, 64}, &parent, candidate)); + author[63] ^= 1; + assert(!track_sharing::validateCandidateRevision({raw, writer.size() - 1}, {author, 64}, &parent, candidate)); + } +} diff --git a/tests/hostlink/test_config_preferences.cpp b/tests/hostlink/test_config_preferences.cpp new file mode 100644 index 00000000..e1ccf106 --- /dev/null +++ b/tests/hostlink/test_config_preferences.cpp @@ -0,0 +1,43 @@ +#include "platform/esp/common/config_preferences.h" +#include + +int main() +{ + using platform::esp::common::ConfigPreferences; + static_assert(sizeof(ConfigPreferences) <= 8, "NVS adapter must contain only handle and mode"); + { + ConfigPreferences preferences; + assert(!preferences.begin("missing", true)); + assert(preferences.getUInt("pin", 123) == 123); + assert(!preferences.putUInt("pin", 42)); + assert(preferences.begin("mt_ble", false)); + assert(preferences.putBool("enabled", false) == 1); + assert(preferences.putUChar("mode", 0) == 1); + assert(preferences.putUInt("pin", 0) == sizeof(uint32_t)); + // Verify exact legacy Arduino NVS scalar representations, including zero. + assert(std::get(fake_nvs::values["mt_ble"]["enabled"]) == 0); + assert(std::get(fake_nvs::values["mt_ble"]["mode"]) == 0); + assert(std::get(fake_nvs::values["mt_ble"]["pin"]) == 0); + const uint8_t module[] = {0, 1, 2, 3}; + assert(preferences.putBytes("cfg", module, sizeof(module)) == sizeof(module)); + uint8_t buffer[4]; + assert(preferences.getBytes("cfg", buffer, 3) == 0); + assert(preferences.getBytes("cfg", buffer, sizeof(buffer)) == sizeof(module)); + assert(!std::memcmp(buffer, module, sizeof(module))); + assert(preferences.begin("mt_ble", true)); + assert(fake_nvs::handles.size() == 1); + assert(!preferences.getBool("enabled", true)); + assert(preferences.getUInt("pin", 123) == 0); + assert(!preferences.putBool("enabled", true)); + assert(!preferences.remove("pin")); + assert(preferences.begin("cfgreset", false)); + assert(preferences.remove("working")); + assert(preferences.putBool("working", true)); + fake_nvs::fail_commit = true; + assert(!preferences.putBool("working", false)); + fake_nvs::fail_commit = false; + assert(preferences.remove("working")); + assert(!preferences.getBool("working")); + } + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_confirm_ip_publication_head.cpp b/tests/hostlink/test_confirm_ip_publication_head.cpp new file mode 100644 index 00000000..a6045862 --- /dev/null +++ b/tests/hostlink/test_confirm_ip_publication_head.cpp @@ -0,0 +1,125 @@ +#include "track_sharing/confirm_ip_publication_head.h" +#include +#include +using namespace track_sharing; +struct Heads +{ + ObjectHead stored; + bool fail = false; + unsigned writes = 0; + HeadStoreResult load(ObjectHead& current, ObjectHead&, uint8_t*, size_t) + { + current = stored; + return HeadStoreResult::Ready; + } + HeadStoreResult advance(const ObjectHead& current, const ObjectHead& next, ObjectHead&, uint8_t*, size_t) + { + assert(objectHeadAdvanceValid(current, next)); + if (fail) return HeadStoreResult::StorageFailure; + stored = next; + ++writes; + return HeadStoreResult::Ready; + } +}; +struct Receipts +{ + bool durable = false; + SignedStoreResult result = SignedStoreResult::Ready; + unsigned calls = 0; + SignedStoreResult persist(geocaching::ByteView, geocaching::ByteView, uint8_t*, size_t) + { + ++calls; + if (result == SignedStoreResult::Ready) durable = true; + return result; + } +}; +int main() +{ + std::array author{}, directory{}, signature{}; + std::array author_hash{}, file_hash{}; + std::array request{}; + std::array scratch{}; + author.fill(1); + directory.fill(2); + signature.fill(3); + author_hash.fill(4); + file_hash.fill(5); + request.fill(6); + RevisionReservation reservation; + std::memset(reservation.operation, 7, 16); + std::memset(reservation.object, 8, 32); + std::memcpy(reservation.author, author_hash.data(), 32); + std::memset(reservation.candidate, 9, 32); + reservation.sequence = 1; + reservation.generation = 1; + reservation.revision = 1; + reservation.phase = ReservationPhase::Signed; + VerifiedManifestView manifest; + manifest.verified = true; + manifest.manifest.author = {author.data(), 64}; + manifest.manifest.revision = 1; + manifest.manifest.file_bytes = 100; + manifest.manifest.file_hash = {file_hash.data(), 32}; + std::memcpy(manifest.object_id, reservation.object, 32); + std::memcpy(manifest.revision_hash, reservation.candidate, 32); + Heads heads; + assert(createSignedObjectHead(manifest, reservation, 1, {author.data(), 64}, {author_hash.data(), 32}, nullptr, heads.stored)); + PublicationJournal expected; + std::memcpy(expected.operation, reservation.operation, 16); + std::memcpy(expected.object, reservation.object, 32); + std::memcpy(expected.revision_hash, reservation.candidate, 32); + std::memcpy(expected.author_hash, author_hash.data(), 32); + std::memcpy(expected.file_hash, file_hash.data(), 32); + std::memset(expected.directory, 10, 16); + std::memset(expected.directory_epoch, 11, 16); + expected.sequence = 1; + expected.generation = 1; + expected.revision = 1; + expected.file_bytes = expected.offset = 100; + expected.phase = PublicationPhase::Publishing; + VerifiedReceiptView proof; + proof.verified = true; + proof.signature = {signature.data(), 64}; + auto& receipt = proof.receipt; + receipt.raw = {signature.data(), 1}; + receipt.request = {request.data(), 16}; + receipt.object = {expected.object, 32}; + receipt.revision_hash = {expected.revision_hash, 32}; + receipt.file_hash = {file_hash.data(), 32}; + receipt.directory_public = {directory.data(), 64}; + receipt.directory = {expected.directory, 16}; + receipt.epoch = {expected.directory_epoch, 16}; + receipt.revision = 1; + receipt.file_bytes = 100; + ObjectHead current, next, verify; + Receipts receipts; + const auto confirm = [&] + { return confirmIpPublicationHead(heads, receipts, proof, expected, {request.data(), 16}, {directory.data(), 64}, + manifest, reservation, 1, {author.data(), 64}, {author_hash.data(), 32}, current, next, verify, scratch.data(), scratch.size()); }; + // No persistence is permitted for unverified, wrong-file or wrong-identity evidence. + proof.verified = false; + assert(confirm() == HeadStoreResult::Invalid && receipts.calls == 0); + proof.verified = true; + receipt.file_bytes = 99; + assert(confirm() == HeadStoreResult::Invalid && receipts.calls == 0); + receipt.file_bytes = 100; + heads.stored.generation = 2; + assert(confirm() == HeadStoreResult::Conflict && receipts.calls == 0); + heads.stored.generation = 1; + expected.operation[0] ^= 1; + assert(confirm() == HeadStoreResult::Conflict && receipts.calls == 0); + expected.operation[0] ^= 1; + // Receipt storage failure leaves Signed intact. + receipts.result = SignedStoreResult::StorageFailure; + assert(confirm() == HeadStoreResult::StorageFailure && heads.writes == 0 && heads.stored.phase == HeadPhase::Signed); + receipts.result = SignedStoreResult::Ready; + heads.fail = true; + assert(confirm() == HeadStoreResult::StorageFailure && receipts.durable && heads.writes == 0); + // Retrying after receipt persistence/failed head write performs one transition. + heads.fail = false; + assert(confirm() == HeadStoreResult::Ready && heads.writes == 1 && heads.stored.phase == HeadPhase::Committed); + assert(confirm() == HeadStoreResult::Ready && heads.writes == 1); + // Committed metadata alone is insufficient when exact receipt persistence conflicts. + receipts.result = SignedStoreResult::Conflict; + assert(confirm() == HeadStoreResult::Conflict && heads.writes == 1); +} diff --git a/tests/hostlink/test_device_author.cpp b/tests/hostlink/test_device_author.cpp new file mode 100644 index 00000000..4a8320c2 --- /dev/null +++ b/tests/hostlink/test_device_author.cpp @@ -0,0 +1,48 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "chat/ports/device_author.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include +#include +#include + +static void hex(const uint8_t* bytes, size_t size) +{ + for (size_t index = 0; index < size; ++index) std::printf("%02x", bytes[index]); +} +int main() +{ + static_assert(sizeof(chat::lxmf::LxmfIdentity) == 200, "Author generation added RAM"); + std::array session; + std::array nonce, seed; + std::array public_key, private_key, signature; + std::array message; + session.fill(1); + nonce.fill(2); + public_key.fill(3); + for (size_t index = 0; index < seed.size(); ++index) seed[index] = static_cast(index); + ed25519_create_keypair(public_key.data() + 32, private_key.data(), seed.data()); + size_t size = 0; + message.fill(0xaa); + assert(!chat::deviceAuthorProofMessage(session.data(), nonce.data(), public_key.data(), 7, message.data(), 1, size)); + assert(size == 0 && message[0] == 0xaa); + assert(!chat::deviceAuthorProofMessage(message.data(), nonce.data(), public_key.data(), 7, message.data(), message.size(), size)); + assert(!chat::deviceAuthorProofMessage(session.data(), nonce.data(), public_key.data(), 0, message.data(), message.size(), size)); + for (uint32_t generation : std::array{1, 127, 128, 255, 256, 65535, 65536, UINT32_MAX}) + { + assert(chat::deviceAuthorProofMessage(session.data(), nonce.data(), public_key.data(), generation, message.data(), message.size(), size)); + assert(size <= 160); + } + assert(chat::deviceAuthorProofMessage(session.data(), nonce.data(), public_key.data(), 7, message.data(), message.size(), size)); + ed25519_sign(signature.data(), message.data(), size, public_key.data() + 32, private_key.data()); + assert(ed25519_verify(signature.data(), message.data(), size, public_key.data() + 32)); + message[30] ^= 1; + assert(!ed25519_verify(signature.data(), message.data(), size, public_key.data() + 32)); + message[30] ^= 1; + std::printf("{\"publicKey\":\""); + hex(public_key.data(), public_key.size()); + std::printf("\",\"message\":\""); + hex(message.data(), size); + std::printf("\",\"signature\":\""); + hex(signature.data(), signature.size()); + std::printf("\"}\n"); +} diff --git a/tests/hostlink/test_geocache_confirmation_request.cpp b/tests/hostlink/test_geocache_confirmation_request.cpp new file mode 100644 index 00000000..f34948e4 --- /dev/null +++ b/tests/hostlink/test_geocache_confirmation_request.cpp @@ -0,0 +1,29 @@ +#include "hostlink/geocache_confirmation_request.h" +#include "geocaching/protocol/cmp_writer.h" +#include +#include +int main() +{ + using namespace hostlink::management; + std::array input{}; + std::array proof{}; + std::array key{},signature{}; + std::array hash{}; + std::array transfer{},epoch{};transfer.fill(1); + const auto encode=[&](uint64_t generation,size_t proof_size,bool tail=false){ + geocaching::protocol::CmpWriter writer(input.data(),input.size()); + assert(writer.array(7)&&writer.binary({transfer.data(),16})&&writer.binary({hash.data(),32})&&writer.unsignedInteger(generation)&& + writer.binary({proof.data(),proof_size})&&writer.binary({signature.data(),64})&&writer.binary({key.data(),64})&&writer.binary({epoch.data(),16})); + if(tail)assert(writer.nil());return writer.size(); + }; + GeocacheConfirmationRequest parsed; + auto size=encode(UINT32_MAX,192);assert(size<=448&&parseGeocacheConfirmationRequest(input.data(),size,parsed)==Status::Ok&&parsed.author_generation==UINT32_MAX); + assert(parsed.proof.data>=input.data()&&parsed.proof.data(&parsed),sizeof(parsed),parsed)==Status::InvalidField&&parsed.author_generation==7); +} diff --git a/tests/hostlink/test_geocache_sign_request.cpp b/tests/hostlink/test_geocache_sign_request.cpp new file mode 100644 index 00000000..30986e51 --- /dev/null +++ b/tests/hostlink/test_geocache_sign_request.cpp @@ -0,0 +1,52 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/geocache_sign_request.h" +#include +#include +int main() +{ + using namespace hostlink::management; + std::array transfer{}, operation{}; + transfer.fill(1); + operation.fill(2); + std::array hash{}; + std::array encoded{}; + const auto make = [&](uint64_t size, uint64_t generation, bool tail = false) + { + geocaching::protocol::CmpWriter writer(encoded.data(), encoded.size()); + assert(writer.array(5) && writer.binary({transfer.data(), transfer.size()}) && writer.binary({hash.data(), hash.size()}) && + writer.unsignedInteger(size) && writer.unsignedInteger(generation) && writer.binary({operation.data(), operation.size()})); + if (tail) assert(writer.nil()); + return writer.size(); + }; + GeocacheSignRequest parsed; + auto size = make(4096, UINT32_MAX); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::Ok && parsed.bytes == 4096 && parsed.author_generation == UINT32_MAX); + assert(parsed.transfer.data >= encoded.data() && parsed.transfer.data < encoded.data() + size); + { + geocaching::protocol::CmpWriter writer(encoded.data(), encoded.size()); + assert(writer.array(7) && writer.binary({transfer.data(), 16}) && writer.binary({hash.data(), 32}) && + writer.unsignedInteger(100) && writer.unsignedInteger(7) && writer.binary({operation.data(), 16}) && + writer.binary({transfer.data(), 16}) && writer.unsignedInteger(UINT64_MAX)); + assert(parseGeocacheSignRequest(encoded.data(), writer.size(), parsed) == Status::Ok && + parsed.draft.size == 16 && parsed.draft_generation == UINT64_MAX); + size = make(4096, UINT32_MAX); + } + assert(parseGeocacheSignRequest(encoded.data(), size - 1, parsed) == Status::InvalidField && !parsed.transfer.data); + for (auto value : {uint64_t(0), uint64_t(4097), UINT64_MAX}) + { + size = make(value, 1); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::InvalidField); + } + size = make(1, uint64_t(UINT32_MAX) + 1); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::InvalidField); + size = make(1, 0); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::InvalidField); + size = make(1, 1, true); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::InvalidField); + operation.fill(0); + size = make(1, 1); + assert(parseGeocacheSignRequest(encoded.data(), size, parsed) == Status::InvalidField); + assert(parseGeocacheSignRequest(nullptr, 0, parsed) == Status::InvalidField); + parsed.bytes = 5; + assert(parseGeocacheSignRequest(reinterpret_cast(&parsed), sizeof(parsed), parsed) == Status::InvalidField && parsed.bytes == 5); +} diff --git a/tests/hostlink/test_geocache_signature_job.cpp b/tests/hostlink/test_geocache_signature_job.cpp new file mode 100644 index 00000000..80b97e66 --- /dev/null +++ b/tests/hostlink/test_geocache_signature_job.cpp @@ -0,0 +1,82 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/geocache_signature_job.h" +#include +#include +struct Port : geocaching::AuthorIssuePort +{ + unsigned signatures = 0, reservations = 0, cancels = 0; + bool durable = false; + bool getAuthorKey(uint8_t* output) override + { + std::memset(output, 3, 64); + return true; + } + geocaching::AuthorReservationResult reserve(geocaching::ByteView, uint8_t*, size_t) override + { + ++reservations; + return durable ? geocaching::AuthorReservationResult::Reserved : geocaching::AuthorReservationResult::Pending; + } + void cancelReservation() override { ++cancels; } + bool sign(geocaching::ByteView record, uint8_t*, size_t, uint8_t* output, size_t capacity, size_t& written) override + { + ++signatures; + assert(durable); + std::array signature{}; + geocaching::protocol::CmpWriter writer(output, capacity); + const bool ok = writer.array(2) && writer.binary(record) && writer.binary({signature.data(), signature.size()}); + written = writer.size(); + return ok; + } +}; +int main() +{ + std::array author; + author.fill(3); + std::array nonce{}; + std::array raw{}, work{}; + std::array description{}; + description.fill('a'); + description.back() = 0; + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(16) && writer.unsignedInteger(1) && writer.binary({author.data(), author.size()}) && writer.binary({nonce.data(), nonce.size()}) && + writer.unsignedInteger(1) && writer.nil() && writer.unsignedInteger(0) && writer.signedInteger(1) && writer.signedInteger(1) && + writer.text("Cache") && writer.text(description.data()) && writer.text("") && writer.unsignedInteger(2) && writer.unsignedInteger(2) && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.unsignedInteger(1)); + const geocaching::ByteView record{raw.data(), writer.size()}; + Port port; + hostlink::management::GeocacheSignatureJob job(port); + assert(job.begin(record, work.data(), work.size(), author.data(), 7)); + geocaching::ByteView chunk; + size_t total = 0; + assert(!job.read(0, 320, author.data(), 7, true, chunk, total)); + job.advance(author.data(), 7, true); + job.advance(author.data(), 7, true); + assert(port.signatures == 0 && port.reservations == 1); + port.durable = true; + job.advance(author.data(), 7, true); + job.advance(author.data(), 7, true); + assert(port.signatures == 1 && job.phase() == geocaching::AuthorIssuePhase::Signed); + assert(job.read(0, 320, author.data(), 7, true, chunk, total) && total > 1024); + const size_t expected = total; + size_t offset = 0; + std::array copied{}; + while (offset < expected) + { + assert(job.read(offset, 17, author.data(), 7, true, chunk, total)); + assert(chunk.size <= 17 && total == expected); + std::memcpy(copied.data() + offset, chunk.data, chunk.size); + offset += chunk.size; + } + assert(offset == expected && !std::memcmp(copied.data(), work.data(), expected)); + assert(!job.read(expected, 320, author.data(), 7, true, chunk, total) && !chunk.data && total == 0); + assert(!job.read(0, 321, author.data(), 7, true, chunk, total)); + assert(!job.read(0, 320, author.data(), 8, true, chunk, total)); + assert(job.phase() == geocaching::AuthorIssuePhase::Cancelled); + assert(!job.read(0, 320, author.data(), 7, true, chunk, total)); + Port cancelled; + hostlink::management::GeocacheSignatureJob pending(cancelled); + assert(pending.begin(record, work.data(), work.size(), author.data(), 7)); + pending.advance(author.data(), 7, true); + pending.advance(author.data(), 7, true); + pending.advance(author.data(), 7, false); + assert(cancelled.signatures == 0 && cancelled.cancels == 1); +} diff --git a/tests/hostlink/test_hostlink_tx_memory.cpp b/tests/hostlink/test_hostlink_tx_memory.cpp new file mode 100644 index 00000000..7961c355 --- /dev/null +++ b/tests/hostlink/test_hostlink_tx_memory.cpp @@ -0,0 +1,104 @@ +#include "hostlink/hostlink_codec.h" +#include "hostlink/sensitive_memory.h" +#include +#include +#include +#include +#include +namespace hostlink +{ +constexpr uint32_t MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_INTERNAL = 2, MALLOC_CAP_8BIT = 4; +constexpr int pdPASS = 1; +struct TxItem +{ + uint8_t* data = nullptr; + size_t len = 0; +}; +TxItem queued; +void* s_tx_queue = &queued; +using QueueHandle_t = void*; +std::map allocation_sizes; +bool full = false, ready = true, fail_allocation = false; +unsigned allocations = 0, frees = 0; +int s_session; +bool is_ready(int) { return ready; } +uint16_t next_seq() { return 17; } +void* heap_caps_malloc_prefer(size_t size, size_t count, ...) +{ + assert(count == 2); + va_list args; + va_start(args, count); + assert(va_arg(args, uint32_t) == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + assert(va_arg(args, uint32_t) == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + va_end(args); + if (fail_allocation) return nullptr; + ++allocations; + auto* value = std::malloc(size); + allocation_sizes[value] = size; + return value; +} +void test_free(void* value) +{ + if (value) + { + const auto size = allocation_sizes.at(value); + for (size_t i = 0; i < size; ++i) assert(static_cast(value)[i] == 0); + allocation_sizes.erase(value); + ++frees; + } + std::free(value); +} +int xQueueSend(void*, const TxItem* item, unsigned) +{ + if (full) return 0; + queued = *item; + full = true; + return pdPASS; +} +int xQueueReceive(void*, TxItem* item, unsigned) +{ + if (!full) return 0; + *item = queued; + full = false; + return pdPASS; +} +#define free test_free +#include "hostlink_tx_actual.inc" +#undef free +} // namespace hostlink +int main() +{ + using namespace hostlink; + uint8_t source[kMaxFrameLen]; + std::memset(source, 42, sizeof(source)); + assert(enqueue_event(0x21, source, sizeof(source), false, 123)); + assert(full && allocations == 1 && !frees && queued.len == kMaxFrameLen + kHeaderSize + kCrcSize); + Decoder decoder; + decoder.push(queued.data, queued.len); + Frame frame; + assert(decoder.next(frame) && frame.type == 0x21 && frame.seq == 123 && frame.payload.size() == sizeof(source) && !std::memcmp(frame.payload.data(), source, sizeof(source))); + const auto* original = queued.data; + assert(!enqueue_event(0x82, source, 1, false, 0)); + assert(queued.data == original && frees == 1); + assert(enqueue_event(0x21, source, 1, true, 124)); + assert(queued.data != original && frees == 2); + fail_allocation = true; + assert(!enqueue_event(0x21, source, 1, true, 0) && frees == 2); + fail_allocation = false; + ready = false; + assert(!enqueue_event(0x21, source, 1, false, 0) && allocations == 3); + ready = true; + assert(!enqueue_event(0x21, nullptr, 1, false, 0) && allocations == 3); + assert(!enqueue_event(0x21, source, kMaxFrameLen + 1, false, 0) && allocations == 3); + TxItem final; + assert(xQueueReceive(s_tx_queue, &final, 0) == pdPASS); + release_tx_item(final); + assert(!final.data && !final.len); + assert(enqueue_event(0x21, source, 96, false, 125)); + ready = false; + drain_tx_queue(s_tx_queue); + assert(!full && allocations == frees); + drain_tx_queue(s_tx_queue); + drain_tx_queue(nullptr); + assert(allocations == frees); +} diff --git a/tests/hostlink/test_identity_atomic_record.cpp b/tests/hostlink/test_identity_atomic_record.cpp new file mode 100644 index 00000000..3c32f910 --- /dev/null +++ b/tests/hostlink/test_identity_atomic_record.cpp @@ -0,0 +1,43 @@ +#include "platform/esp/common/identity_atomic_record.h" +#include +#include +#include +using namespace platform::esp::common; +int main() +{ + std::array pub{}; + pub.fill(2); + std::array priv{}; + priv.fill(3); + std::array work{}; + const auto validate = [](const uint8_t*, const uint8_t*, uint8_t*, size_t) + { return true; }; + assert(readIdentityRecord(work.data(), work.size()) == IdentityRecordResult::Absent); + fake_nvs::values["lxmf_ident"]["enc_pub"] = std::vector(32, 99); + const auto legacy = fake_nvs::values["lxmf_ident"]["enc_pub"]; + assert(commitIdentityRecord(7, 7, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Committed); + for (auto value : work) assert(value == 0); + assert(readIdentityRecord(work.data(), work.size()) == IdentityRecordResult::Ready); + assert(identityRecordU32(work.data() + 8) == 8); + assert(!std::memcmp(work.data() + 12, pub.data(), 64) && !std::memcmp(work.data() + 76, priv.data(), 96)); + assert(fake_nvs::values["lxmf_ident"]["enc_pub"] == legacy); + const auto before = fake_nvs::values; + assert(commitIdentityRecord(7, 8, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::TargetChanged); + assert(commitIdentityRecord(7, 7, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::TargetChanged); + assert(fake_nvs::values == before); + assert(commitIdentityRecord(UINT32_MAX, UINT32_MAX, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Invalid); + fake_nvs::fail_commit = true; + assert(commitIdentityRecord(8, 8, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Indeterminate); + fake_nvs::fail_commit = false; + // Fake NVS models the conservative case: the full new value is visible + // despite a commit error. Recovery must read, never assume a rollback. + assert(readIdentityRecord(work.data(), work.size()) == IdentityRecordResult::Ready); + assert(identityRecordU32(work.data() + 8) == 9); + auto& record = std::get>(fake_nvs::values["lxmf_ident"]["native_v1"]); + record[8] ^= 1; + assert(readIdentityRecord(work.data(), work.size()) == IdentityRecordResult::Invalid); + for (size_t i = 0; i < kIdentityRecordBytes; ++i) assert(work[i] == 0); + const auto corrupt = fake_nvs::values; + assert(commitIdentityRecord(9, 9, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Invalid); + assert(fake_nvs::values == corrupt); +} diff --git a/tests/hostlink/test_identity_backup_export.cpp b/tests/hostlink/test_identity_backup_export.cpp new file mode 100644 index 00000000..90abdedb --- /dev/null +++ b/tests/hostlink/test_identity_backup_export.cpp @@ -0,0 +1,54 @@ +#include "geocaching/protocol/cmp_reader.h" +#include "hostlink/identity_backup_export.h" +#include +#include +struct Identity +{ + bool ready = true, stale_on_sign = false; + mutable uint32_t generation = 7; + std::array hash{}, delivery{}; + bool isReady() const { return ready; } + bool identityStorageError() const { return false; } + uint32_t authorGeneration() const { return generation; } + void combinedPublicKey(uint8_t* output) const { std::memset(output, 3, 64); } + bool copyNativePrivateKey(uint32_t expected, uint8_t* output, size_t capacity) const + { + if (expected != generation || capacity < 96) return false; + std::memset(output, 4, 32); + std::memset(output + 32, 5, 64); + return true; + } + const uint8_t* identityHash() const { return hash.data(); } + const uint8_t* destinationHash() const { return delivery.data(); } + bool sign(const uint8_t* message, size_t size, uint8_t* signature) const + { + assert(size > 96 && !std::memcmp(message, "TrailMate.Identity.Backup.v1", 26)); + std::memset(signature, 6, 64); + if (stale_on_sign) ++generation; + return true; + } +}; +int main() +{ + Identity identity; + std::array output{}; + size_t written = 0; + assert(hostlink::management::exportNativeIdentityBackup(identity, 7, output.data(), output.size(), written) == chat::DeviceAuthorResult::Ok); + geocaching::protocol::CmpReader outer({output.data(), written}); + size_t count = 0; + geocaching::ByteView payload, signature; + assert(outer.array(count, 2) && count == 2 && outer.binary(payload, 320) && outer.binary(signature, 64) && signature.size == 64 && outer.finished()); + geocaching::protocol::CmpReader reader(payload); + uint64_t value = 0; + std::string_view type; + geocaching::ByteView key, secret, hash, delivery; + assert(reader.array(count, 8) && count == 8 && reader.unsignedInteger(value) && value == 1 && reader.text(type, 40) && type == "trailmate.identity.backup.v1" && reader.unsignedInteger(value) && value == 0); + assert(reader.binary(key, 64) && key.size == 64 && reader.binary(secret, 96) && secret.size == 96 && secret.data[0] == 4 && secret.data[32] == 5); + assert(reader.binary(hash, 16) && reader.binary(delivery, 16) && reader.unsignedInteger(value) && value == 7 && reader.finished()); + for (size_t i = written; i < output.size(); ++i) assert(output[i] == 0); + identity.stale_on_sign = true; + assert(hostlink::management::exportNativeIdentityBackup(identity, 7, output.data(), output.size(), written) == chat::DeviceAuthorResult::StaleIdentity && written == 0); + for (auto byte : output) assert(byte == 0); + identity.ready = false; + assert(hostlink::management::exportNativeIdentityBackup(identity, 8, output.data(), output.size(), written) == chat::DeviceAuthorResult::Unavailable); +} diff --git a/tests/hostlink/test_identity_backup_request.cpp b/tests/hostlink/test_identity_backup_request.cpp new file mode 100644 index 00000000..297900ae --- /dev/null +++ b/tests/hostlink/test_identity_backup_request.cpp @@ -0,0 +1,41 @@ +#include "hostlink/identity_backup_request.h" +#include +#include +struct Adapter +{ + mutable unsigned calls = 0; + chat::DeviceAuthorResult result = chat::DeviceAuthorResult::Ok; + chat::DeviceAuthorResult exportDeviceIdentityBackup(uint32_t generation, uint8_t* output, size_t, size_t& written) const + { + assert(generation == 7); + ++calls; + output[0] = 0x90; + written = result == chat::DeviceAuthorResult::Ok ? 1 : 0; + return result; + } +}; +int main() +{ + using namespace hostlink::management; + Adapter adapter; + std::array output{}; + uint8_t session[16]{}; + uint8_t body[] = {0x92, 7, 0xc3, 0}; + size_t written = 0; + EnvelopeView request{session, nullptr, uint16_t(Operation::IdentityBackupExport), body, 3}; + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::Ok && written == 1 && adapter.calls == 1); + body[2] = 0xc2; + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::Unauthorized && adapter.calls == 1); + body[2] = 1; + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::InvalidField && adapter.calls == 1); + body[2] = 0xc3; + request.body_size = 4; + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::InvalidField && adapter.calls == 1); + request.body_size = 3; + adapter.result = chat::DeviceAuthorResult::StaleIdentity; + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::StaleIdentity); + adapter.result = chat::DeviceAuthorResult::Ok; + std::memcpy(output.data(), body, 3); + request.body = output.data(); + assert(processIdentityBackupExport(adapter, request, output.data(), output.size(), written) == Status::Ok); +} diff --git a/tests/hostlink/test_identity_export_material.cpp b/tests/hostlink/test_identity_export_material.cpp new file mode 100644 index 00000000..b581fab0 --- /dev/null +++ b/tests/hostlink/test_identity_export_material.cpp @@ -0,0 +1,37 @@ +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include +#include + +// Only initialization is a fixture; copying uses the production identity +// class, member layout and inline method. No real keys or NVS are accessed. +bool chat::lxmf::LxmfIdentity::init() +{ + enc_priv_.fill(11); + sig_priv_.fill(22); + author_generation_ = 7; + ready_ = true; + return true; +} +int main() +{ + chat::lxmf::LxmfIdentity identity; + std::array output; + output.fill(99); + assert(!identity.copyNativePrivateKey(7, output.data(), output.size())); + for (size_t i = 0; i < 96; ++i) assert(output[i] == 0); + assert(output[96] == 99); + assert(identity.init()); + assert(identity.copyNativePrivateKey(7, output.data(), output.size())); + for (size_t i = 0; i < 32; ++i) assert(output[i] == 11); + for (size_t i = 32; i < 96; ++i) assert(output[i] == 22); + assert(output[96] == 99); + assert(!identity.copyNativePrivateKey(8, output.data(), output.size())); + for (size_t i = 0; i < 96; ++i) assert(output[i] == 0); + output.fill(99); + assert(!identity.copyNativePrivateKey(7, output.data(), 95)); + for (size_t i = 0; i < 95; ++i) assert(output[i] == 0); + assert(output[95] == 99); + assert(!identity.copyNativePrivateKey(7, reinterpret_cast(&identity), sizeof(identity))); + assert(identity.isReady() && identity.authorGeneration() == 7); + assert(identity.copyNativePrivateKey(7, output.data(), output.size())); +} diff --git a/tests/hostlink/test_identity_generation.cpp b/tests/hostlink/test_identity_generation.cpp new file mode 100644 index 00000000..fd8db544 --- /dev/null +++ b/tests/hostlink/test_identity_generation.cpp @@ -0,0 +1,47 @@ +#include "platform/esp/common/identity_generation_store.h" +#include "platform/esp/common/identity_key_state.h" +#include + +int main() +{ + using platform::esp::common::syncIdentityGeneration; + uint8_t first[16]{}, second[16]{}; + first[0] = 1; + second[0] = 2; + uint32_t generation = 99; + assert(platform::esp::common::lxmfIdentityKeysAbsent()); + fake_nvs::values["lxmf_ident"]; + assert(platform::esp::common::lxmfIdentityKeysAbsent()); + fake_nvs::values["lxmf_ident"]["enc_pub"] = std::vector{}; + assert(!platform::esp::common::lxmfIdentityKeysAbsent()); + fake_nvs::values["lxmf_ident"]["enc_pub"] = uint32_t{1}; + assert(!platform::esp::common::lxmfIdentityKeysAbsent()); + fake_nvs::values["lxmf_ident"].clear(); + fake_nvs::values["lxmf_ident"]["sig_priv"] = std::vector(63, 1); + assert(!platform::esp::common::lxmfIdentityKeysAbsent()); + assert(fake_nvs::handles.empty()); + assert(syncIdentityGeneration(first, generation) && generation == 1); + assert(fake_nvs::commits == 1); + assert(syncIdentityGeneration(first, generation) && generation == 1 && fake_nvs::commits == 1); + assert(syncIdentityGeneration(second, generation) && generation == 2); + assert(syncIdentityGeneration(first, generation) && generation == 3); + assert(syncIdentityGeneration(first, generation, true) && generation == 4); + const auto good = std::get>(fake_nvs::values["tm_author"]["epoch"]); + assert(good.size() == 20 && good[0] == 4 && !std::memcmp(good.data() + 4, first, 16)); + auto invalid = good; + invalid[0] = 0; + fake_nvs::values["tm_author"]["epoch"] = invalid; + assert(!syncIdentityGeneration(first, generation) && generation == 0); + assert(std::get>(fake_nvs::values["tm_author"]["epoch"]) == invalid); + invalid.resize(2); + fake_nvs::values["tm_author"]["epoch"] = invalid; + assert(!syncIdentityGeneration(first, generation) && generation == 0); + auto exhausted = good; + for (unsigned index = 0; index < 4; ++index) exhausted[index] = 0xff; + fake_nvs::values["tm_author"]["epoch"] = exhausted; + assert(!syncIdentityGeneration(first, generation, true) && generation == 0); + fake_nvs::values["tm_author"]["epoch"] = good; + fake_nvs::fail_commit = true; + assert(!syncIdentityGeneration(second, generation) && generation == 0); + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_identity_generation_recovery.cpp b/tests/hostlink/test_identity_generation_recovery.cpp new file mode 100644 index 00000000..17b53c03 --- /dev/null +++ b/tests/hostlink/test_identity_generation_recovery.cpp @@ -0,0 +1,32 @@ +#include "platform/esp/common/identity_generation_recovery.h" +#include "platform/esp/common/identity_generation_store.h" +#include +#include +int main() +{ + using namespace platform::esp::common; + std::array old_hash{}, new_hash{}; + old_hash.fill(1); + new_hash.fill(2); + uint32_t generation = 99; + assert(syncIdentityGeneration(old_hash.data(), generation) && generation == 1); + assert(recoverIdentityGeneration(new_hash.data(), 2, generation) && generation == 2); + const unsigned writes = fake_nvs::commits; + assert(recoverIdentityGeneration(new_hash.data(), 2, generation) && generation == 2 && writes == fake_nvs::commits); + assert(!recoverIdentityGeneration(old_hash.data(), 2, generation) && generation == 0); + assert(!recoverIdentityGeneration(old_hash.data(), 1, generation) && generation == 0); + assert(syncIdentityGeneration(new_hash.data(), generation, true) && generation == 3); + assert(recoverIdentityGeneration(new_hash.data(), 2, generation) && generation == 3); + auto& epoch = std::get>(fake_nvs::values["tm_author"]["epoch"]); + epoch.resize(2); + const auto corrupted = fake_nvs::values; + assert(!recoverIdentityGeneration(new_hash.data(), 4, generation) && generation == 0); + assert(fake_nvs::values == corrupted); + fake_nvs::values["tm_author"].clear(); + assert(recoverIdentityGeneration(new_hash.data(), 8, generation) && generation == 8); + fake_nvs::fail_commit = true; + assert(!recoverIdentityGeneration(new_hash.data(), 9, generation) && generation == 0); + fake_nvs::fail_commit = false; + assert(recoverIdentityGeneration(new_hash.data(), 9, generation) && generation == 9); + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_identity_geocaching_admission.cpp b/tests/hostlink/test_identity_geocaching_admission.cpp new file mode 100644 index 00000000..1cd2fba6 --- /dev/null +++ b/tests/hostlink/test_identity_geocaching_admission.cpp @@ -0,0 +1,154 @@ +#include +#include +#include +#include +namespace ui::geocaching +{ +enum class DraftReadStatus +{ + Pending, + Ready +}; +} +namespace gc +{ +enum class QueryClientPhase +{ + Idle, + PageReady, + Failed, + Querying +}; +} +namespace +{ +enum class Phase +{ + Ready, + Failed, + Recover +}; +bool can_lock = true, locked = false, download_busy = false, publication_busy = false, save_busy = false; +struct Guard +{ + bool locked = can_lock; + Guard() { ::locked = locked; } + ~Guard() { ::locked = false; } +}; +bool ensureMutex() { return true; } +bool downloadActive() { return download_busy; } +bool publicationActive() { return publication_busy; } +bool draftSaveActive() { return save_busy; } +struct Store +{ + bool work = false; + bool commitPending() const { return work; } + bool busy() const { return work; } + bool pending() const { return work; } +}; +struct Workspace +{ + bool work = false; + const void* holder() const { return work ? this : nullptr; } +}; +struct Catalog +{ + bool reading = false; +}; +struct Read +{ + ui::geocaching::DraftReadStatus status = ui::geocaching::DraftReadStatus::Ready; +}; +struct Client +{ + gc::QueryClientPhase value = gc::QueryClientPhase::Idle; + gc::QueryClientPhase phase() const { return value; } +}; +struct Session +{ + Phase phase = Phase::Ready; + bool recovery = false, checkpoint = false, response = false, publication_restore_pending = false, download_restore_pending = false, recover = false; + Workspace workspace_owner; + Store *store = nullptr, *dispatch_store = nullptr, *receipts = nullptr, *saved = nullptr; + Catalog* draft_catalog = nullptr; + Read* draft_read = nullptr; + Client* client = nullptr; + bool needsRecovery() const { return recover; } +}; +std::unique_ptr session; +std::atomic pending_reply{nullptr}, pending_announcement{nullptr}; +std::atomic wanted{3}; +std::atomic network_requested{true}, local_storage_requested{true}, active{true}; +uint64_t epoch = 0; +// Production lock/check/callback/release policy with controlled owner states. +#include "identity_geocaching_admission_actual.inc" +unsigned calls = 0; +bool action(void* context) +{ + assert(locked); + ++calls; + return *static_cast(context); +} +} // namespace +int main() +{ + bool committed = false; + const auto blocked = [&]() + {const auto before=calls;assert(!withIdentityMaintenance(action,&committed)&&calls==before&&!locked); }; + can_lock = false; + blocked(); + can_lock = true; + pending_reply.store(&committed); + blocked(); + pending_reply.store(nullptr); + pending_announcement.store(&committed); + blocked(); + pending_announcement.store(nullptr); + session = std::make_unique(); + for (bool Session::*field : {&Session::recovery, &Session::checkpoint, &Session::response, &Session::publication_restore_pending, &Session::download_restore_pending, &Session::recover}) + { + session.get()->*field = true; + blocked(); + session.get()->*field = false; + } + session->phase = Phase::Recover; + blocked(); + session->phase = Phase::Ready; + session->workspace_owner.work = true; + blocked(); + session->workspace_owner.work = false; + for (bool* flag : {&download_busy, &publication_busy, &save_busy}) + { + *flag = true; + blocked(); + *flag = false; + } + Store store; + store.work = true; + for (Store* Session::*field : {&Session::store, &Session::dispatch_store, &Session::receipts, &Session::saved}) + { + session.get()->*field = &store; + blocked(); + session.get()->*field = nullptr; + } + Catalog catalog; + catalog.reading = true; + session->draft_catalog = &catalog; + blocked(); + session->draft_catalog = nullptr; + Read read; + read.status = ui::geocaching::DraftReadStatus::Pending; + session->draft_read = &read; + blocked(); + session->draft_read = nullptr; + Client client; + client.value = gc::QueryClientPhase::Querying; + session->client = &client; + blocked(); + client.value = gc::QueryClientPhase::PageReady; + auto* original = session.get(); + assert(withIdentityMaintenance(action, &committed) && session.get() == original && !locked && wanted == 3); + committed = true; + assert(withIdentityMaintenance(action, &committed) && !session && wanted == 0 && !active && !network_requested && !local_storage_requested && epoch == 1 && !locked); + assert(withIdentityMaintenance(action, &committed) && epoch == 2); +} diff --git a/tests/hostlink/test_identity_init.cpp b/tests/hostlink/test_identity_init.cpp new file mode 100644 index 00000000..e0eb4aca --- /dev/null +++ b/tests/hostlink/test_identity_init.cpp @@ -0,0 +1,70 @@ +#include "platform/esp/common/identity_generation_store.h" +#include "platform/esp/common/identity_key_state.h" +#include +#include +struct FakeConsole +{ + void println(const char*) {} +} Serial; +class LxmfIdentity +{ + public: + bool init(); + bool initExisting(); + bool loaded = false, ready_ = false, identity_storage_error_ = false; + uint32_t author_generation_ = 0; + std::array identity_hash_{1}; + unsigned loads = 0, generates = 0; + bool loadFromPrefs() + { + ++loads; + return loaded; + } + bool generateAndPersist() + { + ++generates; + ready_ = true; + return true; + } +}; +// The production init policy, not a replacement implementation. Crypto/key +// loading is simulated; NVS absence and generation checks use their real code. +#include "identity_init_actual.inc" +int main() +{ + LxmfIdentity absent; + assert(!absent.initExisting() && absent.loads == 1 && absent.generates == 0); + assert(fake_nvs::commits == 0); + LxmfIdentity existing; + existing.loaded = true; + assert(existing.initExisting() && existing.ready_ && existing.author_generation_ == 1 && existing.generates == 0); + const auto existing_loads = existing.loads; + assert(existing.initExisting() && existing.loads == existing_loads); + LxmfIdentity reboot_required; + reboot_required.identity_storage_error_ = true; + reboot_required.author_generation_ = UINT32_MAX; + assert(!reboot_required.initExisting() && reboot_required.loads == 0 && reboot_required.generates == 0); + fake_nvs::values.clear(); + LxmfIdentity first; + assert(first.init() && first.generates == 1); + fake_nvs::values["lxmf_ident"]["enc_pub"] = std::vector(32, 1); + const auto original = fake_nvs::values["lxmf_ident"]; + LxmfIdentity partial; + assert(!partial.init() && partial.generates == 0 && partial.identity_storage_error_); + assert(fake_nvs::values["lxmf_ident"] == original); + fake_nvs::open_error = 42; + LxmfIdentity unavailable; + assert(!unavailable.init() && unavailable.generates == 0 && unavailable.identity_storage_error_); + fake_nvs::open_error = 0; + partial.loaded = true; + assert(partial.init() && partial.ready_ && !partial.identity_storage_error_ && partial.generates == 0); + assert(partial.author_generation_ == 1); + const auto loads = partial.loads, commits = fake_nvs::commits; + assert(partial.init() && partial.loads == loads && fake_nvs::commits == commits); + fake_nvs::values["tm_author"].clear(); + fake_nvs::fail_commit = true; + LxmfIdentity epoch_failure; + epoch_failure.loaded = true; + assert(!epoch_failure.init() && !epoch_failure.ready_ && epoch_failure.author_generation_ == 0 && epoch_failure.identity_storage_error_); + assert(epoch_failure.generates == 0 && fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_identity_load_validation.cpp b/tests/hostlink/test_identity_load_validation.cpp new file mode 100644 index 00000000..f42cb465 --- /dev/null +++ b/tests/hostlink/test_identity_load_validation.cpp @@ -0,0 +1,95 @@ +#include "platform/esp/common/config_preferences.h" +#include "platform/esp/common/identity_generation_recovery.h" +#include "platform/esp/common/identity_native_key_validation.h" +#include +#include +#include +#include +#include +namespace +{ +constexpr const char* kPrefsNs = "lxmf_ident"; +constexpr const char* kEncPubKey = "enc_pub"; +constexpr const char* kEncPrivKey = "enc_priv"; +constexpr const char* kSigPubKey = "sig_pub"; +constexpr const char* kSigPrivKey = "sig_priv"; +bool isAllZero(const uint8_t* data, size_t length) +{ + for (size_t i = 0; i < length; ++i) + if (data[i]) return false; + return true; +} +class LxmfIdentity +{ + public: + static constexpr size_t kSigPubKeySize = 32; + std::array enc_pub_{}, enc_priv_{}, sig_pub_{}; + std::array sig_priv_{}; + unsigned recomputed = 0; + uint32_t author_generation_ = 0; + std::array identity_hash_{}; + bool loadFromPrefs(); + void recomputeDerivedFields() + { + ++recomputed; + identity_hash_.fill(5); + } +}; +// Exact production loader; only derived-address calculation and NVS I/O are fixtures. +#include "identity_load_actual.inc" +} // namespace +void testIdentityStoredKeyValidation() +{ + LxmfIdentity source; + source.enc_priv_.fill(24); + source.enc_priv_[31] = 0x58; + assert(Curve25519::eval(source.enc_pub_.data(), source.enc_priv_.data(), nullptr)); + std::array seed{}; + seed.fill(3); + ed25519_create_keypair(source.sig_pub_.data(), source.sig_priv_.data(), seed.data()); + auto& storage = fake_nvs::values[kPrefsNs]; + storage[kEncPubKey] = std::vector(source.enc_pub_.begin(), source.enc_pub_.end()); + storage[kEncPrivKey] = std::vector(source.enc_priv_.begin(), source.enc_priv_.end()); + storage[kSigPubKey] = std::vector(source.sig_pub_.begin(), source.sig_pub_.end()); + storage[kSigPrivKey] = std::vector(source.sig_priv_.begin(), source.sig_priv_.end()); + const auto original = storage; + LxmfIdentity valid; + assert(valid.loadFromPrefs() && valid.recomputed == 1); + for (const char* key : {kEncPubKey, kEncPrivKey, kSigPubKey, kSigPrivKey}) + { + storage = original; + std::get>(storage[key])[11] ^= 1; + const auto damaged = storage; + LxmfIdentity invalid; + assert(!invalid.loadFromPrefs() && invalid.recomputed == 0); + assert(storage == damaged); + } + storage = original; + std::get>(storage[kEncPrivKey])[0] |= 1; + LxmfIdentity unclamped; + assert(!unclamped.loadFromPrefs()); + storage = original; + std::array combined_pub{}; + std::array combined_priv{}; + std::array work{}; + std::memcpy(combined_pub.data(), source.enc_pub_.data(), 32); + std::memcpy(combined_pub.data() + 32, source.sig_pub_.data(), 32); + std::memcpy(combined_priv.data(), source.enc_priv_.data(), 32); + std::memcpy(combined_priv.data() + 32, source.sig_priv_.data(), 64); + using namespace platform::esp::common; + assert(commitIdentityRecord(5, 5, combined_pub.data(), combined_priv.data(), work.data(), work.size(), validateNativeIdentityKeys) == IdentityRecordResult::Committed); + // New record is authoritative even when legacy encryption keys are damaged. + std::get>(storage[kEncPrivKey])[11] ^= 1; + LxmfIdentity restored; + assert(restored.loadFromPrefs() && restored.author_generation_ == 6 && restored.enc_priv_ == source.enc_priv_); + const auto record = storage["native_v1"]; + std::get>(storage["native_v1"])[8] ^= 1; + LxmfIdentity corrupt_record; + assert(!corrupt_record.loadFromPrefs() && corrupt_record.recomputed == 0); + storage["native_v1"] = record; + auto& epoch = std::get>(fake_nvs::values["tm_author"]["epoch"]); + epoch[4] ^= 1; + LxmfIdentity collision; + assert(!collision.loadFromPrefs() && collision.author_generation_ == 0); + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_identity_native_key_validation.cpp b/tests/hostlink/test_identity_native_key_validation.cpp new file mode 100644 index 00000000..07653ce7 --- /dev/null +++ b/tests/hostlink/test_identity_native_key_validation.cpp @@ -0,0 +1,54 @@ +#include "platform/esp/common/identity_native_key_validation.h" +#include "platform/esp/common/identity_seed_key_conversion.h" +#include +#include +#include + +void testIdentityStoredKeyValidation(); +void testIdentityRestoreActual(); +int main() +{ + testIdentityStoredKeyValidation(); + testIdentityRestoreActual(); + std::array public_key{}; + std::array private_key{}; + std::array seed{}, scratch{}; + for (size_t i = 0; i < 32; ++i) private_key[i] = static_cast(i + 1); + private_key[0] &= 0xf8; + private_key[31] = (private_key[31] & 0x7f) | 0x40; + // Independently derived with Python cryptography X25519, not this backend. + const uint8_t expected[] = {0x07, 0xa3, 0x7c, 0xbc, 0x14, 0x20, 0x93, 0xc8, 0xb7, 0x55, 0xdc, 0x1b, 0x10, 0xe8, 0x6c, 0xb4, 0x26, 0x37, 0x4a, 0xd1, 0x6a, 0xa8, 0x53, 0xed, 0x0b, 0xdf, 0xc0, 0xb2, 0xb8, 0x6d, 0x1c, 0x7c}; + std::memcpy(public_key.data(), expected, 32); + seed.fill(19); + ed25519_create_keypair(public_key.data() + 32, private_key.data() + 32, seed.data()); + std::array seeds{}; + std::array converted{}; + for(size_t i=0;i<32;++i)seeds[i]=uint8_t(i+1); + std::memcpy(seeds.data()+32,seed.data(),32); + const auto original_seeds=seeds; + assert(platform::esp::common::convertSeedIdentityKeys(public_key.data(),seeds.data(),converted.data(),converted.size(),scratch.data(),scratch.size())); + assert(converted==private_key&&seeds==original_seeds); + for(auto byte:scratch)assert(byte==0); + for(const size_t index:{size_t(0),size_t(32)}){ + public_key[index]^=1; + assert(!platform::esp::common::convertSeedIdentityKeys(public_key.data(),seeds.data(),converted.data(),converted.size(),scratch.data(),scratch.size())); + for(auto byte:converted)assert(byte==0); + public_key[index]^=1; + } + assert(!platform::esp::common::convertSeedIdentityKeys(public_key.data(),seeds.data(),seeds.data(),96,scratch.data(),scratch.size())); + assert(seeds==original_seeds); + assert(platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + for (auto byte : scratch) assert(byte == 0); + private_key[11] ^= 1; + assert(!platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + private_key[11] ^= 1; + public_key[3] ^= 1; + assert(!platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + public_key[3] ^= 1; + private_key[44] ^= 1; + assert(!platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + const auto saved = private_key; + assert(!platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), private_key.data() + 80, 32)); + assert(private_key == saved); + assert(!platform::esp::common::validateNativeIdentityKeys(public_key.data(), private_key.data(), scratch.data(), 31)); +} diff --git a/tests/hostlink/test_identity_restart_schedule.cpp b/tests/hostlink/test_identity_restart_schedule.cpp new file mode 100644 index 00000000..27d76c21 --- /dev/null +++ b/tests/hostlink/test_identity_restart_schedule.cpp @@ -0,0 +1,22 @@ +#include +#include +namespace +{ +uint32_t clock_now = 100, s_web_restart_at = 0; +bool s_sync_pending = true; +uint32_t millis() { return clock_now; } +#include "identity_restart_schedule_actual.inc" +} // namespace +int main() +{ + scheduleWebManagementRestart(); + assert(s_web_restart_at == 2100 && !s_sync_pending); + clock_now = 500; + s_sync_pending = true; + scheduleWebManagementRestart(); + assert(s_web_restart_at == 2100 && !s_sync_pending); + s_web_restart_at = 0; + clock_now = UINT32_MAX - 1999; + scheduleWebManagementRestart(); + assert(s_web_restart_at == 1); +} diff --git a/tests/hostlink/test_identity_restore_actual.cpp b/tests/hostlink/test_identity_restore_actual.cpp new file mode 100644 index 00000000..33791fc2 --- /dev/null +++ b/tests/hostlink/test_identity_restore_actual.cpp @@ -0,0 +1,109 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/identity_restore_request.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include "platform/esp/common/identity_generation_recovery.h" +#include "platform/esp/common/identity_generation_store.h" +#include "platform/esp/common/identity_key_state.h" +#include "platform/esp/common/identity_native_key_validation.h" +#include "platform/esp/common/identity_seed_key_conversion.h" +#include +#include +struct RestoreConsole +{ + void println(const char*) {} +} Serial; +// Address primitives are fixtures; real signing, pairing, storage policy and +// actual identity class/methods are exercised. Address algorithms have separate tests. +namespace chat::reticulum +{ +void computeIdentityHash(const uint8_t*, uint8_t* out) { std::memset(out, 9, 16); } +void computeNameHash(const char*, const char*, uint8_t* out) { std::memset(out, 8, 10); } +void computeDestinationHash(const uint8_t*, const uint8_t*, uint8_t* out) { std::memset(out, 10, 16); } +} // namespace chat::reticulum +namespace chat::lxmf +{ +bool LxmfIdentity::loadFromPrefs() { return false; } +bool LxmfIdentity::generateAndPersist() +{ + enc_priv_.fill(24); + enc_priv_[31] = 0x58; + assert(Curve25519::eval(enc_pub_.data(), enc_priv_.data(), nullptr)); + std::array seed{}; + seed.fill(17); + ed25519_create_keypair(sig_pub_.data(), sig_priv_.data(), seed.data()); + identity_hash_.fill(9); + destination_hash_.fill(10); + author_generation_ = 7; + ready_ = true; + return true; +} +#include "identity_init_actual.inc" +#include "identity_restore_actual.inc" +} // namespace chat::lxmf +void testIdentityRestoreActual() +{ + using namespace hostlink::management; + using Result = chat::DeviceIdentityRestoreResult; + using geocaching::protocol::CmpWriter; + const auto run = [](unsigned scenario) + { + fake_nvs::values.clear(); + fake_nvs::fail_commit = false; + chat::lxmf::LxmfIdentity identity; + assert(identity.init()); + std::array pub{}, signature{}; + std::array priv{}; + std::memcpy(pub.data(), identity.encryptionPublicKey(), 32); + std::memcpy(pub.data() + 32, identity.signingPublicKey(), 32); + assert(identity.copyNativePrivateKey(7, priv.data(), priv.size())); + const auto expected_private = priv; + std::array seeds{}; + std::memcpy(seeds.data(),priv.data(),32); + std::memset(seeds.data()+32,17,32); + if(scenario==6)seeds[5]^=1; + if(scenario==7)seeds[35]^=1; + const bool seed_format=scenario>=5; + std::array hash{}, delivery{}; + hash.fill(scenario == 3 ? 11 : 9); + delivery.fill(10); + std::array payload{}; + CmpWriter writer(payload.data(), payload.size()); + assert(writer.array(8) && writer.unsignedInteger(1) && writer.text("trailmate.identity.backup.v1") && writer.unsignedInteger(seed_format?1:0) && writer.binary({pub.data(), 64}) && writer.binary(seed_format?geocaching::ByteView{seeds.data(),64}:geocaching::ByteView{priv.data(),96}) && writer.binary({hash.data(), 16}) && writer.binary({delivery.data(), 16}) && writer.unsignedInteger(2)); + std::array work{}; + constexpr char domain[] = "TrailMate.Identity.Backup.v1"; + std::memcpy(work.data(), domain, sizeof(domain)); + std::memcpy(work.data() + sizeof(domain), payload.data(), writer.size()); + ed25519_sign(signature.data(), work.data(), sizeof(domain) + writer.size(), pub.data() + 32, priv.data() + 32); + if (scenario == 2) signature[0] ^= 1; + std::array body{}; + CmpWriter request(body.data(), body.size()); + assert(request.array(5) && request.unsignedInteger(scenario == 1 ? 6 : 7) && request.binary({pub.data(), 64}) && request.binary({payload.data(), writer.size()}) && request.binary({signature.data(), 64})); + body[request.size()] = 0xc3; + IdentityRestoreView parsed; + assert(parseIdentityRestoreRequest({body.data(), request.size() + 1}, parsed) == Status::Ok); + if (scenario == 4 || scenario == 8) fake_nvs::fail_commit = true; + const auto before = fake_nvs::values; + const auto result = identity.restoreNativeIdentity(parsed, work.data(), work.size()); + for (auto byte : work) assert(byte == 0); + if (scenario == 0 || scenario == 4 || scenario == 5 || scenario == 8) + { + assert(result == (scenario == 0 || scenario == 5 ? Result::CommittedRebootRequired : Result::IndeterminateRebootRequired)); + assert(!identity.isReady() && !identity.init()); + priv.fill(99); + assert(!identity.copyNativePrivateKey(7, priv.data(), priv.size())); + for (auto byte : priv) assert(byte == 0); + fake_nvs::fail_commit = false; + assert(platform::esp::common::readIdentityRecord(work.data(), work.size()) == platform::esp::common::IdentityRecordResult::Ready); + assert(platform::esp::common::identityRecordU32(work.data() + 8) == 8); + assert(!std::memcmp(work.data()+76,expected_private.data(),expected_private.size())); + } + else + { + assert(result == (scenario == 1 ? Result::StaleIdentity : Result::InvalidInput)); + assert(identity.isReady()); + assert(fake_nvs::values == before); + } + assert(fake_nvs::handles.empty()); + }; + for (unsigned scenario = 0; scenario < 9; ++scenario) run(scenario); +} diff --git a/tests/hostlink/test_identity_restore_network.cpp b/tests/hostlink/test_identity_restore_network.cpp new file mode 100644 index 00000000..4cd2651a --- /dev/null +++ b/tests/hostlink/test_identity_restore_network.cpp @@ -0,0 +1,100 @@ +#include "chat/ports/device_identity_restore.h" +#include "hostlink/identity_restore_request.h" +#include +#include +#include +namespace chat::lxmf +{ +namespace runtime +{ +enum class PropagationSyncStage +{ + Idle, + Waiting +}; +} +enum class LinkState +{ + Pending, + Handshake, + Active, + Stale, + Closed +}; +struct LinkSession +{ + LinkState state; +}; +struct Ledger +{ + size_t count = 0; + size_t size() const { return count; } +}; +struct PageClient +{ + bool pending = false; + bool empty() const { return !pending; } +}; +struct Propagation +{ + bool uploads = false; + size_t deliveries = 0; + struct State + { + runtime::PropagationSyncStage sync_stage = runtime::PropagationSyncStage::Idle; + } value; + bool hasPendingUploads() const { return uploads; } + size_t pendingDeliveryCount() const { return deliveries; } + const State& state() const { return value; } +}; +struct Links +{ + std::vector sessions; + template + void forEachSession(F function) + { + for (const auto& item : sessions) function(item); + } +}; +struct Identity +{ + unsigned calls = 0; + DeviceIdentityRestoreResult restoreNativeIdentity(const hostlink::management::IdentityRestoreView&, uint8_t*, size_t) + { + ++calls; + return DeviceIdentityRestoreResult::CommittedRebootRequired; + } +}; +struct LxmfAdapter +{ + Ledger delivery_attempt_ledger_; + PageClient network_page_client_; + Propagation propagation_client_; + Links link_manager_; + Identity identity_; + DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView&, uint8_t*, size_t); +}; +// Exact production admission method; the owners below expose controlled states. +#include "identity_restore_network_actual.inc" +} // namespace chat::lxmf +int main() +{ + using namespace chat::lxmf; + using Result = chat::DeviceIdentityRestoreResult; + hostlink::management::IdentityRestoreView request; + std::array work{}; + for (unsigned condition = 0; condition < 9; ++condition) + { + LxmfAdapter adapter; + if (condition == 0) adapter.delivery_attempt_ledger_.count = 1; + if (condition == 1) adapter.network_page_client_.pending = true; + if (condition == 2) adapter.propagation_client_.uploads = true; + if (condition == 3) adapter.propagation_client_.deliveries = 1; + if (condition == 4) adapter.propagation_client_.value.sync_stage = runtime::PropagationSyncStage::Waiting; + if (condition >= 5) adapter.link_manager_.sessions.push_back({static_cast(condition - 5)}); + assert(adapter.restoreDeviceIdentity(request, work.data(), work.size()) == Result::Unavailable && adapter.identity_.calls == 0); + } + LxmfAdapter idle; + idle.link_manager_.sessions.push_back({LinkState::Closed}); + assert(idle.restoreDeviceIdentity(request, work.data(), work.size()) == Result::CommittedRebootRequired && idle.identity_.calls == 1); +} diff --git a/tests/hostlink/test_identity_restore_request.cpp b/tests/hostlink/test_identity_restore_request.cpp new file mode 100644 index 00000000..be7afc19 --- /dev/null +++ b/tests/hostlink/test_identity_restore_request.cpp @@ -0,0 +1,46 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/identity_restore_request.h" +#include +#include +int main() +{ + using namespace hostlink::management; + using geocaching::protocol::CmpWriter; + std::array pub{}, signature{}; + std::array priv{}; + std::array seed{}; + seed.fill(17); + pub.fill(3); + priv.fill(4); + ed25519_create_keypair(pub.data() + 32, priv.data() + 32, seed.data()); + std::array hash{}; + hash.fill(9); + std::array payload{}; + CmpWriter p(payload.data(), payload.size()); + assert(p.array(8) && p.unsignedInteger(1) && p.text("trailmate.identity.backup.v1") && p.unsignedInteger(0) && p.binary({pub.data(), 64}) && p.binary({priv.data(), 96}) && p.binary({hash.data(), 16}) && p.binary({hash.data(), 16}) && p.unsignedInteger(3)); + std::array work{}; + constexpr char domain[] = "TrailMate.Identity.Backup.v1"; + std::memcpy(work.data(), domain, sizeof(domain)); + std::memcpy(work.data() + sizeof(domain), payload.data(), p.size()); + ed25519_sign(signature.data(), work.data(), sizeof(domain) + p.size(), pub.data() + 32, priv.data() + 32); + std::array request{}; + CmpWriter writer(request.data(), request.size()); + assert(writer.array(5) && writer.unsignedInteger(7) && writer.binary({pub.data(), 64}) && writer.binary({payload.data(), p.size()}) && writer.binary({signature.data(), 64})); + const size_t length = writer.size() + 1; + request[length - 1] = 0xc3; + IdentityRestoreView view; + assert(parseIdentityRestoreRequest({request.data(), length}, view) == Status::Ok); + assert(view.expected_generation == 7 && view.backup_generation == 3 && view.private_key.size == 96); + assert(verifyIdentityRestoreSignature(view, work.data(), work.size())); + for (size_t i = 0; i < sizeof(domain) + p.size(); ++i) assert(work[i] == 0); + const_cast(view.signature.data)[0] ^= 1; + assert(!verifyIdentityRestoreSignature(view, work.data(), work.size())); + const_cast(view.signature.data)[0] ^= 1; + assert(!verifyIdentityRestoreSignature(view, const_cast(view.payload.data), 352)); + request[length - 1] = 0xc2; + assert(parseIdentityRestoreRequest({request.data(), length}, view) == Status::Unauthorized && view.private_key.data == nullptr); + request[length - 1] = 0xc3; + request[length] = 0; + assert(parseIdentityRestoreRequest({request.data(), length + 1}, view) == Status::InvalidField); + for (size_t i = 0; i < length; ++i) assert(parseIdentityRestoreRequest({request.data(), i}, view) != Status::Ok); +} diff --git a/tests/hostlink/test_identity_restore_runtime.cpp b/tests/hostlink/test_identity_restore_runtime.cpp new file mode 100644 index 00000000..ea1ec717 --- /dev/null +++ b/tests/hostlink/test_identity_restore_runtime.cpp @@ -0,0 +1,48 @@ +#include "platform/esp/arduino_common/hostlink/hostlink_identity_restore.h" +#include +#include +int main() +{ + using namespace hostlink::management; + using geocaching::protocol::CmpWriter; + chat::IMeshAdapter adapter; + IdentityRestoreActivity activity; + std::array body{}; + std::array payload{}; + std::array pub{}, signature{}; + std::array priv{}; + std::array hash{}, session{}, output{}; + CmpWriter p(payload.data(), payload.size()); + assert(p.array(8) && p.unsignedInteger(1) && p.text("trailmate.identity.backup.v1") && p.unsignedInteger(0) && p.binary({pub.data(), 64}) && p.binary({priv.data(), 96}) && p.binary({hash.data(), 16}) && p.binary({hash.data(), 16}) && p.unsignedInteger(3)); + CmpWriter writer(body.data(), body.size()); + assert(writer.array(5) && writer.unsignedInteger(7) && writer.binary({pub.data(), 64}) && writer.binary({payload.data(), p.size()}) && writer.binary({signature.data(), 64})); + body[writer.size()] = 0xc3; + EnvelopeView request{session.data(), nullptr, uint16_t(Operation::IdentityBackupRestore), body.data(), writer.size() + 1}; + size_t written = 0; + auto run = [&]() + { return restoreIdentityOperation(adapter, request, activity, output.data(), output.size(), written); }; + activity.upload = true; + assert(run() == Status::Busy && restore_fake::allocations == 0); + activity.upload = false; + restore_fake::allocation_fails = true; + assert(run() == Status::Busy); + restore_fake::allocation_fails = false; + restore_fake::loaded = true; + restore_fake::phase = 1; + assert(run() == Status::Busy && adapter.calls == 0); + restore_fake::loaded = false; + restore_fake::journal_exists = true; + assert(run() == Status::StorageUnavailable && adapter.calls == 0); + restore_fake::journal_exists = false; + restore_fake::admitted = false; + assert(run() == Status::Busy && adapter.calls == 0); + restore_fake::admitted = true; + assert(run() == Status::Ok && written == 3 && output[1] == 0 && output[2] == 8 && restore_fake::dropped); + restore_fake::loaded = true; + restore_fake::phase = 2; + adapter.result = chat::DeviceIdentityRestoreResult::IndeterminateRebootRequired; + assert(run() == Status::Ok && output[1] == 1 && restore_fake::dropped); + adapter.result = chat::DeviceIdentityRestoreResult::InvalidInput; + assert(run() == Status::InvalidField && !restore_fake::dropped); + assert(restore_fake::allocations == restore_fake::frees && restore_fake::allocation_size <= 512); +} diff --git a/tests/hostlink/test_identity_restore_service.cpp b/tests/hostlink/test_identity_restore_service.cpp new file mode 100644 index 00000000..145b0951 --- /dev/null +++ b/tests/hostlink/test_identity_restore_service.cpp @@ -0,0 +1,50 @@ +#include "hostlink/identity_restore_service.h" +#include +#include +struct Adapter +{ + unsigned calls = 0; + chat::DeviceIdentityRestoreResult result = chat::DeviceIdentityRestoreResult::CommittedRebootRequired; + chat::DeviceIdentityRestoreResult restoreDeviceIdentity(const hostlink::management::IdentityRestoreView& view, uint8_t*, size_t capacity) + { + assert(view.expected_generation == 7 && capacity == 352); + ++calls; + return result; + } +}; +int main() +{ + using namespace hostlink::management; + using geocaching::protocol::CmpWriter; + std::array pub{}, sig{}; + std::array priv{}; + std::array hash{}; + std::array payload{}; + CmpWriter p(payload.data(), payload.size()); + assert(p.array(8) && p.unsignedInteger(1) && p.text("trailmate.identity.backup.v1") && p.unsignedInteger(0) && p.binary({pub.data(), 64}) && p.binary({priv.data(), 96}) && p.binary({hash.data(), 16}) && p.binary({hash.data(), 16}) && p.unsignedInteger(3)); + std::array body{}; + CmpWriter request(body.data(), body.size()); + assert(request.array(5) && request.unsignedInteger(7) && request.binary({pub.data(), 64}) && request.binary({payload.data(), p.size()}) && request.binary({sig.data(), 64})); + body[request.size()] = 0xc3; + std::array session{}, output{}; + EnvelopeView envelope{session.data(), nullptr, uint16_t(Operation::IdentityBackupRestore), body.data(), request.size() + 1}; + IdentityRestoreWork work; + IdentityRestoreActivity activity; + Adapter adapter; + size_t written = 99; + for (bool IdentityRestoreActivity::*field : {&IdentityRestoreActivity::configuration, &IdentityRestoreActivity::geocaching, &IdentityRestoreActivity::snapshot, &IdentityRestoreActivity::upload, &IdentityRestoreActivity::prepare, &IdentityRestoreActivity::publication, &IdentityRestoreActivity::network, &IdentityRestoreActivity::restart}) + { + activity.*field = true; + assert(processIdentityRestore(adapter, envelope, activity, work, output.data(), output.size(), written) == Status::Busy && written == 0 && adapter.calls == 0); + activity.*field = false; + } + assert(processIdentityRestore(adapter, envelope, activity, work, output.data(), output.size(), written) == Status::Ok && written == 3 && output[0] == 0x92 && output[1] == 0 && output[2] == 8); + adapter.result = chat::DeviceIdentityRestoreResult::IndeterminateRebootRequired; + assert(processIdentityRestore(adapter, envelope, activity, work, output.data(), output.size(), written) == Status::Ok && output[1] == 1); + adapter.result = chat::DeviceIdentityRestoreResult::StaleIdentity; + assert(processIdentityRestore(adapter, envelope, activity, work, output.data(), output.size(), written) == Status::StaleIdentity && written == 0); + const unsigned calls = adapter.calls; + body[request.size()] = 0xc2; + assert(processIdentityRestore(adapter, envelope, activity, work, output.data(), output.size(), written) == Status::Unauthorized && adapter.calls == calls); + for (size_t i = 0; i < sizeof(work); ++i) assert(reinterpret_cast(&work)[i] == 0); +} diff --git a/tests/hostlink/test_identity_signing_key_validation.cpp b/tests/hostlink/test_identity_signing_key_validation.cpp new file mode 100644 index 00000000..35e54b70 --- /dev/null +++ b/tests/hostlink/test_identity_signing_key_validation.cpp @@ -0,0 +1,28 @@ +#include "hostlink/identity_signing_key_validation.h" +#include +#include + +int main() +{ + std::array seed{}, public_key{}; + std::array private_key{}; + std::array scratch{}; + seed.fill(17); + ed25519_create_keypair(public_key.data(), private_key.data(), seed.data()); + scratch.fill(99); + assert(hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + for (size_t i = 0; i < 32; ++i) assert(scratch[i] == 0); + assert(scratch[32] == 99); + private_key[12] ^= 1; + assert(!hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + private_key[12] ^= 1; + public_key[7] ^= 1; + assert(!hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), scratch.data(), scratch.size())); + public_key[7] ^= 1; + const auto original = private_key; + assert(!hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), private_key.data(), private_key.size())); + assert(private_key == original); + assert(!hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), scratch.data(), 31)); + private_key.fill(0); + assert(!hostlink::validateExpandedSigningKey(public_key.data(), private_key.data(), scratch.data(), scratch.size())); +} diff --git a/tests/hostlink/test_idf_geo_scheduling.cpp b/tests/hostlink/test_idf_geo_scheduling.cpp new file mode 100644 index 00000000..bbc891d6 --- /dev/null +++ b/tests/hostlink/test_idf_geo_scheduling.cpp @@ -0,0 +1,81 @@ +#include "platform/esp/common/storage/storage_contracts.h" +#include +namespace geo +{ +static bool pending = true, finish_next = false; +static unsigned steps = 0; +bool workPending() { return pending; } +void step() +{ + ++steps; + if (finish_next) pending = false; +} +} // namespace geo +using namespace platform::esp::common::storage; +using Operation = StorageOperation; +using OperationGeneration = StorageOperationGeneration; +using Result = StorageOperationResult; +using ResultKind = StorageOperationResultKind; +struct Peer +{ + bool pending = true; + bool persistencePending() const { return pending; } +}; +struct Store +{ + Result stepMaintenance(Operation operation, OperationGeneration generation, const StorageOperationBudget&) + { + return Result::completedResult(operation, generation); + } +}; +struct Adapter final : ISemanticStorageAdapter +{ + Peer peer; + Store store; + Peer* peer_directory_ = &peer; + Store* store_ = &store; + uint8_t geocaching_steps_ = 0; + bool geocaching_turn_ = false; + OperationGeneration peer_payload_generation_ = 0; + unsigned peer_begins = 0, peer_steps = 0; + Result beginHydration(OperationGeneration generation) { return Result::completedResult(Operation::Hydrate, generation); } + Result stepHydration(OperationGeneration generation, const StorageOperationBudget&) { return beginHydration(generation); } + Result beginPeerPersistence(OperationGeneration generation) + { + ++peer_begins; + peer_payload_generation_ = generation; + return Result::inProgressResult(Operation::Persist, generation); + } + Result stepPeerPersistence(OperationGeneration generation) + { + if (generation != peer_payload_generation_) return Result::failure(ResultKind::StaleGeneration, Operation::Persist, generation); + ++peer_steps; + return Result::completedResult(Operation::Persist, generation); + } + Result execute(Operation operation, OperationGeneration generation) { return Result::completedResult(operation, generation); } + void cancelAtStepBoundary(Operation, OperationGeneration) override {} + // Production begin/step methods, with storage actions replaced by traces. +#include "idf_storage_scheduling_actual.inc" +}; +int main() +{ + Adapter adapter; + StorageOperationBudget budget{}; + assert(adapter.begin(Operation::Persist, 1).inProgress()); + assert(adapter.peer_begins == 1 && geo::steps == 0); + assert(adapter.step(Operation::Persist, 1, budget).completed()); + assert(adapter.begin(Operation::Persist, 2).inProgress()); + assert(adapter.geocaching_steps_ == 8); + assert(adapter.step(Operation::Persist, 2, budget).inProgress()); + assert(adapter.begin(Operation::Persist, 2).inProgress() && adapter.geocaching_steps_ == 7); + for (unsigned index = 0; index < 6; ++index) assert(adapter.step(Operation::Persist, 2, budget).inProgress()); + assert(adapter.step(Operation::Persist, 2, budget).completed()); + assert(geo::steps == 8 && adapter.peer_steps == 1 && adapter.peer_payload_generation_ == 0); + assert(adapter.begin(Operation::Persist, 3).inProgress() && adapter.peer_begins == 2); + assert(adapter.step(Operation::Persist, 3, budget).completed()); + adapter.peer.pending = false; + geo::finish_next = true; + assert(adapter.begin(Operation::Persist, 4).inProgress()); + assert(adapter.step(Operation::Persist, 4, budget).completed()); + assert(adapter.geocaching_steps_ == 0 && geo::steps == 9); +} diff --git a/tests/hostlink/test_idf_sd_block_error.cpp b/tests/hostlink/test_idf_sd_block_error.cpp new file mode 100644 index 00000000..cf6d3684 --- /dev/null +++ b/tests/hostlink/test_idf_sd_block_error.cpp @@ -0,0 +1,97 @@ +#include "platform/esp/common/storage/mount_io_state.h" +#include +#include +#include +#include +using Sector_t = uint32_t; +using esp_err_t = int; +constexpr int ESP_OK = 0; +#define ESP_LOGW(...) ((void)0) +struct sdmmc_card_t +{ + struct + { + uint32_t capacity = 128; + } csd; + struct + { + int slot = 0; + bool (*check_buffer_alignment)(int, const void*, size_t) = [](int, const void*, size_t) + { return true; }; + } host; +}; +struct FsBlockDeviceInterface +{ + virtual ~FsBlockDeviceInterface() = default; + virtual void end() = 0; + virtual bool isBusy() = 0; + virtual bool readSector(Sector_t, uint8_t*) = 0; + virtual bool readSectors(Sector_t, uint8_t*, size_t) = 0; + virtual Sector_t sectorCount() = 0; + virtual bool syncDevice() = 0; + virtual bool writeSector(Sector_t, const uint8_t*) = 0; + virtual bool writeSectors(Sector_t, const uint8_t*, size_t) = 0; +}; +static bool fail = false; +static unsigned transfers = 0; +int sdmmc_read_sectors(sdmmc_card_t*, void* output, Sector_t, size_t count) +{ + ++transfers; + if (fail) return 1; + std::memset(output, 0x55, count * 512); + return 0; +} +int sdmmc_write_sectors(sdmmc_card_t*, const void*, Sector_t, size_t) +{ + ++transfers; + return fail ? 1 : 0; +} +namespace platform::esp::arduino_common::storage::detail +{ +static platform::esp::storage::MountIoState s_mounted; +constexpr size_t kSdSectorSize = 512; +static uint8_t s_dma_sector[512]; +#include "idf_sd_block_error_actual.inc" +} // namespace platform::esp::arduino_common::storage::detail +static uint8_t buffer[1024]; +int main() +{ + using namespace platform::esp::arduino_common::storage::detail; + sdmmc_card_t card; + SdmmcBlockDevice device; + device.setCard(&card); + s_mounted = true; + { + platform::esp::storage::MountIoErrorScope scope(s_mounted); + assert(device.readSector(0, buffer) && !scope.error()); + } + fail = true; + for (int operation = 0; operation < 4; ++operation) + { + platform::esp::storage::MountIoErrorScope scope(s_mounted); + const auto before = transfers; + bool result = false; + switch (operation) + { + case 0: + result = device.readSector(0, buffer); + break; + case 1: + result = device.readSectors(0, buffer, 2); + break; + case 2: + result = device.writeSector(0, buffer); + break; + case 3: + result = device.writeSectors(0, buffer, 2); + break; + } + assert(!result && scope.error() && transfers == before + 1 && s_mounted); + } + device.end(); + { + platform::esp::storage::MountIoErrorScope scope(s_mounted); + assert(!device.syncDevice() && scope.error()); + } + return 0; +} diff --git a/tests/hostlink/test_idf_sd_directory.cpp b/tests/hostlink/test_idf_sd_directory.cpp new file mode 100644 index 00000000..0610574b --- /dev/null +++ b/tests/hostlink/test_idf_sd_directory.cpp @@ -0,0 +1,221 @@ +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/arduino_common/storage/sd_file_lifetime.h" +#include "platform/esp/common/storage/mount_io_state.h" +#include "platform/esp/idf_common/hostlink_track_history_scan.h" +#include +#include +#include +#include +#include +namespace platform::esp::arduino_common::storage +{ +static bool strict_allocation_fail = false; +static unsigned strict_allocation_attempts = 0; +constexpr unsigned MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 2; +void* heap_caps_malloc(size_t bytes, unsigned caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + ++strict_allocation_attempts; + return strict_allocation_fail ? nullptr : ::operator new(bytes, std::nothrow); +} +static uint32_t media = 1; +static bool s_external_block_owner_active = false, lock_available = true, replace_on_lock = false; +static bool missing = false, open_error = false, entry_end = false, entry_error = false, name_error = false; +static bool read_error = false, replace_on_read = false; +static unsigned closes = 0, next_calls = 0; +static const char* entry_name="track.gpx"; +static bool entry_directory=false; +static uint64_t probe_bytes = 16; +static platform::esp::storage::MountIoState s_mounted; +using SdIoErrorScope = platform::esp::storage::MountIoErrorScope; +constexpr int O_RDONLY = 0; +struct Info +{ + SdCardBackend backend = SdCardBackend::SdFat; +} s_info; +struct FsFile +{ + bool opened = false, directory = true; + void* volume = nullptr; + uint64_t cursor = 0; + explicit operator bool() const { return opened; } + bool isDir() const { return directory; } + bool close() + { + ++closes; + opened = false; + return true; + } + FsFile openNextFile(int) + { + cursor += 32; + ++next_calls; + if (entry_error) s_mounted.markFailure(); + return {!(entry_error || entry_end), entry_directory}; + } + bool getName(char* name, size_t capacity) + { + if (name_error || capacity <= std::strlen(entry_name)) return false; + std::memcpy(name, entry_name, std::strlen(entry_name)+1); + return true; + } + int getError() const { return 0; } + uint64_t fileSize() const { return probe_bytes; } + uint64_t curPosition() const { return cursor; } + bool seekSet(uint64_t position) + { + cursor = position; + return true; + } + int read(void* output, size_t bytes) + { + if (read_error) + { + s_mounted.markFailure(); + return -1; + } + if (replace_on_read) + { + ++media; + replace_on_read = false; + } + std::memset(output, 0x55, bytes); + return static_cast(bytes); + } +}; +struct Volume +{ + FsFile open(const char*, int) + { + if (open_error) s_mounted.markFailure(); + return {!missing && !open_error}; + } +} s_volume; +uint32_t sd_media_session() { return media; } +bool sd_external_block_owner_active() { return s_external_block_owner_active; } +bool sd_card_ready() { return s_mounted && !s_external_block_owner_active; } +bool path_empty(const char* path) { return !path || !*path; } +const char* normalize_sd_path(const char* path) { return path; } +void copy_path(char* out, size_t bytes, const char* path) { std::snprintf(out, bytes, "%s", path); } +uint32_t sd_io_begin(const char*, const char*, size_t = 0) { return 0; } +void sd_io_end(const char*, const char*, uint32_t, bool, size_t = 0, int32_t = 0) {} +class SdRuntimeBusGuard +{ + public: + explicit SdRuntimeBusGuard(const char*) + { + if (replace_on_lock) + { + ++media; + replace_on_lock = false; + } + } + bool locked() const { return lock_available; } +}; +#include "idf_sd_directory_actual.inc" +#include "idf_sd_probe_actual.inc" +} // namespace platform::esp::arduino_common::storage +static uint8_t buffer[4096]; +int main() +{ + using namespace platform::esp::arduino_common::storage; + strict_allocation_fail = true; + { + SdRuntimeDir failed(SdRuntimeMemoryPolicy::PsramOnly); + assert(!failed.has_backing_storage() && !failed.open("/trailmate/tracks")); + } + strict_allocation_fail = false; + { + SdRuntimeDir admitted(SdRuntimeMemoryPolicy::PsramOnly); + assert(admitted.has_backing_storage()); + } + assert(strict_allocation_attempts == 2); + { + using namespace hostlink::management::publication_runtime; + s_mounted=true; + SdRuntimeDir directory(SdRuntimeMemoryPolicy::PsramOnly); + HistoryScan scan(directory,media);track_sharing::PublicationSlotKey key; + entry_name="1111111111111111111111111111111111111111111111111111111111111111";entry_directory=true; + lock_available=false;assert(scan.step(key)==HistoryScanResult::Busy && !directory.is_open());lock_available=true; + assert(scan.step(key)==HistoryScanResult::Pending && !directory.is_open()); + entry_name="22222222222222222222222222222222.pua";entry_directory=false; + const auto candidate=scan.step(key); + assert(candidate==HistoryScanResult::Candidate && !directory.is_open() && key.object[0]==0x11 && key.operation[0]==0x22); + entry_end=true;assert(scan.step(key)==HistoryScanResult::Pending && !directory.is_open()); + assert(scan.step(key)==HistoryScanResult::End && !directory.is_open());entry_end=false; + ++media;assert(scan.step(key)==HistoryScanResult::Unavailable); + entry_name="track.gpx"; + } + media=1;closes=0;next_calls=0; + using namespace platform::esp::arduino_common::storage; + s_mounted = true; + SdRuntimeDir dir; + missing = true; + assert(dir.open_read_status("/trackers", media) == SdFileReadStatus::Missing); + open_error = true; + assert(dir.open_read_status("/trackers", media) == SdFileReadStatus::IoError); + missing = open_error = false; + lock_available = false; + assert(dir.open_read_status("/trackers", media) == SdFileReadStatus::Busy); + lock_available = true; + replace_on_lock = true; + assert(dir.open_read_status("/trackers", media) == SdFileReadStatus::Unavailable); + assert(dir.open_read_status("/trackers", media) == SdFileReadStatus::Ready); + char name[16] = {}; + bool is_dir = true; + lock_available = false; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::Busy && next_calls == 0); + lock_available = true; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::Entry && !is_dir && !std::strcmp(name, "track.gpx")); + entry_end = true; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::End); + entry_error = true; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::IoError); + entry_end = entry_error = false; + name_error = true; + const auto before_name = closes; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::IoError && closes == before_name + 1 && !name[0]); + name_error = false; + const auto before_close = closes; + replace_on_lock = true; + assert(dir.read_next_status(name, sizeof(name), &is_dir) == SdDirReadStatus::Unavailable); + dir.close(); + assert(closes == before_close && !dir.is_open()); + assert(dir.open("/trackers")); + s_external_block_owner_active = true; + dir.close(); + assert(closes == before_close); + s_external_block_owner_active = false; + missing = true; + assert(sd_read_file("file", buffer, sizeof(buffer)).status == SdFileReadStatus::Missing); + open_error = true; + assert(sd_read_file("file", buffer, sizeof(buffer)).status == SdFileReadStatus::IoError); + missing = open_error = false; + read_error = true; + assert(sd_read_file("file", buffer, sizeof(buffer)).status == SdFileReadStatus::IoError); + read_error = false; + replace_on_read = true; + assert(sd_read_file("file", buffer, sizeof(buffer)).status == SdFileReadStatus::Unavailable); + probe_bytes = 3000; + const auto result = sd_read_file("file", buffer, sizeof(buffer)); + assert(result.status == SdFileReadStatus::Ready && result.bytes_read == 3000); + platform::esp::storage::MountIoState state; + state = true; + { + platform::esp::storage::MountIoErrorScope outer(state); + { + platform::esp::storage::MountIoErrorScope inner(state); + state.markFailure(); + assert(inner.error()); + } + assert(outer.error()); + } + { + platform::esp::storage::MountIoErrorScope fresh(state); + assert(!fresh.error()); + } + assert(state && state.failed()); + state = false; + assert(!state && !state.failed()); + return 0; +} diff --git a/tests/hostlink/test_idf_sd_file_session.cpp b/tests/hostlink/test_idf_sd_file_session.cpp new file mode 100644 index 00000000..2ab6c383 --- /dev/null +++ b/tests/hostlink/test_idf_sd_file_session.cpp @@ -0,0 +1,194 @@ +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/esp/arduino_common/storage/sd_file_lifetime.h" +#include +#include +#include +#include +#include +#include + +namespace platform::esp::arduino_common::storage +{ +static uint32_t media = 1; +static bool s_external_block_owner_active = false, replace_on_lock = false, owner_on_lock = false, lock_available = true; +static void* s_storage_mutex = reinterpret_cast(1); +static unsigned io_calls = 0; +static bool strict_allocation_fail = false; +static unsigned strict_allocation_attempts = 0; +constexpr unsigned MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 2; +void* heap_caps_malloc(size_t bytes, unsigned caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + ++strict_allocation_attempts; + return strict_allocation_fail ? nullptr : ::operator new(bytes, std::nothrow); +} +struct Info +{ + SdCardBackend backend = SdCardBackend::SdFat; +} s_info; +struct FsFile +{ + bool opened = false; + void* volume = nullptr; // Real SdFat file state contains a volume pointer. + explicit operator bool() const { return opened; } + void close() + { + ++io_calls; + opened = false; + } + int available() const + { + ++io_calls; + return 16; + } + int read(void*, size_t bytes) + { + ++io_calls; + return static_cast(bytes); + } + int read() + { + ++io_calls; + return 7; + } + size_t write(const void*, size_t bytes) + { + ++io_calls; + return bytes; + } + bool seekSet(uint64_t) + { + ++io_calls; + return true; + } + uint64_t curPosition() const + { + ++io_calls; + return 8; + } + uint64_t fileSize() const + { + ++io_calls; + return 16; + } + bool sync() + { + ++io_calls; + return true; + } +}; +struct Volume +{ + FsFile open(const char*, int) { return {true}; } +} s_volume; +uint32_t sd_media_session() { return media; } +bool path_empty(const char* path) { return !path || !*path; } +const char* normalize_sd_path(const char* path) { return path; } +void copy_path(char* out, size_t bytes, const char* text) { std::snprintf(out, bytes, "%s", text); } +bool open_mode_mutates(const char* mode) { return mode && (std::strchr(mode, 'w') || std::strchr(mode, 'a') || std::strchr(mode, '+')); } +int sdfat_open_flags(const char*) { return 0; } +uint32_t sd_io_begin(const char*, const char*, size_t = 0) { return 0; } +void sd_io_end(const char*, const char*, uint32_t, bool, size_t = 0, int64_t = 0) {} +bool sd_mutation_blocked_by_external_owner(const char*, const char*, uint32_t, size_t = 0) { return s_external_block_owner_active; } +class SdRuntimeBusGuard +{ + public: + explicit SdRuntimeBusGuard(const char*) + { + if (replace_on_lock) + { + ++media; + replace_on_lock = false; + } + if (owner_on_lock) + { + s_external_block_owner_active = true; + owner_on_lock = false; + } + } + bool locked() const { return lock_available; } +}; +#include "idf_sd_file_session_actual.inc" +} // namespace platform::esp::arduino_common::storage +int main() +{ + using namespace platform::esp::arduino_common::storage; + { + SdRuntimeFile strict(SdRuntimeMemoryPolicy::PsramOnly); + assert(strict.has_backing_storage()); + assert(strict_allocation_attempts == 1); + } + strict_allocation_fail = true; + { + SdRuntimeFile strict(SdRuntimeMemoryPolicy::PsramOnly); + assert(!strict.has_backing_storage()); + assert(!strict.open("snapshot", "r", media)); + SdRuntimeFile ordinary; + assert(ordinary.has_backing_storage()); + } + assert(strict_allocation_attempts == 2); + strict_allocation_fail = false; + uint8_t buffer[8] = {}; + for (int transition = 0; transition < 2; ++transition) + { + for (int operation = 0; operation < 10; ++operation) + { + s_external_block_owner_active = false; + SdRuntimeFile file; + assert(file.open("snapshot", "r+b", media)); + const auto before = io_calls; + if (transition == 0) replace_on_lock = true; + else owner_on_lock = true; + switch (operation) + { + case 0: + assert(file.read(buffer, sizeof(buffer)) < 0); + break; + case 1: + assert(file.read_bytes(reinterpret_cast(buffer), sizeof(buffer)) == 0); + break; + case 2: + assert(file.read_byte() == -1); + break; + case 3: + assert(file.write(buffer, sizeof(buffer)) == 0); + break; + case 4: + assert(!file.seek(0)); + break; + case 5: + assert(file.available() == 0); + break; + case 6: + assert(file.size() == 0); + break; + case 7: + assert(file.position() == 0); + break; + case 8: + assert(!file.flush()); + break; + case 9: + file.close(); + break; + } + assert(io_calls == before && !file.is_open()); + file.close(); + assert(io_calls == before); + } + } + s_external_block_owner_active = false; + SdRuntimeFile file; + assert(file.open("snapshot", "r+b", media)); + assert(file.read(buffer, sizeof(buffer)) == 8 && file.seek(0) && file.write(buffer, sizeof(buffer)) == 8 && file.flush()); + const auto before = io_calls; + lock_available = false; + assert(file.read(buffer, sizeof(buffer)) == -1 && file.read_busy()); + file.close(); + assert(!file.is_open() && io_calls == before); + lock_available = true; + assert(file.open("snapshot", "r+b", media)); + file.close(); + assert(io_calls == before + 1); + return 0; +} diff --git a/tests/hostlink/test_idf_snapshot_lease.cpp b/tests/hostlink/test_idf_snapshot_lease.cpp new file mode 100644 index 00000000..25efd33c --- /dev/null +++ b/tests/hostlink/test_idf_snapshot_lease.cpp @@ -0,0 +1,128 @@ +#include "platform/esp/idf_common/tracker_snapshot_lease.h" +#include +using namespace platform::esp::storage; +using namespace hostlink::management; +static uint32_t media = 1; +static bool external = false, recording = false, held = false, ready = true, replace_on_probe = false, replace_on_remove = false; +static unsigned releases = 0, deletes = 0, publishes = 0; +static auto probe_status = platform::esp::arduino_common::storage::SdFileReadStatus::Ready; +namespace platform::ui::tracker +{ +bool try_acquire_snapshot_read() +{ + if (held || recording) return false; + held = true; + return true; +} +void release_snapshot_read() +{ + assert(held); + held = false; + ++releases; +} +} // namespace platform::ui::tracker +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready() { return ready; } +uint32_t sd_media_session() { return media; } +bool sd_external_block_owner_active() { return external; } +SdFileReadResult sd_read_file(const char*, uint8_t*, size_t) +{ + if (replace_on_probe) + { + ++media; + replace_on_probe = false; + } + SdFileReadResult result; + result.status = probe_status; + return result; +} +bool sd_remove(const char*, uint32_t expected) +{ + if (replace_on_remove) + { + ++media; + replace_on_remove = false; + } + if (expected != media || external) return false; + ++deletes; + return true; +} +} // namespace platform::esp::arduino_common::storage +class Io : public RecordFileIo +{ + public: + explicit Io(uint32_t captured) : session(captured) {} + OpenResult open(const char*, Mode) override { return {}; } + bool close(Handle) override { return false; } + bool size(Handle, uint64_t&) override { return false; } + bool seek(Handle, uint64_t) override { return false; } + size_t read(Handle, void*, size_t) override { return 0; } + size_t write(Handle, const void*, size_t) override { return 0; } + bool sync(Handle) override { return false; } + bool publish(const char*, const char*) override + { + if (session != media || external || fail) return false; + ++publishes; + return true; + } + uint32_t session; + bool fail = false; +}; +static uint8_t id[16] = {1}; +constexpr const char* temporary = "/trailmate/web-tracks/01000000000000000000000000000000.new"; +constexpr const char* destination = "/trailmate/web-tracks/01000000000000000000000000000000.tsn"; +int main() +{ + using platform::esp::idf_common::TrackerSnapshotLease; + Io io(media); + TrackerSnapshotLease lease(io, "/trackers/closed.gpx", temporary, destination, media); + recording = true; + assert(lease.acquire(id, 100, 1) == SnapshotStorageResult::Busy && !held); + recording = false; + assert(lease.acquire(id, 100, 1) == SnapshotStorageResult::Ok && held && lease.valid()); + TrackSnapshotMetadata metadata; + std::memcpy(metadata.id, id, 16); + metadata.bytes = 101; + metadata.format = 1; + assert(!lease.commit(metadata) && publishes == 0); + metadata.bytes = 100; + io.fail = true; + assert(!lease.commit(metadata)); + io.fail = false; + assert(lease.commit(metadata) && publishes == 1); + assert(lease.discard() && deletes == 0); + lease.release(); + lease.release(); + assert(!held && releases == 1); + assert(lease.acquire(id, 100, 1) == SnapshotStorageResult::Ok); + probe_status = platform::esp::arduino_common::storage::SdFileReadStatus::Busy; + assert(!lease.discard() && deletes == 0 && held); + probe_status = platform::esp::arduino_common::storage::SdFileReadStatus::Ready; + replace_on_remove = true; + assert(!lease.discard() && deletes == 0); + assert(lease.discard() && deletes == 0); + lease.release(); + Io new_io(media); + TrackerSnapshotLease fresh(new_io, "/trackers/closed.gpx", temporary, destination, media); + assert(fresh.acquire(id, 100, 1) == SnapshotStorageResult::Ok); + replace_on_probe = true; + assert(fresh.discard() && deletes == 0); + fresh.release(); + TrackerSnapshotLease invalid(new_io, "/trackers/../secret", temporary, destination, media); + assert(invalid.acquire(id, 100, 1) == SnapshotStorageResult::Failed && !held); + TrackerSnapshotLease wrong_id(new_io, "/trackers/closed.gpx", "/trailmate/web-tracks/02000000000000000000000000000000.new", destination, media); + assert(wrong_id.acquire(id, 100, 1) == SnapshotStorageResult::Failed && !held); + external = true; + TrackerSnapshotLease owned(new_io, "/trackers/closed.gpx", temporary, destination, media); + assert(owned.acquire(id, 100, 1) == SnapshotStorageResult::Failed && !held); + external = false; + Io final_io(media); + TrackerSnapshotLease cleanup(final_io, "/trackers/closed.gpx", temporary, destination, media); + assert(cleanup.acquire(id, 100, 1) == SnapshotStorageResult::Ok); + assert(cleanup.discard() && deletes == 1); + probe_status = platform::esp::arduino_common::storage::SdFileReadStatus::Missing; + assert(cleanup.discard() && deletes == 1); + cleanup.release(); + return 0; +} diff --git a/tests/hostlink/test_idf_tracker_gate.cpp b/tests/hostlink/test_idf_tracker_gate.cpp new file mode 100644 index 00000000..a5df67f3 --- /dev/null +++ b/tests/hostlink/test_idf_tracker_gate.cpp @@ -0,0 +1,114 @@ +#include "platform/ui/tracker_access_gate.h" +#include +#include +#include +#include +#include + +static bool available = true, fail_open = false; +static unsigned headers = 0, footers = 0, samples = 0, removals = 0; +namespace platform::ui::gps +{ +struct GpsState +{ + bool valid = true; + double lat = 30, lng = 120; +}; +GpsState get_data() { return {}; } +} // namespace platform::ui::gps +namespace platform::esp::idf_common::bsp_runtime +{ +bool ensure_sdcard_ready() { return available; } +} // namespace platform::esp::idf_common::bsp_runtime +namespace platform::esp::arduino_common::storage +{ +bool sd_remove(const char*) +{ + ++removals; + return true; +} +} // namespace platform::esp::arduino_common::storage +namespace platform::ui::tracker +{ +struct File +{ + bool opened = false; + bool open(const char*, const char*) + { + opened = !fail_open; + return opened; + } + void close() { opened = false; } + bool is_open() const { return opened; } +}; +struct TrackerRuntimeState +{ + bool recording = false, auto_recording = false, has_last_point = false, distance_only = false; + int format = 0; + uint32_t interval_seconds = 1; + uint64_t last_sample_ms = 0; + double last_lat = 0, last_lng = 0; + File file; + std::string current_rel_path; +}; +static TrackerRuntimeState runtime; +constexpr double kDistanceOnlyThresholdMeters = 5.0; +static TrackerControlState tracker_control; +TrackerRuntimeState& state() { return runtime; } +bool ensure_track_dir() { return available; } +std::string make_track_path(int) { return "/trackers/test.gpx"; } +std::string full_path_for(const std::string& value) { return value; } +void write_header(TrackerRuntimeState&) { ++headers; } +void write_footer(TrackerRuntimeState&) { ++footers; } +void write_point(TrackerRuntimeState&, const platform::ui::gps::GpsState&, uint32_t) { ++samples; } +uint64_t now_ms() +{ + static uint64_t counter = 0; + return counter += 2000; +} +uint32_t now_seconds() { return 1700000000; } +double distance_meters(double, double, double, double) { return 10; } +void poll(); +#include "idf_tracker_gate_actual.inc" +} // namespace platform::ui::tracker +int main() +{ + using namespace platform::ui::tracker; + assert(start_recording() && runtime.recording && headers == 1 && samples == 1); + assert(!try_acquire_snapshot_read()); + stop_recording(); + assert(!runtime.recording && footers == 1); + assert(try_acquire_snapshot_read()); + assert(!try_acquire_snapshot_read() && !start_recording() && !remove_track("/trackers/test.gpx")); + runtime.auto_recording = true; + poll(); + stop_recording(); + assert(!runtime.recording && samples == 1 && removals == 0); + release_snapshot_read(); + assert(remove_track("/trackers/test.gpx") && removals == 1); + poll(); + assert(!runtime.recording && !tracker_control.stop_requested.load()); + poll(); + assert(runtime.recording && headers == 2 && samples == 2); + stop_recording(); + runtime.auto_recording = false; + fail_open = true; + assert(!start_recording()); + assert(try_acquire_snapshot_read()); + release_snapshot_read(); + assert(tracker_control.access.tryAcquire()); + assert(!try_acquire_snapshot_read() && !start_recording()); + tracker_control.access.release(); + fail_open = false; + assert(start_recording() && runtime.recording); + assert(tracker_control.access.tryAcquire()); + stop_recording(); + assert(runtime.recording && tracker_control.stop_requested.load()); + tracker_control.access.release(); + assert(!try_acquire_snapshot_read() && !start_recording()); + poll(); + assert(!runtime.recording && !tracker_control.stop_requested.load()); + assert(try_acquire_snapshot_read()); + release_snapshot_read(); + return 0; +} diff --git a/tests/hostlink/test_image_source_binding.cpp b/tests/hostlink/test_image_source_binding.cpp new file mode 100644 index 00000000..ff1dda77 --- /dev/null +++ b/tests/hostlink/test_image_source_binding.cpp @@ -0,0 +1,220 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/file_integrity.h" +#include "track_sharing/image_source_binding_store.h" +#include +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + size_t offset = 0; + unsigned operations = 0, fail_at = 0; + bool active = false, short_write = false; + uint64_t reported_size = 0; + bool fail() { return ++operations == fail_at; } + OpenResult open(const char* name, Mode mode) + { + assert(!active); + if (fail()) return {nullptr, OpenStatus::Unavailable}; + path = name; + offset = 0; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return !fail(); + } + bool size(void*, uint64_t& size) + { + if (fail()) return false; + size = reported_size ? reported_size : files[path].size(); + return true; + } + bool seek(void*, uint64_t at) + { + if (fail()) return false; + offset = size_t(at); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + if (fail()) return 0; + length = std::min(length, files[path].size() - offset); + std::memcpy(output, files[path].data() + offset, length); + offset += length; + return length; + } + size_t write(void*, const uint8_t* input, size_t length) + { + if (fail()) return 0; + if (short_write) length /= 2; + files[path].assign(input, input + length); + return length; + } + bool sync(void*) { return !fail(); } + bool remove(const char* name) + { + assert(!active); + return !fail() && files.erase(name) == 1; + } + bool rename(const char* from, const char* to) + { + assert(!active); + if (fail() || !files.count(from) || files.count(to)) return false; + files[to] = std::move(files[from]); + files.erase(from); + return true; + } +}; +using Result = track_sharing::ImageBindingResult; +const track_sharing::ImageBindingPaths paths = {"image.tmp", "image.jpg", "source.new", "source.meta", "thumb.bmp", "view.bmp"}; +std::array digest(const std::vector& bytes) +{ + std::array hash{}; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 stream(context); + assert(stream.start() && stream.update(bytes.data(), bytes.size()) && stream.finish(hash.data())); + return hash; +} +std::vector record(const track_sharing::ImageSourceBindingView& value) +{ + std::vector output(track_sharing::kImageSourceBindingBytes); + assert(track_sharing::encodeImageSourceBinding(value, output.data(), output.size())); + return output; +} +int main() +{ + const std::vector old_image = {1, 2, 3}, new_image = {4, 5, 6, 7, 8}, old_reference = {'a'}, new_reference = {'b'}; + const auto old_file = digest(old_image), new_file = digest(new_image), old_source = digest(old_reference), new_source = digest(new_reference); + // Independent Python hashlib SHA-256 fixture for bytes 04 05 06 07 08. + const std::array golden = {0x01, 0x5d, 0xaf, 0x60, 0x13, 0x37, 0x6b, 0x77, 0x42, 0x1b, 0x7f, 0xb9, 0xd5, 0x65, 0x4d, 0x84, 0x4c, 0x8c, 0xba, 0x4f, 0x7a, 0x53, 0x26, 0xd8, 0x29, 0x42, 0x6d, 0x87, 0xfd, 0x0d, 0x1a, 0x5f}; + assert(new_file == golden); + const track_sharing::ImageSourceBindingView old_binding = {{old_source.data(), 32}, {old_file.data(), 32}, 3}, expected = {{new_source.data(), 32}, {new_file.data(), 32}, 5}; + const auto old_record = record(old_binding), new_record = record(expected); + assert(new_record.size() == 75 && !std::memcmp(new_record.data(), "TMSI", 4)); + for (size_t i = 0; i < new_record.size(); ++i) + { + auto corrupt = new_record; + corrupt[i] ^= 1; + track_sharing::ImageSourceBindingView view; + assert(!track_sharing::decodeImageSourceBinding(corrupt.data(), corrupt.size(), view) && !view.file_bytes); + } + auto oversized = expected; + oversized.file_bytes = track_sharing::kImageSourceByteLimit + 1; + uint8_t scratch[128]{}; + assert(!track_sharing::encodeImageSourceBinding(oversized, scratch, sizeof(scratch))); + oversized.file_bytes = 0; + assert(!track_sharing::encodeImageSourceBinding(oversized, scratch, sizeof(scratch))); + track_sharing::ImageSourceBindingView alias; + assert(track_sharing::decodeImageSourceBinding(new_record.data(), new_record.size(), alias)); + auto aliased = new_record; + track_sharing::ImageSourceBindingView aliased_view; + assert(track_sharing::decodeImageSourceBinding(aliased.data(), aliased.size(), aliased_view)); + assert(!track_sharing::encodeImageSourceBinding(aliased_view, aliased.data(), aliased.size())); + auto initial = [&]() + {Io io;io.files={{paths.image,old_image},{paths.binding,old_record},{paths.candidate_image,new_image},{paths.preview,{9}},{paths.view,{10}}};return io; }; + unsigned terminal_operations = 0; + for (unsigned failure = 0; failure < 50; ++failure) + { + auto io = initial(); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + track_sharing::FileIntegrityCheck verified(io, hash); + assert(verified.begin(paths.candidate_image, new_image.size(), new_file.data()) == track_sharing::FileIntegrityResult::Pending); + while (verified.active()) verified.step(scratch, sizeof(scratch)); + assert(verified.matchesSource(paths.candidate_image, 5, new_file.data())); + assert(!verified.matchesSource("other.tmp", 5, new_file.data())); + assert(!verified.matchesSource(paths.candidate_image, 4, new_file.data())); + assert(!verified.matchesSource(paths.candidate_image, 5, old_file.data())); + track_sharing::ImageSourceBindingStore store(io); + static_assert(sizeof(store) == sizeof(void*), "Store retained metadata or buffers"); + io.operations = 0; + io.fail_at = failure; + const auto status = store.commit(paths, expected, verified, scratch, sizeof(scratch)); + assert(!io.active); + if (!failure) + { + assert(status == Result::Ready); + terminal_operations = io.operations; + } + else if (failure <= terminal_operations) assert(status != Result::Ready); + else + { + assert(status == Result::Ready); + break; + } + // Whatever failed, a published binding must describe its image bytes. + if (io.files.count(paths.binding)) + { + track_sharing::ImageSourceBindingView saved; + assert(track_sharing::decodeImageSourceBinding(io.files.at(paths.binding).data(), 75, saved)); + assert(io.files.count(paths.image)); + const auto actual = digest(io.files.at(paths.image)); + assert(saved.file_bytes == io.files.at(paths.image).size() && !std::memcmp(saved.file_hash.data, actual.data(), 32)); + if (io.files.at(paths.image) == new_image) assert(!io.files.count(paths.preview) && !io.files.count(paths.view)); + } + if (status == Result::Ready) + { + assert(io.files.at(paths.image) == new_image && io.files.at(paths.binding) == new_record); + assert(!io.files.count(paths.candidate_image) && !io.files.count(paths.candidate_binding)); + // Reusing a proof after its candidate was renamed must not delete + // the published image on retry. + io.fail_at = 0; + const auto before = io.files; + assert(store.commit(paths, expected, verified, scratch, sizeof(scratch)) == Result::Unverified && io.files == before); + } + } + auto io = initial(); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + track_sharing::FileIntegrityCheck unverified(io, hash); + track_sharing::ImageSourceBindingStore store(io); + const auto before = io.files; + assert(store.commit(paths, expected, unverified, scratch, sizeof(scratch)) == Result::Unverified && io.files == before); + assert(unverified.begin(paths.candidate_image, 5, new_file.data()) == track_sharing::FileIntegrityResult::Pending); + while (unverified.active()) unverified.step(scratch, sizeof(scratch)); + auto alias_paths = paths; + alias_paths.binding = "IMAGE.JPG"; + assert(store.commit(alias_paths, expected, unverified, scratch, sizeof(scratch)) == Result::Invalid && io.files == before); + io.short_write = true; + assert(store.commit(paths, expected, unverified, scratch, sizeof(scratch)) == Result::StorageFailure); + assert(io.files.at(paths.image) == old_image && io.files.at(paths.binding) == old_record); + io.short_write = false; + track_sharing::ImageSourceBindingView loaded; + assert(store.read(paths.binding, scratch, sizeof(scratch), loaded) == Result::Ready && track_sharing::imageSourceBindingMatches(loaded, old_binding)); + io.reported_size = uint64_t(UINT32_MAX) + 1 + 75; + assert(store.read(paths.binding, scratch, sizeof(scratch), loaded) == Result::Corrupt && !loaded.file_bytes); + io.reported_size = 0; + io.files.erase(paths.binding); + assert(store.read(paths.binding, scratch, sizeof(scratch), loaded) == Result::Missing && !loaded.file_bytes); +} diff --git a/tests/hostlink/test_image_source_linux.cpp b/tests/hostlink/test_image_source_linux.cpp new file mode 100644 index 00000000..afd681db --- /dev/null +++ b/tests/hostlink/test_image_source_linux.cpp @@ -0,0 +1,57 @@ +#include "platform/linux/runtime_paths.h" +#include "track_sharing/image_source_binding.h" +#include +#include +#include +#include +namespace platform::ui::route_storage +{ +std::filesystem::path root; +std::filesystem::path route_dir_path() { return root; } +#include "image_source_linux_actual.inc" +} // namespace platform::ui::route_storage +int main() +{ + namespace fs = std::filesystem; + using namespace platform::ui::route_storage; + root = fs::current_path() / "image-source-linux-fixture"; + fs::create_directories(root); + const auto image = root / "test-owned.jpg", binding = root / "test-owned.jpg.source"; + const auto outside = fs::current_path() / "image-source-linux-owned-outside.source"; + uint8_t source[32]{1}, file_hash[32]{2}, record[track_sharing::kImageSourceBindingBytes]; + assert(track_sharing::encodeImageSourceBinding({{source, 32}, {file_hash, 32}, 5}, record, sizeof(record))); + const auto write = [](const fs::path& path, const uint8_t* bytes, size_t size) + {std::ofstream output(path,std::ios::binary|std::ios::trunc);assert(output.write(reinterpret_cast(bytes),size)); }; + write(image, file_hash, 5); + write(binding, record, sizeof(record)); + assert(route_image_source_matches(image.string(), source)); + assert(route_image_source_matches("test-owned.jpg", source)); + source[0] ^= 1; + assert(!route_image_source_matches(image.string(), source)); + source[0] ^= 1; + assert(!route_image_source_matches("../image-source-linux-owned-outside.source", source)); + write(outside, record, sizeof(record)); + assert(!route_image_source_matches(outside.string(), source)); + write(image, file_hash, 4); + assert(!route_image_source_matches(image.string(), source)); + write(image, file_hash, 5); + record[20] ^= 1; + write(binding, record, sizeof(record)); + assert(!route_image_source_matches(image.string(), source)); + record[20] ^= 1; + write(binding, record, sizeof(record) - 1); + assert(!route_image_source_matches(image.string(), source)); + fs::remove(binding); + assert(!route_image_source_matches(image.string(), source)); + // Symlink coverage is conditional on the host permitting link creation. + std::error_code error; + fs::create_symlink(outside, binding, error); + if (!error) + { + assert(!route_image_source_matches(image.string(), source)); + fs::remove(binding); + } + fs::remove(image); + fs::remove(outside); + fs::remove(root); +} diff --git a/tests/hostlink/test_image_source_runtime.cpp b/tests/hostlink/test_image_source_runtime.cpp new file mode 100644 index 00000000..b5651007 --- /dev/null +++ b/tests/hostlink/test_image_source_runtime.cpp @@ -0,0 +1,148 @@ +#include "hostlink/route_storage_lease.h" +#include "platform/esp/arduino_common/route_image_source_runtime.h" +#include "platform/ui/http_client_runtime.h" +#include "platform/ui/route_storage.h" +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +namespace image_http +{ +inline bool ok = true, bad_stats = false, change_media = false; +inline unsigned calls = 0; +inline std::vector body = {4, 5, 6, 7, 8}; +} // namespace image_http +namespace platform::ui::device +{ +bool sd_ready() { return image_fake::ready; } +} // namespace platform::ui::device +namespace platform::ui::http_client +{ +bool download(const Request& request, WriteCallback callback, void* context, std::string& error, TransferStats* stats) +{ + ++image_http::calls; + assert(request.max_bytes <= track_sharing::kImageSourceByteLimit); + assert(request.buffer_size == 512 && request.tx_buffer_size == 512); + if (image_http::change_media) ++image_fake::media; + const bool written = callback(image_http::body.data(), 2, context) && callback(image_http::body.data() + 2, image_http::body.size() - 2, context); + stats->http_status = 200; + stats->bytes = uint32_t(image_http::body.size() + image_http::bad_stats); + if (!written || !image_http::ok) + { + error = "HTTP failed"; + return false; + } + return true; +} +} // namespace platform::ui::http_client +namespace platform::ui::route_storage +{ +constexpr size_t kRouteDownloadBufferSize = 512; +constexpr int kRouteDownloadTxBufferSize = 512; +constexpr size_t kRouteHttpInternalFreeTargetBytes = 18 * 1024, kRouteHttpInternalLargestTargetBytes = 8 * 1024; +bool route_image_url_supported(const std::string& url) { return url.substr(0, 8) == "https://"; } +bool ensure_parent_dir(const std::string&) { return true; } +inline void taskYIELD() {} +#include "image_download_actual.inc" +#include "image_query_public_actual.inc" +#include "image_query_reference_actual.inc" +} // namespace platform::ui::route_storage +int main() +{ + using Work = platform::esp::route_images::RouteImageSourceWork; + static_assert(sizeof(Work) < 2048, "Image provenance workspace grew beyond its admission budget"); + const std::string path = "/routes/.trailmate/kml-12345678/images/img-0001.jpg", url = "https://example.invalid/photo.jpg"; + const auto reset = []() + {image_fake::reset();image_fake::psram=true;image_fake::allocation_fail=false;image_http::ok=true;image_http::bad_stats=false;image_http::change_media=false; }; + reset(); + auto download = [&](const std::string& target = std::string{}) + { return platform::ui::route_storage::download_route_image_with_progress(url, target.empty() ? path : target, 4096, nullptr, nullptr); }; + const auto result = download(); + assert(result.ok && result.bytes == 5 && image_fake::live == 0); + assert(image_fake::files.at(path) == image_http::body); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + uint8_t source_hash[32]; + { + std::unique_ptr work(new (std::nothrow) Work); + assert(work && work->referenceHash(url, source_hash) && work->matchesExisting(path, source_hash)); + source_hash[0] ^= 1; + assert(!work->matchesExisting(path, source_hash)); + source_hash[0] ^= 1; + } + assert(image_fake::live == 0); + assert(platform::ui::route_storage::route_image_source_matches(path, source_hash)); + assert(platform::ui::route_storage::route_image_source_matches_reference(path, url)); + assert(!platform::ui::route_storage::route_image_source_matches_reference(path, url + "?different=1")); + image_fake::files[path] = {9, 9, 9}; + { + std::unique_ptr work(new (std::nothrow) Work); + assert(!work->matchesExisting(path, source_hash)); + } + reset(); + image_fake::files[path] = {9, 9, 9}; + { + std::unique_ptr work(new (std::nothrow) Work); + assert(!work->matchesExisting(path, source_hash)); + } + for (const auto& invalid : {"/routes/.trailmate/../images/img-0001.jpg", "/routes/.trailmate/a/images/img-0000.jpg", "/routes/.trailmate/a/images/img-0065.jpg", "/routes/.trailmate/a/images/img-0001.jpg/../other", "/elsewhere/img-0001.jpg"}) + { + reset(); + const auto calls = image_http::calls; + assert(!download(invalid).ok && image_http::calls == calls && image_fake::files.empty()); + } + reset(); + image_fake::allocation_fail = true; + const auto calls = image_http::calls; + assert(!download().ok && image_http::calls == calls && image_fake::files.empty() && image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + reset(); + image_fake::psram = false; + assert(download().ok && image_fake::last_caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + reset(); + image_http::bad_stats = true; + assert(!download().ok && !image_fake::files.count(path) && !image_fake::files.count(path + ".source")); + reset(); + image_fake::flush_fail = true; + assert(!download().ok && !image_fake::files.count(path)); + reset(); + image_fake::write_fail = true; + assert(!download().ok && !image_fake::files.count(path)); + reset(); + image_http::ok = false; + assert(!download().ok && !image_fake::files.count(path)); + reset(); + image_http::change_media = true; + assert(!download().ok && !image_fake::files.count(path)); + reset(); + assert(hostlink::route_storage_lease::acquire()); + const auto busy_calls = image_http::calls; + assert(!download().ok && image_http::calls == busy_calls && image_fake::files.empty()); + hostlink::route_storage_lease::release(); + reset(); + assert(download().ok); + image_fake::files["/routes/.trailmate/kml-12345678/thumbs/thumb-0001.bmp"] = {9}; + image_fake::files["/routes/.trailmate/kml-12345678/views/view-0001.bmp"] = {10}; + image_http::body = {8, 7, 6, 5, 4}; + assert(download().ok); + assert(!image_fake::files.count("/routes/.trailmate/kml-12345678/thumbs/thumb-0001.bmp") && !image_fake::files.count("/routes/.trailmate/kml-12345678/views/view-0001.bmp")); + reset(); + image_fake::busy = true; + { + std::unique_ptr work(new (std::nothrow) Work); + assert(!work->matchesExisting(path, source_hash) && image_fake::removes == 0 && image_fake::renames == 0); + } + assert(image_fake::live == 0); + reset(); + image_fake::files[path] = std::vector(75, 1); + image_fake::open_fail = true; + { + platform::esp::route_images::SourceIo io; + assert(io.open(path.c_str(), decltype(io)::Mode::Read).status == decltype(io)::OpenStatus::Unavailable); + assert(io.open("/missing", decltype(io)::Mode::Read).status == decltype(io)::OpenStatus::Missing); + } +} diff --git a/tests/hostlink/test_image_source_ui.cpp b/tests/hostlink/test_image_source_ui.cpp new file mode 100644 index 00000000..21934245 --- /dev/null +++ b/tests/hostlink/test_image_source_ui.cpp @@ -0,0 +1,350 @@ +#include "platform/esp/arduino_common/route_image_source_runtime.h" +#include "platform/ui/route_storage.h" +#include "track_sharing/installed_package_path.h" +#include "ui/support/disconnected_route_line.h" +#include "ui/support/kml_route_projection.h" +#include "ui/support/route_image_kml_reader.h" +#include "ui/support/route_image_memory.h" +#include "ui/support/route_image_source_state.h" +#include "ui/support/route_segment_sampling.h" +#include "ui/widgets/route_elevation_profile.h" +#include +#include +#include +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +namespace storage_fake +{ +std::set files; +std::map> bindings; +unsigned source_reads = 0, hashes = 0; +platform::ui::route_storage::RouteImageDownloadStatus status; +std::string queued_route; +std::vector queued; +} // namespace storage_fake +namespace platform::ui::route_storage +{ +#include "image_reference_hash_actual.inc" +bool route_asset_file_exists(const std::string& path) +{ + return storage_fake::files.count(path) != 0; +} +bool route_image_source_matches(const std::string& path, const uint8_t* source) +{ + ++storage_fake::source_reads; + const auto it = storage_fake::bindings.find(path); + return it != storage_fake::bindings.end() && route_asset_file_exists(path) && !std::memcmp(it->second.data(), source, 32); +} +RouteImageDownloadStatus route_image_download_status() { return storage_fake::status; } +bool start_route_image_cache_build(const std::string&, std::vector items, std::string&, RouteImageTaskPresentation, const std::string& route) +{ + storage_fake::queued_route = route; + storage_fake::queued = std::move(items); + return true; +} +} // namespace platform::ui::route_storage +namespace platform::ui::device +{ +bool sd_ready() { return true; } +} // namespace platform::ui::device +namespace ui::widgets::map +{ +struct Position +{ + bool valid; + double lat, lon; +}; +inline double fail_longitude = 999; +bool project_point(int, Position position, lv_point_t& output) +{ + if (position.lon == fail_longitude) return false; + output = {static_cast(position.lon * 1000), static_cast(position.lat * 1000)}; + return true; +} +} // namespace ui::widgets::map +namespace navigation +{ +#include "image_ui_navigation_struct_actual.inc" +constexpr size_t kMaxRouteImagePoints = 64; +ui::support::RouteImageVector s_route_images; +std::string s_route_asset_id = "test"; +std::string s_track_file; +bool s_route_image_saved_state_known = false, s_route_image_cache_state_known = false; +bool s_route_image_cache_build_running = false; +unsigned s_route_image_strip_items_hash = 0, s_route_selected_image = 0; +std::vector s_route_image_strip_items; +#include "image_ui_navigation_asset_id_actual.inc" +std::string route_asset_root_for_id(const std::string& id) +{ + return "/routes/.trailmate/" + id; +} +#include "image_ui_navigation_append_actual.inc" +#include "image_ui_navigation_paths_actual.inc" +#include "image_ui_navigation_refresh_actual.inc" + +#include "image_ui_navigation_package_cache_actual.inc" + +#include "image_ui_navigation_load_actual.inc" +} // namespace navigation +namespace preview +{ +#include "image_ui_preview_metrics_struct_actual.inc" +#include "image_ui_preview_point_struct_actual.inc" +#include "image_ui_preview_struct_actual.inc" +constexpr size_t kPreviewMaxImages = 64; +ui::support::RouteImageVector s_preview_images; +std::string s_preview_asset_id = "test"; +bool s_preview_image_saved_state_known = false, s_preview_image_cache_state_known = false; +size_t s_preview_saved_image_count_hint = 0; +unsigned s_preview_selected_image = 0; +ui::support::RouteImageVector s_preview_route_points; +RoutePreviewMetrics s_preview_metrics; +ui::support::RouteImageVector s_preview_elevation_samples; +ui::support::RouteImageVector s_preview_map_line_points; +constexpr size_t kPreviewMaxDrawPoints = 96; +int s_preview_map_runtime = 0; +std::string s_preview_route_name; +#include "image_ui_preview_asset_id_actual.inc" +std::string route_path_for_name(const std::string& name) +{ + return "/routes/" + name; +} +void clear_preview_model() +{ + s_preview_route_points.clear(); + s_preview_images.clear(); + s_preview_route_name.clear(); + s_preview_asset_id.clear(); + s_preview_map_line_points.clear(); + s_preview_elevation_samples.clear(); + s_preview_elevation_samples.clear(); +} +std::string route_asset_root_for_id(const std::string& id) { return "/routes/.trailmate/" + id; } +#include "image_ui_preview_append_actual.inc" +#include "image_ui_preview_package_cache_items_actual.inc" +#include "image_ui_preview_paths_actual.inc" + +#include "image_ui_preview_hint_actual.inc" +#include "image_ui_preview_point_append_actual.inc" +#include "image_ui_preview_refresh_actual.inc" +#include "image_ui_preview_status_actual.inc" + +#include "image_ui_preview_metrics_actual.inc" + +#include "image_ui_preview_draw_points_actual.inc" +#include "image_ui_preview_samples_actual.inc" + +#include "image_ui_preview_load_actual.inc" +} // namespace preview +int main() +{ + image_fake::psram = true; + image_fake::allocation_fail = false; + const std::vector urls = {"https://example.invalid/a", "https://example.invalid/b"}; + for (const auto& url : urls) + { + assert(navigation::append_route_images(url, 30, 120, true)); + assert(preview::append_pending_images(url, 30, 120, true)); + } + navigation::assign_route_image_paths(); + // Actual UI queue producers must enqueue unsigned/relative KML references + // from an installed package without requiring a prior HTTP download. + navigation::ensure_map_route_image_cache_build(); + assert(storage_fake::queued.empty()); // legacy unbound images keep old rule + const auto package_name = "p-" + std::string(64, 'a') + ".kml"; + assert(navigation::route_asset_id_for_path("/routes/" + package_name) == package_name.substr(0, 66)); + assert(preview::route_asset_id_for_name(package_name) == package_name.substr(0, 66)); + auto second_package = package_name; + second_package[65] = 'b'; + assert(navigation::route_asset_id_for_path("/routes/" + second_package) != navigation::route_asset_id_for_path("/routes/" + package_name)); + assert(navigation::route_asset_id_for_path("/routes/legacy.kml") == preview::route_asset_id_for_name("legacy.kml")); + navigation::s_track_file = "/routes/" + package_name; + navigation::ensure_map_route_image_cache_build(); + assert(storage_fake::queued_route == navigation::s_track_file && storage_fake::queued.size() == 2); + for (size_t i = 0; i < 2; ++i) + assert(storage_fake::queued[i].source_hash_valid && !std::memcmp(storage_fake::queued[i].source_hash, navigation::s_route_images[i].source_hash, 32)); + preview::s_preview_route_name = package_name; + assert(preview::route_preview_cache_items().size() == 2); + storage_fake::queued.clear(); + storage_fake::status.busy = true; + navigation::ensure_map_route_image_cache_build(); + assert(storage_fake::queued.empty()); + storage_fake::status.busy = false; + storage_fake::status.asset_id = navigation::s_route_asset_id; + storage_fake::status.phase = platform::ui::route_storage::RouteImageDownloadPhase::Failed; + navigation::ensure_map_route_image_cache_build(); + assert(storage_fake::queued.empty()); // no per-frame retry storm + storage_fake::status = {}; + navigation::s_track_file.clear(); + preview::s_preview_route_name.clear(); + storage_fake::queued_route.clear(); + preview::assign_preview_image_paths(); + assert(navigation::s_route_images.size() == 2 && preview::s_preview_images.size() == 2); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + for (size_t i = 0; i < 2; ++i) + { + const auto& a = navigation::s_route_images[i]; + const auto& b = preview::s_preview_images[i]; + assert(a.source_hash_valid && b.source_hash_valid && !std::memcmp(a.source_hash, b.source_hash, 32)); + storage_fake::files.insert(a.local_path); + storage_fake::files.insert(a.preview_path); + storage_fake::files.insert(a.view_path); + } + // Legacy files exist, but do not enter the ready count or image cache flags. + navigation::refresh_route_image_storage_state(true); + preview::refresh_preview_saved_image_count_hint(); + preview::refresh_preview_image_storage_state(true); + assert(preview::s_preview_saved_image_count_hint == 0 && ui::support::routeImagesNeedSourceReview(navigation::s_route_images)); + for (const auto& image : navigation::s_route_images) assert(!image.downloaded && !image.preview_ready && !image.view_ready); + auto& first = navigation::s_route_images[0]; + std::array binding; + std::memcpy(binding.data(), first.source_hash, 32); + storage_fake::bindings[first.local_path] = binding; + navigation::refresh_route_image_storage_state(true); + preview::refresh_preview_saved_image_count_hint(); + preview::refresh_preview_image_storage_state(true); + assert(first.downloaded && first.preview_ready && first.view_ready); + assert(preview::s_preview_saved_image_count_hint == 1); + assert(preview::s_preview_images[0].downloaded && preview::s_preview_images[0].preview_ready && !preview::s_preview_images[1].downloaded); + platform::ui::route_storage::RouteImageDownloadStatus status; + status.asset_id = "test"; + status.busy = true; + status.saved = 2; + status.phase = platform::ui::route_storage::RouteImageDownloadPhase::Downloading; + assert(!preview::apply_route_preview_download_status_to_images(status)); + assert(!preview::s_preview_images[1].downloaded); + status.phase = platform::ui::route_storage::RouteImageDownloadPhase::Caching; + assert(!preview::apply_route_preview_download_status_to_images(status)); + assert(!preview::s_preview_images[1].preview_ready); + binding[0] ^= 1; + storage_fake::bindings[first.local_path] = binding; + status.busy = false; + status.phase = platform::ui::route_storage::RouteImageDownloadPhase::Done; + assert(preview::apply_route_preview_download_status_to_images(status) && preview::s_preview_saved_image_count_hint == 0); + navigation::refresh_route_image_storage_state(false); + assert(!first.downloaded && !first.preview_ready && !first.view_ready); + // Hash allocation failure does not authorize an image or fall back internally. + image_fake::allocation_fail = true; + uint8_t digest[32]; + assert(!platform::ui::route_storage::route_image_reference_hash(urls[0], digest)); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + image_fake::allocation_fail = false; + ui::support::RouteImageVector fresh; + image_fake::allocation_fail = true; + assert(!fresh.push_back(1) && fresh.failed()); + assert(fresh.empty() && image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + image_fake::allocation_fail = false; + navigation::s_route_images.clear(); + preview::s_preview_images.clear(); + image_fake::allocation_fail = true; + assert(!navigation::append_route_images(urls[0], 0, 0, false)); + assert(!preview::append_pending_images(urls[0], 0, 0, false)); + assert(navigation::s_route_images.empty() && navigation::s_route_images.failed()); + assert(preview::s_preview_images.empty() && preview::s_preview_images.failed()); + image_fake::allocation_fail = false; + navigation::s_route_images.clear(); + preview::s_preview_images.clear(); + for (unsigned i = 0; i < 100; ++i) assert(navigation::append_route_images("same", 0, 0, false) == (i < 64)); + assert(navigation::s_route_images.size() == 64); + ui::support::RouteImageVector{}.swap(navigation::s_route_images); + ui::support::RouteImageVector{}.swap(preview::s_preview_images); + assert(image_fake::live == 0); + // Actual production loaders + actual streaming reader/parser. Filesystem, + // hashing platform admission and ready-record lookup remain mocked above. + const std::string document = "1,2,3 4,5,6]]><img src='second.jpg'>120 30 42121 31 44]]>"; + kml_reader_fake::files["A:/routes/test.kml"] = document; + assert(navigation::load_kml_route_images("/routes/test.kml")); + assert(preview::load_route_preview_model("test.kml")); + assert(navigation::s_route_images.size() == 3 && preview::s_preview_images.size() == 3); + assert(preview::s_preview_images[0].url == "a&b.jpg" && preview::s_preview_images[1].url == "second.jpg"); + assert(navigation::s_route_images[0].lat == 2 && navigation::s_route_images[0].lon == 1); + assert(preview::s_preview_images[1].lat == 30 && preview::s_preview_images[1].lon == 120); + assert(!preview::s_preview_images[2].has_position && !navigation::s_route_images[2].has_position); + assert(preview::s_preview_route_points.size() == 2 && preview::s_preview_route_points[0].lon == 120 && preview::s_preview_route_points[1].altitude_m == 44); + for (size_t i = 0; i < 3; ++i) assert(!std::memcmp(navigation::s_route_images[i].source_hash, preview::s_preview_images[i].source_hash, 32)); + assert(kml_reader_fake::open_handles == 0); + kml_reader_fake::files["A:/routes/line.kml"] = "absolute1,2 3,4,5"; + assert(preview::load_route_preview_model("line.kml")); + assert(preview::s_preview_route_points.size() == 2 && !preview::s_preview_route_points[0].has_altitude && preview::s_preview_route_points[1].has_altitude); + kml_reader_fake::files["A:/routes/bad.kml"] = document.substr(0, document.size() - 8); + assert(!navigation::load_kml_route_images("/routes/bad.kml") && navigation::s_route_images.empty()); + assert(!preview::load_route_preview_model("bad.kml") && preview::s_preview_images.empty() && preview::s_preview_route_points.empty()); + image_fake::allocation_fail = true; + const unsigned admissions = image_fake::allocations; + assert(!navigation::load_kml_route_images("/routes/test.kml")); + assert(image_fake::allocations == admissions + 1 && image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + image_fake::allocation_fail = false; + const auto file_failure = [&]() + { + assert(!navigation::load_kml_route_images("/routes/test.kml") && navigation::s_route_images.empty()); + assert(!preview::load_route_preview_model("test.kml") && preview::s_preview_route_points.empty() && preview::s_preview_images.empty()); + assert(!kml_reader_fake::open_handles && image_fake::live == 0); + }; + kml_reader_fake::close_fail = true; + file_failure(); + kml_reader_fake::close_fail = false; + kml_reader_fake::fail_read = static_cast(kml_reader_fake::reads + 2); + assert(!navigation::load_kml_route_images("/routes/test.kml") && navigation::s_route_images.empty()); + assert(image_fake::live == 0 && !kml_reader_fake::open_handles); + kml_reader_fake::fail_read = -1; + assert(!ui::support::readRouteImageKml("/routes/missing.kml", {})); + image_fake::psram = false; + assert(ui::support::readRouteImageKml("/routes/line.kml", {})); + assert(image_fake::last_caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) && image_fake::live == 0); + image_fake::psram = true; + std::string many = ""; + kml_reader_fake::files["A:/routes/many.kml"] = many + "]]>"; + assert(navigation::load_kml_route_images("/routes/many.kml") && navigation::s_route_images.size() == 64); + assert(preview::load_route_preview_model("many.kml") && preview::s_preview_images.size() == 64); + assert(preview::s_preview_images[63].url == "63.jpg"); + kml_reader_fake::files["A:/routes/segments.kml"] = "absolute0 0 00.001 0 10100 50 100100.001 50 110"; + assert(preview::load_route_preview_model("segments.kml")); + assert(preview::s_preview_route_points.size() == 4); + assert(preview::s_preview_route_points[0].break_before && !preview::s_preview_route_points[1].break_before); + assert(preview::s_preview_route_points[2].break_before && !preview::s_preview_route_points[3].break_before); + assert(preview::s_preview_metrics.ascent_m == 20 && preview::s_preview_metrics.descent_m == 0); + assert(preview::s_preview_metrics.distance_m > 100 && preview::s_preview_metrics.distance_m < 300); + preview::rebuild_preview_elevation_samples(); + assert(preview::s_preview_elevation_samples[2].break_before); + assert(preview::s_preview_elevation_samples[2].distance_m == preview::s_preview_elevation_samples[1].distance_m); + assert(preview::build_preview_map_line_points()); + auto* map_line = lv_line_create(nullptr); + lv_event_t map_event{map_line, &draw_fake::layer, nullptr}; + draw_fake::lines.clear(); + ui::support::drawDisconnectedRouteLine(&map_event, preview::s_preview_map_line_points.data(), preview::s_preview_map_line_points.size()); + assert(draw_fake::lines.size() == 2); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + ui::widgets::map::fail_longitude = 0.001; + assert(preview::build_preview_map_line_points()); + draw_fake::lines.clear(); + ui::support::drawDisconnectedRouteLine(&map_event, preview::s_preview_map_line_points.data(), preview::s_preview_map_line_points.size()); + assert(draw_fake::lines.size() == 1); + ui::widgets::map::fail_longitude = 999; + image_fake::allocation_fail = true; + assert(!preview::build_preview_map_line_points() && preview::s_preview_map_line_points.empty()); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + image_fake::allocation_fail = false; + preview::s_preview_route_points.clear(); + assert(preview::s_preview_route_points.push_back({0, 0, 0, true, true})); + assert(preview::s_preview_route_points.push_back({0, 0.001, 0, false, false})); + assert(preview::s_preview_route_points.push_back({0, 0.002, 100, true, false})); + preview::refresh_preview_metrics(); + assert(preview::s_preview_metrics.ascent_m == 0); + navigation::s_route_images.clear(); + preview::s_preview_images.clear(); + preview::s_preview_map_line_points.clear(); + preview::s_preview_route_points.clear(); + preview::s_preview_elevation_samples.clear(); + assert(image_fake::live == 0 && !kml_reader_fake::open_handles); +} diff --git a/tests/hostlink/test_kml_geometry_events.cpp b/tests/hostlink/test_kml_geometry_events.cpp new file mode 100644 index 00000000..97fc48a8 --- /dev/null +++ b/tests/hostlink/test_kml_geometry_events.cpp @@ -0,0 +1,42 @@ +#include "track_sharing/kml_geometry_events.h" +#include +#include +using track_sharing::KmlGeometryEvents; +int main() +{ + KmlGeometryEvents parser; + const auto ns = KmlGeometryEvents::kml; + track_sharing::GeometryExtent extent; + assert(parser.begin(ns, "kml") && parser.begin(ns, "Document") && parser.begin(ns, "Placemark")); + assert(parser.begin(ns, "description") && parser.begin(ns, "coordinates") && parser.text("0,0", 3) && parser.end() && parser.end()); + assert(parser.begin(ns, "LineString") && parser.begin(ns, "coordinates") && parser.text("179,1 ", 6) && parser.text("-179,-2", 7) && parser.end() && parser.end()); + assert(parser.end() && parser.end() && parser.end() && parser.finish(extent) && extent.points == 2 && extent.west == 1790000000 && extent.east == -1790000000); + KmlGeometryEvents invalid; + assert(!invalid.begin("urn:fake", "kml") && !invalid.finish(extent)); + KmlGeometryEvents point; + assert(point.begin(ns, "kml") && point.begin(ns, "Placemark") && point.begin(ns, "Point") && point.begin(ns, "coordinates") && point.text("0,0 1,1", 7)); + assert(!point.end() && !point.finish(extent)); + KmlGeometryEvents empty; + assert(empty.begin(ns, "kml") && empty.begin(ns, "Placemark") && empty.begin(ns, "Point")); + assert(!empty.end()); + KmlGeometryEvents missing; + assert(missing.begin(ns, "kml") && missing.begin(ns, "Placemark") && missing.begin(KmlGeometryEvents::gx, "Track")); + for (const char* when : {"2024-02-29T00:00:00Z", "", "2024-02-29T00:01:00+08:00"}) + assert(missing.begin(ns, "when") && missing.text(when, std::strlen(when)) && missing.end()); + for (const char* coord : {"1 2 3", "", "2 3 4"}) + assert(missing.begin(KmlGeometryEvents::gx, "coord") && missing.text(coord, std::strlen(coord)) && missing.end()); + assert(missing.end() && missing.end() && missing.end() && missing.finish(extent) && extent.points == 2); + KmlGeometryEvents partial; + assert(partial.begin(ns, "kml") && partial.begin(ns, "Placemark") && partial.begin(KmlGeometryEvents::gx, "Track") && partial.begin(KmlGeometryEvents::gx, "coord") && partial.text("1 2", 3)); + assert(!partial.end()); + KmlGeometryEvents duplicate; + assert(duplicate.begin(ns, "kml") && duplicate.begin(ns, "Placemark") && duplicate.begin(ns, "Point") && duplicate.begin(ns, "coordinates") && duplicate.text("0,0", 3) && duplicate.end()); + assert(!duplicate.begin(ns, "coordinates")); + for (const char* coordinates : {"0,0 1,0 1,1 0,0", "0,0 1,0 1,1 0.00000001,0"}) + { + KmlGeometryEvents ring; + assert(ring.begin(ns, "kml") && ring.begin(ns, "Placemark") && ring.begin(ns, "LinearRing") && ring.begin(ns, "coordinates") && ring.text(coordinates, std::strlen(coordinates))); + const bool valid = ring.end(); + assert(valid == (std::string_view(coordinates) == "0,0 1,0 1,1 0,0")); + } +} diff --git a/tests/hostlink/test_kml_image_sources.cpp b/tests/hostlink/test_kml_image_sources.cpp new file mode 100644 index 00000000..15ebaa3d --- /dev/null +++ b/tests/hostlink/test_kml_image_sources.cpp @@ -0,0 +1,53 @@ +#include "gps/kml/image_sources.h" +#include +#include +#include +#include +namespace navigation +{ +#include "gps_image_sources_actual.inc" +} // namespace navigation +namespace preview +{ +#include "tracker_image_sources_actual.inc" +} // namespace preview +int main() +{ + const auto check = [](const std::string& input, const std::vector& expected) + { + std::vector a, b; + navigation::extract_img_srcs(input, a); + preview::extract_img_srcs(input, b); + assert(a == expected && b == expected); + }; + check("a.jpg\" alt='src=wrong'>", {"right>a.jpg"}); + check("", {"right.jpg"}); + check("text", {"right.jpg"}); + check("", {"first"}); + check("x>y", {"a.jpg", "b.jpg/"}); + check("", {"right"}); + check("", {"right"}); + check("]]>", {"a&b.jpg", "right"}); + check("", {"a"b\"c&d.jpg"}); + check("", {"a&b"c'd.jpg"}); + check("", {"山😀€.jpg"}); + check("", {"���&#-1;&#xZZ;&unknown;.jpg"}); + assert(gps::kml::decodeImageSource(std::string(100000, '&')) == std::string(100000, '&')); + check("", {"资源/山.jpg"}); + std::string many; + for (unsigned i = 0; i < 10000; ++i) many += ""; + std::vector a, b; + navigation::extract_img_srcs(many, a); + preview::extract_img_srcs(many, b); + assert(a == b && a.size() == 64 && a.front() == "0" && a.back() == "63"); + navigation::extract_img_srcs("", a); + assert(a.size() == 64); + unsigned callbacks = 0; + gps::kml::forEachImageSource("", [&](std::string_view value) + { assert(value == "a"); ++callbacks; return false; }); + assert(callbacks == 1); +} diff --git a/tests/hostlink/test_kml_image_stream.cpp b/tests/hostlink/test_kml_image_stream.cpp new file mode 100644 index 00000000..4be6b0bb --- /dev/null +++ b/tests/hostlink/test_kml_image_stream.cpp @@ -0,0 +1,134 @@ +#include "track_sharing/kml_image_xml_stream.h" +#include +#include +#include +#include + +struct Image +{ + std::string source; + bool located = false; + double lat = 0, lon = 0; +}; +struct Result +{ + struct Coordinate + { + double lat, lon; + bool gx, break_before; + }; + std::vector images; + std::vector coordinates; + size_t feature_start = 0; + unsigned features = 0; + track_sharing::KmlImageCallbacks callbacks() + { + return {this, + [](void* p) + { auto& r = *static_cast(p); r.feature_start = r.images.size(); ++r.features; }, + [](void* p, std::string_view source) + { auto& r = *static_cast(p); r.images.push_back({gps::kml::decodeImageSource(source)}); return true; }, + [](void* p, bool located, double lat, double lon) + { auto& r = *static_cast(p); for (size_t i = r.feature_start; i < r.images.size(); ++i) r.images[i].located = located, r.images[i].lat = lat, r.images[i].lon = lon; }, + [](void* p, bool gx, double lat, double lon, double, bool, bool break_before) + { static_cast(p)->coordinates.push_back({lat, lon, gx, break_before}); }}; + } +}; +static bool parse(std::string_view xml, size_t chunk, Result& result) +{ + char names[4096], source[4096]; + track_sharing::KmlImageXmlStream parser(names, sizeof(names), source, sizeof(source), result.callbacks()); + for (size_t i = 0; i < xml.size(); i += chunk) + if (!parser.feed(reinterpret_cast(xml.data() + i), std::min(chunk, xml.size() - i))) return false; + return parser.finish(); +} +static std::vector html(std::string_view text, size_t chunk, size_t capacity = 4096, bool stop = false) +{ + char source[4096]; + gps::kml::HtmlImageSourceStream parser(source, capacity); + std::vector found; + for (size_t i = 0; i < text.size(); i += chunk) + parser.feed(text.substr(i, chunk), [&](std::string_view value) + { found.push_back(gps::kml::decodeImageSource(value)); return !stop; }); + return found; +} +int main() +{ + const std::string input = "" + "120.1234567,30.5,7 -->>]]>" + "<img src="b&amp;c.jpg">121 31 12" + "]]>" + ""; + for (size_t chunk = 1; chunk <= input.size(); ++chunk) + { + Result r; + assert(parse(input, chunk, r)); + assert(r.features == 3 && r.images.size() == 4); + assert(r.images[0].source == "a"山.jpg" && r.images[1].source == "two.jpg"); + assert(r.images[0].located && std::abs(r.images[0].lat - 30.5) < 1e-8 && std::abs(r.images[0].lon - 120.1234567) < 1e-8); + assert(r.images[1].located && r.images[1].lat == r.images[0].lat); + assert(r.images[2].source == "b&c.jpg" && r.images[2].located && std::abs(r.images[2].lon - 121) < 1e-8); + assert(!r.images[3].located); + } + const std::vector>> cases = { + {"", {}}, {"", {"a>b", "b.jpg/"}}, {"", {"good"}}, {"<img src=bad>", {"山😀"}}, {"", {"good"}}, {"" + std::string(100000, ", {"good"}}, {"", {"good"}}}; + for (const auto& item : cases) + for (size_t chunk : {size_t(1), size_t(2), size_t(17), size_t(256), item.first.size()}) assert(html(item.first, chunk) == item.second); + assert(html("", 1, 4) == std::vector({"1234", "x"})); + assert(html("", 1, 4096, true) == std::vector({"a"})); + char source[16]; + gps::kml::HtmlImageSourceStream reset(source, sizeof(source)); + std::vector found; + auto collect = [&](std::string_view v) + { found.emplace_back(v); return true; }; + reset.feed("", collect); + assert(found == std::vector({"good"})); + + const auto check = [](const std::string& doc, bool expected, size_t images) + { + for (size_t chunk : {size_t(1), size_t(7), doc.size()}) + { + Result r; + assert(parse(doc, chunk, r) == expected); + if (expected) assert(r.images.size() == images); + } + }; + check("]]>1,2", true, 1); + check("]]>]]>]]>", true, 1); + check("]]>]]>", true, 1); + check("", false, 0); + check("", false, 0); + check("]]>", false, 0); + check("", false, 0); + check("", false, 0); + check("", false, 0); + check("&bad;", false, 0); + check("", false, 0); + check("1,2,", false, 0); + check("1 2", false, 0); + check("NaN,2", false, 0); + check("1e999,2", false, 0); + std::string deep = ""; + for (unsigned i = 0; i < 64; ++i) deep += ""; + for (unsigned i = 0; i < 64; ++i) deep += ""; + check(deep + "", false, 0); + std::string over_points = ""; + for (unsigned i = 0; i < 100001; ++i) over_points += "1,2 "; + check(over_points + "", false, 0); + check("]]>", true, 1); + Result invalid; + assert(!parse("181,2]]>", 1, invalid)); + Result shadow; + assert(parse("1,2]]>", 1, shadow)); + assert(!shadow.images[0].located); + Result polygon; + assert(parse("1,2 2,3 3,4 1,2]]>", 1, polygon)); + assert(polygon.images[0].located && std::abs(polygon.images[0].lon - 1) < 1e-8); + Result segmented; + assert(parse("1,2 2,33,4 4,55 6 06 7 07 8 08 9 0", 1, segmented)); + assert(segmented.coordinates.size() == 8); + const bool breaks[] = {true, false, true, false, true, true, false, true}; + for (size_t i = 0; i < 8; ++i) assert(segmented.coordinates[i].break_before == breaks[i]); +} diff --git a/tests/hostlink/test_kml_navigation.cpp b/tests/hostlink/test_kml_navigation.cpp new file mode 100644 index 00000000..8cb40cfa --- /dev/null +++ b/tests/hostlink/test_kml_navigation.cpp @@ -0,0 +1,116 @@ +#include "ui/support/kml_route_projection.h" +#include "ui/support/overlay_path_segments.h" +#include "ui/support/route_image_memory.h" +#include "ui/support/route_segment_sampling.h" +#include "ui/widgets/route_elevation_profile.h" +#include +#include +#include +#include +namespace platform::ui::device +{ +bool sd_ready() { return true; } +} // namespace platform::ui::device +constexpr size_t kMaxTrackOverlayPoints = 96; +constexpr double kEarthRadiusM = 6371000; +// clang-format off +#include "image_ui_navigation_route_structs_actual.inc" +TrackPointBuffer s_track_points; +enum class TrackOverlayFileKind { Route, Track }; +bool s_track_overlay_active = true; +TrackOverlayFileKind s_track_overlay_kind = TrackOverlayFileKind::Route; +bool route_deviation_mode_active() { return s_track_overlay_active && !s_track_points.empty(); } +double degrees_to_radians(double d) { return d * 0.017453292519943295; } +#include "image_ui_navigation_route_projection_actual.inc" +double route_point_distance_m(const TrackOverlayPoint& a, const TrackOverlayPoint& b) +{ + const auto la = degrees_to_radians(a.lat), lb = degrees_to_radians(b.lat); + const auto x = std::sin((lb-la)/2), y = std::sin(degrees_to_radians(b.lon-a.lon)/2); + const auto h = std::clamp(x*x+std::cos(la)*std::cos(lb)*y*y,0.0,1.0); + return 2*kEarthRadiusM*std::atan2(std::sqrt(h),std::sqrt(1-h)); +} +#include "image_ui_navigation_route_load_actual.inc" +#include "image_ui_navigation_route_samples_actual.inc" +#include "image_ui_navigation_route_nearest_actual.inc" +#include "image_ui_navigation_route_overlay_actual.inc" +// clang-format on +static std::string document(const std::string& body) +{ + return "" + body + ""; +} +static bool load(const std::string& text, TrackPointBuffer& points, ElevationSampleBuffer& samples, TrackElevationMetrics& metrics) +{ + kml_reader_fake::files["A:/route.kml"] = text; + return load_kml_track_points("/route.kml", points, metrics, samples); +} +int main() +{ + TrackPointBuffer points; + ElevationSampleBuffer samples; + TrackElevationMetrics metrics; + const auto segmented = document("absolute0,0,0 0.001,0,101,0,100 1.001,0,110absolute"); + assert(load(segmented, points, samples, metrics)); + assert(points.size() == 4 && samples.size() == 4 && points[2].break_before && samples[2].break_before); + assert(metrics.ascent_m == 20 && metrics.altitude_count == 4 && samples.back().distance_m < 225 && samples.back().distance_m > 220); + s_track_points = std::move(points); + double distance = 0; + assert(nearest_route_distance_m(0, 0.5, distance) && distance > 50000); // gap is not a nearest segment + ui::map::MapOverlaySnapshot overlay; + append_track_overlay(overlay); + assert(overlay.item_count == 4 && (overlay.items[2].path_flags & 2)); + unsigned drawn = 0; + ui::support::visitOverlayPathSegments( + overlay, + [](const ui::map::MapGeoPoint& p, lv_point_t& out) + {out={int(p.lon*1000),int(p.lat*1000)};return true; }, + [&](const lv_point_t&, const lv_point_t&, ui::map::MapOverlayStyle) + { ++drawn; }); + assert(drawn == 2); + const auto gx = document("8,8,1000 9,9,2000absolute2024-02-29T10:00:00+08:002024-02-29T10:02:00Z0 0 01 0 100"); + assert(load(gx, points, samples, metrics)); + assert(points.size() == 2 && points[1].break_before && metrics.ascent_m == 0 && samples.back().distance_m == 0); + assert(points[0].lon == 0 && points[1].lon == 1); // timed gx wins, never fake joins missing sample + assert(load(document("0,0,0 .001,0 .002,0,100absolute"), points, samples, metrics)); + assert(metrics.ascent_m == 0 && samples[2].break_before && !points[2].break_before); + for (const auto& bad : { + document("2023-02-29T00:00:00Z1 2 3"), + document("2024-01-01T00:00:00Z1 2 3"), + document("1,2,3x"), + document("1,2,3"), + segmented.substr(0, segmented.size() - 5)}) + { + assert(!load(bad, points, samples, metrics)); + assert(points.empty() && samples.empty() && !metrics.altitude_count); + } + for (const auto mode : {"", "clampToGround", "relativeToGround", "relativeToSeaFloor"}) + { + assert(load(document(std::string("0,0,10 1,0,20") + mode + ""), points, samples, metrics)); + assert(!metrics.altitude_count && !points[0].has_altitude && !samples[1].has_altitude); + } + std::string long_route = "absolute"; + for (unsigned i = 0; i < 100000; ++i) long_route += "0," + std::to_string(i / 100000.0) + "," + std::to_string(i % 2) + " "; + long_route += ""; + assert(load(document(long_route), points, samples, metrics)); + assert(points.size() == 96 && samples.size() == 96 && metrics.altitude_count == 100000 && metrics.ascent_m == 50000 && metrics.descent_m == 49999); + assert(points.front().lat == 0 && std::abs(points.back().lat - 0.99999) < 1e-9); + assert(samples.back().distance_m > 111190 && samples.back().distance_m < 111200); + assert(image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT) && !image_fake::internal_allocations); + image_fake::allocation_fail = true; + assert(!load(segmented, points, samples, metrics)); + image_fake::allocation_fail = false; + assert(points.empty() && samples.empty()); + s_track_points.clear(); + assert(!image_fake::live && !kml_reader_fake::open_handles); + struct LegacyItem + { + ui::map::MapOverlayKind kind; + ui::map::MapOverlayStyle style; + ui::map::MapGeoPoint point; + ui::FixedText<32> label; + ui::FixedText<64> detail; + uint32_t id; + uint8_t icon; + bool selected, visible; + }; + static_assert(sizeof(LegacyItem) == sizeof(ui::map::MapOverlayItem), "Path flags must not increase snapshot RAM"); +} diff --git a/tests/hostlink/test_kml_navigation_match.cpp b/tests/hostlink/test_kml_navigation_match.cpp new file mode 100644 index 00000000..f90fff2c --- /dev/null +++ b/tests/hostlink/test_kml_navigation_match.cpp @@ -0,0 +1,230 @@ +#include "ui/support/kml_navigation_match.h" +#include "ui/support/kml_route_projection.h" +#include "ui/support/route_image_memory.h" +#include "ui/widgets/route_elevation_profile.h" +#include +#include +#include +struct Point +{ + double lat = 0, lon = 0, altitude_m = 0; + bool has_altitude = false, break_before = false; +}; +using Points = ui::support::RouteImageVector; +using Samples = ui::support::RouteImageVector; +namespace sys +{ +uint32_t time = 100; +uint32_t millis_now() { return time; } +} // namespace sys +namespace platform::ui::device +{ +bool ready = true; +bool sd_ready() { return ready; } +} // namespace platform::ui::device +enum class Projection +{ + Map, + GpsStatus +}; +void* s_root = reinterpret_cast(1); +void* s_location_request = nullptr; +void* s_map_target = nullptr; +Projection s_projection = Projection::Map; +bool s_route_deviation_active = false, fix_valid = true, route_active = true; +double s_route_deviation_distance_m = 0, fix_lat = 0, fix_lon = 0; +std::string s_track_file = "/route.kml"; +ui::support::KmlNavigationMatch* s_route_navigation = nullptr; +constexpr double kRouteDeviationThresholdM = 10, kRouteDeviationClearThresholdM = 7; +bool current_fix_lat_lon(double& lat, double& lon) +{ + lat = fix_lat; + lon = fix_lon; + return fix_valid; +} +bool route_deviation_mode_active() { return route_active; } +#include "image_ui_navigation_match_state_actual.inc" +static std::string document(const std::string& body) +{ + return "" + body + ""; +} +static bool prepare(const std::string& body) +{ + ui::support::KmlNavigationMatch::destroy(s_route_navigation); + s_route_navigation = ui::support::KmlNavigationMatch::create(); + if (!s_route_navigation) return false; + kml_reader_fake::files["A:/route.kml"] = document(body); + Points points; + Samples samples; + ui::support::KmlElevationMetrics metrics; + return ui::support::projectKmlRoute("/route.kml", points, samples, metrics, 96, &s_route_navigation->expected); +} +static void complete(double lat, double lon) +{ + fix_lat = lat; + fix_lon = lon; + for (unsigned i = 0; i < 10000; ++i) + { + advance_route_navigation(); + if (!s_route_navigation->pending()) return; + sys::time += 20; + } + assert(false); +} +static uint32_t counted_clock() { return sys::time++; } +int main() +{ + using namespace track_sharing; + RouteMatchAccumulator equator({.001, .005}); + assert(equator.point({0, 0}, true) && equator.point({0, .01}, false)); + const auto exact = equator.result(); + assert(exact.valid && !exact.ambiguous && std::abs(exact.distance_m - 111.1949) < .01 && std::abs(exact.progress_m - 555.9746) < .01); + RouteMatchAccumulator date_line({0, 180}); + assert(date_line.point({0, 179.9}, true) && date_line.point({0, -179.9}, false)); + assert(date_line.result().distance_m < .001 && std::abs(date_line.result().total_m - 22238.985) < .01); + RouteMatchAccumulator opposite({0, 0}); + assert(opposite.point({0, 179.9}, true) && opposite.point({0, -179.9}, false)); + assert(opposite.result().distance_m > 19000000); // no segment through longitude zero + RouteMatchAccumulator ambiguous({0, 0}); + assert(ambiguous.point({0, 0}, true) && ambiguous.point({0, .001}, false) && ambiguous.point({0, 0}, false)); + assert(ambiguous.result().ambiguous); + RouteMatchAccumulator invalid({NAN, 0}); + assert(!invalid.point({0, 0}, true)); + RouteMatchAccumulator antipodal; + assert(antipodal.point({0, 0}, true) && !antipodal.point({0, 180}, false)); + + RouteNavigationState state; + RouteMatchResult position{0, 500, 1000, 500, true, false}; + state.observe(position, 100, 0); + auto view = state.project(position, 0, 0); + assert(view.status == RouteNavigationStatus::Position && view.percent == 50 && view.remaining_m == 500); + assert(state.project(position, 5001, 0).status == RouteNavigationStatus::Unavailable); + assert(state.project(position, 0, 3.01).status == RouteNavigationStatus::Unavailable); + position.progress_m = 999; + position.endpoint_distance_m = 1; + state.observe(position, 1000, 0); + state.observe(position, 1000, 0); // A rendered result is not another fix. + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Position); + state.observe(position, 6000, 0); + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Arrived); + assert(state.project(position, 0, 4).status == RouteNavigationStatus::Unavailable); + position.ambiguous = true; + state.observe(position, 6100, 0); + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Ambiguous); + position.ambiguous = false; + state.observe(position, 7000, 0); + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Position); + state.observe(position, 20000, 0); // Missing scans restart the dwell. + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Position); + state.reset(); + state.observe(position, 100, 0); + state.observe(position, 5100, 0); + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Position); // Opening at the destination does not arrive. + position.progress_m = 500; + position.endpoint_distance_m = 500; + state.observe(position, 0xffffe000U, 0); + position.progress_m = 999; + position.endpoint_distance_m = 1; + state.observe(position, 0xfffff000U, 0); + state.observe(position, 0x400U, 0); + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Arrived); // millis rollover + position.total_m = 0; + assert(state.project(position, 0, 0).status == RouteNavigationStatus::Unavailable); + + std::string curve = ""; + for (unsigned i = 0; i < 1000; ++i) curve += std::to_string(i * .00001) + "," + (i == 5 ? "0.001" : "0") + " "; + curve += ""; + assert(prepare(curve)); + // The bend is between display samples; the matcher must see all raw points. + const unsigned before = kml_reader_fake::reads; + s_route_navigation->poll("/route.kml", .001, .00005, sys::time, counted_clock, 64, 4); + assert(s_route_navigation->pending() && !s_route_navigation->result() && kml_reader_fake::reads - before <= 4); + complete(.001, .00005); + assert(s_route_navigation->result() && s_route_navigation->result()->distance_m < .001); + update_route_deviation_state(); + assert(!s_route_deviation_active); + assert(s_route_navigation->navigation(.001, .00005, sys::time).status == RouteNavigationStatus::Position); + assert(s_route_navigation->navigation(.002, .00005, sys::time).status == RouteNavigationStatus::Unavailable); + assert(image_fake::internal_allocations == 0 && kml_reader_fake::open_handles == 0); + double lower = 0, upper = 0; + assert(s_route_navigation->bounds(.002, .00005, sys::time, lower, upper) && lower == 0 && upper > 111); + assert(!s_route_navigation->bounds(.001, .00005, sys::time + 15001, lower, upper)); + // Mutation after successful loading must not silently change the active route. + kml_reader_fake::files["A:/route.kml"] = document("0,0 1,1"); + sys::time += 6000; + complete(.001, .00005); + assert(!s_route_navigation->result()); + assert(s_route_navigation->navigation(.001, .00005, sys::time).status == RouteNavigationStatus::Unavailable); + + assert(prepare("0,0 .001,01,0 1.001,0")); + complete(0, .5); + update_route_deviation_state(); + assert(s_route_navigation->result()->distance_m > 50000 && s_route_navigation->result()->total_m < 223 && s_route_deviation_active); + // An uncertain interval cannot falsely clear a previous off-route alert. + fix_lon = .001; + update_route_deviation_state(); + assert(s_route_deviation_active); + sys::time += 6000; + complete(0, .001); + update_route_deviation_state(); + assert(!s_route_deviation_active); + + assert(prepare("0,0 180,01 0 02 0 0")); + complete(0, 1.5); + assert(s_route_navigation->result() && s_route_navigation->result()->total_m == 0 && s_route_navigation->result()->distance_m > 55000); + // Truncation may have emitted matching points, but can never publish Ready. + kml_reader_fake::files["A:/route.kml"].resize(kml_reader_fake::files["A:/route.kml"].size() - 3); + sys::time += 6000; + complete(0, 1.5); + assert(!s_route_navigation->result()); + + assert(prepare("0,0 .001,0")); + sys::time += 6000; + complete(0, .0001); + assert(s_route_navigation->navigation(0, .0001, sys::time).percent == 10); + sys::time += 6000; + complete(0, .000995); + assert(s_route_navigation->navigation(0, .000995, sys::time).status == RouteNavigationStatus::Position); + sys::time += 5000; + complete(0, .000995); + assert(s_route_navigation->navigation(0, .000995, sys::time).status == RouteNavigationStatus::Arrived); + fix_valid = false; + advance_route_navigation(); + assert(s_route_navigation->navigation(0, .000995, sys::time).status == RouteNavigationStatus::Unavailable); + fix_valid = true; + assert(prepare("0,0 .001,0 0,0")); + sys::time += 6000; + complete(0, 0); + assert(s_route_navigation->navigation(0, 0, sys::time).status == RouteNavigationStatus::Ambiguous); + + assert(prepare(curve)); + s_route_navigation->poll("/route.kml", 0, 0, sys::time, nullptr, 1, 4); + assert(s_route_navigation->pending()); + s_projection = Projection::GpsStatus; + advance_route_navigation(); + assert(!s_route_navigation->pending() && !s_route_navigation->result() && !kml_reader_fake::open_handles); + s_projection = Projection::Map; + kml_reader_fake::fail_read = kml_reader_fake::reads + 1; + complete(0, 0); + assert(!s_route_navigation->result() && !kml_reader_fake::open_handles); + kml_reader_fake::fail_read = -1; + sys::time += 2000; + kml_reader_fake::close_fail = true; + complete(0, 0); + assert(!s_route_navigation->result()); + kml_reader_fake::close_fail = false; + sys::time += 2000; + image_fake::allocation_fail = true; + complete(0, 0); + assert(!s_route_navigation->pending() && !s_route_navigation->result()); + image_fake::allocation_fail = false; + fix_valid = false; + advance_route_navigation(); + update_route_deviation_state(); + assert(!s_route_deviation_active); + ui::support::KmlNavigationMatch::destroy(s_route_navigation); + image_fake::allocation_fail = true; + assert(!ui::support::KmlNavigationMatch::create()); + image_fake::allocation_fail = false; + assert(!image_fake::live && !image_fake::internal_allocations && !kml_reader_fake::open_handles); +} diff --git a/tests/hostlink/test_kml_xml_stream.cpp b/tests/hostlink/test_kml_xml_stream.cpp new file mode 100644 index 00000000..f2105016 --- /dev/null +++ b/tests/hostlink/test_kml_xml_stream.cpp @@ -0,0 +1,81 @@ +#include "track_sharing/kml_xml_stream.h" +#include +#include +#include +static char names[2048]; +int main() +{ + const std::string xml = "0,0]]>179,1 -179,-2"; + for (size_t split = 0; split <= xml.size(); ++split) + { + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert(parser.feed(reinterpret_cast(xml.data()), split) && parser.feed(reinterpret_cast(xml.data() + split), xml.size() - split) && parser.finish(extent)); + assert(extent.points == 2 && extent.west == 1790000000 && extent.east == -1790000000); + } + for (const std::string text : {"", "", "", "", "\xc0\xaf"}) + { + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert(!(parser.feed(reinterpret_cast(text.data()), text.size()) && parser.finish(extent))); + } + for (const auto& sample : {std::pair{"id=\"1\" id=\"2\"", false}, {"a:id=\"1\" b:id=\"2\" xmlns:a=\"urn:same\" xmlns:b=\"urn:same\"", false}, {"a:id=\"1\" b:id=\"2\" xmlns:a=\"urn:a\" xmlns:b=\"urn:b\"", true}, {"id=\"1\" a:id=\"2\" xmlns:a=\"http://www.opengis.net/kml/2.2\"", true}, {"a:id=\"1\"", false}}) + { + const std::string source = std::string("1,2"; + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + const bool valid = parser.feed(reinterpret_cast(source.data()), source.size()) && parser.finish(extent); + assert(valid == sample.second); + } + for (const auto& sample : {std::pair{"中文:属性=\"1\" xmlns:中文=\"urn:a\"", true}, {"\xcc\x80id=\"1\"", false}}) + { + const std::string source = std::string("1,2"; + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert((parser.feed(reinterpret_cast(source.data()), source.size()) && parser.finish(extent)) == sample.second); + } + const std::string body = "1,2"; + const std::string declaration = "\xef\xbb\xbf"; + const std::string declared = declaration + body; + for (size_t split = 0; split <= declared.size(); ++split) + { + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert(parser.feed(reinterpret_cast(declared.data()), split) && parser.feed(reinterpret_cast(declared.data() + split), declared.size() - split) && parser.finish(extent)); + } + for (const char* declaration : {"", "", ""}) + { + const std::string source = declaration + body; + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert(!(parser.feed(reinterpret_cast(source.data()), source.size()) && parser.finish(extent))); + } + for (unsigned times = 0; times < 3; ++times) + { + std::string source = ""; + for (unsigned i = 0; i < times; ++i) source += "2026-10-11T00:00:00Z"; + source += "1 2 3"; + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + assert((parser.feed(reinterpret_cast(source.data()), source.size()) && parser.finish(extent)) == (times < 2)); + } + const std::string tracks = "2026-10-11T00:00:00Z1 2 32 3 4"; + track_sharing::KmlXmlStream trackParser(names, sizeof(names)); + track_sharing::GeometryExtent trackExtent; + assert(trackParser.feed(reinterpret_cast(tracks.data()), tracks.size()) && trackParser.finish(trackExtent) && trackExtent.points == 2); + for (const auto& sample : {std::pair{"2026-02-31T00:00:00Z", false}, {"2024-02-29T00:00:00+14:00", true}, {"2024-02-29T00:00:00+14:01", false}, {"", true}}) + { + const std::string source = std::string("") + sample.first + "1 2 3"; + track_sharing::KmlXmlStream parser(names, sizeof(names)); + track_sharing::GeometryExtent extent; + bool valid = true; + for (const auto ch : source) + if (!parser.feed(reinterpret_cast(&ch), 1)) + { + valid = false; + break; + } + valid = valid && parser.finish(extent); + assert(valid == sample.second); + } +} diff --git a/tests/hostlink/test_lvgl_heap_policy.cpp b/tests/hostlink/test_lvgl_heap_policy.cpp new file mode 100644 index 00000000..3812ec6a --- /dev/null +++ b/tests/hostlink/test_lvgl_heap_policy.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include +#include +constexpr uint32_t MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_INTERNAL = 2, MALLOC_CAP_8BIT = 4; +bool psram_available = true, psram_failure = false, strict_ui = false, font_scope = false; +uint32_t attempted[2]{}; +unsigned attempts = 0, frees = 0; +int external_token, internal_token; +size_t heap_caps_get_total_size(uint32_t) { return psram_available ? 8 * 1024 * 1024 : 0; } +bool external_font_load_fs_scope_active() { return font_scope; } +namespace platform::ui +{ +bool psramUiRequired() { return strict_ui; } +} // namespace platform::ui +void* heap_caps_malloc(size_t, uint32_t caps) +{ + assert(attempts < 2); + attempted[attempts++] = caps; + if (caps & MALLOC_CAP_SPIRAM) return psram_available && !psram_failure ? &external_token : nullptr; + return &internal_token; +} +void* heap_caps_malloc_prefer(size_t size, size_t count, ...) +{ + va_list args; + va_start(args, count); + void* result = nullptr; + for (size_t i = 0; i < count && !result; ++i) result = heap_caps_malloc(size, va_arg(args, uint32_t)); + va_end(args); + return result; +} +bool esp_ptr_external_ram(void* p) { return p == &external_token; } +void* heap_caps_realloc(void*, size_t size, uint32_t caps) { return heap_caps_malloc(size, caps); } +void* heap_caps_realloc_prefer(void*, size_t size, size_t count, ...) +{ + va_list args; + va_start(args, count); + void* result = nullptr; + for (size_t i = 0; i < count && !result; ++i) result = heap_caps_malloc(size, va_arg(args, uint32_t)); + va_end(args); + return result; +} +void heap_caps_free(void*) { ++frees; } +#include "lvgl_heap_actual.inc" +int main() +{ + for (size_t size : {size_t(1), size_t(32), size_t(256), size_t(2047), size_t(2048), size_t(65536)}) + { + attempts = 0; + auto* result = lv_malloc_core(size); +#ifdef ARDUINO_WIO_TRACKER_L2 + assert(result == &external_token && attempted[0] == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); +#else + assert(result == (size >= 2048 ? &external_token : &internal_token)); +#endif + assert(attempts == 1); + } + attempts = 0; + assert(!lv_malloc_core(0) && !attempts); + psram_failure = true; + attempts = 0; + assert(lv_malloc_core(2048) == &internal_token && attempts == 2); + strict_ui = true; + attempts = 0; + assert(!lv_malloc_core(32) && attempts == 1 && attempted[0] == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + strict_ui = false; + font_scope = true; + attempts = 0; + assert(!lv_malloc_core(32) && attempts == 1); + font_scope = false; + attempts = 0; + assert(!lv_realloc_core(&external_token, 32) && attempts == 1 && attempted[0] == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + psram_failure = false; + psram_available = false; + attempts = 0; + assert(lv_malloc_core(32) == &internal_token && attempted[0] == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + attempts = 0; + assert(!lv_realloc_core(&internal_token, 0) && !attempts && frees == 1); + lv_free_core(&internal_token); + assert(frees == 2); +} diff --git a/tests/hostlink/test_lxmf_announce_bounds.cpp b/tests/hostlink/test_lxmf_announce_bounds.cpp new file mode 100644 index 00000000..2f386bc0 --- /dev/null +++ b/tests/hostlink/test_lxmf_announce_bounds.cpp @@ -0,0 +1,29 @@ +#include +#include +#include +#include +#include +#include +constexpr uint8_t kPropagationMetaName = 1; +#include "lxmf_announce_actual.inc" +int main() +{ + for (const size_t length : {size_t(0),size_t(1),size_t(31),size_t(63),size_t(95),size_t(96),size_t(255),size_t(256),size_t(65535),size_t(65536)}) + { + const std::string name(length, 'x'); + for (size_t capacity=0;capacity<=96;++capacity) + { + std::vector output(capacity+32, 0xa5); + size_t used=capacity; + const bool ok=packPropagationAnnounceAppData(UINT32_MAX,true,UINT32_MAX,UINT32_MAX,name.c_str(),output.data()+16,&used); + for(size_t i=0;i<16;++i)assert(output[i]==0xa5 && output[capacity+16+i]==0xa5); + if(ok)assert(used<=capacity && used>0 && output[16]==0x97); + else assert(used==capacity); + } + } + uint8_t output[8]{}, value=7; + size_t used=std::numeric_limits::max(); + assert(!appendMsgpackBytes(&value,1,output,sizeof(output),used)); + used=1;assert(!appendMsgpackBytes(&value,std::numeric_limits::max(),output,sizeof(output),used)); + assert(used==1); +} diff --git a/tests/hostlink/test_management_dispatch.cpp b/tests/hostlink/test_management_dispatch.cpp new file mode 100644 index 00000000..e6f1536d --- /dev/null +++ b/tests/hostlink/test_management_dispatch.cpp @@ -0,0 +1,627 @@ +#include "platform/esp/arduino_common/hostlink/hostlink_management_service.h" +#include "platform/esp/arduino_common/hostlink/web_geocache_signature.h" +#include "platform/esp/arduino_common/hostlink/web_geocache_confirmation.h" +#include +#include +#include +static std::vector response; +static uint16_t response_sequence; +static unsigned draft_reads = 0; +static uint64_t draft_expected = 0; +static unsigned draft_put_queues = 0; +static bool draft_put_release = false; +using WebResult = platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult; +static WebResult job_current_result = WebResult::Ready, job_history_result = WebResult::Ready; +static unsigned job_current_reads = 0, job_history_reads = 0; +static WebResult job_stop_result = WebResult::Ready; +static unsigned job_stop_calls = 0; +static unsigned job_queue_calls = 0, job_admissions = 0; +static bool job_queue_admit = true; +static unsigned local_signature_queues = 0; +static std::array local_signature_operation{}; +static std::array local_signature_hash{}; +namespace platform::esp::arduino_common::geocaching::browse_runtime +{ +WebReadResult queueWebSignature(const uint8_t* operation, const uint8_t*, const uint8_t* transfer, + const uint8_t* hash, uint32_t total, const uint8_t* author, uint32_t generation, + const uint8_t*, uint64_t) +{ + assert(transfer[0] == 33 && total == 100 && generation == 7); + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + std::memcpy(local_signature_operation.data(), operation, 16); std::memcpy(local_signature_hash.data(), hash, 32); + ++local_signature_queues; return WebReadResult::Ready; +} +WebReadResult readWebSignature(const uint8_t* operation, const uint8_t*, bool status, uint32_t, uint16_t, + uint8_t* output, size_t capacity, size_t& written) +{ + assert(status && !std::memcmp(operation, local_signature_operation.data(), 16)); + ::geocaching::protocol::CmpWriter writer(output, capacity); + assert(writer.array(5) && writer.binary({local_signature_operation.data(), 16}) && writer.binary({local_signature_hash.data(), 32}) && + writer.unsignedInteger(7) && writer.unsignedInteger(0) && writer.unsignedInteger(0)); + written = writer.size(); return WebReadResult::Ready; +} +WebReadResult releaseWebSignature(const uint8_t*, const uint8_t*) { return WebReadResult::Ready; } +WebReadResult queueWebConfirmation(const uint8_t* transfer,const uint8_t*,const uint8_t* hash,uint32_t total,const uint8_t*,uint32_t generation,const uint8_t* proof,size_t proof_size) +{ assert(transfer[0]==33&&hash[0]==44&&total==100&&generation==7&&proof&&proof_size>0);++local_signature_queues;return WebReadResult::Ready; } +WebReadResult readWebConfirmation(const uint8_t*,const uint8_t*,uint8_t* output,size_t,size_t& written) +{ output[0]=0x90;written=1;return WebReadResult::Ready; } +WebReadResult releaseWebConfirmation(const uint8_t*,const uint8_t*){return WebReadResult::Ready;} +} +static std::array job_operation{}; +static WebResult draft_put_check = WebResult::Ready, draft_put_queue = WebResult::Pending; +static WebResult prepare_binding_result = WebResult::Ready, prepare_latest_result = WebResult::Pending; +static std::array prepare_binding{}; +static std::array prepare_latest{}; +static uint64_t prepare_clock = 1000; +static uint32_t commit_binding_delay = 0; +static uint8_t prepare_boot = 1; +static unsigned prepare_network_reads = 0; +static platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult draft_result = + platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult::Pending; +namespace platform::esp::arduino_common::geocaching::browse_runtime +{ +bool webLease(bool, bool) { return true; } +WebReadResult requestWebPublicationStop(const uint8_t* id, const uint8_t* author) +{ + ++job_stop_calls; + assert(!std::memcmp(id, job_operation.data(), 16)); + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + return job_stop_result; +} +WebReadResult readWebPublicationJob(const uint8_t* id, const uint8_t* author, uint8_t* output, size_t capacity, size_t& written) +{ + ++job_current_reads; + assert(!std::memcmp(id, job_operation.data(), 16)); + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + if (job_current_result != WebReadResult::Ready) return job_current_result; + std::array hash{}; + ::geocaching::protocol::CmpWriter writer(output, capacity); + assert(writer.array(8) && writer.binary({id, 16}) && writer.unsignedInteger(3) && writer.binary({hash.data(), 32}) && + writer.binary({hash.data(), 32}) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.nil()); + written = writer.size(); + return WebReadResult::Ready; +} +WebReadResult readWebPublicationHistory(const uint8_t* id, const uint8_t* author, uint8_t* output, size_t capacity, size_t& written) +{ + ++job_history_reads; + if (job_history_result != WebReadResult::Ready) return job_history_result; + const auto current = job_current_result; + job_current_result = WebReadResult::Ready; + const auto result = readWebPublicationJob(id, author, output, capacity, written); + --job_current_reads; + job_current_result = current; + return result; +} +bool publicationContext(uint8_t* boot, uint64_t& clock) +{ + std::memset(boot, prepare_boot, 16); + clock = prepare_clock; + return true; +} +WebReadResult readWebPublicationBinding(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*, uint8_t* output, size_t capacity) +{ + assert(capacity == 88 || capacity == 56); + if (capacity == 56) prepare_clock += commit_binding_delay; + if (prepare_binding_result == WebReadResult::Ready) std::memcpy(output, prepare_binding.data(), capacity); + return prepare_binding_result; +} +bool queueWebDraftPublication(const uint8_t*, uint64_t generation, const uint8_t*, const uint8_t* author, + uint32_t revision, const uint8_t* binding, const uint8_t* operation) +{ + ++job_queue_calls; + assert(generation > 0 && revision > 0 && !std::memcmp(binding, prepare_binding.data(), 56)); + assert(!std::memcmp(operation, job_operation.data(), 16)); + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + assert(platform::esp::arduino_common::storage::fake_files.at(hostlink::management::kPublicationTicketPath)[4] == 1); + if (job_queue_admit) { ++job_admissions; job_current_result = WebResult::Ready; } + return job_queue_admit; +} +WebReadResult readWebLatestRevision(const uint8_t*, const uint8_t*, uint8_t* output, size_t capacity) +{ + ++prepare_network_reads; + assert(capacity == 36); + std::memcpy(output, prepare_latest.data(), prepare_latest.size()); + return prepare_latest_result; +} +WebReadResult readWebDirectories(uint32_t offset, uint8_t* output, size_t capacity, size_t& written) +{ + ::geocaching::protocol::CmpWriter writer(output, capacity); + assert(writer.array(3) && writer.unsignedInteger(offset) && writer.unsignedInteger(0) && writer.nil()); + written = writer.size(); + return WebReadResult::Ready; +} +bool releaseWebDraftSave() { return draft_put_release; } +WebReadResult checkWebDraftEditOwner(const uint8_t*, uint64_t, const uint8_t* author, const uint8_t*) +{ + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + return draft_put_check; +} +WebReadResult queueWebDraftFile(const uint8_t*, uint32_t, uint64_t, const uint8_t*, const uint8_t* author, uint32_t generation) +{ + for (unsigned i = 0; i < 64; ++i) assert(author[i] == i); + assert(generation == 7); + ++draft_put_queues; + return draft_put_queue; +} +WebReadResult readWebDraftCatalog(uint32_t, bool, uint64_t, uint8_t* output, size_t, size_t& written) +{ + output[0] = 0x94; + output[1] = 1; + output[2] = 0; + output[3] = 0; + output[4] = 0xc0; + written = 5; + return WebReadResult::Ready; +} +WebReadResult readWebDraftChunk(const uint8_t id[16], uint32_t offset, uint16_t count, uint64_t expected, + uint8_t* output, size_t capacity, size_t& written) +{ + ++draft_reads; + draft_expected = expected; + if (draft_result != WebReadResult::Ready) return draft_result; + const uint8_t data[] = {1, 2, 3}; + ::geocaching::protocol::CmpWriter writer(output, capacity); + assert(offset == 0 && count == 320); + assert(writer.array(5) && writer.binary({id, 16}) && writer.unsignedInteger(7) && writer.unsignedInteger(sizeof(data)) && + writer.unsignedInteger(offset) && writer.binary({data, sizeof(data)})); + written = writer.size(); + return WebReadResult::Ready; +} +} // namespace platform::esp::arduino_common::geocaching::browse_runtime +struct FakeAuthor final : chat::IMeshAdapter +{ + mutable std::vector message; + chat::DeviceAuthorResult failure = chat::DeviceAuthorResult::Ok; + chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* public_key, uint32_t& generation) const override + { + if (failure != chat::DeviceAuthorResult::Ok) return failure; + for (unsigned index = 0; index < 64; ++index) public_key[index] = static_cast(index); + generation = 7; + return chat::DeviceAuthorResult::Ok; + } + chat::DeviceAuthorResult proveDeviceAuthor(const uint8_t* session, const uint8_t* challenge, uint32_t expected, + uint8_t* scratch, size_t capacity, uint8_t* key, + uint32_t& generation, uint8_t* signature) const override + { + const auto result = readDeviceAuthorKey(key, generation); + if (result != chat::DeviceAuthorResult::Ok) return result; + if (expected != generation) return chat::DeviceAuthorResult::StaleIdentity; + size_t written; + if (!chat::deviceAuthorProofMessage(session, challenge, key, generation, scratch, capacity, written)) + return chat::DeviceAuthorResult::InvalidInput; + message.assign(scratch, scratch + written); + std::memset(signature, 0x66, 64); + return chat::DeviceAuthorResult::Ok; + } + bool sendText(chat::ChannelId, const std::string&, chat::MessageId*, chat::NodeId) override { return false; } + bool pollIncomingText(chat::MeshIncomingText*) override { return false; } + bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, bool, chat::MessageId, bool) override { return false; } + bool pollIncomingData(chat::MeshIncomingData*) override { return false; } + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } +}; +namespace hostlink +{ +bool enqueue_event(uint8_t type, const uint8_t* payload, size_t length, bool, uint16_t sequence) +{ + assert(type == static_cast(FrameType::ManagementResponse)); + response.assign(payload, payload + length); + response_sequence = sequence; + return true; +} +} // namespace hostlink +int main() +{ + namespace web_config = hostlink::management::web_config; + using namespace hostlink; + std::array session{}, request{}; + session[0] = 1; + request[0] = 2; + PendingCommand command; + command.type = PendingCommandType::Management; + command.sequence = 92; + const uint8_t empty[] = {0x90}; + size_t written; + auto dispatch = [&](uint16_t op, const uint8_t* body, size_t length) + {assert(management::encodeEnvelope(session.data(),request.data(),op,body,length,command.payload,sizeof(command.payload),written));command.payload_len=static_cast(written);assert(management::execute_management(command)==ErrorCode::Ok);management::EnvelopeView view;assert(management::decodeEnvelope(response.data(),response.size(),view));assert(response_sequence==92);assert(!std::memcmp(view.session,session.data(),16));return view; }; + auto caps = dispatch(0, empty, sizeof(empty)); + assert(caps.code == 0); + geocaching::protocol::CmpReader cap_reader({caps.body, caps.body_size}); + size_t cap_fields = 0, operation_count = 0; + uint64_t cap_value = 0; + assert(cap_reader.array(cap_fields, 6) && cap_fields == 6); + for (unsigned i = 0; i < 4; ++i) assert(cap_reader.unsignedInteger(cap_value)); + assert(cap_reader.array(operation_count, 32) && operation_count == 20); + bool has_job_get = false, has_unimplemented_commit = false, has_job_stop = false; + for (size_t i = 0; i < operation_count; ++i) + { + assert(cap_reader.unsignedInteger(cap_value)); + has_job_get |= cap_value == 48; + has_job_stop |= cap_value == 50; + has_unimplemented_commit |= cap_value == 68; + } + assert(cap_reader.unsignedInteger(cap_value) && cap_value == 7 && cap_reader.finished() && has_job_get && has_unimplemented_commit && has_job_stop); + assert(dispatch(1, empty, sizeof(empty)).code == 5); + FakeAuthor author; + app::fake_messaging.adapter = &author; + job_operation.fill(0x5b); + uint8_t job_body[32]; + geocaching::protocol::CmpWriter job_request(job_body, sizeof(job_body)); + assert(job_request.array(2) && job_request.binary({job_operation.data(), 16}) && job_request.unsignedInteger(7)); + auto job_reply = dispatch(48, job_body, job_request.size()); + assert(job_reply.code == 0 && job_current_reads == 1 && job_history_reads == 0); + geocaching::protocol::CmpReader job_projection({job_reply.body, job_reply.body_size}); + size_t job_fields = 0; + geocaching::ByteView returned_job; + uint64_t phase = 0; + assert(job_projection.array(job_fields, 8) && job_fields == 8 && job_projection.binary(returned_job, 16) && + !std::memcmp(returned_job.data, job_operation.data(), 16) && job_projection.unsignedInteger(phase) && phase == 3); + assert(dispatch(50, job_body, job_request.size()).code == 0 && job_stop_calls == 1); + const auto reads_before_stop_pending = job_current_reads; + job_stop_result = WebResult::Pending; + assert(dispatch(50, job_body, job_request.size()).code == 5 && job_current_reads == reads_before_stop_pending); + job_stop_result = WebResult::Forbidden; + assert(dispatch(50, job_body, job_request.size()).code == 2); + job_stop_result = WebResult::NotFound; + assert(dispatch(50, job_body, job_request.size()).code == 10); + job_stop_result = WebResult::StaleIdentity; + assert(dispatch(50, job_body, job_request.size()).code == 9); + job_stop_result = WebResult::Ready; + job_current_result = WebResult::NotFound; + assert(dispatch(48, job_body, job_request.size()).code == 0 && job_history_reads == 1); + job_history_result = WebResult::Pending; + assert(dispatch(48, job_body, job_request.size()).code == 5); + job_history_result = WebResult::NotFound; + assert(dispatch(48, job_body, job_request.size()).code == 10); + job_history_result = WebResult::Forbidden; + assert(dispatch(48, job_body, job_request.size()).code == 2); + job_history_result = WebResult::Invalid; + assert(dispatch(48, job_body, job_request.size()).code == 6); + job_current_result = WebResult::StaleIdentity; + const auto history_reads_before_stale = job_history_reads; + assert(dispatch(48, job_body, job_request.size()).code == 9 && job_history_reads == history_reads_before_stale); + job_body[job_request.size() - 1] = 8; + const auto reads_before_stale = job_current_reads; + const auto stops_before_stale = job_stop_calls; + assert(dispatch(50, job_body, job_request.size()).code == 9 && job_stop_calls == stops_before_stale); + assert(dispatch(48, job_body, job_request.size()).code == 9 && job_current_reads == reads_before_stale); + assert(dispatch(48, empty, sizeof(empty)).code == 3); + assert(dispatch(50, empty, sizeof(empty)).code == 3 && job_stop_calls == stops_before_stale); + std::memset(job_body + 3, 0, 16); + job_body[job_request.size() - 1] = 7; + assert(dispatch(48, job_body, job_request.size()).code == 3 && job_current_reads == reads_before_stale); + auto author_info = dispatch(1, empty, sizeof(empty)); + assert(author_info.code == 0); + geocaching::protocol::CmpReader facts({author_info.body, author_info.body_size}); + size_t fact_count; + uint64_t generation; + geocaching::ByteView public_key, identity_hash, destination; + assert(facts.array(fact_count, 4) && fact_count == 4 && facts.binary(public_key, 64) && + facts.binary(identity_hash, 16) && facts.binary(destination, 16) && facts.unsignedInteger(generation) && facts.finished()); + assert(public_key.size == 64 && generation == 7 && identity_hash.size == 16 && destination.size == 16); + uint8_t nonce[32], expected_key[64], proof_body[64], expected_message[160]; + std::memset(nonce, 0x77, sizeof(nonce)); + for (unsigned index = 0; index < 64; ++index) expected_key[index] = static_cast(index); + geocaching::protocol::CmpWriter proof_writer(proof_body, sizeof(proof_body)); + assert(proof_writer.array(2) && proof_writer.binary({nonce, 32}) && proof_writer.unsignedInteger(7)); + auto proof = dispatch(2, proof_body, proof_writer.size()); + assert(proof.code == 0 && proof.body[0] == 0x95); + size_t expected_size; + assert(chat::deviceAuthorProofMessage(session.data(), nonce, expected_key, 7, expected_message, sizeof(expected_message), expected_size)); + assert(author.message == std::vector(expected_message, expected_message + expected_size)); + geocaching::protocol::CmpReader proof_reader({proof.body, proof.body_size}); + geocaching::ByteView signature; + assert(proof_reader.array(fact_count, 5) && proof_reader.binary(public_key, 64) && proof_reader.binary(identity_hash, 16) && + proof_reader.binary(destination, 16) && proof_reader.unsignedInteger(generation) && proof_reader.binary(signature, 64) && proof_reader.finished()); + assert(!std::memcmp(public_key.data, expected_key, 64) && generation == 7 && signature.size == 64 && signature.data[0] == 0x66); + proof_body[proof_writer.size() - 1] = 8; + assert(dispatch(2, proof_body, proof_writer.size()).code == 9); + assert(dispatch(2, empty, sizeof(empty)).code == 3); + uint8_t draft_request[64]; + geocaching::protocol::CmpWriter draft_writer(draft_request, sizeof(draft_request)); + assert(draft_writer.array(5) && draft_writer.binary({session.data(), 16}) && draft_writer.unsignedInteger(0) && + draft_writer.unsignedInteger(320) && draft_writer.unsignedInteger(0) && draft_writer.unsignedInteger(7)); + assert(dispatch(65, draft_request, draft_writer.size()).code == 5 && draft_reads == 1 && draft_expected == 0); + draft_result = platform::esp::arduino_common::geocaching::browse_runtime::WebReadResult::Ready; + assert(dispatch(65, draft_request, draft_writer.size()).code == 0 && draft_reads == 2); + draft_request[draft_writer.size() - 1] = 8; + assert(dispatch(65, draft_request, draft_writer.size()).code == 9 && draft_reads == 2); + assert(dispatch(65, empty, sizeof(empty)).code == 3 && draft_reads == 2); + const uint8_t list_first[] = {0x93, 0, 0xc0, 7}; + assert(dispatch(64, list_first, sizeof(list_first)).code == 0); + const uint8_t list_stale_author[] = {0x93, 0, 0xc0, 8}; + assert(dispatch(64, list_stale_author, sizeof(list_stale_author)).code == 9); + const uint8_t list_unbound_continuation[] = {0x93, 1, 0xc0, 7}; + assert(dispatch(64, list_unbound_continuation, sizeof(list_unbound_continuation)).code == 3); + assert(dispatch(64, empty, sizeof(empty)).code == 3); + const uint8_t directory_first[] = {0x91, 0}, directory_outside[] = {0x91, 5}; + assert(dispatch(70, directory_first, sizeof(directory_first)).code == 0); + assert(dispatch(70, directory_outside, sizeof(directory_outside)).code == 3); + assert(dispatch(70, empty, sizeof(empty)).code == 3); + web_config::Metadata upload; + std::memcpy(upload.owner, session.data(), 16); + std::memcpy(upload.id, session.data(), 16); + upload.kind = 2; + upload.phase = 1; + upload.total = upload.offset = 5; + platform::esp::arduino_common::storage::fake_files[web_config::candidate_path(2)] = "draft"; + uint8_t upload_scratch[128]; + assert(web_config::hash_file(web_config::candidate_path(2), upload_scratch, upload.hash, 5)); + assert(web_config::save(upload, upload_scratch)); + uint8_t put_body[80]; + geocaching::protocol::CmpWriter put_writer(put_body, sizeof(put_body)); + assert(put_writer.array(4) && put_writer.binary({upload.id, 16}) && put_writer.binary({request.data(), 16}) && + put_writer.unsignedInteger(0) && put_writer.unsignedInteger(7)); + draft_put_check = WebResult::Forbidden; + assert(dispatch(66, put_body, put_writer.size()).code == 2 && draft_put_queues == 0); + assert(web_config::load(upload, upload_scratch) && upload.phase == 2 && !std::memcmp(upload.base_hash, request.data(), 16)); + auto changed_target = std::array{}; + std::memcpy(changed_target.data(), put_body, put_writer.size()); + changed_target[21] ^= 1; + assert(dispatch(66, changed_target.data(), put_writer.size()).code == 4); + draft_put_check = WebResult::Ready; + assert(dispatch(66, put_body, put_writer.size()).code == 5 && draft_put_queues == 1); + assert(web_config::load(upload, upload_scratch) && upload.phase == 3); + uint8_t abort_body[20]; + geocaching::protocol::CmpWriter abort_writer(abort_body, sizeof(abort_body)); + assert(abort_writer.array(1) && abort_writer.binary({upload.id, 16})); + assert(dispatch(36, abort_body, abort_writer.size()).code == 4); + uint8_t replacement_body[80]; + std::array replacement_id{}; + replacement_id.fill(9); + geocaching::protocol::CmpWriter replacement_writer(replacement_body, sizeof(replacement_body)); + assert(replacement_writer.array(4) && replacement_writer.binary({replacement_id.data(), 16}) && replacement_writer.unsignedInteger(2) && + replacement_writer.unsignedInteger(5) && replacement_writer.binary({upload.hash, 32})); + assert(dispatch(32, replacement_body, replacement_writer.size()).code == 5); + auto changed_author = std::array{}; + std::memcpy(changed_author.data(), put_body, put_writer.size()); + changed_author[put_writer.size() - 1] = 8; + assert(dispatch(66, changed_author.data(), put_writer.size()).code == 9); + draft_put_check = WebResult::Unavailable; + assert(dispatch(66, put_body, put_writer.size()).code == 6); + assert(web_config::load(upload, upload_scratch) && upload.phase == 3); + platform::esp::arduino_common::storage::fake_files.erase(web_config::candidate_path(2)); + draft_put_check = WebResult::AlreadySaved; + auto committed_draft = dispatch(66, put_body, put_writer.size()); + assert(committed_draft.code == 0 && draft_put_queues == 1); + geocaching::protocol::CmpReader committed_reader({committed_draft.body, committed_draft.body_size}); + geocaching::ByteView committed_transfer, committed_id; + uint64_t committed_generation = 0; + assert(committed_reader.array(fact_count, 3) && fact_count == 3 && committed_reader.binary(committed_transfer, 16) && + committed_reader.binary(committed_id, 16) && committed_reader.unsignedInteger(committed_generation) && committed_reader.finished()); + assert(committed_generation == 1 && !std::memcmp(committed_id.data, request.data(), 16)); + assert(web_config::load(upload, upload_scratch) && upload.phase == 4); + draft_put_check = WebResult::Unavailable; + assert(dispatch(66, put_body, put_writer.size()).code == 0 && draft_put_queues == 1); + assert(dispatch(36, abort_body, abort_writer.size()).code == 4); + session[0] = 8; + assert(dispatch(66, put_body, put_writer.size()).code == 2); + session[0] = 1; + upload.id[0] = 3; + upload.phase = 1; + platform::esp::arduino_common::storage::fake_files[web_config::candidate_path(2)] = "draft"; + assert(web_config::save(upload, upload_scratch)); + put_body[3] = 3; + abort_body[3] = 3; + draft_put_check = WebResult::Ready; + draft_put_queue = WebResult::Invalid; + assert(dispatch(66, put_body, put_writer.size()).code == 3); + assert(web_config::load(upload, upload_scratch) && upload.phase == 3); + draft_put_release = true; + assert(dispatch(66, put_body, put_writer.size()).code == 3); + assert(web_config::load(upload, upload_scratch) && upload.phase == 2); + assert(dispatch(36, abort_body, abort_writer.size()).code == 0); + author.failure = chat::DeviceAuthorResult::StorageError; + assert(dispatch(1, empty, sizeof(empty)).code == 6); + app::fake_messaging.adapter = nullptr; + auto unsupported = dispatch(18, empty, sizeof(empty)); + assert(unsupported.code == 1); + const uint8_t read[] = {0x93, 0, 32, 0xc0}; + auto snapshot = dispatch(17, read, sizeof(read)); + assert(snapshot.code == 0); + geocaching::protocol::CmpReader reader({snapshot.body, snapshot.body_size}); + size_t fields; + uint64_t offset, total; + geocaching::ByteView hash, chunk; + assert(reader.array(fields, 4) && fields == 4 && reader.unsignedInteger(offset) && reader.unsignedInteger(total) && reader.binary(hash, 32) && reader.binary(chunk, 320) && reader.finished()); + assert(offset == 0 && total == platform::esp::arduino_common::storage::fake_file.size() && chunk.size == 32); + uint8_t saved_hash[32]; + std::memcpy(saved_hash, hash.data, 32); + const uint8_t no_hash[] = {0x93, 1, 1, 0xc0}; + assert(dispatch(17, no_hash, sizeof(no_hash)).code == 3); + uint8_t continued[64]; + geocaching::protocol::CmpWriter writer(continued, sizeof(continued)); + assert(writer.array(3) && writer.unsignedInteger(32) && writer.unsignedInteger(32) && writer.binary({saved_hash, 32})); + assert(dispatch(17, continued, writer.size()).code == 0); + platform::esp::arduino_common::storage::fake_file[0] ^= 1; + assert(dispatch(17, continued, writer.size()).code == 4); + platform::esp::arduino_common::storage::fake_available = false; + assert(dispatch(17, read, sizeof(read)).code == 6); + using namespace platform::esp::arduino_common::storage; + fake_available = true; + app::fake_messaging.adapter = &author; + author.failure = chat::DeviceAuthorResult::Ok; + prepare_binding.fill(0); + std::memset(prepare_binding.data(), 0xa5, 32); + std::memset(prepare_binding.data() + 32, 0x44, 16); + prepare_binding[52] = 1; + std::array prepare_id{}; + prepare_id[0] = 0x23; + std::array prepare_body{}; + auto prepare = [&](uint64_t edit_generation = 1, uint32_t identity_generation = 7) + { + geocaching::protocol::CmpWriter encoder(prepare_body.data(), prepare_body.size()); + assert(encoder.array(5) && encoder.binary({request.data(), 16}) && encoder.unsignedInteger(edit_generation) && + encoder.binary({session.data(), 16}) && encoder.unsignedInteger(identity_generation) && + encoder.binary({prepare_id.data(), 16})); + return dispatch(67, prepare_body.data(), encoder.size()); + }; + assert(prepare().code == 5); + std::array ticket{}; + assert(management::loadPublicationTicket(ticket.data(), ticket.size()) && ticket[4] == 3); + const auto checking_disk = fake_files.at(management::kPublicationTicketPath); + assert(prepare().code == 5 && fake_files.at(management::kPublicationTicketPath) == checking_disk); + assert(prepare(2).code == 4); + const auto network_calls = prepare_network_reads; + assert(prepare(1, 8).code == 9 && prepare_network_reads == network_calls); + prepare_latest_result = WebResult::Ready; + prepare_latest[0] = 1; + assert(prepare().code == 4); // A different publication already exists in the directory. + prepare_latest_result = WebResult::Absent; + auto prepared = prepare(); + assert(prepared.code == 0); + geocaching::protocol::CmpReader confirmation({prepared.body, prepared.body_size}); + geocaching::ByteView confirmed_token, confirmed_draft, confirmed_author, confirmed_directory, confirmed_epoch, confirmed_hash; + uint64_t confirmed_generation = 0, from_revision = 0, to_revision = 0, remaining = 0; + assert(confirmation.array(fields, 10) && fields == 10 && confirmation.binary(confirmed_token, 16) && + confirmation.binary(confirmed_draft, 16) && confirmation.unsignedInteger(confirmed_generation) && + confirmation.binary(confirmed_author, 64) && confirmation.binary(confirmed_directory, 16) && + confirmation.binary(confirmed_epoch, 16) && confirmation.binary(confirmed_hash, 32) && + confirmation.unsignedInteger(from_revision) && confirmation.unsignedInteger(to_revision) && + confirmation.unsignedInteger(remaining) && confirmation.finished()); + assert(confirmed_token.size == 16 && !std::memcmp(confirmed_token.data, prepare_id.data(), 16) && + confirmed_draft.size == 16 && !std::memcmp(confirmed_draft.data, request.data(), 16) && confirmed_generation == 1 && + confirmed_directory.size == 16 && !std::memcmp(confirmed_directory.data, session.data(), 16) && + confirmed_epoch.size == 16 && !std::memcmp(confirmed_epoch.data, prepare_binding.data() + 32, 16) && + confirmed_hash.size == 32 && !std::memcmp(confirmed_hash.data, prepare_binding.data(), 32) && + from_revision == 0 && to_revision == 1 && remaining == 120000); + for (unsigned i = 0; i < 64; ++i) assert(confirmed_author.data[i] == i); + const auto completed_calls = prepare_network_reads; + prepare_clock += 100; + assert(prepare().code == 0 && prepare_network_reads == completed_calls); + session[0] ^= 1; + assert(prepare().code == 2); + session[0] ^= 1; + prepare_clock = 121000; + assert(prepare().code == 10); + prepare_id[0] += 1; + prepare_latest_result = WebResult::Pending; + assert(prepare().code == 5); + prepare_binding[0] ^= 1; + assert(prepare().code == 4); + prepare_binding[0] ^= 1; + prepare_boot = 2; + assert(prepare().code == 4); + prepare_id[0] += 1; + fake_write_failure = true; + const auto protected_disk = fake_files.at(management::kPublicationTicketPath); + assert(prepare().code == 6 && fake_files.at(management::kPublicationTicketPath) == protected_disk); + fake_write_failure = false; + assert(prepare().code == 5); + assert(management::loadPublicationTicket(ticket.data(), ticket.size())); + assert(management::completePublicationCheck(ticket.data(), management::kPublicationTicketBytes, prepare_clock)); + assert(management::advancePublicationTicket(ticket.data(), management::kPublicationTicketBytes, management::PublicationTicketPhase::Intent)); + assert(management::savePublicationTicket(ticket.data(), management::kPublicationTicketBytes)); + prepare_id[0] += 1; + prepare_clock += 500000; + assert(prepare().code == 4); // Unresolved commit intent is never overwritten by PREPARE. + fake_files[management::kPublicationTicketPath][53] ^= 1; + assert(prepare().code == 6); + fake_files.erase(management::kPublicationTicketPath); + prepare_id[0] += 1; + prepare_binding[48] = 1; + prepare_binding[52] = 2; + std::memset(prepare_binding.data() + 56, 0xb1, 32); + prepare_latest_result = WebResult::Ready; + prepare_latest[0] = 2; + assert(prepare(2).code == 4); + prepare_latest[0] = 1; + assert(prepare(2).code == 4); // Same revision number is insufficient without the predecessor hash. + std::memset(prepare_latest.data() + 4, 0xb1, 32); + assert(prepare(2).code == 0); + ++prepare_id[0]; + session[0] ^= 1; + assert(prepare(2).code == 2); + session[0] ^= 1; + assert(prepare(2).code == 0); // The same owner may replace an unsubmitted review without waiting for expiry. + job_operation = prepare_id; + job_current_result = job_history_result = WebResult::NotFound; + std::array commit_body{}; + geocaching::protocol::CmpWriter commit_encoder(commit_body.data(), commit_body.size()); + assert(commit_encoder.array(3) && commit_encoder.binary({prepare_id.data(), 16}) && + commit_encoder.binary({prepare_binding.data(), 32}) && commit_encoder.unsignedInteger(7)); + const auto commit = [&] { return dispatch(68, commit_body.data(), commit_encoder.size()); }; + job_history_result = WebResult::Ready; + assert(commit().code == 4 && !job_queue_calls); // A retained ID cannot be reused by a new ticket. + job_history_result = WebResult::NotFound; + const auto clock_before_expiry = prepare_clock; + commit_binding_delay = 120000; + assert(commit().code == 10 && !job_queue_calls); + commit_binding_delay = 0; + prepare_clock = clock_before_expiry; + session[0] ^= 1; + assert(commit().code == 2 && !job_queue_calls); + session[0] ^= 1; + commit_body[21] ^= 1; + assert(commit().code == 4 && !job_queue_calls); + commit_body[21] ^= 1; + commit_body[commit_encoder.size() - 1] = 8; + assert(commit().code == 9 && !job_queue_calls); + commit_body[commit_encoder.size() - 1] = 7; + prepare_binding[0] ^= 1; + assert(commit().code == 4 && !job_queue_calls); + prepare_binding[0] ^= 1; + fake_write_failure = true; + assert(commit().code == 6 && !job_queue_calls); + fake_write_failure = false; + job_queue_admit = false; + assert(commit().code == 5 && job_queue_calls == 1 && !job_admissions); + assert(management::loadPublicationTicket(ticket.data(), ticket.size()) && ticket[4] == 1); + job_queue_admit = true; + auto accepted = commit(); + assert(accepted.code == 0 && job_queue_calls == 2 && job_admissions == 1); + geocaching::protocol::CmpReader admission({accepted.body, accepted.body_size}); + geocaching::ByteView operation_id, draft_id, selected_directory, content_hash; + uint64_t admitted_generation = 0, admitted_revision = 0, admitted = 0; + assert(admission.array(fields, 7) && fields == 7 && admission.binary(operation_id, 16) && + admission.binary(draft_id, 16) && admission.unsignedInteger(admitted_generation) && + admission.binary(selected_directory, 16) && admission.binary(content_hash, 32) && + admission.unsignedInteger(admitted_revision) && admission.unsignedInteger(admitted) && admission.finished()); + assert(!std::memcmp(operation_id.data, prepare_id.data(), 16) && !std::memcmp(draft_id.data, request.data(), 16) && + admitted_generation == 2 && !std::memcmp(content_hash.data, prepare_binding.data(), 32) && admitted_revision == 2 && admitted == 1); + assert(management::loadPublicationTicket(ticket.data(), ticket.size()) && ticket[4] == 2); + const auto admitted_disk = fake_files.at(management::kPublicationTicketPath); + assert(commit().code == 0 && job_admissions == 1 && fake_files.at(management::kPublicationTicketPath) == admitted_disk); + ++prepare_boot; + prepare_clock += 500000; + job_current_result = WebResult::NotFound; + job_history_result = WebResult::Ready; + assert(commit().code == 0 && job_admissions == 1); // A saved result survives boot/expiry. + job_history_result = WebResult::NotFound; + assert(commit().code == 5 && job_admissions == 1); // Unknown admission is never requeued. + const auto old_prepare_id = prepare_id; + ++prepare_id[0]; + assert(prepare(2).code == 4 && fake_files.at(management::kPublicationTicketPath) == admitted_disk); + job_history_result = WebResult::Ready; + assert(prepare(2).code == 0); // A verified terminal result allows the next prepare. + assert(job_admissions == 1 && job_operation == old_prepare_id); + management::web_config::Metadata signature_metadata; + signature_metadata.owner[0] = 22; signature_metadata.id[0] = 33; + signature_metadata.hash[0] = 44; signature_metadata.kind = 5; + signature_metadata.phase = 1; signature_metadata.total = signature_metadata.offset = 100; + std::array arena{}; + assert(management::web_config::save(signature_metadata, arena.data())); + std::array signature_operation{}; signature_operation[0] = 55; + geocaching::protocol::CmpWriter signature_writer(arena.data() + 41, arena.size() - 41); + assert(signature_writer.array(5) && signature_writer.binary({signature_metadata.id, 16}) && signature_writer.binary({signature_metadata.hash, 32}) && + signature_writer.unsignedInteger(100) && signature_writer.unsignedInteger(7) && signature_writer.binary({signature_operation.data(), 16})); + management::EnvelopeView signature_request; + signature_request.session = signature_metadata.owner; signature_request.code = uint16_t(management::Operation::CacheSignBegin); + signature_request.body = arena.data() + 41; signature_request.body_size = signature_writer.size(); + size_t signature_written = 0; + assert(management::cache_signature(signature_request, arena.data() + 41, arena.size() - 41, signature_written) == management::Status::Ok); + assert(local_signature_queues == 1 && local_signature_operation == signature_operation && local_signature_hash[0] == 44); + { + signature_metadata.kind=6;assert(management::web_config::save(signature_metadata,arena.data()+41)); + std::array proof_raw{};std::array epoch{};epoch.fill(9);std::array proof_signature{},directory_key{}; + ::geocaching::protocol::CmpWriter proof_writer(proof_raw.data(),proof_raw.size()); + assert(proof_writer.array(8)&&proof_writer.unsignedInteger(1)&&proof_writer.binary({epoch.data(),16})&&proof_writer.binary({epoch.data(),16})&& + proof_writer.binary({signature_metadata.hash,32})&&proof_writer.unsignedInteger(1)&&proof_writer.binary({signature_metadata.hash,32})&&proof_writer.unsignedInteger(0)&&proof_writer.unsignedInteger(1800000000)); + ::geocaching::protocol::CmpWriter confirmation_writer(arena.data()+41,arena.size()-41); + assert(confirmation_writer.array(7)&&confirmation_writer.binary({signature_metadata.id,16})&&confirmation_writer.binary({signature_metadata.hash,32})&&confirmation_writer.unsignedInteger(7)&& + confirmation_writer.binary({proof_raw.data(),proof_writer.size()})&&confirmation_writer.binary({proof_signature.data(),64})&&confirmation_writer.binary({directory_key.data(),64})&&confirmation_writer.binary({epoch.data(),16})); + signature_request.code=uint16_t(management::Operation::CacheConfirmBegin);signature_request.body_size=confirmation_writer.size(); + assert(management::cache_confirmation(signature_request,arena.data()+41,arena.size()-41,signature_written)==management::Status::Ok&&local_signature_queues==2); + } +} diff --git a/tests/hostlink/test_manifest_file_check.cpp b/tests/hostlink/test_manifest_file_check.cpp new file mode 100644 index 00000000..1b8a18a8 --- /dev/null +++ b/tests/hostlink/test_manifest_file_check.cpp @@ -0,0 +1,230 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/manifest_file_check.h" +#include "track_sharing/prepared_signature.h" +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Io +{ + enum class Mode + { + Read + }; + struct OpenResult + { + void* handle; + }; + std::vector bytes; + size_t offset = 0; + bool open_ = false; + OpenResult open(const char*, Mode) + { + assert(!open_); + open_ = true; + return {this}; + } + bool close(void*) + { + open_ = false; + return true; + } + bool size(void*, uint64_t& value) + { + value = bytes.size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= bytes.size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + assert(length <= 320); + std::memcpy(output, bytes.data() + offset, length); + return length; + } +}; +static char names[2048]; +static uint8_t scratch[320]; +struct PreparedAdapter +{ + unsigned calls = 0; + chat::DeviceAuthorResult signTrackManifest(const uint8_t*, size_t, uint32_t, track_sharing::ManifestView&, uint8_t*, size_t, uint8_t* signature, size_t) + { + ++calls; + std::memset(signature, 7, 64); + return chat::DeviceAuthorResult::Ok; + } +}; +struct PreparedBlobs +{ + bool fail = false; + unsigned calls = 0; + track_sharing::SignedStoreResult persist(geocaching::ByteView, geocaching::ByteView, uint8_t*, size_t) + { + ++calls; + return fail ? track_sharing::SignedStoreResult::StorageFailure : track_sharing::SignedStoreResult::Ready; + } +}; +struct PreparedJournal +{ + bool fail = false; + unsigned calls = 0; + track_sharing::ReservationStoreResult validateCurrent(const track_sharing::RevisionReservation&, track_sharing::RevisionReservation&, uint8_t*, size_t) + { + return track_sharing::ReservationStoreResult::Ready; + } + track_sharing::ReservationStoreResult advance(const track_sharing::RevisionReservation& current, track_sharing::ReservationPhase phase, track_sharing::RevisionReservation& verify, uint8_t*, size_t) + { + ++calls; + verify = current; + verify.sequence++; + verify.phase = phase; + return fail ? track_sharing::ReservationStoreResult::StorageFailure : track_sharing::ReservationStoreResult::Ready; + } +}; +int main() +{ + Io io; + const std::string xml = "" + std::string(400, 'x') + "179,1 -179,-2"; + io.bytes.assign(xml.begin(), xml.end()); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t digest[32]; + assert(hash.start() && hash.update(io.bytes.data(), io.bytes.size()) && hash.finish(digest)); + track_sharing::VerifiedManifestView view; + view.verified = true; + auto& manifest = view.manifest; + manifest.file_hash = {digest, 32}; + manifest.file_bytes = uint32_t(io.bytes.size()); + manifest.points = 2; + manifest.west = 1790000000; + manifest.east = -1790000000; + manifest.south = -20000000; + manifest.north = 10000000; + using Check = track_sharing::ManifestFileCheck; + using Result = track_sharing::ManifestFileResult; + { + Check refused(io, hash, names, sizeof(names)); + view.verified = false; + assert(refused.begin("uploaded.kml", view) == Result::Invalid && !io.open_); + view.verified = true; + } + static_assert(sizeof(Check) <= 4096, "Combined file check grew unbounded storage"); + { + uint8_t raw[512], author[64] = {1}, nonce[16] = {2}; + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(14) && writer.unsignedInteger(1) && writer.binary({author, 64}) && writer.binary({nonce, 16}) && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.nil() && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.text("Track") && writer.text("")); + assert(writer.array(4) && writer.signedInteger(manifest.west) && writer.signedInteger(manifest.south) && writer.signedInteger(manifest.east) && writer.signedInteger(manifest.north)); + assert(writer.array(4) && writer.unsignedInteger(0) && writer.nil() && writer.nil() && writer.unsignedInteger(2)); + assert(writer.array(4) && writer.binary({digest, 32}) && writer.unsignedInteger(io.bytes.size()) && writer.text("application/vnd.google-earth.kml+xml") && writer.unsignedInteger(1)); + track_sharing::ManifestView candidate; + Check preflight(io, hash, names, sizeof(names)); + author[0] ^= 1; + assert(preflight.beginCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, candidate) == Result::Invalid && !io.open_); + author[0] ^= 1; + assert(preflight.beginCandidate("uploaded.kml", {raw, writer.size() - 1}, {author, 64}, candidate) == Result::Invalid && !io.open_); + track_sharing::UploadJournal upload; + uint8_t author_hash[32]; + assert(hash.start() && hash.update(author, 64) && hash.finish(author_hash)); + upload.id[0] = 1; + upload.sequence = 1; + upload.generation = 7; + upload.total = uint32_t(io.bytes.size()); + upload.durable_offset = upload.total; + upload.phase = track_sharing::UploadPhase::Sealed; + std::memcpy(upload.author_hash, author_hash, 32); + std::memcpy(upload.file_hash, digest, 32); + assert(preflight.beginUploadedCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, upload, 8, {author_hash, 32}, candidate) == Result::Invalid && !io.open_); + upload.phase = track_sharing::UploadPhase::Checking; + assert(preflight.beginUploadedCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, upload, 7, {author_hash, 32}, candidate) == Result::Invalid && !io.open_); + upload.phase = track_sharing::UploadPhase::Sealed; + upload.file_hash[31] ^= 1; + assert(preflight.beginUploadedCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, upload, 7, {author_hash, 32}, candidate) == Result::Invalid && !io.open_); + upload.file_hash[31] ^= 1; + author_hash[31] ^= 1; + assert(preflight.beginUploadedCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, upload, 7, {author_hash, 32}, candidate) == Result::Invalid && !io.open_); + author_hash[31] ^= 1; + assert(preflight.beginUploadedCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, upload, 7, {author_hash, 32}, candidate) == Result::Pending); + assert(preflight.beginCandidate("uploaded.kml", {raw, writer.size()}, {author, 64}, candidate) == Result::Busy); + Result result; + do + { + result = preflight.step(scratch, sizeof(scratch)); + assert(!io.open_); + } while (result == Result::Pending); + assert(result == Result::Ready && candidate.author.size == 64); + track_sharing::RevisionReservation reserved, verified; + reserved.operation[0] = 1; + reserved.generation = 7; + reserved.revision = 1; + reserved.sequence = 1; + reserved.phase = track_sharing::ReservationPhase::Checked; + std::memcpy(reserved.author, author_hash, 32); + assert(track_sharing::deriveManifestHashes({raw, writer.size()}, candidate, hash, reserved.object, reserved.candidate)); + uint8_t workspace[4200], signature[64]; + PreparedAdapter adapter; + PreparedBlobs blobs; + PreparedJournal journal; + using Prepared = track_sharing::PreparedSignatureResult; + const auto reserved_before = reserved; + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), reserved.operation, 64) == Prepared::Invalid); + assert(!std::memcmp(&reserved, &reserved_before, sizeof(reserved)) && adapter.calls == 0 && blobs.calls == 0); + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), author, 64) == Prepared::Invalid && author[0] == 1 && adapter.calls == 0); + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, reinterpret_cast(&candidate), sizeof(workspace), signature, sizeof(signature)) == Prepared::Invalid && candidate.author.data && adapter.calls == 0); + Check unfinished(io, hash, names, sizeof(names)); + const size_t file_hash_offset = size_t(candidate.file_hash.data - raw); + raw[file_hash_offset] ^= 1; + assert(track_sharing::deriveManifestHashes({raw, writer.size()}, candidate, hash, reserved.object, reserved.candidate)); + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), signature, sizeof(signature)) == Prepared::Conflict && adapter.calls == 0 && blobs.calls == 0); + raw[file_hash_offset] ^= 1; + assert(track_sharing::deriveManifestHashes({raw, writer.size()}, candidate, hash, reserved.object, reserved.candidate)); + assert(track_sharing::persistPreparedSignature(unfinished, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), signature, sizeof(signature)) == Prepared::NotChecked && adapter.calls == 0); + blobs.fail = true; + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), signature, sizeof(signature)) == Prepared::StorageFailure && journal.calls == 0); + for (auto byte : signature) assert(!byte); + blobs.fail = false; + journal.fail = true; + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), signature, sizeof(signature)) == Prepared::StorageFailure && reserved.phase == track_sharing::ReservationPhase::Checked); + for (auto byte : signature) assert(!byte); + journal.fail = false; + assert(track_sharing::persistPreparedSignature(preflight, {raw, writer.size()}, {author, 64}, 7, {author_hash, 32}, reserved, verified, hash, adapter, blobs, journal, candidate, workspace, sizeof(workspace), signature, sizeof(signature)) == Prepared::Ready && reserved.phase == track_sharing::ReservationPhase::Signed); + // A valid v2 document cannot become Ready from only a KML proof. + std::vector extended{0xdc, 0, 16, 2}; + extended.insert(extended.end(), raw + 2, raw + writer.size()); + const uint8_t tail[] = {0x98, 0, 0x90, 0, 0, 0, 0, 0, 0xc0, 0xc0}; + extended.insert(extended.end(), std::begin(tail), std::end(tail)); + track_sharing::ManifestView unsupported_view; + Check unsupported(io, hash, names, sizeof(names)); + assert(unsupported.beginCandidate("uploaded.kml", {extended.data(), extended.size()}, {author, 64}, unsupported_view) == Result::Invalid); + assert(unsupported_view.version == 2 && !unsupported.ready() && !io.open_); + } + for (unsigned mode = 0; mode < 3; ++mode) + { + Check check(io, hash, names, sizeof(names)); + if (mode == 1) manifest.points = 3; + else manifest.points = 2; + if (mode == 2) digest[0] ^= 1; + assert(check.begin("uploaded.kml", view) == Result::Pending); + Result result; + do + { + result = check.step(scratch, sizeof(scratch)); + assert(!io.open_); + } while (result == Result::Pending); + assert(result == (mode == 0 ? Result::Ready : mode == 1 ? Result::GeometryMismatch + : Result::HashMismatch)); + if (mode == 2) digest[0] ^= 1; + } + Check cancelled(io, hash, names, sizeof(names)); + assert(cancelled.begin("uploaded.kml", view) == Result::Pending && cancelled.cancel() == Result::Cancelled && !io.open_); +} diff --git a/tests/hostlink/test_meshtastic_position_consumers.cpp b/tests/hostlink/test_meshtastic_position_consumers.cpp new file mode 100644 index 00000000..440d94c5 --- /dev/null +++ b/tests/hostlink/test_meshtastic_position_consumers.cpp @@ -0,0 +1,152 @@ +#include "chat/runtime/meshtastic_position_core.h" +#include "meshtastic/portnums.pb.h" +#include "pb_decode.h" +#include "pb_encode.h" + +#include +#include +#include +#include + +namespace app +{ +struct Settings +{ + bool primary_channel_has_module_settings = true; + bool secondary_channel_has_module_settings = true; + uint32_t primary_channel_position_precision = 13; + uint32_t secondary_channel_position_precision = 19; +}; +struct Facade +{ + Settings settings{}; + const Settings& readConfig() const { return settings; } +}; +Facade& configFacade() +{ + static Facade value; + return value; +} +} // namespace app + +namespace +{ +namespace runtime = chat::runtime; +enum class ChannelId +{ + PRIMARY, + SECONDARY +}; +meshtastic_Data mirrored = meshtastic_Data_init_zero; +void queueMqttProxyPublishFromWire(const uint8_t*, size_t, const meshtastic_Data* data, ChannelId) +{ + if (data) mirrored = *data; +} +bool appConsumer(meshtastic_Data& data, ChannelId out_channel) +{ + struct + { + meshtastic_Data& decoded; + } scratch{data}; + std::array data_buffer{}, wire_buffer{}; + size_t data_size = 0, wire_size = 0, len = data.payload.size; + const uint8_t* payload = data.payload.bytes; + const auto portnum = data.portnum; + const uint32_t dest_node = 0xffffffff, node_id_ = 42; + const bool use_pki = false; + meshtastic_Routing_Error last_send_error_ = meshtastic_Routing_Error_NONE; + (void)last_send_error_; + // These fragments are extracted from the current production adapter. Only + // the surrounding platform/config/network boundary is a native fixture. +#include "position_app_actual.inc" + assert(payload == data.payload.bytes && len == data.payload.size); + if (portnum == meshtastic_PortNum_POSITION_APP) + { + meshtastic_Data wire = meshtastic_Data_init_zero; + pb_istream_t input = pb_istream_from_buffer(data_buffer.data(), data_size); + assert(pb_decode(&input, meshtastic_Data_fields, &wire)); + assert(wire.payload.size == data.payload.size); + assert(std::memcmp(wire.payload.bytes, data.payload.bytes, data.payload.size) == 0); + } +#include "position_mirror_actual.inc" + assert(mirrored.payload.size == data.payload.size); + assert(std::memcmp(mirrored.payload.bytes, payload, len) == 0); + assert(mirrored.source == 42 && mirrored.dest == 0xffffffff); + return true; +} +bool packetConsumer(meshtastic_Data& data, uint8_t channel) +{ + struct + { + uint8_t channel; + } packet{channel}; + meshtastic_Routing_Error last_send_error_ = meshtastic_Routing_Error_NONE; + (void)last_send_error_; +#include "position_packet_actual.inc" + return true; +} +meshtastic_Data positionData() +{ + runtime::MeshtasticPositionInput input{}; + input.valid = true; + input.latitude_deg = 26.67773; + input.longitude_deg = 107.28225; + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_POSITION_APP; + size_t size = sizeof(data.payload.bytes); + assert(runtime::MeshtasticPositionCore::buildPositionPayload(input, data.payload.bytes, &size)); + data.payload.size = static_cast(size); + return data; +} +uint32_t bits(const meshtastic_Data& data) +{ + meshtastic_Position decoded = meshtastic_Position_init_zero; + pb_istream_t input = pb_istream_from_buffer(data.payload.bytes, data.payload.size); + assert(pb_decode(&input, meshtastic_Position_fields, &decoded)); + return decoded.precision_bits; +} +} // namespace + +int main() +{ + auto& config = app::configFacade().settings; + auto primary = positionData(), secondary = primary; + assert(appConsumer(primary, ChannelId::PRIMARY)); + assert(appConsumer(secondary, ChannelId::SECONDARY)); + assert(bits(primary) == 13 && bits(secondary) == 19); + assert(std::memcmp(primary.payload.bytes, secondary.payload.bytes, primary.payload.size) != 0); + primary = positionData(); + secondary = primary; + assert(packetConsumer(primary, 0)); + assert(packetConsumer(secondary, 1)); + assert(bits(primary) == 13 && bits(secondary) == 19); + config.primary_channel_position_precision = 0; + primary = positionData(); + secondary = primary; + assert(!appConsumer(primary, ChannelId::PRIMARY)); + assert(!packetConsumer(primary, 0)); + assert(appConsumer(secondary, ChannelId::SECONDARY)); + assert(bits(secondary) == 19); + config.primary_channel_has_module_settings = false; + primary = positionData(); + const auto legacy = primary; + assert(appConsumer(primary, ChannelId::PRIMARY)); + assert(std::memcmp(primary.payload.bytes, legacy.payload.bytes, legacy.payload.size) == 0); + assert(packetConsumer(primary, 0)); + config.primary_channel_has_module_settings = true; + config.primary_channel_position_precision = UINT32_MAX; + primary = positionData(); + assert(!appConsumer(primary, ChannelId::PRIMARY)); + assert(!packetConsumer(primary, 0)); + primary.payload.size = 0; + assert(appConsumer(primary, ChannelId::PRIMARY)); + assert(packetConsumer(primary, 0)); + primary.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + primary.payload.size = 3; + std::memcpy(primary.payload.bytes, "abc", 3); + assert(appConsumer(primary, ChannelId::PRIMARY)); + assert(packetConsumer(primary, 0)); + assert(std::memcmp(primary.payload.bytes, "abc", 3) == 0); + std::cout << "Actual ESP app/packet consumers: primary/secondary, disabled/legacy/invalid, wire and MQTT payload agree. Position scalar bytes=" + << sizeof(meshtastic_Position) << '\n'; +} diff --git a/tests/hostlink/test_object_head.cpp b/tests/hostlink/test_object_head.cpp new file mode 100644 index 00000000..cbcb2dc2 --- /dev/null +++ b/tests/hostlink/test_object_head.cpp @@ -0,0 +1,79 @@ +#include "track_sharing/object_head.h" +#include +int main() +{ + using namespace track_sharing; + ObjectHead first, decoded; + first.object[0] = 1; + first.hash[0] = 2; + first.operation[0] = 3; + first.generation = 7; + first.revision = 1; + first.sequence = 1; + uint8_t raw[kObjectHeadBytes]; + assert(encodeObjectHead(first, raw, sizeof(raw)) && decodeObjectHead(raw, sizeof(raw), decoded) && decoded.revision == 1); + for (size_t i = 0; i < sizeof(raw); ++i) + { + raw[i] ^= 1; + assert(!decodeObjectHead(raw, sizeof(raw), decoded) && !decoded.sequence); + raw[i] ^= 1; + } + auto committed = first; + committed.sequence = 2; + committed.phase = HeadPhase::Committed; + assert(objectHeadAdvanceValid(first, committed)); + auto rebound = committed; + ++rebound.sequence; + rebound.generation = 8; + assert(objectHeadAdvanceValid(committed, rebound)); + rebound.phase = HeadPhase::Signed; + assert(!objectHeadAdvanceValid(committed, rebound)); + rebound.phase = HeadPhase::Committed; + rebound.hash[0] ^= 1; + assert(!objectHeadAdvanceValid(committed, rebound)); + rebound.hash[0] ^= 1; + rebound.operation[0] ^= 1; + assert(!objectHeadAdvanceValid(committed, rebound)); + rebound.operation[0] ^= 1; + rebound.archived = true; + assert(!objectHeadAdvanceValid(committed, rebound)); + auto stale_signed = first; + ++stale_signed.sequence; + stale_signed.generation = 8; + assert(!objectHeadAdvanceValid(first, stale_signed)); + auto next = committed; + next.sequence = 3; + next.revision = 2; + next.phase = HeadPhase::Signed; + std::memcpy(next.parent, first.hash, 32); + next.hash[0] = 4; + next.operation[0] = 5; + assert(objectHeadAdvanceValid(committed, next) && !objectHeadAdvanceValid(first, next)); + next.parent[0] ^= 1; + assert(!objectHeadAdvanceValid(committed, next)); + next.parent[0] ^= 1; + next.revision = 3; + assert(!objectHeadAdvanceValid(committed, next)); + next.revision = 2; + next.generation = 8; + assert(!objectHeadAdvanceValid(committed, next)); + next.generation = 7; + next.archived = true; + assert(objectHeadAdvanceValid(committed, next)); + auto archived = next; + archived.sequence = 4; + archived.phase = HeadPhase::Committed; + assert(objectHeadAdvanceValid(next, archived)); + auto reopened = archived; + reopened.sequence = 5; + reopened.revision = 3; + reopened.phase = HeadPhase::Signed; + reopened.archived = false; + std::memcpy(reopened.parent, archived.hash, 32); + reopened.hash[0] = 6; + reopened.operation[0] = 7; + assert(!objectHeadAdvanceValid(archived, reopened)); + first.sequence = UINT64_MAX; + assert(encodeObjectHead(first, raw, sizeof(raw)) && decodeObjectHead(raw, sizeof(raw), decoded) && decoded.sequence == UINT64_MAX); + assert(!objectHeadAdvanceValid(first, committed)); +} diff --git a/tests/hostlink/test_object_head_store.cpp b/tests/hostlink/test_object_head_store.cpp new file mode 100644 index 00000000..4706c917 --- /dev/null +++ b/tests/hostlink/test_object_head_store.cpp @@ -0,0 +1,153 @@ +#include "track_sharing/object_head_store.h" +#include "track_sharing/publication_journal_store.h" +#include +#include +#include +#include +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + bool active = false, short_write = false, unavailable = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + if (unavailable) return {nullptr, OpenStatus::Unavailable}; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool size(void*, uint64_t& value) + { + value = files[path].size(); + return true; + } + size_t read(void*, uint8_t* out, size_t count) + { + count = std::min(count, files[path].size()); + std::memcpy(out, files[path].data(), count); + return count; + } + size_t write(void*, const uint8_t* in, size_t count) + { + if (short_write) count /= 2; + files[path].assign(in, in + count); + return count; + } + bool sync(void*) { return true; } + bool close(void*) + { + active = false; + return true; + } +}; +int main() +{ + using namespace track_sharing; + Io io; + ObjectHeadStore store(io, "a", "b"); + static_assert(sizeof(store) <= 24, "Head store must borrow all records"); + ObjectHead current, next, verify; + uint8_t scratch[kObjectHeadBytes]; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Missing); + current.object[0] = 1; + current.hash[0] = 2; + current.operation[0] = 3; + current.sequence = current.revision = 1; + current.generation = 7; + assert(store.initialize(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready); + const auto initial_files = io.files; + assert(store.initialize(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Conflict && io.files == initial_files); + next = current; + next.sequence = 2; + next.phase = HeadPhase::Committed; + assert(store.advance(current, next, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready); + const auto committed_files = io.files; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready); + assert(store.rebindCommittedGeneration(current, verify, 8, scratch, sizeof(scratch)) == HeadStoreResult::Ready && current.generation == 8 && current.sequence == 3); + const auto rebound_files = io.files; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready && current.generation == 8); + assert(store.rebindCommittedGeneration(current, verify, 8, scratch, sizeof(scratch)) == HeadStoreResult::Ready && io.files == rebound_files); + assert(store.rebindCommittedGeneration(current, verify, 0, scratch, sizeof(scratch)) == HeadStoreResult::Invalid && io.files == rebound_files); + // Generations are local session epochs, not an ordering of author history. + assert(store.rebindCommittedGeneration(current, verify, 2, scratch, sizeof(scratch)) == HeadStoreResult::Ready && current.generation == 2); + io.files = committed_files; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready && current.sequence == 2); + auto stale = next; + stale.sequence = 1; + stale.phase = HeadPhase::Signed; + assert(store.advance(stale, next, verify, scratch, sizeof(scratch)) == HeadStoreResult::Conflict && io.files == committed_files); + next = current; + next.sequence = 3; + next.revision = 2; + next.phase = HeadPhase::Signed; + std::memcpy(next.parent, current.hash, 32); + next.hash[0] = 4; + next.operation[0] = 5; + io.short_write = true; + assert(store.advance(current, next, verify, scratch, sizeof(scratch)) == HeadStoreResult::StorageFailure && !io.active); + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Corrupt && !current.sequence); + io.short_write = false; + io.files = committed_files; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready); + assert(store.advance(current, next, verify, scratch, sizeof(scratch)) == HeadStoreResult::Ready); + io.files["a"][0] ^= 1; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::Corrupt && !current.sequence); + io.unavailable = true; + assert(store.load(current, verify, scratch, sizeof(scratch)) == HeadStoreResult::StorageFailure && !current.sequence && !io.active); + { + Io jobs; + PublicationJournalStore journal(jobs, "job.a", "job.b"); + static_assert(sizeof(journal) <= 24, "Publication store cannot own record buffers"); + PublicationJournal job, desired, proof; + uint8_t buffer[kPublicationJournalBytes]; + assert(journal.load(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Missing); + for (const auto field : {geocaching::ByteView{job.operation, 16}, {job.object, 32}, {job.revision_hash, 32}, {job.directory, 16}, {job.directory_epoch, 16}, {job.file_hash, 32}, {job.author_hash, 32}}) std::memset(const_cast(field.data), 1, field.size); + job.sequence = 1; + job.generation = 7; + job.revision = 2; + job.file_bytes = 750; + assert(journal.initialize(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Ready); + desired = job; + desired.sequence = 2; + desired.phase = PublicationPhase::Uploading; + desired.offset = 320; + assert(journal.advance(job, desired, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Ready); + const auto durable = jobs.files; + assert(journal.load(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Ready && job.sequence == 2 && job.offset == 320); + auto stale = job; + stale.sequence = 1; + stale.phase = PublicationPhase::Accepted; + stale.offset = 0; + assert(journal.advance(stale, job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Conflict && jobs.files == durable); + desired = job; + desired.sequence = 3; + desired.offset = 640; + jobs.short_write = true; + assert(journal.advance(job, desired, proof, buffer, sizeof(buffer)) == PublicationStoreResult::StorageFailure && !jobs.active); + assert(journal.load(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Corrupt && !job.sequence); + jobs.short_write = false; + jobs.files = durable; + assert(journal.load(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Ready); + jobs.files["job.a"][0] ^= 1; + assert(journal.load(job, proof, buffer, sizeof(buffer)) == PublicationStoreResult::Corrupt && !job.sequence); + } +} diff --git a/tests/hostlink/test_object_paths.cpp b/tests/hostlink/test_object_paths.cpp new file mode 100644 index 00000000..5c0c0ede --- /dev/null +++ b/tests/hostlink/test_object_paths.cpp @@ -0,0 +1,47 @@ +#include "track_sharing/object_paths.h" +#include "track_sharing/publication_slot_path.h" +#include +#include +int main() +{ + using namespace track_sharing; + uint8_t object[32], key[32]; for (unsigned i = 0; i < 32; ++i) { object[i] = uint8_t(i * 8); key[i] = 0xab; } + char output[kObjectPathCapacity]; + const std::string root = "/trailmate/tracks/0008101820283038404850586068707880889098a0a8b0b8c0c8d0d8e0e8f0f8/"; + assert(resolveObjectPath({object, 32}, ObjectFile::Directory, {}, output, sizeof(output)) && output == root.substr(0, root.size() - 1)); + assert(resolveObjectPath({object, 32}, ObjectFile::HeadA, {}, output, sizeof(output)) && output == root + "head.a"); + assert(resolveObjectPath({object, 32}, ObjectFile::HeadB, {}, output, sizeof(output)) && output == root + "head.b"); + std::string operation, revision; for (unsigned i = 0; i < 32; ++i) { revision += "ab"; if (i < 16) operation += "ab"; } + assert(resolveObjectPath({object, 32}, ObjectFile::ReservationA, {key, 16}, output, sizeof(output)) && output == root + operation + ".rva"); + assert(resolveObjectPath({object, 32}, ObjectFile::ReservationB, {key, 16}, output, sizeof(output)) && output == root + operation + ".rvb"); + assert(resolveObjectPath({object, 32}, ObjectFile::SignedTemporary, {key, 32}, output, sizeof(output)) && output == root + revision + ".tmp"); + assert(resolveObjectPath({object, 32}, ObjectFile::SignedManifest, {key, 32}, output, sizeof(output)) && output == root + revision + ".cmp"); + assert(resolveObjectPath({object, 32}, ObjectFile::KmlTemporary, {key, 32}, output, sizeof(output)) && output == root + revision + ".ktm"); + assert(resolveObjectPath({object, 32}, ObjectFile::KmlFile, {key, 32}, output, sizeof(output)) && output == root + revision + ".kml"); + assert(resolveObjectPath({object, 32}, ObjectFile::PublicationA, {key, 16}, output, sizeof(output)) && output == root + operation + ".pua"); + assert(resolveObjectPath({object, 32}, ObjectFile::PublicationB, {key, 16}, output, sizeof(output)) && output == root + operation + ".pub"); + assert(resolveObjectPath({object, 32}, ObjectFile::PublicationDirectory, {key, 16}, output, sizeof(output)) && output == root + operation + ".dir"); + assert(resolveObjectPath({object, 32}, ObjectFile::ReceiptTemporary, {key, 16}, output, sizeof(output)) && output == root + operation + ".rct"); + assert(resolveObjectPath({object, 32}, ObjectFile::Receipt, {key, 16}, output, sizeof(output)) && output == root + operation + ".rcp"); + const auto preserved = std::string(output); + PublicationSlotKey parsed; + for (const auto slot : {ObjectFile::PublicationA, ObjectFile::PublicationB}) + { + assert(resolveObjectPath({object,32},slot,{key,16},output,sizeof(output))); + const std::string canonical(output); + assert(parsePublicationSlotPath(output,canonical.size(),parsed)); + assert(!std::memcmp(parsed.object,object,32) && !std::memcmp(parsed.operation,key,16) && parsed.slot==slot); + for (auto invalid : {canonical + "x", canonical.substr(1), root + operation + ".rct", root + operation + ".rcp"}) + assert(!parsePublicationSlotPath(invalid.data(),invalid.size(),parsed)); + auto invalid=canonical;invalid[17]='A';assert(!parsePublicationSlotPath(invalid.data(),invalid.size(),parsed)); + invalid=canonical;invalid.replace(82,32,32,'0');assert(!parsePublicationSlotPath(invalid.data(),invalid.size(),parsed)); + } + std::memcpy(output,preserved.c_str(),preserved.size()+1); + assert(!resolveObjectPath({object, 32}, ObjectFile::SignedManifest, {key, 16}, output, sizeof(output)) && output == preserved); + assert(!resolveObjectPath({object, 32}, ObjectFile::HeadA, {key, 16}, output, sizeof(output)) && output == preserved); + assert(!resolveObjectPath({object, 32}, static_cast(7), {}, output, sizeof(output)) && output == preserved); + assert(!resolveObjectPath({object, 32}, static_cast(9), {key, 32}, output, sizeof(output)) && output == preserved); + assert(!resolveObjectPath({object, 32}, static_cast(14), {key, 16}, output, sizeof(output)) && output == preserved); + assert(!resolveObjectPath({reinterpret_cast(output + 10), 32}, ObjectFile::HeadA, {}, output, sizeof(output)) && output == preserved); + std::memset(object, 0, sizeof(object)); assert(!resolveObjectPath({object, 32}, ObjectFile::HeadA, {}, output, sizeof(output)) && output == preserved); +} diff --git a/tests/hostlink/test_prepare_request.cpp b/tests/hostlink/test_prepare_request.cpp new file mode 100644 index 00000000..c48eea66 --- /dev/null +++ b/tests/hostlink/test_prepare_request.cpp @@ -0,0 +1,84 @@ +#include "track_sharing/prepare_request.h" +#include "track_sharing/commit_request.h" +#include "track_sharing/publication_journal.h" +#include "track_sharing/receipt.h" +#include "geocaching/protocol/cmp_writer.h" +#include +int main() +{ + uint8_t raw[140], operation[16] = {1}, kml[16] = {2}, manifest[16] = {3}, parent[32] = {4}, zero[32]{}; + track_sharing::PrepareRequest output; + { + uint8_t receipt_raw[512],public_key[64]={7}; + geocaching::protocol::CmpWriter writer(receipt_raw,sizeof(receipt_raw)); + assert(writer.array(13)&&writer.unsignedInteger(1)&&writer.binary({operation,16})&&writer.binary({parent,32})&&writer.binary({parent,32})&& + writer.unsignedInteger(2)&&writer.unsignedInteger(0)&&writer.binary({parent,32})&&writer.unsignedInteger(750)&& + writer.binary({public_key,64})&&writer.binary({kml,16})&&writer.binary({manifest,16})&&writer.unsignedInteger(1)&&writer.unsignedInteger(5000000000ULL)); + track_sharing::ReceiptView receipt; + assert(track_sharing::decodeReceipt({receipt_raw,writer.size()},receipt)&&receipt.accepted==5000000000ULL&&receipt.file_bytes==750); + assert(receipt.raw.data==receipt_raw&&receipt.directory_public.size==64); + assert(!track_sharing::decodeReceipt({receipt_raw,writer.size()-1},receipt)); + receipt_raw[writer.size()]=0; + assert(!track_sharing::decodeReceipt({receipt_raw,writer.size()+1},receipt)); + } + { + track_sharing::PublicationJournal job, restored; + for (const auto field : {geocaching::ByteView{job.operation,16},{job.object,32},{job.revision_hash,32},{job.directory,16},{job.directory_epoch,16},{job.file_hash,32},{job.author_hash,32}})std::memset(const_cast(field.data),1,field.size); + job.sequence=1;job.generation=1;job.revision=1;job.file_bytes=750; + uint8_t journal[track_sharing::kPublicationJournalBytes]; + assert(track_sharing::encodePublicationJournal(job,journal,sizeof(journal)) && track_sharing::decodePublicationJournal(journal,sizeof(journal),restored)); + assert(restored.sequence==1&&restored.file_bytes==750&&restored.phase==track_sharing::PublicationPhase::Accepted); + for(size_t i=0;i +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::map writes; + std::string path; + size_t offset = 0; + bool active = false, short_log = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return true; + } + bool size(void*, uint64_t& size) + { + size = files[path].size(); + return true; + } + bool seek(void*, uint64_t position) + { + offset = size_t(position); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* out, size_t size) + { + assert(size <= 320); + size = std::min(size, files[path].size() - offset); + std::memcpy(out, files[path].data() + offset, size); + offset += size; + return size; + } + size_t write(void*, const uint8_t* in, size_t size) + { + assert(size <= 320); + ++writes[path]; + if (short_log && path == "a") size /= 2; + files[path].resize(offset + size); + std::memcpy(files[path].data() + offset, in, size); + offset += size; + return size; + } + bool sync(void*) { return true; } + bool publish(const char* from, const char* to) + { + assert(!active); + if (!files.count(from) || files.count(to)) return false; + files[to] = std::move(files[from]); + files.erase(from); + return true; + } +}; +struct Adapter +{ + uint8_t public_key[64]{}, secret[64]{}; + unsigned calls = 0; + bool isReady() const { return true; } + bool identityStorageError() const { return false; } + uint32_t authorGeneration() const { return 7; } + void combinedPublicKey(uint8_t* out) const { std::memcpy(out, public_key, 64); } + bool sign(const uint8_t* message, size_t size, uint8_t* signature) + { + ++calls; + ed25519_sign(signature, message, size, public_key + 32, secret); + return true; + } + chat::DeviceAuthorResult signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) + { + return track_sharing::signDeviceTrackManifest(*this, raw, size, generation, view, workspace, capacity, signature, signature_capacity); + } +}; +int main() +{ + using namespace track_sharing; + Io io; + Adapter adapter; + uint8_t seed[32] = {1}; + std::memset(adapter.public_key, 3, 32); + ed25519_create_keypair(adapter.public_key + 32, adapter.secret, seed); + const std::string xml = "1,2"; + io.files["data"] = {xml.begin(), xml.end()}; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t file_hash[32], author_hash[32], raw[512], workspace[4200], signature[64], nonce[16] = {1}; + char names[2048]; + assert(hash.start() && hash.update(io.files["data"].data(), xml.size()) && hash.finish(file_hash)); + assert(hash.start() && hash.update(adapter.public_key, 64) && hash.finish(author_hash)); + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(14) && writer.unsignedInteger(1) && writer.binary({adapter.public_key, 64}) && writer.binary({nonce, 16}) && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.nil() && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.text("Track") && writer.text("")); + assert(writer.array(4) && writer.unsignedInteger(10000000) && writer.unsignedInteger(20000000) && writer.unsignedInteger(10000000) && writer.unsignedInteger(20000000)); + assert(writer.array(4) && writer.unsignedInteger(0) && writer.nil() && writer.nil() && writer.unsignedInteger(1)); + assert(writer.array(4) && writer.binary({file_hash, 32}) && writer.unsignedInteger(xml.size()) && writer.text("application/vnd.google-earth.kml+xml") && writer.unsignedInteger(1)); + geocaching::ByteView manifest{raw, writer.size()}; + const geocaching::ByteView author{adapter.public_key, 64}; + ManifestView view; + RevisionReservation current, verify; + uint8_t operation[16] = {1}; + assert(!createCandidateReservation(manifest, author, 0, {author_hash, 32}, {operation, 16}, nullptr, hash, view, current)); + assert(createCandidateReservation(manifest, author, 7, {author_hash, 32}, {operation, 16}, nullptr, hash, view, current) && current.phase == ReservationPhase::Reserved && current.revision == 1 && current.sequence == 1); + ReservationStore journal(io, "a", "b"); + UploadJournal upload; + upload.id[0] = 2; + upload.sequence = 1; + upload.generation = 7; + upload.total = uint32_t(xml.size()); + upload.durable_offset = upload.total; + upload.phase = UploadPhase::Sealed; + std::memcpy(upload.author_hash, author_hash, 32); + std::memcpy(upload.file_hash, file_hash, 32); + io.files["manifest"] = {raw, raw + writer.size()}; + UploadJournal manifest_upload = upload; + manifest_upload.id[0] = 3; + manifest_upload.total = manifest_upload.durable_offset = uint32_t(writer.size()); + assert(hash.start() && hash.update(raw, writer.size()) && hash.finish(manifest_upload.file_hash)); + uint32_t live_generation = 7; + uint8_t loaded[512]; + PrepareRequest request; + request.operation = {operation, 16}; + request.kml_upload = {upload.id, 16}; + request.manifest_upload = {manifest_upload.id, 16}; + request.generation = 7; + for (const char* damaged : {"manifest", "data"}) + { + io.files[damaged][10] ^= 1; + UploadedCandidateCheck rejected(io, hash, manifest_upload, upload, live_generation, author_hash, names, sizeof(names), view); + assert(rejected.begin(request, "manifest", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Pending); + ManifestFileResult failed; + do + { + failed = rejected.step(workspace, 320); + assert(!io.active); + } while (failed == ManifestFileResult::Pending); + assert(failed != ManifestFileResult::Ready && !rejected.manifest().size && adapter.calls == 0 && !io.files.count("signed")); + io.files[damaged][10] ^= 1; + } + UploadedCandidateCheck check(io, hash, manifest_upload, upload, live_generation, author_hash, names, sizeof(names), view); + static_assert(sizeof(check) <= 4096, "Combined checker must borrow manifest and names storage"); + request.generation = 8; + assert(check.begin(request, "manifest", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Invalid && !io.active && adapter.calls == 0); + request.generation = 7; + request.kml_upload = {manifest_upload.id, 16}; + assert(check.begin(request, "manifest", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Invalid && !io.active && adapter.calls == 0); + request.kml_upload = {upload.id, 16}; + { + request.expected_parent = {file_hash, 32}; + UploadedCandidateCheck wrong_parent(io, hash, manifest_upload, upload, live_generation, author_hash, names, sizeof(names), view); + assert(wrong_parent.begin(request, "manifest", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Pending); + ManifestFileResult failed; + do + { + failed = wrong_parent.step(workspace, 320); + } while (failed == ManifestFileResult::Pending); + assert(failed == ManifestFileResult::Invalid && !wrong_parent.manifest().size && !io.active && adapter.calls == 0); + request.expected_parent = {}; + } + assert(check.begin(request, "manifest", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Pending); + ManifestFileResult checked; + do + { + checked = check.step(workspace, 320); + assert(!io.active); + } while (checked == ManifestFileResult::Pending); + assert(checked == ManifestFileResult::Ready); + manifest = check.manifest(); + assert(manifest.data == loaded && manifest.size == writer.size() && !std::memcmp(loaded, raw, manifest.size)); + RevisionReservation prepared_candidate; + assert(persistCheckedReservation(check, request, manifest, author, {author_hash, 32}, 7, nullptr, nullptr, hash, journal, view, prepared_candidate, current, verify, workspace, sizeof(workspace)) == ReservationStoreResult::Ready && current.phase == ReservationPhase::Checked); + const auto checked_files = io.files; + const auto checked_writes = io.writes; + assert(persistCheckedReservation(check, request, manifest, author, {author_hash, 32}, 7, nullptr, nullptr, hash, journal, view, prepared_candidate, current, verify, workspace, sizeof(workspace)) == ReservationStoreResult::Ready && io.files == checked_files && io.writes == checked_writes); + request.generation = 8; + assert(persistCheckedReservation(check, request, manifest, author, {author_hash, 32}, 7, nullptr, nullptr, hash, journal, view, prepared_candidate, current, verify, workspace, sizeof(workspace)) == ReservationStoreResult::Invalid && io.files == checked_files); + request.generation = 7; + SignedManifestStore blobs(io, "signed.tmp", "signed"); + io.short_log = true; + assert(persistPreparedSignature(check, manifest, author, 7, {author_hash, 32}, current, verify, hash, adapter, blobs, journal, view, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::StorageFailure); + for (auto byte : signature) assert(!byte); + assert(io.files.count("signed") && current.phase == ReservationPhase::Checked && !io.active); + const auto persisted = io.files["signed"]; + const auto blob_writes = io.writes["signed.tmp"]; + assert(journal.load(current, verify, workspace, sizeof(workspace)) == ReservationStoreResult::Ready && current.phase == ReservationPhase::Checked); + io.short_log = false; + assert(persistPreparedSignature(check, manifest, author, 7, {author_hash, 32}, current, verify, hash, adapter, blobs, journal, view, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::Ready); + assert(current.phase == ReservationPhase::Signed && io.files["signed"] == persisted && io.writes["signed.tmp"] == blob_writes && !io.active); + platform::esp::storage::TrackManifestCrypto crypto; + assert(crypto.verify({adapter.public_key + 32, 31}, {signature, 64}, {workspace, 32}) == ManifestVerification::InvalidSignature); + assert(crypto.verify({adapter.public_key + 32, 32}, {signature, 63}, {workspace, 32}) == ManifestVerification::InvalidSignature); + assert(crypto.verify({adapter.public_key + 32, 32}, {signature, 64}, {}) == ManifestVerification::InvalidSignature); + VerifiedManifestView signed_view; + uint8_t parent_bytes[4166]; + SignedManifestReader parent_reader(io); + static_assert(sizeof(parent_reader) <= 48, "Signed reader must borrow content storage"); + assert(parent_reader.begin("signed", parent_bytes, sizeof(parent_bytes)) == FileIntegrityResult::Pending && !parent_reader.bytes().size); + FileIntegrityResult parent_read; + do + { + parent_read = parent_reader.step(); + assert(!io.active); + } while (parent_read == FileIntegrityResult::Pending); + assert(parent_read == FileIntegrityResult::Ready && parent_reader.bytes().size == io.files["signed"].size()); + assert(verifyManifest(parent_reader.bytes(), crypto, hash, workspace, sizeof(workspace), signed_view, author) == ManifestVerification::Valid); + VerifiedManifestView rejected_signature; + const auto signature_tail = parent_reader.bytes().size - 1; + parent_bytes[signature_tail] ^= 1; + assert(verifyManifest(parent_reader.bytes(), crypto, hash, workspace, sizeof(workspace), rejected_signature, author) == ManifestVerification::InvalidSignature && !rejected_signature.verified); + parent_bytes[signature_tail] ^= 1; + { + SignedManifestReader resized(io); + assert(resized.begin("signed", workspace, sizeof(workspace)) == FileIntegrityResult::Pending); + io.files["signed"].push_back(0); + assert(resized.step() == FileIntegrityResult::StorageFailure && !resized.bytes().size && !io.active); + for (size_t i = 0; i < persisted.size(); ++i) assert(workspace[i] == 0); + io.files["signed"].pop_back(); + SignedManifestReader cancelled(io); + assert(cancelled.begin("signed", workspace, sizeof(workspace)) == FileIntegrityResult::Pending); + assert(cancelled.cancel() == FileIntegrityResult::Cancelled && !cancelled.bytes().size && !io.active); + } + assert(!std::memcmp(signed_view.object_id, current.object, 32) && !std::memcmp(signed_view.revision_hash, current.candidate, 32)); + ObjectHead head, head_verify, head_next; + assert(!createSignedObjectHead(signed_view, current, 8, author, {author_hash, 32}, nullptr, head) && !head.sequence); + current.candidate[0] ^= 1; + assert(!createSignedObjectHead(signed_view, current, 7, author, {author_hash, 32}, nullptr, head) && !head.sequence); + current.candidate[0] ^= 1; + assert(createSignedObjectHead(signed_view, current, 7, author, {author_hash, 32}, nullptr, head) && head.phase == HeadPhase::Signed); + char head_a[kObjectPathCapacity], head_b[kObjectPathCapacity]; + assert(resolveObjectPath({head.object, 32}, ObjectFile::HeadA, {}, head_a, sizeof(head_a)) && resolveObjectPath({head.object, 32}, ObjectFile::HeadB, {}, head_b, sizeof(head_b))); + ObjectHeadStore heads(io, head_a, head_b); + assert(persistSignedObjectHead(heads, signed_view, current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + assert(heads.load(head, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready && !std::memcmp(head.hash, signed_view.revision_hash, 32)); + const auto head_files = io.files; + const auto head_writes = io.writes; + const auto head_signing_calls = adapter.calls; + assert(persistSignedObjectHead(heads, signed_view, current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + assert(io.files == head_files && io.writes == head_writes && adapter.calls == head_signing_calls); + current.operation[0] ^= 1; + assert(persistSignedObjectHead(heads, signed_view, current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Conflict && io.files == head_files && io.writes == head_writes); + current.operation[0] ^= 1; + assert(!createSignedObjectHead(signed_view, current, 7, author, {author_hash, 32}, &head, head_verify) && io.files == head_files); + io.files[head_a][0] ^= 1; + const auto corrupt_head_files = io.files; + assert(persistSignedObjectHead(heads, signed_view, current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Corrupt); + assert(io.files == corrupt_head_files && io.writes == head_writes && adapter.calls == head_signing_calls); + io.files[head_a][0] ^= 1; + const auto signing_calls = adapter.calls; + assert(restorePreparedSignature(check, signed_view, author, 7, {author_hash, 32}, current, verify, journal, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::Ready && adapter.calls == signing_calls); + io.files["a"][0] ^= 1; + assert(journal.load(current, verify, workspace, sizeof(workspace)) == ReservationStoreResult::Ready && current.phase == ReservationPhase::Checked); + assert(restorePreparedSignature(check, signed_view, author, 8, {author_hash, 32}, current, verify, journal, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::StaleIdentity); + for (auto byte : signature) assert(!byte); + assert(restorePreparedSignature(check, signed_view, author, 7, {author_hash, 32}, current, verify, journal, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::Ready && current.phase == ReservationPhase::Signed && adapter.calls == signing_calls); + assert(io.files["signed"] == persisted && !std::memcmp(signature, signed_view.signature.data, 64)); + // Fixture establishes local task admission; this is not a real network job. + assert(heads.load(head, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + head_next = head; + ++head_next.sequence; + head_next.phase = HeadPhase::Committed; + assert(heads.advance(head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + head = head_next; + char canonical_parent[kObjectPathCapacity]; + assert(resolveObjectPath({head.object, 32}, ObjectFile::SignedManifest, {head.hash, 32}, canonical_parent, sizeof(canonical_parent))); + // Fixture places the same immutable signed bytes under device-derived path. + io.files[canonical_parent] = persisted; + uint8_t loaded_parent[4166]; + VerifiedManifestView verified_parent; + { + const auto stable = io.files; + uint8_t foreign_author[64]; + std::memcpy(foreign_author, author.data, 64); + foreign_author[0] ^= 1; + CurrentParentLoader foreign(io, hash, crypto, heads, head, head_verify, verified_parent, canonical_parent, loaded_parent, sizeof(loaded_parent)); + assert(foreign.begin({current.object, 32}, {signed_view.revision_hash, 32}, {foreign_author, 64}, 8, workspace, sizeof(workspace)) == ParentLoadResult::Pending); + ParentLoadResult status; + do + { + status = foreign.step(workspace, sizeof(workspace)); + } while (status == ParentLoadResult::Pending); + assert(status == ParentLoadResult::Invalid && !foreign.ready() && io.files == stable); + CurrentParentLoader restored(io, hash, crypto, heads, head, head_verify, verified_parent, canonical_parent, loaded_parent, sizeof(loaded_parent)); + assert(restored.begin({current.object, 32}, {signed_view.revision_hash, 32}, author, 8, workspace, sizeof(workspace)) == ParentLoadResult::Pending); + do + { + status = restored.step(workspace, sizeof(workspace)); + } while (status == ParentLoadResult::Pending); + assert(status == ParentLoadResult::Ready && restored.ready() && head.generation == 8 && head.revision == 1 && adapter.calls == signing_calls); + assert(heads.load(head, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready && head.generation == 8); + io.files = stable; + } + { + const auto stable_head_files = io.files; + CurrentParentLoader stale_parent(io, hash, crypto, heads, head, head_verify, verified_parent, canonical_parent, loaded_parent, sizeof(loaded_parent)); + assert(stale_parent.begin({current.object, 32}, {signed_view.revision_hash, 32}, author, 7, workspace, sizeof(workspace)) == ParentLoadResult::Pending); + head_next = head; + ++head_next.sequence; + assert(heads.advance(head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + ParentLoadResult stale_result; + do + { + stale_result = stale_parent.step(workspace, sizeof(workspace)); + } while (stale_result == ParentLoadResult::Pending); + assert(stale_result == ParentLoadResult::Conflict && !stale_parent.ready() && adapter.calls == signing_calls); + io.files = stable_head_files; + } + CurrentParentLoader parent_loader(io, hash, crypto, heads, head, head_verify, verified_parent, canonical_parent, loaded_parent, sizeof(loaded_parent)); + assert(parent_loader.begin({current.object, 32}, {signed_view.revision_hash, 32}, author, 7, workspace, sizeof(workspace)) == ParentLoadResult::Pending); + ParentLoadResult parent_status; + do + { + parent_status = parent_loader.step(workspace, sizeof(workspace)); + assert(!io.active); + } while (parent_status == ParentLoadResult::Pending); + assert(parent_status == ParentLoadResult::Ready && parent_loader.ready() && verified_parent.manifest.revision == 1); + geocaching::protocol::CmpWriter update_writer(raw, sizeof(raw)); + assert(update_writer.array(14) && update_writer.unsignedInteger(1) && update_writer.binary(author) && update_writer.binary({nonce, 16}) && update_writer.unsignedInteger(0) && update_writer.unsignedInteger(2) && update_writer.binary({signed_view.revision_hash, 32}) && update_writer.unsignedInteger(0) && update_writer.unsignedInteger(1) && update_writer.unsignedInteger(2) && update_writer.text("Updated") && update_writer.text("")); + assert(update_writer.array(4) && update_writer.unsignedInteger(10000000) && update_writer.unsignedInteger(20000000) && update_writer.unsignedInteger(10000000) && update_writer.unsignedInteger(20000000)); + assert(update_writer.array(4) && update_writer.unsignedInteger(0) && update_writer.nil() && update_writer.nil() && update_writer.unsignedInteger(1)); + assert(update_writer.array(4) && update_writer.binary({file_hash, 32}) && update_writer.unsignedInteger(xml.size()) && update_writer.text("application/vnd.google-earth.kml+xml") && update_writer.unsignedInteger(1)); + io.files["manifest2"] = {raw, raw + update_writer.size()}; + manifest_upload.id[0] = 4; + manifest_upload.total = manifest_upload.durable_offset = uint32_t(update_writer.size()); + assert(hash.start() && hash.update(raw, update_writer.size()) && hash.finish(manifest_upload.file_hash)); + uint8_t next_operation[16] = {2}; + request.operation = {next_operation, 16}; + request.expected_parent = {signed_view.revision_hash, 32}; + ManifestView update_view; + UploadedCandidateCheck update_check(io, hash, manifest_upload, upload, live_generation, author_hash, names, sizeof(names), update_view); + assert(update_check.begin(request, "manifest2", "data", loaded, sizeof(loaded), author) == ManifestFileResult::Pending); + do + { + checked = update_check.step(workspace, 320); + } while (checked == ManifestFileResult::Pending); + assert(checked == ManifestFileResult::Ready); + ReservationStore update_journal(io, "update.a", "update.b"); + RevisionReservation update_desired, update_current, update_verify; + const auto before_update_reservation = io.files; + request.expected_parent = {file_hash, 32}; + assert(persistCheckedReservation(update_check, request, update_check.manifest(), author, {author_hash, 32}, 7, &head, &verified_parent, hash, update_journal, update_view, update_desired, update_current, update_verify, workspace, sizeof(workspace)) == ReservationStoreResult::Conflict && io.files == before_update_reservation && adapter.calls == signing_calls); + request.expected_parent = {signed_view.revision_hash, 32}; + assert(persistCheckedReservation(update_check, request, update_check.manifest(), author, {author_hash, 32}, 7, &head, &verified_parent, hash, update_journal, update_view, update_desired, update_current, update_verify, workspace, sizeof(workspace)) == ReservationStoreResult::Ready); + SignedManifestStore update_blobs(io, "updated.tmp", "updated"); + assert(persistPreparedSignature(update_check, update_check.manifest(), author, 7, {author_hash, 32}, update_current, update_verify, hash, adapter, update_blobs, update_journal, update_view, workspace, sizeof(workspace), signature, sizeof(signature)) == PreparedSignatureResult::Ready); + VerifiedManifestView verified_update; + assert(verifyManifest({io.files["updated"].data(), io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), verified_update, author) == ManifestVerification::Valid); + assert(verified_update.manifest.revision == 2 && !std::memcmp(verified_update.manifest.previous.data, signed_view.revision_hash, 32)); + assert(persistSignedObjectHead(heads, verified_update, update_current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + assert(head.revision == 2 && head.phase == HeadPhase::Signed && !std::memcmp(head.hash, verified_update.revision_hash, 32) && io.files["signed"] == persisted); + FileIntegrityCheck retained_check(io, hash); + assert(retained_check.begin("data", uint32_t(xml.size()), file_hash) == FileIntegrityResult::Pending); + FileIntegrityResult retained_status; + do + { + retained_status = retained_check.step(workspace, 320); + } while (retained_status == FileIntegrityResult::Pending); + assert(retained_status == FileIntegrityResult::Ready); + uint8_t directory[16] = {8}, epoch[16] = {9}; + CommitRequest commit{{head.operation, 16}, {head.object, 32}, {head.hash, 32}, {directory, 16}, {epoch, 16}, 7, 2}; + PublicationJournal intent; + assert(createPublicationIntent(commit, head, verified_update, update_current, author, {author_hash, 32}, retained_check, intent)); + assert(intent.sequence == 1 && intent.revision == 2 && intent.file_bytes == xml.size() && intent.phase == PublicationPhase::Accepted); + PublicationJournalStore publication(io, "publish.a", "publish.b"); + PublicationJournal stored_job, job_verify; + assert(persistPublicationIntent(publication, intent, stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + auto progressed = stored_job; + progressed.sequence = 2; + progressed.phase = PublicationPhase::Uploading; + progressed.offset = 1; + assert(publication.advance(stored_job, progressed, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + const auto admitted_files = io.files; + assert(persistPublicationIntent(publication, intent, stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && stored_job.sequence == 2 && stored_job.offset == 1 && io.files == admitted_files); + auto wrong_target = intent; + wrong_target.directory_epoch[0] ^= 1; + assert(persistPublicationIntent(publication, wrong_target, stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Conflict && io.files == admitted_files); + io.files["publish.a"][0] ^= 1; + const auto damaged_job = io.files; + assert(persistPublicationIntent(publication, intent, stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Corrupt && io.files == damaged_job); + io.files = admitted_files; + assert(verifyManifest({io.files["updated"].data(), io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), verified_update, author) == ManifestVerification::Valid); + assert(publication.load(stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + assert(commitPublicationHead(heads, publication, stored_job, job_verify, verified_update, update_current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready); + assert(head.phase == HeadPhase::Committed && head.revision == 2 && stored_job.phase == PublicationPhase::Uploading); + const auto committed_job_files = io.files; + assert(commitPublicationHead(heads, publication, stored_job, job_verify, verified_update, update_current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Ready && io.files == committed_job_files); + io.files["publish.a"][0] ^= 1; + const auto broken_admission = io.files; + assert(commitPublicationHead(heads, publication, stored_job, job_verify, verified_update, update_current, 7, author, {author_hash, 32}, head, head_next, head_verify, workspace, sizeof(workspace)) == HeadStoreResult::Conflict && io.files == broken_admission); + commit.generation = 8; + assert(!createPublicationIntent(commit, head, verified_update, update_current, author, {author_hash, 32}, retained_check, intent) && !intent.sequence); + { + uint8_t receipt_raw[512], signed_receipt[1100], receipt_signature[64], directory_public[64]{}, directory_secret[64], directory_seed[32] = {17}, network_request[16] = {5}; + std::memcpy(network_request, stored_job.operation, 16); + directory_public[0] = 3; + ed25519_create_keypair(directory_public + 32, directory_secret, directory_seed); + geocaching::protocol::CmpWriter receipt_writer(receipt_raw, sizeof(receipt_raw)); + assert(receipt_writer.array(13) && receipt_writer.unsignedInteger(1) && receipt_writer.binary({network_request, 16}) && + receipt_writer.binary({stored_job.object, 32}) && receipt_writer.binary({stored_job.revision_hash, 32}) && receipt_writer.unsignedInteger(stored_job.revision) && + receipt_writer.unsignedInteger(0) && receipt_writer.binary({stored_job.file_hash, 32}) && receipt_writer.unsignedInteger(stored_job.file_bytes) && + receipt_writer.binary({directory_public, 64}) && receipt_writer.binary({stored_job.directory, 16}) && receipt_writer.binary({stored_job.directory_epoch, 16}) && + receipt_writer.unsignedInteger(1) && receipt_writer.unsignedInteger(5000000000ULL)); + constexpr char receipt_domain[] = "TrailMate.Track.Receipt.v1"; + std::memcpy(workspace, receipt_domain, sizeof(receipt_domain)); + std::memcpy(workspace + sizeof(receipt_domain), receipt_raw, receipt_writer.size()); + ed25519_sign(receipt_signature, workspace, sizeof(receipt_domain) + receipt_writer.size(), directory_public + 32, directory_secret); + geocaching::protocol::CmpWriter envelope(signed_receipt, sizeof(signed_receipt)); + assert(envelope.array(2) && envelope.binary({receipt_raw, receipt_writer.size()}) && envelope.binary({receipt_signature, 64})); + uint8_t network_response[1200]; + geocaching::protocol::CmpWriter reply(network_response, sizeof(network_response)); + assert(reply.array(6) && reply.unsignedInteger(1) && reply.unsignedInteger(1) && reply.unsignedInteger(7) && + reply.binary({network_request, 16}) && reply.unsignedInteger(200) && reply.array(2) && reply.array(2) && + reply.binary({receipt_raw, receipt_writer.size()}) && reply.binary({receipt_signature, 64}) && reply.unsignedInteger(1)); + PublishResponseView publication_reply; + assert(decodeTrackPublishResponse({network_response, reply.size()}, {stored_job.operation, 16}, publication_reply) && publication_reply.current); + assert(publication_reply.signed_receipt.size == envelope.size() && !std::memcmp(publication_reply.signed_receipt.data, signed_receipt, envelope.size())); + for (size_t length = 0; length < reply.size(); ++length) + assert(!decodeTrackPublishResponse({network_response, length}, {stored_job.operation, 16}, publication_reply)); + assert(decodeTrackPublishResponse({network_response, reply.size()}, {stored_job.operation, 16}, publication_reply)); + platform::esp::storage::TrackReceiptCrypto receipt_crypto; + VerifiedReceiptView proof; + assert(verifyReceipt(publication_reply.signed_receipt, stored_job, {network_request, 16}, {directory_public, 64}, 0, receipt_crypto, workspace, sizeof(workspace), proof) == ReceiptVerification::Valid && proof.verified); + io.files = committed_job_files; + assert(publication.load(stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + SignedManifestStore receipts(io, "receipt.tmp", "receipt.cmp"); + const auto before_confirmation = io.files; + assert(confirmPublication(publication, receipts, proof, stored_job, progressed, job_verify, {network_request, 16}, {directory_public, 64}, 0, workspace, sizeof(workspace)) == PublicationStoreResult::Invalid && io.files == before_confirmation); + progressed = stored_job; + ++progressed.sequence; + progressed.phase = PublicationPhase::FileStored; + progressed.offset = progressed.file_bytes; + assert(publication.advance(stored_job, progressed, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + stored_job = progressed; + const auto file_stored_files = io.files; + assert(verifyManifest({io.files["updated"].data(), io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), verified_update, author) == ManifestVerification::Valid); + assert(markPublicationSending(publication, stored_job, progressed, job_verify, verified_update, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + assert(stored_job.phase == PublicationPhase::Publishing); + const auto publishing_files = io.files; + assert(markPublicationSending(publication, stored_job, progressed, job_verify, verified_update, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && io.files == publishing_files); + uint8_t publish_request[4500]; + size_t publish_bytes = 0; + assert(encodeTrackPublishRequest(stored_job, verified_update, publish_request, sizeof(publish_request), publish_bytes)); + geocaching::protocol::CmpReader publish_reader({publish_request, publish_bytes}); + size_t fields = 0; + uint64_t number = 0; + geocaching::ByteView field; + assert(publish_reader.array(fields, 6) && fields == 6 && publish_reader.unsignedInteger(number) && number == 1 && + publish_reader.unsignedInteger(number) && number == 0 && publish_reader.unsignedInteger(number) && number == 7 && + publish_reader.binary(field, 16) && field.size == 16 && !std::memcmp(field.data, stored_job.operation, 16) && + publish_reader.unsignedInteger(number) && number == 8192 && publish_reader.array(fields, 2) && fields == 2 && + publish_reader.array(fields, 2) && fields == 2 && publish_reader.binary(field, 4096) && field.size == verified_update.manifest.raw.size && + !std::memcmp(field.data, verified_update.manifest.raw.data, field.size) && publish_reader.binary(field, 64) && field.size == 64 && + !std::memcmp(field.data, verified_update.signature.data, 64) && publish_reader.binary(field, 32) && field.size == 32 && + !std::memcmp(field.data, verified_update.manifest.previous.data, 32) && publish_reader.finished()); + assert(confirmPublication(publication, receipts, proof, stored_job, progressed, job_verify, {network_request, 16}, {directory_public, 64}, 0, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + stored_job = progressed; + assert(stored_job.phase == PublicationPhase::Confirmed && io.files["receipt.cmp"] == std::vector(signed_receipt, signed_receipt + envelope.size())); + const auto confirmed_files = io.files; + assert(confirmPublication(publication, receipts, proof, stored_job, progressed, job_verify, {network_request, 16}, {directory_public, 64}, 0, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && io.files == confirmed_files); + io.files = publishing_files; + assert(publication.load(stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && stored_job.phase == PublicationPhase::Publishing); + assert(verifyManifest({io.files["updated"].data(), io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), verified_update, author) == ManifestVerification::Valid); + struct ReceiptGuard + { + decltype(receipts)& store; + bool allowed = false; + SignedStoreResult persist(geocaching::ByteView raw, geocaching::ByteView signature, uint8_t* scratch, size_t capacity) + { + return allowed ? store.persist(raw, signature, scratch, capacity) : SignedStoreResult::StorageFailure; + } + } receipt_guard{receipts, false}; + struct Lease + { + bool valid = true; + bool holds(const PublicationJournal&) const { return valid; } + } lease; + uint8_t local[16], delivery_hash[32]{}, wrong_address[16]{}; + std::memset(local, 6, 16); + PublicationReplyReceiver receiver( + publication, receipt_guard, receipt_crypto, lease, stored_job, progressed, job_verify, proof, {directory_public, 64}, local, 0, workspace, sizeof(workspace)); + static_assert(sizeof(receiver) <= 160, "Publication receiver owns no response buffer"); + struct PublicationSender + { + Io& io; + const uint8_t* expected_author; + PublicationJournal& job; + bool allowed = false; + unsigned calls = 0; + std::vector bytes; + chat::MeshSendResult sendTrackData(const uint8_t* directory, chat::lxmf::ByteSpan payload, const uint8_t* author, uint32_t generation, bool response, std::array*) + { + assert(!io.active && job.phase == PublicationPhase::Publishing && generation == job.generation && !response && + !std::memcmp(directory, job.directory, 16) && !std::memcmp(author, expected_author, 64)); + ++calls; + bytes.assign(payload.data, payload.data + payload.size); + return allowed ? chat::MeshSendResult::success(1) : chat::MeshSendResult::fail(chat::MeshOperationFailure::Busy); + } + } sender{io, author.data, stored_job, false, 0, {}}; + auto send_publication = [&] + { return sendTrackPublicationStep(publication, receiver, sender, stored_job, progressed, job_verify, verified_update, author.data, + publish_request, sizeof(publish_request), workspace, sizeof(workspace)); }; + io.files = file_stored_files; + assert(publication.load(stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && stored_job.phase == PublicationPhase::FileStored); + assert(verifyManifest({io.files["updated"].data(), io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), verified_update, author) == ManifestVerification::Valid); + assert(sendTrackPublicationStep(publication, receiver, sender, stored_job, progressed, job_verify, verified_update, author.data, publish_request, 1, workspace, sizeof(workspace)) == PublicationSendResult::Invalid && io.files == file_stored_files && !sender.calls); + struct SendingStore + { + decltype(publication)& actual; + PublicationStoreResult validateCurrent(const PublicationJournal& job, PublicationJournal& verify, uint8_t* scratch, size_t capacity) + { + return actual.validateCurrent(job, verify, scratch, capacity); + } + PublicationStoreResult advance(const PublicationJournal&, const PublicationJournal&, PublicationJournal&, uint8_t*, size_t) + { + return PublicationStoreResult::StorageFailure; + } + } unavailable_store{publication}; + assert(sendTrackPublicationStep(unavailable_store, receiver, sender, stored_job, progressed, job_verify, verified_update, author.data, publish_request, sizeof(publish_request), workspace, sizeof(workspace)) == PublicationSendResult::StorageFailure && !receiver.pending() && !sender.calls && io.files == file_stored_files && stored_job.phase == PublicationPhase::FileStored); + RequestRetryWindow retry; + assert(retry.begin({stored_job.operation, 16}, 100, 1000, 10, 3) && retry.poll(receiver, 100) == ExchangePoll::Ready); + assert(send_publication() == PublicationSendResult::Rejected && !receiver.pending() && sender.calls == 1 && io.files == publishing_files); + assert(retry.sent(false, 100) && retry.poll(receiver, 109) == ExchangePoll::Waiting && sender.calls == 1); + const auto rejected_bytes = sender.bytes; + sender.allowed = true; + assert(retry.poll(receiver, 110) == ExchangePoll::Ready); + assert(send_publication() == PublicationSendResult::Queued && receiver.pending() && sender.calls == 2 && sender.bytes == rejected_bytes && io.files == publishing_files); + assert(retry.sent(true, 110)); + assert(send_publication() == PublicationSendResult::Waiting && sender.calls == 2); + assert(retry.poll(receiver, 1109) == ExchangePoll::Waiting && receiver.pending()); + assert(retry.poll(receiver, 1110) == ExchangePoll::Waiting && !receiver.pending()); + assert(retry.poll(receiver, 1120) == ExchangePoll::Ready && !std::memcmp(retry.request().data, stored_job.operation, 16)); + assert(send_publication() == PublicationSendResult::Queued && receiver.pending() && sender.calls == 3 && sender.bytes == rejected_bytes && io.files == publishing_files); + assert(retry.sent(true, 1120)); + assert(!receiver.arm()); + chat::lxmf::CustomDeliveryView delivery{{stored_job.directory, 16}, {local, 16}, {delivery_hash, 32}, {network_response, reply.size()}}; + const auto receive = [&] + { return chat::lxmf::deliverVerifiedTrack(&receiver.binding(), false, false, delivery); }; + const auto before_receive = io.files; + lease.valid = false; + assert(!receive() && receiver.pending() && io.files == before_receive); + lease.valid = true; + delivery.source = {wrong_address, 16}; + assert(!receive() && io.files == before_receive); + delivery.source = {stored_job.directory, 16}; + delivery.destination = {wrong_address, 16}; + assert(!receive() && io.files == before_receive); + delivery.destination = {local, 16}; + network_response[reply.size() - 2] ^= 1; + assert(!receive() && receiver.pending() && !proof.verified && io.files == before_receive); + network_response[reply.size() - 2] ^= 1; + assert(!receive() && receiver.pending() && !proof.verified && stored_job.phase == PublicationPhase::Publishing && io.files == before_receive); + receipt_guard.allowed = true; + assert(receive() && !receiver.pending() && !proof.verified && receiver.directoryCurrent() && stored_job.phase == PublicationPhase::Confirmed); + assert(retry.poll(receiver, 1121) == ExchangePoll::Completed); + assert(io.files["receipt.cmp"] == std::vector(signed_receipt, signed_receipt + envelope.size())); + PublicationDirectory pinned_directory; + std::memcpy(pinned_directory.delivery, stored_job.directory, 16); + std::memcpy(pinned_directory.epoch, stored_job.directory_epoch, 16); + std::memcpy(pinned_directory.public_key, directory_public, 64); + pinned_directory.file_limit = 8388608; + pinned_directory.chunk_limit = 1024; + pinned_directory.query_limit = 32; + uint8_t recovered_bytes[1100]; + VerifiedReceiptView recovered_proof; + PublicationReceiptLoader loader(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + static_assert(sizeof(loader) <= 128, "Receipt reader borrows all byte storage"); + assert(loader.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending && !io.active); + ReceiptLoadResult loaded = ReceiptLoadResult::Pending; + for (unsigned attempt = 0; attempt < 8 && loaded == ReceiptLoadResult::Pending; ++attempt) + { + loaded = loader.step(workspace, sizeof(workspace)); + assert(!io.active); + } + assert(loaded == ReceiptLoadResult::Ready && loader.ready() && recovered_proof.verified); + const auto durable_receipt = io.files["receipt.cmp"]; + // Receipt was saved, but the Confirmed journal write was interrupted. + io.files = publishing_files; + io.files["receipt.cmp"] = durable_receipt; + assert(publication.load(stored_job, job_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && stored_job.phase == PublicationPhase::Publishing); + PublicationReceiptLoader interrupted(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(interrupted.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending); + loaded = ReceiptLoadResult::Pending; + for (unsigned attempt = 0; attempt < 8 && loaded == ReceiptLoadResult::Pending; ++attempt) loaded = interrupted.step(workspace, sizeof(workspace)); + assert(loaded == ReceiptLoadResult::Ready && interrupted.ready()); + assert(confirmPublication(publication, receipts, recovered_proof, stored_job, progressed, job_verify, {stored_job.operation, 16}, {directory_public, 64}, 0, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + stored_job = progressed; + assert(stored_job.phase == PublicationPhase::Confirmed); + PublicationReceiptLoader missing(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(missing.begin("missing", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Missing && !recovered_proof.verified); + pinned_directory.public_key[0] ^= 1; + PublicationReceiptLoader wrong_key(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(wrong_key.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending); + loaded = ReceiptLoadResult::Pending; + for (unsigned attempt = 0; attempt < 8 && loaded == ReceiptLoadResult::Pending; ++attempt) loaded = wrong_key.step(workspace, sizeof(workspace)); + assert(loaded == ReceiptLoadResult::Conflict && !wrong_key.ready() && !recovered_proof.verified); + pinned_directory.public_key[0] ^= 1; + io.files["receipt.cmp"].back() ^= 1; + PublicationReceiptLoader damaged(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(damaged.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending); + loaded = ReceiptLoadResult::Pending; + for (unsigned attempt = 0; attempt < 8 && loaded == ReceiptLoadResult::Pending; ++attempt) loaded = damaged.step(workspace, sizeof(workspace)); + assert(loaded == ReceiptLoadResult::Conflict && !damaged.ready() && !recovered_proof.verified); + io.files["receipt.cmp"] = durable_receipt; + { + Io network_io; + network_io.files = io.files; + PublicationJournal network_job, network_next, network_verify; + VerifiedManifestView network_manifest; + VerifiedReceiptView network_proof; + assert(verifyManifest({network_io.files["updated"].data(), network_io.files["updated"].size()}, crypto, hash, workspace, sizeof(workspace), network_manifest, author) == ManifestVerification::Valid); + PublicationJournalStore network_jobs(network_io, "network.a", "network.b"); + auto network_intent = stored_job; + network_intent.sequence = 1; + network_intent.offset = 0; + network_intent.phase = PublicationPhase::Accepted; + assert(persistPublicationIntent(network_jobs, network_intent, network_job, network_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + const auto initial_network_files = network_io.files; + SignedManifestStore network_receipts(network_io, "network-receipt.tmp", "network-receipt.cmp"); + struct PacketSender + { + Io& io; + unsigned calls = 0; + std::vector packet; + chat::MeshSendResult sendTrackData(const uint8_t*, chat::lxmf::ByteSpan bytes, const uint8_t*, uint32_t, bool, std::array*) + { + assert(!io.active); + ++calls; + packet.assign(bytes.data, bytes.data + bytes.size); + return chat::MeshSendResult::success(1); + } + } packet_sender{network_io, 0, {}}; + struct Nonce + { + uint8_t counter = 20; + bool fill(uint8_t* output, size_t size) + { + assert(size == 16); + std::memset(output, 0, size); + output[0] = counter++; + return true; + } + } nonce; + struct BackendIdentity + { + geocaching::ByteView key; + uint32_t generation = 7; + bool isReady() const { return true; } + bool identityStorageError() const { return false; } + uint32_t authorGeneration() const { return generation; } + const uint8_t* encryptionPublicKey() const { return key.data; } + const uint8_t* signingPublicKey() const { return key.data + 32; } + } backend_identity{author, 7}; + uint8_t network_wire[4300], network_scratch[1100], file_scratch[256], response_wire[512], upload_token[16] = {33}; + PublicationNetworkSession execution( + network_io, network_jobs, network_receipts, receipt_crypto, lease, packet_sender, nonce, network_job, network_next, network_verify, + network_manifest, network_proof, {directory_public, 64}, local, author.data, "data", network_wire, sizeof(network_wire), network_scratch, sizeof(network_scratch), + file_scratch, sizeof(file_scratch), 20, 5, 3); + static_assert(sizeof(execution) <= 768, "Network execution owns bounded metadata only"); + lease.valid = false; + assert(execution.advance(0) == NetworkSessionResult::Conflict && !packet_sender.calls); + lease.valid = true; + bool lost_begin = false, confirmed = false; + std::vector original_begin; + for (uint64_t now = 0; now < 1000 && !confirmed; now += 10) + { + const auto step = execution.advance(now); + if (step == NetworkSessionResult::Confirmed) + { + confirmed = true; + break; + } + if (step == NetworkSessionResult::Waiting || step == NetworkSessionResult::Progress) continue; + assert(step == NetworkSessionResult::Queued); + geocaching::protocol::CmpReader packet({packet_sender.packet.data(), packet_sender.packet.size()}); + size_t count = 0; + uint64_t version = 0, operation = 0, budget = 0, offset = 0; + geocaching::ByteView request_id, field; + assert(packet.array(count, 6) && count == 6 && packet.unsignedInteger(version) && version == 1 && packet.unsignedInteger(version) && version == 0 && + packet.unsignedInteger(operation) && packet.binary(request_id, 16) && packet.unsignedInteger(budget) && budget == 8192 && packet.array(count, 3)); + if (operation == 4 && !lost_begin) + { + lost_begin = true; + original_begin = packet_sender.packet; + continue; + } + geocaching::protocol::CmpWriter response_writer(response_wire, sizeof(response_wire)); + assert(response_writer.array(6) && response_writer.unsignedInteger(1) && response_writer.unsignedInteger(1) && response_writer.unsignedInteger(operation) && + response_writer.binary(request_id) && response_writer.unsignedInteger(200)); + if (operation == 4) + { + assert(packet_sender.packet == original_begin && count == 2 && packet.binary(field, 32) && !std::memcmp(field.data, network_job.file_hash, 32) && + packet.unsignedInteger(offset) && offset == network_job.file_bytes && packet.finished()); + assert(response_writer.array(4) && response_writer.binary({upload_token, 16}) && response_writer.unsignedInteger(0) && + response_writer.unsignedInteger(0) && response_writer.unsignedInteger(256)); + } + else if (operation == 5) + { + assert(request_id.data[0] != 20); + assert(count == 3 && packet.binary(field, 16) && !std::memcmp(field.data, upload_token, 16) && packet.unsignedInteger(offset) && + packet.binary(field, 256) && packet.finished() && offset == network_job.offset && + !std::memcmp(field.data, network_io.files["data"].data() + offset, field.size)); + assert(response_writer.array(2) && response_writer.binary({upload_token, 16}) && response_writer.unsignedInteger(offset + field.size)); + } + else if (operation == 6) + { + assert(count == 1 && packet.binary(field, 16) && !std::memcmp(field.data, upload_token, 16) && packet.finished()); + assert(response_writer.array(4) && response_writer.binary({network_job.file_hash, 32}) && response_writer.unsignedInteger(network_job.file_bytes) && + response_writer.unsignedInteger(1) && response_writer.unsignedInteger(0)); + } + else assert(operation == 7 && count == 2 && network_job.phase == PublicationPhase::Publishing && !std::memcmp(request_id.data, network_job.operation, 16)); + chat::lxmf::CustomDeliveryView incoming{{network_job.directory, 16}, {local, 16}, {delivery_hash, 32}, operation == 7 ? chat::lxmf::ByteSpan{network_response, reply.size()} : chat::lxmf::ByteSpan{response_wire, response_writer.size()}}; + const auto sequence_before_reply = network_job.sequence; + assert(!chat::lxmf::deliverVerifiedTrackForAuthor(&execution.binding(), true, false, incoming, backend_identity) && network_job.sequence == sequence_before_reply); + incoming.destination = {wrong_address, 16}; + assert(!chat::lxmf::deliverVerifiedTrackForAuthor(&execution.binding(), false, false, incoming, backend_identity) && network_job.sequence == sequence_before_reply); + incoming.destination = {local, 16}; + ++backend_identity.generation; + assert(!chat::lxmf::deliverVerifiedTrackForAuthor(&execution.binding(), false, false, incoming, backend_identity) && network_job.sequence == sequence_before_reply); + --backend_identity.generation; + assert(chat::lxmf::deliverVerifiedTrackForAuthor(&execution.binding(), false, false, incoming, backend_identity)); + if (operation == 4) + { + const auto calls_before_pause = packet_sender.calls; + execution.pause(); + assert(execution.advance(now + 1) == NetworkSessionResult::Paused && packet_sender.calls == calls_before_pause); + assert(execution.resume(now + 2)); + } + assert(!network_io.active); + } + assert(confirmed && lost_begin && network_job.phase == PublicationPhase::Confirmed && execution.directoryCurrent() && + network_io.files["network-receipt.cmp"] == durable_receipt); + assert(network_jobs.load(network_job, network_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready && network_job.phase == PublicationPhase::Confirmed); + uint8_t cancel_author[32]; + std::memcpy(cancel_author, network_job.author_hash, 32); + assert(execution.cancel(network_job.generation, {cancel_author, 32}) == PublicationStoreResult::Conflict); + network_io.files = initial_network_files; + assert(network_jobs.load(network_job, network_verify, workspace, sizeof(workspace)) == PublicationStoreResult::Ready); + decltype(execution) cancelled_execution(network_io, network_jobs, network_receipts, receipt_crypto, lease, packet_sender, nonce, network_job, network_next, network_verify, + network_manifest, network_proof, {directory_public, 64}, local, author.data, "data", network_wire, sizeof(network_wire), network_scratch, sizeof(network_scratch), + file_scratch, sizeof(file_scratch), 20, 5, 3); + assert(cancelled_execution.advance(1000) == NetworkSessionResult::Queued); + geocaching::protocol::CmpReader cancelled_packet({packet_sender.packet.data(), packet_sender.packet.size()}); + size_t cancelled_fields = 0; + uint64_t cancelled_number = 0; + geocaching::ByteView cancelled_id; + assert(cancelled_packet.array(cancelled_fields, 6) && cancelled_fields == 6 && cancelled_packet.unsignedInteger(cancelled_number) && + cancelled_packet.unsignedInteger(cancelled_number) && cancelled_packet.unsignedInteger(cancelled_number) && cancelled_number == 4 && cancelled_packet.binary(cancelled_id, 16)); + geocaching::protocol::CmpWriter late(response_wire, sizeof(response_wire)); + assert(late.array(6) && late.unsignedInteger(1) && late.unsignedInteger(1) && late.unsignedInteger(4) && late.binary(cancelled_id) && late.unsignedInteger(200) && + late.array(4) && late.nil() && late.unsignedInteger(network_job.file_bytes) && late.unsignedInteger(1) && late.unsignedInteger(1024)); + assert(cancelled_execution.cancel(network_job.generation, {cancel_author, 32}) == PublicationStoreResult::Ready && network_job.phase == PublicationPhase::Cancelled); + const auto cancelled_sequence = network_job.sequence; + const auto cancelled_sends = packet_sender.calls; + uint8_t late_hash[32]{}; + chat::lxmf::CustomDeliveryView late_delivery{{network_job.directory, 16}, {local, 16}, {late_hash, 32}, {response_wire, late.size()}}; + chat::lxmf::deliverVerifiedTrackForAuthor(&cancelled_execution.binding(), false, false, late_delivery, backend_identity); + assert(network_job.sequence == cancelled_sequence && network_job.phase == PublicationPhase::Cancelled && !cancelled_execution.resume(1001)); + assert(cancelled_execution.advance(1002) == NetworkSessionResult::Paused && packet_sender.calls == cancelled_sends); + } + PublicationReceiptLoader cancelled(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(cancelled.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending); + assert(cancelled.step(workspace, sizeof(workspace)) == ReceiptLoadResult::Pending && !io.active); + assert(cancelled.cancel() == ReceiptLoadResult::Cancelled && !cancelled.ready() && !recovered_proof.verified); + for (size_t i = 0; i < durable_receipt.size(); ++i) assert(!recovered_bytes[i]); + PublicationReceiptLoader changed_file(io, receipt_crypto, stored_job, pinned_directory, recovered_proof, 0); + assert(changed_file.begin("receipt.cmp", recovered_bytes, sizeof(recovered_bytes)) == ReceiptLoadResult::Pending); + io.files["receipt.cmp"].pop_back(); + assert(changed_file.step(workspace, sizeof(workspace)) == ReceiptLoadResult::StorageFailure && !changed_file.ready() && !recovered_proof.verified && !io.active); + io.files["receipt.cmp"] = durable_receipt; + network_request[0] ^= 1; + assert(verifyReceipt({signed_receipt, envelope.size()}, stored_job, {network_request, 16}, {directory_public, 64}, 0, receipt_crypto, workspace, sizeof(workspace), proof) == ReceiptVerification::BindingMismatch && !proof.verified); + network_request[0] ^= 1; + signed_receipt[envelope.size() - 1] ^= 1; + assert(verifyReceipt({signed_receipt, envelope.size()}, stored_job, {network_request, 16}, {directory_public, 64}, 0, receipt_crypto, workspace, sizeof(workspace), proof) == ReceiptVerification::InvalidSignature && !proof.verified); + } +} diff --git a/tests/hostlink/test_publication_directory.cpp b/tests/hostlink/test_publication_directory.cpp new file mode 100644 index 00000000..96af36b2 --- /dev/null +++ b/tests/hostlink/test_publication_directory.cpp @@ -0,0 +1,275 @@ +#include "track_sharing/admit_publication_intent.h" +#include "track_sharing/load_publication_history.h" +#include "track_sharing/publication_candidate_reader.h" +#include "track_sharing/cancel_publication.h" +#include +#include +#include +#include +#include + +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path, fail_create_path; + bool active = false, fail_open = false, fail_read = false, fail_sync = false, fail_close = false, short_write = false; + unsigned writes = 0; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + if (fail_open) return {nullptr, OpenStatus::Unavailable}; + path = name; + if (mode == Mode::CreateTemporary && path == fail_create_path) return {nullptr, OpenStatus::Unavailable}; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool size(void*, uint64_t& output) + { + assert(active); + output = files[path].size(); + return true; + } + size_t read(void*, uint8_t* output, size_t size) + { + assert(active); + if (fail_read) return 0; + assert(files[path].size() >= size); + std::memcpy(output, files[path].data(), size); + return size; + } + size_t write(void*, const uint8_t* input, size_t size) + { + assert(active); + ++writes; + if (short_write) --size; + files[path].assign(input, input + size); + return size; + } + bool sync(void*) + { + assert(active); + return !fail_sync; + } + bool close(void*) + { + assert(active); + active = false; + return !fail_close; + } +}; + +int main() +{ + using namespace track_sharing; + PublicationDirectory desired; + std::memset(desired.delivery, 1, 16); + std::memset(desired.public_key, 2, 64); + std::memset(desired.epoch, 3, 16); + desired.file_limit = 8388608; + desired.chunk_limit = 1024; + desired.query_limit = 32; + std::array bytes; + assert(encodePublicationDirectory(desired, bytes.data(), bytes.size())); + PublicationDirectory decoded; + assert(decodePublicationDirectory(bytes.data(), bytes.size(), decoded)); + assert(publicationDirectoriesEqual(desired, decoded)); + for (size_t size = 0; size < bytes.size(); ++size) assert(!decodePublicationDirectory(bytes.data(), size, decoded)); + for (size_t i = 0; i < bytes.size(); ++i) + { + bytes[i] ^= 1; + assert(!decodePublicationDirectory(bytes.data(), bytes.size(), decoded)); + bytes[i] ^= 1; + } + assert(!encodePublicationDirectory(desired, reinterpret_cast(&desired), sizeof(desired))); + assert(!decodePublicationDirectory(reinterpret_cast(&decoded), kPublicationDirectoryBytes, decoded)); + auto invalid = desired; + invalid.chunk_limit = 1025; + assert(!publicationDirectoryValid(invalid)); + invalid = desired; + invalid.query_limit = 0; + assert(!publicationDirectoryValid(invalid)); + // Semantic limits remain mandatory even with a matching damage checksum. + bytes[105] = 1; + bytes[106] = 4; + const auto semantic_crc = uploadJournalCrc(bytes.data(), 108); + bytes[108] = uint8_t(semantic_crc); + bytes[109] = uint8_t(semantic_crc >> 8); + assert(!decodePublicationDirectory(bytes.data(), bytes.size(), decoded)); + assert(encodePublicationDirectory(desired, bytes.data(), bytes.size())); + + PublicationJournal job; + std::memset(job.operation, 1, 16); + std::memset(job.object, 1, 32); + std::memset(job.revision_hash, 1, 32); + std::memcpy(job.directory, desired.delivery, 16); + std::memcpy(job.directory_epoch, desired.epoch, 16); + std::memset(job.file_hash, 1, 32); + std::memset(job.author_hash, 1, 32); + job.sequence = job.generation = job.revision = 1; + job.file_bytes = 4096; + assert(publicationDirectoryMatches(desired, job)); + job.directory_epoch[0] ^= 1; + assert(!publicationDirectoryMatches(desired, job)); + job.directory_epoch[0] ^= 1; + invalid = desired; + invalid.file_limit = 4095; + assert(!publicationDirectoryMatches(invalid, job)); + + Io io; + PublicationDirectoryStore store(io, "/job.dir"); + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Ready); + assert(!io.active && io.writes == 1); + // Restart has no pool/USB owner and preserves exact public key and epoch. + PublicationDirectoryStore restarted(io, "/job.dir"); + decoded = {}; + assert(restarted.load(decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Ready); + assert(publicationDirectoriesEqual(desired, decoded)); + assert(restarted.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Ready && io.writes == 1); + for (unsigned field = 0; field < 3; ++field) + { + auto changed = desired; + if (field == 0) changed.public_key[0] ^= 1; + if (field == 1) changed.epoch[0] ^= 1; + if (field == 2) --changed.chunk_limit; + assert(restarted.admit(changed, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Conflict); + assert(io.writes == 1); + } + io.fail_read = true; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::StorageFailure); + io.fail_read = false; + io.fail_close = true; + assert(store.load(decoded, bytes.data(), bytes.size()) == PublicationStoreResult::StorageFailure && !io.active); + io.fail_close = false; + io.fail_open = true; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::StorageFailure); + io.fail_open = false; + assert(store.admit(desired, desired, bytes.data(), bytes.size()) == PublicationStoreResult::Invalid); + assert(store.load(decoded, reinterpret_cast(&decoded), kPublicationDirectoryBytes) == PublicationStoreResult::Invalid); + + // A torn first write cannot be overwritten by a retry with a new directory. + io.files.clear(); + io.short_write = true; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::StorageFailure && !io.active); + io.short_write = false; + const auto writes = io.writes; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Corrupt && io.writes == writes); + io.files.clear(); + io.fail_sync = true; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::StorageFailure && !io.active); + io.fail_sync = false; + // Readback on the next admission may authorize only an identical record. + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Ready); + io.files["/job.dir"][21] ^= 1; + assert(store.admit(desired, decoded, bytes.data(), bytes.size()) == PublicationStoreResult::Corrupt); + + // Durable pin precedes durable job; a restart in between resumes the same + // operation without replacing its original directory evidence. + Io admission_io; + PublicationDirectoryStore pins(admission_io, "/op.dir"); + PublicationJournalStore jobs(admission_io, "/op.pua", "/op.pub"); + PublicationJournal current, verify; + std::array scratch; + admission_io.short_write = true; + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::StorageFailure); + assert(!admission_io.files.count("/op.pua")); + admission_io.short_write = false; + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Corrupt); + assert(!admission_io.files.count("/op.pua")); + admission_io.files.clear(); + assert(pins.admit(desired, decoded, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(!admission_io.files.count("/op.pua")); + admission_io.fail_create_path = "/op.pua"; + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::StorageFailure); + assert(admission_io.files.count("/op.dir") && !admission_io.files.count("/op.pua") && !admission_io.active); + admission_io.fail_create_path.clear(); + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 1 && current.phase == PublicationPhase::Accepted); + auto next = current; + ++next.sequence; + next.phase = PublicationPhase::Uploading; + assert(jobs.advance(current, next, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + const auto admission_writes = admission_io.writes; + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 2 && current.phase == PublicationPhase::Uploading && admission_io.writes == admission_writes); + auto changed_directory = desired; + changed_directory.public_key[0] ^= 1; + assert(admitPublicationIntent(pins, jobs, changed_directory, decoded, job, current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Conflict); + assert(admission_io.writes == admission_writes); + assert(admitPublicationIntent(pins, jobs, desired, decoded, job, current, current, scratch.data(), scratch.size()) == PublicationStoreResult::Invalid); + next=current;++next.sequence;next.offset=320; + assert(jobs.advance(current,next,verify,scratch.data(),scratch.size())==PublicationStoreResult::Ready); + const auto history_files=admission_io.files; + const auto history_writes=admission_io.writes; + PublicationDirectoryStore restarted_pins(admission_io,"/op.dir"); + PublicationJournalStore restarted_jobs(admission_io,"/op.pua","/op.pub"); + assert(loadPublicationHistory(restarted_pins,restarted_jobs,desired,job,decoded,current,verify,scratch.data(),scratch.size())==PublicationStoreResult::Ready); + assert(current.sequence==3 && current.offset==320 && current.phase==PublicationPhase::Uploading); + assert(admission_io.files==history_files && admission_io.writes==history_writes && !admission_io.active); + auto wrong_author=job;wrong_author.author_hash[0]^=1; + assert(loadPublicationHistory(restarted_pins,restarted_jobs,desired,wrong_author,decoded,current,verify,scratch.data(),scratch.size())==PublicationStoreResult::Conflict); + assert(!current.sequence && !verify.sequence && !decoded.file_limit); + assert(loadPublicationHistory(restarted_pins,restarted_jobs,changed_directory,job,decoded,current,verify,scratch.data(),scratch.size())==PublicationStoreResult::Conflict); + admission_io.files.erase("/op.pua");admission_io.files.erase("/op.pub"); + assert(loadPublicationHistory(restarted_pins,restarted_jobs,desired,job,decoded,current,verify,scratch.data(),scratch.size())==PublicationStoreResult::Missing); + assert(admission_io.writes==history_writes && !current.sequence && !admission_io.active); + { + Io cancel_io;cancel_io.files=history_files; + PublicationJournalStore cancel_jobs(cancel_io,"/op.pua","/op.pub"); + PublicationJournal cancelling,next_cancel,verify_cancel; + assert(cancel_jobs.load(cancelling,verify_cancel,scratch.data(),scratch.size())==PublicationStoreResult::Ready); + uint8_t author[32];std::memcpy(author,job.author_hash,32); + assert(cancelPublicationBeforePublish(cancel_jobs,cancelling,next_cancel,verify_cancel,job.generation+1,{author,32},scratch.data(),scratch.size())==PublicationStoreResult::Conflict && !cancel_io.writes); + cancel_io.fail_sync=true; + assert(cancelPublicationBeforePublish(cancel_jobs,cancelling,next_cancel,verify_cancel,job.generation,{author,32},scratch.data(),scratch.size())==PublicationStoreResult::StorageFailure); + assert(cancelling.sequence==3 && cancelling.offset==320 && cancelling.phase==PublicationPhase::Uploading); + cancel_io.fail_sync=false;cancel_io.files=history_files; + assert(cancelPublicationBeforePublish(cancel_jobs,cancelling,next_cancel,verify_cancel,job.generation,{author,32},scratch.data(),scratch.size())==PublicationStoreResult::Ready); + assert(cancelling.sequence==4 && cancelling.offset==320 && cancelling.phase==PublicationPhase::Cancelled); + const auto writes=cancel_io.writes; + assert(cancelPublicationBeforePublish(cancel_jobs,cancelling,next_cancel,verify_cancel,job.generation,{author,32},scratch.data(),scratch.size())==PublicationStoreResult::Ready && cancel_io.writes==writes); + cancelling.phase=PublicationPhase::Publishing;cancelling.offset=cancelling.file_bytes; + assert(cancelPublicationBeforePublish(cancel_jobs,cancelling,next_cancel,verify_cancel,job.generation,{author,32},scratch.data(),scratch.size())==PublicationStoreResult::Conflict && cancel_io.writes==writes); + } + { + PublicationSlotKey candidate; + std::memcpy(candidate.object,job.object,32);std::memcpy(candidate.operation,job.operation,16); + Io inventory_io;char canonical[kObjectPathCapacity]; + assert(resolveObjectPath({candidate.object,32},ObjectFile::PublicationDirectory,{candidate.operation,16},canonical,sizeof(canonical))); + inventory_io.files[canonical]=history_files.at("/op.dir"); + assert(resolveObjectPath({candidate.object,32},ObjectFile::PublicationA,{candidate.operation,16},canonical,sizeof(canonical))); + inventory_io.files[canonical]=history_files.at("/op.pua"); + assert(resolveObjectPath({candidate.object,32},ObjectFile::PublicationB,{candidate.operation,16},canonical,sizeof(canonical))); + inventory_io.files[canonical]=history_files.at("/op.pub"); + PublicationCandidateReader reader(inventory_io); + assert(reader.load(candidate,scratch.data(),scratch.size())==PublicationStoreResult::Ready); + assert(reader.unverifiedJournal()->sequence==3 && reader.unverifiedJournal()->offset==320 && reader.unverifiedDirectory()); + candidate.slot=ObjectFile::PublicationB; + assert(reader.load(candidate,scratch.data(),scratch.size())==PublicationStoreResult::Ready && !inventory_io.writes && !inventory_io.active); + assert(reader.load(candidate,nullptr,0)==PublicationStoreResult::Invalid && !reader.unverifiedJournal()); + assert(reader.load(candidate,scratch.data(),scratch.size())==PublicationStoreResult::Ready); + inventory_io.files[canonical][10]^=1; + assert(reader.load(candidate,scratch.data(),scratch.size())==PublicationStoreResult::Corrupt && !reader.unverifiedJournal() && !reader.unverifiedDirectory()); + assert(!inventory_io.writes && !inventory_io.active); + } + admission_io.files=history_files;admission_io.files["/op.pub"][10]^=1; + assert(loadPublicationHistory(restarted_pins,restarted_jobs,desired,job,decoded,current,verify,scratch.data(),scratch.size())==PublicationStoreResult::Corrupt); + assert(admission_io.writes==history_writes && !current.sequence && !admission_io.active); +} diff --git a/tests/hostlink/test_publication_ticket.cpp b/tests/hostlink/test_publication_ticket.cpp new file mode 100644 index 00000000..1e742e28 --- /dev/null +++ b/tests/hostlink/test_publication_ticket.cpp @@ -0,0 +1,96 @@ +#include "platform/esp/arduino_common/hostlink/publication_ticket_store.h" +#include +#include + +int main() +{ + using namespace hostlink::management; + using namespace platform::esp::arduino_common::storage; + std::array owner{}, token{}, boot{}, draft{}, directory{}, epoch{}; + std::array author{}; + std::array hash{}; + owner[0] = 1; + token[0] = 2; + boot[0] = 3; + draft[0] = 4; + author[0] = 5; + directory[0] = 6; + epoch[0] = 7; + hash[0] = 8; + // A monotonic 64-bit clock cannot resurrect a ticket after 32-bit millis wrap. + const uint64_t clock = uint64_t(UINT32_MAX) + 50; + PublicationTicketFields fields{owner.data(), token.data(), boot.data(), draft.data(), author.data(), + directory.data(), epoch.data(), hash.data(), UINT64_MAX, 9, 3, 4, + UINT32_MAX - 30, 120}; + std::array scratch{}; + assert(!encodePublicationTicket(fields, scratch.data(), kPublicationTicketBytes - 1)); + assert(encodePublicationTicket(fields, scratch.data(), scratch.size())); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, clock, true) == Status::Ok); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, clock + 40, true) == Status::Expired); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, clock + 39, true) == Status::Ok); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::Expired); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, clock + (uint64_t(1) << 32), true) == Status::Expired); + owner[0] ^= 1; + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::Unauthorized); + owner[0] ^= 1; + token[0] ^= 1; + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::Unauthorized); + token[0] ^= 1; + boot[0] ^= 1; + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::Conflict); + boot[0] ^= 1; + author[0] ^= 1; + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::StaleIdentity); + author[0] ^= 1; + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 10, 50, true) == Status::StaleIdentity); + const auto original = scratch; + for (size_t i = 0; i < kPublicationTicketBytes; ++i) + { + scratch = original; + scratch[i] ^= 1; + assert(!validatePublicationTicket(scratch.data(), kPublicationTicketBytes)); + } + scratch = original; + assert(!advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Accepted)); + assert(savePublicationTicket(scratch.data(), kPublicationTicketBytes)); + const auto prepared_disk = fake_files.at(kPublicationTicketPath); + assert(advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Intent)); + fake_write_failure = true; + assert(!savePublicationTicket(scratch.data(), kPublicationTicketBytes)); + assert(fake_files.at(kPublicationTicketPath) == prepared_disk); + fake_write_failure = false; + assert(savePublicationTicket(scratch.data(), kPublicationTicketBytes)); + scratch.fill(0); + assert(loadPublicationTicket(scratch.data(), scratch.size())); + assert(scratch[4] == 1); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 1000, false) == Status::Ok); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 50, true) == Status::Conflict); + assert(!advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Prepared)); + assert(advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Accepted)); + assert(savePublicationTicket(scratch.data(), kPublicationTicketBytes)); + assert(advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Accepted)); + fake_files[kPublicationTicketPath][53] ^= 1; + assert(!loadPublicationTicket(scratch.data(), scratch.size())); + fake_files[kPublicationTicketPath] = prepared_disk.substr(0, 40); + assert(!loadPublicationTicket(scratch.data(), scratch.size())); + fake_available = false; + assert(!loadPublicationTicket(scratch.data(), scratch.size())); + fake_available = true; + fields.to_revision = 5; + assert(!encodePublicationTicket(fields, scratch.data(), scratch.size())); + fields.to_revision = 4; + fields.lifetime_ms = kPublicationTicketLifetimeMs + 1; + assert(!encodePublicationTicket(fields, scratch.data(), scratch.size())); + fields.lifetime_ms = 120; + fields.author_generation = 0; + assert(!encodePublicationTicket(fields, scratch.data(), scratch.size())); + fields.author_generation = 9; + fields.created_ms = 1000; + assert(encodePublicationTicket(fields, scratch.data(), scratch.size(), PublicationTicketPhase::Checking)); + assert(!advancePublicationTicket(scratch.data(), kPublicationTicketBytes, PublicationTicketPhase::Prepared)); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 1050, true) == Status::Conflict); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 1120, false) == Status::Expired); + assert(completePublicationCheck(scratch.data(), kPublicationTicketBytes, 1050)); + assert(!completePublicationCheck(scratch.data(), kPublicationTicketBytes, 1050)); + assert(matchPublicationTicket(scratch.data(), kPublicationTicketBytes, owner.data(), token.data(), boot.data(), author.data(), 9, 1100, true) == Status::Ok); +} diff --git a/tests/hostlink/test_radio_identity_management.cpp b/tests/hostlink/test_radio_identity_management.cpp new file mode 100644 index 00000000..2102c0a9 --- /dev/null +++ b/tests/hostlink/test_radio_identity_management.cpp @@ -0,0 +1,199 @@ +#include "platform/esp/common/radio_identity_management.h" +#include +#include +#include +#include +using namespace platform::esp::common; +using namespace hostlink::management; +struct Identity +{ + std::array public_key{}, private_key{}; + uint32_t generation = 7; + bool ready = true, stale_on_sign = false; + Identity(uint8_t seed_value) + { + std::array seed{}; + seed.fill(seed_value); + public_key.fill(25); + ed25519_create_keypair(public_key.data() + 32, private_key.data(), seed.data()); + } + bool isReady() const { return ready; } + bool identityStorageError() const { return false; } + uint32_t authorGeneration() const { return generation; } + void combinedPublicKey(uint8_t* out) const { std::memcpy(out, public_key.data(), 64); } + bool sign(const uint8_t* message, size_t size, uint8_t* out) const + { + ed25519_sign(out, message, size, public_key.data() + 32, private_key.data()); + if (stale_on_sign) ++const_cast(this)->generation; + return true; + } +}; +struct Adapter +{ + Identity& identity; + chat::DeviceAuthorResult readRadioIdentityInfo(uint8_t kind, uint32_t generation, uint8_t* out, size_t capacity, size_t& written) const { return platform::esp::common::readRadioIdentityInfo(identity, kind, generation, out, capacity, written); } + chat::DeviceAuthorResult exportRadioIdentityBackup(uint8_t kind, uint32_t generation, uint8_t* out, size_t capacity, size_t& written, const uint8_t* session, const uint8_t* request) const { return platform::esp::common::exportRadioIdentityBackup(identity, kind, generation, out, capacity, written, session, request); } + chat::DeviceIdentityRestoreResult restoreRadioIdentity(const RadioIdentityRestoreView& request, uint8_t* work, size_t size) { return platform::esp::common::restoreRadioIdentity(identity, request, work, size); } +}; +int main(int argc, char** argv) +{ + const bool exporting = argc == 2 && std::string(argv[1]) == "--export"; + const bool restoring = argc == 2 && std::string(argv[1]) == "--restore"; + Identity source(19), target(restoring ? 19 : 21); + Adapter adapter{target}; + std::array exported{}, request_bytes{}; + std::array scratch{}; + std::array session{}; + std::array correlation{}; + session.fill(2); + correlation.fill(3); + std::array pub{}, seed{}; + std::array priv{}; + for (uint8_t kind_value : {1, 2}) + { + fake_nvs::values.clear(); + radio_identity_write_state.store(0); + auto kind = RadioIdentityKind(kind_value); + seed.fill(19); + if (kind_value == 1) ed25519_create_keypair(pub.data(), priv.data(), seed.data()); + else + { + for (size_t i = 0; i < 32; ++i) priv[i] = uint8_t(i + 1); + priv[0] &= 0xf8; + priv[31] = (priv[31] & 0x3f) | 0x40; + assert(Curve25519::eval(pub.data(), priv.data(), nullptr)); + } + assert(commitRadioIdentityRecord(radioIdentityNamespace(kind), kind, 0, pub.data(), priv.data(), scratch.data(), scratch.size(), + [kind](const uint8_t* p, const uint8_t* k, uint8_t* w, size_t size) + { return validateRadioIdentityPair(kind, p, k, w, size); }) == IdentityRecordResult::Committed); + size_t written = 0; + // Decode and answer in the same command buffer, as the ESP dispatcher does. + const std::array export_body{{0x93, 7, kind_value, 0xc3, 0}}; + assert(encodeEnvelope(session.data(), correlation.data(), uint16_t(Operation::RadioIdentityBackupExport), export_body.data(), 4, exported.data(), exported.size(), written)); + EnvelopeView export_request; + assert(decodeEnvelope(exported.data(), written, export_request)); + assert(radioIdentityReadOperation(Adapter{source}, export_request, exported.data(), exported.size(), written) == Status::Ok); + if (exporting) + { + std::array response{}; + size_t response_size = 0; + assert(encodeEnvelope(session.data(), correlation.data(), uint16_t(Status::Ok), exported.data(), written, response.data(), response.size(), response_size)); + const char* digits = "0123456789abcdef"; + for (size_t i = 0; i < response_size; ++i) std::cout << digits[response[i] >> 4] << digits[response[i] & 15]; + std::cout << '\n'; + continue; + } + if (restoring) + { + std::string hex; + assert(std::getline(std::cin, hex) && hex.size() <= 1024 && hex.size() % 2 == 0); + std::array request_frame{}; + const auto nibble = [](char c) + { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : -1; }; + for (size_t i = 0; i < hex.size() / 2; ++i) + { + const int high = nibble(hex[2 * i]), low = nibble(hex[2 * i + 1]); + assert(high >= 0 && low >= 0); + request_frame[i] = uint8_t(high * 16 + low); + } + EnvelopeView input; + assert(decodeEnvelope(request_frame.data(), hex.size() / 2, input)); + RadioIdentityRestoreWork work; + size_t response_size = 0; + const auto result = processRadioIdentityRestore(adapter, input, IdentityRestoreActivity{}, work, request_bytes.data(), request_bytes.size(), response_size); + assert(result == Status::Ok && request_bytes[1] == 0 && request_bytes[2] == kind_value && request_bytes[3] == 2); + assert(target.generation == 7 && radio_identity_write_state.load() == 2); + RadioIdentityWork stored; + uint32_t generation = 0; + bool present = false; + assert(readManagedRadioIdentity(kind, stored, generation, present) == IdentityRecordResult::Ready && present && generation == 2); + assert(std::memcmp(stored.data() + 12, pub.data(), 32) == 0); + const char* digits = "0123456789abcdef"; + for (size_t i = 0; i < response_size; ++i) std::cout << digits[request_bytes[i] >> 4] << digits[request_bytes[i] & 15]; + std::cout << '\n'; + continue; + } + geocaching::protocol::CmpReader reader({exported.data(), written}); + size_t count = 0; + geocaching::ByteView payload, signature; + assert(reader.array(count, 2) && reader.binary(payload, 320) && reader.binary(signature, 64) && reader.finished()); + assert(signature.size == 64); + RadioIdentityRestoreView content; + assert(parseRadioIdentityBackupPayload(payload, content) == Status::Ok); + assert(std::memcmp(content.export_session.data, session.data(), 16) == 0 && std::memcmp(content.export_request.data, correlation.data(), 16) == 0); + content.signature = signature; + assert(verifyRadioIdentityRestoreSignature(content, scratch.data(), scratch.size())); + const auto exported_values = fake_nvs::values; + const auto make_request = [&](uint32_t expected, uint32_t radio_generation, geocaching::ByteView current, bool consent) + { + geocaching::protocol::CmpWriter writer(request_bytes.data(), request_bytes.size()); + assert(writer.array(7) && writer.unsignedInteger(expected) && writer.binary({target.public_key.data(), 64}) && writer.binary(payload) && writer.binary(signature) && writer.unsignedInteger(radio_generation) && + (current.size ? writer.binary(current) : writer.nil())); + request_bytes[writer.size()] = consent ? 0xc3 : 0xc2; + return EnvelopeView{session.data(), nullptr, uint16_t(Operation::RadioIdentityBackupRestore), request_bytes.data(), writer.size() + 1}; + }; + size_t response_size = 0; + RadioIdentityRestoreWork work; + IdentityRestoreActivity activity; + auto input = make_request(7, 1, {pub.data(), 32}, false); + std::array frame{}; + size_t frame_size = 0; + assert(encodeEnvelope(session.data(), session.data(), input.code, input.body, input.body_size, frame.data(), frame.size(), frame_size)); + EnvelopeView decoded_frame; + assert(decodeEnvelope(frame.data(), frame_size, decoded_frame)); + assert(decoded_frame.code == input.code && decoded_frame.body_size == input.body_size); + RadioIdentityRestoreView decoded_request; + assert(parseRadioIdentityRestoreRequest({decoded_frame.body, decoded_frame.body_size}, decoded_request) == Status::Unauthorized); + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::Unauthorized); + assert(fake_nvs::values == exported_values); + input = make_request(8, 1, {pub.data(), 32}, true); + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::StaleIdentity); + input = make_request(7, 0, {pub.data(), 32}, true); + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::Conflict); + input = make_request(7, 1, {pub.data(), 32}, true); + assert(encodeEnvelope(session.data(), session.data(), input.code, input.body, input.body_size, frame.data(), frame.size(), frame_size)); + assert(decodeEnvelope(frame.data(), frame_size, decoded_frame)); + assert(parseRadioIdentityRestoreRequest({decoded_frame.body, decoded_frame.body_size}, decoded_request) == Status::Ok); + activity.upload = true; + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::Busy); + activity.upload = false; + assert(parseRadioIdentityRestoreRequest({input.body, input.body_size}, work.request) == Status::Ok); + const auto original = work.request; + work.request.public_key = {seed.data(), 32}; + assert(platform::esp::common::restoreRadioIdentity(target, work.request, scratch.data(), scratch.size()) == chat::DeviceIdentityRestoreResult::InvalidInput); + work.request = original; + assert(fake_nvs::values == exported_values); + input = make_request(7, 1, {pub.data(), 32}, true); + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::Ok); + assert(response_size >= 37 && request_bytes[1] == 0 && request_bytes[2] == kind_value && request_bytes[3] == 2); + assert(radio_identity_write_state.load() == 2); + assert(target.generation == 7); // RF restore did not change the account. + RadioIdentityWork stored; + uint32_t generation = 0; + bool present = false; + assert(readManagedRadioIdentity(kind, stored, generation, present) == IdentityRecordResult::Ready && present && generation == 2); + assert(commitRadioIdentityRecord(radioIdentityNamespace(kind), kind, 2, pub.data(), priv.data(), scratch.data(), scratch.size(), + [](const uint8_t*, const uint8_t*, uint8_t*, size_t) + { return true; }) == IdentityRecordResult::TargetChanged); + radio_identity_write_state.store(0); // Simulated application reboot, no firmware reset invoked. + input = make_request(7, 2, {pub.data(), 32}, true); + fake_nvs::fail_commit = true; + assert(processRadioIdentityRestore(adapter, input, activity, work, request_bytes.data(), request_bytes.size(), response_size) == Status::Ok); + assert(request_bytes[1] == 1 && request_bytes[3] == 3 && radio_identity_write_state.load() == 2); + fake_nvs::fail_commit = false; + radio_identity_write_state.store(0); + assert(readManagedRadioIdentity(kind, stored, generation, present) == IdentityRecordResult::Ready && generation == 3); + source.stale_on_sign = true; + assert(platform::esp::common::exportRadioIdentityBackup(source, kind_value, 7, exported.data(), exported.size(), written, session.data(), session.data()) == chat::DeviceAuthorResult::StaleIdentity); + source.stale_on_sign = false; + source.generation = 7; + for (auto byte : exported) assert(!byte); + } + fake_nvs::values.clear(); + size_t written = 0; + assert(platform::esp::common::readRadioIdentityInfo(target, 1, 7, exported.data(), exported.size(), written) == chat::DeviceAuthorResult::Ok); + assert(exported[1] == 1 && exported[2] == 0 && exported[3] == 0 && exported[4] == 0xc0); + assert(platform::esp::common::exportRadioIdentityBackup(target, 1, 7, exported.data(), exported.size(), written, session.data(), session.data()) == chat::DeviceAuthorResult::Unavailable); + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_radio_identity_record.cpp b/tests/hostlink/test_radio_identity_record.cpp new file mode 100644 index 00000000..cc8b3b27 --- /dev/null +++ b/tests/hostlink/test_radio_identity_record.cpp @@ -0,0 +1,61 @@ +#include "platform/esp/common/radio_identity_record.h" +#include +#include +#include +using namespace platform::esp::common; +int main() +{ + std::array pub{}; + pub.fill(2); + std::array priv{}; + priv.fill(3); + std::array work{}; + const auto validate = [](const uint8_t*, const uint8_t*, uint8_t*, size_t) + { return true; }; + for (const auto kind : {RadioIdentityKind::MeshCore, RadioIdentityKind::Meshtastic}) + { + fake_nvs::values.clear(); + assert(readRadioIdentityRecord("fixture", kind, work.data(), work.size()) == IdentityRecordResult::Absent); + fake_nvs::values["fixture"]["old_private"] = std::vector{9}; + assert(commitRadioIdentityRecord("fixture", kind, 0, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Committed); + for (auto byte : work) assert(!byte); + assert(readRadioIdentityRecord("fixture", kind, work.data(), work.size()) == IdentityRecordResult::Ready); + assert(identityRecordU32(work.data() + 8) == 1 && work[5] == 0); + assert(!std::memcmp(work.data() + 12, pub.data(), 32)); + assert(!std::memcmp(work.data() + 44, priv.data(), radioIdentityPrivateBytes(kind))); + const auto before = fake_nvs::values; + assert(commitRadioIdentityRecord("fixture", kind, 0, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::TargetChanged); + assert(fake_nvs::values == before); + fake_nvs::fail_commit = true; + assert(commitRadioIdentityRecord("fixture", kind, 1, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Indeterminate); + fake_nvs::fail_commit = false; + assert(readRadioIdentityRecord("fixture", kind, work.data(), work.size()) == IdentityRecordResult::Ready); + assert(identityRecordU32(work.data() + 8) == 2); + assert(commitRadioIdentityRecord("fixture", kind, 2, nullptr, nullptr, work.data(), work.size(), validate, true) == IdentityRecordResult::Committed); + assert(readRadioIdentityRecord("fixture", kind, work.data(), work.size()) == IdentityRecordResult::Ready); + assert(work[5] == 1 && identityRecordU32(work.data() + 8) == 3); + for (size_t i = 12; i < 108; ++i) assert(!work[i]); + assert(fake_nvs::values["fixture"]["old_private"] == before.at("fixture").at("old_private")); + auto& bytes = std::get>(fake_nvs::values["fixture"][kRadioIdentityRecordKey]); + bytes[12] = 1; + identityRecordPutU32(bytes.data() + 108, identityRecordCrc(bytes.data(), 108)); + assert(readRadioIdentityRecord("fixture", kind, work.data(), work.size()) == IdentityRecordResult::Invalid); + const auto corrupted = fake_nvs::values; + assert(commitRadioIdentityRecord("fixture", kind, 3, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Invalid); + assert(fake_nvs::values == corrupted); + assert(commitRadioIdentityRecord("fixture", kind, UINT32_MAX, pub.data(), priv.data(), work.data(), work.size(), validate) == IdentityRecordResult::Invalid); + } + fake_nvs::values.clear(); + assert(readLegacyRadioIdentity("legacy", "pub", "priv", RadioIdentityKind::MeshCore, work.data(), work.size(), "ver") == IdentityRecordResult::Absent); + fake_nvs::values["legacy"]["priv"] = std::vector(64, 7); + assert(readLegacyRadioIdentity("legacy", "pub", "priv", RadioIdentityKind::MeshCore, work.data(), work.size(), "ver") == IdentityRecordResult::Ready); + fake_nvs::values["legacy"]["ver"] = uint8_t(2); + assert(readLegacyRadioIdentity("legacy", "pub", "priv", RadioIdentityKind::MeshCore, work.data(), work.size(), "ver") == IdentityRecordResult::Invalid); + fake_nvs::values["legacy"]["pub"] = uint8_t(1); + assert(!radioIdentityLegacyAbsent("legacy", "pub", "priv")); + assert(readLegacyRadioIdentity("legacy", "pub", "priv", RadioIdentityKind::MeshCore, work.data(), work.size()) == IdentityRecordResult::Invalid); + fake_nvs::open_error = 7; + assert(!radioIdentityLegacyAbsent("empty", "pub", "priv")); + assert(readLegacyRadioIdentity("empty", "pub", "priv", RadioIdentityKind::Meshtastic, work.data(), work.size()) == IdentityRecordResult::StorageError); + assert(fake_nvs::handles.empty()); +} diff --git a/tests/hostlink/test_radio_identity_storage.cpp b/tests/hostlink/test_radio_identity_storage.cpp new file mode 100644 index 00000000..2e1f7914 --- /dev/null +++ b/tests/hostlink/test_radio_identity_storage.cpp @@ -0,0 +1,157 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_identity.h" +#include "platform/esp/arduino_common/mesh/esp_preferences_mesh_identity_store.h" +#include "platform/esp/common/radio_identity_record.h" +#include +#include +#include +#include +#include +#include +// Only used for non-default Options compatibility. Production default paths +// exercise direct NVS and real crypto, not these vector adapters. +namespace chat::infra +{ +bool loadRawBlobFromPreferences(const char* ns, const char* key, std::vector& out) +{ + nvs_handle_t handle = 0; + if (nvs_open(ns, NVS_READONLY, &handle) != ESP_OK) return false; + size_t size = 0; + auto result = nvs_get_blob(handle, key, nullptr, &size); + if (result == ESP_OK) + { + out.resize(size); + result = nvs_get_blob(handle, key, out.data(), &size); + } + nvs_close(handle); + return result == ESP_OK; +} +bool saveRawBlobToPreferences(const char* ns, const char* key, const uint8_t* value, size_t size) +{ + nvs_handle_t handle = 0; + if (nvs_open(ns, NVS_READWRITE, &handle) != ESP_OK) return false; + const auto set = nvs_set_blob(handle, key, value, size), commit = set == ESP_OK ? nvs_commit(handle) : set; + nvs_close(handle); + return commit == ESP_OK; +} +void clearPreferencesKeys(const char* ns, const char* a, const char* b, const char*) +{ + fake_nvs::values[ns].erase(a); + if (b) fake_nvs::values[ns].erase(b); +} +} // namespace chat::infra +void testRadioPkiInit(); +int main() +{ + using namespace platform::esp::common; + using chat::meshcore::MeshCoreIdentity; + std::array seed{}, pub{}; + std::array priv{}; + seed.fill(19); + ed25519_create_keypair(pub.data(), priv.data(), seed.data()); + MeshCoreIdentity imported; + assert(imported.importPrivateKey(priv.data())); + MeshCoreIdentity boot; + assert(boot.init()); + assert(!std::memcmp(boot.publicKey(), pub.data(), 32)); + const auto calls = radio_random_fixture::calls; + auto& native = std::get>(fake_nvs::values["mc_ident"][kRadioIdentityRecordKey]); + const auto original = native; + native[44] ^= 1; + MeshCoreIdentity corrupt; + assert(!corrupt.init()); + assert(radio_random_fixture::calls == calls); + identityRecordPutU32(native.data() + 108, identityRecordCrc(native.data(), 108)); + MeshCoreIdentity wrongPair; + assert(!wrongPair.init()); // A valid CRC does not authenticate the key pair. + assert(radio_random_fixture::calls == calls); + native = original; + fake_nvs::fail_commit = true; + seed.fill(21); + ed25519_create_keypair(pub.data(), priv.data(), seed.data()); + assert(!boot.importPrivateKey(priv.data())); + assert(!boot.isReady()); + fake_nvs::fail_commit = false; + MeshCoreIdentity uncertainBoot; + assert(uncertainBoot.init()); + assert(!std::memcmp(uncertainBoot.publicKey(), pub.data(), 32)); + fake_nvs::values.clear(); + fake_nvs::values["mc_ident"]["priv64"] = std::vector(priv.begin(), priv.end()); + fake_nvs::values["mc_ident"]["ver"] = uint8_t(1); + MeshCoreIdentity legacy; + assert(legacy.init()); + assert(!std::memcmp(legacy.publicKey(), pub.data(), 32)); + assert(fake_nvs::values["mc_ident"].count(kRadioIdentityRecordKey)); + assert(radio_random_fixture::calls == calls); + fake_nvs::values.clear(); + fake_nvs::values["mc_ident"]["pub32"] = std::vector(32, 3); + MeshCoreIdentity partial; + assert(!partial.init()); + assert(radio_random_fixture::calls == calls); + fake_nvs::values.clear(); + MeshCoreIdentity fresh; + assert(fresh.init()); + assert(radio_random_fixture::calls > calls); + + using Store = platform::esp::arduino_common::mesh::EspPreferencesLocalIdentityStore; + Store store; + mesh::LocalIdentity identity{}, loaded{}; + for (size_t i = 0; i < 32; ++i) identity.private_key[i] = uint8_t(i + 1); + identity.private_key[0] &= 0xf8; + identity.private_key[31] = (identity.private_key[31] & 0x3f) | 0x40; + assert(Curve25519::eval(identity.public_key, identity.private_key, nullptr)); + identity.valid = true; + assert(store.save(identity).ok); + assert(store.load(loaded).ok); + assert(!std::memcmp(identity.private_key, loaded.private_key, 32)); + const auto before = fake_nvs::values; + identity.public_key[0] ^= 1; + assert(!store.save(identity).ok); + assert(fake_nvs::values == before); + identity.public_key[0] ^= 1; + radio_heap_fixture::fail = true; + const auto attempts = radio_heap_fixture::attempts; + assert(!store.save(identity).ok); + assert(!store.load(loaded).ok); + assert(fake_nvs::values == before); + assert(radio_heap_fixture::attempts == attempts + 2); + assert(radio_heap_fixture::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); // No internal retry. + radio_heap_fixture::fail = false; + fake_nvs::values["chat"]["pki_pub"] = std::vector(identity.public_key, identity.public_key + 32); + fake_nvs::values["chat"]["pki_priv"] = std::vector(identity.private_key, identity.private_key + 32); + assert(store.clear().ok); + assert(store.load(loaded).failure == mesh::StoreFailure::NotFound); + assert(fake_nvs::values["chat"].count("pki_priv")); // Tombstone blocks resurrection. + assert(store.save(identity).ok); + assert(store.load(loaded).ok); + fake_nvs::values["chat"].erase(kRadioIdentityRecordKey); + assert(store.load(loaded).ok); + auto& legacy_public = std::get>(fake_nvs::values["chat"]["pki_pub"]); + legacy_public[0] ^= 1; + assert(store.load(loaded).failure == mesh::StoreFailure::Corrupt); + assert(!loaded.valid); + fake_nvs::values["chat"]["pki_pub"] = uint8_t(1); + assert(store.load(loaded).failure == mesh::StoreFailure::Corrupt); + assert(!loaded.valid); + fake_nvs::values["chat"].erase("pki_pub"); + assert(store.load(loaded).failure == mesh::StoreFailure::Corrupt); + fake_nvs::open_error = 7; + assert(store.load(loaded).failure == mesh::StoreFailure::IoError); + fake_nvs::open_error = 0; + Store::Options options; + options.public_key = "custom_pub"; + options.private_key = "custom_priv"; + Store custom(options); + assert(custom.save(identity).ok); + assert(custom.load(loaded).ok); + assert(fake_nvs::values["chat"].count("custom_priv")); + assert(!fake_nvs::values["chat"].count(kRadioIdentityRecordKey)); + assert(fake_nvs::handles.empty()); + radio_heap_fixture::external_size = 0; + assert(custom.load(loaded).ok); + assert(radio_heap_fixture::last_caps == MALLOC_CAP_8BIT); + assert(radio_heap_fixture::allocations == radio_heap_fixture::frees && radio_heap_fixture::blocks.empty()); + radio_heap_fixture::external_size = 8 * 1024 * 1024; + testRadioPkiInit(); + assert(radio_heap_fixture::allocations == radio_heap_fixture::frees && radio_heap_fixture::blocks.empty()); +} diff --git a/tests/hostlink/test_radio_pki_init.cpp b/tests/hostlink/test_radio_pki_init.cpp new file mode 100644 index 00000000..7e2306d3 --- /dev/null +++ b/tests/hostlink/test_radio_pki_init.cpp @@ -0,0 +1,71 @@ +#include "platform/esp/arduino_common/mesh/esp_preferences_mesh_identity_store.h" +#include "platform/esp/common/radio_identity_record.h" +#include +#include +#include +#include +#include +#include +#include + +// The new-account RNG path is deliberately unavailable here. Tests prove that +// bad storage and OOM never invoke generation, using the actual init function. +void RNGClass::begin(const char*) { std::abort(); } +void RNGClass::stir(const uint8_t*, size_t, unsigned int) { std::abort(); } +namespace radio_pki_fixture +{ +long random() { std::abort(); } +bool isZeroKey(const uint8_t* value, size_t size) +{ + for (size_t i = 0; i < size; ++i) + if (value[i]) return false; + return true; +} +std::string toHex(const uint8_t*, size_t, size_t) { return "fixture"; } +class MtAdapter +{ + public: + bool initPkiKeys(); + std::array pki_public_key_{}, pki_private_key_{}; + uint8_t mac_addr_[6]{}; + bool pki_ready_ = false; +}; +#define LORA_LOG(...) ((void)0) +#include "radio_pki_init_actual.inc" +#undef LORA_LOG +} // namespace radio_pki_fixture +void testRadioPkiInit() +{ + using namespace radio_pki_fixture; + fake_nvs::values.clear(); + mesh::LocalIdentity identity{}; + for (size_t i = 0; i < 32; ++i) identity.private_key[i] = uint8_t(i + 1); + identity.private_key[0] &= 0xf8; + identity.private_key[31] = (identity.private_key[31] & 0x3f) | 0x40; + assert(Curve25519::eval(identity.public_key, identity.private_key, nullptr)); + identity.valid = true; + platform::esp::arduino_common::mesh::EspPreferencesLocalIdentityStore store; + assert(store.save(identity).ok); + MtAdapter adapter; + assert(adapter.initPkiKeys()); + assert(adapter.pki_ready_ && !std::memcmp(adapter.pki_public_key_.data(), identity.public_key, 32)); + auto& bytes = std::get>(fake_nvs::values["chat"][platform::esp::common::kRadioIdentityRecordKey]); + const auto original = bytes; + bytes[44] ^= 1; + const auto invalid = fake_nvs::values; + assert(!adapter.initPkiKeys()); + assert(!adapter.pki_ready_); + assert(fake_nvs::values == invalid); + for (auto byte : adapter.pki_private_key_) assert(!byte); + bytes = original; + radio_heap_fixture::fail = true; + assert(!adapter.initPkiKeys()); + assert(!adapter.pki_ready_); + radio_heap_fixture::fail = false; + fake_nvs::open_error = 7; + assert(!adapter.initPkiKeys()); + assert(!adapter.pki_ready_); + fake_nvs::open_error = 0; + assert(adapter.initPkiKeys()); + assert(adapter.pki_ready_); +} diff --git a/tests/hostlink/test_request_retry_window.cpp b/tests/hostlink/test_request_retry_window.cpp new file mode 100644 index 00000000..a0abf307 --- /dev/null +++ b/tests/hostlink/test_request_retry_window.cpp @@ -0,0 +1,74 @@ +#include "track_sharing/request_retry_window.h" +#include +#include +struct Receiver +{ + bool waiting = false; + unsigned cancelled = 0; + bool pending() const { return waiting; } + void cancelPending() + { + waiting = false; + ++cancelled; + } +}; +int main() +{ + using namespace track_sharing; + RequestRetryWindow window; + Receiver receiver; + uint8_t request[16]; + std::memset(request, 3, 16); + assert(window.poll(receiver, 0) == ExchangePoll::Invalid); + assert(!window.begin({request, 16}, 100, 0, 3, 2)); + assert(!window.begin({request, 16}, 100, 10, 0, 2)); + assert(!window.begin({request, 16}, 100, 10, 3, 0)); + assert(window.begin({request, 16}, 100, 10, 3, 2)); + request[0] = 9; + assert(window.request().data[0] == 3); + assert(!window.begin({request, 16}, 100, 10, 3, 2)); + assert(!window.sent(true, 100)); + assert(window.poll(receiver, 99) == ExchangePoll::Invalid); + assert(window.poll(receiver, 100) == ExchangePoll::Ready); + receiver.waiting = true; + assert(window.sent(true, 100)); + assert(window.poll(receiver, 109) == ExchangePoll::Waiting && !receiver.cancelled); + assert(window.poll(receiver, 110) == ExchangePoll::Waiting && !receiver.waiting && receiver.cancelled == 1); + assert(window.poll(receiver, 112) == ExchangePoll::Waiting); + assert(window.poll(receiver, 113) == ExchangePoll::Ready && window.request().data[0] == 3); + receiver.waiting = true; + assert(window.sent(true, 113)); + assert(window.poll(receiver, 123) == ExchangePoll::Exhausted && !receiver.waiting && receiver.cancelled == 2); + assert(!window.sent(true, 124)); + assert(window.resume(124) && window.attempts() == 0 && window.request().data[0] == 3); + assert(window.poll(receiver, 124) == ExchangePoll::Ready); + assert(window.sent(false, 124)); + assert(window.poll(receiver, 126) == ExchangePoll::Waiting); + assert(window.poll(receiver, 127) == ExchangePoll::Ready); + receiver.waiting = true; + assert(window.sent(true, 127)); + window.pause(receiver); + assert(!receiver.waiting && window.poll(receiver, 150) == ExchangePoll::Paused); + assert(!window.resume(149)); + assert(window.resume(151) && window.poll(receiver, 151) == ExchangePoll::Ready); + receiver.waiting = true; + assert(window.sent(true, 151)); + receiver.waiting = false; // Verified receipt has been durably consumed. + assert(window.poll(receiver, 152) == ExchangePoll::Completed && !window.request().size); + assert(window.poll(receiver, 153) == ExchangePoll::Invalid); + assert(window.begin({request, 16}, 154, 10, 3, 2)); + assert(window.request().data[0] == 9 && !window.attempts()); + assert(window.poll(receiver,154) == ExchangePoll::Ready); + receiver.waiting = true; + assert(window.sent(true,154)); + receiver.waiting = false; + window.pause(receiver); + assert(!window.request().size && window.poll(receiver,155) == ExchangePoll::Invalid); + assert(window.begin({request,16},200,10,3,3) && window.poll(receiver,200)==ExchangePoll::Ready); + receiver.waiting=true;assert(window.sent(true,200)); + assert(window.dispatchRejected(receiver,200) && !receiver.waiting && window.attempts()==1 && window.request().data[0]==9); + assert(window.poll(receiver,202)==ExchangePoll::Waiting && window.poll(receiver,203)==ExchangePoll::Ready); + receiver.waiting=true;assert(window.sent(true,203)); + receiver.waiting=false; + assert(window.dispatchRejected(receiver,203) && !window.request().size); +} diff --git a/tests/hostlink/test_reservation_store.cpp b/tests/hostlink/test_reservation_store.cpp new file mode 100644 index 00000000..744f4ea5 --- /dev/null +++ b/tests/hostlink/test_reservation_store.cpp @@ -0,0 +1,104 @@ +#include "track_sharing/reservation_store.h" +#include +#include +#include +#include +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + bool active = false, short_write = false, unavailable = false, corrupt_write = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + if (unavailable) return {nullptr, OpenStatus::Unavailable}; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return true; + } + bool size(void*, uint64_t& size) + { + size = files[path].size(); + return true; + } + bool seek(void*, uint64_t position) { return !position; } + size_t read(void*, uint8_t* out, size_t size) + { + size = std::min(size, files[path].size()); + std::memcpy(out, files[path].data(), size); + return size; + } + size_t write(void*, const uint8_t* bytes, size_t size) + { + if (short_write) size /= 2; + files[path].assign(bytes, bytes + size); + if (corrupt_write && !files[path].empty()) files[path][0] ^= 1; + return size; + } + bool sync(void*) { return true; } +}; +int main() +{ + using namespace track_sharing; + using Result = ReservationStoreResult; + Io io; + ReservationStore store(io, "a", "b"); + static_assert(sizeof(store) <= 24, "Store must borrow records"); + RevisionReservation current, verify, initial; + uint8_t scratch[168]; + initial.operation[0] = 1; + initial.object[0] = 2; + initial.author[0] = 3; + initial.candidate[0] = 4; + initial.sequence = 1; + initial.generation = 7; + initial.revision = 1; + assert(store.initialize(initial, verify, scratch, sizeof(scratch)) == Result::Ready); + current = initial; + assert(store.advance(current, ReservationPhase::Signed, verify, scratch, sizeof(scratch)) == Result::Conflict); + io.short_write = true; + assert(store.advance(current, ReservationPhase::Checked, verify, scratch, sizeof(scratch)) == Result::StorageFailure && !io.active); + assert(store.load(current, verify, scratch, sizeof(scratch)) == Result::Ready && current.sequence == 1); + io.short_write = false; + io.corrupt_write = true; + assert(store.advance(current, ReservationPhase::Checked, verify, scratch, sizeof(scratch)) == Result::StorageFailure && !io.active); + assert(store.load(current, verify, scratch, sizeof(scratch)) == Result::Ready && current.sequence == 1); + io.corrupt_write = false; + assert(store.advance(current, ReservationPhase::Checked, verify, scratch, sizeof(scratch)) == Result::Ready); + current = verify; + assert(store.advance(initial, ReservationPhase::Checked, verify, scratch, sizeof(scratch)) == Result::Conflict); + const auto stale_checked = current; + assert(store.advance(current, ReservationPhase::Signed, verify, scratch, sizeof(scratch)) == Result::Ready); + current = verify; + const auto signed_slot = io.files["a"]; + assert(store.advance(stale_checked, ReservationPhase::Cancelled, verify, scratch, sizeof(scratch)) == Result::Conflict && io.files["a"] == signed_slot && !io.active); + const auto saved_files = io.files; + assert(store.initialize(initial, verify, scratch, sizeof(scratch)) == Result::Conflict && io.files == saved_files); + assert(store.advance(current, ReservationPhase::Cancelled, verify, scratch, sizeof(scratch)) == Result::Conflict); + assert(store.load(current, verify, scratch, sizeof(scratch)) == Result::Ready && current.phase == ReservationPhase::Signed); + io.unavailable = true; + assert(store.load(current, verify, scratch, sizeof(scratch)) == Result::StorageFailure && !current.sequence && !io.active); +} diff --git a/tests/hostlink/test_revision_reservation.cpp b/tests/hostlink/test_revision_reservation.cpp new file mode 100644 index 00000000..a77a759a --- /dev/null +++ b/tests/hostlink/test_revision_reservation.cpp @@ -0,0 +1,67 @@ +#include "track_sharing/revision_reservation.h" +#include +int main() +{ + using namespace track_sharing; + RevisionReservation value, decoded; + value.operation[0] = 1; + value.object[0] = 2; + value.author[0] = 3; + value.candidate[0] = 4; + value.generation = 7; + value.revision = 1; + value.sequence = 1; + value.phase = ReservationPhase::Reserved; + decoded = value; + decoded.sequence = 2; + decoded.phase = ReservationPhase::Checked; + assert(reservationAdvanceValid(value, decoded)); + decoded.phase = ReservationPhase::Signed; + assert(!reservationAdvanceValid(value, decoded)); + decoded.phase = ReservationPhase::Checked; + decoded.candidate[31] ^= 1; + assert(!reservationAdvanceValid(value, decoded)); + decoded.candidate[31] ^= 1; + decoded.generation++; + assert(!reservationAdvanceValid(value, decoded)); + decoded.generation--; + value.phase = ReservationPhase::Signed; + decoded.phase = ReservationPhase::Cancelled; + assert(!reservationAdvanceValid(value, decoded)); + decoded.phase = ReservationPhase::Committed; + assert(reservationAdvanceValid(value, decoded)); + value.phase = ReservationPhase::Committed; + decoded.phase = ReservationPhase::Reserved; + assert(!reservationAdvanceValid(value, decoded)); + value.sequence = UINT64_MAX; + decoded.sequence = 1; + decoded.phase = ReservationPhase::Committed; + assert(!reservationAdvanceValid(value, decoded)); + value.phase = ReservationPhase::Reserved; + value.sequence = 0x100000001ULL; + uint8_t raw[kReservationBytes]; + assert(encodeReservation(value, raw, sizeof(raw)) && decodeReservation(raw, sizeof(raw), decoded)); + assert(decoded.sequence == value.sequence && decoded.revision == 1 && decoded.generation == 7); + for (unsigned i = 0; i < sizeof(raw); ++i) + { + raw[i] ^= 1; + assert(!decodeReservation(raw, sizeof(raw), decoded) && !decoded.sequence); + raw[i] ^= 1; + } + value.parent[0] = 1; + assert(!encodeReservation(value, raw, sizeof(raw))); + value.revision = 2; + for (unsigned phase = 0; phase <= 4; ++phase) + { + value.phase = static_cast(phase); + assert(encodeReservation(value, raw, sizeof(raw)) && decodeReservation(raw, sizeof(raw), decoded) && decoded.phase == value.phase); + } + value.phase = static_cast(5); + assert(!encodeReservation(value, raw, sizeof(raw))); + value.phase = ReservationPhase::Reserved; + value.generation = 0; + assert(!encodeReservation(value, raw, sizeof(raw))); + value.generation = 7; + assert(!encodeReservation(value, reinterpret_cast(&value), sizeof(value))); + assert(!decodeReservation(raw, sizeof(raw) - 1, decoded)); +} diff --git a/tests/hostlink/test_route_file_check.cpp b/tests/hostlink/test_route_file_check.cpp new file mode 100644 index 00000000..ecaaa1f7 --- /dev/null +++ b/tests/hostlink/test_route_file_check.cpp @@ -0,0 +1,102 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/route_file_check.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Io +{ + enum class Mode + { + Read + }; + struct OpenResult + { + void* handle; + }; + std::vector bytes; + size_t offset = 0, maximum_read = 0; + bool opened = false, fail_read = false, fail_close = false; + OpenResult open(const char*, Mode) + { + assert(!opened); + opened = true; + return {this}; + } + bool close(void*) + { + opened = false; + return !fail_close; + } + bool size(void*, uint64_t& result) + { + result = bytes.size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= bytes.size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + maximum_read = std::max(maximum_read, length); + if (fail_read) return 0; + assert(offset + length <= bytes.size()); + std::memcpy(output, bytes.data() + offset, length); + return length; + } +}; +static char names[2048]; +static uint8_t scratch[320]; +int main() +{ + const std::string valid = "" + std::string(1400, 'x') + "120,30 120.01,30.01"; + using Result = track_sharing::FileIntegrityResult; + auto run = [&](const std::string& text, unsigned fault, Result expected) + { + Io io; + io.bytes.assign(text.begin(), text.end()); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t digest[32]; + assert(hash.start() && hash.update(io.bytes.data(), io.bytes.size()) && hash.finish(digest)); + if (fault == 1) digest[0] ^= 1; + io.fail_read = fault == 2; + io.fail_close = fault == 3; + track_sharing::RouteFileCheck check(io, hash, names, sizeof(names)); + assert(check.begin("sealed.kml", uint32_t(io.bytes.size()), digest) == Result::Pending); + assert(check.begin("sealed.kml", uint32_t(io.bytes.size()), digest) == Result::Busy); + Result result = Result::Pending; + unsigned steps = 0; + while (result == Result::Pending && steps++ < 100) result = check.step(scratch, sizeof(scratch)); + assert(result == expected && !io.opened && io.maximum_read <= 320); + assert(check.ready() == (expected == Result::Ready)); + if (expected == Result::Ready) assert(check.geometry() && check.geometry()->points == 2 && steps > 1); + else assert(!check.geometry()); + }; + run(valid, 0, Result::Ready); + run(valid, 1, Result::HashMismatch); + run(valid, 2, Result::StorageFailure); + run(valid, 3, Result::StorageFailure); + run("", 0, Result::Invalid); + run("999,30", 0, Result::Invalid); + run("", 0, Result::Invalid); + Io io; + io.bytes.assign(valid.begin(), valid.end()); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t digest[32]{}; + track_sharing::RouteFileCheck check(io, hash, names, sizeof(names)); + assert(check.begin("sealed.kml", uint32_t(io.bytes.size()), digest) == Result::Pending); + assert(check.step(scratch, sizeof(scratch)) == Result::Pending); + assert(check.cancel() == Result::Cancelled && !check.ready()); + assert(check.step(scratch, sizeof(scratch)) == Result::Cancelled && !io.opened); +} diff --git a/tests/hostlink/test_route_install_authorization.cpp b/tests/hostlink/test_route_install_authorization.cpp new file mode 100644 index 00000000..b04f3331 --- /dev/null +++ b/tests/hostlink/test_route_install_authorization.cpp @@ -0,0 +1,47 @@ +#include "hostlink/route_install_authorization.h" +#include +using namespace hostlink::management; +int main() +{ + uint8_t id[16]{1}, digest[32]{2}, author[32]{3}; + RouteInstallView request{{id, 16}, {digest, 32}, 1024, 7}; + track_sharing::UploadJournal journal; + std::memcpy(journal.id, id, 16); + std::memcpy(journal.file_hash, digest, 32); + std::memcpy(journal.author_hash, author, 32); + journal.generation = 7; + journal.sequence = 1; + journal.total = journal.durable_offset = 1024; + journal.phase = track_sharing::UploadPhase::Sealed; + const auto authorize = [&](bool session = true, bool media = true, uint32_t generation = 7) + { + return authorizeRouteInstallUpload(request, journal, track_sharing::UploadKind::Kml, generation, {author, 32}, session, media); + }; + assert(authorize() == Status::Ok); + assert(authorize(false) == Status::Unauthorized); + assert(authorize(true, false) == Status::StorageUnavailable); + assert(authorize(true, true, 8) == Status::StaleIdentity); + author[0] ^= 1; + assert(authorize() == Status::StaleIdentity); + author[0] ^= 1; + journal.generation = 8; + assert(authorize() == Status::StaleIdentity); + journal.generation = 7; + journal.phase = track_sharing::UploadPhase::Receiving; + assert(authorize() == Status::Conflict); + journal.phase = track_sharing::UploadPhase::Sealed; + journal.id[0] ^= 2; + assert(authorize() == Status::Conflict); + journal.id[0] ^= 2; + digest[0] ^= 1; + assert(authorize() == Status::HashMismatch); + digest[0] ^= 1; + request.bytes = 1023; + assert(authorize() == Status::Conflict); + request.bytes = 1024; + assert(authorizeRouteInstallUpload(request, journal, track_sharing::UploadKind::Manifest, 7, {author, 32}, true, true) == Status::InvalidField); + journal.sequence = 0; + assert(authorize() == Status::InvalidField); + journal.sequence = 1; + assert(authorize() == Status::Ok && journal.sequence == 1 && journal.total == 1024 && request.generation == 7); +} diff --git a/tests/hostlink/test_route_install_commit.cpp b/tests/hostlink/test_route_install_commit.cpp new file mode 100644 index 00000000..8f3f7f3b --- /dev/null +++ b/tests/hostlink/test_route_install_commit.cpp @@ -0,0 +1,117 @@ +#include "hostlink/route_install_commit.h" +#include +#include +#include +using namespace hostlink::management; +struct Check +{ + bool ready = true; + std::string source; + bool matchesSource(const char* path, uint32_t bytes, const uint8_t* hash) const + { + return ready && source == path && bytes == 1024 && hash && hash[0] == 2; + } +}; +struct Io +{ + enum class Mode + { + Read, + Update + }; + enum class OpenStatus + { + Ready, + Missing, + IoError + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + bool existing = false, io_error = false, synced = true, published = true, opened = false; + unsigned publishes = 0; + std::vector calls; + OpenResult open(const char*, Mode mode) + { + assert(!opened); + calls.push_back(mode == Mode::Read ? "probe" : "update"); + if (io_error) return {nullptr, OpenStatus::IoError}; + if (mode == Mode::Read && !existing) return {nullptr, OpenStatus::Missing}; + opened = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + assert(opened); + opened = false; + calls.push_back("close"); + return true; + } + bool size(void*, uint64_t& bytes) + { + bytes = 1024; + return true; + } + bool sync(void*) + { + calls.push_back("sync"); + return synced; + } + bool publish(const char*, const char*) + { + assert(!opened); + calls.push_back("publish"); + publishes++; + existing = true; + return published; + } +}; +int main() +{ + uint8_t id[16]{1}, hash[32]{2}; + RouteInstallView request{{id, 16}, {hash, 32}, 1024, 7}; + char source[64], target[77]; + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::Candidate, source, sizeof(source))); + Check check; + check.source = source; + const auto allow = [] + { return Status::Ok; }; + Io io; + assert(publishValidatedRoute(io, check, request, source, target, allow) == Status::Ok); + assert((io.calls == std::vector{"probe", "update", "sync", "close", "publish"})); + Io existing; + existing.existing = true; + assert(publishValidatedRoute(existing, check, request, source, target, allow) == Status::Conflict && !existing.publishes); + Io denied; + assert(publishValidatedRoute(denied, check, request, source, target, [] + { return Status::StaleIdentity; }) == Status::StaleIdentity && + denied.calls.empty()); + Io changed; + unsigned guards = 0; + assert(publishValidatedRoute(changed, check, request, source, target, [&] + { return ++guards == 1 ? Status::Ok : Status::StaleIdentity; }) == Status::StaleIdentity && + !changed.publishes); + Io failure; + failure.published = false; + assert(publishValidatedRoute(failure, check, request, source, target, allow) == Status::Internal && failure.existing && failure.publishes == 1); + Io storage; + storage.synced = false; + assert(publishValidatedRoute(storage, check, request, source, target, allow) == Status::StorageUnavailable && !storage.publishes && !storage.opened); + Io wrong; + check.source = "different.kml"; + assert(publishValidatedRoute(wrong, check, request, source, target, allow) == Status::InvalidField && wrong.calls.empty()); + check.source = source; + const std::string original_source = source; + char alternate[64]{}; + std::strcpy(alternate, "different.kml"); + Io path_error; + assert(publishValidatedRoute(path_error, check, request, alternate, target, allow) == Status::InvalidField && !std::strcmp(alternate, "different.kml") && path_error.calls.empty()); + uint8_t overlap[128]{}; + std::memcpy(overlap + 32, hash, 32); + RouteInstallView overlapping{{id, 16}, {overlap + 32, 32}, 1024, 7}; + Io alias; + assert(publishValidatedRoute(alias, check, overlapping, source, reinterpret_cast(overlap), allow) == Status::InvalidField && !std::memcmp(overlap + 32, hash, 32) && alias.calls.empty()); + assert(original_source == source); +} diff --git a/tests/hostlink/test_route_install_directory.cpp b/tests/hostlink/test_route_install_directory.cpp new file mode 100644 index 00000000..c2e02495 --- /dev/null +++ b/tests/hostlink/test_route_install_directory.cpp @@ -0,0 +1,45 @@ +#include "hostlink/route_install_directory.h" +#include +#include +struct Port +{ + uint32_t media = 1; + bool exists = false, available = true, external = false, failure = false, swap = false; + unsigned creates = 0; + uint32_t session() const { return media; } + bool ready() const { return available; } + bool externalOwner() const { return external; } + bool directory(const char* path, uint32_t expected) const + { + assert(!std::strcmp(path, "/routes")); + return expected == media && exists; + } + bool mkdir(const char* path, uint32_t expected) + { + assert(!std::strcmp(path, "/routes")); + if (swap) ++media; + if (expected != media || failure) return false; + ++creates; + exists = true; + return true; + } +}; +int main() +{ + using hostlink::management::ensureRouteInstallDirectory; + using hostlink::management::Status; + Port fresh; + assert(ensureRouteInstallDirectory(fresh, 1) == Status::Ok && fresh.creates == 1 && fresh.exists); + assert(ensureRouteInstallDirectory(fresh, 1) == Status::Ok && fresh.creates == 1); + Port stale; + assert(ensureRouteInstallDirectory(stale, 2) == Status::StorageUnavailable && !stale.creates); + Port swapped; + swapped.swap = true; + assert(ensureRouteInstallDirectory(swapped, 1) == Status::StorageUnavailable && !swapped.creates && !swapped.exists); + Port failed; + failed.failure = true; + assert(ensureRouteInstallDirectory(failed, 1) == Status::StorageUnavailable && !failed.creates); + Port external; + external.external = true; + assert(ensureRouteInstallDirectory(external, 1) == Status::StorageUnavailable && !external.creates); +} diff --git a/tests/hostlink/test_route_install_job.cpp b/tests/hostlink/test_route_install_job.cpp new file mode 100644 index 00000000..85bf8369 --- /dev/null +++ b/tests/hostlink/test_route_install_job.cpp @@ -0,0 +1,138 @@ +#include "hostlink/route_install_job.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +using namespace hostlink::management; +struct Io +{ + enum class Mode + { + Read, + Update + }; + enum class OpenStatus + { + Ready, + Missing, + IoError + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::vector* opened = nullptr; + size_t offset = 0, max_read = 0; + unsigned publishes = 0; + bool publish_error = false, corrupt_final = false, synced = false; + OpenResult open(const char* path, Mode) + { + assert(!opened); + auto found = files.find(path); + if (found == files.end()) return {nullptr, OpenStatus::Missing}; + opened = &found->second; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + assert(opened); + opened = nullptr; + return true; + } + bool size(void*, uint64_t& size) + { + size = opened->size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= opened->size(); + } + size_t read(void*, uint8_t* output, size_t count) + { + assert(count <= 320 && offset + count <= opened->size()); + max_read = std::max(max_read, count); + std::memcpy(output, opened->data() + offset, count); + return count; + } + bool sync(void*) + { + synced = true; + return true; + } + bool publish(const char* source, const char* target) + { + assert(!opened && !files.count(target)); + publishes++; + files[target] = std::move(files[source]); + files.erase(source); + if (corrupt_final) files[target][10] ^= 1; + return !publish_error; + } +}; +static char names[2048]; +static uint8_t scratch[320]; +int main() +{ + const std::string xml = "" + std::string(1400, 'x') + "120,30 120.01,30.01"; + for (unsigned mode = 0; mode < 7; ++mode) + { + Io io; + uint8_t id[16]{1}, digest[32]; + char source[64], target[77]; + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::Candidate, source, sizeof(source))); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(hash.start() && hash.update(reinterpret_cast(xml.data()), xml.size()) && hash.finish(digest)); + assert(routeInstallTarget({digest, 32}, target, sizeof(target))); + io.files[source] = std::vector(xml.begin(), xml.end()); + if (mode == 2 || mode == 3) + { + io.files[target] = io.files[source]; + io.files.erase(source); + if (mode == 3) io.files[target][xml.find(std::string(1400, 'x')) + 10] ^= 1; + } + io.publish_error = mode == 4; + io.corrupt_final = mode == 5; + RouteInstallView request{{id, 16}, {digest, 32}, uint32_t(xml.size()), 7}; + RouteInstallJob job(io, hash, names, sizeof(names), request, source, target); + const auto guard = [&] + { return mode == 6 && io.synced ? Status::StaleIdentity : Status::Ok; }; + auto status = job.begin(mode != 1, guard); + unsigned steps = 0; + while (status == Status::Busy && steps++ < 100) status = job.step(scratch, sizeof(scratch), guard); + assert(!io.opened && io.max_read <= 320 && steps < 100); + if (mode == 1) + { + assert(status == Status::Ok && job.wireState() == 2 && io.publishes == 0 && io.files.count(source)); + } + else if (mode == 3) + { + assert(status == Status::HashMismatch && io.publishes == 0); + } + else if (mode == 5) + { + assert(status == Status::Internal && job.phase() == RouteInstallPhase::Failed && io.publishes == 1); + } + else if (mode == 6) + { + assert(status == Status::StaleIdentity && io.publishes == 0 && io.files.count(source)); + } + else + { + assert(status == Status::Ok && job.wireState() == 1 && io.files.count(target)); + assert(io.publishes == (mode == 2 ? 0U : 1U)); + } + } +} diff --git a/tests/hostlink/test_route_install_request.cpp b/tests/hostlink/test_route_install_request.cpp new file mode 100644 index 00000000..9b824661 --- /dev/null +++ b/tests/hostlink/test_route_install_request.cpp @@ -0,0 +1,61 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "hostlink/route_install_request.h" +#include +#include +using namespace hostlink::management; +int main() +{ + uint8_t raw[512]{}, id[16]{1}, hash[32]; + std::memset(hash, 0xab, sizeof(hash)); + auto encode = [&](uint64_t bytes, uint64_t generation, size_t id_size = 16) + { + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(4) && writer.binary({id, id_size}) && writer.binary({hash, 32}) && + writer.unsignedInteger(bytes) && writer.unsignedInteger(generation)); + return writer.size(); + }; + RouteInstallView view; + size_t size = encode(8388608, UINT32_MAX - 1); + assert(parseRouteInstallRequest({raw, size}, view) == Status::Ok); + assert(view.bytes == 8388608 && view.generation == UINT32_MAX - 1); + assert(view.transfer.data >= raw && view.transfer.data < raw + size); + assert(view.hash.data >= raw && view.hash.data < raw + size); + char path[78]; + std::memset(path, '!', sizeof(path)); + assert(routeInstallTarget(view.hash, path, 77)); + assert(std::strlen(path) == 76 && path[77] == '!'); + assert(std::strncmp(path, "/routes/abab", 12) == 0 && std::strcmp(path + 72, ".kml") == 0); + assert(!routeInstallTarget(view.hash, path, 76) && path[0] == '\0'); + uint8_t aliased[160], saved[160]; + std::memset(aliased, 0xab, sizeof(aliased)); + std::memcpy(saved, aliased, sizeof(saved)); + for (size_t offset : {size_t(20), size_t(40), size_t(50)}) + { + assert(!routeInstallTarget({aliased + 40, 32}, reinterpret_cast(aliased + offset), 77)); + assert(!std::memcmp(aliased, saved, sizeof(saved))); + } + assert(!routeInstallTarget({aliased + 40, 32}, reinterpret_cast(aliased + 40), 1)); + assert(!std::memcmp(aliased, saved, sizeof(saved))); + for (const auto total : {uint64_t(0), uint64_t(8388609), UINT64_MAX}) + { + size = encode(total, 7); + assert(parseRouteInstallRequest({raw, size}, view) == Status::InvalidField && !view.transfer.data); + } + for (const auto generation : {uint64_t(0), uint64_t(UINT32_MAX), UINT64_MAX}) + { + size = encode(1024, generation); + assert(parseRouteInstallRequest({raw, size}, view) == Status::InvalidField); + } + size = encode(1024, 7, 15); + assert(parseRouteInstallRequest({raw, size}, view) == Status::InvalidField); + std::memset(id, 0, sizeof(id)); + size = encode(1024, 7); + assert(parseRouteInstallRequest({raw, size}, view) == Status::InvalidField); + id[0] = 1; + size = encode(1024, 7); + for (size_t prefix = 0; prefix < size; ++prefix) + assert(parseRouteInstallRequest({raw, prefix}, view) == Status::InvalidField); + raw[size] = 0; + assert(parseRouteInstallRequest({raw, size + 1}, view) == Status::InvalidField); + assert(parseRouteInstallRequest({raw, 449}, view) == Status::InvalidField); +} diff --git a/tests/hostlink/test_route_install_storage_policy.cpp b/tests/hostlink/test_route_install_storage_policy.cpp new file mode 100644 index 00000000..a8f92314 --- /dev/null +++ b/tests/hostlink/test_route_install_storage_policy.cpp @@ -0,0 +1,46 @@ +#include "hostlink/route_install_storage_policy.h" +#include +using namespace hostlink::management; +int main() +{ + RouteInstallStorageActivity activity; + assert(routeInstallStorageAdmission(activity) == Status::Ok); + for (unsigned index = 0; index < 6; ++index) + { + activity = {}; + switch (index) + { + case 0: + activity.restart = true; + break; + case 1: + activity.snapshot = true; + break; + case 2: + activity.prepare = true; + break; + case 3: + activity.publication = true; + break; + case 4: + activity.configuration = true; + break; + default: + activity.active_route = true; + break; + } + assert(routeInstallStorageAdmission(activity) == Status::Busy); + } + for (uint16_t op : {uint16_t(0), uint16_t(1), uint16_t(2), uint16_t(3), uint16_t(4), uint16_t(16), uint16_t(48), uint16_t(85), uint16_t(95)}) assert(!routeInstallBlocksOperation(op)); + for (uint16_t op : {uint16_t(5), uint16_t(17), uint16_t(21), uint16_t(32), uint16_t(33), uint16_t(34), uint16_t(35), uint16_t(36), uint16_t(50), uint16_t(66), uint16_t(67), uint16_t(68), uint16_t(80), uint16_t(81), uint16_t(82), uint16_t(83), uint16_t(86), uint16_t(91), uint16_t(65535)}) assert(routeInstallBlocksOperation(op)); + uint8_t hash[32]; + std::memset(hash, 0xab, sizeof(hash)); + char path[96]{}; + assert(routeInstallTarget({hash, 32}, path, sizeof(path)) && routeInstallPathMatches({hash, 32}, path, sizeof(path))); + path[76] = 'x'; + assert(!routeInstallPathMatches({hash, 32}, path, sizeof(path))); + path[76] = '\0'; + path[9] = 'A'; + assert(!routeInstallPathMatches({hash, 32}, path, sizeof(path))); + assert(!routeInstallPathMatches({hash, 32}, path, 76)); +} diff --git a/tests/hostlink/test_route_png_cache.cpp b/tests/hostlink/test_route_png_cache.cpp new file mode 100644 index 00000000..ba2ade09 --- /dev/null +++ b/tests/hostlink/test_route_png_cache.cpp @@ -0,0 +1,435 @@ +#include "../../third_party/route_png/route_pngle.h" +#include "platform/esp/arduino_common/route_png_cache.h" +#include "platform/ui/route_storage.h" +#include "src/libs/tjpgd/tjpgd.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static unsigned yields = 0, change_at = 0; +void route_png_test_yield() +{ + if (++yields == change_at) ++image_fake::media; +} +inline size_t heap_caps_get_free_size(unsigned) { return 1048576; } +inline size_t heap_caps_get_largest_free_block(unsigned) { return 1048576; } +namespace sd = platform::esp::arduino_common::storage; +namespace platform::esp::arduino_common::storage +{ +bool sd_exists(const char* path) { return image_fake::files.count(path) != 0; } +bool sd_remove(const char* path) { return sd_remove(path, image_fake::media); } +bool sd_rename(const char* from, const char* to) { return sd_rename(from, to, image_fake::media); } +} // namespace platform::esp::arduino_common::storage +namespace platform::ui::route_storage +{ +constexpr size_t kRouteCacheInternalReserveBytes = 96 * 1024, kRouteCacheInternalSlackBytes = 8 * 1024, kRouteJpegDecoderWorkBytes = 3100; +constexpr uint16_t kRouteViewWidth = 320, kRouteViewHeight = 180; +bool route_asset_file_exists(const std::string& path) { return sd::sd_exists(path.c_str()); } +bool ensure_parent_dir(const std::string&) { return true; } +void log_cache_memory_skip(const char*, size_t) {} +// clang-format off +#include "image_ui_png_heap_actual.inc" +#include "image_ui_png_cache_actual.inc" +// clang-format on +} // namespace platform::ui::route_storage +using Bytes = std::vector; +static void big(Bytes& output, uint32_t value) +{ + for (int shift = 24; shift >= 0; shift -= 8) output.push_back(uint8_t(value >> shift)); +} +static uint32_t crc(const uint8_t* input, size_t size) +{ + uint32_t value = ~0u; + for (size_t i = 0; i < size; ++i) + { + value ^= input[i]; + for (unsigned bit = 0; bit < 8; ++bit) value = (value >> 1) ^ (0xedb88320u & (0u - (value & 1))); + } + return ~value; +} +static void chunk(Bytes& output, const char* type, const Bytes& data) +{ + big(output, uint32_t(data.size())); + const auto start = output.size(); + output.insert(output.end(), type, type + 4); + output.insert(output.end(), data.begin(), data.end()); + big(output, crc(output.data() + start, output.size() - start)); +} +static Bytes zlib(const Bytes& raw) +{ + Bytes output{0x78, 0x01}; + uint32_t a = 1, b = 0; + for (const auto byte : raw) + { + a = (a + byte) % 65521; + b = (b + a) % 65521; + } + for (size_t at = 0; at < raw.size();) + { + const uint16_t count = uint16_t(std::min(65535, raw.size() - at)); + output.push_back(at + count == raw.size() ? 1 : 0); + output.push_back(uint8_t(count)); + output.push_back(uint8_t(count >> 8)); + output.push_back(uint8_t(~count)); + output.push_back(uint8_t((~count) >> 8)); + output.insert(output.end(), raw.begin() + at, raw.begin() + at + count); + at += count; + } + big(output, (b << 16) | a); + return output; +} +static int paeth(int a, int b, int c) +{ + const auto p = a + b - c; + const int x = std::abs(p - a), y = std::abs(p - b), z = std::abs(p - c); + return x <= y && x <= z ? a : y <= z ? b + : c; +} +struct Fixture +{ + unsigned width, height; + Bytes png, rgba; +}; +// Independent PNG encoder: stored deflate, all five filters, packed samples, +// full-precision transparency, Adam7 passes and split/empty IDATs. Expected +// pixels are computed from sample values before encoding, not decoder output. +static Fixture fixture(unsigned w, unsigned h, unsigned color, unsigned depth, bool interlaced, bool transparency = false, unsigned raster_fault = 0) +{ + Fixture result{w, h, {137, 80, 78, 71, 13, 10, 26, 10}, {}}; + Bytes header; + big(header, w); + big(header, h); + header.insert(header.end(), {uint8_t(depth), uint8_t(color), 0, 0, uint8_t(interlaced)}); + chunk(result.png, "IHDR", header); + const unsigned channels = color == 0 || color == 3 ? 1 : color == 2 ? 3 + : color == 4 ? 2 + : 4; + const unsigned palettes = color == 3 ? std::min(16U, 1U << depth) : 0; + const auto sample = [&](unsigned x, unsigned y, unsigned c) + { + if (color == 3) return (x + y) % palettes; + if ((color == 4 && c == 1) || (color == 6 && c == 3)) return (x + y) % 3 == 0 ? 0U : (x + y) % 3 == 1 ? (depth == 16 ? 0x8080U : 128U) + : (depth == 16 ? 65535U : 255U); + return depth == 16 ? (c + 1) * 0x1000U + 0x34U + (x + y) % 2 : (x * 5 + y * 7 + c * 3) & ((1U << depth) - 1); + }; + Bytes palette, alpha; + for (unsigned i = 0; i < palettes; ++i) + { + palette.insert(palette.end(), {uint8_t(i * 37), uint8_t(i * 91), uint8_t(i * 13 + 77)}); + alpha.push_back(i % 3 == 0 ? 0 : i % 3 == 1 ? 128 + : 255); + } + if (palettes) chunk(result.png, "PLTE", palette); + if (transparency) + { + Bytes values; + if (palettes) values = alpha; + else + for (unsigned c = 0; c < (color == 0 ? 1U : 3U); ++c) + { + const auto v = sample(0, 0, c); + values.push_back(uint8_t(v >> 8)); + values.push_back(uint8_t(v)); + } + chunk(result.png, "tRNS", values); + } + for (unsigned y = 0; y < h; ++y) + for (unsigned x = 0; x < w; ++x) + { + unsigned r, g, b, a = 255; + if (palettes) + { + const auto i = sample(x, y, 0); + r = palette[i * 3]; + g = palette[i * 3 + 1]; + b = palette[i * 3 + 2]; + if (transparency) a = alpha[i]; + } + else + { + const auto gray = color == 0 || color == 4; + const auto value = [&](unsigned c) + { const auto v = sample(x,y,c); return depth == 16 ? v >> 8 : depth < 8 ? v * 255 / ((1U << depth)-1) : v; }; + r = value(0); + g = gray ? r : value(1); + b = gray ? r : value(2); + if (color == 4 || color == 6) a = value(channels - 1); + if (transparency) + { + bool match = true; + for (unsigned c = 0; c < (gray ? 1U : 3U); ++c) match &= sample(x, y, c) == sample(0, 0, c); + if (match) a = 0; + } + } + result.rgba.insert(result.rgba.end(), {uint8_t(r), uint8_t(g), uint8_t(b), uint8_t(a)}); + } + constexpr unsigned x0[] = {0, 4, 0, 2, 0, 1, 0, 0}, y0[] = {0, 0, 4, 0, 2, 0, 1, 0}; + constexpr unsigned dx[] = {8, 8, 4, 4, 2, 2, 1, 1}, dy[] = {8, 8, 8, 4, 4, 2, 2, 1}; + Bytes raster; + for (unsigned pass = interlaced ? 0 : 7; pass < 8; ++pass) + { + if (interlaced && pass == 7) break; + Bytes previous; + unsigned row_number = 0; + for (unsigned y = y0[pass]; y < h; y += dy[pass]) + { + Bytes row; + unsigned bits = 0; + for (unsigned x = x0[pass]; x < w; x += dx[pass]) + for (unsigned c = 0; c < channels; ++c) + { + const auto v = sample(x, y, c); + if (depth == 16) + { + row.push_back(uint8_t(v >> 8)); + row.push_back(uint8_t(v)); + } + else if (depth == 8) row.push_back(uint8_t(v)); + else + { + if (!(bits % 8)) row.push_back(0); + row.back() |= uint8_t(v << (8 - depth - bits % 8)); + bits += depth; + } + } + if (row.empty()) continue; + if (previous.empty()) previous.resize(row.size()); + const unsigned filter = (row_number++ + pass) % 5, bpp = std::max(1U, (channels * depth + 7) / 8); + raster.push_back(uint8_t(filter)); + for (size_t i = 0; i < row.size(); ++i) + { + const int left = i < bpp ? 0 : row[i - bpp], up = previous[i], upper_left = i < bpp ? 0 : previous[i - bpp]; + const int predicted = filter == 0 ? 0 : filter == 1 ? left + : filter == 2 ? up + : filter == 3 ? (left + up) / 2 + : paeth(left, up, upper_left); + raster.push_back(uint8_t(row[i] - predicted)); + } + previous = std::move(row); + } + } + if (raster_fault == 1) raster.push_back(0); + if (raster_fault == 2) raster[0] = 5; + if (raster_fault == 3) raster.pop_back(); + auto compressed = zlib(raster); + if (raster_fault == 4) compressed.back() ^= 1; + if (raster_fault == 5) compressed.push_back(0); + chunk(result.png, "IDAT", {}); + for (size_t at = 0; at < compressed.size();) + { + const auto n = std::min(137, compressed.size() - at); + chunk(result.png, "IDAT", {compressed.begin() + at, compressed.begin() + at + n}); + at += n; + } + chunk(result.png, "IDAT", {}); + chunk(result.png, "IEND", {}); + return result; +} +static uint32_t little(const uint8_t* p) { return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); } +static bool generate(const Bytes& input, unsigned w, unsigned h) +{ + image_fake::files.clear(); + image_fake::files["/source.jpg"] = input; // file suffix does not determine format + return platform::ui::route_storage::generate_route_image_bmp_cache("/source.jpg", "/cache.bmp", uint16_t(w), uint16_t(h)); +} +static void check(const Fixture& input, unsigned w, unsigned h) +{ + assert(generate(input.png, w, h)); + const auto& bmp = image_fake::files.at("/cache.bmp"); + assert(bmp[0] == 'B' && bmp[1] == 'M' && little(bmp.data() + 18) == w && little(bmp.data() + 22) == h); + const auto stride = (w * 3 + 3) & ~3U; + assert(bmp.size() == 54 + stride * h && !image_fake::files.count("/cache.bmp.tmp")); + for (unsigned y = 0; y < h; ++y) + for (unsigned x = 0; x < w; ++x) + { + unsigned sx, sy; + if (input.width * h > input.height * w) + { + sx = (uint64_t(input.width) * h - uint64_t(input.height) * w + uint64_t(2 * x + 1) * input.height) / (2 * h); + sy = uint64_t(2 * y + 1) * input.height / (2 * h); + } + else + { + sx = uint64_t(2 * x + 1) * input.width / (2 * w); + sy = (uint64_t(input.height) * w - uint64_t(input.width) * h + uint64_t(2 * y + 1) * input.width) / (2 * w); + } + const auto* rgba = input.rgba.data() + (size_t(sy) * input.width + sx) * 4; + const auto* pixel = bmp.data() + 54 + size_t(h - y - 1) * stride + x * 3; + for (unsigned c = 0; c < 3; ++c) + { + const auto expected = (unsigned(rgba[2 - c]) * rgba[3] + 255 * (255 - rgba[3]) + 127) / 255; + if (pixel[c] != expected) std::fprintf(stderr, "PNG mismatch type%u depth%u interlace%u src%ux%u dst%ux%u x%u y%u source%u,%u c%u actual%u expected%u\n", input.png[25], input.png[24], input.png[28], input.width, input.height, w, h, x, y, sx, sy, c, pixel[c], expected); + assert(pixel[c] == expected); + } + } + assert(!image_fake::live && !image_fake::internal_allocations); +} +int main() +{ + image_fake::psram = true; + for (unsigned color : {0U, 2U, 3U, 4U, 6U}) + for (unsigned depth : {1U, 2U, 4U, 8U, 16U}) + { + if ((color == 2 || color == 4 || color == 6) && depth < 8) continue; + if (color == 3 && depth == 16) continue; + for (bool interlace : {false, true}) + for (bool transparent : {false, true}) + { + if (transparent && (color == 4 || color == 6)) continue; + const auto image = fixture(17, 11, color, depth, interlace, transparent); + check(image, 17, 11); + check(image, 13, 7); + check(image, 7, 13); + } + } + for (unsigned w : {1U, 2U, 3U, 7U}) + for (unsigned h : {1U, 2U, 3U, 7U}) check(fixture(w, h, 6, 8, true), w, h); + check(fixture(96, 96, 6, 8, true), 200, 120); // dictionary wrap + upscale/cover + check(fixture(8192, 1, 6, 16, false), 7, 1); // maximum row allocation + stored-block split + for (const char* name : {"rgba-96x96.png", "fixed-96x96.png"}) + { + std::ifstream file(std::string(TRAIL_MATE_PNG_FIXTURE_DIR) + "/" + name, std::ios::binary); + assert(file); + Fixture encoded{96, 96, {std::istreambuf_iterator(file), std::istreambuf_iterator()}, {}}; + for (unsigned y = 0; y < 96; ++y) + for (unsigned x = 0; x < 96; ++x) + encoded.rgba.insert(encoded.rgba.end(), {uint8_t(x * 5), uint8_t(y * 7), uint8_t((x + y) * 13), uint8_t((x + y) % 3 == 0 ? 0 : (x + y) % 3 == 1 ? 128 + : 255)}); + check(encoded, 96, 96); + check(encoded, 200, 120); + } + // Fixed length/distance pair before any output: no undefined dictionary + // history may be read, even if the output ring itself is initialized. + Bytes deflate{0x78, 0x01}; + unsigned bit = 0; + const auto bits = [&](unsigned value, unsigned count) + { + for (unsigned i = 0; i < count; ++i) + { + if (!(bit % 8)) deflate.push_back(0); + deflate.back() |= uint8_t(((value >> i) & 1) << (bit % 8)); + ++bit; + } + }; + bits(3, 3); + bits(64, 7); + bits(0, 5); + bits(0, 7); + big(deflate, 0x00030001); + auto inflater = std::make_unique(); + lgfx_tinfl_init(inflater.get()); + Bytes history(32768, 0xad); + size_t input_bytes = deflate.size(), output_bytes = history.size(); + assert(trail_mate_tinfl_decompress(inflater.get(), deflate.data(), &input_bytes, history.data(), history.data(), &output_bytes, TINFL_FLAG_HAS_MORE_INPUT | TINFL_FLAG_PARSE_ZLIB_HEADER, 0) < TINFL_STATUS_DONE); + assert(!output_bytes && std::all_of(history.begin(), history.end(), [](uint8_t b) + { return b == 0xad; })); + const auto good = fixture(17, 11, 3, 8, true, true); + const auto allocation_start = image_fake::allocations; + const auto yield_start = yields; + check(good, 17, 11); + const auto allocation_count = image_fake::allocations - allocation_start; + const auto full_yields = yields - yield_start; + for (unsigned fail = 1; fail <= allocation_count; ++fail) + { + image_fake::fail_at = image_fake::allocations + fail; + assert(!generate(good.png, 17, 11) && !image_fake::files.count("/cache.bmp") && !image_fake::files.count("/cache.bmp.tmp") && !image_fake::live); + image_fake::fail_at = 0; + } + for (unsigned cut = 1; cut <= 15; ++cut) + { + auto broken = good.png; + broken.resize(broken.size() - cut); + assert(!generate(broken, 17, 11) && !image_fake::files.count("/cache.bmp")); + } + auto bad_crc = good.png; + bad_crc[29] ^= 1; + assert(!generate(bad_crc, 17, 11)); + auto trailing = good.png; + trailing.push_back(0); + assert(!generate(trailing, 17, 11)); + for (unsigned fault = 1; fault <= 5; ++fault) + { + const bool accepted = generate(fixture(17, 11, 6, 8, false, false, fault).png, 17, 11); + if (accepted) std::fprintf(stderr, "Malformed raster accepted fault%u\n", fault); + assert(!accepted); + } + Bytes injected; + chunk(injected, "IHDR", {good.png.begin() + 16, good.png.begin() + 29}); + auto duplicate = good.png; + duplicate.insert(duplicate.begin() + 33, injected.begin(), injected.end()); + assert(!generate(duplicate, 17, 11)); + injected.clear(); + chunk(injected, "ABCD", {}); + auto unknown = good.png; + unknown.insert(unknown.begin() + 33, injected.begin(), injected.end()); + assert(!generate(unknown, 17, 11)); + // Valid CRC but palette index beyond the declared palette must fail. + auto palette_bad = fixture(17, 11, 3, 8, false).png; + const auto palette_size = 16 * 3; + injected.clear(); + chunk(injected, "PLTE", {palette_bad.begin() + 41, palette_bad.begin() + 44}); + palette_bad.erase(palette_bad.begin() + 33, palette_bad.begin() + 33 + 12 + palette_size); + palette_bad.insert(palette_bad.begin() + 33, injected.begin(), injected.end()); + assert(!generate(palette_bad, 17, 11)); + // Illegal IDAT ordering; ancillary chunks may follow but never split IDAT. + auto split = good.png; + const size_t first_data_end = 33 + 12 + 48 + 12 + 16 + 12 + 12 + 137; + injected.clear(); + chunk(injected, "tEXt", {}); + split.insert(split.begin() + first_data_end, injected.begin(), injected.end()); + assert(!generate(split, 17, 11)); + auto oversized = good.png; + oversized.resize(8U * 1024U * 1024U + 1); + assert(!generate(oversized, 17, 11)); + auto huge = good.png; + huge[16] = 0; + huge[17] = 0; + huge[18] = 0x40; + huge[19] = 0; + const auto header_crc = crc(huge.data() + 12, 17); + for (unsigned i = 0; i < 4; ++i) huge[29 + i] = uint8_t(header_crc >> (24 - i * 8)); + assert(!generate(huge, 17, 11)); + Bytes too_many(good.png.begin(), good.png.begin() + 33); + for (unsigned i = 0; i < 65536; ++i) chunk(too_many, "tEXt", {}); + too_many.insert(too_many.end(), good.png.begin() + 33, good.png.end()); + assert(!generate(too_many, 17, 11)); + for (unsigned failure = 0; failure < 3; ++failure) + { + image_fake::write_fail = failure == 0; + image_fake::flush_fail = failure == 1; + image_fake::rename_fail = failure == 2; + assert(!generate(good.png, 17, 11) && !image_fake::files.count("/cache.bmp") && !image_fake::files.count("/cache.bmp.tmp") && !image_fake::live); + image_fake::write_fail = image_fake::flush_fail = image_fake::rename_fail = false; + } + const auto before = image_fake::allocations; + image_fake::psram = false; + assert(!generate(good.png, 17, 11) && image_fake::allocations == before); + image_fake::psram = true; + change_at = yields + 3; + assert(!generate(good.png, 17, 11) && !image_fake::files.count("/cache.bmp") && !image_fake::live); + change_at = 0; + image_fake::media = 1; + change_at = yields + full_yields - 1; + assert(!generate(good.png, 17, 11) && !image_fake::files.count("/cache.bmp") && !image_fake::live); + change_at = 0; + image_fake::media = 1; + std::ifstream jpeg(TRAIL_MATE_JPEG_FIXTURE, std::ios::binary); + assert(jpeg); + const Bytes photo{std::istreambuf_iterator(jpeg), std::istreambuf_iterator()}; + assert(generate(photo, 16, 8)); + const auto& bmp = image_fake::files.at("/cache.bmp"); + for (unsigned i = 54; i < bmp.size(); i += 3) + { + if (!(bmp[i] < 30 && bmp[i + 1] < 30 && bmp[i + 2] > 220)) std::fprintf(stderr, "JPEG bgr %u %u %u at %u\n", bmp[i], bmp[i + 1], bmp[i + 2], i); + assert(bmp[i] < 30 && bmp[i + 1] < 30 && bmp[i + 2] > 220); + } + assert(!image_fake::live && !image_fake::internal_allocations && yields); + std::puts("PNG true pixels/CRC/order/filters/depths/Adam7/transparency/crop/OOM/media and real JPEG cache passed"); +} diff --git a/tests/hostlink/test_route_segment_render.cpp b/tests/hostlink/test_route_segment_render.cpp new file mode 100644 index 00000000..71fb238d --- /dev/null +++ b/tests/hostlink/test_route_segment_render.cpp @@ -0,0 +1,69 @@ +#include "ui/support/disconnected_route_line.h" +#include "ui/support/route_segment_sampling.h" +#include "ui/widgets/route_elevation_profile.h" +#include +#include +#include +#include +using namespace ui::widgets::route_elevation_profile; +int main() +{ + image_fake::psram = true; + auto* parent = lv_obj_create(nullptr); + Widget widget; + create(parent, widget); + assert(widget.line->callback && draw_fake::objects.size() == 4); + Config config; + const Sample samples[] = {{0, 0, true}, {10, 1, true}, {0, 2, false}, {20, 3, true}, {30, 4, true}}; + const Metrics metrics{4, 0, 30, 20, 0, 4}; + assert(update(widget, config, samples, 5, metrics, true, 0)); + assert(widget.points.size() == 5 && image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + draw_fake::draw(widget.line); + assert(draw_fake::lines.size() == 2 && draw_fake::builtin_points == 0); + assert(draw_fake::lines[0].p2.x < draw_fake::lines[1].p1.x); + const Sample segmented[] = {{0, 0, true}, {1, 1, true}, {100, 1, true, true}, {101, 2, true}}; + assert(update(widget, config, segmented, 4, {4, 0, 101, 2, 0, 2}, true, 0)); + draw_fake::draw(widget.line); + assert(draw_fake::lines.size() == 2); // no vertical line between two segments + std::vector long_route(100000); + for (size_t i = 0; i < long_route.size(); ++i) long_route[i] = {double(i), double(i), true, i == 50000}; + config.max_points = 2; + assert(update(widget, config, long_route.data(), long_route.size(), {long_route.size(), 0, 99999, 99998, 0, 99999}, true, 0)); + draw_fake::draw(widget.line); + assert(draw_fake::lines.empty()); // a skipped segment boundary still breaks + long_route[50000].break_before = false; + long_route[50000].has_altitude = false; + assert(update(widget, config, long_route.data(), long_route.size(), {99999, 0, 99999, 99998, 0, 99999}, true, 0)); + draw_fake::draw(widget.line); + assert(draw_fake::lines.empty()); // skipped missing height never interpolates + config.max_points = 96; + for (size_t i = 0; i < long_route.size(); ++i) long_route[i].break_before = i % 4 == 0; + assert(update(widget, config, long_route.data(), long_route.size(), {99999, 0, 99999, 99998, 0, 99999}, true, 0)); + assert(widget.points.size() <= 191); + draw_fake::draw(widget.line); + assert(draw_fake::lines.empty() && draw_fake::objects.size() == 4); + image_fake::allocation_fail = true; + assert(!update(widget, config, samples, 5, metrics, true, 0)); + assert(widget.points.empty() && image_fake::live == 0 && image_fake::last_caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + assert(widget.panel->flags & LV_OBJ_FLAG_HIDDEN); + image_fake::allocation_fail = false; + image_fake::psram = false; + assert(update(widget, config, samples, 5, metrics, true, 0)); + assert(image_fake::last_caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + reset(widget); + assert(image_fake::live == 0); + auto* line_object = lv_line_create(parent); + line_object->x = 10; + line_object->y = 20; + line_object->scroll_x = 2; + line_object->scroll_y = 3; + const lv_point_precise_t points[] = {{1, 2}, {3, 4}, ui::support::routeLineBreak(), {5, 6}, {7, 8}}; + lv_event_t event{line_object, &draw_fake::layer, nullptr}; + draw_fake::lines.clear(); + ui::support::drawDisconnectedRouteLine(&event, points, 5); + assert(draw_fake::lines.size() == 2); + assert(draw_fake::lines[0].p1.x == 9 && draw_fake::lines[0].p1.y == 19); + assert(draw_fake::lines[1].p1.x == 13 && draw_fake::lines[1].p1.y == 23); + assert(draw_fake::lines[0].p2.x == 11 && draw_fake::lines[1].p2.x == 15); + assert(draw_fake::builtin_points == 0); +} diff --git a/tests/hostlink/test_route_storage_lease.cpp b/tests/hostlink/test_route_storage_lease.cpp new file mode 100644 index 00000000..3207afed --- /dev/null +++ b/tests/hostlink/test_route_storage_lease.cpp @@ -0,0 +1,21 @@ +#include "hostlink/route_storage_lease.h" +#include +int main() +{ + namespace lease = hostlink::route_storage_lease; + static_assert(sizeof(lease::held) <= 4, "Route lease must remain a tiny flag"); + assert(lease::acquire()); + { + lease::Guard ui; + assert(!ui.acquired()); + } + assert(!lease::acquire()); // failed UI guard must not release task ownership + lease::release(); + { + lease::Guard ui; + assert(ui.acquired()); + assert(!lease::acquire()); + } + assert(lease::acquire()); + lease::release(); +} diff --git a/tests/hostlink/test_sd_snapshot_mutations.cpp b/tests/hostlink/test_sd_snapshot_mutations.cpp new file mode 100644 index 00000000..ff20ecb6 --- /dev/null +++ b/tests/hostlink/test_sd_snapshot_mutations.cpp @@ -0,0 +1,92 @@ +#include +#include +#include +namespace sys::runtime +{ +enum class BusAccessPolicy +{ + DurableCommit +}; +} +namespace platform::esp::arduino_common::storage +{ +static uint32_t media = 1; +static bool s_external_block_owner_active = false, replace_on_lock = false, owner_on_lock = false, lock_available = true; +static unsigned removes = 0, renames = 0; +constexpr unsigned kSdDurableLockWaitMs = 1; +enum class SdCardBackend +{ + SdFat +}; +struct Info +{ + SdCardBackend backend = SdCardBackend::SdFat; +} s_info; +struct Volume +{ + bool remove(const char*) + { + ++removes; + return true; + } + bool rename(const char*, const char*) + { + ++renames; + return true; + } +} s_volume, s_sdfat; +uint32_t sd_media_session() { return media; } +const char* normalize_sd_path(const char* path) { return path; } +uint32_t sd_io_begin(const char*, const char*) { return 0; } +void sd_io_end(const char*, const char*, uint32_t, bool, size_t = 0, int = 0) {} +bool sd_mutation_blocked_by_external_owner(const char*, const char*, uint32_t) { return s_external_block_owner_active; } +class SdRuntimeBusGuard +{ + public: + explicit SdRuntimeBusGuard(const char*, sys::runtime::BusAccessPolicy = sys::runtime::BusAccessPolicy::DurableCommit, unsigned = 0) + { + if (replace_on_lock) + { + ++media; + replace_on_lock = false; + } + if (owner_on_lock) + { + s_external_block_owner_active = true; + owner_on_lock = false; + } + } + bool locked() const { return lock_available; } +}; +using SdRuntimeOperationGuard = SdRuntimeBusGuard; +bool sd_remove(const char*, uint32_t); +bool sd_rename(const char*, const char*, uint32_t); +#if defined(TRAIL_MATE_SD_MUTATION_ARDUINO) +#include "sd_arduino_remove_actual.inc" +#else +#include "sd_idf_mutations_actual.inc" +#endif +} // namespace platform::esp::arduino_common::storage +int main() +{ + using namespace platform::esp::arduino_common::storage; + assert(sd_remove("pending", media) && removes == 1); + assert(!sd_remove("pending", media - 1) && removes == 1); + replace_on_lock = true; + assert(!sd_remove("pending") && removes == 1); + owner_on_lock = true; + assert(!sd_remove("pending", media) && removes == 1); + s_external_block_owner_active = false; + lock_available = false; + assert(!sd_remove("pending", media) && removes == 1); + lock_available = true; +#if !defined(TRAIL_MATE_SD_MUTATION_ARDUINO) + assert(sd_rename("pending", "snapshot", media) && renames == 1); + assert(!sd_rename("pending", "snapshot", media - 1) && renames == 1); + replace_on_lock = true; + assert(!sd_rename("pending", "snapshot") && renames == 1); + owner_on_lock = true; + assert(!sd_rename("pending", "snapshot", media) && renames == 1); +#endif + return 0; +} diff --git a/tests/hostlink/test_settings_blob_read.cpp b/tests/hostlink/test_settings_blob_read.cpp new file mode 100644 index 00000000..9b4800e7 --- /dev/null +++ b/tests/hostlink/test_settings_blob_read.cpp @@ -0,0 +1,85 @@ +#include "platform/ui/settings_store.h" + +#include +#include +#include + +using nvs_handle_t = unsigned; +constexpr int ESP_OK = 0, ESP_ERR_NVS_NOT_FOUND = 1, ESP_ERR_NVS_TYPE_MISMATCH = 2, + ESP_ERR_NVS_INVALID_LENGTH = 3, NVS_READONLY = 1; +int open_result = 0, measure_result = 0, read_result = 0; +unsigned closed = 0, reads = 0; +bool wrong_length = false; +std::vector stored = {1, 0, 3}; +int nvs_open(const char*, int, nvs_handle_t* handle) +{ + *handle = 7; + return open_result; +} +void nvs_close(nvs_handle_t handle) +{ + assert(handle == 7); + ++closed; +} +int nvs_get_blob(nvs_handle_t, const char*, void* out, std::size_t* size) +{ + if (!out) + { + *size = stored.size(); + return measure_result; + } + ++reads; + assert(*size >= stored.size()); + std::memcpy(out, stored.data(), stored.size()); + *size = stored.size() + (wrong_length ? 1 : 0); + return read_result; +} +namespace platform::esp::common::settings_store_detail +{ +const char* resolve_storage_key(const char* key) { return key; } +bool validate_storage_key(const char*, const char*, const char*, const char*) { return true; } +} // namespace platform::esp::common::settings_store_detail +namespace platform::ui::settings_store +{ +#include "settings_blob_read_actual.inc" +} +int main() +{ + using namespace platform::ui::settings_store; + uint8_t out[4]{}; + std::size_t size = 999; + const auto call = [&] + { return read_blob_into("settings", "timezone_tzdef", out, sizeof(out), &size); }; + assert(call() == BlobReadResult::Ok && size == 3 && out[1] == 0 && closed == 1); + open_result = ESP_ERR_NVS_NOT_FOUND; + assert(call() == BlobReadResult::Missing && size == 0 && closed == 1); + open_result = 9; + assert(call() == BlobReadResult::Unavailable && closed == 1); + open_result = 0; + measure_result = ESP_ERR_NVS_NOT_FOUND; + assert(call() == BlobReadResult::Missing && size == 0 && closed == 2); + measure_result = ESP_ERR_NVS_TYPE_MISMATCH; + assert(call() == BlobReadResult::Invalid && closed == 3); + measure_result = 9; + assert(call() == BlobReadResult::Unavailable && closed == 4); + measure_result = 0; + stored.assign(5, 1); + const auto before = reads; + assert(call() == BlobReadResult::Invalid && reads == before && size == 0 && closed == 5); + stored = {1, 0, 3}; + for (const int failure : {9, ESP_ERR_NVS_INVALID_LENGTH, ESP_ERR_NVS_TYPE_MISMATCH}) + { + read_result = failure; + assert(call() == (failure == 9 ? BlobReadResult::Unavailable : BlobReadResult::Invalid)); + assert(size == 0 && out[0] == 0 && out[1] == 0 && out[2] == 0); + } + read_result = 0; + wrong_length = true; + assert(call() == BlobReadResult::Invalid && size == 0 && out[0] == 0); + wrong_length = false; + stored.clear(); + assert(call() == BlobReadResult::Ok && size == 0); + assert(read_blob_into("settings", "key", nullptr, 0, &size) == BlobReadResult::Ok); + assert(read_blob_into(nullptr, "key", out, sizeof(out), &size) == BlobReadResult::Invalid); + assert(read_blob_into("settings", "key", nullptr, 1, &size) == BlobReadResult::Invalid); +} diff --git a/tests/hostlink/test_settings_projection.cpp b/tests/hostlink/test_settings_projection.cpp new file mode 100644 index 00000000..e6993023 --- /dev/null +++ b/tests/hostlink/test_settings_projection.cpp @@ -0,0 +1,101 @@ +#include "app/tms_config_codec.h" +#include "platform/ui/settings_store.h" + +#include +#include +#include + +namespace platform::ui::settings_store +{ +StringReadResult text_result = StringReadResult::Ok; +BlobReadResult blob_result = BlobReadResult::Ok; +std::string text = "pinyin,latin"; +uint8_t blob[] = {1, 0, 3}; +StringReadResult read_string_into(const char*, const char*, char* out, + std::size_t capacity, std::size_t* length) +{ + *length = 0; + out[0] = 0; + if (text_result != StringReadResult::Ok) return text_result; + if (text.size() >= capacity) return StringReadResult::Invalid; + std::memcpy(out, text.c_str(), text.size() + 1); + *length = text.size(); + return StringReadResult::Ok; +} +bool get_string_into(const char* ns, const char* key, char* out, + std::size_t capacity, std::size_t* length) +{ + return read_string_into(ns, key, out, capacity, length) == StringReadResult::Ok; +} +BlobReadResult read_blob_into(const char*, const char*, void* out, + std::size_t capacity, std::size_t* length) +{ + *length = 0; + if (blob_result != BlobReadResult::Ok) return blob_result; + assert(capacity >= sizeof(blob)); + std::memcpy(out, blob, sizeof(blob)); + *length = sizeof(blob); + return BlobReadResult::Ok; +} +bool get_blob_into(const char* ns, const char* key, void* out, + std::size_t capacity, std::size_t* length) +{ + return read_blob_into(ns, key, out, capacity, length) == BlobReadResult::Ok; +} +} // namespace platform::ui::settings_store + +namespace +{ +namespace tms = app::tms; +struct State +{ + uint8_t timezone_tzdef[192]; +} state; +State* s_state_storage = &state; +#define s_state (*s_state_storage) +#include "settings_projection_actual.inc" +#undef s_state +bool append(void* context, const char* bytes, std::size_t size) +{ + static_cast(context)->append(bytes, size); + return true; +} +} // namespace +int main() +{ + using namespace platform::ui::settings_store; + std::string output; + app::tms::LineScratch scratch{}; + app::tms::RecordWriter writer({&output, append}, scratch, nullptr); + char text_scratch[48]; + for (auto result : {StringReadResult::Ok, StringReadResult::Missing, + StringReadResult::Invalid, StringReadResult::Unavailable}) + { + text_result = result; + output.clear(); + const bool accepted = result == StringReadResult::Ok || result == StringReadResult::Missing; + assert(write_text_setting(writer, "ui.locale", "settings", "display_locale", + text_scratch, sizeof(text_scratch)) == accepted); + assert(!accepted ? output.empty() : output == (result == StringReadResult::Ok ? "ui.locale=str:pinyin%2Clatin\n" : "ui.locale=str:\n")); + output.clear(); + assert(write_string_as_blob(writer, "ui.ime.enabled", "settings", "enabled_imes", 191) == accepted); + assert(!accepted ? output.empty() : output == (result == StringReadResult::Ok ? "ui.ime.enabled=b64:cGlueWluLGxhdGlu\n" : "ui.ime.enabled=b64:\n")); + } + text_result = StringReadResult::Ok; + text.assign(48, 'x'); + output.clear(); + assert(!write_text_setting(writer, "ui.locale", "settings", "display_locale", + text_scratch, sizeof(text_scratch)) && + output.empty()); + text.assign(192, 'x'); + assert(!write_string_as_blob(writer, "ui.ime.enabled", "settings", "enabled_imes", 191) && output.empty()); + for (auto result : {BlobReadResult::Ok, BlobReadResult::Missing, + BlobReadResult::Invalid, BlobReadResult::Unavailable}) + { + blob_result = result; + output.clear(); + const bool accepted = result == BlobReadResult::Ok || result == BlobReadResult::Missing; + assert(write_blob_setting(writer, "ui.timezone.tzdef", "settings", "timezone_tzdef") == accepted); + assert(!accepted ? output.empty() : output == (result == BlobReadResult::Ok ? "ui.timezone.tzdef=b64:AQAD\n" : "ui.timezone.tzdef=b64:\n")); + } +} diff --git a/tests/hostlink/test_settings_scalar_projection.cpp b/tests/hostlink/test_settings_scalar_projection.cpp new file mode 100644 index 00000000..1cc92e14 --- /dev/null +++ b/tests/hostlink/test_settings_scalar_projection.cpp @@ -0,0 +1,177 @@ +#include "app/tms_config_codec.h" +#include "platform/ui/auto_reply_settings.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/timezone_profile.h" +#include "platform/ui/wifi_runtime.h" +#include +#include +#include +#include + +namespace platform::ui::settings_store +{ +std::map values = { + {"screen_timeout", 300000}, {"screen_brightness", 15}, {"speaker_volume", 0}, {"vibration_enabled", 0}, {"timezone_profile", 11}, {"timezone_offset", 60}, {"chat_message_alerts", 0}, {"chat_contact_alerts", 2}, {"chat_auto_reply_enabled", 0}, {"adv_debug", 0}, {"gauge_design_mah", 1500}, {"gauge_full_mah", 1400}}; +std::vector reads; +std::string failed_key; +ScalarReadResult failure = ScalarReadResult::Ok; +ScalarReadResult fetch(const char* key, int64_t& value) +{ + reads.emplace_back(key); + value = 0; + if (failed_key == key) return failure; + const auto found = values.find(key); + if (found == values.end()) return ScalarReadResult::Missing; + value = found->second; + return ScalarReadResult::Ok; +} +ScalarReadResult read_int_into(const char*, const char* key, int32_t* out) +{ + int64_t value; + auto result = fetch(key, value); + *out = static_cast(value); + return result; +} +ScalarReadResult read_uint_into(const char*, const char* key, uint32_t* out) +{ + int64_t value; + auto result = fetch(key, value); + *out = static_cast(value); + return result; +} +ScalarReadResult read_bool_into(const char*, const char* key, bool* out) +{ + int64_t value; + auto result = fetch(key, value); + *out = value != 0; + return result; +} +int get_int(const char* ns, const char* key, int fallback) +{ + int32_t value; + return read_int_into(ns, key, &value) == ScalarReadResult::Ok ? value : fallback; +} +uint32_t get_uint(const char* ns, const char* key, uint32_t fallback) +{ + uint32_t value; + return read_uint_into(ns, key, &value) == ScalarReadResult::Ok ? value : fallback; +} +bool get_bool(const char* ns, const char* key, bool fallback) +{ + bool value; + return read_bool_into(ns, key, &value) == ScalarReadResult::Ok ? value : fallback; +} +} // namespace platform::ui::settings_store +namespace platform::ui::screen +{ +uint32_t timeout_ms() { return 30000; } +} // namespace platform::ui::screen +namespace platform::ui::device +{ +uint8_t screen_brightness() { return 8; } +} // namespace platform::ui::device +namespace platform::ui::time +{ +std::time_t fixture_epoch = 1782907200; // 2026-07-01T12:00:00Z +int timezone_profile_id() { return 11; } +int timezone_offset_min() { return timezone_offset_for_profile_id_at(11, 60, fixture_epoch); } +} // namespace platform::ui::time +namespace platform::ui::wifi +{ +bool snapshot_available = true; +bool read_saved_profiles_into(bool* enabled, std::size_t* count, Config*, std::size_t) +{ + *enabled = false; + *count = 0; + return snapshot_available; +} +} // namespace platform::ui::wifi +namespace +{ +namespace tms = app::tms; +using app::AppConfig; +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kPowerNs = "power"; +constexpr std::size_t kLocaleBytes = 48, kEnabledImesBytes = 192, kAutoReplyBytes = 171; +struct State +{ + uint8_t timezone_tzdef[192]{}; + platform::ui::wifi::Config wifi_profiles[10]{}; +} s_state; +class ScopedWriteState +{ + public: + bool ready() const { return true; } +}; +unsigned later_owners = 0; +bool write_text_setting(tms::RecordWriter&, const char*, const char*, const char*, char*, std::size_t) { return true; } +bool write_string_as_blob(tms::RecordWriter&, const char*, const char*, const char*, std::size_t) { return true; } +bool write_blob_setting(tms::RecordWriter&, const char*, const char*, const char*) { return true; } +bool write_network_records(tms::RecordWriter&, const AppConfig&) +{ + ++later_owners; + return true; +} +namespace ble_extension +{ +bool writeRecords(tms::RecordWriter&) +{ + ++later_owners; + return true; +} +} // namespace ble_extension +#include "settings_scalar_projection_actual.inc" + +#include "settings_records_actual.inc" +bool append(void* context, const char* bytes, std::size_t size) +{ + static_cast(context)->append(bytes, size); + return true; +} +} // namespace +int main() +{ + namespace store = platform::ui::settings_store; + app::AppConfig config; + std::string text; + tms::LineScratch scratch{}; + tms::RecordWriter writer({&text, append}, scratch, nullptr); + assert(platform::ui::time::timezone_offset_min() == 120); + assert(writeRecords(&config, writer)); + assert(text.find("ui.timezone.offset_minutes=i32:60\n") != std::string::npos); + assert(text.find("ui.screen.timeout_ms=u32:300000\n") != std::string::npos); + assert(text.find("ui.audio.speaker_volume=i32:0\n") != std::string::npos); + assert(text.find("ui.haptics.vibration_enabled=bool:0\n") != std::string::npos); + assert(store::reads.size() == 12 && later_owners == 2); + const auto keys = store::reads; + for (const auto& key : keys) + { + for (auto failure : {store::ScalarReadResult::Missing, store::ScalarReadResult::Invalid, + store::ScalarReadResult::Unavailable}) + { + store::failed_key = key; + store::failure = failure; + store::reads.clear(); + later_owners = 0; + text.clear(); + assert(writeRecords(&config, writer) == (failure == store::ScalarReadResult::Missing)); + assert(later_owners == (failure == store::ScalarReadResult::Missing ? 2 : 0)); + if (failure != store::ScalarReadResult::Missing) assert(store::reads.back() == key); + } + } + store::failed_key.clear(); + store::failure = store::ScalarReadResult::Ok; + text.clear(); + platform::ui::time::fixture_epoch = 1767268800; // 2026-01-01T12:00:00Z + assert(platform::ui::time::timezone_offset_min() == 60); + assert(writeRecords(&config, writer)); + // Date-derived runtime offsets can change while persisted settings remain + // unchanged. Repeated snapshots must retain the saved fixed-offset input. + assert(text.find("ui.timezone.offset_minutes=i32:60\n") != std::string::npos); + text.clear(); + later_owners = 0; + platform::ui::wifi::snapshot_available = false; + assert(!writeRecords(&config, writer)); + assert(later_owners == 1); // Network emitted; BLE must not run after failure. + assert(text.find("wifi.profile_count=") == std::string::npos); +} diff --git a/tests/hostlink/test_settings_scalar_read.cpp b/tests/hostlink/test_settings_scalar_read.cpp new file mode 100644 index 00000000..a5f55055 --- /dev/null +++ b/tests/hostlink/test_settings_scalar_read.cpp @@ -0,0 +1,106 @@ +#include "platform/ui/settings_store.h" +#include +#include + +using nvs_handle_t = unsigned; +constexpr int ESP_OK = 0, ESP_ERR_NVS_NOT_FOUND = 1, ESP_ERR_NVS_TYPE_MISMATCH = 2, NVS_READONLY = 1; +int open_result = 0, read_result = 0; +unsigned closed = 0, reads = 0; +int32_t signed_value = -420; +uint32_t unsigned_value = UINT32_MAX; +uint8_t bool_value = 1; +bool valid_key = true; +int nvs_open(const char*, int, nvs_handle_t* handle) +{ + *handle = 7; + return open_result; +} +void nvs_close(nvs_handle_t handle) +{ + assert(handle == 7); + ++closed; +} +int nvs_get_i32(nvs_handle_t, const char*, int32_t* out) +{ + ++reads; + *out = signed_value; + return read_result; +} +int nvs_get_u32(nvs_handle_t, const char*, uint32_t* out) +{ + ++reads; + *out = unsigned_value; + return read_result; +} +int nvs_get_u8(nvs_handle_t, const char*, uint8_t* out) +{ + ++reads; + *out = bool_value; + return read_result; +} +namespace platform::esp::common::settings_store_detail +{ +const char* resolve_storage_key(const char* key) { return key; } +bool validate_storage_key(const char*, const char*, const char*, const char*) { return valid_key; } +} // namespace platform::esp::common::settings_store_detail +namespace platform::ui::settings_store +{ +#include "settings_scalar_read_actual.inc" +} +int main() +{ + using namespace platform::ui::settings_store; + int32_t integer = 0; + uint32_t number = 0; + bool flag = false; + for (int failure : {ESP_OK, ESP_ERR_NVS_NOT_FOUND, ESP_ERR_NVS_TYPE_MISMATCH, 9}) + { + read_result = failure; + const auto expected = failure == ESP_OK ? ScalarReadResult::Ok : failure == ESP_ERR_NVS_NOT_FOUND ? ScalarReadResult::Missing + : failure == ESP_ERR_NVS_TYPE_MISMATCH ? ScalarReadResult::Invalid + : ScalarReadResult::Unavailable; + const auto before = closed; + assert(read_int_into("settings", "timezone_offset", &integer) == expected); + assert(read_uint_into("power", "gauge_full_mah", &number) == expected); + assert(read_bool_into("settings", "adv_debug", &flag) == expected); + assert(closed == before + 3); + assert(integer == (failure == ESP_OK ? -420 : 0)); + assert(number == (failure == ESP_OK ? UINT32_MAX : 0)); + assert(flag == (failure == ESP_OK)); + } + read_result = 0; + for (int failure : {ESP_ERR_NVS_NOT_FOUND, 9}) + { + open_result = failure; + const auto before = reads, close_before = closed; + assert(read_uint_into("power", "gauge_full_mah", &number) == + (failure == ESP_ERR_NVS_NOT_FOUND ? ScalarReadResult::Missing : ScalarReadResult::Unavailable)); + assert(number == 0 && reads == before && closed == close_before); + } + open_result = 0; + for (auto value : {INT32_MIN, -1, 0, INT32_MAX}) + { + signed_value = value; + assert(read_int_into("settings", "timezone_offset", &integer) == ScalarReadResult::Ok && integer == value); + } + const uint32_t unsigned_values[] = {0, 1, UINT32_MAX}; + for (uint32_t value : unsigned_values) + { + unsigned_value = value; + assert(read_uint_into("power", "gauge_full_mah", &number) == ScalarReadResult::Ok && number == value); + } + for (uint8_t value : {0, 1, 2, 255}) + { + bool_value = value; + uint8_t byte = 0; + assert(read_u8_into("mc_ble", "advert_loc", &byte) == ScalarReadResult::Ok && byte == value); + assert(read_bool_into("settings", "adv_debug", &flag) == + (value <= 1 ? ScalarReadResult::Ok : ScalarReadResult::Invalid)); + assert(flag == (value == 1)); + } + assert(read_int_into(nullptr, "key", &integer) == ScalarReadResult::Invalid); + assert(read_uint_into("settings", nullptr, &number) == ScalarReadResult::Invalid); + assert(read_bool_into("settings", "key", nullptr) == ScalarReadResult::Invalid); + valid_key = false; + assert(read_int_into("settings", "key", &integer) == ScalarReadResult::Invalid); +} diff --git a/tests/hostlink/test_settings_string_read.cpp b/tests/hostlink/test_settings_string_read.cpp new file mode 100644 index 00000000..2877edd0 --- /dev/null +++ b/tests/hostlink/test_settings_string_read.cpp @@ -0,0 +1,78 @@ +#include "platform/ui/settings_store.h" +#include +#include +#include +using nvs_handle_t = unsigned; +constexpr int ESP_OK = 0, ESP_ERR_NVS_NOT_FOUND = 1, ESP_ERR_NVS_TYPE_MISMATCH = 2, NVS_READONLY = 1; +int open_result = 0, measure_result = 0, read_result = 0; +unsigned closed = 0, reads = 0; +std::string stored = "secret"; +bool wrong_length = false; +int nvs_open(const char*, int, nvs_handle_t* handle) +{ + *handle = 7; + return open_result; +} +void nvs_close(nvs_handle_t handle) +{ + assert(handle == 7); + closed++; +} +int nvs_get_str(nvs_handle_t, const char*, char* out, size_t* size) +{ + if (!out) + { + *size = stored.size() + 1; + return measure_result; + } + reads++; + assert(*size >= stored.size() + 1); + std::memcpy(out, stored.c_str(), stored.size() + 1); + *size = stored.size() + 1 + (wrong_length ? 1 : 0); + return read_result; +} +namespace platform::esp::common::settings_store_detail +{ +const char* resolve_storage_key(const char* key) { return key; } +bool validate_storage_key(const char*, const char*, const char*, const char*) { return true; } +} // namespace platform::esp::common::settings_store_detail +namespace platform::ui::settings_store +{ +#include "settings_string_read_actual.inc" +} +int main() +{ + using namespace platform::ui::settings_store; + char out[64]{}; + size_t length = 999; + const auto call = [&] + { return read_string_into("c6_companion", "wifi_sta_password", out, sizeof(out), &length); }; + assert(call() == StringReadResult::Ok && length == 6 && !std::strcmp(out, "secret") && closed == 1); + open_result = ESP_ERR_NVS_NOT_FOUND; + assert(call() == StringReadResult::Missing && length == 0 && out[0] == 0 && closed == 1); + open_result = 9; + assert(call() == StringReadResult::Unavailable && closed == 1); + open_result = 0; + measure_result = ESP_ERR_NVS_NOT_FOUND; + assert(call() == StringReadResult::Missing && closed == 2); + measure_result = ESP_ERR_NVS_TYPE_MISMATCH; + assert(call() == StringReadResult::Invalid && closed == 3); + measure_result = 9; + assert(call() == StringReadResult::Unavailable && closed == 4); + measure_result = 0; + stored = std::string(64, 'x'); + const auto before = reads; + assert(call() == StringReadResult::Invalid && reads == before && closed == 5); + stored = "secret"; + read_result = 9; + assert(call() == StringReadResult::Unavailable && out[0] == 0 && length == 0); + read_result = 0; + wrong_length = true; + assert(call() == StringReadResult::Invalid); + wrong_length = false; + stored = std::string("a\0b", 3); + assert(call() == StringReadResult::Invalid && out[0] == 0); + stored = ""; + assert(call() == StringReadResult::Ok && length == 0); + assert(read_string_into(nullptr, "key", out, sizeof(out), &length) == StringReadResult::Invalid); +} diff --git a/tests/hostlink/test_signed_manifest_store.cpp b/tests/hostlink/test_signed_manifest_store.cpp new file mode 100644 index 00000000..af4533a3 --- /dev/null +++ b/tests/hostlink/test_signed_manifest_store.cpp @@ -0,0 +1,120 @@ +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/signed_manifest_store.h" +#include +#include +#include +#include +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + size_t offset = 0, writes = 0; + bool active = false, publish_fail = false, corrupt = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return true; + } + bool size(void*, uint64_t& size) + { + size = files[path].size(); + return true; + } + bool seek(void*, uint64_t position) + { + offset = size_t(position); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* out, size_t size) + { + assert(size <= 128); + size = std::min(size, files[path].size() - offset); + std::memcpy(out, files[path].data() + offset, size); + offset += size; + return size; + } + size_t write(void*, const uint8_t* in, size_t size) + { + assert(size <= 320); + ++writes; + files[path].resize(offset + size); + std::memcpy(files[path].data() + offset, in, size); + offset += size; + if (corrupt) files[path][0] = 0xff; + return size; + } + bool sync(void*) { return true; } + bool publish(const char* from, const char* to) + { + assert(!active); + if (publish_fail || files.count(to) || !files.count(from)) return false; + files[to] = std::move(files[from]); + files.erase(from); + return true; + } +}; +int main() +{ + using namespace track_sharing; + uint8_t scratch[128], signature[64]; + std::memset(signature, 7, 64); + for (size_t count : {size_t(1), size_t(255), size_t(256), size_t(4096)}) + { + Io io; + SignedManifestStore store(io, "tmp", "final"); + static_assert(sizeof(store) <= 24, "Signed store cannot own payloads"); + std::vector raw(count); + for (size_t i = 0; i < count; ++i) raw[i] = uint8_t(i); + assert(store.persist({raw.data(), raw.size()}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::Ready && !io.active && !io.files.count("tmp")); + geocaching::protocol::CmpReader reader({io.files["final"].data(), io.files["final"].size()}); + size_t fields = 0; + geocaching::ByteView manifest, sig; + assert(reader.array(fields, 2) && reader.binary(manifest, 4096) && reader.binary(sig, 64) && reader.finished() && manifest.size == count && !std::memcmp(manifest.data, raw.data(), count) && !std::memcmp(sig.data, signature, 64)); + const auto writes = io.writes; + assert(store.persist({raw.data(), raw.size()}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::Ready && io.writes == writes); + io.files["final"][0] ^= 1; + const auto saved = io.files; + assert(store.persist({raw.data(), raw.size()}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::Conflict && io.files == saved); + } + Io io; + SignedManifestStore store(io, "tmp", "final"); + uint8_t raw[256]{}; + io.publish_fail = true; + assert(store.persist({raw, sizeof(raw)}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::StorageFailure && io.files.count("tmp") && !io.active); + const auto writes = io.writes; + io.publish_fail = false; + assert(store.persist({raw, sizeof(raw)}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::Ready && io.writes == writes); + Io broken; + broken.corrupt = true; + SignedManifestStore failed(broken, "tmp", "final"); + assert(failed.persist({raw, sizeof(raw)}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::StorageFailure && !broken.active && !broken.files.count("final")); + broken.corrupt = false; + const auto saved = broken.files; + const auto failed_writes = broken.writes; + assert(failed.persist({raw, sizeof(raw)}, {signature, 64}, scratch, sizeof(scratch)) == SignedStoreResult::Conflict && broken.files == saved && broken.writes == failed_writes); +} diff --git a/tests/hostlink/test_snapshot_space_budget.cpp b/tests/hostlink/test_snapshot_space_budget.cpp new file mode 100644 index 00000000..1231f1e8 --- /dev/null +++ b/tests/hostlink/test_snapshot_space_budget.cpp @@ -0,0 +1,19 @@ +#include "hostlink/snapshot_space_budget.h" +#include +using hostlink::management::SnapshotSpaceBudget; +int main() +{ + SnapshotSpaceBudget full(8388608); + assert(full.add(8388672, false) && full.complete()); + assert(!full.add(1, false)); + SnapshotSpaceBudget count(1); + for (unsigned i = 0; i < 7; ++i) assert(count.add(0, false)); + assert(count.complete()); + assert(!count.add(0, false)); + SnapshotSpaceBudget directory(1); + assert(!directory.add(0, true) && !directory.complete()); + SnapshotSpaceBudget invalid(0); + assert(!invalid.complete()); + SnapshotSpaceBudget overflow(1); + assert(!overflow.add(UINT64_MAX, false)); +} diff --git a/tests/hostlink/test_snapshot_stream_hash.cpp b/tests/hostlink/test_snapshot_stream_hash.cpp new file mode 100644 index 00000000..b3414f58 --- /dev/null +++ b/tests/hostlink/test_snapshot_stream_hash.cpp @@ -0,0 +1,44 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include +#include +#include + +#if defined(_WIN32) +// Older MinGW headers lack SecureZeroMemory. Keep the test-only platform +// hook explicit without changing the SDK or its actual SHA implementation. +extern "C" void mbedtls_platform_zeroize(void* buffer, size_t length) +{ + auto* bytes = static_cast(buffer); + while (length--) *bytes++ = 0; +} +#endif + +static mbedtls_sha256_context context; +static uint8_t scratch[320], output[32]; +int main() +{ + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(!hash.finish(output)); + assert(hash.start()); + assert(hash.update(reinterpret_cast("a"), 1)); + assert(hash.update(reinterpret_cast("bc"), 2)); + assert(hash.finish(output)); + const uint8_t expected[] = {0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae, 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00, 0x15, 0xad}; + assert(!std::memcmp(output, expected, 32)); + assert(!hash.finish(output)); + assert(hash.start()); + assert(!hash.update(nullptr, 1)); + assert(!hash.finish(output)); + assert(hash.start()); + std::memset(scratch, 'a', sizeof(scratch)); + for (unsigned copied = 0; copied < 1000000;) + { + const auto length = std::min(sizeof(scratch), 1000000 - copied); + assert(hash.update(scratch, length)); + copied += length; + } + assert(hash.finish(output)); + for (auto byte : output) std::printf("%02x", byte); + std::printf("\n"); + return 0; +} diff --git a/tests/hostlink/test_stored_identity_lease.cpp b/tests/hostlink/test_stored_identity_lease.cpp new file mode 100644 index 00000000..d32769f4 --- /dev/null +++ b/tests/hostlink/test_stored_identity_lease.cpp @@ -0,0 +1,116 @@ +#include "chat/ports/stored_identity_lease.h" +#include +#include +#include +struct Identity +{ + static inline bool exists = true, bad = false; + static inline unsigned loads = 0, signs = 0; + std::array secret{}; + bool initExisting() + { + ++loads; + secret.fill(42); + return exists && !bad; + } + bool identityStorageError() const { return bad; } + uint32_t authorGeneration() const { return 7; } + void combinedPublicKey(uint8_t* output) const { std::memset(output, 3, 64); } + bool sign(const uint8_t* message, size_t size, uint8_t* signature) const + { + ++signs; + assert(size > 100 && message[0] == 't'); + std::memset(signature, 8, 64); + return true; + } +}; +struct Memory +{ + static inline bool fail = false; + static inline unsigned releases = 0; + static void* allocate(size_t size) { return fail ? nullptr : std::malloc(size); } + static void release(void* memory) + { + const auto* bytes = static_cast(memory); + for (size_t index = 0; index < sizeof(Identity); ++index) assert(bytes[index] == 0); + ++releases; + std::free(memory); + } +}; +int main() +{ + std::array session{}; + std::array challenge{}; + std::array key{}, signature{}; + std::array work{}; + uint32_t generation = 0; + { + chat::StoredIdentityLease lease; + assert(lease.read(key.data(), generation) == chat::DeviceAuthorResult::Ok && generation == 7); + assert(lease.prove(session.data(), challenge.data(), 7, work.data(), work.size(), key.data(), generation, signature.data()) == chat::DeviceAuthorResult::Ok); + assert(lease.prove(session.data(), challenge.data(), 6, work.data(), work.size(), key.data(), generation, signature.data()) == chat::DeviceAuthorResult::StaleIdentity); + assert(signature[0] == 0 && Identity::signs == 1); + assert(lease.prove(session.data(), challenge.data(), 7, work.data(), 1, key.data(), generation, signature.data()) == chat::DeviceAuthorResult::InvalidInput); + } + assert(Memory::releases == 1); + Identity::exists = false; + { + chat::StoredIdentityLease lease; + assert(lease.read(key.data(), generation) == chat::DeviceAuthorResult::Unavailable && generation == 0 && key[0] == 0); + } + Identity::bad = true; + { + chat::StoredIdentityLease lease; + assert(lease.result() == chat::DeviceAuthorResult::StorageError); + } + Memory::fail = true; + const auto loads = Identity::loads; + { + chat::StoredIdentityLease lease; + assert(lease.result() == chat::DeviceAuthorResult::Unavailable); + } + assert(Identity::loads == loads && Memory::releases == 3); + Memory::fail = false; + Identity::exists = true; + Identity::bad = false; + { + Identity::exists = false; + unsigned initialized = 0; + chat::StoredIdentityLease account([&](Identity& value) { + ++initialized; + if (value.identityStorageError()) return false; + Identity::exists = true; + return value.initExisting(); + }); + assert(initialized == 1 && account.read(key.data(), generation) == chat::DeviceAuthorResult::Ok); + } + using Lease = chat::StoredIdentityLease; + for (const auto status : {chat::DeviceIdentityRestoreResult::Unavailable, + chat::DeviceIdentityRestoreResult::StaleIdentity, + chat::DeviceIdentityRestoreResult::InvalidInput, + chat::DeviceIdentityRestoreResult::StorageError, + chat::DeviceIdentityRestoreResult::Unsupported}) + { + Lease::noteRestore(status); + assert(!Lease::rebootRequired()); + } + { + Lease active; + assert(active.get()); + Lease::noteRestore(chat::DeviceIdentityRestoreResult::CommittedRebootRequired); + assert(Lease::rebootRequired() && !active.get()); + const auto before = Identity::loads; + Lease later; + assert(!later.get() && Identity::loads == before); + assert(active.read(key.data(), generation) == chat::DeviceAuthorResult::Unavailable); + } + struct OtherMemory : Memory + { + }; + using OtherLease = chat::StoredIdentityLease; + OtherLease::noteRestore(chat::DeviceIdentityRestoreResult::IndeterminateRebootRequired); + assert(OtherLease::rebootRequired()); + const auto before = Identity::loads; + OtherLease uncertain; + assert(!uncertain.get() && Identity::loads == before); +} diff --git a/tests/hostlink/test_tms_c6_extension.cpp b/tests/hostlink/test_tms_c6_extension.cpp new file mode 100644 index 00000000..d7a7786f --- /dev/null +++ b/tests/hostlink/test_tms_c6_extension.cpp @@ -0,0 +1,81 @@ +#include "app/tms_c6_config_extension.h" +#include +#include +namespace tms = app::tms; +static bool decode(const std::string& text, tms::c6::Config& target) +{ + target = tms::c6::Config{}; + tms::Decoder decoder( + nullptr, tms::DocumentKind::Working, + [](void* context, const tms::RecordReader& record) + { return tms::c6::consume(*static_cast(context), record); }, + &target, + [](void* context, bool, uint16_t) + { return tms::c6::valid(*static_cast(context)); }); + size_t offset = 0; + while (offset < text.size()) + { + const auto end = text.find('\n', offset); + if (end == std::string::npos || end - offset >= tms::kMaxLineBytes) return false; + char line[tms::kMaxLineBytes]{}; + std::memcpy(line, text.data() + offset, end - offset); + if (!decoder.consumeLine(line)) return false; + offset = end + 1; + } + return decoder.finish(); +} +int main() +{ + app::AppConfig app; + tms::c6::Config source, target; + source.seen = tms::c6::kComplete; + source.wifi_sta_enabled = true; + assert(tms::c6::valid(source)); + source.wifi_enabled = true; + assert(!tms::c6::valid(source)); + std::strcpy(source.wifi_sta_ssid, "Mountain % camp"); + assert(tms::c6::valid(source)); + source.wifi_ap_enabled = true; + source.wifi_ap_ssid[0] = 0; + assert(!tms::c6::valid(source)); + std::strcpy(source.wifi_ap_ssid, "TrailMate-C6"); + assert(tms::c6::valid(source)); + std::strcpy(source.wifi_ap_password, "1234567"); + assert(!tms::c6::valid(source)); + std::strcpy(source.wifi_ap_password, "12345678"); + assert(tms::c6::valid(source)); + source.wifi_enabled = false; + std::strcpy(source.wifi_ap_password, "short"); + assert(tms::c6::valid(source)); + assert(!tms::c6::valid(source, true)); + std::strcpy(source.wifi_ap_password, "12345678"); + assert(tms::c6::valid(source, true)); + std::strcpy(source.wifi_sta_password, "private-value"); + std::string text; + tms::LineScratch scratch; + assert(tms::writeDocument( + app, tms::DocumentKind::Working, + {&text, [](void* out, const char* bytes, size_t size) + { static_cast(out)->append(bytes, size); return true; }}, + scratch, nullptr, + [](void* context, tms::RecordWriter& writer) + { return tms::c6::write(*static_cast(context), writer); }, + &source)); + assert(decode(text, target)); + assert(target.wifi_sta_enabled && !std::strcmp(target.wifi_sta_ssid, source.wifi_sta_ssid) && !std::strcmp(target.wifi_sta_password, source.wifi_sta_password)); + const auto line = std::string("c6_companion.wifi_ap_channel=u8:1\n"); + const auto position = text.find(line); + assert(position != std::string::npos); + auto bad = text; + bad.replace(position, line.size(), "c6_companion.wifi_ap_channel=u8:0\n"); + assert(!decode(bad, target)); + bad = text; + bad.erase(position, line.size()); + assert(!decode(bad, target)); + bad = text; + bad.insert(position, line); + assert(!decode(bad, target)); + bad = text; + bad.replace(position, line.size(), "c6_companion.wifi_ap_channel=bool:1\n"); + assert(!decode(bad, target)); +} diff --git a/tests/hostlink/test_tms_c6_storage.cpp b/tests/hostlink/test_tms_c6_storage.cpp new file mode 100644 index 00000000..a92008e1 --- /dev/null +++ b/tests/hostlink/test_tms_c6_storage.cpp @@ -0,0 +1,152 @@ +#include "app/tms_c6_config_extension.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/wifi_runtime.h" +#include +#include +#include +namespace tms = app::tms; +struct State +{ + tms::c6::Config companion; + platform::ui::wifi::Config wifi_profiles[1]; + uint8_t timezone_tzdef[192]{}; + bool wifi_enabled = false; +} s_state; +std::map numbers; +std::map strings; +bool reject_write = false; +bool unavailable_read = false; +std::string failed_scalar; +platform::ui::settings_store::ScalarReadResult scalar_failure = platform::ui::settings_store::ScalarReadResult::Ok; +namespace platform::ui::settings_store +{ +void begin_change_batch() {} +void end_change_batch() {} +void put_int(const char*, const char* key, int value) +{ + if (!reject_write) numbers[key] = value; +} +void put_bool(const char* ns, const char* key, bool value) { put_int(ns, key, value); } +int get_int(const char*, const char* key, int fallback) +{ + auto it = numbers.find(key); + return it == numbers.end() ? fallback : it->second; +} +bool get_bool(const char* ns, const char* key, bool fallback) { return get_int(ns, key, fallback) != 0; } +ScalarReadResult read_int_into(const char*, const char* key, int32_t* out) +{ + *out = 0; + if (failed_scalar == key) return scalar_failure; + auto it = numbers.find(key); + if (it == numbers.end()) return ScalarReadResult::Missing; + *out = it->second; + return ScalarReadResult::Ok; +} +ScalarReadResult read_bool_into(const char* ns, const char* key, bool* out) +{ + int32_t value = 0; + auto result = read_int_into(ns, key, &value); + *out = false; + if (result != ScalarReadResult::Ok) return result; + if (value != 0 && value != 1) return ScalarReadResult::Invalid; + *out = value != 0; + return ScalarReadResult::Ok; +} +bool put_string(const char*, const char* key, const char* value) +{ + if (reject_write) return false; + strings[key] = value; + return true; +} +bool get_string_into(const char*, const char* key, char* out, size_t capacity, size_t* length) +{ + auto it = strings.find(key); + if (it == strings.end() || it->second.size() >= capacity) return false; + *length = it->second.size(); + std::memcpy(out, it->second.c_str(), *length + 1); + return true; +} +StringReadResult read_string_into(const char* ns, const char* key, char* out, size_t capacity, size_t* length) +{ + if (unavailable_read) return StringReadResult::Unavailable; + auto it = strings.find(key); + if (it == strings.end()) return StringReadResult::Missing; + if (it->second.size() >= capacity) return StringReadResult::Invalid; + return get_string_into(ns, key, out, capacity, length) ? StringReadResult::Ok : StringReadResult::Unavailable; +} +} // namespace platform::ui::settings_store +namespace platform::ui::wifi +{ +bool load_config(Config& config) +{ + config = {}; + return true; +} +} // namespace platform::ui::wifi +#include "tms_c6_storage_actual.inc" +int main() +{ + auto& c = s_state.companion; + c.seen = tms::c6::kComplete; + c.wifi_ap_channel = 6; + c.espnow_channel = 14; + std::strcpy(c.wifi_sta_ssid, "Mountain"); + std::strcpy(c.wifi_sta_password, "private-value"); + assert(apply_c6_records()); + assert(numbers.size() == 10 && strings.size() == 4); + assert(strings["wifi_sta_password"] == "private-value"); + c.wifi_ap_channel = 0; + const auto before = numbers; + assert(!apply_c6_records()); + assert(numbers == before); + c.wifi_ap_channel = 7; + reject_write = true; + assert(!apply_c6_records()); + assert(numbers == before); + reject_write = false; + numbers["espnow_channel"] = 15; + std::string output; + tms::LineScratch scratch; + tms::RecordWriter writer({&output, [](void* context, const char* bytes, size_t count) + {static_cast(context)->append(bytes,count);return true; }}, + scratch, nullptr); + assert(!write_c6_records(writer)); + assert(output.empty()); + numbers["espnow_channel"] = 14; + strings["wifi_sta_password"] = std::string(64, 'x'); + assert(!write_c6_records(writer)); + assert(output.empty()); + strings["wifi_sta_password"] = "private-value"; + unavailable_read = true; + assert(!write_c6_records(writer)); + assert(output.empty()); + unavailable_read = false; + strings.clear(); + assert(write_c6_records(writer)); + assert(!output.empty()); + const char* scalar_keys[] = {"espnow_enabled", "espnow_team_discovery", "espnow_channel", "espnow_beacon_interval_ms", "wifi_enabled", "wifi_sta_enabled", "wifi_ap_enabled", "wifi_persist_credentials", "wifi_sta_channel", "wifi_ap_channel"}; + for (const auto* key : scalar_keys) + for (const auto failure : {platform::ui::settings_store::ScalarReadResult::Invalid, platform::ui::settings_store::ScalarReadResult::Unavailable}) + { + failed_scalar = key; + scalar_failure = failure; + output.clear(); + assert(!write_c6_records(writer)); + assert(output.empty()); + } + failed_scalar.clear(); + scalar_failure = platform::ui::settings_store::ScalarReadResult::Ok; + for (const auto* key : {"espnow_enabled", "espnow_team_discovery", "wifi_enabled", "wifi_sta_enabled", "wifi_ap_enabled", "wifi_persist_credentials"}) + { + const auto previous = numbers[key]; + numbers[key] = 2; + output.clear(); + assert(!write_c6_records(writer)); + assert(output.empty()); + numbers[key] = previous; + } + numbers.clear(); + output.clear(); + assert(write_c6_records(writer)); + assert(output.find("c6_companion.wifi_ap_channel=u8:1\n") != std::string::npos); +} diff --git a/tests/hostlink/test_tms_scalar_storage.cpp b/tests/hostlink/test_tms_scalar_storage.cpp new file mode 100644 index 00000000..4ecdc2f7 --- /dev/null +++ b/tests/hostlink/test_tms_scalar_storage.cpp @@ -0,0 +1,206 @@ +#include "platform/ui/auto_reply_settings.h" +#include +#include +#include +#include + +namespace platform::ui::settings_store +{ +static std::map values; +static unsigned writes = 0; +static unsigned reads = 0; +static unsigned fail_write = 0; +static unsigned fail_read = 0; +static unsigned other_writes = 0; +static unsigned fail_other_write = 0; +static std::string storage_key(const char* ns, const char* key) +{ + return std::string(ns) + "/" + key; +} +static void write(const char* ns, const char* key, int64_t value) +{ + if (++writes != fail_write) values[storage_key(ns, key)] = value; +} +static int64_t read(const char* ns, const char* key, int64_t fallback) +{ + const auto found = values.find(storage_key(ns, key)); + return ++reads == fail_read || found == values.end() ? fallback : found->second; +} +void put_int(const char* ns, const char* key, int value) { write(ns, key, value); } +void put_bool(const char* ns, const char* key, bool value) { write(ns, key, value); } +void put_uint(const char* ns, const char* key, uint32_t value) { write(ns, key, value); } +int get_int(const char* ns, const char* key, int fallback) { return static_cast(read(ns, key, fallback)); } +bool get_bool(const char* ns, const char* key, bool fallback) { return read(ns, key, fallback) != 0; } +uint32_t get_uint(const char* ns, const char* key, uint32_t fallback) { return static_cast(read(ns, key, fallback)); } +bool put_string(const char*, const char*, const char*) +{ + return ++other_writes != fail_other_write; +} +bool put_blob(const char*, const char*, const void*, std::size_t) +{ + return ++other_writes != fail_other_write; +} +} // namespace platform::ui::settings_store + +namespace platform::ui::wifi +{ +bool replace_saved_profiles(bool, const int*, uint8_t) +{ + return ++settings_store::other_writes != settings_store::fail_other_write; +} +} // namespace platform::ui::wifi +namespace platform::ui::reticulum_network_config +{ +bool setFromTms(int) +{ + return ++settings_store::other_writes != settings_store::fail_other_write; +} +} // namespace platform::ui::reticulum_network_config +namespace ble_extension +{ +static bool accepted = true; +bool finishDocument(bool, uint16_t) { return accepted; } +} // namespace ble_extension + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kPowerNs = "power"; +constexpr uint32_t kRequiredBase = 3; +struct ScalarState +{ + uint32_t seen = kRequiredBase; + uint64_t network_seen = 0; + uint32_t cellular_seen = 0; + uint32_t screen_timeout_ms = 30000; + int32_t screen_brightness = 0; + int32_t speaker_volume = 45; + bool vibration_enabled = true; + int32_t timezone_profile = -1; + int32_t timezone_offset_minutes = -420; + int32_t message_alerts = 0; + int32_t contact_alerts = 2; + bool auto_reply_enabled = false; + bool debug_logs_enabled = false; + uint32_t gauge_design_mah = 1500; + uint32_t gauge_full_mah = 1400; + char locale[48] = "en"; + char enabled_imes[192] = {}; + std::size_t enabled_imes_len = 0; + char auto_reply_text[256] = {}; + std::size_t auto_reply_text_len = 0; + uint8_t timezone_tzdef[192] = {}; + std::size_t timezone_tzdef_len = 0; + bool wifi_enabled = false; + int wifi_profiles[1] = {}; + uint8_t wifi_profile_count = 0; + int network = 0; +} s_state; +static ScalarState* s_state_storage = &s_state; +// Admission dependencies are accepted here; the test exercises the actual +// finish/apply control flow, not their independent validation implementations. +bool valid_screen_timeout(uint32_t) { return true; } +bool valid_timezone_offset_minutes(int32_t) { return true; } +bool valid_text_blob(const char*, std::size_t) { return true; } +bool valid_wifi_profile_set() { return true; } +bool valid_network_records(uint16_t) { return true; } + +// Exact production function, extracted by CMake. The fake represents NVS +// write/read failures, not an alternate copy of the implementation. +#include "tms_scalar_storage_actual.inc" + +#include "tms_finish_actual.inc" + +int main() +{ + namespace store = platform::ui::settings_store; + assert(write_base_scalar_settings()); + assert(store::writes == 12 && store::reads == 12 && store::values.size() == 12); + assert(store::values.at("settings/timezone_profile") == -1); + assert(store::values.at("settings/timezone_offset") == -420); + assert(store::values.at("power/gauge_full_mah") == 1400); + for (unsigned index = 1; index <= 12; ++index) + { + store::values.clear(); + store::writes = store::reads = store::fail_read = 0; + store::fail_write = index; + assert(!write_base_scalar_settings()); + assert(store::writes == index && store::reads == index); + assert(store::values.size() == index - 1); // Stop at failure. + + store::values.clear(); + store::writes = store::reads = store::fail_write = 0; + store::fail_read = index; + assert(!write_base_scalar_settings()); + assert(store::writes == index && store::reads == index); + } + store::writes = store::reads = store::fail_read = store::fail_write = 0; + assert(write_base_scalar_settings()); + // A dropped write is acceptable only if durable storage already matches. + for (unsigned index = 1; index <= 12; ++index) + { + store::writes = store::reads = 0; + store::fail_write = index; + assert(write_base_scalar_settings()); + assert(store::writes == 12 && store::reads == 12); + } + store::fail_write = 0; + // Unequal fallback remains valid at scalar extrema, zero and both bools. + s_state.screen_timeout_ms = UINT32_MAX; + s_state.screen_brightness = INT32_MIN; + s_state.speaker_volume = INT32_MAX; + s_state.timezone_profile = 0; + s_state.timezone_offset_minutes = 0; + s_state.vibration_enabled = false; + s_state.auto_reply_enabled = true; + s_state.debug_logs_enabled = true; + assert(write_base_scalar_settings()); + store::values.clear(); + store::writes = store::reads = 0; + store::fail_write = 1; + assert(!write_base_scalar_settings()); + + s_state = ScalarState{}; + store::values.clear(); + store::writes = store::reads = store::fail_write = store::other_writes = 0; + assert(finishDocument(nullptr, false, 7)); + assert(store::writes == 0 && store::reads == 0 && store::other_writes == 0); + for (unsigned index = 1; index <= 12; ++index) + { + for (bool failed_read : {false, true}) + { + store::values.clear(); + store::writes = store::reads = store::other_writes = 0; + store::fail_write = failed_read ? 0 : index; + store::fail_read = failed_read ? index : 0; + assert(!finishDocument(nullptr, true, 7)); + assert(store::writes == index && store::reads == index); + assert(store::other_writes == 0); // No later strings/profiles/network. + } + } + store::values.clear(); + store::writes = store::reads = store::fail_write = store::fail_read = store::other_writes = 0; + s_state.network_seen = 1; + assert(finishDocument(nullptr, true, 7)); + assert(store::writes == 12 && store::reads == 12 && store::other_writes == 6); + for (unsigned index = 1; index <= 6; ++index) + { + store::writes = store::reads = store::other_writes = 0; + store::fail_other_write = index; + assert(!finishDocument(nullptr, true, 7)); + assert(store::other_writes == index); // Wi-Fi failure cannot apply Reticulum settings. + } + store::fail_other_write = 0; + store::writes = store::reads = store::other_writes = 0; + ble_extension::accepted = false; + assert(!finishDocument(nullptr, true, 7)); + assert(store::writes == 0 && store::reads == 0 && store::other_writes == 0); + ble_extension::accepted = true; + s_state.seen = 0; + assert(!finishDocument(nullptr, true, 7)); + assert(store::writes == 0 && store::reads == 0 && store::other_writes == 0); + assert(finishDocument(nullptr, false, 2) == false); // Network record makes it non-legacy. + s_state.network_seen = 0; + assert(finishDocument(nullptr, true, 2)); // Legacy omission retains existing settings. + assert(store::writes == 0 && store::reads == 0 && store::other_writes == 0); + s_state_storage = nullptr; + assert(!finishDocument(nullptr, true, 7)); +} diff --git a/tests/hostlink/test_tms_scratch.cpp b/tests/hostlink/test_tms_scratch.cpp new file mode 100644 index 00000000..b88822b5 --- /dev/null +++ b/tests/hostlink/test_tms_scratch.cpp @@ -0,0 +1,60 @@ +#include "app/tms_config_codec.h" +#include +#include +#include + +namespace tms = app::tms; +constexpr unsigned MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 2; +static unsigned allocated = 0, freed = 0; +static bool fail_allocation = false; +static void* heap_caps_malloc(std::size_t size, unsigned capabilities) +{ + assert(size == sizeof(tms::LineScratch)); + assert(capabilities == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + ++allocated; + return fail_allocation ? nullptr : std::malloc(size); +} +static void heap_caps_free(void* raw) +{ + assert(raw != nullptr); + ++freed; + std::free(raw); +} + +// Compile the production lease verbatim. The fake allocator verifies ownership +// and failure paths without replacing the class with a behavioural model. +#include "tms_scratch_actual.inc" + +int main() +{ + { + ScopedLineScratch outer; + assert(outer.ready()); + auto* original = s_line_scratch_storage; + original->bytes[0] = 'X'; + { + ScopedLineScratch validation; + assert(validation.ready()); + assert(s_line_scratch_storage == original); + assert(s_line_scratch_storage->bytes[0] == 'X'); + assert(allocated == 1); + } + assert(freed == 0 && s_line_scratch_storage == original); + } + assert(freed == 1 && s_line_scratch_storage == nullptr); + fail_allocation = true; + { + ScopedLineScratch failed; + assert(!failed.ready()); + } + assert(allocated == 2 && freed == 1 && s_line_scratch_storage == nullptr); + fail_allocation = false; + auto early_return = [] + { + ScopedLineScratch scratch; + assert(scratch.ready()); + return; + }; + early_return(); + assert(allocated == 3 && freed == 2 && s_line_scratch_storage == nullptr); +} diff --git a/tests/hostlink/test_track_catalog.cpp b/tests/hostlink/test_track_catalog.cpp new file mode 100644 index 00000000..ce0e7a70 --- /dev/null +++ b/tests/hostlink/test_track_catalog.cpp @@ -0,0 +1,61 @@ +#include "platform/esp/idf_common/hostlink_track_catalog.h" +#include +#include +static bool held = false; +namespace platform::ui::tracker +{ +bool try_acquire_catalog_read() +{ + if (held) return false; + held = true; + return true; +} +void release_snapshot_read() +{ + assert(held); + held = false; +} +bool is_recording_filename(const char* name) { return !std::strcmp(name, "live.GPX"); } +} // namespace platform::ui::tracker +static uint8_t request_bytes[64], output[471]; +int main() +{ + using namespace hostlink::management; + namespace sd = platform::esp::arduino_common::storage; + sd::rows = {{"folder", true}, {"active.bin"}, {"notes.txt"}, {"live.GPX", false, 900}, {"山径.csv", false, 1200}}; + const auto request = [&](bool first, uint64_t media, uint64_t position) + {geocaching::protocol::CmpWriter writer(request_bytes,sizeof(request_bytes));assert(writer.array(1));if(first)assert(writer.nil());else assert(writer.array(2)&&writer.unsignedInteger(media)&&writer.unsignedInteger(position));EnvelopeView view;view.body=request_bytes;view.body_size=writer.size();return view; }; + size_t written = 0; + assert(readTrackCatalog(request(true, 0, 0), output, sizeof(output), written) == Status::Ok && !held && sd::scans == 4); + for (size_t i = 0; i < written; ++i) std::printf("%02x", output[i]); + std::printf("\n"); + geocaching::protocol::CmpReader reader({output, written}); + size_t fields = 0; + uint64_t value = 0; + std::string_view name; + assert(reader.array(fields, 3) && fields == 3 && reader.unsignedInteger(value) && value == 1 && reader.unsignedInteger(value) && value == 128 && reader.array(fields, 4) && fields == 4 && reader.text(name, 127) && name == "live.GPX" && reader.unsignedInteger(value) && value == 1 && reader.unsignedInteger(value) && value == 900 && reader.unsignedInteger(value) && value == 1 && reader.finished()); + assert(readTrackCatalog(request(false, 1, 128), output, sizeof(output), written) == Status::Ok && !held); + assert(readTrackCatalog(request(false, 1, 160), output, sizeof(output), written) == Status::Ok && !held); + geocaching::protocol::CmpReader end({output, written}); + assert(end.array(fields, 3) && end.unsignedInteger(value) && end.nil() && end.nil() && end.finished()); + assert(readTrackCatalog(request(false, 2, 0), output, sizeof(output), written) == Status::Conflict && !held); + assert(readTrackCatalog(request(false, 1, 1), output, sizeof(output), written) == Status::InvalidField); + sd::busy = true; + assert(readTrackCatalog(request(true, 0, 0), output, sizeof(output), written) == Status::Busy && !held); + sd::busy = false; + sd::unavailable = true; + assert(readTrackCatalog(request(true, 0, 0), output, sizeof(output), written) == Status::StorageUnavailable && !held); + sd::unavailable = false; + sd::rows = {{"one.txt"}, {"two.txt"}, {"three.txt"}, {"four.txt"}, {"five.bin"}}; + sd::scans = 0; + assert(readTrackCatalog(request(true, 0, 0), output, sizeof(output), written) == Status::Ok && sd::scans == 4); + geocaching::protocol::CmpReader skipped({output, written}); + assert(skipped.array(fields, 3) && skipped.unsignedInteger(value) && skipped.unsignedInteger(value) && value == 128 && skipped.nil() && skipped.finished()); + assert(trackCatalogNameValid("山径.csv") && !trackCatalogNameValid("../bad.gpx") && !trackCatalogNameValid(std::string_view("\xc0\x80.gpx", 6))); + sd::rows = {{"ACTIVE.BIN"}, {"Active.Bin"}, {"active.bin"}, {"walk.bin", false, 42}}; + sd::scans = 0; + assert(readTrackCatalog(request(true, 0, 0), output, sizeof(output), written) == Status::Ok && !held && sd::scans == 4); + geocaching::protocol::CmpReader mixed({output, written}); + assert(mixed.array(fields, 3) && mixed.unsignedInteger(value) && mixed.unsignedInteger(value) && value == 128 && mixed.array(fields, 4) && mixed.text(name, 127) && name == "walk.bin" && mixed.unsignedInteger(value) && value == 3 && mixed.unsignedInteger(value) && value == 42 && mixed.unsignedInteger(value) && value == 0 && mixed.finished()); + return 0; +} diff --git a/tests/hostlink/test_track_coordinate_stream.cpp b/tests/hostlink/test_track_coordinate_stream.cpp new file mode 100644 index 00000000..2e8ffbd2 --- /dev/null +++ b/tests/hostlink/test_track_coordinate_stream.cpp @@ -0,0 +1,34 @@ +#include "track_sharing/coordinate_stream.h" +#include +#include +using namespace track_sharing; +int main() +{ + const std::string text = "179,1,4 -179,-2,6\n"; + for (size_t split = 0; split <= text.size(); ++split) + { + GeometryBounds bounds; + CoordinateStream stream(bounds, CoordinateText::KmlTuples); + assert(stream.feed(text.data(), split) && stream.feed(text.data() + split, text.size() - split) && stream.finish()); + GeometryExtent result; + assert(bounds.finish(result) && result.points == 2 && result.west == 1790000000 && result.east == -1790000000); + } + GeometryBounds gx; + CoordinateStream stream(gx, CoordinateText::GxCoordinate); + const std::string coordinate = "-179 -2 6"; + for (char character : coordinate) assert(stream.feed(&character, 1)); + assert(stream.finish()); + GeometryExtent result; + assert(gx.finish(result) && result.points == 1); + for (const std::string invalid : {"1,,2", "1,2,", "181,2", "1 2", "1e+,2", "nan,2", "1e999,2"}) + { + GeometryBounds bounds; + CoordinateStream parser(bounds, CoordinateText::KmlTuples); + const bool valid = parser.feed(invalid.data(), invalid.size()) && parser.finish(); + assert(!valid && !bounds.finish(result)); + } + GeometryBounds rounded; + CoordinateStream parser(rounded, CoordinateText::KmlTuples); + const std::string half = "-0.00000005,0"; + assert(parser.feed(half.data(), half.size()) && parser.finish() && rounded.finish(result) && result.west == 0); +} diff --git a/tests/hostlink/test_track_datetime.cpp b/tests/hostlink/test_track_datetime.cpp new file mode 100644 index 00000000..b12e3434 --- /dev/null +++ b/tests/hostlink/test_track_datetime.cpp @@ -0,0 +1,10 @@ +#include "track_sharing/track_datetime.h" +#include +int main(int argc, char** argv) +{ + using track_sharing::validTrackDateTime; + if (argc == 2) return validTrackDateTime(argv[1], true) ? 0 : 1; + for (const char* text : {"2024-02-29T12:00:00Z", "2026-10-11T00:00:00.123+14:00", "2026-10-11T24:00:00.000", "-0001-02-29T00:00:00Z"}) assert(validTrackDateTime(text)); + for (const char* text : {"2026-02-29T00:00:00Z", "1900-02-29T00:00:00Z", "2026-10-11T24:00:00.1Z", "2026-10-11T00:00:00+14:01", "0000-01-01T00:00:00Z", "2026-10-11", "2026-10-11T00:00:60Z"}) assert(!validTrackDateTime(text)); + assert(validTrackDateTime(" ", true) && !validTrackDateTime(" ")); +} diff --git a/tests/hostlink/test_track_delivery_binding.cpp b/tests/hostlink/test_track_delivery_binding.cpp new file mode 100644 index 00000000..a9a07151 --- /dev/null +++ b/tests/hostlink/test_track_delivery_binding.cpp @@ -0,0 +1,102 @@ +#include "chat/ports/track_delivery_binding.h" +#include "track_sharing/directory_announcement.h" +#include +#include + +struct State +{ + unsigned calls = 0; + bool durable = false; +}; +bool accept(const chat::lxmf::CustomDeliveryView&, void* context) +{ + auto& state = *static_cast(context); + ++state.calls; + return state.durable; +} +bool announcement(const chat::lxmf::TrackDirectoryAnnouncementView&, void* context) +{ + ++*static_cast(context); + return true; +} +int main() +{ + using namespace chat::lxmf; + const char type[] = "trailmate.track.v1"; + assert(isTrackCustomType({reinterpret_cast(type), sizeof(type) - 1})); + assert(!isTrackCustomType({reinterpret_cast(type), sizeof(type)})); + assert(!isTrackCustomType({nullptr, sizeof(type) - 1})); + uint8_t address[16]{}, hash[32]{}, bytes[8193]{}; + CustomDeliveryView view{{address, 16}, {address, 16}, {hash, 32}, {bytes, 8192}}; + State state; + TrackDeliveryBinding binding{accept, &state}; + assert(!deliverVerifiedTrack(&binding, true, false, view)); + assert(!deliverVerifiedTrack(&binding, false, true, view)); + assert(!deliverVerifiedTrack(nullptr, false, false, view)); + assert(state.calls == 0); + assert(!deliverVerifiedTrack(&binding, false, false, view) && state.calls == 1); + state.durable = true; + assert(deliverVerifiedTrack(&binding, false, false, view) && state.calls == 2); + view.data.size = 8193; + assert(!deliverVerifiedTrack(&binding, false, false, view) && state.calls == 2); + struct Identity + { + uint8_t public_key[64]{}; + uint32_t generation=7; + bool ready=true,error=false; + bool isReady() const { return ready; } + bool identityStorageError() const { return error; } + uint32_t authorGeneration() const { return generation; } + const uint8_t* encryptionPublicKey() const { return public_key; } + const uint8_t* signingPublicKey() const { return public_key+32; } + } identity; + uint8_t expected_public[64];std::memset(expected_public,4,64);std::memcpy(identity.public_key,expected_public,64); + binding.expected_local_author=expected_public;binding.expected_author_generation=7; + view.source.size=16;view.data.size=1; + const auto before_guard=state.calls; + assert(deliverVerifiedTrackForAuthor(&binding,false,false,view,identity) && state.calls==before_guard+1); + identity.ready=false;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity));identity.ready=true; + identity.error=true;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity));identity.error=false; + ++identity.generation;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity));--identity.generation; + identity.public_key[0]^=1;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity));identity.public_key[0]^=1; + identity.public_key[32]^=1;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity));identity.public_key[32]^=1; + assert(state.calls==before_guard+1); + binding.expected_author_generation=0;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity)); + binding.expected_author_generation=7;binding.expected_local_author=nullptr;assert(!deliverVerifiedTrackForAuthor(&binding,false,false,view,identity)); + binding.expected_author_generation=0;identity.ready=false; + assert(trackBindingAuthorMatches(&binding,identity)); + state.calls=before_guard; + view.data.size = 1; + view.source.size = 15; + assert(!deliverVerifiedTrack(&binding, false, false, view) && state.calls == 2); + unsigned announcements = 0; + TrackDeliveryBinding directory_binding{nullptr, &announcements, announcement}; + uint8_t public_key[64]{}; + TrackDirectoryAnnouncementView directory{{address, 16}, {address, 16}, {public_key, 64}, {bytes, 96}}; + assert(deliverVerifiedTrackDirectory(&directory_binding, directory) && announcements == 1); + directory.app_data.size = 129; + assert(!deliverVerifiedTrackDirectory(&directory_binding, directory) && announcements == 1); + directory.app_data.size = 96; + directory.public_key.size = 32; + assert(!deliverVerifiedTrackDirectory(&directory_binding, directory) && announcements == 1); + assert(!deliverVerifiedTrackDirectory(nullptr, directory)); + uint8_t metadata[128], delivery_address[16], epoch[16]; + std::memset(delivery_address, 3, 16); + std::memset(epoch, 4, 16); + geocaching::protocol::CmpWriter writer(metadata, sizeof(metadata)); + assert(writer.array(5) && writer.unsignedInteger(1) && writer.text("trailmate.track.v1") && writer.binary({delivery_address, 16}) && + writer.binary({epoch, 16}) && writer.text(std::string(40, 'x'))); + assert(writer.size() == 99); + directory = {{address, 16}, {delivery_address, 16}, {public_key, 64}, {metadata, writer.size()}}; + track_sharing::DirectoryAdvertisement advertisement; + assert(track_sharing::decodeTrackDirectoryAnnouncement(directory, advertisement) && advertisement.name.size() == 40); + assert(deliverVerifiedTrackDirectory(&directory_binding, directory) && announcements == 2); + delivery_address[0] ^= 1; + assert(!track_sharing::decodeTrackDirectoryAnnouncement(directory, advertisement)); + delivery_address[0] ^= 1; + for (size_t length = 0; length < writer.size(); ++length) + { + directory.app_data.size = length; + assert(!track_sharing::decodeTrackDirectoryAnnouncement(directory, advertisement)); + } +} diff --git a/tests/hostlink/test_track_directories_runtime.cpp b/tests/hostlink/test_track_directories_runtime.cpp new file mode 100644 index 00000000..bf145c88 --- /dev/null +++ b/tests/hostlink/test_track_directories_runtime.cpp @@ -0,0 +1,245 @@ +#include "platform/esp/idf_common/hostlink_track_directories_runtime.h" +#include +#include + +struct Adapter : chat::IMeshAdapter +{ + const chat::lxmf::TrackDeliveryBinding* binding = nullptr; + unsigned binds = 0; + bool allow_bind = true; + bool binding_busy = false; + bool allow_send = true; + unsigned sends = 0; + std::vector network_wire; + unsigned path_requests = 0; + uint8_t path_target[16]{}; + bool allow_path = true; + bool requestTrackDirectoryPath(const uint8_t* destination) override + { + ++path_requests; + std::memcpy(path_target, destination, 16); + return allow_path; + } + chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* key, uint32_t& generation) const override + { + std::memset(key, 7, 64); + generation = 9; + return chat::DeviceAuthorResult::Ok; + } + bool trackLocalDestination(uint8_t* value) override + { + std::memset(value, 8, 16); + return true; + } + chat::MeshSendResult sendTrackData(const uint8_t* destination, chat::lxmf::ByteSpan body, const uint8_t* author, uint32_t generation, bool response, std::array*) override + { + assert(destination[0] == 4 && author[0] == 7 && generation == 9 && !response); + ++sends; + network_wire.assign(body.data, body.data + body.size); + return allow_send ? chat::MeshSendResult::success(1) : chat::MeshSendResult::fail(chat::MeshOperationFailure::Busy); + } + bool bindTrackDelivery(const chat::lxmf::TrackDeliveryBinding* value) override + { + ++binds; + if (!allow_bind) return false; + binding = value; + return true; + } + chat::TrackBindingChange replaceTrackDelivery(const chat::lxmf::TrackDeliveryBinding* expected, const chat::lxmf::TrackDeliveryBinding* replacement) override + { + ++binds; + if (!allow_bind) return chat::TrackBindingChange::Unsupported; + if (binding_busy) return chat::TrackBindingChange::Busy; + if (binding != expected) return chat::TrackBindingChange::Conflict; + binding = replacement; + return chat::TrackBindingChange::Applied; + } + bool sendText(chat::ChannelId, const std::string&, chat::MessageId*, chat::NodeId) override { return false; } + bool pollIncomingText(chat::MeshIncomingText*) override { return false; } + bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, bool, chat::MessageId, bool) override { return false; } + bool pollIncomingData(chat::MeshIncomingData*) override { return false; } + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } +}; +int main() +{ + using namespace hostlink::management; + namespace runtime = directory_runtime; + Adapter adapter; + uint8_t session[16], other[16], body[] = {0x91, 0}, output[320]; + std::memset(session, 1, 16); + std::memset(other, 2, 16); + EnvelopeView request{session, other, uint16_t(Operation::TrackDirectoryList), body, sizeof(body)}; + size_t written = 0; + directory_free_bytes = 1; + assert(runtime::list(adapter, request, 1000, output, sizeof(output), written) == Status::Busy && !runtime::owner && !directory_live_allocations); + directory_free_bytes = 65536; + adapter.allow_bind = false; + assert(runtime::list(adapter, request, 1000, output, sizeof(output), written) == Status::Unsupported && !runtime::owner && !directory_live_allocations); + adapter.allow_bind = true; + assert(runtime::list(adapter, request, 1000, output, sizeof(output), written) == Status::Ok && runtime::owner && directory_live_allocations == 1 && adapter.binding); + assert(written == 3 && output[0] == 0x92 && output[1] == 0xc0 && output[2] == 0x90); + { + uint8_t discover_body[32], target[16]; + std::memset(target, 3, 16); + geocaching::protocol::CmpWriter discovery_request(discover_body, sizeof(discover_body)); + assert(discovery_request.array(1) && discovery_request.binary({target, 16})); + EnvelopeView discovery{session, other, uint16_t(Operation::TrackDirectoryDiscover), discover_body, discovery_request.size()}; + assert(runtime::discover(adapter, discovery, 1000, output, sizeof(output), written) == Status::Ok && written == 1 && output[0] == 0x90); + assert(adapter.path_requests == 1 && !std::memcmp(adapter.path_target, target, 16) && runtime::owner->pool.size() == 0); + adapter.allow_path = false; + assert(runtime::discover(adapter, discovery, 1000, output, sizeof(output), written) == Status::Busy && written == 0); + adapter.allow_path = true; + discovery.session = other; + assert(runtime::discover(adapter, discovery, 1000, output, sizeof(output), written) == Status::Conflict && adapter.path_requests == 2); + discovery.session = session; + std::memset(discover_body + 3, 0, 16); + assert(runtime::discover(adapter, discovery, 1000, output, sizeof(output), written) == Status::InvalidField && adapter.path_requests == 2); + } + uint8_t discovery[16], delivery[16], public_key[64], epoch[16], metadata[128]; + std::memset(discovery, 3, 16); + std::memset(delivery, 4, 16); + std::memset(public_key, 5, 64); + std::memset(epoch, 6, 16); + geocaching::protocol::CmpWriter announcement(metadata, sizeof(metadata)); + assert(announcement.array(5) && announcement.unsignedInteger(1) && announcement.text(track_sharing::kApplicationType) && announcement.binary({delivery, 16}) && announcement.binary({epoch, 16}) && announcement.text("Node")); + chat::lxmf::TrackDirectoryAnnouncementView view{{discovery, 16}, {delivery, 16}, {public_key, 64}, {metadata, announcement.size()}}; + assert(chat::lxmf::deliverVerifiedTrackDirectory(adapter.binding, view)); + assert(runtime::list(adapter, request, 1001, output, sizeof(output), written) == Status::Ok); + geocaching::protocol::CmpReader reader({output, written}); + size_t fields = 0; + uint64_t ready = 0; + geocaching::ByteView field; + std::string_view name; + assert(reader.array(fields, 2) && fields == 2 && reader.nil() && reader.array(fields, 1) && fields == 1 && reader.array(fields, 8) && fields == 8); + assert(reader.binary(field, 16) && field.size == 16 && !std::memcmp(field.data, delivery, 16)); + assert(reader.binary(field, 64) && field.size == 64 && !std::memcmp(field.data, public_key, 64)); + assert(reader.binary(field, 16) && field.size == 16 && !std::memcmp(field.data, epoch, 16)); + assert(reader.text(name, 40) && name == "Node" && reader.unsignedInteger(ready) && ready == 0); + assert(reader.unsignedInteger(ready) && ready == 0 && reader.unsignedInteger(ready) && ready == 0 && reader.unsignedInteger(ready) && ready == 0 && reader.finished()); + runtime::PinnedDirectory pinned; + pinned.file_limit = 123; + assert(runtime::pin(adapter, session, {delivery, 16}, {epoch, 16}, 1002, pinned) == Status::Unauthorized && pinned.file_limit == 123); + uint8_t network_scratch[512], reply[512], local[16], message_hash[32]{}, wrong_source[16]{}; + std::memset(local, 8, 16); + adapter.allow_send = false; + runtime::advance(1002, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 1 && !runtime::owner->pending); + adapter.allow_send = true; + runtime::advance(1003, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 1); + runtime::advance(4003, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 2 && runtime::owner->pending); + runtime::advance(4004, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 2); + geocaching::protocol::CmpReader outgoing({adapter.network_wire.data(), adapter.network_wire.size()}); + uint64_t value = 0; + geocaching::ByteView request_id; + assert(outgoing.array(fields, 6) && fields == 6 && outgoing.unsignedInteger(value) && value == 1 && outgoing.unsignedInteger(value) && value == 0 && + outgoing.unsignedInteger(value) && value == 0 && outgoing.binary(request_id, 16) && request_id.size == 16 && outgoing.unsignedInteger(value) && value == 8192 && + outgoing.array(fields, 0) && fields == 0 && outgoing.finished()); + geocaching::protocol::CmpWriter capabilities(reply, sizeof(reply)); + assert(capabilities.array(6) && capabilities.unsignedInteger(1) && capabilities.unsignedInteger(1) && capabilities.unsignedInteger(0) && + capabilities.binary(request_id) && capabilities.unsignedInteger(200) && capabilities.array(8) && capabilities.unsignedInteger(1) && + capabilities.text(track_sharing::kApplicationType) && capabilities.binary({public_key, 64}) && capabilities.binary({epoch, 16}) && + capabilities.unsignedInteger(8388608) && capabilities.unsignedInteger(1024) && capabilities.unsignedInteger(32) && capabilities.array(9)); + for (unsigned op = 0; op < 9; ++op) assert(capabilities.unsignedInteger(op)); + chat::lxmf::CustomDeliveryView response{{wrong_source, 16}, {local, 16}, {message_hash, 32}, {reply, capabilities.size()}}; + assert(!chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, response) && runtime::owner->pending); + response.source = {delivery, 16}; + assert(!chat::lxmf::deliverVerifiedTrack(adapter.binding, true, false, response)); + assert(chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, response) && !runtime::owner->pending && runtime::owner->pool.at(0)->capabilities_verified); + runtime::advance(4005, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 2); + assert(runtime::list(adapter, request, 4005, output, sizeof(output), written) == Status::Ok); + geocaching::protocol::CmpReader negotiated({output, written}); + assert(negotiated.array(fields, 2) && fields == 2 && negotiated.nil() && negotiated.array(fields, 1) && fields == 1 && negotiated.array(fields, 8) && fields == 8); + for (const auto length : {size_t(16), size_t(64), size_t(16)}) assert(negotiated.binary(field, length) && field.size == length); + assert(negotiated.text(name, 40) && name == "Node" && negotiated.unsignedInteger(value) && value == 1 && + negotiated.unsignedInteger(value) && value == 8388608 && negotiated.unsignedInteger(value) && value == 1024 && negotiated.unsignedInteger(value) && value == 32 && negotiated.finished()); + assert(runtime::pin(adapter, session, {delivery, 16}, {epoch, 16}, 4006, pinned) == Status::Ok); + assert(!std::memcmp(pinned.delivery, delivery, 16) && !std::memcmp(pinned.public_key, public_key, 64) && !std::memcmp(pinned.epoch, epoch, 16)); + assert(pinned.file_limit == 8388608 && pinned.chunk_limit == 1024 && pinned.query_limit == 32); + const auto pinned_before_failure = pinned; + assert(runtime::pin(adapter, other, {delivery, 16}, {epoch, 16}, 4007, pinned) == Status::Unauthorized); + assert(!std::memcmp(pinned.delivery, pinned_before_failure.delivery, 16) && !std::memcmp(pinned.public_key, pinned_before_failure.public_key, 64) && + !std::memcmp(pinned.epoch, pinned_before_failure.epoch, 16) && pinned.file_limit == pinned_before_failure.file_limit && + pinned.chunk_limit == pinned_before_failure.chunk_limit && pinned.query_limit == pinned_before_failure.query_limit); + assert(runtime::pin(adapter, session, {pinned.delivery, 16}, {epoch, 16}, 4007, pinned) == Status::InvalidField); + runtime::owner->locked.test_and_set(); + assert(runtime::pin(adapter, session, {delivery, 16}, {epoch, 16}, 4007, pinned) == Status::Busy); + runtime::owner->locked.clear(); + assert(runtime::pin(adapter, session, {delivery, 16}, {epoch, 16}, 34006, pinned) == Status::Expired); + epoch[0] ^= 1; + assert(runtime::pin(adapter, session, {delivery, 16}, {epoch, 16}, 4007, pinned) == Status::Expired); + directory_clock_us = 5000000; + geocaching::protocol::CmpWriter refreshed(metadata, sizeof(metadata)); + assert(refreshed.array(5) && refreshed.unsignedInteger(1) && refreshed.text(track_sharing::kApplicationType) && refreshed.binary({delivery, 16}) && refreshed.binary({epoch, 16}) && refreshed.text("Node")); + view.app_data.size = refreshed.size(); + assert(chat::lxmf::deliverVerifiedTrackDirectory(adapter.binding, view) && !runtime::owner->pool.at(0)->capabilities_verified); + runtime::advance(5000, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 3 && runtime::owner->pending); + assert(!chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, response) && runtime::owner->pending); + geocaching::protocol::CmpReader fresh({adapter.network_wire.data(), adapter.network_wire.size()}); + geocaching::ByteView fresh_id; + assert(fresh.array(fields, 6) && fields == 6 && fresh.unsignedInteger(value) && value == 1 && fresh.unsignedInteger(value) && value == 0 && fresh.unsignedInteger(value) && value == 0 && fresh.binary(fresh_id, 16)); + geocaching::protocol::CmpWriter late(reply, sizeof(reply)); + assert(late.array(6) && late.unsignedInteger(1) && late.unsignedInteger(1) && late.unsignedInteger(0) && late.binary(fresh_id) && late.unsignedInteger(200) && + late.array(8) && late.unsignedInteger(1) && late.text(track_sharing::kApplicationType) && late.binary({public_key, 64}) && late.binary({epoch, 16}) && + late.unsignedInteger(8388608) && late.unsignedInteger(1024) && late.unsignedInteger(32) && late.array(9)); + for (unsigned op = 0; op < 9; ++op) assert(late.unsignedInteger(op)); + response.data.size = late.size(); + track_sharing::TrackDirectoryCapabilities late_capabilities; + assert(track_sharing::decodeTrackCapabilities({reply, late.size()}, fresh_id, late_capabilities)); + directory_clock_us = 20001000; + assert(!chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, response) && runtime::owner->pending); + runtime::advance(20001, true, network_scratch, sizeof(network_scratch)); + assert(!runtime::owner->pending && adapter.sends == 3); + assert(runtime::list(adapter, request, 20002, output, sizeof(output), written) == Status::Ok); + runtime::advance(20003, true, network_scratch, sizeof(network_scratch)); + assert(adapter.sends == 3); + request.session = other; + assert(runtime::list(adapter, request, 20003, output, sizeof(output), written) == Status::Conflict); + request.session = session; + runtime::owner->locked.test_and_set(); + assert(runtime::list(adapter, request, 20003, output, sizeof(output), written) == Status::Busy); + assert(!chat::lxmf::deliverVerifiedTrackDirectory(adapter.binding, view)); + runtime::owner->locked.clear(); + runtime::closeSession(other); + assert(runtime::owner); + runtime::advance(50002, true); + assert(!runtime::owner && !adapter.binding && !directory_live_allocations); + assert(runtime::list(adapter, request, 70000, output, sizeof(output), written) == Status::Ok); + runtime::advance(70001, false); + assert(!runtime::owner && !adapter.binding && !directory_live_allocations); + assert(runtime::list(adapter, request, 73000, output, sizeof(output), written) == Status::Ok); + runtime::closeSession(session); + assert(!runtime::owner && !adapter.binding && !directory_live_allocations); + assert(!std::memcmp(pinned.public_key, public_key, 64) && !std::memcmp(pinned.epoch, pinned_before_failure.epoch, 16)); + uint8_t persisted[track_sharing::kPublicationDirectoryBytes]; + track_sharing::PublicationDirectory recovered; + assert(track_sharing::encodePublicationDirectory(pinned, persisted, sizeof(persisted))); + assert(track_sharing::decodePublicationDirectory(persisted, sizeof(persisted), recovered)); + assert(track_sharing::publicationDirectoriesEqual(pinned, recovered)); + chat::lxmf::TrackDeliveryBinding job; + job.handler=[](const chat::lxmf::CustomDeliveryView&,void*) {return false;}; + job.expected_local_author=public_key;job.expected_author_generation=1; + assert(runtime::list(adapter,request,80000,output,sizeof(output),written)==Status::Ok && runtime::owner && directory_live_allocations==1); + const auto* directory_binding=adapter.binding; + adapter.binding_busy=true; + assert(runtime::handover(adapter,&job)==chat::TrackBindingChange::Busy && adapter.binding==directory_binding && runtime::owner && directory_live_allocations==1); + assert(!runtime::release() && runtime::owner && adapter.binding==directory_binding); + adapter.binding_busy=false; + assert(runtime::handover(adapter,&job)==chat::TrackBindingChange::Applied && !runtime::owner && adapter.binding==&job && !directory_live_allocations); + runtime::closeSession(session); + assert(adapter.binding==&job); + assert(runtime::list(adapter,request,80001,output,sizeof(output),written)==Status::Busy && !runtime::owner && adapter.binding==&job && !directory_live_allocations); + assert(adapter.replaceTrackDelivery(&job,nullptr)==chat::TrackBindingChange::Applied); + assert(runtime::list(adapter,request,80002,output,sizeof(output),written)==Status::Ok && runtime::owner); + // Another owner has replaced the slot. Retire the obsolete directory + // context after observing Conflict, without clearing the new registration. + adapter.binding=&job; + assert(runtime::release() && !runtime::owner && adapter.binding==&job && !directory_live_allocations); + assert(adapter.replaceTrackDelivery(&job,nullptr)==chat::TrackBindingChange::Applied); +} diff --git a/tests/hostlink/test_track_directory_pool.cpp b/tests/hostlink/test_track_directory_pool.cpp new file mode 100644 index 00000000..61b3c6e7 --- /dev/null +++ b/tests/hostlink/test_track_directory_pool.cpp @@ -0,0 +1,70 @@ +#include "track_sharing/directory_pool.h" +#include + +int main() +{ + using namespace track_sharing; + TrackDirectoryEntry entries[1]{}; + TrackDirectoryPool pool(entries, 1); + uint8_t discovery[16], delivery[16], public_key[64], epoch[16], metadata[128]; + std::memset(discovery, 1, 16); + std::memset(delivery, 2, 16); + std::memset(public_key, 3, 64); + std::memset(epoch, 4, 16); + auto encode = [&] + { + geocaching::protocol::CmpWriter writer(metadata, sizeof(metadata)); + assert(writer.array(5) && writer.unsignedInteger(1) && writer.text(kApplicationType) && writer.binary({delivery, 16}) && writer.binary({epoch, 16}) && writer.text("Directory")); + return writer.size(); + }; + chat::lxmf::TrackDirectoryAnnouncementView view{{discovery, 16}, {delivery, 16}, {public_key, 64}, {metadata, encode()}}; + assert(pool.observe(view, 100) && pool.size() == 1 && pool.at(0) && !pool.at(1)); + assert(pool.find({delivery, 16}, {epoch, 16}) == pool.at(0)); + uint8_t request[16], response[256]; + std::memset(request, 5, 16); + auto capability_response = [&](uint16_t chunk = 1024, uint8_t operations = 9) + { + geocaching::protocol::CmpWriter writer(response, sizeof(response)); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && writer.binary({request, 16}) && + writer.unsignedInteger(200) && writer.array(8) && writer.unsignedInteger(1) && writer.text(kApplicationType) && writer.binary({public_key, 64}) && + writer.binary({epoch, 16}) && writer.unsignedInteger(8388608) && writer.unsignedInteger(chunk) && writer.unsignedInteger(32) && writer.array(operations)); + for (unsigned i = 0; i < operations; ++i) assert(writer.unsignedInteger(i)); + return writer.size(); + }; + auto response_size = capability_response(); + assert(pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + assert(entries[0].capabilities_verified && entries[0].file_limit == 8388608 && entries[0].chunk_limit == 1024 && entries[0].query_limit == 32); + for (size_t length = 0; length < response_size; ++length) + assert(!pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, length}, {request, 16})); + request[0] ^= 1; + assert(!pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + request[0] ^= 1; + response_size = capability_response(0); + assert(!pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + response_size = capability_response(1024, 8); + assert(!pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + public_key[0] ^= 1; + response_size = capability_response(); + public_key[0] ^= 1; + assert(!pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + assert(pool.observe(view, 101) && entries[0].capabilities_verified); + public_key[0] ^= 1; + assert(!pool.observe(view, 102) && entries[0].public_key[0] == 3); + public_key[0] ^= 1; + assert(!pool.observe(view, 99) && entries[0].last_seen == 101); + uint8_t old_epoch[16]; + std::memcpy(old_epoch, epoch, 16); + epoch[0] ^= 1; + view.app_data.size = encode(); + assert(pool.observe(view, 103) && !entries[0].capabilities_verified && !entries[0].file_limit && !entries[0].chunk_limit && !entries[0].query_limit); + assert(!pool.find({delivery, 16}, {old_epoch, 16}) && pool.find({delivery, 16}, {epoch, 16})); + response_size = capability_response(); + assert(pool.confirmCapabilities({delivery, 16}, {epoch, 16}, {response, response_size}, {request, 16})); + assert(!pool.confirmCapabilities({delivery, 16}, {old_epoch, 16}, {response, response_size}, {request, 16})); + discovery[0] ^= 1; + delivery[0] ^= 1; + view.app_data.size = encode(); + assert(!pool.observe(view, 104) && pool.size() == 1 && entries[0].last_seen == 103); + TrackDirectoryPool empty(nullptr, 8); + assert(!empty.observe(view, 104) && empty.size() == 0); +} diff --git a/tests/hostlink/test_track_file_integrity.cpp b/tests/hostlink/test_track_file_integrity.cpp new file mode 100644 index 00000000..985d95ea --- /dev/null +++ b/tests/hostlink/test_track_file_integrity.cpp @@ -0,0 +1,95 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/file_integrity.h" +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Io +{ + enum class Mode + { + Read + }; + struct OpenResult + { + void* handle; + }; + std::vector bytes; + size_t offset = 0; + bool opened = false, short_read = false, close_fail = false; + OpenResult open(const char*, Mode) + { + assert(!opened); + opened = true; + return {this}; + } + bool close(void*) + { + opened = false; + return !close_fail; + } + bool size(void*, uint64_t& value) + { + value = bytes.size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= bytes.size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + assert(length <= 320); + if (short_read) return 0; + std::memcpy(output, bytes.data() + offset, length); + return length; + } +}; +int main() +{ + Io io; + for (unsigned i = 0; i < 750; ++i) io.bytes.push_back(uint8_t(i % 251)); + const char* hex = "c6f564348770880184e0ec9f58c70d4fc8240c38171a7c3278c9fd0bb54d2d72"; + uint8_t expected[32], scratch[320]; + for (unsigned i = 0; i < 32; ++i) + { + unsigned byte = 0; + std::sscanf(hex + i * 2, "%2x", &byte); + expected[i] = uint8_t(byte); + } + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + track_sharing::FileIntegrityCheck check(io, hash); + static_assert(sizeof(check) <= 80, "Integrity check grew a buffer"); + using Result = track_sharing::FileIntegrityResult; + assert(check.begin("device.kml", 750, expected) == Result::Pending); + for (unsigned i = 0; i < 3; ++i) + { + assert(check.step(scratch, sizeof(scratch)) == Result::Pending && !io.opened); + } + assert(check.checkedBytes() == 750 && check.step(scratch, sizeof(scratch)) == Result::Ready && !check.active()); + expected[0] ^= 1; + assert(check.begin("device.kml", 750, expected) == Result::Pending); + for (unsigned i = 0; i < 3; ++i) check.step(scratch, sizeof(scratch)); + assert(check.step(scratch, sizeof(scratch)) == Result::HashMismatch); + expected[0] ^= 1; + for (unsigned mode = 0; mode < 3; ++mode) + { + assert(check.begin("device.kml", 750, expected) == Result::Pending); + io.short_read = mode == 0; + io.close_fail = mode == 1; + if (mode == 2) io.bytes.push_back(0); + assert(check.step(scratch, sizeof(scratch)) == Result::StorageFailure && !io.opened); + io.short_read = false; + io.close_fail = false; + if (mode == 2) io.bytes.pop_back(); + } + assert(check.begin("device.kml", 750, expected) == Result::Pending && check.cancel() == Result::Cancelled && !check.active()); +} diff --git a/tests/hostlink/test_track_geometry_bounds.cpp b/tests/hostlink/test_track_geometry_bounds.cpp new file mode 100644 index 00000000..3e743772 --- /dev/null +++ b/tests/hostlink/test_track_geometry_bounds.cpp @@ -0,0 +1,16 @@ +#include "track_sharing/geometry_bounds.h" +#include +int main() +{ + track_sharing::GeometryBounds bounds; + track_sharing::GeometryExtent output; + assert(!bounds.finish(output)); + assert(bounds.add(1790000000, 10000000) && bounds.add(-1790000000, -20000000) && bounds.finish(output)); + assert(output.west == 1790000000 && output.east == -1790000000 && output.south == -20000000 && output.north == 10000000 && output.points == 2); + track_sharing::GeometryBounds edge; + assert(edge.add(1800000000, 900000000) && edge.finish(output) && output.west == -1800000000); + track_sharing::GeometryBounds limit; + for (unsigned i = 0; i < 100000; ++i) assert(limit.add(0, 0)); + assert(limit.finish(output) && output.points == 100000); + assert(!limit.add(0, 0) && !limit.finish(output)); +} diff --git a/tests/hostlink/test_track_ip_confirmation_request.cpp b/tests/hostlink/test_track_ip_confirmation_request.cpp new file mode 100644 index 00000000..c13e84e8 --- /dev/null +++ b/tests/hostlink/test_track_ip_confirmation_request.cpp @@ -0,0 +1,73 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "track_sharing/ip_confirmation_request.h" +#include +#include +int main() +{ + std::array wire{}; + std::array key{}; + key.fill(1); + std::array hash{}; + hash.fill(2); + std::array id{}; + id.fill(3); + const auto encode = [&](uint64_t generation, uint64_t revision, size_t id_size = 16, bool tail = false) + { + geocaching::protocol::CmpWriter writer(wire.data(), wire.size()); + assert(writer.array(11) && writer.binary({id.data(), id_size}) && writer.binary({id.data(), 16}) && + writer.binary({hash.data(), 32}) && writer.unsignedInteger(generation) && writer.binary({key.data(), 64}) && + writer.binary({id.data(), 16}) && writer.binary({id.data(), 16}) && writer.binary({id.data(), 16}) && + writer.binary({hash.data(), 32}) && writer.binary({hash.data(), 32}) && writer.unsignedInteger(revision)); + if (tail) assert(writer.nil()); + return writer.size(); + }; + track_sharing::IpConfirmationRequest parsed; + auto size = encode(UINT32_MAX, UINT32_MAX); + assert(size <= 288 && track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + assert(parsed.generation == UINT32_MAX && parsed.revision == UINT32_MAX); + assert(parsed.operation.data >= wire.data() && parsed.operation.data < wire.data() + size); + for (size_t truncated = 0; truncated < size; ++truncated) + { + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), truncated}, parsed)); + assert(!parsed.operation.data && parsed.generation == 0); + } + for (const auto value : {uint64_t(0), uint64_t(UINT32_MAX) + 1}) + { + size = encode(value, 1); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + size = encode(1, value); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + } + size = encode(1, 1, 15); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + size = encode(1, 1, 16, true); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + id.fill(0); + size = encode(1, 1); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + id.fill(3); + hash.fill(0); + size = encode(1, 1); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + hash.fill(2); + key.fill(0); + size = encode(1, 1); + assert(!track_sharing::decodeIpConfirmationRequest({wire.data(), size}, parsed)); + parsed.generation = 7; + assert(!track_sharing::decodeIpConfirmationRequest({reinterpret_cast(&parsed), sizeof(parsed)}, parsed)); + assert(parsed.generation == 7); + { + key.fill(1); + id.fill(3); + hash.fill(2); + std::array imported; + geocaching::protocol::CmpWriter writer(imported.data(), imported.size()); + assert(writer.array(14) && writer.binary({id.data(), 16}) && writer.binary({id.data(), 16}) && writer.binary({hash.data(), 32}) && + writer.unsignedInteger(UINT32_MAX) && writer.binary({key.data(), 64}) && writer.binary({id.data(), 16}) && writer.binary({id.data(), 16}) && writer.binary({id.data(), 16}) && + writer.binary({hash.data(), 32}) && writer.binary({hash.data(), 32}) && writer.unsignedInteger(UINT32_MAX) && + writer.binary({id.data(), 16}) && writer.binary({hash.data(), 32}) && writer.binary({key.data(), 64})); + assert(writer.size() <= 416 && !track_sharing::decodeIpConfirmationRequest({imported.data(), writer.size()}, parsed)); + assert(track_sharing::decodeIpConfirmationRequest({imported.data(), writer.size()}, parsed, true) && parsed.manifest_signature.size == 64); + for (size_t truncated = 0; truncated < writer.size(); ++truncated) assert(!track_sharing::decodeIpConfirmationRequest({imported.data(), truncated}, parsed, true)); + } +} diff --git a/tests/hostlink/test_track_manifest.cpp b/tests/hostlink/test_track_manifest.cpp new file mode 100644 index 00000000..89a559bf --- /dev/null +++ b/tests/hostlink/test_track_manifest.cpp @@ -0,0 +1,22 @@ +#include "track_sharing/manifest.h" +#include +#include +#include +#include +int main(int argc, char** argv) +{ + const std::string hex = argc == 2 ? argv[1] : "9e01c44001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101c410020202020202020202020202020202020001c000cf000000012a05f200cf000000012a05f200a174a094000000009400c0c00194c420030303030303030303030303030303030303030303030303030303030303030301d9246170706c69636174696f6e2f766e642e676f6f676c652d65617274682e6b6d6c2b786d6c01"; + if (hex.size() % 2) return 1; + std::vector bytes; + for (size_t i = 0; i < hex.size(); i += 2) + { + unsigned value = 0; + if (std::sscanf(hex.substr(i, 2).c_str(), "%2x", &value) != 1) return 1; + bytes.push_back(uint8_t(value)); + } + track_sharing::ManifestView view; + if (!track_sharing::decodeManifest({bytes.data(), bytes.size()}, view)) return 1; + if(argc != 2 && (view.created != 5000000000ULL || view.points != 1)) return 1; + std::cout << view.created << ',' << view.points << '\n'; + return 0; +} diff --git a/tests/hostlink/test_track_manifest_chain.cpp b/tests/hostlink/test_track_manifest_chain.cpp new file mode 100644 index 00000000..76de0930 --- /dev/null +++ b/tests/hostlink/test_track_manifest_chain.cpp @@ -0,0 +1,42 @@ +#include "track_sharing/manifest_chain.h" +#include +int main() +{ + uint8_t author[64] = {1}, nonce[16] = {2}, hash[32] = {3}, file[32] = {4}, raw = 0; + track_sharing::ManifestView parent, child; + parent.raw = {&raw, 1}; + parent.author = {author, 64}; + parent.nonce = {nonce, 16}; + parent.revision = 1; + parent.created = 100; + parent.updated = 100; + parent.file_hash = {file, 32}; + parent.file_bytes = 200; + child = parent; + child.revision = 2; + child.previous = {hash, 32}; + child.updated = 101; + assert(track_sharing::validateSuccessor(parent, child, hash)); + child.state = 1; + assert(track_sharing::validateSuccessor(parent, child, hash)); + child.file_bytes++; + assert(!track_sharing::validateSuccessor(parent, child, hash)); + child.file_bytes--; + child.created++; + assert(!track_sharing::validateSuccessor(parent, child, hash)); + child.created--; + child.updated = 99; + assert(!track_sharing::validateSuccessor(parent, child, hash)); + child.updated = 101; + child.revision = 3; + assert(!track_sharing::validateSuccessor(parent, child, hash)); + child.revision = 2; + uint8_t different[32] = {9}; + assert(!track_sharing::validateSuccessor(parent, child, different)); + parent.state = 1; + child.state = 0; + assert(!track_sharing::validateSuccessor(parent, child, hash)); + parent.revision = UINT32_MAX; + child.revision = 0; + assert(!track_sharing::validateSuccessor(parent, child, hash)); +} diff --git a/tests/hostlink/test_track_manifest_extensions.cpp b/tests/hostlink/test_track_manifest_extensions.cpp new file mode 100644 index 00000000..3bded417 --- /dev/null +++ b/tests/hostlink/test_track_manifest_extensions.cpp @@ -0,0 +1,64 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/attachment_index.h" +#include "track_sharing/manifest_chain.h" +#include +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +static bool readBytes(std::vector& output) +{ + std::string hex; + if (!std::getline(std::cin, hex) || hex.empty() || hex.size() % 2 || hex.size() > 2 * track_sharing::kMaxAttachmentIndexBytes) return false; + const auto digit = [](char c) + { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : -1; }; + for (size_t i = 0; i < hex.size(); i += 2) + { + if (digit(hex[i]) < 0 || digit(hex[i + 1]) < 0) return false; + output.push_back(static_cast(digit(hex[i]) * 16 + digit(hex[i + 1]))); + } + return true; +} +int main() +{ + std::string mode; + if (!std::getline(std::cin, mode)) + { + assert(track_sharing::attachmentReferenceValid("images/山.jpg")); + assert(!track_sharing::attachmentReferenceValid("images/%2e%2e/a.jpg")); + assert(!track_sharing::attachmentReferenceValid("javascript:bad")); + assert(track_sharing::attachmentMimeValid("image/png")); + assert(!track_sharing::attachmentMimeValid("image/png;bad")); + assert(sizeof(track_sharing::ManifestView) == ((offsetof(track_sharing::ManifestView, has_ascent) + sizeof(bool) + alignof(track_sharing::ManifestView) - 1) / alignof(track_sharing::ManifestView)) * alignof(track_sharing::ManifestView)); + return 0; + } + std::vector a, b, parent_hash; + if (!readBytes(a) || !readBytes(b)) return 1; + track_sharing::ManifestView view; + if (!track_sharing::decodeManifest({b.data(), b.size()}, view)) return 1; + if (mode == "CHAIN") + { + track_sharing::ManifestView previous; + if (!readBytes(parent_hash) || parent_hash.size() != 32 || !track_sharing::decodeManifest({a.data(), a.size()}, previous)) return 1; + return track_sharing::validateSuccessor(previous, view, parent_hash.data()) ? 0 : 1; + } + if (mode != "INDEX") return 1; + track_sharing::ManifestExtensionsView extensions; + if (!track_sharing::getManifestExtensions(view, extensions)) return 1; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + track_sharing::AttachmentIndexCursor cursor({}); + track_sharing::AttachmentEntryView entry; + uint8_t digest[32]; + if (!track_sharing::validateAttachmentIndexBinding({a.data(), a.size()}, extensions.assets, view.file_hash, view.file_bytes, hash, digest, cursor, entry)) return 1; + assert(cursor.finish()); + std::cout << cursor.count() << ',' << cursor.requiredCount() << ',' << cursor.packageBytes() << '\n'; +} diff --git a/tests/hostlink/test_track_manifest_hash.cpp b/tests/hostlink/test_track_manifest_hash.cpp new file mode 100644 index 00000000..96b1b8ab --- /dev/null +++ b/tests/hostlink/test_track_manifest_hash.cpp @@ -0,0 +1,77 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/manifest_hash.h" +#include "track_sharing/reservation_candidate.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +int main(int argc, char** argv) +{ + const std::string hex = argc == 2 ? argv[1] : "9e01c44001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101c410020202020202020202020202020202020001c000cf000000012a05f200cf000000012a05f200a174a094000000009400c0c00194c420030303030303030303030303030303030303030303030303030303030303030301d9246170706c69636174696f6e2f766e642e676f6f676c652d65617274682e6b6d6c2b786d6c01"; + std::vector raw; + for (size_t i = 0; i < hex.size(); i += 2) + { + unsigned byte = 0; + assert(std::sscanf(hex.substr(i, 2).c_str(), "%2x", &byte) == 1); + raw.push_back(uint8_t(byte)); + } + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + track_sharing::ManifestView view; + uint8_t id[32], revision[32]; + assert(!track_sharing::deriveManifestHashes({raw.data(), raw.size()}, view, hash, id, id)); + assert(track_sharing::deriveManifestHashes({raw.data(), raw.size()}, view, hash, id, revision)); + { + track_sharing::RevisionReservation reserved; + uint8_t author_hash[32], scratch[64]; + assert(hash.start() && hash.update(view.author.data, 64) && hash.finish(author_hash)); + reserved.operation[0] = 1; + reserved.sequence = 1; + reserved.generation = 7; + reserved.revision = view.revision; + reserved.phase = track_sharing::ReservationPhase::Checked; + std::memcpy(reserved.object, id, 32); + std::memcpy(reserved.candidate, revision, 32); + std::memcpy(reserved.author, author_hash, 32); + if (view.previous.size) std::memcpy(reserved.parent, view.previous.data, 32); + const geocaching::ByteView source{raw.data(), raw.size()}, author = view.author; + assert(track_sharing::reservationMatchesCandidate(source, author, 7, {author_hash, 32}, reserved, hash, view, scratch, sizeof(scratch))); + reserved.candidate[31] ^= 1; + assert(!track_sharing::reservationMatchesCandidate(source, author, 7, {author_hash, 32}, reserved, hash, view, scratch, sizeof(scratch))); + reserved.candidate[31] ^= 1; + assert(!track_sharing::reservationMatchesCandidate(source, author, 8, {author_hash, 32}, reserved, hash, view, scratch, sizeof(scratch))); + reserved.phase = track_sharing::ReservationPhase::Signed; + assert(!track_sharing::reservationMatchesCandidate(source, author, 7, {author_hash, 32}, reserved, hash, view, scratch, sizeof(scratch))); + } + for (auto byte : id) std::printf("%02x", byte); + std::puts(""); + for (auto byte : revision) std::printf("%02x", byte); + std::puts(""); + struct FailureHash + { + unsigned steps = 0, resets = 0; + bool start() + { + ++steps; + return true; + } + bool update(const uint8_t*, size_t) { return ++steps != 7; } + bool finish(uint8_t* output) + { + ++steps; + std::memset(output, 0x55, 32); + return true; + } + void reset() { ++resets; } + } failure; + assert(!track_sharing::deriveManifestHashes({raw.data(), raw.size()}, view, failure, id, revision)); + assert(failure.resets == 1); + for (unsigned i = 0; i < 32; ++i) assert(id[i] == 0 && revision[i] == 0); +} diff --git a/tests/hostlink/test_track_manifest_sign.cpp b/tests/hostlink/test_track_manifest_sign.cpp new file mode 100644 index 00000000..951e84ce --- /dev/null +++ b/tests/hostlink/test_track_manifest_sign.cpp @@ -0,0 +1,82 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "geocaching/protocol/cmp_writer.h" +#include "track_sharing/device_signer.h" +#include "track_sharing/sign_manifest.h" +#include +#include +struct Identity +{ + uint8_t pub[64]{}, secret[64]{}; + uint32_t generation = 7; + unsigned calls = 0; + bool change_generation = false; + uint32_t authorGeneration() const { return generation; } + bool ready = true, storage_error = false; + bool isReady() const { return ready; } + bool identityStorageError() const { return storage_error; } + void combinedPublicKey(uint8_t* output) { std::memcpy(output, pub, 64); } + bool sign(const uint8_t* message, size_t length, uint8_t* signature) + { + ed25519_sign(signature, message, length, pub + 32, secret); + ++calls; + if (change_generation) ++generation; + return true; + } +}; +static uint8_t raw[4096], workspace[4200], signature[64]; +int main(int argc, char** argv) +{ + const unsigned version = argc == 2 && std::strcmp(argv[1], "2") == 0 ? 2 : 1; + Identity identity; + uint8_t seed[32]{}, nonce[16] = {1}, hash[32] = {2}; + for (unsigned i = 0; i < 32; ++i) + { + seed[i] = uint8_t(i); + identity.pub[i] = 3; + } + ed25519_create_keypair(identity.pub + 32, identity.secret, seed); + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(version == 1 ? 14 : 16) && writer.unsignedInteger(version) && writer.binary({identity.pub, 64}) && writer.binary({nonce, 16}) && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.nil() && writer.unsignedInteger(0) && writer.unsignedInteger(5000000000ULL) && writer.unsignedInteger(5000000001ULL) && writer.text("山径") && writer.text("原生签名")); + assert(writer.array(4)); + for (unsigned i = 0; i < 4; ++i) assert(writer.unsignedInteger(0)); + assert(writer.array(4) && writer.unsignedInteger(123) && writer.nil() && writer.nil() && writer.unsignedInteger(2) && writer.array(4) && writer.binary({hash, 32}) && writer.unsignedInteger(300) && writer.text("application/vnd.google-earth.kml+xml") && writer.unsignedInteger(1)); + if (version == 2) + assert(writer.array(8) && writer.unsignedInteger(1) && writer.array(1) && writer.text("hike") && writer.unsignedInteger(2) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && writer.unsignedInteger(0) && writer.unsignedInteger(1) && writer.nil() && writer.nil()); + track_sharing::ManifestView view; + const geocaching::ByteView bytes{raw, writer.size()}; + assert(!track_sharing::signManifest(bytes, identity, view, raw, sizeof(raw), signature, sizeof(signature))); + assert(!track_sharing::signManifest(bytes, identity, view, workspace, 1, signature, sizeof(signature))); + raw[4] ^= 1; + assert(!track_sharing::signManifest(bytes, identity, view, workspace, sizeof(workspace), signature, sizeof(signature))); + raw[4] ^= 1; + assert(track_sharing::signManifest(bytes, identity, view, workspace, sizeof(workspace), signature, sizeof(signature))); + const auto calls = identity.calls; + assert(!track_sharing::signManifestForGeneration(bytes, identity, 8, view, workspace, sizeof(workspace), signature, sizeof(signature)) && identity.calls == calls); + for (auto byte : signature) assert(!byte); + identity.change_generation = true; + assert(!track_sharing::signManifestForGeneration(bytes, identity, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) && identity.generation == 8); + for (auto byte : signature) assert(!byte); + identity.change_generation = false; + identity.generation = 7; + assert(track_sharing::signManifestForGeneration(bytes, identity, 7, view, workspace, sizeof(workspace), signature, sizeof(signature))); + using Result = chat::DeviceAuthorResult; + identity.ready = false; + assert(track_sharing::signDeviceTrackManifest(identity, raw, bytes.size, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) == Result::Unavailable); + identity.storage_error = true; + assert(track_sharing::signDeviceTrackManifest(identity, raw, bytes.size, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) == Result::StorageError); + identity.ready = true; + identity.storage_error = false; + identity.generation = 0; + assert(track_sharing::signDeviceTrackManifest(identity, raw, bytes.size, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) == Result::StorageError); + identity.generation = 8; + assert(track_sharing::signDeviceTrackManifest(identity, raw, bytes.size, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) == Result::StaleIdentity); + for (auto byte : signature) assert(!byte); + identity.generation = 7; + assert(track_sharing::signDeviceTrackManifest(identity, raw, bytes.size, 7, view, workspace, sizeof(workspace), signature, sizeof(signature)) == Result::Ok); + assert(ed25519_verify(signature, workspace, sizeof("TrailMate.Track.Manifest.v1") + bytes.size, identity.pub + 32)); + assert(view.version == version && workspace[sizeof("TrailMate.Track.Manifest.v1") - 2] == (version == 2 ? '2' : '1')); + for (size_t i = 0; i < bytes.size; ++i) std::printf("%02x", raw[i]); + std::puts(""); + for (auto byte : signature) std::printf("%02x", byte); + std::puts(""); +} diff --git a/tests/hostlink/test_track_manifest_verify.cpp b/tests/hostlink/test_track_manifest_verify.cpp new file mode 100644 index 00000000..9519feda --- /dev/null +++ b/tests/hostlink/test_track_manifest_verify.cpp @@ -0,0 +1,50 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/verify_manifest.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Crypto +{ + track_sharing::ManifestVerification verify(geocaching::ByteView key, geocaching::ByteView signature, geocaching::ByteView message) { return ed25519_verify(signature.data, message.data, message.size, key.data) ? track_sharing::ManifestVerification::Valid : track_sharing::ManifestVerification::InvalidSignature; } +}; +static uint8_t workspace[4200]; +static track_sharing::VerifiedManifestView output; +int main(int argc, char** argv) +{ + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + Crypto crypto; + if (argc != 2) + { + assert(track_sharing::verifyManifest({}, crypto, hash, workspace, sizeof(workspace), output) == track_sharing::ManifestVerification::InvalidManifest && !output.verified); + return 0; + } + const std::string hex = argv[1]; + std::vector raw; + for (size_t i = 0; i < hex.size(); i += 2) + { + unsigned byte = 0; + if (std::sscanf(hex.substr(i, 2).c_str(), "%2x", &byte) != 1) return 1; + raw.push_back(uint8_t(byte)); + } + const auto result = track_sharing::verifyManifest({raw.data(), raw.size()}, crypto, hash, workspace, sizeof(workspace), output); + if (result != track_sharing::ManifestVerification::Valid) + { + assert(!output.verified); + return 1; + } + assert(output.verified); + for (auto byte : output.object_id) std::printf("%02x", byte); + std::puts(""); + for (auto byte : output.revision_hash) std::printf("%02x", byte); + std::puts(""); +} diff --git a/tests/hostlink/test_track_network_read.cpp b/tests/hostlink/test_track_network_read.cpp new file mode 100644 index 00000000..b9ac1809 --- /dev/null +++ b/tests/hostlink/test_track_network_read.cpp @@ -0,0 +1,92 @@ +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/network_read.h" +#include +#include +#include + +int main() +{ + std::array id{}; + std::array object{}, revision{}; + std::array author{}; + std::array output{}; + id.fill(1); + object.fill(2); + revision.fill(3); + author.fill(4); + const geocaching::ByteView request{id.data(), id.size()}, target{object.data(), object.size()}, pin{revision.data(), revision.size()}; + size_t written = 0; + auto header = [&](geocaching::protocol::CmpReader& reader, uint64_t operation, size_t fields) + { + size_t count = 0; + uint64_t value = 0; + geocaching::ByteView actual; + assert(reader.array(count, 6) && count == 6); + assert(reader.unsignedInteger(value) && value == 1); + assert(reader.unsignedInteger(value) && value == 0); + assert(reader.unsignedInteger(value) && value == operation); + assert(reader.binary(actual, 16) && actual.size == 16 && !std::memcmp(actual.data, id.data(), 16)); + assert(reader.unsignedInteger(value) && value == 8192); + assert(reader.array(count, fields) && count == fields); + }; + assert(track_sharing::encodeTrackReadManifest(request, target, {}, 8192, output.data(), output.size(), written)); + { + geocaching::protocol::CmpReader reader({output.data(), written}); + header(reader, 2, 2); + geocaching::ByteView value; + assert(reader.binary(value, 32) && value.size == 32); + assert(reader.nil() && reader.finished()); + } + assert(track_sharing::encodeTrackReadManifest(request, target, pin, 8192, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackReadManifest(request, target, pin, 8192, output.data(), 20, written) && !written); + assert(!track_sharing::encodeTrackReadManifest(request, {output.data(), 32}, {}, 8192, output.data(), output.size(), written)); + assert(track_sharing::encodeTrackReadFile(request, target, 8388607, 1, 8192, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackReadFile(request, target, 8388607, 2, 8192, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackReadFile(request, target, 0, 1025, 8192, output.data(), output.size(), written)); + track_sharing::ReadQuery query; + query.west = 1700000000; + query.east = -1700000000; + query.south = -10000000; + query.north = 10000000; + query.author = {author.data(), author.size()}; + query.kind = 1; + query.state = -1; + query.has_snapshot = query.has_cursor = true; + query.snapshot = 9007199254740993ULL; + query.cursor_object = target; + query.cursor_binding = pin; + assert(track_sharing::encodeTrackReadQuery(request, query, 8192, output.data(), output.size(), written)); + { + geocaching::protocol::CmpReader reader({output.data(), written}); + header(reader, 1, 7); + size_t count; + int64_t value; + uint64_t number; + geocaching::ByteView binary; + assert(reader.array(count, 4) && count == 4); + assert(reader.signedInteger(value) && value == query.west); + assert(reader.signedInteger(value) && value == query.south); + assert(reader.signedInteger(value) && value == query.east); + assert(reader.signedInteger(value) && value == query.north); + assert(reader.unsignedInteger(number) && number == 1); + assert(reader.binary(binary, 64) && binary.size == 64); + assert(reader.nil()); + assert(reader.unsignedInteger(number) && number == 16); + assert(reader.unsignedInteger(number) && number == query.snapshot); + assert(reader.array(count, 2) && count == 2); + assert(reader.binary(binary, 32) && binary.size == 32); + assert(reader.binary(binary, 32) && binary.size == 32); + assert(reader.finished()); + } + query.has_snapshot = false; + assert(!track_sharing::encodeTrackReadQuery(request, query, 8192, output.data(), output.size(), written)); + query.has_cursor = false; + query.limit = 33; + assert(!track_sharing::encodeTrackReadQuery(request, query, 8192, output.data(), output.size(), written)); + query.limit = 16; + query.west = 1800000000; + assert(!track_sharing::encodeTrackReadQuery(request, query, 8192, output.data(), output.size(), written)); + query.west = 0; + id.fill(0); + assert(!track_sharing::encodeTrackReadQuery(request, query, 8192, output.data(), output.size(), written)); +} diff --git a/tests/hostlink/test_track_network_request.cpp b/tests/hostlink/test_track_network_request.cpp new file mode 100644 index 00000000..26f4561f --- /dev/null +++ b/tests/hostlink/test_track_network_request.cpp @@ -0,0 +1,57 @@ +#include "geocaching/protocol/cmp_reader.h" +#include "track_sharing/network_request.h" +#include +#include +#include + +int main() +{ + std::array id{}, token{}; + std::array hash{}; + std::array chunk{}; + std::array output{}; + id.fill(1); + token.fill(2); + hash.fill(3); + chunk.fill(4); + const geocaching::ByteView request{id.data(), id.size()}, upload{token.data(), token.size()}; + size_t written = 0; + assert(track_sharing::encodeTrackUploadBegin(request, {hash.data(), hash.size()}, 8388608, 8192, output.data(), output.size(), written)); + auto checkHeader = [&](geocaching::protocol::CmpReader& reader, unsigned operation, size_t fields) + { + size_t count = 0; + uint64_t value = 0; + geocaching::ByteView decoded; + assert(reader.array(count, 6) && count == 6); + assert(reader.unsignedInteger(value) && value == 1); + assert(reader.unsignedInteger(value) && value == 0); + assert(reader.unsignedInteger(value) && value == operation); + assert(reader.binary(decoded, 16) && decoded.size == 16 && !std::memcmp(decoded.data, id.data(), 16)); + assert(reader.unsignedInteger(value) && value == 8192); + assert(reader.array(count, 3) && count == fields); + }; + geocaching::protocol::CmpReader begin({output.data(), written}); + checkHeader(begin, 4, 2); + geocaching::ByteView decoded; + uint64_t value = 0; + assert(begin.binary(decoded, 32) && decoded.size == 32 && !std::memcmp(decoded.data, hash.data(), 32)); + assert(begin.unsignedInteger(value) && value == 8388608 && begin.finished()); + assert(track_sharing::encodeTrackUploadChunk(request, upload, 8387584, {chunk.data(), chunk.size()}, 8192, output.data(), output.size(), written)); + geocaching::protocol::CmpReader block({output.data(), written}); + checkHeader(block, 5, 3); + assert(block.binary(decoded, 16) && decoded.size == 16 && !std::memcmp(decoded.data, token.data(), 16)); + assert(block.unsignedInteger(value) && value == 8387584); + assert(block.binary(decoded, 1024) && decoded.size == 1024 && !std::memcmp(decoded.data, chunk.data(), 1024) && block.finished()); + assert(track_sharing::encodeTrackUploadFinish(request, upload, 8192, output.data(), output.size(), written)); + geocaching::protocol::CmpReader finish({output.data(), written}); + checkHeader(finish, 6, 1); + assert(finish.binary(decoded, 16) && decoded.size == 16 && finish.finished()); + assert(!track_sharing::encodeTrackUploadChunk(request, upload, 8387585, {chunk.data(), chunk.size()}, 8192, output.data(), output.size(), written) && written == 0); + assert(!track_sharing::encodeTrackUploadChunk(request, upload, 0, {chunk.data(), 0}, 8192, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackUploadChunk(request, upload, 0, {chunk.data(), chunk.size()}, 8192, output.data(), 32, written) && written == 0); + assert(!track_sharing::encodeTrackUploadFinish(request, upload, 511, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackUploadFinish({output.data(), 16}, upload, 8192, output.data(), output.size(), written)); + assert(!track_sharing::encodeTrackUploadBegin(request, {hash.data(), hash.size()}, 8388609, 8192, output.data(), output.size(), written)); + id.fill(0); + assert(!track_sharing::encodeTrackUploadFinish(request, upload, 8192, output.data(), output.size(), written)); +} diff --git a/tests/hostlink/test_track_query_binding.cpp b/tests/hostlink/test_track_query_binding.cpp new file mode 100644 index 00000000..e64884ad --- /dev/null +++ b/tests/hostlink/test_track_query_binding.cpp @@ -0,0 +1,41 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/read_query_binding.h" +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +static mbedtls_sha256_context context; +static track_sharing::ReadQuery query; +static std::array workspace; +static std::array output, previous; +int main() +{ + platform::esp::storage::SnapshotStreamSha256 hash(context); + query.west = 1700000000; + query.east = -1700000000; + query.south = -100000000; + query.north = 100000000; + assert(track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), output.data())); + const uint8_t expected[] = {0x53, 0xc8, 0x26, 0xb0, 0x11, 0x1d, 0xfb, 0xb2, 0x13, 0x6d, 0xf1, 0x5a, 0xe5, 0xc7, 0xda, 0x76, 0xba, 0x7d, 0x93, 0xf2, 0x23, 0x81, 0xb3, 0xc3, 0xd5, 0xa7, 0x3a, 0x64, 0x22, 0x3a, 0x3a, 0x09}; + assert(!std::memcmp(output.data(), expected, 32)); + previous = output; + query.has_snapshot = true; + query.snapshot = 9007199254740993ULL; + assert(track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), output.data()) && output == previous); + query.state = 1; + assert(track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), output.data()) && output != previous); + query.state = 0; + assert(!track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), 1, output.data())); + assert(!track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), workspace.data())); + query.cursor_binding = {output.data(), output.size()}; + assert(!track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), output.data())); + query.cursor_binding = {}; + query.west = 1800000000; + assert(!track_sharing::deriveReadQueryBinding(query, hash, workspace.data(), workspace.size(), output.data())); +} diff --git a/tests/hostlink/test_track_query_response.cpp b/tests/hostlink/test_track_query_response.cpp new file mode 100644 index 00000000..ea41efca --- /dev/null +++ b/tests/hostlink/test_track_query_response.cpp @@ -0,0 +1,58 @@ +#include "track_sharing/read_query_response.h" +#include +#include + +static track_sharing::ReadQuery query; +static track_sharing::ReadQueryRow row; +static track_sharing::ReadQueryResponse result; +static std::array raw; +int main() +{ + std::array id{}; + std::array first{}, second{}, binding{}; + std::array author{}; + id.fill(1); + first.fill(2); + second.fill(3); + binding.fill(4); + author.fill(5); + query.west = 1700000000; + query.east = -1700000000; + query.south = -100000000; + query.north = 100000000; + query.limit = 2; + const geocaching::ByteView request{id.data(), id.size()}, digest{binding.data(), binding.size()}; + auto encode = [&](bool reverse, bool wrong_cursor, uint64_t snapshot, int32_t west) + { + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.binary(request) && writer.unsignedInteger(200) && writer.array(3) && writer.unsignedInteger(snapshot) && writer.array(2)); + assert(writer.binary({second.data(), second.size()}) && writer.binary(wrong_cursor ? geocaching::ByteView{first.data(), first.size()} : digest) && writer.array(2)); + for (unsigned i = 0; i < 2; i++) + { + const auto& object = (i == 0) != reverse ? first : second; + assert(writer.array(10) && writer.binary({object.data(), object.size()}) && writer.binary({second.data(), second.size()}) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && writer.unsignedInteger(0) && writer.text("Trail") && writer.array(4)); + assert(writer.signedInteger(west) && writer.signedInteger(0) && writer.signedInteger(-1750000000) && writer.signedInteger(10000000) && writer.array(4)); + assert(writer.unsignedInteger(1) && writer.nil() && writer.nil() && writer.unsignedInteger(2) && writer.unsignedInteger(100) && writer.binary({author.data(), author.size()})); + } + return writer.size(); + }; + size_t length = encode(false, false, 9007199254740993ULL, 1750000000); + assert(track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result) && result.rows == 2 && result.has_cursor && result.snapshot == 9007199254740993ULL); + raw[length] = 0; + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length + 1}, request, query, digest, row, result)); + length = encode(true, false, 1, 1750000000); + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result)); + length = encode(false, true, 1, 1750000000); + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result)); + length = encode(false, false, 1, 1750000000); + query.kind = 1; + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result)); + query.kind = -1; + query.has_snapshot = true; + query.snapshot = 2; + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result)); + query.has_snapshot = false; + query.west = 0; + query.east = 10000000; + assert(!track_sharing::decodeTrackReadQueryResponse({raw.data(), length}, request, query, digest, row, result)); +} diff --git a/tests/hostlink/test_track_read_exchange.cpp b/tests/hostlink/test_track_read_exchange.cpp new file mode 100644 index 00000000..d2a67993 --- /dev/null +++ b/tests/hostlink/test_track_read_exchange.cpp @@ -0,0 +1,97 @@ +#include "track_sharing/read_network_exchange.h" +#include "track_sharing/network_read.h" +#include +#include + +struct Lease { bool live = true; bool holds() const { return live; } }; +struct Sink +{ + unsigned writes = 0; + bool validateAndStore(uint8_t, geocaching::ByteView, const track_sharing::ReadResponseView& response, geocaching::ByteView) + { if (response.status != 404) return false; ++writes; return true; } +}; +struct Adapter +{ + const chat::lxmf::TrackDeliveryBinding* binding = nullptr; + std::array author{}; + uint32_t generation = 9; + unsigned sends = 0; + bool locked = false, busy = false, send_ok = true; + chat::TrackBindingChange replaceTrackDelivery(const chat::lxmf::TrackDeliveryBinding* old, const chat::lxmf::TrackDeliveryBinding* next) + { + if (busy) return chat::TrackBindingChange::Busy; + if (old != binding) return chat::TrackBindingChange::Conflict; + binding = next; + return chat::TrackBindingChange::Applied; + } + chat::TrackBindingChange visitTrackDelivery(const chat::lxmf::TrackDeliveryBinding* expected, uint8_t* key, uint32_t& epoch, chat::TrackBindingVisit visit, void* context) + { + if (busy) return chat::TrackBindingChange::Busy; + if (expected != binding) return chat::TrackBindingChange::Conflict; + std::memcpy(key, author.data(), 64); + epoch = generation; + locked = true; + visit(context); + locked = false; + return chat::TrackBindingChange::Applied; + } + struct Result { bool ok; }; + Result sendTrackData(const uint8_t*, chat::lxmf::ByteSpan bytes, const uint8_t* key, uint32_t epoch, bool response, void*) + { + assert(!locked && bytes.size && !response && epoch == generation && !std::memcmp(key, author.data(), 64)); + ++sends; + return {send_ok}; + } +}; +int main() +{ + using namespace track_sharing; + using Change = chat::TrackBindingChange; + std::array id{}, directory{}, local{}; + std::array object{}, message{}; + std::array wire{}, reply{}; + id.fill(1); directory.fill(2); local.fill(3); object.fill(4); message.fill(5); + size_t bytes = 0; + assert(encodeTrackReadManifest({id.data(), 16}, {object.data(), 32}, {}, 8192, wire.data(), wire.size(), bytes)); + geocaching::protocol::CmpWriter writer(reply.data(), reply.size()); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(2) && + writer.binary({id.data(), 16}) && writer.unsignedInteger(404) && writer.array(1) && writer.text({"missing", 7})); + chat::lxmf::CustomDeliveryView view{{directory.data(),16},{local.data(),16},{message.data(),32},{reply.data(),writer.size()}}; + for (unsigned scenario = 0; scenario < 6; ++scenario) + { + Adapter adapter; adapter.author.fill(6); + Sink sink; Lease lease; + ReadNetworkExchange exchange(adapter,sink,lease,{adapter.author.data(),64},9); + static_assert(sizeof(exchange) <= 768, "Exchange has only one small PSRAM request, no response queue"); + auto attach = [&] { return exchange.attach(2,{id.data(),16},{directory.data(),16},{local.data(),16},{wire.data(),bytes},8192,100); }; + adapter.busy = true; + assert(attach() == Change::Busy && !exchange.attached()); + adapter.busy = false; + assert(attach() == Change::Applied && exchange.attached()); + ReadExchangeState state = ReadExchangeState::Idle; + if (scenario == 1) adapter.generation++; + if (scenario == 2) lease.live = false; + if (scenario == 3) adapter.send_ok = false; + assert(exchange.advance(scenario == 4 ? 45100 : 101,state) == Change::Applied); + if (scenario == 1 || scenario == 2) assert(state == ReadExchangeState::Stale && adapter.sends == 0); + else if (scenario == 3) assert(state == ReadExchangeState::Rejected && adapter.sends == 1); + else if (scenario == 4) assert(state == ReadExchangeState::Expired && adapter.sends == 0); + else + { + assert(state == ReadExchangeState::Waiting && adapter.sends == 1); + assert(exchange.advance(102,state) == Change::Applied && adapter.sends == 1); + if (scenario == 0) + { + assert(chat::lxmf::deliverVerifiedTrack(adapter.binding,false,false,view)); + assert(exchange.advance(103,state) == Change::Applied && state == ReadExchangeState::Ready && sink.writes == 1); + assert(chat::lxmf::deliverVerifiedTrack(adapter.binding,false,false,view) && sink.writes == 1); + } + } + adapter.busy = true; + assert(exchange.detach() == Change::Busy && exchange.attached()); + adapter.busy = false; + assert(exchange.detach() == Change::Applied && !exchange.attached() && !adapter.binding); + assert(exchange.advance(104,state) == Change::Conflict); + if (scenario == 5) assert(state == ReadExchangeState::Cancelled); + } +} diff --git a/tests/hostlink/test_track_read_manifest.cpp b/tests/hostlink/test_track_read_manifest.cpp new file mode 100644 index 00000000..e07cf591 --- /dev/null +++ b/tests/hostlink/test_track_read_manifest.cpp @@ -0,0 +1,56 @@ +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/verify_read_manifest.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct Crypto +{ + track_sharing::ManifestVerification verify(geocaching::ByteView key, geocaching::ByteView signature, geocaching::ByteView message) + { + return ed25519_verify(signature.data, message.data, message.size, key.data) ? track_sharing::ManifestVerification::Valid : track_sharing::ManifestVerification::InvalidSignature; + } +}; +static uint8_t workspace[4200]; +static mbedtls_sha256_context context; +static track_sharing::VerifiedManifestView output; +std::vector hex(const char* input) +{ + std::string text = input; + std::vector result; + if (text.size() % 2) return {}; + for (size_t i = 0; i < text.size(); i += 2) + { + unsigned value = 0; + if (std::sscanf(text.substr(i, 2).c_str(), "%2x", &value) != 1) return {}; + result.push_back(uint8_t(value)); + } + return result; +} +int main(int argc, char** argv) +{ + Crypto crypto; + platform::esp::storage::SnapshotStreamSha256 hash(context); + if (argc != 5) + { + assert(track_sharing::verifyReadManifest({}, {}, {}, {}, crypto, hash, workspace, sizeof(workspace), output) != track_sharing::ReadManifestVerification::Valid && !output.verified); + return 0; + } + const auto raw = hex(argv[1]), request = hex(argv[2]), object = hex(argv[3]), revision = hex(argv[4]); + const auto result = track_sharing::verifyReadManifest({raw.data(), raw.size()}, {request.data(), request.size()}, {object.data(), object.size()}, {revision.data(), revision.size()}, crypto, hash, workspace, sizeof(workspace), output); + if (result != track_sharing::ReadManifestVerification::Valid) + { + assert(!output.verified); + return 1; + } + assert(output.verified); + return 0; +} diff --git a/tests/hostlink/test_track_read_reply.cpp b/tests/hostlink/test_track_read_reply.cpp new file mode 100644 index 00000000..58a239d4 --- /dev/null +++ b/tests/hostlink/test_track_read_reply.cpp @@ -0,0 +1,95 @@ +#include "track_sharing/read_reply_receiver.h" +#include +#include +#include + +struct Lease +{ + bool live = true; + bool holds() const { return live; } +}; +struct Sink +{ + std::array hash{}; + std::array saved{}; + bool available = true; + unsigned writes = 0; + bool validateAndStore(uint8_t operation, geocaching::ByteView request, + const track_sharing::ReadResponseView& envelope, geocaching::ByteView raw) + { + if (!available) return false; + if (envelope.status != 200) + { + ++writes; + return true; + } + track_sharing::ReadFileResponse file; + if (operation != 8 || !track_sharing::decodeTrackReadFileResponse(raw, request, {hash.data(), hash.size()}, 3, 0, 3, file)) return false; + std::memcpy(saved.data(), file.content.data, 3); + ++writes; + return true; + } +}; +int main() +{ + std::array request{}, directory{}, local{}; + std::array message{}; + std::array author{}, signature{}; + std::array raw{}; + request.fill(1); + directory.fill(2); + local.fill(3); + message.fill(4); + author.fill(5); + signature.fill(6); + Sink sink; + sink.hash.fill(7); + Lease lease; + track_sharing::ReadReplyReceiver receiver(sink, lease, {author.data(), author.size()}, 9); + static_assert(sizeof(receiver) <= 256, "Receiver must not retain payload buffers"); + const geocaching::ByteView id{request.data(), request.size()}, source{directory.data(), directory.size()}, destination{local.data(), local.size()}; + assert(receiver.arm(8, id, source, destination, 512)); + assert(!receiver.arm(8, id, source, destination, 512)); + auto file = [&] + { + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(8) && writer.binary(id) && writer.unsignedInteger(200) && writer.array(4)); + assert(writer.binary({sink.hash.data(), sink.hash.size()}) && writer.unsignedInteger(3) && writer.unsignedInteger(0) && writer.binary({signature.data(), 3})); + return writer.size(); + }; + size_t length = file(); + chat::lxmf::CustomDeliveryView view{{directory.data(), 16}, {local.data(), 16}, {message.data(), 32}, {raw.data(), length}}; + auto deliver = [&] + { return chat::lxmf::deliverVerifiedTrack(&receiver.binding(), false, false, view); }; + assert(!chat::lxmf::deliverVerifiedTrack(&receiver.binding(), true, false, view)); + assert(!chat::lxmf::deliverVerifiedTrack(&receiver.binding(), false, true, view)); + local[0] ^= 1; + assert(!deliver()); + local[0] ^= 1; + raw[length] = 0; + view.data.size++; + assert(!deliver() && receiver.pending()); + view.data.size = length; + sink.available = false; + assert(!deliver() && receiver.pending()); + sink.available = true; + lease.live = false; + assert(!deliver() && receiver.pending()); + lease.live = true; + assert(deliver() && !receiver.pending() && sink.writes == 1 && sink.saved[0] == 6); + assert(deliver() && sink.writes == 1); + message[0] ^= 1; + assert(!deliver()); + message[0] ^= 1; + receiver.cancel(); + assert(!deliver()); + assert(!receiver.arm(7, id, source, destination, 512)); + assert(receiver.arm(2, id, source, destination, 512)); + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(2) && writer.binary(id) && writer.unsignedInteger(200) && writer.array(2) && writer.array(2) && writer.binary({signature.data(), 3}) && writer.binary({signature.data(), 64}) && writer.unsignedInteger(1)); + track_sharing::ReadManifestResponse manifest; + assert(track_sharing::decodeTrackReadManifestResponse({raw.data(), writer.size()}, id, manifest) && manifest.current && manifest.signature.size == 64); + raw[writer.size()] = 0; + assert(!track_sharing::decodeTrackReadManifestResponse({raw.data(), writer.size() + 1}, id, manifest)); + receiver.cancel(); +} diff --git a/tests/hostlink/test_track_read_sink.cpp b/tests/hostlink/test_track_read_sink.cpp new file mode 100644 index 00000000..deb58a66 --- /dev/null +++ b/tests/hostlink/test_track_read_sink.cpp @@ -0,0 +1,111 @@ +#include "track_sharing/read_reply_receiver.h" +#include "track_sharing/read_response_sink.h" +#include +#include +#include +#include + +struct Store +{ + bool available = true; + unsigned calls = 0; + std::vector saved; + track_sharing::ReadStoreResult persist(geocaching::ByteView raw, uint8_t*, size_t) + { + calls++; + if (!available) return track_sharing::ReadStoreResult::StorageFailure; + saved.assign(raw.data, raw.data + raw.size); + return track_sharing::ReadStoreResult::Ready; + } +}; +struct Validator +{ + bool signature_valid = true; + std::array file_hash{}; + bool validate(uint8_t operation, geocaching::ByteView request, const track_sharing::ReadResponseView& response, geocaching::ByteView raw) + { + if (!signature_valid) return false; + if (response.status != 200) return true; + track_sharing::ReadFileResponse file; + return operation == 8 && track_sharing::decodeTrackReadFileResponse(raw, request, {file_hash.data(), file_hash.size()}, 3, 0, 3, file); + } +}; +// Exercises the hash-port lifecycle, not a substitute for SHA-256 validation. +struct Hash +{ + bool available = true, active = false; + unsigned resets = 0; + std::vector input; + bool start() + { + input.clear(); + active = available; + return active; + } + bool update(const uint8_t* bytes, size_t size) + { + if (!active) return false; + input.assign(bytes, bytes + size); + return true; + } + bool finish(uint8_t* output) + { + if (!active) return false; + std::memset(output, 9, 32); + active = false; + return available; + } + void reset() + { + active = false; + resets++; + } +}; +struct Lease +{ + bool holds() const { return true; } +}; +int main() +{ + std::array request{}, directory{}, local{}; + std::array author{}; + std::array message{}; + std::array raw{}; + std::array scratch{}; + const uint8_t content[] = {1, 2, 3}; + request.fill(1); + directory.fill(2); + local.fill(3); + author.fill(4); + message.fill(5); + Store store; + Validator validator; + validator.file_hash.fill(6); + Hash hash; + Lease lease; + track_sharing::StoredReadResponse result; + track_sharing::ReadResponseSink sink(store, validator, hash, result, scratch.data(), scratch.size()); + static_assert(sizeof(sink) <= 64, "Sink cannot retain response buffers"); + track_sharing::ReadReplyReceiver receiver(sink, lease, {author.data(), author.size()}, 7); + assert(receiver.arm(8, {request.data(), request.size()}, {directory.data(), directory.size()}, {local.data(), local.size()}, 512)); + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(8) && writer.binary({request.data(), request.size()}) && writer.unsignedInteger(200) && writer.array(4)); + assert(writer.binary({validator.file_hash.data(), validator.file_hash.size()}) && writer.unsignedInteger(3) && writer.unsignedInteger(0) && writer.binary({content, 3})); + chat::lxmf::CustomDeliveryView view{{directory.data(), 16}, {local.data(), 16}, {message.data(), 32}, {raw.data(), writer.size()}}; + auto deliver = [&] + { return chat::lxmf::deliverVerifiedTrack(&receiver.binding(), false, false, view); }; + validator.signature_valid = false; + assert(!deliver() && !result.ready && store.calls == 0); + validator.signature_valid = true; + store.available = false; + assert(!deliver() && receiver.pending() && !result.ready); + store.available = true; + hash.available = false; + assert(!deliver() && receiver.pending() && !result.ready && hash.resets == 1); + hash.available = true; + assert(deliver() && result.ready && !receiver.pending()); + assert(result.bytes == writer.size() && result.status == 200 && result.operation == 8); + assert(hash.input == store.saved && hash.input.size() == writer.size()); + const unsigned calls = store.calls; + assert(deliver() && store.calls == calls); +} diff --git a/tests/hostlink/test_track_read_store.cpp b/tests/hostlink/test_track_read_store.cpp new file mode 100644 index 00000000..1283be46 --- /dev/null +++ b/tests/hostlink/test_track_read_store.cpp @@ -0,0 +1,128 @@ +#include "track_sharing/read_response_store.h" +#include +#include +#include +#include +#include +#include + +struct Io +{ + enum class Mode + { + Read, + Update, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + IoError + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + size_t offset = 0, writes = 0; + bool active = false, short_write = false, short_read = false, fail_sync = false, fail_publish = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (mode != Mode::CreateTemporary && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + assert(active); + active = false; + return true; + } + bool size(void*, uint64_t& size) + { + size = files[path].size(); + return true; + } + bool seek(void*, uint64_t position) + { + offset = size_t(position); + return offset <= files[path].size(); + } + size_t read(void*, void* output, size_t count) + { + count = std::min(count, files[path].size() - offset); + if (short_read && count) count--; + std::memcpy(output, files[path].data() + offset, count); + offset += count; + return count; + } + size_t write(void*, const void* input, size_t count) + { + assert(count <= 320); + writes++; + if (short_write && count) count--; + const auto* data = static_cast(input); + files[path].insert(files[path].end(), data, data + count); + return count; + } + bool sync(void*) { return !fail_sync; } + bool publish(const char* temporary, const char* destination) + { + assert(!active); + if (fail_publish || files.count(destination) || !files.count(temporary)) return false; + files[destination] = files[temporary]; + files.erase(temporary); + return true; + } +}; +int main() +{ + Io io; + track_sharing::ReadResponseStore store(io, "response.tmp", "response.ready"); + static_assert(sizeof(store) <= 32, "Store cannot retain response buffers"); + std::array scratch{}; + std::array output{}; + std::array source{}; + for (size_t i = 0; i < source.size(); i++) source[i] = uint8_t(i); + const geocaching::ByteView raw{source.data(), source.size()}; + using Result = track_sharing::ReadStoreResult; + io.short_write = true; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::StorageFailure && !io.active && !io.files.count("response.ready")); + io.short_write = false; + io.fail_sync = true; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::StorageFailure && !io.active); + const size_t unsynced_writes = io.writes; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::StorageFailure && !io.active && !io.files.count("response.ready") && io.writes == unsynced_writes); + io.fail_sync = false; + io.fail_publish = true; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::StorageFailure && !io.active); + io.fail_publish = false; + const size_t staged_writes = io.writes; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::Ready && io.writes == staged_writes && !io.active); + const size_t before = io.writes; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::Ready && io.writes == before); + source[0] ^= 1; + assert(store.persist(raw, scratch.data(), scratch.size()) == Result::Conflict && io.writes == before); + source[0] ^= 1; + size_t written = 0; + for (size_t offset = 0; offset < source.size();) + { + const auto count = uint16_t(std::min(size_t(320), source.size() - offset)); + assert(store.read(source.size(), offset, count, output.data(), output.size(), written) == Result::Ready && written == count && !std::memcmp(output.data(), source.data() + offset, count) && !io.active); + offset += count; + } + io.short_read = true; + assert(store.read(source.size(), 0, 320, output.data(), output.size(), written) == Result::StorageFailure && !written && !io.active); + io.short_read = false; + assert(store.read(source.size(), source.size(), 1, output.data(), output.size(), written) == Result::Invalid); + io.files["response.ready"].pop_back(); + assert(store.read(source.size(), 0, 1, output.data(), output.size(), written) == Result::Conflict && !written && !io.active); + assert(store.persist(raw, source.data(), 128) == Result::Invalid); +} diff --git a/tests/hostlink/test_track_read_usb.cpp b/tests/hostlink/test_track_read_usb.cpp new file mode 100644 index 00000000..9e7e5f2a --- /dev/null +++ b/tests/hostlink/test_track_read_usb.cpp @@ -0,0 +1,45 @@ +#include "track_sharing/read_usb_request.h" +#include +#include +int main() +{ + std::array raw{}; + std::array id{}; id.fill(1); + std::array hash{}; hash.fill(2); + track_sharing::ReadUsbRequest output; + for (uint8_t op : {0,1,2,8}) + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size()); + assert(writer.array(6) && writer.binary({id.data(),16}) && writer.binary({id.data(),16}) && writer.binary({id.data(),16}) && writer.unsignedInteger(9) && writer.unsignedInteger(op)); + if (!op) assert(writer.array(0)); + if (op == 1) assert(writer.array(7) && writer.array(4) && writer.signedInteger(1700000000) && writer.signedInteger(-100000000) && writer.signedInteger(-1700000000) && writer.signedInteger(100000000) && + writer.nil() && writer.nil() && writer.unsignedInteger(0) && writer.unsignedInteger(16) && writer.unsignedInteger(9007199254740993ULL) && writer.array(2) && writer.binary({hash.data(),32}) && writer.binary({hash.data(),32})); + if (op == 2) assert(writer.array(2) && writer.binary({hash.data(),32}) && writer.nil()); + if (op == 8) assert(writer.array(4) && writer.binary({hash.data(),32}) && writer.unsignedInteger(1200) && writer.unsignedInteger(1000) && writer.unsignedInteger(200)); + assert(track_sharing::decodeReadUsbRequest({raw.data(),writer.size()},output) && output.operation == op && output.generation == 9); + if (op == 1) assert(output.expected.query == &output.query && output.query.has_cursor && output.query.snapshot == 9007199254740993ULL && output.query.west > output.query.east); + if (op == 8) assert(output.expected.file_bytes == 1200 && output.expected.length == 200); + raw[writer.size()] = 0; + assert(!track_sharing::decodeReadUsbRequest({raw.data(),writer.size()+1},output)); + assert(!track_sharing::decodeReadUsbRequest({raw.data(),writer.size()-1},output)); + // Six-element header: three bin16 fields, then generation and operation. + raw[56] = 4; + assert(!track_sharing::decodeReadUsbRequest({raw.data(),writer.size()},output)); + } + for(uint8_t op : {9,10,11,12,13}) + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size()); + assert(writer.array(6)&&writer.binary({id.data(),16})&&writer.binary({id.data(),16})&&writer.binary({id.data(),16})&&writer.unsignedInteger(9)&&writer.unsignedInteger(op)); + if(op==9)assert(writer.array(0)); + if(op==10)assert(writer.array(7)&&writer.text("test")&&writer.binary({hash.data(),32})&&writer.text("test:r")&&writer.signedInteger(0)&&writer.nil()&&writer.unsignedInteger(3)&&writer.unsignedInteger(16)); + if(op==11)assert(writer.array(4)&&writer.text("test")&&writer.text("test:r")&&writer.binary({hash.data(),32})&&writer.unsignedInteger(3)); + if(op==12)assert(writer.array(7)&&writer.text("test")&&writer.text("test:r")&&writer.binary({hash.data(),32})&&writer.binary({hash.data(),32})&&writer.unsignedInteger(100)&&writer.unsignedInteger(90)&&writer.unsignedInteger(10)); + if(op==13)assert(writer.array(10)&&writer.text("test")&&writer.text("test:r")&&writer.binary({hash.data(),32})&&writer.array(4)&&writer.signedInteger(0)&&writer.signedInteger(0)&&writer.signedInteger(100)&&writer.signedInteger(100)&&writer.nil()&&writer.nil()&&writer.unsignedInteger(0)&&writer.unsignedInteger(16)&&writer.unsignedInteger(9007199254740993ULL)&&writer.nil()); + assert(track_sharing::decodeReadUsbRequest({raw.data(),writer.size()},output)&&output.operation==op); + if(op>=10)assert(output.expected.region==&output.region&&output.region.name_space=="test"); + if(op==10)assert(output.region.parent=="test:r"&&output.region.parent_level==0&&output.expected.query->limit==16); + if(op==12)assert(output.expected.file_bytes==100&&output.expected.offset==90&&output.expected.length==10); + if(op==13)assert(output.expected.query==&output.query&&output.query.snapshot==9007199254740993ULL); + raw[writer.size()]=0;assert(!track_sharing::decodeReadUsbRequest({raw.data(),writer.size()+1},output)); + } +} diff --git a/tests/hostlink/test_track_read_validator.cpp b/tests/hostlink/test_track_read_validator.cpp new file mode 100644 index 00000000..52a88b71 --- /dev/null +++ b/tests/hostlink/test_track_read_validator.cpp @@ -0,0 +1,105 @@ +#include "track_sharing/read_response_validator.h" +#include +#include + +struct Crypto +{ + track_sharing::ManifestVerification verify(geocaching::ByteView, geocaching::ByteView, geocaching::ByteView) { return track_sharing::ManifestVerification::CryptoUnavailable; } +}; +struct Hash +{ + bool start() { return false; } + bool update(const uint8_t*, size_t) { return false; } + bool finish(uint8_t*) { return false; } + void reset() {} +}; +static track_sharing::VerifiedManifestView manifest; +static track_sharing::ReadQueryRow row; +static track_sharing::ReadQueryResponse page; +static std::array workspace; +static track_sharing::RegionReplyWorkspace regional_workspace; +static track_sharing::RegionReadScope regional_scope; +static track_sharing::ReadQuery query; +int main() +{ + std::array request{}, epoch{}; + std::array public_key{}; + std::array file_hash{}; + std::array raw{}; + request.fill(1); + epoch.fill(2); + public_key.fill(3); + file_hash.fill(4); + const geocaching::ByteView id{request.data(), request.size()}; + track_sharing::ReadValidationContext context; + context.directory_public = {public_key.data(), public_key.size()}; + context.directory_epoch = {epoch.data(), epoch.size()}; + context.file_hash = {file_hash.data(), file_hash.size()}; + context.file_bytes = 3; + context.length = 3; + Crypto crypto; + Hash hash; + track_sharing::ReadResponseValidator validator(context, crypto, hash, manifest, row, page, workspace.data(), workspace.size()); + auto envelope = [&](geocaching::protocol::CmpWriter& writer, unsigned operation, unsigned status, unsigned fields) + { return writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(operation) && writer.binary(id) && writer.unsignedInteger(status) && writer.array(fields); }; + auto caps = [&] + {geocaching::protocol::CmpWriter writer(raw.data(),raw.size());assert(envelope(writer,0,200,8) && writer.unsignedInteger(1) && writer.text(track_sharing::kApplicationType) && writer.binary(context.directory_public) && writer.binary(context.directory_epoch) && writer.unsignedInteger(8388608) && writer.unsignedInteger(1024) && writer.unsignedInteger(32) && writer.array(4));for(const unsigned op:{0,1,2,8})assert(writer.unsignedInteger(op));return writer.size(); }; + size_t length = caps(); + track_sharing::ReadResponseView decoded; + assert(track_sharing::decodeTrackReadEnvelope({raw.data(), length}, id, 0, decoded) && validator.validate(0, id, decoded, {raw.data(), length})); + public_key[0] ^= 1; + assert(!validator.validate(0, id, decoded, {raw.data(), length})); + public_key[0] ^= 1; + epoch[0] ^= 1; + assert(!validator.validate(0, id, decoded, {raw.data(), length})); + epoch[0] ^= 1; + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + const uint8_t data[] = {1, 2, 3}; + assert(envelope(writer, 8, 200, 4) && writer.binary(context.file_hash) && writer.unsignedInteger(3) && writer.unsignedInteger(0) && writer.binary({data, 3})); + length = writer.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(), length}, id, 8, decoded) && validator.validate(8, id, decoded, {raw.data(), length})); + context.offset = 1; + assert(!validator.validate(8, id, decoded, {raw.data(), length})); + context.offset = 0; + decoded.status = 422; + assert(!validator.validate(8, id, decoded, {raw.data(), length})); + track_sharing::ReadResponseValidator regional(context, crypto, hash, manifest, row, page, workspace.data(), workspace.size(), ®ional_workspace); + { + geocaching::protocol::CmpWriter response(raw.data(),raw.size()); + assert(envelope(response,9,200,8)&&response.unsignedInteger(1)&&response.text("trailmate.track.regions.v1")&&response.text("test")&&response.binary(context.file_hash)&& + response.unsignedInteger(3)&&response.unsignedInteger(32)&&response.unsignedInteger(1024)&&response.array(5)); + for(unsigned op=9;op<=13;op++)assert(response.unsignedInteger(op)); + length=response.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(),length},id,9,decoded)); + assert(!validator.validate(9,id,decoded,{raw.data(),length})); + assert(regional.validate(9,id,decoded,{raw.data(),length})&®ional_workspace.capabilities.depth==3); + } + regional_scope.name_space="test";regional_scope.id="test:r";regional_scope.version=context.file_hash;regional_scope.depth=3;context.region=®ional_scope;context.query=&query; + { + geocaching::protocol::CmpWriter response(raw.data(),raw.size()); + assert(envelope(response,10,200,3)&&response.binary(context.file_hash)&&response.nil()&&response.array(0));length=response.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(),length},id,10,decoded)&®ional.validate(10,id,decoded,{raw.data(),length})); + context.region=nullptr;assert(!regional.validate(10,id,decoded,{raw.data(),length}));context.region=®ional_scope; + } + { + geocaching::protocol::CmpWriter response(raw.data(),raw.size()); + assert(envelope(response,11,200,3)&&response.binary(context.file_hash)&&response.array(7)&&response.text("test:r")&&response.nil()&&response.unsignedInteger(0)&&response.text("Root")&& + response.array(0)&&response.array(4)&&response.signedInteger(0)&&response.signedInteger(0)&&response.signedInteger(1)&&response.signedInteger(1)&&response.unsignedInteger(0)&& + response.array(3)&&response.text("region-e7-json-v1")&&response.binary(context.file_hash)&&response.unsignedInteger(100));length=response.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(),length},id,11,decoded)&®ional.validate(11,id,decoded,{raw.data(),length})); + regional_scope.id="test:other";assert(!regional.validate(11,id,decoded,{raw.data(),length}));regional_scope.id="test:r"; + } + { + geocaching::protocol::CmpWriter response(raw.data(),raw.size()); + assert(envelope(response,12,200,4)&&response.binary(context.file_hash)&&response.unsignedInteger(3)&&response.unsignedInteger(0)&&response.binary({data,3}));length=response.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(),length},id,12,decoded)&®ional.validate(12,id,decoded,{raw.data(),length})); + context.region=nullptr;assert(!regional.validate(12,id,decoded,{raw.data(),length}));context.region=®ional_scope; + } + { + geocaching::protocol::CmpWriter response(raw.data(),raw.size()); + context.query_binding=context.file_hash; + assert(envelope(response,13,200,3)&&response.unsignedInteger(9007199254740993ULL)&&response.nil()&&response.array(0));length=response.size(); + assert(track_sharing::decodeTrackReadEnvelope({raw.data(),length},id,13,decoded)&®ional.validate(13,id,decoded,{raw.data(),length})); + query.has_snapshot=true;query.snapshot=9007199254740994ULL;assert(!regional.validate(13,id,decoded,{raw.data(),length})); + } +} diff --git a/tests/hostlink/test_track_region_request.cpp b/tests/hostlink/test_track_region_request.cpp new file mode 100644 index 00000000..4c77ba8a --- /dev/null +++ b/tests/hostlink/test_track_region_request.cpp @@ -0,0 +1,61 @@ +#include "track_sharing/region_read_request.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +static mbedtls_sha256_context context; +static track_sharing::ReadQuery query; +static std::array workspace; +static std::array digest,previous; +int main() +{ + using namespace track_sharing; + std::array request{}; + std::array version{},cursor{}; + std::array author{}; + request.fill(2); version.fill(1); cursor.fill(3); author.fill(4); + constexpr char region[] = "test:region"; + const geocaching::ByteView id{reinterpret_cast(region),sizeof(region)-1}, token{request.data(),16}, boundary{version.data(),32}; + assert(regionId(id)); + for(const auto invalid : {std::string_view("TEST:region"),std::string_view("test:"),std::string_view("test:_bad"),std::string_view("test:r:bad"),std::string_view("test:r/../../")}) + assert(!regionId({reinterpret_cast(invalid.data()),invalid.size()})); + size_t written = 0; + auto header=[&](geocaching::protocol::CmpReader& reader,uint64_t op,size_t fields) + { + uint64_t value=0;size_t count=0;geocaching::ByteView actual; + assert(reader.array(count,6)&&count==6&&reader.unsignedInteger(value)&&value==1&&reader.unsignedInteger(value)&&!value&&reader.unsignedInteger(value)&&value==op&& + reader.binary(actual,16)&&actual.size==16&&!std::memcmp(actual.data,request.data(),16)&&reader.unsignedInteger(value)&&value==8192&&reader.array(count,9)&&count==fields); + }; + assert(encodeRegionCapabilities(token,8192,workspace.data(),workspace.size(),written)); + {geocaching::protocol::CmpReader reader({workspace.data(),written});header(reader,9,0);assert(reader.finished());} + assert(encodeRegionChildren(token,boundary,id,{},16,8192,workspace.data(),workspace.size(),written)); + {geocaching::protocol::CmpReader reader({workspace.data(),written});header(reader,10,4);geocaching::ByteView actual;std::string_view name;uint64_t limit=0;assert(reader.binary(actual,32)&&actual.size==32&&reader.text(name,89)&&name==region&&reader.nil()&&reader.unsignedInteger(limit)&&limit==16&&reader.finished());} + assert(encodeRegionDescriptor(token,id,boundary,8192,workspace.data(),workspace.size(),written)); + {geocaching::protocol::CmpReader reader({workspace.data(),written});header(reader,11,2);geocaching::ByteView actual;std::string_view name;assert(reader.text(name,89)&&name==region&&reader.binary(actual,32)&&actual.size==32&&reader.finished());} + assert(encodeRegionBoundaryChunk(token,id,boundary,8190,2,8192,workspace.data(),workspace.size(),written)); + {geocaching::protocol::CmpReader reader({workspace.data(),written});header(reader,12,4);geocaching::ByteView actual;std::string_view name;uint64_t offset=0,count=0;assert(reader.text(name,89)&&reader.binary(actual,32)&&reader.unsignedInteger(offset)&&offset==8190&&reader.unsignedInteger(count)&&count==2&&reader.finished());} + assert(!encodeRegionBoundaryChunk(token,id,boundary,8388607,2,8192,workspace.data(),workspace.size(),written)&&!written); + assert(!encodeRegionChildren(token,boundary,id,{},33,8192,workspace.data(),workspace.size(),written)); + assert(!encodeRegionDescriptor(token,id,boundary,8192,workspace.data(),10,written)&&!written); + assert(!encodeRegionDescriptor(token,id,{workspace.data(),32},8192,workspace.data(),workspace.size(),written)); + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(deriveRegionQueryBinding(id,boundary,query,hash,workspace.data(),workspace.size(),digest.data())); + const uint8_t expected[]={0x30,0x4c,0x87,0x1d,0x43,0x74,0x0c,0x79,0x6e,0x3c,0x47,0xf8,0x3a,0x6f,0x22,0xa6,0x1b,0x0b,0xa3,0x5c,0x98,0x4f,0xcf,0xfa,0x91,0x44,0x83,0x4f,0x77,0x9d,0x7a,0x55}; + assert(!std::memcmp(digest.data(),expected,32));previous=digest; + query.has_snapshot=true;query.snapshot=9007199254740993ULL;query.has_cursor=true;query.cursor_object={cursor.data(),32};query.cursor_binding={digest.data(),32}; + assert(deriveRegionQueryBinding(id,boundary,query,hash,workspace.data(),workspace.size(),previous.data())&&previous==digest); + assert(encodeRegionQuery(token,id,boundary,query,8192,workspace.data(),workspace.size(),written)); + {geocaching::protocol::CmpReader reader({workspace.data(),written});header(reader,13,9);geocaching::ByteView actual;std::string_view name,relation;uint64_t value=0;size_t count=0; + assert(reader.text(name,89)&&name==region&&reader.binary(actual,32)&&reader.text(relation,10)&&relation=="intersects"&&reader.nil()&&reader.nil()&&reader.unsignedInteger(value)&&!value&&reader.unsignedInteger(value)&&value==16&&reader.unsignedInteger(value)&&value==query.snapshot&&reader.array(count,2)&&count==2&&reader.binary(actual,32)&&reader.binary(actual,32)&&reader.finished());} + query.author={author.data(),64}; + char maximum[90];std::memset(maximum,'a',sizeof(maximum));maximum[24]=':'; + assert(encodeRegionQuery(token,{reinterpret_cast(maximum),89},boundary,query,8192,workspace.data(),workspace.size(),written)&&written<=320); + query.has_snapshot=false; + assert(!encodeRegionQuery(token,id,boundary,query,8192,workspace.data(),workspace.size(),written)); +} diff --git a/tests/hostlink/test_track_region_response.cpp b/tests/hostlink/test_track_region_response.cpp new file mode 100644 index 00000000..4ac04b9f --- /dev/null +++ b/tests/hostlink/test_track_region_response.cpp @@ -0,0 +1,95 @@ +#include "track_sharing/region_read_response.h" +#include +#include +using namespace track_sharing; +static std::array raw; +static std::array request; +static std::array version,digest; +static RegionCapabilitiesView caps; +static RegionNodeView row; +static RegionChildrenView page; +static RegionDescriptorView descriptor; +static ReadFileResponse file; +static ReadQuery query; +static ReadQueryRow query_row; +static ReadQueryResponse query_page; +static bool header(geocaching::protocol::CmpWriter& writer,uint8_t op,uint8_t fields) +{ + return writer.array(6)&&writer.unsignedInteger(1)&&writer.unsignedInteger(1)&&writer.unsignedInteger(op)&&writer.binary({request.data(),16})&&writer.unsignedInteger(200)&&writer.array(fields); +} +static void node(geocaching::protocol::CmpWriter& writer,std::string_view id,std::string_view parent,uint8_t level,unsigned names=0,bool duplicate=false) +{ + assert(writer.array(7)&&writer.text(id)&&(parent.empty()?writer.nil():writer.text(parent))&&writer.unsignedInteger(level)&&writer.text("Region")&&writer.array(names)); + for(unsigned i=0;irow.east);assert(!decodeRegionChildren({raw.data(),writer.size()},token,boundary,"test","test:other","",0,3,16,row,page));assert(!decodeRegionChildren({raw.data(),writer.size()},token,boundary,"test","test:r","test:a",0,3,16,row,page));} + } + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size());assert(header(writer,10,3)&&writer.binary(boundary)&&writer.nil()&&writer.array(0)); + assert(decodeRegionChildren({raw.data(),writer.size()},token,boundary,"test","test:leaf","",3,3,16,row,page)&&!page.rows); + } + for(unsigned names : {2U,22U}) + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size());assert(header(writer,10,3)&&writer.binary(boundary)&&writer.nil()&&writer.array(32)); + for(unsigned i=0;i<32;i++){char id[]={'t','e','s','t',':',char('0'+i/10),char('0'+i%10)};node(writer,{id,sizeof(id)},"test:r",1,names);} + assert(writer.size()<8192); + assert(decodeRegionChildren({raw.data(),writer.size()},token,boundary,"test","test:r","",0,3,32,row,page)==(names==2)); + } + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size());assert(header(writer,11,3)&&writer.binary(boundary));node(writer,"test:r","",0); + assert(writer.array(3)&&writer.text("region-e7-json-v1")&&writer.binary({digest.data(),32})&&writer.unsignedInteger(500)); + assert(decodeRegionDescriptor({raw.data(),writer.size()},token,"test:r",boundary,"test",3,descriptor)&&descriptor.bytes==500); + assert(!decodeRegionDescriptor({raw.data(),writer.size()},token,"test:other",boundary,"test",3,descriptor)); + version[0]^=1;assert(!decodeRegionDescriptor({raw.data(),writer.size()},token,"test:r",boundary,"test",3,descriptor));version[0]^=1; + raw[writer.size()]=0;assert(!decodeRegionDescriptor({raw.data(),writer.size()+1},token,"test:r",boundary,"test",3,descriptor)); + } + { + geocaching::protocol::CmpWriter writer(raw.data(),raw.size()); + assert(header(writer,12,4)&&writer.binary({digest.data(),32})&&writer.unsignedInteger(32)&&writer.unsignedInteger(0)&&writer.binary(boundary)); + assert(decodeRegionBoundaryResponse({raw.data(),writer.size()},token,{digest.data(),32},32,0,32,file)&&file.content.size==32); + assert(!decodeTrackReadFileResponse({raw.data(),writer.size()},token,{digest.data(),32},32,0,32,file)); + assert(!decodeTrackReadFileResponse({raw.data(),writer.size()},token,{digest.data(),32},32,0,32,file,13)); + assert(!decodeRegionBoundaryResponse({raw.data(),writer.size()},token,{digest.data(),32},33,0,32,file)); + assert(!decodeRegionBoundaryResponse({raw.data(),writer.size()},token,{digest.data(),32},32,1,31,file)); + digest[0]^=1;assert(!decodeRegionBoundaryResponse({raw.data(),writer.size()},token,{digest.data(),32},32,0,32,file));digest[0]^=1; + raw[writer.size()]=0;assert(!decodeRegionBoundaryResponse({raw.data(),writer.size()+1},token,{digest.data(),32},32,0,32,file)); + } + { + query.west=query.south=-100;query.east=query.north=100; + geocaching::protocol::CmpWriter writer(raw.data(),raw.size()); + assert(header(writer,13,3)&&writer.unsignedInteger(9007199254740993ULL)&&writer.array(2)&&writer.binary(boundary)&&writer.binary({digest.data(),32})&&writer.array(1)); + assert(writer.array(10)&&writer.binary(boundary)&&writer.binary({digest.data(),32})&&writer.unsignedInteger(1)&&writer.unsignedInteger(0)&&writer.unsignedInteger(0)&&writer.text("Track")&& + writer.array(4)&&writer.signedInteger(-1)&&writer.signedInteger(-1)&&writer.signedInteger(1)&&writer.signedInteger(1)&&writer.array(4)&&writer.unsignedInteger(0)&&writer.nil()&&writer.nil()&&writer.unsignedInteger(2)&&writer.unsignedInteger(100)); + std::array author;author.fill(4);assert(writer.binary({author.data(),64})); + assert(decodeRegionQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page)&&query_page.rows==1&&query_page.snapshot==9007199254740993ULL&&query_page.has_cursor); + assert(!decodeTrackReadQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page)); + assert(!decodeTrackReadQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page,12)); + digest[0]^=1;assert(!decodeRegionQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page));digest[0]^=1; + query.has_snapshot=true;query.snapshot=9007199254740994ULL;assert(!decodeRegionQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page));query.has_snapshot=false; + query.state=1;assert(!decodeRegionQueryResponse({raw.data(),writer.size()},token,query,{digest.data(),32},query_row,query_page));query.state=0; + raw[writer.size()]=0;assert(!decodeRegionQueryResponse({raw.data(),writer.size()+1},token,query,{digest.data(),32},query_row,query_page)); + } + assert(regionLocale("zh-Hant-TW")&®ionLocale("en")&&!regionLocale("e")&&!regionLocale("en--US")&&!regionLocale("en-!")&&!regionNamespace("Test")); +} diff --git a/tests/hostlink/test_track_router_dispatch.cpp b/tests/hostlink/test_track_router_dispatch.cpp new file mode 100644 index 00000000..c4a32b2b --- /dev/null +++ b/tests/hostlink/test_track_router_dispatch.cpp @@ -0,0 +1,111 @@ +#include "chat/ports/i_track_transport.h" +#include "chat/ports/device_author.h" +#include "chat/ports/track_binding_registration.h" +#include +#include + +namespace chat +{ +struct TrackPort : ITrackTransport +{ + unsigned calls = 0; + unsigned binds = 0; + const lxmf::TrackDeliveryBinding* binding = nullptr; + MeshSendResult sendTrackData(const uint8_t*, lxmf::ByteSpan, bool, std::array* hash) override + { + ++calls; + if (hash) hash->fill(7); + return MeshSendResult::success(42); + } + void setTrackDeliveryBinding(const lxmf::TrackDeliveryBinding* value) override {binding=value;++binds;} +}; +struct Backend +{ + TrackPort port; + bool ready = true, available = true, supports = true; + uint32_t generation = 9; + uint8_t key[64]{}; + bool isReady() const { return ready; } + ITrackTransport* trackTransport() { return supports ? &port : nullptr; } + DeviceAuthorResult readDeviceAuthorKey(uint8_t* out, uint32_t& gen) const + { + if (!available) return DeviceAuthorResult::Unavailable; + std::memcpy(out,key,64);gen=generation;return DeviceAuthorResult::Ok; + } +}; +class MeshAdapterRouter +{ + public: + Backend backend; + bool admitted = true; + bool mutex_ = true; + const lxmf::TrackDeliveryBinding* track_delivery_binding_ = nullptr; + struct Core + { + Backend* backend = nullptr; + Backend* backendForProtocol(MeshProtocol) { return backend; } + } core_; + struct LockGuard + { + bool held; + LockGuard(bool mutex,unsigned) : held(mutex) {} + bool locked() const { return held; } + }; + MeshAdapterRouter() { core_.backend=&backend; } + const Backend* deviceAuthorBackendLocked() const { return admitted ? &backend : nullptr; } + MeshSendResult sendTrackData(const uint8_t*,lxmf::ByteSpan,const uint8_t*,uint32_t,bool,std::array*); + bool bindTrackDelivery(const lxmf::TrackDeliveryBinding*); + TrackBindingChange replaceTrackDelivery(const lxmf::TrackDeliveryBinding*,const lxmf::TrackDeliveryBinding*); + TrackBindingChange visitTrackDelivery(const lxmf::TrackDeliveryBinding*,uint8_t*,uint32_t&,TrackBindingVisit,void*); +}; +#include "track_router_binding_actual.inc" +#include "track_router_dispatch_actual.inc" +} // namespace chat + +int main() +{ + chat::MeshAdapterRouter router; + uint8_t destination[16]{}, author[64]{}, payload[1]{1}; + std::array hash; + auto send = [&] { hash.fill(9);return router.sendTrackData(destination,{payload,1},author,9,false,&hash); }; + send();assert(router.backend.port.calls==1 && hash[0]==7); + router.backend.key[0]=1;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.backend.key[0]=0;router.backend.generation=10;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.backend.generation=9;router.backend.available=false;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.backend.available=true;router.backend.ready=false;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.backend.ready=true;router.backend.supports=false;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.backend.supports=true;router.mutex_=false;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.mutex_=true;router.admitted=false;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.admitted=true;router.core_.backend=nullptr;send();assert(router.backend.port.calls==1 && hash[0]==0); + router.core_.backend=&router.backend; + router.sendTrackData(nullptr,{payload,1},author,9,false,&hash);assert(router.backend.port.calls==1); + router.sendTrackData(destination,{payload,8193},author,9,false,&hash);assert(router.backend.port.calls==1); + router.sendTrackData(destination,{payload,1},author,0,false,&hash);assert(router.backend.port.calls==1); + send();assert(router.backend.port.calls==2 && hash[0]==7); + chat::lxmf::TrackDeliveryBinding directory,job,foreign; + using chat::TrackBindingChange; + assert(router.replaceTrackDelivery(nullptr,&directory)==TrackBindingChange::Applied && router.backend.port.binding==&directory); + const auto binds=router.backend.port.binds; + assert(router.replaceTrackDelivery(nullptr,&job)==TrackBindingChange::Conflict && router.backend.port.binds==binds); + assert(!router.bindTrackDelivery(nullptr) && !router.bindTrackDelivery(&job) && router.backend.port.binding==&directory); + router.mutex_=false; + assert(router.replaceTrackDelivery(&directory,&job)==TrackBindingChange::Busy && router.backend.port.binding==&directory); + router.mutex_=true; + assert(router.replaceTrackDelivery(&directory,&job)==TrackBindingChange::Applied && router.backend.port.binding==&job); + assert(router.replaceTrackDelivery(&directory,nullptr)==TrackBindingChange::Conflict && router.backend.port.binding==&job); + assert(router.replaceTrackDelivery(&foreign,nullptr)==TrackBindingChange::Conflict && router.backend.port.binding==&job); + assert(router.replaceTrackDelivery(&job,nullptr)==TrackBindingChange::Applied && !router.backend.port.binding); + job.expected_local_author=author;job.expected_author_generation=9; + assert(router.replaceTrackDelivery(nullptr,&job)==TrackBindingChange::Applied); + uint8_t sampled_author[64];uint32_t sampled_generation=0;unsigned visits=0; + const auto visitor=[](void* count){++*static_cast(count);}; + const auto visit=[&]{return router.visitTrackDelivery(&job,sampled_author,sampled_generation,visitor,&visits);}; + assert(visit()==TrackBindingChange::Applied && visits==1 && sampled_generation==9 && !std::memcmp(sampled_author,author,64)); + router.backend.generation=10;assert(visit()==TrackBindingChange::Conflict && visits==1);router.backend.generation=9; + router.backend.key[0]=1;assert(visit()==TrackBindingChange::Conflict && visits==1);router.backend.key[0]=0; + router.backend.available=false;assert(visit()==TrackBindingChange::Busy && visits==1);router.backend.available=true; + router.mutex_=false;assert(visit()==TrackBindingChange::Busy && visits==1);router.mutex_=true; + assert(router.visitTrackDelivery(&job,author,sampled_generation,visitor,&visits)==TrackBindingChange::Conflict && visits==1); + assert(router.visitTrackDelivery(&directory,sampled_author,sampled_generation,visitor,&visits)==TrackBindingChange::Conflict && visits==1); + assert(router.replaceTrackDelivery(&job,nullptr)==TrackBindingChange::Applied); +} diff --git a/tests/hostlink/test_track_snapshot_files.cpp b/tests/hostlink/test_track_snapshot_files.cpp new file mode 100644 index 00000000..2dd291b2 --- /dev/null +++ b/tests/hostlink/test_track_snapshot_files.cpp @@ -0,0 +1,274 @@ +#include "platform/esp/common/storage/snapshot_file_response.h" +#include "platform/esp/common/storage/stdio_record_file_io.h" +#include "platform/esp/common/storage/track_snapshot_file_storage.h" +#include +#include +#include +#include + +#if defined(TRAIL_MATE_REAL_SNAPSHOT_HASH) +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* buffer, size_t length) +{ + auto* bytes = static_cast(buffer); + while (length--) *bytes++ = 0; +} +#endif +#endif + +using namespace platform::esp::storage; +using namespace hostlink::management; +class Io : public RecordFileIo +{ + public: + OpenResult open(const char* path, Mode mode) override + { + auto result = real.open(path, mode); + if (result.handle) + { + ++handles; + assert(handles == 1); + } + return result; + } + bool close(Handle file) override + { + --handles; + return real.close(file); + } + bool size(Handle file, uint64_t& bytes) override { return real.size(file, bytes); } + bool seek(Handle file, uint64_t offset) override { return real.seek(file, offset); } + size_t read(Handle file, void* out, size_t bytes) override { return real.read(file, out, short_read ? bytes - 1 : bytes); } + size_t write(Handle file, const void* data, size_t bytes) override { return real.write(file, data, short_write ? bytes - 1 : bytes); } + bool sync(Handle file) override { return !sync_fail && real.sync(file); } + bool publish(const char* temporary, const char* destination) override { return real.publish(temporary, destination); } + StdioRecordFileIo real; + int handles = 0; + bool short_read = false, short_write = false, sync_fail = false; +}; +#if defined(TRAIL_MATE_REAL_SNAPSHOT_HASH) +class Hash : public SnapshotStreamHash +{ + public: + Hash() : hash(context) {} + bool start() override { return hash.start(); } + bool update(const uint8_t* data, size_t bytes) override { return hash.update(data, bytes); } + bool finish(uint8_t out[32]) override { return hash.finish(out); } + void reset() override { hash.reset(); } + + private: + mbedtls_sha256_context context; + SnapshotStreamSha256 hash; +}; +#else +// Not cryptography: validates that the copy path feeds the stream in order. +// Real SHA-256 and physical SD durability require the platform integration. +class Hash : public SnapshotStreamHash +{ + public: + bool start() override + { + count = 0; + return true; + } + bool update(const uint8_t* data, size_t bytes) override + { + for (size_t i = 0; i < bytes; ++i) assert(data[i] == uint8_t((count + i) % 251)); + count += bytes; + return true; + } + bool finish(uint8_t out[32]) override + { + std::memset(out, 7, 32); + return true; + } + void reset() override { count = 0; } + size_t count = 0; +}; +#endif +class Lease : public SnapshotFileLease +{ + public: + explicit Lease(const std::filesystem::path& root) : source((root / "source.bin").string()), temporary((root / "snapshot.new").string()), destination((root / "snapshot.bin").string()) {} + SnapshotStorageResult acquire(const uint8_t[16], uint32_t, uint8_t) override + { + owned = true; + valid_source = true; + return SnapshotStorageResult::Ok; + } + bool valid() const override { return owned && valid_source; } + const char* sourcePath() const override { return source.c_str(); } + const char* temporaryPath() const override { return temporary.c_str(); } + bool commit(const TrackSnapshotMetadata& metadata) override + { + assert(!metadata.sealed); + if (commit_fail) return false; + std::filesystem::rename(temporary, destination); + committed = true; + return true; + } + bool discard() override + { + if (discard_fail) return false; + std::filesystem::remove(temporary); + return true; + } + void release() override { owned = false; } + std::string source, temporary, destination; + bool owned = false, valid_source = true, committed = false, commit_fail = false, discard_fail = false; +}; +static uint8_t scratch[320], response[448], id[16] = {1}; +int main(int argc, char** argv) +{ + assert(argc > 0); + const auto root = std::filesystem::absolute(argv[0]).parent_path() / "snapshot-file-fixture"; + std::filesystem::create_directories(root); + struct Cleanup + { + std::filesystem::path path; + ~Cleanup() { std::filesystem::remove_all(path); } + } cleanup{root}; + for (int scenario = 0; scenario < 7; ++scenario) + { + Io io; + Hash hash; + Lease lease(root); + std::filesystem::remove(lease.destination); + { + std::ofstream source(lease.source, std::ios::binary); + for (int i = 0; i < 750; ++i) source.put(char(i % 251)); + } + TrackSnapshotFileStorage storage(io, hash, lease); + TrackSnapshotBuilder builder(storage); + TrackSnapshotMetadata metadata; + assert(builder.begin(id, 750, 3) == SnapshotBuildResult::Pending && io.handles == 0); + io.short_read = scenario == 1; + io.short_write = scenario == 2; + io.sync_fail = scenario == 3; + if (scenario == 4) lease.valid_source = false; + lease.commit_fail = scenario == 5; + if (scenario == 6) assert(builder.cancel() == SnapshotBuildResult::Pending); + SnapshotBuildResult result = SnapshotBuildResult::Pending; + for (unsigned steps = 0; result == SnapshotBuildResult::Pending && steps < 10; ++steps) + { + result = builder.step(scratch, sizeof(scratch), metadata); + assert(io.handles == 0); + } + assert(!lease.owned && !std::filesystem::exists(lease.temporary)); + if (scenario == 0) + { + assert(result == SnapshotBuildResult::Ready && metadata.sealed && lease.committed); + const auto container = io.open(lease.destination.c_str(), RecordFileIo::Mode::Read); + TrackSnapshotMetadata restored; + assert(container.handle && readSnapshotContainerHeader(io, container.handle, restored)); + assert(restored.sealed && restored.bytes == metadata.bytes && restored.format == metadata.format && !std::memcmp(restored.hash, metadata.hash, 32)); + assert(io.close(container.handle) && io.handles == 0); + std::ifstream saved(lease.destination, std::ios::binary); + saved.seekg(kSnapshotContainerHeaderBytes); + const std::string bytes((std::istreambuf_iterator(saved)), {}); + assert(bytes.size() == 750); + for (size_t i = 0; i < bytes.size(); ++i) assert(uint8_t(bytes[i]) == i % 251); + saved.close(); + TrackSnapshotRequest request; + std::memcpy(request.id, metadata.id, 16); + request.maximum = 320; + size_t written = 0; + for (const auto offset : {0U, 320U, 640U}) + { + request.offset = offset; + request.has_hash = offset != 0; + std::memcpy(request.expected_hash, metadata.hash, 32); + assert(readSnapshotFileResponse(io, lease.destination.c_str(), request, restored, response, sizeof(response), written) == SnapshotReadResult::Ok && io.handles == 0); + geocaching::protocol::CmpReader reader({response, written}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView data; + assert(reader.array(fields, 6) && fields == 6 && reader.binary(data, 16) && !std::memcmp(data.data, id, 16)); + assert(reader.unsignedInteger(value) && value == 750 && reader.binary(data, 32)); + assert(reader.unsignedInteger(value) && value == 3 && reader.binary(data, 320)); + assert(data.size == std::min(320, 750 - offset)); + for (size_t i = 0; i < data.size; ++i) assert(data.data[i] == (offset + i) % 251); + assert(reader.unsignedInteger(value) && value == offset && reader.finished()); +#if defined(TRAIL_MATE_REAL_SNAPSHOT_HASH) + std::printf("chunk:"); + for (size_t i = 0; i < written; ++i) std::printf("%02x", response[i]); + std::printf("\n"); +#endif + } + request.expected_hash[0] ^= 1; + assert(readSnapshotFileResponse(io, lease.destination.c_str(), request, restored, response, sizeof(response), written) == SnapshotReadResult::Conflict && written == 0 && io.handles == 0); + request.expected_hash[0] ^= 1; + request.offset = 750; + assert(readSnapshotFileResponse(io, lease.destination.c_str(), request, restored, response, sizeof(response), written) == SnapshotReadResult::InvalidRequest && written == 0); + request.offset = 0; + assert(readSnapshotFileResponse(io, lease.destination.c_str(), request, restored, response, 10, written) == SnapshotReadResult::Capacity && written == 0 && io.handles == 0); + for (const auto offset : {0U, 4U, 20U, 52U, 56U, 57U, 62U}) + { + const auto file = io.open(lease.destination.c_str(), RecordFileIo::Mode::Update); + uint8_t original = 0; + assert(file.handle && io.seek(file.handle, offset) && io.read(file.handle, &original, 1) == 1); + const uint8_t corrupt = original ^ 1; + assert(io.seek(file.handle, offset) && io.write(file.handle, &corrupt, 1) == 1 && io.close(file.handle)); + const auto read = io.open(lease.destination.c_str(), RecordFileIo::Mode::Read); + assert(read.handle && !readSnapshotContainerHeader(io, read.handle, restored) && !restored.sealed && io.close(read.handle)); + const auto repair = io.open(lease.destination.c_str(), RecordFileIo::Mode::Update); + assert(repair.handle && io.seek(repair.handle, offset) && io.write(repair.handle, &original, 1) == 1 && io.close(repair.handle)); + } + std::filesystem::resize_file(lease.destination, kSnapshotContainerHeaderBytes + metadata.bytes - 1); + const auto truncated = io.open(lease.destination.c_str(), RecordFileIo::Mode::Read); + assert(truncated.handle && !readSnapshotContainerHeader(io, truncated.handle, restored) && !restored.sealed && io.close(truncated.handle)); +#if defined(TRAIL_MATE_REAL_SNAPSHOT_HASH) + for (auto byte : metadata.hash) std::printf("%02x", byte); + std::printf("\n"); +#endif + } + else + { + assert(result == (scenario == 6 ? SnapshotBuildResult::Cancelled : SnapshotBuildResult::Failed)); + assert(!metadata.sealed && !lease.committed && !std::filesystem::exists(lease.destination)); + } + } + { + Io io; + Hash hash; + Lease lease(root); + lease.temporary = lease.source; + TrackSnapshotFileStorage storage(io, hash, lease); + assert(storage.begin(id, 750, 3) == SnapshotStorageResult::Failed); + assert(storage.abort() == SnapshotStorageResult::Ok && !lease.owned && io.handles == 0); + assert(std::filesystem::file_size(lease.source) == 750); + } +#if defined(TRAIL_MATE_REAL_SNAPSHOT_HASH) + { + Io io; + Hash hash; + Lease lease(root); + std::filesystem::remove(lease.destination); + { + std::ofstream source(lease.source, std::ios::binary); + for (uint32_t i = 0; i < kTrackSnapshotMaxBytes; ++i) source.put(char(i % 251)); + } + TrackSnapshotFileStorage storage(io, hash, lease); + TrackSnapshotBuilder builder(storage); + TrackSnapshotMetadata metadata; + assert(builder.begin(id, kTrackSnapshotMaxBytes, 3) == SnapshotBuildResult::Pending); + SnapshotBuildResult result = SnapshotBuildResult::Pending; + unsigned steps = 0; + while (result == SnapshotBuildResult::Pending && steps++ < kTrackSnapshotMaxBytes / 320 + 3) + { + result = builder.step(scratch, sizeof(scratch), metadata); + assert(io.handles == 0); + } + assert(result == SnapshotBuildResult::Ready && metadata.sealed && !lease.owned); + assert(std::filesystem::file_size(lease.destination) == kSnapshotContainerHeaderBytes + kTrackSnapshotMaxBytes); + std::ifstream saved(lease.destination, std::ios::binary); + saved.seekg(kSnapshotContainerHeaderBytes); + for (uint32_t i = 0; i < kTrackSnapshotMaxBytes; ++i) assert(uint8_t(saved.get()) == i % 251); + assert(saved.get() == std::char_traits::eof()); + for (auto byte : metadata.hash) std::printf("%02x", byte); + std::printf("\n"); + } +#endif + return 0; +} diff --git a/tests/hostlink/test_track_snapshot_owner.cpp b/tests/hostlink/test_track_snapshot_owner.cpp new file mode 100644 index 00000000..20daccce --- /dev/null +++ b/tests/hostlink/test_track_snapshot_owner.cpp @@ -0,0 +1,3071 @@ +#include "chat/ports/i_mesh_adapter.h" +#include "hostlink/management_envelope.h" +#include "platform/esp/arduino_common/route_package_image_runtime.h" +#include "platform/esp/common/storage/track_snapshot_service.h" +#include "platform/esp/idf_common/hostlink_route_install_identity.h" +#include "platform/esp/idf_common/hostlink_route_install_owner.h" +#include "platform/esp/idf_common/hostlink_route_install_runtime.h" +#include "platform/esp/idf_common/hostlink_track_attachment_prepare_owner.h" +#include "platform/esp/idf_common/hostlink_track_history_authentication.h" +#include "platform/esp/idf_common/hostlink_track_history_query.h" +#include "platform/esp/idf_common/hostlink_track_ip_confirmation_owner.h" +#include "platform/esp/idf_common/hostlink_track_ip_confirmation_runtime.h" +#include "platform/esp/idf_common/hostlink_track_kml_retention.h" +#include "platform/esp/idf_common/hostlink_track_object_admission.h" +#include "platform/esp/idf_common/hostlink_track_package_input_owner.h" +#include "platform/esp/idf_common/hostlink_track_package_route_publication.h" +#include "platform/esp/idf_common/hostlink_track_prepare_owner.h" +#include "platform/esp/idf_common/hostlink_track_prepare_persistence.h" +#include "platform/esp/idf_common/hostlink_track_prepare_runtime.h" +#include "platform/esp/idf_common/hostlink_track_prepare_work.h" +#include "platform/esp/idf_common/hostlink_track_public_package_retention.h" +#include "platform/esp/idf_common/hostlink_track_publication_admission.h" +#include "platform/esp/idf_common/hostlink_track_publication_execution.h" +#include "platform/esp/idf_common/hostlink_track_publication_request.h" +#include "platform/esp/idf_common/hostlink_track_publication_runtime.h" +#include "platform/esp/idf_common/hostlink_track_read_owner.h" +#include "platform/esp/idf_common/hostlink_track_read_service.h" +#include "platform/esp/idf_common/hostlink_track_retention_admission.h" +#include "platform/esp/idf_common/hostlink_track_snapshot_owner.h" +#include "platform/esp/idf_common/hostlink_track_upload_identity.h" +#include "platform/esp/idf_common/hostlink_track_upload_maintenance.h" +#include "platform/esp/idf_common/hostlink_track_upload_owner.h" +#include "platform/esp/idf_common/hostlink_track_upload_runtime.h" +#include "track_sharing/device_signer.h" +#include "track_sharing/file_copy_slice.h" +#include "track_sharing/immutable_file_copy.h" +#include "track_sharing/persist_publication_intent.h" +#include "track_sharing/prepare_upload_inputs.h" +#include "track_sharing/publication_candidate_reader.h" +#include "track_sharing/verified_file_publish.h" +#include +#include +#include +#include +#include +#include +#include +#include +using namespace hostlink::management; +struct UploadAuthorAdapter +{ + chat::DeviceAuthorResult result = chat::DeviceAuthorResult::Ok; + uint32_t generation = 7; + chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* output, uint32_t& actual) + { + std::memset(output, 3, 64); + actual = generation; + return result; + } +}; +struct PersistenceAuthor final : chat::IMeshAdapter +{ + uint8_t public_key[64]{}, secret[64]{}; + mutable unsigned signing_calls = 0; + uint32_t identity_generation = 1; + chat::DeviceAuthorResult read_failure = chat::DeviceAuthorResult::Ok; + const chat::lxmf::TrackDeliveryBinding* binding = nullptr; + bool binding_busy = false, in_visit = false, send_allowed = true; + unsigned network_sends = 0; + std::vector network_wire; + PersistenceAuthor() + { + uint8_t seed[32] = {6}; + public_key[0] = 1; + ed25519_create_keypair(public_key + 32, secret, seed); + } + chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* output, uint32_t& generation) const override + { + if (read_failure != chat::DeviceAuthorResult::Ok) return read_failure; + std::memcpy(output, public_key, 64); + generation = identity_generation; + return chat::DeviceAuthorResult::Ok; + } + bool identityStorageError() const { return false; } + const uint8_t* encryptionPublicKey() const { return public_key; } + const uint8_t* signingPublicKey() const { return public_key + 32; } + uint32_t authorGeneration() const { return identity_generation; } + void combinedPublicKey(uint8_t* output) const { std::memcpy(output, public_key, 64); } + bool sign(const uint8_t* message, size_t size, uint8_t* signature) const + { + ++signing_calls; + ed25519_sign(signature, message, size, public_key + 32, secret); + return true; + } + chat::DeviceAuthorResult signTrackManifest(const uint8_t* raw, size_t size, uint32_t generation, track_sharing::ManifestView& view, uint8_t* workspace, size_t capacity, uint8_t* signature, size_t signature_capacity) const override + { + return track_sharing::signDeviceTrackManifest(*this, raw, size, generation, view, workspace, capacity, signature, signature_capacity); + } + bool sendText(chat::ChannelId, const std::string&, chat::MessageId*, chat::NodeId) override { return false; } + bool pollIncomingText(chat::MeshIncomingText*) override { return false; } + bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, bool, chat::MessageId, bool) override { return false; } + bool pollIncomingData(chat::MeshIncomingData*) override { return false; } + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } + chat::TrackBindingChange replaceTrackDelivery(const chat::lxmf::TrackDeliveryBinding* expected, const chat::lxmf::TrackDeliveryBinding* replacement) override + { + if (binding_busy) return chat::TrackBindingChange::Busy; + if (binding != expected) return chat::TrackBindingChange::Conflict; + binding = replacement; + return chat::TrackBindingChange::Applied; + } + bool trackLocalDestination(uint8_t* output) override + { + std::memset(output, 0, 16); + output[0] = 42; + return true; + } + chat::TrackBindingChange visitTrackDelivery(const chat::lxmf::TrackDeliveryBinding* expected, uint8_t* author, uint32_t& generation, chat::TrackBindingVisit visit, void* context) override + { + if (binding_busy || read_failure != chat::DeviceAuthorResult::Ok) return chat::TrackBindingChange::Busy; + if (binding != expected || !expected || !expected->expected_local_author || identity_generation != expected->expected_author_generation || + std::memcmp(public_key, expected->expected_local_author, 64)) return chat::TrackBindingChange::Conflict; + std::memcpy(author, public_key, 64); + generation = identity_generation; + assert(!in_visit); + in_visit = true; + visit(context); + in_visit = false; + return chat::TrackBindingChange::Applied; + } + chat::MeshSendResult sendTrackData(const uint8_t*, chat::lxmf::ByteSpan bytes, const uint8_t* author, uint32_t generation, bool response, std::array*) override + { + assert(!in_visit && !response && generation == identity_generation && !std::memcmp(author, public_key, 64)); + ++network_sends; + network_wire.assign(bytes.data, bytes.data + bytes.size); + return send_allowed ? chat::MeshSendResult::success(1) : chat::MeshSendResult::fail(chat::MeshOperationFailure::Busy); + } +}; +namespace runtime = hostlink::management::snapshot_runtime; +static std::map> files; +static std::map directory_status; +static std::vector created_directories; +static bool mkdir_fail = false; +static uint32_t media = 1; +static int64_t clock_us = 100000; +static bool held = false, recording = false, allocation_fail = false, remove_fail = false; +static bool backing_allocation_fail = false; +static std::string remove_failure_path; +static size_t write_limit = SIZE_MAX; +static bool flush_failure = false; +static bool rename_failure = false, corrupt_after_rename = false; +static unsigned allocations = 0; +void* heap_caps_malloc(size_t bytes, unsigned caps) +{ + assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (allocation_fail) return nullptr; + ++allocations; + return std::malloc(bytes); +} +void heap_caps_free(void* pointer) +{ + --allocations; + std::free(pointer); +} +int64_t esp_timer_get_time() { return clock_us; } +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +namespace platform::ui::tracker +{ +bool try_acquire_snapshot_read() +{ + if (held || recording) return false; + held = true; + return true; +} +void release_snapshot_read() +{ + assert(held); + held = false; +} +} // namespace platform::ui::tracker +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready() { return true; } +uint32_t sd_media_session() { return media; } +bool sd_external_block_owner_active() { return false; } +bool sd_is_directory(const char*) { return true; } +bool sd_mkdir(const char* path) +{ + created_directories.emplace_back(path); + if (mkdir_fail) return false; + directory_status[path] = SdFileReadStatus::Ready; + return true; +} +SdFileReadResult sd_read_file(const char* path, uint8_t* bytes, size_t capacity) +{ + auto found = files.find(path); + if (found == files.end()) return {SdFileReadStatus::Missing}; + if (!bytes || found->second.empty() || found->second.size() > capacity) return {SdFileReadStatus::Invalid}; + std::memcpy(bytes, found->second.data(), found->second.size()); + return {SdFileReadStatus::Ready}; +} +bool sd_remove(const char* path, uint32_t expected) +{ + if (remove_fail || expected != media || remove_failure_path == path) return false; + return files.erase(path) != 0; +} +bool sd_rename(const char* from, const char* to, uint32_t expected) +{ + if (rename_failure || expected != media || files.count(to) || !files.count(from)) return false; + files[to] = std::move(files[from]); + files.erase(from); + if (corrupt_after_rename && !files[to].empty()) files[to][0] ^= 1; + return true; +} +class SdRuntimeFile::Impl +{ + public: + std::string path; + size_t offset = 0; + uint32_t session = 0; + bool open = false; +}; +SdRuntimeFile::SdRuntimeFile() : impl_(new Impl) {} +SdRuntimeFile::SdRuntimeFile(SdRuntimeMemoryPolicy policy) : impl_(backing_allocation_fail ? nullptr : new Impl) +{ + assert(policy == SdRuntimeMemoryPolicy::PsramOnly); +} +SdRuntimeFile::~SdRuntimeFile() { delete impl_; } +bool SdRuntimeFile::open(const char* path, const char* mode, uint32_t expected) +{ + if (expected != media) return false; + if (*mode == 'w') files[path].clear(); + else if (!files.count(path)) return false; + impl_->path = path; + impl_->offset = 0; + impl_->session = expected; + impl_->open = true; + return true; +} +void SdRuntimeFile::close() { impl_->open = false; } +bool SdRuntimeFile::is_open() const { return impl_->open && impl_->session == media; } +bool SdRuntimeFile::seek(uint64_t offset) +{ + if (!is_open() || offset > files[impl_->path].size()) return false; + impl_->offset = size_t(offset); + return true; +} +uint64_t SdRuntimeFile::size() const { return is_open() ? files[impl_->path].size() : 0; } +int SdRuntimeFile::read(void* output, size_t length) +{ + if (!is_open()) return 0; + auto& bytes = files[impl_->path]; + length = std::min(length, bytes.size() - impl_->offset); + std::memcpy(output, bytes.data() + impl_->offset, length); + impl_->offset += length; + return int(length); +} +size_t SdRuntimeFile::write(const void* input, size_t length) +{ + if (!is_open()) return 0; + length = std::min(length, write_limit); + auto& bytes = files[impl_->path]; + bytes.resize(std::max(bytes.size(), impl_->offset + length)); + std::memcpy(bytes.data() + impl_->offset, input, length); + impl_->offset += length; + return length; +} +bool SdRuntimeFile::flush() { return is_open() && !flush_failure; } +class SdRuntimeDir::Impl +{ + public: + size_t index = 0; + std::string prefix; +}; +SdRuntimeDir::SdRuntimeDir() : impl_(new Impl) {} +SdRuntimeDir::SdRuntimeDir(SdRuntimeMemoryPolicy policy) : impl_(backing_allocation_fail ? nullptr : new Impl) +{ + assert(policy == SdRuntimeMemoryPolicy::PsramOnly); +} +SdRuntimeDir::~SdRuntimeDir() { delete impl_; } +SdFileReadStatus SdRuntimeDir::open_read_status(const char* path, uint32_t expected) +{ + impl_->index = 0; + impl_->prefix = std::string(path) + "/"; + if (expected != media) return SdFileReadStatus::Unavailable; + const auto found = directory_status.find(path); + return found == directory_status.end() ? SdFileReadStatus::Ready : found->second; +} +SdFileReadStatus SdRuntimeDir::seek_read_status(uint64_t position) +{ + if (!impl_ || position % 32) return SdFileReadStatus::Invalid; + impl_->index = size_t(position / 32); + return SdFileReadStatus::Ready; +} +SdDirReadStatus SdRuntimeDir::read_next_info(char* name, size_t capacity, bool* directory, uint64_t* bytes, uint64_t* next) +{ + std::map> children; + for (const auto& item : files) + { + if (item.first.rfind(impl_->prefix, 0) != 0) continue; + const auto filename = item.first.substr(impl_->prefix.size()); + const auto slash = filename.find('/'); + children[filename.substr(0, slash)] = {slash != std::string::npos, slash != std::string::npos ? 0 : item.second.size()}; + } + size_t index = 0; + for (const auto& child : children) + { + if (index++ != impl_->index) continue; + if (child.first.size() + 1 > capacity) return SdDirReadStatus::IoError; + std::memcpy(name, child.first.c_str(), child.first.size() + 1); + *directory = child.second.first; + *bytes = child.second.second; + *next = ++impl_->index * 32; + return SdDirReadStatus::Entry; + } + return SdDirReadStatus::End; +} +} // namespace platform::esp::arduino_common::storage +static void testReadOwner() +{ + using namespace track_sharing; + using Owner = hostlink::management::track_read_runtime::TrackReadOwner; + using Change = chat::TrackBindingChange; + PersistenceAuthor adapter; + PublicationDirectory directory; + std::memset(directory.delivery, 4, 16); + std::memset(directory.public_key, 5, 64); + std::memset(directory.epoch, 6, 16); + directory.file_limit = 8388608; + directory.chunk_limit = 1024; + directory.query_limit = 32; + uint8_t request[16], session[16], digest[32], raw[512], output[320]; + std::memset(request, 1, 16); + std::memset(session, 2, 16); + std::memset(digest, 3, 32); + ReadValidationContext expected; + expected.file_hash = {digest, 32}; + expected.file_bytes = 3; + expected.length = 3; + const unsigned before = allocations; + auto* owner = Owner::create(adapter, 8, {request, 16}, {session, 16}, directory, {adapter.public_key, 64}, 1, expected); + assert(owner && allocations > before && owner->matchesSession(session) && owner->matchesRequest({request, 16})); + // Inputs are copied; later directory/config/request edits cannot retarget. + digest[0] = 0; + directory.delivery[0] = 0; + namespace directories = hostlink::management::directory_runtime; + directories::owner = new (heap_caps_malloc(sizeof(directories::Owner), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) directories::Owner(adapter, session, 100); + adapter.binding = &directories::owner->binding; + assert(directories::handoverRead(adapter, *owner, 100) == Change::Applied && !directories::owner); + ReadExchangeState state; + assert(owner->advance(101, state) == Change::Applied && state == ReadExchangeState::Waiting && adapter.network_sends == 1); + uint8_t source[16], local[16], message[32], original_hash[32]; + std::memset(source, 4, 16); + std::memset(local, 0, 16); + local[0] = 42; + std::memset(message, 7, 32); + std::memset(original_hash, 3, 32); + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(6) && writer.unsignedInteger(1) && writer.unsignedInteger(1) && writer.unsignedInteger(8) && writer.binary({request, 16}) && + writer.unsignedInteger(200) && writer.array(4) && writer.binary({original_hash, 32}) && writer.unsignedInteger(3) && writer.unsignedInteger(0) && writer.binary({source, 3})); + chat::lxmf::CustomDeliveryView view{{source, 16}, {local, 16}, {message, 32}, {raw, writer.size()}}; + assert(chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, view)); + assert(owner->advance(102, state) == Change::Applied && state == ReadExchangeState::Ready); + StoredReadResponse metadata; + ReadStoreResult result; + size_t written = 0; + assert(owner->read(0, 0, output, sizeof(output), written, metadata, result) == Change::Applied && result == ReadStoreResult::Ready && metadata.ready && metadata.bytes == writer.size()); + uint8_t response_hash[32]; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(hash.start() && hash.update(raw, writer.size()) && hash.finish(response_hash)); + assert(!std::memcmp(response_hash, metadata.digest, 32)); + assert(owner->read(0, metadata.bytes, output, sizeof(output), written, metadata, result) == Change::Applied && result == ReadStoreResult::Ready && written == writer.size() && !std::memcmp(raw, output, written)); + namespace publication = hostlink::management::publication_runtime; + publication::controller = new (heap_caps_malloc(sizeof(publication::Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) publication::Controller; + publication::controller->adapter = &adapter; + publication::controller->read = owner; + publication::controller->read_state = ReadExchangeState::Ready; + directory.delivery[0] = 4; + publication::controller->directory = directory; + publication::controller->read_directory_cached = true; + std::memcpy(publication::controller->local, session, 16); + uint8_t command[471]; + auto resultCommand = [&](Operation operation, bool chunk) + { + geocaching::protocol::CmpWriter body(command, sizeof(command)); + assert(body.array(chunk ? 3 : 1) && body.binary({request, 16})); + if (chunk) assert(body.unsignedInteger(0) && body.unsignedInteger(writer.size())); + EnvelopeView envelope{session, request, uint16_t(operation), command, body.size()}; + return hostlink::management::track_read_runtime::processReadResult(adapter, envelope, 103, command, sizeof(command), written); + }; + assert(resultCommand(Operation::TrackReadStatus, false) == Status::Ok); + assert(resultCommand(Operation::TrackReadChunk, true) == Status::Ok); + geocaching::protocol::CmpReader response({command, written}); + size_t fields = 0; + uint64_t total = 0, offset = 0; + geocaching::ByteView hash_view, content; + assert(response.array(fields, 4) && fields == 4 && response.binary(hash_view, 32) && !std::memcmp(hash_view.data, response_hash, 32) && + response.unsignedInteger(total) && total == writer.size() && response.unsignedInteger(offset) && !offset && + response.binary(content, 320) && content.size == writer.size() && !std::memcmp(content.data, raw, content.size) && response.finished()); + adapter.binding_busy = true; + assert(resultCommand(Operation::TrackReadRelease, false) == Status::Busy && owner && adapter.binding && publication::controller->read_retiring); + adapter.binding_busy = false; + assert(resultCommand(Operation::TrackReadRelease, false) == Status::Ok && publication::controller && !publication::controller->read && !publication::holdsStorage() && !adapter.binding && allocations == before + 1 && files.empty()); + owner = nullptr; + digest[0] = 3; + directory.delivery[0] = 4; + // Successive file requests reuse the pinned directory for this USB session + // after its discovery callback owner has already been retired. + request[0] = 8; + assert(publication::beginRead(adapter, 8, {request, 16}, {session, 16}, {directory.delivery, 16}, {directory.epoch, 16}, + {adapter.public_key, 64}, 1, expected, 104) == Status::Ok && + publication::controller->read && !directories::owner); + publication::closeSession(session); + assert(!publication::controller && !adapter.binding && allocations == before); + request[0] = 1; + owner = Owner::create(adapter, 8, {request, 16}, {session, 16}, directory, {adapter.public_key, 64}, 1, expected); + assert(owner && owner->attach(200, nullptr) == Change::Applied); + publication::controller = new (heap_caps_malloc(sizeof(publication::Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) publication::Controller; + publication::controller->adapter = &adapter; + publication::controller->read = owner; + publication::controller->read_state = ReadExchangeState::Waiting; + publication::controller->last = 200; + assert(publication::holdsStorage()); + assert(publication::getStatus(adapter, {request, 16}, 1, raw, sizeof(raw), written) == Status::Busy); + const EnvelopeView commit{session, request, uint16_t(Operation::TrackCommit), nullptr, 0}; + assert(publication::processCommit(adapter, commit, 200, raw, sizeof(raw), written) == Status::Busy); + adapter.binding_busy = true; + publication::closeSession(session); + assert(publication::controller && publication::controller->read == owner && publication::controller->read_retiring && adapter.binding); + assert(!chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, view)); + publication::tick(201); + assert(publication::controller && publication::holdsStorage()); + adapter.binding_busy = false; + publication::tick(202); + owner = nullptr; + assert(!publication::controller && !publication::holdsStorage() && !adapter.binding && allocations == before); +} +static void testReadRegionalOwner() +{ + using namespace track_sharing; + using Owner = hostlink::management::track_read_runtime::TrackReadOwner; + using Change = chat::TrackBindingChange; + PersistenceAuthor adapter; + PublicationDirectory directory; + std::memset(directory.delivery, 4, 16); + std::memset(directory.public_key, 5, 64); + std::memset(directory.epoch, 6, 16); + directory.file_limit = 8388608; + directory.chunk_limit = 1024; + directory.query_limit = 32; + uint8_t request[16], session[16], version[32], digest[32], reply_version[32], reply_digest[32], query_author[64], raw[512], message[32], local[16], source[16], output[320]; + std::memset(request, 50, 16); + std::memset(session, 51, 16); + std::memset(message, 52, 32); + std::memset(local, 0, 16); + local[0] = 42; + std::memset(source, 4, 16); + std::memset(reply_version, 2, 32); + std::memset(reply_digest, 3, 32); + for (uint8_t op : {9, 10, 11, 12, 13}) + { + std::memset(version, 2, 32); + std::memset(digest, 3, 32); + std::memset(query_author, 7, 64); + request[0] = op; + char name_space[] = "test", id[] = "test:r", parent[] = "test:r"; + RegionReadScope scope; + scope.name_space = name_space; + scope.id = id; + scope.version = {version, 32}; + scope.depth = 3; + if (op == 10) + { + scope.parent = parent; + scope.parent_level = 0; + } + ReadQuery query; + query.west = query.south = -100; + query.east = query.north = 100; + query.has_snapshot = true; + query.snapshot = 9007199254740993ULL; + query.author = {query_author, 64}; + ReadValidationContext expected; + expected.region = &scope; + expected.query = &query; + expected.file_hash = {digest, 32}; + expected.file_bytes = 3; + expected.length = 3; + const unsigned before = allocations; + if (op >= 10) + { + expected.region = nullptr; + assert(!Owner::create(adapter, op, {request, 16}, {session, 16}, directory, {adapter.public_key, 64}, 1, expected) && allocations == before); + expected.region = &scope; + } + auto* owner = Owner::create(adapter, op, {request, 16}, {session, 16}, directory, {adapter.public_key, 64}, 1, expected); + assert(owner); + name_space[0] = 'X'; + id[5] = 'x'; + parent[5] = 'x'; + version[0] = 9; + digest[0] = 9; + query_author[0] = 9; + scope.depth = 0; + query.snapshot = 0; + expected.file_bytes = 99; + assert(owner->attach(300, nullptr) == Change::Applied); + ReadExchangeState state; + assert(owner->advance(301, state) == Change::Applied && state == ReadExchangeState::Waiting); + geocaching::protocol::CmpReader wire({adapter.network_wire.data(), adapter.network_wire.size()}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView bytes; + std::string_view text; + assert(wire.array(fields, 6) && fields == 6 && wire.unsignedInteger(value) && value == 1 && wire.unsignedInteger(value) && !value && wire.unsignedInteger(value) && value == op && wire.binary(bytes, 16) && wire.unsignedInteger(value) && value == 8192 && wire.array(fields, 9)); + if (op == 9) assert(!fields && wire.finished()); + if (op == 10) assert(fields == 4 && wire.binary(bytes, 32) && bytes.data[0] == 2 && wire.text(text, 89) && text == "test:r" && wire.nil() && wire.unsignedInteger(value) && value == 16 && wire.finished()); + if (op == 11 || op == 12 || op == 13) assert(wire.text(text, 89) && text == "test:r" && wire.binary(bytes, 32) && bytes.data[0] == 2); + if (op == 11) assert(fields == 2 && wire.finished()); + if (op == 12) assert(fields == 4 && wire.unsignedInteger(value) && !value && wire.unsignedInteger(value) && value == 3 && wire.finished()); + if (op == 13) assert(fields == 9 && wire.text(text, 10) && text == "intersects" && wire.nil() && wire.binary(bytes, 64) && bytes.data[0] == 7 && wire.unsignedInteger(value) && !value && wire.unsignedInteger(value) && value == 16 && wire.unsignedInteger(value) && value == 9007199254740993ULL && wire.nil() && wire.finished()); + geocaching::protocol::CmpWriter reply(raw, sizeof(raw)); + assert(reply.array(6) && reply.unsignedInteger(1) && reply.unsignedInteger(1) && reply.unsignedInteger(op) && reply.binary({request, 16}) && reply.unsignedInteger(200) && reply.array(op == 9 ? 8 : op == 12 ? 4 + : 3)); + if (op == 9) + { + assert(reply.unsignedInteger(1) && reply.text("trailmate.track.regions.v1") && reply.text("test") && reply.binary({reply_version, 32}) && reply.unsignedInteger(3) && reply.unsignedInteger(32) && reply.unsignedInteger(1024) && reply.array(5)); + for (unsigned i = 9; i <= 13; i++) assert(reply.unsignedInteger(i)); + } + if (op == 10 || op == 11) + { + assert(reply.binary({reply_version, 32})); + if (op == 10) assert(reply.nil() && reply.array(1)); + assert(reply.array(7) && reply.text(op == 10 ? "test:a" : "test:r") && (op == 10 ? reply.text("test:r") : reply.nil()) && reply.unsignedInteger(op == 10 ? 1 : 0) && reply.text("Region") && reply.array(0) && reply.array(4) && reply.signedInteger(-1) && reply.signedInteger(-1) && reply.signedInteger(1) && reply.signedInteger(1) && reply.unsignedInteger(0)); + if (op == 11) assert(reply.array(3) && reply.text("region-e7-json-v1") && reply.binary({reply_digest, 32}) && reply.unsignedInteger(3)); + } + if (op == 12) + { + const uint8_t data[] = {7, 8, 9}; + assert(reply.binary({reply_digest, 32}) && reply.unsignedInteger(3) && reply.unsignedInteger(0) && reply.binary({data, 3})); + } + if (op == 13) assert(reply.unsignedInteger(9007199254740993ULL) && reply.nil() && reply.array(0)); + chat::lxmf::CustomDeliveryView view{{source, 16}, {local, 16}, {message, 32}, {raw, reply.size()}}; + assert(chat::lxmf::deliverVerifiedTrack(adapter.binding, false, false, view)); + assert(owner->advance(302, state) == Change::Applied && state == ReadExchangeState::Ready); + StoredReadResponse metadata; + ReadStoreResult result; + size_t written = 0; + assert(owner->read(0, 0, output, sizeof(output), written, metadata, result) == Change::Applied && result == ReadStoreResult::Ready && metadata.operation == op); + assert(Owner::release(owner) && !owner && !adapter.binding && allocations == before && files.empty()); + } +} +static void testRouteInstallOwner() +{ + namespace route = hostlink::management::route_install_runtime; + using hostlink::management::Status; + const unsigned before = allocations; + uint8_t id[16]{77}, author[32], public_key[64], session[16]{9}, digest[32], encoded[108], scratch[448]; + UploadAuthorAdapter adapter; + uint32_t generation = 0; + assert(hostlink::management::upload_runtime::readUploadAuthor(adapter, public_key, sizeof(public_key), author, generation) == Status::Ok && generation == 7); + const std::string xml = "120,30 120.01,30.01"; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(hash.start() && hash.update(reinterpret_cast(xml.data()), xml.size()) && hash.finish(digest)); + hostlink::management::RouteInstallView request{{id, 16}, {digest, 32}, uint32_t(xml.size()), 7}; + char source[64], journal_path[64]; + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::Candidate, source, sizeof(source))); + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::JournalA, journal_path, sizeof(journal_path))); + track_sharing::UploadJournal journal; + std::memcpy(journal.id, id, 16); + std::memcpy(journal.author_hash, author, 32); + std::memcpy(journal.file_hash, digest, 32); + journal.sequence = 1; + journal.generation = 7; + journal.total = journal.durable_offset = uint32_t(xml.size()); + journal.phase = track_sharing::UploadPhase::Sealed; + assert(track_sharing::encodeUploadJournal(journal, encoded, sizeof(encoded))); + files[source] = std::vector(xml.begin(), xml.end()); + files[journal_path] = std::vector(encoded, encoded + 108); + const auto allow = [] + { return Status::Ok; }; + auto* owner = route::createRouteInstallOwner(request, session, author, media, 10); + assert(owner && owner->matches(session, request) && owner->identityMatches(7, {author, 32}) && owner->alive(11, true)); + auto status = route::startAuthenticatedRouteInstall(*owner, adapter, true, scratch, sizeof(scratch), allow); + for (unsigned steps = 0; status == Status::Busy && steps < 100; ++steps) status = route::advanceAuthenticatedRouteInstall(*owner, adapter, scratch, sizeof(scratch), allow); + assert(status == Status::Ok && owner->wireState() == 1 && files.count(owner->target()) && !files.count(source)); + route::destroyRouteInstallOwner(owner); + assert(allocations == before); + files.erase(journal_path); + owner = route::createRouteInstallOwner(request, session, author, media, 20); + assert(owner); + status = owner->start(false, scratch, sizeof(scratch), allow); + for (unsigned steps = 0; status == Status::Busy && steps < 100; ++steps) status = owner->advance(scratch, sizeof(scratch), allow); + assert(status == Status::Ok && owner->wireState() == 1); + const auto target = std::string(owner->target()); + route::destroyRouteInstallOwner(owner); + assert(allocations == before); + files.erase(target); + files.erase(journal_path); + files[source] = std::vector(xml.begin(), xml.end()); + files[journal_path] = std::vector(encoded, encoded + 108); + owner = route::createRouteInstallOwner(request, session, author, media, 30); + assert(owner); + assert(route::startAuthenticatedRouteInstall(*owner, adapter, true, scratch, sizeof(scratch), allow) == Status::Busy); + adapter.generation = 8; + assert(route::advanceAuthenticatedRouteInstall(*owner, adapter, scratch, sizeof(scratch), allow) == Status::StaleIdentity && files.count(source) && !files.count(target)); + route::destroyRouteInstallOwner(owner); + adapter.generation = 7; + owner = route::createRouteInstallOwner(request, session, author, media, 30); + assert(owner); + adapter.result = chat::DeviceAuthorResult::StorageError; + assert(route::startAuthenticatedRouteInstall(*owner, adapter, true, scratch, sizeof(scratch), allow) == Status::StorageUnavailable); + adapter.result = chat::DeviceAuthorResult::Ok; + assert(route::startAuthenticatedRouteInstall(*owner, adapter, true, reinterpret_cast(owner), 448, allow) == Status::InvalidField); + route::destroyRouteInstallOwner(owner); + route::RouteInstallOwner* slot = nullptr; + uint8_t body[128]; + geocaching::protocol::CmpWriter writer(body, sizeof(body)); + assert(writer.array(4) && writer.binary({id, 16}) && writer.binary({digest, 32}) && writer.unsignedInteger(xml.size()) && writer.unsignedInteger(7)); + hostlink::management::EnvelopeView envelope; + envelope.code = uint16_t(hostlink::management::Operation::RouteInstall); + envelope.session = session; + envelope.body = body; + envelope.body_size = writer.size(); + size_t written = 0; + const auto policy = [](const auto&, bool) + { return Status::Ok; }; + const auto replyState = [&] + { + geocaching::protocol::CmpReader reply({scratch, written}); + size_t fields = 0; + geocaching::ByteView returned_id, returned_hash; + uint64_t total = 0, state = 255; + std::string_view path; + assert(reply.array(fields, 5) && fields == 5 && reply.binary(returned_id, 16) && returned_id.size == 16 && + reply.binary(returned_hash, 32) && returned_hash.size == 32 && reply.unsignedInteger(total) && reply.text(path, 76) && + reply.unsignedInteger(state) && reply.finished()); + assert(!std::memcmp(returned_id.data, id, 16) && !std::memcmp(returned_hash.data, digest, 32) && total == xml.size() && path == slot->target()); + return state; + }; + assert(route::processRouteInstallRequest(slot, adapter, envelope, 40, scratch, sizeof(scratch), written, policy) == Status::Ok && slot && slot->wireState() == 0 && written); + assert(replyState() == 0); + assert(!route::retireDeliveredRouteInstall(slot) && slot); + uint8_t other_session[16]{8}; + envelope.session = other_session; + assert(route::processRouteInstallRequest(slot, adapter, envelope, 41, scratch, sizeof(scratch), written, policy) == Status::Unauthorized); + envelope.session = session; + for (unsigned step = 0; slot->status() == Status::Busy && step < 100; ++step) route::advanceRouteInstallSlot(slot, &adapter, scratch, sizeof(scratch), 42, true, policy); + assert(slot->wireState() == 1 && !slot->resultDelivered()); + assert(!route::retireDeliveredRouteInstall(slot) && slot); + envelope.code = uint16_t(hostlink::management::Operation::RouteInstallStatus); + assert(route::processRouteInstallRequest(slot, adapter, envelope, 43, scratch, sizeof(scratch), written, policy) == Status::Ok && slot->wireState() == 1 && slot->resultDelivered()); + assert(replyState() == 1); + const std::vector confirmed_reply(scratch, scratch + written); + assert(route::retireDeliveredRouteInstall(slot) && !slot && allocations == before); + assert(std::equal(confirmed_reply.begin(), confirmed_reply.end(), scratch)); + const auto installed_files = files; + assert(route::processRouteInstallRequest(slot, adapter, envelope, 44, scratch, sizeof(scratch), written, policy) == Status::Ok && slot->queryOnly() && slot->wireState() == 0); + for (unsigned step = 0; slot->status() == Status::Busy && step < 100; ++step) route::advanceRouteInstallSlot(slot, &adapter, scratch, sizeof(scratch), 45, true, policy); + assert(route::processRouteInstallRequest(slot, adapter, envelope, 46, scratch, sizeof(scratch), written, policy) == Status::Ok && slot->wireState() == 1 && files == installed_files); + const std::string installed_target = slot->target(); + route::closeRouteInstallSession(slot, session); + assert(!slot && allocations == before); + files.erase(installed_target); + assert(route::processRouteInstallRequest(slot, adapter, envelope, 47, scratch, sizeof(scratch), written, policy) == Status::Ok && slot->queryOnly() && slot->wireState() == 2 && !files.count(installed_target)); + assert(replyState() == 2); + route::closeRouteInstallSession(slot, session); + assert(!slot && allocations == before); + files.erase(source); + files.erase(journal_path); + files[journal_path] = std::vector(encoded, encoded + 108); + unsigned policy_calls = 0; + envelope.code = uint16_t(hostlink::management::Operation::RouteInstall); + const auto files_before_busy = files; + assert(route::processRouteInstallRequest(slot, adapter, envelope, 48, scratch, sizeof(scratch), written, [&](const auto&, bool) + { return ++policy_calls == 1 ? Status::Ok : Status::Busy; }) == Status::Busy); + assert(!slot && allocations == before && files == files_before_busy && written == 0); + files.erase(journal_path); + allocation_fail = true; + assert(!route::createRouteInstallOwner(request, session, author, media, 20)); + allocation_fail = false; + backing_allocation_fail = true; + assert(!route::createRouteInstallOwner(request, session, author, media, 20)); + backing_allocation_fail = false; + assert(allocations == before); +} +static void testAttachmentPreparation() +{ + using namespace track_sharing; + using R = AttachmentPackageResult; + namespace prepare = hostlink::management::prepare_runtime; + namespace upload = hostlink::management::upload_runtime; + const auto saved = files; + assert(!allocations && !upload::owner); + files.clear(); + uint8_t author[32] = {2}, kml_hash[32] = {3}, index_id[16] = {4}, ids[1024]{}, session[16] = {9}; + uint8_t digest[32], index_digest[32], scratch[448]; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + const std::vector resource(641, 6); + const auto calculate = [&](const std::vector& bytes, uint8_t* output) + { assert(hash.start() && hash.update(bytes.data(), bytes.size()) && hash.finish(output)); hash.reset(); }; + calculate(resource, digest); + const auto sealed = [&](const uint8_t* id, UploadKind kind, const std::vector& bytes, const uint8_t* file_hash) + { + char path[64]; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), kind)); + files[path] = bytes; + UploadJournal journal; + std::memcpy(journal.id, id, 16); + std::memcpy(journal.author_hash, author, 32); + std::memcpy(journal.file_hash, file_hash, 32); + journal.generation = 1; + journal.sequence = 3; + journal.phase = UploadPhase::Sealed; + journal.total = journal.durable_offset = bytes.size(); + std::vector encoded(kUploadJournalBytes); + assert(encodeUploadJournal(journal, encoded.data(), encoded.size())); + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path), kind)); + files[path] = encoded; + }; + // Canonical 64-entry index, separate upload IDs can share identical bytes. + std::vector raw(kMaxAttachmentIndexBytes); + geocaching::protocol::CmpWriter writer(raw.data(), raw.size()); + assert(writer.array(4) && writer.unsignedInteger(1) && writer.binary({kml_hash, 32}) && writer.unsignedInteger(300) && writer.array(64)); + for (unsigned i = 0; i < 64; ++i) + { + ids[i * 16] = uint8_t(i + 1); + const auto id = "img" + std::to_string(i); + const auto reference = "photos/" + std::to_string(i) + ".png"; + assert(writer.array(7) && writer.text(id) && writer.binary({digest, 32}) && writer.unsignedInteger(resource.size()) && + writer.text("image/png") && writer.text(reference) && writer.unsignedInteger(0) && writer.unsignedInteger(i == 0 ? 1 : 0)); + sealed(ids + i * 16, UploadKind::AttachmentResource, resource, digest); + } + raw.resize(writer.size()); + calculate(raw, index_digest); + const AttachmentIndexBinding binding{{index_digest, 32}, uint32_t(raw.size()), 64}; + sealed(index_id, UploadKind::AttachmentIndex, raw, index_digest); + const auto intact = files; + for (unsigned fault = 0; fault < 10; ++fault) + { + files = intact; + uint8_t frozen_ids[1024]; + std::memcpy(frozen_ids, ids, sizeof(ids)); + char path[64]; + if (fault == 1) std::memset(frozen_ids + 16, 0, 16); // optional absent + if (fault == 2) std::memset(frozen_ids, 0, 16); // required absent + if (fault == 3 || fault == 4) + { + assert(resolveUploadPath(ids + (fault == 3 ? 0 : 16), 16, UploadFile::Candidate, path, sizeof(path), UploadKind::AttachmentResource)); + files[path][10] ^= 1; // required or supplied optional corruption + } + if (fault == 5) + { + assert(resolveUploadPath(ids, 16, UploadFile::JournalA, path, sizeof(path), UploadKind::AttachmentResource)); + files.erase(path); // missing supplied upload metadata is not optional absence + } + if (fault == 6) + { + assert(resolveUploadPath(index_id, 16, UploadFile::Candidate, path, sizeof(path), UploadKind::AttachmentIndex)); + files[path].back() = 1; // valid required flag, but digest no longer matches + } + const auto before = files; + auto* owner = prepare::createAttachmentPrepareOwner(media, 1, author, index_id, {frozen_ids, sizeof(frozen_ids)}, binding, {kml_hash, 32}, 300); + assert(owner && allocations == 1 && owner->begin() == R::Pending && !owner->ready()); + // Mutation of borrowed constructor input cannot change the admitted job. + std::memset(frozen_ids, 0, sizeof(frozen_ids)); + R result = R::Pending; + unsigned steps = 0; + while (result == R::Pending && steps++ < 3000) + { + result = owner->step(fault == 7 ? 2 : 1, author); + if (fault == 8 && steps == 1) + { + ++media; + result = owner->step(1, author); + --media; + } + if (fault == 9 && steps == 1) + { + owner->cancel(); + result = owner->step(1, author); + } + } + const auto expected = fault <= 1 ? R::Ready : fault == 2 ? R::MissingRequired + : fault == 3 || fault == 4 || fault == 6 ? R::HashMismatch + : fault == 8 ? R::StorageFailure + : fault == 9 ? R::Cancelled + : R::Invalid; + assert(result == expected && files == before); + if (fault <= 1) + { + assert(owner->ready() && owner->matches(binding, {kml_hash, 32}, 300)); + assert(owner->allAvailable() == (fault == 0)); + assert(owner->availability() == (fault == 0 ? UINT64_MAX : UINT64_MAX ^ uint64_t(2))); + auto wrong = binding; + --wrong.count; + assert(!owner->matches(wrong, {kml_hash, 32}, 300)); + assert(!owner->matches(binding, {kml_hash, 32}, 301)); + } + else assert(!owner->ready() && !owner->availability()); + prepare::destroyAttachmentPrepareOwner(owner); + assert(allocations == 0); + } + allocation_fail = true; + assert(!prepare::createAttachmentPrepareOwner(media, 1, author, index_id, {ids, sizeof(ids)}, binding, {kml_hash, 32}, 300)); + allocation_fail = false; + backing_allocation_fail = true; + assert(!prepare::createAttachmentPrepareOwner(media, 1, author, index_id, {ids, sizeof(ids)}, binding, {kml_hash, 32}, 300) && !allocations); + backing_allocation_fail = false; + files = intact; + // All 64 resources (192 candidate/journal slots) are admitted, while the + // historical KML directory still permits only two transactions. + assert(upload::admitUploadSpace(media, resource.size(), scratch, sizeof(scratch), UploadKind::AttachmentResource) == Status::Ok); + for (unsigned i = 0; i < 64; ++i) + { + char path[64]; + assert(resolveUploadPath(ids + i * 16, 16, UploadFile::JournalB, path, sizeof(path), UploadKind::AttachmentResource)); + files[path] = std::vector(108, 0); + } + assert(upload::admitUploadSpace(media, resource.size(), scratch, sizeof(scratch), UploadKind::AttachmentResource) == Status::LimitExceeded); + files.clear(); + // Real upload owner/journals/seal/recovery for each new transfer kind. + for (const auto kind : {UploadKind::AttachmentIndex, UploadKind::AttachmentResource}) + { + upload::TrackUploadOwner* owner = nullptr; + assert(upload::beginNewUpload(owner, index_id, session, media, 1, author, resource.size(), digest, 100, scratch, sizeof(scratch), kind) == Status::Ok); + assert(owner && owner->kind == kind); + for (size_t offset = 0; offset < resource.size(); offset += 320) + assert(owner->resources.append(index_id, offset, resource.data() + offset, std::min(320, resource.size() - offset), scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(owner->resources.beginSeal(scratch, sizeof(scratch)) == UploadSealResult::Pending); + UploadSealResult sealed_result; + do + { + sealed_result = owner->resources.step(scratch, sizeof(scratch)); + } while (sealed_result == UploadSealResult::Pending); + assert(sealed_result == UploadSealResult::Ready); + upload::destroyOwner(owner); + owner = nullptr; + assert(upload::recoverUpload(owner, index_id, session, media, 1, author, resource.size(), digest, 200, scratch, sizeof(scratch), kind) == Status::Ok); + assert(owner && owner->resources.metadata().phase == UploadPhase::Sealed); + upload::destroyOwner(owner); + assert(!allocations); + } + files = saved; +} +static void testPackagePrepareWorkflow() +{ + using namespace track_sharing; + namespace prepare = hostlink::management::prepare_runtime; + const auto saved = files; + files.clear(); + assert(!prepare::work && !allocations); + PersistenceAuthor adapter; + uint8_t author_hash[32], kml_hash[32], resource_hash[32], index_hash[32], nonce[16] = {10}, session[16] = {11}; + uint8_t kml_id[16] = {12}, manifest_id[16] = {13}, index_id[16] = {14}, mapping_id[16] = {15}, operation[16] = {16}, ids[48]{}; + uint8_t payload[151], scratch[471], raw_manifest[4096], signed_raw[4166], verify_workspace[4200]; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + const auto digest = [&](const uint8_t* data, size_t size, uint8_t* output) + { assert(hash.start() && hash.update(data, size) && hash.finish(output)); hash.reset(); }; + digest(adapter.public_key, 64, author_hash); + const std::string xml = "1,2"; + const std::vector resource(641, 17); + digest(reinterpret_cast(xml.data()), xml.size(), kml_hash); + digest(resource.data(), resource.size(), resource_hash); + const auto sealed = [&](const uint8_t* id, UploadKind kind, const uint8_t* data, size_t size) + { + char path[64]; + UploadJournal journal; + std::memcpy(journal.id, id, 16); + std::memcpy(journal.author_hash, author_hash, 32); + digest(data, size, journal.file_hash); + journal.generation = 1; + journal.sequence = 3; + journal.phase = UploadPhase::Sealed; + journal.total = journal.durable_offset = size; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), kind)); + files[path] = {data, data + size}; + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path), kind)); + files[path].resize(108); + assert(encodeUploadJournal(journal, files[path].data(), 108)); + }; + std::vector index(1000); + geocaching::protocol::CmpWriter iw(index.data(), index.size()); + assert(iw.array(4) && iw.unsignedInteger(1) && iw.binary({kml_hash, 32}) && iw.unsignedInteger(xml.size()) && iw.array(3)); + for (unsigned i = 0; i < 3; ++i) + { + if (i != 1) + { + ids[i * 16] = uint8_t(20 + i); + sealed(ids + i * 16, UploadKind::AttachmentResource, resource.data(), resource.size()); + } + const auto name = "img" + std::to_string(i), reference = "photos/" + std::to_string(i) + ".png"; + assert(iw.array(7) && iw.text(name) && iw.binary({resource_hash, 32}) && iw.unsignedInteger(resource.size()) && iw.text("image/png") && + iw.text(reference) && iw.unsignedInteger(0) && iw.unsignedInteger(i == 0 ? 1 : 0)); + } + index.resize(iw.size()); + digest(index.data(), index.size(), index_hash); + sealed(index_id, UploadKind::AttachmentIndex, index.data(), index.size()); + uint8_t mapping[1100]; + geocaching::protocol::CmpWriter mw(mapping, sizeof(mapping)); + assert(mw.array(3) && mw.unsignedInteger(1) && mw.binary({index_id, 16}) && mw.binary({ids, sizeof(ids)})); + sealed(mapping_id, UploadKind::AttachmentBindings, mapping, mw.size()); + geocaching::protocol::CmpWriter manifest(raw_manifest, sizeof(raw_manifest)); + assert(manifest.array(16) && manifest.unsignedInteger(2) && manifest.binary({adapter.public_key, 64}) && manifest.binary({nonce, 16}) && + manifest.unsignedInteger(0) && manifest.unsignedInteger(1) && manifest.nil() && manifest.unsignedInteger(0) && manifest.unsignedInteger(100) && + manifest.unsignedInteger(100) && manifest.text("Package") && manifest.text("")); + assert(manifest.array(4) && manifest.unsignedInteger(10000000) && manifest.unsignedInteger(20000000) && manifest.unsignedInteger(10000000) && manifest.unsignedInteger(20000000)); + assert(manifest.array(4) && manifest.unsignedInteger(0) && manifest.nil() && manifest.nil() && manifest.unsignedInteger(1)); + assert(manifest.array(4) && manifest.binary({kml_hash, 32}) && manifest.unsignedInteger(xml.size()) && manifest.text("application/vnd.google-earth.kml+xml") && manifest.unsignedInteger(1)); + assert(manifest.array(8) && manifest.unsignedInteger(0) && manifest.array(0) && manifest.unsignedInteger(0) && manifest.unsignedInteger(0) && + manifest.unsignedInteger(0) && manifest.unsignedInteger(0) && manifest.unsignedInteger(0) && manifest.nil()); + assert(manifest.array(4) && manifest.binary({index_hash, 32}) && manifest.unsignedInteger(index.size()) && manifest.unsignedInteger(3) && manifest.unsignedInteger(1)); + const size_t manifest_size = manifest.size(); + sealed(kml_id, UploadKind::Kml, reinterpret_cast(xml.data()), xml.size()); + sealed(manifest_id, UploadKind::Manifest, raw_manifest, manifest_size); + const auto uploads = files; + const auto request = [&](bool package, Operation command) + { + geocaching::protocol::CmpWriter pw(payload, sizeof(payload)); + assert(pw.array(package ? 6 : 5) && pw.binary({operation, 16}) && pw.binary({kml_id, 16}) && pw.binary({manifest_id, 16}) && pw.unsignedInteger(1) && pw.nil()); + if (package) assert(pw.binary({mapping_id, 16})); + EnvelopeView value; + value.code = uint16_t(command); + value.session = session; + value.body = payload; + value.body_size = pw.size(); + return value; + }; + size_t written = 0; + assert(prepare::processPrepareRequest(adapter, request(true, Operation::TrackSignPrepare), 100, scratch, sizeof(scratch), written) == Status::InvalidField && !prepare::work); + for (unsigned fault = 0; fault < 3; ++fault) + { + files = uploads; + adapter.signing_calls = 0; + char path[64]; + if (fault == 0) + { + assert(resolveUploadPath(ids, 16, UploadFile::Candidate, path, sizeof(path), UploadKind::AttachmentResource)); + files[path][100] ^= 1; + } + const auto before = files; + auto envelope = request(fault != 1, fault == 1 ? Operation::TrackSignPrepare : Operation::TrackPackageSignPrepare); + Status result = Status::Ok; + bool ready = false, restarted = false; + for (unsigned step = 0; step < 3000 && (result == Status::Ok || result == Status::Busy) && !ready; ++step) + { + envelope = request(fault != 1, fault == 1 ? Operation::TrackSignPrepare : Operation::TrackPackageSignPrepare); + result = prepare::processPrepareRequest(adapter, envelope, 100 + step, scratch, sizeof(scratch), written); + if (result != Status::Ok) continue; + geocaching::protocol::CmpReader response({scratch, written}); + size_t fields = 0; + geocaching::ByteView returned_operation, object, revision_hash, signature; + uint64_t state = 0, revision = 0; + assert(response.array(fields, 6) && fields == 6 && response.binary(returned_operation, 16) && response.unsignedInteger(state)); + if (!state) + { + assert(response.nil() && response.nil() && response.unsignedInteger(revision) && !revision && response.nil() && response.finished()); + // Drop the complete job during a partial resource copy, then + // restart from persisted signature and verified file prefix. + if (fault == 2 && !restarted) + { + for (const auto& file : files) + if (file.first.size() > 4 && file.first.substr(file.first.size() - 4) == ".atm" && !file.second.empty() && file.second.size() < resource.size()) + { + prepare::closePrepareSession(session); + restarted = true; + assert(!allocations && adapter.signing_calls == 1); + break; + } + } + continue; + } + assert(response.binary(object, 32) && response.binary(revision_hash, 32) && response.unsignedInteger(revision) && revision == 1 && response.binary(signature, 64) && response.finished()); + assert(fault == 2 && restarted && adapter.signing_calls == 1); + geocaching::protocol::CmpWriter sw(signed_raw, sizeof(signed_raw)); + assert(sw.array(2) && sw.binary({raw_manifest, manifest_size}) && sw.binary(signature)); + platform::esp::storage::TrackManifestCrypto crypto; + VerifiedManifestView verified; + assert(verifyManifest({signed_raw, sw.size()}, crypto, hash, verify_workspace, sizeof(verify_workspace), verified, {adapter.public_key, 64}) == ManifestVerification::Valid); + assert(!std::memcmp(object.data, verified.object_id, 32) && !std::memcmp(revision_hash.data, verified.revision_hash, 32)); + char retained[160]; + assert(resolveObjectPath(object, ObjectFile::AttachmentIndex, {index_hash, 32}, retained, sizeof(retained)) && files.at(retained) == index); + assert(resolveObjectPath(object, ObjectFile::AttachmentResource, {resource_hash, 32}, retained, sizeof(retained)) && files.at(retained) == resource); + assert(resolveObjectPath(object, ObjectFile::KmlFile, {kml_hash, 32}, retained, sizeof(retained)) && files.at(retained) == std::vector(xml.begin(), xml.end())); + const auto completed = files; + assert(prepare::processPrepareRequest(adapter, request(true, Operation::TrackPackageSignPrepare), 4000, scratch, sizeof(scratch), written) == Status::Ok && files == completed && adapter.signing_calls == 1); + ready = true; + } + if (fault < 2) assert(result == (fault == 0 ? Status::HashMismatch : Status::InvalidField) && files == before && !adapter.signing_calls && !ready); + else + { + if (!ready) + { + std::fprintf(stderr, "package result=%u signs=%u restart=%u allocations=%u work=%u holds=%u\n", unsigned(result), adapter.signing_calls, unsigned(restarted), allocations, unsigned(prepare::work->status()), unsigned(prepare::work->holdsStages())); + for (const auto& file : files) std::fprintf(stderr, "%s %u\n", file.first.c_str(), unsigned(file.second.size())); + } + assert(ready); + } + prepare::closePrepareSession(session); + assert(!allocations && !prepare::work); + } + files = saved; +} +// Test transport only: actual production upload/prepare code and cryptography, +// same simulated SD/heap as the native regression. No USB/RF/private-key output. +static void testSharedPackageInput() +{ + using namespace track_sharing; + namespace package = hostlink::management::package_install_runtime; + const auto saved = files; + files.clear(); + assert(!allocations); + PersistenceAuthor local, remote; + uint8_t seed[32] = {9}; + remote.public_key[0] = 2; + ed25519_create_keypair(remote.public_key + 32, remote.secret, seed); + uint8_t actor[32], xml_hash[32], resource_hash[32], index_hash[32], raw[4096], signed_raw[4166], workspace[4200], signature[64], scratch[471]; + uint8_t nonce[16] = {1}, kml_id[16] = {2}, signed_id[16] = {3}, index_id[16] = {4}, map_id[16] = {5}, resource_id[16] = {6}; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + const auto digest = [&](const uint8_t* bytes, size_t size, uint8_t* out) + {assert(hash.start()&&hash.update(bytes,size)&&hash.finish(out));hash.reset(); }; + digest(local.public_key, 64, actor); + const std::string xml = "1,2"; + const std::vector resource(641, 7); + digest(reinterpret_cast(xml.data()), xml.size(), xml_hash); + digest(resource.data(), resource.size(), resource_hash); + std::vector index(1000); + geocaching::protocol::CmpWriter iw(index.data(), index.size()); + assert(iw.array(4) && iw.unsignedInteger(1) && iw.binary({xml_hash, 32}) && iw.unsignedInteger(xml.size()) && iw.array(1) && iw.array(7) && iw.text("photo") && iw.binary({resource_hash, 32}) && iw.unsignedInteger(resource.size()) && iw.text("image/png") && iw.text("photos/p.png") && iw.unsignedInteger(0) && iw.unsignedInteger(1)); + index.resize(iw.size()); + digest(index.data(), index.size(), index_hash); + const auto sealed = [&](const uint8_t* id, UploadKind kind, const uint8_t* bytes, size_t size, const uint8_t* owner = static_cast(nullptr)) + { + UploadJournal journal; + std::memcpy(journal.id, id, 16); + std::memcpy(journal.author_hash, owner ? owner : actor, 32); + digest(bytes, size, journal.file_hash); + journal.generation = 1; + journal.sequence = 3; + journal.phase = UploadPhase::Sealed; + journal.total = journal.durable_offset = size; + char path[64]; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), kind)); + files[path] = {bytes, bytes + size}; + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path), kind)); + files[path].resize(108); + assert(encodeUploadJournal(journal, files[path].data(), 108)); + }; + for (unsigned version : {1U, 2U}) + { + files.clear(); + geocaching::protocol::CmpWriter mw(raw, sizeof(raw)); + assert(mw.array(version == 1 ? 14 : 16) && mw.unsignedInteger(version) && mw.binary({remote.public_key, 64}) && mw.binary({nonce, 16}) && mw.unsignedInteger(0) && mw.unsignedInteger(1) && mw.nil() && mw.unsignedInteger(0) && mw.unsignedInteger(100) && mw.unsignedInteger(100) && mw.text("Shared") && mw.text("")); + assert(mw.array(4) && mw.unsignedInteger(10000000) && mw.unsignedInteger(20000000) && mw.unsignedInteger(10000000) && mw.unsignedInteger(20000000) && mw.array(4) && mw.unsignedInteger(0) && mw.nil() && mw.nil() && mw.unsignedInteger(1)); + assert(mw.array(4) && mw.binary({xml_hash, 32}) && mw.unsignedInteger(xml.size()) && mw.text("application/vnd.google-earth.kml+xml") && mw.unsignedInteger(1)); + if (version == 2) + { + assert(mw.array(8) && mw.unsignedInteger(0) && mw.array(0) && mw.unsignedInteger(0) && mw.unsignedInteger(0) && mw.unsignedInteger(0) && mw.unsignedInteger(0) && mw.unsignedInteger(0) && mw.nil()); + assert(mw.array(4) && mw.binary({index_hash, 32}) && mw.unsignedInteger(index.size()) && mw.unsignedInteger(1) && mw.unsignedInteger(1)); + } + ManifestView view; + assert(remote.signTrackManifest(raw, mw.size(), 1, view, workspace, sizeof(workspace), signature, sizeof(signature)) == chat::DeviceAuthorResult::Ok); + geocaching::protocol::CmpWriter sw(signed_raw, sizeof(signed_raw)); + assert(sw.array(2) && sw.binary({raw, mw.size()}) && sw.binary({signature, 64})); + platform::esp::storage::TrackManifestCrypto crypto; + VerifiedManifestView verified; + assert(verifyManifest({signed_raw, sw.size()}, crypto, hash, workspace, sizeof(workspace), verified) == ManifestVerification::Valid); + sealed(kml_id, UploadKind::Kml, reinterpret_cast(xml.data()), xml.size()); + sealed(signed_id, UploadKind::SignedManifest, signed_raw, sw.size()); + geocaching::protocol::CmpWriter mapping(scratch, sizeof(scratch)); + assert(mapping.array(3) && mapping.unsignedInteger(1) && mapping.binary({index_id, 16}) && mapping.binary({resource_id, 16})); + std::vector mapping_bytes(scratch, scratch + mapping.size()); + if (version == 2) + { + sealed(index_id, UploadKind::AttachmentIndex, index.data(), index.size()); + sealed(resource_id, UploadKind::AttachmentResource, resource.data(), resource.size()); + sealed(map_id, UploadKind::AttachmentBindings, mapping_bytes.data(), mapping_bytes.size()); + } + const auto intact = files; + for (unsigned fault = 0; fault < 8; ++fault) + { + files = intact; + uint8_t expected_object[32], expected_revision[32]; + std::memcpy(expected_object, verified.object_id, 32); + std::memcpy(expected_revision, verified.revision_hash, 32); + if (fault == 1) expected_revision[0] ^= 1; + if (fault == 2) + { + auto altered = std::vector(signed_raw, signed_raw + sw.size()); + altered.back() ^= 1; + sealed(signed_id, UploadKind::SignedManifest, altered.data(), altered.size()); + } + if (fault == 3) + { + uint8_t foreign_actor[32]; + digest(remote.public_key, 64, foreign_actor); + sealed(kml_id, UploadKind::Kml, reinterpret_cast(xml.data()), xml.size(), foreign_actor); + } + const auto before = files; + auto* input = package::createPackageInputOwner(media, 1, actor, {kml_id, 16}, {signed_id, 16}, version == 2 ? geocaching::ByteView{map_id, 16} : geocaching::ByteView{}, {expected_object, 32}, {expected_revision, 32}); + assert(input && allocations == 1); + auto result = input->begin(scratch, sizeof(scratch)); + // Expected identifiers are frozen, not borrowed from request arena. + std::memset(expected_object, 0, 32); + std::memset(expected_revision, 0, 32); + if (fault == 4) ++media; + if (fault == 5) input->cancel(); + unsigned steps = 0; + while (result == ManifestFileResult::Pending && steps++ < 1000) result = input->step(fault == 6 ? 2 : 1, actor, scratch, sizeof(scratch)); + if (fault == 4) --media; + if (fault == 7) + { + uint8_t changed[32] = {99}; + result = input->step(1, changed, scratch, sizeof(scratch)); + } + if (!fault) + { + assert(result == ManifestFileResult::Ready && input->ready() && input->verified()); + assert(!std::memcmp(input->verified()->manifest.author.data, remote.public_key, 64)); + assert(std::memcmp(input->verified()->manifest.author.data, local.public_key, 64)); + assert(input->signedBytes().size == sw.size() && !std::memcmp(input->signedBytes().data, signed_raw, sw.size())); + } + else assert(!input->ready() && !input->verified() && !input->signedBytes().size); + assert(files == before && !local.signing_calls); + if (!fault) + { + auto* retained = package::createPublicPackageRetention(*input, media); + assert(retained); + assert(!package::createPackageRoutePublication(*input, *retained, media)); + Status copied = Status::Busy; + // Fail a real write, retain its prefix, release only copy work, + // then resume the original signed bytes without any signer. + write_limit = 32; + copied = retained->step(1, actor, scratch, sizeof(scratch)); + write_limit = SIZE_MAX; + assert(copied == Status::StorageUnavailable && !retained->ready()); + package::destroyPublicPackageRetention(retained); + retained = package::createPublicPackageRetention(*input, media); + assert(retained); + for (unsigned i = 0; i < 1000 && copied != Status::Ok; ++i) copied = retained->step(1, actor, scratch, sizeof(scratch)); + assert(copied == Status::Ok && retained->ready() && !local.signing_calls); + char cached[160]; + const geocaching::ByteView revision_key{verified.revision_hash, 32}; + assert(resolvePackageCachePath(revision_key, ObjectFile::SignedManifest, {}, cached, sizeof(cached)) && files.at(cached) == std::vector(signed_raw, signed_raw + sw.size())); + assert(resolvePackageCachePath(revision_key, ObjectFile::KmlFile, {}, cached, sizeof(cached)) && files.at(cached) == std::vector(xml.begin(), xml.end())); + if (version == 2) + { + assert(resolvePackageCachePath(revision_key, ObjectFile::AttachmentIndex, {}, cached, sizeof(cached)) && files.at(cached) == index); + assert(resolvePackageCachePath(revision_key, ObjectFile::AttachmentResource, {resource_hash, 32}, cached, sizeof(cached)) && files.at(cached) == resource); + } + for (const auto& file : files) assert(file.first.rfind("/trailmate/tracks/", 0) != 0 && file.first.rfind("/routes/", 0) != 0); + const auto complete = files; + package::destroyPublicPackageRetention(retained); + retained = package::createPublicPackageRetention(*input, media); + assert(retained); + copied = Status::Busy; + for (unsigned i = 0; i < 1000 && copied == Status::Busy; ++i) copied = retained->step(1, actor, scratch, sizeof(scratch)); + assert(copied == Status::Ok && files == complete); + auto* publication = package::createPackageRoutePublication(*input, *retained, media); + assert(publication); + const std::string target = publication->target(); + assert(target.rfind("/routes/p-", 0) == 0 && !files.count(target)); + const auto allow = [](const char*) + { return Status::Ok; }; + const auto blocked = [](const char*) + { return Status::Busy; }; + const auto before_blocked = files; + assert(publication->step(1, actor, scratch, sizeof(scratch), blocked) == Status::Busy && files == before_blocked && !publication->ready()); + write_limit = 32; + assert(publication->step(1, actor, scratch, sizeof(scratch), allow) == Status::StorageUnavailable && !publication->ready() && !files.count(target)); + write_limit = SIZE_MAX; + package::destroyPackageRoutePublication(publication); + publication = package::createPackageRoutePublication(*input, *retained, media); + assert(publication); + copied = publication->step(1, actor, scratch, sizeof(scratch), allow); + assert(copied == Status::Busy && !files.count(target)); // Marker alone is not a visible route. + char marker[80]; + assert(routePackageTarget(revision_key, PackageRouteFile::Binding, marker, sizeof(marker)) && files.count(marker)); + InstalledPackageBinding installed; + assert(decodeInstalledPackageBinding(files.at(marker).data(), files.at(marker).size(), installed) && !std::memcmp(installed.object, verified.object_id, 32) && !std::memcmp(installed.revision, verified.revision_hash, 32)); + assert(installed.count == (version == 2 ? 1 : 0) && installed.availability == (version == 2 ? 1U : 0U)); + const auto valid_marker = files.at(marker); + for (size_t byte = 0; byte < valid_marker.size(); ++byte) + { + auto bad = valid_marker; + bad[byte] ^= 1; + InstalledPackageBinding rejected; + assert(!decodeInstalledPackageBinding(bad.data(), bad.size(), rejected)); + } + for (unsigned i = 0; i < 1000 && copied == Status::Busy; ++i) copied = publication->step(1, actor, scratch, sizeof(scratch), allow); + assert(copied == Status::Ok && publication->ready() && files.at(target) == std::vector(xml.begin(), xml.end()) && !local.signing_calls); + const auto installed_files = files; + package::destroyPackageRoutePublication(publication); + publication = package::createPackageRoutePublication(*input, *retained, media); + assert(publication); + copied = Status::Busy; + for (unsigned i = 0; i < 1000 && copied == Status::Busy; ++i) copied = publication->step(1, actor, scratch, sizeof(scratch), allow); + assert(copied == Status::Ok && files == installed_files); + package::destroyPackageRoutePublication(publication); + files[marker].back() ^= 1; + const auto corrupted_marker = files; + publication = package::createPackageRoutePublication(*input, *retained, media); + assert(publication); + assert(publication->step(1, actor, scratch, sizeof(scratch), allow) == Status::Conflict && !publication->ready() && files == corrupted_marker); + package::destroyPackageRoutePublication(publication); + files = installed_files; + { + namespace images = platform::esp::route_images; + const auto baseline = allocations; + auto* image_work = images::createRoutePackageImageWork(); + assert(image_work && !hostlink::route_storage_lease::acquire()); + uint8_t reference[32]; + constexpr char image_ref[] = "photos/p.png"; + digest(reinterpret_cast(image_ref), sizeof(image_ref) - 1, reference); + auto checked = image_work->begin(target); + assert(!image_work->materialize(reference, "/routes/.trailmate/wrong/images/img-0001.jpg")); + for (unsigned i = 0; i < 1000 && checked == ManifestFileResult::Pending; ++i) checked = image_work->step(); + assert(checked == ManifestFileResult::Ready && image_work->ready() && allocations == baseline + 1); + char destination[128]; + std::snprintf(destination, sizeof(destination), "/routes/.trailmate/%.*s/images/img-0001.jpg", 66, target.data() + 8); + assert(!image_work->materialize(reference, "/routes/.trailmate/wrong/images/img-0001.jpg")); + if (version == 2) + { + const auto cached_before_images = files; + assert(image_work->materialize(reference, destination)); + assert(files.at(destination) == resource); + const auto copied_images = files; + allocation_fail = true; + assert(!image_work->materialize(reference, destination) && files == copied_images); + allocation_fail = false; + backing_allocation_fail = true; + assert(!image_work->materialize(reference, destination) && files == copied_images); + backing_allocation_fail = false; + images::RouteImageSourceWork bound(platform::esp::arduino_common::storage::SdRuntimeMemoryPolicy::PsramOnly); + assert(bound.matchesExisting(destination, reference)); + // Fresh copy rechecks resource bytes after package proof. + assert(resolvePackageCachePath(revision_key, ObjectFile::AttachmentResource, {resource_hash, 32}, cached, sizeof(cached))); + files[cached][0] ^= 1; + assert(!image_work->materialize(reference, destination)); + ++media; + assert(!image_work->ready() && !image_work->materialize(reference, destination)); + --media; + files = cached_before_images; + } + else assert(!image_work->materialize(reference, destination)); + images::destroyRoutePackageImageWork(image_work); + assert(allocations == baseline && hostlink::route_storage_lease::acquire()); + hostlink::route_storage_lease::release(); + // Missing actual route is never an image proof. + const auto route_bytes = files.at(target); + files.erase(target); + image_work = images::createRoutePackageImageWork(); + assert(image_work && image_work->begin(target) != ManifestFileResult::Pending && !image_work->ready()); + images::destroyRoutePackageImageWork(image_work); + files[target] = route_bytes; + if (version == 2) + { + assert(resolvePackageCachePath(revision_key, ObjectFile::AttachmentResource, {resource_hash, 32}, cached, sizeof(cached))); + files[cached][0] ^= 1; + image_work = images::createRoutePackageImageWork(); + assert(image_work); + checked = image_work->begin(target); + for (unsigned i = 0; i < 1000 && checked == ManifestFileResult::Pending; ++i) checked = image_work->step(); + assert(checked == ManifestFileResult::HashMismatch && !image_work->ready() && !image_work->materialize(reference, destination)); + images::destroyRoutePackageImageWork(image_work); + files[cached][0] ^= 1; + } + allocation_fail = true; + assert(!images::createRoutePackageImageWork()); + allocation_fail = false; + backing_allocation_fail = true; + assert(!images::createRoutePackageImageWork()); + backing_allocation_fail = false; + assert(allocations == baseline && hostlink::route_storage_lease::acquire()); + hostlink::route_storage_lease::release(); + } + // Cache/history never becomes an owned publication head. + for (const auto& file : files) assert(file.first.rfind("/trailmate/tracks/", 0) != 0); + package::destroyPublicPackageRetention(retained); + } + package::destroyPackageInputOwner(input); + assert(!allocations); + } + } + allocation_fail = true; + assert(!package::createPackageInputOwner(media, 1, actor, {kml_id, 16}, {signed_id, 16}, {}, {resource_hash, 32}, {index_hash, 32})); + allocation_fail = false; + // Global cache and route-directory quotas count unknown/orphan entries. + files.clear(); + for (unsigned i = 0; i < 64; ++i) files["/trailmate/packages/orphan" + std::to_string(i) + "/bytes"] = {}; + assert(prepare_runtime::ensurePackageCacheDirectory(media, {index_hash, 32}, scratch, sizeof(scratch)) == Status::LimitExceeded); + assert(prepare_runtime::ensurePackageCacheDirectory(media, {index_hash, 32}, scratch, 160) == Status::InvalidField); + files.clear(); + for (unsigned i = 0; i < 256; ++i) files["/routes/orphan" + std::to_string(i)] = {}; + assert(package::admitPackageRouteStorage(media, {index_hash, 32}, 1, scratch, sizeof(scratch)) == Status::LimitExceeded); + files = saved; +} +static int packageProtocolBridge() +{ + PersistenceAuthor adapter; + uint8_t session[16] = {1}, author_hash[32], delivery[32], prefix[32], scratch[471], input[471]; + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + assert(hash.start() && hash.update(adapter.public_key, 64) && hash.finish(author_hash)); + hash.reset(); + constexpr char aspect[] = "lxmf.delivery"; + assert(hash.start() && hash.update(reinterpret_cast(aspect), sizeof(aspect) - 1) && hash.finish(prefix)); + hash.reset(); + assert(hash.start() && hash.update(prefix, 10) && hash.update(author_hash, 16) && hash.finish(delivery)); + hash.reset(); + const auto emit = [&](Status status, const uint8_t* bytes, size_t size) + { + std::printf("%u ", unsigned(status)); + for (size_t i = 0; i < size; ++i) std::printf("%02x", bytes[i]); + std::printf("\n"); + std::fflush(stdout); + }; + emit(Status::Ok, adapter.public_key, 64); + uint64_t now = 100; + std::string line; + while (std::getline(std::cin, line)) + { + size_t written = 0; + Status status = Status::InvalidField; + if (line == "state") + { + geocaching::protocol::CmpWriter writer(scratch, sizeof(scratch)); + assert(writer.array(2) && writer.unsignedInteger(adapter.signing_calls) && writer.unsignedInteger(allocations)); + emit(Status::Ok, scratch, writer.size()); + continue; + } + if (line == "close") + { + prepare_runtime::closePrepareSession(session); + upload_runtime::closeUploadSession(session); + scratch[0] = 0x90; + emit(Status::Ok, scratch, 1); + continue; + } + if (line == "clear-uploads" || line == "corrupt-cache" || line == "remove-installed") + { + upload_runtime::closeUploadSession(session); + for (auto it = files.begin(); it != files.end();) + { + if (line == "clear-uploads" && it->first.rfind("/trailmate/web-", 0) == 0) + { + it = files.erase(it); + continue; + } + if (line == "remove-installed" && it->first.rfind("/routes/p-", 0) == 0 && it->first.substr(it->first.size() - 4) == ".kml") + { + it = files.erase(it); + continue; + } + if (line == "corrupt-cache" && it->first.rfind("/trailmate/packages/", 0) == 0 && it->first.substr(it->first.size() - 4) == ".ast" && !it->second.empty()) it->second.back() ^= 1; + ++it; + } + scratch[0] = 0x90; + emit(Status::Ok, scratch, 1); + continue; + } + std::istringstream fields(line); + unsigned operation = 0; + std::string hex, tail; + const auto digit = [](char c) + { return c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : -1; }; + bool valid = bool(fields >> operation >> hex) && !(fields >> tail) && operation <= UINT16_MAX && !hex.empty() && !(hex.size() % 2) && hex.size() <= sizeof(input) * 2; + if (valid) + for (size_t i = 0; i < hex.size() / 2; ++i) + { + const int a = digit(hex[2 * i]), b = digit(hex[2 * i + 1]); + if (a < 0 || b < 0) + { + valid = false; + break; + } + input[i] = uint8_t(a * 16 + b); + } + if (valid) + { + EnvelopeView request; + request.code = operation; + request.session = session; + request.body = input; + request.body_size = hex.size() / 2; + upload_runtime::advanceUploads(scratch, sizeof(scratch), now, true); + if (operation == 1) + { + geocaching::protocol::CmpWriter writer(scratch, sizeof(scratch)); + assert(writer.array(4) && writer.binary({adapter.public_key, 64}) && writer.binary({author_hash, 16}) && writer.binary({delivery, 16}) && writer.unsignedInteger(1)); + status = Status::Ok; + written = writer.size(); + } + else if (operation >= 32 && operation <= 36) + status = upload_runtime::processUploadRequest(request, 1, author_hash, now, scratch, sizeof(scratch), written); + else if (operation == 82 || operation == 96 || operation == 99 || operation == 100 || operation == 101) + status = prepare_runtime::processPrepareRequest(adapter, request, now, scratch, sizeof(scratch), written, [](const char*, uint8_t*, size_t) + { return Status::Ok; }); + else status = Status::Unsupported; + ++now; + } + if (status != Status::Ok) + { + scratch[0] = 0x90; + written = 1; + } + emit(status, scratch, written); + } + prepare_runtime::releasePrepare(); + upload_runtime::closeUploadSession(session); + assert(!allocations); + return 0; +} +int main(int argc, char** argv) +{ + if (argc == 2 && !std::strcmp(argv[1], "package-protocol")) return packageProtocolBridge(); + testAttachmentPreparation(); + testPackagePrepareWorkflow(); + testSharedPackageInput(); + { + namespace upload = hostlink::management::upload_runtime; + const auto saved = files; + const auto before = allocations; + uint8_t id[16] = {70}, session[16] = {71}, other[16] = {72}, digest[32] = {73}, author[32] = {74}; + uint8_t raw[128], output[448], content[2] = {9, 8}; + assert(upload::beginNewUpload(upload::owner, id, session, media, 7, author, 3, digest, 100, output, sizeof(output)) == Status::Ok); + assert(upload::owner->resources.append(id, 0, content, sizeof(content), output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + geocaching::protocol::CmpWriter clean(raw, sizeof(raw)); + assert(clean.array(4) && clean.binary({id, 16}) && clean.unsignedInteger(3) && clean.unsignedInteger(3) && clean.binary({digest, 32})); + EnvelopeView request{session, session, uint16_t(Operation::TransferCleanup), raw, clean.size()}; + size_t written = 0; + const auto populated = files; + assert(upload::cleanupBoundUpload(request, 8, author, 101, output, sizeof(output), written) == Status::StaleIdentity && files == populated); + request.session = other; + assert(upload::cleanupBoundUpload(request, 7, author, 102, output, sizeof(output), written) == Status::Unauthorized && files == populated); + request.session = session; + char path[64]; + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::JournalB, path, sizeof(path))); + remove_failure_path = path; + assert(upload::cleanupBoundUpload(request, 7, author, 103, output, sizeof(output), written) == Status::StorageUnavailable); + assert(upload::owner && upload::owner->discarding && files.count(path)); + upload::closeUploadSession(session); + assert(!upload::owner && allocations == before); + remove_failure_path.clear(); + // Catalogue recovers metadata even though the candidate and A journal + // are already removed. It never reads candidate bytes or alters files. + const auto partial = files; + uint8_t catalog[8] = {0x92, 3, 0xc0}; + EnvelopeView list{session, session, uint16_t(Operation::TransferListOwned), catalog, 3}; + assert(upload::listOwnedUploads(list, 7, author, 104, output, sizeof(output), written) == Status::Ok && !upload::owner && allocations == before && files == partial); + geocaching::protocol::CmpReader reader({output, written}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView found; + assert(reader.array(fields, 3) && fields == 3 && reader.unsignedInteger(value) && value == media && reader.unsignedInteger(value)); + assert(reader.array(fields, 7) && fields == 7 && reader.binary(found, 16) && !std::memcmp(found.data, id, 16)); + assert(reader.unsignedInteger(value) && value == 3 && reader.unsignedInteger(value) && value == 3 && reader.binary(found, 32) && !std::memcmp(found.data, digest, 32)); + assert(reader.unsignedInteger(value) && value == 7 && reader.unsignedInteger(value) && value == 0 && reader.unsignedInteger(value) && value == 2 && reader.finished()); + uint8_t foreign[32] = {75}; + assert(upload::listOwnedUploads(list, 7, foreign, 104, output, sizeof(output), written) == Status::Ok && files == partial && allocations == before); + geocaching::protocol::CmpReader hidden({output, written}); + assert(hidden.array(fields, 3) && fields == 3 && hidden.unsignedInteger(value) && hidden.unsignedInteger(value) && hidden.nil() && hidden.finished()); + backing_allocation_fail = true; + assert(upload::listOwnedUploads(list, 7, author, 104, output, sizeof(output), written) == Status::Busy && files == partial && allocations == before); + backing_allocation_fail = false; + const auto valid_journal = files[path]; + files[path][0] ^= 1; + const auto corrupted = files; + assert(upload::cleanupBoundUpload(request, 7, author, 104, output, sizeof(output), written) == Status::Conflict && !upload::owner && files == corrupted && allocations == before); + files[path] = valid_journal; + // A changed hash cannot delete the remaining bound journal. + raw[clean.size() - 1] ^= 1; + assert(upload::cleanupBoundUpload(request, 7, author, 105, output, sizeof(output), written) == Status::Conflict && !upload::owner && files == partial && allocations == before); + raw[clean.size() - 1] ^= 1; + assert(upload::cleanupBoundUpload(request, 7, author, 106, output, sizeof(output), written) == Status::Ok && !upload::owner && written == 1 && output[0] == 0x90 && files == saved && allocations == before); + assert(upload::cleanupBoundUpload(request, 7, author, 107, output, sizeof(output), written) == Status::Ok && files == saved && allocations == before); + // An unbound orphan is never removed merely because both journals miss. + assert(track_sharing::resolveUploadPath(id, 16, track_sharing::UploadFile::Candidate, path, sizeof(path))); + files[path] = {1, 2, 3}; + const auto orphan = files; + assert(upload::cleanupBoundUpload(request, 7, author, 108, output, sizeof(output), written) == Status::Conflict && files == orphan && !upload::owner && allocations == before); + files = saved; + catalog[0] = 0x92; + catalog[1] = 3; + catalog[2] = 0x92; + catalog[3] = uint8_t(media + 1); + catalog[4] = 0; + list.body_size = 5; + assert(upload::listOwnedUploads(list, 7, author, 109, output, sizeof(output), written) == Status::Conflict && allocations == before); + } + { + namespace confirmation = hostlink::management::ip_confirmation_runtime; + uint8_t id[16] = {1}, hash[32] = {2}, key[64] = {3}; + const track_sharing::IpConfirmationRequest request{{id, 16}, {id, 16}, {hash, 32}, {key, 64}, {id, 16}, {id, 16}, {id, 16}, {hash, 32}, {hash, 32}, 1, 1}; + const auto before = allocations; + allocation_fail = true; + assert(!confirmation::createOwner(request, media, key, hash) && allocations == before); + allocation_fail = false; + backing_allocation_fail = true; + assert(!confirmation::createOwner(request, media, key, hash) && allocations == before); + backing_allocation_fail = false; + auto* owner = confirmation::createOwner(request, media, key, hash); + assert(owner && owner->backingReady() && allocations == before + 1 && !owner->result()); + confirmation::destroyOwner(owner); + assert(allocations == before); + // Controller's storage gate and lifetime retain only a PSRAM pointer. + confirmation::controller = new (heap_caps_malloc(sizeof(confirmation::Controller), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) confirmation::Controller; + confirmation::controller->media = media; + confirmation::controller->last = 100; + std::memcpy(confirmation::controller->session, id, 16); + confirmation::controller->work = confirmation::createOwner(request, media, key, hash); + assert(confirmation::holdsStorage() && confirmation::blocksOperation(uint16_t(Operation::TransferBegin)) && + confirmation::blocksOperation(uint16_t(Operation::IdentityBackupRestore)) && + !confirmation::blocksOperation(uint16_t(Operation::TrackConfirmPublication)) && + !confirmation::blocksOperation(uint16_t(Operation::SessionClose))); + uint8_t other[16] = {4}; + confirmation::closeSession(other); + assert(confirmation::controller); + confirmation::tick(30099, true); + assert(confirmation::controller); + confirmation::tick(30100, true); + assert(!confirmation::controller && allocations == before); + PersistenceAuthor adapter; + adapter.read_failure = chat::DeviceAuthorResult::Unavailable; + uint8_t raw[288], scratch[448]; + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(11) && writer.binary(request.operation) && writer.binary(request.transfer) && writer.binary(request.proof_hash) && + writer.unsignedInteger(1) && writer.binary(request.directory_public) && writer.binary(request.directory) && writer.binary(request.epoch) && + writer.binary(request.network_request) && writer.binary(request.object) && writer.binary(request.revision_hash) && writer.unsignedInteger(1)); + const EnvelopeView envelope{id, id, uint16_t(Operation::TrackConfirmPublication), raw, writer.size()}; + size_t written = 0; + assert(confirmation::processConfirmation(adapter, envelope, 100, scratch, sizeof(scratch), written) == Status::Busy); + assert(confirmation::controller && !confirmation::holdsStorage() && !written); + auto wrong_session = envelope; + wrong_session.session = other; + assert(confirmation::processConfirmation(adapter, wrong_session, 101, scratch, sizeof(scratch), written) == Status::Conflict); + // An in-progress request cannot be replaced, even by its own session. + raw[writer.size() - 1] = 2; + assert(confirmation::processConfirmation(adapter, envelope, 102, scratch, sizeof(scratch), written) == Status::Conflict); + // Model a completed response cache with its PSRAM work still retained. + confirmation::controller->work = confirmation::createOwner(request, media, key, hash); + confirmation::controller->status = Status::Ok; + assert(confirmation::processConfirmation(adapter, wrong_session, 103, scratch, sizeof(scratch), written) == Status::Conflict); + assert(confirmation::processConfirmation(adapter, envelope, 104, scratch, sizeof(scratch), written) == Status::Busy); + assert(confirmation::controller && !confirmation::controller->work && confirmation::controller->decoded.revision == 2 && allocations == before + 1); + confirmation::closeSession(id); + assert(!confirmation::controller && allocations == before); + } + testRouteInstallOwner(); + testReadOwner(); + testReadRegionalOwner(); + { + namespace storage = platform::esp::arduino_common::storage; + storage::SdRuntimeFile file; + storage::SdBorrowedRecordFileIo io(file, media); + files["/copy-source"] = std::vector(750, 17); + files["/copy-temp"] = {}; + uint8_t scratch[320]; + uint32_t next = 0; + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Pending && next == 320 && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 320, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Pending && next == 640 && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 640, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Copied && next == 750 && !file.is_open()); + assert(files["/copy-temp"] == files["/copy-source"]); + const auto saved = files; + next = 19; + std::strcpy(reinterpret_cast(scratch), "/copy-source"); + assert(track_sharing::copyFileSlice(io, reinterpret_cast(scratch), "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Invalid && next == 19 && files == saved && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, reinterpret_cast(&next), sizeof(next), next) == track_sharing::FileCopyResult::Invalid && next == 19 && files == saved && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, reinterpret_cast(&io), sizeof(io), next) == track_sharing::FileCopyResult::Invalid && next == 19 && files == saved && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Conflict && next == 0 && files == saved && !file.is_open()); + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 751, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Conflict && files == saved && !file.is_open()); + storage::SdBorrowedRecordFileIo wrong_media(file, media + 1); + assert(track_sharing::copyFileSlice(wrong_media, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::StorageFailure && next == 0 && files == saved && !file.is_open()); + files["/copy-temp"].clear(); + write_limit = 17; + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::StorageFailure && next == 0 && !file.is_open()); + assert(files["/copy-temp"].size() == 17 && files["/copy-source"] == saved.at("/copy-source")); + write_limit = SIZE_MAX; + const auto short_written = files; + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Conflict && next == 0 && files == short_written && !file.is_open()); + files["/copy-temp"].clear(); + flush_failure = true; + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::StorageFailure && next == 0 && !file.is_open()); + assert(files["/copy-temp"].size() == 320 && files["/copy-source"] == saved.at("/copy-source")); + flush_failure = false; + const auto unsynced = files; + assert(track_sharing::copyFileSlice(io, "/copy-source", "/copy-temp", 750, 0, scratch, sizeof(scratch), next) == track_sharing::FileCopyResult::Conflict && next == 0 && files == unsynced && !file.is_open()); + files.erase("/copy-source"); + files.erase("/copy-temp"); + files["/verified-temp"] = std::vector(750, 17); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t expected[32]; + assert(hash.start() && hash.update(files["/verified-temp"].data(), 750) && hash.finish(expected)); + track_sharing::VerifiedFilePublish publisher(io, hash); + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + auto verified = track_sharing::FileIntegrityResult::Pending; + for (unsigned step = 0; step < 16 && verified == track_sharing::FileIntegrityResult::Pending; ++step) verified = publisher.step(scratch, sizeof(scratch)); + assert(verified == track_sharing::FileIntegrityResult::Ready && !files.count("/verified-temp") && files["/verified-final"] == std::vector(750, 17) && !file.is_open()); + const auto published_files = files; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::Ready && files == published_files && !file.is_open()); + assert(publisher.begin(nullptr, "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Invalid); + assert(publisher.step(scratch, sizeof(scratch)) == track_sharing::FileIntegrityResult::Invalid && files == published_files); + files["/verified-final"][0] ^= 1; + files["/verified-temp"] = std::vector(750, 17); + const auto corrupt_final = files; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::HashMismatch && files == corrupt_final && !file.is_open()); + files.erase("/verified-final"); + files["/verified-temp"][0] ^= 1; + const auto corrupt_temp = files; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::HashMismatch && files == corrupt_temp && !file.is_open()); + files["/verified-temp"][0] ^= 1; + rename_failure = true; + const auto before_publish_failure = files; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::StorageFailure && files == before_publish_failure && !file.is_open()); + rename_failure = false; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::Ready && !files.count("/verified-temp") && !file.is_open()); + files.erase("/verified-final"); + files["/verified-temp"] = std::vector(750, 17); + corrupt_after_rename = true; + assert(publisher.begin("/verified-temp", "/verified-final", 750, expected) == track_sharing::FileIntegrityResult::Pending); + do + { + verified = publisher.step(scratch, sizeof(scratch)); + } while (verified == track_sharing::FileIntegrityResult::Pending); + assert(verified == track_sharing::FileIntegrityResult::HashMismatch && files.count("/verified-final") && !files.count("/verified-temp") && !file.is_open()); + corrupt_after_rename = false; + files.erase("/verified-temp"); + files.erase("/verified-final"); + using Copier = track_sharing::ImmutableFileCopy; + static_assert(sizeof(Copier) <= 224, "Immutable copier cannot own file buffers"); + Copier copier(io, hash); + files["/immutable-source"] = std::vector(750, 17); + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + auto preserved = track_sharing::ImmutableCopyResult::Pending; + for (unsigned step = 0; step < 24 && preserved == track_sharing::ImmutableCopyResult::Pending; ++step) preserved = copier.step(scratch, sizeof(scratch)); + assert(preserved == track_sharing::ImmutableCopyResult::Ready && files["/immutable-source"] == files["/immutable-final"] && !files.count("/immutable-temp") && !file.is_open()); + const auto immutable_files = files; + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + do + { + preserved = copier.step(scratch, sizeof(scratch)); + } while (preserved == track_sharing::ImmutableCopyResult::Pending); + assert(preserved == track_sharing::ImmutableCopyResult::Ready && files == immutable_files); + files.erase("/immutable-final"); + files["/immutable-temp"] = {1, 2, 3}; + const auto partial_files = files; + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + assert(copier.step(scratch, sizeof(scratch)) == track_sharing::ImmutableCopyResult::Conflict && files == partial_files && !file.is_open()); + files["/immutable-temp"] = std::vector(317, 17); + const auto before_prefix_sync = files; + flush_failure = true; + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + do + { + preserved = copier.step(scratch, sizeof(scratch)); + } while (preserved == track_sharing::ImmutableCopyResult::Pending); + assert(preserved == track_sharing::ImmutableCopyResult::StorageFailure && files == before_prefix_sync && !file.is_open()); + flush_failure = false; + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + do + { + preserved = copier.step(scratch, sizeof(scratch)); + } while (preserved == track_sharing::ImmutableCopyResult::Pending); + assert(preserved == track_sharing::ImmutableCopyResult::Ready && files["/immutable-final"] == files["/immutable-source"] && !files.count("/immutable-temp") && !file.is_open()); + files.erase("/immutable-final"); + files["/immutable-temp"] = std::vector(750, 17); + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + assert(copier.cancel() == track_sharing::ImmutableCopyResult::Cancelled && copier.step(scratch, sizeof(scratch)) == track_sharing::ImmutableCopyResult::Cancelled); + const auto cancelled_files = files; + assert(files.count("/immutable-temp") && !files.count("/immutable-final") && !file.is_open()); + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + assert(copier.step(nullptr, 0) == track_sharing::ImmutableCopyResult::Invalid && files == cancelled_files); + assert(copier.begin("/immutable-source", "/immutable-temp", "/immutable-final", 750, expected) == track_sharing::ImmutableCopyResult::Pending); + do + { + preserved = copier.step(scratch, sizeof(scratch)); + } while (preserved == track_sharing::ImmutableCopyResult::Pending); + assert(preserved == track_sharing::ImmutableCopyResult::Ready && !files.count("/immutable-temp") && files["/immutable-final"] == files["/immutable-source"] && !file.is_open()); + files.erase("/immutable-final"); + files.erase("/immutable-source"); + files.erase("/immutable-temp"); + } + { + uint8_t object[32] = {7}, scratch[320]; + uint8_t retention_hash[32] = {8}; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Ok); + assert(prepare_runtime::admitRetainedKml(media + 1, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::StorageUnavailable); + char path[160]; + assert(prepare_runtime::ensureObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Ok); + assert(prepare_runtime::ensureObjectDirectory(media + 1, {object, 32}, scratch, sizeof(scratch)) == Status::StorageUnavailable); + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Ok); + assert(track_sharing::resolveObjectPath({object, 32}, track_sharing::ObjectFile::Directory, {}, path, sizeof(path))); + const auto retention_root = std::string(path); + for (unsigned i = 0; i < 128; ++i) files[retention_root + "/entry" + std::to_string(i)] = {1}; + const auto full_count = files; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::LimitExceeded && files == full_count); + char retained_temp[160]; + assert(track_sharing::resolveObjectPath({object, 32}, track_sharing::ObjectFile::KmlTemporary, {retention_hash, 32}, retained_temp, sizeof(retained_temp))); + files.erase(retention_root + "/entry127"); + files[retained_temp] = std::vector(300, 17); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Ok); + files[retained_temp].resize(751); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Conflict); + files.erase(retained_temp); + for (unsigned i = 0; i < 127; ++i) files.erase(retention_root + "/entry" + std::to_string(i)); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {scratch, 32}, 750, scratch, sizeof(scratch)) == Status::InvalidField); + files[retention_root + "/unknown-large"] = std::vector(33554432 - 750, 0); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Ok); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 751, scratch, sizeof(scratch)) == Status::LimitExceeded); + files[retained_temp] = std::vector(300, 17); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Ok); + files.erase(retained_temp); + char retained_final[160]; + assert(track_sharing::resolveObjectPath({object, 32}, track_sharing::ObjectFile::KmlFile, {retention_hash, 32}, retained_final, sizeof(retained_final))); + files[retained_final] = std::vector(750, 17); + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Ok); + uint8_t different_hash[32] = {9}; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {different_hash, 32}, 1, scratch, sizeof(scratch)) == Status::LimitExceeded); + files.erase(retained_final); + files.erase(retention_root + "/unknown-large"); + using DirectoryStatus = platform::esp::arduino_common::storage::SdFileReadStatus; + directory_status[path] = DirectoryStatus::Missing; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::StorageUnavailable); + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Ok); + assert(prepare_runtime::ensureObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Ok && created_directories.size() == 1 && created_directories[0] == path); + created_directories.clear(); + directory_status[path] = DirectoryStatus::Busy; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::Busy); + assert(prepare_runtime::ensureObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Busy && created_directories.empty()); + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Busy); + directory_status[path] = DirectoryStatus::IoError; + assert(prepare_runtime::admitRetainedKml(media, {object, 32}, {retention_hash, 32}, 750, scratch, sizeof(scratch)) == Status::StorageUnavailable); + assert(prepare_runtime::ensureObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::StorageUnavailable && created_directories.empty()); + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::StorageUnavailable); + directory_status[path] = DirectoryStatus::Missing; + mkdir_fail = true; + assert(prepare_runtime::ensureObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::StorageUnavailable && created_directories.size() == 1); + mkdir_fail = false; + created_directories.clear(); + directory_status.clear(); + const auto orphan = std::string(path) + "/orphan.cmp"; + files[orphan] = {1, 2, 3}; + const auto saved = files; + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Conflict && files == saved); + assert(prepare_runtime::requireEmptyObjectDirectory(media + 1, {object, 32}, scratch, sizeof(scratch)) == Status::StorageUnavailable && files == saved); + files.erase(orphan); + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Ok); + namespace sd = platform::esp::arduino_common::storage; + sd::SdRuntimeFile file; + sd::SdBorrowedRecordFileIo io(file, media); + track_sharing::RevisionReservation reservation, verify; + std::memcpy(reservation.object, object, 32); + reservation.author[0] = 9; + reservation.candidate[0] = 8; + reservation.operation[0] = 3; + reservation.sequence = reservation.revision = 1; + reservation.generation = 7; + char a[160], b[160]; + assert(track_sharing::resolveObjectPath({object, 32}, track_sharing::ObjectFile::ReservationA, {reservation.operation, 16}, a, sizeof(a))); + assert(track_sharing::resolveObjectPath({object, 32}, track_sharing::ObjectFile::ReservationB, {reservation.operation, 16}, b, sizeof(b))); + track_sharing::ReservationStore store(io, a, b); + assert(store.initialize(reservation, verify, scratch, sizeof(scratch)) == track_sharing::ReservationStoreResult::Ready); + track_sharing::PrepareRequest request; + request.operation = {reservation.operation, 16}; + request.generation = 7; + const auto owned_files = files; + assert(prepare_runtime::requireEmptyObjectDirectory(media, {object, 32}, scratch, sizeof(scratch)) == Status::Conflict); + assert(prepare_runtime::requireOwnedFirstRevisionDirectory(media, request, reservation.author, reservation, store, verify, scratch, sizeof(scratch)) == Status::Ok && files == owned_files); + files[orphan] = {1}; + const auto blocked_files = files; + assert(prepare_runtime::requireOwnedFirstRevisionDirectory(media, request, reservation.author, reservation, store, verify, scratch, sizeof(scratch)) == Status::Conflict && files == blocked_files); + files.erase(orphan); + request.generation = 8; + assert(prepare_runtime::requireOwnedFirstRevisionDirectory(media, request, reservation.author, reservation, store, verify, scratch, sizeof(scratch)) == Status::Conflict && files == owned_files); + files.erase(a); + } + { + UploadAuthorAdapter adapter; + uint8_t key[64], hash[32]; + uint32_t generation = 0; + assert(upload_runtime::readUploadAuthor(adapter, key, sizeof(key), hash, generation) == Status::Ok && generation == 7); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 verify_hash(context); + uint8_t expected[32]; + assert(verify_hash.start() && verify_hash.update(key, 64) && verify_hash.finish(expected) && !std::memcmp(expected, hash, 32)); + adapter.result = chat::DeviceAuthorResult::StorageError; + assert(upload_runtime::readUploadAuthor(adapter, key, sizeof(key), hash, generation) == Status::StorageUnavailable && generation == 0); + for (auto byte : hash) assert(!byte); + adapter.result = chat::DeviceAuthorResult::Ok; + adapter.generation = 0; + assert(upload_runtime::readUploadAuthor(adapter, key, sizeof(key), hash, generation) == Status::StaleIdentity && generation == 0); + } + uint8_t session[16] = {1}, id[16] = {2}, payload[256], output[471]; + size_t written = 0; + auto request = [&](uint32_t expected, uint32_t count) + {geocaching::protocol::CmpWriter writer(payload,sizeof(payload));assert(writer.array(5)&&writer.binary({id,16})&&writer.unsignedInteger(expected)&&writer.text("ridge.gpx")&&writer.unsignedInteger(count)&&writer.unsignedInteger(1));EnvelopeView view;view.session=session;view.body=payload;view.body_size=writer.size();return view; }; + files["/trackers/ridge.gpx"].resize(750, 42); + allocation_fail = true; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Busy && allocations == 0); + allocation_fail = false; + backing_allocation_fail = true; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Busy); + assert(!runtime::owner && allocations == 0 && !held); + backing_allocation_fail = false; + recording = true; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Busy && !runtime::owner && !held); + recording = false; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Ok && held && allocations == 1); + assert(runtime::build(request(1, 749), output, sizeof(output), written) == Status::Conflict); + for (unsigned i = 0; i < 4; ++i) runtime::advance(output, sizeof(output), true); + assert(runtime::owner->task.state() == SnapshotSessionState::Ready && !held); + const std::string destination = runtime::owner->destination; + assert(files.count(destination) && files[destination].size() == 814); + assert(std::equal(files[destination].begin() + 64, files[destination].end(), files["/trackers/ridge.gpx"].begin())); + { + geocaching::protocol::CmpWriter writer(payload, sizeof(payload)); + assert(writer.array(4) && writer.binary({id, 16}) && writer.unsignedInteger(0) && writer.unsignedInteger(320) && writer.nil()); + EnvelopeView read; + read.body = payload; + read.body_size = writer.size(); + assert(readTrackSnapshotService(read, runtime::owner->io, output, sizeof(output), written) == Status::Ok && written > 320); + } + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Ok); + runtime::advance(output, sizeof(output), false); + assert(!runtime::owner && allocations == 0 && files.count(destination)); + // The actual full-file SD reader rejects the one-byte existence probe for + // an existing large snapshot; it must still refuse any overwrite. + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::StorageUnavailable && allocations == 0); + id[0] = 3; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Ok && held); + const std::string temporary = runtime::owner->temporary; + remove_fail = true; + runtime::advance(output, sizeof(output), false); + assert(runtime::owner && held && allocations == 1 && files.count(temporary)); + remove_fail = false; + runtime::advance(output, sizeof(output), false); + assert(!runtime::owner && !held && allocations == 0 && !files.count(temporary)); + id[0] = 4; + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Ok); + ++media; + runtime::advance(output, sizeof(output), false); + assert(!runtime::owner && !held && allocations == 0); + assert(runtime::build(request(1, 750), output, sizeof(output), written) == Status::Conflict); + id[0] = 5; + assert(runtime::build(request(media, 750), output, sizeof(output), written) == Status::Ok); + uint8_t unrelated[16] = {9}; + runtime::closeSession(unrelated); + runtime::advance(output, sizeof(output), true); + assert(runtime::owner && held); + runtime::closeSession(session); + runtime::advance(output, sizeof(output), true); + assert(!runtime::owner && !held && allocations == 0); + id[0] = 6; + assert(runtime::build(request(media, 750), output, sizeof(output), written) == Status::Ok); + const auto releaseRequest = [&]() + {geocaching::protocol::CmpWriter writer(payload,sizeof(payload));assert(writer.array(1)&&writer.binary({id,16}));EnvelopeView view;view.session=session;view.body=payload;view.body_size=writer.size();return view; }; + assert(runtime::releaseTrackSnapshot(releaseRequest(), runtime::owner->io, media, output, sizeof(output), written) == Status::Busy); + for (unsigned i = 0; i < 4; ++i) runtime::advance(output, sizeof(output), true); + const std::string released = runtime::owner->destination; + remove_fail = true; + assert(runtime::releaseTrackSnapshot(releaseRequest(), runtime::owner->io, media, output, sizeof(output), written) == Status::StorageUnavailable && files.count(released) && runtime::owner); + remove_fail = false; + assert(runtime::releaseTrackSnapshot(releaseRequest(), runtime::owner->io, media, output, sizeof(output), written) == Status::Ok && written == 1 && !runtime::owner && !files.count(released) && allocations == 0); + platform::esp::arduino_common::storage::SdRuntimeFile file; + platform::esp::arduino_common::storage::SdBorrowedRecordFileIo io(file, media); + assert(runtime::releaseTrackSnapshot(releaseRequest(), io, media, output, sizeof(output), written) == Status::Ok && files.count("/trackers/ridge.gpx")); + id[0] = 93; + assert(runtime::build(request(media, 750), output, sizeof(output), written) == Status::Ok && held); + assert(runtime::admitSpace(media, 750, output, sizeof(output)) == Status::Busy); + runtime::advance(output, sizeof(output), true); + const std::string stopped_candidate = runtime::owner->temporary; + assert(files.at(stopped_candidate).size() == 384); + remove_fail = true; + assert(runtime::retireForStop() && !runtime::owner && !held && allocations == 0); + assert(files.count(stopped_candidate) && files.count(destination)); + const auto source_before_recovery = files.at("/trackers/ridge.gpx"); + files["/trailmate/web-tracks/notes.new"] = {17}; + remove_fail = false; + id[0] = 94; + assert(runtime::build(request(media, 750), output, sizeof(output), written) == Status::Ok && held); + assert(!files.count(stopped_candidate) && files.count(destination)); + assert(files.at("/trailmate/web-tracks/notes.new") == std::vector{17}); + assert(files.at("/trackers/ridge.gpx") == source_before_recovery); + assert(runtime::retireForStop() && !runtime::owner && !held && allocations == 0); + assert(runtime::retireForStop()); + for (unsigned i = 0; i < 8; ++i) files["/trailmate/web-tracks/orphan" + std::to_string(i)] = {}; + id[0] = 7; + assert(runtime::build(request(media, 750), output, sizeof(output), written) == Status::LimitExceeded && !runtime::owner && allocations == 0); + { + namespace upload = hostlink::management::upload_runtime; + uint8_t author[32] = {3}; + assert(upload::admitUploadSpace(media, 5, output, sizeof(output)) == Status::Ok); + assert(upload::admitUploadSpace(media + 1, 5, output, sizeof(output)) == Status::StorageUnavailable); + allocation_fail = true; + assert(!upload::createOwner(id, session, media, 1, author, 100) && allocations == 0); + allocation_fail = false; + assert(!upload::createOwner(id, session, media + 1, 1, author, 100) && allocations == 0); + backing_allocation_fail = true; + assert(!upload::createOwner(id, session, media, 1, author, 100) && allocations == 0); + backing_allocation_fail = false; + auto* owner = upload::createOwner(id, session, media, 1, author, 100); + assert(owner && allocations == 1); + uint8_t content[320]; + for (unsigned i = 0; i < sizeof(content); ++i) content[i] = uint8_t(i); + uint8_t digest[32]; + assert(owner->hash.start() && owner->hash.update(content, sizeof(content)) && owner->hash.finish(digest)); + upload::destroyOwner(owner); + owner = nullptr; + allocation_fail = true; + assert(upload::beginNewUpload(owner, id, session, media, 1, author, sizeof(content), digest, 100, output, sizeof(output)) == Status::Busy && !owner && allocations == 0); + allocation_fail = false; + assert(upload::beginNewUpload(owner, id, session, media, 1, author, sizeof(content), digest, 100, output, sizeof(output)) == Status::Ok && owner && allocations == 1); + track_sharing::UploadRequest decoded; + geocaching::protocol::CmpWriter chunk_command(output, sizeof(output)); + assert(chunk_command.array(3) && chunk_command.binary({id, 16}) && chunk_command.unsignedInteger(0) && chunk_command.binary({content, sizeof(content)})); + assert(upload::processExistingUpload(*owner, track_sharing::UploadCommand::Chunk, {output, chunk_command.size()}, session, 101, decoded, output, sizeof(output), written) == Status::Ok); + geocaching::protocol::CmpWriter finish_command(output, sizeof(output)); + assert(finish_command.array(1) && finish_command.binary({id, 16})); + const size_t finish_size = finish_command.size(); + assert(upload::processExistingUpload(*owner, track_sharing::UploadCommand::Finish, {output, finish_size}, session, 102, decoded, output, sizeof(output), written) == Status::Ok); + assert(owner->session.advance(media, 103, true, output, sizeof(output)) == track_sharing::UploadSealResult::Pending); + assert(owner->session.advance(media, 104, true, output, sizeof(output)) == track_sharing::UploadSealResult::Ready); + assert(owner->resources.metadata().phase == track_sharing::UploadPhase::Sealed); + geocaching::protocol::CmpWriter poll(output, sizeof(output)); + assert(poll.array(1) && poll.binary({id, 16})); + assert(upload::processExistingUpload(*owner, track_sharing::UploadCommand::Finish, {output, poll.size()}, session, 105, decoded, output, sizeof(output), written) == Status::Ok); + geocaching::protocol::CmpReader progress({output, written}); + size_t fields = 0; + geocaching::ByteView result_id; + uint64_t value = 0; + assert(progress.array(fields, 4) && progress.binary(result_id, 16) && progress.unsignedInteger(value) && value == 320 && progress.unsignedInteger(value) && value == sizeof(content) && progress.unsignedInteger(value) && value == 1 && progress.finished()); + upload::destroyOwner(owner); + owner = nullptr; + assert(allocations == 0); + const auto saved_uploads = files; + using track_sharing::UploadKind; + assert(upload::beginNewUpload(owner, id, session, media, 1, author, 4097, digest, 100, output, sizeof(output), UploadKind::Manifest) == Status::InvalidField && !owner && files == saved_uploads); + assert(upload::recoverUpload(upload::owner, id, session, media, 1, author, sizeof(content), digest, 99, output, sizeof(output)) == Status::Ok && upload::owner); + uint8_t manifest_id[16] = {11}; + geocaching::protocol::CmpWriter manifest_begin(output, sizeof(output)); + assert(manifest_begin.array(4) && manifest_begin.binary({manifest_id, 16}) && manifest_begin.unsignedInteger(4) && manifest_begin.unsignedInteger(sizeof(content)) && manifest_begin.binary({digest, 32})); + EnvelopeView manifest_request; + manifest_request.code = static_cast(Operation::TransferBegin); + manifest_request.session = session; + manifest_request.body = output; + manifest_request.body_size = manifest_begin.size(); + assert(upload::routeTrackUpload(manifest_request)); + uint8_t wrong_session[16] = {12}; + manifest_request.session = wrong_session; + assert(upload::processUploadRequest(manifest_request, 1, author, 100, output, sizeof(output), written) == Status::Unauthorized && upload::owner->kind == UploadKind::Kml && files == saved_uploads); + manifest_request.session = session; + assert(upload::processUploadRequest(manifest_request, 2, author, 100, output, sizeof(output), written) == Status::StaleIdentity && upload::owner->kind == UploadKind::Kml && files == saved_uploads); + assert(upload::processUploadRequest(manifest_request, 1, author, 100, output, sizeof(output), written) == Status::Ok && upload::owner && upload::owner->kind == UploadKind::Manifest); + const auto before_wrong_kind = files; + geocaching::protocol::CmpWriter wrong_kind(output, sizeof(output)); + assert(wrong_kind.array(4) && wrong_kind.binary({manifest_id, 16}) && wrong_kind.unsignedInteger(3) && wrong_kind.unsignedInteger(sizeof(content)) && wrong_kind.binary({digest, 32})); + manifest_request.body_size = wrong_kind.size(); + assert(upload::processUploadRequest(manifest_request, 1, author, 100, output, sizeof(output), written) == Status::Conflict && files == before_wrong_kind); + owner = upload::owner; + upload::owner = nullptr; + const std::string manifest_candidate = owner->resources.candidatePath(); + assert(manifest_candidate.find("/web-manifest/") != std::string::npos); + assert(owner->session.append(session, media, 101, manifest_id, 0, content, sizeof(content), output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + assert(owner->session.beginSeal(session, media, 102, output, sizeof(output)) == track_sharing::UploadSealResult::Pending); + track_sharing::UploadSealResult manifest_result; + do + { + manifest_result = owner->session.advance(media, 103, true, output, sizeof(output)); + } while (manifest_result == track_sharing::UploadSealResult::Pending); + assert(manifest_result == track_sharing::UploadSealResult::Ready); + upload::destroyOwner(owner); + owner = nullptr; + assert(upload::recoverUpload(owner, manifest_id, session, media, 1, author, sizeof(content), digest, 200, output, sizeof(output), UploadKind::Manifest) == Status::Ok && owner); + track_sharing::UploadJournal prepare_kml, prepare_manifest, prepare_verify; + char prepare_kml_path[64], prepare_manifest_path[64]; + track_sharing::PrepareRequest prepare_request; + prepare_request.kml_upload = {id, 16}; + prepare_request.manifest_upload = {manifest_id, 16}; + prepare_request.generation = 1; + const auto before_prepare = files; + assert(track_sharing::loadPrepareUploadInputs(owner->io, prepare_request, 1, author, prepare_kml, prepare_manifest, prepare_verify, prepare_kml_path, prepare_manifest_path, output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + assert(!std::memcmp(prepare_kml.id, id, 16) && !std::memcmp(prepare_manifest.id, manifest_id, 16)); + assert(std::string(prepare_manifest_path) == manifest_candidate && std::string(prepare_kml_path).find("/web-upload/") != std::string::npos && files == before_prepare); + const auto preserved_kml = prepare_kml; + const auto preserved_manifest = prepare_manifest; + for (auto* invalid_path : {reinterpret_cast(&prepare_kml), reinterpret_cast(&prepare_manifest), reinterpret_cast(&prepare_verify)}) + { + assert(track_sharing::loadPrepareUploadInputs(owner->io, prepare_request, 1, author, prepare_kml, prepare_manifest, prepare_verify, invalid_path, prepare_manifest_path, output, sizeof(output)) == track_sharing::JournalStoreResult::Invalid); + assert(!std::memcmp(&preserved_kml, &prepare_kml, sizeof(prepare_kml)) && !std::memcmp(&preserved_manifest, &prepare_manifest, sizeof(prepare_manifest)) && files == before_prepare); + } + prepare_request.operation = {output, 16}; + assert(track_sharing::loadPrepareUploadInputs(owner->io, prepare_request, 1, author, prepare_kml, prepare_manifest, prepare_verify, prepare_kml_path, prepare_manifest_path, output, sizeof(output)) == track_sharing::JournalStoreResult::Invalid && files == before_prepare); + prepare_request.operation = {}; + prepare_request.generation = 2; + assert(track_sharing::loadPrepareUploadInputs(owner->io, prepare_request, 2, author, prepare_kml, prepare_manifest, prepare_verify, prepare_kml_path, prepare_manifest_path, output, sizeof(output)) == track_sharing::JournalStoreResult::Conflict); + assert(!prepare_kml.sequence && !prepare_manifest.sequence && !prepare_kml_path[0] && !prepare_manifest_path[0] && files == before_prepare); + prepare_request.generation = 1; + prepare_request.operation = {id, 16}; + PersistenceAuthor persistence_author; + uint8_t prepare_public[64]; + std::memcpy(prepare_public, persistence_author.public_key, 64); + allocation_fail = true; + assert(!prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, author)); + allocation_fail = false; + const auto files_before_backing_failure = files; + const auto allocations_before_backing_failure = allocations; + backing_allocation_fail = true; + assert(!prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, author)); + assert(allocations == allocations_before_backing_failure && files == files_before_backing_failure); + backing_allocation_fail = false; + auto* preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, author); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + track_sharing::ManifestFileResult preparation_result; + do + { + preparation_result = preparation->step(1, author, output, sizeof(output)); + } while (preparation_result == track_sharing::ManifestFileResult::Pending); + assert(preparation_result == track_sharing::ManifestFileResult::Invalid && files == before_prepare); + prepare_runtime::destroyPrepareOwner(preparation); + assert(allocations == 1); + { + const auto original_files = files; + prepare_request.generation = 1; + assert(track_sharing::loadPrepareUploadInputs(owner->io, prepare_request, 1, author, prepare_kml, prepare_manifest, prepare_verify, prepare_kml_path, prepare_manifest_path, output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + const std::string xml = "1,2"; + uint8_t proof_hash[32], xml_hash[32], raw_manifest[512]; + assert(owner->hash.start() && owner->hash.update(prepare_public, 64) && owner->hash.finish(proof_hash)); + assert(owner->hash.start() && owner->hash.update(reinterpret_cast(xml.data()), xml.size()) && owner->hash.finish(xml_hash)); + geocaching::protocol::CmpWriter manifest_writer(raw_manifest, sizeof(raw_manifest)); + assert(manifest_writer.array(14) && manifest_writer.unsignedInteger(1) && manifest_writer.binary({prepare_public, 64}) && manifest_writer.binary({id, 16}) && manifest_writer.unsignedInteger(0) && manifest_writer.unsignedInteger(1) && manifest_writer.nil() && manifest_writer.unsignedInteger(0) && manifest_writer.unsignedInteger(1) && manifest_writer.unsignedInteger(1) && manifest_writer.text("Platform") && manifest_writer.text("")); + assert(manifest_writer.array(4) && manifest_writer.unsignedInteger(10000000) && manifest_writer.unsignedInteger(20000000) && manifest_writer.unsignedInteger(10000000) && manifest_writer.unsignedInteger(20000000)); + assert(manifest_writer.array(4) && manifest_writer.unsignedInteger(0) && manifest_writer.nil() && manifest_writer.nil() && manifest_writer.unsignedInteger(1)); + assert(manifest_writer.array(4) && manifest_writer.binary({xml_hash, 32}) && manifest_writer.unsignedInteger(xml.size()) && manifest_writer.text("application/vnd.google-earth.kml+xml") && manifest_writer.unsignedInteger(1)); + files[prepare_kml_path] = {xml.begin(), xml.end()}; + files[prepare_manifest_path] = {raw_manifest, raw_manifest + manifest_writer.size()}; + prepare_kml.total = prepare_kml.durable_offset = uint32_t(xml.size()); + prepare_manifest.total = prepare_manifest.durable_offset = uint32_t(manifest_writer.size()); + std::memcpy(prepare_kml.file_hash, xml_hash, 32); + assert(owner->hash.start() && owner->hash.update(raw_manifest, manifest_writer.size()) && owner->hash.finish(prepare_manifest.file_hash)); + for (auto* record : {&prepare_kml, &prepare_manifest}) + { + std::memcpy(record->author_hash, proof_hash, 32); + record->sequence = 1; + char journal_path[64]; + const auto kind = record == &prepare_kml ? UploadKind::Kml : UploadKind::Manifest; + assert(track_sharing::resolveUploadPath(record->id, 16, track_sharing::UploadFile::JournalA, journal_path, sizeof(journal_path), kind)); + assert(track_sharing::encodeUploadJournal(*record, output, sizeof(output))); + files[journal_path] = {output, output + track_sharing::kUploadJournalBytes}; + assert(track_sharing::resolveUploadPath(record->id, 16, track_sharing::UploadFile::JournalB, journal_path, sizeof(journal_path), kind)); + files.erase(journal_path); + } + const auto valid_files = files; + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + do + { + preparation_result = preparation->step(1, proof_hash, output, sizeof(output)); + } while (preparation_result == track_sharing::ManifestFileResult::Pending); + assert(preparation_result == track_sharing::ManifestFileResult::Ready && preparation->ready()); + auto* persistence = prepare_runtime::createPreparePersistence(*preparation); + assert(persistence && !persistence->ready()); + assert(persistence->finish(persistence_author, nullptr, output, sizeof(output)) == Status::Ok && persistence->ready() && persistence_author.signing_calls == 1); + const auto first_head = persistence->head(); + char recovery_log[160], recovery_signature[160]; + assert(track_sharing::resolveObjectPath({persistence->head().object, 32}, track_sharing::ObjectFile::ReservationA, prepare_request.operation, recovery_log, sizeof(recovery_log))); + assert(track_sharing::resolveObjectPath({persistence->head().object, 32}, track_sharing::ObjectFile::SignedManifest, {persistence->head().hash, 32}, recovery_signature, sizeof(recovery_signature))); + const auto prepared_files = files; + prepare_runtime::destroyPreparePersistence(persistence); + persistence = prepare_runtime::createPreparePersistence(*preparation); + assert(persistence && persistence->finish(persistence_author, nullptr, output, sizeof(output)) == Status::Ok && persistence->ready()); + assert(files == prepared_files && persistence_author.signing_calls == 1); + prepare_runtime::destroyPreparePersistence(persistence); + files[recovery_log][0] ^= 1; + persistence = prepare_runtime::createPreparePersistence(*preparation); + assert(persistence && persistence->finish(persistence_author, nullptr, output, sizeof(output)) == Status::Ok && persistence->ready()); + assert(files == prepared_files && persistence_author.signing_calls == 1); + prepare_runtime::destroyPreparePersistence(persistence); + files[recovery_signature].back() ^= 1; + const auto corrupt_signature_files = files; + persistence = prepare_runtime::createPreparePersistence(*preparation); + assert(persistence && persistence->finish(persistence_author, nullptr, output, sizeof(output)) == Status::Conflict && !persistence->ready()); + assert(files == corrupt_signature_files && persistence_author.signing_calls == 1); + prepare_runtime::destroyPreparePersistence(persistence); + files = prepared_files; + assert(preparation->view.points == 1 && preparation->view.west == 10000000 && preparation->view.south == 20000000); + assert(preparation->manifest().size == manifest_writer.size() && !std::memcmp(preparation->manifest().data, raw_manifest, manifest_writer.size()) && files == prepared_files); + assert(preparation->step(2, proof_hash, output, sizeof(output)) == track_sharing::ManifestFileResult::Invalid); + assert(!preparation->ready() && !preparation->manifest().size && !preparation->matchesCandidate(preparation->view)); + assert(preparation->step(1, proof_hash, output, sizeof(output)) == track_sharing::ManifestFileResult::Invalid && files == prepared_files); + prepare_runtime::destroyPrepareOwner(preparation); + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + do + { + preparation_result = preparation->step(1, proof_hash, output, sizeof(output)); + } while (preparation_result == track_sharing::ManifestFileResult::Pending); + assert(preparation_result == track_sharing::ManifestFileResult::Ready); + ++media; + assert(!preparation->ready() && !preparation->manifest().size); + assert(preparation->step(1, proof_hash, output, sizeof(output)) == track_sharing::ManifestFileResult::StorageFailure); + --media; + assert(preparation->step(1, proof_hash, output, sizeof(output)) == track_sharing::ManifestFileResult::StorageFailure && !preparation->ready() && files == prepared_files); + prepare_runtime::destroyPrepareOwner(preparation); + assert(allocations == 1); + // Fixture supplies local task admission; no real network job exists. + char head_a[160], head_b[160]; + assert(track_sharing::resolveObjectPath({first_head.object, 32}, track_sharing::ObjectFile::HeadA, {}, head_a, sizeof(head_a))); + assert(track_sharing::resolveObjectPath({first_head.object, 32}, track_sharing::ObjectFile::HeadB, {}, head_b, sizeof(head_b))); + track_sharing::ObjectHead committed = first_head, head_verify; + committed.phase = track_sharing::HeadPhase::Committed; + ++committed.sequence; + track_sharing::ObjectHeadStore heads(owner->io, head_a, head_b); + assert(heads.advance(first_head, committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready); + uint8_t update_id[16] = {12}, update_operation[16] = {4}; + prepare_request.manifest_upload = {update_id, 16}; + prepare_request.operation = {update_operation, 16}; + prepare_request.expected_parent = {first_head.hash, 32}; + geocaching::protocol::CmpWriter update_writer(raw_manifest, sizeof(raw_manifest)); + assert(update_writer.array(14) && update_writer.unsignedInteger(1) && update_writer.binary({prepare_public, 64}) && update_writer.binary({id, 16}) && update_writer.unsignedInteger(0) && update_writer.unsignedInteger(2) && update_writer.binary({first_head.hash, 32}) && update_writer.unsignedInteger(0) && update_writer.unsignedInteger(1) && update_writer.unsignedInteger(2) && update_writer.text("Updated platform") && update_writer.text("")); + assert(update_writer.array(4) && update_writer.unsignedInteger(10000000) && update_writer.unsignedInteger(20000000) && update_writer.unsignedInteger(10000000) && update_writer.unsignedInteger(20000000)); + assert(update_writer.array(4) && update_writer.unsignedInteger(0) && update_writer.nil() && update_writer.nil() && update_writer.unsignedInteger(1)); + assert(update_writer.array(4) && update_writer.binary({xml_hash, 32}) && update_writer.unsignedInteger(xml.size()) && update_writer.text("application/vnd.google-earth.kml+xml") && update_writer.unsignedInteger(1)); + std::memcpy(prepare_manifest.id, update_id, 16); + prepare_manifest.total = prepare_manifest.durable_offset = uint32_t(update_writer.size()); + assert(owner->hash.start() && owner->hash.update(raw_manifest, update_writer.size()) && owner->hash.finish(prepare_manifest.file_hash)); + assert(track_sharing::resolveUploadPath(update_id, 16, track_sharing::UploadFile::Candidate, prepare_manifest_path, sizeof(prepare_manifest_path), UploadKind::Manifest)); + files[prepare_manifest_path] = {raw_manifest, raw_manifest + update_writer.size()}; + char update_log[64]; + assert(track_sharing::resolveUploadPath(update_id, 16, track_sharing::UploadFile::JournalA, update_log, sizeof(update_log), UploadKind::Manifest)); + assert(track_sharing::encodeUploadJournal(prepare_manifest, output, sizeof(output))); + files[update_log] = {output, output + track_sharing::kUploadJournalBytes}; + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + do + { + preparation_result = preparation->step(1, proof_hash, output, sizeof(output)); + } while (preparation_result == track_sharing::ManifestFileResult::Pending); + assert(preparation_result == track_sharing::ManifestFileResult::Ready); + persistence = prepare_runtime::createPreparePersistence(*preparation); + assert(persistence && persistence->finish(persistence_author, nullptr, output, sizeof(output)) == Status::Busy && !persistence->ready() && persistence_author.signing_calls == 1); + Status update_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && update_result == Status::Busy; ++attempt) update_result = persistence->finish(persistence_author, nullptr, output, sizeof(output)); + assert(update_result == Status::Ok && persistence->ready() && persistence->head().revision == 2 && persistence_author.signing_calls == 2); + assert(!std::memcmp(persistence->head().parent, first_head.hash, 32) && !std::memcmp(persistence->head().object, first_head.object, 32)); + std::string retained_path; + const auto retained_bytes = files.at(preparation->kml_path); + { + prepare_runtime::TrackKmlRetention retention(*preparation); + auto retained = retention.begin({first_head.object, 32}); + for (unsigned step = 0; step < 32 && retained == track_sharing::ImmutableCopyResult::Pending; ++step) retained = retention.step(output, sizeof(output)); + assert(retained == track_sharing::ImmutableCopyResult::Ready && files.at(retention.destination()) == files.at(preparation->kml_path)); + retained_path = retention.destination(); + } + files.erase(retained_path); + prepare_runtime::destroyPreparePersistence(persistence); + prepare_runtime::destroyPrepareOwner(preparation); + assert(allocations == 1); + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + auto* work = prepare_runtime::createPrepareWork(preparation, session, 100); + assert(work && work->matches(session, prepare_request) && work->alive(media, 100, true)); + Status work_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && work_result == Status::Busy; ++attempt) work_result = work->advance(persistence_author, output, sizeof(output)); + assert(work_result == Status::Ok && work->ready() && work->result()->revision == 2 && allocations == 2 && persistence_author.signing_calls == 2); + assert(files.at(retained_path) == retained_bytes); + { + namespace storage = platform::esp::arduino_common::storage; + storage::SdRuntimeFile journal_file; + storage::SdBorrowedRecordFileIo journal_io(journal_file, media); + track_sharing::PublicationJournal intent, current_job, verify_job; + const auto& prepared_head = *work->result(); + std::memcpy(intent.operation, prepared_head.operation, 16); + std::memcpy(intent.object, prepared_head.object, 32); + std::memcpy(intent.revision_hash, prepared_head.hash, 32); + std::memcpy(intent.author_hash, proof_hash, 32); + std::memcpy(intent.file_hash, prepare_kml.file_hash, 32); + intent.directory[0] = 8; + intent.directory_epoch[0] = 9; + intent.sequence = 1; + intent.generation = 1; + intent.revision = 2; + intent.file_bytes = uint32_t(retained_bytes.size()); + char slot_a[160], slot_b[160]; + assert(track_sharing::resolveObjectPath({intent.object, 32}, track_sharing::ObjectFile::PublicationA, {intent.operation, 16}, slot_a, sizeof(slot_a))); + assert(track_sharing::resolveObjectPath({intent.object, 32}, track_sharing::ObjectFile::PublicationB, {intent.operation, 16}, slot_b, sizeof(slot_b))); + track_sharing::PublicationJournalStore journal(journal_io, slot_a, slot_b); + assert(track_sharing::persistPublicationIntent(journal, intent, current_job, verify_job, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + assert(journal.load(current_job, verify_job, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready && current_job.revision == 2 && !journal_file.is_open()); + files.erase(slot_a); + files.erase(slot_b); + } + const auto cached_files = files; + persistence_author.read_failure = chat::DeviceAuthorResult::Unavailable; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::Busy && !work->ready() && !work->result()); + persistence_author.read_failure = chat::DeviceAuthorResult::Ok; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::Ok && work->ready() && files == cached_files && persistence_author.signing_calls == 2); + assert(!work->alive(media, 30100, true) && !work->alive(media, 99, true) && !work->alive(media, 101, false)); + uint8_t wrong_session[16] = {17}; + assert(!work->authorize(wrong_session, prepare_request, 200)); + assert(work->authorize(session, prepare_request, 200) && work->alive(media, 30199, true)); + prepare_runtime::work = work; + prepare_runtime::closePrepareSession(wrong_session); + assert(prepare_runtime::work == work && allocations == 2); + assert(prepare_runtime::advancePrepare(nullptr, output, sizeof(output), 201, true) == Status::Busy && !work->ready()); + assert(prepare_runtime::advancePrepare(&persistence_author, output, sizeof(output), 202, true) == Status::Ok && work->ready()); + prepare_runtime::closePrepareSession(session); + assert(!prepare_runtime::work); + assert(allocations == 1); + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + prepare_runtime::work = prepare_runtime::createPrepareWork(preparation, session, 300); + assert(prepare_runtime::work && allocations == 3); + assert(prepare_runtime::advancePrepare(&persistence_author, output, sizeof(output), 30300, true) == Status::Expired && !prepare_runtime::work && allocations == 1 && files == cached_files); + files[retained_path][0] ^= 1; + const auto damaged_retention = files; + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + work = prepare_runtime::createPrepareWork(preparation, session, 350); + assert(work); + work_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && work_result == Status::Busy; ++attempt) work_result = work->advance(persistence_author, output, sizeof(output)); + assert(work_result == Status::HashMismatch && !work->ready() && !work->holdsStages() && allocations == 2 && files == damaged_retention && persistence_author.signing_calls == 2); + prepare_runtime::destroyPrepareWork(work); + assert(allocations == 1); + files = cached_files; + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + work = prepare_runtime::createPrepareWork(preparation, session, 355); + assert(work); + work_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && allocations < 5 && work_result == Status::Busy; ++attempt) work_result = work->advance(persistence_author, output, sizeof(output)); + assert(work_result == Status::Busy && allocations == 5 && work->holdsStages() && !work->ready()); + const auto before_media_change = files; + ++media; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::StorageUnavailable && !work->ready() && !work->holdsStages() && allocations == 2 && files == before_media_change); + --media; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::StorageUnavailable && !work->ready() && files == before_media_change && persistence_author.signing_calls == 2); + prepare_runtime::destroyPrepareWork(work); + assert(allocations == 1); + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + work = prepare_runtime::createPrepareWork(preparation, session, 357); + assert(work); + work_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && allocations < 5 && work_result == Status::Busy; ++attempt) work_result = work->advance(persistence_author, output, sizeof(output)); + assert(work_result == Status::Busy && allocations == 5 && work->holdsStages()); + persistence_author.read_failure = chat::DeviceAuthorResult::Unavailable; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::Busy && !work->ready() && allocations == 5 && files == cached_files); + persistence_author.read_failure = chat::DeviceAuthorResult::Ok; + persistence_author.identity_generation = 2; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::StaleIdentity && !work->ready() && !work->holdsStages() && allocations == 2 && files == cached_files); + persistence_author.identity_generation = 1; + assert(work->advance(persistence_author, output, sizeof(output)) == Status::StaleIdentity && !work->ready() && files == cached_files && persistence_author.signing_calls == 2); + prepare_runtime::destroyPrepareWork(work); + assert(allocations == 1); + files = cached_files; + files.erase(retained_path); + const auto retained_temporary = retained_path.substr(0, retained_path.size() - 4) + ".ktm"; + files[retained_temporary] = {retained_bytes.begin(), retained_bytes.begin() + retained_bytes.size() / 2}; + preparation = prepare_runtime::createPrepareOwner(prepare_request, media, prepare_public, proof_hash); + assert(preparation && preparation->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + work = prepare_runtime::createPrepareWork(preparation, session, 360); + assert(work); + work_result = Status::Busy; + for (unsigned attempt = 0; attempt < 32 && work_result == Status::Busy; ++attempt) work_result = work->advance(persistence_author, output, sizeof(output)); + assert(work_result == Status::Ok && work->ready() && !work->holdsStages() && allocations == 2 && files == cached_files && persistence_author.signing_calls == 2); + prepare_runtime::destroyPrepareWork(work); + assert(allocations == 1); + // Request and response deliberately share the command scratch. The + // runtime must copy request bindings before advancing file readers. + EnvelopeView direct_request; + direct_request.session = session; + direct_request.body = output; + direct_request.code = static_cast(Operation::TrackPrepare); + bool direct_ready = false; + for (unsigned attempt = 0; attempt < 32 && !direct_ready; ++attempt) + { + geocaching::protocol::CmpWriter request_writer(output, sizeof(output)); + assert(request_writer.array(5) && request_writer.binary(prepare_request.operation) && + request_writer.binary(prepare_request.kml_upload) && request_writer.binary(prepare_request.manifest_upload) && + request_writer.unsignedInteger(prepare_request.generation) && request_writer.binary(prepare_request.expected_parent)); + direct_request.body_size = request_writer.size(); + assert(prepare_runtime::processPrepareRequest(persistence_author, direct_request, 400 + attempt, output, sizeof(output), written) == Status::Ok); + geocaching::protocol::CmpReader response({output, written}); + size_t fields = 0; + geocaching::ByteView operation, object, revision_hash; + uint64_t ready = 0, revision = 0; + assert(response.array(fields, 5) && fields == 5 && response.binary(operation, 16) && operation.size == 16 && + !std::memcmp(operation.data, prepare_request.operation.data, 16) && response.unsignedInteger(ready) && ready <= 1); + if (ready) + { + assert(response.binary(object, 32) && object.size == 32 && response.binary(revision_hash, 32) && revision_hash.size == 32 && + response.unsignedInteger(revision) && revision == 2 && response.finished()); + assert(!std::memcmp(object.data, first_head.object, 32) && !std::memcmp(revision_hash.data, prepare_runtime::work->result()->hash, 32)); + direct_ready = true; + } + else assert(response.nil() && response.nil() && response.unsignedInteger(revision) && revision == 0 && response.finished()); + } + assert(direct_ready && files == cached_files && persistence_author.signing_calls == 2 && allocations == 2); + auto encode_direct_request = [&](uint32_t generation) + { + geocaching::protocol::CmpWriter writer(output, sizeof(output)); + assert(writer.array(5) && writer.binary(prepare_request.operation) && writer.binary(prepare_request.kml_upload) && + writer.binary(prepare_request.manifest_upload) && writer.unsignedInteger(generation) && writer.binary(prepare_request.expected_parent)); + direct_request.body_size = writer.size(); + }; + auto* retained_work = prepare_runtime::work; + encode_direct_request(1); + direct_request.session = wrong_session; + assert(prepare_runtime::processPrepareRequest(persistence_author, direct_request, 450, output, sizeof(output), written) == Status::Conflict && written == 0); + assert(prepare_runtime::work == retained_work && files == cached_files && persistence_author.signing_calls == 2); + direct_request.session = session; + encode_direct_request(2); + assert(prepare_runtime::processPrepareRequest(persistence_author, direct_request, 451, output, sizeof(output), written) == Status::StaleIdentity && written == 0); + assert(prepare_runtime::work == retained_work && files == cached_files && persistence_author.signing_calls == 2); + encode_direct_request(1); + persistence_author.read_failure = chat::DeviceAuthorResult::Unavailable; + assert(prepare_runtime::processPrepareRequest(persistence_author, direct_request, 452, output, sizeof(output), written) == Status::Busy && written == 0); + persistence_author.read_failure = chat::DeviceAuthorResult::Ok; + assert(prepare_runtime::work == retained_work && files == cached_files && persistence_author.signing_calls == 2); + encode_direct_request(1); + assert(prepare_runtime::processPrepareRequest(persistence_author, direct_request, 453, output, sizeof(output), written) == Status::Ok && written > 0); + assert(prepare_runtime::work == retained_work && files == cached_files && persistence_author.signing_calls == 2 && allocations == 2); + const auto publication_head = *prepare_runtime::work->result(); + prepare_runtime::closePrepareSession(session); + assert(!prepare_runtime::work && allocations == 1); + { + namespace publication = hostlink::management::publication_runtime; + track_sharing::PublicationDirectory directory; + directory.delivery[0] = 8; + directory.public_key[0] = 16; + directory.epoch[0] = 9; + uint8_t directory_secret[64], directory_seed[32] = {19}; + ed25519_create_keypair(directory.public_key + 32, directory_secret, directory_seed); + // IP confirmation also authenticates the LXMF delivery address. + uint8_t directory_hash[32], service_hash[32], delivery_hash[32], delivery_input[26]; + constexpr char delivery_service[] = "lxmf.delivery"; + assert(owner->hash.start() && owner->hash.update(directory.public_key, 64) && owner->hash.finish(directory_hash)); + assert(owner->hash.start() && owner->hash.update(reinterpret_cast(delivery_service), sizeof(delivery_service) - 1) && owner->hash.finish(service_hash)); + std::memcpy(delivery_input, service_hash, 10); + std::memcpy(delivery_input + 10, directory_hash, 16); + assert(owner->hash.start() && owner->hash.update(delivery_input, sizeof(delivery_input)) && owner->hash.finish(delivery_hash)); + std::memcpy(directory.delivery, delivery_hash, 16); + directory.file_limit = 8388608; + directory.chunk_limit = 1024; + directory.query_limit = 32; + track_sharing::CommitRequest commit{{publication_head.operation, 16}, {publication_head.object, 32}, {publication_head.hash, 32}, {directory.delivery, 16}, {directory.epoch, 16}, 1, 2}; + allocation_fail = true; + assert(!publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash) && allocations == 1); + allocation_fail = false; + backing_allocation_fail = true; + assert(!publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash) && allocations == 1); + backing_allocation_fail = false; + auto* admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + assert(admission && allocations == 2 && !admission->ready()); + commit.revision = 99; + directory.public_key[0] ^= 1; + Status admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + commit.revision = 2; + directory.public_key[0] ^= 1; + assert(admitted == Status::Ok && admission->ready() && admission->result()->phase == track_sharing::PublicationPhase::Accepted); + assert(admission->manifest()->verified && persistence_author.signing_calls == 2 && files.at(retained_path) == retained_bytes); + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready && committed.phase == track_sharing::HeadPhase::Committed && committed.revision == 2); + track_sharing::PublicationJournal accepted_job = *admission->result(), uploading_job = accepted_job, job_verify; + const auto accepted_files = files; + uint8_t local_delivery[16] = {42}; + chat::TrackBindingChange binding_change; + allocation_fail = true; + assert(!publication::TrackPublicationExecution::create(admission, persistence_author, local_delivery, binding_change) && admission && binding_change == chat::TrackBindingChange::Busy && allocations == 2); + allocation_fail = false; + persistence_author.binding_busy = true; + assert(!publication::TrackPublicationExecution::create(admission, persistence_author, local_delivery, binding_change) && admission && allocations == 2 && !persistence_author.binding); + persistence_author.binding_busy = false; + auto* execution = publication::TrackPublicationExecution::create(admission, persistence_author, local_delivery, binding_change); + assert(execution && !admission && binding_change == chat::TrackBindingChange::Applied && allocations == 3 && persistence_author.binding); + persistence_author.send_allowed = false; + assert(execution->advance(1000) == track_sharing::NetworkSessionResult::Rejected && persistence_author.network_sends == 1); + const auto rejected_packet = persistence_author.network_wire; + assert(execution->advance(1001) == track_sharing::NetworkSessionResult::Waiting && persistence_author.network_sends == 1); + persistence_author.send_allowed = true; + assert(execution->advance(4000) == track_sharing::NetworkSessionResult::Queued && persistence_author.network_sends == 2 && persistence_author.network_wire == rejected_packet); + geocaching::protocol::CmpReader begin_packet({persistence_author.network_wire.data(), persistence_author.network_wire.size()}); + size_t begin_fields = 0; + uint64_t begin_value = 0; + geocaching::ByteView begin_id; + assert(begin_packet.array(begin_fields, 6) && begin_fields == 6 && begin_packet.unsignedInteger(begin_value) && begin_value == 1 && + begin_packet.unsignedInteger(begin_value) && begin_value == 0 && begin_packet.unsignedInteger(begin_value) && begin_value == 4 && begin_packet.binary(begin_id, 16)); + uint8_t remote_response[128], message_hash[32]{}; + geocaching::protocol::CmpWriter remote_writer(remote_response, sizeof(remote_response)); + assert(remote_writer.array(6) && remote_writer.unsignedInteger(1) && remote_writer.unsignedInteger(1) && remote_writer.unsignedInteger(4) && remote_writer.binary(begin_id) && + remote_writer.unsignedInteger(200) && remote_writer.array(4) && remote_writer.nil() && remote_writer.unsignedInteger(accepted_job.file_bytes) && + remote_writer.unsignedInteger(1) && remote_writer.unsignedInteger(1024)); + chat::lxmf::CustomDeliveryView completed_file{{directory.delivery, 16}, {local_delivery, 16}, {message_hash, 32}, {remote_response, remote_writer.size()}}; + assert(chat::lxmf::deliverVerifiedTrackForAuthor(persistence_author.binding, false, false, completed_file, persistence_author)); + assert(execution->advance(4001) == track_sharing::NetworkSessionResult::Progress); + assert(execution->advance(4002) == track_sharing::NetworkSessionResult::Queued && persistence_author.network_sends == 3); + ++persistence_author.identity_generation; + assert(execution->advance(4003) == track_sharing::NetworkSessionResult::Conflict && persistence_author.network_sends == 3); + --persistence_author.identity_generation; + persistence_author.binding_busy = true; + assert(execution->advance(4004) == track_sharing::NetworkSessionResult::Waiting && persistence_author.network_sends == 3); + assert(!publication::TrackPublicationExecution::release(execution) && execution && allocations == 3 && persistence_author.binding); + persistence_author.binding_busy = false; + assert(publication::TrackPublicationExecution::release(execution) && !execution && allocations == 1 && !persistence_author.binding); + files = accepted_files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Ok); + execution = publication::TrackPublicationExecution::create(admission, persistence_author, local_delivery, binding_change); + assert(execution && !admission && execution->advance(5000) == track_sharing::NetworkSessionResult::Queued); + const auto before_cancel = files; + track_sharing::PublicationJournal cancelled_job; + persistence_author.binding_busy = true; + assert(execution->cancel(commit.operation, commit.generation, cancelled_job) == Status::Busy && files == before_cancel); + persistence_author.binding_busy = false; + ++persistence_author.identity_generation; + assert(execution->cancel(commit.operation, commit.generation, cancelled_job) == Status::StaleIdentity && files == before_cancel); + --persistence_author.identity_generation; + uint8_t wrong_cancel[16]; + std::memcpy(wrong_cancel, commit.operation.data, 16); + wrong_cancel[0] ^= 1; + assert(execution->cancel({wrong_cancel, 16}, commit.generation, cancelled_job) == Status::Conflict && files == before_cancel); + assert(execution->cancel(commit.operation, commit.generation, cancelled_job) == Status::Ok && cancelled_job.phase == track_sharing::PublicationPhase::Cancelled); + const auto sends_after_cancel = persistence_author.network_sends; + assert(execution->advance(5001) == track_sharing::NetworkSessionResult::Paused && persistence_author.network_sends == sends_after_cancel); + assert(publication::TrackPublicationExecution::release(execution) && !execution && allocations == 1 && !persistence_author.binding); + files = accepted_files; + publication::destroyPublicationAdmission(admission); + assert(allocations == 1); + char job_a[160], job_b[160]; + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::PublicationA, commit.operation, job_a, sizeof(job_a))); + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::PublicationB, commit.operation, job_b, sizeof(job_b))); + track_sharing::PublicationJournalStore running_jobs(owner->io, job_a, job_b); + ++uploading_job.sequence; + uploading_job.phase = track_sharing::PublicationPhase::Uploading; + uploading_job.offset = 32; + assert(running_jobs.advance(accepted_job, uploading_job, job_verify, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + const auto before_newer_head = files; + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready); + auto newer_head = committed; + ++newer_head.sequence; + ++newer_head.revision; + newer_head.phase = track_sharing::HeadPhase::Signed; + std::memcpy(newer_head.parent, committed.hash, 32); + newer_head.hash[0] ^= 1; + newer_head.operation[0] ^= 1; + assert(heads.advance(committed, newer_head, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready); + auto admitted_files = files; + auto* stale_commit = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + assert(stale_commit && stale_commit->step(persistence_author, output, sizeof(output)) == Status::Conflict && files == admitted_files); + publication::destroyPublicationAdmission(stale_commit); + assert(allocations == 1); + track_sharing::PublicationCandidateReader history_candidate(owner->io); + track_sharing::PublicationSlotKey history_key; + std::memcpy(history_key.object, uploading_job.object, 32); + std::memcpy(history_key.operation, uploading_job.operation, 16); + assert(history_candidate.load(history_key, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + { + struct CandidateScan + { + track_sharing::PublicationSlotKey key; + unsigned steps = 0; + publication::HistoryScanResult step(track_sharing::PublicationSlotKey& output) + { + if (!steps++) return publication::HistoryScanResult::Busy; + if (steps > 2) return publication::HistoryScanResult::End; + output = key; + return publication::HistoryScanResult::Candidate; + } + } scan{history_key}; + publication::HistoryQuery query(scan, history_candidate, commit.operation, commit.generation, media); + assert(query.step(persistence_author, output, sizeof(output)) == Status::Busy && !query.result()); + Status recovered = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && recovered == Status::Busy; ++attempt) recovered = query.step(persistence_author, output, sizeof(output)); + assert(recovered == Status::Ok && query.result() && query.result()->offset == 32 && files == admitted_files); + ++persistence_author.identity_generation; + assert(query.step(persistence_author, output, sizeof(output)) == Status::StaleIdentity && !query.result()); + --persistence_author.identity_generation; + } + assert(allocations == 1 && files == admitted_files); + admission = nullptr; + ++persistence_author.identity_generation; + assert(publication::beginHistoryAuthentication(persistence_author, *history_candidate.unverifiedJournal(), *history_candidate.unverifiedDirectory(), media, output, sizeof(output), admission) == Status::StaleIdentity && !admission); + --persistence_author.identity_generation; + assert(publication::beginHistoryAuthentication(persistence_author, *history_candidate.unverifiedJournal(), *history_candidate.unverifiedDirectory(), media, output, sizeof(output), admission) == Status::Busy && admission); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Ok && admission->ready() && files == admitted_files && admission->result()->phase == track_sharing::PublicationPhase::Uploading && admission->result()->offset == 32); + assert(admission->readOnly()); + uint8_t history_local[16] = {42}; + chat::TrackBindingChange history_binding; + auto* history_owner = admission; + assert(!publication::TrackPublicationExecution::create(admission, persistence_author, history_local, history_binding) && admission == history_owner && files == admitted_files); + persistence_author.read_failure = chat::DeviceAuthorResult::Unavailable; + assert(admission->step(persistence_author, output, sizeof(output)) == Status::Busy && !admission->ready() && !admission->result()); + persistence_author.read_failure = chat::DeviceAuthorResult::Ok; + assert(admission->step(persistence_author, output, sizeof(output)) == Status::Ok && admission->ready()); + ++persistence_author.identity_generation; + assert(admission->step(persistence_author, output, sizeof(output)) == Status::StaleIdentity && !admission->ready() && files == admitted_files); + --persistence_author.identity_generation; + publication::destroyPublicationAdmission(admission); + assert(allocations == 1); + files = before_newer_head; + admitted_files = files; + assert(running_jobs.load(uploading_job, job_verify, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + auto stored_remote = uploading_job; + ++stored_remote.sequence; + stored_remote.offset = stored_remote.file_bytes; + stored_remote.phase = track_sharing::PublicationPhase::FileStored; + assert(running_jobs.advance(uploading_job, stored_remote, job_verify, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + uploading_job = stored_remote; + ++stored_remote.sequence; + stored_remote.phase = track_sharing::PublicationPhase::Publishing; + assert(running_jobs.advance(uploading_job, stored_remote, job_verify, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + uint8_t receipt_raw[512], receipt_signature[64]; + geocaching::protocol::CmpWriter receipt_writer(receipt_raw, sizeof(receipt_raw)); + assert(receipt_writer.array(13) && receipt_writer.unsignedInteger(1) && receipt_writer.binary({stored_remote.operation, 16}) && + receipt_writer.binary({stored_remote.object, 32}) && receipt_writer.binary({stored_remote.revision_hash, 32}) && + receipt_writer.unsignedInteger(stored_remote.revision) && receipt_writer.unsignedInteger(0) && receipt_writer.binary({stored_remote.file_hash, 32}) && + receipt_writer.unsignedInteger(stored_remote.file_bytes) && receipt_writer.binary({directory.public_key, 64}) && + receipt_writer.binary({directory.delivery, 16}) && receipt_writer.binary({directory.epoch, 16}) && + receipt_writer.unsignedInteger(1) && receipt_writer.unsignedInteger(5000000000ULL)); + constexpr char receipt_domain[] = "TrailMate.Track.Receipt.v1"; + uint8_t receipt_signing[sizeof(receipt_domain) + 512]; + std::memcpy(receipt_signing, receipt_domain, sizeof(receipt_domain)); + std::memcpy(receipt_signing + sizeof(receipt_domain), receipt_raw, receipt_writer.size()); + ed25519_sign(receipt_signature, receipt_signing, sizeof(receipt_domain) + receipt_writer.size(), directory.public_key + 32, directory_secret); + char receipt_temporary[160], receipt_path[160]; + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::ReceiptTemporary, commit.operation, receipt_temporary, sizeof(receipt_temporary))); + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::Receipt, commit.operation, receipt_path, sizeof(receipt_path))); + track_sharing::SignedManifestStore receipt_store(owner->io, receipt_temporary, receipt_path); + assert(receipt_store.persist({receipt_raw, receipt_writer.size()}, {receipt_signature, 64}, output, sizeof(output)) == track_sharing::SignedStoreResult::Ready); + uploading_job = stored_remote; + ++stored_remote.sequence; + stored_remote.phase = track_sharing::PublicationPhase::Confirmed; + assert(running_jobs.advance(uploading_job, stored_remote, job_verify, output, sizeof(output)) == track_sharing::PublicationStoreResult::Ready); + const auto confirmed_remote = files; + { + namespace confirmation = hostlink::management::ip_confirmation_runtime; + char signed_path[160], object_path[160]; + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::SignedManifest, commit.revision_hash, signed_path, sizeof(signed_path))); + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::Directory, {}, object_path, sizeof(object_path))); + const auto signed_bytes = files.at(signed_path), receipt_bytes = files.at(receipt_path); + geocaching::protocol::CmpReader signed_reader({signed_bytes.data(), signed_bytes.size()}); + size_t fields = 0; + geocaching::ByteView original_raw, original_signature; + assert(signed_reader.array(fields, 2) && fields == 2 && signed_reader.binary(original_raw, 4096) && signed_reader.binary(original_signature, 64) && signed_reader.finished()); + uint8_t imported_id[16] = {44}, imported_operation[16] = {45}, receipt_hash[32]; + assert(owner->hash.start() && owner->hash.update(receipt_bytes.data(), receipt_bytes.size()) && owner->hash.finish(receipt_hash)); + // Replacement device has the SAME author, fresh session epoch, + // uploaded evidence, and no old local object files or keys. + for (auto it = files.begin(); it != files.end();) + { + if (it->first.rfind(std::string(object_path) + "/", 0) == 0) it = files.erase(it); + else ++it; + } + persistence_author.identity_generation = 2; + auto imported_manifest = prepare_manifest; + imported_manifest.generation = 2; + track_sharing::UploadJournal imported_receipt = imported_manifest; + std::memcpy(imported_receipt.id, imported_id, 16); + std::memcpy(imported_receipt.file_hash, receipt_hash, 32); + imported_receipt.total = imported_receipt.durable_offset = uint32_t(receipt_bytes.size()); + for (const auto* item : {&imported_manifest, &imported_receipt}) + { + const auto kind = item == &imported_manifest ? UploadKind::Manifest : UploadKind::IpReceipt; + char path[64]; + assert(track_sharing::resolveUploadPath(item->id, 16, track_sharing::UploadFile::Candidate, path, sizeof(path), kind)); + files[path] = item == &imported_manifest ? std::vector(original_raw.data, original_raw.data + original_raw.size) : receipt_bytes; + assert(track_sharing::resolveUploadPath(item->id, 16, track_sharing::UploadFile::JournalA, path, sizeof(path), kind)); + assert(track_sharing::encodeUploadJournal(*item, output, sizeof(output))); + files[path] = {output, output + track_sharing::kUploadJournalBytes}; + assert(track_sharing::resolveUploadPath(item->id, 16, track_sharing::UploadFile::JournalB, path, sizeof(path), kind)); + files.erase(path); + } + uint8_t wire[416]; + geocaching::protocol::CmpWriter writer(wire, sizeof(wire)); + assert(writer.array(14) && writer.binary({imported_operation, 16}) && writer.binary({imported_id, 16}) && writer.binary({receipt_hash, 32}) && + writer.unsignedInteger(2) && writer.binary({directory.public_key, 64}) && writer.binary({directory.delivery, 16}) && writer.binary({directory.epoch, 16}) && + writer.binary(commit.operation) && writer.binary(commit.object) && writer.binary(commit.revision_hash) && writer.unsignedInteger(2) && + writer.binary({imported_manifest.id, 16}) && writer.binary({imported_manifest.file_hash, 32}) && writer.binary(original_signature)); + EnvelopeView envelope{session, session, uint16_t(Operation::TrackImportHistory), wire, writer.size()}; + const auto uploaded = files; + uint64_t import_clock = 1000; + const auto poll = [&]() + { + size_t written = 0; + Status status = Status::Busy; + for (unsigned attempt = 0; attempt < 64; ++attempt) + { + status = confirmation::processConfirmation(persistence_author, envelope, import_clock++, output, sizeof(output), written); + if (status != Status::Busy && (status != Status::Ok || (confirmation::controller->work && confirmation::controller->work->result()))) return status; + } + return Status::Busy; + }; + files[std::string(object_path) + "/unknown.bin"] = {1}; + const auto unknown = files; + assert(poll() == Status::Conflict && files == unknown); + confirmation::release(); + files = uploaded; + wire[writer.size() - 1] ^= 1; + assert(poll() == Status::Conflict && files == uploaded); + confirmation::release(); + wire[writer.size() - 1] ^= 1; + ++persistence_author.identity_generation; + assert(poll() == Status::StaleIdentity && files == uploaded); + confirmation::release(); + --persistence_author.identity_generation; + const auto imported_result = poll(); + assert(imported_result == Status::Ok && confirmation::controller->work->result()->revision == 2 && confirmation::controller->work->result()->generation == 2); + const auto adopted = files; + confirmation::release(); // Lost reply/session: exact import recovers without signing or network sends. + assert(poll() == Status::Ok && files == adopted); + // Actual browser retries reupload proof before querying the + // completed controller. The sealed upload slot must not + // survive a matching cached confirmation and block cleanup. + for (const auto* item : {&imported_receipt, &imported_manifest}) + { + const auto kind = item == &imported_receipt ? UploadKind::IpReceipt : UploadKind::Manifest; + upload::owner = upload::createOwner(item->id, session, media, 2, item->author_hash, import_clock, kind); + assert(upload::owner && upload::owner->resources.recover(output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + assert(poll() == Status::Ok && !upload::owner && files == adopted); + } + for (unsigned fault = 0; fault < 7; ++fault) + { + const uint8_t other_session[16] = {71}; + upload::owner = upload::createOwner(imported_receipt.id, fault == 6 ? other_session : session, + media, 2, imported_receipt.author_hash, import_clock, UploadKind::IpReceipt); + assert(upload::owner && upload::owner->resources.recover(output, sizeof(output)) == track_sharing::JournalStoreResult::Ready); + // Fault injection into the actual transient owner: a + // cached success must not discard any mismatched or + // still-active writer, even if its proof ID resembles ours. + auto& metadata = const_cast(upload::owner->resources.metadata()); + if (fault == 0) metadata.id[0] ^= 1; + if (fault == 1) metadata.file_hash[0] ^= 1; + if (fault == 2) metadata.author_hash[0] ^= 1; + if (fault == 3) ++metadata.generation; + if (fault == 4) metadata.phase = track_sharing::UploadPhase::Receiving; + if (fault == 5) upload::owner->discarding = true; + assert(poll() == Status::Ok && upload::owner && files == adopted); + upload::destroyOwner(upload::owner); + upload::owner = nullptr; + } + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready && committed.phase == track_sharing::HeadPhase::Committed && committed.sequence == 1); + confirmation::release(); + // The same signed content may acquire its directory receipt + // after an identity restore. Keep the original signing op. + files = uploaded; + for (const auto& item : cached_files) + if (item.first.rfind(std::string(object_path) + "/", 0) == 0) files[item.first] = item.second; + track_sharing::ObjectHead old_signed, old_verify; + assert(heads.load(old_signed, old_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready && + old_signed.phase == track_sharing::HeadPhase::Signed && old_signed.generation == 1 && old_signed.revision == 2); + const auto pending_signed = files; + assert(poll() == Status::Ok); + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready && + committed.phase == track_sharing::HeadPhase::Committed && committed.generation == 2 && + committed.sequence == old_signed.sequence + 2 && !std::memcmp(committed.operation, old_signed.operation, 16)); + const auto resumed_signed = files; + confirmation::release(); + assert(poll() == Status::Ok && files == resumed_signed); + confirmation::release(); + // Model restart between the durable commit and generation + // adoption: retry must finish the second journal transition. + files = pending_signed; + auto intermediate = old_signed; + ++intermediate.sequence; + intermediate.phase = track_sharing::HeadPhase::Committed; + assert(heads.advance(old_signed, intermediate, old_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready); + assert(poll() == Status::Ok && files == resumed_signed); + confirmation::release(); + // A valid receipt for another hash cannot replace an + // unfinished local version, even with the same author. + files = pending_signed; + auto conflicting_signed = old_signed; + conflicting_signed.hash[0] ^= 1; + assert(track_sharing::encodeObjectHead(conflicting_signed, output, sizeof(output))); + files[old_signed.sequence & 1 ? head_a : head_b] = {output, output + track_sharing::kObjectHeadBytes}; + const auto signed_conflict = files; + assert(poll() == Status::Conflict && files == signed_conflict); + confirmation::release(); + files = adopted; + assert(poll() == Status::Ok); + { + // Restored history must actually permit the next device + // signature, not just display recovered metadata. + uint8_t next_raw[1024], next_kml_id[16] = {48}, next_manifest_id[16] = {49}, next_operation[16] = {50}; + geocaching::protocol::CmpWriter next_writer(next_raw, sizeof(next_raw)); + assert(next_writer.array(14) && next_writer.unsignedInteger(1) && next_writer.binary({prepare_public, 64}) && next_writer.binary({id, 16}) && + next_writer.unsignedInteger(0) && next_writer.unsignedInteger(3) && next_writer.binary(commit.revision_hash) && next_writer.unsignedInteger(0) && + next_writer.unsignedInteger(1) && next_writer.unsignedInteger(3) && next_writer.text("Restored author update") && next_writer.text("")); + assert(next_writer.array(4) && next_writer.unsignedInteger(10000000) && next_writer.unsignedInteger(20000000) && next_writer.unsignedInteger(10000000) && next_writer.unsignedInteger(20000000)); + assert(next_writer.array(4) && next_writer.unsignedInteger(0) && next_writer.nil() && next_writer.nil() && next_writer.unsignedInteger(1)); + assert(next_writer.array(4) && next_writer.binary({xml_hash, 32}) && next_writer.unsignedInteger(xml.size()) && next_writer.text("application/vnd.google-earth.kml+xml") && next_writer.unsignedInteger(1)); + auto next_kml = prepare_kml, next_manifest = imported_manifest; + std::memcpy(next_kml.id, next_kml_id, 16); + next_kml.generation = 2; + std::memcpy(next_manifest.id, next_manifest_id, 16); + next_manifest.total = next_manifest.durable_offset = uint32_t(next_writer.size()); + assert(owner->hash.start() && owner->hash.update(next_raw, next_writer.size()) && owner->hash.finish(next_manifest.file_hash)); + for (const auto* item : {&next_kml, &next_manifest}) + { + const auto kind = item == &next_kml ? UploadKind::Kml : UploadKind::Manifest; + char path[64]; + assert(track_sharing::resolveUploadPath(item->id, 16, track_sharing::UploadFile::Candidate, path, sizeof(path), kind)); + files[path] = item == &next_kml ? std::vector(xml.begin(), xml.end()) : std::vector(next_raw, next_raw + next_writer.size()); + assert(track_sharing::resolveUploadPath(item->id, 16, track_sharing::UploadFile::JournalA, path, sizeof(path), kind)); + assert(track_sharing::encodeUploadJournal(*item, output, sizeof(output))); + files[path] = {output, output + track_sharing::kUploadJournalBytes}; + } + track_sharing::PrepareRequest next_request{{next_operation, 16}, {next_kml_id, 16}, {next_manifest_id, 16}, commit.revision_hash, 2}; + auto* next_input = prepare_runtime::createPrepareOwner(next_request, media, prepare_public, proof_hash); + assert(next_input && next_input->start(output, sizeof(output)) == track_sharing::ManifestFileResult::Pending); + auto checked = track_sharing::ManifestFileResult::Pending; + for (unsigned attempt = 0; attempt < 64 && checked == track_sharing::ManifestFileResult::Pending; ++attempt) checked = next_input->step(2, proof_hash, output, sizeof(output)); + assert(checked == track_sharing::ManifestFileResult::Ready); + auto* next_persistence = prepare_runtime::createPreparePersistence(*next_input); + assert(next_persistence); + PersistenceAuthor restored_author; + restored_author.identity_generation = 2; + Status next_status = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && next_status == Status::Busy; ++attempt) next_status = next_persistence->finish(restored_author, nullptr, output, sizeof(output)); + assert(next_status == Status::Ok && next_persistence->head().revision == 3 && next_persistence->head().generation == 2 && + !std::memcmp(next_persistence->head().object, commit.object.data, 32) && !std::memcmp(next_persistence->head().parent, commit.revision_hash.data, 32) && restored_author.signing_calls == 1); + prepare_runtime::destroyPreparePersistence(next_persistence); + prepare_runtime::destroyPrepareOwner(next_input); + // A completed response cache cannot authorize the old + // version after this object advances to its successor. + assert(poll() == Status::Conflict); + confirmation::release(); + files = adopted; + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready); + } + auto foreign_head = committed; + foreign_head.hash[0] ^= 1; + assert(track_sharing::encodeObjectHead(foreign_head, output, sizeof(output))); + files[head_a] = {output, output + track_sharing::kObjectHeadBytes}; + const auto conflicting = files; + assert(poll() == Status::Conflict && files == conflicting); + confirmation::release(); + files = adopted; + files[head_a][0] ^= 1; + const auto corrupt = files; + assert(poll() == Status::Conflict && files == corrupt); + confirmation::release(); + persistence_author.identity_generation = 1; + files = confirmed_remote; + assert(allocations == 1 && persistence_author.signing_calls == 2); + } + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Ok && admission->ready() && admission->result()->phase == track_sharing::PublicationPhase::Confirmed && admission->manifest()->verified && files == confirmed_remote); + publication::destroyPublicationAdmission(admission); + assert(allocations == 1); + files.erase(receipt_path); + const auto receipt_missing = files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Conflict && !admission->ready() && !admission->result() && files == receipt_missing); + publication::destroyPublicationAdmission(admission); + files = confirmed_remote; + files[receipt_path].back() ^= 1; + const auto receipt_damaged = files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Conflict && !admission->ready() && files == receipt_damaged); + publication::destroyPublicationAdmission(admission); + files = admitted_files; + directory.public_key[0] ^= 1; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Conflict && !admission->ready() && files == admitted_files); + publication::destroyPublicationAdmission(admission); + directory.public_key[0] ^= 1; + files[retained_path][0] ^= 1; + const auto damaged_file = files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::HashMismatch && !admission->ready() && files == damaged_file); + publication::destroyPublicationAdmission(admission); + files = admitted_files; + char job_path[160]; + assert(track_sharing::resolveObjectPath(commit.object, track_sharing::ObjectFile::PublicationA, commit.operation, job_path, sizeof(job_path))); + files.erase(job_path); + const auto orphan_head = files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Conflict && !admission->ready() && files == orphan_head); + publication::destroyPublicationAdmission(admission); + files = cached_files; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + assert(admission->step(persistence_author, output, sizeof(output)) == Status::Busy); + ++media; + assert(admission->step(persistence_author, output, sizeof(output)) == Status::StorageUnavailable && !admission->ready()); + --media; + publication::destroyPublicationAdmission(admission); + assert(allocations == 1 && files == cached_files); + flush_failure = true; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::StorageUnavailable && !admission->ready()); + publication::destroyPublicationAdmission(admission); + assert(heads.load(committed, head_verify, output, sizeof(output)) == track_sharing::HeadStoreResult::Ready && committed.phase == track_sharing::HeadPhase::Signed && committed.revision == 2); + flush_failure = false; + admission = publication::createPublicationAdmission(commit, directory, media, prepare_public, proof_hash); + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) admitted = admission->step(persistence_author, output, sizeof(output)); + assert(admitted == Status::Ok && admission->ready() && admission->result()->phase == track_sharing::PublicationPhase::Accepted); + publication::destroyPublicationAdmission(admission); + assert(allocations == 1); + files = cached_files; + uint8_t commit_body[160]; + geocaching::protocol::CmpWriter commit_writer(commit_body, sizeof(commit_body)); + assert(commit_writer.array(7) && commit_writer.binary(commit.operation) && commit_writer.binary(commit.object) && + commit_writer.binary(commit.revision_hash) && commit_writer.unsignedInteger(1) && commit_writer.unsignedInteger(2) && + commit_writer.binary(commit.directory) && commit_writer.binary(commit.directory_epoch)); + EnvelopeView commit_envelope; + commit_envelope.code = uint16_t(Operation::TrackCommit); + commit_envelope.session = session; + commit_envelope.body = commit_body; + commit_envelope.body_size = commit_writer.size(); + publication::TrackPublicationRequestWork* commit_work = nullptr; + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) + { + admitted = publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 500 + attempt, output, sizeof(output), written); + assert(commit_work); + if (admitted == Status::Busy) assert(!written && commit_work->holdsAdmission() && allocations == 3); + } + assert(admitted == Status::Ok && written && commit_work->result() && !commit_work->holdsAdmission() && allocations == 2); + geocaching::protocol::CmpReader admission_reply({output, written}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView field; + assert(admission_reply.array(fields, 8) && fields == 8 && admission_reply.binary(field, 16) && !std::memcmp(field.data, publication_head.operation, 16) && + admission_reply.binary(field, 32) && !std::memcmp(field.data, publication_head.object, 32) && + admission_reply.binary(field, 32) && !std::memcmp(field.data, publication_head.hash, 32) && + admission_reply.unsignedInteger(value) && value == 1 && admission_reply.unsignedInteger(value) && value == 2 && + admission_reply.binary(field, 16) && !std::memcmp(field.data, directory.delivery, 16) && + admission_reply.binary(field, 16) && !std::memcmp(field.data, directory.epoch, 16) && + admission_reply.unsignedInteger(value) && value == 1 && admission_reply.finished()); + const auto committed_files = files; + uint8_t unrelated_session[16] = {22}; + commit_envelope.session = unrelated_session; + auto* retained_commit = commit_work; + assert(publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 600, output, sizeof(output), written) == Status::Conflict && !written && commit_work == retained_commit && files == committed_files); + commit_envelope.session = session; + persistence_author.read_failure = chat::DeviceAuthorResult::Unavailable; + assert(publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 601, output, sizeof(output), written) == Status::Busy && !written && !commit_work->result()); + persistence_author.read_failure = chat::DeviceAuthorResult::Ok; + assert(publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 602, output, sizeof(output), written) == Status::Ok && written && commit_work->result() && files == committed_files); + assert(publication::advancePublicationRequest(commit_work, nullptr, output, sizeof(output), 603, true) == Status::Busy && !commit_work->result()); + assert(publication::advancePublicationRequest(commit_work, &persistence_author, output, sizeof(output), 604, true) == Status::Ok && commit_work->result()); + assert(publication::advancePublicationRequest(commit_work, &persistence_author, output, sizeof(output), 605, false) == Status::Expired && !commit_work && allocations == 1 && files == committed_files); + commit_envelope.session = unrelated_session; + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) + admitted = publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 700 + attempt, output, sizeof(output), written); + assert(admitted == Status::Ok && written && !commit_work->holdsAdmission() && files == committed_files); + assert(publication::advancePublicationRequest(commit_work, &persistence_author, output, sizeof(output), 99999, true) == Status::Expired && !commit_work && allocations == 1 && files == committed_files); + files = cached_files; + assert(publication::processPublicationRequest(persistence_author, commit_work, commit_envelope, directory, 100000, output, sizeof(output), written) == Status::Busy && !written && commit_work->holdsAdmission() && allocations == 3); + assert(publication::advancePublicationRequest(commit_work, &persistence_author, output, sizeof(output), 100001, false) == Status::Expired && !commit_work && allocations == 1 && files == cached_files); + uint8_t directory_request_body[2] = {0x91, 0}; + EnvelopeView directory_request; + directory_request.code = uint16_t(Operation::TrackDirectoryList); + directory_request.session = session; + directory_request.body = directory_request_body; + directory_request.body_size = sizeof(directory_request_body); + assert(directory_runtime::list(persistence_author, directory_request, 110000, output, sizeof(output), written) == Status::Ok && directory_runtime::owner); + uint8_t directory_metadata[128], discovery_hash[16] = {55}, capabilities_response[256], capability_id[16] = {56}; + geocaching::protocol::CmpWriter advertised(directory_metadata, sizeof(directory_metadata)); + assert(advertised.array(5) && advertised.unsignedInteger(1) && advertised.text(track_sharing::kApplicationType) && advertised.binary({directory.delivery, 16}) && + advertised.binary({directory.epoch, 16}) && advertised.text("Directory")); + chat::lxmf::TrackDirectoryAnnouncementView advertised_view{{discovery_hash, 16}, {directory.delivery, 16}, {directory.public_key, 64}, {directory_metadata, advertised.size()}}; + assert(directory_runtime::owner->pool.observe(advertised_view, 110000)); + geocaching::protocol::CmpWriter caps(capabilities_response, sizeof(capabilities_response)); + assert(caps.array(6) && caps.unsignedInteger(1) && caps.unsignedInteger(1) && caps.unsignedInteger(0) && caps.binary({capability_id, 16}) && caps.unsignedInteger(200) && + caps.array(8) && caps.unsignedInteger(1) && caps.text(track_sharing::kApplicationType) && caps.binary({directory.public_key, 64}) && caps.binary({directory.epoch, 16}) && + caps.unsignedInteger(8388608) && caps.unsignedInteger(1024) && caps.unsignedInteger(32) && caps.array(9)); + for (unsigned op = 0; op < 9; ++op) assert(caps.unsignedInteger(op)); + assert(directory_runtime::owner->pool.confirmCapabilities({directory.delivery, 16}, {directory.epoch, 16}, {capabilities_response, caps.size()}, {capability_id, 16})); + commit_envelope.session = session; + admitted = Status::Busy; + for (unsigned attempt = 0; attempt < 64 && admitted == Status::Busy; ++attempt) + admitted = publication::processCommit(persistence_author, commit_envelope, 110001 + attempt, output, sizeof(output), written); + assert(admitted == Status::Ok && written && publication::controller && publication::controller->execution && !directory_runtime::owner); + assert(publication::processCommit(persistence_author, commit_envelope, 110090, output, sizeof(output), written) == Status::Ok && written); + const auto& active_job = publication::controller->latest; + assert(publication::getStatus(persistence_author, {active_job.operation, 16}, active_job.generation, output, sizeof(output), written) == Status::Ok && written && output[0] == 0x9e); + assert(publication::getStatus(persistence_author, {active_job.operation, 16}, active_job.generation + 1, output, sizeof(output), written) == Status::StaleIdentity && !written); + commit_body[3] ^= 1; + assert(publication::processCommit(persistence_author, commit_envelope, 110091, output, sizeof(output), written) == Status::Conflict && !written); + commit_body[3] ^= 1; + const auto sends_before_disconnect = persistence_author.network_sends; + uint8_t other_session[16]{}; + std::memcpy(other_session, session, 16); + other_session[0] ^= 1; + publication::closeSession(other_session); + assert(publication::controller->request && publication::controller->execution); + publication::closeSession(session); + assert(publication::controller && publication::controller->execution && !publication::controller->request); + publication::disconnect(); + publication::tick(110100); + assert(persistence_author.network_sends == sends_before_disconnect + 1); + assert(publication::cancelStatus(persistence_author, {active_job.operation, 16}, active_job.generation + 1, output, sizeof(output), written) == Status::StaleIdentity && !written); + persistence_author.binding_busy = true; + assert(publication::cancelStatus(persistence_author, {active_job.operation, 16}, active_job.generation, output, sizeof(output), written) == Status::Busy && !written); + persistence_author.binding_busy = false; + assert(publication::cancelStatus(persistence_author, {active_job.operation, 16}, active_job.generation, output, sizeof(output), written) == Status::Ok && written && publication::controller->retiring && active_job.phase == track_sharing::PublicationPhase::Cancelled); + const auto cancelled_sends = persistence_author.network_sends; + publication::tick(110101); + assert(!publication::controller->execution && persistence_author.network_sends == cancelled_sends && !persistence_author.binding); + assert(publication::cancelStatus(persistence_author, {active_job.operation, 16}, active_job.generation, output, sizeof(output), written) == Status::Ok && written); + assert(publication::destroyController() && !publication::controller && !persistence_author.binding && allocations == 1); + } + files = original_files; + } + assert(owner->resources.metadata().phase == track_sharing::UploadPhase::Sealed); + assert(upload::abortOwnedUpload(*owner, session, manifest_id, 201, output, sizeof(output)) == Status::Ok); + upload::destroyOwner(owner); + owner = nullptr; + assert(allocations == 0 && files == saved_uploads); + assert(upload::beginNewUpload(owner, id, session, media, 1, author, sizeof(content), digest, 100, output, sizeof(output)) == Status::Conflict && !owner && allocations == 0 && files == saved_uploads); + digest[31] ^= 1; + assert(upload::recoverUpload(owner, id, session, media, 1, author, sizeof(content), digest, 200, output, sizeof(output)) == Status::Conflict && !owner && allocations == 0 && files == saved_uploads); + digest[31] ^= 1; + assert(upload::recoverUpload(owner, id, session, media, 2, author, sizeof(content), digest, 200, output, sizeof(output)) == Status::Conflict && !owner && allocations == 0); + assert(upload::recoverUpload(owner, id, session, media, 1, author, sizeof(content), digest, 200, output, sizeof(output)) == Status::Ok && owner && owner->resources.metadata().phase == track_sharing::UploadPhase::Sealed && files == saved_uploads); + const std::string candidate = owner->resources.candidatePath(); + upload::destroyOwner(owner); + owner = nullptr; + files[candidate].pop_back(); + assert(upload::recoverUpload(owner, id, session, media, 1, author, sizeof(content), digest, 200, output, sizeof(output)) == Status::StorageUnavailable && owner && owner->discarding && allocations == 1); + assert(upload::abortOwnedUpload(*owner, session, id, 201, output, sizeof(output)) == Status::Ok); + assert(!files.count(candidate)); + upload::destroyOwner(owner); + owner = nullptr; + assert(allocations == 0); + files = saved_uploads; + geocaching::protocol::CmpWriter resume_request(output, sizeof(output)); + assert(resume_request.array(4) && resume_request.binary({id, 16}) && resume_request.unsignedInteger(3) && resume_request.unsignedInteger(sizeof(content)) && resume_request.binary({digest, 32})); + EnvelopeView upload_request; + upload_request.session = session; + upload_request.code = static_cast(Operation::TransferBegin); + upload_request.body = output; + upload_request.body_size = resume_request.size(); + assert(upload::processUploadRequest(upload_request, 1, author, 300, output, sizeof(output), written) == Status::Ok && upload::owner && allocations == 1); + uint8_t unrelated[16] = {9}; + upload::closeUploadSession(unrelated); + assert(upload::owner && allocations == 1); + upload::advanceUploads(output, sizeof(output), 301, false); + assert(!upload::owner && allocations == 0 && files == saved_uploads); + UploadAuthorAdapter adapter; + uint8_t new_id[16] = {8}; + geocaching::protocol::CmpWriter auth_begin(output, sizeof(output)); + assert(auth_begin.array(4) && auth_begin.binary({new_id, 16}) && auth_begin.unsignedInteger(3) && auth_begin.unsignedInteger(sizeof(content)) && auth_begin.binary({digest, 32})); + upload_request.body_size = auth_begin.size(); + assert(upload::routeTrackUpload(upload_request)); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 400, output, sizeof(output), written) == Status::Ok && upload::owner); + geocaching::protocol::CmpWriter auth_chunk(output, sizeof(output)); + assert(auth_chunk.array(3) && auth_chunk.binary({new_id, 16}) && auth_chunk.unsignedInteger(0) && auth_chunk.binary({content, sizeof(content)})); + upload_request.code = static_cast(Operation::TransferChunk); + upload_request.body_size = auth_chunk.size(); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 401, output, sizeof(output), written) == Status::Ok); + assert(files[upload::owner->resources.candidatePath()] == std::vector(content, content + sizeof(content))); + geocaching::protocol::CmpWriter query_status(output, sizeof(output)); + assert(query_status.array(1) && query_status.binary({new_id, 16})); + upload_request.code = static_cast(Operation::TransferStatus); + upload_request.body_size = query_status.size(); + const auto before_status = files; + assert(upload::processAuthenticatedUpload(adapter, upload_request, 402, output, sizeof(output), written) == Status::Ok && upload::owner->resources.metadata().phase == track_sharing::UploadPhase::Receiving && !upload::owner->resources.sealingStarted() && files == before_status); + geocaching::protocol::CmpWriter bad_finish(output, sizeof(output)); + assert(bad_finish.array(1) && bad_finish.binary({new_id, 16})); + upload_request.code = static_cast(Operation::TransferFinish); + upload_request.body_size = bad_finish.size(); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 403, output, sizeof(output), written) == Status::Ok); + files[upload::owner->resources.candidatePath()][100] ^= 1; + upload::advanceUploads(output, sizeof(output), 404, true); + upload::advanceUploads(output, sizeof(output), 405, true); + assert(upload::owner && upload::owner->resources.metadata().phase == track_sharing::UploadPhase::Failed); + const auto failed_files = files; + upload::advanceUploads(output, sizeof(output), 406, false); + assert(!upload::owner && allocations == 0 && files == failed_files); + geocaching::protocol::CmpWriter failed_resume(output, sizeof(output)); + assert(failed_resume.array(4) && failed_resume.binary({new_id, 16}) && failed_resume.unsignedInteger(3) && failed_resume.unsignedInteger(sizeof(content)) && failed_resume.binary({digest, 32})); + upload_request.code = static_cast(Operation::TransferBegin); + upload_request.body_size = failed_resume.size(); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 407, output, sizeof(output), written) == Status::HashMismatch && upload::owner && upload::owner->discarding && files == failed_files); + geocaching::protocol::CmpWriter abort_request(output, sizeof(output)); + assert(abort_request.array(1) && abort_request.binary({new_id, 16})); + upload_request.code = static_cast(Operation::TransferAbort); + upload_request.body_size = abort_request.size(); + remove_fail = true; + assert(upload::processAuthenticatedUpload(adapter, upload_request, 408, output, sizeof(output), written) == Status::StorageUnavailable && upload::owner && upload::owner->discarding); + remove_fail = false; + const std::string abort_candidate = upload::owner->resources.candidatePath(); + char journal_path[64]; + assert(track_sharing::resolveUploadPath(new_id, 16, track_sharing::UploadFile::JournalA, journal_path, sizeof(journal_path))); + remove_failure_path = journal_path; + geocaching::protocol::CmpWriter retry_abort(output, sizeof(output)); + assert(retry_abort.array(1) && retry_abort.binary({new_id, 16})); + upload_request.body_size = retry_abort.size(); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 409, output, sizeof(output), written) == Status::StorageUnavailable && upload::owner && upload::owner->discarding); + assert(!files.count(abort_candidate) && files.count(journal_path)); + geocaching::protocol::CmpWriter discarded_finish(output, sizeof(output)); + assert(discarded_finish.array(1) && discarded_finish.binary({new_id, 16})); + upload_request.code = static_cast(Operation::TransferFinish); + upload_request.body_size = discarded_finish.size(); + const auto partial_files = files; + assert(upload::processAuthenticatedUpload(adapter, upload_request, 410, output, sizeof(output), written) == Status::Conflict && files == partial_files); + remove_failure_path.clear(); + geocaching::protocol::CmpWriter final_abort(output, sizeof(output)); + assert(final_abort.array(1) && final_abort.binary({new_id, 16})); + upload_request.code = static_cast(Operation::TransferAbort); + upload_request.body_size = final_abort.size(); + assert(upload::processAuthenticatedUpload(adapter, upload_request, 411, output, sizeof(output), written) == Status::Ok && written == 1 && output[0] == 0x90); + assert(!upload::owner && allocations == 0); + assert(files == saved_uploads); + files["/trailmate/web-upload/orphan"] = {}; + assert(upload::admitUploadSpace(media, 5, output, sizeof(output)) == Status::LimitExceeded && allocations == 0); + } +} diff --git a/tests/hostlink/test_track_snapshot_selection.cpp b/tests/hostlink/test_track_snapshot_selection.cpp new file mode 100644 index 00000000..a63e4716 --- /dev/null +++ b/tests/hostlink/test_track_snapshot_selection.cpp @@ -0,0 +1,18 @@ +#include "hostlink/track_snapshot_selection.h" +#include +using namespace hostlink::management; +int main() +{ + uint8_t id[16] = {1}, payload[256]; + const auto parse = [&](const char* name, uint64_t bytes, uint64_t format) + {geocaching::protocol::CmpWriter writer(payload,sizeof(payload));assert(writer.array(5)&&writer.binary({id,16})&&writer.unsignedInteger(9)&&writer.text(name)&&writer.unsignedInteger(bytes)&&writer.unsignedInteger(format));TrackSnapshotSelection result;return decodeTrackSnapshotSelection({payload,writer.size()},result); }; + assert(parse("山径.GPX", 750, 1)); + assert(parse("a.kml", 8388608, 0)); + assert(!parse("a.kml", 0, 0)); + assert(!parse("a.kml", 8388609, 0)); + assert(!parse("../a.gpx", 10, 1)); + assert(!parse("ACTIVE.BIN", 10, 3)); + assert(!parse("a.gpx", 10, 2)); + id[0] = 0; + assert(!parse("a.gpx", 10, 1)); +} diff --git a/tests/hostlink/test_track_snapshot_service.cpp b/tests/hostlink/test_track_snapshot_service.cpp new file mode 100644 index 00000000..408a5dad --- /dev/null +++ b/tests/hostlink/test_track_snapshot_service.cpp @@ -0,0 +1,86 @@ +#include "platform/esp/common/storage/track_snapshot_service.h" +#include +#include +#include +using namespace platform::esp::storage; +using namespace hostlink::management; +struct MemoryFile final : RecordFileIo +{ + std::vector data; + size_t position = 0; + std::string path; + bool closed = true, fail_close = false; + OpenStatus state = OpenStatus::Ready; + OpenResult open(const char* name, Mode mode) override + { + assert(mode == Mode::Read); + path = name; + if (state != OpenStatus::Ready) return {nullptr, state}; + closed = false; + position = 0; + return {this, state}; + } + bool close(Handle) override + { + closed = true; + return !fail_close; + } + bool size(Handle, uint64_t& bytes) override + { + bytes = data.size(); + return true; + } + bool seek(Handle, uint64_t offset) override + { + position = size_t(offset); + return position <= data.size(); + } + size_t read(Handle, void* output, size_t bytes) override + { + if (position > data.size()) return 0; + bytes = std::min(bytes, data.size() - position); + std::memcpy(output, data.data() + position, bytes); + position += bytes; + return bytes; + } + size_t write(Handle, const void* input, size_t bytes) override + { + if (position + bytes > data.size()) data.resize(position + bytes); + std::memcpy(data.data() + position, input, bytes); + position += bytes; + return bytes; + } + bool sync(Handle) override { return true; } + bool publish(const char*, const char*) override { return false; } +}; +int main() +{ + MemoryFile io; + TrackSnapshotMetadata metadata; + metadata.id[0] = 1; + metadata.hash[0] = 2; + metadata.bytes = 750; + metadata.format = 1; + assert(writeSnapshotContainerHeader(io, &io, metadata)); + io.data.resize(814); + for (size_t i = 64; i < io.data.size(); ++i) io.data[i] = uint8_t(i); + uint8_t payload[96], output[471]; + const auto request = [&](uint32_t offset, bool hash) + {geocaching::protocol::CmpWriter writer(payload,sizeof(payload));assert(writer.array(4)&&writer.binary({metadata.id,16})&&writer.unsignedInteger(offset)&&writer.unsignedInteger(320));assert(hash?writer.binary({metadata.hash,32}):writer.nil());EnvelopeView view;view.body=payload;view.body_size=writer.size();return view; }; + size_t written = 0; + assert(readTrackSnapshotService(request(0, false), io, output, sizeof(output), written) == Status::Ok && written > 320 && io.closed); + assert(io.path == "/trailmate/web-tracks/01000000000000000000000000000000.tsn"); + assert(readTrackSnapshotService(request(640, true), io, output, sizeof(output), written) == Status::Ok && written < 200 && io.closed); + assert(readTrackSnapshotService(request(750, true), io, output, sizeof(output), written) == Status::InvalidField && written == 0 && io.closed); + metadata.hash[0] = 3; + assert(readTrackSnapshotService(request(320, true), io, output, sizeof(output), written) == Status::Conflict && written == 0 && io.closed); + metadata.hash[0] = 2; + io.state = RecordFileIo::OpenStatus::Missing; + assert(readTrackSnapshotService(request(0, false), io, output, sizeof(output), written) == Status::Expired && written == 0); + io.state = RecordFileIo::OpenStatus::Unavailable; + assert(readTrackSnapshotService(request(0, false), io, output, sizeof(output), written) == Status::StorageUnavailable && written == 0); + io.state = RecordFileIo::OpenStatus::Ready; + io.fail_close = true; + assert(readTrackSnapshotService(request(0, false), io, output, sizeof(output), written) == Status::StorageUnavailable && written == 0 && io.closed); + assert(readTrackSnapshotService(request(1, false), io, output, sizeof(output), written) == Status::InvalidField && written == 0); +} diff --git a/tests/hostlink/test_track_snapshot_session.cpp b/tests/hostlink/test_track_snapshot_session.cpp new file mode 100644 index 00000000..5fe2112d --- /dev/null +++ b/tests/hostlink/test_track_snapshot_session.cpp @@ -0,0 +1,100 @@ +#include "hostlink/track_snapshot_session.h" +#include +using namespace hostlink::management; +struct Storage final : TrackSnapshotStorage +{ + TrackSnapshotMetadata metadata; + unsigned copies = 0, aborts = 0; + bool owned = false, fail_begin = false, fail_copy = false, fail_abort = false, busy_abort = false; + SnapshotStorageResult begin(const uint8_t id[16], uint32_t bytes, uint8_t format) override + { + owned = true; + std::memcpy(metadata.id, id, 16); + metadata.bytes = bytes; + metadata.format = format; + return fail_begin ? SnapshotStorageResult::Failed : SnapshotStorageResult::Ok; + } + SnapshotStorageResult copy(uint32_t, uint8_t*, size_t) override + { + ++copies; + return fail_copy ? SnapshotStorageResult::Failed : SnapshotStorageResult::Ok; + } + SnapshotStorageResult finish(TrackSnapshotMetadata& output) override + { + metadata.sealed = true; + output = metadata; + owned = false; + return SnapshotStorageResult::Ok; + } + SnapshotStorageResult abort() override + { + ++aborts; + if (busy_abort) return SnapshotStorageResult::Busy; + if (fail_abort) return SnapshotStorageResult::Failed; + owned = false; + return SnapshotStorageResult::Ok; + } +}; +int main() +{ + uint8_t session[16] = {1}, other[16] = {2}, id[16] = {3}, scratch[320]; + { + Storage storage; + TrackSnapshotBuilder builder(storage); + SnapshotBuildSession task(builder); + assert(task.begin(session, id, 750, 1, 100) == SnapshotBuildResult::Pending); + assert(!task.releasable() && !task.touch(other, id, 101) && !task.cancel(other, id)); + assert(task.begin(session, id, 750, 1, 101) == SnapshotBuildResult::Busy); + for (unsigned i = 0; i < 3; ++i) task.advance(scratch, sizeof(scratch), 102 + i, true); + assert(task.copiedBytes() == 750 && task.state() == SnapshotSessionState::Building); + task.advance(scratch, sizeof(scratch), 106, true); + assert(task.state() == SnapshotSessionState::Ready && task.metadata().sealed && task.releasable()); + assert(task.cancel(session, id)); + task.advance(scratch, sizeof(scratch), 50000, false); + assert(task.state() == SnapshotSessionState::Ready && storage.aborts == 0); + } + { + Storage storage; + storage.fail_abort = true; + TrackSnapshotBuilder builder(storage); + SnapshotBuildSession task(builder); + assert(task.begin(session, id, 750, 1, 100) == SnapshotBuildResult::Pending); + task.advance(scratch, sizeof(scratch), 101, false); + assert(task.state() == SnapshotSessionState::Cancelling && !task.releasable() && storage.owned); + assert(!task.abandonForStop() && !task.releasable() && storage.owned); + storage.fail_abort = false; + task.advance(scratch, sizeof(scratch), 102, false); + assert(task.state() == SnapshotSessionState::Cancelled && task.releasable() && !storage.owned); + } + { + Storage storage; + storage.busy_abort = true; + TrackSnapshotBuilder builder(storage); + SnapshotBuildSession task(builder); + assert(task.begin(session, id, 750, 1, 0) == SnapshotBuildResult::Pending); + assert(task.touch(session, id, 1000)); + task.advance(scratch, sizeof(scratch), 30000, true); + assert(task.state() == SnapshotSessionState::Building && storage.aborts == 0); + task.advance(scratch, sizeof(scratch), 31000, true); + assert(task.state() == SnapshotSessionState::Cancelling && !task.releasable()); + storage.busy_abort = false; + task.advance(scratch, sizeof(scratch), 31001, true); + assert(task.state() == SnapshotSessionState::Cancelled && task.releasable()); + } + for (bool initialization : {false, true}) + { + Storage storage; + storage.fail_begin = initialization; + storage.fail_copy = !initialization; + storage.fail_abort = true; + TrackSnapshotBuilder builder(storage); + SnapshotBuildSession task(builder); + assert(task.begin(session, id, 750, 1, 0) == SnapshotBuildResult::Pending); + task.advance(scratch, sizeof(scratch), 1, true); + task.advance(scratch, sizeof(scratch), 2, true); + assert(!task.releasable() && storage.owned); + storage.fail_abort = false; + task.advance(scratch, sizeof(scratch), 3, true); + assert(task.state() == SnapshotSessionState::Failed && task.releasable() && !storage.owned); + } +} diff --git a/tests/hostlink/test_track_upload_journal.cpp b/tests/hostlink/test_track_upload_journal.cpp new file mode 100644 index 00000000..8fda1ea5 --- /dev/null +++ b/tests/hostlink/test_track_upload_journal.cpp @@ -0,0 +1,32 @@ +#include "track_sharing/upload_journal.h" +#include +int main() +{ + using namespace track_sharing; + UploadJournal value, decoded; + value.id[0] = 1; + value.author_hash[0] = 2; + value.file_hash[0] = 3; + value.sequence = 0x100000001ULL; + value.generation = 7; + value.total = 8388608; + value.durable_offset = 320; + uint8_t raw[108]; + assert(encodeUploadJournal(value, raw, sizeof(raw)) && decodeUploadJournal(raw, sizeof(raw), decoded)); + assert(decoded.sequence == value.sequence && decoded.total == value.total && decoded.durable_offset == 320); + for (unsigned i = 0; i < sizeof(raw); ++i) + { + raw[i] ^= 1; + assert(!decodeUploadJournal(raw, sizeof(raw), decoded)); + raw[i] ^= 1; + } + value.phase = UploadPhase::Checking; + assert(!encodeUploadJournal(value, raw, sizeof(raw))); + value.durable_offset = value.total; + assert(encodeUploadJournal(value, raw, sizeof(raw))); + value.phase = UploadPhase::Sealed; + assert(encodeUploadJournal(value, raw, sizeof(raw)) && decodeUploadJournal(raw, sizeof(raw), decoded) && decoded.phase == UploadPhase::Sealed); + value.generation = 0; + assert(!encodeUploadJournal(value, raw, sizeof(raw))); + assert(!decodeUploadJournal(raw, sizeof(raw) - 1, decoded)); +} diff --git a/tests/hostlink/test_track_upload_response.cpp b/tests/hostlink/test_track_upload_response.cpp new file mode 100644 index 00000000..58142d54 --- /dev/null +++ b/tests/hostlink/test_track_upload_response.cpp @@ -0,0 +1,269 @@ +#include "track_sharing/network_upload_response.h" +#include "track_sharing/persist_upload_response.h" +#include "track_sharing/upload_reply_receiver.h" +#include "track_sharing/upload_send_step.h" +#include +#include +#include +#include +#include +#include +#include + +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + bool active = false, fail_create = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary && fail_create) return {nullptr, OpenStatus::Unavailable}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool size(void*, uint64_t& out) + { + out = files[path].size(); + return true; + } + size_t read(void*, uint8_t* out, size_t count) + { + count = std::min(count, files[path].size()); + std::memcpy(out, files[path].data(), count); + return count; + } + size_t write(void*, const uint8_t* data, size_t count) + { + files[path].assign(data, data + count); + return count; + } + bool sync(void*) { return true; } + bool close(void*) + { + active = false; + return true; + } +}; + +struct FileIo : Io +{ + size_t offset = 0; + bool seek(void*, uint64_t position) + { + offset = size_t(position); + return offset <= files[path].size(); + } + size_t read(void*, void* out, size_t count) + { + count = std::min(count, files[path].size() - offset); + std::memcpy(out, files[path].data() + offset, count); + offset += count; + return count; + } +}; +struct Sender +{ + bool allowed = true; + FileIo& io; + unsigned calls = 0; + std::vector payload; + chat::MeshSendResult sendTrackData(const uint8_t* directory, chat::lxmf::ByteSpan bytes, const uint8_t*, uint32_t generation, bool response, std::array*) + { + assert(!io.active && directory[0] == 1 && generation == 9 && !response); + ++calls; + payload.assign(bytes.data, bytes.data + bytes.size); + return allowed ? chat::MeshSendResult::success(42) : chat::MeshSendResult::fail(chat::MeshOperationFailure::Busy); + } +}; + +int main(int argc, char** argv) +{ + if (argc != 2) return 2; + auto load = [&](const char* name) + { + std::ifstream input(std::string(argv[1]) + "/" + name, std::ios::binary); + assert(input.good()); + return std::vector(std::istreambuf_iterator(input), {}); + }; + auto begin = load("begin.bin"), ack = load("ack.bin"), sealed = load("sealed.bin"), existing = load("existing.bin"), hash = load("hash.bin"); + std::array request{}, token{}; + request.fill('r'); + const geocaching::ByteView id{request.data(), 16}; + track_sharing::NetworkUploadBegin result; + assert(track_sharing::decodeTrackUploadBegin({begin.data(), begin.size()}, id, 1024, result)); + assert(!result.sealed && result.offset == 0 && result.chunk_bytes == 1024 && result.token.size == 16); + std::memcpy(token.data(), result.token.data, 16); + assert(track_sharing::decodeTrackUploadAck({ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, 1024)); + assert(!track_sharing::decodeTrackUploadAck({ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1023, 1024)); + assert(track_sharing::decodeTrackUploadSealed({sealed.data(), sealed.size()}, id, {hash.data(), hash.size()}, 1024)); + assert(track_sharing::decodeTrackUploadBegin({existing.data(), existing.size()}, id, 1024, result)); + assert(result.sealed && result.offset == 1024 && result.token.size == 0); + { + using namespace track_sharing; + Io io; + PublicationJournalStore store(io, "a", "b"); + PublicationJournal current, next, verify; + std::array scratch{}; + for (const auto field : {geocaching::ByteView{current.operation, 16}, {current.object, 32}, {current.revision_hash, 32}, {current.directory, 16}, {current.directory_epoch, 16}, {current.author_hash, 32}}) + std::memset(const_cast(field.data), 1, field.size); + std::memcpy(current.file_hash, hash.data(), 32); + current.sequence = 1; + current.generation = 9; + current.revision = 1; + current.file_bytes = 1024; + assert(store.initialize(current, verify, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + const auto initial_files = io.files; + const auto initial_job = current; + assert(persistTrackUploadBegin(store, current, next, verify, {begin.data(), begin.size()}, id, result, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 2 && current.phase == PublicationPhase::Uploading && current.offset == 0); + const auto started = io.files; + io.fail_create = true; + assert(persistTrackUploadAck(store, current, next, verify, {ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, scratch.data(), scratch.size()) == PublicationStoreResult::StorageFailure); + assert(current.sequence == 2 && current.offset == 0 && io.files == started); + io.fail_create = false; + assert(persistTrackUploadAck(store, current, next, verify, {ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 3 && current.offset == 1024); + const auto uploaded = io.files; + assert(persistTrackUploadAck(store, current, next, verify, {ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 3 && io.files == uploaded); + assert(persistTrackUploadBegin(store, current, next, verify, {begin.data(), begin.size()}, id, result, scratch.data(), scratch.size()) == PublicationStoreResult::Conflict); + assert(current.offset == 1024 && io.files == uploaded); + assert(persistTrackUploadSealed(store, current, next, verify, {sealed.data(), sealed.size()}, id, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 4 && current.phase == PublicationPhase::FileStored); + const auto stored = io.files; + assert(persistTrackUploadSealed(store, current, next, verify, {sealed.data(), sealed.size()}, id, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(current.sequence == 4 && io.files == stored); + PublicationJournal recovered, other; + assert(store.load(recovered, other, scratch.data(), scratch.size()) == PublicationStoreResult::Ready); + assert(recovered.offset == 1024 && recovered.phase == PublicationPhase::FileStored && recovered.sequence == 4); + io.files = initial_files; + current = initial_job; + struct Lease + { + bool valid = true; + bool holds(const PublicationJournal&) const { return valid; } + } lease; + uint8_t local[16], message_hash[32]{}; + std::memset(local, 2, 16); + UploadReplyReceiver receiver(store, lease, current, next, verify, local, scratch.data(), scratch.size()); + static_assert(sizeof(receiver) <= 160, "Receiver stores bounded metadata only"); + assert(receiver.arm(4, id)); + assert(!receiver.arm(4, id)); + io.files = initial_files; + current = initial_job; + UploadReplyReceiver sending(store, lease, current, next, verify, local, scratch.data(), scratch.size()); + FileIo file_io; + file_io.files["kml"] = std::vector(1024, '\n'); + const char prefix[] = ""; + std::memcpy(file_io.files["kml"].data(), prefix, 6); + Sender sender{true, file_io, 0, {}}; + uint8_t author[64]{}, wire[320]{}, file_buffer[256]{}; + const auto send_step = [&] + { return sendTrackUploadStep(file_io, sending, sender, current, "kml", id, author, wire, sizeof(wire), file_buffer, sizeof(file_buffer)); }; + sender.allowed = false; + assert(send_step() == UploadSendResult::Rejected && !sending.pending() && current.sequence == 1); + sender.allowed = true; + assert(send_step() == UploadSendResult::Queued && sending.pending()); + const auto calls = sender.calls; + assert(send_step() == UploadSendResult::Waiting && sender.calls == calls); + const auto receive_step = [&](const std::vector& bytes) + { + chat::lxmf::CustomDeliveryView incoming{{current.directory, 16}, {local, 16}, {message_hash, 32}, {bytes.data(), bytes.size()}}; + return chat::lxmf::deliverVerifiedTrack(&sending.binding(), false, false, incoming); + }; + assert(receive_step(begin) && current.phase == PublicationPhase::Uploading); + file_io.files["kml"].pop_back(); + assert(send_step() == UploadSendResult::Conflict && !sending.pending() && !file_io.active && current.offset == 0); + file_io.files["kml"].push_back('\n'); + for (uint32_t offset = 0; offset < 1024; offset += 256) + { + assert(send_step() == UploadSendResult::Queued && sending.pending() && current.offset == offset); + geocaching::protocol::CmpReader packet({sender.payload.data(), sender.payload.size()}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView decoded; + assert(packet.array(fields, 6) && fields == 6); + assert(packet.unsignedInteger(value) && value == 1); + assert(packet.unsignedInteger(value) && value == 0); + assert(packet.unsignedInteger(value) && value == 5); + assert(packet.binary(decoded, 16) && decoded.size == 16); + assert(packet.unsignedInteger(value) && value == 8192); + assert(packet.array(fields, 3) && fields == 3); + assert(packet.binary(decoded, 16) && decoded.size == 16 && !std::memcmp(decoded.data, token.data(), 16)); + assert(packet.unsignedInteger(value) && value == offset); + assert(packet.binary(decoded, 256) && decoded.size == 256 && !std::memcmp(decoded.data, file_io.files["kml"].data() + offset, 256) && packet.finished()); + const auto confirmation = load(("ack" + std::to_string(offset + 256) + ".bin").c_str()); + assert(receive_step(confirmation) && current.offset == offset + 256); + } + assert(send_step() == UploadSendResult::Queued && sending.pending()); + assert(receive_step(sealed) && current.phase == PublicationPhase::FileStored); + assert(send_step() == UploadSendResult::FileStored && !sending.pending()); + io.files = initial_files; + current = initial_job; + chat::lxmf::CustomDeliveryView view{{current.directory, 16}, {local, 16}, {message_hash, 32}, {begin.data(), begin.size()}}; + const auto deliver = [&] + { return chat::lxmf::deliverVerifiedTrack(&receiver.binding(), false, false, view); }; + lease.valid = false; + assert(!deliver() && receiver.pending() && current.sequence == 1); + lease.valid = true; + uint8_t wrong[16]{}; + view.source = {wrong, 16}; + assert(!deliver() && current.sequence == 1); + view.source = {current.directory, 16}; + view.destination = {wrong, 16}; + assert(!deliver() && current.sequence == 1); + view.destination = {local, 16}; + assert(deliver() && !receiver.pending() && current.sequence == 2 && receiver.token().size == 16 && receiver.chunkLimit() == 1024); + assert(!receiver.arm(6, id)); + assert(receiver.arm(5, id, 1024)); + view.data = {begin.data(), begin.size()}; + assert(!deliver() && receiver.pending()); + view.data = {ack.data(), ack.size()}; + io.fail_create = true; + assert(!deliver() && receiver.pending() && current.offset == 0); + io.fail_create = false; + assert(deliver() && !receiver.pending() && current.offset == 1024 && current.sequence == 3); + assert(!receiver.arm(5, id, 1)); + assert(receiver.arm(6, id)); + view.data = {sealed.data(), sealed.size()}; + assert(deliver() && !receiver.pending() && current.phase == PublicationPhase::FileStored && current.sequence == 4); + assert(!receiver.arm(4, id)); + } + for (size_t length = 0; length < begin.size(); ++length) + assert(!track_sharing::decodeTrackUploadBegin({begin.data(), length}, id, 1024, result)); + for (size_t length = 0; length < ack.size(); ++length) + assert(!track_sharing::decodeTrackUploadAck({ack.data(), length}, id, {token.data(), 16}, 0, 1024, 1024)); + for (size_t length = 0; length < sealed.size(); ++length) + assert(!track_sharing::decodeTrackUploadSealed({sealed.data(), length}, id, {hash.data(), hash.size()}, 1024)); + request[0] ^= 1; + assert(!track_sharing::decodeTrackUploadBegin({begin.data(), begin.size()}, id, 1024, result)); + assert(!track_sharing::decodeTrackUploadAck({ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, 1024)); + assert(!track_sharing::decodeTrackUploadSealed({sealed.data(), sealed.size()}, id, {hash.data(), hash.size()}, 1024)); + request[0] ^= 1; + token[0] ^= 1; + assert(!track_sharing::decodeTrackUploadAck({ack.data(), ack.size()}, id, {token.data(), 16}, 0, 1024, 1024)); + hash[0] ^= 1; + assert(!track_sharing::decodeTrackUploadSealed({sealed.data(), sealed.size()}, id, {hash.data(), hash.size()}, 1024)); + begin.push_back(0); + assert(!track_sharing::decodeTrackUploadBegin({begin.data(), begin.size()}, id, 1024, result)); +} diff --git a/tests/hostlink/test_upload_chunk_writer.cpp b/tests/hostlink/test_upload_chunk_writer.cpp new file mode 100644 index 00000000..7b097b9d --- /dev/null +++ b/tests/hostlink/test_upload_chunk_writer.cpp @@ -0,0 +1,173 @@ +#include "track_sharing/upload_chunk_writer.h" +#include "track_sharing/upload_seal_task.h" +#include +#include +#include +#include +struct ChunkIo +{ + enum class Mode + { + Read, + Update, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::vector events; + std::string path; + size_t offset = 0; + bool active = false, short_log = false, fail_data_sync = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (mode != Mode::CreateTemporary && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + events.push_back("open:" + path); + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + assert(active); + active = false; + events.push_back("close:" + path); + return true; + } + bool size(void*, uint64_t& value) + { + value = files[path].size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* out, size_t count) + { + count = std::min(count, files[path].size() - offset); + std::memcpy(out, files[path].data() + offset, count); + offset += count; + return count; + } + size_t write(void*, const uint8_t* in, size_t count) + { + if (short_log && path != "data") count /= 2; + files[path].resize(std::max(files[path].size(), offset + count)); + std::memcpy(files[path].data() + offset, in, count); + offset += count; + return count; + } + bool sync(void*) + { + events.push_back("sync:" + path); + return !(fail_data_sync && path == "data"); + } +}; +struct SealCheck +{ + track_sharing::FileIntegrityResult outcome = track_sharing::FileIntegrityResult::Ready; + bool cancelled = false; + unsigned steps = 0; + track_sharing::FileIntegrityResult begin(const char*, uint32_t total, const uint8_t*) + { + assert(total == 640); + return track_sharing::FileIntegrityResult::Pending; + } + track_sharing::FileIntegrityResult step(uint8_t*, size_t) { return ++steps == 1 ? track_sharing::FileIntegrityResult::Pending : outcome; } + void cancel() { cancelled = true; } +}; +int main() +{ + using namespace track_sharing; + ChunkIo io; + UploadJournalStore store(io, "a", "b"); + UploadJournal current, verify, initial; + initial.id[0] = 1; + initial.author_hash[0] = 2; + initial.generation = 1; + initial.total = 640; + initial.sequence = 1; + uint8_t scratch[128], bytes[320]; + std::memset(bytes, 42, sizeof(bytes)); + assert(store.initialize(initial, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + current = initial; + io.files["data"] = {}; + UploadChunkWriter writer(io, store, current, verify, "data", initial.generation, initial.author_hash); + static_assert(sizeof(writer) <= 56, "Chunk writer must borrow all storage"); + io.events.clear(); + initial.generation = 2; + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && io.events.empty()); + initial.generation = 1; + initial.author_hash[31] = 1; + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && io.events.empty()); + initial.author_hash[31] = 0; + io.events.clear(); + io.short_log = true; + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure); + assert(current.durable_offset == 0 && io.files["data"].size() == 320 && !io.active); + const auto synced = std::find(io.events.begin(), io.events.end(), "sync:data"); + assert(synced != io.events.end() && *(synced + 1) == "close:data" && *(synced + 2) == "open:a"); + assert(store.load(current, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && current.sequence == 1); + io.short_log = false; + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(current.sequence == 2 && current.durable_offset == 320); + current = initial; + bytes[0] = 99; + io.events.clear(); + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + assert(io.files["data"][0] == 42 && !io.active); + assert(std::find(io.events.begin(), io.events.end(), "open:data") == io.events.end()); + bytes[0] = 42; + assert(store.load(current, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && current.sequence == 2); + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Ready && current.sequence == 2); + bytes[200] ^= 1; + assert(writer.append(initial.id, 0, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && !io.active); + bytes[200] ^= 1; + assert(writer.append(initial.id, 321, bytes, 1, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + initial.id[0] = 9; + assert(writer.append(initial.id, 320, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + initial.id[0] = 1; + io.fail_data_sync = true; + assert(writer.append(initial.id, 320, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && current.sequence == 2 && !io.active); + io.fail_data_sync = false; + assert(writer.append(initial.id, 320, bytes, 320, scratch, sizeof(scratch)) == JournalStoreResult::Ready && current.durable_offset == 640); + assert(writer.append(initial.id, 0, scratch, 1, scratch, sizeof(scratch)) == JournalStoreResult::Invalid); + for (unsigned mode = 0; mode < 3; ++mode) + { + ChunkIo sealing_io = io; + UploadJournal state = current, checked; + UploadJournalStore sealing_store(sealing_io, "a", "b"); + SealCheck check; + if (mode == 1) check.outcome = FileIntegrityResult::HashMismatch; + uint32_t generation = 1; + UploadSealTask task(check, sealing_store, state, checked, generation, initial.author_hash); + static_assert(sizeof(task) <= 56, "Seal controller must borrow storage"); + assert(task.begin("data", scratch, sizeof(scratch)) == UploadSealResult::Pending && state.phase == UploadPhase::Checking); + assert(task.step(scratch, sizeof(scratch)) == UploadSealResult::Pending); + if (mode == 2) + { + generation = 2; + assert(task.step(scratch, sizeof(scratch)) == UploadSealResult::Conflict && check.cancelled && state.phase == UploadPhase::Checking); + } + else + { + assert(task.step(scratch, sizeof(scratch)) == (mode ? UploadSealResult::HashMismatch : UploadSealResult::Ready)); + assert(state.phase == (mode ? UploadPhase::Failed : UploadPhase::Sealed)); + assert(sealing_store.load(state, checked, scratch, sizeof(scratch)) == JournalStoreResult::Ready && state.phase == (mode ? UploadPhase::Failed : UploadPhase::Sealed)); + } + assert(!sealing_io.active); + } +} diff --git a/tests/hostlink/test_upload_journal_store.cpp b/tests/hostlink/test_upload_journal_store.cpp new file mode 100644 index 00000000..ca2b64b9 --- /dev/null +++ b/tests/hostlink/test_upload_journal_store.cpp @@ -0,0 +1,148 @@ +#include "track_sharing/upload_journal_store.h" +#include +#include +#include +#include +struct Io +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing, + Unavailable + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + size_t offset = 0; + bool active = false, short_write = false, unavailable_b = false, corrupt_write = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (unavailable_b && path == "b") return {nullptr, OpenStatus::Unavailable}; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return true; + } + bool size(void*, uint64_t& value) + { + value = files[path].size(); + return true; + } + bool seek(void*, uint64_t position) + { + offset = size_t(position); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* output, size_t length) + { + length = std::min(length, files[path].size() - offset); + std::memcpy(output, files[path].data() + offset, length); + offset += length; + return length; + } + size_t write(void*, const uint8_t* input, size_t length) + { + if (short_write) length /= 2; + files[path].assign(input, input + length); + if (corrupt_write && !files[path].empty()) files[path][0] ^= 1; + return length; + } + bool sync(void*) { return true; } +}; +int main() +{ + using namespace track_sharing; + Io io; + UploadJournalStore store(io, "a", "b"); + static_assert(sizeof(store) <= 24, "Journal store grew permanent metadata"); + uint8_t scratch[108]; + UploadJournal current, next, verify, loaded; + assert(store.load(current, verify, scratch, sizeof(scratch)) == JournalStoreResult::Missing); + next.id[0] = 1; + next.author_hash[0] = 2; + next.file_hash[0] = 3; + next.generation = 7; + next.total = 1000; + next.sequence = 1; + { + Io fresh; + UploadJournalStore first(fresh, "a", "b"); + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && verify.sequence == 1 && !fresh.active); + const auto saved = fresh.files["a"]; + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && fresh.files["a"] == saved); + fresh.files.clear(); + fresh.files["b"] = {1, 2, 3}; + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && !fresh.files.count("a")); + fresh.files.clear(); + fresh.unavailable_b = true; + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && !fresh.files.count("a")); + fresh.unavailable_b = false; + fresh.short_write = true; + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && !fresh.active); + fresh.short_write = false; + assert(first.initialize(next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + assert(first.initialize(next, next, scratch, sizeof(scratch)) == JournalStoreResult::Invalid); + } + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && !io.active); + current = next; + next.sequence = 2; + next.durable_offset = 320; + assert(store.advance(current, 1001, UploadPhase::Receiving, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + assert(store.advance(current, 320, UploadPhase::Checking, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + assert(store.advance(current, 0, UploadPhase::Receiving, current, scratch, sizeof(scratch)) == JournalStoreResult::Invalid); + io.short_write = true; + assert(store.advance(current, 320, UploadPhase::Receiving, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && current.sequence == 1 && !io.active); + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && !io.active); + assert(store.load(loaded, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && loaded.sequence == 1); + io.short_write = false; + io.corrupt_write = true; + assert(store.advance(current, 320, UploadPhase::Receiving, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && current.durable_offset == 0); + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure); + assert(store.load(loaded, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && loaded.sequence == 1); + io.corrupt_write = false; + assert(store.advance(current, 320, UploadPhase::Receiving, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(verify.sequence == 2 && verify.durable_offset == 320 && current.sequence == 1); + const auto newer_slot = io.files["b"]; + assert(store.commit(current, next, loaded, scratch, sizeof(scratch)) == JournalStoreResult::Conflict && io.files["b"] == newer_slot && !io.active); + assert(store.advance(current, 320, UploadPhase::Receiving, loaded, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + current = next; + next.sequence = 3; + next.durable_offset = 1000; + next.phase = UploadPhase::Sealed; + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + next.phase = UploadPhase::Checking; + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + current = next; + next.sequence = 4; + next.phase = UploadPhase::Sealed; + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + current = next; + assert(store.load(loaded, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && loaded.sequence == 4 && loaded.phase == UploadPhase::Sealed); + next.sequence = 5; + next.author_hash[0] ^= 1; + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + next.author_hash[0] ^= 1; + io.files["b"][5] ^= 1; + assert(store.load(loaded, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready && loaded.sequence == 3); + assert(store.commit(current, next, verify, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + io.unavailable_b = true; + assert(store.load(loaded, verify, scratch, sizeof(scratch)) == JournalStoreResult::StorageFailure && loaded.sequence == 0 && !io.active); +} diff --git a/tests/hostlink/test_upload_paths.cpp b/tests/hostlink/test_upload_paths.cpp new file mode 100644 index 00000000..3e3149b0 --- /dev/null +++ b/tests/hostlink/test_upload_paths.cpp @@ -0,0 +1,36 @@ +#include "track_sharing/upload_paths.h" +#include +#include +int main() +{ + using namespace track_sharing; + uint8_t id[16]; + for (unsigned i = 0; i < 16; ++i) id[i] = uint8_t(i * 17); + char path[64]; + const std::string base = "/trailmate/web-upload/00112233445566778899aabbccddeeff"; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path)) && path == base + ".kml"); + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path)) && path == base + ".uja"); + assert(resolveUploadPath(id, 16, UploadFile::JournalB, path, sizeof(path)) && path == base + ".ujb"); + const auto saved = std::string(path); + assert(uploadByteLimit(UploadKind::Kml) == 8388608 && uploadByteLimit(UploadKind::Manifest) == 4096); + assert(uploadByteLimit(static_cast(5)) == 0); + assert(uploadByteLimit(UploadKind::IpReceipt) == 1100); + assert(uploadDirectory(static_cast(5)) == nullptr); + const std::string receipt = "/trailmate/web-receipt/00112233445566778899aabbccddeeff"; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), UploadKind::IpReceipt) && path == receipt + ".cmp"); + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path), UploadKind::IpReceipt) && path == receipt + ".uja"); + assert(resolveUploadPath(id, 16, UploadFile::JournalB, path, sizeof(path), UploadKind::IpReceipt) && path == receipt + ".ujb"); + assert(resolveUploadPath(id, 16, UploadFile::JournalB, path, sizeof(path)) && path == saved); + assert(!resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), static_cast(5)) && path == saved); + const std::string manifest = "/trailmate/web-manifest/00112233445566778899aabbccddeeff"; + assert(resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path), UploadKind::Manifest) && path == manifest + ".cmp"); + assert(resolveUploadPath(id, 16, UploadFile::JournalA, path, sizeof(path), UploadKind::Manifest) && path == manifest + ".uja"); + assert(resolveUploadPath(id, 16, UploadFile::JournalB, path, sizeof(path), UploadKind::Manifest) && path == manifest + ".ujb"); + assert(resolveUploadPath(id, 16, UploadFile::JournalB, path, sizeof(path)) && path == saved); + assert(!resolveUploadPath(id, 15, UploadFile::Candidate, path, sizeof(path)) && path == saved); + assert(!resolveUploadPath(id, 16, UploadFile::Candidate, path, 63) && path == saved); + assert(!resolveUploadPath(id, 16, static_cast(3), path, sizeof(path)) && path == saved); + assert(!resolveUploadPath(reinterpret_cast(path + 10), 16, UploadFile::Candidate, path, sizeof(path)) && path == saved); + std::memset(id, 0, sizeof(id)); + assert(!resolveUploadPath(id, 16, UploadFile::Candidate, path, sizeof(path)) && path == saved); +} diff --git a/tests/hostlink/test_upload_protocol.cpp b/tests/hostlink/test_upload_protocol.cpp new file mode 100644 index 00000000..d1f448b7 --- /dev/null +++ b/tests/hostlink/test_upload_protocol.cpp @@ -0,0 +1,110 @@ +#include "track_sharing/upload_protocol.h" +#include +#include +int main(int argc, char** argv) +{ + using namespace track_sharing; + uint8_t body[471], id[16] = {1}, hash[32] = {2}, chunk[320] = {3}; + UploadRequest request; + for (const auto kind : {UploadKind::AttachmentIndex, UploadKind::AttachmentResource, UploadKind::AttachmentBindings, UploadKind::SignedManifest}) + { + for (const auto bytes : {uint32_t(0), uint32_t(1), uploadByteLimit(kind), uploadByteLimit(kind) + 1}) + { + geocaching::protocol::CmpWriter writer(body, sizeof(body)); + assert(writer.array(4) && writer.binary({id, 16}) && writer.unsignedInteger(static_cast(kind)) && writer.unsignedInteger(bytes) && writer.binary({hash, 32})); + assert(decodeUploadRequest(UploadCommand::Begin, {body, writer.size()}, request) == (bytes > 0 && bytes <= uploadByteLimit(kind))); + } + } + for (const auto bytes : {uint32_t(1), uint32_t(1100), uint32_t(1101)}) + { + geocaching::protocol::CmpWriter writer(body, sizeof(body)); + assert(writer.array(4) && writer.binary({id, 16}) && writer.unsignedInteger(7) && writer.unsignedInteger(bytes) && writer.binary({hash, 32})); + assert(decodeUploadRequest(UploadCommand::Begin, {body, writer.size()}, request) == (bytes <= 1100)); + if (bytes <= 1100) assert(request.kind == UploadKind::IpReceipt && request.total == bytes); + } + if (argc == 3) + { + const auto command = !std::strcmp(argv[1], "begin") ? UploadCommand::Begin : !std::strcmp(argv[1], "chunk") ? UploadCommand::Chunk + : !std::strcmp(argv[1], "finish") ? UploadCommand::Finish + : static_cast(255); + const size_t length = std::strlen(argv[2]); + if (!length || length % 2 || length > sizeof(body) * 2) return 1; + const auto digit = [](char value) + { return value >= '0' && value <= '9' ? value - '0' : value >= 'a' && value <= 'f' ? value - 'a' + 10 + : -1; }; + for (size_t i = 0; i < length / 2; ++i) + { + const int a = digit(argv[2][i * 2]), b = digit(argv[2][i * 2 + 1]); + if (a < 0 || b < 0) return 1; + body[i] = uint8_t(a * 16 + b); + } + if (!decodeUploadRequest(command, {body, length / 2}, request)) return 1; + std::printf("%u,%u,%u\n", request.total, request.offset, static_cast(request.chunk.size)); + UploadJournal journal; + std::memcpy(journal.id, request.id.data, 16); + journal.author_hash[0] = 1; + journal.sequence = 1; + journal.generation = 1; + journal.total = command == UploadCommand::Begin ? request.total : command == UploadCommand::Chunk ? request.offset + uint32_t(request.chunk.size) + : 640; + journal.durable_offset = command == UploadCommand::Begin ? 0 : journal.total; + journal.phase = command == UploadCommand::Finish ? UploadPhase::Sealed : UploadPhase::Receiving; + size_t size = 0; + if (!encodeUploadProgress(journal, body, sizeof(body), size)) return 1; + for (size_t i = 0; i < size; ++i) std::printf("%02x", body[i]); + std::puts(""); + return 0; + } + geocaching::protocol::CmpWriter begin(body, sizeof(body)); + assert(begin.array(4) && begin.binary({id, 16}) && begin.unsignedInteger(3) && begin.unsignedInteger(8388608) && begin.binary({hash, 32})); + assert(decodeUploadRequest(UploadCommand::Begin, {body, begin.size()}, request) && request.total == 8388608 && request.hash.size == 32); + assert(!decodeUploadRequest(UploadCommand::Begin, {body, begin.size() - 1}, request)); + body[begin.size()] = 0; + assert(!decodeUploadRequest(UploadCommand::Begin, {body, begin.size() + 1}, request)); + for (unsigned mode = 0; mode < 4; ++mode) + { + geocaching::protocol::CmpWriter writer(body, sizeof(body)); + assert(writer.array(4) && writer.binary({id, 16}) && writer.unsignedInteger(mode == 3 ? 5 : 4) && writer.unsignedInteger(mode == 1 ? 4097 : mode == 2 ? 0 + : 4096) && + writer.binary({hash, 32})); + assert(decodeUploadRequest(UploadCommand::Begin, {body, writer.size()}, request) == (mode == 0)); + if (mode == 0) assert(request.kind == UploadKind::Manifest && request.total == 4096); + } + for (unsigned mode = 0; mode < 3; ++mode) + { + geocaching::protocol::CmpWriter writer(body, sizeof(body)); + assert(writer.array(3) && writer.binary({id, 16}) && writer.unsignedInteger(mode == 1 ? 8388608 : mode == 2 ? 8388607 + : 0) && + writer.binary({chunk, 320})); + assert(decodeUploadRequest(UploadCommand::Chunk, {body, writer.size()}, request) == (mode == 0)); + } + geocaching::protocol::CmpWriter finish(body, sizeof(body)); + assert(finish.array(1) && finish.binary({id, 16})); + assert(decodeUploadRequest(UploadCommand::Finish, {body, finish.size()}, request)); + body[3] = 0; + assert(!decodeUploadRequest(UploadCommand::Finish, {body, finish.size()}, request)); + UploadJournal journal; + journal.id[0] = 1; + journal.author_hash[0] = 2; + journal.generation = 1; + journal.sequence = 1; + journal.total = 640; + journal.durable_offset = 640; + size_t written = 0; + for (const auto phase : {UploadPhase::Checking, UploadPhase::Sealed, UploadPhase::Failed}) + { + journal.phase = phase; + const bool ok = encodeUploadProgress(journal, body, sizeof(body), written); + assert(ok == (phase != UploadPhase::Failed)); + if (!ok) + { + assert(!written); + continue; + } + geocaching::protocol::CmpReader reader({body, written}); + size_t fields; + geocaching::ByteView received; + uint64_t value; + assert(reader.array(fields, 4) && reader.binary(received, 16) && reader.unsignedInteger(value) && value == 320 && reader.unsignedInteger(value) && value == 640 && reader.unsignedInteger(value) && value == (phase == UploadPhase::Sealed ? 1 : 0) && reader.finished()); + } +} diff --git a/tests/hostlink/test_upload_seal_sha256.cpp b/tests/hostlink/test_upload_seal_sha256.cpp new file mode 100644 index 00000000..3ef339ec --- /dev/null +++ b/tests/hostlink/test_upload_seal_sha256.cpp @@ -0,0 +1,245 @@ +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "track_sharing/upload_chunk_writer.h" +#include "track_sharing/upload_resources.h" +#include "track_sharing/upload_seal_task.h" +#include "track_sharing/upload_session.h" +#include "track_sharing/uploaded_manifest_reader.h" +#include +#include +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* pointer, size_t length) +{ + auto* bytes = static_cast(pointer); + while (length--) *bytes++ = 0; +} +#endif +struct SealIo +{ + enum class Mode + { + Read, + Update, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path; + size_t position = 0; + bool active = false, short_log = false; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + position = 0; + if (mode != Mode::CreateTemporary && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + active = false; + return true; + } + bool size(void*, uint64_t& value) + { + value = files[path].size(); + return true; + } + bool seek(void*, uint64_t value) + { + position = size_t(value); + return position <= files[path].size(); + } + size_t read(void*, uint8_t* output, size_t count) + { + assert(count <= 320); + count = std::min(count, files[path].size() - position); + std::memcpy(output, files[path].data() + position, count); + position += count; + return count; + } + size_t write(void*, const uint8_t* input, size_t count) + { + if (short_log && path != "data") count /= 2; + files[path].resize(std::max(files[path].size(), position + count)); + std::memcpy(files[path].data() + position, input, count); + position += count; + return count; + } + bool sync(void*) { return true; } +}; +int main() +{ + using namespace track_sharing; + const std::string content(750, 'x'); + mbedtls_sha256_context context; + platform::esp::storage::SnapshotStreamSha256 hash(context); + uint8_t digest[32], scratch[320], author[32] = {2}; + uint32_t generation = 7; + assert(hash.start() && hash.update(reinterpret_cast(content.data()), content.size()) && hash.finish(digest)); + for (unsigned mode = 0; mode < 6; ++mode) + { + SealIo io; + io.files["manifest"] = std::vector(content.begin(), content.end()); + UploadJournal journal; + journal.id[0] = 1; + journal.sequence = 1; + journal.generation = generation; + journal.total = journal.durable_offset = uint32_t(content.size()); + journal.phase = UploadPhase::Sealed; + std::memcpy(journal.author_hash, author, 32); + std::memcpy(journal.file_hash, digest, 32); + uint8_t raw[4096]{}; + UploadedManifestReader receipt_reader(io, hash, journal, generation, author); + static_assert(sizeof(receipt_reader) == sizeof(UploadedManifestReader), "Receipt bound must not add runtime storage"); + auto oversized = journal; + oversized.total = oversized.durable_offset = 1101; + UploadedManifestReader rejected(io, hash, oversized, generation, author); + assert(rejected.begin("manifest", raw, sizeof(raw)) == FileIntegrityResult::Invalid && !io.active); + UploadedManifestReader reader(io, hash, journal, generation, author); + static_assert(sizeof(reader) <= 128, "Reader must borrow manifest storage"); + assert(reader.begin("manifest", raw, sizeof(raw)) == FileIntegrityResult::Pending && !reader.bytes().size); + if (mode >= 3) + { + const auto preserved = io.files; + const auto saved_journal = journal; + uint8_t* invalid_scratch = mode == 3 ? reinterpret_cast(&reader) : mode == 4 ? reinterpret_cast(&journal) + : author; + assert(reader.step(invalid_scratch, 32) == FileIntegrityResult::Invalid && !reader.bytes().size && !io.active); + assert(io.files == preserved && !std::memcmp(&saved_journal, &journal, sizeof(journal)) && author[0] == 2 && generation == 7); + continue; + } + if (mode == 1) io.files["manifest"][500] ^= 1; + assert(reader.step(scratch, sizeof(scratch)) == FileIntegrityResult::Pending && !io.active); + if (mode == 2) ++generation; + FileIntegrityResult result; + do + { + result = reader.step(scratch, sizeof(scratch)); + assert(!io.active); + } while (result == FileIntegrityResult::Pending); + assert(result == (mode == 0 ? FileIntegrityResult::Ready : mode == 1 ? FileIntegrityResult::HashMismatch + : FileIntegrityResult::Invalid)); + if (mode == 0) + { + assert(reader.bytes().size == content.size() && !std::memcmp(raw, content.data(), content.size())); + ++generation; + assert(!reader.bytes().size); + --generation; + } + else + { + assert(!reader.bytes().size); + for (size_t i = 0; i < content.size(); ++i) assert(raw[i] == 0); + } + if (mode == 2) --generation; + } + for (const auto kind : {UploadKind::Kml, UploadKind::Manifest, UploadKind::IpReceipt}) + { + SealIo io; + uint8_t id[16] = {5}; + using Resources = UploadResources; + static_assert(sizeof(Resources) <= 768, "Upload resource aggregate exceeded transient admission"); + Resources owner(io, hash, id, generation, author, kind); + assert(std::string(owner.candidatePath()).find(uploadDirectory(kind)) == 0); + assert(owner.recover(scratch, sizeof(scratch)) == JournalStoreResult::Missing); + io.files[owner.candidatePath()] = {}; + if (kind != UploadKind::Kml) + { + const auto unchanged = io.files; + assert(owner.initialize(uploadByteLimit(kind) + 1, digest, scratch, sizeof(scratch)) == JournalStoreResult::Invalid); + assert(io.files == unchanged && !io.active); + } + assert(owner.initialize(750, digest, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + uint8_t session_id[16] = {1}, other_session[16] = {2}; + UploadSession session(owner, session_id, 9, 100); + assert(session.append(other_session, 9, 101, id, 0, reinterpret_cast(content.data()), 320, scratch, sizeof(scratch)) == JournalStoreResult::Conflict); + assert(io.files[owner.candidatePath()].empty()); + for (uint32_t offset = 0; offset < 750; offset += 320) + assert(session.append(session_id, 9, 102, id, offset, reinterpret_cast(content.data() + offset), std::min(size_t(320), 750 - offset), scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(session.beginSeal(session_id, 9, 103, scratch, sizeof(scratch)) == UploadSealResult::Pending); + assert(session.advance(9, 104, true, scratch, sizeof(scratch)) == UploadSealResult::Pending); + const auto saved_files = io.files; + assert(session.advance(9, 105, false, scratch, sizeof(scratch)) == UploadSealResult::Cancelled && session.releasable()); + assert(io.files == saved_files && owner.metadata().phase == UploadPhase::Checking && !io.active); + assert(session.beginSeal(session_id, 9, 106, scratch, sizeof(scratch)) == UploadSealResult::Conflict); + Resources resumed(io, hash, id, generation, author, kind); + assert(resumed.recover(scratch, sizeof(scratch)) == JournalStoreResult::Ready && resumed.metadata().phase == UploadPhase::Checking); + UploadSession new_session(resumed, other_session, 9, 200); + assert(new_session.beginSeal(other_session, 9, 201, scratch, sizeof(scratch)) == UploadSealResult::Pending); + UploadSealResult result; + do + { + result = new_session.advance(9, 202, true, scratch, sizeof(scratch)); + assert(!io.active); + } while (result == UploadSealResult::Pending); + assert(result == UploadSealResult::Ready && resumed.metadata().phase == UploadPhase::Sealed); + Resources recovered(io, hash, id, generation, author, kind); + assert(recovered.recover(scratch, sizeof(scratch)) == JournalStoreResult::Ready && recovered.metadata().phase == UploadPhase::Sealed); + generation = 8; + Resources wrong(io, hash, id, generation, author, kind); + assert(wrong.recover(scratch, sizeof(scratch)) == JournalStoreResult::Conflict && wrong.metadata().sequence == 0); + generation = 7; + } + for (unsigned mode = 0; mode < 3; ++mode) + { + SealIo io; + io.files["data"] = {}; + UploadJournal current, verify; + current.id[0] = 1; + std::memcpy(current.author_hash, author, 32); + std::memcpy(current.file_hash, digest, 32); + current.generation = generation; + current.total = 750; + current.sequence = 1; + UploadJournalStore store(io, "a", "b"); + assert(store.initialize(current, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + UploadChunkWriter writer(io, store, current, verify, "data", generation, author); + for (uint32_t offset = 0; offset < 750; offset += 320) + assert(writer.append(current.id, offset, reinterpret_cast(content.data() + offset), std::min(size_t(320), 750 - offset), scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(io.files["data"] == std::vector(content.begin(), content.end())); + if (mode == 1) io.files["data"][500] ^= 1; + using Check = FileIntegrityCheck; + using Task = UploadSealTask; + Check check(io, hash); + Task task(check, store, current, verify, generation, author); + assert(task.begin("data", scratch, sizeof(scratch)) == UploadSealResult::Pending && current.phase == UploadPhase::Checking); + if (mode == 2) io.short_log = true; + UploadSealResult result; + do + { + result = task.step(scratch, sizeof(scratch)); + assert(!io.active); + } while (result == UploadSealResult::Pending); + assert(result == (mode == 0 ? UploadSealResult::Ready : mode == 1 ? UploadSealResult::HashMismatch + : UploadSealResult::StorageFailure)); + assert(store.load(current, verify, scratch, sizeof(scratch)) == JournalStoreResult::Ready); + assert(current.phase == (mode == 0 ? UploadPhase::Sealed : mode == 1 ? UploadPhase::Failed + : UploadPhase::Checking)); + if (mode == 2) + { + io.short_log = false; + Check resumed(io, hash); + Task retry(resumed, store, current, verify, generation, author); + assert(retry.begin("data", scratch, sizeof(scratch)) == UploadSealResult::Pending); + do + { + result = retry.step(scratch, sizeof(scratch)); + assert(!io.active); + } while (result == UploadSealResult::Pending); + assert(result == UploadSealResult::Ready && current.phase == UploadPhase::Sealed); + } + } +} diff --git a/tests/hostlink/test_upload_session.cpp b/tests/hostlink/test_upload_session.cpp new file mode 100644 index 00000000..e1a0a12b --- /dev/null +++ b/tests/hostlink/test_upload_session.cpp @@ -0,0 +1,54 @@ +#include "track_sharing/upload_session.h" +#include +struct Owner +{ + unsigned cancellations = 0, steps = 0, writes = 0; + track_sharing::JournalStoreResult write_result = track_sharing::JournalStoreResult::Ready; + void cancel() { ++cancellations; } + track_sharing::UploadSealResult step(uint8_t*, size_t) + { + ++steps; + return track_sharing::UploadSealResult::Pending; + } + track_sharing::UploadSealResult beginSeal(uint8_t*, size_t) { return track_sharing::UploadSealResult::Pending; } + track_sharing::JournalStoreResult append(const uint8_t*, uint32_t, const uint8_t*, size_t, uint8_t*, size_t) + { + ++writes; + return write_result; + } +}; +int main() +{ + using namespace track_sharing; + uint8_t session[16] = {1}, other[16] = {2}, zero[16]{}, scratch[128]; + { + Owner rejected;rejected.write_result=JournalStoreResult::Conflict; + UploadSession lease(rejected,session,7,100); + assert(lease.matches(session,7,30099)); + assert(lease.append(session,7,30099,session,0,session,1,scratch,sizeof(scratch))==JournalStoreResult::Conflict); + assert(lease.advance(7,30100,true,scratch,sizeof(scratch))==UploadSealResult::Cancelled&&lease.releasable()); + } + for (unsigned mode = 0; mode < 4; ++mode) + { + Owner owner; + UploadSession task(owner, session, 7, 100); + static_assert(sizeof(task) <= 48, "Session must not own payloads"); + assert(!task.authorize(other, 7, 101) && !task.close(other)); + assert(task.append(session, 7, 102, session, 0, session, 1, scratch, sizeof(scratch)) == JournalStoreResult::Ready && owner.writes == 1); + assert(task.beginSeal(session, 7, 103, scratch, sizeof(scratch)) == UploadSealResult::Pending); + assert(task.advance(7, 104, true, scratch, sizeof(scratch)) == UploadSealResult::Pending && owner.steps == 1); + const auto result = task.advance(mode == 1 ? 8 : 7, mode == 2 ? 30103 : mode == 3 ? 102 + : 105, + mode != 0, scratch, sizeof(scratch)); + assert(result == UploadSealResult::Cancelled && owner.cancellations == 1 && task.releasable()); + assert(!task.authorize(session, 7, 106) && owner.cancellations == 1); + } + Owner owner; + UploadSession task(owner, session, 7, 100); + assert(!task.authorize(other, 7, 30099)); + assert(task.advance(7, 30100, true, scratch, sizeof(scratch)) == UploadSealResult::Cancelled); + UploadSession invalid(owner, zero, 7, 100); + assert(invalid.releasable() && !invalid.authorize(zero, 7, 100)); + UploadSession closed(owner, session, 7, 100); + assert(closed.close(session) && closed.releasable() && !closed.close(session)); +} diff --git a/tests/hostlink/test_upload_space_budget.cpp b/tests/hostlink/test_upload_space_budget.cpp new file mode 100644 index 00000000..caf73c66 --- /dev/null +++ b/tests/hostlink/test_upload_space_budget.cpp @@ -0,0 +1,39 @@ +#include "track_sharing/upload_space_budget.h" +#include +int main() +{ + using track_sharing::UploadKind; + using track_sharing::UploadSpaceBudget; + UploadSpaceBudget resources(1, UploadKind::AttachmentResource); + for (unsigned i = 0; i < 189; ++i) assert(resources.add(0, false)); + assert(resources.complete() && !resources.add(0, false)); + UploadSpaceBudget resource_bytes(1, UploadKind::AttachmentResource); + assert(resource_bytes.add(64ULL * 1024 * 1024 + 63 * 216 - 1, false)); + assert(!resource_bytes.add(1, false)); + UploadSpaceBudget index(294912, UploadKind::AttachmentIndex), index_large(294913, UploadKind::AttachmentIndex); + assert(index.add(294912, false) && index.add(108, false) && index.add(108, false) && !index.add(0, false)); + assert(!index_large.complete()); + UploadSpaceBudget signed_manifest(4166,UploadKind::SignedManifest),signed_large(4167,UploadKind::SignedManifest); + assert(signed_manifest.add(4166,false)&&signed_manifest.add(108,false)&&signed_manifest.add(108,false)&&!signed_manifest.add(0,false)); + assert(!signed_large.complete()); + UploadSpaceBudget full(8388608); + assert(full.add(8388608, false) && full.add(108, false) && full.add(108, false) && full.complete()); + assert(!full.add(0, false) && !full.complete()); + UploadSpaceBudget excess(8388608); + assert(!excess.add(8388608 + 217, false)); + UploadSpaceBudget directory(1); + assert(!directory.add(0, true)); + UploadSpaceBudget zero(0), large(8388609); + assert(!zero.complete() && !large.complete()); + UploadSpaceBudget overflow(1); + assert(!overflow.add(UINT64_MAX, false)); + UploadSpaceBudget manifest(4096, 4096); + assert(manifest.add(4096, false) && manifest.add(108, false) && manifest.add(108, false) && manifest.complete()); + assert(!manifest.add(0, false)); + UploadSpaceBudget manifest_excess(4096, 4096), invalid_limit(1, 4097), manifest_large(4097, 4096); + assert(!manifest_excess.add(4096 + 217, false)); + assert(!invalid_limit.complete() && !manifest_large.complete()); + UploadSpaceBudget receipt(1100, 1100), receipt_large(1101, 1100); + assert(receipt.add(1100, false) && receipt.add(108, false) && receipt.add(108, false) && receipt.complete()); + assert(!receipt.add(0, false) && !receipt_large.complete()); +} diff --git a/tests/hostlink/test_verify_ip_confirmation.cpp b/tests/hostlink/test_verify_ip_confirmation.cpp new file mode 100644 index 00000000..5c35bbd0 --- /dev/null +++ b/tests/hostlink/test_verify_ip_confirmation.cpp @@ -0,0 +1,210 @@ +#include "geocaching/protocol/cmp_writer.h" +#include "platform/esp/common/storage/snapshot_stream_sha256.h" +#include "platform/esp/common/storage/track_manifest_crypto.h" +#include "platform/esp/common/storage/track_receipt_crypto.h" +#include "track_confirmation_memory_io.h" +#include "track_sharing/ip_confirmation_work.h" +#include "track_sharing/verify_ip_confirmation.h" +#include +#include +#if defined(_WIN32) +extern "C" void mbedtls_platform_zeroize(void* buffer, size_t length) +{ + auto* bytes = static_cast(buffer); + while (length--) *bytes++ = 0; +} +#endif +static uint8_t author[64], author_hash[32], directory[64], secret[64], delivery[32], name_hash[32], id_hash[32], joined[26]; +static uint8_t raw[1024], signed_bytes[1100], domain[1100], workspace[1200], signature[64], proof_hash[32], file_hash[32]; +static uint8_t operation[16] = {1}, transfer[16] = {2}, epoch[16] = {3}, network_request[16] = {4}; +static mbedtls_sha256_context context; +static uint8_t local_raw[4096], local_signed[4166], author_secret[64], work_signed[4166], work_receipt[1100], work_domain[4166], work_scratch[448]; +int main() +{ + using namespace track_sharing; + platform::esp::storage::SnapshotStreamSha256 hash(context); + const auto digest = [&](geocaching::ByteView source, uint8_t* output) + { + assert(hash.start() && hash.update(source.data, source.size) && hash.finish(output)); + hash.reset(); + }; + std::memset(author, 7, 64); + std::memset(directory, 8, 32); + std::memset(file_hash, 9, 32); + uint8_t seed[32] = {10}; + ed25519_create_keypair(directory + 32, secret, seed); + seed[0] = 11; + ed25519_create_keypair(author + 32, author_secret, seed); + digest({author, 64}, author_hash); + digest({directory, 64}, id_hash); + constexpr char service[] = "lxmf.delivery"; + digest({reinterpret_cast(service), sizeof(service) - 1}, name_hash); + std::memcpy(joined, name_hash, 10); + std::memcpy(joined + 10, id_hash, 16); + digest({joined, 26}, delivery); + VerifiedManifestView manifest; + manifest.verified = true; + manifest.manifest.author = {author, 64}; + manifest.manifest.revision = 1; + manifest.manifest.file_hash = {file_hash, 32}; + manifest.manifest.file_bytes = 500; + std::memset(manifest.object_id, 11, 32); + std::memset(manifest.revision_hash, 12, 32); + geocaching::protocol::CmpWriter local_writer(local_raw, sizeof(local_raw)); + assert(local_writer.array(14) && local_writer.unsignedInteger(1) && local_writer.binary({author, 64}) && local_writer.binary({transfer, 16}) && + local_writer.unsignedInteger(0) && local_writer.unsignedInteger(1) && local_writer.nil() && local_writer.unsignedInteger(0) && + local_writer.unsignedInteger(1000) && local_writer.unsignedInteger(1000) && local_writer.text("Hardware-independent confirmation") && local_writer.text("")); + assert(local_writer.array(4)); + for (unsigned i = 0; i < 4; ++i) assert(local_writer.unsignedInteger(0)); + assert(local_writer.array(4) && local_writer.unsignedInteger(0) && local_writer.nil() && local_writer.nil() && local_writer.unsignedInteger(1) && + local_writer.array(4) && local_writer.binary({file_hash, 32}) && local_writer.unsignedInteger(500) && + local_writer.text("application/vnd.google-earth.kml+xml") && local_writer.unsignedInteger(1)); + constexpr char local_prefix[] = "TrailMate.Track.Manifest.v1"; + std::memcpy(domain, local_prefix, sizeof(local_prefix)); + std::memcpy(domain + sizeof(local_prefix), local_raw, local_writer.size()); + ed25519_sign(signature, domain, sizeof(local_prefix) + local_writer.size(), author + 32, author_secret); + geocaching::protocol::CmpWriter local_envelope(local_signed, sizeof(local_signed)); + assert(local_envelope.array(2) && local_envelope.binary({local_raw, local_writer.size()}) && local_envelope.binary({signature, 64})); + platform::esp::storage::TrackManifestCrypto manifest_crypto; + assert(verifyManifest({local_signed, local_envelope.size()}, manifest_crypto, hash, work_domain, sizeof(work_domain), manifest, {author, 64}) == ManifestVerification::Valid); + geocaching::protocol::CmpWriter writer(raw, sizeof(raw)); + assert(writer.array(13) && writer.unsignedInteger(1) && writer.binary({network_request, 16}) && writer.binary({manifest.object_id, 32}) && + writer.binary({manifest.revision_hash, 32}) && writer.unsignedInteger(1) && writer.unsignedInteger(0) && writer.binary({file_hash, 32}) && + writer.unsignedInteger(500) && writer.binary({directory, 64}) && writer.binary({delivery, 16}) && writer.binary({epoch, 16}) && + writer.unsignedInteger(1) && writer.unsignedInteger(5000000000ULL)); + const auto raw_size = writer.size(); + constexpr char prefix[] = "TrailMate.Track.Receipt.v1"; + std::memcpy(domain, prefix, sizeof(prefix)); + std::memcpy(domain + sizeof(prefix), raw, raw_size); + ed25519_sign(signature, domain, sizeof(prefix) + raw_size, directory + 32, secret); + geocaching::protocol::CmpWriter envelope(signed_bytes, sizeof(signed_bytes)); + assert(envelope.array(2) && envelope.binary({raw, raw_size}) && envelope.binary({signature, 64})); + const geocaching::ByteView receipt{signed_bytes, envelope.size()}; + digest(receipt, proof_hash); + IpConfirmationRequest request{{operation, 16}, {transfer, 16}, {proof_hash, 32}, {directory, 64}, {delivery, 16}, {epoch, 16}, {network_request, 16}, {manifest.object_id, 32}, {manifest.revision_hash, 32}, 1, 1}; + PublicationJournal expected; + VerifiedReceiptView proof; + platform::esp::storage::TrackReceiptCrypto crypto; + const auto check = [&] + { return verifyIpConfirmation(request, receipt, manifest, 1, {author, 64}, {author_hash, 32}, crypto, hash, + workspace, sizeof(workspace), expected, proof); }; + assert(check() == ReceiptVerification::Valid && proof.verified && publicationJournalValid(expected)); + assert(!std::memcmp(expected.operation, operation, 16) && proof.receipt.accepted == 5000000000ULL); + request.generation = 2; + assert(check() == ReceiptVerification::BindingMismatch && !proof.verified); + request.generation = 1; + author_hash[0] ^= 1; + assert(check() == ReceiptVerification::BindingMismatch); + author_hash[0] ^= 1; + proof_hash[0] ^= 1; + assert(check() == ReceiptVerification::BindingMismatch); + proof_hash[0] ^= 1; + delivery[0] ^= 1; + assert(check() == ReceiptVerification::BindingMismatch); + delivery[0] ^= 1; + epoch[0] ^= 1; + assert(check() == ReceiptVerification::BindingMismatch && !proof.verified); + epoch[0] ^= 1; + network_request[0] ^= 1; + assert(check() == ReceiptVerification::BindingMismatch); + network_request[0] ^= 1; + // Rehashing corrupted signature bytes cannot establish directory authority. + signed_bytes[receipt.size - 1] ^= 1; + digest(receipt, proof_hash); + assert(check() == ReceiptVerification::InvalidSignature && !proof.verified && !expected.sequence); + signed_bytes[receipt.size - 1] ^= 1; + digest(receipt, proof_hash); + assert(check() == ReceiptVerification::Valid); + assert(verifyIpConfirmation(request, receipt, manifest, 1, {author, 64}, {author_hash, 32}, crypto, hash, + signed_bytes, sizeof(signed_bytes), expected, proof) == ReceiptVerification::WorkspaceTooSmall); + + TrackConfirmationMemoryIo baseline; + char head_a[160], head_b[160], reservation_path[160], signed_path[160], receipt_path[160], receipt_temp[160], upload_path[64], upload_a[64]; + assert(resolveObjectPath(request.object, ObjectFile::HeadA, {}, head_a, sizeof(head_a)) && + resolveObjectPath(request.object, ObjectFile::HeadB, {}, head_b, sizeof(head_b)) && + resolveObjectPath(request.object, ObjectFile::ReservationA, request.operation, reservation_path, sizeof(reservation_path)) && + resolveObjectPath(request.object, ObjectFile::SignedManifest, request.revision_hash, signed_path, sizeof(signed_path)) && + resolveObjectPath(request.object, ObjectFile::Receipt, request.network_request, receipt_path, sizeof(receipt_path)) && + resolveObjectPath(request.object, ObjectFile::ReceiptTemporary, request.network_request, receipt_temp, sizeof(receipt_temp)) && + resolveUploadPath(transfer, 16, UploadFile::Candidate, upload_path, sizeof(upload_path), UploadKind::IpReceipt) && + resolveUploadPath(transfer, 16, UploadFile::JournalA, upload_a, sizeof(upload_a), UploadKind::IpReceipt)); + RevisionReservation reservation; + std::memcpy(reservation.operation, operation, 16); + std::memcpy(reservation.object, manifest.object_id, 32); + std::memcpy(reservation.author, author_hash, 32); + std::memcpy(reservation.candidate, manifest.revision_hash, 32); + reservation.generation = reservation.revision = reservation.sequence = 1; + reservation.phase = ReservationPhase::Signed; + ObjectHead initial; + assert(createSignedObjectHead(manifest, reservation, 1, {author, 64}, {author_hash, 32}, nullptr, initial)); + assert(encodeObjectHead(initial, work_scratch, sizeof(work_scratch))); + baseline.files[head_a] = {work_scratch, work_scratch + kObjectHeadBytes}; + assert(encodeReservation(reservation, work_scratch, sizeof(work_scratch))); + baseline.files[reservation_path] = {work_scratch, work_scratch + kReservationBytes}; + UploadJournal upload; + std::memcpy(upload.id, transfer, 16); + std::memcpy(upload.author_hash, author_hash, 32); + std::memcpy(upload.file_hash, proof_hash, 32); + upload.generation = upload.sequence = 1; + upload.total = upload.durable_offset = uint32_t(receipt.size); + upload.phase = UploadPhase::Sealed; + assert(encodeUploadJournal(upload, work_scratch, sizeof(work_scratch))); + baseline.files[upload_a] = {work_scratch, work_scratch + kUploadJournalBytes}; + baseline.files[upload_path] = {receipt.data, receipt.data + receipt.size}; + baseline.files[signed_path] = {local_signed, local_signed + local_envelope.size()}; + using Work = IpConfirmationWork; + static_assert(sizeof(Work) <= 4608, "Confirmation metadata must remain a bounded transient owner"); + for (unsigned mode = 0; mode < 7; ++mode) + { + auto io = baseline; + if (mode == 1) io.files[upload_path][100] ^= 1; + if (mode == 2) io.files[signed_path][100] ^= 1; + if (mode == 5) io.fail_create = receipt_temp; + if (mode == 6) io.fail_create = head_b; + const auto before = io.files; + auto work = std::make_unique(io, hash, manifest_crypto, crypto, request, geocaching::ByteView{author, 64}, + geocaching::ByteView{author_hash, 32}, work_signed, sizeof(work_signed), work_receipt, sizeof(work_receipt), work_domain, sizeof(work_domain)); + // Constructor freezes the network request before another task can alter input. + if (mode == 0) network_request[0] ^= 1; + auto result = IpConfirmationResult::Pending; + for (unsigned step = 0; step < 32 && result == IpConfirmationResult::Pending; ++step) + { + result = work->step(mode == 3 ? 2 : 1, {author, 64}, {author_hash, 32}, mode != 4, work_scratch, sizeof(work_scratch)); + assert(!io.active); + } + if (mode == 0) network_request[0] ^= 1; + if (mode == 0) + { + assert(result == IpConfirmationResult::Ready && work->result()->phase == HeadPhase::Committed && io.files.count(receipt_path)); + const auto durable = io.files; + const auto writes = io.writes; + work.reset(); + auto repeated = std::make_unique(io, hash, manifest_crypto, crypto, request, geocaching::ByteView{author, 64}, + geocaching::ByteView{author_hash, 32}, work_signed, sizeof(work_signed), work_receipt, sizeof(work_receipt), work_domain, sizeof(work_domain)); + result = IpConfirmationResult::Pending; + for (unsigned step = 0; step < 32 && result == IpConfirmationResult::Pending; ++step) + result = repeated->step(1, {author, 64}, {author_hash, 32}, true, work_scratch, sizeof(work_scratch)); + assert(result == IpConfirmationResult::Ready && io.files == durable && io.writes == writes); + } + else if (mode < 5) + { + assert(result == (mode == 1 ? IpConfirmationResult::HashMismatch : mode == 2 ? IpConfirmationResult::Conflict + : mode == 3 ? IpConfirmationResult::StaleIdentity + : IpConfirmationResult::StorageFailure)); + assert(io.files == before && io.writes == 0 && !work->result()); + } + else + { + assert(result == IpConfirmationResult::StorageFailure && io.files[head_a] == before.at(head_a) && !io.files.count(head_b)); + if (mode == 6) assert(io.files.count(receipt_path)); + io.fail_create.clear(); + work.reset(); + auto resumed = std::make_unique(io, hash, manifest_crypto, crypto, request, geocaching::ByteView{author, 64}, + geocaching::ByteView{author_hash, 32}, work_signed, sizeof(work_signed), work_receipt, sizeof(work_receipt), work_domain, sizeof(work_domain)); + result = IpConfirmationResult::Pending; + for (unsigned step = 0; step < 32 && result == IpConfirmationResult::Pending; ++step) + result = resumed->step(1, {author, 64}, {author_hash, 32}, true, work_scratch, sizeof(work_scratch)); + assert(result == IpConfirmationResult::Ready && resumed->result()->phase == HeadPhase::Committed); + } + } +} diff --git a/tests/hostlink/test_web_config_upload.cpp b/tests/hostlink/test_web_config_upload.cpp new file mode 100644 index 00000000..eb9236a5 --- /dev/null +++ b/tests/hostlink/test_web_config_upload.cpp @@ -0,0 +1,367 @@ +#include "platform/esp/arduino_common/hostlink/hostlink_management_service.h" +namespace platform::esp::arduino_common::geocaching::browse_runtime +{ +bool webLease(bool, bool) { return true; } +WebReadResult requestWebPublicationStop(const uint8_t*, const uint8_t*) { return WebReadResult::Unavailable; } +WebReadResult readWebPublicationJob(const uint8_t*, const uint8_t*, uint8_t*, size_t, size_t& written) +{ + written = 0; + return WebReadResult::Unavailable; +} +WebReadResult readWebPublicationHistory(const uint8_t*, const uint8_t*, uint8_t*, size_t, size_t& written) +{ + written = 0; + return WebReadResult::Unavailable; +} +bool queueWebDraftPublication(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*, uint32_t, const uint8_t*, const uint8_t*) { return false; } +bool publicationContext(uint8_t*, uint64_t&) { return false; } +WebReadResult readWebPublicationBinding(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*, uint8_t*, size_t) { return WebReadResult::Unavailable; } +WebReadResult readWebLatestRevision(const uint8_t*, const uint8_t*, uint8_t*, size_t) { return WebReadResult::Unavailable; } +WebReadResult readWebDirectories(uint32_t, uint8_t*, size_t, size_t& written) +{ + written = 0; + return WebReadResult::Unavailable; +} +bool releaseWebDraftSave() { return true; } +WebReadResult fake_owner_result = WebReadResult::Unavailable; +WebReadResult checkWebDraftEditOwner(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*) { return fake_owner_result; } +WebReadResult queueWebDraftFile(const uint8_t*, uint32_t, uint64_t, const uint8_t*, const uint8_t*, uint32_t) { return WebReadResult::Unavailable; } +WebReadResult readWebDraftCatalog(uint32_t, bool, uint64_t, uint8_t*, size_t, size_t& written) +{ + written = 0; + return WebReadResult::Unavailable; +} +WebReadResult readWebDraftChunk(const uint8_t*, uint32_t, uint16_t, uint64_t, uint8_t*, size_t, size_t& written) +{ + written = 0; + return WebReadResult::Unavailable; +} +} // namespace platform::esp::arduino_common::geocaching::browse_runtime +#include "geocaching/storage/draft_record.h" +#include +#include +#include + +static std::vector response; +struct RecoveryAuthor final : chat::IMeshAdapter +{ + uint32_t generation = 7; + chat::DeviceAuthorResult result = chat::DeviceAuthorResult::Ok; + chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* key, uint32_t& actual) const override + { + if (result != chat::DeviceAuthorResult::Ok) return result; + std::memset(key, 0, 64); + actual = generation; + return result; + } + bool sendText(chat::ChannelId, const std::string&, chat::MessageId*, chat::NodeId) override { return false; } + bool pollIncomingText(chat::MeshIncomingText*) override { return false; } + bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, bool, chat::MessageId, bool) override { return false; } + bool pollIncomingData(chat::MeshIncomingData*) override { return false; } + void applyConfig(const chat::MeshConfig&) override {} + bool isReady() const override { return true; } + bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } +}; +namespace hostlink +{ +bool enqueue_event(uint8_t, const uint8_t* bytes, size_t size, bool, uint16_t) +{ + response.assign(bytes, bytes + size); + return true; +} +} // namespace hostlink +int main() +{ + using namespace hostlink::management; + using namespace platform::esp::arduino_common::storage; + std::array owner{}, request{}, id{}; + owner[0] = 1; + id[0] = 2; + const auto original = fake_file; + const std::string candidate = "TMSET7\nschema.version=u16:7\ndocument.kind=enum:working\ndevice.node_name=str:NewDevice\nEND\n"; + uint8_t scratch[512], base[32], hash[32]; + assert(web_config::hash_file("/trailmate/config.tms", scratch, base)); + mbedtls_sha256_context ctx; + platform::esp::common::crypto::sha256_update(&ctx, reinterpret_cast(candidate.data()), candidate.size()); + platform::esp::common::crypto::sha256_finish(&ctx, hash); + auto invoke = [&](Operation op, auto body) + {uint8_t input[512];geocaching::protocol::CmpWriter writer(input,sizeof(input));body(writer);assert(writer.good());hostlink::PendingCommand command;size_t size;assert(encodeEnvelope(owner.data(),request.data(),static_cast(op),input,writer.size(),command.payload,sizeof(command.payload),size));command.payload_len=size;assert(execute_management(command)==hostlink::ErrorCode::Ok);EnvelopeView result;assert(decodeEnvelope(response.data(),response.size(),result));return result.code; }; + auto begin = [&](auto& w) + {w.array(4);w.binary({id.data(),16});w.unsignedInteger(1);w.unsignedInteger(candidate.size());w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, begin) == 0); + assert(invoke(Operation::TransferBegin, begin) == 0); + auto chunk = [&](auto& w) + {w.array(3);w.binary({id.data(),16});w.unsignedInteger(0);w.binary({reinterpret_cast(candidate.data()),candidate.size()}); }; + assert(invoke(Operation::TransferChunk, chunk) == 0); + assert(invoke(Operation::TransferChunk, chunk) == 0); + auto only_id = [&](auto& w) + {w.array(1);w.binary({id.data(),16}); }; + assert(invoke(Operation::TransferFinish, only_id) == 0); + fake_file[0] ^= 1; + auto commit = [&](auto& w) + {w.array(2);w.binary({id.data(),16});w.binary({base,32}); }; + assert(invoke(Operation::ConfigCommit, commit) == 4); + fake_file = original; + assert(invoke(Operation::ConfigCommit, commit) == 0); + assert(fake_file == candidate && app::sd_tms::fake_commits == 1); + assert(invoke(Operation::ConfigCommit, commit) == 0); + assert(app::sd_tms::fake_commits == 1); + owner[0] = 9; + assert(invoke(Operation::TransferStatus, only_id) == 2); + owner[0] = 1; + id[0] = 3; + app::sd_tms::fake_restart = false; + const auto config_after_commit = fake_file; + std::array encoded_draft{}; + std::string description(650, 'g'); + geocaching::storage::DraftView draft; + draft.generation = 1; + draft.name = "Web draft"; + draft.description = description; + size_t draft_size = 0; + assert(geocaching::storage::encodeDraft({id.data(), 16}, draft, encoded_draft.data(), encoded_draft.size(), draft_size)); + mbedtls_sha256_context draft_ctx; + platform::esp::common::crypto::sha256_update(&draft_ctx, encoded_draft.data(), draft_size); + platform::esp::common::crypto::sha256_finish(&draft_ctx, hash); + auto draft_begin = [&](auto& w) + {w.array(4);w.binary({id.data(),16});w.unsignedInteger(2);w.unsignedInteger(draft_size);w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, draft_begin) == 0); + assert(invoke(Operation::TransferBegin, draft_begin) == 0); + auto same_content_wrong_kind = [&](auto& w) + {w.array(4);w.binary({id.data(),16});w.unsignedInteger(1);w.unsignedInteger(draft_size);w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, same_content_wrong_kind) == 4); + // One shared slot prevents a configuration upload from replacing a draft. + assert(invoke(Operation::TransferBegin, begin) == 4); + for (size_t offset = 0; offset < draft_size; offset += 320) + { + const size_t length = std::min(size_t(320), draft_size - offset); + auto draft_chunk = [&](auto& w) + {w.array(3);w.binary({id.data(),16});w.unsignedInteger(offset);w.binary({encoded_draft.data()+offset,length}); }; + assert(invoke(Operation::TransferChunk, draft_chunk) == 0); + assert(invoke(Operation::TransferChunk, draft_chunk) == 0); + } + fake_files[web_config::candidate_path(2)][0] ^= 1; + assert(invoke(Operation::TransferFinish, only_id) == 8); + fake_files[web_config::candidate_path(2)][0] ^= 1; + assert(invoke(Operation::TransferFinish, only_id) == 0); + web_config::Metadata metadata; + assert(web_config::load(metadata, scratch) && metadata.kind == 2 && metadata.phase == 1 && metadata.offset == draft_size); + assert(fake_files[web_config::kMetaPath].size() == 111); + assert(invoke(Operation::ConfigCommit, commit) == 3); + assert(invoke(Operation::ConfigValidate, only_id) == 3); + assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); + owner[0] = 9; + assert(invoke(Operation::TransferAbort, only_id) == 2); + owner[0] = 1; + assert(invoke(Operation::TransferAbort, only_id) == 0); + assert(!sd_exists(web_config::candidate_path(2)) && !sd_exists(web_config::kMetaPath)); + id[0] = 4; + auto signature_begin = [&](auto& w) + { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(5); w.unsignedInteger(draft_size); w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, signature_begin) == 0); + assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 0); + for (size_t offset = 0; offset < draft_size; offset += 320) + { + const auto count = std::min(size_t(320), draft_size - offset); + auto chunk = [&](auto& w) + { w.array(3); w.binary({id.data(),16}); w.unsignedInteger(offset); w.binary({encoded_draft.data()+offset,count}); }; + assert(invoke(Operation::TransferChunk, chunk) == 0); + } + assert(invoke(Operation::TransferFinish, only_id) == 0); + assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 1); + assert(static_cast(fake_files[web_config::kMetaPath][76]) == 0x41); + assert(fake_files[web_config::candidate_path(5)].size() == draft_size); + assert(std::memcmp(fake_files[web_config::candidate_path(5)].data(), encoded_draft.data(), draft_size) == 0); + assert(invoke(Operation::ConfigValidate, only_id) == 3 && invoke(Operation::ConfigCommit, commit) == 3); + assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); + metadata.kind = 9; + const auto retained_meta = fake_files[web_config::kMetaPath]; + assert(!web_config::save(metadata, scratch) && fake_files[web_config::kMetaPath] == retained_meta); + assert(invoke(Operation::TransferAbort, only_id) == 0); + auto oversized_signature = [&](auto& w) + { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(5); w.unsignedInteger(4097); w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, oversized_signature) == 3); + assert(!sd_exists(web_config::candidate_path(5)) && !sd_exists(web_config::kMetaPath)); + auto confirmation_begin = [&](auto& w) + { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(6); w.unsignedInteger(draft_size); w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, confirmation_begin) == 0); + for (size_t offset = 0; offset < draft_size; offset += 320) + { + const auto count = std::min(size_t(320), draft_size - offset); + auto chunk = [&](auto& w) + {w.array(3);w.binary({id.data(),16});w.unsignedInteger(offset);w.binary({encoded_draft.data()+offset,count}); }; + assert(invoke(Operation::TransferChunk, chunk) == 0); + } + assert(invoke(Operation::TransferFinish, only_id) == 0); + assert(web_config::load(metadata, scratch) && metadata.kind == 6 && metadata.phase == 1); + assert(static_cast(fake_files[web_config::kMetaPath][76]) == 0xc1); + assert(fake_files[web_config::candidate_path(6)].size() == draft_size); + assert(invoke(Operation::ConfigValidate, only_id) == 3 && invoke(Operation::ConfigCommit, commit) == 3); + assert(invoke(Operation::TransferAbort, only_id) == 0); + auto oversized_confirmation = [&](auto& w) + {w.array(4);w.binary({id.data(),16});w.unsignedInteger(6);w.unsignedInteger(4167);w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, oversized_confirmation) == 3); + namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; + metadata.kind = 2; + metadata.phase = 3; + web_config::put32(metadata.base_hash + 24, 7); + assert(web_config::save(metadata, scratch)); + std::array author{}; + const auto before_recovery = fake_files; + for (const auto result : {runtime::WebReadResult::Pending, runtime::WebReadResult::Ready, runtime::WebReadResult::Forbidden, runtime::WebReadResult::Conflict, runtime::WebReadResult::Unavailable}) + { + runtime::fake_owner_result = result; + assert(web_config::reconcileCommittedDraft(metadata, author.data(), 7, scratch) != Status::Ok); + assert(metadata.phase == 3 && fake_files == before_recovery); + } + runtime::fake_owner_result = runtime::WebReadResult::AlreadySaved; + assert(web_config::reconcileCommittedDraft(metadata, author.data(), 8, scratch) == Status::StaleIdentity); + assert(fake_files == before_recovery && metadata.phase == 3); + assert(web_config::reconcileCommittedDraft(metadata, author.data(), 7, scratch) == Status::Ok); + assert(metadata.phase == 4 && web_config::load(metadata, scratch) && metadata.phase == 4); + auto new_id = id; + new_id[0]++; + owner[0] = 9; // A new browser session can reuse an already-committed slot. + auto new_signature = [&](auto& w) + {w.array(4);w.binary({new_id.data(),16});w.unsignedInteger(5);w.unsignedInteger(draft_size);w.binary({hash,32}); }; + assert(invoke(Operation::TransferBegin, new_signature) == 0); + assert(web_config::load(metadata, scratch) && metadata.phase == 0 && metadata.kind == 5); + assert(!std::memcmp(metadata.owner, owner.data(), 16) && !std::memcmp(metadata.id, new_id.data(), 16)); + metadata.kind = 2; + metadata.phase = 3; + metadata.offset = metadata.total; + metadata.id[0] = 44; + web_config::put32(metadata.base_hash + 24, 7); + assert(web_config::save(metadata, scratch)); + const auto stranded_upload = fake_files; + RecoveryAuthor recovery; + app::fake_messaging.adapter = &recovery; + owner[0] = 22; + runtime::fake_owner_result = runtime::WebReadResult::Pending; + assert(invoke(Operation::TransferBegin, new_signature) == 5 && fake_files == stranded_upload); + recovery.generation = 8; + assert(invoke(Operation::TransferBegin, new_signature) == 9 && fake_files == stranded_upload); + recovery.generation = 7; + recovery.result = chat::DeviceAuthorResult::StorageError; + assert(invoke(Operation::TransferBegin, new_signature) == 6 && fake_files == stranded_upload); + recovery.result = chat::DeviceAuthorResult::Ok; + runtime::fake_owner_result = runtime::WebReadResult::Forbidden; + assert(invoke(Operation::TransferBegin, new_signature) == 2 && fake_files == stranded_upload); + runtime::fake_owner_result = runtime::WebReadResult::AlreadySaved; + assert(invoke(Operation::TransferBegin, new_signature) == 0); + assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 0); + assert(!std::memcmp(metadata.owner, owner.data(), 16)); + // Recovery rotates only transfer metadata, not device config or the saved draft. + assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); + assert(fake_files.count(web_config::candidate_path(2)) == stranded_upload.count(web_config::candidate_path(2))); + if (stranded_upload.count(web_config::candidate_path(2))) + assert(fake_files.at(web_config::candidate_path(2)) == stranded_upload.at(web_config::candidate_path(2))); + app::fake_messaging.adapter = nullptr; + // Recovery only exposes the owner of exact sealed unsigned input belonging + // to the current author. Query never changes files or signs/deletes input. + std::array current_author{}; + current_author[0] = 27; + std::array nonce{}; + nonce[0] = 28; + geocaching::protocol::CmpWriter unsigned_writer(encoded_draft.data(), encoded_draft.size()); + assert(unsigned_writer.array(16) && unsigned_writer.unsignedInteger(1) && + unsigned_writer.binary({current_author.data(), 64}) && unsigned_writer.binary({nonce.data(), 16})); + for (unsigned i = 0; i < 13; ++i) assert(unsigned_writer.nil()); + const auto raw_size = unsigned_writer.size(); + fake_files[web_config::candidate_path(5)].assign(reinterpret_cast(encoded_draft.data()), raw_size); + metadata.kind = 5; + metadata.phase = 1; + metadata.total = metadata.offset = raw_size; + std::memcpy(metadata.owner, owner.data(), 16); + std::memcpy(metadata.id, id.data(), 16); + assert(web_config::hash_file(web_config::candidate_path(5), scratch, metadata.hash, raw_size)); + assert(web_config::save(metadata, scratch)); + const uint8_t query_body[]{0x92, 5, 0xc0}; + EnvelopeView query; + query.session = request.data(); // A different session is allowed to query. + query.body = query_body; + query.body_size = sizeof(query_body); + size_t written = 0; + const auto sealed_files = fake_files; + auto query_upload = [&] + { return web_config::listSharedSignatureUpload(query, current_author.data(), 7, 11, scratch, sizeof(scratch), written); }; + assert(query_upload() == Status::Ok && fake_files == sealed_files); + geocaching::protocol::CmpReader recovery_reader({scratch, written}); + size_t fields = 0; + uint64_t value = 0; + geocaching::ByteView view; + assert(recovery_reader.array(fields, 3) && fields == 3 && recovery_reader.unsignedInteger(value) && value == 11 && recovery_reader.nil()); + assert(recovery_reader.array(fields, 9) && fields == 9 && recovery_reader.binary(view, 16) && !std::memcmp(view.data, metadata.id, 16)); + assert(recovery_reader.unsignedInteger(value) && value == 5 && recovery_reader.unsignedInteger(value) && value == raw_size); + assert(recovery_reader.binary(view, 32) && !std::memcmp(view.data, metadata.hash, 32)); + assert(recovery_reader.unsignedInteger(value) && value == 7 && recovery_reader.unsignedInteger(value) && value == 1 && + recovery_reader.unsignedInteger(value) && value == raw_size); + assert(recovery_reader.binary(view, 16) && !std::memcmp(view.data, owner.data(), 16)); + assert(recovery_reader.binary(view, 16) && !std::memcmp(view.data, nonce.data(), 16) && recovery_reader.finished()); + current_author[0]++; + assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == sealed_files); + current_author[0]--; + fake_files[web_config::candidate_path(5)].back() ^= 1; + const auto corrupted_files = fake_files; + assert(query_upload() == Status::HashMismatch && written == 0 && fake_files == corrupted_files); + fake_files = sealed_files; + for (const auto phase : {0, 2}) + { + metadata.phase = phase; + assert(web_config::save(metadata, scratch)); + const auto before = fake_files; + assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == before); + } + metadata.kind = 6; + metadata.phase = 1; + assert(web_config::save(metadata, scratch)); + const auto confirmation_files = fake_files; + assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == confirmation_files); + query.body_size--; + assert(query_upload() == Status::InvalidField && written == 0 && fake_files == confirmation_files); + const uint8_t confirm_query[]{0x92, 6, 0xc0}; + query.body = confirm_query; + query.body_size = sizeof(confirm_query); + std::array signed_bytes{}; + std::array signature{}; + geocaching::protocol::CmpWriter signed_writer(signed_bytes.data(), signed_bytes.size()); + assert(signed_writer.array(2) && signed_writer.binary({encoded_draft.data(), raw_size}) && signed_writer.binary({signature.data(), signature.size()})); + metadata.total = metadata.offset = signed_writer.size(); + fake_files[web_config::candidate_path(6)].assign(reinterpret_cast(signed_bytes.data()), metadata.total); + assert(web_config::hash_file(web_config::candidate_path(6), scratch, metadata.hash, metadata.total)); + assert(web_config::save(metadata, scratch)); + const auto sealed_confirmation = fake_files; + assert(query_upload() == Status::Ok && fake_files == sealed_confirmation); + geocaching::protocol::CmpReader confirmed_row({scratch, written}); + assert(confirmed_row.array(fields, 3) && fields == 3 && confirmed_row.unsignedInteger(value) && confirmed_row.nil()); + assert(confirmed_row.array(fields, 9) && fields == 9 && confirmed_row.binary(view, 16) && confirmed_row.unsignedInteger(value) && value == 6); + auto completion = [&](unsigned phase, bool wrong_hash = false) + { + geocaching::protocol::CmpWriter writer(scratch, sizeof(scratch)); + auto hash = metadata.hash; + uint8_t bad_hash[32]{}; + assert(writer.array(8) && writer.binary({metadata.id, 16}) && writer.binary({wrong_hash ? bad_hash : hash, 32}) && + writer.unsignedInteger(7) && writer.unsignedInteger(phase)); + if (phase == 1) assert(writer.binary({base, 32}) && writer.unsignedInteger(1) && writer.binary({base, 32}) && writer.unsignedInteger(1)); + else assert(writer.nil() && writer.nil() && writer.nil() && writer.unsignedInteger(0)); + written = writer.size(); + return web_config::finishConfirmedUpload(query, scratch, sizeof(scratch), written); + }; + assert(completion(1) == Status::Conflict && written == 0 && fake_files == sealed_confirmation); // foreign correlation + query.session = metadata.owner; + assert(completion(1, true) == Status::Conflict && written == 0 && fake_files == sealed_confirmation); + assert(completion(0) == Status::Ok && fake_files == sealed_confirmation); + fake_write_failure = true; + assert(completion(1) == Status::StorageUnavailable && written == 0); + fake_write_failure = false; + assert(web_config::load(metadata, scratch) && metadata.phase == 1); + assert(completion(1) == Status::Ok); + assert(web_config::load(metadata, scratch) && metadata.kind == 6 && metadata.phase == 2); + const auto finished_confirmation = fake_files; + assert(completion(1) == Status::Ok && fake_files == finished_confirmation); // exact lost-reply replay + assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0); + id[0]++; + assert(invoke(Operation::TransferBegin, draft_begin) == 0); + assert(web_config::load(metadata, scratch) && metadata.kind == 2 && metadata.phase == 0); + assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); +} diff --git a/tests/hostlink/test_wifi_persistent_snapshot.cpp b/tests/hostlink/test_wifi_persistent_snapshot.cpp new file mode 100644 index 00000000..08d497a9 --- /dev/null +++ b/tests/hostlink/test_wifi_persistent_snapshot.cpp @@ -0,0 +1,181 @@ +#include "platform/esp/common/wifi_profile_snapshot.h" +#include +#include +#include +#include + +namespace platform::ui::settings_store +{ +std::map scalars; +std::map texts; +std::string fault_key; +bool unavailable = false; +std::vector reads; +ScalarReadResult read_int_into(const char*, const char* key, int32_t* out) +{ + reads.emplace_back(key); + *out = 0; + if (fault_key == key) return unavailable ? ScalarReadResult::Unavailable : ScalarReadResult::Invalid; + const auto entry = scalars.find(key); + if (entry == scalars.end()) return ScalarReadResult::Missing; + *out = entry->second; + return ScalarReadResult::Ok; +} +ScalarReadResult read_bool_into(const char* ns, const char* key, bool* out) +{ + int32_t value = 0; + auto result = read_int_into(ns, key, &value); + *out = false; + if (result != ScalarReadResult::Ok) return result; + if (value != 0 && value != 1) return ScalarReadResult::Invalid; + *out = value != 0; + return result; +} +StringReadResult read_string_into(const char*, const char* key, char* out, + std::size_t capacity, std::size_t* size) +{ + reads.emplace_back(key); + *size = 0; + std::memset(out, 0, capacity); + if (fault_key == key) return unavailable ? StringReadResult::Unavailable : StringReadResult::Invalid; + const auto entry = texts.find(key); + if (entry == texts.end()) return StringReadResult::Missing; + if (entry->second.size() >= capacity) return StringReadResult::Invalid; + std::memcpy(out, entry->second.c_str(), entry->second.size() + 1); + *size = entry->second.size(); + return StringReadResult::Ok; +} +} // namespace platform::ui::settings_store + +namespace platform::ui::wifi +{ +#if defined(WIFI_SNAPSHOT_IDF) +#include "wifi_idf_snapshot_actual.inc" +#else +#include "wifi_common_snapshot_actual.inc" +#endif +} // namespace platform::ui::wifi + +int main() +{ + namespace store = platform::ui::settings_store; + using platform::ui::wifi::Config; + constexpr auto capacity = platform::ui::wifi::kMaxSavedProfileCount; + Config profiles[capacity]{}; // Desktop fixture; firmware supplies PSRAM staging. + bool enabled = true; + std::size_t count = 99; + const auto read = [&] + { return platform::ui::wifi::read_saved_profiles_into(&enabled, &count, profiles, capacity); }; + const auto empty = [&] + { + assert(!enabled && count == 0); + for (const auto& profile : profiles) + assert(!profile.enabled && profile.ssid[0] == '\0' && profile.password[0] == '\0'); + }; + assert(read()); // Fresh NVS: default disabled, no profiles. + empty(); + store::scalars = {{"wifi_enabled", 1}, {"wifi_prof_count", 10}}; + for (std::size_t i = 0; i < capacity; ++i) + { + store::texts["wifi_ssid_" + std::to_string(i)] = "fixture-" + std::to_string(i); + store::texts["wifi_password_" + std::to_string(i)] = std::string(i == 0 ? 64 : 8, 'x'); + } + store::texts["wifi_ssid"] = "fixture-0"; + store::texts["wifi_password"] = "old-mirror"; + store::reads.clear(); + assert(read()); + assert(enabled && count == 10 && std::strlen(profiles[0].password) == 64); + for (std::size_t i = 0; i < capacity; ++i) + assert(profiles[i].ssid == std::string("fixture-") + std::to_string(i)); + const auto keys = store::reads; + assert(keys.size() == 24); + for (const auto& key : keys) + { + for (const bool unavailable : {false, true}) + { + store::fault_key = key; + store::unavailable = unavailable; + assert(!read()); + empty(); // No partial profile set or secret retained on error. + } + } + store::fault_key.clear(); + const auto original = store::texts; + for (std::size_t i = 0; i < capacity; ++i) + { + const auto ssid_key = "wifi_ssid_" + std::to_string(i); + store::texts.erase(ssid_key); + assert(!read()); // Explicit count cannot silently shrink. + empty(); + store::texts = original; + } + store::texts["wifi_ssid_1"] = store::texts["wifi_ssid_0"]; + assert(!read()); // Malformed explicit set: duplicate SSID. + store::texts = original; + for (const auto value : {-1, 11}) + { + store::scalars["wifi_prof_count"] = value; + assert(!read()); + empty(); + } + store::scalars["wifi_prof_count"] = 10; + for (const auto value : {2, 255}) + { + store::scalars["wifi_enabled"] = value; + assert(!read()); + } + store::scalars["wifi_enabled"] = 1; + store::texts["wifi_ssid_0"] = std::string(33, 's'); + assert(!read()); // Never truncate an SSID into a different network. + store::texts = original; + store::texts["wifi_password_0"] = std::string(65, 'p'); + assert(!read()); + store::texts = original; + + // Current empty password has priority over an obsolete legacy password. + store::texts["wifi_password_0"] = ""; + store::texts["wifi_pass_0"] = "legacy-fixture"; + assert(read() && profiles[0].password[0] == '\0'); + store::texts.erase("wifi_password_0"); + assert(read() && !std::strcmp(profiles[0].password, "legacy-fixture")); + for (const bool unavailable : {false, true}) + { + store::fault_key = "wifi_pass_0"; + store::unavailable = unavailable; + assert(!read()); + empty(); + } + store::fault_key.clear(); + store::texts.erase("wifi_pass_0"); + assert(read() && profiles[0].password[0] == '\0'); // Open network. + + // Missing/zero count recovers sparse legacy slots without a storage write. + store::scalars.erase("wifi_prof_count"); + store::texts = {{"wifi_ssid_4", "legacy-network"}, {"wifi_pass_4", "legacy-fixture"}, {"wifi_ssid_7", "legacy-network"}, {"wifi_password_7", "second-duplicate"}, {"wifi_ssid", "primary-network"}, {"wifi_password", "primary-fixture"}}; + const auto legacy = store::texts; + assert(read() && count == 2); + assert(!std::strcmp(profiles[0].password, "legacy-fixture")); + assert(!std::strcmp(profiles[1].ssid, "primary-network")); + assert(store::texts == legacy && !store::scalars.count("wifi_prof_count")); + store::scalars["wifi_prof_count"] = 0; + assert(read() && count == 2); + store::texts.clear(); + store::texts["wifi_ssid"] = std::string(32, 's'); + store::texts["wifi_password"] = std::string(64, 'p'); + assert(read() && count == 1 && std::strlen(profiles[0].ssid) == 32); + store::texts = original; + store::scalars["wifi_prof_count"] = 10; + store::texts["wifi_ssid"] = "overflow-primary"; + assert(!read()); // Cannot silently drop the eleventh unique network. + empty(); + + store::reads.clear(); + assert(!platform::esp::common::read_persisted_wifi_profiles(nullptr, &count, profiles, capacity)); + assert(!platform::esp::common::read_persisted_wifi_profiles(&enabled, nullptr, profiles, capacity)); + assert(!platform::esp::common::read_persisted_wifi_profiles(&enabled, &count, nullptr, capacity)); + for (auto& profile : profiles) profile.ssid[0] = 'x'; + assert(!platform::esp::common::read_persisted_wifi_profiles(&enabled, &count, profiles, capacity - 1)); + for (std::size_t i = 0; i < capacity - 1; ++i) assert(profiles[i].ssid[0] == '\0'); + assert(profiles[capacity - 1].ssid[0] == 'x'); // Guard never touches past capacity. + assert(store::reads.empty()); +} diff --git a/tests/hostlink/test_wifi_profile_storage.cpp b/tests/hostlink/test_wifi_profile_storage.cpp new file mode 100644 index 00000000..117a158a --- /dev/null +++ b/tests/hostlink/test_wifi_profile_storage.cpp @@ -0,0 +1,216 @@ +#include "platform/ui/wifi_runtime.h" +#include +#include +#include +#include +#include +#include + +// Storage and runtime helpers are simulated; the four persistence/migration +// functions below are extracted unchanged from both production backends. +namespace platform::ui::settings_store +{ +std::map text; +std::map scalar; +std::vector writes; +std::string failed_write, failed_read; +bool uncertain_string_write = false; +struct ScopedChangeBatch +{ + ~ScopedChangeBatch() {} +}; +bool put_string(const char*, const char* key, const char* value) +{ + writes.emplace_back(key); + if (key != failed_write || uncertain_string_write) text[key] = value; + return key != failed_write; +} +bool get_string(const char*, const char* key, std::string& value) +{ + const auto found = text.find(key); + if (key == failed_read || found == text.end()) return false; + value = found->second; + return true; +} +void put_int(const char*, const char* key, int value) +{ + writes.emplace_back(key); + if (key != failed_write) scalar[key] = value; +} +int get_int(const char*, const char* key, int fallback) +{ + const auto found = scalar.find(key); + return key == failed_read || found == scalar.end() ? fallback : found->second; +} +void put_bool(const char* ns, const char* key, bool value) { put_int(ns, key, value); } +bool get_bool(const char* ns, const char* key, bool fallback) { return get_int(ns, key, fallback) != 0; } +void remove_keys(const char*, const char* const* keys, std::size_t count) +{ + for (std::size_t i = 0; i < count; ++i) text.erase(keys[i]); +} +} // namespace platform::ui::settings_store + +using platform::ui::wifi::Config; +namespace store = platform::ui::settings_store; +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kWifiProfileCountKey = "wifi_prof_count"; +constexpr const char* kWifiSsidKey = "wifi_ssid"; +constexpr const char* kWifiPasswordKey = "wifi_password"; +constexpr const char* kWifiEnabledKey = "wifi_enabled"; +constexpr std::size_t kWifiProfileCapacity = platform::ui::wifi::kMaxSavedProfileCount; +struct RuntimeFixture +{ + Config profiles[kWifiProfileCapacity]{}; + Config config{}, saved_config{}; + std::size_t profile_count = 0, next_profile_index = 0, automatic_profile_attempts = 0; + bool profiles_cached = true, config_cached = true; +} s_runtime; +void profile_key(char* out, std::size_t size, const char* field, std::size_t index) +{ + std::snprintf(out, size, "wifi_%s_%u", field, static_cast(index)); +} +void copy_bounded(char* out, std::size_t size, const char* value) { std::snprintf(out, size, "%s", value); } +void copy_text(char* out, std::size_t size, const char* value) { copy_bounded(out, size, value); } +bool has_saved_credentials(const Config& value) { return value.ssid[0] != '\0'; } +bool same_ssid(const Config& lhs, const Config& rhs) { return !std::strcmp(lhs.ssid, rhs.ssid); } +void clear_profiles() +{ + for (auto& profile : s_runtime.profiles) profile = {}; + s_runtime.profile_count = 0; +} +void upsert_profile_front(const Config& config) +{ + s_runtime.profiles[0] = config; + s_runtime.profile_count = 1; +} +void cache_config(const Config& config) +{ + s_runtime.config = config; + s_runtime.config_cached = true; +} +void refresh_runtime_status_message() {} +bool read_config_from_store(Config&) { return true; } +Config load_saved_config() { return s_runtime.saved_config; } + +#if defined(WIFI_STORAGE_IDF) +#include "wifi_idf_persist_actual.inc" +#include "wifi_idf_read_actual.inc" +#include "wifi_idf_replace_actual.inc" +#include "wifi_idf_save_actual.inc" +#else +#include "wifi_common_persist_actual.inc" +#include "wifi_common_read_actual.inc" +#include "wifi_common_replace_actual.inc" +#include "wifi_common_save_actual.inc" +#endif + +void testWifiStorageFailures() +{ + const auto reset = [] + { + s_runtime = {}; + store::text.clear(); + store::scalar.clear(); + store::writes.clear(); + store::failed_write.clear(); + store::failed_read.clear(); + store::uncertain_string_write = false; + }; + Config profile{}; + copy_bounded(profile.ssid, sizeof(profile.ssid), "trail-test"); + copy_bounded(profile.password, sizeof(profile.password), "fixture-password"); + const auto save = [&](bool replace) + { + if (replace) return ::replace_saved_profiles(profile.enabled, &profile, 1); +#if defined(WIFI_STORAGE_IDF) + return save_saved_config(profile); +#else + return ::save_config(profile); +#endif + }; + for (const bool replace : {false, true}) + { + for (const bool enabled : {false, true}) + { + profile.enabled = enabled; + reset(); + assert(save(replace)); + assert(store::scalar.at(kWifiProfileCountKey) == 1); + assert(store::scalar.at(kWifiEnabledKey) == enabled); + assert(store::text.at("wifi_password_0") == profile.password); + assert(s_runtime.config_cached && s_runtime.profiles_cached); + const auto keys = store::writes; + assert(keys.size() == 24); + assert(keys[20] == kWifiProfileCountKey); // All 20 slots committed first. + for (const auto& key : keys) + { + reset(); + store::scalar[kWifiEnabledKey] = !enabled; + store::scalar[kWifiProfileCountKey] = 0; + store::failed_write = key; + assert(!save(replace)); + assert(!s_runtime.config_cached && !s_runtime.profiles_cached); + if (key.find("_0") != std::string::npos || key.find("_9") != std::string::npos) + assert(store::scalar.at(kWifiProfileCountKey) == 0); + } + for (const auto* key : {kWifiProfileCountKey, kWifiEnabledKey}) + { + reset(); + store::failed_read = key; + assert(!save(replace)); + assert(!s_runtime.config_cached && !s_runtime.profiles_cached); + } + } + } + reset(); + assert(::replace_saved_profiles(false, nullptr, 0)); + assert(store::scalar.at(kWifiProfileCountKey) == 0); + assert(store::text.at(kWifiSsidKey).empty() && store::text.at(kWifiPasswordKey).empty()); + for (const bool password : {false, true}) + { + reset(); + auto invalid = profile; + if (password) std::memset(invalid.password, 'x', sizeof(invalid.password)); + else std::memset(invalid.ssid, 'x', sizeof(invalid.ssid)); + assert(!::replace_saved_profiles(true, &invalid, 1)); + assert(store::writes.empty()); + } + reset(); + const Config duplicate[] = {profile, profile}; + assert(!::replace_saved_profiles(true, duplicate, 2)); + assert(!::replace_saved_profiles(true, nullptr, 1)); + assert(!::replace_saved_profiles(true, &profile, kWifiProfileCapacity + 1)); + assert(store::writes.empty()); +} + +void testWifiLegacyMigration() +{ + for (const bool uncertain : {false, true}) + { + store::text = {{"wifi_ssid_0", "legacy-fixture"}, {"wifi_pass_0", "legacy-password"}}; + store::failed_read.clear(); + store::failed_write = "wifi_password_0"; + store::uncertain_string_write = uncertain; + Config loaded{}; + assert(read_profile_from_store(0, loaded)); + assert(!std::strcmp(loaded.password, "legacy-password")); + assert(store::text.at("wifi_pass_0") == "legacy-password"); + } + store::text = {{"wifi_ssid_0", "legacy-fixture"}, {"wifi_pass_0", "legacy-password"}}; + store::failed_write.clear(); + store::uncertain_string_write = false; + Config loaded{}; + assert(read_profile_from_store(0, loaded)); + assert(store::text.at("wifi_password_0") == "legacy-password"); + assert(!store::text.count("wifi_pass_0")); + store::text["wifi_pass_0"] = "old-password"; + assert(read_profile_from_store(0, loaded)); + assert(!std::strcmp(loaded.password, "legacy-password")); + assert(store::text.at("wifi_pass_0") == "old-password"); // Existing new key wins. +} + +int main() +{ + testWifiStorageFailures(); + testWifiLegacyMigration(); +} diff --git a/tests/hostlink/test_xml_name.cpp b/tests/hostlink/test_xml_name.cpp new file mode 100644 index 00000000..96748772 --- /dev/null +++ b/tests/hostlink/test_xml_name.cpp @@ -0,0 +1,13 @@ +#include "track_sharing/xml_name.h" +#include +int main() +{ + using track_sharing::validXmlName; + using track_sharing::validXmlQName; + assert(validXmlQName("中文:属性") && validXmlQName("a_1.2") && validXmlName("a:b", false)); + assert(!validXmlQName(":a") && !validXmlQName("a:") && !validXmlQName("a:b:c") && !validXmlQName("1a")); + assert(!validXmlName("\xcc\x80")); + assert(validXmlName("a\xcc\x80")); + assert(!validXmlName("\xc0\xaf") && !validXmlName("\xed\xa0\x80") && !validXmlName("\xf4\x8f\xbf\xbf")); + assert(!validXmlName("\xc2\xb7") && validXmlName("a\xc2\xb7")); +} diff --git a/tests/hostlink/test_xml_namespace_scope.cpp b/tests/hostlink/test_xml_namespace_scope.cpp new file mode 100644 index 00000000..01e234b9 --- /dev/null +++ b/tests/hostlink/test_xml_namespace_scope.cpp @@ -0,0 +1,26 @@ +#include "track_sharing/xml_namespace_scope.h" +#include +using namespace track_sharing; +int main() +{ + XmlNamespaceScope scope; + XmlNamespace space; + std::string_view local; + assert(scope.enter() && scope.declare("", "http://www.opengis.net/kml/2.2") && scope.declare("gx", "http://www.google.com/kml/ext/2.2")); + assert(scope.element("kml", space, local) && space == XmlNamespace::Kml && local == "kml"); + assert(scope.element("gx:Track", space, local) && space == XmlNamespace::Gx); + assert(!scope.element("unknown:Point", space, local)); + assert(scope.enter() && scope.declare("gx", "urn:other") && scope.declare("", "")); + assert(scope.element("gx:Track", space, local) && space == XmlNamespace::Other); + assert(scope.element("Point", space, local) && space == XmlNamespace::None); + assert(scope.leave() && scope.element("gx:Track", space, local) && space == XmlNamespace::Gx && scope.element("Point", space, local) && space == XmlNamespace::Kml); + XmlNamespaceScope duplicate; + assert(duplicate.enter() && duplicate.declare("gx", "urn:first") && !duplicate.declare("gx", "urn:second")); + XmlNamespaceScope reserved; + assert(reserved.enter() && !reserved.declare("xml", "urn:fake")); + XmlNamespaceScope builtin; + assert(builtin.enter() && builtin.element("xml:lang", space, local) && space == XmlNamespace::Other); + XmlNamespaceScope budget; + for (unsigned i = 0; i < 64; ++i) assert(budget.enter()); + assert(!budget.enter()); +} diff --git a/tests/hostlink/test_yxml_stream.cpp b/tests/hostlink/test_yxml_stream.cpp new file mode 100644 index 00000000..ed0f651b --- /dev/null +++ b/tests/hostlink/test_yxml_stream.cpp @@ -0,0 +1,44 @@ +extern "C" +{ +#include "yxml.h" +} +#include +#include +#include +static yxml_t parser; +static char stack[1024]; +int main() +{ + const std::string source = "A&B120,30,10"; + for (size_t split = 0; split <= source.size(); ++split) + { + yxml_init(&parser, stack, sizeof(stack)); + unsigned starts = 0, ends = 0; + std::string content; + for (unsigned chunk = 0; chunk < 2; ++chunk) + { + const size_t begin = chunk ? split : 0, end = chunk ? source.size() : split; + for (size_t i = begin; i < end; ++i) + { + const auto result = yxml_parse(&parser, static_cast(source[i])); + assert(result >= 0); + if (result == YXML_ELEMSTART) ++starts; + if (result == YXML_ELEMEND) ++ends; + if (result == YXML_CONTENT) content += parser.data; + } + } + assert(yxml_eof(&parser) >= 0 && starts == ends && starts == 6 && content == "A&B120,30,10"); + } + for (const char* text : {"", "", "&unknown;"}) + { + yxml_init(&parser, stack, sizeof(stack)); + bool invalid = false; + for (size_t i = 0; i < std::strlen(text); ++i) + if (yxml_parse(&parser, text[i]) < 0) + { + invalid = true; + break; + } + assert(invalid || yxml_eof(&parser) < 0); + } +} diff --git a/tests/hostlink/tms_complete_config_ports.cpp b/tests/hostlink/tms_complete_config_ports.cpp new file mode 100644 index 00000000..3a3330bc --- /dev/null +++ b/tests/hostlink/tms_complete_config_ports.cpp @@ -0,0 +1,120 @@ +// Desktop-only NVS and runtime fixtures. Configuration emit/consume/apply, +// settings_store, Wi-Fi persistence and module codecs are production code. +#include "app/tms_config_codec.h" +#include "platform/ui/reticulum_network_config_runtime.h" +#include "platform/ui/wifi_runtime.h" +#include +#include + +// Keep production diagnostic output outside the binary test bridge stream. +#define vprintf(fmt, args) vfprintf(stderr, fmt, args) +#include "platform/esp/common/settings_store_impl.h" +#undef vprintf +#include "platform/esp/common/wifi_profile_snapshot.h" + +namespace platform::ui::screen +{ +uint32_t timeout_ms() { return 30000; } +} // namespace platform::ui::screen +namespace platform::ui::device +{ +uint8_t screen_brightness() { return 8; } +} // namespace platform::ui::device +namespace platform::ui::time +{ +int timezone_profile_id() { return 0; } +int timezone_offset_min() { return 0; } +} // namespace platform::ui::time +namespace platform::ui::wifi +{ +namespace +{ +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kWifiEnabledKey = "wifi_enabled"; +constexpr const char* kWifiSsidKey = "wifi_ssid"; +constexpr const char* kWifiPasswordKey = "wifi_password"; +constexpr const char* kWifiProfileCountKey = "wifi_prof_count"; +constexpr auto kWifiProfileCapacity = kMaxSavedProfileCount; +struct Runtime +{ + Config profiles[kWifiProfileCapacity]{}; + Config config{}; + std::size_t profile_count = 0, next_profile_index = 0; + bool config_cached = false, profiles_cached = false; +} s_runtime; +void profile_key(char* out, std::size_t size, const char* field, std::size_t index) +{ + std::snprintf(out, size, "wifi_%s_%u", field, static_cast(index)); +} +bool has_saved_credentials(const Config& config) { return config.ssid[0] != 0; } +bool same_ssid(const Config& a, const Config& b) { return !std::strcmp(a.ssid, b.ssid); } +void clear_profiles() +{ + for (auto& profile : s_runtime.profiles) profile = {}; + s_runtime.profile_count = 0; + s_runtime.profiles_cached = true; +} +void cache_config(const Config& config) +{ + s_runtime.config = config; + s_runtime.config_cached = true; +} +void refresh_runtime_status_message() {} +#include "wifi_common_persist_actual.inc" +} // namespace +#include "wifi_common_replace_actual.inc" +#include "wifi_common_snapshot_actual.inc" +} // namespace platform::ui::wifi + +namespace platform::ui::reticulum_network_config +{ +namespace +{ +using NetworkConfig = chat::reticulum::ReticulumNetworkConfig; +using InterfaceConfig = chat::reticulum::NetworkInterfaceConfig; +NetworkConfig fixture; +#include "complete_network_helpers_actual.inc" +#include "complete_network_validate_actual.inc" +} // namespace +bool validateForTms(const NetworkConfig& config) { return validate_config(config); } +bool setFromTms(const NetworkConfig& config) +{ + if (!validate_config(config)) return false; + fixture = config; + return true; +} +bool snapshotForTms(const chat::MeshConfig&, NetworkConfig* out) +{ + if (!out || !validate_config(fixture)) return false; + *out = fixture; + return true; +} +} // namespace platform::ui::reticulum_network_config + +void resetCompleteConfigFixture() +{ + fake_nvs::values.clear(); + fake_nvs::handles.clear(); + fake_nvs::next = 1; + fake_nvs::fail_commit = false; + fake_nvs::open_error = 0; + using namespace platform::ui::reticulum_network_config; + fixture = {}; + fixture.interface_count = 5; + for (std::size_t i = 0; i < fixture.interface_count; ++i) + { + auto& entry = fixture.interfaces[i]; + std::snprintf(entry.id, sizeof(entry.id), "fixture-%u", static_cast(i)); + entry.type = i == 0 ? chat::reticulum::NetworkInterfaceType::IntegratedLoRa : i == 1 ? chat::reticulum::NetworkInterfaceType::Auto + : chat::reticulum::NetworkInterfaceType::TcpClient; + entry.enabled = false; + if (i >= 2) std::snprintf(entry.target_host, sizeof(entry.target_host), "fixture-%u.invalid", static_cast(i)); + } + fake_nvs::values["settings"]["wifi_prof_count"] = int32_t{2}; + fake_nvs::values["settings"]["wifi_enabled"] = uint8_t{0}; + for (unsigned i = 0; i < 2; ++i) + { + fake_nvs::values["settings"]["wifi_ssid_" + std::to_string(i)] = "fixture-wifi-" + std::to_string(i); + fake_nvs::values["settings"]["wifi_password_" + std::to_string(i)] = std::string("fixture-password"); + } +} diff --git a/tests/hostlink/tms_config_interop_cli.cpp b/tests/hostlink/tms_config_interop_cli.cpp new file mode 100644 index 00000000..8f99c373 --- /dev/null +++ b/tests/hostlink/tms_config_interop_cli.cpp @@ -0,0 +1,134 @@ +// Native test bridge only. The browser test supplies synthetic configurations; +// this does not open a device or store secrets/configuration on disk. +#include "app/tms_config_codec.h" +#if defined(TMS_COMPLETE_INTEROP) +#include "platform/esp/arduino_common/app_config_tms_settings_extension.h" +void resetCompleteConfigFixture(); +#endif + +#include +#include +#include +#include +#if defined(_WIN32) +#include +#include +#endif + +namespace +{ +constexpr uint32_t kMaxDocument = 32768; + +bool append(void* context, const char* bytes, std::size_t size) +{ + static_cast(context)->append(bytes, size); + return true; +} + +bool emit(const app::AppConfig& config, std::string& out) +{ + app::tms::LineScratch scratch{}; + out.clear(); + return app::tms::writeDocument(config, app::tms::DocumentKind::Working, + {&out, append}, scratch +#if defined(TMS_COMPLETE_INTEROP) + , + nullptr, app::sd_tms::settings_extension::writeRecords, + const_cast(&config) +#endif + ); +} + +bool decode(const std::string& text, app::AppConfig* target) +{ + if (text.find('\0') != std::string::npos) return false; +#if defined(TMS_COMPLETE_INTEROP) + app::sd_tms::settings_extension::beginRead(target != nullptr); + struct EndRead + { + ~EndRead() { app::sd_tms::settings_extension::endRead(); } + } end_read; + app::tms::Decoder decoder(target, app::tms::DocumentKind::Working, + app::sd_tms::settings_extension::consumeRecord, nullptr, + app::sd_tms::settings_extension::finishDocument); +#else + app::tms::Decoder decoder(target, app::tms::DocumentKind::Working); +#endif + std::size_t offset = 0; + char line[app::tms::kMaxLineBytes]{}; + while (offset < text.size()) + { + const auto end = text.find('\n', offset); + if (end == std::string::npos || end - offset >= sizeof(line)) return false; + std::memcpy(line, text.data() + offset, end - offset); + line[end - offset] = 0; + if (!decoder.consumeLine(line)) return false; + offset = end + 1; + } + return decoder.finish(); +} + +uint32_t length(const uint8_t* bytes) +{ + return uint32_t(bytes[0]) | uint32_t(bytes[1]) << 8U | + uint32_t(bytes[2]) << 16U | uint32_t(bytes[3]) << 24U; +} + +bool reply(uint8_t status, const std::string& text) +{ + const auto size = static_cast(text.size()); + const uint8_t header[] = {status, static_cast(size), + static_cast(size >> 8U), + static_cast(size >> 16U), + static_cast(size >> 24U)}; + return std::fwrite(header, 1, sizeof(header), stdout) == sizeof(header) && + std::fwrite(text.data(), 1, text.size(), stdout) == text.size(); +} +} // namespace + +int main(int argc, char** argv) +{ +#if defined(_WIN32) + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); +#endif + if (argc != 2) return 2; +#if defined(TMS_COMPLETE_INTEROP) + resetCompleteConfigFixture(); +#endif + if (std::strcmp(argv[1], "--emit") == 0) + { + app::AppConfig source; + std::string text; + if (!emit(source, text)) return 3; + return std::fwrite(text.data(), 1, text.size(), stdout) == text.size() ? 0 : 4; + } + if (std::strcmp(argv[1], "--batch") != 0) return 2; + for (;;) + { + uint8_t header[4]; + const auto count = std::fread(header, 1, sizeof(header), stdin); + if (count == 0 && std::feof(stdin)) return 0; + if (count != sizeof(header)) return 5; + const uint32_t size = length(header); + if (size == 0 || size > kMaxDocument) return 6; + std::string input(size, '\0'), output; + if (std::fread(input.data(), 1, size, stdin) != size) return 7; +#if defined(TMS_COMPLETE_INTEROP) + resetCompleteConfigFixture(); +#endif + app::AppConfig restored; + if (!decode(input, nullptr)) + { + if (!reply(1, output)) return 4; + } + else if (!decode(input, &restored)) + { + if (!reply(2, output)) return 4; + } + else + { + if (!emit(restored, output) || !reply(0, output)) return 3; + } + } +} diff --git a/tests/hostlink/track_confirmation_memory_io.h b/tests/hostlink/track_confirmation_memory_io.h new file mode 100644 index 00000000..92c4ebbf --- /dev/null +++ b/tests/hostlink/track_confirmation_memory_io.h @@ -0,0 +1,83 @@ +#pragma once +#include +#include +#include +#include +#include +#include +struct TrackConfirmationMemoryIo +{ + enum class Mode + { + Read, + CreateTemporary + }; + enum class OpenStatus + { + Ready, + Missing + }; + struct OpenResult + { + void* handle; + OpenStatus status; + }; + std::map> files; + std::string path, fail_create; + size_t offset = 0; + bool active = false; + unsigned writes = 0; + OpenResult open(const char* name, Mode mode) + { + assert(!active); + path = name; + offset = 0; + if (mode == Mode::CreateTemporary && path == fail_create) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::Read && !files.count(path)) return {nullptr, OpenStatus::Missing}; + if (mode == Mode::CreateTemporary) files[path].clear(); + active = true; + return {this, OpenStatus::Ready}; + } + bool close(void*) + { + assert(active); + active = false; + return true; + } + bool size(void*, uint64_t& size) + { + size = files[path].size(); + return true; + } + bool seek(void*, uint64_t value) + { + offset = size_t(value); + return offset <= files[path].size(); + } + size_t read(void*, uint8_t* output, size_t count) + { + assert(active && count <= 320); + count = std::min(count, files[path].size() - offset); + std::memcpy(output, files[path].data() + offset, count); + offset += count; + return count; + } + size_t write(void*, const uint8_t* input, size_t count) + { + assert(active && count <= 320); + files[path].resize(std::max(files[path].size(), offset + count)); + std::memcpy(files[path].data() + offset, input, count); + offset += count; + ++writes; + return count; + } + bool sync(void*) { return true; } + bool publish(const char* temporary, const char* destination) + { + assert(!active); + if (!files.count(temporary) || files.count(destination)) return false; + files[destination] = files[temporary]; + files.erase(temporary); + return true; + } +}; diff --git a/tests/sd_record_io/CMakeLists.txt b/tests/sd_record_io/CMakeLists.txt index 7a1e8824..847d8c2d 100644 --- a/tests/sd_record_io/CMakeLists.txt +++ b/tests/sd_record_io/CMakeLists.txt @@ -12,3 +12,13 @@ else() target_compile_options(sd_record_io_test PRIVATE -UNDEBUG) endif() add_test(NAME sd_record_io_session COMMAND sd_record_io_test) +add_executable(sd_borrowed_snapshot_test test_borrowed_snapshot.cpp) +target_include_directories(sd_borrowed_snapshot_test PRIVATE stubs + ../../platform/esp/arduino_common/include ../../platform/esp/common/include) +target_compile_features(sd_borrowed_snapshot_test PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(sd_borrowed_snapshot_test PRIVATE /UNDEBUG) +else() + target_compile_options(sd_borrowed_snapshot_test PRIVATE -UNDEBUG) +endif() +add_test(NAME sd_borrowed_snapshot COMMAND sd_borrowed_snapshot_test) diff --git a/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h b/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h index d6a3845e..57c12406 100644 --- a/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h +++ b/tests/sd_record_io/stubs/platform/esp/arduino_common/storage/sd_card_runtime.h @@ -22,6 +22,7 @@ class SdRuntimeFile public: bool open(const char*, const char*, uint32_t); bool is_open() const; + void close() { session_ = 0; } uint64_t size() const { return 16; } bool seek(uint64_t) { return is_open(); } int read(void*, std::size_t bytes) { return is_open() ? static_cast(bytes) : 0; } diff --git a/tests/sd_record_io/test_borrowed_snapshot.cpp b/tests/sd_record_io/test_borrowed_snapshot.cpp new file mode 100644 index 00000000..349acc0a --- /dev/null +++ b/tests/sd_record_io/test_borrowed_snapshot.cpp @@ -0,0 +1,75 @@ +#include "platform/esp/arduino_common/storage/sd_borrowed_record_file_io.h" +#include +namespace runtime = platform::esp::arduino_common::storage; +static uint32_t session = 1; +static bool external = false, replace_on_open = false, replace_on_probe = false; +static unsigned opens = 0, publishes = 0; +namespace platform::esp::arduino_common::storage +{ +bool sd_card_ready() { return true; } +bool sd_external_block_owner_active() { return external; } +uint32_t sd_media_session() { return session; } +SdFileReadResult sd_read_file(const char*, void*, size_t) +{ + if (replace_on_probe) + { + ++session; + replace_on_probe = false; + } + return {SdFileReadStatus::Missing}; +} +bool sd_rename(const char*, const char*, uint32_t expected) +{ + if (expected != session || external) return false; + ++publishes; + return true; +} +bool SdRuntimeFile::open(const char*, const char*, uint32_t expected) +{ + if (replace_on_open) + { + ++session; + replace_on_open = false; + } + if (expected != session || external) return false; + session_ = expected; + ++opens; + return true; +} +bool SdRuntimeFile::is_open() const { return session_ == session && !external; } +} // namespace platform::esp::arduino_common::storage +int main() +{ + runtime::SdRuntimeFile file; + runtime::SdBorrowedRecordFileIo io(file, session); + using Mode = runtime::SdBorrowedRecordFileIo::Mode; + using Status = runtime::SdBorrowedRecordFileIo::OpenStatus; + auto first = io.open("snapshot", Mode::Read); + assert(first.status == Status::Ready && first.handle == &file); + assert(!io.open("second", Mode::Read).handle && opens == 1); + assert(io.close(first.handle) && !file.is_open()); + auto second = io.open("snapshot", Mode::Read); + assert(second.handle == first.handle && opens == 2); + ++session; + uint8_t byte = 0; + assert(io.read(second.handle, &byte, 1) == 0 && !io.sync(second.handle)); + assert(!io.close(second.handle)); + assert(io.open("snapshot", Mode::Read).status == Status::Unavailable); + assert(!io.publish("pending", "snapshot")); + runtime::SdBorrowedRecordFileIo current(file, session); + replace_on_open = true; + assert(current.open("snapshot", Mode::Read).status == Status::Unavailable); + runtime::SdBorrowedRecordFileIo newest(file, session); + replace_on_probe = true; + assert(!newest.publish("pending", "snapshot") && publishes == 0); + runtime::SdBorrowedRecordFileIo stable(file, session); + external = true; + assert(stable.open("snapshot", Mode::Read).status == Status::Unavailable); + external = false; + assert(stable.publish("pending", "snapshot") && publishes == 1); + { + runtime::SdBorrowedRecordFileIo scoped(file, session); + assert(scoped.open("snapshot", Mode::Read).handle); + } + assert(!file.is_open()); +} diff --git a/tests/tracker_recording/CMakeLists.txt b/tests/tracker_recording/CMakeLists.txt new file mode 100644 index 00000000..30c169d8 --- /dev/null +++ b/tests/tracker_recording/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.20) +project(TrackerRecordingTests LANGUAGES CXX) +enable_testing() +find_package(Threads REQUIRED) +add_executable(tracker_recording test_runtime.cpp + ../../platform/esp/arduino_common/src/gps/track_recorder.cpp) +target_include_directories(tracker_recording PRIVATE stubs + ../../platform/esp/arduino_common/include ../../modules/core_gps/include) +target_compile_features(tracker_recording PRIVATE cxx_std_17) +target_link_libraries(tracker_recording PRIVATE Threads::Threads) +if(MSVC) + target_compile_options(tracker_recording PRIVATE /UNDEBUG) +else() + target_compile_options(tracker_recording PRIVATE -UNDEBUG) +endif() +add_test(NAME tracker_deferred_settings COMMAND tracker_recording) diff --git a/tests/tracker_recording/stubs/Arduino.h b/tests/tracker_recording/stubs/Arduino.h new file mode 100644 index 00000000..f080f458 --- /dev/null +++ b/tests/tracker_recording/stubs/Arduino.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include +#include +inline std::atomic test_clock{10000}; +inline uint32_t millis() { return test_clock.load(); } +#if defined(_WIN32) +inline std::tm* gmtime_r(const std::time_t* input, std::tm* output) +{ + // Recorder timestamp calls are serialized by its mutex in this harness. + const auto* parsed = std::gmtime(input); + if (!parsed) return nullptr; + *output = *parsed; + return output; +} +#endif +class String : public std::string +{ + public: + using std::string::string; + bool isEmpty() const { return empty(); } +}; diff --git a/tests/tracker_recording/stubs/esp_heap_caps.h b/tests/tracker_recording/stubs/esp_heap_caps.h new file mode 100644 index 00000000..ea9fb451 --- /dev/null +++ b/tests/tracker_recording/stubs/esp_heap_caps.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +#define MALLOC_CAP_INTERNAL 4 +inline void* heap_caps_malloc_prefer(size_t bytes, size_t, ...) { return std::malloc(bytes); } diff --git a/tests/tracker_recording/stubs/esp_system.h b/tests/tracker_recording/stubs/esp_system.h new file mode 100644 index 00000000..1fd040c1 --- /dev/null +++ b/tests/tracker_recording/stubs/esp_system.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +inline uint32_t esp_random() +{ + static std::atomic counter{1}; + return counter++; +} diff --git a/tests/tracker_recording/stubs/freertos/FreeRTOS.h b/tests/tracker_recording/stubs/freertos/FreeRTOS.h new file mode 100644 index 00000000..617b0969 --- /dev/null +++ b/tests/tracker_recording/stubs/freertos/FreeRTOS.h @@ -0,0 +1,7 @@ +#pragma once +#include +using TickType_t = uint32_t; +using BaseType_t = int; +inline constexpr int pdTRUE = 1; +inline constexpr int pdFALSE = 0; +#define pdMS_TO_TICKS(value) (value) diff --git a/tests/tracker_recording/stubs/freertos/semphr.h b/tests/tracker_recording/stubs/freertos/semphr.h new file mode 100644 index 00000000..3467cb3e --- /dev/null +++ b/tests/tracker_recording/stubs/freertos/semphr.h @@ -0,0 +1,20 @@ +#pragma once +#include "freertos/FreeRTOS.h" +#include +#include +struct TestMutex +{ + std::atomic_flag held = ATOMIC_FLAG_INIT; +}; +using SemaphoreHandle_t = TestMutex*; +inline SemaphoreHandle_t xSemaphoreCreateMutex() { return new TestMutex; } +inline int xSemaphoreTake(SemaphoreHandle_t mutex, TickType_t) +{ + return mutex && !mutex->held.test_and_set() ? pdTRUE : pdFALSE; +} +inline int xSemaphoreGive(SemaphoreHandle_t mutex) +{ + assert(mutex && mutex->held.test_and_set()); + mutex->held.clear(); + return pdTRUE; +} diff --git a/tests/tracker_recording/test_runtime.cpp b/tests/tracker_recording/test_runtime.cpp new file mode 100644 index 00000000..d19fc788 --- /dev/null +++ b/tests/tracker_recording/test_runtime.cpp @@ -0,0 +1,196 @@ +#include "platform/esp/arduino_common/gps/track_recorder.h" +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include +#include +#include +#include +#include +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif +#include + +namespace sd = platform::esp::arduino_common::storage; +static bool card_ready = true; +static std::map> files; + +namespace platform::esp::arduino_common::storage +{ +class SdRuntimeFile::Impl +{ + public: + std::string path; + size_t offset = 0; + bool opened = false; +}; +SdRuntimeFile::SdRuntimeFile() : impl_(new Impl) {} +SdRuntimeFile::~SdRuntimeFile() { delete impl_; } +bool SdRuntimeFile::open(const char* path, const char* mode) +{ + if (!card_ready) return false; + impl_->path = path; + if (mode[0] == 'r' && !files.count(path)) return false; + auto& content = files[path]; + if (mode[0] == 'w') content.clear(); + impl_->offset = mode[0] == 'a' ? content.size() : 0; + impl_->opened = true; + return true; +} +void SdRuntimeFile::close() { impl_->opened = false; } +bool SdRuntimeFile::is_open() const { return impl_->opened; } +bool SdRuntimeFile::flush() { return impl_->opened && card_ready; } +size_t SdRuntimeFile::write(const void* input, size_t bytes) +{ + if (!impl_->opened || !card_ready) return 0; + auto& content = files[impl_->path]; + content.resize(impl_->offset + bytes); + std::memcpy(content.data() + impl_->offset, input, bytes); + impl_->offset += bytes; + return bytes; +} +int SdRuntimeFile::read(void* output, size_t bytes) +{ + if (!impl_->opened || !card_ready) return 0; + const auto& content = files.at(impl_->path); + bytes = std::min(bytes, content.size() - impl_->offset); + std::memcpy(output, content.data() + impl_->offset, bytes); + impl_->offset += bytes; + return static_cast(bytes); +} +size_t SdRuntimeFile::print(const char* text) { return write(text, std::strlen(text)); } +size_t SdRuntimeFile::printf(const char* format, ...) +{ + char buffer[512]; + va_list args; + va_start(args, format); + const int count = std::vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + assert(count >= 0 && static_cast(count) < sizeof(buffer)); + return write(buffer, static_cast(count)); +} +bool sd_card_ready() { return card_ready; } +bool sd_exists(const char* path) { return files.count(path) != 0; } +bool sd_is_directory(const char*) { return card_ready; } +bool sd_mkdir(const char*) { return card_ready; } +bool sd_remove(const char* path) { return files.erase(path) != 0; } +SdRuntimeDir::SdRuntimeDir() : impl_(nullptr) {} +SdRuntimeDir::~SdRuntimeDir() = default; +bool SdRuntimeDir::open(const char*) { return false; } +void SdRuntimeDir::close() {} +bool SdRuntimeDir::read_next(char*, size_t, bool*) { return false; } +} // namespace platform::esp::arduino_common::storage + +static std::string content(const std::string& path) +{ + const auto& bytes = files.at(path); + return {bytes.begin(), bytes.end()}; +} + +struct SettingWriter +{ + gps::TrackRecorder* recorder; + unsigned worker; +}; +#if defined(_WIN32) +static DWORD WINAPI write_settings(void* context) +#else +static void write_settings(void* context) +#endif +{ + const auto& writer = *static_cast(context); + for (unsigned update = 0; update < 1000; ++update) + { + writer.recorder->setFormat(static_cast((writer.worker + update) % 3)); + writer.recorder->setAutoRecording((writer.worker + update) % 2); + } +#if defined(_WIN32) + return 0; +#endif +} + +int main() +{ + auto& recorder = gps::TrackRecorder::getInstance(); + recorder.setAutoRecording(false); + assert(recorder.tryAcquireRead(true)); + recorder.setFormat(gps::TrackFormat::CSV); + recorder.setFormat(gps::TrackFormat::Binary); + recorder.setAutoRecording(true); + recorder.setAutoRecording(false); + assert(!recorder.isRecording()); + recorder.releaseRead(); + assert(recorder.start()); + assert(recorder.currentPath().find(".bin") != std::string::npos); + assert(content(recorder.currentPath()).substr(0, 4) == "TRK1"); + recorder.stop(); + + // Hold the actual recorder read lock while concurrent setting writers run. + assert(recorder.tryAcquireRead(true)); + SettingWriter requests[4]; +#if defined(_WIN32) + HANDLE writers[4]; +#else + std::vector writers; +#endif + for (unsigned worker = 0; worker < 4; ++worker) + { + requests[worker] = {&recorder, worker}; +#if defined(_WIN32) + writers[worker] = CreateThread(nullptr, 0, write_settings, &requests[worker], 0, nullptr); + assert(writers[worker]); +#else + writers.emplace_back(write_settings, &requests[worker]); +#endif + } +#if defined(_WIN32) + assert(WaitForMultipleObjects(4, writers, TRUE, INFINITE) == WAIT_OBJECT_0); + for (const auto writer : writers) assert(CloseHandle(writer)); +#else + for (auto& writer : writers) writer.join(); +#endif + recorder.setFormat(gps::TrackFormat::CSV); + recorder.setAutoRecording(false); + recorder.releaseRead(); + assert(recorder.start()); + assert(recorder.currentPath().find(".csv") != std::string::npos); + recorder.stop(); + + card_ready = false; + recorder.setAutoRecording(true); + assert(!recorder.isRecording()); + card_ready = true; + test_clock += 999; + recorder.applyPendingSettings(); + assert(!recorder.isRecording()); + test_clock += 1; + recorder.applyPendingSettings(); + assert(recorder.isRecording()); + const std::string automatic_path = recorder.currentPath(); + recorder.setAutoRecording(true); + recorder.applyPendingSettings(); + assert(recorder.currentPath() == automatic_path); + recorder.setAutoRecording(false); + assert(!recorder.isRecording()); + + recorder.setFormat(gps::TrackFormat::GPX); + assert(recorder.start()); + const std::string old_path = recorder.currentPath(); + gps::TrackPoint point; + point.lat = 30.0; + point.lon = 120.0; + point.satellites = 6; + point.timestamp = 1700000000; + recorder.appendPoint(point); // remains pending until format rotation + assert(content(old_path).find("") != std::string::npos); + assert(content(recorder.currentPath()).find("lat,lon,ts,sat\n") == 0); + recorder.stop(); + std::puts("Actual recorder deferred settings, concurrent writers, retry, and old-format pending-point preservation passed."); +} diff --git a/third_party/bzip2/TRAIL_MATE_CHANGES.md b/third_party/bzip2/TRAIL_MATE_CHANGES.md new file mode 100644 index 00000000..8ca20c32 --- /dev/null +++ b/third_party/bzip2/TRAIL_MATE_CHANGES.md @@ -0,0 +1,20 @@ +# Trail Mate integration changes + +The vendored source is libbzip2 1.0.8. Preserve its original license and notices. + +`BZ2_crc32Table` and `BZ2_rNums` have matching `const` definitions and external +declarations. Their values and the compression/decompression algorithms are +unchanged. These lookup tables are never mutated by this library. On ESP the +change allows 3072 bytes formerly linked into internal writable `.data` to be +linked as read-only data in flash. Confirm the final firmware map after builds; +object-file placement alone is not proof of the final link location. + +The legacy randomized-block table is retained for decoder compatibility. +Resource decompression workspaces use the existing application PSRAM allocator; +this patch does not change their allocation or lifetime. + +`tests/hostlink/bzip2_compat_cli.c` builds against the actual vendored sources. +`tests/hostlink/test_bzip2_compat.py` checks both compression directions against +Python libbz2, levels 1 and 9, both decompression memory modes, and corrupted +headers/checksums. Tests use modern streams and do not prove legacy randomized +block decoding. The values of the legacy lookup table are unchanged. diff --git a/third_party/bzip2/src/bzlib_private.h b/third_party/bzip2/src/bzlib_private.h index 3755a6f7..2578c2dc 100644 --- a/third_party/bzip2/src/bzlib_private.h +++ b/third_party/bzip2/src/bzlib_private.h @@ -128,7 +128,7 @@ extern void bz_internal_error ( int errcode ); /*-- Stuff for randomising repetitive blocks. --*/ -extern Int32 BZ2_rNums[512]; +extern const Int32 BZ2_rNums[512]; #define BZ_RAND_DECLS \ Int32 rNToGo; \ @@ -152,7 +152,7 @@ extern Int32 BZ2_rNums[512]; /*-- Stuff for doing CRCs. --*/ -extern UInt32 BZ2_crc32Table[256]; +extern const UInt32 BZ2_crc32Table[256]; #define BZ_INITIALISE_CRC(crcVar) \ { \ diff --git a/third_party/bzip2/src/crctable.c b/third_party/bzip2/src/crctable.c index 2b33c253..2852cc8e 100644 --- a/third_party/bzip2/src/crctable.c +++ b/third_party/bzip2/src/crctable.c @@ -28,7 +28,8 @@ comp.compression FAQ. --*/ -UInt32 BZ2_crc32Table[256] = { +/* Trail Mate: immutable lookup data belongs in flash, not internal DRAM. */ +const UInt32 BZ2_crc32Table[256] = { /*-- Ugly, innit? --*/ diff --git a/third_party/bzip2/src/randtable.c b/third_party/bzip2/src/randtable.c index bdc6d4a4..cf88fe95 100644 --- a/third_party/bzip2/src/randtable.c +++ b/third_party/bzip2/src/randtable.c @@ -23,7 +23,8 @@ /*---------------------------------------------*/ -Int32 BZ2_rNums[512] = { +/* Trail Mate: retain legacy randomized-block decoding with a read-only table. */ +const Int32 BZ2_rNums[512] = { 619, 720, 127, 481, 931, 816, 813, 233, 566, 247, 985, 724, 205, 454, 863, 491, 741, 242, 949, 214, 733, 859, 335, 708, 621, 574, 73, 654, 730, 472, diff --git a/third_party/route_png/MINIZ-LICENSE.txt b/third_party/route_png/MINIZ-LICENSE.txt new file mode 100644 index 00000000..ed7cccdf --- /dev/null +++ b/third_party/route_png/MINIZ-LICENSE.txt @@ -0,0 +1,26 @@ +/* + This is free and unencumbered software released into the public domain. + + Anyone is free to copy, modify, publish, use, compile, sell, or + distribute this software, either in source code form or as a compiled + binary, for any purpose, commercial or non-commercial, and by any + means. + + In jurisdictions that recognize copyright laws, the author or authors + of this software dedicate any and all copyright interest in the + software to the public domain. We make this dedication for the benefit + of the public at large and to the detriment of our heirs and + successors. We intend this dedication to be an overt act of + relinquishment in perpetuity of all present and future rights to this + software under copyright law. + + 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 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. + + For more information, please refer to +*/ diff --git a/third_party/route_png/README.md b/third_party/route_png/README.md new file mode 100644 index 00000000..57d97660 --- /dev/null +++ b/third_party/route_png/README.md @@ -0,0 +1,44 @@ +# Private route PNG decoder + +The route image cache uses a private compiled instance of pngle from the pinned +LovyanGFX 1.2.28 dependency. The original LovyanGFX PNG API and shared inflater +remain unchanged. Platform wrappers compile these sources; do not also compile +them as an independent library. + +Source provenance: + +- `lgfx_pngle.c` SHA256 before modification: + `90a4b5e352771939ebe42af87731dc9b376f449fc4c1f4dd5a45941a97d7da42`. +- `lgfx_miniz.c` SHA256 before extraction: + `069f08db6105969b3fa677160a66deb2a5d630e5283d8f371cccb429ed4d0926`. + `route_inflate.c` copies its low-level inflater only, originally lines + 1390–1710, with caller-supplied produced-history validation. +- pngle's MIT notices remain in its source/header. miniz is public domain; + the upstream statement is retained in `MINIZ-LICENSE.txt`. + +Local changes: + +- Prefix the private exports and use caller allocator hooks. ESP route hooks + allocate only in PSRAM, with a bounded allocation size and no internal fallback. +- Zero decoder storage; validate match distance against produced history in + wrapping mode, including zero/oversized distances. No undeclared dictionary + bytes can be read for a match. +- Check Adler in the PNG caller; upstream disables it in the shared inflater. +- Require raster completion and actual zlib termination at IEND; reject extra + raster/stream bytes and stalled decompression. Allow legal empty IDAT chunks. +- Bound source dimensions and pixel count; reject unsupported depth/header data. +- Complete tiny Adam7 passes correctly; reset filter prefix and pass rows. +- Preserve full 16-bit precision for transparent sample matching; reject invalid + palette indices and transparent samples. Avoid signed shifts and unsigned + guard-index underflow. + +`route_png_cache.cpp` additionally validates complete PNG chunk CRCs, ordering, +lengths, critical chunk types and EOF before creating the decoder. It streams +rows into a caller-provided BGR24 cover image, composites alpha over white and +uses integer nearest-sample coordinates. It never allocates the full source +image. Source limits are 8 MiB, 8192 per dimension, 32 million pixels and 65536 +chunks. Existing route cache outputs are at most 320×180. + +The wrapper currently requires the pinned LovyanGFX miniz headers and checksum +exports. A build without them returns unsupported rather than allocating another +decoder. Wio L2 is the verified SDK target; this does not prove other boards. diff --git a/third_party/route_png/library.json b/third_party/route_png/library.json new file mode 100644 index 00000000..a163b9f9 --- /dev/null +++ b/third_party/route_png/library.json @@ -0,0 +1,9 @@ +{ + "name": "trailmate-route-png-source", + "version": "0.1.0", + "build": { + "includeDir": ".", + "srcDir": ".", + "srcFilter": ["-<*>"] + } +} diff --git a/third_party/route_png/route_inflate.c b/third_party/route_png/route_inflate.c new file mode 100644 index 00000000..ca94ca8e --- /dev/null +++ b/third_party/route_png/route_inflate.c @@ -0,0 +1,582 @@ +// miniz 1.15/LovyanGFX 1.2.28 inflater-only snapshot, public domain by +// Rich Geldreich. Private route instance validates already-produced history. +#include +#include +#include +#define MZ_MAX(a, b) (((a) > (b)) ? (a) : (b)) +#define MZ_MIN(a, b) (((a) < (b)) ? (a) : (b)) +#define MZ_CLEAR_OBJ(obj) memset(&(obj), 0, sizeof(obj)) +#define MZ_READ_LE16(p) ((lgfx_mz_uint32)((const lgfx_mz_uint8*)(p))[0] | ((lgfx_mz_uint32)((const lgfx_mz_uint8*)(p))[1] << 8)) +#define MZ_READ_LE32(p) (MZ_READ_LE16(p) | (MZ_READ_LE16((const lgfx_mz_uint8*)(p) + 2) << 16)) +#define lgfx_tinfl_decompress trail_mate_tinfl_decompress +#define TINFL_MEMCPY(d, s, l) memcpy(d, s, l) +#define TINFL_MEMSET(p, c, l) memset(p, c, l) + +#define TINFL_CR_BEGIN \ + switch (r->m_state) \ + { \ + case 0: +#define TINFL_CR_RETURN(state_index, result) \ + do \ + { \ + status = result; \ + r->m_state = state_index; \ + goto common_exit; \ + case state_index:; \ + } \ + MZ_MACRO_END +#define TINFL_CR_RETURN_FOREVER(state_index, result) \ + do \ + { \ + for (;;) \ + { \ + TINFL_CR_RETURN(state_index, result); \ + } \ + } \ + MZ_MACRO_END +#define TINFL_CR_FINISH } + +// TODO: If the caller has indicated that there's no more input, and we attempt to read beyond the input buf, then something is wrong with the input because the inflator never +// reads ahead more than it needs to. Currently TINFL_GET_BYTE() pads the end of the stream with 0's in this scenario. +#define TINFL_GET_BYTE(state_index, c) \ + do \ + { \ + while (pIn_buf_cur >= pIn_buf_end && (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT)) \ + { \ + TINFL_CR_RETURN(state_index, TINFL_STATUS_NEEDS_MORE_INPUT); \ + } \ + c = 0; \ + if (pIn_buf_cur < pIn_buf_end) \ + { \ + c = *pIn_buf_cur++; \ + } \ + } \ + MZ_MACRO_END +#define TINFL_NEED_BITS(state_index, n) \ + do \ + { \ + lgfx_mz_uint c; \ + TINFL_GET_BYTE(state_index, c); \ + bit_buf |= (((lgfx_tinfl_bit_buf_t)c) << num_bits); \ + num_bits += 8; \ + } while (num_bits < (lgfx_mz_uint)(n)) +#define TINFL_SKIP_BITS(state_index, n) \ + do \ + { \ + if (num_bits < (lgfx_mz_uint)(n)) \ + { \ + TINFL_NEED_BITS(state_index, n); \ + } \ + bit_buf >>= (n); \ + num_bits -= (n); \ + } \ + MZ_MACRO_END +#define TINFL_GET_BITS(state_index, b, n) \ + do \ + { \ + if (num_bits < (lgfx_mz_uint)(n)) \ + { \ + TINFL_NEED_BITS(state_index, n); \ + } \ + b = bit_buf & ((1 << (n)) - 1); \ + bit_buf >>= (n); \ + num_bits -= (n); \ + } \ + MZ_MACRO_END + +// TINFL_HUFF_BITBUF_FILL() is only used rarely, when the number of bytes remaining in the input buffer falls below 2. +// It reads just enough bytes from the input stream that are needed to decode the next Huffman code (and absolutely no more). It works by trying to fully decode a +// Huffman code by using whatever bits are currently present in the bit buffer. If this fails, it reads another byte, and tries again until it succeeds or until the +// bit buffer contains >=15 bits (deflate's max. Huffman code size). +#define TINFL_HUFF_BITBUF_FILL(state_index, pHuff) \ + do \ + { \ + temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]; \ + if (temp >= 0) \ + { \ + code_len = temp >> 9; \ + if ((code_len) && (num_bits >= code_len)) \ + break; \ + } \ + else if (num_bits > TINFL_FAST_LOOKUP_BITS) \ + { \ + code_len = TINFL_FAST_LOOKUP_BITS; \ + do \ + { \ + temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \ + } while ((temp < 0) && (num_bits >= (code_len + 1))); \ + if (temp >= 0) break; \ + } \ + TINFL_GET_BYTE(state_index, c); \ + bit_buf |= (((lgfx_tinfl_bit_buf_t)c) << num_bits); \ + num_bits += 8; \ + } while (num_bits < 15); + +// TINFL_HUFF_DECODE() decodes the next Huffman coded symbol. It's more complex than you would initially expect because the zlib API expects the decompressor to never read +// beyond the final byte of the deflate stream. (In other words, when this macro wants to read another byte from the input, it REALLY needs another byte in order to fully +// decode the next Huffman code.) Handling this properly is particularly important on raw deflate (non-zlib) streams, which aren't followed by a byte aligned adler-32. +// The slow path is only executed at the very end of the input buffer. +#define TINFL_HUFF_DECODE(state_index, sym, pHuff) \ + do \ + { \ + int temp; \ + lgfx_mz_uint code_len, c; \ + if (num_bits < 15) \ + { \ + if ((pIn_buf_end - pIn_buf_cur) < 2) \ + { \ + TINFL_HUFF_BITBUF_FILL(state_index, pHuff); \ + } \ + else \ + { \ + c = ((lgfx_tinfl_bit_buf_t)pIn_buf_cur[0]) + (((lgfx_tinfl_bit_buf_t)pIn_buf_cur[1]) << 8); \ + bit_buf += c << num_bits; \ + pIn_buf_cur += 2; \ + num_bits += 16; \ + } \ + } \ + if ((temp = (pHuff)->m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= 0) \ + code_len = temp >> 9, temp &= 511; \ + else \ + { \ + code_len = TINFL_FAST_LOOKUP_BITS; \ + do \ + { \ + temp = (pHuff)->m_tree[~temp + ((bit_buf >> code_len++) & 1)]; \ + } while (temp < 0); \ + } \ + sym = temp; \ + bit_buf >>= code_len; \ + num_bits -= code_len; \ + } \ + MZ_MACRO_END + +lgfx_tinfl_status lgfx_tinfl_decompress(lgfx_tinfl_decompressor* r, const lgfx_mz_uint8* pIn_buf_next, size_t* pIn_buf_size, lgfx_mz_uint8* pOut_buf_start, lgfx_mz_uint8* pOut_buf_next, size_t* pOut_buf_size, const lgfx_mz_uint32 decomp_flags, uint64_t produced_before) +{ + static const lgfx_mz_uint16 s_length_base[32] = {0, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0}; + static const lgfx_mz_uint8 s_length_extra[32] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, 0, 0}; + static const lgfx_mz_uint16 s_dist_base[32] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, 0, 0}; + static const lgfx_mz_uint8 s_dist_extra[32] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; + static const lgfx_mz_uint8 s_length_dezigzag[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + static const lgfx_mz_uint16 s_min_table_sizes[3] = {257, 1, 4}; + + lgfx_tinfl_status status = TINFL_STATUS_FAILED; + lgfx_mz_uint32 num_bits, dist, counter, num_extra; + lgfx_tinfl_bit_buf_t bit_buf; + const lgfx_mz_uint8 *pIn_buf_cur = pIn_buf_next, *const pIn_buf_end = pIn_buf_next + *pIn_buf_size; + lgfx_mz_uint8 *pOut_buf_cur = pOut_buf_next, *const pOut_buf_end = pOut_buf_next + *pOut_buf_size; + size_t out_buf_size_mask = (decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF) ? (size_t)-1 : ((pOut_buf_next - pOut_buf_start) + *pOut_buf_size) - 1, dist_from_out_buf_start; + + // Ensure the output buffer's size is a power of 2, unless the output buffer is large enough to hold the entire output file (in which case it doesn't matter). + if (((out_buf_size_mask + 1) & out_buf_size_mask) || (pOut_buf_next < pOut_buf_start)) + { + *pIn_buf_size = *pOut_buf_size = 0; + return TINFL_STATUS_BAD_PARAM; + } + + num_bits = r->m_num_bits; + bit_buf = r->m_bit_buf; + dist = r->m_dist; + counter = r->m_counter; + num_extra = r->m_num_extra; + dist_from_out_buf_start = r->m_dist_from_out_buf_start; + TINFL_CR_BEGIN + + bit_buf = num_bits = dist = counter = num_extra = r->m_zhdr0 = r->m_zhdr1 = 0; + r->m_z_adler32 = r->m_check_adler32 = 1; + if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) + { + TINFL_GET_BYTE(1, r->m_zhdr0); + TINFL_GET_BYTE(2, r->m_zhdr1); + counter = (((r->m_zhdr0 * 256 + r->m_zhdr1) % 31 != 0) || (r->m_zhdr1 & 32) || ((r->m_zhdr0 & 15) != 8)); + if (!(decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF)) counter |= (((1U << (8U + (r->m_zhdr0 >> 4))) > 32768U) || ((out_buf_size_mask + 1) < (size_t)(1U << (8U + (r->m_zhdr0 >> 4))))); + if (counter) + { + TINFL_CR_RETURN_FOREVER(36, TINFL_STATUS_FAILED); + } + } + + do + { + TINFL_GET_BITS(3, r->m_final, 3); + r->m_type = r->m_final >> 1; + if (r->m_type == 0) + { + TINFL_SKIP_BITS(5, num_bits & 7); + for (counter = 0; counter < 4; ++counter) + { + if (num_bits) TINFL_GET_BITS(6, r->m_raw_header[counter], 8); + else TINFL_GET_BYTE(7, r->m_raw_header[counter]); + } + if ((counter = (r->m_raw_header[0] | (r->m_raw_header[1] << 8))) != (lgfx_mz_uint)(0xFFFF ^ (r->m_raw_header[2] | (r->m_raw_header[3] << 8)))) + { + TINFL_CR_RETURN_FOREVER(39, TINFL_STATUS_FAILED); + } + while ((counter) && (num_bits)) + { + TINFL_GET_BITS(51, dist, 8); + while (pOut_buf_cur >= pOut_buf_end) + { + TINFL_CR_RETURN(52, TINFL_STATUS_HAS_MORE_OUTPUT); + } + *pOut_buf_cur++ = (lgfx_mz_uint8)dist; + counter--; + } + while (counter) + { + size_t n; + while (pOut_buf_cur >= pOut_buf_end) + { + TINFL_CR_RETURN(9, TINFL_STATUS_HAS_MORE_OUTPUT); + } + while (pIn_buf_cur >= pIn_buf_end) + { + if (decomp_flags & TINFL_FLAG_HAS_MORE_INPUT) + { + TINFL_CR_RETURN(38, TINFL_STATUS_NEEDS_MORE_INPUT); + } + else + { + TINFL_CR_RETURN_FOREVER(40, TINFL_STATUS_FAILED); + } + } + n = MZ_MIN(MZ_MIN((size_t)(pOut_buf_end - pOut_buf_cur), (size_t)(pIn_buf_end - pIn_buf_cur)), counter); + TINFL_MEMCPY(pOut_buf_cur, pIn_buf_cur, n); + pIn_buf_cur += n; + pOut_buf_cur += n; + counter -= (lgfx_mz_uint)n; + } + } + else if (r->m_type == 3) + { + TINFL_CR_RETURN_FOREVER(10, TINFL_STATUS_FAILED); + } + else + { + if (r->m_type == 1) + { + lgfx_mz_uint8* p = r->m_tables[0].m_code_size; + lgfx_mz_uint i; + r->m_table_sizes[0] = 288; + r->m_table_sizes[1] = 32; + TINFL_MEMSET(r->m_tables[1].m_code_size, 5, 32); + for (i = 0; i <= 143; ++i) + { + *p++ = 8; + } + for (; i <= 255; ++i) + { + *p++ = 9; + } + for (; i <= 279; ++i) + { + *p++ = 7; + } + for (; i <= 287; ++i) + { + *p++ = 8; + } + } + else + { + for (counter = 0; counter < 3; counter++) + { + TINFL_GET_BITS(11, r->m_table_sizes[counter], "\05\05\04"[counter]); + r->m_table_sizes[counter] += s_min_table_sizes[counter]; + } + MZ_CLEAR_OBJ(r->m_tables[2].m_code_size); + for (counter = 0; counter < r->m_table_sizes[2]; counter++) + { + lgfx_mz_uint s; + TINFL_GET_BITS(14, s, 3); + r->m_tables[2].m_code_size[s_length_dezigzag[counter]] = (lgfx_mz_uint8)s; + } + r->m_table_sizes[2] = 19; + } + for (; (int)r->m_type >= 0; r->m_type--) + { + int tree_next, tree_cur; + lgfx_tinfl_huff_table* pTable; + lgfx_mz_uint i, j, used_syms, total, sym_index, next_code[17], total_syms[16]; + pTable = &r->m_tables[r->m_type]; + MZ_CLEAR_OBJ(total_syms); + MZ_CLEAR_OBJ(pTable->m_look_up); + MZ_CLEAR_OBJ(pTable->m_tree); + i = r->m_table_sizes[r->m_type] - 1; + do + { + total_syms[pTable->m_code_size[i]]++; + } while (--i != ~0u); + used_syms = 0, total = 0; + next_code[0] = next_code[1] = 0; + for (i = 1; i <= 15; ++i) + { + used_syms += total_syms[i]; + next_code[i + 1] = (total = ((total + total_syms[i]) << 1)); + } + if ((65536 != total) && (used_syms > 1)) + { + TINFL_CR_RETURN_FOREVER(35, TINFL_STATUS_FAILED); + } + for (tree_next = -1, sym_index = 0; sym_index < r->m_table_sizes[r->m_type]; ++sym_index) + { + lgfx_mz_uint rev_code = 0, l, cur_code, code_size = pTable->m_code_size[sym_index]; + if (!code_size) continue; + cur_code = next_code[code_size]++; + for (l = code_size; l > 0; l--, cur_code >>= 1) rev_code = (rev_code << 1) | (cur_code & 1); + if (code_size <= TINFL_FAST_LOOKUP_BITS) + { + lgfx_mz_int16 k = (lgfx_mz_int16)((code_size << 9) | sym_index); + while (rev_code < TINFL_FAST_LOOKUP_SIZE) + { + pTable->m_look_up[rev_code] = k; + rev_code += (1 << code_size); + } + continue; + } + if (0 == (tree_cur = pTable->m_look_up[rev_code & (TINFL_FAST_LOOKUP_SIZE - 1)])) + { + pTable->m_look_up[rev_code & (TINFL_FAST_LOOKUP_SIZE - 1)] = (lgfx_mz_int16)tree_next; + tree_cur = tree_next; + tree_next -= 2; + } + rev_code >>= (TINFL_FAST_LOOKUP_BITS - 1); + for (j = code_size; j > (TINFL_FAST_LOOKUP_BITS + 1); j--) + { + tree_cur -= ((rev_code >>= 1) & 1); + if (!pTable->m_tree[-tree_cur - 1]) + { + pTable->m_tree[-tree_cur - 1] = (lgfx_mz_int16)tree_next; + tree_cur = tree_next; + tree_next -= 2; + } + else tree_cur = pTable->m_tree[-tree_cur - 1]; + } + tree_cur -= ((rev_code >>= 1) & 1); + pTable->m_tree[-tree_cur - 1] = (lgfx_mz_int16)sym_index; + } + if (r->m_type == 2) + { + for (counter = 0; counter < (r->m_table_sizes[0] + r->m_table_sizes[1]);) + { + lgfx_mz_uint s; + TINFL_HUFF_DECODE(16, dist, &r->m_tables[2]); + if (dist < 16) + { + r->m_len_codes[counter++] = (lgfx_mz_uint8)dist; + continue; + } + if ((dist == 16) && (!counter)) + { + TINFL_CR_RETURN_FOREVER(17, TINFL_STATUS_FAILED); + } + num_extra = "\02\03\07"[dist - 16]; + TINFL_GET_BITS(18, s, num_extra); + s += "\03\03\013"[dist - 16]; + TINFL_MEMSET(r->m_len_codes + counter, (dist == 16) ? r->m_len_codes[counter - 1] : 0, s); + counter += s; + } + if ((r->m_table_sizes[0] + r->m_table_sizes[1]) != counter) + { + TINFL_CR_RETURN_FOREVER(21, TINFL_STATUS_FAILED); + } + TINFL_MEMCPY(r->m_tables[0].m_code_size, r->m_len_codes, r->m_table_sizes[0]); + TINFL_MEMCPY(r->m_tables[1].m_code_size, r->m_len_codes + r->m_table_sizes[0], r->m_table_sizes[1]); + } + } + for (;;) + { + lgfx_mz_uint8* pSrc; + for (;;) + { + if (((pIn_buf_end - pIn_buf_cur) < 4) || ((pOut_buf_end - pOut_buf_cur) < 2)) + { + TINFL_HUFF_DECODE(23, counter, &r->m_tables[0]); + if (counter >= 256) + break; + while (pOut_buf_cur >= pOut_buf_end) + { + TINFL_CR_RETURN(24, TINFL_STATUS_HAS_MORE_OUTPUT); + } + *pOut_buf_cur++ = (lgfx_mz_uint8)counter; + } + else + { + int sym2; + lgfx_mz_uint code_len; +#if TINFL_USE_64BIT_BITBUF + if (num_bits < 30) + { + bit_buf |= (((lgfx_tinfl_bit_buf_t)MZ_READ_LE32(pIn_buf_cur)) << num_bits); + pIn_buf_cur += 4; + num_bits += 32; + } +#else + if (num_bits < 15) + { + bit_buf |= (((lgfx_tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits); + pIn_buf_cur += 2; + num_bits += 16; + } +#endif + if ((sym2 = r->m_tables[0].m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= 0) + code_len = sym2 >> 9; + else + { + code_len = TINFL_FAST_LOOKUP_BITS; + do + { + sym2 = r->m_tables[0].m_tree[~sym2 + ((bit_buf >> code_len++) & 1)]; + } while (sym2 < 0); + } + counter = sym2; + bit_buf >>= code_len; + num_bits -= code_len; + if (counter & 256) + break; + +#if !TINFL_USE_64BIT_BITBUF + if (num_bits < 15) + { + bit_buf |= (((lgfx_tinfl_bit_buf_t)MZ_READ_LE16(pIn_buf_cur)) << num_bits); + pIn_buf_cur += 2; + num_bits += 16; + } +#endif + if ((sym2 = r->m_tables[0].m_look_up[bit_buf & (TINFL_FAST_LOOKUP_SIZE - 1)]) >= 0) + code_len = sym2 >> 9; + else + { + code_len = TINFL_FAST_LOOKUP_BITS; + do + { + sym2 = r->m_tables[0].m_tree[~sym2 + ((bit_buf >> code_len++) & 1)]; + } while (sym2 < 0); + } + bit_buf >>= code_len; + num_bits -= code_len; + + pOut_buf_cur[0] = (lgfx_mz_uint8)counter; + if (sym2 & 256) + { + pOut_buf_cur++; + counter = sym2; + break; + } + pOut_buf_cur[1] = (lgfx_mz_uint8)sym2; + pOut_buf_cur += 2; + } + } + if ((counter &= 255) == 0) break; + + num_extra = s_length_extra[counter]; + counter = s_length_base[counter]; + if (num_extra) + { + lgfx_mz_uint extra_bits; + TINFL_GET_BITS(25, extra_bits, num_extra); + counter += extra_bits; + } + + TINFL_HUFF_DECODE(26, dist, &r->m_tables[1]); + num_extra = s_dist_extra[dist]; + dist = s_dist_base[dist]; + if (num_extra) + { + lgfx_mz_uint extra_bits; + TINFL_GET_BITS(27, extra_bits, num_extra); + dist += extra_bits; + } + + dist_from_out_buf_start = pOut_buf_cur - pOut_buf_start; + if (!dist || dist > 32768 || (uint64_t)dist > produced_before + (size_t)(pOut_buf_cur - pOut_buf_next)) + { + TINFL_CR_RETURN_FOREVER(43, TINFL_STATUS_FAILED); + } + if ((decomp_flags & TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF) && (dist > dist_from_out_buf_start)) + { + TINFL_CR_RETURN_FOREVER(37, TINFL_STATUS_FAILED); + } + + pSrc = pOut_buf_start + ((dist_from_out_buf_start - dist) & out_buf_size_mask); + + if ((MZ_MAX(pOut_buf_cur, pSrc) + counter) > pOut_buf_end) + { + while (counter--) + { + while (pOut_buf_cur >= pOut_buf_end) + { + TINFL_CR_RETURN(53, TINFL_STATUS_HAS_MORE_OUTPUT); + } + *pOut_buf_cur++ = pOut_buf_start[(dist_from_out_buf_start++ - dist) & out_buf_size_mask]; + } + continue; + } +#if MINIZ_USE_UNALIGNED_LOADS_AND_STORES + else if ((counter >= 9) && (counter <= dist)) + { + lgfx_mz_uint32 copylen = counter & ~3; + memcpy(pOut_buf_cur, pSrc, copylen); + pOut_buf_cur += copylen; + pSrc += copylen; + if ((counter &= 3) < 3) + { + if (counter) + { + pOut_buf_cur[0] = pSrc[0]; + if (counter > 1) + pOut_buf_cur[1] = pSrc[1]; + pOut_buf_cur += counter; + } + continue; + } + } +#endif + lgfx_mz_uint32 i = 0; + do + { + pOut_buf_cur[i] = pSrc[i]; + } while (++i != counter); + pOut_buf_cur += counter; + } + } + } while (!(r->m_final & 1)); + if (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) + { + TINFL_SKIP_BITS(32, num_bits & 7); + for (counter = 0; counter < 4; ++counter) + { + lgfx_mz_uint s; + if (num_bits) TINFL_GET_BITS(41, s, 8); + else TINFL_GET_BYTE(42, s); + r->m_z_adler32 = (r->m_z_adler32 << 8) | s; + } + } + TINFL_CR_RETURN_FOREVER(34, TINFL_STATUS_DONE); + TINFL_CR_FINISH + +common_exit: + r->m_num_bits = num_bits; + r->m_bit_buf = bit_buf; + r->m_dist = dist; + r->m_counter = counter; + r->m_num_extra = num_extra; + r->m_dist_from_out_buf_start = dist_from_out_buf_start; + *pIn_buf_size = pIn_buf_cur - pIn_buf_next; + *pOut_buf_size = pOut_buf_cur - pOut_buf_next; + /* + if ((decomp_flags & (TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_COMPUTE_ADLER32)) && (status >= 0)) + { + const lgfx_mz_uint8 *ptr = pOut_buf_next; size_t buf_len = *pOut_buf_size; + lgfx_mz_uint32 i, s1 = r->m_check_adler32 & 0xffff, s2 = r->m_check_adler32 >> 16; size_t block_len = buf_len % 5552; + while (buf_len) + { + for (i = 0; i < block_len; ++i) s1 += *ptr++, s2 += s1; + s1 %= 65521U, s2 %= 65521U; buf_len -= block_len; block_len = 5552; + } + r->m_check_adler32 = (s2 << 16) + s1; if ((status == TINFL_STATUS_DONE) && (decomp_flags & TINFL_FLAG_PARSE_ZLIB_HEADER) && (r->m_check_adler32 != r->m_z_adler32)) status = TINFL_STATUS_ADLER32_MISMATCH; + } + //*/ + return status; +} +#undef lgfx_tinfl_decompress diff --git a/third_party/route_png/route_pngle.c b/third_party/route_png/route_pngle.c new file mode 100644 index 00000000..120163da --- /dev/null +++ b/third_party/route_png/route_pngle.c @@ -0,0 +1,735 @@ +/*- + * MIT License + * + * Copyright (c) 2019 kikuchan + * + * 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. + */ + +/*----------------------------------------------------------------------------/ +/ original source is here : https://github.com/kikuchan/pngle/ +/ +/ Modified for LGFX by lovyan03, 2020-2022 +/ tweak for 32bit processor +/----------------------------------------------------------------------------*/ + +#include +#include +#include +#include +#include +#include + +#include "route_pngle.h" +#include + +#include "pgmspace.h" +#define free trail_mate_png_free + +#ifndef MIN +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +#endif + +#ifdef PNGLE_DEBUG +#define debug_printf(...) fprintf(stderr, __VA_ARGS__) +#else +#define debug_printf(...) ((void)0) +#endif + +typedef enum +{ + PNGLE_STATE_ERROR = -2, + PNGLE_STATE_OK = 0, +} pngle_state_t; + +#define PNGLE_ERROR(s) (debug_printf(s), PNGLE_STATE_ERROR) +#define PNGLE_MALLOC(a, b, name) trail_mate_png_alloc((size_t)(a) * (size_t)(b)) + +#define LGFX_PNGLE_OUTBUF_LEN 64 /// At least 32 required +#define LGFX_PNGLE_READBUF_LEN 512 /// At least 256 required + +#define LGFX_PNGLE_NON_TRANS_COLOR 0x01000000 + +typedef enum +{ + // Supported chunks + PNGLE_CHUNK_IDAT = 0x49444154UL, // IDAT + PNGLE_CHUNK_IEND = 0x49454e44UL, // IEND + PNGLE_CHUNK_PLTE = 0x504c5445UL, // PLTE + PNGLE_CHUNK_tRNS = 0x74524e53UL, // tRNS +} pngle_chunk_t; + +// typedef struct _pngle_t pngle_t; // declared in pngle.h +struct _pngle_t +{ + // PLTE chunk + uint8_t* scanline_buf; + uint8_t* palette; + + size_t n_palettes; + uint32_t trans_color; + + // scanline decoder (reset on every set_interlace_pass() call) + size_t scanline_pixels; + size_t scanline_stride; + size_t scanline_remain_bytes_to_render; + + lgfx_pngle_read_callback_t read_callback; + lgfx_pngle_draw_callback_t draw_callback; + void* user_data; + + uint32_t drawing_y; + + // for grayscale + uint32_t magni; + + pngle_ihdr_t hdr; + + uint8_t filter_type; + // interlace + uint8_t interlace_pass; + + // 0 indicates IHDR hasn't been processed yet + uint8_t channels; + uint8_t image_done, stream_done, invalid, transparency_set; + uint16_t transparent_gray, transparent_rgb[3]; + uint32_t adler; + uint64_t produced; + + // decompression state (reset on IHDR) + uint8_t* next_out; // NULL indicates IDAT hasn't been processed yet + size_t avail_out; + uint32_t out_buf[LGFX_PNGLE_OUTBUF_LEN >> 2]; // out_buf + read_buf (Do not change the order) + uint8_t read_buf[LGFX_PNGLE_READBUF_LEN]; + lgfx_tinfl_decompressor inflator; // 11000 bytes + uint8_t lz_buf[TINFL_LZ_DICT_SIZE]; // 32768 bytes +}; + +// magic +static const uint8_t png_header[] PROGMEM = {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}; +static const uint8_t interlace_div_y[9] PROGMEM = {8, 8, 8, 4, 4, 2, 2, 1, 1}; +static const uint8_t interlace_off_y[9] PROGMEM = {0, 0, 4, 0, 2, 0, 1, 0, 0}; +static const uint8_t* interlace_off_x = &interlace_off_y[1]; +static const uint8_t* interlace_div_x = &interlace_div_y[1]; + +static inline uint32_t swap32(uint32_t c) +{ + c = (c >> 16) + (c << 16); + return ((c >> 8) & 0xFF00FF) + ((c & 0xFF00FF) << 8); +} + +static inline uint32_t read_uint32(const uint8_t* p) +{ + return ((uint32_t)p[0] << 24) + (p[1] << 16) + (p[2] << 8) + (p[3] << 0); +} + +pngle_t* lgfx_pngle_new() +{ + pngle_t* res = (pngle_t*)PNGLE_MALLOC(1, sizeof(pngle_t), "pngle_t"); + if (res) + { + memset(res, 0, sizeof(*res)); + res->palette = NULL; + res->scanline_buf = NULL; + } + return res; +} + +void lgfx_pngle_destroy(pngle_t* pngle) +{ + if (pngle) + { + if (pngle->scanline_buf) + { + free(pngle->scanline_buf); + } + if (pngle->palette) + { + free(pngle->palette); + } + free(pngle); + } +} + +uint32_t lgfx_pngle_get_width(pngle_t* pngle) +{ + if (!pngle) return 0; + return pngle->hdr.width; +} + +uint32_t lgfx_pngle_get_height(pngle_t* pngle) +{ + if (!pngle) return 0; + return pngle->hdr.height; +} + +pngle_ihdr_t* lgfx_pngle_get_ihdr(pngle_t* pngle) +{ + if (!pngle) return NULL; + if (pngle->channels == 0) return NULL; + return &pngle->hdr; +} + +static void make_pixels(pngle_t* pngle, const uint8_t* buf, uint32_t* rgbbuf, size_t len, size_t bit_offset) +{ + size_t depth = pngle->hdr.depth; + uint32_t* argb32 = rgbbuf - 1; + uint32_t* last = &argb32[len]; + if (depth >= 8) + { + size_t add = depth >> 3; + switch (pngle->channels) + { + case 1: // grayscale or palette + do + { + *++argb32 = buf[0] | ((depth == 16 && pngle->hdr.color_type == 0 && pngle->transparency_set && + (((uint16_t)buf[0] << 8) | buf[1]) == pngle->transparent_gray) + ? 0x10000u + : 0); + buf += add; + } while (argb32 != last); + break; + + case 2: // grayscale with alpha + do + { + /// grayscale to color (* 0x010101) + alpha channel + *++argb32 = buf[0] * 0x01010100 + buf[add]; + buf += add * 2; + } while (argb32 != last); + break; + + case 3: // color + do + { + *++argb32 = ((depth == 16 && pngle->transparency_set && + (((uint16_t)buf[0] << 8) | buf[1]) == pngle->transparent_rgb[0] && + (((uint16_t)buf[add] << 8) | buf[add + 1]) == pngle->transparent_rgb[1] && + (((uint16_t)buf[add * 2] << 8) | buf[add * 2 + 1]) == pngle->transparent_rgb[2]) + ? 0u + : 0xFFu) + + (buf[0] << 8) + (buf[add] << 16) + ((uint32_t)buf[add * 2] << 24); + buf += add * 3; + } while (argb32 != last); + break; + + default: // 4 : color + alpha + do + { + *++argb32 = (buf[0] << 8) + (buf[add] << 16) + ((uint32_t)buf[add * 2] << 24) + (buf[add * 3]); + buf += add * 4; + } while (argb32 != last); + break; + } + } + else + { + size_t mask = ((1 << depth) - 1); + size_t shift = 8 - bit_offset; + size_t b = buf[0]; + do + { + shift -= depth; + *++argb32 = (b >> shift) & mask; + if (shift == 0) + { + shift = 8; + if (argb32 != last) b = *++buf; + } + } while (argb32 != last); + } + + argb32 = rgbbuf - 1; + // color type: 0000 0111 + // ^-- indexed color (palette) + // ^--- Color + // ^---- Alpha channel + switch (pngle->hdr.color_type) + { + case 0: // grayscale + { + uint32_t tp = pngle->trans_color; + uint32_t magni = pngle->magni; + do + { + uint32_t sample = *++argb32; + uint32_t tmp = sample & 0xFFu; + *argb32 = ((depth == 16 ? (sample & 0x10000u) : tmp == tp) ? 0 : 0xFF) + (tmp * magni); + } while (argb32 != last); + } + break; + + case 2: // true color + if (depth != 16 && pngle->trans_color != LGFX_PNGLE_NON_TRANS_COLOR) + { + uint32_t tp = pngle->trans_color; + do + { + if (*++argb32 == tp) + { + ((uint8_t*)argb32)[0] = 0; + } + } while (argb32 != last); + } + break; + + case 3: // indexed color + { + size_t n_palettes = pngle->n_palettes; + uint32_t* palette = ((uint32_t*)(pngle->palette)); + do + { // lookup palette info + size_t pidx = *++argb32; + if (pidx >= n_palettes) pngle->invalid = 1; + *argb32 = pidx < n_palettes ? palette[pidx] : ~0u; + } while (argb32 != last); + } + break; + + // case 4: // grayscale with alpha ...nothing to do. + // case 6: // truecolor with alpha ...nothing to do. + default: + break; + } +} + +static inline int paeth(int a, int b, int c) +{ + int pa = b - c; + int pb = a - c; + int pc = abs(pa + pb); + pa = abs(pa); + pb = abs(pb); + if (pb < pa) + { + pa = pb; + a = b; + } + return (pc < pa) ? c : a; +} + +static int set_interlace_pass(pngle_t* pngle, uint_fast8_t pass) +{ + const uint_fast8_t last_pass = pngle->hdr.interlace ? 6 : 7; + while (pass <= last_pass && (pngle->hdr.width <= pgm_read_byte(&interlace_off_x[pass]) || pngle->hdr.height <= pgm_read_byte(&interlace_off_y[pass]))) + { + ++pass; + } + if (pass > last_pass) return 0; + pngle->interlace_pass = pass; + pngle->drawing_y = pgm_read_byte(&interlace_off_y[pass]); + size_t div_x = pgm_read_byte(&interlace_div_x[pass]); + size_t scanline_pixels = (pngle->hdr.width - pgm_read_byte(&interlace_off_x[pass]) + div_x - 1) / div_x; + pngle->scanline_pixels = scanline_pixels; + size_t scanline_stride = (scanline_pixels * pngle->channels * pngle->hdr.depth + 7) >> 3; + pngle->scanline_stride = scanline_stride; + pngle->scanline_remain_bytes_to_render = scanline_stride; + return 1; +} + +static int pngle_on_data(pngle_t* pngle, uint8_t* lzbuf, size_t len, size_t outbuf_len) +{ + if (pngle->image_done) return len ? PNGLE_ERROR("Extra raster bytes") : 0; + uint_fast8_t bytes_per_pixel = (pngle->channels * pngle->hdr.depth + 7) >> 3; // 1 if depth <= 8 + size_t filter_type = pngle->filter_type; + size_t remain_bytes = pngle->scanline_remain_bytes_to_render; + + const uint8_t* p = lzbuf; + uint8_t* scanline = &(pngle->scanline_buf[bytes_per_pixel]); + while (len) + { + if (filter_type > 4) + { + filter_type = *p++; // 0 - 4 + memset(scanline - bytes_per_pixel, 0, bytes_per_pixel); + if (filter_type >= 4) + { + if (filter_type > 4) + { + debug_printf("[pngle] Invalid filter type is found; 0x%02x\n", *p); + return PNGLE_ERROR("Invalid filter type is found"); + } + // Only when using the paeth filter, shift the previous result to the right by bytes_per_pixel. (To refer to the result of the upper left pixel). + memmove(scanline, scanline - bytes_per_pixel, bytes_per_pixel + pngle->scanline_stride); + } + if (--len == 0) + { + break; + } + } + size_t cidx = pngle->scanline_stride - remain_bytes; + + size_t l = (len < remain_bytes) ? len : remain_bytes; + remain_bytes -= l; + len -= l; + + const uint8_t* newdata = p - cidx; + p += l; + size_t last = cidx + l; + + switch (filter_type) + { + default: + memcpy(&scanline[cidx], &newdata[cidx], l); + cidx = last; + break; + case 1: + do + { + scanline[cidx] = newdata[cidx] + *(scanline + cidx - bytes_per_pixel); + } while (++cidx != last); + break; + case 2: + do + { + scanline[cidx] += newdata[cidx]; + } while (++cidx != last); + break; + case 3: + do + { + scanline[cidx] = newdata[cidx] + ((*(scanline + cidx - bytes_per_pixel) + scanline[cidx]) >> 1); + } while (++cidx != last); + break; + case 4: + do + { + scanline[cidx] = newdata[cidx] + paeth(*(scanline + cidx - bytes_per_pixel), scanline[cidx + bytes_per_pixel], scanline[cidx]); + } while (++cidx != last); + break; + } + if (remain_bytes) + { + break; + } + + remain_bytes = pngle->scanline_stride; // reset + filter_type = ~0; + + uint32_t draw_x = pgm_read_byte(&interlace_off_x[pngle->interlace_pass]); + uint32_t div_x = pgm_read_byte(&interlace_div_x[pngle->interlace_pass]); + size_t scanline_pixels = pngle->scanline_pixels; + size_t out_pos = 0; + size_t out_len = ((((scanline_pixels + 7) & ~7) - 1) % outbuf_len) + 1; + + do + { + if (out_len > scanline_pixels - out_pos) + { + out_len = scanline_pixels - out_pos; + } + size_t bit_pos = out_pos * pngle->channels * pngle->hdr.depth; + make_pixels(pngle, &scanline[bit_pos >> 3], pngle->out_buf, out_len, bit_pos & 7); + if (pngle->invalid) return PNGLE_ERROR("Invalid palette index"); + pngle->draw_callback(pngle->user_data, draw_x + out_pos * div_x, pngle->drawing_y, div_x, out_len, (const uint8_t*)pngle->out_buf); + + out_pos += out_len; + out_len = outbuf_len; + } while (out_pos < scanline_pixels); + + pngle->drawing_y += pgm_read_byte(&interlace_div_y[pngle->interlace_pass]); + if (pngle->drawing_y >= pngle->hdr.height) + { + // Interlace: Next pass + if (!set_interlace_pass(pngle, pngle->interlace_pass + 1)) + { + pngle->image_done = 1; + return len ? PNGLE_ERROR("Extra raster bytes") : 0; + } + debug_printf("[pngle] interlace pass changed to: %d\n", pngle->interlace_pass); + remain_bytes = pngle->scanline_stride; // reset + memset(pngle->scanline_buf, 0, remain_bytes + 2 * bytes_per_pixel); + } + } + pngle->scanline_remain_bytes_to_render = remain_bytes; + pngle->filter_type = filter_type; + + return 0; +} + +int lgfx_pngle_prepare(pngle_t* pngle, lgfx_pngle_read_callback_t read_cb, void* user_data) +{ + if (pngle == NULL || read_cb == NULL) + { + return PNGLE_STATE_ERROR; + } + if (pngle->palette) + { + free(pngle->palette); + pngle->palette = NULL; + } + if (pngle->scanline_buf) + { + free(pngle->scanline_buf); + pngle->scanline_buf = NULL; + } + + pngle->read_callback = read_cb; + pngle->user_data = user_data; + pngle->n_palettes = 0; + pngle->next_out = pngle->lz_buf; + pngle->avail_out = TINFL_LZ_DICT_SIZE; + pngle->filter_type = ~0; + pngle->image_done = pngle->stream_done = pngle->invalid = pngle->transparency_set = 0; + pngle->adler = 1; + pngle->produced = 0; + pngle->trans_color = LGFX_PNGLE_NON_TRANS_COLOR; + lgfx_tinfl_init(&pngle->inflator); + + if (pngle->read_callback(user_data, pngle->read_buf, 29) != 29 || memcmp_P(pngle->read_buf, png_header, sizeof(png_header))) return PNGLE_ERROR("Incorrect PNG signature"); + + memcpy(&(pngle->hdr), &pngle->read_buf[16], sizeof(pngle_ihdr_t)); + pngle->hdr.width = swap32(pngle->hdr.width); + pngle->hdr.height = swap32(pngle->hdr.height); + if (!pngle->hdr.width || !pngle->hdr.height || pngle->hdr.width > 8192 || pngle->hdr.height > 8192 || + (uint64_t)pngle->hdr.width * pngle->hdr.height > 32000000 || pngle->hdr.interlace > 1) + return PNGLE_ERROR("Image exceeds route decode budget"); + + debug_printf("[pngle] width : %d\n", pngle->hdr.width); + debug_printf("[pngle] height : %d\n", pngle->hdr.height); + debug_printf("[pngle] depth : %d\n", pngle->hdr.depth); + debug_printf("[pngle] color_type : %d\n", pngle->hdr.color_type); + debug_printf("[pngle] compression: %d\n", pngle->hdr.compression); + debug_printf("[pngle] filter : %d\n", pngle->hdr.filter); + debug_printf("[pngle] interlace : %d\n", pngle->hdr.interlace); + + if (pngle->hdr.compression != 0) return PNGLE_ERROR("Unsupported compression type in IHDR"); + if (pngle->hdr.filter != 0) return PNGLE_ERROR("Unsupported filter type in IHDR"); + + /* + Color Allowed Interpretation channels + Type Bit Depths + + 0 1,2,4,8,16 Each pixel is a grayscale sample. 1 channels (Brightness) + + 2 8,16 Each pixel is an R,G,B triple. 3 channels (R, G, B) + + 3 1,2,4,8 Each pixel is a palette index; 1 channels (palette info) + a PLTE chunk must appear. + + 4 8,16 Each pixel is a grayscale sample, 2 channels (Brightness, Alpha) + followed by an alpha sample. + + 6 8,16 Each pixel is an R,G,B triple, 4 channels (R, G, B, Alpha) + followed by an alpha sample. + */ + // 111 + // ^-- indexed color (palette) + // ^--- Color + // ^---- Alpha channel + { + static const uint8_t table[] PROGMEM = + {1 << 5 | 0b00000 // type 0 : 1 channels (Brightness) + , + 0b11111, 3 << 5 | 0b00111 // type 2 : 3 channels (R, G, B) + , + 1 << 5 | 0b10000 // type 3 : 1 channels (palette info) + , + 2 << 5 | 0b00111 // type 4 : 2 channels (Brightness, Alpha) + , + 0b11111, 4 << 5 | 0b00111 // type 6 : 4 channels (R, G, B, Alpha) + , + 0b11111}; + uint_fast8_t ch_mask = pgm_read_byte(&table[pngle->hdr.color_type & 7]); + pngle->channels = ch_mask >> 5; + int depth = pngle->hdr.depth; + if (pngle->hdr.color_type > 7 || pngle->channels == 0 || depth == 0 || depth > 16 || (depth & ch_mask) || (depth != (depth & (-depth)))) return PNGLE_ERROR("Incorrect IHDR info"); + + pngle->magni = 0x01010100 * ((depth == 1) ? 0xFF + : (depth == 2) ? 0x55 + : (depth == 4) ? 0x11 + : 0x01); + + uint_fast8_t bytes_per_pixel = (pngle->channels * pngle->hdr.depth + 7) >> 3; + size_t memlen = ((pngle->hdr.width * pngle->channels * pngle->hdr.depth + 7) >> 3) + (2 * bytes_per_pixel); + if ((pngle->scanline_buf = (uint8_t*)PNGLE_MALLOC(memlen, 1, "scanline flipbuf")) == NULL) return PNGLE_ERROR("Insufficient memory"); + memset(pngle->scanline_buf, 0, memlen); + } + // interlace + if (!set_interlace_pass(pngle, pngle->hdr.interlace ? 0 : 7)) return PNGLE_ERROR("Invalid scanline"); + + return 0; +} + +int lgfx_pngle_decomp(pngle_t* pngle, lgfx_pngle_draw_callback_t draw_cb) +{ + if (pngle == NULL || draw_cb == NULL) + { + return PNGLE_STATE_ERROR; + } + pngle->draw_callback = draw_cb; + + uint8_t* read_buf = pngle->read_buf; + for (;;) + { + if (pngle->read_callback(pngle->user_data, read_buf, 12) != 12) + { + return PNGLE_ERROR("Insufficient data"); + } + + uint32_t chunk_remain = read_uint32(&read_buf[4]); + uint32_t chunk_type = read_uint32(&read_buf[8]); + + debug_printf("[pngle] Chunk '%.4s' len %u\n", read_buf + 8, chunk_remain); + + // initialize & sanity check + switch (chunk_type) + { + default: + if (pngle->read_callback(pngle->user_data, NULL, chunk_remain) != chunk_remain) + { + return PNGLE_ERROR("Insufficient data"); + } + break; + + case PNGLE_CHUNK_IDAT: + if (chunk_remain == 0) break; // legal empty consecutive IDAT chunk + if (pngle->stream_done || (pngle->hdr.color_type == 3 && !pngle->palette)) return PNGLE_ERROR("Invalid IDAT state"); + + do + { + size_t len = pngle->read_callback(pngle->user_data, read_buf, (chunk_remain < LGFX_PNGLE_READBUF_LEN) ? chunk_remain : LGFX_PNGLE_READBUF_LEN); + if (len == 0) + { + return PNGLE_ERROR("Insufficient data"); + } + chunk_remain -= len; + + debug_printf("[pngle] Reading IDAT (len %zd / chunk remain %u)\n", len, chunk_remain); + + // debug_printf("[pngle] in_bytes %zd, out_bytes %zd, next_out %p\n", in_bytes, out_bytes, pngle->next_out); + size_t in_pos = 0; + do + { + size_t in_bytes = len; + size_t out_bytes = pngle->avail_out; + + // XXX: lgfx_tinfl_decompress always requires (next_out - lz_buf + avail_out) == TINFL_LZ_DICT_SIZE + lgfx_tinfl_status status = trail_mate_tinfl_decompress(&pngle->inflator, (const lgfx_mz_uint8*)&read_buf[in_pos], &in_bytes, pngle->lz_buf, (lgfx_mz_uint8*)pngle->next_out, &out_bytes, TINFL_FLAG_HAS_MORE_INPUT | TINFL_FLAG_PARSE_ZLIB_HEADER, pngle->produced); + if (status < TINFL_STATUS_DONE) + { + // Decompression failed. + debug_printf("[pngle] lgfx_tinfl_decompress() failed with status %d!\n", status); + return PNGLE_ERROR("Failed to decompress the IDAT stream"); + } + if (!in_bytes && !out_bytes) return PNGLE_ERROR("Inflate made no progress"); + + len -= in_bytes; + in_pos += in_bytes; + + // debug_printf("[pngle] lgfx_tinfl_decompress\n"); + // debug_printf("[pngle] => in_bytes %zd, out_bytes %zd, next_out %p, status %d\n", in_bytes, out_bytes, pngle->next_out, status); + + if (out_bytes) + { + pngle->produced += out_bytes; + // LovyanGFX disables tinfl's Adler check. Keep it in this private + // caller without altering the shared inflater or adding buffers. + pngle->adler = (uint32_t)lgfx_mz_adler32(pngle->adler, pngle->next_out, out_bytes); + if (pngle_on_data(pngle, pngle->next_out, out_bytes, (LGFX_PNGLE_OUTBUF_LEN >> 2) + (len ? in_pos >> 2 : (LGFX_PNGLE_READBUF_LEN >> 2))) < 0) return -1; + } + if (status == TINFL_STATUS_DONE) + { + pngle->stream_done = 1; + if (len || chunk_remain || !pngle->image_done || pngle->adler != pngle->inflator.m_z_adler32) return PNGLE_ERROR("Invalid stream termination"); + } + pngle->next_out += out_bytes; + pngle->avail_out -= out_bytes; + // debug_printf("[pngle] => avail_out %zd, next_out %p\n", pngle->avail_out, pngle->next_out); + if (pngle->avail_out == 0 || status == TINFL_STATUS_DONE) + { // Output buffer is full, or decompression is done, so write buffer to output file. + pngle->avail_out = TINFL_LZ_DICT_SIZE; + pngle->next_out = pngle->lz_buf; + } + } while (len); + } while (chunk_remain); + break; + + case PNGLE_CHUNK_IEND: + return !chunk_remain && pngle->image_done && pngle->stream_done && pngle->read_callback(pngle->user_data, NULL, 4) == 4 ? 0 : PNGLE_ERROR("Incomplete image"); + + case PNGLE_CHUNK_PLTE: + { + // Allow only 2, 3, 6. (2=truecolor / 3=indexed color / 6=truecolor+alpha) + if (((1 << pngle->hdr.color_type) & 0b1001100) == 0) + { + return PNGLE_ERROR("PLTE chunk is prohibited on the color type"); + } + + uint32_t chunk_remain_3 = chunk_remain / 3; + if (chunk_remain_3 > MIN(256, (1UL << pngle->hdr.depth))) return PNGLE_ERROR("Too many palettes in PLTE"); + if (chunk_remain_3 <= 0 || chunk_remain != chunk_remain_3 * 3) return PNGLE_ERROR("Invalid PLTE chunk size"); + if (pngle->palette) return PNGLE_ERROR("Too many PLTE chunk"); + uint8_t* plt = (uint8_t*)PNGLE_MALLOC(chunk_remain_3, 4, "palette"); + if (plt == NULL) return PNGLE_ERROR("Insufficient memory"); + pngle->palette = plt; + pngle->n_palettes = chunk_remain_3; + + if (chunk_remain != pngle->read_callback(pngle->user_data, plt, chunk_remain)) + { + return PNGLE_ERROR("Insufficient data"); + } + while (chunk_remain_3--) + { + ((uint32_t*)(plt))[chunk_remain_3] = (plt[chunk_remain_3 * 3 + 0] << 8) + (plt[chunk_remain_3 * 3 + 1] << 16) + ((uint32_t)plt[chunk_remain_3 * 3 + 2] << 24) + 0xFF; + } + break; + } + + case PNGLE_CHUNK_tRNS: + if (chunk_remain <= 0 || chunk_remain > 256) return PNGLE_ERROR("Invalid tRNS chunk size"); + if (chunk_remain != pngle->read_callback(pngle->user_data, read_buf, chunk_remain)) + { + return PNGLE_ERROR("Insufficient data"); + } + switch (pngle->hdr.color_type) + { + case 3: // indexed color + if (chunk_remain > pngle->n_palettes) return PNGLE_ERROR("Too many palettes in tRNS"); + for (size_t i = 0; i < chunk_remain; ++i) + { + pngle->palette[i * 4] = read_buf[i]; + } + break; + + case 0: // grayscale + if (chunk_remain != 2) return PNGLE_ERROR("Invalid tRNS chunk size"); + pngle->transparency_set = 1; + pngle->transparent_gray = ((uint16_t)read_buf[0] << 8) | read_buf[1]; + if (pngle->hdr.depth < 16 && pngle->transparent_gray >= (1u << pngle->hdr.depth)) return PNGLE_ERROR("Invalid transparent sample"); + pngle->trans_color = read_buf[1]; + break; + + case 2: // truecolor + if (chunk_remain != 6) return PNGLE_ERROR("Invalid tRNS chunk size"); + pngle->transparency_set = 1; + for (unsigned i = 0; i < 3; ++i) pngle->transparent_rgb[i] = ((uint16_t)read_buf[i * 2] << 8) | read_buf[i * 2 + 1]; + if (pngle->hdr.depth == 8 && (read_buf[0] || read_buf[2] || read_buf[4])) return PNGLE_ERROR("Invalid transparent sample"); + pngle->trans_color = (read_buf[1] << 8) + (read_buf[3] << 16) + ((uint32_t)read_buf[5] << 24) + 0xFF; + break; + + default: + return PNGLE_ERROR("tRNS chunk is prohibited on the color type"); + } + break; + } + } + return 0; +} diff --git a/third_party/route_png/route_pngle.h b/third_party/route_png/route_pngle.h new file mode 100644 index 00000000..7cd11266 --- /dev/null +++ b/third_party/route_png/route_pngle.h @@ -0,0 +1,100 @@ +/*- + * MIT License + * + * Copyright (c) 2019 kikuchan + * + * 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. + */ + +/*----------------------------------------------------------------------------/ +/ original source is here : https://github.com/kikuchan/pngle/ +/ +/ Modified for LGFX by lovyan03, 2020-2022 +/ tweak for 32bit processor +/----------------------------------------------------------------------------*/ + +#ifndef TRAIL_MATE_ROUTE_PNGLE_H +#define TRAIL_MATE_ROUTE_PNGLE_H + +#include +#include +#include + +// Private compiled instance: original LovyanGFX symbols and allocator remain +// unchanged. These aliases namespace only this vendored route decoder. +#define lgfx_pngle_new trail_mate_pngle_new +#define lgfx_pngle_destroy trail_mate_pngle_destroy +#define lgfx_pngle_prepare trail_mate_pngle_prepare +#define lgfx_pngle_decomp trail_mate_pngle_decomp +#define lgfx_pngle_get_width trail_mate_pngle_get_width +#define lgfx_pngle_get_height trail_mate_pngle_get_height +#define lgfx_pngle_get_ihdr trail_mate_pngle_get_ihdr + +#ifdef __cplusplus +extern "C" +{ +#endif + + void* trail_mate_png_alloc(size_t bytes); + void trail_mate_png_free(void* memory); + lgfx_tinfl_status trail_mate_tinfl_decompress(lgfx_tinfl_decompressor*, const lgfx_mz_uint8*, size_t*, lgfx_mz_uint8*, lgfx_mz_uint8*, size_t*, lgfx_mz_uint32, uint64_t); + + // Main Pngle object + typedef struct _pngle_t pngle_t; + + // Callback signatures + typedef uint32_t (*lgfx_pngle_read_callback_t)(void* user_data, uint8_t* buf, uint32_t len); + typedef void (*lgfx_pngle_draw_callback_t)(void* user_data, uint32_t x, uint32_t y, uint_fast8_t div_x, size_t len, const uint8_t* argb); + + // ---------------- + // Basic interfaces + // ---------------- + pngle_t* lgfx_pngle_new(); + + int lgfx_pngle_prepare(pngle_t* pngle, lgfx_pngle_read_callback_t read_cb, void* user_data); + int lgfx_pngle_decomp(pngle_t* pngle, lgfx_pngle_draw_callback_t draw_cb); + + void lgfx_pngle_destroy(pngle_t* pngle); + + uint32_t lgfx_pngle_get_width(pngle_t* pngle); + uint32_t lgfx_pngle_get_height(pngle_t* pngle); + + // ---------------- + // Debug interfaces + // ---------------- + + typedef struct __attribute__((packed)) _pngle_ihdr_t + { + uint32_t width; + uint32_t height; + uint8_t depth; + uint8_t color_type; + uint8_t compression; + uint8_t filter; + uint8_t interlace; + } pngle_ihdr_t; + + // Get IHDR information + pngle_ihdr_t* lgfx_pngle_get_ihdr(pngle_t* pngle); + +#ifdef __cplusplus +} +#endif + +#endif /* __PNGLE_H__ */ diff --git a/third_party/yxml/COPYING b/third_party/yxml/COPYING new file mode 100644 index 00000000..138d3c35 --- /dev/null +++ b/third_party/yxml/COPYING @@ -0,0 +1,20 @@ +Copyright (c) 2013-2014 Yoran Heling + +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/third_party/yxml/README.trail-mate.md b/third_party/yxml/README.trail-mate.md new file mode 100644 index 00000000..292ed651 --- /dev/null +++ b/third_party/yxml/README.trail-mate.md @@ -0,0 +1,20 @@ +# Yxml source provenance + +Unmodified Yxml C sources and MIT license by Yoran Heling. + +- Upstream: https://g.blicky.net/yxml.git +- Documentation: https://dev.yorhel.nl/yxml/man +- Pinned commit: `66507906673bc6159d5d620414479954c9c21c24` +- Retrieved from the Fuchsia upstream mirror: https://fuchsia.googlesource.com/third_party/yxml/+/66507906673bc6159d5d620414479954c9c21c24/ +- The upstream HEAD query returned the same commit on 2026-10-11. +- SHA-256 yxml.c: `7699a9c0e9d9b987b74ff86bf3fc28be6deed062d22d9a0f8376b868b15864e5` +- SHA-256 yxml.h: `6f79158a3811bf1fab575ef512b91ffc87a795f070b562f7fb6bcbf71fdd9078` +- SHA-256 COPYING: `b9b5ca63904b3cf589d5719be2968436550914dc3fdf5d81b37ddbdd67986d06` + +Keep vendor files unchanged. Application wrappers must enforce UTF-8/Unicode, +namespace and duplicate-attribute policy, reject DTD/entity declarations, and +apply document/depth/element budgets. Yxml itself does not provide all these +checks. No firmware or global workspace is instantiated by this addition. + +`library.json` packages this pinned source for local PlatformIO use. Its +`0.0.0` version is a local packaging identifier, not an upstream release. diff --git a/third_party/yxml/library.json b/third_party/yxml/library.json new file mode 100644 index 00000000..94c65526 --- /dev/null +++ b/third_party/yxml/library.json @@ -0,0 +1,9 @@ +{ + "name": "trailmate-yxml", + "version": "0.0.0", + "build": { + "includeDir": ".", + "srcDir": ".", + "srcFilter": ["+"] + } +} diff --git a/third_party/yxml/yxml.c b/third_party/yxml/yxml.c new file mode 100644 index 00000000..23d04a97 --- /dev/null +++ b/third_party/yxml/yxml.c @@ -0,0 +1,1060 @@ +/* This file is generated by yxml-gen.pl using yxml-states and yxml.c.in as input files. + * It is preferable to edit those files instead of this one if you want to make a change. + * The source files can be found through the homepage: https://dev.yorhel.nl/yxml */ + +/* Copyright (c) 2013-2014 Yoran Heling + + 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. +*/ + +#include +#include + +typedef enum { + YXMLS_string, + YXMLS_attr0, + YXMLS_attr1, + YXMLS_attr2, + YXMLS_attr3, + YXMLS_attr4, + YXMLS_cd0, + YXMLS_cd1, + YXMLS_cd2, + YXMLS_comment0, + YXMLS_comment1, + YXMLS_comment2, + YXMLS_comment3, + YXMLS_comment4, + YXMLS_dt0, + YXMLS_dt1, + YXMLS_dt2, + YXMLS_dt3, + YXMLS_dt4, + YXMLS_elem0, + YXMLS_elem1, + YXMLS_elem2, + YXMLS_elem3, + YXMLS_enc0, + YXMLS_enc1, + YXMLS_enc2, + YXMLS_enc3, + YXMLS_etag0, + YXMLS_etag1, + YXMLS_etag2, + YXMLS_init, + YXMLS_le0, + YXMLS_le1, + YXMLS_le2, + YXMLS_le3, + YXMLS_lee1, + YXMLS_lee2, + YXMLS_leq0, + YXMLS_misc0, + YXMLS_misc1, + YXMLS_misc2, + YXMLS_misc2a, + YXMLS_misc3, + YXMLS_pi0, + YXMLS_pi1, + YXMLS_pi2, + YXMLS_pi3, + YXMLS_pi4, + YXMLS_std0, + YXMLS_std1, + YXMLS_std2, + YXMLS_std3, + YXMLS_ver0, + YXMLS_ver1, + YXMLS_ver2, + YXMLS_ver3, + YXMLS_xmldecl0, + YXMLS_xmldecl1, + YXMLS_xmldecl2, + YXMLS_xmldecl3, + YXMLS_xmldecl4, + YXMLS_xmldecl5, + YXMLS_xmldecl6, + YXMLS_xmldecl7, + YXMLS_xmldecl8, + YXMLS_xmldecl9 +} yxml_state_t; + + +#define yxml_isChar(c) 1 +/* 0xd should be part of SP, too, but yxml_parse() already normalizes that into 0xa */ +#define yxml_isSP(c) (c == 0x20 || c == 0x09 || c == 0x0a) +#define yxml_isAlpha(c) ((c|32)-'a' < 26) +#define yxml_isNum(c) (c-'0' < 10) +#define yxml_isHex(c) (yxml_isNum(c) || (c|32)-'a' < 6) +#define yxml_isEncName(c) (yxml_isAlpha(c) || yxml_isNum(c) || c == '.' || c == '_' || c == '-') +#define yxml_isNameStart(c) (yxml_isAlpha(c) || c == ':' || c == '_' || c >= 128) +#define yxml_isName(c) (yxml_isNameStart(c) || yxml_isNum(c) || c == '-' || c == '.') +/* XXX: The valid characters are dependent on the quote char, hence the access to x->quote */ +#define yxml_isAttValue(c) (yxml_isChar(c) && c != x->quote && c != '<' && c != '&') +/* Anything between '&' and ';', the yxml_ref* functions will do further + * validation. Strictly speaking, this is "yxml_isName(c) || c == '#'", but + * this parser doesn't understand entities with '.', ':', etc, anwyay. */ +#define yxml_isRef(c) (yxml_isNum(c) || yxml_isAlpha(c) || c == '#') + +#define INTFROM5CHARS(a, b, c, d, e) ((((uint64_t)(a))<<32) | (((uint64_t)(b))<<24) | (((uint64_t)(c))<<16) | (((uint64_t)(d))<<8) | (uint64_t)(e)) + + +/* Set the given char value to ch (0<=ch<=255). */ +static inline void yxml_setchar(char *dest, unsigned ch) { + *(unsigned char *)dest = ch; +} + + +/* Similar to yxml_setchar(), but will convert ch (any valid unicode point) to + * UTF-8 and appends a '\0'. dest must have room for at least 5 bytes. */ +static void yxml_setutf8(char *dest, unsigned ch) { + if(ch <= 0x007F) + yxml_setchar(dest++, ch); + else if(ch <= 0x07FF) { + yxml_setchar(dest++, 0xC0 | (ch>>6)); + yxml_setchar(dest++, 0x80 | (ch & 0x3F)); + } else if(ch <= 0xFFFF) { + yxml_setchar(dest++, 0xE0 | (ch>>12)); + yxml_setchar(dest++, 0x80 | ((ch>>6) & 0x3F)); + yxml_setchar(dest++, 0x80 | (ch & 0x3F)); + } else { + yxml_setchar(dest++, 0xF0 | (ch>>18)); + yxml_setchar(dest++, 0x80 | ((ch>>12) & 0x3F)); + yxml_setchar(dest++, 0x80 | ((ch>>6) & 0x3F)); + yxml_setchar(dest++, 0x80 | (ch & 0x3F)); + } + *dest = 0; +} + + +static inline yxml_ret_t yxml_datacontent(yxml_t *x, unsigned ch) { + yxml_setchar(x->data, ch); + x->data[1] = 0; + return YXML_CONTENT; +} + + +static inline yxml_ret_t yxml_datapi1(yxml_t *x, unsigned ch) { + yxml_setchar(x->data, ch); + x->data[1] = 0; + return YXML_PICONTENT; +} + + +static inline yxml_ret_t yxml_datapi2(yxml_t *x, unsigned ch) { + x->data[0] = '?'; + yxml_setchar(x->data+1, ch); + x->data[2] = 0; + return YXML_PICONTENT; +} + + +static inline yxml_ret_t yxml_datacd1(yxml_t *x, unsigned ch) { + x->data[0] = ']'; + yxml_setchar(x->data+1, ch); + x->data[2] = 0; + return YXML_CONTENT; +} + + +static inline yxml_ret_t yxml_datacd2(yxml_t *x, unsigned ch) { + x->data[0] = ']'; + x->data[1] = ']'; + yxml_setchar(x->data+2, ch); + x->data[3] = 0; + return YXML_CONTENT; +} + + +static inline yxml_ret_t yxml_dataattr(yxml_t *x, unsigned ch) { + /* Normalize attribute values according to the XML spec section 3.3.3. */ + yxml_setchar(x->data, ch == 0x9 || ch == 0xa ? 0x20 : ch); + x->data[1] = 0; + return YXML_ATTRVAL; +} + + +static yxml_ret_t yxml_pushstack(yxml_t *x, char **res, unsigned ch) { + if(x->stacklen+2 >= x->stacksize) + return YXML_ESTACK; + x->stacklen++; + *res = (char *)x->stack+x->stacklen; + x->stack[x->stacklen] = ch; + x->stacklen++; + x->stack[x->stacklen] = 0; + return YXML_OK; +} + + +static yxml_ret_t yxml_pushstackc(yxml_t *x, unsigned ch) { + if(x->stacklen+1 >= x->stacksize) + return YXML_ESTACK; + x->stack[x->stacklen] = ch; + x->stacklen++; + x->stack[x->stacklen] = 0; + return YXML_OK; +} + + +static void yxml_popstack(yxml_t *x) { + do + x->stacklen--; + while(x->stack[x->stacklen]); +} + + +static inline yxml_ret_t yxml_elemstart (yxml_t *x, unsigned ch) { return yxml_pushstack(x, &x->elem, ch); } +static inline yxml_ret_t yxml_elemname (yxml_t *x, unsigned ch) { return yxml_pushstackc(x, ch); } +static inline yxml_ret_t yxml_elemnameend(yxml_t *x, unsigned ch) { return YXML_ELEMSTART; } + + +/* Also used in yxml_elemcloseend(), since this function just removes the last + * element from the stack and returns ELEMEND. */ +static yxml_ret_t yxml_selfclose(yxml_t *x, unsigned ch) { + yxml_popstack(x); + if(x->stacklen) { + x->elem = (char *)x->stack+x->stacklen-1; + while(*(x->elem-1)) + x->elem--; + return YXML_ELEMEND; + } + x->elem = (char *)x->stack; + x->state = YXMLS_misc3; + return YXML_ELEMEND; +} + + +static inline yxml_ret_t yxml_elemclose(yxml_t *x, unsigned ch) { + if(*((unsigned char *)x->elem) != ch) + return YXML_ECLOSE; + x->elem++; + return YXML_OK; +} + + +static inline yxml_ret_t yxml_elemcloseend(yxml_t *x, unsigned ch) { + if(*x->elem) + return YXML_ECLOSE; + return yxml_selfclose(x, ch); +} + + +static inline yxml_ret_t yxml_attrstart (yxml_t *x, unsigned ch) { return yxml_pushstack(x, &x->attr, ch); } +static inline yxml_ret_t yxml_attrname (yxml_t *x, unsigned ch) { return yxml_pushstackc(x, ch); } +static inline yxml_ret_t yxml_attrnameend(yxml_t *x, unsigned ch) { return YXML_ATTRSTART; } +static inline yxml_ret_t yxml_attrvalend (yxml_t *x, unsigned ch) { yxml_popstack(x); return YXML_ATTREND; } + + +static inline yxml_ret_t yxml_pistart (yxml_t *x, unsigned ch) { return yxml_pushstack(x, &x->pi, ch); } +static inline yxml_ret_t yxml_piname (yxml_t *x, unsigned ch) { return yxml_pushstackc(x, ch); } +static inline yxml_ret_t yxml_piabort (yxml_t *x, unsigned ch) { yxml_popstack(x); return YXML_OK; } +static inline yxml_ret_t yxml_pinameend(yxml_t *x, unsigned ch) { + return (x->pi[0]|32) == 'x' && (x->pi[1]|32) == 'm' && (x->pi[2]|32) == 'l' && !x->pi[3] ? YXML_ESYN : YXML_PISTART; +} +static inline yxml_ret_t yxml_pivalend (yxml_t *x, unsigned ch) { yxml_popstack(x); x->pi = (char *)x->stack; return YXML_PIEND; } + + +static inline yxml_ret_t yxml_refstart(yxml_t *x, unsigned ch) { + memset(x->data, 0, sizeof(x->data)); + x->reflen = 0; + return YXML_OK; +} + + +static yxml_ret_t yxml_ref(yxml_t *x, unsigned ch) { + if(x->reflen >= sizeof(x->data)-1) + return YXML_EREF; + yxml_setchar(x->data+x->reflen, ch); + x->reflen++; + return YXML_OK; +} + + +static yxml_ret_t yxml_refend(yxml_t *x, yxml_ret_t ret) { + unsigned char *r = (unsigned char *)x->data; + unsigned ch = 0; + if(*r == '#') { + if(r[1] == 'x') + for(r += 2; yxml_isHex((unsigned)*r); r++) + ch = (ch<<4) + (*r <= '9' ? *r-'0' : (*r|32)-'a' + 10); + else + for(r++; yxml_isNum((unsigned)*r); r++) + ch = (ch*10) + (*r-'0'); + if(*r) + ch = 0; + } else { + uint64_t i = INTFROM5CHARS(r[0], r[1], r[2], r[3], r[4]); + ch = + i == INTFROM5CHARS('l','t', 0, 0, 0) ? '<' : + i == INTFROM5CHARS('g','t', 0, 0, 0) ? '>' : + i == INTFROM5CHARS('a','m','p', 0, 0) ? '&' : + i == INTFROM5CHARS('a','p','o','s',0) ? '\'': + i == INTFROM5CHARS('q','u','o','t',0) ? '"' : 0; + } + + /* Codepoints not allowed in the XML 1.1 definition of a Char */ + if(!ch || ch > 0x10FFFF || ch == 0xFFFE || ch == 0xFFFF || (ch-0xDFFF) < 0x7FF) + return YXML_EREF; + yxml_setutf8(x->data, ch); + return ret; +} + + +static inline yxml_ret_t yxml_refcontent(yxml_t *x, unsigned ch) { return yxml_refend(x, YXML_CONTENT); } +static inline yxml_ret_t yxml_refattrval(yxml_t *x, unsigned ch) { return yxml_refend(x, YXML_ATTRVAL); } + + +void yxml_init(yxml_t *x, void *stack, size_t stacksize) { + memset(x, 0, sizeof(*x)); + x->line = 1; + x->stack = (unsigned char*)stack; + x->stacksize = stacksize; + *x->stack = 0; + x->elem = x->pi = x->attr = (char *)x->stack; + x->state = YXMLS_init; +} + + +yxml_ret_t yxml_parse(yxml_t *x, int _ch) { + /* Ensure that characters are in the range of 0..255 rather than -126..125. + * All character comparisons are done with positive integers. */ + unsigned ch = (unsigned)(_ch+256) & 0xff; + if(!ch) + return YXML_ESYN; + x->total++; + + /* End-of-Line normalization, "\rX", "\r\n" and "\n" are recognized and + * normalized to a single '\n' as per XML 1.0 section 2.11. XML 1.1 adds + * some non-ASCII character sequences to this list, but we can only handle + * ASCII here without making assumptions about the input encoding. */ + if(x->ignore == ch) { + x->ignore = 0; + return YXML_OK; + } + x->ignore = (ch == 0xd) * 0xa; + if(ch == 0xa || ch == 0xd) { + ch = 0xa; + x->line++; + x->byte = 0; + } + x->byte++; + + switch((yxml_state_t)x->state) { + case YXMLS_string: + if(ch == *x->string) { + x->string++; + if(!*x->string) + x->state = x->nextstate; + return YXML_OK; + } + break; + case YXMLS_attr0: + if(yxml_isName(ch)) + return yxml_attrname(x, ch); + if(yxml_isSP(ch)) { + x->state = YXMLS_attr1; + return yxml_attrnameend(x, ch); + } + if(ch == (unsigned char)'=') { + x->state = YXMLS_attr2; + return yxml_attrnameend(x, ch); + } + break; + case YXMLS_attr1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'=') { + x->state = YXMLS_attr2; + return YXML_OK; + } + break; + case YXMLS_attr2: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_attr3; + x->quote = ch; + return YXML_OK; + } + break; + case YXMLS_attr3: + if(yxml_isAttValue(ch)) + return yxml_dataattr(x, ch); + if(ch == (unsigned char)'&') { + x->state = YXMLS_attr4; + return yxml_refstart(x, ch); + } + if(x->quote == ch) { + x->state = YXMLS_elem2; + return yxml_attrvalend(x, ch); + } + break; + case YXMLS_attr4: + if(yxml_isRef(ch)) + return yxml_ref(x, ch); + if(ch == (unsigned char)'\x3b') { + x->state = YXMLS_attr3; + return yxml_refattrval(x, ch); + } + break; + case YXMLS_cd0: + if(ch == (unsigned char)']') { + x->state = YXMLS_cd1; + return YXML_OK; + } + if(yxml_isChar(ch)) + return yxml_datacontent(x, ch); + break; + case YXMLS_cd1: + if(ch == (unsigned char)']') { + x->state = YXMLS_cd2; + return YXML_OK; + } + if(yxml_isChar(ch)) { + x->state = YXMLS_cd0; + return yxml_datacd1(x, ch); + } + break; + case YXMLS_cd2: + if(ch == (unsigned char)']') + return yxml_datacontent(x, ch); + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return YXML_OK; + } + if(yxml_isChar(ch)) { + x->state = YXMLS_cd0; + return yxml_datacd2(x, ch); + } + break; + case YXMLS_comment0: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment1; + return YXML_OK; + } + break; + case YXMLS_comment1: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment2; + return YXML_OK; + } + break; + case YXMLS_comment2: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment3; + return YXML_OK; + } + if(yxml_isChar(ch)) + return YXML_OK; + break; + case YXMLS_comment3: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment4; + return YXML_OK; + } + if(yxml_isChar(ch)) { + x->state = YXMLS_comment2; + return YXML_OK; + } + break; + case YXMLS_comment4: + if(ch == (unsigned char)'>') { + x->state = x->nextstate; + return YXML_OK; + } + break; + case YXMLS_dt0: + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc1; + return YXML_OK; + } + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_dt1; + x->quote = ch; + x->nextstate = YXMLS_dt0; + return YXML_OK; + } + if(ch == (unsigned char)'<') { + x->state = YXMLS_dt2; + return YXML_OK; + } + if(yxml_isChar(ch)) + return YXML_OK; + break; + case YXMLS_dt1: + if(x->quote == ch) { + x->state = x->nextstate; + return YXML_OK; + } + if(yxml_isChar(ch)) + return YXML_OK; + break; + case YXMLS_dt2: + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi0; + x->nextstate = YXMLS_dt0; + return YXML_OK; + } + if(ch == (unsigned char)'!') { + x->state = YXMLS_dt3; + return YXML_OK; + } + break; + case YXMLS_dt3: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment1; + x->nextstate = YXMLS_dt0; + return YXML_OK; + } + if(yxml_isChar(ch)) { + x->state = YXMLS_dt4; + return YXML_OK; + } + break; + case YXMLS_dt4: + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_dt1; + x->quote = ch; + x->nextstate = YXMLS_dt4; + return YXML_OK; + } + if(ch == (unsigned char)'>') { + x->state = YXMLS_dt0; + return YXML_OK; + } + if(yxml_isChar(ch)) + return YXML_OK; + break; + case YXMLS_elem0: + if(yxml_isName(ch)) + return yxml_elemname(x, ch); + if(yxml_isSP(ch)) { + x->state = YXMLS_elem1; + return yxml_elemnameend(x, ch); + } + if(ch == (unsigned char)'/') { + x->state = YXMLS_elem3; + return yxml_elemnameend(x, ch); + } + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return yxml_elemnameend(x, ch); + } + break; + case YXMLS_elem1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'/') { + x->state = YXMLS_elem3; + return YXML_OK; + } + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return YXML_OK; + } + if(yxml_isNameStart(ch)) { + x->state = YXMLS_attr0; + return yxml_attrstart(x, ch); + } + break; + case YXMLS_elem2: + if(yxml_isSP(ch)) { + x->state = YXMLS_elem1; + return YXML_OK; + } + if(ch == (unsigned char)'/') { + x->state = YXMLS_elem3; + return YXML_OK; + } + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return YXML_OK; + } + break; + case YXMLS_elem3: + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return yxml_selfclose(x, ch); + } + break; + case YXMLS_enc0: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'=') { + x->state = YXMLS_enc1; + return YXML_OK; + } + break; + case YXMLS_enc1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_enc2; + x->quote = ch; + return YXML_OK; + } + break; + case YXMLS_enc2: + if(yxml_isAlpha(ch)) { + x->state = YXMLS_enc3; + return YXML_OK; + } + break; + case YXMLS_enc3: + if(yxml_isEncName(ch)) + return YXML_OK; + if(x->quote == ch) { + x->state = YXMLS_xmldecl6; + return YXML_OK; + } + break; + case YXMLS_etag0: + if(yxml_isNameStart(ch)) { + x->state = YXMLS_etag1; + return yxml_elemclose(x, ch); + } + break; + case YXMLS_etag1: + if(yxml_isName(ch)) + return yxml_elemclose(x, ch); + if(yxml_isSP(ch)) { + x->state = YXMLS_etag2; + return yxml_elemcloseend(x, ch); + } + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return yxml_elemcloseend(x, ch); + } + break; + case YXMLS_etag2: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc2; + return YXML_OK; + } + break; + case YXMLS_init: + if(ch == (unsigned char)'\xef') { + x->state = YXMLS_string; + x->nextstate = YXMLS_misc0; + x->string = (unsigned char *)"\xbb\xbf"; + return YXML_OK; + } + if(yxml_isSP(ch)) { + x->state = YXMLS_misc0; + return YXML_OK; + } + if(ch == (unsigned char)'<') { + x->state = YXMLS_le0; + return YXML_OK; + } + break; + case YXMLS_le0: + if(ch == (unsigned char)'!') { + x->state = YXMLS_lee1; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_leq0; + return YXML_OK; + } + if(yxml_isNameStart(ch)) { + x->state = YXMLS_elem0; + return yxml_elemstart(x, ch); + } + break; + case YXMLS_le1: + if(ch == (unsigned char)'!') { + x->state = YXMLS_lee1; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi0; + x->nextstate = YXMLS_misc1; + return YXML_OK; + } + if(yxml_isNameStart(ch)) { + x->state = YXMLS_elem0; + return yxml_elemstart(x, ch); + } + break; + case YXMLS_le2: + if(ch == (unsigned char)'!') { + x->state = YXMLS_lee2; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi0; + x->nextstate = YXMLS_misc2; + return YXML_OK; + } + if(ch == (unsigned char)'/') { + x->state = YXMLS_etag0; + return YXML_OK; + } + if(yxml_isNameStart(ch)) { + x->state = YXMLS_elem0; + return yxml_elemstart(x, ch); + } + break; + case YXMLS_le3: + if(ch == (unsigned char)'!') { + x->state = YXMLS_comment0; + x->nextstate = YXMLS_misc3; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi0; + x->nextstate = YXMLS_misc3; + return YXML_OK; + } + break; + case YXMLS_lee1: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment1; + x->nextstate = YXMLS_misc1; + return YXML_OK; + } + if(ch == (unsigned char)'D') { + x->state = YXMLS_string; + x->nextstate = YXMLS_dt0; + x->string = (unsigned char *)"OCTYPE"; + return YXML_OK; + } + break; + case YXMLS_lee2: + if(ch == (unsigned char)'-') { + x->state = YXMLS_comment1; + x->nextstate = YXMLS_misc2; + return YXML_OK; + } + if(ch == (unsigned char)'[') { + x->state = YXMLS_string; + x->nextstate = YXMLS_cd0; + x->string = (unsigned char *)"CDATA["; + return YXML_OK; + } + break; + case YXMLS_leq0: + if(ch == (unsigned char)'x') { + x->state = YXMLS_xmldecl0; + x->nextstate = YXMLS_misc1; + return yxml_pistart(x, ch); + } + if(yxml_isNameStart(ch)) { + x->state = YXMLS_pi1; + x->nextstate = YXMLS_misc1; + return yxml_pistart(x, ch); + } + break; + case YXMLS_misc0: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'<') { + x->state = YXMLS_le0; + return YXML_OK; + } + break; + case YXMLS_misc1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'<') { + x->state = YXMLS_le1; + return YXML_OK; + } + break; + case YXMLS_misc2: + if(ch == (unsigned char)'<') { + x->state = YXMLS_le2; + return YXML_OK; + } + if(ch == (unsigned char)'&') { + x->state = YXMLS_misc2a; + return yxml_refstart(x, ch); + } + if(yxml_isChar(ch)) + return yxml_datacontent(x, ch); + break; + case YXMLS_misc2a: + if(yxml_isRef(ch)) + return yxml_ref(x, ch); + if(ch == (unsigned char)'\x3b') { + x->state = YXMLS_misc2; + return yxml_refcontent(x, ch); + } + break; + case YXMLS_misc3: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'<') { + x->state = YXMLS_le3; + return YXML_OK; + } + break; + case YXMLS_pi0: + if(yxml_isNameStart(ch)) { + x->state = YXMLS_pi1; + return yxml_pistart(x, ch); + } + break; + case YXMLS_pi1: + if(yxml_isName(ch)) + return yxml_piname(x, ch); + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi4; + return yxml_pinameend(x, ch); + } + if(yxml_isSP(ch)) { + x->state = YXMLS_pi2; + return yxml_pinameend(x, ch); + } + break; + case YXMLS_pi2: + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi3; + return YXML_OK; + } + if(yxml_isChar(ch)) + return yxml_datapi1(x, ch); + break; + case YXMLS_pi3: + if(ch == (unsigned char)'>') { + x->state = x->nextstate; + return yxml_pivalend(x, ch); + } + if(yxml_isChar(ch)) { + x->state = YXMLS_pi2; + return yxml_datapi2(x, ch); + } + break; + case YXMLS_pi4: + if(ch == (unsigned char)'>') { + x->state = x->nextstate; + return yxml_pivalend(x, ch); + } + break; + case YXMLS_std0: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'=') { + x->state = YXMLS_std1; + return YXML_OK; + } + break; + case YXMLS_std1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_std2; + x->quote = ch; + return YXML_OK; + } + break; + case YXMLS_std2: + if(ch == (unsigned char)'y') { + x->state = YXMLS_string; + x->nextstate = YXMLS_std3; + x->string = (unsigned char *)"es"; + return YXML_OK; + } + if(ch == (unsigned char)'n') { + x->state = YXMLS_string; + x->nextstate = YXMLS_std3; + x->string = (unsigned char *)"o"; + return YXML_OK; + } + break; + case YXMLS_std3: + if(x->quote == ch) { + x->state = YXMLS_xmldecl8; + return YXML_OK; + } + break; + case YXMLS_ver0: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'=') { + x->state = YXMLS_ver1; + return YXML_OK; + } + break; + case YXMLS_ver1: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'\'' || ch == (unsigned char)'"') { + x->state = YXMLS_string; + x->quote = ch; + x->nextstate = YXMLS_ver2; + x->string = (unsigned char *)"1."; + return YXML_OK; + } + break; + case YXMLS_ver2: + if(yxml_isNum(ch)) { + x->state = YXMLS_ver3; + return YXML_OK; + } + break; + case YXMLS_ver3: + if(yxml_isNum(ch)) + return YXML_OK; + if(x->quote == ch) { + x->state = YXMLS_xmldecl4; + return YXML_OK; + } + break; + case YXMLS_xmldecl0: + if(ch == (unsigned char)'m') { + x->state = YXMLS_xmldecl1; + return yxml_piname(x, ch); + } + if(yxml_isName(ch)) { + x->state = YXMLS_pi1; + return yxml_piname(x, ch); + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi4; + return yxml_pinameend(x, ch); + } + if(yxml_isSP(ch)) { + x->state = YXMLS_pi2; + return yxml_pinameend(x, ch); + } + break; + case YXMLS_xmldecl1: + if(ch == (unsigned char)'l') { + x->state = YXMLS_xmldecl2; + return yxml_piname(x, ch); + } + if(yxml_isName(ch)) { + x->state = YXMLS_pi1; + return yxml_piname(x, ch); + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_pi4; + return yxml_pinameend(x, ch); + } + if(yxml_isSP(ch)) { + x->state = YXMLS_pi2; + return yxml_pinameend(x, ch); + } + break; + case YXMLS_xmldecl2: + if(yxml_isSP(ch)) { + x->state = YXMLS_xmldecl3; + return yxml_piabort(x, ch); + } + if(yxml_isName(ch)) { + x->state = YXMLS_pi1; + return yxml_piname(x, ch); + } + break; + case YXMLS_xmldecl3: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'v') { + x->state = YXMLS_string; + x->nextstate = YXMLS_ver0; + x->string = (unsigned char *)"ersion"; + return YXML_OK; + } + break; + case YXMLS_xmldecl4: + if(yxml_isSP(ch)) { + x->state = YXMLS_xmldecl5; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_xmldecl9; + return YXML_OK; + } + break; + case YXMLS_xmldecl5: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'?') { + x->state = YXMLS_xmldecl9; + return YXML_OK; + } + if(ch == (unsigned char)'e') { + x->state = YXMLS_string; + x->nextstate = YXMLS_enc0; + x->string = (unsigned char *)"ncoding"; + return YXML_OK; + } + if(ch == (unsigned char)'s') { + x->state = YXMLS_string; + x->nextstate = YXMLS_std0; + x->string = (unsigned char *)"tandalone"; + return YXML_OK; + } + break; + case YXMLS_xmldecl6: + if(yxml_isSP(ch)) { + x->state = YXMLS_xmldecl7; + return YXML_OK; + } + if(ch == (unsigned char)'?') { + x->state = YXMLS_xmldecl9; + return YXML_OK; + } + break; + case YXMLS_xmldecl7: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'?') { + x->state = YXMLS_xmldecl9; + return YXML_OK; + } + if(ch == (unsigned char)'s') { + x->state = YXMLS_string; + x->nextstate = YXMLS_std0; + x->string = (unsigned char *)"tandalone"; + return YXML_OK; + } + break; + case YXMLS_xmldecl8: + if(yxml_isSP(ch)) + return YXML_OK; + if(ch == (unsigned char)'?') { + x->state = YXMLS_xmldecl9; + return YXML_OK; + } + break; + case YXMLS_xmldecl9: + if(ch == (unsigned char)'>') { + x->state = YXMLS_misc1; + return YXML_OK; + } + break; + } + return YXML_ESYN; +} + + +yxml_ret_t yxml_eof(yxml_t *x) { + if(x->state != YXMLS_misc3) + return YXML_EEOF; + return YXML_OK; +} + + +/* vim: set noet sw=4 ts=4: */ diff --git a/third_party/yxml/yxml.h b/third_party/yxml/yxml.h new file mode 100644 index 00000000..a0cc5f9d --- /dev/null +++ b/third_party/yxml/yxml.h @@ -0,0 +1,162 @@ +/* Copyright (c) 2013-2014 Yoran Heling + + 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. +*/ + +#ifndef YXML_H +#define YXML_H + +#include +#include + +#if defined(_MSC_VER) && !defined(__cplusplus) && !defined(inline) +#define inline __inline +#endif + +/* Full API documentation for this library can be found in the "yxml.md" file + * in the yxml git repository, or online at http://dev.yorhel.nl/yxml/man */ + +typedef enum { + YXML_EEOF = -5, /* Unexpected EOF */ + YXML_EREF = -4, /* Invalid character or entity reference (&whatever;) */ + YXML_ECLOSE = -3, /* Close tag does not match open tag ( .. ) */ + YXML_ESTACK = -2, /* Stack overflow (too deeply nested tags or too long element/attribute name) */ + YXML_ESYN = -1, /* Syntax error (unexpected byte) */ + YXML_OK = 0, /* Character consumed, no new token present */ + YXML_ELEMSTART = 1, /* Start of an element: '' or '' */ + YXML_ATTRSTART = 4, /* Attribute: 'Name=..' */ + YXML_ATTRVAL = 5, /* Attribute value */ + YXML_ATTREND = 6, /* End of attribute '.."' */ + YXML_PISTART = 7, /* Start of a processing instruction */ + YXML_PICONTENT = 8, /* Content of a PI */ + YXML_PIEND = 9 /* End of a processing instruction */ +} yxml_ret_t; + +/* When, exactly, are tokens returned? + * + * ' ELEMSTART + * '/' ELEMSTART, '>' ELEMEND + * ' ' ELEMSTART + * '>' + * '/', '>' ELEMEND + * Attr + * '=' ATTRSTART + * "X ATTRVAL + * 'Y' ATTRVAL + * 'Z' ATTRVAL + * '"' ATTREND + * '>' + * '/', '>' ELEMEND + * + * ' ELEMEND + */ + + +typedef struct { + /* PUBLIC (read-only) */ + + /* Name of the current element, zero-length if not in any element. Changed + * after YXML_ELEMSTART. The pointer will remain valid up to and including + * the next non-YXML_ATTR* token, the pointed-to buffer will remain valid + * up to and including the YXML_ELEMEND for the corresponding element. */ + char *elem; + + /* The last read character(s) of an attribute value (YXML_ATTRVAL), element + * data (YXML_CONTENT), or processing instruction (YXML_PICONTENT). Changed + * after one of the respective YXML_ values is returned, and only valid + * until the next yxml_parse() call. Usually, this string only consists of + * a single byte, but multiple bytes are returned in the following cases: + * - "": The two characters "?x" + * - "": The two characters "]x" + * - "": The three characters "]]x" + * - "&#N;" and "&#xN;", where dec(n) > 127. The referenced Unicode + * character is then encoded in multiple UTF-8 bytes. + */ + char data[8]; + + /* Name of the current attribute. Changed after YXML_ATTRSTART, valid up to + * and including the next YXML_ATTREND. */ + char *attr; + + /* Name/target of the current processing instruction, zero-length if not in + * a PI. Changed after YXML_PISTART, valid up to (but excluding) + * the next YXML_PIEND. */ + char *pi; + + /* Line number, byte offset within that line, and total bytes read. These + * values refer to the position _after_ the last byte given to + * yxml_parse(). These are useful for debugging and error reporting. */ + uint64_t byte; + uint64_t total; + uint32_t line; + + + /* PRIVATE */ + int state; + unsigned char *stack; /* Stack of element names + attribute/PI name, separated by \0. Also starts with a \0. */ + size_t stacksize, stacklen; + unsigned reflen; + unsigned quote; + int nextstate; /* Used for '@' state remembering and for the "string" consuming state */ + unsigned ignore; + unsigned char *string; +} yxml_t; + + +#ifdef __cplusplus +extern "C" { +#endif + +void yxml_init(yxml_t *, void *, size_t); + + +yxml_ret_t yxml_parse(yxml_t *, int); + + +/* May be called after the last character has been given to yxml_parse(). + * Returns YXML_OK if the XML document is valid, YXML_EEOF otherwise. Using + * this function isn't really necessary, but can be used to detect documents + * that don't end correctly. In particular, an error is returned when the XML + * document did not contain a (complete) root element, or when the document + * ended while in a comment or processing instruction. */ +yxml_ret_t yxml_eof(yxml_t *); + +#ifdef __cplusplus +} +#endif + + +/* Returns the length of the element name (x->elem), attribute name (x->attr), + * or PI name (x->pi). This function should ONLY be used directly after the + * YXML_ELEMSTART, YXML_ATTRSTART or YXML_PISTART (respectively) tokens have + * been returned by yxml_parse(), calling this at any other time may not give + * the correct results. This function should also NOT be used on strings other + * than x->elem, x->attr or x->pi. */ +static inline size_t yxml_symlen(yxml_t *x, const char *s) { + return (x->stack + x->stacklen) - (const unsigned char*)s; +} + +#endif + +/* vim: set noet sw=4 ts=4: */ diff --git a/tools/hostlink/compare_firmware_ram.py b/tools/hostlink/compare_firmware_ram.py new file mode 100644 index 00000000..19ccfe5f --- /dev/null +++ b/tools/hostlink/compare_firmware_ram.py @@ -0,0 +1,38 @@ +"""Compare ESP32-S3 internal data/BSS symbols in two saved ELF images.""" +import argparse +import json +import re +import subprocess +from collections import defaultdict +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument("--nm", required=True) +parser.add_argument("--before", required=True) +parser.add_argument("--after", required=True) +args = parser.parse_args() + + +def read_symbols(filename): + result = subprocess.run([args.nm, "-S", "-C", "--size-sort", filename], check=True, capture_output=True, text=True) + symbols = defaultdict(int) + seen = set() + for line in result.stdout.splitlines(): + match = re.match(r"^([0-9a-f]+) ([0-9a-f]+) ([bBdDsSgG]) (.+)$", line) + if not match: + continue + address, size = int(match[1], 16), int(match[2], 16) + if not 0x3FC00000 <= address < 0x3FD00000 or (address, size) in seen: + continue + seen.add((address, size)) + symbols[match[4]] += size + return symbols + + +before = read_symbols(str(Path(args.before).resolve())) +after = read_symbols(str(Path(args.after).resolve())) +changes = [{"name": name, "before": before[name], "after": after[name], "delta": after[name] - before[name]} + for name in before.keys() | after.keys() if before[name] != after[name]] +changes.sort(key=lambda row: abs(row["delta"]), reverse=True) +print(json.dumps({"symbolBytesBefore": sum(before.values()), "symbolBytesAfter": sum(after.values()), + "delta": sum(after.values()) - sum(before.values()), "changes": changes[:30]}, indent=2)) diff --git a/tools/hostlink/config_presentation_audit.mjs b/tools/hostlink/config_presentation_audit.mjs new file mode 100644 index 00000000..13e807f8 --- /dev/null +++ b/tools/hostlink/config_presentation_audit.mjs @@ -0,0 +1,11 @@ +// Synthetic native fixture only. Inventory keys/UI semantics, never real device +// configuration or secrets. Reuse the existing native complete-codec executable. +import {spawnSync} from 'node:child_process';import path from 'node:path';import fs from 'node:fs/promises'; +import {TmsDocument} from '../../site/device/src/tms-document.js';import {configurationField} from '../../site/device/src/config-schema.js';import {configurationPresentation} from '../../site/device/src/config-field-presentation.js'; +import {isWebConfigurationField} from '../../site/device/src/config-web-scope.js';import {configurationMaskOptions} from '../../site/device/src/config-mask.js';import {configurationRadioTuning} from '../../site/device/src/config-radio-tuning.js';import {MODULE_SCHEMA} from '../../site/device/src/meshtastic-module-schema.js';import {isWebModuleField} from '../../site/device/src/config-module-web-scope.js'; +const executable=path.resolve('.codex-build/hostlink-codec/native/tms_complete_interop_cli'+(process.platform==='win32'?'.exe':'')),result=spawnSync(executable,['--emit'],{windowsHide:true,maxBuffer:1048576});if(result.error||result.status!==0)throw Error('Native fixture unavailable'); +const document=new TmsDocument(new Uint8Array(result.stdout)); +const records=document.entries(),rows=records.filter(record=>isWebConfigurationField(record.key)).map(record=>{const schema=configurationField(record,configurationPresentation(record.key,'zh-Hans'),document),numeric=/^(u8|u16|u32|i8|i32|f32)$/.test(record.type),module=record.key.startsWith('mt_module.'),tuning=configurationRadioTuning(record);return {key:record.key,type:record.type,label:schema.label,control:module?'module container':schema.readOnly?'read-only':schema.character?'character':configurationMaskOptions(record.key)?'bit choices':schema.duration?'duration with unit':schema.frequency?'frequency with unit':schema.percentScale?'percent slider':schema.options?schema.allowCustom?'choice with custom':'choice':record.type==='bool'?'toggle':record.type==='b64'?'binary/text adapter':schema.numericPurpose?.kind==='channelId'?'identifier with bases':schema.numericPurpose?'number with purpose':numeric?'number':'text',range:schema.range??null,help:schema.help??null,unit:schema.duration?.nativeUnit??(schema.frequency?'MHz':schema.radioValue?.kind==='power'?'dBm':schema.numericPurpose?.kind==='capacity'?'mAh':schema.numericPurpose?.kind??(tuning?.kind==='bandwidth'?'kHz':tuning?.kind??null)),options:schema.options?.map(option=>({value:option.value,label:option.label}))??null,secret:schema.secret};}); +const moduleRows=[];function inspectModule(message,path=[]){for(const field of MODULE_SCHEMA.messages[message].fields){const next=[...path,field.name];moduleRows.push({key:next.join('.'),included:isWebModuleField(next)});if(field.type==='MESSAGE')inspectModule(field.message,next);}}inspectModule(MODULE_SCHEMA.root); +const totals=Object.fromEntries([...new Set(rows.map(row=>row.control))].map(control=>[control,rows.filter(row=>row.control===control).length])); +const output=path.resolve('.codex-build/hostlink-codec/config-presentation-audit.json');await fs.writeFile(output,JSON.stringify({source:'synthetic complete production codec fixture; no hardware data and no configuration values',fields:document.size,webRecords:rows.length,excludedRecords:records.length-rows.length,totals,moduleDescriptors:moduleRows.filter(row=>row.included).length,excludedModules:moduleRows.filter(row=>!row.included).map(row=>row.key),rows},null,2));console.log(JSON.stringify({fields:document.size,webRecords:rows.length,totals,moduleDescriptors:moduleRows.filter(row=>row.included).length,excludedModules:moduleRows.filter(row=>!row.included).map(row=>row.key),output,numericNeedingPurposeAudit:rows.filter(row=>row.control==='number').map(row=>row.key)})); diff --git a/tools/hostlink/config_roundtrip_probe.mjs b/tools/hostlink/config_roundtrip_probe.mjs new file mode 100644 index 00000000..e99022aa --- /dev/null +++ b/tools/hostlink/config_roundtrip_probe.mjs @@ -0,0 +1,111 @@ +// Explicit, reversible hardware test. Only public test fields and hashes are +// checkpointed; the complete configuration and secrets remain in process memory. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {readFile,writeFile} from 'node:fs/promises'; +import {encodeFrame,FrameDecoder,FRAME} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {readConfigSnapshot} from '../../site/device/src/config-snapshot.js'; +import {saveConfigDocument} from '../../site/device/src/config-save.js'; +import {equal} from '../../site/geocaching/src/protocol.js'; + +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +if(!args.includes('--port')||!args.includes('--python')||!args.includes('--checkpoint')|| + Number(args.includes('--run'))+Number(args.includes('--verify'))!==1) + throw Error('Use --port --python --checkpoint and exactly one of --run / --verify'); +const hex=value=>Buffer.from(value).toString('hex'),bin=value=>Uint8Array.from(Buffer.from(value,'hex')); +const random=()=>crypto.getRandomValues(new Uint8Array(16)),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +const checkpoint=option('--checkpoint'); +let job=await readFile(checkpoint,'utf8').then(JSON.parse).catch(error=>{if(error.code==='ENOENT')return null;throw error;}),connection; +const fields=['device.node_name','map.contour_enabled','ui.screen.timeout_ms']; +const persist=()=>writeFile(checkpoint,JSON.stringify(job)); + +async function configRoundtripConnect(){ + const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true}); + let pending,sequence=0; + const lines=createInterface({input:child.stdout}); + lines.on('line',line=>{const reply=JSON.parse(line);if(pending){const current=pending;pending=null;reply.error?current.reject(Error(reply.error)):current.resolve(reply);}}); + child.on('exit',code=>{if(pending){pending.reject(Error('Serial bridge exited '+code));pending=null;}}); + const exchange=async(type,payload,response)=>{ + assert.ok(!pending);const next=++sequence; + const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:hex(encodeFrame(type,next,payload)),sequence:next,response})+'\n');}); + const frame=new FrameDecoder().push(bin(reply.hex))[0];assert.ok(frame&&frame.type===response&&frame.sequence===next);return frame.payload; + }; + const session={id:job?bin(job.session):random(),async request(operation,body){ + if(![0,1,2,3,17,21,32,33,35].includes(operation))throw Error('Operation outside configuration roundtrip'); + if(args.includes('--verify')&&[21,32,33,35].includes(operation))throw Error('Read-only verification cannot write'); + const id=random(),reply=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,id,operation,body),FRAME.MANAGEMENT_RESPONSE)); + assert.ok(equal(reply.session,this.id)&&equal(reply.request,id)); + if(reply.code)throw Object.assign(Error('Device status '+reply.code+' for '+operation),{code:reply.code});return reply.body; + }}; + const close=()=>{child.stdin.end();lines.close();}; + try{await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK);return {session,close};}catch(error){close();throw error;} +} +function configRoundtripClose(){connection?.close();connection=null;} +async function configRoundtripRead(){ + connection=await configRoundtripConnect();const author=await proveDeviceAuthor(connection.session); + if(job){assert.equal(author.fingerprint,job.identity,'Device identity changed');assert.equal(author.generation,job.generation,'Device identity generation changed');} + return {author,snapshot:await readConfigSnapshot(connection.session)}; +} +async function configRoundtripReconnect(expected){ + configRoundtripClose();await sleep(1500);const deadline=performance.now()+60000; + for(;;){ + try{const result=await configRoundtripRead();assert.equal(hex(result.snapshot.sha256),expected,'Complete configuration differs after restart');return result;} + catch(error){configRoundtripClose();if(error.code==='ERR_ASSERTION'||performance.now()>deadline)throw error;await sleep(500);} + } +} +try{ + let {author,snapshot}=await configRoundtripRead(); + if(!job){ + assert.ok(args.includes('--run'),'Verification requires an existing checkpoint'); + const original=Object.fromEntries(fields.map(key=>[key,snapshot.document.get(key).value])); + assert.equal(typeof original[fields[0]],'string');assert.equal(typeof original[fields[1]],'boolean');assert.ok(Number.isInteger(original[fields[2]])); + const target={ + [fields[0]]:original[fields[0]]==='USB Config Probe'?'USB Config Check':'USB Config Probe', + [fields[1]]:!original[fields[1]], + [fields[2]]:original[fields[2]]===30000?60000:30000 + }; + for(const key of fields)snapshot.document.set(key,target[key]); + job={identity:author.fingerprint,generation:author.generation,session:hex(connection.session.id),original,target,originalHash:hex(snapshot.sha256),targetHash:hex(new Uint8Array(await crypto.subtle.digest('SHA-256',snapshot.document.serialize({kind:'working'})))),targetUpload:hex(random()),restoreUpload:hex(random()),phase:'ready'}; + await persist();for(const key of fields)snapshot.document.set(key,original[key]); + } + assert.deepEqual(Object.keys(job.original),fields);assert.deepEqual(Object.keys(job.target),fields); + if(args.includes('--verify')){ + assert.ok(['restoring','restored'].includes(job.phase),'Original restoration has not been submitted');assert.equal(hex(snapshot.sha256),job.originalHash); + for(const key of fields)assert.equal(snapshot.document.get(key).value,job.original[key]); + if(job.phase==='restoring'){job.phase='restored';await persist();} + console.log(JSON.stringify({readOnly:true,originalRestored:true,completeConfigurationHashVerified:true,identityUnchanged:true,fields:snapshot.document.size,testedFields:fields})); + }else{ + if(job.phase==='ready'){ + assert.equal(hex(snapshot.sha256),job.originalHash,'Configuration changed before test'); + for(const key of fields)snapshot.document.set(key,job.target[key]); + job.phase='writing';await persist(); + const receipt=await saveConfigDocument(connection.session,snapshot.document,snapshot.sha256,{id:bin(job.targetUpload)}); + console.log(JSON.stringify({targetCommitAccepted:true,restartPending:receipt.restartPending,testedFields:fields})); + } + if(job.phase==='writing'){ + // Lost commit replies are resolved only by reading. Never blindly repeat 21. + ({author,snapshot}=await configRoundtripReconnect(job.targetHash)); + for(const key of fields)assert.equal(snapshot.document.get(key).value,job.target[key]); + job.phase='target_verified';await persist();console.log(JSON.stringify({targetReadBack:true,completeConfigurationHashVerified:true})); + } + if(job.phase==='target_verified'){ + assert.equal(hex(snapshot.sha256),job.targetHash,'Configuration changed before restoration'); + for(const key of fields)snapshot.document.set(key,job.original[key]); + job.phase='restoring';await persist(); + const receipt=await saveConfigDocument(connection.session,snapshot.document,snapshot.sha256,{id:bin(job.restoreUpload)}); + console.log(JSON.stringify({originalRestoreAccepted:true,restartPending:receipt.restartPending})); + } + if(job.phase==='restoring'){ + ({author,snapshot}=await configRoundtripReconnect(job.originalHash)); + for(const key of fields)assert.equal(snapshot.document.get(key).value,job.original[key]); + job.phase='restored';await persist(); + } + assert.equal(job.phase,'restored');assert.equal(hex(snapshot.sha256),job.originalHash); + console.log(JSON.stringify({configurationRoundtripVerified:true,originalRestored:true,completeConfigurationHashVerified:true,identityUnchanged:true,fields:snapshot.document.size,testedFields:fields})); + } + await connection.session.request(3,[]); +}finally{configRoundtripClose();} diff --git a/tools/hostlink/geocaching_hardware_exchange.mjs b/tools/hostlink/geocaching_hardware_exchange.mjs new file mode 100644 index 00000000..2df0df78 --- /dev/null +++ b/tools/hostlink/geocaching_hardware_exchange.mjs @@ -0,0 +1,71 @@ +// Explicit hardware acceptance only. The browser API shim transports the real +// page's frames unchanged; it does not simulate the firmware or its signatures. +import {spawn} from 'node:child_process';import {createInterface} from 'node:readline';import {fileURLToPath} from 'node:url';import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {FrameDecoder,FRAME} from '../../site/device/src/hostlink.js';import {decodeManagement} from '../../site/device/src/management-protocol.js';import {decode,verifySigned,equal} from '../../site/geocaching/src/protocol.js';import {validateAuthorInfo} from '../../site/device/src/author-identity.js'; +const allowed=new Set([0,1,2,3,32,33,34,35,36,37,38,64,65,66,71,72,73,74,75,76,77]),hex=value=>Buffer.from(value).toString('hex'); +export class GeocachingHardwareExchange{ + constructor({port,python,fingerprint,namePrefix,resumeDraftId=null,calls=[],signedVersions=[],checkpointPath=null}){ + if(!/^COM\d+$/.test(port)||!/^[a-f0-9]{64}$/.test(fingerprint)||!namePrefix)throw Error('Explicit port, expected author and isolated test-name prefix required'); + this.fingerprint=fingerprint;this.namePrefix=namePrefix;this.calls=calls;this.signedVersions=signedVersions;this.signatures=new Set();this.confirmations=new Set();this.confirmed=new Set();this.drafts=new Set();this.transfers=new Map();this.outputs=new Map();this.completed=new Set();this.pending=null;this.queue=Promise.resolve();this.broken=null; + if(resumeDraftId!==null&&(!/^[a-f0-9]{32}$/.test(resumeDraftId)||!/^Wio网页验收-[a-z0-9]+$/.test(namePrefix)))throw Error('Resume requires an explicit isolated acceptance ID and name');this.resumeDraftId=resumeDraftId; + this.checkpointPath=checkpointPath; + this.child=spawn(python,[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),port],{windowsHide:true,stdio:['pipe','pipe','pipe']});this.stderr='';this.child.stderr.on('data',bytes=>{this.stderr=(this.stderr+bytes).slice(-4096);}); + this.done=new Promise((resolve,reject)=>{this.child.on('error',error=>{this.broken=error;this.pending?.reject(error);this.pending=null;reject(error);});this.child.on('exit',code=>{if(this.pending){this.pending.reject(Error('Hardware serial process exited: '+code));this.pending=null;}resolve(code);});});this.done.catch(()=>{}); + this.lines=createInterface({input:this.child.stdout});this.lines.on('line',line=>{try{const reply=JSON.parse(line),pending=this.pending;this.pending=null;if(pending)reply.error?pending.reject(Error(reply.error)):pending.resolve(reply);}catch(error){this.pending?.reject(error);this.pending=null;}}); + } + get signCount(){return this.signatures.size;} + get confirmCount(){return this.confirmed.size;} + exchange(raw){const operation=this.queue.then(()=>this.run(raw));this.queue=operation.catch(error=>{this.broken=error;});return operation;} + async run(raw){ + if(this.broken)throw this.broken;const bytes=Uint8Array.from(raw),frames=new FrameDecoder().push(bytes);assert.equal(frames.length,1);const frame=frames[0];let request; + if(frame.type===FRAME.MANAGEMENT_REQUEST){request=decodeManagement(frame.payload);assert.ok(allowed.has(request.code),'Hardware test rejected unrelated operation '+request.code);this.guard(request);this.calls.push(request.code);}else assert.equal(frame.type,FRAME.HELLO); + const response=frame.type===FRAME.HELLO?FRAME.HELLO_ACK:FRAME.MANAGEMENT_RESPONSE; + if(request?.code===71&&this.checkpointPath){ + const body=request.body,row=this.transfers.get(hex(body[0])); + // Retain public input and exact correlation BEFORE admission. An abandoned + // test browser must not lose a potentially reserved signed revision. + await fs.writeFile(this.checkpointPath,JSON.stringify({version:1,authorFingerprint:this.fingerprint,authorGeneration:this.author.generation, + testName:this.namePrefix,draftId:hex(body[5]),draftGeneration:String(body[6]),transferId:hex(body[0]),hash:hex(body[1]),bytes:body[2], + operationId:hex(body[4]),owner:hex(request.session),unsignedInput:Buffer.from(row.content).toString('base64')},null,2)); + } + if(request?.code===75&&this.checkpointPath){ + const body=request.body,row=this.transfers.get(hex(body[0])),signed=decode(row.content,4166),record=decode(signed[0],4096); + // Preserve the original public proof before confirmation admission too. + // Neither the browser's lifetime nor a lost reply may discard recovery data. + await fs.writeFile(path.join(path.dirname(this.checkpointPath),'confirmation-recovery.json'),JSON.stringify({version:1, + authorFingerprint:this.fingerprint,authorGeneration:this.author.generation,testName:this.namePrefix,draftId:hex(record[2]), + revision:record[3],transferId:hex(body[0]),hash:hex(body[1]),bytes:row.total,owner:hex(request.session), + signedInput:Buffer.from(row.content).toString('base64'),receipt:{raw:[...body[3]],signature:[...body[4]],publicKey:[...body[5]],epoch:[...body[6]],delivery:[...decode(body[3],192)[1]]}},null,2)); + } + const reply=await new Promise((resolve,reject)=>{this.pending={resolve,reject};this.child.stdin.write(JSON.stringify({hex:hex(bytes),sequence:frame.sequence,response})+'\n',error=>{if(error){this.pending=null;reject(error);}});}); + const result=Uint8Array.from(Buffer.from(reply.hex,'hex')),responseFrames=new FrameDecoder().push(result);assert.equal(responseFrames.length,1);assert.equal(responseFrames[0].type,response);assert.equal(responseFrames[0].sequence,frame.sequence); + if(request){const value=decodeManagement(responseFrames[0].payload);assert.ok(equal(value.session,request.session)&&equal(value.request,request.request));this.responses??=[];this.responses.push({operation:request.code,status:value.code});if(value.code===0)await this.observe(request,value.body);} + return [...result]; + } + guard({code,body}){ + if(![0,1,2,3,64].includes(code))assert.ok(this.author?.fingerprint===this.fingerprint&&this.author,'Device author must match the explicit baseline before maintenance writes'); + if(code===32){assert.ok([2,5,6].includes(body[1]));assert.ok(body[2]>0&&body[2]<=4166);const id=hex(body[0]),old=this.transfers.get(id);if(old){assert.equal(old.kind,body[1]);assert.equal(old.total,body[2]);assert.ok(equal(old.hash,body[3]));}else this.transfers.set(id,{kind:body[1],total:body[2],hash:body[3].slice(),content:new Uint8Array(body[2]),offset:0,sealed:false});} + if([33,34,35,36,37].includes(code)){assert.ok(this.transfers.has(hex(body[0])),'Unrelated transfer rejected');if(code===33){const row=this.transfers.get(hex(body[0]));assert.ok(body[1]>=0&&body[1]+body[2].length<=row.total);}} + if(code===38)assert.ok(Array.isArray(body)&&body.length===2&&[5,6].includes(body[0])&&body[1]===null,'Unrelated staging query rejected'); + if(code===66){const row=this.transfers.get(hex(body[0]));assert.ok(row?.sealed&&row.kind===2);const edit=decode(row.content,4166);assert.ok(edit[0]===null&&edit[1]===null&&edit[2][3].startsWith(this.namePrefix));assert.ok([0,2].includes(edit[2][0]));assert.equal(edit[2][1],310000000);assert.equal(edit[2][2],1210000000);assert.ok(BigInt(body[2])===0n||this.drafts.has(hex(body[1])),'Existing unrelated draft rejected');} + if(code===65)assert.ok(this.drafts.has(hex(body[0])),'Unrelated draft detail rejected'); + if(code===71){const row=this.transfers.get(hex(body[0]));assert.ok(row?.sealed&&row.kind===5);const record=decode(row.content,4166);assert.ok(record[8].startsWith(this.namePrefix));assert.ok(this.drafts.has(hex(body[5])));} + if([72,73,74].includes(code))assert.ok(this.signatures.has(hex(body[0])),'Unrelated signing operation rejected'); + if(code===75){const row=this.transfers.get(hex(body[0]));assert.ok(row?.sealed&&row.kind===6);const signed=decode(row.content,4166),record=decode(signed[0],4096);assert.ok(this.drafts.has(hex(record[2]))&&record[8].startsWith(this.namePrefix));} + if([76,77].includes(code))assert.ok(this.confirmations.has(hex(body[0])),'Unrelated confirmation rejected'); + } + async observe({code,body},value){ + if(code===1||code===2){const author=await validateAuthorInfo(value.slice(0,4));assert.equal(author.fingerprint,this.fingerprint,'Physical device author differs from acceptance baseline');this.author=author;} + if(code===33){const row=this.transfers.get(hex(body[0]));row.content.set(body[2],body[1]);row.offset=Math.max(row.offset,body[1]+body[2].length);} + if(code===35){const row=this.transfers.get(hex(body[0]));assert.equal(row.offset,row.total);assert.ok(equal(new Uint8Array(await crypto.subtle.digest('SHA-256',row.content)),row.hash));row.sealed=true;} + if(code===66)this.drafts.add(hex(body[1])); + if(code===64&&value[3]&&hex(value[3][0])===this.resumeDraftId&&!this.drafts.has(this.resumeDraftId)){const row=value[3];assert.equal(row[3],this.namePrefix);assert.equal(row[5],310000000);assert.equal(row[6],1210000000);assert.equal(row[4],0);assert.equal(row[8][0],0);assert.ok(row[2]===null||equal(row[2],this.author.publicKey));this.drafts.add(this.resumeDraftId);} + if(code===71)this.signatures.add(hex(body[4])); + if(code===75)this.confirmations.add(hex(body[0])); + if([75,76].includes(code)&&value[3]===1&&value[7]===1)this.confirmed.add(hex(body[0])); + if(code===73){const id=hex(body[0]);if(this.completed.has(id))return;let output=this.outputs.get(id);if(!output){assert.equal(value[1],0);assert.ok(value[2]>0&&value[2]<=4166);output={bytes:new Uint8Array(value[2]),hash:value[3].slice(),offset:0};this.outputs.set(id,output);}assert.equal(value[2],output.bytes.length);assert.ok(equal(value[3],output.hash));if(value[1]=0&&value[1]+value[4].length<=output.offset);assert.ok(equal(output.bytes.slice(value[1],value[1]+value[4].length),value[4]),'Retried signature bytes changed');return;}assert.equal(value[1],output.offset);output.bytes.set(value[4],output.offset);output.offset+=value[4].length;if(output.offset===output.bytes.length){const signed=decode(output.bytes,4166),verified=await verifySigned(signed);assert.ok(equal(verified.record[1],this.author.publicKey));this.signedVersions.push(signed);this.completed.add(id);this.outputs.delete(id);}} + } + async close(){await this.queue;this.child.stdin.end();const code=await this.done;this.lines.close();if(code!==0&&!this.broken)throw Error('Hardware serial process exited '+code+': '+this.stderr);} +} diff --git a/tools/hostlink/geocaching_inventory_probe.mjs b/tools/hostlink/geocaching_inventory_probe.mjs new file mode 100644 index 00000000..adbbb1fe --- /dev/null +++ b/tools/hostlink/geocaching_inventory_probe.mjs @@ -0,0 +1,15 @@ +// Read-only catalogue diagnosis. Only matching isolated acceptance names are logged. +import {GeocachingHardwareExchange} from './geocaching_hardware_exchange.mjs'; +import {encodeFrame,FrameDecoder,FRAME} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {readDeviceDraftPage} from '../../site/geocaching/src/device-draft-catalog.js'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +if(!['--port','--python','--expected-author'].every(flag=>args.includes(flag)))throw Error('Explicit port/Python/expected author required'); +const hardware=new GeocachingHardwareExchange({port:option('--port'),python:option('--python'),fingerprint:option('--expected-author'),namePrefix:'Wio网页验收-'}); +let sequence=0; +async function exchange(type,payload){const raw=await hardware.exchange(encodeFrame(type,++sequence,payload));return new FrameDecoder().push(Uint8Array.from(raw))[0].payload;} +const session={id:crypto.getRandomValues(new Uint8Array(16)),async request(code,body){if(![0,1,2,3,64].includes(code))throw Error('Read-only catalogue probe rejected operation');const value=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,crypto.getRandomValues(new Uint8Array(16)),code,body)));if(value.code)throw Object.assign(Error('Device status '+value.code),{code:value.code});return value.body;}}; +try{await exchange(FRAME.HELLO,new Uint8Array());await session.request(0,[]);const author=await proveDeviceAuthor(session);let offset=0,generation=null,total=0;const matching=[]; + for(let i=0;i<128;i++){const page=await readDeviceDraftPage(session,author,{offset,catalogGeneration:generation});total+=page.rows.length;for(const row of page.rows)if(row.name.startsWith('Wio网页验收-'))matching.push({id:Buffer.from(row.id).toString('hex'),name:row.name,generation:String(row.generation),publication:row.publication});if(!page.hasMore){console.log(JSON.stringify({author:author.fingerprint,total,matching}));break;}offset=page.nextOffset;generation=page.catalogGeneration;if(i===127)throw Error('Catalogue exceeded bounded scan');} +}finally{console.log(JSON.stringify({responses:hardware.responses}));await hardware.close();} diff --git a/tools/hostlink/identity_restore_probe.mjs b/tools/hostlink/identity_restore_probe.mjs new file mode 100644 index 00000000..36304dbf --- /dev/null +++ b/tools/hostlink/identity_restore_probe.mjs @@ -0,0 +1,56 @@ +// Explicit same-account Reticulum restore, or read-only post-reboot verification. +// No private backup/password/configuration bytes are retained in files or logs. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {readFile,writeFile} from 'node:fs/promises'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {exportEncryptedDeviceIdentity} from '../../site/device/src/identity-backup-export.js'; +import {previewEncryptedDeviceIdentity} from '../../site/device/src/identity-backup-preview.js'; +import {verifyRestoredDeviceIdentity} from '../../site/device/src/identity-restore-verification.js'; +import {readDeviceRadioIdentity} from '../../site/device/src/radio-identity.js'; +import {readConfigSnapshot} from '../../site/device/src/config-snapshot.js'; +import {equal} from '../../site/geocaching/src/protocol.js'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1],hex=value=>Buffer.from(value).toString('hex'),restore=args.includes('--restore'); +if(!args.includes('--port')||!args.includes('--python')||!args.includes('--checkpoint')||restore===args.includes('--verify'))throw Error('Use --port --python --checkpoint and exactly one of --restore / --verify'); +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true});let pending,sequence=0; +createInterface({input:child.stdout}).on('line',line=>{const reply=JSON.parse(line);if(pending){const current=pending;pending=null;reply.error?current.reject(Error(reply.error)):current.resolve(reply);}}); +child.on('exit',code=>{if(pending){pending.reject(Error('Serial bridge exited '+code));pending=null;}}); +async function exchange(type,payload,response){ + assert.ok(!pending);const next=++sequence,raw=encodeFrame(type,next,payload); + try{const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:hex(raw),sequence:next,response})+'\n');}); + const frame=new FrameDecoder().push(Uint8Array.from(Buffer.from(reply.hex,'hex')))[0];assert.ok(frame&&frame.type===response&&frame.sequence===next);return frame.payload; + }finally{raw.fill(0);} +} +try{ + console.log(JSON.stringify({handshake:decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK))})); + const session={id:crypto.getRandomValues(new Uint8Array(16)),async request(operation,body){ + if(![0,1,2,3,4,6,7,17].includes(operation)&&!(operation===5&&restore))throw Error('Probe operation not admitted'); + const id=crypto.getRandomValues(new Uint8Array(16));let raw,incoming; + try{raw=encodeManagement(this.id,id,operation,body);incoming=await exchange(FRAME.MANAGEMENT_REQUEST,raw,FRAME.MANAGEMENT_RESPONSE);const reply=decodeManagement(incoming); + assert.ok(equal(reply.session,this.id)&&equal(reply.request,id));if(reply.code)throw Object.assign(Error('Device status '+reply.code+' for '+operation),{code:reply.code});return reply.body; + }finally{raw?.fill(0);incoming?.fill(0);} + }}; + const operations=(await session.request(0,[]))[4];assert.ok([1,2,4,5,7,17].every(op=>operations.includes(op))); + const author=await proveDeviceAuthor(session),radios=[]; + for(const kind of [1,2]){const info=await readDeviceRadioIdentity(session,kind,author,{operations});radios.push({kind,present:info.present,generation:info.generation,publicKey:info.publicKey?hex(info.publicKey):null});} + const snapshot=await readConfigSnapshot(session),configHash=hex(new Uint8Array(await crypto.subtle.digest('SHA-256',snapshot.document.originalBytes))); + if(restore){ + const password=hex(crypto.getRandomValues(new Uint8Array(32)));let exported,preview; + try{exported=await exportEncryptedDeviceIdentity(session,password,{consent:true});preview=await previewEncryptedDeviceIdentity(exported.bytes,password,{session,operations}); + assert.equal(preview.metadata.changesIdentity,false);assert.equal(preview.metadata.targetGeneration,author.generation);assert.equal(preview.metadata.publicKey,hex(author.publicKey)); + const expectation={publicKey:preview.metadata.publicKey,identityHash:preview.metadata.identityHash,delivery:preview.metadata.delivery,generation:author.generation+1}; + await writeFile(option('--checkpoint'),JSON.stringify({expectation,radios,configHash,fields:snapshot.document.size})); + try{const result=await preview.restore({consent:true});console.log(JSON.stringify({sameReticulumIdentityRestore:result.state,expectedGeneration:result.expectation.generation,publicKeyUnchanged:true,retainedSecretFiles:0}));} + catch(error){if(error.code!=='IDENTITY_RESTORE_OUTCOME_UNKNOWN')throw error;console.log(JSON.stringify({sameReticulumIdentityRestore:'indeterminate',expectedGeneration:error.expectation.generation,retainedSecretFiles:0}));} + }finally{preview?.close();exported?.bytes.fill(0);} + }else{ + const checkpoint=JSON.parse(await readFile(option('--checkpoint'),'utf8'));await verifyRestoredDeviceIdentity(session,checkpoint.expectation); + assert.deepEqual(radios,checkpoint.radios);assert.equal(configHash,checkpoint.configHash);assert.equal(snapshot.document.size,checkpoint.fields); + console.log(JSON.stringify({ReticulumRestoreVerified:true,generation:author.generation,publicKeyUnchanged:true,bothRFIdentitiesUnchanged:true,fullConfigBytesUnchanged:true,fields:snapshot.document.size}));await session.request(3,[]); + } + snapshot.document.originalBytes.fill(0); +}finally{child.stdin.end();} diff --git a/tools/hostlink/probe.mjs b/tools/hostlink/probe.mjs new file mode 100644 index 00000000..17296cc5 --- /dev/null +++ b/tools/hostlink/probe.mjs @@ -0,0 +1,40 @@ +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {readConfigSnapshot} from '../../site/device/src/config-snapshot.js'; +import {readDeviceRadioIdentity} from '../../site/device/src/radio-identity.js'; +import {equal} from '../../site/geocaching/src/protocol.js'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +if(!args.includes('--port')||!args.includes('--python'))throw Error('Usage: node tools/hostlink/probe.mjs --port COMxx --python '); +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true}); +let pending,sequence=0; +const lines=createInterface({input:child.stdout}); +lines.on('line',line=>{const reply=JSON.parse(line);if(pending){const active=pending;pending=null;reply.error?active.reject(Error(reply.error)):active.resolve(reply);}}); +child.on('exit',code=>{if(pending){pending.reject(Error('Serial transport exited: '+code));pending=null;}}); +async function exchange(type,payload,response){ + if(pending)throw Error('Probe exchanges must be sequential'); + const next=++sequence,raw=encodeFrame(type,next,payload); + const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:Buffer.from(raw).toString('hex'),sequence:next,response})+'\n');}); + const frame=new FrameDecoder().push(Uint8Array.from(Buffer.from(reply.hex,'hex')))[0]; + if(!frame||frame.type!==response||frame.sequence!==next)throw Error('Invalid serial response');return frame.payload; +} +try{ + const hello=decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK)); + console.log(JSON.stringify({handshake:hello})); + const session={id:crypto.getRandomValues(new Uint8Array(16)),async request(operation,body){ + if(![0,1,2,6,7,17].includes(operation))throw Error('Read-only probe rejected operation'); + const id=crypto.getRandomValues(new Uint8Array(16)),reply=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,id,operation,body),FRAME.MANAGEMENT_RESPONSE)); + if(!equal(reply.session,this.id)||!equal(reply.request,id))throw Error('Management correlation mismatch'); + if(reply.code!==0)throw Object.assign(Error('Device status '+reply.code),{code:reply.code});return reply.body; + }}; + const capabilities=await session.request(0,[]);console.log(JSON.stringify({capabilities})); + if(capabilities[4]?.includes(6))console.log(JSON.stringify({identityFormats:await session.request(6,[])})); + if([1,2].every(op=>capabilities[4]?.includes(op))){try{const author=await proveDeviceAuthor(session);console.log(JSON.stringify({authorVerified:author.verified,fingerprint:author.fingerprint,generation:author.generation})); + if(args.includes('--expect-author')&&author.fingerprint!==option('--expect-author'))throw Error('Author fingerprint differs from expected baseline'); + if(args.includes('--radio-info')&&capabilities[4].includes(7))for(const kind of [1,2]){const radio=await readDeviceRadioIdentity(session,kind,author,{operations:capabilities[4]});console.log(JSON.stringify({radioIdentityInfo:{kind,present:radio.present,generation:radio.generation,publicKey:radio.publicKey?Buffer.from(radio.publicKey).toString('hex'):null}}));} + }catch(error){console.log(JSON.stringify({identityCheckFailed:true,code:error.code??null,error:error.message}));process.exitCode=1;}} + if(capabilities[4]?.includes(17)){const snapshot=await readConfigSnapshot(session),sha256=Buffer.from(snapshot.sha256).toString('hex');console.log(JSON.stringify({configVerified:true,fields:snapshot.document.size,bytes:snapshot.document.originalBytes.length,meshProtocol:snapshot.document.get('protocol.active').value,sha256}));if(args.includes('--expect-config-sha')&&sha256!==option('--expect-config-sha'))throw Error('Configuration differs from expected baseline');} +}finally{child.stdin.end();} diff --git a/tools/hostlink/radio_identity_probe.mjs b/tools/hostlink/radio_identity_probe.mjs new file mode 100644 index 00000000..4df68335 --- /dev/null +++ b/tools/hostlink/radio_identity_probe.mjs @@ -0,0 +1,75 @@ +// Read/export by default; explicit --restore-kind restores exactly the exported +// current RF key and advances its generation. --verify only reads after reboot. +// Secret responses and encryption passwords stay transient; logs contain public results. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {readFile,writeFile} from 'node:fs/promises'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {readDeviceRadioIdentity,exportEncryptedRadioIdentity,decryptRadioIdentityBackup,clearRadioIdentityBackup,restoreRadioIdentityBackup,verifyRestoredRadioIdentity} from '../../site/device/src/radio-identity.js'; +import {exportEncryptedDeviceIdentity} from '../../site/device/src/identity-backup-export.js'; +import {equal} from '../../site/geocaching/src/protocol.js'; + +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +if(!args.includes('--port')||!args.includes('--python'))throw Error('Use --port COMxx --python '); +const restoreKind=args.includes('--restore-kind')?Number(option('--restore-kind')):null; +if(restoreKind!==null&&(![1,2].includes(restoreKind)||!args.includes('--checkpoint')||args.includes('--verify')))throw Error('Explicit kind 1/2 and public --checkpoint required'); +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true}); +let pending,sequence=0; +createInterface({input:child.stdout}).on('line',line=>{const reply=JSON.parse(line);if(pending){const active=pending;pending=null;reply.error?active.reject(Error(reply.error)):active.resolve(reply);}}); +child.on('exit',code=>{if(pending){pending.reject(Error('Serial transport exited '+code));pending=null;}}); +async function exchange(type,payload,response){ + if(pending)throw Error('Sequential exchange required');const next=++sequence,raw=encodeFrame(type,next,payload); + try{const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:Buffer.from(raw).toString('hex'),sequence:next,response})+'\n');}); + const incoming=new FrameDecoder().push(Uint8Array.from(Buffer.from(reply.hex,'hex')))[0]; + if(!incoming||incoming.type!==response||incoming.sequence!==next)throw Error('Unexpected serial frame');return incoming.payload; + }finally{raw.fill(0);} +} +try{ + console.log(JSON.stringify({handshake:decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK))})); + const session={id:crypto.getRandomValues(new Uint8Array(16)),async request(operation,body,{requestId}={}){ + if(![0,1,2,4,6,7,8].includes(operation)&&!(operation===9&&restoreKind!==null))throw Error('Probe operation not admitted'); + const id=requestId??crypto.getRandomValues(new Uint8Array(16));let raw,response; + try{raw=encodeManagement(this.id,id,operation,body);response=await exchange(FRAME.MANAGEMENT_REQUEST,raw,FRAME.MANAGEMENT_RESPONSE);const reply=decodeManagement(response); + if(!equal(reply.session,this.id)||!equal(reply.request,id))throw Error('Management correlation mismatch'); + if(reply.code!==0)throw Object.assign(Error('Device rejected operation '+operation+' with status '+reply.code),{code:reply.code});return reply.body; + }finally{raw?.fill(0);response?.fill(0);} + }}; + const capabilities=await session.request(0,[]),operations=capabilities[4]; + assert.ok([1,2,7,8,9].every(operation=>operations.includes(operation)));console.log(JSON.stringify({managementOperations:operations.length,RFManagement:true})); + if(args.includes('--verify')){ + const expectation=JSON.parse(await readFile(option('--verify'),'utf8')); + assert.ok([1,2].includes(expectation.kind)&&/^[a-f0-9]{64}$/.test(expectation.publicKey)&&/^[a-f0-9]{128}$/.test(expectation.authorPublicKey)); + assert.ok(Number.isInteger(expectation.generation)&&expectation.generation>0&&Number.isInteger(expectation.authorGeneration)&&expectation.authorGeneration>0); + await verifyRestoredRadioIdentity(session,expectation,{operations});console.log(JSON.stringify({RFRestoreVerifiedAfterReboot:true,kind:expectation.kind,radioGeneration:expectation.generation,accountUnchanged:true})); + }else{ + const author=await proveDeviceAuthor(session);console.log(JSON.stringify({authorVerified:author.verified,fingerprint:author.fingerprint,generation:author.generation})); + // Fresh random password is never written to a file or printed. No backup is retained. + const password=Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); + for(const kind of [1,2]){ + const info=await readDeviceRadioIdentity(session,kind,author,{operations}); + console.log(JSON.stringify({kind,present:info.present,radioGeneration:info.generation})); + if(!info.present)continue;let exported,decoded; + try{exported=await exportEncryptedRadioIdentity(session,kind,password,{operations,consent:true});decoded=await decryptRadioIdentityBackup(exported.bytes,password); + assert.ok(equal(decoded.identity.publicKey,info.publicKey));assert.equal(decoded.identity.radioGeneration,info.generation); + assert.ok(equal(decoded.identity.sourceAuthor,author.publicKey));assert.equal(decoded.identity.authorGeneration,author.generation); + console.log(JSON.stringify({kind,signedUSBExportVerified:true,encryptionRoundtripVerified:true,encryptedBytes:exported.bytes.length})); + if(kind===restoreKind){ + const toHex=value=>Buffer.from(value).toString('hex'),expectation={kind,publicKey:toHex(info.publicKey),generation:info.generation+1,authorPublicKey:toHex(author.publicKey),authorGeneration:author.generation}; + await writeFile(option('--checkpoint'),JSON.stringify(expectation)); + try{const result=await restoreRadioIdentityBackup(session,decoded,author,info,{operations,consent:true});console.log(JSON.stringify({sameRFKeyRestore:result.state,expectation:result.expectation}));} + catch(error){if(error.code!=='RADIO_IDENTITY_OUTCOME_UNKNOWN')throw error;console.log(JSON.stringify({sameRFKeyRestore:'indeterminate',expectation:error.expectation}));} + break; + } + }finally{clearRadioIdentityBackup(decoded);exported?.bytes.fill(0);} + } + if(restoreKind===null){ + if(operations.includes(4)){const result=await exportEncryptedDeviceIdentity(session,password,{operations,consent:true});console.log(JSON.stringify({ReticulumEncryptedExportVerified:true,encryptedBytes:result.bytes.length}));result.bytes.fill(0);} + const after=await proveDeviceAuthor(session);assert.ok(equal(author.publicKey,after.publicKey));assert.equal(after.generation,author.generation); + console.log(JSON.stringify({accountUnchanged:true,identityWrites:0,networkPublications:0,retainedSecretFiles:0})); + } + } +}finally{child.stdin.end();} diff --git a/tools/hostlink/recover_geocaching_test_upload.mjs b/tools/hostlink/recover_geocaching_test_upload.mjs new file mode 100644 index 00000000..2ee99a07 --- /dev/null +++ b/tools/hostlink/recover_geocaching_test_upload.mjs @@ -0,0 +1,96 @@ +// Explicit, narrowly scoped maintenance of an isolated unpublished acceptance +// draft. No config/key/RF command; never delete a saved draft or signed version. +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import fs from 'node:fs/promises'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello,CAPABILITY} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {readDeviceDraft} from '../../site/geocaching/src/device-maintenance.js'; +import {readDeviceDraftPage} from '../../site/geocaching/src/device-draft-catalog.js'; +import {readSignatureRecovery} from '../../site/geocaching/src/device-signature-recovery.js'; +import {verifyPublicationReceipt} from '../../site/geocaching/src/publication-receipt.js'; +import {beginDevicePublicationConfirmation,readDevicePublicationConfirmation} from '../../site/geocaching/src/device-confirmation-status.js'; +import {equal,encode,verifySigned} from '../../site/geocaching/src/protocol.js'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +for(const name of ['--port','--python','--expected-author','--draft','--name'])if(!args.includes(name))throw Error('Explicit port, Python, author, isolated draft ID and name required'); +if(!/^COM\d+$/.test(option('--port'))||!/^[a-f0-9]{64}$/.test(option('--expected-author'))||!/^[a-f0-9]{32}$/.test(option('--draft'))||!/^Wio网页验收-[a-z0-9]+$/.test(option('--name')))throw Error('Invalid isolated test binding'); +const draftId=Uint8Array.from(Buffer.from(option('--draft'),'hex')); +const kind=args.includes('--confirmed-record')?6:5; +const resume=args.includes('--resume-confirmation'); +if(resume&&(kind!==6||args.includes('--abort')))throw Error('Confirmation resume requires the exact accepted record and excludes cleanup'); +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','pipe'],windowsHide:true}); +let pending,sequence=0,stderr='';child.stderr.on('data',data=>{stderr=(stderr+data).slice(-4096);}); +const done=new Promise((resolve,reject)=>{child.on('error',reject);child.on('exit',code=>{pending?.reject(Error('Serial transport stopped'));pending=null;resolve(code);});});done.catch(()=>{}); +const lines=createInterface({input:child.stdout});lines.on('line',line=>{try{const value=JSON.parse(line),current=pending;pending=null;if(current)value.error?current.reject(Error(value.error)):current.resolve(value);}catch(error){pending?.reject(error);pending=null;}}); +async function exchange(type,payload,response){ + if(pending)throw Error('Concurrent serial exchange rejected');const next=++sequence,raw=encodeFrame(type,next,payload); + const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:Buffer.from(raw).toString('hex'),sequence:next,response})+'\n');}); + const frames=new FrameDecoder().push(Uint8Array.from(Buffer.from(reply.hex,'hex'))),frame=frames[0]; + if(frames.length!==1||frame.type!==response||frame.sequence!==next)throw Error('Invalid serial frame');return frame.payload; +} +let abortAllowed=false,abortIssued=false,confirmationBinding=null,ownedSession=false; +const session={id:crypto.getRandomValues(new Uint8Array(16)),async request(op,body){ + if(![0,1,2,3,38,64,65,36,75,76].includes(op)||(op===65&&!equal(body[0],draftId))||(op===38&&(body[0]!==kind||body[1]!==null))||(op===64&&kind!==6))throw Error('Unrelated command rejected'); + if([75,76].includes(op)&&(!resume||!confirmationBinding||!equal(body[0],confirmationBinding.id)||(op===75&&(!equal(body[1],confirmationBinding.hash)||body[2]!==confirmationBinding.authorGeneration||!equal(body[3],confirmationBinding.receipt.raw)||!equal(body[4],confirmationBinding.receipt.signature)||!equal(body[5],confirmationBinding.receipt.publicKey)||!equal(body[6],confirmationBinding.receipt.epoch)))))throw Error('Unverified confirmation rejected'); + if(op===36){if(!abortAllowed||abortIssued)throw Error('Unverified or repeated abort rejected');abortIssued=true;} + const requestId=crypto.getRandomValues(new Uint8Array(16)),reply=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,requestId,op,body),FRAME.MANAGEMENT_RESPONSE)); + if(!equal(reply.session,this.id)||!equal(reply.request,requestId))throw Error('Management correlation mismatch'); + if(reply.code!==0)throw Object.assign(Error('Device status '+reply.code),{code:reply.code});return reply.body; +}}; +try{ + const hello=decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK)); + if(!(hello.capabilities&(kind===5?CAPABILITY.SHARED_SIGNATURE_RECOVERY:CAPABILITY.SHARED_CONFIRMATION_RECOVERY)))throw Error('Running firmware does not support safe input recovery'); + const caps=await session.request(0,[]),author=await proveDeviceAuthor(session); + if(author.fingerprint!==option('--expected-author'))throw Error('Unexpected device author'); + const snapshot=await readDeviceDraft(session,draftId,author,{timeoutMs:90000,retryMs:500}),draft=snapshot.draft; + if(!snapshot.editable||draft.name!==option('--name')||draft.state!==0||draft.latitudeE7!==310000000||draft.longitudeE7!==1210000000)throw Error('Draft is not the isolated acceptance fixture'); + let expectedHash,expectedSize,accepted,receipt; + if(kind===5){if(BigInt(draft.generation)!==1n||draft.baseHash!==null)throw Error('Unpublished acceptance fixture changed; preserved');} + else{ + const signed=JSON.parse(await fs.readFile(option('--confirmed-record'),'utf8')).map(part=>Uint8Array.from(part)),verified=await verifySigned(signed),record=verified.record; + if(BigInt(draft.generation)<2n||!equal(record[1],author.publicKey)||!equal(record[2],draftId)||record[3]!==1||record[5]!==0||record[8]!==option('--name')||!equal(verified.summary[2],draft.baseHash))throw Error('Accepted record does not match the device fixture'); + let page,row; + for(let count=0;count<32&&!row;count++){page=await readDeviceDraftPage(session,author,page?{offset:page.nextOffset,catalogGeneration:page.catalogGeneration}:{});row=page.rows.find(row=>equal(row.id,draftId));if(!page.hasMore)break;} + console.log(JSON.stringify({draft:option('--draft'),publication:row?.publication??null})); + // An interrupted IP confirmation has not yet created the device request + // ledger, so its projection can still be 0/0. The signed author/nonce, + // reserved draft base, exact staging SHA and signed receipt bind the resume. + if(!row||row.publication.latestRevision>1||row.publication.localChanges||!equal(row.baseHash,verified.summary[2])||(!resume&&(row.publication.latestRevision!==1||row.publication.confirmedRevision!==1||row.publication.pending)))throw Error('Device state does not match the exact accepted version'); + const source=encode(signed);expectedHash=new Uint8Array(await crypto.subtle.digest('SHA-256',source));expectedSize=source.length; + if(resume){accepted=signed;receipt=Object.fromEntries(Object.entries(JSON.parse(await fs.readFile(option('--resume-confirmation'),'utf8'))).map(([key,value])=>[key,Uint8Array.from(value)]));await verifyPublicationReceipt([receipt.raw,receipt.signature],accepted,receipt);} + } + await session.request(3,[]); + const settings={capabilities:hello.capabilities,operations:caps[4],timeoutMs:15000,kind}; + const row=await readSignatureRecovery(session,author,settings); + if(!row){console.log(JSON.stringify({pendingUnsignedInput:false,aborted:false,draft:option('--draft')}));} + else{ + if(!equal(row.draftId,draftId))throw Error('Shared input belongs to another draft; preserved'); + if(kind===6&&(!equal(row.hash,expectedHash)||row.total!==expectedSize))throw Error('Confirmation input differs from accepted signed version; preserved'); + console.log(JSON.stringify({pendingStagingKind:kind,draft:option('--draft'),bytes:row.total,authorGeneration:row.authorGeneration})); + if(resume){ + session.id=row.owner.slice();const proved=await proveDeviceAuthor(session);ownedSession=true; + if(proved.fingerprint!==author.fingerprint||proved.generation!==author.generation)throw Error('Device author changed; input preserved'); + const checked=await readSignatureRecovery(session,proved,settings); + if(!checked||!equal(checked.id,row.id)||!equal(checked.owner,row.owner)||!equal(checked.hash,expectedHash)||!equal(checked.draftId,draftId)||checked.total!==expectedSize)throw Error('Confirmation input changed; preserved'); + confirmationBinding={id:row.id.slice(),hash:expectedHash,authorGeneration:proved.generation,receipt}; + let result=await beginDevicePublicationConfirmation(session,{id:row.id,sha256:expectedHash,sealed:true,authorGeneration:proved.generation,receipt},proved); + const deadline=performance.now()+90000; + while(!result.confirmed){if(result.phase===2||performance.now()>=deadline)throw Error('Exact confirmation remains unconfirmed');await new Promise(resolve=>setTimeout(resolve,500));result=await readDevicePublicationConfirmation(session,result.binding,proved);} + const remaining=await readSignatureRecovery(session,proved,settings);if(remaining)throw Error('Confirmed input is still active'); + console.log(JSON.stringify({resumed:true,confirmed:true,revision:result.binding.revision,terminalInputReusable:true,draft:option('--draft'),newSignature:false})); + } + if(args.includes('--abort')){ + session.id=row.owner.slice();const proved=await proveDeviceAuthor(session); + if(proved.fingerprint!==author.fingerprint||proved.generation!==author.generation)throw Error('Device author changed; input preserved'); + const checked=await readSignatureRecovery(session,proved,settings); + if(!checked||!equal(checked.id,row.id)||!equal(checked.owner,row.owner)||!equal(checked.hash,row.hash)||!equal(checked.draftId,draftId)||checked.total!==row.total)throw Error('Shared input changed; preserved'); + abortAllowed=true;const response=await session.request(36,[row.id]); + if(!Array.isArray(response)||response.length)throw Error('Invalid cleanup response'); + const remaining=await readSignatureRecovery(session,proved,settings); + if(remaining)throw Error('Cleanup not verified'); + console.log(JSON.stringify({aborted:true,onlyStagingKind:kind,savedDraftPreserved:true,confirmedVersionPreserved:kind===6})); + } + } +}finally{if(ownedSession)await session.request(3,[]).catch(()=>{});child.stdin.end();const code=await done;lines.close();if(code!==0)throw Error('Serial transport failed: '+stderr);} diff --git a/tools/hostlink/serial_transport.py b/tools/hostlink/serial_transport.py new file mode 100644 index 00000000..6781a37b --- /dev/null +++ b/tools/hostlink/serial_transport.py @@ -0,0 +1,47 @@ +"""Bounded serial exchanges for the read-only HostLink probe.""" +import binascii +import json +import struct +import sys +import time +import serial + +port = serial.Serial(port=None, baudrate=115200, timeout=0.1, write_timeout=2) +port.port = sys.argv[1] +port.dtr = True +port.rts = True +try: + port.open() + for line in sys.stdin: + command = json.loads(line) + outgoing = bytes.fromhex(command['hex']) + port.write(outgoing) + deadline = time.monotonic() + 8 + buffered = bytearray() + result = None + while time.monotonic() < deadline and result is None: + buffered.extend(port.read(512)) + while len(buffered) >= 8: + if buffered[:3] != b'HL\x01': + del buffered[0] + continue + sequence, size = struct.unpack_from(' 512: + del buffered[0] + continue + if len(buffered) < size + 10: + break + frame = bytes(buffered[:size + 10]) + del buffered[:size + 10] + if binascii.crc_hqx(frame[:-2], 0xffff) != int.from_bytes(frame[-2:], 'little'): + continue + if sequence == command['sequence'] and frame[3] == command['response']: + result = frame.hex() + break + print(json.dumps({'hex': result, 'error': None if result else 'TIMEOUT'}), flush=True) +except Exception as error: + print(json.dumps({'error': type(error).__name__ + ': ' + str(error)}), flush=True) + sys.exit(1) +finally: + if port.is_open: + port.close() diff --git a/tools/hostlink/serve_device_test.mjs b/tools/hostlink/serve_device_test.mjs new file mode 100644 index 00000000..1e6eebfa --- /dev/null +++ b/tools/hostlink/serve_device_test.mjs @@ -0,0 +1,35 @@ +// Operator opens this URL in their ordinary visible browser. No browser is +// launched by this process. The current device page is gated to read-only USB. +import http from 'node:http';import path from 'node:path';import fs from 'node:fs/promises';import {build} from '../../site/node_modules/esbuild/lib/main.js'; +const root=path.resolve('site'),resultPath=path.resolve('.codex-build/hostlink-wio-l2/web-serial-read.json'); +const hook=await build({entryPoints:['tools/hostlink/web_serial_observer.js'],bundle:true,write:false,format:'iife',platform:'browser'}); +let lastReport; +const server=http.createServer(async(req,res)=>{ + try{ + const pathname=new URL(req.url,'http://localhost').pathname; + if(pathname==='/__observer.js'){res.setHeader('Content-Type','text/javascript');res.end(hook.outputFiles[0].text);return;} + if(pathname==='/__status'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify(lastReport??{waiting:true}));return;} + if(pathname==='/__report'&&req.method==='POST'){ + if(req.headers.origin!==origin){res.writeHead(403).end();return;} + const parts=[];let size=0;for await(const part of req){size+=part.length;if(size>16384){res.writeHead(413).end();return;}parts.push(part);} + const row=JSON.parse(Buffer.concat(parts).toString('utf8')); + // Explicit allowlist: even a modified browser report cannot persist a full + // configuration, arbitrary field values, keys or other supplied properties. + if(!Number.isSafeInteger(row.opened)||!Number.isSafeInteger(row.closed)||!Number.isSafeInteger(row.snapshots)||!Array.isArray(row.operations)||row.operations.length>512||row.operations.some(op=>![0,1,2,3,7,17].includes(op)))throw Error('Report shape'); + const config=row.config;if(config&&(!/^[a-f0-9]{64}$/.test(config.sha256)||!Number.isSafeInteger(config.fields)||!Number.isSafeInteger(config.bytes)))throw Error('Report config'); + lastReport={nativeSerial:true,opened:row.opened,closed:row.closed,snapshots:row.snapshots,capabilityCount:row.capabilityCount??null,operations:row.operations.slice(),config:config?{sha256:config.sha256,fields:config.fields,bytes:config.bytes}:null,authorVerified:row.authorVerified===true,configurationWrites:0,identityExports:0}; + await fs.writeFile(resultPath,JSON.stringify(lastReport,null,2));console.log(JSON.stringify(lastReport));res.end('ok');return; + } + const name=decodeURIComponent(pathname);if(!name.startsWith('/trail-mate/'))throw Error('prefix');const file=path.resolve(root,name.slice(12)+(name.endsWith('/')?'index.html':''));if(!file.startsWith(root+path.sep))throw Error('path'); + let bytes=await fs.readFile(file); + if(file===path.join(root,'device','index.html')){ + const banner=''; + const reporter=''; + bytes=Buffer.from(bytes.toString('utf8').replace('','').replace('',''+banner+reporter)); + } + res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.ttf':'font/ttf'})[path.extname(file)]??'application/octet-stream');res.end(bytes); + }catch{res.writeHead(404).end();} +}); +await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port; +console.log(JSON.stringify({event:'device_test_ready',url:origin+'/trail-mate/device/?lang=zh-Hans',status:origin+'/__status',durationMinutes:30})); +const end=setTimeout(()=>server.close(),30*60*1000);end.unref();process.on('SIGINT',()=>server.close());process.on('SIGTERM',()=>server.close()); diff --git a/tools/hostlink/track_history_probe.mjs b/tools/hostlink/track_history_probe.mjs new file mode 100644 index 00000000..b952f0a9 --- /dev/null +++ b/tools/hostlink/track_history_probe.mjs @@ -0,0 +1,47 @@ +// Rebind only the previously confirmed, isolated hardware-test archive using +// its original signed manifest and directory receipt. No new publication/signing. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {readFile,writeFile} from 'node:fs/promises'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {decodeTrackPublicationPlan} from '../../site/tracks/src/publication-plan.js'; +import {verifyTrackManifest} from '../../site/tracks/src/manifest.js'; +import {confirmDeviceTrackPublication} from '../../site/tracks/src/device-ip-confirmation.js'; +import {equal,encode} from '../../site/geocaching/src/protocol.js'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1],hex=value=>Buffer.from(value).toString('hex'),bin=value=>Uint8Array.from(Buffer.from(value,'hex')); +if(!args.includes('--port')||!args.includes('--python')||!args.includes('--archive')||!args.includes('--checkpoint'))throw Error('Use --port --python --archive --checkpoint'); +const job=JSON.parse(await readFile(option('--archive'),'utf8'));assert.ok(job.confirmed&&job.finished&&job.directory); +const plan=decodeTrackPublicationPlan(bin(job.plan)),signed=[plan.manifest,bin(job.signature)],version=await verifyTrackManifest(signed); +assert.equal(version.record[4],3);assert.equal(version.record[6],1); +const directory=job.directory,record={stage:'confirmed',draftId:'hardware-archive',draftVersion:3,authorKey:version.record[1],requestId:bin(job.request),directoryKey:bin(directory.directory),delivery:bin(directory.delivery),epoch:bin(directory.epoch),plan,signature:signed[1],receipt:encode(directory.receipt.map(bin))}; +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true});let pending,sequence=0,session; +createInterface({input:child.stdout}).on('line',line=>{const reply=JSON.parse(line);if(pending){const current=pending;pending=null;reply.error?current.reject(Error(reply.error)):current.resolve(reply);}}); +child.on('exit',code=>{if(pending){pending.reject(Error('Serial bridge exited '+code));pending=null;}}); +async function exchange(type,payload,response){ + assert.ok(!pending);const next=++sequence,reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:hex(encodeFrame(type,next,payload)),sequence:next,response})+'\n');}); + const frame=new FrameDecoder().push(bin(reply.hex))[0];assert.ok(frame&&frame.type===response&&frame.sequence===next);return frame.payload; +} +try{ + console.log(JSON.stringify({handshake:decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK))})); + session={id:crypto.getRandomValues(new Uint8Array(16)),async request(operation,body){ + if(![0,1,2,3,32,33,35,37,98].includes(operation))throw Error('Operation outside confirmed history test'); + const id=crypto.getRandomValues(new Uint8Array(16)),reply=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,id,operation,body),FRAME.MANAGEMENT_RESPONSE)); + assert.ok(equal(reply.session,this.id)&&equal(reply.request,id));if(reply.code)throw Object.assign(Error('Device status '+reply.code+' for '+operation),{code:reply.code});return reply.body; + }}; + const operations=(await session.request(0,[]))[4],author=await proveDeviceAuthor(session);assert.ok(equal(author.publicKey,record.authorKey));assert.notEqual(author.generation,plan.generation); + const saved=await readFile(option('--checkpoint'),'utf8').then(JSON.parse).catch(error=>{if(error.code==='ENOENT')return null;throw error;}); + if(saved){assert.equal(saved.generation,author.generation);assert.equal(saved.objectId,hex(version.objectId));assert.equal(saved.revisionHash,hex(version.revisionHash));assert.match(saved.receiptId,/^[a-f0-9]{32}$/);assert.match(saved.manifestId,/^[a-f0-9]{32}$/);} + const binding=saved?{receiptId:bin(saved.receiptId),manifestId:bin(saved.manifestId),generation:saved.generation}:{receiptId:crypto.getRandomValues(new Uint8Array(16)),manifestId:crypto.getRandomValues(new Uint8Array(16)),generation:author.generation}; + if(!saved)await writeFile(option('--checkpoint'),JSON.stringify({receiptId:hex(binding.receiptId),manifestId:hex(binding.manifestId),generation:binding.generation,objectId:hex(version.objectId),revisionHash:hex(version.revisionHash)})); + let phase;const options={operations,id:binding.receiptId,manifestId:binding.manifestId,importHistory:true,retryMs:250,onProgress:event=>{if(phase!==event.phase){phase=event.phase;console.log(JSON.stringify({phase}));}}}; + const first=await confirmDeviceTrackPublication(session,record,author,options);assert.ok(first.durable&&first.revision===3); + const second=await confirmDeviceTrackPublication(session,record,author,options);assert.ok(second.durable&&equal(first.objectId,second.objectId)&&equal(first.revisionHash,second.revisionHash)); + for(const [id,kind,data] of [[binding.manifestId,4,plan.manifest],[binding.receiptId,7,record.receipt]]){ + const digest=new Uint8Array(await crypto.subtle.digest('SHA-256',data));assert.deepEqual(await session.request(37,[id,kind,data.length,digest]),[]); + } + console.log(JSON.stringify({sameAuthorHistoryRebound:true,originalGeneration:plan.generation,currentGeneration:author.generation,revision:3,archived:true,durable:true,repeatConfirmationConsistent:true,contentSignRequests:0,networkPublications:0,temporaryProofUploadsCleaned:true})); +}finally{if(session)try{await session.request(3,[]);}catch{}child.stdin.end();} diff --git a/tools/hostlink/track_package_probe.mjs b/tools/hostlink/track_package_probe.mjs new file mode 100644 index 00000000..3385bbb5 --- /dev/null +++ b/tools/hostlink/track_package_probe.mjs @@ -0,0 +1,53 @@ +// Installs one isolated, foreign-author, signed KML/PNG fixture; never publishes. +// --query verifies persistent disk state after a new connection without uploading. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {readFile,writeFile} from 'node:fs/promises'; +import {Identity} from '../../site/node_modules/@reticulum/core/src/index.js'; +import {encodeFrame,FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {encodeManagement,decodeManagement} from '../../site/device/src/management-protocol.js'; +import {proveDeviceAuthor} from '../../site/device/src/author-identity.js'; +import {encode,equal} from '../../site/geocaching/src/protocol.js'; +import {encodeTrackManifest,trackManifestSigningBytes,verifyTrackManifest} from '../../site/tracks/src/manifest.js'; +import {installDeviceTrackPackage} from '../../site/tracks/src/device-package-install.js'; +import {queryInstalledDevicePackage} from '../../site/tracks/src/device-package-query.js'; + +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1],hex=value=>Buffer.from(value).toString('hex'),unhex=value=>Uint8Array.from(Buffer.from(value,'hex')); +if(!args.includes('--port')||!args.includes('--python')||!args.includes('--checkpoint'))throw Error('Use --port --python --checkpoint [--query]'); +const child=spawn(option('--python'),[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),option('--port')],{stdio:['pipe','pipe','inherit'],windowsHide:true});let pending,sequence=0; +createInterface({input:child.stdout}).on('line',line=>{const reply=JSON.parse(line);if(pending){const current=pending;pending=null;reply.error?current.reject(Error(reply.error)):current.resolve(reply);}}); +child.on('exit',code=>{if(pending){pending.reject(Error('Serial bridge exited '+code));pending=null;}}); +async function exchange(type,payload,response){ + assert.ok(!pending);const next=++sequence; + const reply=await new Promise((resolve,reject)=>{pending={resolve,reject};child.stdin.write(JSON.stringify({hex:hex(encodeFrame(type,next,payload)),sequence:next,response})+'\n');}); + const frame=new FrameDecoder().push(unhex(reply.hex))[0];assert.ok(frame&&frame.type===response&&frame.sequence===next);return frame.payload; +} +try{ + console.log(JSON.stringify({handshake:decodeHello(await exchange(FRAME.HELLO,new Uint8Array(),FRAME.HELLO_ACK))})); + const calls=[],session={id:crypto.getRandomValues(new Uint8Array(16)),async request(operation,body){ + if(![0,1,2,3,32,33,35,100,101].includes(operation))throw Error('Non-isolated package operation');calls.push(operation); + const id=crypto.getRandomValues(new Uint8Array(16)),response=decodeManagement(await exchange(FRAME.MANAGEMENT_REQUEST,encodeManagement(this.id,id,operation,body),FRAME.MANAGEMENT_RESPONSE)); + assert.ok(equal(response.session,this.id)&&equal(response.request,id));if(response.code)throw Object.assign(Error('Device status '+response.code+' for '+operation),{code:response.code});return response.body; + }}; + const capabilities=await session.request(0,[]),operations=capabilities[4],author=await proveDeviceAuthor(session),id=()=>crypto.getRandomValues(new Uint8Array(16)); + if(args.includes('--query')){ + const checkpoint=JSON.parse(await readFile(option('--checkpoint'),'utf8'));assert.equal(hex(author.publicKey),checkpoint.deviceAuthor); + const result=await queryInstalledDevicePackage(session,{author,operationId:id(),objectId:unhex(checkpoint.objectId),revisionHash:unhex(checkpoint.revisionHash)},{operations,timeoutMs:90000,retryMs:250}); + assert.ok(result.installed&&!result.missing&&result.target===checkpoint.target);assert.equal(calls.some(op=>[32,33,35,100].includes(op)),false); + console.log(JSON.stringify({persistentPackageVerified:true,target:result.target,originalGeneration:checkpoint.authorGeneration,currentGeneration:author.generation,uploads:0,installRequests:0})); + }else{ + const remote=await Identity.generate(),publicKey=await remote.getPublicKey();assert.ok(!equal(publicKey,author.publicKey)); + const kml=new TextEncoder().encode('USB package verification]]>1,2,0 1.001,2.001,0'); + const photo=unhex('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000b4944415478da636000020000050001a5f645400000000049454e44ae426082'); + const kmlHash=await Identity.fullHash(kml),photoHash=await Identity.fullHash(photo),index=encode([1,kmlHash,kml.length,[['photo',photoHash,photo.length,'image/png','photos/p.png',0,1]]]),timestamp=Math.floor(Date.now()/1000); + const manifest=encodeTrackManifest([2,publicKey,id(),0,1,null,0,timestamp,timestamp,'USB package verification','Isolated hardware test; not published.',[10000000,20000000,10010000,20010000],[157,null,null,2],[kmlHash,kml.length,'application/vnd.google-earth.kml+xml',1],[0,[],0,0,0,0,0,null],[await Identity.fullHash(index),index.length,1,1]]); + const signed=[manifest,await remote.sign(trackManifestSigningBytes(manifest))],verified=await verifyTrackManifest(signed),input={author,kml,signed,objectId:verified.objectId,revisionHash:verified.revisionHash,operationId:id(),kmlUploadId:id(),signedUploadId:id(),attachmentIndex:index,attachmentFiles:[{id:'photo',content:photo}],attachmentUploadIds:[{id:'photo',uploadId:id()}],indexUploadId:id(),packageUploadId:id()}; + const target='/routes/p-'+hex(verified.revisionHash)+'.kml';await writeFile(option('--checkpoint'),JSON.stringify({deviceAuthor:hex(author.publicKey),authorGeneration:author.generation,objectId:hex(verified.objectId),revisionHash:hex(verified.revisionHash),target})); + let previous;const result=await installDeviceTrackPackage(session,input,{operations,timeoutMs:90000,retryMs:250,onProgress:event=>{if(previous!==event.phase){previous=event.phase;console.log(JSON.stringify({phase:event.phase}));}}}); + assert.ok(result.installed&&result.target===target);assert.deepEqual(result.signed,signed);assert.equal(calls.some(op=>[71,96,99].includes(op)),false); + console.log(JSON.stringify({foreignAuthorPackageInstalled:true,target,PNGBytes:photo.length,originalSignaturePreserved:true,deviceContentSignatures:0,networkPublications:0})); + } + await session.request(3,[]); +}finally{child.stdin.end();} diff --git a/tools/hostlink/web_serial_observer.js b/tools/hostlink/web_serial_observer.js new file mode 100644 index 00000000..a292405b --- /dev/null +++ b/tools/hostlink/web_serial_observer.js @@ -0,0 +1,46 @@ +import {FrameDecoder,FRAME,decodeHello} from '../../site/device/src/hostlink.js'; +import {decodeManagement} from '../../site/device/src/management-protocol.js'; +import {TmsDocument} from '../../site/device/src/tms-document.js'; +// Diagnostic page only: native SerialPort streams remain native. Do not record +// configuration bytes, private keys or field values outside this page's memory. +const hex=value=>Array.from(value,byte=>byte.toString(16).padStart(2,'0')).join(''); +const allowed=new Set([0,1,2,3,7,17]),pending=new Map(),operations=[]; +let snapshots=0,opened=0,closed=0,hello,capabilities,readError,output,outputHash,configSummary; +const nativeOpen=SerialPort.prototype.open,nativeClose=SerialPort.prototype.close; +SerialPort.prototype.open=async function(...args){ + await nativeOpen.apply(this,args);opened++; + const input=new FrameDecoder(),getReader=this.readable.getReader.bind(this.readable),getWriter=this.writable.getWriter.bind(this.writable); + this.readable.getReader=(...args)=>{ + const reader=getReader(...args),read=reader.read.bind(reader); + reader.read=async(...args)=>{ + const result=await read(...args); + if(result.value)for(const frame of input.push(result.value))try{ + if(frame.type===FRAME.HELLO_ACK)hello=decodeHello(frame.payload); + if(frame.type!==FRAME.MANAGEMENT_RESPONSE)continue; + const reply=decodeManagement(frame.payload),op=pending.get(hex(reply.request));pending.delete(hex(reply.request)); + if(reply.code){readError='device status '+reply.code;continue;} + if(op===0)capabilities=reply.body;if(op!==17)continue; + const [offset,total,hash,bytes]=reply.body; + if(offset===0){output=new Uint8Array(total);outputHash=hash.slice();} + if(!output||offset+bytes.length>output.length)throw Error('Invalid snapshot offsets'); + output.set(bytes,offset); + if(offset+bytes.length===output.length){ + const retained=output;output=undefined; + try{const actual=hex(new Uint8Array(await crypto.subtle.digest('SHA-256',retained)));if(actual!==hex(outputHash))throw Error('Observed snapshot digest mismatch');configSummary={sha256:actual,fields:new TmsDocument(retained).size,bytes:retained.length};snapshots++;}finally{retained.fill(0);} + } + }catch(error){readError=String(error);} + return result; + };return reader; + }; + this.writable.getWriter=(...args)=>{ + const writer=getWriter(...args),write=writer.write.bind(writer); + writer.write=(bytes)=>{ + const frames=new FrameDecoder().push(bytes);if(frames.length!==1)throw Error('Hardware probe rejected non-HostLink write');const frame=frames[0]; + if(frame.type===FRAME.MANAGEMENT_REQUEST){const request=decodeManagement(frame.payload),operation=request.code;if(!allowed.has(operation))throw Error('Read-only acceptance rejected operation '+operation);operations.push(operation);pending.set(hex(request.request),operation);} + else if(frame.type!==FRAME.HELLO)throw Error('Read-only acceptance rejected frame '+frame.type); + return write(bytes); + };return writer; + }; +}; +SerialPort.prototype.close=async function(...args){const result=await nativeClose.apply(this,args);closed++;return result;}; +globalThis.webSerialProbeSummary=()=>({nativeSerial:true,opened,closed,snapshots,hello,operations:operations.slice(),capabilityCount:capabilities?.[4]?.length,config:configSummary,readError:readError??null}); diff --git a/tools/hostlink/web_serial_probe.mjs b/tools/hostlink/web_serial_probe.mjs new file mode 100644 index 00000000..55471095 --- /dev/null +++ b/tools/hostlink/web_serial_probe.mjs @@ -0,0 +1,36 @@ +// Explicit hardware acceptance, never routine CI. Uses native navigator.serial +// and the actual device page. Attempts CDP DeviceAccess selection where exposed; +// --interactive lets the operator select the native browser chooser instead. +// A writer guard permits only HELLO and read-only management operations. +import http from 'node:http';import fs from 'node:fs/promises';import path from 'node:path';import assert from 'node:assert/strict'; +import {build} from '../../site/node_modules/esbuild/lib/main.js'; +import {chromium} from '../../site/node_modules/playwright/index.mjs'; +const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]; +if(!args.includes('--port')||!/^COM\d+$/.test(option('--port')))throw Error('Use --port COMxx [--expect-config-sha SHA256] [--expect-author fingerprint]'); +const port=option('--port'),root=path.resolve('site'),resultPath=path.resolve('.codex-build/hostlink-wio-l2/web-serial-read.json'); +const observer=`import{FrameDecoder,FRAME,decodeHello}from'./site/device/src/hostlink.js';import{decodeManagement}from'./site/device/src/management-protocol.js';import{TmsDocument}from'./site/device/src/tms-document.js'; +const hex=value=>Array.from(value,byte=>byte.toString(16).padStart(2,'0')).join(''),allowed=new Set([0,1,2,3,7,17]),pending=new Map();let snapshots=0,opened=0,closed=0,hello,capabilities,readError,output,outputHash,configSummary; +const operations=[],nativeOpen=SerialPort.prototype.open,nativeClose=SerialPort.prototype.close; +SerialPort.prototype.open=async function(...args){await nativeOpen.apply(this,args);opened++;const input=new FrameDecoder(),getReader=this.readable.getReader.bind(this.readable),getWriter=this.writable.getWriter.bind(this.writable); + this.readable.getReader=(...args)=>{const reader=getReader(...args),read=reader.read.bind(reader);reader.read=async(...args)=>{const result=await read(...args);if(result.value)for(const frame of input.push(result.value)){try{if(frame.type===FRAME.HELLO_ACK)hello=decodeHello(frame.payload);if(frame.type!==FRAME.MANAGEMENT_RESPONSE)continue;const reply=decodeManagement(frame.payload),op=pending.get(hex(reply.request));pending.delete(hex(reply.request));if(reply.code){readError='device status '+reply.code;continue;}if(op===0)capabilities=reply.body;if(op!==17)continue;const [offset,total,hash,bytes]=reply.body;if(offset===0){output=new Uint8Array(total);outputHash=hash.slice();}if(!output||offset+bytes.length>output.length)throw Error('Invalid snapshot offsets');output.set(bytes,offset);if(offset+bytes.length===output.length){const retained=output;output=undefined;const actual=hex(new Uint8Array(await crypto.subtle.digest('SHA-256',retained)));if(actual!==hex(outputHash))throw Error('Observed snapshot digest mismatch');configSummary={sha256:actual,fields:new TmsDocument(retained).size,bytes:retained.length};retained.fill(0);snapshots++;}}catch(error){readError=String(error);}}return result;};return reader;}; + this.writable.getWriter=(...args)=>{const writer=getWriter(...args),write=writer.write.bind(writer);writer.write=(bytes)=>{const frames=new FrameDecoder().push(bytes);if(frames.length!==1)throw Error('Hardware probe rejected non-HostLink write');const frame=frames[0];if(frame.type===FRAME.MANAGEMENT_REQUEST){const request=decodeManagement(frame.payload),operation=request.code;if(!allowed.has(operation))throw Error('Hardware probe rejected modifying operation '+operation);operations.push(operation);pending.set(hex(request.request),operation);}else if(frame.type!==FRAME.HELLO)throw Error('Hardware probe rejected frame '+frame.type);return write(bytes);};return writer;}; +}; +SerialPort.prototype.close=async function(...args){const result=await nativeClose.apply(this,args);closed++;return result;}; +globalThis.webSerialProbeSummary=()=>({nativeSerial:true,opened,closed,snapshots,hello,operations:operations.slice(),capabilityCount:capabilities?.[4]?.length,config:configSummary,readError:readError??null});`; +const hook=await build({stdin:{contents:observer,resolveDir:process.cwd()},bundle:true,write:false,format:'iife',platform:'browser'}); +const server=http.createServer(async(req,res)=>{try{const name=decodeURIComponent(new URL(req.url,'http://localhost').pathname);if(!name.startsWith('/trail-mate/'))throw Error('prefix');const file=path.resolve(root,name.slice(12)+(name.endsWith('/')?'index.html':''));if(!file.startsWith(root+path.sep))throw Error('path');const bytes=await fs.readFile(file);res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.ttf':'font/ttf'})[path.extname(file)]??'application/octet-stream');res.end(bytes);}catch{res.writeHead(404).end();}});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); +const interactive=args.includes('--interactive'),browser=await chromium.launch({channel:process.env.TRAIL_MATE_TEST_BROWSER_CHANNEL??'msedge',headless:!interactive}),errors=[],prompts=[];let page,chooserError; +try{ + page=await browser.newPage();page.on('pageerror',error=>errors.push(String(error)));await page.addInitScript({content:hook.outputFiles[0].text}); + const cdp=await page.context().newCDPSession(page);await cdp.send('DeviceAccess.enable'); + cdp.on('DeviceAccess.deviceRequestPrompted',async event=>{try{prompts.push(event.devices.map(device=>device.name));const candidates=event.devices.filter(device=>new RegExp('\\b'+port+'\\b','i').test(device.name));if(candidates.length===0)return;if(candidates.length!==1){await cdp.send('DeviceAccess.cancelPrompt',{id:event.id});throw Error('Expected exactly one '+port+' chooser entry');}await cdp.send('DeviceAccess.selectPrompt',{id:event.id,deviceId:candidates[0].id});}catch(error){chooserError=String(error);}}); + await page.goto(`http://127.0.0.1:${server.address().port}/trail-mate/device/?lang=zh-Hans`);assert.equal(await page.evaluate(()=>Boolean(navigator.serial)),true); + await page.getByRole('button',{name:'连接设备',exact:true}).click();if(interactive)console.log('Choose '+port+' in the browser serial dialog. This probe cannot modify configuration or identity.');await page.getByText('已验证配置快照,共 309 个字段。',{exact:true}).waitFor({timeout:interactive?300000:30000});await page.getByText('设备作者签名已验证。',{exact:true}).waitFor(); + if(chooserError)throw Error(chooserError);if(args.includes('--expect-author'))await page.getByText(option('--expect-author'),{exact:true}).waitFor(); + const first=await page.evaluate(()=>webSerialProbeSummary());assert.equal(first.snapshots,1);assert.equal(first.capabilityCount,56);assert.equal(first.readError,null);if(args.includes('--expect-config-sha'))assert.equal(first.config.sha256,option('--expect-config-sha')); + await page.getByRole('button',{name:'断开设备',exact:true}).click();await page.waitForFunction(()=>webSerialProbeSummary().closed===1);await page.getByText('设备已断开,作者授权已结束。',{exact:true}).waitFor(); + await page.getByRole('button',{name:'连接设备',exact:true}).click();if(interactive)console.log('Reconnect: choose '+port+' again.');await page.waitForFunction(()=>webSerialProbeSummary().snapshots===2,{},{timeout:interactive?300000:30000});await page.getByText('设备作者签名已验证。',{exact:true}).waitFor(); + await page.getByRole('button',{name:'断开设备',exact:true}).click();await page.waitForFunction(()=>webSerialProbeSummary().closed===2); + const result=await page.evaluate(()=>webSerialProbeSummary());assert.deepEqual(result.config,first.config);assert.equal(result.readError,null);assert.deepEqual(errors,[]);assert.ok(result.operations.every(op=>[0,1,2,3,7,17].includes(op)));await fs.writeFile(resultPath,JSON.stringify({passed:true,port,...result,configurationWrites:0,identityExports:0},null,2));console.log(JSON.stringify({passed:true,port,...result,configurationWrites:0,identityExports:0})); +}catch(error){const summary=page?await page.evaluate(()=>typeof webSerialProbeSummary==='function'?webSerialProbeSummary():null).catch(()=>null):null;await fs.writeFile(resultPath,JSON.stringify({passed:false,port,error:String(error),chooserError,prompts,summary,errors},null,2));console.error(JSON.stringify({chooserError,prompts,summary}));throw error;} +finally{await browser.close();await new Promise(resolve=>server.close(resolve));} diff --git a/variants/wio_tracker_l2/envs/wio_tracker_l2.ini b/variants/wio_tracker_l2/envs/wio_tracker_l2.ini index 2a2cd39b..913ddd97 100644 --- a/variants/wio_tracker_l2/envs/wio_tracker_l2.ini +++ b/variants/wio_tracker_l2/envs/wio_tracker_l2.ini @@ -8,8 +8,15 @@ upload_protocol = esptool upload_speed = 921600 monitor_speed = 115200 build_unflags = ${arduino_base.build_unflags} + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 build_flags = ${arduino_base.build_flags} + -DARDUINO_USB_MODE=0 + -DARDUINO_USB_CDC_ON_BOOT=0 + -DTRAIL_MATE_ENABLE_USB_CDC_TRANSPORT=1 + -DTRAIL_MATE_USB_HOSTLINK_AUTOSTART=1 + -Imodules/core_hostlink/include -DARDUINO_LOOP_STACK_SIZE=12288 -DCONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=1 -DCONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=1 @@ -35,10 +42,15 @@ build_src_filter = - lib_deps = ${arduino_base.lib_deps} + file://third_party/yxml lovyan03/LovyanGFX @ 1.2.28 https://github.com/pschatzmann/arduino-audio-driver/archive/refs/tags/v0.3.1.zip lib_ignore = - ${arduino_base.lib_ignore} + gat562_mesh_evb_pro + boards_gat562_mesh_evb_pro + trailmate-nrf52-node-app-shell + platform_nrf52_arduino_common + SD boards_tdeck boards_tdeck_pro boards_tlora_pager