mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 05:32:18 +00:00
feat(firmware): add bounded USB configuration and device author operations
This commit is contained in:
1 parent
30d6e7be19
commit
ec513cbacf
611 files changed
+57180
-1562
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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) |
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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<uintptr_t>(output);
|
||||
const auto overlaps = [start, required](const uint8_t* source, size_t length)
|
||||
{
|
||||
const auto input = reinterpret_cast<uintptr_t>(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<uint8_t>(length);
|
||||
std::memcpy(output + offset, source, length);
|
||||
offset += length;
|
||||
}
|
||||
if (integer_bytes == 1) output[offset++] = static_cast<uint8_t>(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<uint8_t>(generation >> ((remaining - 1) * 8));
|
||||
}
|
||||
written = offset;
|
||||
return true;
|
||||
}
|
||||
} // namespace chat
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
namespace chat
|
||||
{
|
||||
enum class DeviceIdentityRestoreResult : uint8_t
|
||||
{
|
||||
CommittedRebootRequired,
|
||||
IndeterminateRebootRequired,
|
||||
Unavailable,
|
||||
StaleIdentity,
|
||||
InvalidInput,
|
||||
StorageError,
|
||||
Unsupported,
|
||||
StaleRadioIdentity,
|
||||
};
|
||||
}
|
||||
@@ -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 <memory>
|
||||
|
||||
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<uint8_t,32>*) { return MeshSendResult::fail(MeshOperationFailure::Unsupported); }
|
||||
virtual ~IMeshAdapter() = default;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<uint8_t, 32>* accepted_hash) = 0;
|
||||
// Lifetime is owned by the application worker; unbind before destruction.
|
||||
virtual void setTrackDeliveryBinding(const lxmf::TrackDeliveryBinding* binding) = 0;
|
||||
};
|
||||
} // namespace chat
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include "chat/ports/device_author.h"
|
||||
#include "chat/ports/device_identity_restore.h"
|
||||
#include <atomic>
|
||||
#include <new>
|
||||
|
||||
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 Identity, class Memory>
|
||||
class StoredIdentityLease
|
||||
{
|
||||
public:
|
||||
StoredIdentityLease() : StoredIdentityLease([](Identity& identity) { return identity.initExisting(); }) {}
|
||||
template <class Initialize>
|
||||
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<volatile uint8_t*>(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<bool> reboot_required_{false};
|
||||
};
|
||||
} // namespace chat
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
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*);
|
||||
}
|
||||
@@ -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 <class Identity>
|
||||
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 <class Identity>
|
||||
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
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#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
|
||||
@@ -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 <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<int32_t>((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<pb_size_t>(output.bytes_written);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace chat::runtime
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
|
||||
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<pb_size_t>(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<int32_t>(latitude * 1e7));
|
||||
assert(decoded.longitude_i == static_cast<int32_t>(longitude * 1e7));
|
||||
}
|
||||
else
|
||||
{
|
||||
const int64_t width = int64_t{1} << (32 - bits);
|
||||
assert(std::abs(int64_t{decoded.latitude_i} - static_cast<int32_t>(latitude * 1e7)) <= width);
|
||||
assert(std::abs(int64_t{decoded.longitude_i} - static_cast<int32_t>(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<pb_size_t>(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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<uintptr_t>(a.data), y = reinterpret_cast<uintptr_t>(b.data);
|
||||
return x <= y ? y - x < a.size : x - y < b.size;
|
||||
};
|
||||
const ByteView result_bytes{reinterpret_cast<const uint8_t*>(&output), sizeof(output)};
|
||||
const ByteView inputs[] = {raw, signature, public_key, directory, epoch, content.record.encoded, content.signature,
|
||||
{reinterpret_cast<const uint8_t*>(&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<uintptr_t>(workspace), b = reinterpret_cast<uintptr_t>(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<uint8_t>(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<const uint8_t*>(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<uint32_t>(revision), static_cast<uint8_t>(state), observed};
|
||||
return VerificationResult::Valid;
|
||||
}
|
||||
} // namespace geocaching::protocol
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<uintptr_t>(encoded.data),b=reinterpret_cast<uintptr_t>(&output);if(a<=b?b-a<encoded.size:a-b<sizeof(output))return false;}
|
||||
output = {};
|
||||
protocol::CmpReader reader(encoded);
|
||||
size_t fields = 0;
|
||||
uint64_t version = 0;
|
||||
PublicationConfirmationView value;
|
||||
if (!encoded.data || !encoded.size || encoded.size > 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<uintptr_t>(output),b=reinterpret_cast<uintptr_t>(view.data);if(a<=b?b-a<capacity:a-b<view.size)return false;}
|
||||
protocol::CmpWriter writer(output,capacity<384?capacity:384);
|
||||
if(!writer.array(4)||!writer.unsignedInteger(1)||!writer.binary(input.raw)||!writer.binary(input.signature)||!writer.binary(input.public_key))return false;
|
||||
written=writer.size();return true;
|
||||
}
|
||||
// Recovery must verify this proof against the original verified publication,
|
||||
// not interpret a stored terminal state alone as directory confirmation.
|
||||
inline protocol::VerificationResult verifyPublicationConfirmation(ByteView encoded, ByteView directory,
|
||||
const protocol::VerifiedRecordView& content, protocol::RecordCrypto& crypto,
|
||||
uint8_t* workspace, size_t capacity, protocol::PublicationReceiptView& output)
|
||||
{
|
||||
if(encoded.data){const auto a=reinterpret_cast<uintptr_t>(encoded.data),b=reinterpret_cast<uintptr_t>(&output);if(a<=b?b-a<encoded.size:a-b<sizeof(output))return protocol::VerificationResult::InvalidRecord;}
|
||||
output={};
|
||||
PublicationConfirmationView proof;
|
||||
if(!decodePublicationConfirmation(encoded,proof))return protocol::VerificationResult::InvalidRecord;
|
||||
protocol::CmpReader raw(proof.raw);
|
||||
size_t fields=0;uint64_t version=0;ByteView destination,epoch;
|
||||
if(!raw.array(fields,8)||fields!=8||!raw.unsignedInteger(version)||version!=1||!raw.binary(destination,16)||destination.size!=16||
|
||||
!raw.binary(epoch,16)||epoch.size!=16)return protocol::VerificationResult::InvalidRecord;
|
||||
return protocol::verifyPublicationReceipt(proof.raw,proof.signature,proof.public_key,directory,epoch,content,crypto,workspace,capacity,output);
|
||||
}
|
||||
} // namespace geocaching::storage
|
||||
@@ -24,10 +24,28 @@ class PublicationHistoryScan
|
||||
AuthorIssuedView issued;
|
||||
if (!decodeAuthorIssued(key, value, issued) || !issued.issued_at.has_utc ||
|
||||
std::memcmp(issued.author_public_key.data, author.data, 64) || count_ == UINT32_MAX) return false;
|
||||
if(recovery_validation_)
|
||||
{
|
||||
if(issued.revision>history_.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_||confirmed<recovery_floor_||confirmed>history_.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
|
||||
@@ -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<uintptr_t>(input.data), destination = reinterpret_cast<uintptr_t>(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
|
||||
@@ -8,19 +8,26 @@ struct PublicationRecoveryFilter
|
||||
Destination local, remote;
|
||||
GeocacheId cache;
|
||||
RevisionHash hash;
|
||||
std::array<uint8_t, 16> 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 <class View>
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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<DirectoryEntry, 4> directory_entries_{};
|
||||
std::array<DirectoryEntry, kDirectoryCapacity> directory_entries_{};
|
||||
DirectoryPool directories_;
|
||||
DirectoryEntry* selected_ = nullptr;
|
||||
protocol::QueryRegion region_;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include "runtime_environment.h"
|
||||
namespace app
|
||||
{
|
||||
inline AppContext& appFacade() { return AppContext::getInstance(); }
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#pragma once
|
||||
#include <Arduino.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<uint32_t>(runtime_test::clock_ms); }
|
||||
inline int64_t esp_timer_get_time() { return static_cast<int64_t>(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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<uint8_t> 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<uint8_t> 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<uint8_t>(status >> 8);
|
||||
malformed[24] = static_cast<uint8_t>(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));
|
||||
}
|
||||
@@ -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<uint8_t, 16> 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<uint8_t> 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<uint8_t, 64>{};
|
||||
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<uint8_t> 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<uint8_t, 16> 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<uint8_t, 48> 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<unsigned>(static_cast<uint8_t>(::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();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#include "geocaching/storage/publication_recovery.h"
|
||||
#include <cassert>
|
||||
|
||||
int main()
|
||||
{
|
||||
using namespace geocaching;
|
||||
using namespace geocaching::storage;
|
||||
PublicationRecoveryFilter filter;
|
||||
std::array<uint8_t, 48> key{};
|
||||
std::array<uint8_t, 16> 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<uint8_t, 32> 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);
|
||||
}
|
||||
@@ -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 <array>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <vector>
|
||||
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<uint8_t> signed_record((std::istreambuf_iterator<char>(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<uint8_t, 64> public_key{}, private_key{}, signature{};
|
||||
std::array<uint8_t, 32> seed{}, hash{};
|
||||
for (unsigned i = 0; i < 32; ++i) { seed[i] = static_cast<uint8_t>(100 + i); public_key[i] = static_cast<uint8_t>(i + 1); }
|
||||
ed25519_create_keypair(public_key.data() + 32, private_key.data(), seed.data());
|
||||
std::array<uint8_t, 16> 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<const uint8_t*>(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<uint8_t, 256> 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<uint8_t>(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<const uint8_t*>(&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<uint8_t,384> 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<uint8_t,48> request_key{};
|
||||
std::memcpy(request_key.data()+16,delivery.data(),16);std::memcpy(request_key.data()+32,request_id.bytes.data(),16);
|
||||
std::vector<uint8_t> 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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ int main(int argc, char** argv)
|
||||
assert(argc == 3);
|
||||
Port port;
|
||||
auto client = std::make_unique<geocaching::QueryClient>(port);
|
||||
assert(client->directoryCount() == 0 && client->directory(0) == nullptr);
|
||||
geocaching::Destination discovery, delivery;
|
||||
std::array<std::uint8_t, 64> key{};
|
||||
std::vector<std::uint8_t> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#pragma once
|
||||
|
||||
#include "gps/kml/image_sources.h"
|
||||
#include <cstring>
|
||||
|
||||
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 <class Callback>
|
||||
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 <class Callback>
|
||||
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
|
||||
@@ -0,0 +1,196 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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 <class Callback>
|
||||
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) == "<!--")
|
||||
{
|
||||
const auto end = text.find("-->", 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) == "<![CDATA[")
|
||||
{
|
||||
offset = opening + 9;
|
||||
continue;
|
||||
}
|
||||
if (offset >= 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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<uintptr_t>(input), out = reinterpret_cast<uintptr_t>(&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<uint32_t>(generation);
|
||||
output = value;
|
||||
return Status::Ok;
|
||||
}
|
||||
} // namespace hostlink::management
|
||||
@@ -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<uintptr_t>(input), out = reinterpret_cast<uintptr_t>(&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<uint32_t>(bytes);
|
||||
parsed.author_generation = static_cast<uint32_t>(generation);
|
||||
output = parsed;
|
||||
return Status::Ok;
|
||||
}
|
||||
} // namespace hostlink::management
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
#include "geocaching/usecase/author_issue.h"
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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
|
||||
@@ -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<uint8_t>& out);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ enum class HostlinkCommandId : uint8_t
|
||||
SetTime,
|
||||
GetGps,
|
||||
TxAppData,
|
||||
Management,
|
||||
};
|
||||
|
||||
enum class HostlinkFrameDecisionType : uint8_t
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
#include "chat/ports/device_author.h"
|
||||
#include "geocaching/protocol/cmp_writer.h"
|
||||
#include <cstring>
|
||||
|
||||
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 <class Identity>
|
||||
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<uintptr_t>(output), object = reinterpret_cast<uintptr_t>(&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<uint8_t>(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
|
||||
@@ -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 <class Adapter>
|
||||
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
|
||||
@@ -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 <cstring>
|
||||
|
||||
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<uintptr_t>(work), input = reinterpret_cast<uintptr_t>(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<uintptr_t>(value.signature.data);
|
||||
const auto public_key = reinterpret_cast<uintptr_t>(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
|
||||
@@ -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 <typename Adapter>
|
||||
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<volatile uint8_t*>(&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
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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<uintptr_t>(scratch);
|
||||
const uintptr_t pub = reinterpret_cast<uintptr_t>(public_key);
|
||||
const uintptr_t priv = reinterpret_cast<uintptr_t>(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
|
||||
@@ -0,0 +1,282 @@
|
||||
#pragma once
|
||||
|
||||
#include "hostlink_types.h"
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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<int64_t>((~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<size_t>(n))) return false;
|
||||
offset_ += static_cast<size_t>(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<uint16_t>(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<uint8_t>(code);
|
||||
else if (code <= 255)
|
||||
{
|
||||
out[38] = 0xcc;
|
||||
out[39] = static_cast<uint8_t>(code);
|
||||
}
|
||||
else
|
||||
{
|
||||
out[38] = 0xcd;
|
||||
out[39] = static_cast<uint8_t>(code >> 8);
|
||||
out[40] = static_cast<uint8_t>(code);
|
||||
}
|
||||
written = prefix + body_size;
|
||||
return true;
|
||||
}
|
||||
} // namespace hostlink::management
|
||||
@@ -0,0 +1,144 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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<uint32_t>(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
|
||||
@@ -0,0 +1,127 @@
|
||||
#pragma once
|
||||
|
||||
#include "hostlink/hostlink_codec.h"
|
||||
#include "hostlink/management_envelope.h"
|
||||
#include <cstring>
|
||||
|
||||
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<uint8_t>(crc) || bytes[size - 1] != static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(fields.draft_generation >> (8 * i));
|
||||
output[217 + i] = static_cast<uint8_t>(fields.created_ms >> (8 * i));
|
||||
}
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
{
|
||||
output[77 + i] = static_cast<uint8_t>(fields.author_generation >> (8 * i));
|
||||
output[209 + i] = static_cast<uint8_t>(fields.from_revision >> (8 * i));
|
||||
output[213 + i] = static_cast<uint8_t>(fields.to_revision >> (8 * i));
|
||||
output[225 + i] = static_cast<uint8_t>(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<uint8_t>(crc);
|
||||
output[kPublicationTicketBytes - 1] = static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(crc);
|
||||
bytes[size - 1] = static_cast<uint8_t>(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<uint8_t>(now_ms >> (8 * i));
|
||||
const auto crc = crc16_ccitt(bytes, size - 2);
|
||||
bytes[size - 2] = static_cast<uint8_t>(crc);
|
||||
bytes[size - 1] = static_cast<uint8_t>(crc >> 8);
|
||||
return true;
|
||||
}
|
||||
} // namespace hostlink::management
|
||||
@@ -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<uintptr_t>(work);
|
||||
for (const auto view : {request.payload, request.source_author, request.signature})
|
||||
{
|
||||
const auto input = reinterpret_cast<uintptr_t>(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 <class Adapter>
|
||||
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<volatile uint8_t*>(&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 <class Adapter>
|
||||
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
|
||||
@@ -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
|
||||
@@ -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 <class Io, class Check, class Guard>
|
||||
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<uintptr_t>(input), b = reinterpret_cast<uintptr_t>(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
|
||||
@@ -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 <class Port>
|
||||
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
|
||||
@@ -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 Io, class Hash>
|
||||
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 <class Guard>
|
||||
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<uintptr_t>(data), b = reinterpret_cast<uintptr_t>(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 <class Guard>
|
||||
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<Io, Hash> kml_;
|
||||
track_sharing::FileIntegrityCheck<Io, Hash> final_;
|
||||
RouteInstallPhase phase_ = RouteInstallPhase::Idle;
|
||||
Status status_ = Status::Busy;
|
||||
bool mutation_possible_ = false;
|
||||
};
|
||||
} // namespace hostlink::management
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
#include "geocaching/protocol/cmp_reader.h"
|
||||
#include "hostlink/management_envelope.h"
|
||||
#include <cstring>
|
||||
|
||||
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<uint32_t>(total);
|
||||
parsed.generation = static_cast<uint32_t>(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<uintptr_t>(hash.data), target = reinterpret_cast<uintptr_t>(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
|
||||
@@ -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>(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
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include "geocaching/protocol/cmp_reader.h"
|
||||
#include "geocaching/protocol/cmp_writer.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
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<uint32_t>(offset);
|
||||
output.maximum = static_cast<uint16_t>(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<uint32_t>(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
|
||||
@@ -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<size_t>(std::min<size_t>(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<uint32_t>(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
|
||||
@@ -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<uint8_t>(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<uint32_t>(media);
|
||||
output.bytes = static_cast<uint32_t>(bytes);
|
||||
output.format = static_cast<uint8_t>(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<uint8_t>(task.state())) ||
|
||||
!writer.unsignedInteger(task.copiedBytes()) || !writer.unsignedInteger(metadata.bytes) || !writer.unsignedInteger(metadata.format)) return false;
|
||||
written = writer.size();
|
||||
return true;
|
||||
}
|
||||
} // namespace hostlink::management
|
||||
@@ -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
|
||||
@@ -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<uint8_t>(seq);
|
||||
out[5] = static_cast<uint8_t>(seq >> 8);
|
||||
out[6] = static_cast<uint8_t>(len);
|
||||
out[7] = static_cast<uint8_t>(len >> 8);
|
||||
const uint16_t crc = crc16_ccitt(out, kHeaderSize + len);
|
||||
out[kHeaderSize + len] = static_cast<uint8_t>(crc);
|
||||
out[kHeaderSize + len + 1] = static_cast<uint8_t>(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<uint8_t>& out)
|
||||
{
|
||||
|
||||
@@ -37,6 +37,10 @@ HostlinkFrameDecision route_frame(const Frame& frame, bool ready)
|
||||
|
||||
switch (static_cast<FrameType>(frame.type))
|
||||
{
|
||||
case FrameType::ManagementRequest:
|
||||
return make_decision(HostlinkFrameDecisionType::DispatchCommand,
|
||||
HostlinkCommandId::Management,
|
||||
ErrorCode::Ok);
|
||||
case FrameType::CmdTxMsg:
|
||||
return make_decision(HostlinkFrameDecisionType::DispatchCommand,
|
||||
HostlinkCommandId::TxMsg,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "hostlink/hostlink_codec.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::array<uint8_t, hostlink::kMaxFrameLen + hostlink::kHeaderSize + hostlink::kCrcSize + 2> arena;
|
||||
std::array<uint8_t, hostlink::kMaxFrameLen> payload;
|
||||
for (size_t i = 0; i < payload.size(); ++i) payload[i] = static_cast<uint8_t>(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<uint8_t> 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()));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "hostlink/management_envelope.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
|
||||
int main()
|
||||
{
|
||||
std::array<uint8_t, 16> session, request;
|
||||
session.fill(1);
|
||||
request.fill(2);
|
||||
std::array<uint8_t, 512> 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<uint8_t, 474> 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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#include "hostlink/hostlink_frame_router.h"
|
||||
#include "hostlink/hostlink_service_codec.h"
|
||||
#include <cassert>
|
||||
|
||||
// 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<uint8_t>(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<uint8_t>(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);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "hostlink/object_transfer.h"
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
|
||||
using namespace hostlink::management;
|
||||
struct Storage : TransferStorage
|
||||
{
|
||||
std::vector<uint8_t> 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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "hostlink/track_snapshot.h"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "hostlink/track_snapshot_builder.h"
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
|
||||
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<uint32_t> 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<uint32_t>{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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include "app/tms_config_codec.h"
|
||||
#include <cstring>
|
||||
|
||||
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
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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<RouteImageCacheItem>& items,
|
||||
std::vector<RouteImageCacheItem> 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
|
||||
@@ -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<uint8_t>& out);
|
||||
bool get_blob_into(const char* ns,
|
||||
const char* key,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
|
||||
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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <class Directories, class Jobs>
|
||||
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<const uint8_t*>(a), a_size,
|
||||
reinterpret_cast<const uint8_t*>(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
|
||||
@@ -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<unsigned char>(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<unsigned char>(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<unsigned char>(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<uint32_t>(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<uint8_t>(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<uint32_t>(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<uint32_t>(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 <class Hash>
|
||||
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<const uint8_t*>(&cursor), sizeof(cursor)) ||
|
||||
signingSpansOverlap(raw.data, raw.size, reinterpret_cast<const uint8_t*>(&entry), sizeof(entry)) ||
|
||||
signingSpansOverlap(digest, 32, reinterpret_cast<const uint8_t*>(&cursor), sizeof(cursor)) ||
|
||||
signingSpansOverlap(digest, 32, reinterpret_cast<const uint8_t*>(&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
|
||||
@@ -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 Io, class Hash>
|
||||
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<const uint8_t*>(this);
|
||||
if (!stream_.admitted() || signingSpansOverlap(window, capacity, self, sizeof(*this)) ||
|
||||
signingSpansOverlap(reinterpret_cast<const uint8_t*>(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<const uint8_t*>(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<Io, Hash> integrity_;
|
||||
AttachmentIndexStream stream_;
|
||||
AttachmentIndexBinding expected_;
|
||||
uint8_t chunk_[320]{};
|
||||
size_t chunk_size_ = 0, chunk_position_ = 0;
|
||||
AttachmentIndexFileResult result_ = AttachmentIndexFileResult::Idle;
|
||||
};
|
||||
} // namespace track_sharing
|
||||
@@ -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<const uint8_t*>(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints)) ||
|
||||
signingSpansOverlap(window, capacity, kml_hash.data, 32) ||
|
||||
signingSpansOverlap(kml_hash.data, 32, reinterpret_cast<const uint8_t*>(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints)) ||
|
||||
signingSpansOverlap(window, capacity, reinterpret_cast<const uint8_t*>(this), sizeof(*this)) ||
|
||||
signingSpansOverlap(reinterpret_cast<const uint8_t*>(fingerprints), kMaxTrackAttachments * sizeof(*fingerprints), reinterpret_cast<const uint8_t*>(this), sizeof(*this)))
|
||||
phase_ = Phase::Failed;
|
||||
total_ = static_cast<uint64_t>(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<const uint8_t*>(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<uint8_t>(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<uint32_t>(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<uint8_t>(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
|
||||
@@ -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 Io, class Hash, class Resolver, size_t PathCapacity = 64>
|
||||
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<const uint8_t*>(this);
|
||||
if (&index_hash == &resource_hash ||
|
||||
signingSpansOverlap(window, capacity, self, sizeof(*this)) ||
|
||||
signingSpansOverlap(reinterpret_cast<const uint8_t*>(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<Io, Hash> index_;
|
||||
FileIntegrityCheck<Io, Hash> 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
|
||||
@@ -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<const uint8_t*>(&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
|
||||
@@ -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 <class Io>
|
||||
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<const uint8_t*>(a), as, static_cast<const uint8_t*>(b), bs); };
|
||||
for (const auto source : {id, geocaching::ByteView{author, 32}, geocaching::ByteView{reinterpret_cast<const uint8_t*>(&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<char*>(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<Io> 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
|
||||
@@ -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<class Jobs>
|
||||
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<kPublicationJournalBytes)
|
||||
return PublicationStoreResult::Invalid;
|
||||
for(const auto record:{geocaching::ByteView{reinterpret_cast<const uint8_t*>(¤t),sizeof(current)},
|
||||
{reinterpret_cast<const uint8_t*>(&next),sizeof(next)},{reinterpret_cast<const uint8_t*>(&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;
|
||||
}
|
||||
}
|
||||
@@ -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<const uint8_t*>(&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
|
||||
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user