@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.
- package/components/mikrojs/CMakeLists.txt +9 -2
- package/components/mikrojs/include/mikrojs_esp32.h +23 -0
- package/components/mikrojs/mik_config.cpp +159 -5
- package/components/mikrojs/mik_deploy.cpp +54 -35
- package/components/mikrojs/mik_main.cpp +66 -5
- package/components/mikrojs/mik_nvs_kv.cpp +64 -18
- package/components/mikrojs/mik_ota.cpp +1791 -0
- package/components/mikrojs/platform_esp32.cpp +59 -0
- package/components/mikrojs/test/CMakeLists.txt +1 -0
- package/components/mikrojs/test/ble_test.cpp +2 -2
- package/components/mikrojs/test/fs_js_test.cpp +1 -1
- package/components/mikrojs/test/fs_pub_test.cpp +23 -23
- package/components/mikrojs/test/http_server_test.cpp +12 -2
- package/components/mikrojs/test/http_test.cpp +2 -2
- package/components/mikrojs/test/modules_extended_test.cpp +2 -2
- package/components/mikrojs/test/modules_test.cpp +2 -2
- package/components/mikrojs/test/nvs_kv_test.cpp +245 -0
- package/components/mikrojs/test/ota_host/gunzip_host_test.cpp +118 -0
- package/components/mikrojs/test/ota_host/run.sh +174 -0
- package/components/mikrojs/test/rtc_test.cpp +1 -1
- package/components/mikrojs/test/sleep_test.cpp +1 -1
- package/components/mikrojs/test/sntp_test.cpp +1 -1
- package/components/mikrojs/test/stdio_test.cpp +1 -1
- package/components/mikrojs/test/sys_test.cpp +1 -1
- package/components/mikrojs/test/udp_test.cpp +5 -5
- package/components/mikrojs/test/wifi_test.cpp +2 -2
- package/package.json +3 -3
- package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32/mikrojs.bin +0 -0
- package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c3/mikrojs.bin +0 -0
- package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c5/mikrojs.bin +0 -0
- package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
- package/prebuilds/esp32c6/mikrojs.bin +0 -0
- package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
- 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
|