Test radio and Access Control against pinned official app code
This commit is contained in:
1 parent
6d0906ba7c
commit
1d96725e73
6 files changed
+542
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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; });
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user