@mikrojs/native 0.18.0 → 0.18.1

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 (68) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +2 -1
  27. package/include/mikrojs/ota_client.h +342 -0
  28. package/include/mikrojs/ota_config.h +100 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/sys_codec.h +61 -0
  34. package/package.json +7 -5
  35. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  36. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  38. package/runtime/internal.d.ts +22 -16
  39. package/runtime/kv/shared.ts +11 -5
  40. package/runtime/kv/types.ts +4 -4
  41. package/runtime/ota/client.ts +12 -51
  42. package/runtime/ota/config.ts +18 -0
  43. package/runtime/ota/ota.ts +28 -70
  44. package/runtime/ota/types.ts +220 -2
  45. package/runtime/schema/core.ts +539 -0
  46. package/runtime/schema/schema.ts +36 -314
  47. package/runtime/schema/shared.ts +494 -0
  48. package/runtime/schema/types.ts +84 -12
  49. package/scripts/bundle-runtime.js +33 -0
  50. package/scripts/gen-checkin-fixtures.js +323 -0
  51. package/src/builtins.cpp +7 -8
  52. package/src/fs.cpp +3 -0
  53. package/src/mem.cpp +38 -0
  54. package/src/mik_abort.cpp +8 -1
  55. package/src/mik_cbor.cpp +43 -5
  56. package/src/mik_inspect.cpp +128 -22
  57. package/src/mik_ota_client.cpp +1230 -0
  58. package/src/mik_ota_config.cpp +296 -0
  59. package/src/mik_ota_js_hooks.cpp +190 -0
  60. package/src/mik_ota_policy.cpp +419 -0
  61. package/src/mik_ota_slots.cpp +249 -0
  62. package/src/mik_repl.cpp +9 -3
  63. package/src/mik_result.cpp +3 -1
  64. package/src/mik_sys_codec.cpp +167 -0
  65. package/src/mikrojs.cpp +15 -0
  66. package/src/modules.cpp +32 -13
  67. package/runtime/ota/client-impl.ts +0 -590
  68. package/runtime/ota/policy.ts +0 -299
@@ -0,0 +1,419 @@
1
+ #include "mikrojs/ota_policy.h"
2
+
3
+ #include <cstdio>
4
+ #include <cstring>
5
+ #include <memory>
6
+ #include <string>
7
+
8
+ #include "mikrojs/platform.h"
9
+
10
+ namespace mikrojs {
11
+
12
+ /* Staging attempts for one url before a checksum is abandoned. */
13
+ constexpr int32_t MIK__OTA_MAX_TRIES = 3;
14
+
15
+ const char* mik__ota_outcome_to_str(MIKOtaApplyOutcome outcome) {
16
+ switch (outcome) {
17
+ case MIKOtaApplyOutcome::kStaged:
18
+ return "staged";
19
+ case MIKOtaApplyOutcome::kTrialPending:
20
+ return "trial-pending";
21
+ case MIKOtaApplyOutcome::kCurrent:
22
+ return "current";
23
+ case MIKOtaApplyOutcome::kAbandoned:
24
+ return "abandoned";
25
+ case MIKOtaApplyOutcome::kExhausted:
26
+ return "exhausted";
27
+ }
28
+ return "unknown";
29
+ }
30
+
31
+ // ── MIKOtaStore ──────────────────────────────────────────────────────────────
32
+
33
+ namespace {
34
+
35
+ /* FNV-1a, rendered as hex. Identity for a cache key, not a security property:
36
+ * the worst a collision does is deny one build a fresh retry budget. */
37
+ std::string url_digest(const std::string& url) {
38
+ uint64_t hash = 1469598103934665603ull;
39
+ for (unsigned char c : url) {
40
+ hash ^= c;
41
+ hash *= 1099511628211ull;
42
+ }
43
+ char out[17];
44
+ snprintf(out, sizeof(out), "%016llx", static_cast<unsigned long long>(hash));
45
+ return out;
46
+ }
47
+
48
+ } // namespace
49
+
50
+ bool MIKOtaStore::UrlMatches(const std::string& url) const {
51
+ if (!env_ || !env_->kv_get_str) return false;
52
+ char buf[17] = {};
53
+ if (!env_->kv_get_str(env_->opaque, "ota.url", buf, sizeof(buf))) return false;
54
+ return url_digest(url) == buf;
55
+ }
56
+
57
+ void MIKOtaStore::SetUrl(const std::string& url) {
58
+ if (!env_ || !env_->kv_set_str) return;
59
+ env_->kv_set_str(env_->opaque, "ota.url", url_digest(url).c_str());
60
+ }
61
+
62
+ std::string MIKOtaStore::GetAttempt() const {
63
+ if (!env_ || !env_->kv_get_str) return "";
64
+ char buf[128] = {};
65
+ if (env_->kv_get_str(env_->opaque, "ota.att", buf, sizeof(buf))) {
66
+ return buf;
67
+ }
68
+ return "";
69
+ }
70
+
71
+ void MIKOtaStore::SetAttempt(const std::string& checksum) {
72
+ if (!env_ || !env_->kv_set_str) return;
73
+ env_->kv_set_str(env_->opaque, "ota.att", checksum.c_str());
74
+ }
75
+
76
+ int32_t MIKOtaStore::GetTries() const {
77
+ if (!env_ || !env_->kv_get_i32) return 0;
78
+ int32_t val = 0;
79
+ if (env_->kv_get_i32(env_->opaque, "ota.tries", &val)) {
80
+ return val;
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ void MIKOtaStore::SetTries(int32_t n) {
86
+ if (!env_ || !env_->kv_set_i32) return;
87
+ env_->kv_set_i32(env_->opaque, "ota.tries", n);
88
+ }
89
+
90
+ std::string MIKOtaStore::GetBad() const {
91
+ if (!env_ || !env_->kv_get_str) return "";
92
+ char buf[128] = {};
93
+ if (env_->kv_get_str(env_->opaque, "ota.bad", buf, sizeof(buf))) {
94
+ return buf;
95
+ }
96
+ return "";
97
+ }
98
+
99
+ void MIKOtaStore::SetBad(const std::string& checksum) {
100
+ if (!env_ || !env_->kv_set_str) return;
101
+ env_->kv_set_str(env_->opaque, "ota.bad", checksum.c_str());
102
+ }
103
+
104
+ bool MIKOtaStore::GetInFlight() const {
105
+ if (!env_ || !env_->kv_get_i32) return false;
106
+ int32_t val = 0;
107
+ if (env_->kv_get_i32(env_->opaque, "ota.inflight", &val)) {
108
+ return val == 1;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ void MIKOtaStore::SetInFlight(bool in_flight) {
114
+ if (!env_ || !env_->kv_set_i32) return;
115
+ env_->kv_set_i32(env_->opaque, "ota.inflight", in_flight ? 1 : 0);
116
+ }
117
+
118
+ // ── Offer parsing ────────────────────────────────────────────────────────────
119
+
120
+ bool mik__ota_parse_offer(const char* url, const char* checksum, int64_t size, bool allow_insecure,
121
+ MIKOtaOffer* out_offer, std::string* out_warn_reason) {
122
+ if (!url) return false;
123
+
124
+ std::string s_url = url;
125
+ bool is_https = s_url.rfind("https://", 0) == 0;
126
+ bool is_http = s_url.rfind("http://", 0) == 0;
127
+ bool scheme_ok = is_https || (allow_insecure && is_http);
128
+
129
+ size_t query_pos = s_url.find_first_of("?#");
130
+ std::string path = (query_pos == std::string::npos) ? s_url : s_url.substr(0, query_pos);
131
+
132
+ bool ends_with_tgz = path.size() >= 4 && path.compare(path.size() - 4, 4, ".tgz") == 0;
133
+
134
+ if (!scheme_ok || !ends_with_tgz) {
135
+ if (out_warn_reason) {
136
+ *out_warn_reason =
137
+ allow_insecure ? "url must be an http(s) .tgz" : "url must be an https .tgz";
138
+ }
139
+ return false;
140
+ }
141
+
142
+ if (!checksum || strlen(checksum) == 0) {
143
+ if (out_warn_reason) {
144
+ *out_warn_reason = "missing checksum";
145
+ }
146
+ return false;
147
+ }
148
+
149
+ if (size <= 0) {
150
+ if (out_warn_reason) {
151
+ *out_warn_reason = "invalid size";
152
+ }
153
+ return false;
154
+ }
155
+
156
+ if (out_offer) {
157
+ out_offer->url = s_url;
158
+ out_offer->checksum = checksum;
159
+ out_offer->size = static_cast<size_t>(size);
160
+ }
161
+ return true;
162
+ }
163
+
164
+ // ── Policy Operations ────────────────────────────────────────────────────────
165
+
166
+ MIKOtaReconcileOutcome mik__ota_policy_reconcile(const MIKOtaEnv* env) {
167
+ MIKOtaStore store(env);
168
+ if (store.GetInFlight()) {
169
+ store.SetInFlight(false);
170
+ } else {
171
+ store.SetTries(0);
172
+ }
173
+ MIKOtaReconcileOutcome outcome = {};
174
+ if (env && env->reconcile) {
175
+ env->reconcile(env->opaque, &outcome);
176
+ }
177
+ return outcome;
178
+ }
179
+
180
+ MIKOtaRunningBuild mik__ota_policy_running(const MIKOtaEnv* env) {
181
+ MIKOtaRunningBuild run = {};
182
+ if (env && env->running) {
183
+ env->running(env->opaque, &run);
184
+ }
185
+ if (strlen(run.version) == 0 && env && env->read_app_version) {
186
+ env->read_app_version(env->opaque, run.version, sizeof(run.version));
187
+ }
188
+ return run;
189
+ }
190
+
191
+ bool mik__ota_policy_revert(const MIKOtaEnv* env, MIKOtaError* out_err) {
192
+ char err_buf[256] = {};
193
+ if (env && env->revert && env->revert(env->opaque, err_buf, sizeof(err_buf))) {
194
+ return true;
195
+ }
196
+ if (out_err) {
197
+ out_err->name = "InstallFailed";
198
+ out_err->kind = "transient";
199
+ out_err->message = err_buf[0] ? err_buf : "revert failed";
200
+ }
201
+ return false;
202
+ }
203
+
204
+ void mik__ota_policy_confirm(const MIKOtaEnv* env) {
205
+ if (env && env->mark_valid) {
206
+ env->mark_valid(env->opaque);
207
+ }
208
+ }
209
+
210
+ namespace {
211
+
212
+ /* The staging session handed to the download pump. Bounds every write against
213
+ * the offered size, counting bytes already on flash from a resumed transfer. */
214
+ class UpdateImpl : public MIKOtaUpdate {
215
+ public:
216
+ UpdateImpl(const MIKOtaEnv* env, size_t size, size_t resume_offset)
217
+ : env_(env), size_(size), resume_offset_(resume_offset), written_(resume_offset) {}
218
+
219
+ size_t resume_offset() const override { return resume_offset_; }
220
+
221
+ bool write(const uint8_t* bytes, size_t len, MIKOtaError* out_err) override {
222
+ if (written_ + len > size_) {
223
+ if (out_err) {
224
+ out_err->name = "TooLarge";
225
+ char buf[128];
226
+ snprintf(buf, sizeof(buf), "build exceeds offered size %zu", size_);
227
+ out_err->message = buf;
228
+ }
229
+ return false;
230
+ }
231
+ char eb[256] = {};
232
+ if (!env_ || !env_->stage_write ||
233
+ !env_->stage_write(env_->opaque, bytes, len, eb, sizeof(eb))) {
234
+ if (out_err) {
235
+ out_err->name = "StagingFull";
236
+ out_err->message = eb[0] ? eb : "staging full";
237
+ }
238
+ return false;
239
+ }
240
+ written_ += len;
241
+ return true;
242
+ }
243
+
244
+ bool finish(const MIKOtaInstallOptions& opts, MIKOtaError* out_err) override {
245
+ char eb[256] = {};
246
+ int err_kind = 0;
247
+ if (!env_ || !env_->stage_finish ||
248
+ !env_->stage_finish(env_->opaque, opts.trial_boots, opts.require_confirm,
249
+ opts.install_now, eb, sizeof(eb), &err_kind)) {
250
+ if (out_err) {
251
+ out_err->name = "InstallFailed";
252
+ out_err->kind = (err_kind == MIK_OTA_ERR_CORRUPT) ? "corrupt"
253
+ : (err_kind == MIK_OTA_ERR_OOM) ? "oom"
254
+ : "transient";
255
+ out_err->message = eb[0] ? eb : "install failed";
256
+ }
257
+ return false;
258
+ }
259
+ return true;
260
+ }
261
+
262
+ void abort() override {
263
+ if (env_ && env_->stage_abort) {
264
+ env_->stage_abort(env_->opaque);
265
+ }
266
+ }
267
+
268
+ private:
269
+ const MIKOtaEnv* env_;
270
+ size_t size_;
271
+ size_t resume_offset_;
272
+ size_t written_;
273
+ };
274
+
275
+ } // namespace
276
+
277
+ bool mik__ota_policy_apply_begin(const MIKOtaEnv* env, const MIKOtaOffer& offer,
278
+ MIKOtaApplySession* session, MIKOtaApplyOutcome* out_outcome,
279
+ MIKOtaError* out_err) {
280
+ session->env = env;
281
+ session->checksum = offer.checksum;
282
+ session->update.reset();
283
+
284
+ MIKOtaRunningBuild run = {};
285
+ if (env && env->running) {
286
+ env->running(env->opaque, &run);
287
+ }
288
+
289
+ // (a) a trial is unresolved. Reported distinctly because the caller's
290
+ // response differs from every other skip: the build on trial still needs
291
+ // confirming, and treating this like "nothing to do" lets the trial lapse
292
+ // and roll back a healthy build just because a newer one was published.
293
+ if (run.trial) {
294
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kTrialPending;
295
+ return true;
296
+ }
297
+
298
+ // (b) already running this build
299
+ if (!offer.checksum.empty() && offer.checksum == run.checksum) {
300
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kCurrent;
301
+ return true;
302
+ }
303
+
304
+ // (d) device has given up on this build
305
+ MIKOtaStore store(env);
306
+ if (!offer.checksum.empty() && offer.checksum == store.GetBad()) {
307
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kAbandoned;
308
+ return true;
309
+ }
310
+
311
+ // (e) retry budget, keyed on url *and* checksum. Exhausting it only stops
312
+ // attempts against this exact build at this exact url; the checksum is not
313
+ // abandoned, because everything counted here is transient by construction
314
+ // (a corrupt build is abandoned in apply_end instead).
315
+ if (!store.UrlMatches(offer.url) || offer.checksum != store.GetAttempt()) {
316
+ store.SetUrl(offer.url);
317
+ store.SetAttempt(offer.checksum);
318
+ store.SetTries(0);
319
+ }
320
+ int32_t tries = store.GetTries();
321
+ if (tries >= MIK__OTA_MAX_TRIES) {
322
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kExhausted;
323
+ return true;
324
+ }
325
+
326
+ // (f) bump before the attempt, so an attempt that crashes the device still
327
+ // counts — the in-flight flag is what carries that across the reboot (see
328
+ // mik__ota_policy_reconcile). apply_end clears it on every ordinary exit,
329
+ // and deliberately never runs when the attempt takes the device down.
330
+ store.SetTries(tries + 1);
331
+ store.SetInFlight(true);
332
+
333
+ // (g) open staging
334
+ size_t resume_offset = 0;
335
+ char err_buf[256] = {};
336
+ if (!env || !env->stage_begin ||
337
+ !env->stage_begin(env->opaque, offer.checksum.c_str(), offer.size, &resume_offset, err_buf,
338
+ sizeof(err_buf))) {
339
+ store.SetInFlight(false);
340
+ if (out_err) {
341
+ out_err->name = "StagingFailed";
342
+ out_err->message = err_buf[0] ? err_buf : "staging failed";
343
+ }
344
+ return false;
345
+ }
346
+
347
+ session->update = std::make_unique<UpdateImpl>(env, offer.size, resume_offset);
348
+ return true;
349
+ }
350
+
351
+ bool mik__ota_policy_apply_end(MIKOtaApplySession* session, bool download_ok,
352
+ const std::string& download_message,
353
+ const MIKOtaInstallOptions& options,
354
+ MIKOtaApplyOutcome* out_outcome, MIKOtaError* out_err) {
355
+ MIKOtaStore store(session->env);
356
+ MIKOtaUpdate* update = session->update.get();
357
+ struct Closer {
358
+ MIKOtaApplySession* s;
359
+ MIKOtaStore& store;
360
+ ~Closer() {
361
+ s->update.reset();
362
+ store.SetInFlight(false);
363
+ }
364
+ } closer{session, store};
365
+
366
+ if (!update) {
367
+ if (out_err) {
368
+ out_err->name = "StagingFailed";
369
+ out_err->message = "no staging session";
370
+ }
371
+ return false;
372
+ }
373
+
374
+ // A failure in the download is transient whatever tripped it: keep the
375
+ // bumped tries so it retries next time, and do NOT abandon the checksum
376
+ // (that is only for a corrupt build). The pump's own error name — a
377
+ // TooLarge or StagingFull from a write — is folded into the message, so
378
+ // the caller can key on DownloadFailed alone.
379
+ if (!download_ok) {
380
+ update->abort();
381
+ if (out_err) {
382
+ out_err->name = "DownloadFailed";
383
+ out_err->kind.clear();
384
+ out_err->message = download_message;
385
+ }
386
+ return false;
387
+ }
388
+
389
+ // A corrupt build is the one thing worth abandoning: the same bytes will
390
+ // fail identically forever. Abort first, or the verified-bad staging file
391
+ // (up to the whole build) sits on the app partition until some later offer
392
+ // happens to reclaim it.
393
+ if (!update->finish(options, out_err)) {
394
+ if (out_err && out_err->name == "InstallFailed" && out_err->kind == "corrupt") {
395
+ update->abort();
396
+ store.SetBad(session->checksum);
397
+ }
398
+ return false;
399
+ }
400
+
401
+ store.SetTries(0);
402
+ if (out_outcome) *out_outcome = MIKOtaApplyOutcome::kStaged;
403
+ return true;
404
+ }
405
+
406
+ bool mik__ota_policy_apply_offer(const MIKOtaEnv* env, const MIKOtaOffer& offer,
407
+ MIKOtaDownloadFn download, const MIKOtaInstallOptions& options,
408
+ MIKOtaApplyOutcome* out_outcome, MIKOtaError* out_err) {
409
+ MIKOtaApplySession session;
410
+ if (!mik__ota_policy_apply_begin(env, offer, &session, out_outcome, out_err)) return false;
411
+ if (!session.update) return true; // declined before staging
412
+
413
+ MIKOtaError dl_err;
414
+ bool downloaded = download(*session.update, &dl_err);
415
+ std::string message = dl_err.message.empty() ? "download failed" : dl_err.message;
416
+ return mik__ota_policy_apply_end(&session, downloaded, message, options, out_outcome, out_err);
417
+ }
418
+
419
+ } // namespace mikrojs
@@ -0,0 +1,249 @@
1
+ #include "mikrojs/ota_slots.h"
2
+
3
+ #include <nanocbor/nanocbor.h>
4
+
5
+ #include <cstring>
6
+ #include <string>
7
+
8
+ #include "mikrojs/cbor_helpers.h"
9
+
10
+ namespace mikrojs {
11
+
12
+ namespace {
13
+
14
+ const char* slot_key(MIKOtaConfigSlot slot) {
15
+ switch (slot) {
16
+ case MIK_OTA_CFG_CURRENT:
17
+ return "ota.cfg";
18
+ case MIK_OTA_CFG_NEXT:
19
+ return "ota.cfgNext";
20
+ case MIK_OTA_CFG_PREV:
21
+ return "ota.cfgPrev";
22
+ }
23
+ return "ota.cfg";
24
+ }
25
+
26
+ const char* kTrialKey = "ota.cfgTrial";
27
+ const char* kErrorKey = "ota.cfgErr";
28
+
29
+ /* Read a whole blob, distinguishing absent from failed. */
30
+ MIKOtaKvStatus kv_read(const MIKOtaEnv* env, const char* key, std::vector<uint8_t>* out) {
31
+ if (!env || !env->kv_get_blob) return MIK_OTA_KV_ABSENT;
32
+ size_t len = 0;
33
+ MIKOtaKvStatus status = env->kv_get_blob(env->opaque, key, nullptr, &len);
34
+ if (status != MIK_OTA_KV_OK) return status;
35
+ if (len == 0) return MIK_OTA_KV_ABSENT;
36
+ out->resize(len);
37
+ status = env->kv_get_blob(env->opaque, key, out->data(), &len);
38
+ if (status != MIK_OTA_KV_OK) return status;
39
+ out->resize(len);
40
+ return MIK_OTA_KV_OK;
41
+ }
42
+
43
+ bool take_str(nanocbor_value_t* it, char* out, size_t out_len) {
44
+ const uint8_t* ptr = nullptr;
45
+ size_t len = 0;
46
+ if (nanocbor_get_tstr(it, &ptr, &len) < 0) return false;
47
+ snprintf(out, out_len, "%.*s", static_cast<int>(len), reinterpret_cast<const char*>(ptr));
48
+ return true;
49
+ }
50
+
51
+ bool take_key(nanocbor_value_t* map, std::string* out) {
52
+ const uint8_t* ptr = nullptr;
53
+ size_t len = 0;
54
+ if (nanocbor_get_tstr(map, &ptr, &len) < 0) return false;
55
+ out->assign(reinterpret_cast<const char*>(ptr), len);
56
+ return true;
57
+ }
58
+
59
+ template <typename Fn>
60
+ std::vector<uint8_t> encode_to_vector(Fn&& encode) {
61
+ /* The measuring pass needs a non-null base: zero-length appends (an empty
62
+ * string, an empty document) still reach memcpy. */
63
+ static uint8_t measure_base;
64
+ nanocbor_encoder_t enc;
65
+ nanocbor_encoder_init(&enc, &measure_base, 0);
66
+ encode(&enc);
67
+ std::vector<uint8_t> out(nanocbor_encoded_len(&enc));
68
+ nanocbor_encoder_init(&enc, out.data(), out.size());
69
+ encode(&enc);
70
+ return out;
71
+ }
72
+
73
+ /* Append pre-encoded CBOR verbatim. A stored document keeps the exact bytes the
74
+ * registry sent, so the JS reader decodes the same value the app would have
75
+ * seen; re-encoding would mean carrying a value model for no reason. */
76
+ void put_raw(nanocbor_encoder_t* enc, const uint8_t* data, size_t len) {
77
+ enc->len += len;
78
+ if (enc->fits(enc, enc->context, len)) {
79
+ enc->append(enc, enc->context, data, len);
80
+ }
81
+ }
82
+
83
+ } // namespace
84
+
85
+ /* The slot holds the CBOR map the registry path wrote; anything else reads as
86
+ * absent. */
87
+ MIKOtaLoadedConfig mik__ota_load_slot(const MIKOtaEnv* env, MIKOtaConfigSlot slot) {
88
+ MIKOtaLoadedConfig out;
89
+ MIKOtaKvStatus status = kv_read(env, slot_key(slot), &out.bytes);
90
+ if (status == MIK_OTA_KV_ERROR) {
91
+ out.failed = true;
92
+ return out;
93
+ }
94
+ if (status != MIK_OTA_KV_OK) return out;
95
+
96
+ nanocbor_value_t val;
97
+ nanocbor_decoder_init(&val, out.bytes.data(), out.bytes.size());
98
+ nanocbor_value_t map;
99
+ if (nanocbor_get_type(&val) != NANOCBOR_TYPE_MAP || nanocbor_enter_map(&val, &map) < 0) {
100
+ return out;
101
+ }
102
+
103
+ while (!nanocbor_at_end(&map)) {
104
+ std::string key;
105
+ if (!take_key(&map, &key)) break;
106
+ if (key == "version") {
107
+ if (!take_str(&map, out.cfg.version, sizeof(out.cfg.version))) {
108
+ mik__cbor_skip_value(&map);
109
+ }
110
+ } else if (key == "rev") {
111
+ if (!take_str(&map, out.cfg.rev, sizeof(out.cfg.rev))) mik__cbor_skip_value(&map);
112
+ } else if (key == "doc") {
113
+ const uint8_t* start = map.cur;
114
+ if (nanocbor_get_null(&map) >= 0) continue; // null normalises to absent
115
+ if (mik__cbor_skip_value(&map) < 0) break;
116
+ out.cfg.doc_cbor = const_cast<uint8_t*>(start);
117
+ out.cfg.doc_cbor_len = static_cast<size_t>(map.cur - start);
118
+ } else {
119
+ mik__cbor_skip_value(&map);
120
+ }
121
+ }
122
+ out.present = out.cfg.version[0] != '\0';
123
+ return out;
124
+ }
125
+
126
+ void mik__ota_store_slot(const MIKOtaEnv* env, MIKOtaConfigSlot slot,
127
+ const MIKOtaStoredConfig& cfg) {
128
+ if (!env || !env->kv_set_blob) return;
129
+ size_t map_size = 1; // version
130
+ if (cfg.rev[0]) map_size++;
131
+ if (cfg.doc_cbor && cfg.doc_cbor_len > 0) map_size++;
132
+
133
+ std::vector<uint8_t> buf = encode_to_vector([&](nanocbor_encoder_t* enc) {
134
+ nanocbor_fmt_map(enc, map_size);
135
+ nanocbor_put_tstr(enc, "version");
136
+ nanocbor_put_tstr(enc, cfg.version);
137
+ if (cfg.rev[0]) {
138
+ nanocbor_put_tstr(enc, "rev");
139
+ nanocbor_put_tstr(enc, cfg.rev);
140
+ }
141
+ if (cfg.doc_cbor && cfg.doc_cbor_len > 0) {
142
+ nanocbor_put_tstr(enc, "doc");
143
+ put_raw(enc, cfg.doc_cbor, cfg.doc_cbor_len);
144
+ }
145
+ });
146
+ env->kv_set_blob(env->opaque, slot_key(slot), buf.data(), buf.size());
147
+ }
148
+
149
+ void mik__ota_clear_slot(const MIKOtaEnv* env, MIKOtaConfigSlot slot) {
150
+ if (env && env->kv_remove) env->kv_remove(env->opaque, slot_key(slot));
151
+ }
152
+
153
+ MIKOtaKvStatus mik__ota_load_trial(const MIKOtaEnv* env, MIKOtaConfigTrial* out) {
154
+ std::vector<uint8_t> bytes;
155
+ MIKOtaKvStatus status = kv_read(env, kTrialKey, &bytes);
156
+ if (status != MIK_OTA_KV_OK) return status;
157
+ nanocbor_value_t val;
158
+ nanocbor_decoder_init(&val, bytes.data(), bytes.size());
159
+ nanocbor_value_t map;
160
+ if (nanocbor_get_type(&val) != NANOCBOR_TYPE_MAP || nanocbor_enter_map(&val, &map) < 0) {
161
+ return MIK_OTA_KV_ABSENT;
162
+ }
163
+ bool has_left = false;
164
+ bool has_read = false;
165
+ while (!nanocbor_at_end(&map)) {
166
+ std::string key;
167
+ if (!take_key(&map, &key)) break;
168
+ if (key == "left") {
169
+ int32_t left = 0;
170
+ if (nanocbor_get_int32(&map, &left) >= 0) {
171
+ out->left = left;
172
+ has_left = true;
173
+ } else {
174
+ mik__cbor_skip_value(&map);
175
+ }
176
+ } else if (key == "read") {
177
+ bool read = false;
178
+ if (nanocbor_get_bool(&map, &read) >= 0) {
179
+ out->read = read;
180
+ has_read = true;
181
+ } else {
182
+ mik__cbor_skip_value(&map);
183
+ }
184
+ } else {
185
+ mik__cbor_skip_value(&map);
186
+ }
187
+ }
188
+ return (has_left && has_read) ? MIK_OTA_KV_OK : MIK_OTA_KV_ABSENT;
189
+ }
190
+
191
+ void mik__ota_store_trial(const MIKOtaEnv* env, const MIKOtaConfigTrial& trial) {
192
+ if (!env || !env->kv_set_blob) return;
193
+ std::vector<uint8_t> buf = encode_to_vector([&](nanocbor_encoder_t* enc) {
194
+ nanocbor_fmt_map(enc, 2);
195
+ nanocbor_put_tstr(enc, "left");
196
+ nanocbor_fmt_int(enc, trial.left);
197
+ nanocbor_put_tstr(enc, "read");
198
+ nanocbor_fmt_bool(enc, trial.read);
199
+ });
200
+ env->kv_set_blob(env->opaque, kTrialKey, buf.data(), buf.size());
201
+ }
202
+
203
+ void mik__ota_clear_trial(const MIKOtaEnv* env) {
204
+ if (env && env->kv_remove) env->kv_remove(env->opaque, kTrialKey);
205
+ }
206
+
207
+ bool mik__ota_load_config_error(const MIKOtaEnv* env, MIKOtaConfigErrorReport* out) {
208
+ std::vector<uint8_t> bytes;
209
+ if (kv_read(env, kErrorKey, &bytes) != MIK_OTA_KV_OK) return false;
210
+ nanocbor_value_t val;
211
+ nanocbor_decoder_init(&val, bytes.data(), bytes.size());
212
+ nanocbor_value_t map;
213
+ if (nanocbor_get_type(&val) != NANOCBOR_TYPE_MAP || nanocbor_enter_map(&val, &map) < 0) {
214
+ return false;
215
+ }
216
+ memset(out, 0, sizeof(*out));
217
+ bool has_message = false;
218
+ while (!nanocbor_at_end(&map)) {
219
+ std::string key;
220
+ if (!take_key(&map, &key)) break;
221
+ if (key == "rev") {
222
+ if (!take_str(&map, out->rev, sizeof(out->rev))) mik__cbor_skip_value(&map);
223
+ } else if (key == "message") {
224
+ has_message = take_str(&map, out->message, sizeof(out->message));
225
+ if (!has_message) mik__cbor_skip_value(&map);
226
+ } else {
227
+ mik__cbor_skip_value(&map);
228
+ }
229
+ }
230
+ return out->rev[0] != '\0' && has_message;
231
+ }
232
+
233
+ void mik__ota_store_config_error(const MIKOtaEnv* env, const MIKOtaConfigErrorReport& report) {
234
+ if (!env || !env->kv_set_blob) return;
235
+ std::vector<uint8_t> buf = encode_to_vector([&](nanocbor_encoder_t* enc) {
236
+ nanocbor_fmt_map(enc, 2);
237
+ nanocbor_put_tstr(enc, "rev");
238
+ nanocbor_put_tstr(enc, report.rev);
239
+ nanocbor_put_tstr(enc, "message");
240
+ nanocbor_put_tstr(enc, report.message);
241
+ });
242
+ env->kv_set_blob(env->opaque, kErrorKey, buf.data(), buf.size());
243
+ }
244
+
245
+ void mik__ota_clear_config_error(const MIKOtaEnv* env) {
246
+ if (env && env->kv_remove) env->kv_remove(env->opaque, kErrorKey);
247
+ }
248
+
249
+ } // namespace mikrojs
package/src/mik_repl.cpp CHANGED
@@ -852,9 +852,12 @@ static std::vector<uint8_t> proto_complete(JSContext* ctx, const char* partial,
852
852
  const char* argv[1] = {partial};
853
853
  completion_callback(cb, 1, argv);
854
854
 
855
- /* Two-pass CBOR encode: {"prefix": tstr, "items": [tstr, ...]} */
855
+ /* Two-pass CBOR encode: {"prefix": tstr, "items": [tstr, ...]}. The
856
+ * measuring pass needs a non-null base: zero-length appends still hit
857
+ * memcpy, and memcpy(NULL, ..., 0) is undefined behavior. */
858
+ static uint8_t measure_base;
856
859
  nanocbor_encoder_t enc;
857
- nanocbor_encoder_init(&enc, nullptr, 0);
860
+ nanocbor_encoder_init(&enc, &measure_base, 0);
858
861
  nanocbor_fmt_map(&enc, 2);
859
862
  nanocbor_put_tstr(&enc, "prefix");
860
863
  nanocbor_put_tstrn(&enc, partial, len);
@@ -904,8 +907,11 @@ static void refresh_ready(MIKReplTransport* transport);
904
907
  * the length the encoding needs, which exceeds cap when it did not fit. */
905
908
  static size_t encode_ready(uint8_t* buf, size_t cap, const char* chip, const char* id,
906
909
  const char* version, const char* fw, const char* name) {
910
+ /* Measuring calls pass buf=NULL; substitute a non-null base so
911
+ * zero-length appends never hand memcpy a null pointer (UB). */
912
+ static uint8_t measure_base;
907
913
  nanocbor_encoder_t enc;
908
- nanocbor_encoder_init(&enc, buf, cap);
914
+ nanocbor_encoder_init(&enc, buf ? buf : &measure_base, buf ? cap : 0);
909
915
  nanocbor_fmt_map(&enc, 3 + (fw ? 1 : 0) + (name ? 1 : 0));
910
916
  nanocbor_put_tstr(&enc, "chip");
911
917
  nanocbor_put_tstr(&enc, chip);