Add durable TempRadio, ACL, clock, bridge, GPS/I2C, USB logging, and Indicator recovery safeguards together with expanded automation, build/profile contracts, release metadata, and the hardware validation ledger.
881 lines
39 KiB
Python
881 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import base64
|
|
import hashlib
|
|
import re
|
|
from pathlib import Path
|
|
import unittest
|
|
import zlib
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
POLICY = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontRecoveryPolicy.h"
|
|
CLIENT = ROOT / "variants/sensecap_indicator-espnow/IndicatorFontClient.cpp"
|
|
DISPLAY = ROOT / "variants/sensecap_indicator-espnow/SCIndicatorDisplay.h"
|
|
PROFILE = ROOT / "variants/sensecap_indicator-espnow/platformio.ini"
|
|
RP2040 = ROOT / "tools/sensecap_indicator_rp2040/src/main.cpp"
|
|
STAGE_V2_PROTOCOL = ROOT / "src/helpers/IndicatorFontStageV2Protocol.h"
|
|
ASSET = ROOT / "variants/sensecap_indicator-espnow/sd/ui-font.vlw"
|
|
|
|
|
|
def source(path: Path) -> str:
|
|
return path.read_text()
|
|
|
|
|
|
def function_body(text: str, signature: str, next_signature: str) -> str:
|
|
start = text.index(signature)
|
|
end = text.index(next_signature, start)
|
|
return text[start:end]
|
|
|
|
|
|
def profile_section(name: str) -> str:
|
|
text = source(PROFILE)
|
|
match = re.search(
|
|
rf"^\[{re.escape(name)}\]\n(?P<body>.*?)(?=^\[|\Z)",
|
|
text,
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
if match is None:
|
|
raise AssertionError(f"missing profile section {name}")
|
|
return match.group("body")
|
|
|
|
|
|
class IndicatorFontRecoveryTest(unittest.TestCase):
|
|
def test_compiled_asset_identity_matches_checked_in_file(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
data = ASSET.read_bytes()
|
|
|
|
size = int(re.search(r"kAssetSize = (\d+)", policy).group(1))
|
|
crc = int(re.search(r"kAssetCrc32 = 0x([0-9a-f]+)", policy).group(1), 16)
|
|
sha = re.search(r'kAssetSha256\[\] =\s*"([0-9a-f]{64})"', policy).group(1)
|
|
|
|
self.assertEqual(len(data), size)
|
|
self.assertEqual(zlib.crc32(data) & 0xFFFFFFFF, crc)
|
|
self.assertEqual(hashlib.sha256(data).hexdigest(), sha)
|
|
digest_initializer = client[
|
|
client.index("EXPECTED_SHA256[32]") : client.index(
|
|
"enum class FontInfoResult"
|
|
)
|
|
]
|
|
compiled_digest = bytes(
|
|
int(value, 16)
|
|
for value in re.findall(r"0x([0-9a-f]{2})", digest_initializer)
|
|
)
|
|
self.assertEqual(compiled_digest.hex(), sha)
|
|
|
|
def test_source_is_immutable_https_and_tls_is_never_disabled(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
url_literals = re.findall(
|
|
r'"([^"]*)"',
|
|
policy[policy.index("kAssetUrl") : policy.index("kAssetSize")],
|
|
)
|
|
url = "".join(url_literals)
|
|
|
|
self.assertRegex(
|
|
url,
|
|
r"^https://api\.github\.com/repos/mikecarper/MeshCore/git/blobs/"
|
|
r"[0-9a-f]{40}$",
|
|
)
|
|
self.assertNotIn("setInsecure", client)
|
|
self.assertNotIn("client.setCACertBundle(", client)
|
|
self.assertIn("client.setCACert(FONT_TLS_GITHUB_ROOT_CA)", client)
|
|
self.assertIn("Accept: application/vnd.github.raw+json", client)
|
|
self.assertIn("X-GitHub-Api-Version: 2026-03-10", client)
|
|
self.assertIn("parseHttpStatusCode(line, status_code)", client)
|
|
|
|
pem_match = re.search(
|
|
r'FONT_TLS_GITHUB_ROOT_CA\[\].*?R"CERT\((.*?)\)CERT";',
|
|
client,
|
|
re.DOTALL,
|
|
)
|
|
self.assertIsNotNone(pem_match)
|
|
pem_lines = [
|
|
line.strip() for line in pem_match.group(1).strip().splitlines()
|
|
]
|
|
self.assertEqual(pem_lines[0], "-----BEGIN CERTIFICATE-----")
|
|
self.assertEqual(pem_lines[-1], "-----END CERTIFICATE-----")
|
|
der = base64.b64decode("".join(pem_lines[1:-1]), validate=True)
|
|
self.assertEqual(
|
|
hashlib.sha256(der).hexdigest(),
|
|
"c90f26f0fb1b4018b22227519b5ca2b53e2ca5b3be5cf18efe1bef47380c5383",
|
|
)
|
|
|
|
def test_wifi_only_recovery_bootstraps_a_bounded_tls_clock(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
proof = function_body(
|
|
client, "bool fontTlsClockProofValid", "bool prepareTlsClock"
|
|
)
|
|
callback = function_body(
|
|
client, "void clearFontNtpCallback", "bool fontTlsClockProofValid"
|
|
)
|
|
clock = function_body(
|
|
client, "bool prepareTlsClock", "uint32_t remainingHttpHeaderTimeout"
|
|
)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
transfer = function_body(
|
|
client,
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
"void finishRecoveryFailure",
|
|
)
|
|
|
|
self.assertIn("kAssetPublishedEpoch = 1787708237UL", policy)
|
|
self.assertIn("kNtpSyncWaitMillis = 15000UL", policy)
|
|
self.assertIn(
|
|
"fontNtpTimeReceived.store(false, std::memory_order_release)",
|
|
clock,
|
|
)
|
|
self.assertIn("sntp_set_time_sync_notification_cb(noteFontNtpTime)", clock)
|
|
self.assertIn("sntp_set_sync_status(SNTP_SYNC_STATUS_RESET)", clock)
|
|
self.assertIn('configTime(0, 0, "time.cloudflare.com"', clock)
|
|
self.assertIn("OperationLease sntp_operation", clock)
|
|
self.assertIn("processWideCoordinator()", clock)
|
|
self.assertIn("if (!sntp_operation.tryAcquire())", clock)
|
|
self.assertNotIn("sntp_set_time_sync_notification_cb(nullptr)", clock)
|
|
self.assertIn("sntp_set_time_sync_notification_cb(nullptr)", callback)
|
|
self.assertIn("processWideCoordinator().owns(generation)", callback)
|
|
self.assertIn(
|
|
"fontNtpTimeReceived.load(std::memory_order_acquire)", proof
|
|
)
|
|
self.assertIn("WiFi.status() == WL_CONNECTED", proof)
|
|
self.assertIn("mesh::tls_clock::proofIsValid", proof)
|
|
self.assertIn("mesh::tls_clock::proofAgeIsValid", proof)
|
|
self.assertIn("mesh::tls_clock::proofGenerationIsValid", proof)
|
|
self.assertIn("FONT_TLS_PROOF_MAX_AGE_MS = 300000UL", client)
|
|
self.assertIn("const time_t now = time(nullptr)", clock)
|
|
self.assertIn("fontTlsClockProofValid(now)", clock)
|
|
self.assertIn(
|
|
"now >= (time_t)mesh::indicator_font::kAssetPublishedEpoch", clock
|
|
)
|
|
self.assertNotIn("(uint32_t)time(nullptr)", clock)
|
|
self.assertIn("WiFi.status() != WL_CONNECTED", clock)
|
|
self.assertNotIn("alreadyPlausible", clock)
|
|
self.assertTrue(clock.rstrip().endswith("}"))
|
|
self.assertIn("return false;", clock)
|
|
self.assertIn("prepareTlsClock()", transfer)
|
|
self.assertLess(
|
|
transfer.index("prepareTlsClock()"),
|
|
transfer.index("openAssetResponse("),
|
|
)
|
|
self.assertIn("const time_t tls_now = time(nullptr)", response)
|
|
proof_check = response.index("fontTlsClockProofValid(tls_now)")
|
|
tls_connect = response.index("client.connect(host, 443)")
|
|
self.assertLess(proof_check, tls_connect)
|
|
self.assertIn(
|
|
"std::atomic<bool> fontNtpTimeReceived{false}", client
|
|
)
|
|
self.assertIn(
|
|
"fontNtpTimeReceived.store(true, std::memory_order_release)",
|
|
client,
|
|
)
|
|
|
|
def test_release_source_has_no_forced_download_interruption(self):
|
|
client = source(CLIENT)
|
|
self.assertNotIn("DOWNLOAD_TEST_INTERRUPT_AT", client)
|
|
self.assertNotIn("test hook: closing initial HTTP response", client)
|
|
|
|
def test_http_headers_have_one_total_deadline(self):
|
|
client = source(CLIENT)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
self.assertIn("HTTP_HEADER_TOTAL_TIMEOUT_MS = 30000UL", client)
|
|
self.assertIn("remainingHttpHeaderTimeout(headerStarted)", response)
|
|
self.assertNotIn(
|
|
"readHttpLine(client, line, sizeof(line), HTTP_LINE_TIMEOUT_MS)",
|
|
response,
|
|
)
|
|
|
|
def test_http_wire_parser_is_bounded_and_rejects_ambiguous_framing(self):
|
|
client = source(CLIENT)
|
|
reader = function_body(
|
|
client, "HttpLineResult readHttpLine", "bool isSecurityCriticalHttpHeader"
|
|
)
|
|
decimal = function_body(
|
|
client, "bool parseUnsignedDecimalHeader", "bool parseHttpStatusCode"
|
|
)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
|
|
self.assertRegex(client, r"HTTP_HEADER_LIMIT = (?:6[4-9]|[7-9][0-9]|\d{3,})")
|
|
self.assertIn("HTTP_WIRE_LINE_LIMIT = 2048", client)
|
|
drain = reader.index("while (client.available())")
|
|
self.assertIn(
|
|
"millis() - started >= timeout_millis", reader[drain:]
|
|
)
|
|
self.assertIn("++wire_length > HTTP_WIRE_LINE_LIMIT", reader)
|
|
self.assertIn("HttpLineResult::TooLong", reader)
|
|
|
|
# Decimal framing accepts digits only, with optional surrounding OWS;
|
|
# strtoul-style signs and overflow are not accepted.
|
|
self.assertIn("*value < '0' || *value > '9'", decimal)
|
|
self.assertIn("UINT64_MAX - digit", decimal)
|
|
self.assertNotIn("strtoul", response)
|
|
self.assertIn("parsed > MAX_FONT_BYTES", response)
|
|
|
|
# Reject every obsolete folded field before handling either retained
|
|
# or overflowed extension headers.
|
|
obs_fold = response.index("if (line[0] == ' ' || line[0] == '\\t')")
|
|
overflow = response.index("if (line_result == HttpLineResult::Overflow)")
|
|
self.assertLess(obs_fold, overflow)
|
|
|
|
def test_non_200_headers_are_parsed_and_failures_are_classified(self):
|
|
client = source(CLIENT)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
classify = function_body(
|
|
client, "RecoveryAttemptOutcome classifyHttpResponse", "bool splitHttpsUrl"
|
|
)
|
|
finish = function_body(
|
|
client,
|
|
"void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)",
|
|
"void finishRecoveryFailure()",
|
|
)
|
|
|
|
# The status is recorded first, but classification happens only after
|
|
# Retry-After and X-RateLimit-Reset have been read from the full block.
|
|
self.assertIn('"Retry-After:"', response)
|
|
self.assertIn('"X-RateLimit-Reset:"', response)
|
|
self.assertGreater(
|
|
response.index("classifyHttpResponse("),
|
|
response.index("while (header_count <= HTTP_HEADER_LIMIT)"),
|
|
)
|
|
self.assertNotIn("status_code != 200", response)
|
|
|
|
self.assertIn("status_code == 403 || status_code == 429", classify)
|
|
self.assertIn("retry_after_seconds", classify)
|
|
self.assertIn("rate_reset_epoch", classify)
|
|
self.assertIn("HTTP_RATE_LIMIT_MIN_DELAY_MS", classify)
|
|
self.assertIn("HTTP_RATE_LIMIT_FALLBACK_DELAY_MS", classify)
|
|
self.assertIn("RecoveryAttemptDisposition::RateLimited", classify)
|
|
self.assertIn("status_code >= 500 && status_code <= 599", classify)
|
|
self.assertIn("RecoveryAttemptDisposition::RetryableFailure", classify)
|
|
self.assertTrue(
|
|
classify.rstrip().endswith(
|
|
"return recoveryOutcome(RecoveryAttemptDisposition::PermanentFailure);\n}"
|
|
)
|
|
)
|
|
|
|
self.assertIn("outcome.retryDelayMillis > delay_millis", finish)
|
|
self.assertIn("outcome.retryDelayMillis", finish)
|
|
self.assertIn(
|
|
"outcome.disposition == RecoveryAttemptDisposition::PermanentFailure",
|
|
finish,
|
|
)
|
|
self.assertIn("recoveryState = RecoveryState::Exhausted", finish)
|
|
|
|
def test_valid_current_font_does_not_arm_download(self):
|
|
client = source(CLIENT)
|
|
load = client[client.index("uint8_t* IndicatorFontClient::load") :]
|
|
self.assertIn("if (!loaded.currentAsset)", load)
|
|
self.assertNotIn(
|
|
"armRecovery(mesh::indicator_font::RecoveryNeed::None)", load
|
|
)
|
|
self.assertIn("loaded.currentAsset = loaded.size ==", client)
|
|
self.assertIn("memcmp(digest, EXPECTED_SHA256", client)
|
|
|
|
def test_length_and_digest_fail_before_commit(self):
|
|
client = source(CLIENT)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
transfer = function_body(
|
|
client,
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
"void finishRecoveryFailure",
|
|
)
|
|
|
|
self.assertIn("mesh::indicator_font::kAssetSize - requested_offset", response)
|
|
self.assertIn("content_length != expected_content_length", response)
|
|
self.assertIn("Transfer-Encoding:", response)
|
|
self.assertIn("Content-Encoding:", response)
|
|
self.assertIn("HTTP_HEADER_LIMIT", response)
|
|
self.assertIn("DOWNLOAD_TOTAL_TIMEOUT_MS", transfer)
|
|
self.assertIn("DOWNLOAD_IDLE_TIMEOUT_MS", transfer)
|
|
self.assertLess(transfer.index("digest_ok"), transfer.index("MCFONT COMMIT"))
|
|
self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin"))
|
|
|
|
def test_range_resume_keeps_one_buffer_and_sha_stream_and_is_strict(self):
|
|
client = source(CLIENT)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
transfer = function_body(
|
|
client,
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
"void finishRecoveryFailure",
|
|
)
|
|
content_range = function_body(
|
|
client, "bool parseContentRangeHeader", "bool parseStrongEtagHeader"
|
|
)
|
|
strong_etag = function_body(
|
|
client, "bool parseStrongEtagHeader", "bool parseHttpStatusCode"
|
|
)
|
|
|
|
self.assertIn("DOWNLOAD_MAX_RESUME_RECONNECTS = 2", client)
|
|
self.assertIn("const bool is_resume = expected_etag != nullptr", response)
|
|
self.assertIn('"Range: bytes=%lu-%lu\\r\\nIf-Range: %s\\r\\n"', response)
|
|
self.assertIn("const int expected_status = is_resume ? 206 : 200", response)
|
|
self.assertIn("status_code != expected_status", response)
|
|
self.assertIn("content_length != expected_content_length", response)
|
|
self.assertIn("parseContentRangeHeader", response)
|
|
self.assertIn("parseStrongEtagHeader", response)
|
|
self.assertIn("content_range_first != requested_offset", response)
|
|
self.assertIn(
|
|
"content_range_last != mesh::indicator_font::kAssetSize - 1",
|
|
response,
|
|
)
|
|
self.assertIn(
|
|
"content_range_total != mesh::indicator_font::kAssetSize", response
|
|
)
|
|
self.assertIn("strcmp(response_etag, expected_etag) != 0", response)
|
|
self.assertIn("if (content_range_seen)", response)
|
|
self.assertIn("!etag_seen || !etag_valid", response)
|
|
self.assertIn("transfer_encoding_seen", response)
|
|
self.assertIn("!content_encoding_identity", response)
|
|
|
|
# Content-Range and reflected If-Range inputs use strict decimal and
|
|
# quoted-tag grammars; signs, overflow, weak tags, control bytes, and
|
|
# embedded quotes cannot pass or inject another request field.
|
|
self.assertIn('strncasecmp(value, "bytes ", 6)', content_range)
|
|
self.assertIn("parsed_first > parsed_last", content_range)
|
|
self.assertIn("parsed_last >= parsed_total", content_range)
|
|
self.assertIn("parsed_total > MAX_FONT_BYTES", content_range)
|
|
self.assertIn("value[0] != '\"'", strong_etag)
|
|
self.assertIn("value[length - 1] != '\"'", strong_etag)
|
|
self.assertIn("byte < 0x21 || byte > 0x7e || byte == '\"'", strong_etag)
|
|
|
|
# One fresh SNTP reply covers the bounded 180-second transfer. Every
|
|
# initial/Range TLS handshake revalidates that proof and the signed
|
|
# wall clock centrally in openAssetResponse().
|
|
self.assertEqual(transfer.count("prepareTlsClock()"), 1)
|
|
proof_check = response.index("fontTlsClockProofValid(tls_now)")
|
|
tls_connect = response.index("client.connect(host, 443)")
|
|
self.assertLess(proof_check, tls_connect)
|
|
self.assertEqual(
|
|
transfer.count("mbedtls_sha256_starts_ret(&sha, 0)"), 1
|
|
)
|
|
self.assertEqual(
|
|
transfer.count("ps_malloc(mesh::indicator_font::kAssetSize)"), 1
|
|
)
|
|
initial = transfer.index("client, 0, nullptr, asset_etag")
|
|
resume = transfer.index("client, received, asset_etag, nullptr, 0")
|
|
self.assertLess(initial, resume)
|
|
self.assertIn(
|
|
"resume_reconnects >= DOWNLOAD_MAX_RESUME_RECONNECTS", transfer
|
|
)
|
|
self.assertIn("discarding %lu unverified bytes", transfer)
|
|
self.assertIn("last_progress = millis()", transfer[resume:])
|
|
|
|
# `received` moves only after the complete block has entered the one
|
|
# SHA stream. A failed partial block is overwritten at that boundary.
|
|
update = transfer.index("mbedtls_sha256_update_ret")
|
|
advance = transfer.index("received += wanted", update)
|
|
resume_call = transfer.index("openAssetResponse(", advance)
|
|
self.assertLess(update, advance)
|
|
self.assertLess(advance, resume_call)
|
|
self.assertNotIn("received += filled", transfer)
|
|
self.assertEqual(transfer.count("uint32_t started = millis();"), 1)
|
|
self.assertNotIn("\n started = millis();", transfer[resume:])
|
|
self.assertLess(transfer.index("digest_ok"), transfer.index("Serial2.begin"))
|
|
|
|
def test_download_is_buffered_before_receiver_paced_staging(self):
|
|
client = source(CLIENT)
|
|
protocol = source(STAGE_V2_PROTOCOL)
|
|
transfer = function_body(
|
|
client,
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
"void finishRecoveryFailure",
|
|
)
|
|
self.assertIn("kStageV2ChunkBytes = 512", protocol)
|
|
self.assertIn("ps_malloc(mesh::indicator_font::kAssetSize)", transfer)
|
|
self.assertIn("if (wanted > 16 * 1024) wanted = 16 * 1024", transfer)
|
|
self.assertIn("client.read(asset + received + filled", transfer)
|
|
self.assertLess(transfer.index("client.stop()"), transfer.index("Serial2.begin"))
|
|
self.assertLess(transfer.index("if (!digest_ok)"), transfer.index("Serial2.begin"))
|
|
self.assertIn("free(asset)", transfer)
|
|
self.assertIn("writeAll(Serial2, asset + staged_bytes", transfer)
|
|
self.assertIn("stagedChunkAcknowledged(Serial2, next_staged)", transfer)
|
|
self.assertIn("stageV2ChunkSize(", transfer)
|
|
self.assertIn("advanceStageV2Offset(", transfer)
|
|
self.assertIn("delay(LEGACY_STAGE_PACE_MS)", transfer)
|
|
|
|
def test_stage_v2_is_receiver_paced_and_legacy_fallback_is_explicit(self):
|
|
client = source(CLIENT)
|
|
rp = source(RP2040)
|
|
protocol = source(STAGE_V2_PROTOCOL)
|
|
negotiate = function_body(
|
|
client, "bool beginStagedUpload", "bool stagedChunkAcknowledged"
|
|
)
|
|
acknowledge = function_body(
|
|
client,
|
|
"bool stagedChunkAcknowledged",
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
)
|
|
receiver = function_body(
|
|
rp, "void receiveFontStage2", "struct CommandReader"
|
|
)
|
|
|
|
self.assertIn("IndicatorFontStageV2Protocol.h", client)
|
|
self.assertIn("IndicatorFontStageV2Protocol.h", rp)
|
|
self.assertIn("kStageV2ChunkBytes = 512", protocol)
|
|
self.assertIn('kStageV2ReadyReply[] = "READY 2 512"', protocol)
|
|
self.assertIn(
|
|
'kStageV2LegacyUnsupportedReply[] = "ERROR COMMAND"', protocol
|
|
)
|
|
self.assertIn('"MCFONT STAGEV2 %lu %08lx %lu"', negotiate)
|
|
self.assertNotIn('"MCFONT STAGE2 ', negotiate)
|
|
self.assertNotIn('"MCFONT STAGE2 ', rp)
|
|
self.assertIn("classifyStageV2BeginReply(replied, reply)", negotiate)
|
|
self.assertIn("StageV2BeginAction::UseAcknowledged", negotiate)
|
|
self.assertIn("StageV2BeginAction::UseLegacy", negotiate)
|
|
# A missing/delayed protocol-2 reply fails closed. Only the old
|
|
# service's explicit unsupported-command response can reach STAGE.
|
|
timeout_failure = negotiate.index("action !=")
|
|
legacy_command = negotiate.index('"MCFONT STAGE %lu %08lx"')
|
|
self.assertLess(timeout_failure, legacy_command)
|
|
self.assertIn("return false;", negotiate[timeout_failure:legacy_command])
|
|
self.assertIn("if (!replied)", negotiate[timeout_failure:legacy_command])
|
|
|
|
self.assertIn("parseStageV2Ack(reply, expected_offset)", acknowledge)
|
|
self.assertNotIn("strtoul", acknowledge)
|
|
|
|
self.assertIn("RECEIVE_TOTAL_TIMEOUT_MS = 180000", rp)
|
|
self.assertIn("output.print(mesh::indicator_font::kStageV2ReadyReply)", receiver)
|
|
self.assertIn("stageV2ChunkSize(expectedSize, received)", receiver)
|
|
fragment_loop = receiver.index("while (chunkReceived < chunkSize)")
|
|
write = receiver.index("font.write(buffer, chunkSize)")
|
|
ack = receiver.index('output.printf("ACK %lu\\n"')
|
|
self.assertLess(fragment_loop, write)
|
|
self.assertIn("chunkReceived += actual", receiver[fragment_loop:write])
|
|
self.assertLess(write, ack)
|
|
self.assertIn("advanceStageV2Offset(", receiver[write:ack])
|
|
self.assertIn("received = nextReceived", receiver[write:ack])
|
|
checksum = receiver.index("~crc != expectedCrc")
|
|
metadata = receiver.index("writeMetadata(STAGED_META_PATH")
|
|
staged = receiver.index('output.print("STAGED\\n")')
|
|
close = receiver.index("font.close()")
|
|
post_close_deadline = receiver.index(
|
|
"espStage2LastElapsedMs >= RECEIVE_TOTAL_TIMEOUT_MS", close
|
|
)
|
|
failure_handling = receiver.index(
|
|
"if (failure != Stage2Result::Idle)", post_close_deadline
|
|
)
|
|
self.assertLess(ack, checksum)
|
|
self.assertLess(close, post_close_deadline)
|
|
self.assertLess(post_close_deadline, failure_handling)
|
|
self.assertLess(checksum, metadata)
|
|
self.assertLess(metadata, staged)
|
|
self.assertIn(
|
|
'"MCFONT STAGEV2 %lu %lx %lu %n"',
|
|
rp,
|
|
)
|
|
self.assertIn("reader.line[consumed] == 0", rp)
|
|
|
|
def test_rp2040_retries_a_transient_sd_mount_for_a_bounded_window(self):
|
|
rp = source(RP2040)
|
|
mount = function_body(rp, "bool mountSdCard", "void serviceSdMount")
|
|
service = function_body(rp, "void serviceSdMount", "void sendInfo")
|
|
setup = function_body(rp, "void setup()", "void loop()")
|
|
loop = rp[rp.index("void loop()") :]
|
|
|
|
self.assertIn("SD_MOUNT_RETRY_INTERVAL_MS = 2000", rp)
|
|
self.assertIn("SD_MOUNT_MAX_ATTEMPTS = 30", rp)
|
|
self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", mount)
|
|
self.assertIn("if (sdMountAttempts != 0) SD.end(false)", mount)
|
|
self.assertLess(mount.index("++sdMountAttempts"), mount.index("SD.begin("))
|
|
self.assertIn("sdMountNextAttempt = millis() +", mount)
|
|
self.assertIn("sdMountAttempts >= SD_MOUNT_MAX_ATTEMPTS", service)
|
|
self.assertIn("now - sdMountNextAttempt", service)
|
|
self.assertIn("serviceSdMount(millis())", setup)
|
|
self.assertIn("serviceSdMount(millis())", loop)
|
|
self.assertNotIn("SD.begin(", setup)
|
|
|
|
def test_legacy_stage_and_usb_put_have_idle_and_total_deadlines(self):
|
|
rp = source(RP2040)
|
|
receive = function_body(rp, "void receiveFont(Stream&", "// STAGEV2")
|
|
|
|
self.assertIn("const uint32_t started = millis()", receive)
|
|
self.assertIn("now - started >= RECEIVE_TOTAL_TIMEOUT_MS", receive)
|
|
self.assertIn("now - lastProgress >= RECEIVE_IDLE_TIMEOUT_MS", receive)
|
|
self.assertIn("timedOut = true", receive)
|
|
self.assertGreater(
|
|
receive.index("millis() - started >= RECEIVE_TOTAL_TIMEOUT_MS"),
|
|
receive.index("font.close()"),
|
|
)
|
|
self.assertIn('timedOut ? "ERROR TIMEOUT\\n"', receive)
|
|
|
|
def test_interrupted_stage_cannot_replace_live_font(self):
|
|
rp = source(RP2040)
|
|
recovery = function_body(rp, "void recoverFontTransaction", "bool refreshFontInfo")
|
|
mount = function_body(rp, "bool mountSdCard", "void serviceSdMount")
|
|
stage_install = function_body(rp, "bool installStagedPair", "enum class ReceiveMode")
|
|
receive = function_body(rp, "void receiveFont", "struct CommandReader")
|
|
|
|
self.assertNotIn("STAGED_FONT_PATH", recovery)
|
|
self.assertNotIn("STAGED_META_PATH", recovery)
|
|
self.assertIn("cleanStagedFiles();", mount)
|
|
self.assertLess(stage_install.index("validatePairCrc"),
|
|
stage_install.index("installTemporaryPair"))
|
|
self.assertIn("removeIfPresent(destinationFont)", receive)
|
|
self.assertIn("ReceiveMode::StageOnly", receive)
|
|
self.assertIn('output.print("STAGED\\n")', receive)
|
|
|
|
def test_interrupted_pair_renames_are_crc_checked_and_recoverable(self):
|
|
rp = source(RP2040)
|
|
validator = function_body(
|
|
rp, "bool validateStoredPairCrc", "void removeIfPresent"
|
|
)
|
|
promotion = function_body(rp, "bool promotePair", "void recoverFontTransaction")
|
|
recovery = function_body(
|
|
rp, "void recoverFontTransaction", "bool refreshFontInfo"
|
|
)
|
|
|
|
self.assertIn("validatePairCrc(fontPath, metadataPath, size, crc)", validator)
|
|
for font_path, metadata_path in (
|
|
("FONT_PATH", "TEMP_META_PATH"),
|
|
("FONT_PATH", "BACKUP_META_PATH"),
|
|
("BACKUP_FONT_PATH", "FONT_META_PATH"),
|
|
("TEMP_FONT_PATH", "TEMP_META_PATH"),
|
|
("BACKUP_FONT_PATH", "BACKUP_META_PATH"),
|
|
):
|
|
self.assertIn(
|
|
f"validateStoredPairCrc({font_path}, {metadata_path}", recovery
|
|
)
|
|
|
|
# A reset while moving the old pair to backup leaves its metadata at
|
|
# the live name. Complete that backup before trying the new temp pair,
|
|
# so a failed temp promotion still has a rollback candidate.
|
|
split_backup = recovery.index(
|
|
"validateStoredPairCrc(BACKUP_FONT_PATH, FONT_META_PATH"
|
|
)
|
|
temp_promotion = recovery.index(
|
|
"validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH"
|
|
)
|
|
self.assertLess(split_backup, temp_promotion)
|
|
self.assertIn("SD.rename(FONT_META_PATH, BACKUP_META_PATH)", recovery)
|
|
|
|
# Once the source font has moved, a metadata-rename failure must leave
|
|
# the split pair intact. Recovery also stops after trying any CRC-valid
|
|
# candidate so later fallbacks/cleanup cannot erase that retry state.
|
|
self.assertEqual(promotion.count("removeIfPresent(FONT_PATH)"), 1)
|
|
self.assertNotIn(
|
|
"if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;\n"
|
|
" removeIfPresent(FONT_PATH)",
|
|
promotion,
|
|
)
|
|
self.assertIn(
|
|
"if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH, size, crc))",
|
|
recovery,
|
|
)
|
|
self.assertIn(
|
|
"if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc))",
|
|
recovery,
|
|
)
|
|
temp_start = recovery.index(
|
|
"if (validateStoredPairCrc(TEMP_FONT_PATH, TEMP_META_PATH"
|
|
)
|
|
backup_start = recovery.index(
|
|
"if (validateStoredPairCrc(BACKUP_FONT_PATH, BACKUP_META_PATH"
|
|
)
|
|
cleanup_start = recovery.index("removeIfPresent(FONT_PATH)", backup_start)
|
|
for block in (
|
|
recovery[temp_start:backup_start],
|
|
recovery[backup_start:cleanup_start],
|
|
):
|
|
self.assertIn("promotePair", block)
|
|
self.assertIn("return;", block)
|
|
|
|
# Keep the ordinary healthy-boot INFO path fast; full-file CRC reads
|
|
# are reserved for abnormal transaction candidates.
|
|
healthy = recovery.index("validatePair(FONT_PATH, FONT_META_PATH")
|
|
first_transaction_crc = recovery.index("validateStoredPairCrc(")
|
|
self.assertLess(healthy, first_transaction_crc)
|
|
|
|
def test_internal_uart_commit_is_separate_from_usb_put(self):
|
|
rp = source(RP2040)
|
|
self.assertIn(
|
|
"CommandReader usbCommands = {Serial, Serial, true, false, false}", rp
|
|
)
|
|
self.assertIn(
|
|
"CommandReader espCommands = {Serial1, Serial1, false, true, true}", rp
|
|
)
|
|
self.assertIn("if (!reader.allowStagedUpload)", rp)
|
|
self.assertIn("if (!reader.allowUpload)", rp)
|
|
|
|
def test_retry_budget_and_backoff_are_bounded(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
self.assertIn("kMaximumAttemptsPerBoot = 4", policy)
|
|
self.assertIn("30000UL", policy)
|
|
self.assertIn("120000UL", policy)
|
|
self.assertIn("600000UL", policy)
|
|
self.assertIn(
|
|
"recoveryAttempts >= mesh::indicator_font::kMaximumAttemptsPerBoot",
|
|
client,
|
|
)
|
|
self.assertIn("RecoveryState::Exhausted", client)
|
|
self.assertIn("WiFi.status() == WL_CONNECTED", client)
|
|
|
|
def test_initial_unavailable_service_gets_bounded_post_wifi_reprobe(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
load = function_body(
|
|
client,
|
|
"uint8_t* IndicatorFontClient::load",
|
|
"uint8_t* IndicatorFontClient::serviceRecovery",
|
|
)
|
|
probe = function_body(client, "void serviceProbeTask", "void recoveryTask")
|
|
service = function_body(
|
|
client,
|
|
"uint8_t* IndicatorFontClient::serviceRecovery",
|
|
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
|
)
|
|
|
|
self.assertIn("armServiceProbe();", load)
|
|
self.assertIn("RecoveryState::ProbeWaiting", service)
|
|
self.assertIn("WiFi.status() == WL_CONNECTED", service)
|
|
self.assertIn("++serviceProbeAttempts", service)
|
|
self.assertIn("xTaskCreatePinnedToCore(serviceProbeTask", service)
|
|
|
|
self.assertIn("LoadedFont loaded = loadFromService();", probe)
|
|
self.assertIn("if (loaded.currentAsset)", probe)
|
|
self.assertIn("RecoveryState::Ready", probe)
|
|
self.assertIn("RecoveryNeed::Missing", probe)
|
|
self.assertIn("RecoveryNeed::Corrupt", probe)
|
|
self.assertIn("RecoveryNeed::VersionMismatch, true", probe)
|
|
self.assertIn("finishServiceProbeFailure();", probe)
|
|
|
|
self.assertIn("kMaximumServiceProbeAttemptsPerBoot = 4", policy)
|
|
self.assertIn("serviceProbeRetryDelayAfter", policy)
|
|
self.assertIn("2000UL", policy)
|
|
self.assertIn("5000UL", policy)
|
|
self.assertIn("15000UL", policy)
|
|
self.assertIn(
|
|
">= mesh::indicator_font::kMaximumServiceProbeAttemptsPerBoot",
|
|
client,
|
|
)
|
|
|
|
def test_acknowledged_commit_uses_bounded_local_reprobe_not_redownload(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
transfer = function_body(
|
|
client,
|
|
"RecoveryAttemptOutcome downloadAndInstallAsset",
|
|
"void finishRecoveryFailure",
|
|
)
|
|
arm = function_body(
|
|
client, "void armPostCommitProbe", "enum class HttpLineResult"
|
|
)
|
|
finish = function_body(
|
|
client,
|
|
"void finishPostCommitProbeFailure",
|
|
"void serviceProbeTask",
|
|
)
|
|
probe = function_body(
|
|
client, "void postCommitProbeTask", "void recoveryTask"
|
|
)
|
|
recovery = function_body(
|
|
client, "void recoveryTask", "#endif // INDICATOR_WIFI_FONT_RECOVERY"
|
|
)
|
|
service = function_body(
|
|
client,
|
|
"uint8_t* IndicatorFontClient::serviceRecovery",
|
|
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
|
)
|
|
|
|
# An acknowledged COMMIT is distinct from a network/staging failure.
|
|
# A transient post-commit INFO failure, or an ambiguous/missing COMMIT
|
|
# reply, enters the local-only state. Only an explicit ERROR is allowed
|
|
# to resume network attempts.
|
|
self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", client)
|
|
commit = transfer.index("const bool commitReplied = commandReply(")
|
|
committed = transfer.index("const bool committed =", commit)
|
|
rejected = transfer.index("const bool commitExplicitlyRejected", committed)
|
|
verified = transfer.index("bool postCommitVerified = false", commit)
|
|
probe_outcome = transfer.index(
|
|
"RecoveryAttemptDisposition::CommittedNeedsProbe", verified
|
|
)
|
|
self.assertLess(commit, committed)
|
|
self.assertLess(committed, rejected)
|
|
self.assertLess(rejected, verified)
|
|
self.assertLess(verified, probe_outcome)
|
|
self.assertIn("if (!committed)", transfer[commit:probe_outcome])
|
|
unresolved = transfer[
|
|
transfer.index("if (!committed)", commit) :
|
|
transfer.index("return recoveryOutcome(postCommitVerified", commit)
|
|
]
|
|
self.assertIn("commitExplicitlyRejected", unresolved)
|
|
self.assertIn("RecoveryAttemptDisposition::RetryableFailure", unresolved)
|
|
self.assertIn("RecoveryAttemptDisposition::CommittedNeedsProbe", unresolved)
|
|
|
|
self.assertIn("armPostCommitProbe(activateCommittedLive)", recovery)
|
|
network_failure = function_body(
|
|
client,
|
|
"void finishRecoveryFailure(const RecoveryAttemptOutcome& outcome)",
|
|
"void finishRecoveryFailure()",
|
|
)
|
|
self.assertIn(
|
|
"RecoveryAttemptDisposition::CommittedNeedsProbe",
|
|
network_failure,
|
|
)
|
|
self.assertIn("RecoveryState::Exhausted", network_failure)
|
|
local_load = recovery.index("LoadedFont loaded = loadFromService()")
|
|
local_failure = recovery.index("loaded.data == nullptr", local_load)
|
|
local_success = recovery.index("data = loaded.data", local_failure)
|
|
self.assertIn("armPostCommitProbe(true)", recovery[local_failure:local_success])
|
|
self.assertNotIn(
|
|
"finishRecoveryFailure", recovery[local_failure:local_success]
|
|
)
|
|
|
|
# The local path preserves the consumed download budget, needs no
|
|
# Wi-Fi/NTP/TLS, and can only become Ready/Complete or retry/exhaust.
|
|
self.assertNotIn("recoveryAttempts =", arm)
|
|
self.assertIn("RecoveryNeed::None", arm)
|
|
self.assertIn("RecoveryState::PostCommitProbeWaiting", arm)
|
|
info_only = probe[
|
|
probe.index("if (!activateLive)") :
|
|
probe.index("LoadedFont loaded = loadFromService();")
|
|
]
|
|
self.assertIn("probeServiceInfo(size, crc)", info_only)
|
|
self.assertIn("mesh::indicator_font::kAssetSize", info_only)
|
|
self.assertIn("mesh::indicator_font::kAssetCrc32", info_only)
|
|
self.assertIn("RecoveryState::Complete", info_only)
|
|
self.assertNotIn("ps_malloc", info_only)
|
|
self.assertNotIn("loadFromService", info_only)
|
|
self.assertIn("LoadedFont loaded = loadFromService();", probe)
|
|
self.assertIn("loaded.data != nullptr && loaded.currentAsset", probe)
|
|
self.assertIn("RecoveryState::Ready", probe)
|
|
self.assertIn("RecoveryState::Complete", probe)
|
|
self.assertIn("finishPostCommitProbeFailure();", probe)
|
|
for forbidden in (
|
|
"downloadAndInstallAsset",
|
|
"armRecovery",
|
|
"finishRecoveryFailure",
|
|
"prepareTlsClock",
|
|
"WiFi.status",
|
|
):
|
|
self.assertNotIn(forbidden, probe)
|
|
|
|
self.assertIn(
|
|
"kMaximumPostCommitProbeAttemptsPerBoot = 4", policy
|
|
)
|
|
post_commit_policy = policy[
|
|
policy.index("postCommitProbeRetryDelayAfter") :
|
|
policy.index("constexpr bool deadlineReached")
|
|
]
|
|
self.assertIn("2000UL", post_commit_policy)
|
|
self.assertIn("5000UL", post_commit_policy)
|
|
self.assertIn("15000UL", post_commit_policy)
|
|
self.assertIn("RecoveryState::Exhausted", finish)
|
|
self.assertIn("RecoveryState::PostCommitProbeWaiting", finish)
|
|
self.assertNotIn("RecoveryState::Waiting", finish)
|
|
|
|
post_commit_branch = service[
|
|
service.index("RecoveryState::PostCommitProbeWaiting") :
|
|
service.index("RecoveryState::ProbeWaiting")
|
|
]
|
|
self.assertNotIn("wifiConnected", post_commit_branch)
|
|
self.assertIn("++postCommitProbeAttempts", post_commit_branch)
|
|
self.assertIn("launchPostCommitProbe = true", post_commit_branch)
|
|
self.assertIn("xTaskCreatePinnedToCore(", service)
|
|
self.assertIn("postCommitProbeTask", service)
|
|
self.assertIn("finishPostCommitProbeFailure();", service)
|
|
|
|
maximum = int(
|
|
re.search(
|
|
r"kMaximumPostCommitProbeAttemptsPerBoot = (\d+)", policy
|
|
).group(1)
|
|
)
|
|
downloads_started = 1
|
|
state = "PostCommitProbeWaiting"
|
|
for attempt in range(1, maximum + 1):
|
|
state = (
|
|
"Exhausted"
|
|
if attempt >= maximum
|
|
else "PostCommitProbeWaiting"
|
|
)
|
|
self.assertEqual(downloads_started, 1)
|
|
self.assertEqual(state, "Exhausted")
|
|
|
|
def test_rejected_exact_recovered_font_cannot_redownload(self):
|
|
client = source(CLIENT)
|
|
display = source(DISPLAY)
|
|
rejected = function_body(
|
|
client,
|
|
"void IndicatorFontClient::noteRecoveredFontInvalid",
|
|
"#endif",
|
|
)
|
|
service = function_body(
|
|
display,
|
|
"void serviceFontRecovery",
|
|
"void turnOn",
|
|
)
|
|
|
|
self.assertIn("noteRecoveredFontInvalid()", service)
|
|
self.assertIn("RecoveryNeed::None", rejected)
|
|
self.assertIn("RecoveryState::Exhausted", rejected)
|
|
self.assertNotIn("armRecovery", rejected)
|
|
self.assertNotIn("finishRecoveryFailure", rejected)
|
|
self.assertNotIn("finishPostCommitProbeFailure", rejected)
|
|
self.assertNotIn("RecoveryState::Waiting", rejected)
|
|
self.assertNotIn("RecoveryState::PostCommitProbeWaiting", rejected)
|
|
self.assertNotIn("recoveryAttempts =", rejected)
|
|
self.assertNotIn("downloadAndInstallAsset", rejected)
|
|
|
|
def test_missing_corrupt_and_version_mismatch_are_distinct(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
for state in ("Missing", "Corrupt", "VersionMismatch"):
|
|
self.assertIn(state, policy)
|
|
self.assertIn(f"RecoveryNeed::{state}", client)
|
|
|
|
def test_live_activation_only_replaces_fallback_font(self):
|
|
policy = source(POLICY)
|
|
client = source(CLIENT)
|
|
display = source(DISPLAY)
|
|
self.assertIn(
|
|
"need == RecoveryNeed::Missing || need == RecoveryNeed::Corrupt",
|
|
policy,
|
|
)
|
|
self.assertIn("!hadRuntimeFont", client)
|
|
self.assertIn("IndicatorFontClient::serviceRecovery", display)
|
|
self.assertIn("installRuntimeFont(fontData, fontSize)", display)
|
|
self.assertIn("noteRuntimeFontInstalled", display)
|
|
self.assertIn("noteRuntimeFontInvalid", display)
|
|
|
|
def test_tls_cost_and_global_state_are_limited_to_wifi_indicator_profile(self):
|
|
wifi = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb_wifi")
|
|
usb = profile_section("env:SenseCapIndicator-LoRa_comp_radio_usb")
|
|
self.assertIn("INDICATOR_WIFI_FONT_RECOVERY=1", wifi)
|
|
self.assertNotIn("board_build.embed_files", wifi)
|
|
self.assertNotIn("INDICATOR_WIFI_FONT_RECOVERY", usb)
|
|
self.assertNotIn("board_build.embed_files", usb)
|
|
|
|
def test_core2_timeout_and_worker_stack_fit_internal_tls_buffers(self):
|
|
client = source(CLIENT)
|
|
response = function_body(
|
|
client, "RecoveryAttemptOutcome openAssetResponse", "bool writeAll"
|
|
)
|
|
service = function_body(
|
|
client,
|
|
"uint8_t* IndicatorFontClient::serviceRecovery",
|
|
"void IndicatorFontClient::noteRuntimeFontInstalled",
|
|
)
|
|
|
|
self.assertIn("ESP_ARDUINO_VERSION_MAJOR >= 3", response)
|
|
self.assertIn("HTTP_LINE_TIMEOUT_MS / 1000UL", response)
|
|
self.assertIn("RECOVERY_TASK_STACK_BYTES = 8192UL", client)
|
|
self.assertIn("RECOVERY_TASK_STACK_BYTES", service)
|
|
self.assertNotIn('"indicator-font", 24576', service)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|