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..2196fe10cea 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 @@ -259,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/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/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; 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; 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() {}