feat(firmware): add bounded USB configuration and device author operations

This commit is contained in:
liu weikai committed 2026-10-14 14:58:02 +08:00
1 parent 30d6e7be19
commit ec513cbacf
611 files changed
+57180 -1562

No files matched your search

+158
View File
@@ -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
+13 -1
View File
@@ -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"
+4
View File
@@ -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(); }
}
@@ -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, &current, &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(&current==&next || &current==&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*>(&current),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