diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 4b9fe7174..a57bc1671 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -280,6 +280,8 @@ jobs: python3 -B tools/mota/test_bootloader_migration.py -v python3 -B tools/mota/test_bootloader_migration_runtime.py -v python3 -B test/test_bootloader_update_cli.py -v + python3 -B test/test_bootloader_migration_role_persistence.py -v + python3 -B test/test_bootloader_migration_store.py -v - name: Verify old-firmware compatibility in both OTA launchers run: python3 -B -m unittest discover -s tools/lora_ota -p test_lora_ota_bootloader_version.py -v diff --git a/docs/nrf52_embedded_bootloader.md b/docs/nrf52_embedded_bootloader.md index 7e1f67671..c2433f68d 100644 --- a/docs/nrf52_embedded_bootloader.md +++ b/docs/nrf52_embedded_bootloader.md @@ -6,9 +6,12 @@ An opt-in Repeater or Room Server build can carry its application firmware and the exact board's [OTAFIX 2.4.12 bootloader](https://github.com/mikecarper/Adafruit_nRF52_Bootloader_OTAFIX/releases/tag/v0.11.0-OTAFIX2.4.12) -in one application image. Install that application first, then activate the -bootloader replacement with an authenticated admin command. Booting the image -does not flash the bootloader automatically. +in one application image. After installing that application, it checks the +bootloader once at 70 seconds after startup and updates it when needed. The +check waits for idle radio and OTA activity, and skips a valid matching +bootloader at the same or newer version. Recovery and unrecognized bootloaders +are excluded from automatic installation. Ordinary firmware has no automatic +bootloader update. This follows the internal-flash bridge in [MeshEnvy firmware PR #2](https://github.com/MeshEnvy/meshcore-firmware/pull/2). @@ -74,8 +77,10 @@ P1 image, not a recovery image. updater. An internal-only legacy receiver needs a delta from its exact installed application; this image does not give a stock bootloader its first LoRa update capability. -2. After the new application is running, use the local CLI or authenticated - remote admin CLI: +2. Leave the new application running for at least 70 seconds. It checks once + when radio and OTA activity are idle, then waits for the usual 1.5-second + grace and persistence checks before replacement and reboot. To start sooner, + use the local CLI or authenticated remote admin CLI: ```text ota bootloader update @@ -92,6 +97,28 @@ P1 image, not a recovery image. `ota bootloader embedded install HASH16` command remains available with the confirmation shown by `status`. +Automatic installation is limited to one attempt per firmware image. A saved +40-byte record in `/bl_auto` contains the full SHA-256 of the running image, +including its version trailer. It survives reboots and power cycles. Flashing +a different firmware image permits a new check; recovery bootloaders remain +excluded. Older recovery images without a recognizable normal bootloader label +also skip automatic installation. + +The record is committed before inspecting the bootloader, so an interrupted or +failed check also requires manual retry or different firmware. Missing persistent +storage, corrupt records, or an unfinished file transaction block automatic updates. +Manual `ota bootloader update` remains available under its existing identity, +version and persistence checks. The first lock write changes the filesystem +audit hash; it does not change the application or SoftDevice. + +The retained reboot guard still covers the initial checks and copy. A guard +from the previous firmware does not prevent a verified different image from +qualifying again. Status reports `auto=locked for this firmware`, `auto=recovery`, +`auto=unrecognized`, `auto=storage unavailable`, or `auto=blocked` when relevant. +These guards prevent repeated update attempts; they cannot repair a bootloader +that fails before the application starts. Erasing the filesystem also erases +the saved firmware lock. + The bridge validates the live application, candidate hash, installed identity, chip/layout, MBR addresses, watchdog and flash protection before copying. A protected region is refused; the bridge does not unlock it. Existing staged diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 0bb4aea4f..4e3e6d3eb 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1323,6 +1323,12 @@ public: bool handleHostCliSerialReply(const char* command, char* reply); #endif void loop(); +#if defined(OTA_BOOTLOADER_MIGRATION) + bool prepareForOtaReboot() override { + // Let the normal ACL save finish, including its failure backoff. + return dirty_contacts_expiry == 0; + } +#endif bool canRecoverUsbLogging() const { return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply() && !saved_radio_apply_pending diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index dbc90de38..6271a98a1 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -19,6 +19,9 @@ #endif #include "MyMesh.h" +#if defined(OTA_BOOTLOADER_MIGRATION) +#include +#endif #if defined(MESH_SOAK_DIAGNOSTICS) #include "../../tools/hil/S3SoakDiagnostics.h" #endif @@ -377,6 +380,9 @@ void setup() { mesh::hilStartupTrace("mesh_begin"); the_mesh.begin(fs); mesh::hilStartupTrace("mesh_ready"); +#if defined(OTA_BOOTLOADER_MIGRATION) + mesh::ota::bootloader_migration_begin(volatile_primary_fs ? nullptr : fs); +#endif mesh::hilStartupTrace("watchdog_begin"); #if defined(NRF52_PLATFORM) diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 08cbf1721..a2722510b 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -654,6 +654,12 @@ public: return mesh::wireless::WiFi; } void loop(); +#if defined(OTA_BOOTLOADER_MIGRATION) + bool prepareForOtaReboot() override { + // Let the normal ACL save finish, including its failure backoff. + return dirty_contacts_expiry == 0; + } +#endif bool canRecoverUsbLogging() const { return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply() && !saved_radio_apply_pending diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index b4809e5af..4d0ce08ca 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -18,6 +18,9 @@ #endif #include "MyMesh.h" +#if defined(OTA_BOOTLOADER_MIGRATION) +#include +#endif #if defined(ESP32_PLATFORM) #include @@ -253,6 +256,9 @@ void setup() { sensors.begin(); the_mesh.begin(fs); +#if defined(OTA_BOOTLOADER_MIGRATION) + mesh::ota::bootloader_migration_begin(volatile_primary_fs ? nullptr : fs); +#endif #if defined(NRF52_PLATFORM) mesh::loadUsbLoggingWatchdog(fs, !volatile_primary_fs, diff --git a/scripts/qualify_nrf52_embedded_bootloader.py b/scripts/qualify_nrf52_embedded_bootloader.py index 216a74a13..76fcce4a4 100644 --- a/scripts/qualify_nrf52_embedded_bootloader.py +++ b/scripts/qualify_nrf52_embedded_bootloader.py @@ -52,6 +52,7 @@ def qualify(directory, manifest_path): "legacy_sha256": metadata.get("legacy_sha256"), "app_offset": offset, "image_bytes": BOOT_SIZE, "activation": "ota bootloader update", + "automatic_delay_seconds": 70, } manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") return manifest["embedded_bootloader"] diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 284c462e3..235fc61da 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -481,13 +481,14 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } #if defined(ENABLE_OTA) #if defined(OTA_BOOTLOADER_MIGRATION) - if (ota::bootloader_migration_pending()) { + if (ota::bootloader_migration_service_due()) { // This bridge owns no OTA workspace. Service it even after a dynamic // context has been released, and never allocate one just to poll it. const ota::OtaContext* context = ota::ota_context_if_active(); const bool ota_idle = !context || context->embeddedBootloaderIdle(); const bool outbound_idle = !hasPendingRadioWork(); - const bool ready = outbound_idle && ota_idle && prepareForOtaReboot(); + const bool ready = outbound_idle && ota_idle && + (!ota::bootloader_migration_pending() || prepareForOtaReboot()); ota::bootloader_migration_tick(ready, ota_idle); } #endif diff --git a/src/helpers/ota/BootloaderMigration.cpp b/src/helpers/ota/BootloaderMigration.cpp index aaaa0ee8f..1675ac3e7 100644 --- a/src/helpers/ota/BootloaderMigration.cpp +++ b/src/helpers/ota/BootloaderMigration.cpp @@ -24,10 +24,20 @@ static_assert(migration::ACL_READ_DENIED == ACL_ACL_PERM_READ_Msk, "ACL read per static_assert(migration::ACL_WRITE_DENIED == ACL_ACL_PERM_WRITE_Msk, "ACL write permission bit"); namespace { bool pending = false; +bool automatic_attempted = false; +bool automatic_blocked = false; +constexpr uint32_t AUTOMATIC_DELAY_MS = 70000u; +constexpr uint8_t ATTEMPT_MARKER = 0xD0; uint32_t armed_at = 0; uint8_t last_result = 0; bool result_is_current = false; bool already_installed = false; +FILESYSTEM *state_fs = nullptr; +uint8_t firmware_hash[32] = {}; +MigrationAutoLock automatic_lock = MigrationAutoLock::Unavailable; +bool firmware_identified = false; +migration::InstalledVariant automatic_variant = migration::InstalledVariant::Unknown; +bool loader_excluded = false; // The pinned framework's historical SVC wrapper has no memory clobber or IPA // barrier. The MBR reads this pointed-to command after exception entry, which @@ -77,19 +87,67 @@ bool installed_up_to_date() { migration_softdevice_fwid); } -uint8_t retained_result() { - if (result_is_current) return last_result; +bool installed_allows_automatic() { + const uint8_t *boot = reinterpret_cast(migration::BOOT_BASE); + OtaBootloaderIdentity installed; + const bool valid = ota_bootloader_identity_from_image(boot, migration::BOOT_SIZE, installed); + automatic_variant = migration::installed_variant(boot, migration::BOOT_SIZE, + valid ? installed.boot_version : 0); + if (automatic_variant == migration::InstalledVariant::Recovery) return false; + if (valid && automatic_variant == migration::InstalledVariant::Normal && + migration::installed_matches_resource(installed, migration_board_id, migration_device_name, + mota_nrf52_app_base(), migration_softdevice_fwid)) + return true; + // Only the explicitly pinned legacy bridge may omit modern identity/text. + if (!migration_has_legacy) return false; + uint8_t digest[32]; + hash_region(migration::BOOT_BASE, migration::BOOT_SIZE, digest); + return memcmp(digest, migration_legacy_sha256, sizeof(digest)) == 0; +} + +bool lock_automatic_for_firmware() { + if (!firmware_identified || !bootloader_migration_lock_auto(state_fs, firmware_hash)) { + automatic_lock = MigrationAutoLock::Unavailable; + return false; + } + automatic_lock = MigrationAutoLock::Locked; + return true; +} + +bool read_reboot_marker(uint32_t &result) { uint8_t enabled = 0; - uint32_t result = 0; - if (sd_softdevice_is_enabled(&enabled) != NRF_SUCCESS) return 0; + if (sd_softdevice_is_enabled(&enabled) != NRF_SUCCESS) return false; if (enabled) { - if (sd_power_gpregret_get(0, &result) != NRF_SUCCESS) return 0; + if (sd_power_gpregret_get(0, &result) != NRF_SUCCESS) return false; } else { result = NRF_POWER->GPREGRET; } + return true; +} + +uint8_t retained_result() { + if (result_is_current) return last_result; + uint32_t result = 0; + if (!read_reboot_marker(result)) return 0; return result >= 0xC0 && result <= 0xCF ? result & 15 : last_result; } +bool write_reboot_marker(uint8_t marker) { + uint8_t enabled = 0; + if (sd_softdevice_is_enabled(&enabled) != NRF_SUCCESS) return false; + if (enabled) { + uint32_t current = 0; + if (sd_power_gpregret_get(0, ¤t) != NRF_SUCCESS) return false; + // Arming after the automatic image check must preserve its existing + // guard, rather than creating an unguarded gap between clear and set. + if (current == marker) return true; + if (sd_power_gpregret_clr(0, 0xFF) != NRF_SUCCESS) return false; + return marker == 0 || sd_power_gpregret_set(0, marker) == NRF_SUCCESS; + } + NRF_POWER->GPREGRET = marker; + return NRF_POWER->GPREGRET == marker; +} + // Called only after the operator reply has drained and SoftDevice is stopped. // GPREGRET (not GPREGRET2) survives this legacy loader's diagnostic writes. void fail_and_reset(uint8_t result) { @@ -174,7 +232,10 @@ void install() { if (NRF_WDT->RREN & (1u << i)) NRF_WDT->RR[i] = 0x6E524635u; } } - NRF_POWER->GPREGRET = 0; + // The installed loader preserves our non-DFU marker. If COPY_BL or its + // preflight is interrupted, the next application boot will stay available + // instead of automatically trying the same operation again. + NRF_POWER->GPREGRET = ATTEMPT_MARKER; NRF_POWER->GPREGRET2 = 0; sd_mbr_command_t command = {}; command.command = SD_MBR_COMMAND_COPY_BL; @@ -187,16 +248,40 @@ void install() { } } // namespace +void bootloader_migration_begin(FILESYSTEM *fs) { + state_fs = fs; + firmware_identified = false; + automatic_lock = MigrationAutoLock::Unavailable; + SelfFwInfo app; + if (fs && ota_self_firmware(app) && app.valid) { + hash_region(mota_nrf52_app_base(), app.image_len, firmware_hash); + firmware_identified = true; + automatic_lock = bootloader_migration_auto_lock(fs, firmware_hash); + } + automatic_attempted = automatic_lock == MigrationAutoLock::Locked || + automatic_lock == MigrationAutoLock::Unavailable; + automatic_blocked = automatic_attempted; +} + bool bootloader_migration_pending() { return pending; } +bool bootloader_migration_service_due() { + return pending || (!automatic_attempted && millis() >= AUTOMATIC_DELAY_MS); +} + void bootloader_migration_status(char *reply, size_t capacity) { char hash[17]; hex8(migration_candidate_sha256, hash); - snprintf(reply, capacity, "BL embedded 2.4.12: 40960 B; hash=%s; %s; last=%u", hash, + snprintf(reply, capacity, "BL embedded 2.4.12: 40960 B; hash=%s; %s; last=%u; auto=%s", hash, pending ? "armed" : already_installed ? "already installed" : - installed_up_to_date() ? "up to date" : "idle", retained_result()); + installed_up_to_date() ? "up to date" : "idle", retained_result(), + loader_excluded ? automatic_variant == migration::InstalledVariant::Recovery ? + "recovery" : "unrecognized" : + automatic_lock == MigrationAutoLock::Locked ? "locked for this firmware" : + automatic_lock == MigrationAutoLock::Unavailable ? "storage unavailable" : + automatic_blocked ? "blocked" : automatic_attempted ? "checked" : "70s after boot"); } bool bootloader_migration_request(const char *confirmation, char *reply, size_t capacity) { @@ -206,6 +291,10 @@ bool bootloader_migration_request(const char *confirmation, char *reply, size_t snprintf(reply, capacity, "ERR confirm exact image: ota bootloader embedded install %s", hash); return false; } + // A manual attempt also satisfies the startup check, including a cancelled + // or failed attempt. Only another explicit command may retry it this boot. + automatic_attempted = true; + automatic_blocked = false; if (pending) { snprintf(reply, capacity, "OK bootloader update already armed"); return true; @@ -229,6 +318,18 @@ bool bootloader_migration_request(const char *confirmation, char *reply, size_t return true; } already_installed = false; + if (!lock_automatic_for_firmware()) { + last_result = 18; + result_is_current = true; + snprintf(reply, capacity, "ERR cannot save bootloader automatic-update lock"); + return false; + } + if (!write_reboot_marker(ATTEMPT_MARKER)) { + last_result = 17; + result_is_current = true; + snprintf(reply, capacity, "ERR bootloader reboot guard unavailable"); + return false; + } pending = true; armed_at = millis(); last_result = 0; @@ -258,7 +359,53 @@ void bootloader_migration_audit(char *reply, size_t capacity) { } void bootloader_migration_tick(bool outbound_idle, bool ota_idle) { - if (!pending) return; + if (!pending) { + if (!bootloader_migration_service_due() || !outbound_idle || !ota_idle) return; + // A failed MBR/preflight reset leaves this marker. Do not create a loop + // that retries and resets every 70 seconds; the CLI can retry explicitly. + uint32_t marker = 0; + if (!read_reboot_marker(marker) || + (automatic_lock != MigrationAutoLock::PreviousImage && + (marker == ATTEMPT_MARKER || (marker > 0xC0 && marker <= 0xCF)))) { + automatic_attempted = true; + automatic_blocked = true; + return; + } + automatic_attempted = true; + // Mark before reading either image too, so a reset in the version/hash + // check cannot turn into a recurring 70-second reboot loop. + if (!write_reboot_marker(ATTEMPT_MARKER)) { + last_result = 17; + result_is_current = true; + automatic_blocked = true; + return; + } + // Save the current image before inspecting the bootloader. A new firmware + // may ignore its predecessor's retained marker, so GPREGRET alone cannot + // protect a reset during that first check. + if (!lock_automatic_for_firmware()) { + last_result = 18; + result_is_current = true; + automatic_blocked = true; + return; + } + if (!installed_allows_automatic()) { + loader_excluded = true; + automatic_blocked = true; + if (!write_reboot_marker(0)) { + last_result = 17; + result_is_current = true; + } + return; + } + char reply[160]; + bootloader_migration_request(nullptr, reply, sizeof(reply)); + if (!pending && last_result == 0 && !write_reboot_marker(0)) { + last_result = 17; + result_is_current = true; + } + return; + } const uint32_t elapsed = millis() - armed_at; // An incoming advert may have started autofetch since the admin command. // Never disable SoftDevice while another OTA session owns the flash store. diff --git a/src/helpers/ota/BootloaderMigration.h b/src/helpers/ota/BootloaderMigration.h index e0d8bfe8b..87e96f509 100644 --- a/src/helpers/ota/BootloaderMigration.h +++ b/src/helpers/ota/BootloaderMigration.h @@ -2,10 +2,13 @@ #if defined(OTA_BOOTLOADER_MIGRATION) #include +#include "BootloaderMigrationStore.h" namespace mesh { namespace ota { +void bootloader_migration_begin(FILESYSTEM *fs); bool bootloader_migration_pending(); +bool bootloader_migration_service_due(); // nullptr selects the pinned embedded image; a supplied hash must match exactly. bool bootloader_migration_request(const char *confirmation, char *reply, size_t capacity); void bootloader_migration_status(char *reply, size_t capacity); diff --git a/src/helpers/ota/BootloaderMigrationPolicy.h b/src/helpers/ota/BootloaderMigrationPolicy.h index 83f893832..078ec06d3 100644 --- a/src/helpers/ota/BootloaderMigrationPolicy.h +++ b/src/helpers/ota/BootloaderMigrationPolicy.h @@ -2,6 +2,7 @@ #include #include +#include #include "OtaBootloaderUpdate.h" namespace mesh { @@ -16,6 +17,55 @@ static const uint32_t SETTINGS_BASE = 0xFF000u; static const uint32_t ACL_READ_DENIED = 1u << 2; static const uint32_t ACL_WRITE_DENIED = 1u << 1; +enum class InstalledVariant { Unknown, Normal, Recovery }; + +inline bool boot_text_matches(const uint8_t *text, size_t available, + const char *expected, size_t length) { + return available >= length && memcmp(text, expected, length) == 0; +} + +// Recovery builds deliberately retain the normal board identity and BLM2 +// flags. Their on-device version text is the only published discriminator. +// Older recovery images can omit that text entirely, so automatic installation +// also requires one complete, recognized normal INFO_UF2 version record. +// The caller must separately verify the image CRC, identity and platform. +inline InstalledVariant installed_variant(const uint8_t *image, size_t size, + uint32_t version) { + if (!image || size != BOOT_SIZE || !ota_bootloader_version_valid(version)) + return InstalledVariant::Unknown; + static const char header[] = "UF2 Bootloader "; + static const char recovery[] = "R_0x"; + static const char old_recovery[] = "-recovery-allow-all"; + static const char recovery_header[] = "UF2 Bootloader R_"; + for (size_t i = 0; i < size; ++i) { + const size_t left = size - i; + if (boot_text_matches(image + i, left, recovery, sizeof(recovery) - 1) || + boot_text_matches(image + i, left, old_recovery, sizeof(old_recovery) - 1) || + boot_text_matches(image + i, left, recovery_header, sizeof(recovery_header) - 1)) + return InstalledVariant::Recovery; + } + char normal[48], packed[16]; + const unsigned preview = version & 0xFFu; + snprintf(normal, sizeof(normal), + preview == 0xFFu ? "0.11.0-OTAFIX%u.%u.%u\r\n" : + "0.11.0-OTAFIX%u.%u.%u-preview.%u\r\n", + unsigned(version >> 24), unsigned((version >> 16) & 0xFFu), + unsigned((version >> 8) & 0xFFu), preview); + snprintf(packed, sizeof(packed), "BL_0x%08lX\r\n", (unsigned long)version); + unsigned records = 0; + for (size_t i = 0; i + sizeof(header) - 1 < size; ++i) { + if (!boot_text_matches(image + i, size - i, header, sizeof(header) - 1)) continue; + const uint8_t *label = image + i + sizeof(header) - 1; + size_t left = size - i - (sizeof(header) - 1); + bool matches = boot_text_matches(label, left, packed, strlen(packed)); + if (*label == 'v') { ++label; --left; } + if (!matches && !boot_text_matches(label, left, normal, strlen(normal))) + return InstalledVariant::Unknown; + if (++records > 1) return InstalledVariant::Unknown; + } + return records == 1 ? InstalledVariant::Normal : InstalledVariant::Unknown; +} + struct AclRegion { uint32_t address; uint32_t size; @@ -101,13 +151,19 @@ inline bool installed_can_migrate(const OtaBootloaderIdentity &installed, 140, fwid, app_base, 1) == OTA_BOOT_CONTINUITY_OK; } -inline bool installed_up_to_date(const OtaBootloaderIdentity &installed, - uint32_t board_id, const char name[16], - uint32_t version, uint32_t app_base, uint16_t fwid) { +inline bool installed_matches_resource(const OtaBootloaderIdentity &installed, + uint32_t board_id, const char name[16], + uint32_t app_base, uint16_t fwid) { return installed.present && installed.crc_ok && installed.image_start == BOOT_BASE && installed.image_size == BOOT_SIZE && installed.board_id == board_id && memcmp(installed.device_name, name, 16) == 0 && - ota_bootloader_continuity_matches_runtime(installed, 140, fwid, app_base, 1) && + ota_bootloader_continuity_matches_runtime(installed, 140, fwid, app_base, 1); +} + +inline bool installed_up_to_date(const OtaBootloaderIdentity &installed, + uint32_t board_id, const char name[16], + uint32_t version, uint32_t app_base, uint16_t fwid) { + return installed_matches_resource(installed, board_id, name, app_base, fwid) && installed.boot_version >= version; } diff --git a/src/helpers/ota/BootloaderMigrationStore.cpp b/src/helpers/ota/BootloaderMigrationStore.cpp new file mode 100644 index 000000000..9602a2b27 --- /dev/null +++ b/src/helpers/ota/BootloaderMigrationStore.cpp @@ -0,0 +1,57 @@ +#if defined(OTA_BOOTLOADER_MIGRATION) +#include "BootloaderMigrationStore.h" + +#include +#include +#include + +namespace mesh { namespace ota { +namespace { +constexpr size_t RecordSize = 40; +const char* const Path = "/bl_auto"; +const char* const Temp = "/bl_auto.tmp"; +const uint8_t Magic[4] = {'B', 'A', 'L', 1}; + +bool readRecord(FILESYSTEM* fs, uint8_t record[RecordSize]) { + File file(*fs); + if (!file.open(Path, FILE_O_READ)) return false; + const bool complete = !file.isDirectory() && file.size() == RecordSize + && file.read(record, RecordSize) == static_cast(RecordSize); + file.close(); + return complete + && memcmp(record, Magic, sizeof(Magic)) == 0 + && storage::readLE32(record + RecordSize - 4) + == storage::updateCRC32(0xffffffffU, record, RecordSize - 4); +} +} + +MigrationAutoLock bootloader_migration_auto_lock( + FILESYSTEM* fs, const uint8_t image_hash[32]) { + bool present = false; + if (!fs || !image_hash || !filePresence(fs, Temp, present) || present) + return MigrationAutoLock::Unavailable; + if (!filePresence(fs, Path, present)) return MigrationAutoLock::Unavailable; + if (!present) return MigrationAutoLock::Eligible; + uint8_t record[RecordSize]; + if (!readRecord(fs, record)) return MigrationAutoLock::Unavailable; + return memcmp(record + sizeof(Magic), image_hash, 32) == 0 + ? MigrationAutoLock::Locked : MigrationAutoLock::PreviousImage; +} + +bool bootloader_migration_lock_auto(FILESYSTEM* fs, const uint8_t image_hash[32]) { + const auto state = bootloader_migration_auto_lock(fs, image_hash); + if (state == MigrationAutoLock::Locked) return true; + if (state == MigrationAutoLock::Unavailable) return false; + uint8_t record[RecordSize], verify[RecordSize]; + memcpy(record, Magic, sizeof(Magic)); + memcpy(record + sizeof(Magic), image_hash, 32); + storage::writeLE32(record + RecordSize - 4, + storage::updateCRC32(0xffffffffU, record, RecordSize - 4)); + AtomicFileWriter writer(fs, Path); + return writer && writer.write(record, sizeof(record)) == sizeof(record) + && writer.commit() && readRecord(fs, verify) + && memcmp(record, verify, sizeof(record)) == 0; +} + +} } // namespace mesh::ota +#endif diff --git a/src/helpers/ota/BootloaderMigrationStore.h b/src/helpers/ota/BootloaderMigrationStore.h new file mode 100644 index 000000000..e137eb6e0 --- /dev/null +++ b/src/helpers/ota/BootloaderMigrationStore.h @@ -0,0 +1,20 @@ +#pragma once + +#if defined(OTA_BOOTLOADER_MIGRATION) +#include + +namespace mesh { namespace ota { + +enum class MigrationAutoLock : uint8_t { Eligible, PreviousImage, Locked, Unavailable }; + +// image_hash is SHA-256 of the complete, verified running application. Pass +// nullptr as the filesystem when the role is using volatile fallback storage. +MigrationAutoLock bootloader_migration_auto_lock( + FILESYSTEM* fs, const uint8_t image_hash[32]); + +// Record an attempt before inspecting the bootloader. A matching durable record is +// already sufficient; a different image may atomically replace its predecessor. +bool bootloader_migration_lock_auto(FILESYSTEM* fs, const uint8_t image_hash[32]); + +} } // namespace mesh::ota +#endif diff --git a/test/test_bootloader_migration_role_persistence.py b/test/test_bootloader_migration_role_persistence.py new file mode 100644 index 000000000..b06c3447b --- /dev/null +++ b/test/test_bootloader_migration_role_persistence.py @@ -0,0 +1,211 @@ +"""Exercise embedded migration gates against the roles' normal ACL saves.""" + +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = r''' +#include +#include +#include +#include "helpers/LazyPersistence.h" +#define LAZY_CONTACTS_WRITE_DELAY 5000 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +static uint32_t now = 70000; +static bool millisHasNowPassed(unsigned long deadline) { + return int32_t(now - uint32_t(deadline)) > 0; +} +static unsigned long futureMillis(uint32_t delay) { return uint32_t(now + delay); } +struct Base { virtual bool prepareForOtaReboot() { return true; } }; +struct Role : Base { + using MyMesh = Role; + unsigned long dirty_contacts_expiry = 75000; + uint8_t contacts_save_failures = 0; + static bool saveFilter(int*) { return true; } + void* _fs = nullptr; + struct Acl { + bool succeeds = false; + unsigned saves = 0; + bool save(void*, bool (*)(int*) = nullptr) { saves++; return succeeds; } + } acl; + @GATE@ + void servicePersistence() { + @SAVE@ + } +}; +int main() { + Role role; +#ifdef OTA_BOOTLOADER_MIGRATION + static_assert(std::is_same::value, + "embedded role must override the reboot gate"); + assert(!role.prepareForOtaReboot()); + role.servicePersistence(); + assert(role.acl.saves == 0 && !role.prepareForOtaReboot()); + now = 75001; + role.servicePersistence(); + assert(role.acl.saves == 1 && role.contacts_save_failures == 1); + assert(!role.prepareForOtaReboot()); + // Repeated migration polls must not bypass the failed save's backoff. + now = 80000; + for (unsigned poll = 0; poll < 100; poll++) { + assert(!role.prepareForOtaReboot()); + role.servicePersistence(); + } + assert(role.acl.saves == 1); + role.acl.succeeds = true; + now = 80002; + role.servicePersistence(); + assert(role.acl.saves == 2 && role.contacts_save_failures == 0); + assert(role.dirty_contacts_expiry == 0 && role.prepareForOtaReboot()); +#else + // Ordinary firmware retains the base implementation without an override. + assert(role.prepareForOtaReboot()); + static_assert(std::is_same::value, + "ordinary role must retain its base reboot gate"); +#endif +} +''' + +MESH_SOURCE = r''' +#include +namespace ota { +static bool due = false, pending = false; +static unsigned context_polls = 0, ticks = 0; +static bool last_ready = false, last_idle = false; +struct OtaContext { + bool idle = true; + bool embeddedBootloaderIdle() const { return idle; } +}; +static OtaContext* active = nullptr; +static bool bootloader_migration_service_due() { return due; } +static bool bootloader_migration_pending() { return pending; } +static const OtaContext* ota_context_if_active() { context_polls++; return active; } +static void bootloader_migration_tick(bool ready, bool idle) { + ticks++; last_ready = ready; last_idle = idle; +} +} +struct Mesh { + bool radio_busy = false, persistence_ready = false; + unsigned persistence_checks = 0; + bool hasPendingRadioWork() const { return radio_busy; } + bool prepareForOtaReboot() { persistence_checks++; return persistence_ready; } + void service() { @SERVICE@ } +}; +int main() { + Mesh mesh; + mesh.service(); + assert(ota::ticks == 0 && ota::context_polls == 0); + ota::due = true; + mesh.service(); + // Startup checks work with a released workspace and no persistence flush. + assert(ota::ticks == 1 && ota::context_polls == 1); + assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 0); + ota::pending = true; + mesh.service(); + assert(!ota::last_ready && ota::last_idle && mesh.persistence_checks == 1); + mesh.persistence_ready = true; + mesh.service(); + assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 2); + mesh.radio_busy = true; + mesh.service(); + assert(!ota::last_ready && ota::last_idle && mesh.persistence_checks == 2); + mesh.radio_busy = false; + ota::OtaContext context; + context.idle = false; + ota::active = &context; + mesh.service(); + assert(!ota::last_ready && !ota::last_idle && mesh.persistence_checks == 2); + ota::pending = false; + context.idle = true; + mesh.service(); + assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 2); +} +''' + + +class BootloaderMigrationRolePersistenceTest(unittest.TestCase): + def test_both_roles_initialize_the_lock_after_mesh_with_durable_storage_only(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + source = (ROOT / "examples" / role / "main.cpp").read_text(encoding="utf-8") + setup = body(source, "void setup()") + startup = re.search( + r"#if defined\(OTA_BOOTLOADER_MIGRATION\)\n" + r"\s*mesh::ota::bootloader_migration_begin\(" + r"volatile_primary_fs \? nullptr : fs\);\n#endif", setup) + self.assertIsNotNone(startup) + self.assertEqual(setup.count("bootloader_migration_begin("), 1) + self.assertLess(setup.index("the_mesh.begin(fs);"), startup.start()) + self.assertLess(setup.index("internalPrimaryFilesystemReady("), startup.start()) + self.assertLess(setup.index("volatile_primary_fs = true;"), startup.start()) + + def test_mesh_services_startup_checks_without_allocating_an_ota_workspace(self): + source = (ROOT / "src/Mesh.cpp").read_text(encoding="utf-8") + maintenance = body(source, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()") + service = body(maintenance, "if (ota::bootloader_migration_service_due())") + dynamic_context = maintenance.index("#if OTA_DYNAMIC_CONTEXT") + self.assertLess(maintenance.index(service) + len(service), dynamic_context) + released = body(maintenance[dynamic_context:], "if (!ota::ota_context_if_active())") + self.assertIn("return;", released) + # Allocating ota_ctx() merely to poll would defeat dynamic-context RAM savings. + self.assertNotRegex(service, r"\bota_ctx\s*\(") + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + sanitize = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="migration-mesh-service-") as directory: + unit = Path(directory) / "service.cpp" + binary = Path(directory) / ("service.exe" if os.name == "nt" else "service") + unit.write_text(MESH_SOURCE.replace("@SERVICE@", service), encoding="ascii") + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + *sanitize, str(unit), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_repeater_and_room_wait_for_the_normal_acl_save(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + sanitize = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="migration-role-persistence-") as directory: + for role in ("simple_repeater", "simple_room_server"): + header = (ROOT / "examples" / role / "MyMesh.h").read_text(encoding="utf-8") + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text(encoding="utf-8") + gate = re.search( + r"#if defined\(OTA_BOOTLOADER_MIGRATION\)\n" + r"\s*bool prepareForOtaReboot\(\) override \{.*?\n\s*\}\n#endif", + header, re.DOTALL) + self.assertIsNotNone(gate, role) + save = body(source, "if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry))") + generated = SOURCE.replace("@GATE@", gate.group()).replace("@SAVE@", save) + for embedded in (False, True): + with self.subTest(role=role, embedded=embedded): + unit = Path(directory) / (role + ".cpp") + binary = Path(directory) / (role + (".exe" if os.name == "nt" else "")) + unit.write_text(generated, encoding="ascii") + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", *sanitize, + *(["-DOTA_BOOTLOADER_MIGRATION=1"] if embedded else []), + "-I", str(ROOT / "src"), str(unit), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_bootloader_migration_store.py b/test/test_bootloader_migration_store.py new file mode 100644 index 000000000..0815cd78f --- /dev/null +++ b/test/test_bootloader_migration_store.py @@ -0,0 +1,343 @@ +"""Exercise the production per-image bootloader lock with filesystem faults.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +FILESYSTEM = r''' +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +struct lfs_info {}; +static const int LFS_ERR_NOENT = -2; +static const uint8_t FILE_O_READ = 0, FILE_O_WRITE = 1; +class FakeFilesystem; +class File { + FakeFilesystem* fs; + std::string path; + size_t position = 0; + bool opened = false, writing = false; +public: + explicit File(FakeFilesystem& filesystem) : fs(&filesystem) {} + bool open(const char*, uint8_t); + size_t write(const uint8_t*, size_t); + int read(void*, uint16_t); + uint32_t size() const; + bool isDirectory() const; + void flush() {} + void close() { opened = false; } + explicit operator bool() const { return opened; } +}; +class FakeFilesystem { +public: + std::map> files; + std::set directories; + std::string stat_error; + bool fail_read = false, fail_write_open = false, fail_rename = false; + bool false_success_rename = false, error_after_publish = false; + bool corrupt_after_publish = false, fail_reads_after_publish = false; + size_t max_write = std::numeric_limits::max(); + size_t max_read = std::numeric_limits::max(); + unsigned writes = 0, renames = 0; + bool exists(const char* path) const { + return files.count(path) || directories.count(path); + } + bool remove(const char* path) { return files.erase(path) != 0; } + bool rename(const char* from, const char* to) { + ++renames; + if (fail_rename) return false; + if (false_success_rename) return true; + auto found = files.find(from); + if (found == files.end() || directories.count(to)) return false; + files[to] = found->second; + files.erase(found); + if (corrupt_after_publish && !files[to].empty()) files[to][0] ^= 1; + if (fail_reads_after_publish) fail_read = true; + return !error_after_publish; + } + void _lockFS() {} + void _unlockFS() {} + FakeFilesystem* _getFS() { return this; } +}; +inline int lfs_stat(FakeFilesystem* fs, const char* path, lfs_info*) { + if (fs->stat_error == path) return -5; + return fs->exists(path) ? 0 : LFS_ERR_NOENT; +} +inline bool File::open(const char* filename, uint8_t mode) { + path = filename; + writing = mode == FILE_O_WRITE; + if (fs->directories.count(path)) return false; + if (writing) { + if (fs->fail_write_open) return false; + position = fs->files[path].size(); + } else { + if (fs->fail_read || !fs->files.count(path)) return false; + position = 0; + } + opened = true; + return true; +} +inline size_t File::write(const uint8_t* data, size_t size) { + if (!opened || !writing) return 0; + ++fs->writes; + const size_t count = std::min(size, fs->max_write); + auto& contents = fs->files[path]; + contents.insert(contents.end(), data, data + count); + position += count; + return count; +} +inline int File::read(void* data, uint16_t size) { + if (!opened || writing || fs->fail_read) return -1; + const auto& contents = fs->files[path]; + const size_t count = std::min( + std::min(size, fs->max_read), + contents.size() - std::min(position, contents.size())); + if (count) memcpy(data, contents.data() + position, count); + position += count; + return static_cast(count); +} +inline uint32_t File::size() const { + auto found = fs->files.find(path); + return found == fs->files.end() ? 0 : found->second.size(); +} +inline bool File::isDirectory() const { return fs->directories.count(path); } +#define FILESYSTEM FakeFilesystem +''' + +SOURCE = r''' +#include +#include +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#include +#include +using namespace mesh::ota; +static const char* Path = "/bl_auto"; +static const char* Temp = "/bl_auto.tmp"; +int main(int argc, char** argv) { + assert(argc == 2); + const std::string scenario = argv[1]; + uint8_t a[32], b[32]; + memset(a, 0x31, sizeof(a)); + memcpy(b, a, sizeof(b)); + b[31] ^= 1; // Deliberately preserve the 8-byte EndF hash prefix. + FakeFilesystem fs; + if (scenario == "fresh") { + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Eligible); + assert(bootloader_migration_auto_lock(nullptr, a) == MigrationAutoLock::Unavailable); + assert(bootloader_migration_auto_lock(&fs, nullptr) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(nullptr, a)); + assert(!bootloader_migration_lock_auto(&fs, nullptr)); + } else if (scenario == "persist") { + assert(bootloader_migration_lock_auto(&fs, a)); + assert(fs.files.at(Path).size() == 40 && !fs.exists(Temp)); + FakeFilesystem rebooted; + rebooted.files = fs.files; + assert(bootloader_migration_auto_lock(&rebooted, a) == MigrationAutoLock::Locked); + assert(bootloader_migration_auto_lock(&rebooted, b) == MigrationAutoLock::PreviousImage); + assert(bootloader_migration_lock_auto(&rebooted, b)); + assert(bootloader_migration_auto_lock(&rebooted, b) == MigrationAutoLock::Locked); + assert(bootloader_migration_auto_lock(&rebooted, a) == MigrationAutoLock::PreviousImage); + } else if (scenario == "locked") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto original = fs.files; + const unsigned writes = fs.writes, renames = fs.renames; + fs.max_write = 0; + fs.fail_rename = true; + assert(bootloader_migration_lock_auto(&fs, a)); + assert(fs.files == original && fs.writes == writes && fs.renames == renames); + } else if (scenario == "malformed") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto good = fs.files.at(Path); + for (unsigned kind = 0; kind < 4; ++kind) { + fs.files[Path] = good; + if (kind == 0) fs.files[Path].clear(); + if (kind == 1) fs.files[Path].push_back(0); + if (kind == 2) { + fs.files[Path][0] ^= 1; + mesh::storage::writeLE32(fs.files[Path].data() + 36, + mesh::storage::updateCRC32(0xffffffffU, fs.files[Path].data(), 36)); + } + if (kind == 3) fs.files[Path][39] ^= 1; + const auto before = fs.files; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + } + } else if (scenario == "directory") { + fs.directories.insert(Path); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, a)); + } else if (scenario == "stat") { + for (const auto path : {Path, Temp}) { + fs.stat_error = path; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, a)); + assert(fs.writes == 0 && fs.renames == 0); + } + } else if (scenario == "read") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto before = fs.files; + fs.fail_read = true; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, b)); + fs.fail_read = false; + fs.max_read = 7; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + } else if (scenario == "temporary") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto complete = fs.files.at(Path); + for (bool primary : {false, true}) { + fs = FakeFilesystem(); + if (primary) assert(bootloader_migration_lock_auto(&fs, a)); + for (size_t size : {size_t(0), size_t(7), size_t(40)}) { + fs.files[Temp] = std::vector(size, 0x31); + const auto before = fs.files; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + } + fs.files[Temp] = complete; + const auto before = fs.files; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + assert(bootloader_migration_auto_lock(&fs, b) == MigrationAutoLock::Unavailable); + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + } + } else if (scenario == "short_write" || scenario == "write_open") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto before = fs.files; + if (scenario == "short_write") fs.max_write = 7; + else fs.fail_write_open = true; + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); + } else if (scenario == "rename_fail" || scenario == "rename_false_success") { + assert(bootloader_migration_lock_auto(&fs, a)); + const auto before = fs.files; + fs.fail_rename = scenario == "rename_fail"; + fs.false_success_rename = scenario == "rename_false_success"; + assert(!bootloader_migration_lock_auto(&fs, b)); + assert(fs.files == before); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); + } else if (scenario == "rename_late_error") { + fs.error_after_publish = true; + assert(bootloader_migration_lock_auto(&fs, a)); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); + } else if (scenario == "publish_corrupt") { + fs.corrupt_after_publish = true; + assert(!bootloader_migration_lock_auto(&fs, a)); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + } else if (scenario == "publish_unreadable") { + fs.fail_reads_after_publish = true; + assert(!bootloader_migration_lock_auto(&fs, a)); + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Unavailable); + fs.fail_read = false; + assert(bootloader_migration_auto_lock(&fs, a) == MigrationAutoLock::Locked); + } else if (scenario == "settings") { + fs.files["/node_prefs"] = {1, 2, 3, 4}; + fs.files["/_main"] = {9, 8, 7}; + const auto before = fs.files; + assert(bootloader_migration_lock_auto(&fs, a)); + for (const auto& file : before) assert(fs.files.at(file.first) == file.second); + assert(fs.files.size() == before.size() + 1); + } else assert(false); +} +''' + + +class BootloaderMigrationStoreTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("a host C++17 compiler is required") + cls.temporary = tempfile.TemporaryDirectory(prefix="bootloader-lock-store-") + cls.addClassCleanup(cls.temporary.cleanup) + directory = Path(cls.temporary.name) + headers = directory / "helpers" + headers.mkdir() + (headers / "IdentityStore.h").write_text(FILESYSTEM, encoding="ascii") + source = directory / "test.cpp" + source.write_text(SOURCE, encoding="ascii") + cls.binary = directory / ("test.exe" if os.name == "nt" else "test") + sanitize = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-DNRF52_PLATFORM=1", "-DOTA_BOOTLOADER_MIGRATION=1", *sanitize, + "-I", str(directory), "-I", str(ROOT / "src"), str(source), + str(ROOT / "src/helpers/ota/BootloaderMigrationStore.cpp"), + "-o", str(cls.binary), + ], capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + + def scenario(self, name): + tested = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=30) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + + def test_missing_store_and_volatile_storage(self): + self.scenario("fresh") + + def test_power_cycle_and_new_firmware_compare_all_32_hash_bytes(self): + self.scenario("persist") + + def test_matching_image_never_rewrites_its_lock(self): + self.scenario("locked") + + def test_malformed_records_are_preserved_and_block_install(self): + self.scenario("malformed") + + def test_directory_cannot_authorize_install(self): + self.scenario("directory") + + def test_metadata_errors_are_not_treated_as_absence(self): + self.scenario("stat") + + def test_unreadable_and_partial_records_are_preserved(self): + self.scenario("read") + + def test_interrupted_transaction_never_authorizes_another_attempt(self): + self.scenario("temporary") + + def test_partial_write_cannot_authorize_install(self): + self.scenario("short_write") + + def test_write_open_failure_cannot_authorize_install(self): + self.scenario("write_open") + + def test_failed_rename_preserves_previous_firmware_lock(self): + self.scenario("rename_fail") + + def test_false_success_rename_cannot_authorize_install(self): + self.scenario("rename_false_success") + + def test_verified_public_record_survives_late_rename_error(self): + self.scenario("rename_late_error") + + def test_corruption_after_publication_fails_closed(self): + self.scenario("publish_corrupt") + + def test_unreadable_publication_fails_closed_and_later_remains_locked(self): + self.scenario("publish_unreadable") + + def test_settings_and_identity_are_untouched(self): + self.scenario("settings") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_embedded_variant.py b/test/test_nrf52_embedded_variant.py index 9451a7aa3..535469ae4 100644 --- a/test/test_nrf52_embedded_variant.py +++ b/test/test_nrf52_embedded_variant.py @@ -180,6 +180,7 @@ class PackagedApplicationTests(unittest.TestCase): self.assertEqual(result["profile"], self.profile["id"]) self.assertEqual(result["candidate_sha256"], hashlib.sha256(self.candidate).hexdigest()) self.assertEqual(result["activation"], "ota bootloader update") + self.assertEqual(result["automatic_delay_seconds"], 70) self.patches["select_profile"].assert_called_once_with( {"profiles": [self.profile]}, TARGET) self.patches["validate_candidate_zip"].assert_called_once_with( diff --git a/tools/mota/test_bootloader_migration.py b/tools/mota/test_bootloader_migration.py index 8aebd0139..298712a18 100644 --- a/tools/mota/test_bootloader_migration.py +++ b/tools/mota/test_bootloader_migration.py @@ -47,9 +47,73 @@ static Snapshot good() { s.app_image_size=s.app_body_size+ENDF_LEN; return s; } +static std::array labelled(const char *text, size_t offset=100) { + std::array bytes; + bytes.fill(0xFF); + assert(offset + std::strlen(text) + 1 <= bytes.size()); + std::memcpy(bytes.data() + offset, text, std::strlen(text) + 1); + return bytes; +} +static InstalledVariant variant(const std::array &image, + uint32_t version=0x02040CFF) { + return installed_variant(image.data(), image.size(), version); +} int main(int argc,char **argv) { assert(argc==2); - if (!std::strcmp(argv[1],"geometry")) { + if (!std::strcmp(argv[1],"normal-labels")) { + assert(variant(labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\nModel: test\r\n")) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader 0.11.0-OTAFIX2.4.12\r\n")) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader BL_0x02040CFF\r\n")) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.11\r\n"),0x02040BFF) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12-preview.16\r\n"),0x02040C10) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader BL_0x02040C10\r\n"),0x02040C10) == InstalledVariant::Normal); + assert(variant(labelled("UF2 Bootloader v0.11.0-OTAFIX255.255.254-preview.254\r\n"),0xFFFFFEFE) == InstalledVariant::Normal); + } else if (!std::strcmp(argv[1],"recovery-labels")) { + for (const char *text: {"R_0x02040CFF", "UF2 Bootloader R_0x02040CFF\r\n", + "UF2 Bootloader R_v0.11.0-OTAFIX2.4.12\r\n", + "0.11.0-OTAFIX2.4.7-preview.2-recovery-allow-all s140 6.1.1"}) { + auto bytes=labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\n"); + std::memcpy(bytes.data()+500,text,std::strlen(text)+1); + assert(variant(bytes)==InstalledVariant::Recovery); // Recovery wins over a normal decoy. + assert(variant(labelled(text))==InstalledVariant::Recovery); + } + // Historical compact recovery images have neither version label nor flag. + assert(variant(labelled(""))==InstalledVariant::Unknown); + auto bytes=labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\n"); + std::memcpy(bytes.data()+BOOT_SIZE-4,"R_0x",4); + assert(variant(bytes)==InstalledVariant::Recovery); + } else if (!std::strcmp(argv[1],"unknown-labels")) { + for (const char *text: {"", "v0.11.0-OTAFIX2.4.12", "UF2 Bootloader ", + "UF2 Bootloader v0.11.0-OTAFIX2.4.11\r\n", + "UF2 Bootloader v0.11.0-OTAFIX2.4.12-preview.1\r\n", + "UF2 Bootloader v0.11.0-OTAFIX2.4.12-dirty\r\n", + "UF2 Bootloader v0.11.0-OTAFIX2.4.12\n", + "UF2 Bootloader v0.11.0-OTAFIX2.4.12", + "UF2 Bootloader vBL_0x02040CFF\r\n", + "UF2 Bootloader BL_0x02040DFF\r\n"}) + assert(variant(labelled(text))==InstalledVariant::Unknown); + auto bytes=labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\n"); + assert(installed_variant(nullptr,BOOT_SIZE,0x02040CFF)==InstalledVariant::Unknown); + assert(installed_variant(bytes.data(),BOOT_SIZE-1,0x02040CFF)==InstalledVariant::Unknown); + assert(variant(bytes,0)==InstalledVariant::Unknown); + assert(variant(bytes,UINT32_MAX)==InstalledVariant::Unknown); + assert(variant(bytes,0x02040C00)==InstalledVariant::Unknown); + const char *duplicate="UF2 Bootloader BL_0x02040CFF\r\n"; + std::memcpy(bytes.data()+500,duplicate,std::strlen(duplicate)+1); + assert(variant(bytes)==InstalledVariant::Unknown); + bytes=labelled("UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\n"); + const char *conflict="UF2 Bootloader v0.11.0-OTAFIX2.4.11\r\n"; + std::memcpy(bytes.data()+500,conflict,std::strlen(conflict)+1); + assert(variant(bytes)==InstalledVariant::Unknown); + for (size_t tail=1;tail<40;++tail) { + bytes.fill(0xFF); + const char *label="UF2 Bootloader v0.11.0-OTAFIX2.4.12\r\n"; + size_t count=std::strlen(label); + if (tail>=count) continue; + std::memcpy(bytes.data()+BOOT_SIZE-tail,label,tail); + assert(variant(bytes)==InstalledVariant::Unknown); + } + } else if (!std::strcmp(argv[1],"geometry")) { assert(preflight(good())==Gate::Ok); Snapshot s=good(); s.part=0x52833; assert(preflight(s)==Gate::Chip); s=good(); s.flash_page_size=2048; assert(preflight(s)==Gate::Chip); @@ -101,6 +165,7 @@ int main(int argc,char **argv) { assert(candidate_matches_resource(v7,0x239A0029,name,0x02040CFF,0x27000,0x123)); assert(installed_can_migrate(installed,candidate,false,false,0x26000,0xB6)); if (!std::strcmp(argv[1],"up-to-date")) { + assert(installed_matches_resource(installed,0x239A0029,name,0x26000,0xB6)); assert(!installed_up_to_date(installed,0x239A0029,name,0x02040CFF,0x26000,0xB6)); installed.boot_version=0x02040CFE; // Preview precedes the stable release. assert(!installed_up_to_date(installed,0x239A0029,name,0x02040CFF,0x26000,0xB6)); @@ -189,6 +254,15 @@ class MigrationPolicyTests(unittest.TestCase): def test_actual_v6_v7_geometry_and_linked_source_bounds(self): self.scenario('geometry') + def test_automatic_variant_recognizes_complete_normal_release_labels(self): + self.scenario('normal-labels') + + def test_recovery_markers_override_normal_labels_and_missing_text_fails_closed(self): + self.scenario('recovery-labels') + + def test_automatic_variant_rejects_unknown_ambiguous_or_truncated_labels(self): + self.scenario('unknown-labels') + def test_all_acl_slots_protect_source_bootloader_and_parameter_page(self): self.scenario('acl') @@ -219,6 +293,22 @@ class MigrationPolicyTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn('Assertion', result.stderr) + def test_negative_control_cannot_ignore_recovery_version_marker(self): + original = (ROOT / 'src/helpers/ota/BootloaderMigrationPolicy.h').read_text() + changed = original.replace( + 'boot_text_matches(image + i, left, recovery, sizeof(recovery) - 1)', 'false') + self.assertNotEqual(original, changed) + with tempfile.TemporaryDirectory(prefix='migration-recovery-negative-') as directory: + directory = Path(directory) + header = directory / 'helpers/ota/BootloaderMigrationPolicy.h' + header.parent.mkdir(parents=True) + header.write_text(changed.replace('#include "OtaBootloaderUpdate.h"', + '#include "helpers/ota/OtaBootloaderUpdate.h"')) + binary = self.compile(directory, HARNESS, directory) + result = subprocess.run([str(binary), 'recovery-labels'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Assertion', result.stderr) + if __name__ == '__main__': unittest.main() diff --git a/tools/mota/test_bootloader_migration_runtime.py b/tools/mota/test_bootloader_migration_runtime.py index df0ebaf89..381b09773 100644 --- a/tools/mota/test_bootloader_migration_runtime.py +++ b/tools/mota/test_bootloader_migration_runtime.py @@ -5,7 +5,8 @@ The ARM SVC adapter is replaced by a failing trap, and the EndF stub refuses installation before hardware access. Real CRC-bound installed manifests test the version check; a controlled digest exercises the exact candidate no-op. This tests scheduling and cancellation, not cryptography, MBR copying or -power-loss recovery; those require separate qualification. +power-loss recovery; those require separate qualification. The durable-store +boundary is modeled here and tested against real file faults separately. """ import os from pathlib import Path @@ -40,6 +41,84 @@ class MigrationRuntimeTests(unittest.TestCase): def run_scenario(self, scenario): subprocess.run([str(self.binary), scenario], check=True) + def test_recovery_unknown_or_damaged_bootloaders_require_an_explicit_command(self): + for scenario in ('automatic-recovery-prefix', 'automatic-recovery-suffix', + 'automatic-unlabelled-loader', 'automatic-ambiguous-loader', + 'automatic-foreign-loader', 'automatic-damaged-loader'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_same_firmware_remains_locked_after_a_cold_boot(self): + for scenario in ('durable-same-firmware-cold-boot', + 'durable-same-firmware-old-loader'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_new_image_classification_interruption_is_locked_before_reboot(self): + self.run_scenario('previous-image-interrupted-classification') + + def test_a_different_full_firmware_hash_allows_a_new_automatic_check(self): + for scenario in ('durable-different-firmware', + 'durable-different-firmware-failure-marker'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_missing_or_failed_durable_state_refuses_a_genuine_update(self): + for scenario in ('automatic-store-unavailable', 'automatic-store-null', + 'automatic-invalid-application', 'automatic-store-write-failure'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_automatic_update_waits_70_seconds_then_observes_install_grace(self): + for scenario in ('automatic-delay', 'automatic-arm-enabled'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_automatic_update_defers_until_radio_and_ota_are_idle(self): + self.run_scenario('automatic-busy') + + def test_automatic_update_skips_same_or_newer_version_and_exact_image(self): + for scenario in ('automatic-same-version', 'automatic-same-version-enabled', + 'automatic-newer-version', + 'automatic-exact-image'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_retained_failure_prevents_automatic_retry_but_allows_manual_retry(self): + self.run_scenario('automatic-retained-failure') + + def test_manual_attempt_prevents_a_second_automatic_attempt(self): + for scenario in ('manual-cancel-consumes-automatic', + 'manual-failure-consumes-automatic'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_cancelled_automatic_update_is_not_rearmed(self): + self.run_scenario('automatic-cancel') + + def test_invalid_confirmation_does_not_cancel_the_automatic_update(self): + self.run_scenario('invalid-confirmation-preserves-automatic') + + def test_reboot_guard_is_written_through_softdevice_before_arming(self): + self.run_scenario('guard-softdevice-enabled') + + def test_reboot_guard_read_errors_block_the_automatic_attempt(self): + for scenario in ('automatic-status-read-failure', 'automatic-marker-read-failure'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_reboot_guard_api_failures_refuse_to_arm(self): + for scenario in ('guard-softdevice-status-failure', 'guard-marker-read-failure', + 'guard-clear-failure', + 'guard-set-failure'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + + def test_reset_during_check_or_armed_attempt_cannot_create_an_update_loop(self): + for scenario in ('automatic-interrupted-check', 'automatic-interrupted-arm'): + with self.subTest(scenario=scenario): + self.run_scenario(scenario) + def test_wrong_confirmation_cannot_arm(self): self.run_scenario('wrong-confirmation') @@ -95,6 +174,26 @@ class MigrationRuntimeTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn('!bootloader_migration_pending()', result.stderr) + def test_negative_control_automatic_check_must_lock_before_classification(self): + driver = (ROOT / 'src/helpers/ota/BootloaderMigration.cpp').read_text() + start = driver.index('void bootloader_migration_tick(') + changed_tick = driver[start:].replace( + 'if (!lock_automatic_for_firmware()) {', 'if (false) {', 1) + self.assertNotEqual(driver[start:], changed_tick) + changed = driver[:start] + changed_tick + harness = (STUBS / 'runtime.cpp').read_text().replace( + '#include "helpers/ota/BootloaderMigration.cpp"', changed) + with tempfile.TemporaryDirectory(prefix='migration-auto-lock-negative-') as directory: + directory = Path(directory) + source = directory / 'runtime.cpp' + source.write_text(harness) + binary = self.compile(directory, source) + result = subprocess.run( + [str(binary), 'previous-image-interrupted-classification'], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('durable_lock_present && store_writes == 1', result.stderr) + def test_grace_and_radio_drain_are_both_required(self): self.run_scenario('grace-and-drain') diff --git a/tools/mota/tests/migration_stubs/helpers/IdentityStore.h b/tools/mota/tests/migration_stubs/helpers/IdentityStore.h new file mode 100644 index 000000000..11d1f052b --- /dev/null +++ b/tools/mota/tests/migration_stubs/helpers/IdentityStore.h @@ -0,0 +1,7 @@ +#pragma once +#include + +// Only the filesystem handle crosses the migration driver's store boundary. +// The real persistence implementation is exercised by its separate fault tests. +class MigrationTestFilesystem {}; +#define FILESYSTEM MigrationTestFilesystem diff --git a/tools/mota/tests/migration_stubs/nrf_soc.h b/tools/mota/tests/migration_stubs/nrf_soc.h index d484aee6e..18db044f6 100644 --- a/tools/mota/tests/migration_stubs/nrf_soc.h +++ b/tools/mota/tests/migration_stubs/nrf_soc.h @@ -2,3 +2,5 @@ #include uint32_t sd_power_gpregret_get(uint32_t index, uint32_t *result); +uint32_t sd_power_gpregret_clr(uint32_t index, uint32_t mask); +uint32_t sd_power_gpregret_set(uint32_t index, uint32_t mask); diff --git a/tools/mota/tests/migration_stubs/runtime.cpp b/tools/mota/tests/migration_stubs/runtime.cpp index a5eef1dc2..c6016ceac 100644 --- a/tools/mota/tests/migration_stubs/runtime.cpp +++ b/tools/mota/tests/migration_stubs/runtime.cpp @@ -30,7 +30,28 @@ unsigned softdevice_checks = 0; unsigned disables = 0; unsigned resets = 0; unsigned copies = 0; +unsigned retained_reads = 0; +unsigned retained_clears = 0; +unsigned retained_sets = 0; bool candidate_installed = false; +bool interrupt_hash = false; +bool interrupt_classification = false; +unsigned classification_checks = 0; +bool softdevice_enabled = false; +bool startup_metadata = false; +bool startup_valid = true; +bool store_available = true; +bool store_write_ok = true; +bool durable_lock_present = false; +uint8_t application_hash[32] = {1}; +uint8_t durable_lock_hash[32] = {}; +unsigned store_reads = 0; +unsigned store_writes = 0; +unsigned store_lock_calls = 0; +uint32_t softdevice_status = NRF_SUCCESS; +uint32_t retained_get_status = NRF_SUCCESS; +uint32_t retained_clear_status = NRF_SUCCESS; +uint32_t retained_set_status = NRF_SUCCESS; void mbr_command() { ++copies; @@ -38,14 +59,29 @@ void mbr_command() { } } // namespace migration_test +#include "helpers/ota/BootloaderMigrationPolicy.h" +namespace mesh { namespace ota { namespace migration { +InstalledVariant installed_variant_for_runtime(const uint8_t *image, size_t size, + uint32_t version) { + ++migration_test::classification_checks; + if (migration_test::interrupt_classification) + throw std::runtime_error("interrupted bootloader classification"); + return installed_variant(image, size, version); +} +} } } + // Include the actual driver. Only its target-specific naked SVC adapter is // replaced: function-like 'volatile(...)' captures the extended assembly, but // leaves ordinary 'volatile uint8_t *' memory reads unchanged. The adapter is // an explicit failing trap, never a successful simulated flash operation. +// A classification boundary permits a reset injection while still using the +// real classifier for every completed check. #define __attribute__(...) #define __asm #define volatile(...) migration_test::mbr_command() +#define installed_variant installed_variant_for_runtime #include "helpers/ota/BootloaderMigration.cpp" +#undef installed_variant #undef volatile #undef __asm #undef __attribute__ @@ -83,8 +119,8 @@ void NVIC_SystemReset() { uint32_t sd_softdevice_is_enabled(uint8_t *enabled) { ++migration_test::softdevice_checks; - *enabled = 0; - return NRF_SUCCESS; + *enabled = migration_test::softdevice_enabled; + return migration_test::softdevice_status; } uint32_t sd_softdevice_disable() { @@ -92,12 +128,34 @@ uint32_t sd_softdevice_disable() { throw std::runtime_error("unexpected SoftDevice disable in state-machine test"); } -uint32_t sd_power_gpregret_get(uint32_t, uint32_t *result) { - *result = 0; - return NRF_SUCCESS; +uint32_t sd_power_gpregret_get(uint32_t index, uint32_t *result) { + assert(index == 0); + ++migration_test::retained_reads; + *result = power_registers.GPREGRET; + return migration_test::retained_get_status; +} + +uint32_t sd_power_gpregret_clr(uint32_t index, uint32_t mask) { + assert(index == 0 && mask == 0xFF); + assert(!mesh::ota::bootloader_migration_pending()); + ++migration_test::retained_clears; + if (migration_test::retained_clear_status == NRF_SUCCESS) + power_registers.GPREGRET &= ~mask; + return migration_test::retained_clear_status; +} + +uint32_t sd_power_gpregret_set(uint32_t index, uint32_t mask) { + assert(index == 0 && mask == mesh::ota::ATTEMPT_MARKER); + assert(!mesh::ota::bootloader_migration_pending()); + ++migration_test::retained_sets; + if (migration_test::retained_set_status == NRF_SUCCESS) + power_registers.GPREGRET |= mask; + return migration_test::retained_set_status; } void SHA256::reset() { + if (migration_test::interrupt_hash) + throw std::runtime_error("interrupted bootloader check"); } void SHA256::update(const void *, size_t) { } @@ -106,16 +164,47 @@ void SHA256::finalize(void *out, size_t size) { // Scheduling tests exercise the full memory-read loop; cryptography itself // is the production SHA256 library's contract, not this hardware stub. std::memset(out, 0, size); - if (migration_test::candidate_installed) + if (migration_test::startup_metadata) + std::memcpy(out, migration_test::application_hash, size); + else if (migration_test::candidate_installed) std::memcpy(out, migration_candidate_sha256, size); } namespace mesh { namespace ota { -bool ota_self_firmware(SelfFwInfo &) { +bool ota_self_firmware(SelfFwInfo &out) { ++migration_test::endf_checks; + if (migration_test::startup_metadata && migration_test::startup_valid) { + out.valid = true; + out.body_len = 256 - ENDF_LEN; + out.image_len = 256; + return true; + } return false; // Stop at the first install preflight, before hardware access. } + +MigrationAutoLock bootloader_migration_auto_lock(FILESYSTEM *fs, const uint8_t hash[32]) { + ++migration_test::store_reads; + assert(std::memcmp(hash, migration_test::application_hash, 32) == 0); + if (!fs || !migration_test::store_available) return MigrationAutoLock::Unavailable; + if (!migration_test::durable_lock_present) return MigrationAutoLock::Eligible; + return std::memcmp(hash, migration_test::durable_lock_hash, 32) == 0 + ? MigrationAutoLock::Locked : MigrationAutoLock::PreviousImage; +} + +bool bootloader_migration_lock_auto(FILESYSTEM *fs, const uint8_t hash[32]) { + ++migration_test::store_lock_calls; + assert(!bootloader_migration_pending()); + assert(std::memcmp(hash, migration_test::application_hash, 32) == 0); + if (!fs || !migration_test::store_available) return false; + if (migration_test::durable_lock_present && + std::memcmp(migration_test::durable_lock_hash, hash, 32) == 0) return true; + if (!migration_test::store_write_ok) return false; + ++migration_test::store_writes; + std::memcpy(migration_test::durable_lock_hash, hash, 32); + migration_test::durable_lock_present = true; + return true; +} } // namespace ota } // namespace mesh @@ -123,6 +212,7 @@ namespace { using namespace mesh::ota; const char confirmation[] = "0123456789ABCDEF"; char reply[160]; +FILESYSTEM test_filesystem; void wr16(uint8_t *p, uint16_t value) { p[0] = value; @@ -137,6 +227,8 @@ void wr32(uint8_t *p, uint32_t value) { uint8_t *installed_manifest(uint32_t version) { uint8_t *boot = reinterpret_cast(migration::BOOT_BASE); std::memset(boot, 0xFF, migration::BOOT_SIZE); + std::snprintf(reinterpret_cast(boot + 0x100), 64, + "UF2 Bootloader BL_0x%08X\r\n", version); uint8_t *p = boot + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; wr32(p, OTA_BOOT_MANIFEST_MAGIC0); wr32(p + 4, OTA_BOOT_MANIFEST_MAGIC1); @@ -166,15 +258,26 @@ void seal_manifest(uint8_t *p) { OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40)); } +void replace_boot_label(const char *label) { + auto *boot = reinterpret_cast(migration::BOOT_BASE); + std::memset(boot + 0x100, 0xFF, 256); + std::strcpy(reinterpret_cast(boot + 0x100), label); + seal_manifest(boot + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET); +} + void arm() { assert(bootloader_migration_request(confirmation, reply, sizeof(reply))); assert(bootloader_migration_pending()); assert(std::strstr(reply, "OK migration armed")); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + assert(migration_test::durable_lock_present); + assert(std::memcmp(migration_test::durable_lock_hash, + migration_test::application_hash, 32) == 0); } -void no_install() { +void no_install(unsigned softdevice_reads = 0) { assert(migration_test::endf_checks == 0); - assert(migration_test::softdevice_checks == 0); + assert(migration_test::softdevice_checks == softdevice_reads); assert(migration_test::disables == 0); assert(migration_test::resets == 0); assert(migration_test::copies == 0); @@ -186,6 +289,33 @@ void status_result(unsigned result) { std::snprintf(expected, sizeof(expected), "last=%u", result); assert(std::strstr(reply, expected)); } + +void begin_application(FILESYSTEM *fs = &test_filesystem) { + migration_test::startup_metadata = true; + bootloader_migration_begin(fs); + migration_test::startup_metadata = false; + migration_test::endf_checks = 0; +} + +void restart_application(FILESYSTEM *fs = &test_filesystem) { + // Recreate zero-initialized driver state while retaining GPREGRET, as a + // hardware reset would. Test counters deliberately survive for assertions. + mesh::ota::pending = false; + mesh::ota::automatic_attempted = false; + mesh::ota::automatic_blocked = false; + mesh::ota::armed_at = 0; + mesh::ota::last_result = 0; + mesh::ota::result_is_current = false; + mesh::ota::already_installed = false; + mesh::ota::state_fs = nullptr; + std::memset(mesh::ota::firmware_hash, 0, sizeof(mesh::ota::firmware_hash)); + mesh::ota::automatic_lock = MigrationAutoLock::Unavailable; + mesh::ota::firmware_identified = false; + mesh::ota::automatic_variant = migration::InstalledVariant::Unknown; + mesh::ota::loader_excluded = false; + migration_test::now = 0; + begin_application(fs); +} } // namespace int main(int argc, char **argv) { @@ -198,8 +328,401 @@ int main(int argc, char **argv) { assert(mmap(reinterpret_cast(0xF0000), 0x10000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) != MAP_FAILED); #endif +#if defined(_WIN32) + assert(VirtualAlloc(reinterpret_cast(0x20000), 0x10000, + MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE) != nullptr); +#else + assert(mmap(reinterpret_cast(0x26000), 4096, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) != MAP_FAILED); +#endif + seal_manifest(installed_manifest(0x02040BFF)); + begin_application(); const char *scenario = argv[1]; - if (std::strcmp(scenario, "already-installed") == 0) { + if (std::strcmp(scenario, "automatic-recovery-prefix") == 0 || + std::strcmp(scenario, "automatic-recovery-suffix") == 0 || + std::strcmp(scenario, "automatic-unlabelled-loader") == 0 || + std::strcmp(scenario, "automatic-ambiguous-loader") == 0 || + std::strcmp(scenario, "automatic-foreign-loader") == 0 || + std::strcmp(scenario, "automatic-damaged-loader") == 0) { + if (std::strcmp(scenario, "automatic-recovery-prefix") == 0) + replace_boot_label("UF2 Bootloader R_0x02040BFF\r\n"); + else if (std::strcmp(scenario, "automatic-recovery-suffix") == 0) + replace_boot_label("UF2 Bootloader v0.11.0-OTAFIX2.4.11-recovery-allow-all\r\n"); + else if (std::strcmp(scenario, "automatic-unlabelled-loader") == 0) + replace_boot_label(""); + else if (std::strcmp(scenario, "automatic-ambiguous-loader") == 0) + replace_boot_label("UF2 Bootloader BL_0x02040BFF\r\nUF2 Bootloader BL_0x02040BFF\r\n"); + else { + auto *p = reinterpret_cast(migration::BOOT_BASE) + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; + if (std::strcmp(scenario, "automatic-foreign-loader") == 0) { + wr32(p + 20, migration_board_id ^ 1); + seal_manifest(p); + } else { + p[40] ^= 1; + } + } + now = 70000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + assert(durable_lock_present && store_writes == 1); + no_install(softdevice_checks); + bootloader_migration_status(reply, sizeof(reply)); + const bool recovery = std::strstr(scenario, "recovery") != nullptr; + assert(std::strstr(reply, recovery ? "auto=recovery" : "auto=unrecognized")); + now = 140000; + bootloader_migration_tick(true, true); + no_install(softdevice_checks); + arm(); // Recovery/unknown loaders remain an explicit operator decision. + assert(durable_lock_present && store_writes == 1); + now = 141500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + } else if (std::strcmp(scenario, "previous-image-interrupted-classification") == 0) { + durable_lock_present = true; + std::memcpy(durable_lock_hash, application_hash, 32); + durable_lock_hash[31] ^= 1; + power_registers.GPREGRET = mesh::ota::ATTEMPT_MARKER; + restart_application(); + now = 70000; + interrupt_classification = true; + bool interrupted = false; + try { + bootloader_migration_tick(true, true); + } catch (const std::runtime_error &error) { + assert(std::strcmp(error.what(), "interrupted bootloader classification") == 0); + interrupted = true; + } + assert(interrupted && classification_checks == 1); + assert(!bootloader_migration_pending()); + assert(durable_lock_present && store_writes == 1); + assert(std::memcmp(durable_lock_hash, application_hash, 32) == 0); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + const unsigned reads_before_reset = softdevice_checks; + interrupt_classification = false; + restart_application(); + now = 70000; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(classification_checks == 1 && store_writes == 1); + no_install(reads_before_reset); + } else if (std::strcmp(scenario, "durable-same-firmware-cold-boot") == 0 || + std::strcmp(scenario, "durable-same-firmware-old-loader") == 0) { + now = 70000; + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + assert(durable_lock_present && store_writes == 1); + const bool installed = std::strcmp(scenario, "durable-same-firmware-cold-boot") == 0; + if (installed) seal_manifest(installed_manifest(migration_boot_version)); + power_registers.GPREGRET = 0; // A power cycle loses the retained register. + restart_application(); + const unsigned reads_before = softdevice_checks; + now = 70000; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(store_writes == 1); + no_install(reads_before); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(bootloader_migration_pending() == !installed); + assert(store_writes == 1); // An explicit retry reuses this image's lock. + } else if (std::strcmp(scenario, "durable-different-firmware") == 0 || + std::strcmp(scenario, "durable-different-firmware-failure-marker") == 0) { + now = 70000; + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + assert(durable_lock_present && store_writes == 1); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + if (std::strcmp(scenario, "durable-different-firmware-failure-marker") == 0) + power_registers.GPREGRET = 0xCC; + application_hash[31] ^= 1; // The full 32-byte identity must distinguish images. + restart_application(); + now = 70000; + assert(bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); // Old retained guards belong to the old image. + assert(store_writes == 2); + assert(std::memcmp(durable_lock_hash, application_hash, 32) == 0); + assert(endf_checks == 0); + } else if (std::strcmp(scenario, "automatic-store-unavailable") == 0 || + std::strcmp(scenario, "automatic-store-null") == 0 || + std::strcmp(scenario, "automatic-invalid-application") == 0 || + std::strcmp(scenario, "automatic-store-write-failure") == 0) { + if (std::strcmp(scenario, "automatic-store-unavailable") == 0) + store_available = false; + else if (std::strcmp(scenario, "automatic-store-null") == 0) { + // The role has no durable filesystem handle at this boot. + } + else if (std::strcmp(scenario, "automatic-invalid-application") == 0) + startup_valid = false; + else + store_write_ok = false; + restart_application(std::strcmp(scenario, "automatic-store-null") == 0 + ? nullptr : &test_filesystem); + now = 70000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + assert(!durable_lock_present && store_writes == 0); + no_install(softdevice_checks); + assert(!bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(!bootloader_migration_pending()); + no_install(softdevice_checks); + seal_manifest(installed_manifest(migration_boot_version)); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(!bootloader_migration_pending()); // A version no-op needs no store write. + } else if (std::strcmp(scenario, "automatic-delay") == 0 || + std::strcmp(scenario, "automatic-arm-enabled") == 0) { + softdevice_enabled = std::strcmp(scenario, "automatic-arm-enabled") == 0; + assert(!bootloader_migration_service_due()); + now = 69999; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + no_install(); + now = 70000; + assert(bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + assert(bootloader_migration_service_due()); + no_install(3); + assert(durable_lock_present && store_writes == 1); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + assert(retained_reads == (softdevice_enabled ? 3u : 0u)); + assert(retained_clears == (softdevice_enabled ? 1u : 0u)); + assert(retained_sets == (softdevice_enabled ? 1u : 0u)); + now = 71499; + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + no_install(3); + now = 71500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + now = 140000; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); // A failed preflight must not be retried automatically. + assert(softdevice_checks == 3); + status_result(1); + } else if (std::strcmp(scenario, "automatic-busy") == 0) { + now = 70000; + bootloader_migration_tick(false, true); + assert(!bootloader_migration_pending()); + assert(bootloader_migration_service_due()); + no_install(); + now = 72000; + bootloader_migration_tick(true, false); + assert(!bootloader_migration_pending()); + assert(bootloader_migration_service_due()); + no_install(); + now = 74000; + bootloader_migration_tick(false, false); + assert(!bootloader_migration_pending()); + assert(bootloader_migration_service_due()); + no_install(); + now = 76000; + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + no_install(3); + now = 77499; + bootloader_migration_tick(true, true); + no_install(3); + now = 77500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); // Grace starts when the deferred request is armed. + } else if (std::strcmp(scenario, "automatic-same-version") == 0 || + std::strcmp(scenario, "automatic-same-version-enabled") == 0 || + std::strcmp(scenario, "automatic-newer-version") == 0 || + std::strcmp(scenario, "automatic-exact-image") == 0) { + softdevice_enabled = std::strcmp(scenario, "automatic-same-version-enabled") == 0; + if (std::strcmp(scenario, "automatic-exact-image") == 0) { + candidate_installed = true; + } else { + seal_manifest(installed_manifest( + (std::strcmp(scenario, "automatic-same-version") == 0 || softdevice_enabled) + ? migration_boot_version : 0x02040D01)); + } + now = 70000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(3); + assert(power_registers.GPREGRET == 0); // Clear the provisional check marker. + assert(retained_reads == (softdevice_enabled ? 3u : 0u)); + assert(retained_clears == (softdevice_enabled ? 2u : 0u)); + assert(retained_sets == (softdevice_enabled ? 1u : 0u)); + now = 140000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(3); + bootloader_migration_status(reply, sizeof(reply)); + assert(std::strstr(reply, candidate_installed ? "already installed" : "up to date")); + } else if (std::strcmp(scenario, "automatic-retained-failure") == 0) { + power_registers.GPREGRET = 0xCC; + now = 70000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(1); + now = 140000; + bootloader_migration_tick(true, true); + no_install(1); // Retained failures must not create a warm-reset retry loop. + bootloader_migration_status(reply, sizeof(reply)); + assert(std::strstr(reply, "auto=blocked")); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(bootloader_migration_pending()); + assert(std::strstr(reply, "OK migration armed")); + now = 141500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); // An explicit operator request may still retry. + assert(!bootloader_migration_pending()); + } else if (std::strcmp(scenario, "manual-cancel-consumes-automatic") == 0 || + std::strcmp(scenario, "manual-failure-consumes-automatic") == 0) { + arm(); + now = 1500; + const bool cancel = std::strcmp(scenario, "manual-cancel-consumes-automatic") == 0; + bootloader_migration_tick(true, !cancel); + assert(!bootloader_migration_pending()); + assert(endf_checks == (cancel ? 0u : 1u)); + now = 70000; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(endf_checks == (cancel ? 0u : 1u)); + assert(softdevice_checks == 1); + } else if (std::strcmp(scenario, "automatic-cancel") == 0) { + now = 70000; + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + now = 70100; + bootloader_migration_tick(true, false); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(3); + now = 140000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(3); + status_result(16); + } else if (std::strcmp(scenario, "invalid-confirmation-preserves-automatic") == 0) { + now = 1000; + assert(!bootloader_migration_request("0123456789ABCDE0", reply, sizeof(reply))); + assert(!bootloader_migration_pending()); + now = 70000; + assert(bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(bootloader_migration_pending()); + no_install(3); + now = 71500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + } else if (std::strcmp(scenario, "automatic-status-read-failure") == 0 || + std::strcmp(scenario, "automatic-marker-read-failure") == 0) { + power_registers.GPREGRET = mesh::ota::ATTEMPT_MARKER; + if (std::strcmp(scenario, "automatic-status-read-failure") == 0) { + softdevice_status = 1; + } else { + softdevice_enabled = true; + retained_get_status = 1; + } + now = 70000; + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + no_install(1); + assert(retained_reads == (softdevice_enabled ? 1u : 0u)); + assert(retained_clears == 0 && retained_sets == 0); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + bootloader_migration_status(reply, sizeof(reply)); + assert(std::strstr(reply, "auto=blocked")); + } else if (std::strcmp(scenario, "guard-softdevice-enabled") == 0) { + softdevice_enabled = true; + power_registers.GPREGRET = 0xAA; + arm(); + assert(retained_clears == 1 && retained_sets == 1); + no_install(1); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(retained_clears == 1 && retained_sets == 1); // Duplicates do no work. + now = 1500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + } else if (std::strcmp(scenario, "guard-softdevice-status-failure") == 0 || + std::strcmp(scenario, "guard-marker-read-failure") == 0 || + std::strcmp(scenario, "guard-clear-failure") == 0 || + std::strcmp(scenario, "guard-set-failure") == 0) { + power_registers.GPREGRET = 0x37; + if (std::strcmp(scenario, "guard-softdevice-status-failure") == 0) { + softdevice_status = 1; + } else { + softdevice_enabled = true; + if (std::strcmp(scenario, "guard-marker-read-failure") == 0) + retained_get_status = 1; + else if (std::strcmp(scenario, "guard-clear-failure") == 0) + retained_clear_status = 1; + else + retained_set_status = 1; + } + assert(!bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(std::strstr(reply, "ERR bootloader reboot guard unavailable")); + assert(!bootloader_migration_pending()); + no_install(1); + assert(retained_clears == (softdevice_enabled && !retained_get_status ? 1u : 0u)); + assert(retained_sets == (retained_set_status ? 1u : 0u)); + assert(power_registers.GPREGRET == (retained_set_status ? 0u : 0x37u)); + now = 70000; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + no_install(1); + status_result(17); + } else if (std::strcmp(scenario, "automatic-interrupted-check") == 0 || + std::strcmp(scenario, "automatic-interrupted-arm") == 0) { + const bool during_check = std::strcmp(scenario, "automatic-interrupted-check") == 0; + now = 70000; + interrupt_hash = during_check; + bool interrupted = false; + try { + bootloader_migration_tick(true, true); + } catch (const std::runtime_error &error) { + assert(during_check); + assert(std::strcmp(error.what(), "interrupted bootloader check") == 0); + interrupted = true; + } + assert(interrupted == during_check); + assert(bootloader_migration_pending() == !during_check); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + assert(durable_lock_present); + assert(endf_checks == 0); + const unsigned initial_reads = softdevice_checks; + interrupt_hash = false; + restart_application(); + now = 69999; + assert(!bootloader_migration_service_due()); + now = 70000; + assert(!bootloader_migration_service_due()); + bootloader_migration_tick(true, true); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_service_due()); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); + no_install(initial_reads); + now = 140000; + bootloader_migration_tick(true, true); + no_install(initial_reads); // A second reset-time check must not repeat. + bootloader_migration_status(reply, sizeof(reply)); + assert(std::strstr(reply, "auto=locked for this firmware")); + arm(); // An operator can retry after correcting an interruption's cause. + now = 141500; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + assert(!bootloader_migration_pending()); + } else if (std::strcmp(scenario, "already-installed") == 0) { + power_registers.GPREGRET = 0x37; candidate_installed = true; assert(bootloader_migration_request(confirmation, reply, sizeof(reply))); assert(!bootloader_migration_pending()); @@ -207,10 +730,12 @@ int main(int argc, char **argv) { now = 2000; bootloader_migration_tick(true, true); no_install(); + assert(power_registers.GPREGRET == 0x37); bootloader_migration_status(reply, sizeof(reply)); assert(std::strstr(reply, "already installed")); } else if (std::strcmp(scenario, "same-version") == 0 || std::strcmp(scenario, "newer-version") == 0) { + power_registers.GPREGRET = 0x37; const uint32_t version = std::strcmp(scenario, "same-version") == 0 ? migration_boot_version : 0x02040D01; seal_manifest(installed_manifest(version)); @@ -229,6 +754,7 @@ int main(int argc, char **argv) { now += 2000; bootloader_migration_tick(true, true); no_install(); + assert(power_registers.GPREGRET == 0x37); } } else if (std::strcmp(scenario, "easy-update") == 0 || std::strcmp(scenario, "older-preview") == 0) { @@ -239,7 +765,7 @@ int main(int argc, char **argv) { assert(std::strstr(reply, "OK migration armed")); now = 1499; bootloader_migration_tick(true, true); - no_install(); + no_install(1); ++now; bootloader_migration_tick(true, true); assert(endf_checks == 1); @@ -279,13 +805,13 @@ int main(int argc, char **argv) { arm(); now = 1499; bootloader_migration_tick(true, true); - no_install(); + no_install(1); now = 1500; bootloader_migration_tick(false, true); - no_install(); + no_install(1); now = 29999; bootloader_migration_tick(false, true); - no_install(); + no_install(1); assert(bootloader_migration_pending()); bootloader_migration_tick(true, true); assert(endf_checks == 1); @@ -309,7 +835,7 @@ int main(int argc, char **argv) { now = 30000; bootloader_migration_tick(true, true); assert(!bootloader_migration_pending()); - no_install(); + no_install(1); status_result(15); bootloader_migration_tick(true, true); assert(endf_checks == 0); @@ -318,7 +844,7 @@ int main(int argc, char **argv) { now = 100; bootloader_migration_tick(false, false); assert(!bootloader_migration_pending()); - no_install(); + no_install(1); status_result(16); now = 2000; bootloader_migration_tick(true, true); @@ -335,7 +861,7 @@ int main(int argc, char **argv) { now = 1500; bootloader_migration_tick(true, false); assert(!bootloader_migration_pending()); - no_install(); + no_install(1); status_result(16); } else if (std::strcmp(scenario, "stale-retained-result") == 0) { power_registers.GPREGRET = 0xCC; @@ -352,14 +878,14 @@ int main(int argc, char **argv) { bootloader_migration_tick(true, false); assert(!bootloader_migration_pending()); status_result(16); - assert(power_registers.GPREGRET == 0xCC); + assert(power_registers.GPREGRET == mesh::ota::ATTEMPT_MARKER); assert(endf_checks == 0); } else if (std::strcmp(scenario, "clock-wrap") == 0) { now = UINT32_MAX - 999u; arm(); now += 1499u; bootloader_migration_tick(true, true); - no_install(); + no_install(1); ++now; bootloader_migration_tick(true, true); assert(endf_checks == 1);