diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index b87b77aaf..d24390017 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -796,7 +796,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/meshcore-tests - key: official-clients-84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68-aff42ad8ef2b332f24d644ff6a783f8272a88ff2 + key: official-clients-${{ hashFiles('test/test_official_app_compatibility.py', 'test/test_official_config_gui.py', 'test/test_official_web_console.py') }} - name: Verify stock app radio, Access Control and room compatibility run: | diff --git a/lib/ed25519/fe.c b/lib/ed25519/fe.c index 909ce0710..6ea6102f2 100644 --- a/lib/ed25519/fe.c +++ b/lib/ed25519/fe.c @@ -9,6 +9,7 @@ /* helper functions */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD static uint64_t load_3(const unsigned char *in) { uint64_t result; @@ -29,6 +30,7 @@ static uint64_t load_4(const unsigned char *in) { return result; } +#endif @@ -301,6 +303,7 @@ void fe_copy(fe h, const fe f) { #endif +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_frombytes(fe h, const unsigned char *s) { int64_t h0 = load_4(s); int64_t h1 = load_3(s + 4) << 6; @@ -368,6 +371,8 @@ void fe_frombytes(fe h, const unsigned char *s) { +#endif + void fe_invert(fe out, const fe z) { fe t0; fe t1; @@ -482,6 +487,7 @@ int fe_isnegative(const fe f) { |f| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD int fe_isnonzero(const fe f) { unsigned char s[32]; unsigned char r; @@ -525,6 +531,7 @@ int fe_isnonzero(const fe f) { return r != 0; } +#endif @@ -785,6 +792,7 @@ Postconditions: |h| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_mul121666(fe h, fe f) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -852,7 +860,6 @@ Postconditions: |h| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. */ -#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_neg(fe h, const fe f) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -1400,6 +1407,7 @@ Proof: #endif +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_tobytes(unsigned char *s, const fe h) { int32_t h0 = h[0]; int32_t h1 = h[1]; @@ -1506,3 +1514,4 @@ void fe_tobytes(unsigned char *s, const fe h) { s[30] = (unsigned char) (h9 >> 10); s[31] = (unsigned char) (h9 >> 18); } +#endif diff --git a/lib/ed25519/fe_compact.h b/lib/ed25519/fe_compact.h index a00c005fc..a21ba771f 100644 --- a/lib/ed25519/fe_compact.h +++ b/lib/ed25519/fe_compact.h @@ -1,15 +1,18 @@ /* - * Fixed-loop form of the ref10 field products in fe.c. Only the headless - * STM32 Companion size recipes opt in; other builds keep the unrolled code. + * Fixed-loop form of the ref10 field arithmetic in fe.c. The existing STM32 + * size recipes opt in; other builds keep the unrolled code. * The radix alternates 26 and 25 bits. Odd-by-odd products therefore have * weight 2, and terms beyond bit 254 fold back with weight 19, exactly as in - * ref10. Product accumulation and the rounded carry order are unchanged. + * ref10. Product accumulation, decoding, encoding and carry orders are unchanged. * * All loops, branches and array addresses depend only on public limb indices * or the public operation (multiply, square, double-square), not input values. * Under ref10's 1.65*2^26 / 1.65*2^25 input bounds, even doubled sums fit in * signed 64 bits. Outputs are written only after reading every input, so h - * may alias f or g. No additional persistent RAM or heap is needed. + * may alias f or g. No additional persistent RAM or heap is needed. Compared + * with the unrolled forms, public-index loop overhead trades CPU time for + * flash; the fixed number of field operations and secret-independent control + * flow are retained for every opted-in Companion and Mini Repeater profile. */ void fe_add(fe h, const fe f, const fe g) { @@ -48,6 +51,84 @@ void fe_cswap(fe f, fe g, unsigned int b) { } } +static void fe_compact_carry(fe h, int64_t limbs[10], + const unsigned char *order, int count) { + int i; + for (i = 0; i < count; ++i) { + const int index = order[i]; + const int bits = 26 - (index & 1); + const int64_t radix = (int64_t)1 << bits; + const int64_t carry = (limbs[index] + radix / 2) >> bits; + if (index == 9) limbs[0] += carry * 19; + else limbs[index + 1] += carry; + /* A carry may be negative; do not left-shift that signed value. */ + limbs[index] -= carry * radix; + } + for (i = 0; i < 10; ++i) h[i] = (int32_t)limbs[i]; +} + +static const unsigned char fe_decode_carries[] = {9, 1, 3, 5, 7, 0, 2, 4, 6, 8}; + +void fe_frombytes(fe h, const unsigned char *s) { + static const unsigned char offsets[] = {0, 4, 7, 10, 13, 16, 20, 23, 26, 29}; + static const unsigned char shifts[] = {0, 6, 5, 3, 2, 0, 7, 5, 4, 2}; + int64_t limbs[10]; + int i, j; + for (i = 0; i < 10; ++i) { + uint32_t word = 0; + const int count = i == 0 || i == 5 ? 4 : 3; + for (j = 0; j < count; ++j) + word |= (uint32_t)s[offsets[i] + j] << (j * 8); + if (i == 9) word &= 8388607; /* Ignore the encoded sign bit. */ + limbs[i] = (int64_t)word << shifts[i]; + } + fe_compact_carry(h, limbs, fe_decode_carries, sizeof(fe_decode_carries)); +} + +void fe_mul121666(fe h, fe f) { + int64_t limbs[10]; + int i; + for (i = 0; i < 10; ++i) limbs[i] = (int64_t)f[i] * 121666; + fe_compact_carry(h, limbs, fe_decode_carries, sizeof(fe_decode_carries)); +} + +void fe_tobytes(unsigned char *s, const fe h) { + int32_t limbs[10], q = (19 * h[9] + (1 << 24)) >> 25; + int i, index = 0, offset = 0; + for (i = 0; i < 10; ++i) { + limbs[i] = h[i]; + q = (limbs[i] + q) >> (26 - (i & 1)); + } + limbs[0] += 19 * q; + for (i = 0; i < 10; ++i) { + const int bits = 26 - (i & 1); + const int32_t carry = limbs[i] >> bits; + if (i < 9) limbs[i + 1] += carry; + limbs[i] -= carry * ((int32_t)1 << bits); + } + /* Each byte crosses at most one public radix boundary. Normalized limbs + * are nonnegative, and unsigned shifts avoid overflowing signed int32. */ + for (i = 0; i < 32; ++i) { + uint32_t byte = (uint32_t)limbs[index] >> offset; + offset += 8; + if (offset >= 26 - (index & 1)) { + offset -= 26 - (index & 1); + ++index; + if (offset && index < 10) + byte |= (uint32_t)limbs[index] << (8 - offset); + } + s[i] = (unsigned char)byte; + } +} + +int fe_isnonzero(const fe f) { + unsigned char bytes[32], value = 0; + int i; + fe_tobytes(bytes, f); + for (i = 0; i < 32; ++i) value |= bytes[i]; + return value != 0; +} + static void fe_compact_product(fe h, const fe f, const fe g, int doubled) { static const unsigned char carry_order[] = {0, 4, 1, 5, 2, 6, 3, 7, 4, 8, 9, 0}; int64_t limbs[10] = {0}; @@ -63,17 +144,7 @@ static void fe_compact_product(fe h, const fe f, const fe g, int doubled) { if (doubled) { for (i = 0; i < 10; ++i) limbs[i] += limbs[i]; } - for (i = 0; i < (int)sizeof(carry_order); ++i) { - const int index = carry_order[i]; - const int bits = 26 - (index & 1); - const int64_t radix = (int64_t)1 << bits; - const int64_t carry = (limbs[index] + radix / 2) >> bits; - if (index == 9) limbs[0] += carry * 19; - else limbs[index + 1] += carry; - /* A carry may be negative; do not left-shift that signed value. */ - limbs[index] -= carry * radix; - } - for (i = 0; i < 10; ++i) h[i] = (int32_t)limbs[i]; + fe_compact_carry(h, limbs, carry_order, sizeof(carry_order)); } void fe_mul(fe h, const fe f, const fe g) { diff --git a/src/helpers/ESPNowRawFragmentation.h b/src/helpers/ESPNowRawFragmentation.h index 96796f0dd..32d644653 100644 --- a/src/helpers/ESPNowRawFragmentation.h +++ b/src/helpers/ESPNowRawFragmentation.h @@ -35,20 +35,47 @@ static constexpr size_t ESPNOW_RAW_FRAGMENT_MAGIC_SIZE = static constexpr size_t ESPNOW_RAW_FRAGMENT_HEADER_SIZE = 13; static constexpr size_t ESPNOW_RAW_FRAGMENT_DATA_SIZE = ESPNOW_RAW_MAX_FRAME_SIZE - ESPNOW_RAW_FRAGMENT_HEADER_SIZE; +static constexpr size_t ESPNOW_RAW_MAX_SECOND_FRAME_SIZE = + ESPNOW_RAW_FRAGMENT_HEADER_SIZE + ESPNOW_RAW_MAX_PACKET_SIZE + - ESPNOW_RAW_FRAGMENT_DATA_SIZE; static_assert(ESPNOW_RAW_FRAGMENT_DATA_SIZE < ESPNOW_RAW_MAX_FRAME_SIZE, "ESP-NOW fragment header must leave room for data"); static_assert(ESPNOW_RAW_FRAGMENT_DATA_SIZE * ESPNOW_RAW_FRAGMENT_COUNT >= ESPNOW_RAW_MAX_PACKET_SIZE, "two ESP-NOW fragments must hold one maximum MeshCore packet"); +static_assert(ESPNOW_RAW_FRAGMENT_COUNT == 2 + && ESPNOW_RAW_MAX_SECOND_FRAME_SIZE <= ESPNOW_RAW_MAX_FRAME_SIZE, + "compact frame storage requires one full frame and one bounded tail"); + +// Only the first frame can reach 250 bytes. The second holds a 13-byte header +// and at most 18 remaining packet bytes. Keep both frames owned by the queue +// without reserving another full frame for each of its six transmit slots. +struct ESPNowRawFrameData { + uint8_t first[ESPNOW_RAW_MAX_FRAME_SIZE]; + uint8_t second[ESPNOW_RAW_MAX_SECOND_FRAME_SIZE]; + + uint8_t* operator[](size_t index) { + return index == 0 ? first : index == 1 ? second : nullptr; + } + const uint8_t* operator[](size_t index) const { + return index == 0 ? first : index == 1 ? second : nullptr; + } + static constexpr size_t capacity(size_t index) { + return index == 0 ? ESPNOW_RAW_MAX_FRAME_SIZE + : index == 1 ? ESPNOW_RAW_MAX_SECOND_FRAME_SIZE : 0; + } +}; struct ESPNowRawFrames { uint8_t count; uint16_t lengths[ESPNOW_RAW_FRAGMENT_COUNT]; - uint8_t data[ESPNOW_RAW_FRAGMENT_COUNT][ESPNOW_RAW_MAX_FRAME_SIZE]; + ESPNowRawFrameData data; ESPNowRawFrames() : count(0), lengths{0, 0}, data{} {} }; +static_assert(sizeof(ESPNowRawFrames) <= 288, + "raw transmit frames must retain compact second-frame storage"); inline uint32_t espNowRawCrc32(const uint8_t* data, size_t len) { if (data == nullptr && len != 0) return 0; diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index b75106f92..3a51c6838 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -381,7 +381,7 @@ bool ESPNowBridge::queueTransmitFrames( } for (uint8_t i = 0; i < frames.count; ++i) { if (frames.lengths[i] == 0 - || frames.lengths[i] > MAX_ESPNOW_PACKET_SIZE) { + || frames.lengths[i] > frames.data.capacity(i)) { return false; } } @@ -486,7 +486,8 @@ void ESPNowBridge::pumpTransmitQueue() { QueuedTransmit& queued_tx = _tx_queue[_tx_tail]; if (queued_tx.next_frame < queued_tx.frames.count) { frame_len = queued_tx.frames.lengths[queued_tx.next_frame]; - if (frame_len > 0 && frame_len <= sizeof(frame)) { + if (frame_len > 0 && frame_len <= sizeof(frame) + && frame_len <= queued_tx.frames.data.capacity(queued_tx.next_frame)) { memcpy(frame, queued_tx.frames.data[queued_tx.next_frame], frame_len); } else { frame_len = 0; @@ -869,7 +870,7 @@ bool ESPNowBridge::sendTest(uint32_t nonce) { probe.nonce = nonce; probe.format = _active_format; probe.channel = _prefs->bridge_channel; - if (!encodeProbe(frames.data[0], sizeof(frames.data[0]), probe)) { + if (!encodeProbe(frames.data[0], frames.data.capacity(0), probe)) { recordError(Stage::TestQueue, ESP_ERR_INVALID_ARG); return false; } diff --git a/test/fixtures/companion_radio_diagnostics/test.cpp b/test/fixtures/companion_radio_diagnostics/test.cpp index 3d642b862..520233908 100644 --- a/test/fixtures/companion_radio_diagnostics/test.cpp +++ b/test/fixtures/companion_radio_diagnostics/test.cpp @@ -209,6 +209,7 @@ int main() { assert(h.driver.n_recv==0&&h.driver.n_sent==0&&h.driver.n_recv_errors==0); assert(h.driver._rx_ps_arm_failures==0); assert(h.driver._rx_ps_arm_retry.consecutiveFailures()==0&&h.driver._rx_ps_arm_retry.canAttempt()); + assert(h.driver._rx_ps_enabled&&h.driver._rx_ps_rx_us==123456&&h.driver._rx_ps_sleep_us==765432); assert(h.dispatcher.n_sent_flood==0&&h.dispatcher.n_sent_direct==0&&h.dispatcher.n_recv_flood==0&&h.dispatcher.n_recv_direct==0); assert(h.dispatcher._err_flags==0&&h.tables._direct_dups==0&&h.tables._flood_dups==0); assert(h.driver.n_wd_soft==11&&h.driver.n_wd_hard==13&&h.driver.last_rx==1234); diff --git a/test/fixtures/official_app_compatibility/README.txt b/test/fixtures/official_app_compatibility/README.txt index 3e5bc29ed..99dbd1fef 100644 --- a/test/fixtures/official_app_compatibility/README.txt +++ b/test/fixtures/official_app_compatibility/README.txt @@ -1,9 +1,12 @@ Official app compatibility source witness -Source: https://app.meshcore.nz/main.dart.js -Observed: 2026-10-04 -Bytes: 9494192 -SHA-256: 84bc39a950735aaffa93d912556852a4c22e235d0cc4e4d04790113207f13a68 +Source: https://files.liamcottle.net/MeshCore/v1.50.0/MeshCore-v1.50.0%2B78-b7528a7-web.zip +Reviewed: 2026-10-23 (official v1.50.0+78, build b7528a7) +Archive bytes: 16950142 +Archive SHA-256: c28f5549a63ce42a50a8a5bd80081c69ef85aeb1dca221c36bc692b838a92dd7 +Member: web/main.dart.js +JavaScript bytes: 9491330 +JavaScript SHA-256: aafc714732d1e1af749104da446cd250ea72e288f7cd60ce742f9733da30f309 Run: python3 -B test/test_official_app_compatibility.py -v @@ -13,16 +16,18 @@ 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 + ~/.cache/meshcore-tests/aafc714732d1e1af749104da446cd250ea72e288f7cd60ce742f9733da30f309/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. +downloads the official versioned archive and verifies both the archive and +JavaScript's exact size and SHA before saving only the JavaScript member. 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. +~/.cache/meshcore-tests, keyed by the source-pin tests. The live app URL is no +longer used, so its deployments cannot break a cold-cache run. Changed archive +or cached bytes still fail explicitly. Updating the source pin and function +witnesses requires review; the test never silently uses a 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 diff --git a/test/fixtures/official_app_compatibility/app_contract.js b/test/fixtures/official_app_compatibility/app_contract.js index 2283f2ba7..4a3feeeb2 100644 --- a/test/fixtures/official_app_compatibility/app_contract.js +++ b/test/fixtures/official_app_compatibility/app_contract.js @@ -31,7 +31,7 @@ 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() {}, +const $ = {cT: () => new Uint8Array(0), cq9() {}, dF() {}, ay() {}, fQ() {}, vI: () => ({Ia: () => 0x78563412})}; const B = { e: {aN: (a, b) => a >> b, qP: (a, b) => Math.trunc(a / b)}, @@ -43,13 +43,13 @@ const B = { 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) => { + pn: a => new Uint8Array(a), lx: (a, b, c) => new DataView(a, b, c), + c6: (a, b) => a.push(b), aaU: (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) + cTB: a => a.a, kz: (a, b) => a.split(b), aA: a => a.length, + a1: (a, i) => a[i], p: (a, b) => a === b, crc: (a, n) => a.toFixed(n) }; function deferred() { const result = {a: 0}; @@ -79,12 +79,12 @@ const A = { 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()), + csB: 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, + c: () => ({glo: () => "invalid radio fields", gla: () => "radio error"}), + au: (...args) => errors.push(args), 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"}, @@ -93,15 +93,15 @@ 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"]) { +for (const name of ["cZ", "d3", "fo", "ab8", "U1", "ab5", "PS", "acr", + "aSq", "aSp", "aSr", "aSs", "aSf", "pJ", "b0S", "b0R", + "b0Q", "b0T", "b0P"]) constructor(name); +for (const name of ["cZ", "d3", "fo", "ab5", "aSq", "aSp", "aSr", "aSs", "aSf", + "b0O", "b0S", "b0R", "b0Q", "b0T", "b0P", "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)); +Object.assign(A, vm.runInContext(`({${extract("\neD(a){")},${extract("\nd_9(a){")},${extract("\nbO(a,b){")}})`, context)); function connection() { const connection = new A.wc(); @@ -111,8 +111,8 @@ function connection() { 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]; + for (const name of ["eY", "N", "bCk", "bu", "dT", "W6"]) + connection[name] = A.b0O.prototype[name]; connection.writes = []; connection.d6 = async bytes => { connection.writes.push(Buffer.from(bytes)); }; return connection; diff --git a/test/fixtures/official_room_app_compatibility/room_contract.js b/test/fixtures/official_room_app_compatibility/room_contract.js index 0b069e0a4..2dd042b2c 100644 --- a/test/fixtures/official_room_app_compatibility/room_contract.js +++ b/test/fixtures/official_room_app_compatibility/room_contract.js @@ -29,15 +29,15 @@ $.lw = $.c5 = () => {}; $.a4 = null; A.au = value => Promise.resolve(value); A.aN = bytes => Buffer.from(bytes).toString("hex"); -A.jV = (items, predicate) => items.find(item => predicate.$1(item)) || null; +A.jU = (items, predicate) => items.find(item => predicate.$1(item)) || null; // SQLite's map and insert boundaries retain values produced by real app code. A.z = () => ({fields: {}, l(_, key, value) { this.fields[key] = value.a; }}); context.Date = {now: () => 1900000000000}; -for (const name of ["VU", "Nu", "be", "ax", "VW", "bZ7", "aS1", "aS2", "aS3", "aS4"]) +for (const name of ["VT", "Nt", "be", "ax", "VV", "bZ1", "aS1", "aS2", "aS3", "aS4"]) constructor(name); -for (const name of ["VW", "bZ7", "aS1", "aS2", "aS3", "aS4"]) +for (const name of ["VV", "bZ1", "aS1", "aS2", "aS3", "aS4"]) vm.runInContext(extract(`A.${name}.prototype={`), context); -Object.assign(A, vm.runInContext(`({${extract("czU(a,b,c,d,e,f,g,h,i,j,k,l,m,n){")}})`, context)); +Object.assign(A, vm.runInContext(`({${extract("czO(a,b,c,d,e,f,g,h,i,j,k,l,m,n){")}})`, context)); const methods = vm.runInContext(`({${extract("\nbA0(a,b,c){")},${extract("\nbCv(a,b,c){")}})`, context); function connection() { @@ -47,8 +47,8 @@ function connection() { i: (_, key) => listeners.get(key), Z: (_, key) => listeners.has(key), ey: (_, fn) => { for (const [key, value] of listeners) fn.$2(key, value); } }; - for (const name of ["eY", "N", "bCk", "bu", "dT", "W7"]) - client[name] = A.b0V.prototype[name]; + for (const name of ["eY", "N", "bCk", "bu", "dT", "W6"]) + client[name] = A.b0O.prototype[name]; client.writes = []; client.d6 = async bytes => { client.writes.push(Buffer.from(bytes)); }; return client; @@ -75,7 +75,7 @@ async function persist(message) { abP(owner, contact, decoded) { return methods.bA0.call(this, owner, contact, decoded); }, a33: async () => null }; - $.e_ = {d: ownerKey}; $.aj = database; + $.dZ = {d: ownerKey}; $.aj = database; // This is the stock app's ordinary/backlog message intake, with widget // notifications disabled and its SQL duplicate-query boundary mocked. await methods.bCv.call({k2: [room]}, message, false, false); diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py index 9f2fa2e6d..8952aed2a 100644 --- a/test/test_bridge_recovery.py +++ b/test/test_bridge_recovery.py @@ -399,6 +399,66 @@ int main(){ send_result=ESP_OK;now_ms+=20;bridge.loop();complete(bridge);assert(bridge._tx_count==0); bridge.end(); } +''', (40400, 50200, 50500)) + + def test_compact_espnow_frames_keep_every_length_and_all_queue_slots(self): + self.run_fixture(r''' +int main(){ + using namespace mesh::espnow; + static_assert(ESPNOW_RAW_MAX_SECOND_FRAME_SIZE==31); + static_assert(sizeof(ESPNowRawFrames)==288); + static_assert(sizeof(ESPNowRawFrameData)==281); + const uint8_t mac[6]={1,2,3,4,5,6}; + for(size_t length=2;length<=255;++length){ + auto original=packet(1,length); + for(size_t i=1;i0;){ + assert(copy.lengths[index]<=copy.data.capacity(index)); + result=assembler.acceptFrame(mac,copy.data[index],copy.lengths[index],100,out,sizeof(out),size); + } + assert(result==(length<=250?ESPNowRawReassemblyResult::PASSTHROUGH:ESPNowRawReassemblyResult::PACKET_COMPLETE)); + assert(size==length&&!memcmp(out,original.data,length)); + } + Backend backend;backend.wifi=true;mesh::wireless::control().begin(backend); + sdk_initialized=sdk_started=true;sdk_mode=WIFI_STA; + NodePrefs prefs;mesh::PacketManager mgr;mesh::RTCClock rtc;ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + ESPNowRawFrames invalid;auto source=packet(1,255); + assert(encodeEspNowRawFrames(source.data,source.length,invalid)); + invalid.lengths[1]=32; + assert(!bridge.queueTransmitFrames(invalid,source)&&bridge._tx_count==0); + invalid.lengths[1]=0; + assert(!bridge.queueTransmitFrames(invalid,source)&&bridge._tx_count==0); + invalid.lengths[1]=31;invalid.lengths[0]=251; + assert(!bridge.queueTransmitFrames(invalid,source)&&bridge._tx_count==0); + assert(send_calls==0); + // Retain all six largest packets at once, then overwrite the caller's copy. + for(uint8_t id=1;id<=6;++id){ + auto p=packet(id,255);bridge.sendPacket(&p);memset(p.data,0,sizeof(p.data)); + } + assert(bridge._tx_count==6); + bridge.loop(); + for(unsigned i=0;i<12;++i)complete(bridge); + assert(bridge._tx_count==0&&sent.size()==12); + for(unsigned i=0;i<6;++i){ + ESPNowRawReassembler assembler;uint8_t out[255];size_t size=0; + assert(sent[2*i].size()==250&&sent[2*i+1].size()==31); + assembler.acceptFrame(mac,sent[2*i].data(),sent[2*i].size(),100,out,sizeof(out),size); + assert(assembler.acceptFrame(mac,sent[2*i+1].data(),sent[2*i+1].size(),101,out,sizeof(out),size)==ESPNowRawReassemblyResult::PACKET_COMPLETE); + auto expected=packet(uint8_t(i+1),255); + assert(size==255&&!memcmp(out,expected.data,size)); + } + bridge.end(); +} ''', (40400, 50200, 50500)) def test_missing_callback_restarts_session_and_failed_teardown_blocks_admission(self): diff --git a/test/test_ed25519_compact_field.py b/test/test_ed25519_compact_field.py index a017c6a32..2593850aa 100644 --- a/test/test_ed25519_compact_field.py +++ b/test/test_ed25519_compact_field.py @@ -1,4 +1,4 @@ -"""Prove compact ref10 products against unrolled code and integer arithmetic.""" +"""Prove compact ref10 field operations against unrolled code and integers.""" import ctypes from pathlib import Path @@ -61,6 +61,14 @@ class Ed25519CompactFieldTest(unittest.TestCase): for name in ("fe_cmov", "fe_cswap"): getattr(library, name).argtypes = [ctypes.POINTER(ctypes.c_int32)] * 2 + [ctypes.c_uint] getattr(library, name).restype = None + library.fe_mul121666.argtypes = [ctypes.POINTER(ctypes.c_int32)] * 2 + library.fe_mul121666.restype = None + library.fe_frombytes.argtypes = [ctypes.POINTER(ctypes.c_int32), ctypes.c_void_p] + library.fe_frombytes.restype = None + library.fe_tobytes.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32)] + library.fe_tobytes.restype = None + library.fe_isnonzero.argtypes = [ctypes.POINTER(ctypes.c_int32)] + library.fe_isnonzero.restype = ctypes.c_int cls.libraries.append(library) @classmethod @@ -146,6 +154,123 @@ class Ed25519CompactFieldTest(unittest.TestCase): getattr(self.libraries[1], name)(a, a, select) self.assertEqual(list(a), first) + def test_decode_matches_original_limbs_and_modular_integer_including_sign_bit(self): + rng = random.Random(0xDEC0DE) + values = [0, PRIME - 1, PRIME, PRIME + 1, 2**255 - 1] + values += [value for bit in range(255) + for value in ((1 << bit) - 1, 1 << bit, (1 << bit) + 1)] + values += [rng.getrandbits(255) for _ in range(10000)] + for index, value in enumerate(values): + for sign in (0, 1): + encoded = (value | sign << 255).to_bytes(32, "little") + outputs = [] + for library in self.libraries: + output = Field() + library.fe_frombytes(output, encoded) + outputs.append(list(output)) + self.assertEqual(outputs[0], outputs[1], (index, sign)) + self.assertEqual(integer(outputs[1]), value % PRIME, (index, sign)) + # The entire source is consumed before any destination write. + overlap = ctypes.create_string_buffer(40) + ctypes.memmove(overlap, encoded, 32) + self.libraries[1].fe_frombytes(ctypes.cast(overlap, ctypes.POINTER(ctypes.c_int32)), overlap) + self.assertEqual(list(Field.from_buffer(overlap)), outputs[0]) + + def test_encode_and_nonzero_match_original_and_canonical_integer(self): + values = vectors(0xEAC0DE, 10000) + # Encoding and multiplication-by-121666 have a tighter ref10 bound. + bounds = [110 * (1 << (26 - (i & 1))) // 100 for i in range(10)] + values = [[min(bound, max(-bound, limb)) + for limb, bound in zip(value, bounds)] for value in values] + # Include nonzero representations of field zero and reduction edges. + values += [[-19, 0, 0, 0, 0, 0, 0, 0, 0, 1 << 25], + [19, 0, 0, 0, 0, 0, 0, 0, 0, -(1 << 25)]] + for index, value in enumerate(values): + outputs = [] + for library in self.libraries: + output = ctypes.create_string_buffer(32) + library.fe_tobytes(output, Field(*value)) + outputs.append(output.raw) + self.assertEqual(library.fe_isnonzero(Field(*value)), int(integer(value) != 0), index) + self.assertEqual(outputs[0], outputs[1], index) + self.assertEqual(int.from_bytes(outputs[1], "little"), integer(value), index) + overlap = Field(*value) + self.libraries[1].fe_tobytes(overlap, overlap) + self.assertEqual(ctypes.string_at(overlap, 32), outputs[0]) + + def test_multiply_constant_matches_original_integer_and_aliases(self): + bounds = [110 * (1 << (26 - (i & 1))) // 100 for i in range(10)] + for index, value in enumerate(vectors(0x121666, 10000)): + value = [min(bound, max(-bound, limb)) for limb, bound in zip(value, bounds)] + reference = Field() + self.libraries[0].fe_mul121666(reference, Field(*value)) + for alias in (False, True): + data = Field(*value) + output = data if alias else Field() + self.libraries[1].fe_mul121666(output, data) + self.assertEqual(list(output), list(reference), (index, alias)) + self.assertEqual(integer(output), integer(value) * 121666 % PRIME, (index, alias)) + for limb, result in enumerate(output): + self.assertLessEqual(abs(result), 110 * (1 << (25 - (limb & 1))) // 100) + + def test_serialization_and_constant_multiply_under_sanitizers(self): + values = vectors(0xC0DE, 128) + bounds = [110 * (1 << (26 - (i & 1))) // 100 for i in range(10)] + rows = [] + for index, value in enumerate(values): + value = [min(bound, max(-bound, limb)) for limb, bound in zip(value, bounds)] + encoded = ctypes.create_string_buffer(32) + multiplied = Field() + decoded = Field() + self.libraries[0].fe_tobytes(encoded, Field(*value)) + # Alternate ignored high bits while retaining the encoded value. + source = encoded.raw[:31] + bytes([encoded.raw[31] | ((index & 1) << 7)]) + self.libraries[0].fe_frombytes(decoded, source) + self.libraries[0].fe_mul121666(multiplied, Field(*value)) + row = (value, list(multiplied), list(decoded), list(source), list(encoded.raw)) + rows.append("{" + ",".join("{" + ",".join(map(str, item)) + "}" + for item in row) + "}") + source = r''' +#include +#include +#include "fe.h" +struct Vector { fe input, multiplied, decoded; unsigned char source[32], encoded[32]; }; +static const struct Vector vectors[] = { @VECTORS@ }; +int main(void) { + for (unsigned int i = 0; i < sizeof(vectors) / sizeof(vectors[0]); ++i) { + const struct Vector *v = &vectors[i]; + fe output; + unsigned char bytes[32]; + fe_frombytes(output, v->source); + assert(memcmp(output, v->decoded, sizeof(fe)) == 0); + fe_tobytes(bytes, v->input); + assert(memcmp(bytes, v->encoded, sizeof(bytes)) == 0); + unsigned char nonzero = 0; + for (unsigned int j = 0; j < sizeof(bytes); ++j) nonzero |= bytes[j]; + assert(fe_isnonzero(v->input) == (nonzero != 0)); + memcpy(output, v->input, sizeof(fe)); + fe_mul121666(output, output); + assert(memcmp(output, v->multiplied, sizeof(fe)) == 0); + union { fe limbs; unsigned char bytes[40]; } overlap; + memcpy(overlap.bytes, v->source, 32); + fe_frombytes(overlap.limbs, overlap.bytes); + assert(memcmp(overlap.limbs, v->decoded, sizeof(fe)) == 0); + memcpy(overlap.limbs, v->input, sizeof(fe)); + fe_tobytes(overlap.bytes, overlap.limbs); + assert(memcmp(overlap.bytes, v->encoded, 32) == 0); + } +} +'''.replace("@VECTORS@", ",\n".join(rows)) + with tempfile.TemporaryDirectory() as directory: + harness = Path(directory) / "encoding.c" + harness.write_text(source) + executable = Path(directory) / "encoding" + subprocess.run(["cc", "-std=c99", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-no-pie", + "-DED25519_COMPACT_FIELD=1", "-I", str(ED25519), + str(harness), str(ED25519 / "fe.c"), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) + def test_compact_products_under_address_and_undefined_sanitizers(self): values = vectors(0xC0FE, 128) # Keep every edge/carry-boundary vector and a bounded random selection. diff --git a/test/test_esp32_hwcdc_recipes.py b/test/test_esp32_hwcdc_recipes.py index 0a5c7e805..8a68d01a3 100644 --- a/test/test_esp32_hwcdc_recipes.py +++ b/test/test_esp32_hwcdc_recipes.py @@ -247,7 +247,10 @@ class Esp32HwcdcRecipeTests(unittest.TestCase): self.assertFalse(self.sdk["native_primary_enabled"](row["env"])) self.assertEqual(self.config.get(f"env:{name}", "platform"), "platformio/espressif32@6.11.0") - self.assertFalse(self.config.get(f"env:{name}", "platform_packages", [])) + self.assertEqual(self.config.get(f"env:{name}", "platform_packages", []), [ + "framework-arduinoespressif32 @ https://github.com/IoTThinks/" + "powersaving-arduino-lib/releases/download/2.0.17/esp32-2.0.17.zip", + ]) self.assertIn("pre:scripts/esp32_usb_session_fix.py", self.config.get(f"env:{name}", "extra_scripts", [])) if name not in PREVIOUS_G2_HWCDC: diff --git a/test/test_nrf52_hil_irq_binding.py b/test/test_nrf52_hil_irq_binding.py index 532cebf61..392130fcf 100644 --- a/test/test_nrf52_hil_irq_binding.py +++ b/test/test_nrf52_hil_irq_binding.py @@ -1,6 +1,7 @@ """Verify explicit HIL observer bindings without editing the installed core.""" import importlib.util +import os from pathlib import Path import platform import shutil @@ -180,6 +181,9 @@ class Nrf52HilIrqBindingTest(unittest.TestCase): # The original same-object weak aliases reproduce the final-ELF failure. # Explicit vector targets must call each wrapper and each real handler # exactly once, including when the other C++ translation unit uses LTO. + # Keep the synthetic real definitions public: native LTO cannot see the + # later linker rewrite from __real_* to the real symbol. The separate + # ARM-toolchain check below uses unannotated real definitions. with tempfile.TemporaryDirectory(prefix="mesh-hil-irq-link-", dir="/var/tmp") as directory: directory = Path(directory) source = '#include \n#include \n' @@ -187,7 +191,8 @@ class Nrf52HilIrqBindingTest(unittest.TestCase): source += 'extern "C" void SVC_Handler() {}\nextern "C" void PendSV_Handler() {}\n' source += f"static unsigned real_calls[{len(PATCH.IRQ_HANDLERS)}], wrapped_calls[{len(PATCH.IRQ_HANDLERS)}];\n" for index, symbol in enumerate(PATCH.IRQ_HANDLERS): - source += f'extern "C" void {symbol}() {{ ++real_calls[{index}]; }}\n' + source += (f'extern "C" __attribute__((used, externally_visible)) ' + f'void {symbol}() {{ ++real_calls[{index}]; }}\n') source += f'extern "C" void __real_{symbol}();\n' source += (f'extern "C" void __wrap_{symbol}() {{ ++wrapped_calls[{index}]; ' f'__real_{symbol}(); }}\n') @@ -209,6 +214,62 @@ class Nrf52HilIrqBindingTest(unittest.TestCase): result = subprocess.run([str(binary)], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) + def test_target_lto_retains_unannotated_real_handlers_and_explicit_wrappers(self): + core = Path(os.environ.get("PLATFORMIO_CORE_DIR", Path.home() / ".platformio")) + toolchain = core / "packages/toolchain-gccarmnoneeabi/bin" + suffix = ".exe" if os.name == "nt" else "" + compiler = toolchain / ("arm-none-eabi-g++" + suffix) + nm = toolchain / ("arm-none-eabi-nm" + suffix) + if not compiler.is_file() or not nm.is_file(): + self.skipTest("installed nRF52 target toolchain unavailable") + # Link the real target architecture with the nRF52 LTO profile. Native + # GCC 11 and target GCC 14 differ in their late --wrap symbol retention. + # No used/externally_visible annotations hide a target liveness failure. + source = 'extern "C" const unsigned test_vectors[];\n' + source += f"static volatile unsigned calls[{len(PATCH.IRQ_HANDLERS)}];\n" + for index, symbol in enumerate(PATCH.IRQ_HANDLERS): + source += f'extern "C" void {symbol}() {{ ++calls[{index}]; }}\n' + source += f'extern "C" void __real_{symbol}();\n' + source += f'extern "C" void __wrap_{symbol}() {{ __real_{symbol}(); }}\n' + source += 'extern "C" void SVC_Handler() {}\nextern "C" void PendSV_Handler() {}\n' + source += f"int main() {{ for (unsigned i=0; i<{len(PATCH.IRQ_HANDLERS)}; ++i) " + source += "reinterpret_cast(test_vectors[i+2])(); return 0; }\n" + flags = [ + "-std=c++17", "-mcpu=cortex-m4", "-mthumb", "-mfloat-abi=hard", "-mfpu=fpv4-sp-d16", + "-Oz", "-flto", "-flto-partition=one", "-fno-inline-small-functions", "-fipa-pta", + "-fmerge-all-constants", "-fno-ipa-cp-clone", "-fno-partial-inlining", "-fno-jump-tables", + "-fno-tree-switch-conversion", "-fno-semantic-interposition", "-fno-unwind-tables", + "-fno-asynchronous-unwind-tables", "-fno-schedule-insns2", "-ffunction-sections", + "-fdata-sections", "-fno-exceptions", "-fno-rtti", "-nostdlib", "-Wl,-e,main", + "-Wl,--gc-sections", "-Wl,--sort-section=alignment", + ] + with tempfile.TemporaryDirectory(prefix="mesh-hil-irq-arm-link-") as directory: + directory = Path(directory) + handlers = directory / "handlers.cpp" + handlers.write_text(source) + for patched in (False, True): + with self.subTest(patched=patched): + startup = PATCH.patched_startup(STARTUP) if patched else STARTUP + startup = ".syntax unified\n.thumb\n" + startup.replace( + " .text\n", " .text\n .thumb_func\n") + startup = startup.replace(" ret\n", " bx lr\n").replace( + " .set ", " .thumb_set ").replace("@progbits", "%progbits") + assembly = directory / "startup.S" + assembly.write_text(startup) + binary = directory / "vectors.elf" + built = subprocess.run([ + str(compiler), *flags, str(handlers), str(assembly), + *("-Wl,--wrap=" + name for name in PATCH.IRQ_HANDLERS), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + listed = subprocess.run([str(nm), "--defined-only", str(binary)], + check=True, capture_output=True, text=True, timeout=20) + symbols = {line.split()[-1]: line.split()[-2] for line in listed.stdout.splitlines() + if len(line.split()) >= 3} + for symbol in PATCH.IRQ_HANDLERS: + self.assertEqual(symbols.get(symbol), "T") + self.assertEqual("__wrap_" + symbol in symbols, patched) + def test_nrf52_base_installs_binding_hook_once(self): config = (ROOT / "platformio.ini").read_text() base = config.split("[nrf52_base]", 1)[1].split("\n[", 1)[0] diff --git a/test/test_official_app_compatibility.py b/test/test_official_app_compatibility.py index e506ebb11..bedd24d37 100644 --- a/test/test_official_app_compatibility.py +++ b/test/test_official_app_compatibility.py @@ -1,14 +1,15 @@ #!/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. +The official v1.50.0+78 web archive is the source witness, not proof of the +installed iOS app. The whole app remains outside Git. Both the archive and its +JavaScript have size/SHA gates; updates require an explicit source-pin review. No PlatformIO, devices, app fork, settings writes or real radio are involved. """ from pathlib import Path import hashlib +import io import json import os import shutil @@ -16,7 +17,9 @@ import subprocess import sys import tempfile import unittest +from unittest import mock import urllib.request +import zipfile from test_client_acl_response import production_acl_methods from test_companion_primary_radio_persistence import ( @@ -25,9 +28,14 @@ from test_companion_delayed_reply_delivery import production_delayed_reply_input 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 +# Use the publisher's versioned archive: the live app URL changes independently +# of firmware and cannot reproduce the contract witness on a cold CI cache. +APP_URL = ("https://files.liamcottle.net/MeshCore/v1.50.0/" + "MeshCore-v1.50.0%2B78-b7528a7-web.zip") +APP_ARCHIVE_SHA256 = "c28f5549a63ce42a50a8a5bd80081c69ef85aeb1dca221c36bc692b838a92dd7" +APP_ARCHIVE_BYTES = 16950142 +APP_SHA256 = "aafc714732d1e1af749104da446cd250ea72e288f7cd60ce742f9733da30f309" +APP_BYTES = 9491330 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 []) @@ -41,7 +49,15 @@ def official_app_bundle(): 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) + archive = response.read(APP_ARCHIVE_BYTES + 1) + if (len(archive) != APP_ARCHIVE_BYTES or + hashlib.sha256(archive).hexdigest() != APP_ARCHIVE_SHA256): + raise AssertionError("Official app archive SHA/size mismatch: review its source pin") + with zipfile.ZipFile(io.BytesIO(archive)) as release: + member = release.getinfo("web/main.dart.js") + if member.file_size != APP_BYTES: + raise AssertionError("Official app JavaScript archive size mismatch") + data = release.read(member) 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) @@ -265,6 +281,53 @@ public: return replies +class OfficialAppSourcePinTests(unittest.TestCase): + def test_archive_and_member_gates_before_caching(self): + script = b"stock app witness" + + def archive_for(contents): + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as release: + release.writestr("web/main.dart.js", contents) + return output.getvalue() + + archive = archive_for(script) + wrong_member = archive_for(script[:-1] + b"!") + wrong_size = archive_for(script + b"!") + digest = hashlib.sha256(script).hexdigest() + cases = ( + ("short archive", archive[:-1], archive), + ("changed archive", bytes([archive[0] ^ 1]) + archive[1:], archive), + ("changed member", wrong_member, wrong_member), + ("wrong member size", wrong_size, wrong_size), + ("verified", archive, archive), + ) + with tempfile.TemporaryDirectory(prefix="official-app-source-pin-") as directory: + home = Path(directory) + cache = home / ".cache/meshcore-tests" / digest / "main.dart.js" + with mock.patch.dict(os.environ, {"MESHCORE_OFFICIAL_APP_BUNDLE": ""}), \ + mock.patch.object(Path, "home", return_value=home): + for name, downloaded, expected_archive in cases: + with self.subTest(name=name), mock.patch.multiple( + sys.modules[__name__], APP_BYTES=len(script), APP_SHA256=digest, + APP_ARCHIVE_BYTES=len(expected_archive), + APP_ARCHIVE_SHA256=hashlib.sha256(expected_archive).hexdigest()), \ + mock.patch.object(urllib.request, "urlopen", + return_value=io.BytesIO(downloaded)) as fetch: + if name != "verified": + with self.assertRaises(AssertionError): + official_app_bundle() + self.assertFalse(cache.exists()) + else: + self.assertEqual(official_app_bundle(), cache.resolve()) + self.assertEqual(cache.read_bytes(), script) + self.assertEqual(official_app_bundle(), cache.resolve()) + fetch.assert_called_once_with(APP_URL, timeout=45) + cache.write_bytes(script[:-1] + b"!") + with self.assertRaisesRegex(AssertionError, "source SHA/size mismatch"): + official_app_bundle() + + class OfficialAppCompatibilityTests(unittest.TestCase): def test_pinned_app_functions_with_production_request_and_outputs(self): compiler = shutil.which("g++") or shutil.which("clang++") diff --git a/test/test_official_config_gui.py b/test/test_official_config_gui.py index e1996b899..65c318e94 100644 --- a/test/test_official_config_gui.py +++ b/test/test_official_config_gui.py @@ -292,13 +292,15 @@ class OfficialConfigGuiTests(unittest.TestCase): return result def test_raw_uart_first_stock_gui_command_survives_preconnect_idle(self): - for idle_ms in (180001, 10800000, 0xFFFFFFFE): + for idle_ms in (300001, 10800000, 0xFFFFFFFE): with self.subTest(idle_ms=idle_ms): result = self.run_contract(raw_uart=True, preconnect_idle_ms=idle_ms) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) def test_missing_raw_uart_sleep_guard_breaks_first_stock_gui_command(self): - result = self.run_contract(raw_uart=True, preconnect_idle_ms=180001, + # Run after the five-minute startup grace so removing the UART veto + # actually permits sleep before the first GUI command arrives. + result = self.run_contract(raw_uart=True, preconnect_idle_ms=300001, lose_raw_uart_guard=True) self.assertNotEqual(result.returncode, 0, "Negative control did not fail") self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr)