diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index eff9898f..565ca822 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -81,6 +81,7 @@ jobs: sudo apt-get update sudo apt-get install -y libssl-dev python3 -B test/test_companion_tx_routing.py -v + python3 -B test/test_companion_preferences_transaction.py -v python3 -B test/test_contact_cache.py -v - name: Verify receive recovery and pinned radio status transport @@ -108,6 +109,9 @@ jobs: python3 -B test/test_reply_tx_integration.py python3 -B test/test_ota_speed.py -v python3 -B test/test_ota_apply_delivery.py -v + python3 -B test/test_ota_install_preflight.py -v + python3 -B test/test_ota_store_resume.py -v + python3 -B test/test_ota_store_flash_nrf52_hybrid.py -v python3 -B test/test_ota_dev_staging.py -v python3 -B test/test_ota_dev_reboot_guard.py -v diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0921a5f..b7e699b8 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -641,16 +641,23 @@ bool DataStore::loadPrefs(CompanionNodePrefs& prefs, double& node_lat, return true; } #else + if (_prefs_load_incomplete) return false; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(_fs, "/new_prefs")) { + _prefs_load_incomplete = true; + return false; + } +#endif if (_fs->exists("/new_prefs")) { - return loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); + const bool loaded = loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); + _prefs_load_incomplete = !loaded; + return loaded; } if (!_fs->exists("/node_prefs")) return true; #endif if (!loadPrefsInt("/node_prefs", prefs, node_lat, node_lon)) { -#if defined(NRF52_PLATFORM) _prefs_load_incomplete = true; -#endif return false; } #if defined(NRF52_PLATFORM) @@ -826,10 +833,14 @@ bool DataStore::loadPrefsInt(const char *filename, } bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, double node_lon) { -#if defined(NRF52_PLATFORM) if (_prefs_load_incomplete) return false; +#if defined(NRF52_PLATFORM) if (_primary_storage_unavailable) return false; +#endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) mesh::AtomicFileWriter file(_fs, "/new_prefs"); +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + mesh::ContactFileTransaction file(_fs, "/new_prefs"); #else File file = openWrite(_fs, "/new_prefs"); #endif @@ -928,7 +939,7 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou sizeof(_prefs.bluetooth_stealth_mode)) == sizeof(_prefs.bluetooth_stealth_mode); -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic preferences write failed"); #else diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 4edf9579..e03169e5 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -39,6 +39,7 @@ class DataStore FILESYSTEM* _configuredFsExtra; mesh::RTCClock* _clock; IdentityStore identity_store; + bool _prefs_load_incomplete = false; #if !defined(NRF52_PLATFORM) bool _channel_load_incomplete = false; #endif @@ -68,7 +69,6 @@ class DataStore bool _identity_creation_blocked = false; bool _primary_storage_unavailable = false; bool _secondary_authority_unknown = false; - bool _prefs_load_incomplete = false; uint32_t _contact_page_generations[mesh::storage::CONTACT_PAGE_COUNT]; bool _legacy_contacts_pending_cleanup; bool _legacy_migration_ready; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 1df88f62..eac90eca 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -202,6 +202,18 @@ bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl Packet* p = m->createOtaPacket(msg, len); if (!p) return false; p->radio_reply = len && ota::ota_is_response_message(msg[0]); + if (p->radio_reply) { + if (const auto* context = ota::ota_context_if_active()) { + const auto route = context->manager.replyRoute(); + if (route.profile < 2) { + // DATA, PROOF and MANIFEST are emitted after the RX call stack ends. + // Restore origin only; normal reply policy still decides one/both TX. + p->radio_profile = p->radio_origin = route.profile; + p->radio_generation = p->radio_origin_generation = route.generation; + p->radio_local = false; + } + } + } const uint8_t mask = m->getTransmitProfileMask(p); const int copies = (mask & 1 ? 1 : 0) + (mask & 2 ? 1 : 0); // p already occupies one slot. Reserve the other copy as well, keeping the @@ -461,7 +473,15 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { _next_ota_tick = futureMillis(ota::scaleDelay(OTA_RETRY_TICK_MS, ota_speed < 1.0f ? ota_speed : 1.0f)); } syncOtaTiming(ota::ota_ctx().manager); - ota::ota_ctx().manager.serviceEgress(); + ota::ota_ctx().manager.serviceEgress([](void* ctx, const ota::OtaReplyRoute& route) { + if (route.profile == 0xFF) return true; + const auto* profiles = static_cast(ctx)->_radio->profiles(); + // A completed/reconfigured temp session must not leak its retained replies + // onto the replacement channel, or pin a stale descriptor behind backpressure. + return route.profile < 2 && (!profiles ? route.profile == 0 + : (route.profile == 0 || profiles->enabled()) + && route.generation == profiles->generation[route.profile]); + }, this); const uint32_t ota_loop_interval = ota::ota_ctx().manager.loopIntervalMs(OTA_RETRY_TICK_MS); if ((int32_t)(_next_ota_tick - _ms->getMillis()) > (int32_t)ota_loop_interval) { // Entering local verification must not inherit a long radio retry wait. @@ -1275,7 +1295,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (ota::OtaContext* context = ota::ota_context_if_active()) { syncOtaTiming(context->manager); context->manager.note_rx_path_hops(n); - terminal_ota = context->manager.on_message(pkt->payload, pkt->payload_len); + ota::OtaReplyRoute route; + route.profile = pkt->radio_profile; + route.generation = pkt->radio_generation; + terminal_ota = context->manager.on_message(pkt->payload, pkt->payload_len, route); context->track_session(context->manager.fetchState(), _ms->getMillis()); onOtaRecv(pkt); } diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index 6e7bf714..76640328 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -15,8 +15,8 @@ #endif class ConfigSerializer { - bool _first; - int8_t _depth; + bool _first = false; + int8_t _depth = 0; bool _dirty = false; protected: diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 9205beae..72a1af15 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -9,6 +9,7 @@ #include "SignerAllowlist.h" #include "OtaApply.h" #include "OtaFormat.h" +#include "OtaByteIO.h" #include "OtaSelf.h" // ota_self_firmware() - prefer self-describing EndF identity at begin() #include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read @@ -262,12 +263,18 @@ struct OtaContext { // hardware-compatibility gate (brick-safety) - refuse a .mota whose hw_id is for different hardware, // independent of signature; covers a manual cross-target `ota dev want` onto an incompatible board. { - uint8_t hdr[8], mb[256]; + uint8_t hdr[8], mb[MOTA_MFL]; uint32_t total = fetch_store.staged_size(); - if (total >= 13 && fetch_store.read(0, hdr, 8) && memcmp(hdr, MOTA_MAGIC, 4) == 0) { - uint32_t mr = total - 8; if (mr > sizeof(mb)) mr = sizeof(mb); + // A failed first read must not bypass these policy gates and reach an + // applier whose later read happens to succeed. + if (total < 8 + MOTA_MFL + 5 || !fetch_store.read(0, hdr, sizeof(hdr)) + || memcmp(hdr, MOTA_MAGIC, sizeof(MOTA_MAGIC)) != 0 + || rd_u32le(hdr + 4) != total) { + strncpy(msg, "refused: invalid or unreadable staged header", 96); msg[95] = 0; return false; + } + { MotaManifest mm; - if (!fetch_store.read(8, mb, mr) || !mota_parse_manifest(mb, mr, mm)) { + if (!fetch_store.read(8, mb, sizeof(mb)) || !mota_parse_manifest(mb, sizeof(mb), mm)) { strncpy(msg, "refused: invalid staged manifest", 96); msg[95] = 0; return false; } if (mm.is_bootloader()) { @@ -283,7 +290,7 @@ struct OtaContext { if (!ota_self_firmware(self) || !self.valid || !ota_trusted_auto_version_allows(self.fw_version, mm.fw_version)) { snprintf(msg, 96, - "refused: trusted auto-install is forward-only (running=%08lX candidate=%08lX); use ota install to override", + "refused: auto-install needs newer firmware (%08lX -> %08lX); use ota install", (unsigned long)(self.valid ? self.fw_version : 0), (unsigned long)mm.fw_version); return false; @@ -291,7 +298,7 @@ struct OtaContext { } if (!hwMatches(mm.hw_id)) { char want[33] = {0}; memcpy(want, mm.hw_id, 32); - snprintf(msg, 96, "refused: .mota hw_id '%.32s' != this device '%s' (incompatible hardware)", want, hw_id); + snprintf(msg, 96, "refused: incompatible hardware (%.24s / %.24s)", want, hw_id); return false; } } diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 691b25a9..c8ae4d2a 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -483,7 +483,7 @@ bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want bool wire_v2, bool allow_deflate, bool extended_length) { for (uint8_t i = 0; i < _n_serve_jobs; i++) { ServeJob& job = _serve_jobs[i]; - if (job.block != block || memcmp(job.mid, mid, 4) != 0) continue; + if (job.block != block || memcmp(job.mid, mid, 4) != 0 || !(job.route == _request_route)) continue; // A proof-only request can complete whichever representation is already queued. DATA requests with // different geometries remain distinct because their bitmap positions do not describe the same bytes. if (want_mask != 0 && (job.wire_v2 != wire_v2 || @@ -495,6 +495,7 @@ bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want } if (_n_serve_jobs >= OTA_SERVE_QUEUE) return false; ServeJob& job = _serve_jobs[_n_serve_jobs++]; + job.route = _request_route; memcpy(job.mid, mid, 4); job.block = block; job.pending_mask = want_mask; @@ -510,12 +511,13 @@ bool OtaManager::queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want bool OtaManager::queueManifestJob(const uint8_t* mid, uint16_t want_mask) { for (uint8_t i = 0; i < _n_manifest_jobs; i++) { ManifestServeJob& job = _manifest_jobs[i]; - if (memcmp(job.mid, mid, 4) != 0) continue; + if (memcmp(job.mid, mid, 4) != 0 || !(job.route == _request_route)) continue; job.pending_mask |= (uint16_t)(want_mask & ~job.emitted_mask); return true; } if (_n_manifest_jobs >= OTA_MANIFEST_SERVE_QUEUE) return false; ManifestServeJob& job = _manifest_jobs[_n_manifest_jobs++]; + job.route = _request_route; memcpy(job.mid, mid, 4); job.pending_mask = want_mask; job.emitted_mask = 0; @@ -563,9 +565,13 @@ void OtaManager::popManifestJob() { _n_manifest_jobs--; } -bool OtaManager::serviceManifestEgress() { +bool OtaManager::serviceManifestEgress(OtaReplyRouteValid route_valid, void* route_ctx) { if (_n_manifest_jobs == 0) return false; ManifestServeJob& job = _manifest_jobs[0]; + if (route_valid && !route_valid(route_ctx, job.route)) { + popManifestJob(); + return true; + } if ((int32_t)(_now_ms - job.ready_at) < 0) { return true; } @@ -603,7 +609,7 @@ bool OtaManager::serviceManifestEgress() { message.len = (uint16_t)len; uint8_t wire[MAX_PACKET_PAYLOAD]; const uint16_t wire_len = encode_manifest(wire, sizeof(wire), message); - if (emit(wire, wire_len, false)) { + if (emit(wire, wire_len, false, &job.route)) { OTA_DBG("OTA: MANIFEST tx frag=%u/%u len=%u\n", (unsigned)fragment, (unsigned)ftotal, (unsigned)len); const uint16_t bit = (uint16_t)(1u << fragment); @@ -716,10 +722,10 @@ bool OtaManager::handleReqProof(const uint8_t* m, uint16_t n) { return queueServeJob(v->m.merkle_root, rp.block_idx, 0); // proof-only, or merge with queued DATA } -void OtaManager::serviceEgress() { +void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) { // Metadata comes first so an older receiver gets every manifest fragment before its retry horizon. The // send callback supplies radio-queue backpressure, and a rejected fragment remains in this descriptor. - if (serviceManifestEgress()) return; + if (serviceManifestEgress(route_valid, route_ctx)) return; // A proactive proof normally follows the final DATA fragment. If it was lost, or the source is older and // never sent one, issue the legacy proof request after a short grace. Stay RX-silent while another slot @@ -738,6 +744,10 @@ void OtaManager::serviceEgress() { if (_n_serve_jobs == 0) return; ServeJob& job = _serve_jobs[0]; + if (route_valid && !route_valid(route_ctx, job.route)) { + popServeJob(); + return; + } ServeView* v = resolve(job.mid); if (!v || job.block >= v->m.block_count || (uint64_t)v->m.block_count * 4 > v->scratch_sz) { @@ -789,7 +799,7 @@ void OtaManager::serviceEgress() { dm.data_len = (uint16_t)frag_len; } uint8_t b[MAX_PACKET_PAYLOAD]; - if (emit(b, encode_data(b, sizeof(b), dm), false)) { + if (emit(b, encode_data(b, sizeof(b), dm), false, &job.route)) { const uint16_t bit = (uint16_t)(1u << fragment); job.pending_mask &= (uint16_t)~bit; job.emitted_mask |= bit; @@ -818,7 +828,7 @@ void OtaManager::serviceEgress() { pm.n_proof = np; pm.proof = proof; uint8_t b[MAX_PACKET_PAYLOAD]; - if (emit(b, encode_proof(b, sizeof(b), pm), false)) popServeJob(); + if (emit(b, encode_proof(b, sizeof(b), pm), false, &job.route)) popServeJob(); } // ---------------- fetch ---------------- @@ -1585,19 +1595,15 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { || _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED) { return false; } - _wire_v2_session = _wire_v2_enabled; - _wire_v2_confirmed = false; - _wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr; - _wire_empty_stalls = 0; if (!_fetch->reopen()) return false; // nothing persisted in the store uint32_t total = _fetch->staged_size(); uint8_t hdr[8]; - if (total < 13 || !_fetch->read(0, hdr, 8) || memcmp(hdr, MOTA_MAGIC, 4) != 0) return false; + if (total < 8u + MOTA_MFL + 5u || !_fetch->read(0, hdr, sizeof(hdr)) + || memcmp(hdr, MOTA_MAGIC, 4) != 0 || rd_u32le(hdr + 4) != total) return false; // read + parse the stored manifest (everything before leaves[]) to recompute the geometry - uint8_t mbuf[256]; - uint32_t mread = total - 8; if (mread > sizeof(mbuf)) mread = sizeof(mbuf); + uint8_t mbuf[MOTA_MFL]; MotaManifest m; - if (!_fetch->read(8, mbuf, mread) || !mota_parse_manifest(mbuf, mread, m)) return false; + if (!_fetch->read(8, mbuf, sizeof(mbuf)) || !mota_parse_manifest(mbuf, sizeof(mbuf), m)) return false; if (want_mid && memcmp(m.merkle_root, want_mid, 4) != 0) return false; // a different fw is staged const bool automatic_resume = want_mid == nullptr; // A boot-time resume is an automatic fetch decision and therefore belongs @@ -1625,7 +1631,14 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { uint32_t leaves_off = 8 + mfl; uint32_t payload_off = leaves_off + bc * 4; if ((uint64_t)payload_off + m.payload_size + 5 != total) return false; // geometry must match the header + uint8_t trailer[5]; + if (!_fetch->read(total - sizeof(trailer), trailer, sizeof(trailer)) + || memcmp(trailer, MOTA_TRAILER, sizeof(trailer)) != 0) return false; + _wire_v2_session = _wire_v2_enabled; + _wire_v2_confirmed = false; + _wire_allow_deflate = _wire_v2_session && _deflate_decode != nullptr; + _wire_empty_stalls = 0; memcpy(_fid, m.merkle_root, 4); memcpy(_froot, m.merkle_root, 4); _fflags = m.flags; @@ -2147,7 +2160,15 @@ void OtaManager::loop() { // ---------------- dispatch ---------------- -bool OtaManager::on_message(const uint8_t* msg, uint16_t len) { +bool OtaManager::on_message(const uint8_t* msg, uint16_t len, OtaReplyRoute route) { + const OtaReplyRoute previous = _request_route; + _request_route = route; + const bool consumed = dispatchMessage(msg, len); + _request_route = previous; + return consumed; +} + +bool OtaManager::dispatchMessage(const uint8_t* msg, uint16_t len) { switch (ota_msg_type(msg, len)) { case OTA_ADV: handleAdv(msg, len); return false; case OTA_QUERY: handleQuery(msg, len); return false; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 809e3c53..84be2a2a 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -30,6 +30,17 @@ namespace ota { // replies. False applies backpressure: the manager retains paced DATA/PROOF egress and tries it again. typedef bool (*OtaSend)(void* ctx, const uint8_t* msg, uint16_t len, bool flood); +// Deferred replies retain the request's radio session, without changing the +// portable send callback or putting radio-routing metadata on the OTA wire. +struct OtaReplyRoute { + uint32_t generation = 0; + uint8_t profile = 0xFF; // no radio affinity (portable callers) + bool operator==(const OtaReplyRoute& other) const { + return profile == other.profile && generation == other.generation; + } +}; +typedef bool (*OtaReplyRouteValid)(void* ctx, const OtaReplyRoute& route); + // Read `len` payload bytes at offset `off` from the serve source (flash-backed self-serve); false on // error. nullptr means the payload is a contiguous RAM buffer (the staged `.mota`). typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len); @@ -477,11 +488,15 @@ public: // Feed one received OTA message. True means this node terminally consumed a // bulk request/response, so Mesh must not echo it as if this node were an // intermediate relay. - bool on_message(const uint8_t* msg, uint16_t len); + bool on_message(const uint8_t* msg, uint16_t len, OtaReplyRoute route = {}); void loop(); // drive fetch (re-request missing blocks) // Fast, non-blocking service called from the main radio loop. It admits at most one retained server // response or proof fallback per call; the send callback provides packet-pool/queue backpressure. - void serviceEgress(); + // An optional validator discards expired request sessions before reading or sending a response. + void serviceEgress(OtaReplyRouteValid route_valid = nullptr, void* route_ctx = nullptr); + // Available only inside the send callback; returns a value, never a retained + // pointer into a queue that may be compacted after the callback returns. + OtaReplyRoute replyRoute() const { return _reply_route ? *_reply_route : OtaReplyRoute{}; } void clearPendingEgress(); uint8_t pendingServeJobs() const { return _n_serve_jobs; } uint8_t pendingManifestJobs() const { return _n_manifest_jobs; } @@ -542,8 +557,11 @@ private: uint16_t catalogCapacity() const { return _catalog_heap ? OTA_MAX_CATALOG : OTA_INLINE_CATALOG; } bool expandCatalog(); - bool emit(const uint8_t* b, uint16_t n, bool flood) { + bool emit(const uint8_t* b, uint16_t n, bool flood, const OtaReplyRoute* route = nullptr) { + const OtaReplyRoute* previous = _reply_route; + _reply_route = route; const bool sent = _send && n && _send(_ctx, b, n, flood); + _reply_route = previous; if (sent) _packets_sent++; return sent; } @@ -593,7 +611,8 @@ private: bool queueManifestJob(const uint8_t* mid, uint16_t want_mask); uint32_t manifestEgressGapMs() const; uint32_t proofEgressGapMs() const; - bool serviceManifestEgress(); + bool serviceManifestEgress(OtaReplyRouteValid route_valid, void* route_ctx); + bool dispatchMessage(const uint8_t* msg, uint16_t len); void popManifestJob(); bool loadActiveServeBlock(); void popServeJob(); @@ -655,6 +674,7 @@ private: // Server-side response descriptors are tiny. The active logical block has its own buffer so proof generation // or a simultaneous fetch cannot overwrite DATA retained behind radio-queue backpressure. struct ServeJob { + OtaReplyRoute route; uint8_t mid[4] = {0}; uint16_t block = 0; uint16_t pending_mask = 0; @@ -668,6 +688,7 @@ private: ServeJob _serve_jobs[OTA_SERVE_QUEUE]; uint8_t _n_serve_jobs = 0; struct ManifestServeJob { + OtaReplyRoute route; uint8_t mid[4]; uint16_t pending_mask; uint16_t emitted_mask; @@ -675,6 +696,8 @@ private: }; ManifestServeJob _manifest_jobs[OTA_MANIFEST_SERVE_QUEUE]; uint8_t _n_manifest_jobs = 0; + OtaReplyRoute _request_route; + const OtaReplyRoute* _reply_route = nullptr; uint8_t _serve_block[OTA_MAX_BLOCK]; uint16_t _serve_block_len = 0; bool _serve_block_loaded = false; diff --git a/src/helpers/ota/OtaStoreFlashEsp32.cpp b/src/helpers/ota/OtaStoreFlashEsp32.cpp index 697b9bbe..e074203b 100644 --- a/src/helpers/ota/OtaStoreFlashEsp32.cpp +++ b/src/helpers/ota/OtaStoreFlashEsp32.cpp @@ -210,7 +210,11 @@ const uint8_t* OtaStoreFlashEsp32::meta_slot_c(uint32_t L) const { // Bytes from `pos` that stay in one region, and (for payload) one flash sector. uint32_t OtaStoreFlashEsp32::run(uint32_t pos, uint32_t remain) const { if (pos >= _total - 5) return remain; // trailer (<=5, one buffer) - if (pos < _meta_span) { uint32_t c = _meta_span - pos; return remain < c ? remain : c; } + if (pos < _meta_span) { + uint32_t end = _meta_span < _total - 5u ? _meta_span : _total - 5u; + uint32_t c = end - pos; + return remain < c ? remain : c; // never consume the separate trailer through metadata + } uint32_t poff = pay_part(pos); uint32_t to_sec = SEC - (poff % SEC); uint32_t to_end = (_total - 5) - pos; // don't cross into the trailer @@ -286,6 +290,16 @@ bool OtaStoreFlashEsp32::finalize() { // trailer sits right after the payload; this also covers the rare case it spills into a fresh sector). uint32_t tpoff = _write_start + (_total - 5); for (uint32_t off = 0; off < 5; ) { + const uint32_t logical = _total - 5u + off; + if (logical < _meta_span) { + // A small delta's tail can share the pinned metadata sector. Put those + // bytes in the buffer that will be flushed last, including a split tail. + uint32_t n = _meta_span - logical; + if (n > 5u - off) n = 5u - off; + memcpy(_meta + logical, _trailer + off, n); + off += n; + continue; + } uint32_t sec = (tpoff + off) / SEC; if (!_pay_open || sec != _pay_sec) open_pay(sec); uint32_t in = SEC - ((tpoff + off) % SEC); if (in > 5 - off) in = 5 - off; @@ -332,7 +346,12 @@ bool OtaStoreFlashEsp32::reopen() { if (esp_partition_read(_part, _meta_part, _meta, _meta_flush) != ESP_OK) { free(_meta); _meta = nullptr; _total = 0; return false; } - memset(_trailer, 0xFF, sizeof(_trailer)); // delta trailer (re-written at finalize); full reads it from _meta + // Raw staging defers the fixed trailer; an unvisited sector may still + // contain old firmware bytes after an early checkpoint. Restore framing + // from the validated layout, not that stale tail. The manager separately + // revalidates the selected image and every present payload block. + memcpy(_trailer, MOTA_TRAILER, sizeof(_trailer)); + if (_full) memcpy(_meta + _meta_bytes, MOTA_TRAILER, sizeof(_trailer)); _pay_open = false; _pay_sec = 0; _flushed = false; _io_ok = true; _pay_max_sec = (_pay_part0 + _pay_size + SEC) / SEC; // treat all payload sectors as seen -> RMW preserves committed blocks OTA_DBG("OTA esp32: reopen %s total=%u meta_part=%u\n", _full ? "FULL" : "DELTA", (unsigned)_total, (unsigned)o); diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index 27173983..e6052eec 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -599,7 +599,10 @@ bool OtaStoreFlashNrf52::reopen() { _total = total; _hybrid = false; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue - memcpy(_trailer, p + (total - 5), 5); // recover the trailer tail (flushed at last finalize, if any) + // The tail is deferred until finalize and can still hold old flash bytes. + // Reconstruct only fixed framing; the manager must verify the manifest and + // rehash every present payload block before the container becomes complete. + memcpy(_trailer, MOTA_TRAILER, sizeof(_trailer)); _pay_idx = 0; _flushed = false; _io_ok = true; diff --git a/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp index 980d5be7..1d8c77f9 100644 --- a/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp +++ b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp @@ -235,6 +235,57 @@ static void hybrid_state_is_one_shot_and_not_reopened_after_restart() { CHECK(after_restart.staged_size() == 0u); } +static void checkpointed_flash_resume_restores_deferred_trailer() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = 3072u; + auto bytes = patterned_container(total); + std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u); + { + OtaStoreFlashNrf52 store; + CHECK(store.plan_layout(false, 0x80000u, 205u, total - 210u, false)); + CHECK(store.begin(total)); + CHECK(!store.is_hybrid()); + CHECK(store.write(0, bytes.data(), total)); + store.checkpoint(); + } + OtaStoreFlashNrf52 resumed; + CHECK(resumed.reopen()); + CHECK(resumed.finalize()); + std::vector output(total); + CHECK(resumed.read(0, output.data(), total)); + CHECK(output == bytes); + CHECK(std::memcmp(resumed.data(), bytes.data(), total) == 0); +} + +static void dirty_flash_partial_checkpoint_finishes(bool reboot) { + using namespace mesh::ota; + reset_target_memory(); + std::memset(reinterpret_cast(FLASH_MAP_START), 0xA5, FLASH_MAP_SIZE); + install_bootloader_capabilities(); + const uint32_t total = 3u * MOTA_NRF52_FLASH_PAGE; + auto bytes = patterned_container(total); + std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u); + OtaStoreFlashNrf52 original, reopened; + // Exercise the ordinary pure-flash backend rather than this build's optional + // preplanned volatile hybrid placement. + CHECK(original.begin(total)); + CHECK(original.write(0, bytes.data(), 1024u)); + CHECK(original.write(total - 5u, MOTA_TRAILER, 5u)); + original.checkpoint(); + OtaStoreFlashNrf52& active = reboot ? reopened : original; + if (reboot) CHECK(active.reopen()); + uint8_t first[1024]; + CHECK(active.read(0, first, sizeof(first))); + CHECK(std::memcmp(first, bytes.data(), sizeof(first)) == 0); + CHECK(active.write(1024u, bytes.data() + 1024u, total - 1024u - 5u)); + CHECK(active.finalize()); + std::vector output(total); + CHECK(active.read(0, output.data(), total)); + CHECK(output == bytes); + CHECK(std::memcmp(active.data(), bytes.data(), total) == 0); +} + int main() { map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE); map_target_region(RAM_MAP_START, RAM_MAP_SIZE); @@ -246,6 +297,12 @@ int main() { std::puts("PASS: persistence failure stays closed"); hybrid_state_is_one_shot_and_not_reopened_after_restart(); std::puts("PASS: restart cannot reopen volatile suffix"); - std::puts("4 OtaStoreFlashNrf52 hybrid lifecycle checks passed"); + checkpointed_flash_resume_restores_deferred_trailer(); + std::puts("PASS: checkpointed flash resume restores deferred trailer"); + dirty_flash_partial_checkpoint_finishes(true); + std::puts("PASS: dirty flash partial checkpoint resumes and finishes"); + dirty_flash_partial_checkpoint_finishes(false); + std::puts("PASS: dirty flash partial checkpoint continues without reboot"); + std::puts("7 OtaStoreFlashNrf52 hybrid lifecycle checks passed"); return 0; } diff --git a/test/fixtures/ota_store_resume/mocks/esp_ota_ops.h b/test/fixtures/ota_store_resume/mocks/esp_ota_ops.h new file mode 100644 index 00000000..4a0deae7 --- /dev/null +++ b/test/fixtures/ota_store_resume/mocks/esp_ota_ops.h @@ -0,0 +1,3 @@ +#pragma once +#include "esp_partition.h" +const esp_partition_t* esp_ota_get_next_update_partition(const esp_partition_t*); diff --git a/test/fixtures/ota_store_resume/mocks/esp_partition.h b/test/fixtures/ota_store_resume/mocks/esp_partition.h new file mode 100644 index 00000000..e5c20121 --- /dev/null +++ b/test/fixtures/ota_store_resume/mocks/esp_partition.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include +typedef int esp_err_t; +constexpr esp_err_t ESP_OK = 0; +struct esp_partition_t { uint32_t size; }; +esp_err_t esp_partition_read(const esp_partition_t*, size_t, void*, size_t); +esp_err_t esp_partition_write(const esp_partition_t*, size_t, const void*, size_t); +esp_err_t esp_partition_erase_range(const esp_partition_t*, size_t, size_t); diff --git a/test/fixtures/ota_store_resume/test.cpp b/test/fixtures/ota_store_resume/test.cpp new file mode 100644 index 00000000..f4285c96 --- /dev/null +++ b/test/fixtures/ota_store_resume/test.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include "../../test_ota/mota_vectors.h" +#include +#include +#include + +using namespace mesh::ota; +static std::vector flash(65536, 0xFF); +static const esp_partition_t partition{65536}; +const esp_partition_t* esp_ota_get_next_update_partition(const esp_partition_t*) { + return &partition; +} +esp_err_t esp_partition_read(const esp_partition_t*, size_t off, void* data, size_t n) { + if (off > flash.size() || n > flash.size() - off) return -1; + memcpy(data, flash.data() + off, n); + return ESP_OK; +} +esp_err_t esp_partition_write(const esp_partition_t*, size_t off, const void* data, size_t n) { + if (off > flash.size() || n > flash.size() - off) return -1; + const auto* bytes = static_cast(data); + for (size_t i = 0; i < n; ++i) { + assert((flash[off + i] & bytes[i]) == bytes[i]); + flash[off + i] &= bytes[i]; + } + return ESP_OK; +} +esp_err_t esp_partition_erase_range(const esp_partition_t*, size_t off, size_t n) { + if (off > flash.size() || n > flash.size() - off || off % 4096 || n % 4096) return -1; + memset(flash.data() + off, 0xFF, n); + return ESP_OK; +} +static bool send(void*, const uint8_t*, uint16_t, bool) { return true; } + +// Folder/SD store size comes from the backing file, independently of its header. +struct StatSizeStore : OtaStoreRam<4096> { + bool fail_trailer_read = false; + bool reopen() override { return staged_size() != 0; } + bool read(uint32_t off, uint8_t* bytes, uint32_t n) const override { + return !(fail_trailer_read && off == staged_size() - 5) + && OtaStoreRam<4096>::read(off, bytes, n); + } +}; + +static void resume_checks_file_envelope(int corruption) { + StatSizeStore store; + std::vector bytes(SIM_MOTA_1K, SIM_MOTA_1K + SIM_MOTA_1K_LEN); + MotaManifest manifest; + assert(mota_parse(bytes.data(), bytes.size(), manifest)); + uint8_t mid[4]; memcpy(mid, manifest.merkle_root, sizeof(mid)); + if (corruption == 1) wr_u32le(bytes.data() + 4, bytes.size() + 1); + if (corruption == 2) bytes.back() ^= 1; + if (corruption == 3) store.fail_trailer_read = true; + if (corruption == 4) memset(bytes.data() + bytes.size() - 5, 0xFF, 5); + assert(store.begin(bytes.size())); + assert(store.write(0, bytes.data(), bytes.size())); + OtaManager receiver; + receiver.begin(SIM_TARGET_ID, send, nullptr); + receiver.set_fetch_store(&store); + const bool adopted = receiver.resumeStagedExplicit(mid, SIM_TARGET_ID); + assert(adopted == (corruption == 0)); + if (adopted) { + for (unsigned guard = 0; guard < 100 && receiver.fetchState() == OtaManager::VERIFYING_STAGED; ++guard) + receiver.loop(); + assert(receiver.fetchState() == OtaManager::COMPLETE); + } +} + +static std::vector delta_container(uint32_t total) { + uint32_t blocks = (total - 210u + 1023u) / 1024u; + const uint32_t payload_size = total - 210u - blocks * 4u; + assert((payload_size + 1023u) / 1024u == blocks); + std::vector bytes(total); + memcpy(bytes.data(), MOTA_VEC, 8u + MOTA_MFL); + wr_u32le(bytes.data() + 4, total); + bytes[8 + 1] = 0; + bytes[8 + 56] = CODEC_DETOOLS_SEQUENTIAL; + wr_u32le(bytes.data() + 8 + 15, payload_size); + uint8_t* leaves = bytes.data() + 8u + MOTA_MFL; + uint8_t* payload = leaves + blocks * 4u; + for (uint32_t i = 0; i < payload_size; ++i) payload[i] = static_cast(i * 31u); + for (uint32_t i = 0; i < blocks; ++i) { + const uint32_t n = std::min(1024u, payload_size - i * 1024u); + merkle_leaf(leaves + i * 4u, payload + i * 1024u, n); + } + merkle_root(bytes.data() + 8 + 20, leaves, blocks); + memcpy(bytes.data() + total - 5, MOTA_TRAILER, 5); + return bytes; +} + +static void esp32_resume_preserves_container_trailer(bool finalized, bool full, uint32_t small_size = 0) { + std::fill(flash.begin(), flash.end(), 0xFF); + std::vector bytes = small_size ? delta_container(small_size) + : std::vector(MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN); + if (!full) { + bytes[8 + 1] = 0; + bytes[8 + 56] = CODEC_DETOOLS_SEQUENTIAL; + } + MotaManifest manifest; + assert(mota_parse(bytes.data(), bytes.size(), manifest)); + uint8_t mid[4]; memcpy(mid, manifest.merkle_root, sizeof(mid)); + { + OtaStoreFlashEsp32 store; + assert(store.plan_layout(full, manifest.image_size, + manifest.payload - bytes.data(), manifest.payload_size, false)); + assert(store.begin(bytes.size())); + // The manager writes the trailer separately from the metadata/payload. + assert(store.write(0, bytes.data(), bytes.size() - 5)); + assert(store.write(bytes.size() - 5, bytes.data() + bytes.size() - 5, 5)); + if (finalized) assert(store.finalize()); + else store.checkpoint(); + } + OtaStoreFlashEsp32 resumed; + OtaManager receiver; + receiver.begin(EXP_TARGET_ID, send, nullptr); + receiver.set_fetch_store(&resumed); + assert(receiver.resumeStagedExplicit(mid, EXP_TARGET_ID)); + for (unsigned guard = 0; guard < 100 && receiver.fetchState() == OtaManager::VERIFYING_STAGED; ++guard) + receiver.loop(); + assert(receiver.fetchState() == OtaManager::COMPLETE); + std::vector output(bytes.size()); + assert(resumed.read(0, output.data(), output.size())); + assert(output == bytes); + MotaManifest parsed; + assert(mota_parse(output.data(), output.size(), parsed)); + assert(mota_check_payload(parsed)); +} + +static void dirty_partial_checkpoint_can_finish(bool reboot) { + std::fill(flash.begin(), flash.end(), 0xA5); + auto bytes = delta_container(5547); + MotaManifest manifest; + assert(mota_parse(bytes.data(), bytes.size(), manifest)); + const uint32_t payload_off = manifest.payload - bytes.data(); + const uint32_t leaves_off = manifest.leaves - bytes.data(); + uint8_t mid[4]; memcpy(mid, manifest.merkle_root, sizeof(mid)); + OtaStoreFlashEsp32 original, reopened; + assert(original.plan_layout(false, manifest.image_size, payload_off, manifest.payload_size, false)); + assert(original.begin(bytes.size())); + assert(original.write(0, bytes.data(), leaves_off)); + assert(original.write(payload_off, manifest.payload, 1024)); + assert(original.write(leaves_off, manifest.leaves, 4)); + assert(original.write(bytes.size() - 5, MOTA_TRAILER, 5)); + original.checkpoint(); + OtaStoreFlashEsp32& active = reboot ? reopened : original; + OtaManager receiver; + receiver.begin(EXP_TARGET_ID, send, nullptr); + receiver.set_fetch_store(&active); + if (reboot) { + assert(receiver.resumeStagedExplicit(mid, EXP_TARGET_ID)); + receiver.loop(); + assert(receiver.fetchState() == OtaManager::FETCHING); + assert(receiver.blocksHave() == 1); + } + for (uint32_t i = 1; i < manifest.block_count; ++i) { + const uint32_t n = std::min(1024u, manifest.payload_size - i * 1024u); + assert(active.write(payload_off + i * 1024u, manifest.payload + i * 1024u, n)); + assert(active.write(leaves_off + i * 4u, manifest.leaves + i * 4u, 4)); + } + assert(active.finalize()); + receiver.reset_session(); + assert(receiver.resumeStagedExplicit(mid, EXP_TARGET_ID)); + receiver.loop(); + assert(receiver.fetchState() == OtaManager::COMPLETE); + std::vector output(bytes.size()); + assert(active.read(0, output.data(), output.size())); + assert(output == bytes); +} + +int main(int argc, char** argv) { + const int scenario = argc > 1 ? atoi(argv[1]) : 0; + if (scenario < 5) resume_checks_file_envelope(scenario); + else if (scenario < 9) esp32_resume_preserves_container_trailer((scenario & 1) == 0, scenario >= 7); + else if (scenario < 13) esp32_resume_preserves_container_trailer((scenario & 1) == 0, false, + scenario < 11 ? 2278 : 4098); + else dirty_partial_checkpoint_can_finish(scenario == 13); +} diff --git a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h index 2b4046cb..bf3127f8 100644 --- a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h +++ b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h @@ -29,6 +29,7 @@ class MemoryFS { public: std::map> files; bool fail_write = false; + int fail_write_after = -1; bool fail_read_open = false; int fail_read_after = -1; bool fail_remove = false; @@ -71,6 +72,10 @@ inline int File::read(uint8_t* data, size_t size) { inline size_t File::write(const uint8_t* data, size_t size) { if (!fs_ || fs_->fail_write) return 0; auto& bytes = fs_->files[path_]; + if (fs_->fail_write_after >= 0) { + if (bytes.size() >= static_cast(fs_->fail_write_after)) return 0; + size = std::min(size, static_cast(fs_->fail_write_after) - bytes.size()); + } bytes.insert(bytes.end(), data, data + size); return size; } #define FILESYSTEM MemoryFS diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py new file mode 100644 index 00000000..23d448fc --- /dev/null +++ b/test/test_companion_preferences_transaction.py @@ -0,0 +1,192 @@ +"""Exercise real Companion preferences saves and startup recovery with I/O faults.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include "ContactFileTransaction.h" +#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#endif +#include "examples/companion_radio/NodePrefs.h" +#define MESH_DEBUG_PRINTLN(...) ((void)0) +struct DataStore { + MemoryFS fs; + FILESYSTEM* _fs=&fs; + bool _prefs_load_incomplete=false, _primary_storage_unavailable=false; + bool _secondary_authority_unknown=false; + DataStore(){ +#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM) + fs.rename_replaces=true; +#endif + } + bool loadPrefs(CompanionNodePrefs&, double&, double&); + bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&); + bool savePrefs(const CompanionNodePrefs&, double, double); +}; +File openRead(FILESYSTEM* fs,const char* path){return fs->open(path,"r");} +File openWrite(FILESYSTEM* fs,const char* path){return fs->open(path,"w");} +bool contactPathPresence(FILESYSTEM* fs,const char* path,bool& present){ + present=fs->exists(path);return true; +} +@METHODS@ +int main(int argc,char** argv){ + const int scenario=argc>1?atoi(argv[1]):0; + DataStore store;CompanionNodePrefs original; + strcpy(original.node_name,"durable node");original.freq=910.525f; + original.ble_pin=876543;original.bluetooth_stealth_mode=2; + original.gps_interval=123;original.sf=7;original.cr=7;original.bw=62.5f; + assert(store.savePrefs(original,47.1,-122.2)); + const auto disk=store.fs.files["/new_prefs"]; + CompanionNodePrefs changed=original;changed.freq=910.1f; + if(scenario==0){ + store.fs.fail_write=true; + assert(!store.savePrefs(changed,42.3,-121.2)); + assert(store.fs.files["/new_prefs"]==disk); + store.fs.fail_write=false;store.fs.fail_write_after=17; + assert(!store.savePrefs(changed,42.3,-121.2)); + assert(store.fs.files["/new_prefs"]==disk); + store.fs.fail_write_after=-1; +#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM) + const int rename_steps[]={1}; +#else + const int rename_steps[]={1,2}; +#endif + for(int fail_step : rename_steps){ + store.fs.fail_rename=fail_step; + assert(!store.savePrefs(changed,42.3,-121.2)); + assert(store.fs.files["/new_prefs"]==disk); + } + assert(store.savePrefs(changed,42.3,-121.2)); + CompanionNodePrefs loaded;double lat=0,lon=0; + assert(store.loadPrefs(loaded,lat,lon)); + assert(loaded.freq==changed.freq&&loaded.ble_pin==original.ble_pin); + assert(lat==42.3&&lon==-121.2); + } else if(scenario==1){ + for(int fault : {0,1,2,3,4}){ + for(const char* path : {"/new_prefs","/node_prefs"}){ + DataStore boot;boot.fs.files[path]=disk; + CompanionNodePrefs live;double lat=1,lon=2; + if(fault==0)boot.fs.fail_read_open=true; + if(fault==1)boot.fs.fail_read_after=0; + if(fault==2)boot.fs.fail_read_after=84; + if(fault==3)boot.fs.fail_read_after=214; + if(fault==4)boot.fs.files[path].resize(87); + const auto durable=boot.fs.files[path]; + assert(!boot.loadPrefs(live,lat,lon)); + assert(live.node_name[0]==0&&lat==1&&lon==2); + boot.fs.fail_read_after=-1;boot.fs.fail_read_open=false; + assert(!boot.loadPrefs(live,lat,lon)); // quarantine lasts this boot + assert(!boot.savePrefs(live,lat,lon)); + assert(boot.fs.files[path]==durable); + } + } + DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0; + assert(fresh.loadPrefs(defaults,lat,lon)); + assert(fresh.savePrefs(defaults,lat,lon)); + } else if(scenario==2){ +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + store.fs.fail_rename_from={"/new_prefs.tmp","/new_prefs.bak"}; + assert(!store.savePrefs(changed,42.3,-121.2)); + assert(!store.fs.exists("/new_prefs")); + assert(store.fs.files["/new_prefs.bak"]==disk); + DataStore failed_boot;failed_boot.fs=store.fs; + CompanionNodePrefs loaded;double lat=0,lon=0; + assert(!failed_boot.loadPrefs(loaded,lat,lon)); + failed_boot.fs.fail_rename_from.clear(); + assert(!failed_boot.loadPrefs(loaded,lat,lon)); + assert(!failed_boot.savePrefs(changed,42.3,-121.2)); + assert(failed_boot.fs.files["/new_prefs.bak"]==disk); + DataStore reboot;reboot.fs=failed_boot.fs; + assert(reboot.loadPrefs(loaded,lat,lon)); + assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin); + assert(lat==47.1&&lon==-122.2); +#endif + } else if(scenario==3){ + // Copy the real adapter-bearing preferences from dirty storage. This + // catches reads of uninitialized serializer bools under UBSan reliably. + alignas(CompanionNodePrefs) unsigned char memory[sizeof(CompanionNodePrefs)]; + memset(memory,0xa5,sizeof(memory)); + auto* prefs=new(memory)CompanionNodePrefs(); + CompanionNodePrefs snapshot=*prefs; + assert(!snapshot.isDirty()); + prefs->~CompanionNodePrefs(); + } else if(scenario==4){ + DataStore legacy;legacy.fs.files["/node_prefs"]=disk; + legacy.fs.fail_write_after=17; + CompanionNodePrefs loaded;double lat=0,lon=0; + assert(legacy.loadPrefs(loaded,lat,lon)); + assert(loaded.freq==original.freq); + assert(legacy.fs.files["/node_prefs"]==disk&&!legacy.fs.exists("/new_prefs")); + legacy.fs.fail_write_after=-1; + assert(legacy.loadPrefs(loaded,lat,lon)); + assert(!legacy.fs.exists("/node_prefs")&&legacy.fs.files["/new_prefs"]==disk); + } +} +''' + + +class CompanionPreferencesTransactionTests(unittest.TestCase): + def test_real_preferences_io_failures(self): + source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text() + methods = '\n'.join(method(source, signature) for signature in ( + 'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', + 'bool DataStore::savePrefs(')) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + # Binary preference persistence never invokes JSON blob conversion. + # Satisfy those unrelated serializer links without crypto libraries. + (work / 'Utils.h').write_text('''#pragma once +#include +#include +namespace mesh { struct Utils { + static void printHex(Stream&,const uint8_t*,size_t){assert(false);} + static void fromHex(uint8_t*,size_t,const char*){assert(false);} +}; } +''') + (work / 'platform_shim.h').write_text('''#include +#include +inline char* utoa(unsigned int value,char* output,int base){ + if(base!=10)abort();sprintf(output,"%u",value);return output; +} +''') + transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text() + (work / 'ContactFileTransaction.h').write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + (work / 'test.cpp').write_text(HARNESS.replace('@METHODS@', methods)) + for platform in ('ESP32_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM', 'NRF52_PLATFORM'): + with self.subTest(platform=platform): + binary = work / 'test' + build = subprocess.run(['g++', '-std=c++17', + '-D'+platform+'=1', '-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie', '-include', str(work / 'platform_shim.h'), + '-I', str(work), + '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'), + '-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), + str(work / 'test.cpp'), + str(ROOT / 'src/helpers/ConfigSerializer.cpp'), + str(ROOT / 'src/helpers/DynamicConfigSerializer.cpp'), + str(ROOT / 'src/helpers/CommonRadioPrefs.cpp'), + str(ROOT / 'src/helpers/TxtDataHelpers.cpp'), + '-o', str(binary)], capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + for scenario in range(5): + with self.subTest(scenario=scenario): + run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_ota_install_preflight.py b/test/test_ota_install_preflight.py new file mode 100644 index 00000000..195a16fa --- /dev/null +++ b/test/test_ota_install_preflight.py @@ -0,0 +1,144 @@ +"""Exercise production install policy before handing a store to the applier.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaInstallPreflightTest(unittest.TestCase): + def test_storage_failures_cannot_skip_hardware_and_auto_install_policy(self): + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + production = method(context, "bool hwMatches(") + "\n" + method( + context, "bool apply_fetched_impl(") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "preflight.h").write_text(production) + source = path / "test.cpp" + source.write_text(r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include "test_ota/mota_vectors.h" +using namespace mesh::ota; +struct Store { + std::vector bytes{MOTA_VEC, MOTA_VEC + MOTA_VEC_LEN}; + mutable int fail_at = -1; + uint32_t staged_size() const { return bytes.size(); } + bool read(uint32_t off, uint8_t* out, uint32_t len) const { + if (int(off) == fail_at) { fail_at = -1; return false; } + if (off > bytes.size() || len > bytes.size() - off) return false; + memcpy(out, bytes.data() + off, len); return true; + } + const uint8_t* data() const { return bytes.data(); } +}; +static int apply_calls = 0; +static bool apply_ok = true, self_ok = true; +static uint32_t running_version = EXP_FW_VERSION - 1; +static bool ota_self_firmware(SelfFwInfo& self) { + self.valid = self_ok; self.fw_version = running_version; return self_ok; +} +// Hardware apply is the boundary under test: a failed preflight must never +// reach it, regardless of whether a second store read might now succeed. +static bool ota_apply_detools_mota(Store&, int, int&, char*) { + ++apply_calls; return apply_ok; +} +static bool ota_apply_mota_nrf52(Store&, int, int&, char*) { + ++apply_calls; return apply_ok; +} +struct Context { + Store fetch_store; + int allow = 0, apply_st = 0; + bool fetch_to_folder = false, apply_pending = false, bootloader_apply_pending = false; + char hw_id[33] = "TESTHW"; +#include "preflight.h" +}; +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = atoi(argv[1]); + Context context; + char reply[160] = {}; + bool trusted_auto = false; + bool expected = false; + if (scenario == 0) { + strcpy(context.hw_id, "WRONG_BOARD"); context.fetch_store.fail_at = 0; + } else if (scenario == 1) { + context.fetch_store.fail_at = 0; trusted_auto = true; // unsigned package + } else if (scenario == 2) { + context.fetch_store.bytes[9] |= MFLAG_SIGNED; + running_version = EXP_FW_VERSION; trusted_auto = true; context.fetch_store.fail_at = 0; + } else if (scenario == 3) { + context.fetch_store.bytes.resize(8); + } else if (scenario == 4) { + context.fetch_store.bytes[0] ^= 1; + } else if (scenario == 5) { + context.fetch_store.bytes[4] ^= 1; + } else if (scenario == 6) { + context.fetch_store.fail_at = 8; + } else if (scenario == 7) { + strcpy(context.hw_id, "WRONG_BOARD"); + } else if (scenario == 8) { + trusted_auto = true; // unsigned is never eligible for auto-install + } else if (scenario == 9) { + context.fetch_store.bytes[9] |= MFLAG_SIGNED; + trusted_auto = true; running_version = EXP_FW_VERSION; + } else if (scenario == 10) { + context.fetch_store.bytes[9] |= MFLAG_SIGNED; + trusted_auto = true; self_ok = false; + } else if (scenario == 11) { + context.fetch_store.bytes[9] |= MFLAG_SIGNED; + trusted_auto = true; expected = true; + } else if (scenario == 12) { + running_version = EXP_FW_VERSION + 1; expected = true; // manual override retained + } else if (scenario == 13) { + context.fetch_to_folder = true; + } else if (scenario == 14) { + context.hw_id[0] = 0; expected = true; // existing unknown-hardware policy + } else if (scenario == 15) { + apply_ok = false; + } + assert(context.apply_fetched_impl(nullptr, trusted_auto, reply) == expected); + assert(context.apply_pending == expected); + assert(!context.bootloader_apply_pending); + assert(apply_calls == (expected || scenario == 15 ? 1 : 0)); + if (scenario == 0) { + // Once I/O recovers, retry still evaluates hardware compatibility. + assert(!context.apply_fetched_impl(nullptr, false, reply)); + assert(apply_calls == 0 && strstr(reply, "incompatible hardware")); + strcpy(context.hw_id, "TESTHW"); + assert(context.apply_fetched_impl(nullptr, false, reply)); + assert(apply_calls == 1 && context.apply_pending); + } +} +''') + for platform in ("esp32", "nrf52_qspi"): + defines = (["-DESP32_PLATFORM", "-DOTA_FLASH_STORE"] + if platform == "esp32" else ["-DNRF52_PLATFORM", "-DOTA_QSPI_STORE"]) + binary = path / platform + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + result = subprocess.run([ + "c++", "-std=c++17", "-ffunction-sections", "-fdata-sections", + "-Wl,--gc-sections", *flags, *defines, + "-I", str(ROOT / "src"), "-I", str(ROOT / "test"), + "-I", str(ROOT / "test/mocks"), + str(source), str(ROOT / "src/helpers/ota/MotaContainer.cpp"), + "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + for scenario in range(16): + with self.subTest(platform=platform, scenario=scenario): + result = subprocess.run([str(binary), str(scenario)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_store_flash_nrf52_hybrid.py b/test/test_ota_store_flash_nrf52_hybrid.py index a3e7cad0..7f2a0d7e 100644 --- a/test/test_ota_store_flash_nrf52_hybrid.py +++ b/test/test_ota_store_flash_nrf52_hybrid.py @@ -51,10 +51,10 @@ class OtaStoreFlashNrf52HybridTest(unittest.TestCase): ) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) self.assertIn( - "4 OtaStoreFlashNrf52 hybrid lifecycle checks passed", + "7 OtaStoreFlashNrf52 hybrid lifecycle checks passed", checked.stdout, ) - self.assertEqual(checked.stdout.count("PASS:"), 4) + self.assertEqual(checked.stdout.count("PASS:"), 7) if __name__ == "__main__": diff --git a/test/test_ota_store_resume.py b/test/test_ota_store_resume.py new file mode 100644 index 00000000..af237bc2 --- /dev/null +++ b/test/test_ota_store_resume.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Run OTA staged-file validation and real ESP32 flash resume on the host.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/ota_store_resume" + + +class OtaStoreResumeTest(unittest.TestCase): + def test_container_envelope_and_flash_resume(self): + with tempfile.TemporaryDirectory(prefix="ota-resume-") as directory: + path = Path(directory) + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + tinf = path / "tinf.o" + subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags, + "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), + "-o", str(tinf)], check=True) + sources = ["OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp", + "MerkleTree.cpp", "OtaDeflate.cpp", "OtaStoreFlashEsp32.cpp"] + binary = path / "resume.exe" + result = subprocess.run([ + "c++", "-std=c++17", *flags, "-DENABLE_OTA=1", "-DESP32_PLATFORM=1", + "-DOTA_FLASH_STORE=1", "-I", str(FIXTURE / "mocks"), + "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(FIXTURE / "test.cpp"), + *[str(ROOT / "src/helpers/ota" / name) for name in sources], + str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for scenario in range(15): + with self.subTest(scenario=scenario): + subprocess.run([str(binary), str(scenario)], check=True, timeout=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_reply_tx_integration.py b/test/test_reply_tx_integration.py index b81e7634..846a4596 100644 --- a/test/test_reply_tx_integration.py +++ b/test/test_reply_tx_integration.py @@ -12,6 +12,223 @@ ROOT = Path(__file__).resolve().parents[1] class ReplyTxIntegrationTest(unittest.TestCase): + def test_deferred_responses_keep_the_request_profile(self): + mesh_source = (ROOT / 'src/Mesh.cpp').read_text() + dispatcher = (ROOT / 'src/Dispatcher.cpp').read_text() + harness = r''' +#include +#include +#include +#include +#include +#include +#include "mota_vectors.h" +namespace mesh { +namespace ota { +struct OtaContext { OtaManager& manager; }; +static OtaContext* active; +OtaContext* ota_context_if_active() { return active; } +OtaContext& ota_ctx() { return *active; } +} +struct Manager { + int free=40; + int getFreeCount() { return free; } +}; +static int queuedPacedOtaResponses(Manager*) { return 0; } +static constexpr int OTA_EGRESS_QUEUE_CREDIT=4, OTA_EGRESS_MIN_FREE=4; +@PACED@ +struct Radio { + RadioProfiles config; + const RadioProfiles* profiles() const { return &config; } +}; +struct Mesh { + Manager manager; Manager* _mgr=&manager; + Radio radio; Radio* _radio=&radio; + ota::OtaManager ota_manager; + ota::OtaContext context{ota_manager}; + Packet packet; + std::vector masks; + uint8_t receive_profile=0; + bool receiving=false; + Mesh() { ota::active=&context; } + bool isAnyTempRadioActive() { return true; } + Packet* createOtaPacket(const uint8_t*,uint16_t) { + packet=Packet(); packet.header=PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT; + packet.radio_profile=receive_profile; packet.radio_local=!receiving; + packet.radio_generation=receiving ? radio.config.generation[receive_profile] : 0; + return &packet; + } + void releasePacket(Packet*) {} + bool sendOtaFlood(Packet* p) { masks.push_back(getTransmitProfileMask(p)); return true; } + uint8_t getTransmitProfileMask(const Packet*) const; + static bool otaSendAdapter(void*,const uint8_t*,uint16_t,bool); + void service() { @SERVICE@ } +}; +@MASK@ +@ADAPTER@ +} +int main() { + mesh::Mesh node; + node.radio.config.primary_temporary=true; + node.radio.config.secondary_temporary=true; + node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx; + node.radio.config.cross=mesh::RadioCrossMode::Off; + node.ota_manager.begin(0,node.otaSendAdapter,&node); + assert(node.ota_manager.serve(MOTA_VEC,sizeof(MOTA_VEC))); + mesh::ota::ReqMsg request{}; + memcpy(request.manifest_id,EXP_MERKLE_ROOT,4); + request.block_idx=0; request.want_mask=1; + uint8_t wire[MAX_PACKET_PAYLOAD]; + const auto length=mesh::ota::encode_req(wire,sizeof(wire),request); + node.receiving=true; node.receive_profile=1; + mesh::ota::OtaReplyRoute route; + route.profile=1; route.generation=node.radio.config.generation[1]; + assert(node.ota_manager.on_message(wire,length,route)); + assert(node.masks.empty()); // DATA is deliberately deferred until later + node.receiving=false; node.receive_profile=0; + node.manager.free=4; node.service(); // backpressure must retain origin + assert(node.ota_manager.pendingServeJobs()==1 && node.masks.empty()); + node.manager.free=40; + node.service(); + assert(node.masks.size()==1 && node.masks[0]==2); + assert(node.packet.radio_profile==1 && !node.packet.radio_local); + assert(node.packet.radio_generation==route.generation); + assert(node.ota_manager.replyRoute().profile==0xFF); + + // Infrastructure reply-both still overrides AUTO origin selection, including + // a receive-only secondary when the configured reply policy forces it. + node.ota_manager.clearPendingEgress(); node.masks.clear(); + node.radio.config.reply_tx=mesh::RADIO_TX_BOTH; + node.radio.config.reply_force=true; + node.radio.config.secondary.mode=mesh::RadioProfileMode::Rx; + assert(node.ota_manager.on_message(wire,length,route)); + node.service(); + assert(node.masks.size()==1 && node.masks[0]==3); + node.radio.config.reply_tx=mesh::RADIO_TX_AUTO; + node.radio.config.reply_force=false; + node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx; + + // Identical block requests heard on separate radio domains must not merge. + node.ota_manager.clearPendingEgress(); node.masks.clear(); + assert(node.ota_manager.on_message(wire,length,route)); + auto primary=route; primary.profile=0; + assert(node.ota_manager.on_message(wire,length,primary)); + assert(node.ota_manager.pendingServeJobs()==2); + node.service(); + mesh::ota::ReqProofMsg proof{}; + memcpy(proof.manifest_id,EXP_MERKLE_ROOT,4); proof.block_idx=0; + uint8_t proof_wire[MAX_PACKET_PAYLOAD]; + const auto proof_len=mesh::ota::encode_req_proof(proof_wire,sizeof(proof_wire),proof); + assert(node.ota_manager.on_message(proof_wire,proof_len,primary)); + assert(node.ota_manager.pendingServeJobs()==2); // primary proof merges only with primary DATA + assert(node.ota_manager.on_message(proof_wire,proof_len,route)); + node.service(); // secondary proof + node.service(); // primary DATA + node.service(); // primary proof + assert(node.ota_manager.pendingServeJobs()==0); + assert((node.masks==std::vector{2,2,1,1})); + + // Reconfiguration retires the stale job even when it was waiting on proof. + node.masks.clear(); + assert(node.ota_manager.on_message(wire,length,route)); + node.service(); + const auto sent=node.ota_manager.packetsSent(); + ++node.radio.config.generation[1]; + auto replacement=route; ++replacement.generation; + assert(node.ota_manager.on_message(wire,length,replacement)); + assert(node.ota_manager.pendingServeJobs()==2); + node.service(); + assert(node.ota_manager.pendingServeJobs()==1); + assert(node.ota_manager.packetsSent()==sent); // discarded is not reported as sent + node.service(); + assert(node.masks.size()==2 && node.packet.radio_generation==replacement.generation); + node.radio.config.secondary.mode=mesh::RadioProfileMode::Off; + node.service(); + assert(node.ota_manager.pendingServeJobs()==0); + node.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx; + + // The paced manifest queue also separates origins and expires stale sessions. + node.masks.clear(); + mesh::ota::GetManifestMsg manifest{}; + memcpy(manifest.manifest_id,EXP_MERKLE_ROOT,4); manifest.want_mask=1; + uint8_t manifest_wire[MAX_PACKET_PAYLOAD]; + const auto manifest_len=mesh::ota::encode_get_manifest(manifest_wire,sizeof(manifest_wire),manifest); + assert(node.ota_manager.on_message(manifest_wire,manifest_len,route)); + assert(node.ota_manager.on_message(manifest_wire,manifest_len,replacement)); + assert(node.ota_manager.on_message(manifest_wire,manifest_len,primary)); + assert(node.ota_manager.pendingManifestJobs()==3); + node.service(); // stale route is discarded even before its pacing deadline + assert(node.ota_manager.pendingManifestJobs()==2 && node.masks.empty()); + node.ota_manager.set_clock(1000000); + node.service(); node.service(); + assert((node.masks==std::vector{2,1})); + assert(node.ota_manager.pendingManifestJobs()==0); + + // Changing primary also retires its old request before any fragment is sent. + node.masks.clear(); + assert(node.ota_manager.on_message(wire,length,primary)); + ++node.radio.config.generation[0]; + ++primary.generation; + assert(node.ota_manager.on_message(wire,length,primary)); + node.service(); + assert(node.masks.empty() && node.ota_manager.pendingServeJobs()==1); + node.service(); + assert(node.masks.size()==1 && node.masks[0]==1); + assert(node.packet.radio_generation==primary.generation); + node.ota_manager.clearPendingEgress(); + + // LEAVES remains an immediate response and inherits the live receive scope. + // Deferred affinity cannot bleed into that callback or a later local send. + node.masks.clear(); + mesh::ota::GetLeavesMsg leaves{}; + memcpy(leaves.manifest_id,EXP_MERKLE_ROOT,4); leaves.want_mask=1; + uint8_t leaves_wire[MAX_PACKET_PAYLOAD]; + const auto leaves_len=mesh::ota::encode_get_leaves(leaves_wire,sizeof(leaves_wire),leaves); + node.receiving=true; node.receive_profile=1; + node.ota_manager.on_message(leaves_wire,leaves_len,replacement); + assert(node.masks.size()==1 && node.masks[0]==2); + assert(node.packet.radio_reply && !node.packet.radio_local); + node.receiving=false; node.receive_profile=0; + const uint8_t data=mesh::ota::OTA_DATA; + assert(node.otaSendAdapter(&node,&data,1,false)); + assert(node.masks.size()==2 && node.masks[1]==1 && node.packet.radio_local); + + // A regular primary and temporary secondary stay isolated unless cross is on. + node.radio.config.primary_temporary=false; + node.masks.clear(); + assert(node.ota_manager.on_message(wire,length,replacement)); + node.service(); + assert(node.masks.size()==1 && node.masks[0]==2); + node.ota_manager.clearPendingEgress(); + node.radio.config.cross=mesh::RadioCrossMode::On; + assert(node.ota_manager.on_message(wire,length,replacement)); + node.service(); + assert(node.masks.size()==2 && node.masks[1]==3); +} +''' + harness = harness.replace('@PACED@', method(mesh_source, 'static bool isPacedOtaResponse(')) + harness = harness.replace('@ADAPTER@', method(mesh_source, 'bool Mesh::otaSendAdapter(')) + harness = harness.replace('@MASK@', method(dispatcher, 'uint8_t Dispatcher::getTransmitProfileMask(') + .replace('Dispatcher::', 'Mesh::')) + start = mesh_source.index('ota::ota_ctx().manager.serviceEgress(') + service = mesh_source[start:mesh_source.index('}, this);', start) + len('}, this);')] + harness = harness.replace('@SERVICE@', service) + self.assertIn('context->manager.on_message(pkt->payload, pkt->payload_len, route)', mesh_source) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + source = path / 'routing.cpp'; source.write_text(harness) + binary = path / 'routing' + sources = ['OtaManager.cpp', 'OtaProtocol.cpp', 'MotaContainer.cpp', 'MerkleTree.cpp'] + result = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-I', str(ROOT / 'src'), '-I', str(ROOT / 'test/mocks'), + '-I', str(ROOT / 'test/test_ota'), str(source), + *[str(ROOT / 'src/helpers/ota' / name) for name in sources], + str(ROOT / 'src/Utils.cpp'), str(ROOT / 'src/Packet.cpp'), '-o', str(binary)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + def test_async_ota_adapter_marks_only_response_types_and_retains_backpressure(self): source = (ROOT / 'src/Mesh.cpp').read_text() adapter = method(source, 'bool Mesh::otaSendAdapter(') @@ -22,7 +239,16 @@ class ReplyTxIntegrationTest(unittest.TestCase): #include #include namespace mesh { -struct Packet { bool radio_reply=false; }; +struct Packet { + bool radio_reply=false, radio_local=true; + uint8_t radio_profile=0, radio_origin=0; + uint32_t radio_generation=0, radio_origin_generation=0; +}; +namespace ota { +struct Route { uint32_t generation=0; uint8_t profile=0xFF; }; +struct Context { struct Manager { Route replyRoute() const { return {}; } } manager; }; +Context* ota_context_if_active() { return nullptr; } +} struct Manager { int free=10; int getFreeCount() { return free; } }; static int queued; static int queuedPacedOtaResponses(Manager*) { return queued; }