From a833cfcb715b018c497419628d9d403ae8fcffaf Mon Sep 17 00:00:00 2001 From: gotnull Date: Tue, 5 May 2026 15:54:31 +1000 Subject: [PATCH 1/3] firmware: implement local client app data store Add a small bounded local-node-only metadata store for companion apps, exposed through the new AdminMessage tags 104..107. Each record carries an opaque payload up to 512 bytes keyed by app_id, persisted to /prefs/clientappdata.proto via the existing NodeDB::saveProto helper, and cleared automatically by factoryReset() through the existing rmDir("/prefs") path. Includes: - protobufs submodule bump to the matching feature commit - regenerated nanopb output for admin and localonly - clientAppDataFileName constant in NodeDB.h - new ClientAppDataStore class (modules/ClientAppDataStore.{h,cpp}) with strict app_id validation (^[a-z0-9._-]{1,32}$), bounded slot table (max 4 records, no heap), in-place overwrite, slot compaction on remove, server-set updated_at via getValidTime(RTCQualityDevice) - ClientAppDataStore::init() wired into main.cpp after nodeDB init, alongside the other /prefs-reading initializers The store is namespaced, not owned: firmware enforces shape and capacity but does not authenticate which client is writing. Callers must treat payloads as untrusted, optional, and recoverable. AdminModule dispatch wiring and tests follow in subsequent commits. --- protobufs | 2 +- src/main.cpp | 5 + src/mesh/NodeDB.h | 1 + src/mesh/generated/meshtastic/admin.pb.cpp | 3 + src/mesh/generated/meshtastic/admin.pb.h | 102 ++++++++++- .../generated/meshtastic/localonly.pb.cpp | 3 + src/mesh/generated/meshtastic/localonly.pb.h | 33 +++- src/modules/ClientAppDataStore.cpp | 162 ++++++++++++++++++ src/modules/ClientAppDataStore.h | 93 ++++++++++ 9 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 src/modules/ClientAppDataStore.cpp create mode 100644 src/modules/ClientAppDataStore.h diff --git a/protobufs b/protobufs index 1d6f1a71ff3..3183ec21790 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f +Subproject commit 3183ec21790b0d209f8e4b384c38686d78095321 diff --git a/src/main.cpp b/src/main.cpp index 6f78c0b960b..14e6c56124d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -34,6 +34,7 @@ #include "main.h" #include "mesh/generated/meshtastic/config.pb.h" #include "meshUtils.h" +#include "modules/ClientAppDataStore.h" #include "modules/Modules.h" #include "sleep.h" #include "target_specific.h" @@ -707,6 +708,10 @@ void setup() // Initialize transmit history to persist broadcast throttle timers across reboots TransmitHistory::getInstance()->loadFromDisk(); + + // Bounded local client-app metadata store (see modules/ClientAppDataStore.h). + // Loads /prefs/clientappdata.proto if present; missing/corrupt -> empty store. + ClientAppDataStore::init(); #if HAS_TFT if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { tftSetup(); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index f6be963c184..88334e16a0d 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -102,6 +102,7 @@ static constexpr const char *configFileName = "/prefs/config.proto"; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; static constexpr const char *channelFileName = "/prefs/channels.proto"; +static constexpr const char *clientAppDataFileName = "/prefs/clientappdata.proto"; static constexpr const char *backupFileName = "/backups/backup.proto"; /// Given a node, return how many seconds in the past (vs now) that we last heard from it diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index 3dcc241d9b8..d406354d014 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -42,6 +42,9 @@ PB_BIND(meshtastic_SCD30_config, meshtastic_SCD30_config, AUTO) PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO) +PB_BIND(meshtastic_ClientAppData, meshtastic_ClientAppData, 2) + + diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 58e0356ca39..726561e57ac 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -250,6 +250,55 @@ typedef struct _meshtastic_SensorConfig { meshtastic_SHTXX_config shtxx_config; } meshtastic_SensorConfig; +typedef PB_BYTES_ARRAY_T(512) meshtastic_ClientAppData_payload_t; +/* Optional, bounded, local-node-only storage for opaque, app-owned metadata + that a companion application can ask its locally-connected node to persist + on its behalf. Not a filesystem, not a database, and not arbitrary NVRAM. + It is a tiny fixed-slot table for a few small records. + + Intended to give clients an explicit, firmware-bounded place to store + non-secret convenience metadata, without overloading user-visible + fields like long_name or unrelated configuration surfaces. + + IMPORTANT: namespaced, not owned. The firmware enforces shape, payload + size, and record-count limits, but does NOT authenticate which companion + application is making a write request. app_id prevents accidental name + collisions between apps, NOT malicious or intentional overwrites: any + admin-capable client may overwrite or delete any app_id. Callers must + therefore treat stored payloads as untrusted, optional, and recoverable. + + Do NOT store secrets, identity keys, session keys, paid-entitlement + state, trust authority, blocklists, or any data used to make security, + routing, authentication, or purchase decisions. Treat this as a + convenience cache for non-critical app state only. + + Firmware never interprets `payload`, never broadcasts these records over + LoRa, never includes them in NodeInfo, and never relays them via MQTT. + Records are local-node-only, survive reboot, and are cleared by factory + reset. + + Client guidance: gracefully fall back to app-local storage when the + firmware does not support this feature; version your payloads via the + `version` field below; keep payloads small (well under the 512-byte cap) + to leave headroom for other apps. */ +typedef struct _meshtastic_ClientAppData { + /* Namespacing key. Must match `^[a-z0-9._-]{1,32}$`. + Convention examples: "meshtastic-ios", + "meshtastic-android", "thirdparty.example". */ + char app_id[33]; + /* Application-defined schema version for `payload`. The firmware does not + interpret this; clients use it to migrate or reject stale payloads. */ + uint32_t version; + /* Opaque app-owned bytes, max 512. Firmware never inspects or interprets + the contents. */ + meshtastic_ClientAppData_payload_t payload; + /* Unix epoch seconds, set by the firmware on every successful write. + May be 0 if the firmware does not yet have a valid wall-clock time. + Useful for clients to detect that another admin-capable client has + overwritten or deleted-then-recreated the record since the last read. */ + uint32_t updated_at; +} meshtastic_ClientAppData; + typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t; /* This message is handled by the Admin module and is responsible for all settings/channel read/write operations. This message is used to do settings operations to both remote AND local nodes. @@ -384,6 +433,27 @@ typedef struct _meshtastic_AdminMessage { meshtastic_AdminMessage_OTAEvent ota_request; /* Parameters and sensor configuration */ meshtastic_SensorConfig sensor_config; + /* Write a bounded local client-app metadata record. Local-only by + default: the firmware refuses set_client_app_data from non-local + senders. See top-level ClientAppData for the namespaced-not-owned + caveat: any admin-capable client may overwrite any app_id, so + callers must treat stored payloads as untrusted and recoverable. + Validation failures (bad app_id, oversize payload, no slot free) + are reported via meshtastic_Routing_Error_BAD_REQUEST. + TODO(maintainer): confirm field-number allocation for 104..107. */ + meshtastic_ClientAppData set_client_app_data; + /* Read a stored client-app metadata record by app_id. The firmware + replies with get_client_app_data_response: a populated record on + hit, or a record with empty app_id to signal NOT_FOUND. */ + char get_client_app_data_request[33]; + /* Stored client-app metadata in response to get_client_app_data_request. + If app_id is empty, no record exists for the requested key. */ + meshtastic_ClientAppData get_client_app_data_response; + /* Delete a stored client-app metadata record by app_id. Local-only by + default. Returns Routing_Error_NONE on success (record removed) or + Routing_Error_BAD_REQUEST on invalid app_id. Deleting a missing + app_id is treated as success (idempotent). */ + char delete_client_app_data_request[33]; }; /* The node generates this key and sends it with any get_x_response packets. The client MUST include the same key with any set_x commands. Key expires after 300 seconds. @@ -437,6 +507,7 @@ extern "C" { + /* Initializer values for message structs */ #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} @@ -450,6 +521,7 @@ extern "C" { #define meshtastic_SEN5X_config_init_default {false, 0, false, 0} #define meshtastic_SCD30_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_default {false, 0} +#define meshtastic_ClientAppData_init_default {"", 0, {0, {0}}, 0} #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} @@ -462,6 +534,7 @@ extern "C" { #define meshtastic_SEN5X_config_init_zero {false, 0, false, 0} #define meshtastic_SCD30_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_zero {false, 0} +#define meshtastic_ClientAppData_init_zero {"", 0, {0, {0}}, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_AdminMessage_InputEvent_event_code_tag 1 @@ -503,6 +576,10 @@ extern "C" { #define meshtastic_SensorConfig_sen5x_config_tag 2 #define meshtastic_SensorConfig_scd30_config_tag 3 #define meshtastic_SensorConfig_shtxx_config_tag 4 +#define meshtastic_ClientAppData_app_id_tag 1 +#define meshtastic_ClientAppData_version_tag 2 +#define meshtastic_ClientAppData_payload_tag 3 +#define meshtastic_ClientAppData_updated_at_tag 4 #define meshtastic_AdminMessage_get_channel_request_tag 1 #define meshtastic_AdminMessage_get_channel_response_tag 2 #define meshtastic_AdminMessage_get_owner_request_tag 3 @@ -560,6 +637,10 @@ extern "C" { #define meshtastic_AdminMessage_nodedb_reset_tag 100 #define meshtastic_AdminMessage_ota_request_tag 102 #define meshtastic_AdminMessage_sensor_config_tag 103 +#define meshtastic_AdminMessage_set_client_app_data_tag 104 +#define meshtastic_AdminMessage_get_client_app_data_request_tag 105 +#define meshtastic_AdminMessage_get_client_app_data_response_tag 106 +#define meshtastic_AdminMessage_delete_client_app_data_request_tag 107 #define meshtastic_AdminMessage_session_passkey_tag 101 /* Struct field encoding specification for nanopb */ @@ -621,7 +702,11 @@ X(a, STATIC, ONEOF, INT32, (payload_variant,factory_reset_config,factory X(a, STATIC, ONEOF, BOOL, (payload_variant,nodedb_reset,nodedb_reset), 100) \ X(a, STATIC, SINGULAR, BYTES, session_passkey, 101) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,ota_request,ota_request), 102) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config), 103) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,set_client_app_data,set_client_app_data), 104) \ +X(a, STATIC, ONEOF, STRING, (payload_variant,get_client_app_data_request,get_client_app_data_request), 105) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,get_client_app_data_response,get_client_app_data_response), 106) \ +X(a, STATIC, ONEOF, STRING, (payload_variant,delete_client_app_data_request,delete_client_app_data_request), 107) #define meshtastic_AdminMessage_CALLBACK NULL #define meshtastic_AdminMessage_DEFAULT NULL #define meshtastic_AdminMessage_payload_variant_get_channel_response_MSGTYPE meshtastic_Channel @@ -644,6 +729,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,sensor_config,sensor_config) #define meshtastic_AdminMessage_payload_variant_key_verification_MSGTYPE meshtastic_KeyVerificationAdmin #define meshtastic_AdminMessage_payload_variant_ota_request_MSGTYPE meshtastic_AdminMessage_OTAEvent #define meshtastic_AdminMessage_payload_variant_sensor_config_MSGTYPE meshtastic_SensorConfig +#define meshtastic_AdminMessage_payload_variant_set_client_app_data_MSGTYPE meshtastic_ClientAppData +#define meshtastic_AdminMessage_payload_variant_get_client_app_data_response_MSGTYPE meshtastic_ClientAppData #define meshtastic_AdminMessage_InputEvent_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, event_code, 1) \ @@ -734,6 +821,14 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1) #define meshtastic_SHTXX_config_CALLBACK NULL #define meshtastic_SHTXX_config_DEFAULT NULL +#define meshtastic_ClientAppData_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, app_id, 1) \ +X(a, STATIC, SINGULAR, UINT32, version, 2) \ +X(a, STATIC, SINGULAR, BYTES, payload, 3) \ +X(a, STATIC, SINGULAR, FIXED32, updated_at, 4) +#define meshtastic_ClientAppData_CALLBACK NULL +#define meshtastic_ClientAppData_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; @@ -746,6 +841,7 @@ extern const pb_msgdesc_t meshtastic_SCD4X_config_msg; extern const pb_msgdesc_t meshtastic_SEN5X_config_msg; extern const pb_msgdesc_t meshtastic_SCD30_config_msg; extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; +extern const pb_msgdesc_t meshtastic_ClientAppData_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg @@ -760,12 +856,14 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_SEN5X_config_fields &meshtastic_SEN5X_config_msg #define meshtastic_SCD30_config_fields &meshtastic_SCD30_config_msg #define meshtastic_SHTXX_config_fields &meshtastic_SHTXX_config_msg +#define meshtastic_ClientAppData_fields &meshtastic_ClientAppData_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size #define meshtastic_AdminMessage_InputEvent_size 14 #define meshtastic_AdminMessage_OTAEvent_size 36 -#define meshtastic_AdminMessage_size 511 +#define meshtastic_AdminMessage_size 575 +#define meshtastic_ClientAppData_size 560 #define meshtastic_HamParameters_size 31 #define meshtastic_KeyVerificationAdmin_size 25 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 diff --git a/src/mesh/generated/meshtastic/localonly.pb.cpp b/src/mesh/generated/meshtastic/localonly.pb.cpp index 34391df73e1..de0fa518fe7 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.cpp +++ b/src/mesh/generated/meshtastic/localonly.pb.cpp @@ -12,4 +12,7 @@ PB_BIND(meshtastic_LocalConfig, meshtastic_LocalConfig, 2) PB_BIND(meshtastic_LocalModuleConfig, meshtastic_LocalModuleConfig, 2) +PB_BIND(meshtastic_LocalClientAppData, meshtastic_LocalClientAppData, 2) + + diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 27f5ad7bfdf..88b3223256b 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -4,6 +4,7 @@ #ifndef PB_MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_INCLUDED #define PB_MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_INCLUDED #include +#include "meshtastic/admin.pb.h" #include "meshtastic/config.pb.h" #include "meshtastic/module_config.pb.h" @@ -98,6 +99,22 @@ typedef struct _meshtastic_LocalModuleConfig { meshtastic_ModuleConfig_TAKConfig tak; } meshtastic_LocalModuleConfig; +/* On-disk wrapper for the bounded local client-app metadata store. + Persisted by the firmware to /prefs/clientappdata.proto. Never sent over + the wire and never broadcast. See ClientAppData (admin.proto) for the + namespaced-not-owned caveat that governs how clients should treat the + stored payloads. */ +typedef struct _meshtastic_LocalClientAppData { + /* The set of stored records. Bounded by localonly.options to a small + number (initially 4). Firmware enforces uniqueness on app_id. */ + pb_size_t records_count; + meshtastic_ClientAppData records[4]; + /* A version integer used to invalidate old save files when we make + incompatible changes. Set at build time by NodeDB.cpp in the device + code, mirroring LocalConfig.version / LocalModuleConfig.version. */ + uint32_t version; +} meshtastic_LocalClientAppData; + #ifdef __cplusplus extern "C" { @@ -106,8 +123,10 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_LocalConfig_init_default {false, meshtastic_Config_DeviceConfig_init_default, false, meshtastic_Config_PositionConfig_init_default, false, meshtastic_Config_PowerConfig_init_default, false, meshtastic_Config_NetworkConfig_init_default, false, meshtastic_Config_DisplayConfig_init_default, false, meshtastic_Config_LoRaConfig_init_default, false, meshtastic_Config_BluetoothConfig_init_default, 0, false, meshtastic_Config_SecurityConfig_init_default} #define meshtastic_LocalModuleConfig_init_default {false, meshtastic_ModuleConfig_MQTTConfig_init_default, false, meshtastic_ModuleConfig_SerialConfig_init_default, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_default, false, meshtastic_ModuleConfig_StoreForwardConfig_init_default, false, meshtastic_ModuleConfig_RangeTestConfig_init_default, false, meshtastic_ModuleConfig_TelemetryConfig_init_default, false, meshtastic_ModuleConfig_CannedMessageConfig_init_default, 0, false, meshtastic_ModuleConfig_AudioConfig_init_default, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_default, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_default, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_default, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_default, false, meshtastic_ModuleConfig_PaxcounterConfig_init_default, false, meshtastic_ModuleConfig_StatusMessageConfig_init_default, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_default, false, meshtastic_ModuleConfig_TAKConfig_init_default} +#define meshtastic_LocalClientAppData_init_default {0, {meshtastic_ClientAppData_init_default, meshtastic_ClientAppData_init_default, meshtastic_ClientAppData_init_default, meshtastic_ClientAppData_init_default}, 0} #define meshtastic_LocalConfig_init_zero {false, meshtastic_Config_DeviceConfig_init_zero, false, meshtastic_Config_PositionConfig_init_zero, false, meshtastic_Config_PowerConfig_init_zero, false, meshtastic_Config_NetworkConfig_init_zero, false, meshtastic_Config_DisplayConfig_init_zero, false, meshtastic_Config_LoRaConfig_init_zero, false, meshtastic_Config_BluetoothConfig_init_zero, 0, false, meshtastic_Config_SecurityConfig_init_zero} #define meshtastic_LocalModuleConfig_init_zero {false, meshtastic_ModuleConfig_MQTTConfig_init_zero, false, meshtastic_ModuleConfig_SerialConfig_init_zero, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero, false, meshtastic_ModuleConfig_StoreForwardConfig_init_zero, false, meshtastic_ModuleConfig_RangeTestConfig_init_zero, false, meshtastic_ModuleConfig_TelemetryConfig_init_zero, false, meshtastic_ModuleConfig_CannedMessageConfig_init_zero, 0, false, meshtastic_ModuleConfig_AudioConfig_init_zero, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_zero, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_zero, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_zero, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_zero, false, meshtastic_ModuleConfig_PaxcounterConfig_init_zero, false, meshtastic_ModuleConfig_StatusMessageConfig_init_zero, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_zero, false, meshtastic_ModuleConfig_TAKConfig_init_zero} +#define meshtastic_LocalClientAppData_init_zero {0, {meshtastic_ClientAppData_init_zero, meshtastic_ClientAppData_init_zero, meshtastic_ClientAppData_init_zero, meshtastic_ClientAppData_init_zero}, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_LocalConfig_device_tag 1 @@ -136,6 +155,8 @@ extern "C" { #define meshtastic_LocalModuleConfig_statusmessage_tag 15 #define meshtastic_LocalModuleConfig_traffic_management_tag 16 #define meshtastic_LocalModuleConfig_tak_tag 17 +#define meshtastic_LocalClientAppData_records_tag 1 +#define meshtastic_LocalClientAppData_version_tag 2 /* Struct field encoding specification for nanopb */ #define meshtastic_LocalConfig_FIELDLIST(X, a) \ @@ -196,15 +217,25 @@ X(a, STATIC, OPTIONAL, MESSAGE, tak, 17) #define meshtastic_LocalModuleConfig_traffic_management_MSGTYPE meshtastic_ModuleConfig_TrafficManagementConfig #define meshtastic_LocalModuleConfig_tak_MSGTYPE meshtastic_ModuleConfig_TAKConfig +#define meshtastic_LocalClientAppData_FIELDLIST(X, a) \ +X(a, STATIC, REPEATED, MESSAGE, records, 1) \ +X(a, STATIC, SINGULAR, UINT32, version, 2) +#define meshtastic_LocalClientAppData_CALLBACK NULL +#define meshtastic_LocalClientAppData_DEFAULT NULL +#define meshtastic_LocalClientAppData_records_MSGTYPE meshtastic_ClientAppData + extern const pb_msgdesc_t meshtastic_LocalConfig_msg; extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; +extern const pb_msgdesc_t meshtastic_LocalClientAppData_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_LocalConfig_fields &meshtastic_LocalConfig_msg #define meshtastic_LocalModuleConfig_fields &meshtastic_LocalModuleConfig_msg +#define meshtastic_LocalClientAppData_fields &meshtastic_LocalClientAppData_msg /* Maximum encoded size of messages (where known) */ -#define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size +#define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalClientAppData_size +#define meshtastic_LocalClientAppData_size 2258 #define meshtastic_LocalConfig_size 757 #define meshtastic_LocalModuleConfig_size 820 diff --git a/src/modules/ClientAppDataStore.cpp b/src/modules/ClientAppDataStore.cpp new file mode 100644 index 00000000000..7566353797c --- /dev/null +++ b/src/modules/ClientAppDataStore.cpp @@ -0,0 +1,162 @@ +#include "ClientAppDataStore.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" +#include + +ClientAppDataStore *clientAppDataStore = nullptr; + +void ClientAppDataStore::init() +{ + if (clientAppDataStore == nullptr) { + clientAppDataStore = new ClientAppDataStore(); + } + + if (nodeDB == nullptr) { + // NodeDB owns the filesystem helpers; defer until it exists. + return; + } + + LoadFileResult state = + nodeDB->loadProto(clientAppDataFileName, meshtastic_LocalClientAppData_size, + sizeof(meshtastic_LocalClientAppData), &meshtastic_LocalClientAppData_msg, + &clientAppDataStore->store_); + + if (state != LoadFileResult::LOAD_SUCCESS) { + // Missing file is the expected first-boot case; only flag genuine + // decode failures to avoid log noise on a fresh device. + if (state == LoadFileResult::DECODE_FAILED) { + LOG_WARN("ClientAppData: stored file failed to decode, starting empty"); + } + memset(&clientAppDataStore->store_, 0, sizeof(meshtastic_LocalClientAppData)); + } + + if (clientAppDataStore->store_.records_count > kMaxRecords) { + // Should never happen: nanopb caps records[4] at compile time. + // Defensive trim in case a future schema bump changes the bound. + clientAppDataStore->store_.records_count = kMaxRecords; + } +} + +void ClientAppDataStore::clear() +{ + if (clientAppDataStore == nullptr) { + return; + } + memset(&clientAppDataStore->store_, 0, sizeof(meshtastic_LocalClientAppData)); +} + +bool ClientAppDataStore::isValidAppId(const char *appId) +{ + if (appId == nullptr || appId[0] == '\0') { + return false; + } + size_t i = 0; + for (; appId[i] != '\0'; i++) { + if (i >= kMaxAppIdLen) { + return false; + } + char c = appId[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + return i >= 1 && i <= kMaxAppIdLen; +} + +int ClientAppDataStore::findIndex_(const char *appId) const +{ + for (pb_size_t i = 0; i < store_.records_count; i++) { + if (strncmp(store_.records[i].app_id, appId, kMaxAppIdLen + 1) == 0) { + return static_cast(i); + } + } + return -1; +} + +size_t ClientAppDataStore::recordCount() const +{ + return store_.records_count; +} + +ClientAppDataStore::Result ClientAppDataStore::get(const char *appId, meshtastic_ClientAppData *out) const +{ + if (out == nullptr || !isValidAppId(appId)) { + return Result::InvalidAppId; + } + int idx = findIndex_(appId); + if (idx < 0) { + return Result::NotFound; + } + *out = store_.records[idx]; + return Result::Ok; +} + +ClientAppDataStore::Result ClientAppDataStore::set(const meshtastic_ClientAppData &record) +{ + if (!isValidAppId(record.app_id)) { + LOG_WARN("ClientAppData: rejected set for invalid app_id"); + return Result::InvalidAppId; + } + if (record.payload.size > kMaxPayloadBytes) { + LOG_WARN("ClientAppData: rejected set for app_id=%s, payload %u > %u", record.app_id, + (unsigned)record.payload.size, (unsigned)kMaxPayloadBytes); + return Result::PayloadTooLarge; + } + + int idx = findIndex_(record.app_id); + if (idx < 0) { + if (store_.records_count >= kMaxRecords) { + LOG_WARN("ClientAppData: rejected set for app_id=%s, store full (%u/%u)", record.app_id, + (unsigned)store_.records_count, (unsigned)kMaxRecords); + return Result::NoSpace; + } + idx = static_cast(store_.records_count); + store_.records_count++; + } + + meshtastic_ClientAppData *slot = &store_.records[idx]; + *slot = record; + slot->updated_at = getValidTime(RTCQualityDevice); + + if (!persistToDisk_()) { + LOG_ERROR("ClientAppData: persist failed for app_id=%s", record.app_id); + return Result::StorageError; + } + return Result::Ok; +} + +ClientAppDataStore::Result ClientAppDataStore::remove(const char *appId) +{ + if (!isValidAppId(appId)) { + LOG_WARN("ClientAppData: rejected remove for invalid app_id"); + return Result::InvalidAppId; + } + int idx = findIndex_(appId); + if (idx < 0) { + return Result::NotFound; + } + + // Compact: shift trailing records left by one to free the slot. + for (pb_size_t i = static_cast(idx); i + 1 < store_.records_count; i++) { + store_.records[i] = store_.records[i + 1]; + } + store_.records_count--; + memset(&store_.records[store_.records_count], 0, sizeof(meshtastic_ClientAppData)); + + if (!persistToDisk_()) { + LOG_ERROR("ClientAppData: persist failed after remove for app_id=%s", appId); + return Result::StorageError; + } + return Result::Ok; +} + +bool ClientAppDataStore::persistToDisk_() +{ + if (nodeDB == nullptr) { + return false; + } + return nodeDB->saveProto(clientAppDataFileName, meshtastic_LocalClientAppData_size, + &meshtastic_LocalClientAppData_msg, &store_); +} diff --git a/src/modules/ClientAppDataStore.h b/src/modules/ClientAppDataStore.h new file mode 100644 index 00000000000..53480df3e69 --- /dev/null +++ b/src/modules/ClientAppDataStore.h @@ -0,0 +1,93 @@ +#pragma once + +#include "meshtastic/admin.pb.h" +#include "meshtastic/localonly.pb.h" +#include + +/** + * Bounded local storage for opaque, app-owned metadata records that + * companion applications persist on the locally-connected node via + * AdminMessage. See meshtastic/admin.proto's ClientAppData message for + * the full namespaced-not-owned caveat: the firmware enforces shape, + * payload size, and record-count limits but does NOT authenticate which + * companion application is writing. Any admin-capable client may + * overwrite or delete any app_id. Records are local-node-only, never + * broadcast over LoRa, never included in NodeInfo, never relayed via + * MQTT, and never interpreted by the firmware. + * + * Persisted to /prefs/clientappdata.proto (NodeDB::clientAppDataFileName) + * and cleared automatically by factoryReset() via the existing + * rmDir("/prefs") path. + */ +class ClientAppDataStore +{ + public: + enum class Result { + Ok, + NotFound, + InvalidAppId, + PayloadTooLarge, + NoSpace, + StorageError + }; + + /** + * Construct the global store and load any existing records from + * /prefs/clientappdata.proto. Safe to call before NodeDB has been + * fully initialized; a missing or corrupt file yields an empty store. + */ + static void init(); + + /** + * Reset the in-memory table to empty. Does NOT delete the on-disk + * file; factoryReset() handles that via rmDir("/prefs"). + */ + static void clear(); + + /** + * Copy the record matching appId into *out. + * @return Ok on hit, NotFound if no record matches, InvalidAppId + * if appId is null/empty/malformed. + */ + Result get(const char *appId, meshtastic_ClientAppData *out) const; + + /** + * Insert or overwrite a record. Overwriting an existing app_id reuses + * the same slot and does not consume capacity. updated_at is set to + * the current valid wall-clock time, or 0 if the firmware has no + * valid time yet. The caller-supplied updated_at is ignored. + * @return Ok on success and persistence; InvalidAppId / PayloadTooLarge + * / NoSpace / StorageError on failure. + */ + Result set(const meshtastic_ClientAppData &record); + + /** + * Remove the record matching appId. Compacts the slot table so a + * later set() can reuse the freed capacity. Deleting a missing + * appId returns NotFound (callers may treat as idempotent success). + */ + Result remove(const char *appId); + + /** + * Validate an app_id against ^[a-z0-9._-]{1,32}$. Public for tests. + * Returns false for null, empty, or any character outside the set. + */ + static bool isValidAppId(const char *appId); + + /** + * Number of records currently stored (0..kMaxRecords). + */ + size_t recordCount() const; + + static constexpr size_t kMaxRecords = 4; + static constexpr size_t kMaxPayloadBytes = 512; + static constexpr size_t kMaxAppIdLen = 32; + + private: + meshtastic_LocalClientAppData store_ = meshtastic_LocalClientAppData_init_zero; + + bool persistToDisk_(); + int findIndex_(const char *appId) const; +}; + +extern ClientAppDataStore *clientAppDataStore; From d05404994e9d3788dbfff66f32387cd1cda64eae Mon Sep 17 00:00:00 2001 From: gotnull Date: Tue, 5 May 2026 15:54:51 +1000 Subject: [PATCH 2/3] firmware: handle client app data admin requests Wire AdminMessage dispatch for the four new client_app_data tags (104..107) into AdminModule::handleReceivedProtobuf: - set_client_app_data: local-only (mp.from != 0 -> NOT_AUTHORIZED). On valid input: ClientAppDataStore::set; relies on the existing auto-ACK path to emit Routing_Error_NONE. Validation, payload-size, no-space, and storage failures map to Routing_Error_BAD_REQUEST. - get_client_app_data_request: validates app_id and replies with get_client_app_data_response. On miss the response carries an empty app_id sentinel (the firmware returns the _init_default record because the store leaves *out untouched on NotFound). Calls setPassKey on the response, mirroring handleGetOwner / handleGetDeviceUIConfig style. Honors mp.pki_encrypted. - delete_client_app_data_request: local-only. Treats delete-of- missing as success (idempotent), matching remove_by_nodenum / remove_favorite_node / delete_file_request precedent. Also extend messageIsRequest()/messageIsResponse() to enumerate the new request/response tags so the auth path does not mis-classify them as state-changing writes (which would force a session_passkey check). No new private methods on AdminModule; all logic is inline in the case bodies, matching the existing handler style. --- src/modules/AdminModule.cpp | 76 ++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7b249f656cd..b9484a93457 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,4 +1,5 @@ #include "AdminModule.h" +#include "modules/ClientAppDataStore.h" #include "Channels.h" #include "MeshService.h" #include "NodeDB.h" @@ -547,6 +548,75 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; #endif + /** + * Bounded local client-app metadata store. See modules/ClientAppDataStore.h + * and meshtastic/admin.proto's ClientAppData for the namespaced-not-owned + * caveat: writes/deletes are local-only by default; reads follow the same + * authorization as other get_* requests. + */ + case meshtastic_AdminMessage_set_client_app_data_tag: { + LOG_DEBUG("Client set client_app_data app_id=%s", r->set_client_app_data.app_id); + if (mp.from != 0) { + LOG_WARN("Reject remote set_client_app_data"); + myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); + break; + } + if (clientAppDataStore == nullptr) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } + ClientAppDataStore::Result result = clientAppDataStore->set(r->set_client_app_data); + if (result != ClientAppDataStore::Result::Ok) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + } + break; + } + case meshtastic_AdminMessage_get_client_app_data_request_tag: { + LOG_DEBUG("Client get client_app_data app_id=%s", r->get_client_app_data_request); + if (clientAppDataStore == nullptr) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } + if (!ClientAppDataStore::isValidAppId(r->get_client_app_data_request)) { + LOG_WARN("Reject get_client_app_data: invalid app_id"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } + if (mp.decoded.want_response) { + meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; + res.which_payload_variant = meshtastic_AdminMessage_get_client_app_data_response_tag; + // get() leaves *out untouched on NotFound, so the _init_default + // empty-app_id sentinel passes through automatically. + clientAppDataStore->get(r->get_client_app_data_request, &res.get_client_app_data_response); + setPassKey(&res); + myReply = allocDataProtobuf(res); + if (mp.pki_encrypted) { + myReply->pki_encrypted = true; + } + } + break; + } + case meshtastic_AdminMessage_delete_client_app_data_request_tag: { + LOG_DEBUG("Client delete client_app_data app_id=%s", r->delete_client_app_data_request); + if (mp.from != 0) { + LOG_WARN("Reject remote delete_client_app_data"); + myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); + break; + } + if (clientAppDataStore == nullptr) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } + ClientAppDataStore::Result result = clientAppDataStore->remove(r->delete_client_app_data_request); + // NotFound is treated as success: matches existing delete semantics + // (remove_by_nodenum, remove_favorite_node, delete_file_request all + // silently succeed on missing targets, falling through to auto-ACK). + if (result != ClientAppDataStore::Result::Ok && result != ClientAppDataStore::Result::NotFound) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + } + break; + } + default: meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; AdminMessageHandleResult handleResult = MeshModule::handleAdminMessageForAllModules(mp, r, &res); @@ -1501,7 +1571,8 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r) r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_response_tag || r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_response_tag || r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag || - r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_response_tag) + r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_response_tag || + r->which_payload_variant == meshtastic_AdminMessage_get_client_app_data_response_tag) return true; else return false; @@ -1518,7 +1589,8 @@ bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_request_tag || r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_request_tag || r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag || - r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag) + r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag || + r->which_payload_variant == meshtastic_AdminMessage_get_client_app_data_request_tag) return true; else return false; From 597903299615101bf724870df2bd6a9048923d32 Mon Sep 17 00:00:00 2001 From: gotnull Date: Tue, 5 May 2026 15:55:28 +1000 Subject: [PATCH 3/3] firmware: add client app data tests Add test/test_client_app_data/test_main.cpp covering ClientAppDataStore end-to-end against a MockNodeDB that intercepts loadProto/saveProto for the LocalClientAppData descriptor. 27 tests across: - isValidAppId() accept/reject matrix (conventional names, null, empty, uppercase, whitespace, oversize, forbidden chars) - set/get round trip + payload-size bounds (at-max, over-max) - slot accounting (overwrite reuses, delete frees, max-records enforced) - delete-then-get NotFound, delete-of-missing semantic - server-set updated_at (caller-supplied value is ignored, real epoch when test RTC is seeded by initializeTestEnvironment) - persistence-survives-reinit (mock retains saved snapshot) - storage error injection on set and remove - factory-reset semantic (modeled by mock returning OTHER_FAILURE, matching the post-rmDir("/prefs") world) - first-boot empty store Also mark NodeDB::loadProto and NodeDB::saveProto virtual so tests can substitute MockNodeDB. Mirrors the existing MockNodeDB::getMeshNode override pattern in test/test_traffic_management. No production behavior change; one indirect call per save/load is negligible vs flash I/O. Dispatch-path tests (AdminModule cases for tags 104..107) are deliberately omitted as harness-limited: exercising them in isolation would require standing up channels/service/MeshPacket fakes that the native test harness does not provide, and would re-test glue logic the store-level cases already cover. Documented inline at the top of test_main.cpp. Both bin/test-native-docker.sh -f test_client_app_data and bin/test-native-docker.sh -f test_default pass clean. Zero compile errors, zero linker errors, zero warnings on any feature file. --- src/mesh/NodeDB.h | 8 +- test/test_client_app_data/test_main.cpp | 548 ++++++++++++++++++++++++ 2 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 test/test_client_app_data/test_main.cpp diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 88334e16a0d..2196fe10cea 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -260,10 +260,10 @@ class NodeDB bool factoryReset(bool eraseBleBonds = false); - LoadFileResult loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields, - void *dest_struct); - bool saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct, - bool fullAtomic = true); + virtual LoadFileResult loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields, + void *dest_struct); + virtual bool saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct, + bool fullAtomic = true); void installRoleDefaults(meshtastic_Config_DeviceConfig_Role role); diff --git a/test/test_client_app_data/test_main.cpp b/test/test_client_app_data/test_main.cpp new file mode 100644 index 00000000000..152bff354f9 --- /dev/null +++ b/test/test_client_app_data/test_main.cpp @@ -0,0 +1,548 @@ +/** + * Tests for ClientAppDataStore, the bounded local app-metadata store + * exposed through AdminMessage tags 104..107. Targets: + * + * 1. isValidAppId() accept/reject matrix + * 2. set/get/remove behaviour and Result mapping + * 3. Slot accounting (overwrite reuses, delete frees, full rejects) + * 4. Payload size limits + * 5. Server-side updated_at semantics + * 6. Persistence round-trip via mocked NodeDB::saveProto/loadProto + * 7. Storage-error propagation + * 8. Init behaviour for first-boot, decode-fail, and reload paths + * + * Dispatch-layer tests (AdminModule cases for tags 104..107) are + * deliberately omitted: the case bodies are thin glue around the store + * (validation, Result -> Routing_Error mapping, MeshPacket reply + * construction). Exercising them in isolation requires standing up + * channels/service/PhoneAPI machinery that this harness does not provide, + * and would re-test logic the store-level cases below already cover. + * Coverage gap noted in the Phase 5 report. + */ + +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "mesh/NodeDB.h" +#include "modules/ClientAppDataStore.h" + +#include + +// --------------------------------------------------------------------------- +// MockNodeDB: overrides the now-virtual loadProto/saveProto so the store's +// persistence path can be exercised without touching the real filesystem. +// Only handles the LocalClientAppData proto descriptor; falls through to the +// base behaviour for anything else (no other suite shares this fixture). +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + meshtastic_LocalClientAppData saved = meshtastic_LocalClientAppData_init_zero; + bool hasSaved = false; + bool saveOk = true; + bool fileExists = true; + int saveCalls = 0; + int loadCalls = 0; + + bool saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct, + bool fullAtomic = true) override + { + (void)filename; + (void)protoSize; + (void)fullAtomic; + if (fields == &meshtastic_LocalClientAppData_msg) { + saveCalls++; + if (!saveOk) { + return false; + } + saved = *static_cast(dest_struct); + hasSaved = true; + return true; + } + return true; + } + + LoadFileResult loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields, + void *dest_struct) override + { + (void)filename; + (void)protoSize; + (void)objSize; + if (fields == &meshtastic_LocalClientAppData_msg) { + loadCalls++; + if (!fileExists || !hasSaved) { + return LoadFileResult::OTHER_FAILURE; + } + *static_cast(dest_struct) = saved; + return LoadFileResult::LOAD_SUCCESS; + } + return LoadFileResult::OTHER_FAILURE; + } +}; + +static MockNodeDB *mockNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static meshtastic_ClientAppData makeRecord(const char *appId, uint32_t version, uint8_t fillByte, size_t payloadLen) +{ + meshtastic_ClientAppData rec = meshtastic_ClientAppData_init_zero; + strncpy(rec.app_id, appId, sizeof(rec.app_id) - 1); + rec.app_id[sizeof(rec.app_id) - 1] = '\0'; + rec.version = version; + rec.payload.size = static_cast(payloadLen); + for (size_t i = 0; i < payloadLen && i < sizeof(rec.payload.bytes); i++) { + rec.payload.bytes[i] = fillByte; + } + rec.updated_at = 0; + return rec; +} + +static void freshStore() +{ + if (clientAppDataStore != nullptr) { + delete clientAppDataStore; + clientAppDataStore = nullptr; + } + mockNodeDB->saveOk = true; + mockNodeDB->fileExists = true; + mockNodeDB->hasSaved = false; + mockNodeDB->saved = meshtastic_LocalClientAppData_init_zero; + mockNodeDB->saveCalls = 0; + mockNodeDB->loadCalls = 0; + ClientAppDataStore::init(); +} + +// --------------------------------------------------------------------------- +// isValidAppId() matrix +// --------------------------------------------------------------------------- + +static void test_isValidAppId_acceptsConventionalNames() +{ + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("exampleapp")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("meshtastic-ios")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("meshtastic-android")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("thirdparty.example")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("a")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("0")); + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("a.b_c-d.0")); + // Exactly 32 chars (the maximum), covering a..z + 0..9 + period and underscore. + TEST_ASSERT_TRUE(ClientAppDataStore::isValidAppId("abcdefghijklmnopqrstuvwxyz012345")); +} + +static void test_isValidAppId_rejectsNull() +{ + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId(nullptr)); +} + +static void test_isValidAppId_rejectsEmpty() +{ + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("")); +} + +static void test_isValidAppId_rejectsUppercase() +{ + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("Exampleapp")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("MeshTastic-iOS")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("APP")); +} + +static void test_isValidAppId_rejectsWhitespace() +{ + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("with space")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId(" app")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app ")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("a\tb")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("a\nb")); +} + +static void test_isValidAppId_rejectsOverlyLong() +{ + // 33 chars: one past the maximum + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("abcdefghijklmnopqrstuvwxyz0123456")); + // 64 chars + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); +} + +static void test_isValidAppId_rejectsForbiddenChars() +{ + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app!")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app/x")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app:x")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app\\x")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("app@x")); + TEST_ASSERT_FALSE(ClientAppDataStore::isValidAppId("\xff")); +} + +// --------------------------------------------------------------------------- +// set/get round-trip +// --------------------------------------------------------------------------- + +static void test_setThenGet_returnsExactBytes() +{ + freshStore(); + meshtastic_ClientAppData in = makeRecord("exampleapp", 7, 0xAB, 64); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(in))); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("exampleapp", &out))); + + TEST_ASSERT_EQUAL_STRING("exampleapp", out.app_id); + TEST_ASSERT_EQUAL_UINT32(7, out.version); + TEST_ASSERT_EQUAL_UINT(64, out.payload.size); + for (size_t i = 0; i < 64; i++) { + TEST_ASSERT_EQUAL_HEX8(0xAB, out.payload.bytes[i]); + } +} + +static void test_set_payloadAtMaxAccepted() +{ + freshStore(); + meshtastic_ClientAppData in = makeRecord("exampleapp", 1, 0x5A, ClientAppDataStore::kMaxPayloadBytes); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(in))); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("exampleapp", &out))); + TEST_ASSERT_EQUAL_UINT(ClientAppDataStore::kMaxPayloadBytes, out.payload.size); +} + +static void test_set_payloadOverMaxRejected() +{ + freshStore(); + meshtastic_ClientAppData in = makeRecord("exampleapp", 1, 0x5A, 16); + // Forge an oversize size field. The payload buffer can only hold 512 bytes + // (per nanopb), but the store must consult the declared `.size` field, not + // the underlying buffer capacity, when deciding to reject. + in.payload.size = ClientAppDataStore::kMaxPayloadBytes + 1; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::PayloadTooLarge), + static_cast(clientAppDataStore->set(in))); +} + +static void test_set_invalidAppIdRejected() +{ + freshStore(); + meshtastic_ClientAppData in = makeRecord("Bad ID", 1, 0, 0); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::InvalidAppId), + static_cast(clientAppDataStore->set(in))); +} + +static void test_get_invalidAppIdRejected() +{ + freshStore(); + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::InvalidAppId), + static_cast(clientAppDataStore->get("BAD", &out))); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::InvalidAppId), + static_cast(clientAppDataStore->get(nullptr, &out))); +} + +static void test_get_missingReturnsNotFound() +{ + freshStore(); + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NotFound), + static_cast(clientAppDataStore->get("exampleapp", &out))); +} + +// --------------------------------------------------------------------------- +// Slot accounting +// --------------------------------------------------------------------------- + +static void test_overwrite_doesNotConsumeSlot() +{ + freshStore(); + meshtastic_ClientAppData v1 = makeRecord("exampleapp", 1, 0x11, 8); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(v1))); + TEST_ASSERT_EQUAL(1, clientAppDataStore->recordCount()); + + meshtastic_ClientAppData v2 = makeRecord("exampleapp", 2, 0x22, 16); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(v2))); + TEST_ASSERT_EQUAL_MESSAGE(1, clientAppDataStore->recordCount(), + "Overwrite of same app_id must not consume an extra slot"); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("exampleapp", &out))); + TEST_ASSERT_EQUAL_UINT32(2, out.version); + TEST_ASSERT_EQUAL_UINT(16, out.payload.size); + TEST_ASSERT_EQUAL_HEX8(0x22, out.payload.bytes[0]); +} + +static void test_maxRecordCount_enforced() +{ + freshStore(); + const char *ids[ClientAppDataStore::kMaxRecords] = {"app.a", "app.b", "app.c", "app.d"}; + for (size_t i = 0; i < ClientAppDataStore::kMaxRecords; i++) { + meshtastic_ClientAppData rec = makeRecord(ids[i], static_cast(i), 0, 4); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(rec))); + } + TEST_ASSERT_EQUAL(ClientAppDataStore::kMaxRecords, clientAppDataStore->recordCount()); + + meshtastic_ClientAppData overflow = makeRecord("app.e", 99, 0, 4); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NoSpace), + static_cast(clientAppDataStore->set(overflow))); + TEST_ASSERT_EQUAL(ClientAppDataStore::kMaxRecords, clientAppDataStore->recordCount()); +} + +static void test_deleteFreesSlot() +{ + freshStore(); + const char *ids[ClientAppDataStore::kMaxRecords] = {"app.a", "app.b", "app.c", "app.d"}; + for (size_t i = 0; i < ClientAppDataStore::kMaxRecords; i++) { + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord(ids[i], 0, 0, 4)))); + } + TEST_ASSERT_EQUAL(ClientAppDataStore::kMaxRecords, clientAppDataStore->recordCount()); + + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->remove("app.b"))); + TEST_ASSERT_EQUAL(ClientAppDataStore::kMaxRecords - 1, clientAppDataStore->recordCount()); + + // Slot freed, so a brand-new app_id should now fit. + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("app.e", 0, 0, 4)))); + TEST_ASSERT_EQUAL(ClientAppDataStore::kMaxRecords, clientAppDataStore->recordCount()); + + // app.b really gone, app.e present. + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NotFound), + static_cast(clientAppDataStore->get("app.b", &out))); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("app.e", &out))); +} + +static void test_deleteThenGet_returnsNotFound() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("exampleapp", 1, 0xCC, 4)))); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->remove("exampleapp"))); + TEST_ASSERT_EQUAL(0, clientAppDataStore->recordCount()); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NotFound), + static_cast(clientAppDataStore->get("exampleapp", &out))); +} + +static void test_deleteMissingReturnsNotFound() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NotFound), + static_cast(clientAppDataStore->remove("never-set"))); +} + +static void test_remove_invalidAppIdRejected() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::InvalidAppId), + static_cast(clientAppDataStore->remove("BAD"))); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::InvalidAppId), + static_cast(clientAppDataStore->remove(nullptr))); +} + +// --------------------------------------------------------------------------- +// updated_at: server-set, ignores caller-supplied value +// --------------------------------------------------------------------------- + +static void test_updatedAt_isFirmwareSetNotCallerSupplied() +{ + freshStore(); + meshtastic_ClientAppData in = makeRecord("exampleapp", 1, 0, 8); + in.updated_at = 0xDEADBEEF; // poisoned, must be overwritten by firmware + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(in))); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("exampleapp", &out))); + TEST_ASSERT_NOT_EQUAL_MESSAGE(0xDEADBEEFu, out.updated_at, + "Firmware must overwrite caller-supplied updated_at"); + // initializeTestEnvironment() seeds the RTC via perhapsSetRTC(RTCQualityNTP, ...), + // so on the Portduino test env updated_at must be a real epoch (>= year 2000). + // 946684800 = 2000-01-01T00:00:00Z. + TEST_ASSERT_TRUE_MESSAGE(out.updated_at >= 946684800u, + "updated_at should be a real epoch when test RTC is seeded"); +} + +// --------------------------------------------------------------------------- +// Persistence: calls into the (mocked) NodeDB layer +// --------------------------------------------------------------------------- + +static void test_set_callsSaveProto() +{ + freshStore(); + int before = mockNodeDB->saveCalls; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("a.b", 1, 0, 4)))); + TEST_ASSERT_EQUAL(before + 1, mockNodeDB->saveCalls); +} + +static void test_remove_callsSaveProto() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("a.b", 1, 0, 4)))); + int before = mockNodeDB->saveCalls; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->remove("a.b"))); + TEST_ASSERT_EQUAL(before + 1, mockNodeDB->saveCalls); +} + +static void test_storageError_setReturnsStorageError() +{ + freshStore(); + mockNodeDB->saveOk = false; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::StorageError), + static_cast(clientAppDataStore->set(makeRecord("exampleapp", 1, 0, 8)))); +} + +static void test_storageError_removeReturnsStorageError() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("exampleapp", 1, 0, 8)))); + mockNodeDB->saveOk = false; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::StorageError), + static_cast(clientAppDataStore->remove("exampleapp"))); +} + +static void test_persistenceSurvivesReinit() +{ + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("exampleapp", 42, 0xEE, 32)))); + + // Simulate a reboot: tear down the in-memory store, leave the mock's + // saved snapshot intact, re-init. The store must reload the record. + delete clientAppDataStore; + clientAppDataStore = nullptr; + mockNodeDB->saveCalls = 0; + mockNodeDB->loadCalls = 0; + ClientAppDataStore::init(); + + TEST_ASSERT_EQUAL_MESSAGE(1, mockNodeDB->loadCalls, + "init() must call loadProto exactly once"); + + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->get("exampleapp", &out))); + TEST_ASSERT_EQUAL_UINT32(42, out.version); + TEST_ASSERT_EQUAL_UINT(32, out.payload.size); + TEST_ASSERT_EQUAL_HEX8(0xEE, out.payload.bytes[0]); +} + +static void test_factoryResetSemantic_emptyAfterFileVanishes() +{ + // True factoryReset() does rmDir("/prefs") which deletes + // /prefs/clientappdata.proto. We model that by setting fileExists=false + // (loadProto then returns OTHER_FAILURE, the same outcome as a missing + // file). After re-init the store must be empty. + freshStore(); + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::Ok), + static_cast(clientAppDataStore->set(makeRecord("exampleapp", 1, 0, 8)))); + TEST_ASSERT_EQUAL(1, clientAppDataStore->recordCount()); + + delete clientAppDataStore; + clientAppDataStore = nullptr; + mockNodeDB->fileExists = false; // == post-factoryReset world + ClientAppDataStore::init(); + + TEST_ASSERT_EQUAL(0, clientAppDataStore->recordCount()); + meshtastic_ClientAppData out = meshtastic_ClientAppData_init_zero; + TEST_ASSERT_EQUAL(static_cast(ClientAppDataStore::Result::NotFound), + static_cast(clientAppDataStore->get("exampleapp", &out))); +} + +static void test_init_firstBoot_isEmpty() +{ + if (clientAppDataStore != nullptr) { + delete clientAppDataStore; + clientAppDataStore = nullptr; + } + mockNodeDB->hasSaved = false; + mockNodeDB->fileExists = true; // exists doesn't matter when nothing saved + mockNodeDB->saveCalls = 0; + mockNodeDB->loadCalls = 0; + ClientAppDataStore::init(); + TEST_ASSERT_EQUAL(1, mockNodeDB->loadCalls); + TEST_ASSERT_EQUAL(0, clientAppDataStore->recordCount()); +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; +} + +void tearDown(void) +{ + if (clientAppDataStore != nullptr) { + delete clientAppDataStore; + clientAppDataStore = nullptr; + } + nodeDB = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + // isValidAppId + RUN_TEST(test_isValidAppId_acceptsConventionalNames); + RUN_TEST(test_isValidAppId_rejectsNull); + RUN_TEST(test_isValidAppId_rejectsEmpty); + RUN_TEST(test_isValidAppId_rejectsUppercase); + RUN_TEST(test_isValidAppId_rejectsWhitespace); + RUN_TEST(test_isValidAppId_rejectsOverlyLong); + RUN_TEST(test_isValidAppId_rejectsForbiddenChars); + + // CRUD round-trip + RUN_TEST(test_setThenGet_returnsExactBytes); + RUN_TEST(test_set_payloadAtMaxAccepted); + RUN_TEST(test_set_payloadOverMaxRejected); + RUN_TEST(test_set_invalidAppIdRejected); + RUN_TEST(test_get_invalidAppIdRejected); + RUN_TEST(test_get_missingReturnsNotFound); + + // Slot accounting + RUN_TEST(test_overwrite_doesNotConsumeSlot); + RUN_TEST(test_maxRecordCount_enforced); + RUN_TEST(test_deleteFreesSlot); + RUN_TEST(test_deleteThenGet_returnsNotFound); + RUN_TEST(test_deleteMissingReturnsNotFound); + RUN_TEST(test_remove_invalidAppIdRejected); + + // updated_at + RUN_TEST(test_updatedAt_isFirmwareSetNotCallerSupplied); + + // Persistence + storage error + RUN_TEST(test_set_callsSaveProto); + RUN_TEST(test_remove_callsSaveProto); + RUN_TEST(test_storageError_setReturnsStorageError); + RUN_TEST(test_storageError_removeReturnsStorageError); + RUN_TEST(test_persistenceSurvivesReinit); + RUN_TEST(test_factoryResetSemantic_emptyAfterFileVanishes); + RUN_TEST(test_init_firstBoot_isEmpty); + + exit(UNITY_END()); +} + +void loop() {}