Test radio and Access Control against pinned official app code

This commit is contained in:
mikecarper committed 2026-10-04 14:47:10 -07:00
1 parent 6d0906ba7c
commit 1d96725e73
6 files changed
+542 -63

No files matched your search

+16
View File
@@ -219,6 +219,22 @@ jobs:
python3 -B test/test_client_acl_cli.py -v
python3 -B test/test_client_acl_response.py -v
- name: Setup Node for official app compatibility tests
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache pinned official app protocol source
uses: actions/cache@v4
with:
path: ~/.cache/meshcore-tests
key: official-app-84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68
- name: Verify stock app radio and Access Control compatibility
run: |
node --version
python3 -B test/test_official_app_compatibility.py -v
- name: Verify ESP32 USB reboot sleep grace and G3 button wake
run: |
python3 -B test/test_usb_host_sleep_policy.py
+55
View File
@@ -0,0 +1,55 @@
Official app compatibility source witness
Source: https://app.meshcore.nz/main.dart.js
Observed: 2026-10-04
Bytes: 9494192
SHA-256: 84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68
Run:
python3 -B test/test_official_app_compatibility.py -v
Requires Node.js 22 and a native C++17 compiler. Linux native fixtures run
under AddressSanitizer and UndefinedBehaviorSanitizer. No PlatformIO or
hardware is used. The complete web application is not copied into Git.
Default external cache:
~/.cache/meshcore-tests/84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68/main.dart.js
An optional MESHCORE_OFFICIAL_APP_BUNDLE path selects an existing external
bundle. Its bytes and SHA must match. Without that variable, a cold cache
downloads the public source and verifies its exact size and SHA before saving.
CI should use actions/setup-node with node-version 22 and actions/cache for
~/.cache/meshcore-tests, keyed by the full SHA above. The public URL is mutable:
if it changes and no pinned cache exists, the test fails explicitly. Updating
the source pin and function witnesses requires review; it never silently uses
the new app or replaces its parsers with test implementations.
The JS harness extracts and evaluates unmodified compiled app function bodies:
ACL request builder/decoder, CMD50 builder, binary frame parser, SENT/Binary
listeners, CLI response stripping, and all three radio settings screen parsers.
It also executes the app's byte writer/reader and event emitter. Mocked
boundaries are the Dart runtime/type helpers, timer scheduler, transport,
widget state/localization and RNG. The app's schema/tag matching/decoders are
not reimplemented. The deterministic RNG only makes a synthetic request
repeatable; its random value is not the Companion's replay timestamp.
The exact app-generated seven-byte ACL query is passed to the production
repeater receive/ACL handler, with actual packet budget/admission/serialization.
Only the crypto primitive and radio queue are mocked in that native fixture;
zero padding reflects the production cipher block size, not crypto validation.
The one-admin direct case asserts 11-byte plaintext, 16-byte padded ciphertext,
20-byte payload and 22-byte zero-path wire size. All 32 decoded cases cover
0/1/22/23/24/25/32/256 entries, direct/flood and zero/maximum return path.
Large ACLs retain legacy one-packet truncation, not a complete list guarantee.
Actual Companion and CommonCLI radio replies cover saved automatic/explicit
preambles and an active temporary/secondary profile. A fifth field must invoke
each stock app radio screen's existing error path.
The host listener accepts a matching ACL reply 3s into its estimate-2s/+5s
window. Actual firmware callback/SENT deadline behavior is separately exercised
by test_companion_delayed_reply_delivery.py. The global uncorrelated SENT race
is retained as an expected app-level mechanism witness. Neither this synthetic
race nor the newer firmware deadline regression proves the cause of the user's
intermittent Access Control timeout. The successful pre-fix live retry remains
valid. This public web code is not proof of the installed iOS binary/version,
and these native tests do not exercise RF, BLE fragmentation or app UI lifecycle.
+207
View File
@@ -0,0 +1,207 @@
// Execute unmodified functions extracted from the SHA-pinned official web app.
// Only Dart runtime, scheduler, widget state and transport boundaries are mocked.
// This is a protocol contract witness, not an iOS/BLE UI automation test.
"use strict";
const assert = require("assert/strict");
const fs = require("fs");
const vm = require("vm");
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const source = fs.readFileSync(input.bundle, "utf8");
function extract(needle, at = 0) {
const begin = source.indexOf(needle, at);
assert(begin >= 0, `Missing pinned app function: ${needle}`);
const open = source.indexOf("{", begin);
let depth = 0, quote = null, escaped = false;
for (let end = open; end < source.length; ++end) {
const ch = source[end];
if (quote) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") ++depth;
else if (ch === "}" && --depth === 0) return source.slice(begin, end + 1);
}
throw Error(`Unclosed app function: ${needle}`);
}
let now = 0, timers = [], radioConnection;
const errors = [];
const type = {a: value => value, b: value => value instanceof Uint8Array,
h() { return this; }};
const t = new Proxy({}, {get: () => type});
const $ = {cT: () => new Uint8Array(0), cqf() {}, dF() {}, ay() {}, fQ() {},
vI: () => ({Ia: () => 0x78563412})};
const B = {
e: {aN: (a, b) => a >> b, qP: (a, b) => Math.trunc(a / b)},
j: {c8: (a, start, end, b) => a.set(b.subarray(0, end - start), start),
gcc: a => a.buffer, gS: a => a[0]},
be: {gcc: a => a.buffer},
d: {bc: (a, b) => a.includes(b), iV: (a, b, c) => a.split(b).join(c)}
};
const J = {
a3: () => ({gt: a => a.length, gap: a => a.length === 0, i: (a, i) => a[i]}),
i2: (a, b, c) => new Uint8Array(a, b, c),
po: a => new Uint8Array(a), lx: (a, b, c) => new DataView(a, b, c),
c6: (a, b) => a.push(b), aaW: (a, predicate) => {
for (let i = a.length - 1; i >= 0; --i) if (predicate.$1(a[i])) a.splice(i, 1);
},
bf: a => { let i = -1; return {v: () => ++i < a.length, gM: () => a[i]}; },
cTH: a => a.a, kz: (a, b) => a.split(b), aA: a => a.length,
a1: (a, i) => a[i], p: (a, b) => a === b, cri: (a, n) => a.toFixed(n)
};
function deferred() {
const result = {a: 0};
result.promise = new Promise((resolve, reject) => {
result.resolve = resolve; result.reject = reject;
});
result.then = result.promise.then.bind(result.promise);
return result;
}
const A = {
k: deferred, f: fn => fn,
j: (fn, result) => { fn(0, null); return result.promise; },
e: (value, fn) => Promise.resolve(value).then(v => fn(0, v), e => fn(1, e)),
i: (value, result) => result.resolve(value), h: (error, result) => result.reject(error),
V: function() { return deferred(); },
am: function(value) {
this.a = value;
this.bK = (_, result) => { value.a = 30; value.resolve(result); };
this.bD = error => { value.a = 30; value.reject(error); };
},
a: value => value, aX: value => value,
ew: (start, end, length) => {
const result = end === null ? length : end;
assert(start >= 0 && result >= start && result <= length); return result;
},
en: (bytes, start, end) => ({cS: () => bytes.slice(start, end)}), cj: () => type,
b3: () => ({$1() {}}), b6() {},
bF: (_, __, ___, ms) => ({a: ms * 1000}), bT: function(a) { this.a = a; },
fh: (duration, callback) => timers.push({due: now + duration.a / 1000, callback}),
csH: callback => queueMicrotask(() => callback.$0()),
wc: function() {}, an: () => radioConnection,
kY: value => { const number = Number(value); assert(Number.isFinite(number)); return number; },
cS: value => { const number = Number(value); assert(Number.isInteger(number)); return number; },
c: () => ({glp: () => "invalid radio fields", glb: () => "radio error"}),
av: (...args) => errors.push(args), L: error => error,
aH: (_, error) => errors.push(error)
};
const context = vm.createContext({A, B, J, t, $, u: {AD: "tag mismatch", b1: "CLI mismatch"},
Uint8Array, DataView, ArrayBuffer, Math, Date});
function constructor(name) {
const code = extract(`${name}:function ${name}(`).replace(`${name}:`, `A.${name}=`);
vm.runInContext(code, context);
}
for (const name of ["cZ", "d3", "fo", "ab7", "U1", "aba", "PS", "acs",
"aSq", "aSp", "aSr", "aSs", "aSf", "pK", "b0Z", "b0Y",
"b0X", "b1_", "b0W"]) constructor(name);
for (const name of ["cZ", "d3", "fo", "aba", "aSq", "aSp", "aSr", "aSs", "aSf",
"b0V", "b0Z", "b0Y", "b0X", "b1_", "b0W", "wc"]) {
if (!A[name]) A[name] = function() {};
vm.runInContext(extract(`A.${name}.prototype={`), context);
}
Object.assign(A, vm.runInContext(`({${extract("eD(a){")},${extract("d_f(a){")},${extract("bO(a,b){")}})`, context));
function connection() {
const connection = new A.wc();
const map = new Map();
connection.a = {
cu(_, key, factory) { if (!map.has(key)) map.set(key, factory.$0()); },
i: (_, key) => map.get(key), Z: (_, key) => map.has(key),
ey: (_, fn) => { for (const [key, value] of map) fn.$2(key, value); }
};
for (const name of ["eY", "N", "bCk", "bu", "dT", "W7"])
connection[name] = A.b0V.prototype[name];
connection.writes = [];
connection.d6 = async bytes => { connection.writes.push(Buffer.from(bytes)); };
return connection;
}
async function settle() { for (let i = 0; i < 20; ++i) await Promise.resolve(); }
async function advance(ms) {
now += ms;
for (const timer of timers.filter(timer => timer.due <= now)) timer.callback.$0();
timers = timers.filter(timer => timer.due > now); await settle();
}
function sent(tag, estimate = 2000) {
const bytes = Buffer.alloc(10); bytes[0] = 6;
bytes.writeUInt32LE(tag, 2); bytes.writeUInt32LE(estimate, 6); return bytes;
}
function binary(tag, hex) {
const prefix = Buffer.alloc(6); prefix[0] = 140; prefix.writeUInt32LE(tag, 2);
return Buffer.concat([prefix, Buffer.from(hex, "hex")]);
}
const key = Uint8Array.from({length: 32}, (_, i) => i + 1);
async function main() {
const builder = connection();
const pending = builder.aQ1(key); pending.catch(() => {}); await settle();
assert.equal(builder.writes.length, 1);
assert.equal(builder.writes[0].length, 40);
assert.equal(builder.writes[0][0], 50);
assert.equal(builder.writes[0].subarray(1, 33).toString("hex"), Buffer.from(key).toString("hex"));
assert.equal(builder.writes[0].subarray(33).toString("hex"), "05000012345678");
if (input.mode === "build") {
console.log(JSON.stringify({command: builder.writes[0].toString("hex"),
request: builder.writes[0].subarray(33).toString("hex")}));
return;
}
let decoded = 0;
for (const reply of input.acl) {
const client = connection(); const promise = client.aQ1(key); await settle();
client.aL9(sent(51)); await settle();
const timer = timers[timers.length - 1]; assert.equal(timer.due - now, 7000);
// Valid matching ACL data remains accepted past the old 2400ms deadline.
await advance(3000); client.aL9(binary(51, reply.body)); await settle();
const result = await promise;
assert.equal(result.a.length, reply.entries);
for (let index = 0; index < result.a.length; ++index) {
const entry = result.a[index]; assert.equal(entry.b.a, 3);
assert.equal(Buffer.from(entry.a).toString("hex"), reply.body.slice(index * 14, index * 14 + 12));
}
++decoded;
}
// Retain the stock app's global SENT-listener race as an explicit witness.
// This demonstrates a mechanism and does not identify an observed iOS cause.
const raced = connection(); let failure;
const request = raced.aQ1(key).catch(error => { failure = error; }); await settle();
raced.aL9(sent(50)); await settle(); raced.aL9(sent(51)); await settle();
raced.aL9(binary(51, input.acl.find(reply => reply.entries === 1).body)); await settle();
await advance(7000); await request; assert.equal(failure, "timeout");
// Feed actual firmware CLI output through the stock response stripping
// callback, then through every stock radio screen's unmodified HH parser.
let screens = 0;
const radioNeedle = 'return A.e(A.an().d5(m.a.c,"get radio"),$async$HH)';
let at = 0;
while ((at = source.indexOf(radioNeedle, at)) >= 0) {
const begin = source.lastIndexOf("\nHH(){", at) + 1;
assert(begin > 0); const code = extract("HH(){", begin);
const method = vm.runInContext(`({${code}}).HH`, context);
for (const name of code.matchAll(/new A\.(\w+)\(m\)/g)) constructor(name[1]);
for (const radio of input.radio) {
const state = {y: false, a: {c: key}, c: {}, m() {}};
const values = [];
for (const field of ["CW", "ax", "ay"])
state[field] = {sav: (_, value) => values.push(value)};
const completion = deferred(); const completer = new A.am(completion);
const listener = new A.aSf({a: null, b: null, c: null}, {N() {}}, key.slice(0, 6), "ab", completer);
listener.$1({a: key.slice(0, 6), e: `ab|${radio}`});
radioConnection = {d5: async () => completion.promise};
errors.length = 0; await method.call(state);
assert.deepEqual(values, ["909.500"]); assert.equal(errors.length, 0);
const fields = code.match(/m\.(\w+)=i\nm\.(\w+)=h\nm\.(\w+)=g/);
assert(fields); assert.equal(state[fields[1]], 62.5);
assert.equal(state[fields[2]], 7); assert.equal(state[fields[3]], 5);
}
// The exact previously broken fifth field must reach the app's error path.
const state = {y: false, a: {c: key}, c: {}, m() {}};
radioConnection = {d5: async () => "909.5,62.5,7,5,48"};
errors.length = 0; await method.call(state); assert.equal(errors.length, 1);
++screens; at += radioNeedle.length;
}
assert.equal(screens, 3);
console.log(JSON.stringify({acl_cases: decoded, radio_screens: screens,
stock_sent_race_witness: true, actual_ios_cause_proven: false}));
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+30 -25
View File
@@ -28,36 +28,41 @@ def parse_acl(buf):
return res
def production_acl_methods():
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
handler = extract_braced(source, "int MyMesh::handleRequest(")
prefix = handler[:handler.index(" if (payload[0] == REQ_TYPE_GET_STATUS)")]
acl = extract_braced(handler, "if (payload[0] == REQ_TYPE_GET_ACCESS_LIST)")
generated = prefix + acl + "\nreturn 0;\n}\n"
receive = extract_braced(source, "if (type == PAYLOAD_TYPE_REQ) { // request")
generated += """
void MyMesh::receive(mesh::Packet* packet, uint8_t* data, size_t len) {
ClientInfo* client = &sender;
const uint8_t type = PAYLOAD_TYPE_REQ;
uint8_t secret[PUB_KEY_SIZE] = {};
""" + receive + "\n}\n"
mesh_source = (ROOT / "src/Mesh.cpp").read_text()
generated += "namespace mesh {\n"
generated += "#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE)\n"
for signature in (
"Packet* Mesh::createPathReturn(const Identity& dest,",
"Packet* Mesh::createPathReturn(const uint8_t* dest_hash,",
"Packet* Mesh::createDatagram(",
):
generated += extract_braced(mesh_source, signature) + "\n"
packet_source = (ROOT / "src/Packet.cpp").read_text()
for signature in ("Packet::Packet()", "bool Packet::isValidPathLen("):
generated += extract_braced(packet_source, signature) + "\n"
generated += "}\n"
return generated
class ClientAclResponseTest(unittest.TestCase):
def test_production_acl_route_admission_and_replay(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
handler = extract_braced(source, "int MyMesh::handleRequest(")
prefix = handler[:handler.index(" if (payload[0] == REQ_TYPE_GET_STATUS)")]
acl = extract_braced(handler, "if (payload[0] == REQ_TYPE_GET_ACCESS_LIST)")
generated = prefix + acl + "\nreturn 0;\n}\n"
receive = extract_braced(source, "if (type == PAYLOAD_TYPE_REQ) { // request")
generated += """
void MyMesh::receive(mesh::Packet* packet, uint8_t* data, size_t len) {
ClientInfo* client = &sender;
const uint8_t type = PAYLOAD_TYPE_REQ;
uint8_t secret[PUB_KEY_SIZE] = {};
""" + receive + "\n}\n"
mesh_source = (ROOT / "src/Mesh.cpp").read_text()
generated += "namespace mesh {\n"
generated += "#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE)\n"
for signature in (
"Packet* Mesh::createPathReturn(const Identity& dest,",
"Packet* Mesh::createPathReturn(const uint8_t* dest_hash,",
"Packet* Mesh::createDatagram(",
):
generated += extract_braced(mesh_source, signature) + "\n"
packet_source = (ROOT / "src/Packet.cpp").read_text()
for signature in ("Packet::Packet()", "bool Packet::isValidPathLen("):
generated += extract_braced(packet_source, signature) + "\n"
generated += "}\n"
generated = production_acl_methods()
with tempfile.TemporaryDirectory(prefix=".tmp-acl-response-", dir=ROOT) as directory:
work = Path(directory)
(work / "production.inc").write_text(generated, encoding="ascii")
@@ -186,46 +186,51 @@ int main() {
'''
def production_primary_radio_harness():
companion = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
header = (ROOT/'examples/companion_radio/MyMesh.h').read_text(encoding='utf-8')
infra = (ROOT/'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
datastore = (ROOT/'examples/companion_radio/DataStore.cpp').read_text(encoding='utf-8')
handler = extract_braced(companion, 'bool MyMesh::handleCommand(')
# Keep the complete preprocessing sequence through the shared primary
# tuple validation. Only unrelated dispatch routes are omitted.
dispatch = handler[:handler.index(' if (sender_timestamp == 0 && handleDirectCommand')]
dispatch += extract_braced(handler, 'if (isCompanionRadioPrefsCommand(command))')
dispatch += '\nreturn false;\n}'
def boot_checks(source, member):
nonfinite = re.search(r'if \(!isfinite\(' + re.escape(member) + r'\)\)[^;]+;', source)
clamp = re.search(re.escape(member) + r' = constrain\([^;]+;', source)
assert nonfinite is not None
assert clamp is not None
return nonfinite.group() + '\n' + clamp.group()
support = PREFERENCES_HARNESS[:PREFERENCES_HARNESS.index('int main(')]
support = support.replace('@METHODS@', esp_recovery_helpers(datastore) + '\n'.join(extract_braced(datastore, sig) for sig in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', 'bool DataStore::savePrefs(')))
replacements = {
'@SAVE_PREFS@': extract_braced(header, 'bool savePrefs()'),
'@COMMAND_FILTER@': extract_braced(companion, 'static bool isCompanionRadioPrefsCommand('),
'@DISPATCH@': dispatch,
'@COMPANION_SANITIZE@': boot_checks(companion, '_prefs.airtime_factor'),
'@INFRA_SANITIZE@': boot_checks(infra, '_prefs->airtime_factor'),
'@INFRA_SETTER@': extract_braced(infra, 'if (memcmp(config, "dutycycle ", 10) == 0)'),
'@INFRA_RADIO@': extract_braced(infra, 'if (configKeyEquals(config, "radio"))').split('{', 1)[1].rsplit('}', 1)[0],
}
harness = support + HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
# Saving an unrelated field must not resurrect a failed tuple later.
harness = harness.replace('static bool error(', '''static bool saveUnrelated(MyMesh& m) {
m._prefs.ble_pin=654321;return m.savePrefs();
}
static bool error(''')
return harness
class CompanionPrimaryRadioPersistenceTests(unittest.TestCase):
def test_production_validation_rollback_and_dutycycle_boot(self):
companion = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
header = (ROOT/'examples/companion_radio/MyMesh.h').read_text(encoding='utf-8')
infra = (ROOT/'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
datastore = (ROOT/'examples/companion_radio/DataStore.cpp').read_text(encoding='utf-8')
handler = extract_braced(companion, 'bool MyMesh::handleCommand(')
# Keep the complete preprocessing sequence through the shared primary
# tuple validation. Only unrelated dispatch routes are omitted.
dispatch = handler[:handler.index(' if (sender_timestamp == 0 && handleDirectCommand')]
dispatch += extract_braced(handler, 'if (isCompanionRadioPrefsCommand(command))')
dispatch += '\nreturn false;\n}'
def boot_checks(source, member):
nonfinite = re.search(r'if \(!isfinite\(' + re.escape(member) + r'\)\)[^;]+;', source)
clamp = re.search(re.escape(member) + r' = constrain\([^;]+;', source)
self.assertIsNotNone(nonfinite)
self.assertIsNotNone(clamp)
return nonfinite.group() + '\n' + clamp.group()
support = PREFERENCES_HARNESS[:PREFERENCES_HARNESS.index('int main(')]
support = support.replace('@METHODS@', esp_recovery_helpers(datastore) + '\n'.join(extract_braced(datastore, sig) for sig in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', 'bool DataStore::savePrefs(')))
replacements = {
'@SAVE_PREFS@': extract_braced(header, 'bool savePrefs()'),
'@COMMAND_FILTER@': extract_braced(companion, 'static bool isCompanionRadioPrefsCommand('),
'@DISPATCH@': dispatch,
'@COMPANION_SANITIZE@': boot_checks(companion, '_prefs.airtime_factor'),
'@INFRA_SANITIZE@': boot_checks(infra, '_prefs->airtime_factor'),
'@INFRA_SETTER@': extract_braced(infra, 'if (memcmp(config, "dutycycle ", 10) == 0)'),
'@INFRA_RADIO@': extract_braced(infra, 'if (configKeyEquals(config, "radio"))').split('{', 1)[1].rsplit('}', 1)[0],
}
harness = support + HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
# Saving an unrelated field must not resurrect a failed tuple later.
harness = harness.replace('static bool error(', '''static bool saveUnrelated(MyMesh& m) {
m._prefs.ble_pin=654321;return m.savePrefs();
}
static bool error(''')
harness = production_primary_radio_harness()
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix='companion-primary-radio-') as directory:
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Execute pinned stock app code against actual firmware ACL/radio outputs.
The public web build is the source witness, not proof of the installed iOS app.
The whole app remains outside Git. A changed upstream bundle fails its SHA gate;
an explicit source-pin update is required, never silent parser replacement.
No PlatformIO, devices, app fork, settings writes or real radio are involved.
"""
from pathlib import Path
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
import urllib.request
from test_client_acl_response import production_acl_methods
from test_companion_primary_radio_persistence import production_primary_radio_harness
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
APP_URL = "https://app.meshcore.nz/main.dart.js"
APP_SHA256 = "84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68"
APP_BYTES = 9494192
JS = ROOT / "test/fixtures/official_app_compatibility/app_contract.js"
SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
def official_app_bundle():
explicit = os.environ.get("MESHCORE_OFFICIAL_APP_BUNDLE")
path = Path(explicit) if explicit else (
Path.home() / ".cache/meshcore-tests" / APP_SHA256 / "main.dart.js")
if not path.exists():
if explicit:
raise AssertionError(f"Missing explicit official app bundle: {path}")
with urllib.request.urlopen(APP_URL, timeout=45) as response:
data = response.read(APP_BYTES + 1)
if len(data) != APP_BYTES or hashlib.sha256(data).hexdigest() != APP_SHA256:
raise AssertionError("Official app changed: obtain the pinned cached build or review/update its source pin")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
data = path.read_bytes()
if len(data) != APP_BYTES or hashlib.sha256(data).hexdigest() != APP_SHA256:
raise AssertionError(f"Official app source SHA/size mismatch: {path}")
return path.resolve()
def checked(command, timeout=60):
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
return result.stdout
def acl_outputs(work, compiler, app_request):
source = production_acl_methods()
packet = (ROOT / "src/Packet.cpp").read_text()
source += "namespace mesh {\n" + "\n".join(extract_braced(packet, signature) for signature in (
"size_t Packet::writePath(", "int Packet::getRawLength() const",
"uint8_t Packet::writeTo(uint8_t dest[]) const")) + "\n}\n"
(work / "production.inc").write_text(source, encoding="ascii")
fixture = (ROOT / "test/fixtures/client_acl_response/test_client_acl_response.cpp").read_text()
fixture = fixture[:fixture.index("int main()")]
request = ",".join(str(byte) for byte in bytes.fromhex(app_request))
fixture += r'''
int main() {
uint8_t app_query[] = {APP_QUERY};
static_assert(sizeof(app_query) == 7, "Exact stock app ACL request schema");
for (bool flood : {false, true}) for (uint8_t path_len : {0, 0x60})
for (unsigned clients : {0, 1, 22, 23, 24, 25, 32, 256}) {
MyMesh target; fill(target.acl, clients);
target.sender.last_timestamp = 50;
mesh::Packet request; request.header = flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT;
request.path_len = path_len;
uint8_t plaintext[11]; uint32_t tag = 51;
memcpy(plaintext, &tag, 4); memcpy(plaintext + 4, app_query, sizeof(app_query));
uint8_t secret[PUB_KEY_SIZE] = {};
auto* transmitted = target.createDatagram(PAYLOAD_TYPE_REQ, target.sender.id,
secret, plaintext, sizeof(plaintext));
// Radio send boundaries set the route bits after packet construction.
assert(transmitted != nullptr); transmitted->header |= ROUTE_TYPE_DIRECT;
assert(transmitted && transmitted->payload_len == 20 && transmitted->getRawLength() == 22);
target.receive(&request, plaintext, sizeof(plaintext));
assert(target.queued == 1 && target.sender.last_timestamp == tag);
auto* reply = target.last_reply; assert(reply != nullptr);
reply->header |= flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT;
const unsigned capacity = mesh::clientACLReplyCapacity(flood, path_len);
const unsigned entries = std::min(clients, (capacity - 4) / 7);
const unsigned route_bytes = (path_len & 63) * ((path_len >> 6) + 1);
const size_t body_offset = 2 * PATH_HASH_SIZE + CIPHER_MAC_SIZE + 4
+ (flood ? 2 + route_bytes : 0);
if (!flood && clients == 1) {
assert(sizeof(plaintext) == 11 && reply->payload_len == 20 && reply->getRawLength() == 22);
uint8_t wire[MAX_PACKET_PAYLOAD + MAX_PATH_SIZE + 6];
assert(reply->writeTo(wire) == 22); // 11 plaintext -> 16 cipher -> 20 payload -> 22 wire.
}
print_legacy(reply->payload + body_offset, entries, reply->payload_len - body_offset);
}
}
'''.replace("APP_QUERY", request)
(work / "acl.cpp").write_text(fixture, encoding="ascii")
binary = work / "acl.exe"
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-unused-function", *SANITIZERS,
f"-I{work}", f"-I{ROOT / 'src'}", str(work / "acl.cpp"), "-o", str(binary)])
output = checked([str(binary)], timeout=20)
records = []
for line in output.splitlines():
if line.startswith("LEGACY:"):
_, entries, body = line.split(":")
records.append({"entries": int(entries), "body": body})
if len(records) != 32:
raise AssertionError(output)
return records
def radio_outputs(work, compiler):
harness = production_primary_radio_harness()
# Execute the same production Companion dispatch, shared preferences parser
# and CommonCLI infrastructure branch with automatic/explicit preambles.
begin = harness.index("int main()")
harness = harness[:begin] + r'''
int main() {
for (uint32_t timestamp : {0U, 1700000000U}) for (uint16_t preamble : {0U, 48U}) {
MyMesh node; assert(node._radio_profiles.savePrimaryPreamble(preamble));
node.radio.p.primary_temporary = true;
node.radio.p.primary.freq = 915; node.radio.p.primary.preamble = 96;
node.radio.p.secondary.params = {916, 125, 64, 8, 6};
node.radio.p.secondary.mode = mesh::RadioProfileMode::RxTx;
printf("RADIO:%s\n", node.command("get radio", timestamp));
Infrastructure repeater; char reply[160] = {};
repeater.radioCommand(reply); printf("RADIO:%s\n", reply);
}
}
'''
utils = (ROOT / "test/mocks/Utils.h").read_text().replace("class Utils {\npublic:", """class Utils {
public:
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
""")
(work / "Utils.h").write_text("#include <Arduino.h>\n#include <cassert>\n" + utils, encoding="ascii")
(work / "Identity.h").write_text((ROOT / "test/mocks/Identity.h").read_text(), encoding="ascii")
transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text()
(work / "ContactFileTransaction.h").write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'), encoding="ascii")
(work / "radio.cpp").write_text(harness, encoding="ascii")
binary = work / "radio.exe"
checked([compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-function",
"-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-reorder",
"-DESP32_PLATFORM=1", *SANITIZERS,
f"-I{work}", f"-I{ROOT / 'test/fixtures/radio_profiles/mocks'}",
f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", f"-I{ROOT / 'src/helpers'}", f"-I{ROOT}",
str(work / "radio.cpp"), *[str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",
"TxtDataHelpers.cpp", "RadioProfileCLI.cpp")], "-o", str(binary)])
output = checked([str(binary)], timeout=20)
replies = [line.removeprefix("RADIO:") for line in output.splitlines() if line.startswith("RADIO:")]
if len(replies) != 8:
raise AssertionError(output)
return replies
class OfficialAppCompatibilityTests(unittest.TestCase):
def test_pinned_app_functions_with_production_request_and_outputs(self):
compiler = shutil.which("g++") or shutil.which("clang++")
node = shutil.which("node")
self.assertIsNotNone(compiler, "native C++ compiler required")
self.assertIsNotNone(node, "Node.js required for actual stock app functions")
bundle = official_app_bundle()
with tempfile.TemporaryDirectory(prefix="meshcore-official-app-") as directory:
work = Path(directory)
config = work / "input.json"
config.write_text(json.dumps({"bundle": str(bundle), "mode": "build"}), encoding="ascii")
generated = json.loads(checked([node, str(JS), str(config)], timeout=20))
config.write_text(json.dumps({"bundle": str(bundle), "mode": "check",
"acl": acl_outputs(work, compiler, generated["request"]),
"radio": radio_outputs(work, compiler)}), encoding="ascii")
result = json.loads(checked([node, str(JS), str(config)], timeout=20))
self.assertEqual(result["acl_cases"], 32)
self.assertEqual(result["radio_screens"], 3)
self.assertTrue(result["stock_sent_race_witness"])
self.assertFalse(result["actual_ios_cause_proven"])
if __name__ == "__main__":
unittest.main()