@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
@@ -89,6 +89,10 @@ async function minifyWithSwc(code) {
89
89
 
90
90
  mkdirSync(outDir, {recursive: true})
91
91
 
92
+ // Every source file each bundle inlined, for the duplication check below.
93
+ // name -> [{module, bytes}]
94
+ const inlinedBy = new Map()
95
+
92
96
  for (const name of moduleNames) {
93
97
  // Resolve entry point: {name}/{name}.ts (core modules) or {name}.ts (board/driver)
94
98
  const nested = join(runtimeDir, name, `${name}.ts`)
@@ -105,8 +109,18 @@ for (const name of moduleNames) {
105
109
  platform: 'neutral',
106
110
  format: 'esm',
107
111
  external: ['mikro', 'mikro/*', '@mikrojs/*', 'native:*'],
112
+ metafile: true,
108
113
  })
109
114
 
115
+ for (const output of Object.values(result.metafile.outputs)) {
116
+ for (const [input, {bytesInOutput}] of Object.entries(output.inputs)) {
117
+ // Zero bytes = type-only import, erased from the output.
118
+ if (bytesInOutput === 0) continue
119
+ if (!inlinedBy.has(input)) inlinedBy.set(input, [])
120
+ inlinedBy.get(input).push({module: name, bytes: bytesInOutput})
121
+ }
122
+ }
123
+
110
124
  const output = result.outputFiles?.[0]
111
125
  if (!output) {
112
126
  // eslint-disable-next-line no-console
@@ -147,3 +161,22 @@ for (const name of moduleNames) {
147
161
  // eslint-disable-next-line no-console
148
162
  console.log(`Bundled ${name} (${Buffer.byteLength(source)} bytes, ${externals.length} externals)`)
149
163
  }
164
+
165
+ // A source file inlined into more than one bundle ships (and loads) twice:
166
+ // duplicate flash bytes and a second live module instance in RAM. It happens
167
+ // when sibling builtins share a file via a RELATIVE import — cross-builtin
168
+ // `mikro/*` imports stay external and link at runtime, so the fix is to give
169
+ // the shared file its own builtin and import it as `mikro/<mod>/shared`
170
+ // (see kv/shared and ota/shared for the pattern).
171
+ const duplicated = [...inlinedBy.entries()].filter(([, users]) => users.length > 1)
172
+ if (duplicated.length > 0) {
173
+ for (const [input, users] of duplicated) {
174
+ // eslint-disable-next-line no-console
175
+ console.error(
176
+ `Duplicated across bundles: ${input} — ${users
177
+ .map((u) => `${u.module} (${u.bytes}B)`)
178
+ .join(', ')}`,
179
+ )
180
+ }
181
+ process.exit(1)
182
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Check-in wire fixtures: response bodies captured from the real reference
3
+ * registry, replayed by test/ota_wire_fixtures_test.cpp.
4
+ *
5
+ * The reference registry (packages/@mikrojs/registry) and the device check-in
6
+ * client (src/mik_ota_client.cpp) implement one wire contract with two
7
+ * independent test suites, and they drifted: the registry answers a
8
+ * nothing-new check-in with CBOR null, the C client required a map, and every
9
+ * quiet round on real devices failed to the 60s retry cadence while both
10
+ * suites stayed green (fixed in c41feb3). The C++ suite's fakes only ever
11
+ * produced the shapes the test author imagined — CheckinResponse{}.Encode() is
12
+ * always a map. These fixtures make the client's round logic consume bytes the
13
+ * real registry produced, so a response-shape change on either side breaks a
14
+ * test instead of a fleet.
15
+ *
16
+ * Deterministic and offline: fixed device id, app, versions and build bytes,
17
+ * and nothing clock-derived reaches a response body.
18
+ *
19
+ * Requires the registry's built dist, so run `pnpm build:ts` first.
20
+ *
21
+ * Usage: node gen-checkin-fixtures.js <outdir>
22
+ */
23
+
24
+ import {createHash} from 'node:crypto'
25
+ import {mkdirSync, rmSync, writeFileSync} from 'node:fs'
26
+ import {join} from 'node:path'
27
+
28
+ import {createRegistry, memoryStorage} from '@mikrojs/registry'
29
+
30
+ // The registry picks its response format from the request's content-type, so a
31
+ // fixture that captures what a device receives has to POST CBOR. The codec is
32
+ // not part of the package's public exports; take it from beside the entry point
33
+ // rather than growing a second encoder here.
34
+ const {decodeCbor, encodeCbor} = await import(
35
+ new URL('./cbor.js', import.meta.resolve('@mikrojs/registry')).href
36
+ )
37
+
38
+ const outDir = process.argv[2]
39
+ if (!outDir) {
40
+ // eslint-disable-next-line no-console
41
+ console.error('Usage: node gen-checkin-fixtures.js <outdir>')
42
+ process.exit(1)
43
+ }
44
+
45
+ const TOKEN = 'tok_fixture'
46
+ const BASE = 'https://reg.example'
47
+ const DEVICE_ID = 'dev-1'
48
+ const APP = 'sensor'
49
+ const FIRMWARE = '0.16.0'
50
+ const BYTECODE = 42
51
+
52
+ /** One defaulted leaf: valid with no operator input at all. */
53
+ const SCHEMA = JSON.stringify({kind: 'object', shape: {interval: {kind: 'number', default: 60}}})
54
+
55
+ function fresh() {
56
+ const storage = memoryStorage()
57
+ return {storage, registry: createRegistry({storage, token: TOKEN})}
58
+ }
59
+
60
+ async function publish(registry, {version, content, configSchema}) {
61
+ const bytes = new TextEncoder().encode(content)
62
+ const checksum = createHash('sha256').update(bytes).digest('hex')
63
+ const form = new FormData()
64
+ form.set('app', APP)
65
+ form.set('version', version)
66
+ form.set('checksum', checksum)
67
+ form.set('size', String(bytes.byteLength))
68
+ form.set('firmwareVersion', FIRMWARE)
69
+ form.set('bytecodeVersion', String(BYTECODE))
70
+ // A stored build is served to no device until a channel points at it.
71
+ form.set('channel', 'main')
72
+ if (configSchema !== undefined) form.set('configSchema', configSchema)
73
+ form.set('build', new Blob([bytes], {type: 'application/gzip'}), 'app.tgz')
74
+ const response = await registry.fetch(
75
+ new Request(`${BASE}/api/v1/builds`, {
76
+ method: 'POST',
77
+ headers: {authorization: `Bearer ${TOKEN}`},
78
+ body: form,
79
+ }),
80
+ )
81
+ if (response.status !== 201) throw new Error(`publish ${version}: ${await response.text()}`)
82
+ return {checksum, size: bytes.byteLength}
83
+ }
84
+
85
+ async function enroll(registry) {
86
+ const response = await registry.fetch(
87
+ new Request(`${BASE}/api/v1/devices`, {
88
+ method: 'POST',
89
+ headers: {authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json'},
90
+ body: JSON.stringify({deviceId: DEVICE_ID, app: APP}),
91
+ }),
92
+ )
93
+ if (response.status !== 201) throw new Error(`enroll: ${await response.text()}`)
94
+ return (await response.json()).credential
95
+ }
96
+
97
+ async function putConfig(registry, body) {
98
+ const response = await registry.fetch(
99
+ new Request(`${BASE}/api/v1/devices/${DEVICE_ID}/config`, {
100
+ method: 'PUT',
101
+ headers: {authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json'},
102
+ body: JSON.stringify(body),
103
+ }),
104
+ )
105
+ if (response.status !== 200) throw new Error(`putConfig: ${await response.text()}`)
106
+ }
107
+
108
+ /** A check-in body exactly as encode_report() in mik_ota_client.cpp sends it. */
109
+ function checkin(registry, credential, extra = {}) {
110
+ return registry.fetch(
111
+ new Request(`${BASE}/api/v1/checkin`, {
112
+ method: 'POST',
113
+ headers: {
114
+ authorization: `Bearer ${credential}`,
115
+ 'content-type': 'application/cbor',
116
+ accept: 'application/cbor',
117
+ },
118
+ body: encodeCbor({
119
+ deviceId: DEVICE_ID,
120
+ firmware: FIRMWARE,
121
+ firmwareHash: 'fwhash',
122
+ bytecode: BYTECODE,
123
+ running: {trial: false},
124
+ name: [1, 'shed'],
125
+ free: 900000,
126
+ ...extra,
127
+ }),
128
+ }),
129
+ )
130
+ }
131
+
132
+ /** The rev the registry hands down for the running release's overlay. */
133
+ async function revFrom(response) {
134
+ const body = decodeCbor(new Uint8Array(await response.arrayBuffer()))
135
+ return body.config.rev
136
+ }
137
+
138
+ /** What the device must conclude, with every field always present so the C++
139
+ * reader stays a flat lookup. `nameRev` -1 means "no rename expected". */
140
+ function expect({
141
+ result,
142
+ configUpdated = false,
143
+ offer,
144
+ configStaged = false,
145
+ nameRev = -1,
146
+ name = '',
147
+ }) {
148
+ return {
149
+ result,
150
+ configUpdated,
151
+ offerUrl: offer?.url ?? '',
152
+ offerChecksum: offer?.checksum ?? '',
153
+ offerSize: offer?.size ?? 0,
154
+ configStaged,
155
+ nameRev,
156
+ name,
157
+ }
158
+ }
159
+
160
+ const scenarios = [
161
+ {
162
+ scenario: 'quiet-round',
163
+ note: 'nothing to offer, nothing to configure: the registry answers CBOR null',
164
+ expect: expect({result: 'up-to-date'}),
165
+ async capture() {
166
+ const {registry} = fresh()
167
+ return checkin(registry, await enroll(registry))
168
+ },
169
+ },
170
+ {
171
+ scenario: 'config-delivered',
172
+ note: 'an operator overlay for the running release, not yet held by the device',
173
+ expect: expect({result: 'up-to-date', configUpdated: true}),
174
+ async capture() {
175
+ const {registry} = fresh()
176
+ const build = await publish(registry, {
177
+ version: '1.0.0',
178
+ content: 'build-1.0.0',
179
+ configSchema: SCHEMA,
180
+ })
181
+ const credential = await enroll(registry)
182
+ await putConfig(registry, {values: {interval: 30}, version: '1.0.0'})
183
+ return checkin(registry, credential, {
184
+ running: {checksum: build.checksum, version: '1.0.0', trial: false},
185
+ })
186
+ },
187
+ },
188
+ {
189
+ scenario: 'config-rev-echoed',
190
+ note: 'the device echoes the rev it holds: the registry sends nothing back',
191
+ expect: expect({result: 'up-to-date'}),
192
+ async capture() {
193
+ const {registry} = fresh()
194
+ const build = await publish(registry, {
195
+ version: '1.0.0',
196
+ content: 'build-1.0.0',
197
+ configSchema: SCHEMA,
198
+ })
199
+ const credential = await enroll(registry)
200
+ await putConfig(registry, {values: {interval: 30}, version: '1.0.0'})
201
+ const running = {checksum: build.checksum, version: '1.0.0', trial: false}
202
+ const rev = await revFrom(await checkin(registry, credential, {running}))
203
+ return checkin(registry, credential, {running, configRev: rev})
204
+ },
205
+ },
206
+ {
207
+ scenario: 'config-cleared',
208
+ note: 'the overlay was removed: a config field with a version and no doc',
209
+ expect: expect({result: 'up-to-date', configUpdated: true}),
210
+ async capture() {
211
+ const {registry} = fresh()
212
+ const build = await publish(registry, {
213
+ version: '1.0.0',
214
+ content: 'build-1.0.0',
215
+ configSchema: SCHEMA,
216
+ })
217
+ const credential = await enroll(registry)
218
+ await putConfig(registry, {values: {interval: 30}, version: '1.0.0'})
219
+ const running = {checksum: build.checksum, version: '1.0.0', trial: false}
220
+ const rev = await revFrom(await checkin(registry, credential, {running}))
221
+ await putConfig(registry, {values: {}, version: '1.0.0'})
222
+ // The device still holds the delivered document, so the test seeds it.
223
+ this.seedConfigRev = rev
224
+ return checkin(registry, credential, {running, configRev: rev})
225
+ },
226
+ },
227
+ {
228
+ scenario: 'name-changed',
229
+ note: 'renamed registry-side at a higher revision: the pair comes back alone',
230
+ expect: expect({result: 'up-to-date', nameRev: 3, name: 'kitchen'}),
231
+ async capture() {
232
+ const {registry, storage} = fresh()
233
+ const credential = await enroll(registry)
234
+ const device = await storage.getDevice(DEVICE_ID)
235
+ await storage.putDevice({...device, name: 'kitchen', nameRev: 3})
236
+ return checkin(registry, credential)
237
+ },
238
+ },
239
+ {
240
+ scenario: 'offer',
241
+ note: 'a newer build for this firmware range',
242
+ async capture() {
243
+ const {registry} = fresh()
244
+ const first = await publish(registry, {version: '1.0.0', content: 'build-1.0.0'})
245
+ const next = await publish(registry, {version: '2.0.0', content: 'build-2.0.0'})
246
+ const credential = await enroll(registry)
247
+ this.expect = expect({
248
+ result: 'staged',
249
+ offer: {
250
+ url: `${BASE}/api/v1/builds/${next.checksum}.tgz`,
251
+ checksum: next.checksum,
252
+ size: next.size,
253
+ },
254
+ })
255
+ return checkin(registry, credential, {
256
+ running: {checksum: first.checksum, version: '1.0.0', trial: false},
257
+ })
258
+ },
259
+ },
260
+ {
261
+ scenario: 'offer-with-config',
262
+ note: "the offer and the offered release's config travel together (offer rule 5)",
263
+ async capture() {
264
+ const {registry} = fresh()
265
+ const first = await publish(registry, {
266
+ version: '1.0.0',
267
+ content: 'build-1.0.0',
268
+ configSchema: SCHEMA,
269
+ })
270
+ const next = await publish(registry, {
271
+ version: '2.0.0',
272
+ content: 'build-2.0.0',
273
+ configSchema: SCHEMA,
274
+ })
275
+ const credential = await enroll(registry)
276
+ await putConfig(registry, {values: {interval: 30}, version: '2.0.0'})
277
+ this.expect = expect({
278
+ result: 'staged',
279
+ configStaged: true,
280
+ offer: {
281
+ url: `${BASE}/api/v1/builds/${next.checksum}.tgz`,
282
+ checksum: next.checksum,
283
+ size: next.size,
284
+ },
285
+ })
286
+ return checkin(registry, credential, {
287
+ running: {checksum: first.checksum, version: '1.0.0', trial: false},
288
+ })
289
+ },
290
+ },
291
+ {
292
+ scenario: 'unauthorized',
293
+ note: 'a credential the registry does not know: 401 with a JSON error body',
294
+ expect: expect({result: 'unauthorized'}),
295
+ async capture() {
296
+ const {registry} = fresh()
297
+ await enroll(registry)
298
+ return checkin(registry, 'duk_wrong')
299
+ },
300
+ },
301
+ ]
302
+
303
+ rmSync(outDir, {recursive: true, force: true})
304
+ mkdirSync(outDir, {recursive: true})
305
+
306
+ const manifest = []
307
+ for (const [index, entry] of scenarios.entries()) {
308
+ entry.seedConfigRev = ''
309
+ const response = await entry.capture()
310
+ const body = new Uint8Array(await response.arrayBuffer())
311
+ const file = `${String(index + 1).padStart(2, '0')}-${entry.scenario}.cbor`
312
+ writeFileSync(join(outDir, file), body)
313
+ manifest.push({
314
+ file,
315
+ scenario: entry.scenario,
316
+ note: entry.note,
317
+ status: response.status,
318
+ seedConfigRev: entry.seedConfigRev,
319
+ expect: entry.expect,
320
+ })
321
+ }
322
+
323
+ writeFileSync(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
package/src/builtins.cpp CHANGED
@@ -52,16 +52,15 @@ static JSModuleDef* mik__deserialize_builtin(JSContext* ctx, const char* name,
52
52
  return NULL;
53
53
  }
54
54
 
55
- /* Initialize import.meta (url, env, etc.) for the builtin module */
55
+ /* Initialize import.meta (url, env, etc.) for the builtin module.
56
+ * Binding resolution is deliberately NOT done here: this runs inside
57
+ * the module loader, and a failing JS_ResolveModule frees every
58
+ * not-yet-resolved module in the context — including whatever
59
+ * partially-read module an outer JS_ReadModule frame still holds.
60
+ * The root resolve covers loader-returned modules; direct consumers
61
+ * (the abort lazy-loader) resolve before evaluating. */
56
62
  js_module_set_import_meta(ctx, obj, false, false);
57
63
 
58
- /* Resolve imports so the module loader is called for dependencies */
59
- if (JS_ResolveModule(ctx, obj) < 0) {
60
- platform->log(MIK_LOG_ERROR, "mikrojs", "Failed to resolve imports for builtin '%s'", name);
61
- JS_FreeValue(ctx, obj);
62
- return NULL;
63
- }
64
-
65
64
  JSModuleDef* m = static_cast<JSModuleDef*>(JS_VALUE_GET_PTR(obj));
66
65
  JS_FreeValue(ctx, obj);
67
66
 
package/src/fs.cpp CHANGED
@@ -383,6 +383,9 @@ static JSValue mik__file_stat(JSContext* ctx, JSValue this_val, int argc, JSValu
383
383
  return mik__fs_err_result(ctx, EBADF, nullptr);
384
384
  }
385
385
 
386
+ /* Push buffered writes to the OS so fstat sees the true size */
387
+ fflush(fh->file);
388
+
386
389
  struct stat st;
387
390
  if (fstat(fileno(fh->file), &st) != 0) {
388
391
  return mik__fs_err_result(ctx, errno, nullptr);
package/src/mem.cpp CHANGED
@@ -18,6 +18,34 @@
18
18
 
19
19
  #define HDR_SIZE sizeof(size_t)
20
20
 
21
+ /* Fault injection for the host OOM tests: fail the JS-heap allocators after
22
+ * a countdown, and keep a net live-allocation counter across every allocator
23
+ * here (all of them free through mik__free, so one counter balances). */
24
+ #ifdef MIK_OOM_INJECT
25
+ static int64_t g_oom_countdown = -1; /* -1 = disabled */
26
+ static int64_t g_live_allocs = 0;
27
+
28
+ void mik__oom_inject_fail_after(int64_t n) {
29
+ g_oom_countdown = n;
30
+ }
31
+
32
+ int64_t mik__oom_inject_live_allocs(void) {
33
+ return g_live_allocs;
34
+ }
35
+
36
+ static bool oom_inject_should_fail(void) {
37
+ if (g_oom_countdown < 0) return false;
38
+ if (g_oom_countdown == 0) return true;
39
+ g_oom_countdown--;
40
+ return false;
41
+ }
42
+ #define OOM_INJECT_FAIL() oom_inject_should_fail()
43
+ #define LIVE_ALLOCS_ADD(d) (g_live_allocs += (d))
44
+ #else
45
+ #define OOM_INJECT_FAIL() false
46
+ #define LIVE_ALLOCS_ADD(d) ((void)0)
47
+ #endif
48
+
21
49
  static inline size_t hdr_read(void* user) {
22
50
  size_t sz;
23
51
  memcpy(&sz, static_cast<char*>(user) - HDR_SIZE, sizeof(sz));
@@ -37,6 +65,7 @@ void* mik__malloc(size_t size) {
37
65
  void* raw = malloc(size + HDR_SIZE);
38
66
  if (!raw) return nullptr;
39
67
  hdr_write(raw, size);
68
+ LIVE_ALLOCS_ADD(1);
40
69
  return static_cast<char*>(raw) + HDR_SIZE;
41
70
  }
42
71
 
@@ -48,11 +77,13 @@ void* mik__calloc(size_t count, size_t size) {
48
77
  void* raw = calloc(1, total + HDR_SIZE);
49
78
  if (!raw) return nullptr;
50
79
  hdr_write(raw, total);
80
+ LIVE_ALLOCS_ADD(1);
51
81
  return static_cast<char*>(raw) + HDR_SIZE;
52
82
  }
53
83
 
54
84
  void mik__free(void* ptr) {
55
85
  if (!ptr) return;
86
+ LIVE_ALLOCS_ADD(-1);
56
87
  free(static_cast<char*>(ptr) - HDR_SIZE);
57
88
  }
58
89
 
@@ -61,6 +92,7 @@ void* mik__realloc(void* ptr, size_t size) {
61
92
  raw = realloc(raw, size + HDR_SIZE);
62
93
  if (!raw) return nullptr;
63
94
  hdr_write(raw, size);
95
+ if (!ptr) LIVE_ALLOCS_ADD(1);
64
96
  return static_cast<char*>(raw) + HDR_SIZE;
65
97
  }
66
98
 
@@ -82,6 +114,7 @@ bool mik__is_quickjs_heap_psram(void) {
82
114
  }
83
115
 
84
116
  void* mik__js_malloc(size_t size) {
117
+ if (OOM_INJECT_FAIL()) return nullptr;
85
118
  void* raw = nullptr;
86
119
  if (g_quickjs_heap_psram) {
87
120
  const MIKPlatform* p = MIK_GetPlatform();
@@ -92,10 +125,12 @@ void* mik__js_malloc(size_t size) {
92
125
  if (!raw) raw = malloc(size + HDR_SIZE);
93
126
  if (!raw) return nullptr;
94
127
  hdr_write(raw, size);
128
+ LIVE_ALLOCS_ADD(1);
95
129
  return static_cast<char*>(raw) + HDR_SIZE;
96
130
  }
97
131
 
98
132
  void* mik__js_calloc(size_t count, size_t size) {
133
+ if (OOM_INJECT_FAIL()) return nullptr;
99
134
  if (size && count > SIZE_MAX / size) return nullptr;
100
135
  size_t total = count * size;
101
136
  void* raw = nullptr;
@@ -108,10 +143,12 @@ void* mik__js_calloc(size_t count, size_t size) {
108
143
  if (!raw) raw = calloc(1, total + HDR_SIZE);
109
144
  if (!raw) return nullptr;
110
145
  hdr_write(raw, total);
146
+ LIVE_ALLOCS_ADD(1);
111
147
  return static_cast<char*>(raw) + HDR_SIZE;
112
148
  }
113
149
 
114
150
  void* mik__js_realloc(void* ptr, size_t size) {
151
+ if (OOM_INJECT_FAIL()) return nullptr;
115
152
  void* raw = ptr ? static_cast<char*>(ptr) - HDR_SIZE : nullptr;
116
153
  void* new_raw = nullptr;
117
154
  if (g_quickjs_heap_psram) {
@@ -123,5 +160,6 @@ void* mik__js_realloc(void* ptr, size_t size) {
123
160
  if (!new_raw) new_raw = realloc(raw, size + HDR_SIZE);
124
161
  if (!new_raw) return nullptr;
125
162
  hdr_write(new_raw, size);
163
+ if (!ptr) LIVE_ALLOCS_ADD(1);
126
164
  return static_cast<char*>(new_raw) + HDR_SIZE;
127
165
  }
package/src/mik_abort.cpp CHANGED
@@ -58,7 +58,14 @@ static JSValue mik__abort_lazy_get(JSContext* ctx, JSValue this_val, int magic)
58
58
  JS_FreeValue(ctx, global_obj);
59
59
  return JS_EXCEPTION;
60
60
  }
61
- JSValue ret = JS_EvalFunction(ctx, JS_DupValue(ctx, JS_MKPTR(JS_TAG_MODULE, m)));
61
+ /* The loader no longer resolves bindings (unsafe when nested); this is
62
+ * a direct evaluation, so resolve here before running the module. */
63
+ JSValue mod_val = JS_MKPTR(JS_TAG_MODULE, m);
64
+ if (JS_ResolveModule(ctx, mod_val) < 0) {
65
+ JS_FreeValue(ctx, global_obj);
66
+ return JS_EXCEPTION;
67
+ }
68
+ JSValue ret = JS_EvalFunction(ctx, JS_DupValue(ctx, mod_val));
62
69
  if (JS_IsException(ret)) {
63
70
  JS_FreeValue(ctx, global_obj);
64
71
  return JS_EXCEPTION;
package/src/mik_cbor.cpp CHANGED
@@ -136,9 +136,12 @@ static JSValue mik__cbor_encode(JSContext* ctx, JSValue this_val, int argc, JSVa
136
136
 
137
137
  JSValue val = argv[0];
138
138
 
139
- /* Pass 1: compute size with NULL buffer */
139
+ /* Pass 1: compute size with a zero-capacity buffer. The base pointer
140
+ * must be non-null: zero-length appends (empty strings) still reach
141
+ * memcpy, and memcpy(NULL, ..., 0) is undefined behavior. */
142
+ static uint8_t measure_base;
140
143
  nanocbor_encoder_t enc;
141
- nanocbor_encoder_init(&enc, nullptr, 0);
144
+ nanocbor_encoder_init(&enc, &measure_base, 0);
142
145
  if (mik__cbor_encode_value(ctx, &enc, val, 0) < 0) {
143
146
  return mik__result_err_named(
144
147
  ctx, "EncodeFailed",
@@ -166,6 +169,26 @@ static JSValue mik__cbor_encode(JSContext* ctx, JSValue this_val, int argc, JSVa
166
169
 
167
170
  /* ── Decoder ────────────────────────────────────────────────────────── */
168
171
 
172
+ /* nanocbor_at_end also reports true when the buffer runs out mid-container,
173
+ * so a truncated container must be caught explicitly: a definite-length
174
+ * container with items still remaining, or an indefinite-length one whose
175
+ * buffer ended before the break byte (`remaining` stays 0 for indefinite). */
176
+ static bool mik__cbor_container_truncated(const nanocbor_value_t* container) {
177
+ if (nanocbor_container_indefinite(container)) {
178
+ return container->cur >= container->end;
179
+ }
180
+ return nanocbor_container_remaining(container) != 0;
181
+ }
182
+
183
+ /* Vendored nanocbor's _get_str checks the declared length against the bytes
184
+ * remaining before the length-prefix header, but returns a span starting after
185
+ * it, so a string truncated at the buffer tail passes the check with a span
186
+ * that overruns `end` by up to the header size. Reject such spans before
187
+ * handing them to QuickJS. */
188
+ static bool mik__cbor_str_in_bounds(const nanocbor_value_t* val, const uint8_t* buf, size_t len) {
189
+ return buf != nullptr && buf <= val->end && static_cast<size_t>(val->end - buf) >= len;
190
+ }
191
+
169
192
  JSValue mik__cbor_decode_value(JSContext* ctx, nanocbor_value_t* val, int depth) {
170
193
  if (depth > MIK_CBOR_MAX_DEPTH) {
171
194
  return JS_EXCEPTION;
@@ -193,14 +216,20 @@ JSValue mik__cbor_decode_value(JSContext* ctx, nanocbor_value_t* val, int depth)
193
216
  case NANOCBOR_TYPE_BSTR: {
194
217
  const uint8_t* buf = nullptr;
195
218
  size_t len = 0;
196
- if (nanocbor_get_bstr(val, &buf, &len) < 0) return JS_EXCEPTION;
219
+ if (nanocbor_get_bstr(val, &buf, &len) < 0 ||
220
+ !mik__cbor_str_in_bounds(val, buf, len)) {
221
+ return JS_EXCEPTION;
222
+ }
197
223
  return JS_NewUint8ArrayCopy(ctx, buf, len);
198
224
  }
199
225
 
200
226
  case NANOCBOR_TYPE_TSTR: {
201
227
  const uint8_t* buf = nullptr;
202
228
  size_t len = 0;
203
- if (nanocbor_get_tstr(val, &buf, &len) < 0) return JS_EXCEPTION;
229
+ if (nanocbor_get_tstr(val, &buf, &len) < 0 ||
230
+ !mik__cbor_str_in_bounds(val, buf, len)) {
231
+ return JS_EXCEPTION;
232
+ }
204
233
  return JS_NewStringLen(ctx, reinterpret_cast<const char*>(buf), len);
205
234
  }
206
235
 
@@ -218,6 +247,10 @@ JSValue mik__cbor_decode_value(JSContext* ctx, nanocbor_value_t* val, int depth)
218
247
  }
219
248
  JS_SetPropertyUint32(ctx, result, idx++, elem);
220
249
  }
250
+ if (mik__cbor_container_truncated(&arr)) {
251
+ JS_FreeValue(ctx, result);
252
+ return JS_EXCEPTION;
253
+ }
221
254
  nanocbor_leave_container(val, &arr);
222
255
  return result;
223
256
  }
@@ -235,7 +268,8 @@ JSValue mik__cbor_decode_value(JSContext* ctx, nanocbor_value_t* val, int depth)
235
268
  }
236
269
  const uint8_t* key_buf = nullptr;
237
270
  size_t key_len = 0;
238
- if (nanocbor_get_tstr(&map, &key_buf, &key_len) < 0) {
271
+ if (nanocbor_get_tstr(&map, &key_buf, &key_len) < 0 ||
272
+ !mik__cbor_str_in_bounds(&map, key_buf, key_len)) {
239
273
  JS_FreeValue(ctx, result);
240
274
  return JS_EXCEPTION;
241
275
  }
@@ -251,6 +285,10 @@ JSValue mik__cbor_decode_value(JSContext* ctx, nanocbor_value_t* val, int depth)
251
285
  JS_SetProperty(ctx, result, atom, prop_val);
252
286
  JS_FreeAtom(ctx, atom);
253
287
  }
288
+ if (mik__cbor_container_truncated(&map)) {
289
+ JS_FreeValue(ctx, result);
290
+ return JS_EXCEPTION;
291
+ }
254
292
  nanocbor_leave_container(val, &map);
255
293
  return result;
256
294
  }