Fix firmware size limits and refresh CI compatibility checks

Compact the existing STM32 field arithmetic and ESP-NOW tail-frame storage without removing features or lowering memory reserves. Refresh power-saving fixtures and pin official-app tests to a published archive.
This commit is contained in:
mikecarper committed 2026-10-23 19:04:43 -07:00
1 parent cf98125c65
commit c563c40c66
15 files changed
+494 -66

No files matched your search

+1 -1
View File
@@ -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: |
+10 -1
View File
@@ -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
+86 -15
View File
@@ -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) {
+28 -1
View File
@@ -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;
+4 -3
View File
@@ -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;
}
+1
View File
@@ -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);
+15 -10
View File
@@ -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
+16 -16
View File
@@ -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;
@@ -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);
+60
View File
@@ -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;i<length;++i)original.data[i]=uint8_t(i*37+length);
ESPNowRawFrames frames;
assert(encodeEspNowRawFrames(original.data,length,frames));
assert(frames.count==(length<=250?1:2));
assert(frames.data.capacity(0)==250&&frames.data.capacity(1)==31);
assert(frames.data.capacity(2)==0&&frames.data[2]==nullptr);
const ESPNowRawFrames copy=frames;
memset(&frames,0,sizeof(frames)); // Queue copies must own both arrays.
ESPNowRawReassembler assembler;uint8_t out[255]={};size_t size=0;
ESPNowRawReassemblyResult result=ESPNowRawReassemblyResult::REJECTED;
// Reverse-order fragments exercise the compact tail independently.
for(unsigned index=copy.count;index-->0;){
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):
+126 -1
View File
@@ -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 <assert.h>
#include <string.h>
#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.
+4 -1
View File
@@ -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:
+62 -1
View File
@@ -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 <cassert>\n#include <cstdint>\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<void(*)()>(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]
+70 -7
View File
@@ -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++")
+4 -2
View File
@@ -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)