@mikrojs/firmware 0.16.0 → 0.17.0-next.20260722220623

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/components/mikrojs/CMakeLists.txt +9 -2
  2. package/components/mikrojs/include/mikrojs_esp32.h +23 -0
  3. package/components/mikrojs/mik_config.cpp +159 -5
  4. package/components/mikrojs/mik_deploy.cpp +54 -35
  5. package/components/mikrojs/mik_main.cpp +66 -5
  6. package/components/mikrojs/mik_nvs_kv.cpp +64 -18
  7. package/components/mikrojs/mik_ota.cpp +1791 -0
  8. package/components/mikrojs/platform_esp32.cpp +59 -0
  9. package/components/mikrojs/test/CMakeLists.txt +1 -0
  10. package/components/mikrojs/test/ble_test.cpp +2 -2
  11. package/components/mikrojs/test/fs_js_test.cpp +1 -1
  12. package/components/mikrojs/test/fs_pub_test.cpp +23 -23
  13. package/components/mikrojs/test/http_server_test.cpp +12 -2
  14. package/components/mikrojs/test/http_test.cpp +2 -2
  15. package/components/mikrojs/test/modules_extended_test.cpp +2 -2
  16. package/components/mikrojs/test/modules_test.cpp +2 -2
  17. package/components/mikrojs/test/nvs_kv_test.cpp +245 -0
  18. package/components/mikrojs/test/ota_host/gunzip_host_test.cpp +118 -0
  19. package/components/mikrojs/test/ota_host/run.sh +174 -0
  20. package/components/mikrojs/test/rtc_test.cpp +1 -1
  21. package/components/mikrojs/test/sleep_test.cpp +1 -1
  22. package/components/mikrojs/test/sntp_test.cpp +1 -1
  23. package/components/mikrojs/test/stdio_test.cpp +1 -1
  24. package/components/mikrojs/test/sys_test.cpp +1 -1
  25. package/components/mikrojs/test/udp_test.cpp +5 -5
  26. package/components/mikrojs/test/wifi_test.cpp +2 -2
  27. package/package.json +3 -3
  28. package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
  29. package/prebuilds/esp32/mikrojs.bin +0 -0
  30. package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
  31. package/prebuilds/esp32c3/mikrojs.bin +0 -0
  32. package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
  33. package/prebuilds/esp32c5/mikrojs.bin +0 -0
  34. package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
  35. package/prebuilds/esp32c6/mikrojs.bin +0 -0
  36. package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
  37. package/prebuilds/esp32s3/mikrojs.bin +0 -0
@@ -0,0 +1,1791 @@
1
+ // Native OTA install module for Mikro.js firmware.
2
+ //
3
+ // Registers `native:mikro/ota`, the privileged half of over-the-air updates the
4
+ // JS layer can't do: /app is read-only from JS and there is no gunzip/untar in
5
+ // the runtime, but C in firmware has a writable FS, miniz, and plain file I/O.
6
+ //
7
+ // The surface (consumed by the JS helper that owns the OTA protocol):
8
+ // stageBegin(checksum, size) stage a download, resume if partial
9
+ // stageWrite(bytes) append to the staged .tgz
10
+ // stageFinish(trialBoots, requireConfirm, installNow) verify + arm install
11
+ // stageAbort() drop the in-progress staging file
12
+ // markValid() confirm the running trial (promote)
13
+ // revert() roll back to the last-good build
14
+ // running() { checksum?, trial }
15
+ // reconcile() outcome of the boot-time reconcile
16
+ //
17
+ // The actual install (gunzip -> untar -> atomic swap) runs at boot on a clean
18
+ // heap from mik__ota_boot_reconcile(), which also drives the trial/rollback
19
+ // state machine. The unpack functions (gunzip + untar + SHA-256) are pure and
20
+ // host-tested under MIK_OTA_HOST_TEST (see test/ota_host/); everything that
21
+ // needs ESP-IDF / QuickJS / NVS is excluded from that build.
22
+
23
+ #ifndef MIK_OTA_HOST_TEST
24
+ #include <esp_app_desc.h>
25
+ #include <esp_system.h>
26
+ #include <nvs.h>
27
+ #include <nvs_flash.h>
28
+
29
+ #include "mikrojs/app_store.h"
30
+ #include "mikrojs/mikrojs.h"
31
+ #include "mikrojs/platform.h"
32
+ #include "mikrojs_esp32.h"
33
+ #include "quickjs.h"
34
+ #endif
35
+
36
+ #include <cerrno>
37
+ #include <climits>
38
+ #include <cstdint>
39
+ #include <cstdio>
40
+ #include <cstdlib>
41
+ #include <cstring>
42
+ #include <dirent.h>
43
+ #include <sys/stat.h>
44
+ #include <unistd.h>
45
+
46
+ // tinfl comes from miniz, whose implementation is in the chip mask ROM and whose
47
+ // header the always-available `esp_rom` dependency provides, so no REQUIRES entry
48
+ // is needed. IDF versions differ on where the header sits: a target that lacks
49
+ // ROM miniz needs the `miniz` managed component in idf_component.yml.
50
+ #include "miniz.h"
51
+
52
+ namespace {
53
+
54
+ constexpr size_t kChunk = 4096;
55
+ constexpr size_t kTarBlock = 512;
56
+ constexpr size_t kTarName = 100; // ustar name field width
57
+ constexpr int kMaxNameDepth = 8; // path components a member may create
58
+
59
+ // Classified failure cause carried back to JS. `corrupt` means the bytes are
60
+ // deterministically bad (gzip/tar/SHA) so retrying identical bytes can't help;
61
+ // `transient` is fs/mount/io; `oom` is an allocation failure.
62
+ enum class OtaErr { kCorrupt, kTransient, kOom };
63
+
64
+ const char* err_kind(OtaErr k) {
65
+ switch (k) {
66
+ case OtaErr::kCorrupt:
67
+ return "corrupt";
68
+ case OtaErr::kOom:
69
+ return "oom";
70
+ case OtaErr::kTransient:
71
+ default:
72
+ return "transient";
73
+ }
74
+ }
75
+
76
+ // ── SHA-256 (FIPS 180-4) ─────────────────────────────────────────────────────
77
+ // Self-contained and host-validated against the standard vectors, so the build
78
+ // checksum is verified identically on host and device with no mbedTLS dependency
79
+ // to wire into the component. The offer checksum and `mikro app pack` both use
80
+ // SHA-256 over the whole .tgz.
81
+ struct Sha256 {
82
+ uint32_t h[8];
83
+ uint64_t len; // total bytes hashed
84
+ uint8_t buf[64];
85
+ size_t buflen;
86
+ };
87
+
88
+ inline uint32_t sha256_ror(uint32_t v, int b) {
89
+ return (v >> b) | (v << (32 - b));
90
+ }
91
+
92
+ void sha256_block(Sha256* c, const uint8_t* p) {
93
+ static const uint32_t K[64] = {
94
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
95
+ 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
96
+ 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
97
+ 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
98
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
99
+ 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
100
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
101
+ 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
102
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
103
+ 0xc67178f2};
104
+ uint32_t w[64];
105
+ for (int i = 0; i < 16; i++) {
106
+ w[i] = ((uint32_t)p[i * 4] << 24) | ((uint32_t)p[i * 4 + 1] << 16) |
107
+ ((uint32_t)p[i * 4 + 2] << 8) | p[i * 4 + 3];
108
+ }
109
+ for (int i = 16; i < 64; i++) {
110
+ uint32_t s0 = sha256_ror(w[i - 15], 7) ^ sha256_ror(w[i - 15], 18) ^ (w[i - 15] >> 3);
111
+ uint32_t s1 = sha256_ror(w[i - 2], 17) ^ sha256_ror(w[i - 2], 19) ^ (w[i - 2] >> 10);
112
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1;
113
+ }
114
+ uint32_t a = c->h[0], b = c->h[1], cc = c->h[2], d = c->h[3];
115
+ uint32_t e = c->h[4], f = c->h[5], g = c->h[6], hh = c->h[7];
116
+ for (int i = 0; i < 64; i++) {
117
+ uint32_t S1 = sha256_ror(e, 6) ^ sha256_ror(e, 11) ^ sha256_ror(e, 25);
118
+ uint32_t ch = (e & f) ^ ((~e) & g);
119
+ uint32_t t1 = hh + S1 + ch + K[i] + w[i];
120
+ uint32_t S0 = sha256_ror(a, 2) ^ sha256_ror(a, 13) ^ sha256_ror(a, 22);
121
+ uint32_t maj = (a & b) ^ (a & cc) ^ (b & cc);
122
+ uint32_t t2 = S0 + maj;
123
+ hh = g;
124
+ g = f;
125
+ f = e;
126
+ e = d + t1;
127
+ d = cc;
128
+ cc = b;
129
+ b = a;
130
+ a = t1 + t2;
131
+ }
132
+ c->h[0] += a;
133
+ c->h[1] += b;
134
+ c->h[2] += cc;
135
+ c->h[3] += d;
136
+ c->h[4] += e;
137
+ c->h[5] += f;
138
+ c->h[6] += g;
139
+ c->h[7] += hh;
140
+ }
141
+
142
+ void sha256_init(Sha256* c) {
143
+ c->h[0] = 0x6a09e667;
144
+ c->h[1] = 0xbb67ae85;
145
+ c->h[2] = 0x3c6ef372;
146
+ c->h[3] = 0xa54ff53a;
147
+ c->h[4] = 0x510e527f;
148
+ c->h[5] = 0x9b05688c;
149
+ c->h[6] = 0x1f83d9ab;
150
+ c->h[7] = 0x5be0cd19;
151
+ c->len = 0;
152
+ c->buflen = 0;
153
+ }
154
+
155
+ void sha256_update(Sha256* c, const uint8_t* p, size_t n) {
156
+ c->len += n;
157
+ while (n) {
158
+ size_t take = 64 - c->buflen;
159
+ if (take > n) take = n;
160
+ memcpy(c->buf + c->buflen, p, take);
161
+ c->buflen += take;
162
+ p += take;
163
+ n -= take;
164
+ if (c->buflen == 64) {
165
+ sha256_block(c, c->buf);
166
+ c->buflen = 0;
167
+ }
168
+ }
169
+ }
170
+
171
+ void sha256_final(Sha256* c, uint8_t out[32]) {
172
+ uint64_t bits = c->len * 8; // captured before padding
173
+ uint8_t pad = 0x80;
174
+ sha256_update(c, &pad, 1);
175
+ uint8_t zero = 0;
176
+ while (c->buflen != 56) sha256_update(c, &zero, 1);
177
+ uint8_t lenb[8];
178
+ for (int i = 0; i < 8; i++) lenb[i] = (uint8_t)(bits >> (56 - 8 * i));
179
+ sha256_update(c, lenb, 8);
180
+ for (int i = 0; i < 8; i++) {
181
+ out[i * 4] = c->h[i] >> 24;
182
+ out[i * 4 + 1] = c->h[i] >> 16;
183
+ out[i * 4 + 2] = c->h[i] >> 8;
184
+ out[i * 4 + 3] = (uint8_t)c->h[i];
185
+ }
186
+ }
187
+
188
+ // Hash a whole file into `out` (65 bytes: 64 lowercase hex + NUL). Hashing the
189
+ // staged file (not an in-memory running hash) is deliberate: a partial download
190
+ // can resume across a reboot, and the verify still works because it reads the
191
+ // bytes back off flash. Returns false on any I/O error.
192
+ bool sha256_file(const char* path, char out[65]) {
193
+ FILE* f = fopen(path, "rb");
194
+ if (!f) return false;
195
+ unsigned char* buf = (unsigned char*)malloc(kChunk);
196
+ if (!buf) {
197
+ fclose(f);
198
+ return false;
199
+ }
200
+ Sha256 c;
201
+ sha256_init(&c);
202
+ size_t n;
203
+ while ((n = fread(buf, 1, kChunk, f)) > 0) sha256_update(&c, buf, n);
204
+ bool read_err = ferror(f) != 0;
205
+ free(buf);
206
+ fclose(f);
207
+ if (read_err) return false;
208
+ uint8_t d[32];
209
+ sha256_final(&c, d);
210
+ static const char* H = "0123456789abcdef";
211
+ for (int i = 0; i < 32; i++) {
212
+ out[i * 2] = H[d[i] >> 4];
213
+ out[i * 2 + 1] = H[d[i] & 0xf];
214
+ }
215
+ out[64] = 0;
216
+ return true;
217
+ }
218
+
219
+ // ── CRC-32 (gzip/zlib polynomial) ────────────────────────────────────────────
220
+ // Bit-by-bit so it needs no lookup table; one-shot over the decompressed build
221
+ // at install time, so the loop cost is irrelevant. Self-contained rather than
222
+ // miniz's mz_crc32 so it can't be compiled out by ESP-IDF's miniz config.
223
+ uint32_t crc32_update(uint32_t crc, const unsigned char* p, size_t n) {
224
+ crc = crc ^ 0xFFFFFFFFu;
225
+ for (size_t i = 0; i < n; i++) {
226
+ crc ^= p[i];
227
+ for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
228
+ }
229
+ return crc ^ 0xFFFFFFFFu;
230
+ }
231
+
232
+ // Keep the last 8 bytes seen across a byte stream — the gzip trailer, captured
233
+ // as the file is read so it needs neither fseek (unreliable on some VFS) nor
234
+ // tinfl's end-of-stream input accounting (the esp32c6 ROM miniz mis-reports it).
235
+ void tail8_push(unsigned char tail[8], size_t* len, const unsigned char* p, size_t n) {
236
+ if (n >= 8) {
237
+ memcpy(tail, p + n - 8, 8);
238
+ *len = 8;
239
+ return;
240
+ }
241
+ if (n == 0) return;
242
+ size_t keep = (*len + n > 8) ? (8 - n) : *len; // bytes of the old tail to retain
243
+ memmove(tail, tail + (*len - keep), keep);
244
+ memcpy(tail + keep, p, n);
245
+ *len = keep + n;
246
+ }
247
+
248
+ long octal(const char* p, int len) {
249
+ long v = 0;
250
+ for (int i = 0; i < len && p[i] >= '0' && p[i] <= '7'; i++) v = (v << 3) + (p[i] - '0');
251
+ return v;
252
+ }
253
+
254
+ // Create every parent directory of `path` (the last component is the leaf and
255
+ // is left for the caller to create as a file or dir). EEXIST is fine. Makes the
256
+ // streaming untar robust whether or not the archive carries explicit dir
257
+ // entries. littlefs may ignore the mode argument; nothing here depends on it.
258
+ void mkdir_parents(const char* path) {
259
+ char tmp[512];
260
+ size_t n = strlen(path);
261
+ if (n >= sizeof(tmp)) return;
262
+ memcpy(tmp, path, n + 1);
263
+ for (size_t i = 1; i < n; i++) {
264
+ if (tmp[i] == '/') {
265
+ tmp[i] = 0;
266
+ mkdir(tmp, 0755);
267
+ tmp[i] = '/';
268
+ }
269
+ }
270
+ }
271
+
272
+ // ── Streaming ustar extraction ───────────────────────────────────────────────
273
+ // Fed arbitrary byte chunks (the gunzip output), it reassembles 512-byte tar
274
+ // blocks across chunk boundaries and writes members to disk as they arrive, so
275
+ // no intermediate `.tar` ever lands on the 1 MB filesystem. Members are expected
276
+ // to be prefixed `app/...` (the build is `tar -C build app`), so dest_dir
277
+ // receives `app/...` and mik__app_commit renames dest_dir/app -> /app.
278
+ struct Untar {
279
+ char dest[256];
280
+ unsigned char block[kTarBlock];
281
+ size_t fill; // bytes accumulated toward the current 512-block
282
+ enum { kHeader, kFile, kSkip } mode;
283
+ FILE* cur;
284
+ long remaining; // file content bytes left to write
285
+ long skip_blocks; // 512-blocks left to discard (unknown entry types)
286
+ bool failed;
287
+ bool done; // saw the end-of-archive marker
288
+ const char* err;
289
+ OtaErr kind; // kCorrupt for a malformed archive, kTransient for I/O
290
+ };
291
+
292
+ void untar_init(Untar* u, const char* dest) {
293
+ snprintf(u->dest, sizeof(u->dest), "%s", dest);
294
+ u->fill = 0;
295
+ u->mode = Untar::kHeader;
296
+ u->cur = nullptr;
297
+ u->remaining = 0;
298
+ u->skip_blocks = 0;
299
+ u->failed = false;
300
+ u->done = false;
301
+ u->err = nullptr;
302
+ u->kind = OtaErr::kCorrupt;
303
+ }
304
+
305
+ // Rejects member names that would escape dest_dir. The checksum is no defence
306
+ // here: it arrives in the same offer as the URL, so whoever serves the offer
307
+ // controls both. Without this, a member named `../../app/main.js` writes
308
+ // straight over the live app (or the rollback baseline) during boot reconcile,
309
+ // before any JS gets to run.
310
+ bool unsafe_member_name(const unsigned char* block) {
311
+ const char* n = (const char*)block;
312
+ if (n[0] == '/' || n[0] == 0) return true;
313
+ const size_t len = strnlen(n, kTarName);
314
+ // A name filling the field without a terminator is truncated by the %.100s
315
+ // below into a different path than the archive declared.
316
+ if (len == kTarName) return true;
317
+ // The ustar `prefix` field is not honored when building the path, so a
318
+ // member relying on it would silently extract somewhere else.
319
+ if (memcmp(block + 257, "ustar", 5) == 0 && block[345] != 0) return true;
320
+ // Depth is capped because every directory level created here is a level
321
+ // rmtree/rmdir_recursive later recurses over, and those run from boot
322
+ // recovery where a stack overflow costs the device permanently. `a/` twice
323
+ // per level fits ~49 levels in this field; real builds are 2-3 deep.
324
+ int depth = 0;
325
+ for (size_t i = 0; i < len; i++) {
326
+ if (n[i] == '/' && ++depth > kMaxNameDepth) return true;
327
+ }
328
+ for (size_t i = 0; i + 1 < len; i++) {
329
+ const bool at_start = (i == 0 || n[i - 1] == '/');
330
+ if (!at_start || n[i] != '.' || n[i + 1] != '.') continue;
331
+ if (i + 2 == len || n[i + 2] == '/') return true;
332
+ }
333
+ return false;
334
+ }
335
+
336
+ void untar_block(Untar* u) {
337
+ if (u->mode == Untar::kHeader) {
338
+ if (u->block[0] == 0) { // zero block = end of archive
339
+ u->done = true;
340
+ return;
341
+ }
342
+ if (unsafe_member_name(u->block)) {
343
+ u->failed = true;
344
+ u->err = "unsafe path in build archive";
345
+ u->kind = OtaErr::kCorrupt;
346
+ return;
347
+ }
348
+ char name[256];
349
+ snprintf(name, sizeof(name), "%s/%.100s", u->dest, (const char*)u->block);
350
+ long size = octal((const char*)u->block + 124, 12);
351
+ char type = u->block[156];
352
+ // A negative size would make `want` below wrap to a huge size_t and read
353
+ // past the 512-byte block.
354
+ if (size < 0) {
355
+ u->failed = true;
356
+ u->err = "bad member size in build archive";
357
+ u->kind = OtaErr::kCorrupt;
358
+ return;
359
+ }
360
+
361
+ if (type == '5') { // directory
362
+ mkdir_parents(name);
363
+ mkdir(name, 0755);
364
+ return;
365
+ }
366
+ if (type == '0' || type == 0) { // regular file
367
+ mkdir_parents(name);
368
+ u->cur = fopen(name, "wb");
369
+ if (!u->cur) {
370
+ u->failed = true;
371
+ u->err = "create extracted file";
372
+ u->kind = OtaErr::kTransient;
373
+ return;
374
+ }
375
+ u->remaining = size;
376
+ if (size == 0) { // no data blocks follow
377
+ if (fclose(u->cur) != 0) {
378
+ u->failed = true;
379
+ u->err = "close extracted file";
380
+ u->kind = OtaErr::kTransient;
381
+ }
382
+ u->cur = nullptr;
383
+ return;
384
+ }
385
+ u->mode = Untar::kFile;
386
+ return;
387
+ }
388
+ // Other entry types (symlink/longname/etc.) aren't produced by our
389
+ // builds — discard their data blocks.
390
+ u->skip_blocks = (size + kTarBlock - 1) / kTarBlock;
391
+ if (u->skip_blocks > 0) u->mode = Untar::kSkip;
392
+ return;
393
+ }
394
+
395
+ if (u->mode == Untar::kFile) {
396
+ size_t want = u->remaining < (long)kTarBlock ? (size_t)u->remaining : kTarBlock;
397
+ if (fwrite(u->block, 1, want, u->cur) != want) {
398
+ fclose(u->cur);
399
+ u->cur = nullptr;
400
+ u->failed = true;
401
+ u->err = "write extracted file";
402
+ u->kind = OtaErr::kTransient;
403
+ return;
404
+ }
405
+ u->remaining -= (long)want; // trailing bytes of this block are padding
406
+ if (u->remaining <= 0) {
407
+ // littlefs surfaces ENOSPC at close, not at write, so an unchecked
408
+ // fclose here would promote a silently truncated app file.
409
+ if (fclose(u->cur) != 0) {
410
+ u->failed = true;
411
+ u->err = "close extracted file";
412
+ u->kind = OtaErr::kTransient;
413
+ }
414
+ u->cur = nullptr;
415
+ u->mode = Untar::kHeader;
416
+ }
417
+ return;
418
+ }
419
+
420
+ // kSkip
421
+ if (--u->skip_blocks <= 0) u->mode = Untar::kHeader;
422
+ }
423
+
424
+ void untar_feed(Untar* u, const unsigned char* data, size_t n) {
425
+ while (n > 0 && !u->failed && !u->done) {
426
+ size_t take = kTarBlock - u->fill;
427
+ if (take > n) take = n;
428
+ memcpy(u->block + u->fill, data, take);
429
+ u->fill += take;
430
+ data += take;
431
+ n -= take;
432
+ if (u->fill == kTarBlock) {
433
+ u->fill = 0;
434
+ untar_block(u);
435
+ }
436
+ }
437
+ }
438
+
439
+ // Release the open member handle, if any. Split out from untar_finish because
440
+ // the failure path has to close it too, and must not overwrite the error that
441
+ // caused the failure with untar_finish's own.
442
+ void untar_close(Untar* u) {
443
+ if (u->cur) {
444
+ fclose(u->cur);
445
+ u->cur = nullptr;
446
+ }
447
+ }
448
+
449
+ bool untar_finish(Untar* u, const char** err) {
450
+ if (u->cur) {
451
+ // Still open means the archive ended mid-member: the app file on disk is
452
+ // short. Reporting success here would promote a truncated app.
453
+ if (fclose(u->cur) != 0 && !u->failed) {
454
+ u->err = "close extracted file";
455
+ u->kind = OtaErr::kTransient;
456
+ } else if (!u->failed) {
457
+ u->err = "build archive ended mid-file (truncated)";
458
+ u->kind = OtaErr::kCorrupt;
459
+ }
460
+ u->failed = true;
461
+ u->cur = nullptr;
462
+ }
463
+ if (u->failed) {
464
+ *err = u->err;
465
+ return false;
466
+ }
467
+ return true;
468
+ }
469
+
470
+ // ── Streaming gunzip -> untar ────────────────────────────────────────────────
471
+ // Inflate in_path with miniz's low-level tinfl and feed the decompressed bytes
472
+ // straight into the untar state machine, so the .tar never hits disk. The gzip
473
+ // trailer (CRC-32 + ISIZE) is still verified — a corrupt or truncated download
474
+ // decompresses to garbage that won't match, so it is rejected before any swap.
475
+ bool unpack_tgz(const char* in_path, const char* dest_dir, const char** err, OtaErr* kind) {
476
+ *kind = OtaErr::kTransient;
477
+ FILE* in = fopen(in_path, "rb");
478
+ if (!in) {
479
+ *err = "open .tgz";
480
+ return false;
481
+ }
482
+
483
+ // Parse the gzip header, honoring the optional fields the FLG byte advertises
484
+ // (FEXTRA/FNAME/FCOMMENT/FHCRC) instead of assuming a fixed 10-byte header.
485
+ // Magic(2) CM(1) FLG(1) MTIME(4) XFL(1) OS(1) = 10 fixed bytes, then extras.
486
+ int b0 = fgetc(in), b1 = fgetc(in), cm = fgetc(in), flg = fgetc(in);
487
+ if (b0 != 0x1f || b1 != 0x8b || cm == EOF || flg == EOF) {
488
+ fclose(in);
489
+ *err = "not a gzip stream";
490
+ *kind = OtaErr::kCorrupt;
491
+ return false;
492
+ }
493
+ for (int i = 0; i < 6; i++) fgetc(in); // MTIME + XFL + OS
494
+ bool hdr_ok = true;
495
+ if (flg & 0x04) { // FEXTRA: 2-byte LE length, then that many bytes
496
+ int xl = fgetc(in), xh = fgetc(in);
497
+ if (xl == EOF || xh == EOF) {
498
+ hdr_ok = false;
499
+ } else {
500
+ for (int i = 0, xlen = xl | (xh << 8); i < xlen && hdr_ok; i++) {
501
+ if (fgetc(in) == EOF) hdr_ok = false;
502
+ }
503
+ }
504
+ }
505
+ if (hdr_ok && (flg & 0x08)) { // FNAME
506
+ int c;
507
+ do {
508
+ c = fgetc(in);
509
+ } while (c != 0 && c != EOF);
510
+ if (c == EOF) hdr_ok = false;
511
+ }
512
+ if (hdr_ok && (flg & 0x10)) { // FCOMMENT
513
+ int c;
514
+ do {
515
+ c = fgetc(in);
516
+ } while (c != 0 && c != EOF);
517
+ if (c == EOF) hdr_ok = false;
518
+ }
519
+ if (hdr_ok && (flg & 0x02)) { // FHCRC
520
+ if (fgetc(in) == EOF || fgetc(in) == EOF) hdr_ok = false;
521
+ }
522
+ if (!hdr_ok) {
523
+ fclose(in);
524
+ *err = "bad gzip header";
525
+ *kind = OtaErr::kCorrupt;
526
+ return false;
527
+ }
528
+
529
+ // Everything here goes on the HEAP, not the stack: this runs deep inside the
530
+ // QuickJS / boot call chain where a multi-KB stack array would overflow.
531
+ // That includes the decompressor itself -- tinfl_decompressor is ~11 KiB
532
+ // against the ESP-IDF ROM miniz (three 3.5 KiB huffman tables), which alone
533
+ // is half the main task stack. TINFL_LZ_DICT_SIZE (32 KiB) is the sliding
534
+ // window tinfl requires.
535
+ tinfl_decompressor* inflator = (tinfl_decompressor*)malloc(sizeof(tinfl_decompressor));
536
+ unsigned char* window = (unsigned char*)malloc(TINFL_LZ_DICT_SIZE);
537
+ unsigned char* inbuf = (unsigned char*)malloc(kChunk);
538
+ if (!inflator || !window || !inbuf) {
539
+ free(inflator);
540
+ free(window);
541
+ free(inbuf);
542
+ fclose(in);
543
+ *err = "oom (inflate buffers)";
544
+ *kind = OtaErr::kOom;
545
+ return false;
546
+ }
547
+ tinfl_init(inflator);
548
+
549
+ Untar untar;
550
+ untar_init(&untar, dest_dir);
551
+
552
+ // Single streaming loop (validated on host against real `tar -czf` builds).
553
+ // NOTE: do NOT gate the loop on TINFL_FLAG_HAS_MORE_INPUT — once input drains
554
+ // while that flag is set, tinfl returns NEEDS_MORE_INPUT with zero progress
555
+ // and the loop spins forever. Drive it off `avail_in`/`eof` only.
556
+ size_t window_pos = 0;
557
+ const unsigned char* next_in = inbuf;
558
+ size_t avail_in = 0;
559
+ bool eof = false;
560
+ bool ok = true;
561
+ uint32_t crc = 0; // running CRC-32 of the decompressed output
562
+ uint32_t total_out = 0; // uncompressed byte count (mod 2^32 == gzip ISIZE)
563
+ unsigned char tail[8]; // rolling last-8-bytes of the file == the gzip trailer
564
+ size_t tail_len = 0;
565
+ for (;;) {
566
+ if (avail_in == 0 && !eof) {
567
+ size_t in_n = fread(inbuf, 1, kChunk, in);
568
+ // A read error also returns 0. Distinguish it from real EOF: treating
569
+ // an I/O glitch as a short stream classifies it kCorrupt below, which
570
+ // makes the policy blacklist a perfectly good build forever.
571
+ if (in_n == 0 && ferror(in)) {
572
+ *err = "read staged build (i/o error)";
573
+ *kind = OtaErr::kTransient;
574
+ ok = false;
575
+ break;
576
+ }
577
+ next_in = inbuf;
578
+ avail_in = in_n;
579
+ tail8_push(tail, &tail_len, inbuf, in_n); // remember the file's last 8 bytes
580
+ if (in_n == 0) eof = true;
581
+ }
582
+ size_t in_used = avail_in;
583
+ size_t out_used = TINFL_LZ_DICT_SIZE - window_pos;
584
+ int flags = eof ? 0 : TINFL_FLAG_HAS_MORE_INPUT;
585
+ tinfl_status st = tinfl_decompress(inflator, next_in, &in_used, window,
586
+ window + window_pos, &out_used, flags);
587
+ next_in += in_used;
588
+ avail_in -= in_used;
589
+ if (out_used) {
590
+ untar_feed(&untar, window + window_pos, out_used);
591
+ if (untar.failed) {
592
+ *err = untar.err;
593
+ *kind = untar.kind;
594
+ ok = false;
595
+ break;
596
+ }
597
+ crc = crc32_update(crc, window + window_pos, out_used);
598
+ total_out += (uint32_t)out_used;
599
+ }
600
+ window_pos = (window_pos + out_used) & (TINFL_LZ_DICT_SIZE - 1);
601
+ if (st == TINFL_STATUS_DONE) break;
602
+ if (st < TINFL_STATUS_DONE) {
603
+ *err = "inflate error";
604
+ *kind = OtaErr::kCorrupt;
605
+ ok = false;
606
+ break;
607
+ }
608
+ if (st == TINFL_STATUS_NEEDS_MORE_INPUT && eof) {
609
+ *err = "truncated gzip";
610
+ *kind = OtaErr::kCorrupt;
611
+ ok = false;
612
+ break;
613
+ }
614
+ }
615
+
616
+ // tinfl stops at the end of the DEFLATE stream, so the 8-byte trailer may be
617
+ // sitting unread in the file. Drain whatever's left through the same rolling
618
+ // tail so `tail` holds the file's true final 8 bytes.
619
+ if (ok) {
620
+ unsigned char drain[64];
621
+ size_t dn;
622
+ while ((dn = fread(drain, 1, sizeof(drain), in)) > 0) {
623
+ tail8_push(tail, &tail_len, drain, dn);
624
+ }
625
+ }
626
+
627
+ // Verify the gzip trailer from `tail` (the file's last 8 bytes) rather than
628
+ // from tinfl's leftover input: the esp32c6 ROM tinfl mis-accounts its input
629
+ // consumption at end-of-stream (it draws the trailer into its bit-buffer
630
+ // lookahead and reports it consumed), so the post-DONE buffer is unreliable
631
+ // and a valid build would wrongly read as "missing trailer". `crc`/
632
+ // `total_out` come from the decoded output, so they're independent of that.
633
+ if (ok) {
634
+ if (tail_len < 8) {
635
+ *err = "gzip: missing trailer (truncated)";
636
+ *kind = OtaErr::kCorrupt;
637
+ ok = false;
638
+ } else {
639
+ uint32_t want_crc = (uint32_t)tail[0] | ((uint32_t)tail[1] << 8) |
640
+ ((uint32_t)tail[2] << 16) | ((uint32_t)tail[3] << 24);
641
+ uint32_t want_size = (uint32_t)tail[4] | ((uint32_t)tail[5] << 8) |
642
+ ((uint32_t)tail[6] << 16) | ((uint32_t)tail[7] << 24);
643
+ if (crc != want_crc) {
644
+ *err = "gzip: crc mismatch (corrupt build)";
645
+ *kind = OtaErr::kCorrupt;
646
+ ok = false;
647
+ } else if (total_out != want_size) {
648
+ *err = "gzip: size mismatch (corrupt build)";
649
+ *kind = OtaErr::kCorrupt;
650
+ ok = false;
651
+ }
652
+ }
653
+ }
654
+
655
+ if (ok && !untar_finish(&untar, err)) {
656
+ *kind = untar.kind;
657
+ ok = false;
658
+ }
659
+ // untar_finish is skipped on the failure path above, and it is the only
660
+ // other owner of `cur`. Without this an aborted unpack (inflate error, crc
661
+ // mismatch, truncation) strands an open littlefs handle -- on a tree
662
+ // install_build is about to rmtree -- once per attempt, and nothing ever
663
+ // reclaims it. No-op when untar_finish already closed it.
664
+ untar_close(&untar);
665
+
666
+ free(inflator);
667
+ free(window);
668
+ free(inbuf);
669
+ fclose(in);
670
+ return ok;
671
+ }
672
+
673
+ } // namespace
674
+
675
+ #ifndef MIK_OTA_HOST_TEST
676
+
677
+ namespace {
678
+
679
+ // ── On-disk layout (raw VFS; LittleFS is mounted at /appfs) ──────────────────
680
+ constexpr const char* kFsBase = "/appfs";
681
+ constexpr const char* kStaging = "/appfs/.ota-staging.tgz"; // in-progress download sink
682
+ constexpr const char* kPending = "/appfs/.ota-pending.tgz"; // verified, awaiting install
683
+ constexpr const char* kLastGood = "/appfs/.ota-last-good.tgz"; // revert target
684
+ constexpr const char* kLastGoodTmp = "/appfs/.ota-last-good.tmp"; // adopt: install source
685
+ // Reuse the deploy staging dir so MIK_DeployRecover() (which runs at boot before
686
+ // our reconcile) cleans up an OTA unpack interrupted by a brownout, exactly as
687
+ // it does for an interrupted serial deploy. mik__app_commit expects the new app
688
+ // at <base>/.deploy-tmp/app.
689
+ constexpr const char* kDeployTmp = "/appfs/.deploy-tmp";
690
+ constexpr const char* kDeployOld = "/appfs/.deploy-old";
691
+
692
+ // NVS namespace + keys. Keys are kept <= 15 chars (the NVS limit).
693
+ constexpr const char* kNs = "mik.ota";
694
+ constexpr const char* kKeyState = "state"; // u8: 0 GOOD, 1 TRIAL
695
+ constexpr const char* kKeyTrialLeft = "trialLeft"; // u8
696
+ constexpr const char* kKeyTrialN = "trialN"; // u8: trial budget for a pending install
697
+ constexpr const char* kKeyConfirm = "confirm"; // u8: trial requires markValid() to promote
698
+ constexpr const char* kKeyPending = "pending"; // u8: 1 = .ota-pending.tgz awaits clean-heap install
699
+ constexpr const char* kKeyInstLeft = "instLeft"; // u8: install-attempt budget
700
+ constexpr const char* kKeyPromLeft = "promLeft"; // u8: promote-attempt budget
701
+ constexpr const char* kKeyRbLeft = "rbLeft"; // u8: rollback-attempt budget
702
+ constexpr const char* kKeyNeutLeft = "neutLeft"; // u8: brownout boots a trial may absorb
703
+ constexpr const char* kKeyInstChk = "instChk"; // str: running good (last-good) checksum
704
+ constexpr const char* kKeyPendChk = "pendChk"; // str: pending/trial checksum
705
+ constexpr const char* kKeyStgChk = "stgChk"; // str: in-progress staging expected checksum
706
+ constexpr const char* kKeyStgSize = "stgSize"; // u32: expected staged size
707
+ constexpr const char* kKeyFwHash = "fwHash"; // str: firmware elf sha256 (reflash guard)
708
+ constexpr const char* kKeyTrialBad = "trialBad"; // u8: trial app hit a fatal JS error this boot
709
+ constexpr const char* kKeyBadDetail = "badDetail"; // str: that error, for the revert diagnostic
710
+ // Boot-reconcile outcome, read once by reconcile() and then cleared.
711
+ constexpr const char* kKeyOInst = "oInst"; // str: checksum just installed
712
+ constexpr const char* kKeyORevert = "oRevert"; // u8
713
+ constexpr const char* kKeyORsn = "oRsn"; // str: diagnostic reason
714
+ constexpr const char* kKeyODetail = "oDetail"; // str: diagnostic detail
715
+
716
+ constexpr uint8_t kStateGood = 0;
717
+ constexpr uint8_t kStateTrial = 1;
718
+ constexpr uint8_t kInstallBudget = 3; // boot install attempts before abandoning a pending build
719
+ // Boot attempts to make a survived trial the revert target before giving up on
720
+ // the revert target rather than on the device: a trial that cannot promote and
721
+ // never resolves leaves applyOffer skipping every future offer, so the device
722
+ // stops being updatable at all and only a reflash recovers it.
723
+ constexpr uint8_t kPromoteBudget = 3;
724
+ // Boot attempts to install the revert target before keeping the trial build
725
+ // instead. install_build can panic rather than return, so an unbudgeted rollback
726
+ // that crashes mid-install re-enters itself every boot forever.
727
+ constexpr uint8_t kRollbackBudget = 3;
728
+ // Brownout boots a trial absorbs before they start counting as ordinary boots.
729
+ // A brownout is ambiguous (bad supply, or the new build drawing more than the
730
+ // board can deliver), so don't convict on the first one -- but don't absorb them
731
+ // forever either: applyOffer skips every offer while a trial is unresolved, so a
732
+ // trial held open indefinitely shuts the one channel that could ship a fix.
733
+ constexpr uint8_t kNeutralBudget = 3;
734
+
735
+ // ── small fs helpers ─────────────────────────────────────────────────────────
736
+ bool path_exists(const char* path) {
737
+ struct stat st;
738
+ return stat(path, &st) == 0;
739
+ }
740
+
741
+ /* `stat` succeeds for any node, so a guard that means "a directory is here"
742
+ * has to say so: an archive carrying a regular file named `app` would
743
+ * otherwise be accepted as an unpacked build. */
744
+ bool dir_exists(const char* path) {
745
+ struct stat st;
746
+ return stat(path, &st) == 0 && S_ISDIR(st.st_mode);
747
+ }
748
+
749
+ long file_size(const char* path) {
750
+ struct stat st;
751
+ if (stat(path, &st) != 0) return -1;
752
+ return (long)st.st_size;
753
+ }
754
+
755
+ // Uncompressed size from the gzip ISIZE trailer (last 4 bytes), or -1 when it
756
+ // can't be read. Only a hint for the free-space precheck: it is attacker-
757
+ // influenced and mod 2^32, so nothing may trust it -- the real check is the
758
+ // crc/size verification against the decoded output in unpack_tgz.
759
+ long gzip_isize(const char* path) {
760
+ FILE* f = fopen(path, "rb");
761
+ if (!f) return -1;
762
+ unsigned char t[4];
763
+ long got = -1;
764
+ if (fseek(f, -4, SEEK_END) == 0 && fread(t, 1, 4, f) == 4) {
765
+ uint32_t v = (uint32_t)t[0] | ((uint32_t)t[1] << 8) | ((uint32_t)t[2] << 16) |
766
+ ((uint32_t)t[3] << 24);
767
+ if (v <= LONG_MAX) got = (long)v;
768
+ }
769
+ fclose(f);
770
+ return got;
771
+ }
772
+
773
+ // Capacity and free bytes of the app filesystem. False when the platform can't
774
+ // report them (no littlefs build); callers then skip their size check rather
775
+ // than block an install on a missing measurement.
776
+ bool fs_space(long* total, long* free_bytes) {
777
+ const MIKPlatform* p = MIK_GetPlatform();
778
+ size_t t = 0;
779
+ size_t u = 0;
780
+ if (!p || !p->get_fs_info || !p->get_fs_info("user", &t, &u)) return false;
781
+ *total = (long)t;
782
+ *free_bytes = t > u ? (long)(t - u) : 0;
783
+ return true;
784
+ }
785
+
786
+ // Make the pending build the new revert target. The old last-good is only
787
+ // dropped once the replacement is in hand: unlinking first and then failing the
788
+ // rename (ENOSPC on littlefs metadata, or kPending lost to a power cut) would
789
+ // leave a GOOD state whose instChk names a build with no .tgz on disk, so a
790
+ // later revert() would have nothing to reinstall.
791
+ bool promote_pending(void) {
792
+ if (!path_exists(kPending)) return false;
793
+ // rename replaces an existing destination atomically (lfs_rename removes it
794
+ // as part of the same commit), so kLastGood must NOT be unlinked first: a
795
+ // failed rename after the unlink would leave a GOOD state whose instChk
796
+ // names a build with no .tgz on disk, and a later revert() would have
797
+ // nothing to reinstall.
798
+ return rename(kPending, kLastGood) == 0;
799
+ }
800
+
801
+ // Deepest tree rmtree will descend into. kMaxNameDepth caps what untar will
802
+ // create, so this is only reachable through state an older firmware left
803
+ // behind. The bound matters because rmtree runs from boot reconcile: a stack
804
+ // overflow there reboots into the same call and never reaches the install
805
+ // budget, so the device would never recover. Past the bound the tree stays on
806
+ // disk and fails the next install instead.
807
+ constexpr int kMaxRmDepth = 32;
808
+
809
+ // `path` is a mutable buffer of `cap` bytes; each level appends into it and
810
+ // truncates on the way out, so a frame costs a few dozen bytes rather than a
811
+ // 512-byte path of its own.
812
+ void rmtree_at(char* path, size_t cap, int depth) {
813
+ DIR* d = opendir(path);
814
+ if (!d) {
815
+ unlink(path);
816
+ return;
817
+ }
818
+ const size_t base = strlen(path);
819
+ dirent* ent;
820
+ while ((ent = readdir(d)) != nullptr) {
821
+ if (!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) continue;
822
+ const int n = snprintf(path + base, cap - base, "/%s", ent->d_name);
823
+ if (n < 0 || (size_t)n >= cap - base) {
824
+ path[base] = 0; // would truncate to a different path; skip it
825
+ continue;
826
+ }
827
+ struct stat st;
828
+ if (!stat(path, &st) && S_ISDIR(st.st_mode)) {
829
+ if (depth < kMaxRmDepth) rmtree_at(path, cap, depth + 1);
830
+ } else {
831
+ unlink(path);
832
+ }
833
+ path[base] = 0;
834
+ }
835
+ closedir(d);
836
+ rmdir(path);
837
+ }
838
+
839
+ void rmtree(const char* path) {
840
+ char buf[512];
841
+ const size_t n = strlen(path);
842
+ if (n >= sizeof(buf)) return;
843
+ memcpy(buf, path, n + 1);
844
+ rmtree_at(buf, sizeof(buf), 0);
845
+ }
846
+
847
+ // ── NVS helpers ──────────────────────────────────────────────────────────────
848
+ uint8_t nvs_u8(nvs_handle_t h, const char* key, uint8_t def) {
849
+ uint8_t v = def;
850
+ if (nvs_get_u8(h, key, &v) != ESP_OK) v = def;
851
+ return v;
852
+ }
853
+
854
+ uint32_t nvs_u32(nvs_handle_t h, const char* key, uint32_t def) {
855
+ uint32_t v = def;
856
+ if (nvs_get_u32(h, key, &v) != ESP_OK) v = def;
857
+ return v;
858
+ }
859
+
860
+ // Read a string key into `buf`. Returns true and a non-empty string on success.
861
+ bool nvs_str(nvs_handle_t h, const char* key, char* buf, size_t cap) {
862
+ size_t len = cap;
863
+ if (nvs_get_str(h, key, buf, &len) != ESP_OK || len == 0) {
864
+ buf[0] = 0;
865
+ return false;
866
+ }
867
+ return buf[0] != 0;
868
+ }
869
+
870
+ // Copy src to dst (truncating dst). Used to move the staged build to the
871
+ // rollback slot before install_build rmtrees the staging tree it lives under.
872
+ bool copy_file(const char* src, const char* dst) {
873
+ FILE* in = fopen(src, "rb");
874
+ if (!in) return false;
875
+ FILE* out = fopen(dst, "wb");
876
+ if (!out) {
877
+ fclose(in);
878
+ return false;
879
+ }
880
+ unsigned char* buf = (unsigned char*)malloc(kChunk);
881
+ if (!buf) {
882
+ fclose(in);
883
+ fclose(out);
884
+ return false;
885
+ }
886
+ bool ok = true;
887
+ size_t n;
888
+ while ((n = fread(buf, 1, kChunk, in)) > 0) {
889
+ if (fwrite(buf, 1, n, out) != n) {
890
+ ok = false;
891
+ break;
892
+ }
893
+ }
894
+ if (ok && ferror(in)) ok = false;
895
+ free(buf);
896
+ if (fclose(out) != 0) ok = false;
897
+ fclose(in);
898
+ return ok;
899
+ }
900
+
901
+ // ── install (gunzip -> untar -> atomic swap) ─────────────────────────────────
902
+ // Unpacks `tgz` into <base>/.deploy-tmp/app and promotes it to /app via the
903
+ // shared app-store engine (crash-safe: an interrupted swap is rolled back by
904
+ // MIK_DeployRecover at the next boot).
905
+ bool install_build(const char* tgz, const char** err, OtaErr* kind) {
906
+ rmtree(kDeployTmp);
907
+ rmtree(kDeployOld);
908
+ // Reject an oversized build up front instead of failing partway through the
909
+ // unpack and burning an install attempt. Prefer the gzip ISIZE trailer (the
910
+ // unpacked tar size) over the compressed size, which understates the real
911
+ // requirement several-fold and lets through builds that cannot fit.
912
+ long need = gzip_isize(tgz);
913
+ if (need < 0) need = file_size(tgz);
914
+ long total = 0;
915
+ long free_bytes = 0;
916
+ if (need > 0 && fs_space(&total, &free_bytes) && free_bytes < need) {
917
+ *err = "not enough free space to install build";
918
+ *kind = OtaErr::kTransient;
919
+ return false;
920
+ }
921
+ if (mkdir(kDeployTmp, 0755) != 0 && errno != EEXIST) {
922
+ *err = "mkdir stage";
923
+ *kind = OtaErr::kTransient;
924
+ return false;
925
+ }
926
+ if (!unpack_tgz(tgz, kDeployTmp, err, kind)) {
927
+ rmtree(kDeployTmp);
928
+ return false;
929
+ }
930
+ // mik__app_commit reports OK when nothing was staged -- correct for the
931
+ // serial deploy path, but here it would turn an archive with no `app/`
932
+ // member into a silent no-op that still promotes and records the checksum
933
+ // as installed, so the device never retries. A build that unpacks to
934
+ // nothing is a bad build.
935
+ char staged_app[512];
936
+ snprintf(staged_app, sizeof(staged_app), "%s/app", kDeployTmp);
937
+ if (!dir_exists(staged_app)) {
938
+ *err = "build archive contains no app/ directory";
939
+ *kind = OtaErr::kCorrupt;
940
+ rmtree(kDeployTmp);
941
+ return false;
942
+ }
943
+ MIKAppCommitResult r = mik__app_commit(kFsBase, false);
944
+ if (r != MIK_APP_COMMIT_OK) {
945
+ *err = r == MIK_APP_COMMIT_STASH_FAILED ? "stash old app failed" : "swap new app failed";
946
+ *kind = OtaErr::kTransient;
947
+ rmtree(kDeployTmp);
948
+ return false;
949
+ }
950
+ return true;
951
+ }
952
+
953
+ // Drop the trial crash flag and its diagnostic. Must run wherever the state
954
+ // machine leaves a trial or arms a new one: the flag outliving the build that
955
+ // set it makes the *next* trial roll back on its first reboot, blamed with the
956
+ // previous build's error string.
957
+ void clear_trial_failure(nvs_handle_t h) {
958
+ nvs_erase_key(h, kKeyTrialBad);
959
+ nvs_erase_key(h, kKeyBadDetail);
960
+ }
961
+
962
+ // Record a boot/promote diagnostic for the next reconcile() call to report.
963
+ void record_diag(nvs_handle_t h, const char* reason, const char* detail) {
964
+ nvs_set_str(h, kKeyORsn, reason);
965
+ if (detail && detail[0]) nvs_set_str(h, kKeyODetail, detail);
966
+ }
967
+
968
+ // ── staging write cache ──────────────────────────────────────────────────────
969
+ // stageBegin fills these, stageWrite appends without reopening, and
970
+ // stageFinish/stageAbort flush them. Without the cache every 4 KB chunk cost an
971
+ // nvs_open + stat + fopen/fclose (~50 flash metadata commits for a 200 KB
972
+ // build). Safe as plain statics because staging writes and the boot reconcile
973
+ // never overlap: both run on the single main task.
974
+ FILE* g_stage_file = nullptr;
975
+ uint32_t g_stage_cap = 0; // declared size from stageBegin; 0 = uncapped
976
+ long g_stage_have = 0; // bytes in the staging file
977
+
978
+ // Flush and close the staging file. False if the close failed, which on
979
+ // littlefs is where a full disk surfaces.
980
+ bool stage_close(void) {
981
+ if (!g_stage_file) return true;
982
+ bool ok = fclose(g_stage_file) == 0;
983
+ g_stage_file = nullptr;
984
+ return ok;
985
+ }
986
+
987
+ // ── JS result-object builders ────────────────────────────────────────────────
988
+ JSValue ok_obj(JSContext* ctx) {
989
+ JSValue o = JS_NewObject(ctx);
990
+ JS_SetPropertyStr(ctx, o, "ok", JS_TRUE);
991
+ return o;
992
+ }
993
+
994
+ JSValue err_obj(JSContext* ctx, const char* msg) {
995
+ JSValue o = JS_NewObject(ctx);
996
+ JS_SetPropertyStr(ctx, o, "ok", JS_FALSE);
997
+ JS_SetPropertyStr(ctx, o, "error", JS_NewString(ctx, msg));
998
+ return o;
999
+ }
1000
+
1001
+ JSValue err_obj_kind(JSContext* ctx, const char* msg, OtaErr kind) {
1002
+ JSValue o = err_obj(ctx, msg);
1003
+ JS_SetPropertyStr(ctx, o, "kind", JS_NewString(ctx, err_kind(kind)));
1004
+ return o;
1005
+ }
1006
+
1007
+ // ── JS bindings ──────────────────────────────────────────────────────────────
1008
+
1009
+ // sha256_file emits lowercase hex and the comparison in stageFinish is strcmp,
1010
+ // so anything else can never match. Rejecting it here matters because the
1011
+ // mismatch would otherwise surface as kCorrupt, which the policy layer treats
1012
+ // as "these bytes can never succeed" and abandons the checksum permanently: an
1013
+ // uppercase digest from a registry would blacklist a perfectly good build.
1014
+ bool valid_checksum(const char* s) {
1015
+ size_t i = 0;
1016
+ for (; s[i]; i++) {
1017
+ if (i >= 64) return false;
1018
+ const char c = s[i];
1019
+ if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false;
1020
+ }
1021
+ return i == 64;
1022
+ }
1023
+
1024
+ // stageBegin(checksum, size) -> { ok, resumeOffset } | { ok:false, error }
1025
+ JSValue js_stage_begin(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
1026
+ if (argc < 2) return err_obj(ctx, "missing checksum/size");
1027
+ const char* checksum = JS_ToCString(ctx, argv[0]);
1028
+ if (!checksum) {
1029
+ // JS_ToCString throws on a value with a throwing toString. Clear it
1030
+ // rather than strand it on the context for some unrelated later
1031
+ // operation to surface, the same way the size path below does.
1032
+ JS_FreeValue(ctx, JS_GetException(ctx));
1033
+ return err_obj(ctx, "checksum not a string");
1034
+ }
1035
+ if (!valid_checksum(checksum)) {
1036
+ JS_FreeCString(ctx, checksum);
1037
+ // kTransient, not the default: the build is fine, the offer is
1038
+ // malformed, so this must not count against the build's retry budget.
1039
+ return err_obj_kind(ctx, "checksum must be 64 lowercase hex characters",
1040
+ OtaErr::kTransient);
1041
+ }
1042
+ int64_t size = 0;
1043
+ if (JS_ToInt64(ctx, &size, argv[1]) || size <= 0) {
1044
+ JS_FreeCString(ctx, checksum);
1045
+ // JS_ToInt64 throws on a value with a throwing valueOf. We report through
1046
+ // the result object, so clear it rather than strand it on the context for
1047
+ // some unrelated later operation to surface.
1048
+ JS_FreeValue(ctx, JS_GetException(ctx));
1049
+ return err_obj(ctx, "invalid size");
1050
+ }
1051
+ // Reject an impossible size here, while it is still int64: stgSize is u32,
1052
+ // so a bogus offer above 4 GB would otherwise truncate into a small cap.
1053
+ long fs_total = 0;
1054
+ long fs_free = 0;
1055
+ if (fs_space(&fs_total, &fs_free) && size > fs_total) {
1056
+ JS_FreeCString(ctx, checksum);
1057
+ return err_obj(ctx, "offered build is larger than the filesystem");
1058
+ }
1059
+
1060
+ stage_close(); // flush any prior staging file before stat/unlink below
1061
+
1062
+ nvs_handle_t h;
1063
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) {
1064
+ JS_FreeCString(ctx, checksum);
1065
+ return err_obj(ctx, "nvs open failed");
1066
+ }
1067
+
1068
+ // Resume only if the in-progress staging file is for the same checksum+size
1069
+ // and hasn't already grown past the declared size.
1070
+ char prev[96];
1071
+ uint32_t prev_size = nvs_u32(h, kKeyStgSize, 0);
1072
+ long have = file_size(kStaging);
1073
+ int64_t resume = 0;
1074
+ if (nvs_str(h, kKeyStgChk, prev, sizeof(prev)) && strcmp(prev, checksum) == 0 &&
1075
+ prev_size == (uint32_t)size && have >= 0 && have <= size) {
1076
+ resume = have;
1077
+ } else {
1078
+ unlink(kStaging); // different target — start fresh
1079
+ nvs_set_str(h, kKeyStgChk, checksum);
1080
+ nvs_set_u32(h, kKeyStgSize, (uint32_t)size);
1081
+ nvs_commit(h);
1082
+ }
1083
+ nvs_close(h);
1084
+ JS_FreeCString(ctx, checksum);
1085
+
1086
+ g_stage_cap = (uint32_t)size;
1087
+ g_stage_have = resume;
1088
+
1089
+ JSValue o = ok_obj(ctx);
1090
+ JS_SetPropertyStr(ctx, o, "resumeOffset", JS_NewInt64(ctx, resume));
1091
+ return o;
1092
+ }
1093
+
1094
+ // stageWrite(bytes) -> { ok } | { ok:false, error, kind? }
1095
+ JSValue js_stage_write(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
1096
+ if (argc < 1) return err_obj(ctx, "missing bytes");
1097
+ size_t len = 0;
1098
+ const uint8_t* data = JS_GetUint8Array(ctx, &len, argv[0]);
1099
+ if (!data) {
1100
+ JS_FreeValue(ctx, JS_GetException(ctx)); // JS_GetUint8Array throws; we report via err_obj
1101
+ return err_obj(ctx, "bytes not a Uint8Array");
1102
+ }
1103
+ if (len == 0) return ok_obj(ctx);
1104
+
1105
+ // Enforce the size cap from stageBegin so a runaway download can't fill flash.
1106
+ if (g_stage_cap > 0 && (uint64_t)g_stage_have + len > g_stage_cap) {
1107
+ return err_obj_kind(ctx, "staged write exceeds declared size", OtaErr::kTransient);
1108
+ }
1109
+
1110
+ if (!g_stage_file) {
1111
+ g_stage_file = fopen(kStaging, "ab");
1112
+ if (!g_stage_file) return err_obj_kind(ctx, "open staging file", OtaErr::kTransient);
1113
+ }
1114
+ size_t wrote = fwrite(data, 1, len, g_stage_file);
1115
+ if (wrote != len) {
1116
+ // A short write still leaves `wrote` bytes on disk. Close first so the
1117
+ // stream flushes, then resync from the file itself: an understated
1118
+ // g_stage_have desyncs the resume offset from the real file, and the
1119
+ // duplicated/skipped bytes surface later as a checksum mismatch, which
1120
+ // is classified kCorrupt and blacklists the checksum forever. A full
1121
+ // filesystem must stay transient.
1122
+ stage_close();
1123
+ long actual = file_size(kStaging);
1124
+ g_stage_have = actual < 0 ? g_stage_have + (long)wrote : actual;
1125
+ return err_obj_kind(ctx, "write staging file (disk full?)", OtaErr::kTransient);
1126
+ }
1127
+ g_stage_have += (long)len;
1128
+ return ok_obj(ctx);
1129
+ }
1130
+
1131
+ // stageFinish(trialBoots, requireConfirm, installNow)
1132
+ // -> { ok } | { ok:false, error, kind }
1133
+ // Verifies SHA-256 + size over the whole staged file, then either installs in
1134
+ // place now (installNow) or marks a verified build staged-for-install so the
1135
+ // next boot's reconcile installs it on a clean heap.
1136
+ JSValue js_stage_finish(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {
1137
+ int64_t trial_boots = 1;
1138
+ if (argc >= 1 && JS_ToInt64(ctx, &trial_boots, argv[0])) {
1139
+ // A throwing valueOf leaves trial_boots indeterminate and an exception
1140
+ // pending; we report through the result object, so clear it and default.
1141
+ JS_FreeValue(ctx, JS_GetException(ctx));
1142
+ trial_boots = 1;
1143
+ }
1144
+ bool require_confirm = argc >= 2 && JS_ToBool(ctx, argv[1]);
1145
+ bool install_now = argc >= 3 && JS_ToBool(ctx, argv[2]);
1146
+ if (trial_boots < 0) trial_boots = 0;
1147
+ // A confirm-gated trial needs at least one boot to run in. Boot reconcile
1148
+ // evaluates the trial before the app loads, so trialBoots:0 with confirm set
1149
+ // reverts at the next boot without the new build ever having had the chance
1150
+ // to call markValid() -- every such update would revert, deterministically.
1151
+ if (require_confirm && trial_boots < 1) trial_boots = 1;
1152
+ if (trial_boots > 255) trial_boots = 255;
1153
+
1154
+ if (!stage_close()) {
1155
+ return err_obj_kind(ctx, "write staging file (disk full?)", OtaErr::kTransient);
1156
+ }
1157
+
1158
+ nvs_handle_t h;
1159
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) {
1160
+ return err_obj_kind(ctx, "nvs open failed", OtaErr::kTransient);
1161
+ }
1162
+ char want_chk[96];
1163
+ bool has_chk = nvs_str(h, kKeyStgChk, want_chk, sizeof(want_chk));
1164
+ uint32_t want_size = nvs_u32(h, kKeyStgSize, 0);
1165
+
1166
+ if (!has_chk) {
1167
+ nvs_close(h);
1168
+ return err_obj_kind(ctx, "no staged build", OtaErr::kTransient);
1169
+ }
1170
+ long have = file_size(kStaging);
1171
+ if (have < 0) {
1172
+ nvs_close(h);
1173
+ return err_obj_kind(ctx, "staging file missing", OtaErr::kTransient);
1174
+ }
1175
+ if ((uint32_t)have != want_size) {
1176
+ nvs_close(h);
1177
+ return err_obj_kind(ctx, "staged size mismatch", OtaErr::kCorrupt);
1178
+ }
1179
+ char got_chk[65];
1180
+ if (!sha256_file(kStaging, got_chk)) {
1181
+ nvs_close(h);
1182
+ return err_obj_kind(ctx, "read staging file", OtaErr::kTransient);
1183
+ }
1184
+ if (strcmp(got_chk, want_chk) != 0) {
1185
+ nvs_close(h);
1186
+ return err_obj_kind(ctx, "checksum mismatch (corrupt download)", OtaErr::kCorrupt);
1187
+ }
1188
+
1189
+ // Verified. Promote the staging file to the pending build.
1190
+ unlink(kPending);
1191
+ if (rename(kStaging, kPending) != 0) {
1192
+ nvs_close(h);
1193
+ return err_obj_kind(ctx, "stage pending build", OtaErr::kTransient);
1194
+ }
1195
+ g_stage_cap = 0;
1196
+ g_stage_have = 0;
1197
+ nvs_erase_key(h, kKeyStgChk);
1198
+ nvs_erase_key(h, kKeyStgSize);
1199
+ nvs_set_str(h, kKeyPendChk, want_chk);
1200
+ nvs_set_u8(h, kKeyTrialN, (uint8_t)trial_boots);
1201
+ nvs_set_u8(h, kKeyConfirm, require_confirm ? 1 : 0);
1202
+
1203
+ if (install_now) {
1204
+ const char* err = nullptr;
1205
+ OtaErr kind = OtaErr::kTransient;
1206
+ if (!install_build(kPending, &err, &kind)) {
1207
+ // The verified .tgz is already on flash. Leaving it with pending=0
1208
+ // would strand it there forever and let the next stageBegin build a
1209
+ // second staging file beside it.
1210
+ if (kind == OtaErr::kCorrupt) {
1211
+ nvs_set_u8(h, kKeyPending, 0); // identical bytes can't succeed
1212
+ unlink(kPending);
1213
+ } else {
1214
+ nvs_set_u8(h, kKeyPending, 1); // boot reconcile retries it
1215
+ nvs_set_u8(h, kKeyInstLeft, kInstallBudget);
1216
+ }
1217
+ nvs_commit(h);
1218
+ nvs_close(h);
1219
+ return err_obj_kind(ctx, err, kind);
1220
+ }
1221
+ // New app is live but unproven: enter the trial. The caller restarts.
1222
+ nvs_set_u8(h, kKeyState, kStateTrial);
1223
+ nvs_set_u8(h, kKeyTrialLeft, (uint8_t)trial_boots);
1224
+ nvs_set_u8(h, kKeyPending, 0);
1225
+ nvs_erase_key(h, kKeyPromLeft); // per-trial budgets
1226
+ nvs_erase_key(h, kKeyRbLeft);
1227
+ nvs_erase_key(h, kKeyNeutLeft);
1228
+ clear_trial_failure(h); // fresh trial: this build has not failed yet
1229
+ nvs_commit(h);
1230
+ nvs_close(h);
1231
+ return ok_obj(ctx);
1232
+ }
1233
+
1234
+ // Defer: install at the next boot on a clean heap.
1235
+ nvs_set_u8(h, kKeyPending, 1);
1236
+ nvs_set_u8(h, kKeyInstLeft, kInstallBudget);
1237
+ nvs_commit(h);
1238
+ nvs_close(h);
1239
+ return ok_obj(ctx);
1240
+ }
1241
+
1242
+ // stageAbort() -> void
1243
+ JSValue js_stage_abort(JSContext* ctx, JSValueConst, int, JSValueConst*) {
1244
+ stage_close();
1245
+ g_stage_cap = 0;
1246
+ g_stage_have = 0;
1247
+ unlink(kStaging);
1248
+ nvs_handle_t h;
1249
+ if (nvs_open(kNs, NVS_READWRITE, &h) == ESP_OK) {
1250
+ nvs_erase_key(h, kKeyStgChk);
1251
+ nvs_erase_key(h, kKeyStgSize);
1252
+ nvs_commit(h);
1253
+ nvs_close(h);
1254
+ }
1255
+ return JS_UNDEFINED;
1256
+ }
1257
+
1258
+ // markValid() -> void. Confirm the running trial: promote it to GOOD and make
1259
+ // the pending build the new revert target.
1260
+ JSValue js_mark_valid(JSContext* ctx, JSValueConst, int, JSValueConst*) {
1261
+ nvs_handle_t h;
1262
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) return JS_UNDEFINED;
1263
+ if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial) {
1264
+ // Stay in the trial if the promotion failed: going GOOD anyway would
1265
+ // point instChk at a build that has no .tgz left to revert to.
1266
+ if (promote_pending()) {
1267
+ char chk[96];
1268
+ if (nvs_str(h, kKeyPendChk, chk, sizeof(chk))) nvs_set_str(h, kKeyInstChk, chk);
1269
+ nvs_set_u8(h, kKeyState, kStateGood);
1270
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1271
+ nvs_set_u8(h, kKeyPending, 0);
1272
+ nvs_erase_key(h, kKeyPromLeft);
1273
+ clear_trial_failure(h);
1274
+ } else {
1275
+ record_diag(h, "promote-failed", "could not make the trial build the revert target");
1276
+ }
1277
+ nvs_commit(h);
1278
+ }
1279
+ nvs_close(h);
1280
+ return JS_UNDEFINED;
1281
+ }
1282
+
1283
+ // revert() -> { ok } | { ok:false, error }. Re-install the last-good build.
1284
+ JSValue js_revert(JSContext* ctx, JSValueConst, int, JSValueConst*) {
1285
+ if (!path_exists(kLastGood)) return err_obj(ctx, "no last-good build to revert to");
1286
+ const char* err = nullptr;
1287
+ OtaErr kind = OtaErr::kTransient;
1288
+ if (!install_build(kLastGood, &err, &kind)) return err_obj(ctx, err);
1289
+ nvs_handle_t h;
1290
+ if (nvs_open(kNs, NVS_READWRITE, &h) == ESP_OK) {
1291
+ nvs_set_u8(h, kKeyState, kStateGood);
1292
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1293
+ nvs_set_u8(h, kKeyPending, 0);
1294
+ clear_trial_failure(h);
1295
+ unlink(kPending);
1296
+ nvs_commit(h);
1297
+ nvs_close(h);
1298
+ }
1299
+ return ok_obj(ctx);
1300
+ }
1301
+
1302
+ // running() -> { checksum?, trial }
1303
+ JSValue js_running(JSContext* ctx, JSValueConst, int, JSValueConst*) {
1304
+ JSValue o = JS_NewObject(ctx);
1305
+ bool trial = false;
1306
+ char chk[96] = {0};
1307
+ nvs_handle_t h;
1308
+ if (nvs_open(kNs, NVS_READONLY, &h) == ESP_OK) {
1309
+ trial = nvs_u8(h, kKeyState, kStateGood) == kStateTrial;
1310
+ nvs_str(h, trial ? kKeyPendChk : kKeyInstChk, chk, sizeof(chk));
1311
+ nvs_close(h);
1312
+ }
1313
+ if (chk[0]) JS_SetPropertyStr(ctx, o, "checksum", JS_NewString(ctx, chk));
1314
+ JS_SetPropertyStr(ctx, o, "trial", JS_NewBool(ctx, trial));
1315
+ return o;
1316
+ }
1317
+
1318
+ // reconcile() -> { installed?, reverted, diagnostic? }. Returns the outcome the
1319
+ // boot-time reconcile recorded, then clears it so a second call reads empty.
1320
+ JSValue js_reconcile(JSContext* ctx, JSValueConst, int, JSValueConst*) {
1321
+ JSValue o = JS_NewObject(ctx);
1322
+ bool reverted = false;
1323
+ char installed[96] = {0};
1324
+ char reason[96] = {0};
1325
+ char detail[160] = {0};
1326
+
1327
+ nvs_handle_t h;
1328
+ if (nvs_open(kNs, NVS_READWRITE, &h) == ESP_OK) {
1329
+ reverted = nvs_u8(h, kKeyORevert, 0) != 0;
1330
+ nvs_str(h, kKeyOInst, installed, sizeof(installed));
1331
+ nvs_str(h, kKeyORsn, reason, sizeof(reason));
1332
+ nvs_str(h, kKeyODetail, detail, sizeof(detail));
1333
+ nvs_erase_key(h, kKeyOInst);
1334
+ nvs_erase_key(h, kKeyORevert);
1335
+ nvs_erase_key(h, kKeyORsn);
1336
+ nvs_erase_key(h, kKeyODetail);
1337
+ nvs_commit(h);
1338
+ nvs_close(h);
1339
+ }
1340
+
1341
+ if (installed[0]) JS_SetPropertyStr(ctx, o, "installed", JS_NewString(ctx, installed));
1342
+ JS_SetPropertyStr(ctx, o, "reverted", JS_NewBool(ctx, reverted));
1343
+ if (reason[0]) {
1344
+ JSValue diag = JS_NewObject(ctx);
1345
+ JS_SetPropertyStr(ctx, diag, "reason", JS_NewString(ctx, reason));
1346
+ if (detail[0]) JS_SetPropertyStr(ctx, diag, "detail", JS_NewString(ctx, detail));
1347
+ JS_SetPropertyStr(ctx, o, "diagnostic", diag);
1348
+ }
1349
+ return o;
1350
+ }
1351
+
1352
+ // ── module registration ──────────────────────────────────────────────────────
1353
+ int mik__ota_module_init(JSContext* ctx, JSModuleDef* m) {
1354
+ JS_SetModuleExport(ctx, m, "stageBegin",
1355
+ JS_NewCFunction(ctx, js_stage_begin, "stageBegin", 2));
1356
+ JS_SetModuleExport(ctx, m, "stageWrite",
1357
+ JS_NewCFunction(ctx, js_stage_write, "stageWrite", 1));
1358
+ JS_SetModuleExport(ctx, m, "stageFinish",
1359
+ JS_NewCFunction(ctx, js_stage_finish, "stageFinish", 3));
1360
+ JS_SetModuleExport(ctx, m, "stageAbort",
1361
+ JS_NewCFunction(ctx, js_stage_abort, "stageAbort", 0));
1362
+ JS_SetModuleExport(ctx, m, "markValid", JS_NewCFunction(ctx, js_mark_valid, "markValid", 0));
1363
+ JS_SetModuleExport(ctx, m, "revert", JS_NewCFunction(ctx, js_revert, "revert", 0));
1364
+ JS_SetModuleExport(ctx, m, "running", JS_NewCFunction(ctx, js_running, "running", 0));
1365
+ JS_SetModuleExport(ctx, m, "reconcile", JS_NewCFunction(ctx, js_reconcile, "reconcile", 0));
1366
+ return 0;
1367
+ }
1368
+
1369
+ JSModuleDef* mik__ota_init(JSContext* ctx) {
1370
+ JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/ota", mik__ota_module_init);
1371
+ if (!m) return nullptr;
1372
+ JS_AddModuleExport(ctx, m, "stageBegin");
1373
+ JS_AddModuleExport(ctx, m, "stageWrite");
1374
+ JS_AddModuleExport(ctx, m, "stageFinish");
1375
+ JS_AddModuleExport(ctx, m, "stageAbort");
1376
+ JS_AddModuleExport(ctx, m, "markValid");
1377
+ JS_AddModuleExport(ctx, m, "revert");
1378
+ JS_AddModuleExport(ctx, m, "running");
1379
+ JS_AddModuleExport(ctx, m, "reconcile");
1380
+ return m;
1381
+ }
1382
+
1383
+ // ── boot-time reconcile (trial state machine) ────────────────────────────────
1384
+
1385
+ // Map a reset reason to a trial verdict.
1386
+ enum class TrialVerdict { kCrash, kNeutral, kClean };
1387
+
1388
+ TrialVerdict classify_reset(esp_reset_reason_t r) {
1389
+ switch (r) {
1390
+ case ESP_RST_PANIC:
1391
+ case ESP_RST_INT_WDT:
1392
+ case ESP_RST_TASK_WDT:
1393
+ case ESP_RST_WDT:
1394
+ return TrialVerdict::kCrash;
1395
+ case ESP_RST_BROWNOUT:
1396
+ // Ambiguous: a bad supply, or the new build drawing more than the
1397
+ // board can deliver. Absorbed a bounded number of times (kNeutralBudget).
1398
+ return TrialVerdict::kNeutral;
1399
+ default: // ESP_RST_POWERON, ESP_RST_SW, ESP_RST_DEEPSLEEP, ESP_RST_EXT, ...
1400
+ // A cold start is an ordinary boot: nothing went wrong, and the app
1401
+ // is about to get its chance to run and confirm. Counting it as
1402
+ // neutral would let a mains device that only ever power-cycles sit
1403
+ // in an unresolved trial forever.
1404
+ return TrialVerdict::kClean;
1405
+ }
1406
+ }
1407
+
1408
+ const char* reset_reason_name(esp_reset_reason_t r) {
1409
+ switch (r) {
1410
+ case ESP_RST_PANIC:
1411
+ return "panic";
1412
+ case ESP_RST_INT_WDT:
1413
+ return "interrupt-watchdog";
1414
+ case ESP_RST_TASK_WDT:
1415
+ return "task-watchdog";
1416
+ case ESP_RST_WDT:
1417
+ return "watchdog";
1418
+ default:
1419
+ return "crash";
1420
+ }
1421
+ }
1422
+
1423
+ // Record the just-installed checksum so reconcile() can report it after the
1424
+ // post-install restart.
1425
+ void record_installed(nvs_handle_t h, const char* chk) {
1426
+ nvs_set_str(h, kKeyOInst, chk);
1427
+ }
1428
+
1429
+ void record_revert(nvs_handle_t h, const char* reason, const char* detail) {
1430
+ nvs_set_u8(h, kKeyORevert, 1);
1431
+ record_diag(h, reason, detail);
1432
+ }
1433
+
1434
+ // The trial build is still what /app holds, so make instChk name it. Leaving
1435
+ // instChk on the previous build would make running() report a checksum the
1436
+ // device is not running: the registry would never re-offer the build that is
1437
+ // actually live, and revert() would install the older build over the newer one.
1438
+ void keep_trial_as_installed(nvs_handle_t h) {
1439
+ char chk[96];
1440
+ if (nvs_str(h, kKeyPendChk, chk, sizeof(chk))) nvs_set_str(h, kKeyInstChk, chk);
1441
+ }
1442
+
1443
+ // Install the revert target, ending the trial. Both trial-failure paths funnel
1444
+ // here so the attempt budget is charged identically.
1445
+ //
1446
+ // The budget is charged and committed BEFORE the install, for the reason the
1447
+ // deferred-install path documents: install_build does not only return false, it
1448
+ // can panic (a littlefs assert, the task watchdog, an allocation the OOM handler
1449
+ // turns into a restart). Charging on the return paths alone means a rollback
1450
+ // that crashes mid-install leaves NVS untouched, so the next boot re-enters the
1451
+ // identical rollback and the budget is never reached -- a loop only a reflash
1452
+ // escapes, and one every device that took the bad build enters together.
1453
+ //
1454
+ // Returns false if the last-good build is not live, in which case the caller
1455
+ // keeps the trial build and must carry pendChk across.
1456
+ bool attempt_rollback(nvs_handle_t h, const char* reason, const char* detail) {
1457
+ if (!path_exists(kLastGood)) {
1458
+ record_revert(h, reason, "no rollback build available");
1459
+ return false;
1460
+ }
1461
+ uint8_t left = nvs_u8(h, kKeyRbLeft, kRollbackBudget);
1462
+ if (left == 0) {
1463
+ record_revert(h, reason, "rollback install failed repeatedly; keeping this build");
1464
+ return false;
1465
+ }
1466
+ nvs_set_u8(h, kKeyRbLeft, (uint8_t)(left - 1));
1467
+ nvs_commit(h);
1468
+
1469
+ const char* err = nullptr;
1470
+ OtaErr kind = OtaErr::kTransient;
1471
+ if (!install_build(kLastGood, &err, &kind)) {
1472
+ record_revert(h, reason, "rollback install failed");
1473
+ return false;
1474
+ }
1475
+ nvs_erase_key(h, kKeyRbLeft);
1476
+ record_revert(h, reason, detail);
1477
+ return true;
1478
+ }
1479
+
1480
+ } // namespace
1481
+
1482
+ // Adopt a streamed .tgz as the live app with NO trial (the deploy / baseline
1483
+ // path, distinct from an OTA download which runs a trial). Verifies the build
1484
+ // against `checksum`, copies it to the rollback slot first so it survives the
1485
+ // commit (install_build rmtrees the staging tree the source lives under),
1486
+ // installs it, then records it as the running good build. NOT JS-exposed —
1487
+ // called from the serial DEPLOY_BUILD handler.
1488
+ bool mik__ota_adopt_build(const char* tgz_path, const char* checksum, const char** err) {
1489
+ if (!path_exists(tgz_path)) {
1490
+ *err = "build not staged";
1491
+ return false;
1492
+ }
1493
+ // Verify SHA-256 before install so a corrupt upload can't become the
1494
+ // rollback baseline. Empty checksum skips the check.
1495
+ if (checksum && checksum[0]) {
1496
+ char got[65];
1497
+ if (!sha256_file(tgz_path, got)) {
1498
+ *err = "read staged build";
1499
+ return false;
1500
+ }
1501
+ if (strcmp(got, checksum) != 0) {
1502
+ *err = "checksum mismatch (corrupt build)";
1503
+ return false;
1504
+ }
1505
+ }
1506
+ // Install from a copy outside the staging tree: install_build commits by
1507
+ // rmtree-ing that tree, and the staged .tgz lives under it. Promote the copy
1508
+ // to the rollback slot only once the install succeeded -- overwriting
1509
+ // kLastGood up front would, on a failed install, leave the revert target
1510
+ // naming a build that is not installed and never was, so a later revert()
1511
+ // would roll *forward* onto it while reporting the old checksum.
1512
+ unlink(kLastGoodTmp);
1513
+ if (!copy_file(tgz_path, kLastGoodTmp)) {
1514
+ *err = "stage rollback baseline";
1515
+ return false;
1516
+ }
1517
+ OtaErr kind = OtaErr::kTransient;
1518
+ if (!install_build(kLastGoodTmp, err, &kind)) {
1519
+ unlink(kLastGoodTmp);
1520
+ return false;
1521
+ }
1522
+ unlink(kLastGood);
1523
+ if (rename(kLastGoodTmp, kLastGood) != 0) {
1524
+ // Installed and live, but with no revert target until the next adopt or
1525
+ // promote. Not worth failing the deploy over.
1526
+ unlink(kLastGoodTmp);
1527
+ }
1528
+ nvs_handle_t h;
1529
+ if (nvs_open(kNs, NVS_READWRITE, &h) == ESP_OK) {
1530
+ nvs_set_str(h, kKeyInstChk, checksum ? checksum : "");
1531
+ nvs_set_u8(h, kKeyState, kStateGood);
1532
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1533
+ nvs_set_u8(h, kKeyPending, 0);
1534
+ clear_trial_failure(h);
1535
+ unlink(kPending);
1536
+ nvs_erase_key(h, kKeyPendChk);
1537
+ nvs_commit(h);
1538
+ nvs_close(h);
1539
+ }
1540
+ return true;
1541
+ }
1542
+
1543
+ // True while an unconfirmed OTA trial is the running build.
1544
+ bool mik__ota_in_trial(void) {
1545
+ nvs_handle_t h;
1546
+ if (nvs_open(kNs, NVS_READONLY, &h) != ESP_OK) return false;
1547
+ bool trial = nvs_u8(h, kKeyState, kStateGood) == kStateTrial;
1548
+ nvs_close(h);
1549
+ return trial;
1550
+ }
1551
+
1552
+ // Flag the running trial as failed after a fatal JS error, so the next reconcile
1553
+ // reverts it even though the reboot will look like a clean software reset. No-op
1554
+ // outside a trial; the first detail recorded wins.
1555
+ void mik__ota_note_trial_failure(const char* detail) {
1556
+ nvs_handle_t h;
1557
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) return;
1558
+ if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial && nvs_u8(h, kKeyTrialBad, 0) == 0) {
1559
+ nvs_set_u8(h, kKeyTrialBad, 1);
1560
+ if (detail && detail[0]) nvs_set_str(h, kKeyBadDetail, detail);
1561
+ nvs_commit(h);
1562
+ }
1563
+ nvs_close(h);
1564
+ }
1565
+
1566
+ // Boot reconcile: NOT JS-exposed. Called from MIK_Main() before the JS app
1567
+ // loads, after MIK_DeployRecover(). Runs the reflash guard, the trial verdict,
1568
+ // and a deferred install in that order.
1569
+ void mik__ota_boot_reconcile(void) {
1570
+ nvs_handle_t h;
1571
+ if (nvs_open(kNs, NVS_READWRITE, &h) != ESP_OK) return;
1572
+
1573
+ // An adopt that died between copying its install source and promoting it to
1574
+ // the rollback slot leaves this behind. It is never valid across a boot, and
1575
+ // it is a whole build's worth of flash.
1576
+ unlink(kLastGoodTmp);
1577
+
1578
+ // 1. Reflash guard. An out-of-band `idf.py flash` swaps the firmware without
1579
+ // touching OTA state; a stale pending/staged build must not then revert
1580
+ // or overwrite the freshly-flashed /app. Detect via the running app's ELF
1581
+ // SHA-256 and scrub OTA install state when it changed.
1582
+ const esp_app_desc_t* desc = esp_app_get_description();
1583
+ char fw_now[65];
1584
+ static const char* H = "0123456789abcdef";
1585
+ for (int i = 0; i < 32; i++) {
1586
+ fw_now[i * 2] = H[desc->app_elf_sha256[i] >> 4];
1587
+ fw_now[i * 2 + 1] = H[desc->app_elf_sha256[i] & 0xf];
1588
+ }
1589
+ fw_now[64] = 0;
1590
+ char fw_stored[65];
1591
+ bool have_fw = nvs_str(h, kKeyFwHash, fw_stored, sizeof(fw_stored));
1592
+ if (!have_fw || strcmp(fw_stored, fw_now) != 0) {
1593
+ // A firmware flash leaves /appfs alone, so if a trial was in flight the
1594
+ // trial build is what /app still holds. Forcing GOOD without moving
1595
+ // pendChk across would leave instChk naming the previous build: the
1596
+ // device would report the wrong checksum to the registry forever, and
1597
+ // revert() would install that older build over the newer live one.
1598
+ if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial) {
1599
+ char pend_chk[96];
1600
+ if (nvs_str(h, kKeyPendChk, pend_chk, sizeof(pend_chk))) {
1601
+ nvs_set_str(h, kKeyInstChk, pend_chk);
1602
+ }
1603
+ }
1604
+ nvs_erase_key(h, kKeyPendChk);
1605
+ nvs_set_u8(h, kKeyState, kStateGood);
1606
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1607
+ nvs_set_u8(h, kKeyPending, 0);
1608
+ unlink(kPending);
1609
+ unlink(kStaging);
1610
+ nvs_erase_key(h, kKeyStgChk);
1611
+ nvs_erase_key(h, kKeyStgSize);
1612
+ nvs_set_str(h, kKeyFwHash, fw_now);
1613
+ nvs_commit(h);
1614
+ }
1615
+
1616
+ // 2. Trial verdict. Evaluate why we just reset while a trial was in flight.
1617
+ if (nvs_u8(h, kKeyState, kStateGood) == kStateTrial) {
1618
+ esp_reset_reason_t reason = esp_reset_reason();
1619
+ TrialVerdict verdict = classify_reset(reason);
1620
+ // A fatal JS error the trial app flagged in-process (a top-level throw or
1621
+ // an unhandled rejection) reboots as a clean software reset, which would
1622
+ // otherwise be read as "survived". Treat it like a crash.
1623
+ bool js_failed = nvs_u8(h, kKeyTrialBad, 0) != 0;
1624
+ if (js_failed || verdict == TrialVerdict::kCrash) {
1625
+ // The new app failed: roll back to last-good immediately, bypassing
1626
+ // the counter. If no rollback build exists (first-ever OTA), we
1627
+ // can't reinstall — stop the trial loop and record the diagnostic so
1628
+ // the app can surface it (the deploy app remains serial-recoverable).
1629
+ char detail[160];
1630
+ const char* rsn;
1631
+ const char* why;
1632
+ if (js_failed) {
1633
+ rsn = "startup-crash";
1634
+ if (!nvs_str(h, kKeyBadDetail, detail, sizeof(detail))) {
1635
+ snprintf(detail, sizeof(detail), "trial app threw at startup");
1636
+ }
1637
+ why = detail;
1638
+ } else {
1639
+ rsn = reset_reason_name(reason);
1640
+ why = "reverted to last-good build";
1641
+ }
1642
+ if (!attempt_rollback(h, rsn, why)) keep_trial_as_installed(h);
1643
+ nvs_set_u8(h, kKeyState, kStateGood);
1644
+ nvs_set_u8(h, kKeyTrialLeft, 0);
1645
+ nvs_set_u8(h, kKeyPending, 0);
1646
+ nvs_erase_key(h, kKeyPendChk);
1647
+ nvs_erase_key(h, kKeyTrialBad);
1648
+ nvs_erase_key(h, kKeyBadDetail);
1649
+ unlink(kPending);
1650
+ nvs_commit(h);
1651
+ nvs_close(h);
1652
+ return;
1653
+ }
1654
+ if (verdict == TrialVerdict::kNeutral) {
1655
+ uint8_t left = nvs_u8(h, kKeyNeutLeft, kNeutralBudget);
1656
+ if (left > 0) {
1657
+ nvs_set_u8(h, kKeyNeutLeft, (uint8_t)(left - 1));
1658
+ nvs_commit(h);
1659
+ } else {
1660
+ // Budget spent: stop absorbing and let the trial progress on the
1661
+ // clean path, so it reaches a verdict instead of staying open.
1662
+ verdict = TrialVerdict::kClean;
1663
+ }
1664
+ }
1665
+ if (verdict == TrialVerdict::kClean) {
1666
+ uint8_t left = nvs_u8(h, kKeyTrialLeft, 0);
1667
+ bool confirm = nvs_u8(h, kKeyConfirm, 0) != 0;
1668
+ if (left == 0) {
1669
+ if (confirm) {
1670
+ // Trial elapsed without an explicit markValid(): roll back.
1671
+ if (!attempt_rollback(h, "unconfirmed",
1672
+ "trial elapsed without markValid()")) {
1673
+ keep_trial_as_installed(h);
1674
+ }
1675
+ nvs_set_u8(h, kKeyState, kStateGood);
1676
+ nvs_set_u8(h, kKeyPending, 0);
1677
+ nvs_erase_key(h, kKeyPendChk);
1678
+ unlink(kPending);
1679
+ } else if (promote_pending()) {
1680
+ // Promote: the trial proved stable.
1681
+ char chk[96];
1682
+ if (nvs_str(h, kKeyPendChk, chk, sizeof(chk))) nvs_set_str(h, kKeyInstChk, chk);
1683
+ nvs_set_u8(h, kKeyState, kStateGood);
1684
+ nvs_erase_key(h, kKeyPromLeft);
1685
+ } else if (uint8_t prom = nvs_u8(h, kKeyPromLeft, kPromoteBudget); prom > 1) {
1686
+ // Stay in the trial rather than go GOOD with no revert
1687
+ // target; the next clean boot retries the promotion.
1688
+ nvs_set_u8(h, kKeyPromLeft, (uint8_t)(prom - 1));
1689
+ record_diag(h, "promote-failed",
1690
+ "could not make the trial build the revert target");
1691
+ } else {
1692
+ // Budget spent, so the failure is not transient (kPending
1693
+ // lost, or a filesystem that will not take the rename).
1694
+ // Resolve to GOOD with no revert target. That is the lesser
1695
+ // loss: the build has survived its whole trial and is
1696
+ // running, whereas staying in TRIAL makes applyOffer skip
1697
+ // every future offer, so the one channel that could ship a
1698
+ // fix is shut. The next update re-establishes a baseline.
1699
+ char chk[96];
1700
+ if (nvs_str(h, kKeyPendChk, chk, sizeof(chk))) nvs_set_str(h, kKeyInstChk, chk);
1701
+ nvs_set_u8(h, kKeyState, kStateGood);
1702
+ nvs_set_u8(h, kKeyPending, 0);
1703
+ nvs_erase_key(h, kKeyPromLeft);
1704
+ unlink(kPending);
1705
+ record_diag(h, "promote-failed",
1706
+ "gave up making the trial build the revert target; "
1707
+ "this build is kept but cannot be rolled back");
1708
+ }
1709
+ nvs_commit(h);
1710
+ } else {
1711
+ nvs_set_u8(h, kKeyTrialLeft, (uint8_t)(left - 1));
1712
+ nvs_commit(h);
1713
+ }
1714
+ }
1715
+ // kNeutral: don't penalize — leave the trial untouched and proceed.
1716
+ }
1717
+
1718
+ // 3. Deferred install. A build verified by stageFinish(installNow=false) is
1719
+ // installed here, on a clean heap, then we restart into its trial.
1720
+ if (nvs_u8(h, kKeyPending, 0) == 1 && nvs_u8(h, kKeyState, kStateGood) == kStateGood) {
1721
+ if (!path_exists(kPending)) {
1722
+ nvs_set_u8(h, kKeyPending, 0);
1723
+ nvs_commit(h);
1724
+ nvs_close(h);
1725
+ return;
1726
+ }
1727
+ // Charge the attempt BEFORE making it, and commit, so the budget
1728
+ // survives a boot that never comes back. install_build does not only
1729
+ // return false: it can panic (a littlefs assert, the task watchdog, an
1730
+ // allocation the OOM handler turns into a restart). Decrementing on the
1731
+ // return paths alone means a build that crashes mid-install leaves NVS
1732
+ // untouched, so the next boot re-enters the identical install and the
1733
+ // budget is never reached — a fleet-wide loop only a reflash escapes.
1734
+ // Same reasoning rmtree's depth cap is documented with.
1735
+ uint8_t inst_left = nvs_u8(h, kKeyInstLeft, kInstallBudget);
1736
+ if (inst_left == 0) {
1737
+ nvs_set_u8(h, kKeyPending, 0);
1738
+ unlink(kPending);
1739
+ nvs_commit(h);
1740
+ nvs_close(h);
1741
+ return;
1742
+ }
1743
+ nvs_set_u8(h, kKeyInstLeft, (uint8_t)(inst_left - 1));
1744
+ nvs_commit(h);
1745
+
1746
+ const char* err = nullptr;
1747
+ OtaErr kind = OtaErr::kTransient;
1748
+ if (install_build(kPending, &err, &kind)) {
1749
+ char chk[96] = {0};
1750
+ nvs_str(h, kKeyPendChk, chk, sizeof(chk));
1751
+ record_installed(h, chk);
1752
+ uint8_t trial_n = nvs_u8(h, kKeyTrialN, 1);
1753
+ nvs_set_u8(h, kKeyState, kStateTrial);
1754
+ nvs_set_u8(h, kKeyTrialLeft, trial_n);
1755
+ nvs_set_u8(h, kKeyPending, 0);
1756
+ nvs_erase_key(h, kKeyPromLeft); // per-trial budgets
1757
+ nvs_erase_key(h, kKeyRbLeft);
1758
+ nvs_erase_key(h, kKeyNeutLeft);
1759
+ nvs_set_u8(h, kKeyTrialBad, 0); // fresh trial: clear any prior crash flag
1760
+ nvs_erase_key(h, kKeyBadDetail);
1761
+ nvs_commit(h);
1762
+ nvs_close(h);
1763
+ esp_restart(); // boot into the freshly-installed app on a clean heap
1764
+ return;
1765
+ }
1766
+ // Recorded like every other failure, and for a reason this path needs
1767
+ // more than most: the diagnostic is what reaches the registry as
1768
+ // `lastInstall`, and that is what stops the same build being offered
1769
+ // again. Without it a build that verifies but will not unpack is
1770
+ // re-downloaded and rewritten to flash on every boot, forever, since
1771
+ // nothing on either side learns it failed. `install: 'next-boot'` is
1772
+ // the default, so this is the ordinary path, not a corner.
1773
+ record_diag(h, kind == OtaErr::kCorrupt ? "install-corrupt" : "install-failed",
1774
+ err != nullptr ? err : "deferred install failed");
1775
+ // The attempt is already charged above; this only decides whether to
1776
+ // keep the build for another boot.
1777
+ if (kind == OtaErr::kCorrupt) {
1778
+ nvs_set_u8(h, kKeyPending, 0); // identical bytes can't succeed — abandon
1779
+ unlink(kPending);
1780
+ } else if (inst_left <= 1) {
1781
+ nvs_set_u8(h, kKeyPending, 0); // budget exhausted — abandon
1782
+ unlink(kPending);
1783
+ }
1784
+ nvs_commit(h);
1785
+ }
1786
+ nvs_close(h);
1787
+ }
1788
+
1789
+ MIK_REGISTER_MODULE(ota, "native:mikro/ota", mik__ota_init, nullptr, nullptr)
1790
+
1791
+ #endif // MIK_OTA_HOST_TEST