diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 753846199..4b9fe7174 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -279,6 +279,7 @@ jobs: python3 -B test/test_sensecap_bootloader_runtime.py -v 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 - 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 5f446176e..7e1f67671 100644 --- a/docs/nrf52_embedded_bootloader.md +++ b/docs/nrf52_embedded_bootloader.md @@ -78,17 +78,19 @@ P1 image, not a recovery image. remote admin CLI: ```text - ota bootloader embedded status - ota bootloader embedded audit - ota bootloader embedded install HASH16 + ota bootloader update ``` - Replace `HASH16` with the image confirmation shown by `status`. Save the audit - before installation. The acknowledgment means armed; replacement waits for - the reply and queued/in-flight radio transmissions to drain. -3. After reboot, check `get bootloader.ver` and repeat the audit. Confirm 2.4.12 - and compare the application, filesystem, settings and SoftDevice hashes. - Check status before repeating a command whose reply was lost. + The command checks the installed bootloader and skips the update if a valid, + matching bootloader is already 2.4.12 or newer. Repeating the command while + it is armed is harmless. An `armed` reply means the update is queued until + the CLI reply and queued/in-flight radio transmissions drain. +3. After reboot, check `get bootloader.ver`. Optional diagnostics are + `ota bootloader embedded status` and `ota bootloader embedded audit`; save an + audit before installation to compare the application, filesystem, settings + and SoftDevice hashes afterward. The older + `ota bootloader embedded install HASH16` command remains available with the + confirmation shown by `status`. The bridge validates the live application, candidate hash, installed identity, chip/layout, MBR addresses, watchdog and flash protection before copying. A diff --git a/scripts/qualify_nrf52_embedded_bootloader.py b/scripts/qualify_nrf52_embedded_bootloader.py index 8cf2e4250..216a74a13 100644 --- a/scripts/qualify_nrf52_embedded_bootloader.py +++ b/scripts/qualify_nrf52_embedded_bootloader.py @@ -51,7 +51,7 @@ def qualify(directory, manifest_path): "manifest_sha256": MANIFEST_SHA256, "legacy_sha256": metadata.get("legacy_sha256"), "app_offset": offset, "image_bytes": BOOT_SIZE, - "activation": "ota bootloader embedded install HASH16", + "activation": "ota bootloader update", } manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") return manifest["embedded_bootloader"] diff --git a/src/helpers/ota/BootloaderMigration.cpp b/src/helpers/ota/BootloaderMigration.cpp index 96ec2f475..aaaa0ee8f 100644 --- a/src/helpers/ota/BootloaderMigration.cpp +++ b/src/helpers/ota/BootloaderMigration.cpp @@ -68,6 +68,15 @@ void hash_region(uint32_t address, uint32_t size, uint8_t output[32]) { hash.finalize(output, 32); } +bool installed_up_to_date() { + OtaBootloaderIdentity installed; + return ota_bootloader_identity_from_image( + reinterpret_cast(migration::BOOT_BASE), migration::BOOT_SIZE, installed) && + migration::installed_up_to_date(installed, migration_board_id, migration_device_name, + migration_boot_version, mota_nrf52_app_base(), + migration_softdevice_fwid); +} + uint8_t retained_result() { if (result_is_current) return last_result; uint8_t enabled = 0; @@ -186,20 +195,28 @@ 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, - pending ? "armed" : already_installed ? "already installed" : "idle", retained_result()); + pending ? "armed" : already_installed ? "already installed" : + installed_up_to_date() ? "up to date" : "idle", retained_result()); } bool bootloader_migration_request(const char *confirmation, char *reply, size_t capacity) { char hash[17]; hex8(migration_candidate_sha256, hash); - if (pending) { - snprintf(reply, capacity, "ERR bootloader migration is already armed"); - return false; - } - if (strcmp(confirmation, hash) != 0) { + if (confirmation && strcmp(confirmation, hash) != 0) { snprintf(reply, capacity, "ERR confirm exact image: ota bootloader embedded install %s", hash); return false; } + if (pending) { + snprintf(reply, capacity, "OK bootloader update already armed"); + return true; + } + if (installed_up_to_date()) { + already_installed = false; + last_result = 0; + result_is_current = true; + snprintf(reply, capacity, "OK bootloader already up to date"); + return true; + } // Bootloader diagnostics already read this memory-mapped region while // SoftDevice runs. Detect a no-op before stopping it so BLE stays usable. uint8_t digest[32]; diff --git a/src/helpers/ota/BootloaderMigration.h b/src/helpers/ota/BootloaderMigration.h index d02384398..e0d8bfe8b 100644 --- a/src/helpers/ota/BootloaderMigration.h +++ b/src/helpers/ota/BootloaderMigration.h @@ -6,6 +6,7 @@ namespace mesh { namespace ota { bool bootloader_migration_pending(); +// 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); void bootloader_migration_audit(char *reply, size_t capacity); diff --git a/src/helpers/ota/BootloaderMigrationPolicy.h b/src/helpers/ota/BootloaderMigrationPolicy.h index 646b116f4..83f893832 100644 --- a/src/helpers/ota/BootloaderMigrationPolicy.h +++ b/src/helpers/ota/BootloaderMigrationPolicy.h @@ -101,6 +101,16 @@ 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) { + 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) && + installed.boot_version >= version; +} + } // namespace migration } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 1e22b79d7..40116eba8 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -1031,6 +1031,17 @@ static __attribute__((noinline)) bool handle_control_command( } else if (is_cmd(a, "bootloader|blupdate", &rest)) { #if defined(OTA_BOOTLOADER_MIGRATION) + const char* update_args = nullptr; + if (is_cmd(rest, "update", &update_args)) { + if (*update_args) { + strcpy(reply, "ERR usage: ota bootloader update"); + } else if (!c.embeddedBootloaderIdle()) { + strcpy(reply, "ERR another OTA operation is active"); + } else { + bootloader_migration_request(nullptr, reply, 160); + } + return true; + } const char* embedded = nullptr; if (is_cmd(rest, "embedded", &embedded)) { if (*embedded == 0 || strcmp(embedded, "status") == 0) { diff --git a/test/test_bootloader_update_cli.py b/test/test_bootloader_update_cli.py new file mode 100644 index 000000000..fc09b0d12 --- /dev/null +++ b/test/test_bootloader_update_cli.py @@ -0,0 +1,209 @@ +"""Run the production bootloader CLI branch with and without an embedded image.""" + +import os +from pathlib import Path +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 +#include + +namespace mesh { struct Utils { + static bool fromHex(uint8_t* out, size_t size, const char* text) { + for (size_t i = 0; i < size; i++) { + unsigned value; + if (sscanf(text + i * 2, "%2x", &value) != 1) return false; + out[i] = value; + } + return true; + } + static void toHex(char* out, const uint8_t* bytes, size_t size) { + for (size_t i = 0; i < size; i++) sprintf(out + i * 2, "%02X", bytes[i]); + } +}; } +struct OtaBootloaderIdentity { + bool present = true, crc_ok = true; + uint32_t board_id = 0x12345678, crc32 = 0; + const char* device_name = "TEST_DFU"; +}; +struct OtaBlCaps { unsigned apply_abi = 3, storage_flags = 1; }; +static uint32_t ota_bootloader_target_id(const OtaBootloaderIdentity& id) { return id.board_id; } +static bool ota_bootloader_self_update_caps_valid(const OtaBlCaps&) { return true; } +struct MotaManifest { + uint8_t image_hash[8] = {}; + bool is_bootloader() const { return true; } +}; +struct OtaManager { + enum FetchState { IDLE, WANT_MANIFEST, FETCHING, COMPLETE, FAILED, PAUSED }; + FetchState state = IDLE; + bool serve_jobs = false, manifest_jobs = false; + uint8_t mid[4] = {0x12, 0x34, 0x56, 0x78}; + FetchState fetchState() const { return state; } + bool pendingServeJobs() const { return serve_jobs; } + bool pendingManifestJobs() const { return manifest_jobs; } + const uint8_t* fetchManifestId() const { return mid; } + bool fetched_is_bootloader() const { return true; } +}; +struct OtaContext { + OtaManager manager; + struct Store { unsigned staged_size() const { return 100; } } fetch_store; + bool apply_pending = false, bootloader_apply_pending = false, fetch_to_folder = false; + bool sd_cache_fetching = false; + unsigned staged_installs = 0; + OtaBootloaderIdentity identity; + OtaBlCaps caps; + bool sdCacheFetching() const { return sd_cache_fetching; } + @IDLE@ + const OtaBootloaderIdentity& bootloaderIdentity() const { return identity; } + const OtaBlCaps& bootloaderUpdateCaps() const { return caps; } + bool apply_fetched_bootloader(const uint8_t* mid, const uint8_t* hash, char* reply) { + assert(mid[0] == 0x12 && mid[3] == 0x78 && hash[0] == 0x01 && hash[7] == 0xEF); + staged_installs++; + strcpy(reply, "staged install"); + return true; + } +}; +static bool staged_manifest(OtaContext&, MotaManifest&) { return true; } +static unsigned requests = 0, statuses = 0, audits = 0; +static bool automatic = false; +static std::string confirmation; +static void bootloader_migration_status(char* out, size_t) { statuses++; strcpy(out, "STATUS"); } +static void bootloader_migration_audit(char* out, size_t) { audits++; strcpy(out, "AUDIT"); } +static bool bootloader_migration_request(const char* hash, char* out, size_t) { + requests++; + automatic = hash == nullptr; + confirmation = hash ? hash : ""; + strcpy(out, automatic ? "AUTO" : "HASH"); + return true; +} +@HELPERS@ +static bool command(const char* a, char* reply, OtaContext& c) { + const char* rest = nullptr; + @COMMAND@ + else { return false; } + return true; +} +static void expect(const char* text, const char* expected, OtaContext& c) { + char reply[160] = {}; + assert(command(text, reply, c)); + if (strcmp(reply, expected)) { + fprintf(stderr, "%s: expected '%s', got '%s'\n", text, expected, reply); + assert(false); + } +} +int main() { + OtaContext c; +#if defined(OTA_BOOTLOADER_MIGRATION) + expect("bootloader update ignored", "ERR usage: ota bootloader update", c); + assert(requests == 0); + expect("bootloader update", "AUTO", c); + assert(requests == 1 && automatic); + expect("blupdate update ", "AUTO", c); + assert(requests == 2 && automatic); + for (auto state : {OtaManager::IDLE, OtaManager::COMPLETE, OtaManager::FAILED}) { + c.manager.state = state; + expect("bootloader update", "AUTO", c); + } + for (auto state : {OtaManager::FETCHING, OtaManager::WANT_MANIFEST, OtaManager::PAUSED}) { + c.manager.state = state; + expect("bootloader update", "ERR another OTA operation is active", c); + } + c.manager.state = OtaManager::IDLE; + for (bool* flag : {&c.apply_pending, &c.bootloader_apply_pending, &c.manager.serve_jobs, + &c.manager.manifest_jobs, &c.sd_cache_fetching}) { + *flag = true; + expect("bootloader update", "ERR another OTA operation is active", c); + expect("bootloader embedded install 0123456789ABCDEF", + "ERR another OTA operation is active", c); + *flag = false; + } + assert(requests == 5); + expect("bootloader embedded", "STATUS", c); + expect("bootloader embedded status", "STATUS", c); + expect("bootloader embedded audit", "AUDIT", c); + assert(statuses == 2 && audits == 1 && requests == 5); + expect("bootloader embedded install 0123456789ABCDEF", "HASH", c); + assert(requests == 6 && !automatic && confirmation == "0123456789ABCDEF"); + expect("bootloader embedded install", "HASH", c); + assert(requests == 7 && !automatic && confirmation.empty()); + expect("bootloader embedded install 0123456789ABCDEF extra", "HASH", c); + assert(requests == 8 && !automatic && confirmation == "0123456789ABCDEF extra"); +#else + // Ordinary images keep their existing staged-update command surface. +#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + expect("bootloader update", "ERR usage: ota bootloader install ", c); + expect("bootloader update ignored", "ERR usage: ota bootloader install ", c); +#else + expect("bootloader update", "ERR this build cannot update its bootloader over LoRa", c); +#endif + assert(requests == 0); +#endif +#if defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY) && defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + expect("bootloader updatejunk", "ERR usage: ota bootloader install ", c); + expect("bootloader install", "ERR usage: ota bootloader install ", c); + c.manager.state = OtaManager::COMPLETE; + expect("bootloader install 12345678 0123456789ABCDEF", "OK | staged install", c); + assert(c.staged_installs == 1); + expect("bootloader install 12345678 0123456789ABCDEF extra", + "ERR usage: ota bootloader install ", c); + assert(c.staged_installs == 1); +#else + expect("bootloader install 12345678 0123456789ABCDEF", + "ERR this build cannot update its bootloader over LoRa", c); + assert(c.staged_installs == 0); +#endif +} +''' + + +def harness(): + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text(encoding="utf-8") + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text(encoding="utf-8") + return (SOURCE.replace("@IDLE@", body(context, "bool embeddedBootloaderIdle() const")) + .replace("@HELPERS@", "\n".join(body(cli, signature) for signature in ( + "static bool is_cmd(", "static bool parse_hex_exact("))) + .replace("@COMMAND@", body(cli, 'if (is_cmd(a, "bootloader|blupdate", &rest))'))) + + +class BootloaderUpdateCliTest(unittest.TestCase): + def test_production_dispatch_keeps_embedded_and_staged_updates_separate(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + variants = { + "embedded": ("OTA_BOOTLOADER_MIGRATION", "NRF52_PLATFORM", + "OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_SD_STORE"), + "staged": ("NRF52_PLATFORM", "OTA_INTERNAL_BOOTLOADER_UPDATE"), + "unsupported": (), + "seeder": ("NRF52_PLATFORM", "OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_SEEDER_ONLY"), + } + sanitize = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="bootloader-update-cli-") as directory: + source = Path(directory) / "cli.cpp" + source.write_text(harness(), encoding="ascii") + for name, macros in variants.items(): + with self.subTest(variant=name): + binary = Path(directory) / (name + (".exe" if os.name == "nt" else "")) + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", "-Wno-unused-parameter", *sanitize, + *["-D" + macro + "=1" for macro in macros], str(source), "-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_nrf52_embedded_variant.py b/test/test_nrf52_embedded_variant.py index b3a93a7d8..9451a7aa3 100644 --- a/test/test_nrf52_embedded_variant.py +++ b/test/test_nrf52_embedded_variant.py @@ -179,7 +179,7 @@ class PackagedApplicationTests(unittest.TestCase): self.assertEqual(result["release"], "2.4.12") self.assertEqual(result["profile"], self.profile["id"]) self.assertEqual(result["candidate_sha256"], hashlib.sha256(self.candidate).hexdigest()) - self.assertEqual(result["activation"], "ota bootloader embedded install HASH16") + self.assertEqual(result["activation"], "ota bootloader update") 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 ab79472fc..8aebd0139 100644 --- a/tools/mota/test_bootloader_migration.py +++ b/tools/mota/test_bootloader_migration.py @@ -100,7 +100,43 @@ int main(int argc,char **argv) { auto v7=identity(0x02040CFF,0x27000,0x123); 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],"monotonic")) { + if (!std::strcmp(argv[1],"up-to-date")) { + 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)); + installed.boot_version=0x02040CFF; + assert(installed_up_to_date(installed,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + installed.boot_version=0x02040D01; // Even a later preview must not be downgraded. + assert(installed_up_to_date(installed,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.present=false; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.crc_ok=false; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.board_id^=1; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.device_name[0]='X'; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.image_start+=4096; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; --bad.image_size; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.continuity_present=false; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.softdevice_family=132; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.softdevice_fwid=0x123; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.app_base=0x27000; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.layout_abi=2; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.compat_flags=1; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.boot_version=0; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + bad=installed; bad.boot_version=UINT32_MAX; + assert(!installed_up_to_date(bad,0x239A0029,name,0x02040CFF,0x26000,0xB6)); + } else if (!std::strcmp(argv[1],"monotonic")) { installed.boot_version=candidate.boot_version; assert(!installed_can_migrate(installed,candidate,true,true,0x26000,0xB6)); ++installed.boot_version; @@ -159,6 +195,9 @@ class MigrationPolicyTests(unittest.TestCase): def test_modern_loader_requires_strictly_newer_candidate(self): self.scenario('monotonic') + def test_installed_version_noop_requires_exact_identity_and_platform(self): + self.scenario('up-to-date') + def test_crc_exact_identity_and_runtime_platform(self): self.scenario('identity') diff --git a/tools/mota/test_bootloader_migration_runtime.py b/tools/mota/test_bootloader_migration_runtime.py index 15be44f60..df0ebaf89 100644 --- a/tools/mota/test_bootloader_migration_runtime.py +++ b/tools/mota/test_bootloader_migration_runtime.py @@ -2,9 +2,10 @@ """Exercise the production migration arm/tick state machine with host stubs. The ARM SVC adapter is replaced by a failing trap, and the EndF stub refuses -installation before hardware access. 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. +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. """ import os from pathlib import Path @@ -29,6 +30,7 @@ class MigrationRuntimeTests(unittest.TestCase): subprocess.run([ os.environ.get('CXX', 'g++'), '-std=c++17', '-O2', '-Wall', '-Wextra', '-DOTA_BOOTLOADER_MIGRATION', '-DNRF52_PLATFORM', '-DENABLE_OTA', + '-DMOTA_NRF52_TEST_APP_BASE=0x26000', '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', '-I', str(STUBS), '-I', str(ROOT / 'src'), '-I', str(ROOT / 'src/helpers/ota'), str(source), '-o', str(binary), @@ -44,6 +46,24 @@ class MigrationRuntimeTests(unittest.TestCase): def test_already_installed_preserves_running_application(self): self.run_scenario('already-installed') + def test_easy_update_needs_no_hash_and_preserves_install_preflight(self): + self.run_scenario('easy-update') + + def test_same_version_is_idle_before_and_after_repeated_requests(self): + self.run_scenario('same-version') + + def test_newer_preview_is_idle_and_cannot_be_downgraded(self): + self.run_scenario('newer-version') + + def test_older_preview_is_still_eligible_for_the_stable_update(self): + self.run_scenario('older-preview') + + def test_version_noop_requires_valid_matching_identity_and_continuity(self): + for mutation in ('board', 'name', 'family', 'fwid', 'app', 'abi', + 'continuity', 'crc'): + with self.subTest(mutation=mutation): + self.run_scenario('invalid-version-' + mutation) + def test_negative_control_noop_must_not_arm_or_stop_softdevice(self): driver = (ROOT / 'src/helpers/ota/BootloaderMigration.cpp').read_text() changed = driver.replace('if (memcmp(digest, migration_candidate_sha256, sizeof(digest)) == 0) {', @@ -60,6 +80,21 @@ class MigrationRuntimeTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertIn('!bootloader_migration_pending()', result.stderr) + def test_negative_control_version_noop_must_not_queue_an_install(self): + driver = (ROOT / 'src/helpers/ota/BootloaderMigration.cpp').read_text() + changed = driver.replace('if (installed_up_to_date()) {', 'if (false) {') + self.assertNotEqual(driver, changed) + harness = (STUBS / 'runtime.cpp').read_text().replace( + '#include "helpers/ota/BootloaderMigration.cpp"', changed) + with tempfile.TemporaryDirectory(prefix='migration-version-negative-') as directory: + directory = Path(directory) + source = directory / 'runtime.cpp' + source.write_text(harness) + binary = self.compile(directory, source) + result = subprocess.run([str(binary), 'same-version'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('!bootloader_migration_pending()', 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/runtime.cpp b/tools/mota/tests/migration_stubs/runtime.cpp index 37584c498..a5eef1dc2 100644 --- a/tools/mota/tests/migration_stubs/runtime.cpp +++ b/tools/mota/tests/migration_stubs/runtime.cpp @@ -1,6 +1,11 @@ #include "Arduino.h" #include "BootloaderMigrationResource.h" #include "SHA256.h" +// The test app-base override bypasses the target linker's declarations. +namespace mesh { namespace ota { +extern "C" uint32_t __flash_arduino_end[]; +extern "C" const uint8_t g_meshcore_internal_extrafs __attribute__((weak)); +} } #include "helpers/ota/OtaSelf.h" #include "nrf.h" #include "nrf_mbr.h" @@ -119,6 +124,48 @@ using namespace mesh::ota; const char confirmation[] = "0123456789ABCDEF"; char reply[160]; +void wr16(uint8_t *p, uint16_t value) { + p[0] = value; + p[1] = value >> 8; +} + +void wr32(uint8_t *p, uint32_t value) { + wr16(p, value); + wr16(p + 2, value >> 16); +} + +uint8_t *installed_manifest(uint32_t version) { + uint8_t *boot = reinterpret_cast(migration::BOOT_BASE); + std::memset(boot, 0xFF, migration::BOOT_SIZE); + uint8_t *p = boot + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET; + wr32(p, OTA_BOOT_MANIFEST_MAGIC0); + wr32(p + 4, OTA_BOOT_MANIFEST_MAGIC1); + wr16(p + 8, 1); + wr16(p + 10, 44); + wr32(p + 12, migration::BOOT_BASE); + wr32(p + 16, migration::BOOT_SIZE); + wr32(p + 20, migration_board_id); + std::memcpy(p + 24, migration_device_name, 16); + wr32(p + 44, OTA_BOOT_CONTINUITY_MAGIC0); + wr32(p + 48, OTA_BOOT_CONTINUITY_MAGIC1); + wr16(p + 52, 2); + wr16(p + 54, 32); + wr32(p + 56, version); + wr16(p + 60, 140); + wr16(p + 62, migration_softdevice_fwid); + wr32(p + 64, migration_app_base); + wr16(p + 68, 1); + wr16(p + 70, 0); + wr32(p + 72, 0); + return p; +} + +void seal_manifest(uint8_t *p) { + const uint8_t *boot = reinterpret_cast(migration::BOOT_BASE); + wr32(p + 40, ota_boot_image_crc32(boot, migration::BOOT_SIZE, + OTA_BOOT_CANDIDATE_MANIFEST_OFFSET + 40)); +} + void arm() { assert(bootloader_migration_request(confirmation, reply, sizeof(reply))); assert(bootloader_migration_pending()); @@ -162,6 +209,64 @@ int main(int argc, char **argv) { no_install(); 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) { + const uint32_t version = std::strcmp(scenario, "same-version") == 0 + ? migration_boot_version : 0x02040D01; + seal_manifest(installed_manifest(version)); + // Live status has to detect this before any request, as it would after reboot. + bootloader_migration_status(reply, sizeof(reply)); + assert(std::strstr(reply, "up to date")); + assert(!bootloader_migration_pending()); + assert(!bootloader_migration_request("0123456789ABCDE0", reply, sizeof(reply))); + assert(!bootloader_migration_pending()); + softdevice_checks = 0; // Status may read the retained diagnostic result. + const char *tokens[] = {nullptr, confirmation}; + for (const char *token : tokens) { + assert(bootloader_migration_request(token, reply, sizeof(reply))); + assert(!bootloader_migration_pending()); + assert(std::strstr(reply, "up to date")); + now += 2000; + bootloader_migration_tick(true, true); + no_install(); + } + } else if (std::strcmp(scenario, "easy-update") == 0 || + std::strcmp(scenario, "older-preview") == 0) { + if (std::strcmp(scenario, "older-preview") == 0) + seal_manifest(installed_manifest(0x02040CFE)); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(bootloader_migration_pending()); + assert(std::strstr(reply, "OK migration armed")); + now = 1499; + bootloader_migration_tick(true, true); + no_install(); + ++now; + bootloader_migration_tick(true, true); + assert(endf_checks == 1); + assert(!bootloader_migration_pending()); + } else if (std::strncmp(scenario, "invalid-version-", 16) == 0) { + uint8_t *p = installed_manifest(0x02040DFF); + const char *mutation = scenario + 16; + if (std::strcmp(mutation, "board") == 0) wr32(p + 20, migration_board_id ^ 1); + else if (std::strcmp(mutation, "name") == 0) p[24] = 'X'; + else if (std::strcmp(mutation, "family") == 0) wr16(p + 60, 132); + else if (std::strcmp(mutation, "fwid") == 0) wr16(p + 62, 0x123); + else if (std::strcmp(mutation, "app") == 0) wr32(p + 64, 0x27000); + else if (std::strcmp(mutation, "abi") == 0) wr16(p + 68, 2); + else if (std::strcmp(mutation, "continuity") == 0) std::memset(p + 44, 0xFF, 32); + else assert(std::strcmp(mutation, "crc") == 0); + seal_manifest(p); + if (std::strcmp(mutation, "crc") == 0) p[40] ^= 1; + bootloader_migration_status(reply, sizeof(reply)); + assert(!std::strstr(reply, "up to date")); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(bootloader_migration_pending()); + assert(!std::strstr(reply, "up to date")); + now = 1500; + bootloader_migration_tick(true, true); + // The production install preflight still runs, and the EndF stub refuses it. + assert(endf_checks == 1); + assert(!bootloader_migration_pending()); } else if (std::strcmp(scenario, "wrong-confirmation") == 0) { assert(!bootloader_migration_request("0123456789ABCDE0", reply, sizeof(reply))); assert(!bootloader_migration_request("0123456789abcdef", reply, sizeof(reply))); @@ -190,7 +295,11 @@ int main(int argc, char **argv) { } else if (std::strcmp(scenario, "duplicate-arm") == 0) { arm(); now = 1499; - assert(!bootloader_migration_request(confirmation, reply, sizeof(reply))); + assert(!bootloader_migration_request("0123456789ABCDE0", reply, sizeof(reply))); + assert(bootloader_migration_pending()); + assert(bootloader_migration_request(nullptr, reply, sizeof(reply))); + assert(std::strstr(reply, "OK bootloader update already armed")); + assert(bootloader_migration_request(confirmation, reply, sizeof(reply))); assert(std::strstr(reply, "already armed")); now = 1500; bootloader_migration_tick(true, true);