@esp-vision/device-core 0.3.0 → 0.4.0
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/CHANGELOG.md +12 -0
- package/README.md +46 -3
- package/package.json +12 -6
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser.js +61 -0
- package/src/browser_rom.js +9 -0
- package/src/browser_rom_compat.js +65 -0
- package/src/firmware.js +4 -0
- package/src/firmware_feed.js +116 -3
- package/src/firmware_io.js +68 -0
- package/src/firmware_release.js +47 -0
- package/src/firmware_updates.js +17 -36
- package/tests/check-browser.mjs +151 -0
- package/tests/check-package.mjs +4 -1
- package/tests/test-browser-rom.mjs +85 -0
- package/tests/test-firmware-source.mjs +143 -0
- package/tests/test-release-manifest.mjs +214 -0
package/src/firmware_updates.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { loadOfficialFirmware } from './firmware_feed.js'
|
|
5
5
|
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
|
+
import { validateFirmwareRelease as release } from './firmware_release.js'
|
|
7
|
+
import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
|
|
6
8
|
|
|
7
9
|
function record(value, keys, name) {
|
|
8
10
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -16,38 +18,7 @@ function identity(value) {
|
|
|
16
18
|
if (!value || typeof value.board !== 'string' || !value.board || typeof value.firmwareVersion !== 'string' || !value.firmwareVersion) throw new TypeError('An inspected identity with board and firmwareVersion is required')
|
|
17
19
|
return structuredClone(value)
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
|
|
21
|
-
const image = value.image
|
|
22
|
-
const revisionValid = image?.chipRevision !== undefined
|
|
23
|
-
? Number.isSafeInteger(image.chipRevision) && image.chipRevision >= 0
|
|
24
|
-
: Number.isSafeInteger(image?.chipRevisionMin) && image.chipRevisionMin >= 0
|
|
25
|
-
&& Number.isSafeInteger(image?.chipRevisionMax) && image.chipRevisionMax >= image.chipRevisionMin
|
|
26
|
-
if (!image || image.evMuxVersion !== 4 || !Number.isSafeInteger(image.chipFamily)
|
|
27
|
-
|| image.chipFamily < 0 || !revisionValid || !Number.isSafeInteger(image.offset) || image.offset < 0
|
|
28
|
-
|| !Number.isSafeInteger(image.bytes) || image.bytes <= 0 || !Number.isSafeInteger(image.partitionSize) || image.bytes > image.partitionSize
|
|
29
|
-
|| !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
|
|
30
|
-
|| !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
|
|
31
|
-
|| !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
|
|
32
|
-
if (mode === 'install') {
|
|
33
|
-
const install = value.install
|
|
34
|
-
if (!install || install.offset !== 0 || !Number.isSafeInteger(install.bytes) || install.bytes <= 0
|
|
35
|
-
|| !Number.isSafeInteger(install.flashSize) || install.flashSize < install.bytes || install.flashSize > 2 ** 30
|
|
36
|
-
|| !Number.isInteger(Math.log2(install.flashSize)) || install.flashSize < 2 ** 18
|
|
37
|
-
|| !/^[a-f0-9]{64}$/.test(install.sha256 ?? '') || !install.bootloader
|
|
38
|
-
|| !Number.isSafeInteger(install.chipRevisionMin) || !Number.isSafeInteger(install.chipRevisionMax)
|
|
39
|
-
|| install.chipRevisionMin < (image.chipRevision ?? image.chipRevisionMin) || install.chipRevisionMax > (image.chipRevision ?? image.chipRevisionMax)
|
|
40
|
-
|| install.chipRevisionMin < 0 || install.chipRevisionMax < install.chipRevisionMin) throw Object.assign(new TypeError('Release lacks qualified complete-install metadata'), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
|
|
41
|
-
let end = 0
|
|
42
|
-
for (const part of [install.bootloader, image.partitionTable, image]) {
|
|
43
|
-
if (!Number.isSafeInteger(part.offset) || !Number.isSafeInteger(part.bytes) || part.bytes <= 0
|
|
44
|
-
|| part.offset < end || part.offset + part.bytes > install.bytes
|
|
45
|
-
|| !/^[a-f0-9]{64}$/.test(part.sha256 ?? '')) throw new TypeError('Invalid complete-install segment bounds or hashes')
|
|
46
|
-
end = part.offset + part.bytes
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return structuredClone(value)
|
|
50
|
-
}
|
|
21
|
+
|
|
51
22
|
async function download(url, fetcher, signal) {
|
|
52
23
|
if (typeof url !== 'string' || new URL(url).protocol !== 'https:') throw new TypeError('Firmware URLs must use HTTPS')
|
|
53
24
|
if (typeof fetcher !== 'function') throw new TypeError('Provide fetch or an in-memory catalog/image')
|
|
@@ -64,7 +35,7 @@ export async function checkFirmware(request = {}, connectedIdentity) {
|
|
|
64
35
|
if (request.version !== undefined && (typeof request.version !== 'string' || !request.version.trim())) throw new TypeError('version must be a non-empty firmware version string')
|
|
65
36
|
const mode = firmwareMode(request.mode)
|
|
66
37
|
const current = identity(connectedIdentity ?? request.identity)
|
|
67
|
-
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
38
|
+
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, version: request.version, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
68
39
|
abort(signal)
|
|
69
40
|
if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.releases)) throw new TypeError('Catalog requires schemaVersion 1 and releases ordered newest first')
|
|
70
41
|
// Feed ordering is authoritative: firmware versions may be calendar/git labels.
|
|
@@ -88,15 +59,25 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
88
59
|
if (typeof openLoader !== 'function' || typeof onProgress !== 'function' || typeof onPhase !== 'function' || (reset !== undefined && typeof reset !== 'function')) throw new TypeError('Firmware update requires openLoader and valid callbacks')
|
|
89
60
|
const mode = firmwareMode(request.mode)
|
|
90
61
|
const target = release(request.release, mode)
|
|
91
|
-
const
|
|
62
|
+
const installation = target.install ? {
|
|
92
63
|
...target.image, offset: target.install.offset, bytes: target.install.bytes,
|
|
93
64
|
flashSize: target.install.flashSize, sha256: target.install.sha256, url: target.install.url,
|
|
94
65
|
bootloader: target.install.bootloader, application: target.image,
|
|
95
66
|
chipRevision: undefined, chipRevisionMin: target.install.chipRevisionMin, chipRevisionMax: target.install.chipRevisionMax,
|
|
96
|
-
} :
|
|
67
|
+
} : null
|
|
68
|
+
const image = mode === 'install' ? installation : target.image
|
|
97
69
|
const board = connectedIdentity?.board ?? request.board
|
|
98
70
|
if (!board || board !== target.board || (request.board !== undefined && request.board !== board)) throw new TypeError('Firmware release does not match the connected or explicitly selected board')
|
|
99
|
-
const
|
|
71
|
+
const source = mode === 'update' ? image.source : undefined
|
|
72
|
+
const descriptor = source ?? image
|
|
73
|
+
let bytes = request.data === undefined
|
|
74
|
+
? await downloadBytes(descriptor.url, { fetch: fetcher, signal, maxBytes: MAX_FIRMWARE_BYTES, expectedBytes: descriptor.bytes, immutable: target.image.source !== undefined })
|
|
75
|
+
: request.data instanceof Uint8Array ? request.data.slice() : request.data
|
|
76
|
+
if (source) {
|
|
77
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.bytes) throw new Error('Complete BIN source size does not match the approved build')
|
|
78
|
+
await validateFirmwareImage(installation, bytes, { mode: 'install' })
|
|
79
|
+
bytes = bytes.slice(source.offset, source.offset + image.bytes)
|
|
80
|
+
}
|
|
100
81
|
await validateFirmwareImage(image, bytes, { mode })
|
|
101
82
|
abort(signal)
|
|
102
83
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
|
+
import vm from 'node:vm'
|
|
9
|
+
import { webcrypto } from 'node:crypto'
|
|
10
|
+
import { build } from 'esbuild'
|
|
11
|
+
|
|
12
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
13
|
+
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-browser-'))
|
|
14
|
+
try {
|
|
15
|
+
// Bundle the actual pinned dependency and its dynamic JSON stubs for browsers.
|
|
16
|
+
const result = await build({
|
|
17
|
+
stdin: { contents: `export { createDevice } from './src/index.js'; export { openBrowserRom } from './src/browser.js'; export { loadReleaseManifest } from './src/firmware.js'; export { ESPLoader as TestLoader } from 'tasmota-webserial-esptool'; export { browserRomCompatibility as testCompatibility } from './src/browser_rom_compat.js';`, resolveDir: root },
|
|
18
|
+
bundle: true, platform: 'browser', format: 'iife', globalName: 'DeviceCore',
|
|
19
|
+
write: false, metafile: true,
|
|
20
|
+
})
|
|
21
|
+
const code = result.outputFiles[0].text
|
|
22
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('tasmota-webserial-esptool/dist/esp_loader.js')))
|
|
23
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
|
|
24
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
|
|
25
|
+
assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
|
|
26
|
+
const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
27
|
+
vm.runInContext(code, context)
|
|
28
|
+
let opens = 0, closes = 0
|
|
29
|
+
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
30
|
+
async open() { opens++; throw new Error('real dependency reached SerialPort.open') },
|
|
31
|
+
async close() { closes++ },
|
|
32
|
+
} })
|
|
33
|
+
assert.equal(opens, 0, 'import and construction must not open/reset the port')
|
|
34
|
+
await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
|
|
35
|
+
assert.equal(opens, 1)
|
|
36
|
+
// Run upstream's actual SLIP parser in both CDC and UART modes. A blank board
|
|
37
|
+
// may emit a boot log before the stub greeting; invalid escapes must still fail.
|
|
38
|
+
for (const cdc of [false, true]) {
|
|
39
|
+
const raw = new context.DeviceCore.TestLoader({}, { log() {}, error() {}, debug() {} })
|
|
40
|
+
context.DeviceCore.testCompatibility(raw)
|
|
41
|
+
raw._isCDCDevice = cdc
|
|
42
|
+
raw.__inputBuffer = []
|
|
43
|
+
raw._inputBuffer.push(71, 97, 114, 0xc0, 79, 72, 65, 73, 0xc0)
|
|
44
|
+
assert.deepEqual(Array.from(await raw.readPacket(100)), [79, 72, 65, 73])
|
|
45
|
+
raw._clearInputBuffer()
|
|
46
|
+
raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
|
|
47
|
+
await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
|
|
48
|
+
}
|
|
49
|
+
const prototype = context.DeviceCore.TestLoader.prototype
|
|
50
|
+
const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
|
|
51
|
+
let initializeCalls = 0, disconnectCalls = 0
|
|
52
|
+
prototype.disconnect = async function () { disconnectCalls++; await this.port.close() }
|
|
53
|
+
const fakePort = () => ({
|
|
54
|
+
async open() { this.readable = {}; this.writable = {} },
|
|
55
|
+
async close() { this.readable = null; this.writable = null },
|
|
56
|
+
})
|
|
57
|
+
try {
|
|
58
|
+
prototype.initialize = async () => { initializeCalls++ }
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
const loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
|
|
61
|
+
const first = loader.initialize()
|
|
62
|
+
assert.equal(loader.initialize(), first, 'initialize must share one ROM connection')
|
|
63
|
+
await first
|
|
64
|
+
controller.abort()
|
|
65
|
+
assert.equal(disconnectCalls, 0, 'abort after initialization must not interrupt flash/readback')
|
|
66
|
+
await loader.disconnect(); await loader.disconnect()
|
|
67
|
+
assert.equal(disconnectCalls, 1)
|
|
68
|
+
assert.equal(initializeCalls, 1)
|
|
69
|
+
prototype.initialize = async () => { throw new Error('sync failed') }
|
|
70
|
+
await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /sync failed/)
|
|
71
|
+
assert.equal(disconnectCalls, 2, 'failed ROM sync must release the acquired port')
|
|
72
|
+
let finishOpen
|
|
73
|
+
const waitingPort = fakePort()
|
|
74
|
+
const open = waitingPort.open
|
|
75
|
+
waitingPort.open = async function () {
|
|
76
|
+
await new Promise(resolve => { finishOpen = resolve })
|
|
77
|
+
await open.call(this)
|
|
78
|
+
}
|
|
79
|
+
const cancelled = new AbortController()
|
|
80
|
+
const late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
|
|
81
|
+
const starting = late.initialize()
|
|
82
|
+
while (!finishOpen) await new Promise(resolve => setTimeout(resolve, 1))
|
|
83
|
+
cancelled.abort()
|
|
84
|
+
await assert.rejects(starting, { name: 'AbortError' })
|
|
85
|
+
finishOpen()
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
87
|
+
assert.equal(disconnectCalls, 3, 'an open completing after cancellation must close')
|
|
88
|
+
assert.equal(waitingPort.readable, null)
|
|
89
|
+
const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
|
|
90
|
+
const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
91
|
+
const release = { board: 'BOARD', version: 'browser-test', image: {
|
|
92
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302,
|
|
93
|
+
offset: 65536, bytes: data.byteLength, partitionSize: 4096,
|
|
94
|
+
sha256: await hash(data), partitionTable: { offset: 32768, bytes: table.byteLength, sha256: await hash(table) },
|
|
95
|
+
} }
|
|
96
|
+
let stubs = 0, writes = 0, resets = 0
|
|
97
|
+
prototype.initialize = async function () { this.chipFamily = 0x3280; this.chipRevision = 302 }
|
|
98
|
+
prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
|
|
99
|
+
prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
|
|
100
|
+
const device = context.DeviceCore.createDevice()
|
|
101
|
+
const request = {
|
|
102
|
+
release, board: 'BOARD', data,
|
|
103
|
+
openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: fakePort(), signal }),
|
|
104
|
+
}
|
|
105
|
+
await assert.rejects(device.firmware.update(request), /Secure boot/)
|
|
106
|
+
assert.equal(stubs, 0, 'ROM security must be checked before the browser launches a stub')
|
|
107
|
+
await assert.rejects(device.firmware.update({ ...request, data: new Uint8Array(4) }), /SHA-256/)
|
|
108
|
+
assert.equal(stubs, 0)
|
|
109
|
+
prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
|
|
110
|
+
const writing = new AbortController()
|
|
111
|
+
prototype.runStub = async () => {
|
|
112
|
+
stubs++
|
|
113
|
+
return {
|
|
114
|
+
IS_STUB: true,
|
|
115
|
+
async flashData() { writes++; writing.abort() },
|
|
116
|
+
async readFlash(offset) { return offset === 32768 ? table : data },
|
|
117
|
+
async readRegister() { return 0 },
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
prototype.hardReset = async () => { resets++ }
|
|
121
|
+
const beforeUpdateCloses = disconnectCalls
|
|
122
|
+
const updated = await device.firmware.update({ ...request, signal: writing.signal })
|
|
123
|
+
assert.equal(updated.verified, true)
|
|
124
|
+
assert.equal(updated.needsReset, false)
|
|
125
|
+
assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 1)
|
|
126
|
+
assert.equal(disconnectCalls, beforeUpdateCloses + 1, 'write-time abort must preserve complete readback, reset and one cleanup')
|
|
127
|
+
const manifest = { schemaVersion: 1, version: 'browser-test', sourceCommit: 'a'.repeat(40), pipelineId: 1, layout: 'legacy',
|
|
128
|
+
releases: [{ ...release, image: { ...release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/app.bin' } }],
|
|
129
|
+
website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin',
|
|
130
|
+
boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
|
|
131
|
+
const rawManifest = new TextEncoder().encode(JSON.stringify(manifest)), caches = []
|
|
132
|
+
const snapshot = await context.DeviceCore.loadReleaseManifest({ url: 'https://dl.espressif.com/AE/esp-vision/releases/browser-test/manifest.json', sha256: await hash(rawManifest),
|
|
133
|
+
async fetch(_url, options) {
|
|
134
|
+
caches.push(options.cache)
|
|
135
|
+
if (caches.length === 1) throw new TypeError('Cached response CORS failure from the host realm')
|
|
136
|
+
return new Response(rawManifest)
|
|
137
|
+
} })
|
|
138
|
+
assert.equal(snapshot.version, 'browser-test')
|
|
139
|
+
assert.deepEqual(caches, ['default', 'no-store'], 'real browser bundle must recover a fetch TypeError from host-provided fetch')
|
|
140
|
+
|
|
141
|
+
} finally { Object.assign(prototype, originals) }
|
|
142
|
+
// Node import must not eagerly resolve tasmota's browser-only, extensionless ESM.
|
|
143
|
+
const { openBrowserRom } = await import(pathToFileURL(join(root, 'src/browser.js')))
|
|
144
|
+
assert.equal(typeof openBrowserRom, 'function')
|
|
145
|
+
const dependency = JSON.parse(await readFile(join(root, 'node_modules/tasmota-webserial-esptool/package.json'), 'utf8'))
|
|
146
|
+
assert.equal(dependency.version, '9.4.2')
|
|
147
|
+
const stubsLicense = await readFile(join(root, 'node_modules/tasmota-webserial-esptool/src/stubs/README.md'), 'utf8')
|
|
148
|
+
assert.match(stubsLicense, /dual licensed.*Apache License Version 2.0 or the MIT license/s)
|
|
149
|
+
await writeFile(join(temporary, 'browser.js'), code)
|
|
150
|
+
console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true }))
|
|
151
|
+
} finally { await rm(temporary, { recursive: true, force: true }) }
|
package/tests/check-package.mjs
CHANGED
|
@@ -37,8 +37,11 @@ try {
|
|
|
37
37
|
const api = await import('@esp-vision/device-core');
|
|
38
38
|
if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
|
|
39
39
|
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
|
-
const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
|
|
40
|
+
const { FirmwareFlashSession, loadOfficialFirmware, loadReleaseManifest } = await import('@esp-vision/device-core/firmware');
|
|
41
|
+
if (typeof loadOfficialFirmware !== 'function' || typeof loadReleaseManifest !== 'function') throw new Error('Missing public release manifest readers');
|
|
41
42
|
const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
43
|
+
const { openBrowserRom } = await import('@esp-vision/device-core/browser');
|
|
44
|
+
if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
|
|
42
45
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
43
46
|
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
44
47
|
const { EventEmitter } = await import('node:events');
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { openBrowserRom } from '../src/browser.js'
|
|
6
|
+
import { browserRomCompatibility } from '../src/browser_rom_compat.js'
|
|
7
|
+
|
|
8
|
+
const port = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
|
|
9
|
+
test('browser ROM construction and disconnect are passive without browser globals', async () => {
|
|
10
|
+
const adapter = openBrowserRom({ port: port() })
|
|
11
|
+
assert.equal(adapter.chipFamily, null)
|
|
12
|
+
assert.equal(adapter.chipRevision, null)
|
|
13
|
+
assert.equal(adapter.chipName, null)
|
|
14
|
+
await adapter.disconnect(); await adapter.disconnect()
|
|
15
|
+
await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
|
|
16
|
+
})
|
|
17
|
+
test('browser ROM validates its port and cancellation before loading the browser dependency', async () => {
|
|
18
|
+
assert.throws(() => openBrowserRom(), /SerialPort/)
|
|
19
|
+
assert.throws(() => openBrowserRom({ port: port(), signal: {} }), /AbortSignal/)
|
|
20
|
+
const controller = new AbortController()
|
|
21
|
+
controller.abort(new Error('cancelled before open'))
|
|
22
|
+
await assert.rejects(openBrowserRom({ port: port(), signal: controller.signal }).initialize(), /cancelled before open/)
|
|
23
|
+
})
|
|
24
|
+
test('SLIP prefix compatibility retries only pre-header bytes within the original deadline', async () => {
|
|
25
|
+
let count = 0
|
|
26
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
27
|
+
if (++count < 3) throw new Error('Invalid head of packet (0x47)')
|
|
28
|
+
return [79, 72, 65, 73]
|
|
29
|
+
} })
|
|
30
|
+
assert.deepEqual(await loader.readPacket(100), [79, 72, 65, 73])
|
|
31
|
+
loader.readPacket = browserRomCompatibility({ readPacket: async () => { throw new Error('Invalid SLIP escape (0xdb, 0x47)') } }).readPacket
|
|
32
|
+
await assert.rejects(loader.readPacket(100), /Invalid SLIP escape/)
|
|
33
|
+
})
|
|
34
|
+
test('SLIP log noise cannot extend the operation deadline indefinitely', async () => {
|
|
35
|
+
let count = 0
|
|
36
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
37
|
+
count++
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
39
|
+
throw new Error('Invalid head of packet (0x47)')
|
|
40
|
+
} })
|
|
41
|
+
await assert.rejects(loader.readPacket(15), /Invalid head/)
|
|
42
|
+
assert.ok(count <= 4)
|
|
43
|
+
})
|
|
44
|
+
function stubFixture({ native = true, error = 'Timed out waiting for packet header', handshake = true } = {}) {
|
|
45
|
+
let timeout, reads = 0
|
|
46
|
+
const stub = { IS_STUB: true, readPacket: async () => [], checkCommand: (...args) => { timeout = args[3] } }
|
|
47
|
+
const raw = {
|
|
48
|
+
isUsbJtagOrOtg: native,
|
|
49
|
+
memFinish: async () => { if (error) throw new Error(error) },
|
|
50
|
+
readPacket: async () => { reads++; return handshake ? [79, 72, 65, 73] : [] },
|
|
51
|
+
async runStub(skip) {
|
|
52
|
+
assert.equal(skip, true)
|
|
53
|
+
await this.memFinish()
|
|
54
|
+
assert.deepEqual(await this.readPacket(2500), [79, 72, 65, 73], 'OHAI required')
|
|
55
|
+
return stub
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return { loader: browserRomCompatibility(raw), stub, timeout: () => timeout, reads: () => reads }
|
|
59
|
+
}
|
|
60
|
+
test('native USB may miss MEM_END acknowledgement but still requires OHAI', async () => {
|
|
61
|
+
const fixture = stubFixture()
|
|
62
|
+
assert.equal(await fixture.loader.runStub(true), fixture.stub)
|
|
63
|
+
assert.equal(fixture.reads(), 1)
|
|
64
|
+
await assert.rejects(stubFixture({ handshake: false }).loader.runStub(true), /OHAI required/)
|
|
65
|
+
})
|
|
66
|
+
test('UART timeouts and native USB non-timeout errors retain their failures', async () => {
|
|
67
|
+
await assert.rejects(stubFixture({ native: false }).loader.runStub(true), /Timed out/)
|
|
68
|
+
await assert.rejects(stubFixture({ error: 'Port disconnected' }).loader.runStub(true), /Port disconnected/)
|
|
69
|
+
})
|
|
70
|
+
test('stub launch restores temporary hooks on failure and rejects ROM fallback', async () => {
|
|
71
|
+
const fixture = stubFixture({ handshake: false })
|
|
72
|
+
const finish = fixture.loader.memFinish, packet = fixture.loader.readPacket
|
|
73
|
+
await assert.rejects(fixture.loader.runStub(true))
|
|
74
|
+
assert.equal(fixture.loader.memFinish, finish)
|
|
75
|
+
assert.equal(fixture.loader.readPacket, packet)
|
|
76
|
+
await assert.rejects(browserRomCompatibility({ runStub: async () => ({ IS_STUB: false }) }).runStub(true), /no supported browser flash stub/)
|
|
77
|
+
})
|
|
78
|
+
test('stub FLASH_BEGIN timeout scales with uncompressed erase size and preserves other commands', async () => {
|
|
79
|
+
const fixture = stubFixture()
|
|
80
|
+
const stub = await fixture.loader.runStub(true)
|
|
81
|
+
await stub.checkCommand(2, [0, 0, 128, 0], 0, 3000)
|
|
82
|
+
assert.ok(fixture.timeout() > 240000)
|
|
83
|
+
await stub.checkCommand(3, [], 0, 1234)
|
|
84
|
+
assert.equal(fixture.timeout(), 1234)
|
|
85
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { createDevice } from '../src/index.js'
|
|
6
|
+
import { firmwareSha256 } from '../src/firmware.js'
|
|
7
|
+
|
|
8
|
+
async function fixture() {
|
|
9
|
+
const full = new Uint8Array(0x11000).fill(0xff), appOffset = 0x10000, appLength = 4, calls = []
|
|
10
|
+
full.set([0xe9, 1, 2, 3], 0x2000); full.set([0xaa, 0x50, 4, 5], 0x8000); full.set([0xe9, 6, 7, 8], appOffset)
|
|
11
|
+
const segment = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(full.subarray(offset, offset + 4)) })
|
|
12
|
+
const source = { url: 'https://example.test/full.bin', bytes: full.length, sha256: await firmwareSha256(full), offset: appOffset }
|
|
13
|
+
const release = { board: 'BOARD', version: 'new', image: {
|
|
14
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(appOffset)), partitionSize: 4096,
|
|
15
|
+
partitionTable: await segment(0x8000), url: source.url, source,
|
|
16
|
+
}, install: { url: source.url, offset: 0, bytes: full.length, sha256: source.sha256, flashSize: 0x1000000,
|
|
17
|
+
chipRevisionMin: 302, chipRevisionMax: 302, bootloader: await segment(0x2000) } }
|
|
18
|
+
const flash = new Uint8Array(full.length).fill(0x5a)
|
|
19
|
+
flash.set(full.subarray(0x8000, 0x8004), 0x8000)
|
|
20
|
+
const before = flash.slice()
|
|
21
|
+
const loader = {
|
|
22
|
+
chipFamily: 0x3280, chipRevision: 302,
|
|
23
|
+
async initialize() { calls.push('initialize') },
|
|
24
|
+
async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
|
|
25
|
+
async runStub() { calls.push('stub'); return loader },
|
|
26
|
+
async flashId() { return 0x1840ef },
|
|
27
|
+
async flashData(buffer, progress, offset) { calls.push(['write', offset, buffer.byteLength]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
|
|
28
|
+
async readFlash(offset, bytes) { return flash.slice(offset, offset + bytes) },
|
|
29
|
+
async readRegister() { return 0 },
|
|
30
|
+
async hardReset() { calls.push('reset') },
|
|
31
|
+
async disconnect() { calls.push('close') },
|
|
32
|
+
}
|
|
33
|
+
const request = { release, board: 'BOARD', openLoader: async () => { calls.push('open'); return loader }, fetch: async (url, options) => {
|
|
34
|
+
calls.push(['download', url]); assert.equal(options.redirect, 'error'); assert.equal(options.headers, undefined)
|
|
35
|
+
return new Response(full)
|
|
36
|
+
} }
|
|
37
|
+
return { device: createDevice(), release, request, full, flash, before, loader, calls, appOffset, appLength }
|
|
38
|
+
}
|
|
39
|
+
test('single BIN update verifies the container then writes only the application, preserving all other bytes', async () => {
|
|
40
|
+
const f = await fixture()
|
|
41
|
+
const result = await f.device.firmware.update(f.request)
|
|
42
|
+
assert.equal(result.verified, true)
|
|
43
|
+
assert.deepEqual(f.calls.filter(call => Array.isArray(call)), [['download', f.release.image.source.url], ['write', f.appOffset, f.appLength]])
|
|
44
|
+
assert.deepEqual(f.flash.slice(0, f.appOffset), f.before.slice(0, f.appOffset))
|
|
45
|
+
assert.deepEqual(f.flash.slice(f.appOffset + f.appLength), f.before.slice(f.appOffset + f.appLength))
|
|
46
|
+
assert.deepEqual(f.flash.slice(f.appOffset, f.appOffset + f.appLength), f.full.slice(f.appOffset, f.appOffset + f.appLength))
|
|
47
|
+
})
|
|
48
|
+
test('provided data means the complete BIN when image.source exists; application bytes alone are rejected', async () => {
|
|
49
|
+
const f = await fixture()
|
|
50
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, data: f.full.slice(f.appOffset, f.appOffset + f.appLength) }), /source size/)
|
|
51
|
+
assert.deepEqual(f.calls, [])
|
|
52
|
+
assert.equal((await f.device.firmware.update({ ...f.request, data: f.full })).verified, true)
|
|
53
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'download'))
|
|
54
|
+
})
|
|
55
|
+
test('old application-only descriptors still download and verify a raw application BIN', async () => {
|
|
56
|
+
const f = await fixture(); delete f.release.image.source; f.release.image.url = 'https://example.test/app.bin'
|
|
57
|
+
let downloads = 0
|
|
58
|
+
const result = await f.device.firmware.update({ ...f.request, fetch: async url => {
|
|
59
|
+
downloads++; assert.equal(url, f.release.image.url); return new Response(f.full.slice(f.appOffset, f.appOffset + f.appLength))
|
|
60
|
+
} })
|
|
61
|
+
assert.equal(result.verified, true); assert.equal(downloads, 1)
|
|
62
|
+
})
|
|
63
|
+
for (const mutation of ['outside-app-corruption', 'source-truncated', 'source-long', 'app-hash', 'source-offset', 'source-bounds', 'source-size-limit', 'source-install-hash', 'source-install-url', 'image-source-url', 'source-http', 'bootloader-descriptor', 'table-descriptor']) {
|
|
64
|
+
test(`invalid source ${mutation} rejects before any ROM acquisition`, async () => {
|
|
65
|
+
const f = await fixture(), source = f.release.image.source
|
|
66
|
+
let bytes = f.full
|
|
67
|
+
if (mutation === 'outside-app-corruption') bytes[1] ^= 1
|
|
68
|
+
if (mutation === 'source-truncated') bytes = bytes.slice(0, -1)
|
|
69
|
+
if (mutation === 'source-long') bytes = new Uint8Array(bytes.length + 1)
|
|
70
|
+
if (mutation === 'app-hash') f.release.image.sha256 = '0'.repeat(64)
|
|
71
|
+
if (mutation === 'source-offset') source.offset++
|
|
72
|
+
if (mutation === 'source-bounds') source.bytes = 1
|
|
73
|
+
if (mutation === 'source-size-limit') source.bytes = 257 * 1024 * 1024
|
|
74
|
+
if (mutation === 'source-install-hash') source.sha256 = '0'.repeat(64)
|
|
75
|
+
if (mutation === 'source-install-url') f.release.install.url = 'https://example.test/other.bin'
|
|
76
|
+
if (mutation === 'image-source-url') f.release.image.url = 'https://example.test/app.bin'
|
|
77
|
+
if (mutation === 'bootloader-descriptor') f.release.install.bootloader.sha256 = '0'.repeat(64)
|
|
78
|
+
if (mutation === 'table-descriptor') f.release.image.partitionTable.sha256 = '0'.repeat(64)
|
|
79
|
+
if (mutation === 'source-http') source.url = f.release.image.url = f.release.install.url = 'http://example.test/full.bin'
|
|
80
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, data: bytes }))
|
|
81
|
+
assert.deepEqual(f.calls, [])
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
test('downloaded full BIN enforces approved length during streaming before opening ROM', async () => {
|
|
85
|
+
const f = await fixture()
|
|
86
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, fetch: async () => new Response(new Uint8Array(f.full.length + 1)) }), /byte limit/)
|
|
87
|
+
assert.deepEqual(f.calls, [])
|
|
88
|
+
})
|
|
89
|
+
test('single BIN explicit install writes the complete container without slicing it', async () => {
|
|
90
|
+
const f = await fixture()
|
|
91
|
+
assert.equal((await f.device.firmware.update({ ...f.request, data: f.full, mode: 'install' })).verified, true)
|
|
92
|
+
assert.deepEqual(f.flash, f.full)
|
|
93
|
+
assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', 0, f.full.length])
|
|
94
|
+
})
|
|
95
|
+
test('single BIN application update still refuses changed partition layouts with no install fallback', async () => {
|
|
96
|
+
const f = await fixture(); f.flash[0x8000] ^= 1
|
|
97
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, data: f.full }), /Partition layout/)
|
|
98
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
|
|
99
|
+
assert.equal(f.calls.at(-1), 'close')
|
|
100
|
+
})
|
|
101
|
+
test('source extraction retains complete application readback verification and does not reset on corruption', async () => {
|
|
102
|
+
const f = await fixture(), read = f.loader.readFlash
|
|
103
|
+
f.loader.readFlash = async (offset, count) => offset === 0x8000 ? read(offset, count) : new Uint8Array(count)
|
|
104
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, data: f.full }), /verification failed/)
|
|
105
|
+
assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('single BIN update and explicit installation reuse hash-pinned browser-cache responses', async () => {
|
|
109
|
+
for (const mode of ['update', 'install']) {
|
|
110
|
+
const f = await fixture(), caches = []
|
|
111
|
+
const result = await f.device.firmware.update({ ...f.request, mode, fetch: async (_url, options) => {
|
|
112
|
+
caches.push(options.cache)
|
|
113
|
+
return new Response(f.full)
|
|
114
|
+
} })
|
|
115
|
+
assert.equal(result.verified, true)
|
|
116
|
+
assert.deepEqual(caches, ['default'])
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
test('single BIN cached CORS failure retries once and retains application-only verification', async () => {
|
|
120
|
+
const f = await fixture(), caches = []
|
|
121
|
+
const result = await f.device.firmware.update({ ...f.request, fetch: async (_url, options) => {
|
|
122
|
+
caches.push(options.cache)
|
|
123
|
+
if (caches.length === 1) throw new TypeError('Cached response lacks CORS permission')
|
|
124
|
+
return new Response(f.full)
|
|
125
|
+
} })
|
|
126
|
+
assert.equal(result.verified, true)
|
|
127
|
+
assert.deepEqual(caches, ['default', 'no-store'])
|
|
128
|
+
assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', f.appOffset, f.appLength])
|
|
129
|
+
})
|
|
130
|
+
test('single BIN hash mismatch never retries cache or opens ROM; legacy app BIN stays no-store', async () => {
|
|
131
|
+
const f = await fixture(), caches = []
|
|
132
|
+
f.full[0] ^= 1
|
|
133
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, fetch: async (_url, options) => {
|
|
134
|
+
caches.push(options.cache); return new Response(f.full)
|
|
135
|
+
} }), /SHA-256/)
|
|
136
|
+
assert.deepEqual(caches, ['default']); assert.deepEqual(f.calls, [])
|
|
137
|
+
delete f.release.image.source
|
|
138
|
+
caches.length = 0
|
|
139
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, fetch: async (_url, options) => {
|
|
140
|
+
caches.push(options.cache); throw new TypeError('Network failure')
|
|
141
|
+
} }), /Network failure/)
|
|
142
|
+
assert.deepEqual(caches, ['no-store']); assert.deepEqual(f.calls, [])
|
|
143
|
+
})
|