Files
MeshCore/test/test_ota_target_names.py
T
mikecarper 114a0d98c5 Keep RAK Companion Ethernet waits off the mesh loop
Run DHCP and renewal in a cooperative worker, bound socket commands, and retain SPI ownership until cleanup is safe. Preserve framing deadlines, response ownership and whole-frame replay, including pending-ACK queue pinning.

Budget the worker additively and retain all OTA names with an exact-checked Full-only compact index. Add full-driver, sanitizer, queue, shutdown, storage and memory regression coverage.
2026-10-16 02:23:03 -07:00

205 lines
11 KiB
Python

"""All OTA names survive front coding; original Companion API stays unchanged."""
import re
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
import zlib
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / "tools" / "mota"), str(ROOT / "scripts")]
import gen_targets
from test_serial_wifi_sessions import SANITIZER_FLAGS
HEADER = ROOT / "src" / "helpers" / "ota" / "OtaTargets.h"
HARNESS = ROOT / "test" / "fixtures" / "ota_target_names" / "test.cpp"
class OtaTargetNameTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.text = HEADER.read_text(encoding="utf-8")
cls.rows = [(int(target, 16), name) for target, name in re.findall(
r'\{ 0x([0-9a-fA-F]{8}), "([^"]+)" \},', cls.text)]
def compile_and_lookup(self, flags, ids, header=None):
with tempfile.TemporaryDirectory() as temp_dir:
executable = Path(temp_dir) / "target_names"
# Keep production SHA-256 and tinf implementations. Instrument
# only their call boundaries to assert candidate filtering,
# one decode per chunk, one bounded allocation and cleanup.
header = self.text if header is None else header
rows = [(int(target, 16), name) for target, name in re.findall(
r'\{ 0x([0-9a-fA-F]{8}), "([^"]+)" \},', header)]
fingerprints = Counter(target & 255 for target, _ in rows)
instrumented = header.replace("mesh::Utils::sha256(", "target_name_sha256(")
instrumented = instrumented.replace("tinf_uncompress_exact(", "target_name_uncompress_exact(")
instrumented = instrumented.replace("malloc(DECODE_CHUNK_BYTES)", "target_name_malloc(DECODE_CHUNK_BYTES)")
instrumented = instrumented.replace("free(bytes)", "target_name_free(bytes)")
alternate = Path(temp_dir) / "helpers/ota"
alternate.mkdir(parents=True)
(alternate / "OtaTargets.h").write_text(instrumented, encoding="ascii")
(alternate / "tinf").mkdir()
(alternate / "tinf/tinf.h").write_text(
(ROOT / "src/helpers/ota/tinf/tinf.h").read_text(), encoding="utf-8")
candidates = [Path(os.environ["MESHCORE_CRYPTO_DIR"])] if os.environ.get("MESHCORE_CRYPTO_DIR") else []
candidates += sorted((ROOT / ".pio/libdeps").glob("*/Crypto"))
crypto = next((path for path in candidates if (path / "SHA256.cpp").is_file()), None)
self.assertIsNotNone(crypto, "Install rweather/Crypto or set MESHCORE_CRYPTO_DIR (no hash mock fallback)")
decoder = Path(temp_dir) / "tinf.o"
built = subprocess.run([
os.environ.get("CC") or shutil.which("cc") or "gcc",
"-Os", *SANITIZER_FLAGS, "-DMESHCORE_TINF_IMPLEMENTATION=1", "-c",
str(ROOT / "src/helpers/ota/tinf/tinflate.c"), "-o", str(decoder),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
built = subprocess.run([
os.environ.get("CXX") or shutil.which("g++") or "c++",
"-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-DHOST_BUILD",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections", *SANITIZER_FLAGS,
*flags, f"-DOTA_TEST_TARGET_COUNT={len(rows)}", f"-I{temp_dir}", f"-I{ROOT / 'src'}", f"-I{crypto}",
"-isystem", str(ROOT / "test/mocks"),
str(HARNESS), str(ROOT / "src/Utils.cpp"),
*[str(crypto / name) for name in ("SHA256.cpp", "Hash.cpp", "Crypto.cpp",
"AES128.cpp", "AESCommon.cpp", "BlockCipher.cpp")],
str(decoder), "-o", str(executable),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run(
[str(executable)], input="\n".join(f"{target:08x} {fingerprints[target & 255]}" for target in ids),
text=True, capture_output=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result.stdout.splitlines()
def test_generator_reproduces_checked_in_header_and_all_ids(self):
self.assertGreater(len(self.rows), 600)
self.assertEqual(self.rows, gen_targets.target_rows(name for _, name in self.rows))
self.assertEqual(self.text, gen_targets.generate_header(self.rows))
def test_zopfli_chunks_retain_every_name_and_have_bounded_output(self):
data, offsets, raw_lengths, firsts = gen_targets.compressed_name_chunks(self.rows)
actual = []
for group, raw_length in enumerate(raw_lengths):
raw = zlib.decompress(data[offsets[group]:offsets[group + 1]], -15)
self.assertEqual(len(raw), raw_length)
self.assertLessEqual(raw_length, 512)
position = 0
previous = b""
for row in range(firsts[group], firsts[group + 1]):
prefix = raw[position]
self.assertLessEqual(prefix, len(previous))
end = raw.index(0, position + 1)
previous = previous[:prefix] + raw[position + 1:end]
position = end + 1
actual.append(previous.decode("utf-8"))
self.assertEqual(position, len(raw))
self.assertEqual(actual, [name for _, name in self.rows])
original_size = sum(8 + len(name.encode("utf-8")) + 1 for _, name in self.rows)
compressed_size = 4 * len(self.rows) + 2 * (len(offsets) + len(raw_lengths) + len(firsts)) + len(data)
self.assertGreater(original_size - compressed_size, 19000)
def test_generator_requires_google_zopfli_1000(self):
from unittest.mock import patch
sys.path.insert(0, str(ROOT / "scripts"))
import zopfli_compress
gen_targets._compressed_name_chunks.cache_clear()
original = zopfli_compress.raw_deflate_compress
with patch.object(zopfli_compress, "raw_deflate_compress", wraps=original) as compress:
gen_targets.compressed_name_chunks([(1, "unique_test_target")])
self.assertEqual(compress.call_count, 1)
self.assertEqual(compress.call_args.kwargs, {"numiterations": 1000})
def test_cpp_standard_and_compact_match_all_names(self):
ids = [target for target, _ in self.rows] + [0, 0xffffffff]
expected = [name for _, name in self.rows] + ["?", "?"]
for flags in ([], ["-DOTA_TARGET_NAME_FRONT_CODED=1"],
["-DOTA_TARGET_NAME_FRONT_CODED=1", "-DOTA_RAK15002_SD_STORE=1", "-DCOMPANION_RADIO_FULL=1"]):
with self.subTest(flags=flags):
self.assertEqual(self.compile_and_lookup(flags, ids), expected)
def test_rak_full_lookup_fails_closed_without_heap_and_keeps_every_name(self):
flags = ["-DOTA_TARGET_NAME_FRONT_CODED=1", "-DOTA_RAK15002_SD_STORE=1", "-DCOMPANION_RADIO_FULL=1"]
data, offsets, lengths, firsts = gen_targets.compressed_name_chunks(
self.rows, gen_targets.RAK_FULL_NAME_DECODE_CHUNK_SIZE)
ordinary = gen_targets.compressed_name_chunks(self.rows)
self.assertGreater(len(ordinary[0]) - len(data), 1500)
self.assertLessEqual(max(lengths), 4096)
self.assertEqual(firsts[-1], len(self.rows))
self.assertEqual(offsets[-1], len(data))
self.assertEqual(self.compile_and_lookup(flags + ["-DOTA_TEST_MALLOC_FAIL=1"],
[self.rows[0][0], 0]), ["?", "?"])
def test_rak_full_fingerprint_collisions_verify_the_entire_target_id(self):
flags = ["-DOTA_TARGET_NAME_FRONT_CODED=1", "-DOTA_RAK15002_SD_STORE=1", "-DCOMPANION_RADIO_FULL=1"]
known = {target for target, _ in self.rows}
buckets = Counter(target & 255 for target in known)
self.assertGreater(max(buckets.values()), 1)
# Preserve every real target's low-byte fingerprint while changing
# another ID byte. No filtered candidate may be accepted by itself.
unknown = {target ^ 0x100 for target in known} - known
# Also cover every bucket, including buckets containing no targets.
unknown.update(0xa5a50000 | fingerprint for fingerprint in range(256))
unknown.difference_update(known)
self.assertGreater(len(unknown), len(self.rows))
self.assertEqual(self.compile_and_lookup(flags, sorted(unknown)), ["?"] * len(unknown))
def test_rak_full_index_keeps_one_byte_per_target_without_changing_names(self):
encoded = re.search(r"static const uint8_t FINGERPRINTS\[\] = \{(.*?)\n \};", self.text, re.S)
self.assertIsNotNone(encoded)
fingerprints = [int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", encoded[1])]
self.assertEqual(fingerprints, [target & 255 for target, _ in self.rows])
self.assertGreater(4 * len(self.rows) - len(fingerprints), 2000)
def test_table_disabled_preserves_optional_local_name(self):
local_id, local_name = self.rows[0]
ids = [local_id, self.rows[1][0], 0]
for compressed in (0, 1):
flags = ["-DOTA_TARGET_NAME_TABLE=0", f"-DOTA_TARGET_NAME_FRONT_CODED={compressed}"]
with self.subTest(compressed=compressed):
self.assertEqual(self.compile_and_lookup(flags, ids), ["?", "?", "?"])
self.assertEqual(self.compile_and_lookup(flags + [
f"-DOTA_LOCAL_TARGET_ID=0x{local_id:08x}",
f'-DOTA_LOCAL_TARGET_NAME="{local_name}"',
], ids), [local_name, "?", "?"])
def test_cpp_decoder_fails_closed_on_invalid_records_and_trailing_bytes(self):
from zopfli_compress import raw_deflate_compress
rows = gen_targets.target_rows(["hello"])
target = rows[0][0]
header = gen_targets.generate_header(rows)
original, _, _, _ = gen_targets.compressed_name_chunks(rows)
invalid = [
raw_deflate_compress(b"\xffhello\0", numiterations=1000), # invalid restart prefix
raw_deflate_compress(b"\0hello!", numiterations=1000), # missing terminator
original + b"\0", # trailing whole byte
original[:-1], # truncated input
]
for stream in invalid:
altered = re.sub(
r"(static const uint8_t NAMES\[\] = \{).*?(\n \};)",
lambda match: match[1] + "\n " + ", ".join(f"0x{byte:02x}" for byte in stream) + "," + match[2],
header, flags=re.S)
altered = re.sub(
r"(static const uint16_t OFFSETS\[\] = \{).*?(\n \};)",
lambda match: match[1] + f"\n 0, {len(stream)}," + match[2],
altered, flags=re.S)
with self.subTest(stream=stream.hex()):
for flags in (["-DOTA_TARGET_NAME_FRONT_CODED=1"],
["-DOTA_TARGET_NAME_FRONT_CODED=1", "-DOTA_RAK15002_SD_STORE=1", "-DCOMPANION_RADIO_FULL=1"]):
self.assertEqual(self.compile_and_lookup(flags, [target], altered), ["?"])
def test_generator_rejects_unencodable_names(self):
for name in ("x\0y", "x" * 256):
with self.subTest(name=name):
with self.assertRaises(ValueError):
gen_targets.compressed_name_chunks([(1, name)])
if __name__ == "__main__":
unittest.main()