@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.
- package/CMakeLists.txt +62 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
- package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
- package/dist/runtime/result/native-result.node-shim.js +41 -0
- package/dist/runtime/result/native-result.node-shim.js.map +1 -0
- package/dist/runtime/result/types.d.ts +55 -0
- package/dist/runtime/result/types.d.ts.map +1 -0
- package/dist/runtime/result/types.js +2 -0
- package/dist/runtime/result/types.js.map +1 -0
- package/dist/runtime/schema/core.d.ts +115 -0
- package/dist/runtime/schema/core.d.ts.map +1 -0
- package/dist/runtime/schema/core.js +259 -0
- package/dist/runtime/schema/core.js.map +1 -0
- package/dist/runtime/schema/shared.d.ts +54 -0
- package/dist/runtime/schema/shared.d.ts.map +1 -0
- package/dist/runtime/schema/shared.js +489 -0
- package/dist/runtime/schema/shared.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/include/mikrojs/cbor_helpers.h +20 -0
- package/include/mikrojs/mem.h +11 -0
- package/include/mikrojs/mikrojs.h +2 -1
- package/include/mikrojs/ota_client.h +342 -0
- package/include/mikrojs/ota_config.h +100 -0
- package/include/mikrojs/ota_env.h +192 -0
- package/include/mikrojs/ota_js_hooks.h +71 -0
- package/include/mikrojs/ota_policy.h +131 -0
- package/include/mikrojs/ota_slots.h +47 -0
- package/include/mikrojs/sys_codec.h +61 -0
- package/package.json +7 -5
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/internal.d.ts +22 -16
- package/runtime/kv/shared.ts +11 -5
- package/runtime/kv/types.ts +4 -4
- package/runtime/ota/client.ts +12 -51
- package/runtime/ota/config.ts +18 -0
- package/runtime/ota/ota.ts +28 -70
- package/runtime/ota/types.ts +220 -2
- package/runtime/schema/core.ts +539 -0
- package/runtime/schema/schema.ts +36 -314
- package/runtime/schema/shared.ts +494 -0
- package/runtime/schema/types.ts +84 -12
- package/scripts/bundle-runtime.js +33 -0
- package/scripts/gen-checkin-fixtures.js +323 -0
- package/src/builtins.cpp +7 -8
- package/src/fs.cpp +3 -0
- package/src/mem.cpp +38 -0
- package/src/mik_abort.cpp +8 -1
- package/src/mik_cbor.cpp +43 -5
- package/src/mik_inspect.cpp +128 -22
- package/src/mik_ota_client.cpp +1230 -0
- package/src/mik_ota_config.cpp +296 -0
- package/src/mik_ota_js_hooks.cpp +190 -0
- package/src/mik_ota_policy.cpp +419 -0
- package/src/mik_ota_slots.cpp +249 -0
- package/src/mik_repl.cpp +9 -3
- package/src/mik_result.cpp +3 -1
- package/src/mik_sys_codec.cpp +167 -0
- package/src/mikrojs.cpp +15 -0
- package/src/modules.cpp +32 -13
- package/runtime/ota/client-impl.ts +0 -590
- 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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
}
|