diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 38bd1429f..817748f0c 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/test/fixtures/official_app_compatibility/README.txt b/test/fixtures/official_app_compatibility/README.txt new file mode 100644 index 000000000..afb5ea7e9 --- /dev/null +++ b/test/fixtures/official_app_compatibility/README.txt @@ -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. diff --git a/test/fixtures/official_app_compatibility/app_contract.js b/test/fixtures/official_app_compatibility/app_contract.js new file mode 100644 index 000000000..8a798e875 --- /dev/null +++ b/test/fixtures/official_app_compatibility/app_contract.js @@ -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; }); diff --git a/test/test_client_acl_response.py b/test/test_client_acl_response.py index 0b0f34791..473a2ebe8 100644 --- a/test/test_client_acl_response.py +++ b/test/test_client_acl_response.py @@ -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") diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 809668eb1..9ae779a51 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -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: diff --git a/test/test_official_app_compatibility.py b/test/test_official_app_compatibility.py new file mode 100644 index 000000000..263a6cf86 --- /dev/null +++ b/test/test_official_app_compatibility.py @@ -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 \n#include \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 '), 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()