diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c670d0b6..a8aaa533 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -233,6 +233,8 @@ jobs: run: | bash test/test_build_size_detection.sh python3 -B test/test_firmware_ram.py + python3 -B test/test_neighbor_scope_storage.py + python3 -B test/test_ed25519_compact.py python3 -B test/test_cascade_release_package.py python3 -B test/test_t096_full_memory.py python3 -B test/test_nrf52_ble_startup.py diff --git a/build.sh b/build.sh index 894103bd..0b50f6ca 100755 --- a/build.sh +++ b/build.sh @@ -4384,7 +4384,7 @@ run_pio_with_size_detection() { build_status=1 elif [ "$build_status" -ne 0 ] \ && grep -Eiq \ - 'will not fit in region|region .+ overflowed by|section .+ will not fit|sketch too big|program size is greater than maximum|exceed(s|ing).*(flash|partition|app)' \ + 'will not fit in region|region .+ overflowed by|section .+ will not fit|sketch too big|program size.*is greater than maximum|exceed(s|ing).*(flash|partition|app)' \ "$build_output_log"; then build_status=42 fi @@ -5066,6 +5066,13 @@ is_runtime_setting_alias_target() { } is_redundant_bulk_build_target() { + # Bench fixtures and migration utilities use their dedicated PlatformIO + # recipes; they are not node firmware for the release/OTA packaging matrix. + case "${1,,}" in + profile_switch_*|*_partition_migrator|*_partition_migrator_test_*|*_partition_legacy_seed|*_sim) + return 0 + ;; + esac # Keep every legacy name available to `build-firmware` and # `build-matching-firmwares`, but do not republish binaries that differ only # by a saved/default setting, or roles already supplied by Full Companion. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 9121059e..7ea38f87 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2285,7 +2285,11 @@ void halt() { clear_bonds, stealth_pair_once, bonded_only_peer_ptr)) { strncpy(companion_bluetooth_start_failure, +#if defined(NRF52_PLATFORM) bluetooth_interface.beginFailure(), +#else + "Bluetooth initialization failed", +#endif sizeof(companion_bluetooth_start_failure) - 1); companion_bluetooth_start_failure[ sizeof(companion_bluetooth_start_failure) - 1] = 0; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index bed83da0..b459e6f5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3662,7 +3662,7 @@ void MyMesh::begin(FILESYSTEM *fs) { loadTelemetryHistoryTxPrefs(); #endif -#ifdef SIM_WIFI_SSID +#if defined(SIM_WIFI_SSID) && defined(WITH_MQTT_BRIDGE) // Emulator builds (Wokwi) boot with fresh NVS every run. Seed WiFi so the // observer auto-joins the simulator's network and brings the MQTT bridge up // (WiFi is driven by the bridge task), instead of raising the setup AP that @@ -12842,7 +12842,7 @@ bool MyMesh::completeNeighborDiscoverEntry() { pubkey_hex, entry.snr / 4.0f, UINT32_MAX, - entry.scopes, + neighbor_discover_scopes[neighbor_discover_next], entry.status == ND_RESPONDED ? "responded" : (entry.status == ND_SEND_FAILED ? "send_failed" : "timeout") }; @@ -12868,7 +12868,8 @@ bool MyMesh::completeNeighborDiscoverEntry() { bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, const uint8_t* data, size_t len, float snr, int16_t rssi) { - if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count) return false; + if (overlay_idx < 0 || overlay_idx >= neighbor_discover_count + || (size_t)overlay_idx >= NEIGHBOR_SCOPE_RESULTS) return false; NeighborDiscoverEntry& entry = neighbor_discover[overlay_idx]; if (entry.status != ND_PENDING || len < 8) return false; @@ -12877,11 +12878,12 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, if (tag != entry.tag) return false; size_t scope_len = len - 8; - if (scope_len >= sizeof(entry.scopes)) { - scope_len = sizeof(entry.scopes) - 1; + auto& scopes = neighbor_discover_scopes[overlay_idx]; + if (scope_len >= sizeof(scopes)) { + scope_len = sizeof(scopes) - 1; } - memcpy(entry.scopes, &data[8], scope_len); - entry.scopes[scope_len] = 0; + memcpy(scopes, &data[8], scope_len); + scopes[scope_len] = 0; entry.status = ND_RESPONDED; // A zero-hop reply is proof we heard this neighbour now, so re-stamp both the // snapshot and live table with this packet's measurements. A stamp taken @@ -13028,7 +13030,7 @@ void MyMesh::finishNeighborDiscover() { bool heard_known = neighborHeardAgeUsable(entry.heard_timestamp, now_secs); entries[i].heard_unknown = !heard_known; entries[i].heard_secs_ago = heard_known ? (now_secs - entry.heard_timestamp) : 0; - entries[i].scopes = entry.scopes; + entries[i].scopes = neighbor_discover_scopes[i]; switch (entry.status) { case ND_RESPONDED: entries[i].status = "responded"; break; case ND_SEND_FAILED: entries[i].status = "send_failed"; break; @@ -13169,7 +13171,6 @@ bool MyMesh::startNeighborDiscover(char* reply) { entry.heard_timestamp = neighbours[i].heard_timestamp; entry.snr = neighbours[i].snr; entry.rssi = neighbours[i].rssi; - entry.scopes[0] = 0; entry.tag = 0; entry.status = ND_UNSENT; neighbor_discover_count++; @@ -13193,6 +13194,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { neighbor_discover[j] = entry; } + memset(neighbor_discover_scopes, 0, sizeof(neighbor_discover_scopes)); neighbor_discover_next = 0; resetNeighborDiscoverJsonBudget(); neighbor_discover_active = true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 64a81848..13875a4e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -644,10 +644,16 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks int8_t snr; // multiplied by 4 int16_t rssi; // dBm from the last packet heard from this neighbour uint32_t tag; // anon-regions request tag we're waiting on - char scopes[96]; // scope names from the response uint8_t status; // NeighborDiscoverStatus }; NeighborDiscoverEntry neighbor_discover[MAX_NEIGHBOURS]; + // Only the publishable prefix (plus the result that detects truncation) + // receives scope queries. Keep every neighbor snapshot, without reserving + // 96 response bytes for each entry that will never be queried. + static constexpr size_t NEIGHBOR_SCOPE_RESULTS = + MAX_NEIGHBOURS < MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES + 1 + ? MAX_NEIGHBOURS : MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES + 1; + char neighbor_discover_scopes[NEIGHBOR_SCOPE_RESULTS][96]; uint8_t neighbor_discover_count; uint8_t neighbor_discover_next; // newest-first entry currently being queried uint8_t neighbor_discover_publish_count; // completed prefix that fits the JSON buffer diff --git a/lib/ed25519/sha512.c b/lib/ed25519/sha512.c index cb8ae717..61bcd120 100644 --- a/lib/ed25519/sha512.c +++ b/lib/ed25519/sha512.c @@ -115,6 +115,19 @@ static int sha512_compress(sha512_context *md, unsigned char *buf) d += t0; \ h = t0 + t1; +#if defined(ED25519_COMPACT_SHA512) + /* Same 80 rounds, with a fixed state rotation instead of eight copies of + the round body. Small STM32 images trade loop overhead for flash space; + neither the loop bounds nor memory indexes depend on secret data. */ + for (i = 0; i < 80; ++i) { + int j; + uint64_t next; + RND(S[0],S[1],S[2],S[3],S[4],S[5],S[6],S[7],i); + next = S[7]; + for (j = 7; j > 0; --j) S[j] = S[j - 1]; + S[0] = next; + } +#else for (i = 0; i < 80; i += 8) { RND(S[0],S[1],S[2],S[3],S[4],S[5],S[6],S[7],i+0); RND(S[7],S[0],S[1],S[2],S[3],S[4],S[5],S[6],i+1); @@ -125,6 +138,7 @@ static int sha512_compress(sha512_context *md, unsigned char *buf) RND(S[2],S[3],S[4],S[5],S[6],S[7],S[0],S[1],i+6); RND(S[1],S[2],S[3],S[4],S[5],S[6],S[7],S[0],i+7); } +#endif #undef RND diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 38a82e00..f0d24bdd 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -12,6 +12,13 @@ fail() { [ "$OPTION3_BUILD_WORKERS" -eq 1 ] \ || fail "logging matrix permits concurrent PlatformIO target builds" +for utility in profile_switch_t096_sx1262 xiao_s3_partition_migrator \ + heltec_v4_partition_migrator_test_hold xiao_s3_partition_legacy_seed \ + Heltec_v3_repeater_observer_mqtt_sim; do + is_redundant_bulk_build_target "$utility" \ + || fail "release matrix includes development utility $utility" +done + # Reduced RAK profiles all retain the compact INA set, but GPS depends on # whether the bridge already owns Serial1. Keep those independent contracts so # a valid Serial1 image cannot fail release qualification for an absent GPS diff --git a/test/test_build_size_detection.sh b/test/test_build_size_detection.sh index bac3bff8..3ddcaecb 100644 --- a/test/test_build_size_detection.sh +++ b/test/test_build_size_detection.sh @@ -38,7 +38,8 @@ pio_result=1 for pio_message in \ "firmware.elf section .text will not fit in region FLASH" \ "region FLASH overflowed by 43080 bytes" \ - "Error: The program size is greater than maximum allowed"; do + "Error: The program size is greater than maximum allowed" \ + "Error: The program size (1314113 bytes) is greater than maximum allowed (1310720 bytes)"; do check_status 42 grep -Fq "$pio_message" "$test_root/output" || fail "compiler output was lost" done diff --git a/test/test_ed25519_compact.py b/test/test_ed25519_compact.py index d40e27cb..92b37553 100644 --- a/test/test_ed25519_compact.py +++ b/test/test_ed25519_compact.py @@ -1,3 +1,5 @@ +import ctypes +import hashlib import subprocess import tempfile import unittest @@ -22,6 +24,22 @@ SOURCES = [ class Ed25519CompactTest(unittest.TestCase): + def test_compact_sha512_matches_hashlib_at_block_boundaries(self): + with tempfile.TemporaryDirectory() as temp_dir: + library = Path(temp_dir) / "sha512.so" + subprocess.run(["cc", "-std=c99", "-O2", "-shared", "-fPIC", + "-DED25519_COMPACT_SHA512=1", str(ED25519 / "sha512.c"), + "-o", str(library)], check=True) + sha = ctypes.CDLL(str(library)).sha512 + sha.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p] + sha.restype = ctypes.c_int + for length in (0, 1, 63, 64, 111, 112, 127, 128, 129, 255, 256, 1024, 4097): + data = bytes((i * 73 + 19) % 256 for i in range(length)) + output = ctypes.create_string_buffer(64) + with self.subTest(length=length): + self.assertEqual(sha(data, len(data), output), 0) + self.assertEqual(output.raw, hashlib.sha512(data).digest()) + def test_stm32_builds_enable_compact_base_table(self): platformio = (ROOT / "platformio.ini").read_text() stm32_base = platformio.split("[stm32_base]", 1)[1].split("\n[", 1)[0] @@ -32,6 +50,8 @@ class Ed25519CompactTest(unittest.TestCase): for label, extra_flags in ( ("standard", []), ("compact", ["-DED25519_COMPACT_BASE=1"]), + ("compact_sha512", ["-DED25519_COMPACT_BASE=1", + "-DED25519_COMPACT_SHA512=1"]), ): executable = Path(temp_dir) / label subprocess.run( diff --git a/test/test_neighbor_scope_storage.py b/test/test_neighbor_scope_storage.py new file mode 100644 index 00000000..1f7d0a8d --- /dev/null +++ b/test/test_neighbor_scope_storage.py @@ -0,0 +1,91 @@ +"""Exercise production scope storage and truncation with 254 neighbors.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class NeighborScopeStorageTest(unittest.TestCase): + def test_response_bounds_and_publish_prefix(self): + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + layout = header[header.index(" struct NeighborDiscoverEntry {"): + header.index(" uint8_t neighbor_discover_count;")] + methods = "\n".join(extract_braced(source, signature) for signature in ( + "bool MyMesh::completeNeighborDiscoverEntry()", + "bool MyMesh::handleNeighborDiscoverResponse(")) + code = r''' +#include +#include +#include +#include +#define MAX_NEIGHBOURS 254 +#define PUB_KEY_SIZE 32 +namespace mesh { +struct Identity { uint8_t pub_key[32]; }; +struct Utils { static void toHex(char* out, const uint8_t*, size_t) { out[0] = 0; } }; +} +struct MQTTBridge { + static constexpr int NEIGHBORS_MAX_PUBLISH_ENTRIES = 20; + static constexpr size_t NEIGHBORS_JSON_BUFFER_SIZE = 8192; +}; +struct MQTTMessageBuilder { + struct NeighborsMessageEntry { + const char* key; float snr; uint32_t age; const char* scopes; + const char* status; int16_t rssi; + }; + static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry&) { return 100; } +}; +struct Clock { uint32_t getCurrentTime() { return 100; } }; +struct MyMesh { + enum { ND_UNSENT, ND_QUEUED, ND_PENDING, ND_RESPONDED, ND_TIMEOUT, ND_SEND_FAILED }; +''' + layout + r''' + unsigned neighbor_discover_next = 0, neighbor_discover_count = 254; + unsigned neighbor_discover_publish_count = 0; + size_t neighbor_discover_json_size = 0; + bool neighbor_discover_truncated = false, finished = false; + Clock clock; + Clock* getRTCClock() { return &clock; } + void touchNeighbourHeard(const mesh::Identity&, uint32_t, float, int16_t) {} + void finishNeighborDiscover() { finished = true; } + bool completeNeighborDiscoverEntry(); + bool handleNeighborDiscoverResponse(int, const uint8_t*, size_t, float, int16_t); +}; +''' + methods + r''' +int main() { + MyMesh m = {}; + static_assert(sizeof(m.neighbor_discover_scopes) == 21 * 96, "bounded scope storage"); + static_assert(sizeof(m.neighbor_discover) / sizeof(m.neighbor_discover[0]) == 254, + "retain every neighbor snapshot"); + uint8_t response[200] = {}; + uint32_t tag = 123; + memcpy(response, &tag, 4); + memset(response + 8, 'x', sizeof(response) - 8); + for (int i = 0; i <= 20; ++i) { + auto& entry = m.neighbor_discover[i]; + entry.status = MyMesh::ND_PENDING; + entry.tag = tag; + assert(m.handleNeighborDiscoverResponse(i, response, sizeof(response), 5, -100)); + assert(strlen(m.neighbor_discover_scopes[i]) == 95); + assert(m.completeNeighborDiscoverEntry() == (i < 20)); + } + assert(m.finished && m.neighbor_discover_truncated); + assert(m.neighbor_discover_publish_count == 20); + assert(!m.handleNeighborDiscoverResponse(21, response, sizeof(response), 5, -100)); + assert(!m.handleNeighborDiscoverResponse(-1, response, sizeof(response), 5, -100)); +} +''' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path / "test.cpp").write_text(code) + subprocess.run(["g++", "-std=c++17", "-fsanitize=address,undefined", + str(path / "test.cpp"), "-o", str(path / "test")], check=True) + subprocess.run([str(path / "test")], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index c87fe8e9..5d0e4900 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -66,6 +66,7 @@ extends = rak3x72 platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${rak3x72.build_flags} + -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -Oz -flto -fno-inline-small-functions diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index d1167185..3f7cd01d 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -72,6 +72,7 @@ extends = Tiny_Relay platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${Tiny_Relay.build_flags} + -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -Oz -flto -fno-inline-small-functions diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index f8de5dbf..e278562b 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -90,6 +90,7 @@ extends = lora_e5 platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5.build_flags} + -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -Oz -flto -fno-inline-small-functions diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index c201c588..a34d4132 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -79,6 +79,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5_mini.build_flags} + -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -Oz -flto -fno-inline-small-functions