diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index be45664..c9b5442 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -865,6 +865,8 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) if (off < len) { prefs.v_contact_flags = buf[off++]; } + + sanitizeNodePrefs(&prefs); } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index d79f1b1..f9651ae 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -264,15 +264,9 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { prefs.cr = 8; prefs.tx_power_dbm = 22; } - if (prefs.path_hash_mode > 2) prefs.path_hash_mode = 0; - if (prefs.loop_detect > LOOP_DETECT_STRICT) prefs.loop_detect = LOOP_DETECT_MINIMAL; - if (prefs.rx_boost > 1) prefs.rx_boost = 0; - if (prefs.rx_duty_cycle > 1) prefs.rx_duty_cycle = 0; - if (prefs.meshtimesync > 1) prefs.meshtimesync = 0; - if (prefs.cad_auto > 1) prefs.cad_auto = 0; - if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) prefs.cad_offset = 0; - if (prefs.probe_interval != 0 && prefs.probe_interval < 10) prefs.probe_interval = 10; - if (prefs.cad_busycap > 90) prefs.cad_busycap = 90; + /* Everything else that came off flash — bounds, NaNs, and the char fields, + * which the file format stores without terminators. */ + sanitizeNodePrefs(&prefs); /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore * extension fields, and stored path_hash_mode/loop_detect as zero padding. diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 4c26a82..39e412f 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -149,6 +149,117 @@ struct NodePrefs { }; /* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ +/* Range guards for prefs that came off flash. + * + * The atomic replace in every savePrefs() plus littlefs's own CRCs make a torn + * write impossible, so this is not about power loss — it is about a blob that + * is structurally intact and semantically wrong. Several of these fields make + * a node look bricked when they are: auto_shutdown_mv powers it off seconds + * after boot, ble_pin locks pairing out, and the char fields are stored as + * fixed-size blocks with no terminator in the file format, so an unterminated + * one runs every later %s off the end of the struct. + * + * Called at the end of each role's loadPrefs(). Fields whose whole range is + * legal (autoadd_config bitmask, discovery_mod_timestamp, the v_contact_* + * sentinels) are deliberately left alone. */ +template +static inline T clampPref(T v, T lo, T hi) { return v < lo ? lo : (v > hi ? hi : v); } + +/* Floats need the NaN test spelled out: every comparison against NaN is false, + * so a plain clamp passes it straight through. */ +template +static inline T sanePrefFloat(T v, T lo, T hi, T fallback) { + if (v != v || v < lo || v > hi) return fallback; + return v; +} + +/* A byte that is neither 0 nor 1 carries no user intent — take the default + * rather than clamping, which would round every garbage value up to "on". */ +template +static inline T saneBool(T v, T fallback) { return (v == 0 || v == 1) ? v : fallback; } + +static inline void sanitizeNodePrefs(NodePrefs* p) { + p->node_name[sizeof(p->node_name) - 1] = '\0'; + p->password[sizeof(p->password) - 1] = '\0'; + p->guest_password[sizeof(p->guest_password) - 1] = '\0'; + p->owner_info[sizeof(p->owner_info) - 1] = '\0'; + p->default_scope_name[sizeof(p->default_scope_name) - 1] = '\0'; + + p->airtime_factor = sanePrefFloat(p->airtime_factor, 0.0f, 9.0f, 9.0f); + p->rx_delay_base = sanePrefFloat(p->rx_delay_base, 0.0f, 3600.0f, 0.0f); + p->adc_multiplier = sanePrefFloat(p->adc_multiplier, 0.0f, 30000.0f, 0.0f); + p->node_lat = sanePrefFloat(p->node_lat, -90.0, 90.0, 0.0); + p->node_lon = sanePrefFloat(p->node_lon, -180.0, 180.0, 0.0); + + p->cr = clampPref(p->cr, 5, 8); + p->tx_power_dbm = clampPref(p->tx_power_dbm, -9, 30); +#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM + if (p->tx_power_dbm > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { + p->tx_power_dbm = (int8_t)CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; + } +#endif + + /* Booleans and enums fall back to a safe value rather than being clamped + * to their maximum: clamping is the wrong direction for anything that + * grants a permission, hides the node, or turns a radio mode on. A byte + * that is neither 0 nor 1 carries no user intent, so the fallback is + * simply the field's own default. */ + p->client_repeat = saneBool(p->client_repeat, 0); + p->manual_add_contacts = saneBool(p->manual_add_contacts, 0); + p->multi_acks = saneBool(p->multi_acks, 0); + p->buzzer_quiet = saneBool(p->buzzer_quiet, 0); + p->gps_enabled = saneBool(p->gps_enabled, 0); + p->rx_duty_cycle = saneBool(p->rx_duty_cycle, 0); + p->leds_disabled = saneBool(p->leds_disabled, 0); + p->meshtimesync = saneBool(p->meshtimesync, 0); + p->cad_auto = saneBool(p->cad_auto, 0); + p->allow_read_only = saneBool(p->allow_read_only, 0); + p->powersaving_enabled = saneBool(p->powersaving_enabled, 0); + /* Defaults that are on, not off. */ + p->rx_boost = saneBool(p->rx_boost, 1); + p->wake_on_msg = saneBool(p->wake_on_msg, 1); + p->v_contact_enabled = saneBool(p->v_contact_enabled, 1); + /* Never let a corrupt byte take the radio off the air or hide BLE — both + * remove the only ways left to fix the node. */ + p->ble_disabled = saneBool(p->ble_disabled, 0); + + /* Permission and privacy enums: out of range means deny/withhold. */ + if (p->telemetry_mode_base > TELEM_MODE_ALLOW_ALL) p->telemetry_mode_base = TELEM_MODE_DENY; + if (p->telemetry_mode_loc > TELEM_MODE_ALLOW_ALL) p->telemetry_mode_loc = TELEM_MODE_DENY; + if (p->telemetry_mode_env > TELEM_MODE_ALLOW_ALL) p->telemetry_mode_env = TELEM_MODE_DENY; + if (p->advert_loc_policy > ADVERT_LOC_PREFS) p->advert_loc_policy = ADVERT_LOC_NONE; + + if (p->loop_detect > LOOP_DETECT_STRICT) p->loop_detect = LOOP_DETECT_MINIMAL; + if (p->path_hash_mode > 2) p->path_hash_mode = 0; + p->autoadd_max_hops = clampPref(p->autoadd_max_hops, 0, 64); + p->flood_max_unscoped = clampPref(p->flood_max_unscoped, 0, 64); + p->flood_max_advert = clampPref(p->flood_max_advert, 0, 64); + p->cad_busycap = clampPref(p->cad_busycap, 0, 90); + /* Neutral, not the boundary — a garbage offset is not a request to run + * the CAD staircase at one end of its range. */ + if (p->cad_offset < CAD_OFFSET_MIN || p->cad_offset > CAD_OFFSET_MAX) p->cad_offset = 0; + if (p->probe_interval != 0 && p->probe_interval < 10) p->probe_interval = 10; + + /* 6-digit BLE passkey — anything else is rejected at pairing time and + * leaves no way back in over BLE. */ + if (p->ble_pin > 999999) p->ble_pin = 0; + /* Seconds; 0 = off. A year is already far past any sane setting. */ + if (p->gps_interval > 31536000UL) p->gps_interval = 0; + /* 0 = board/Kconfig default on both, else the range the UI offers. */ + if (p->display_brightness != 0) p->display_brightness = clampPref(p->display_brightness, 10, 100); + if (p->screen_off_secs != 0) p->screen_off_secs = clampPref(p->screen_off_secs, 5, 300); + /* 0 = off. Outside the cell range it either never fires or powers the + * node off the moment it boots — the one that looks like a dead device. */ + if (p->auto_shutdown_mv != 0 && + (p->auto_shutdown_mv < 2900 || p->auto_shutdown_mv > 4200)) { +#ifdef CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS + p->auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; +#else + p->auto_shutdown_mv = 0; +#endif + } +} + static inline void initNodePrefs(NodePrefs* prefs) { memset(prefs, 0, sizeof(NodePrefs)); prefs->airtime_factor = 9.0f; /* Arduino formula: duty% = 100 / (af + 1) → 10% */