@esp-vision/device-core 0.6.1 → 0.7.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 +17 -0
- package/README.md +7 -4
- package/package.json +2 -2
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +33 -5
- package/src/node_rom.py +26 -1
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +8 -1
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom.mjs +87 -3
|
@@ -2,43 +2,15 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { test } from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
|
-
import {
|
|
6
|
-
import { firmwareSha256 } from '../src/firmware_flash.js'
|
|
7
|
-
|
|
8
|
-
async function fixture() {
|
|
9
|
-
const data = new Uint8Array(0x11000).fill(0xff), calls = []
|
|
10
|
-
data.set([0xe9, 1, 2, 3], 0x2000)
|
|
11
|
-
data.set([0xaa, 0x50, 4, 5], 0x8000)
|
|
12
|
-
data.set([0xe9, 6, 7, 8], 0x10000)
|
|
13
|
-
const part = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(data.subarray(offset, offset + 4)) })
|
|
14
|
-
const release = { board: 'BOARD', version: 'new', image: {
|
|
15
|
-
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
|
|
16
|
-
partitionSize: 4096, partitionTable: await part(0x8000), url: 'https://example.test/app.bin',
|
|
17
|
-
}, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
|
|
18
|
-
sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
|
|
19
|
-
const flash = new Uint8Array(data.length).fill(0xff)
|
|
20
|
-
const loader = {
|
|
21
|
-
chipFamily: 0x3280, chipRevision: 302,
|
|
22
|
-
async initialize() { calls.push('initialize') },
|
|
23
|
-
async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
|
|
24
|
-
async runStub() { calls.push('stub'); return loader },
|
|
25
|
-
async flashId() { calls.push('capacity'); return 0x1840ef },
|
|
26
|
-
async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
|
|
27
|
-
async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
|
|
28
|
-
async readRegister() { return 0 },
|
|
29
|
-
async hardReset() { calls.push('reset') },
|
|
30
|
-
async disconnect() { calls.push('close') },
|
|
31
|
-
}
|
|
32
|
-
const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
|
|
33
|
-
return { data, calls, release, loader, device, request, flash }
|
|
34
|
-
}
|
|
5
|
+
import { installFixture as fixture } from './firmware-fixtures.mjs'
|
|
35
6
|
|
|
36
7
|
test('blank device installs through ROM without an EV-MUX transport or existing partition table', async () => {
|
|
37
8
|
const f = await fixture(), progress = []
|
|
38
9
|
const result = await f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) })
|
|
39
10
|
assert.equal(result.verified, true); assert.equal(result.reconnected, false)
|
|
40
|
-
assert.deepEqual(f.flash, f.data)
|
|
41
|
-
assert.
|
|
11
|
+
assert.deepEqual(f.flash.slice(0, f.data.length), f.data)
|
|
12
|
+
assert.ok(f.flash.subarray(0x20000).every(value => value === 0xff))
|
|
13
|
+
assert.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['erase', 0x20000, 0x21000], ['read', 0x20000], ['read', 0x30000], ['read', 0x40000], ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
|
|
42
14
|
assert.equal(progress.at(-1), 100)
|
|
43
15
|
})
|
|
44
16
|
|
|
@@ -54,7 +26,7 @@ test('complete image is downloaded from install URL with the requested cancellat
|
|
|
54
26
|
await f.device.firmware.update({ ...f.request, data: undefined, signal: controller.signal,
|
|
55
27
|
async fetch(url, options) { assert.equal(url, f.release.install.url); assert.equal(options.signal, controller.signal); return { ok: true, arrayBuffer: async () => f.data.buffer } },
|
|
56
28
|
})
|
|
57
|
-
assert.deepEqual(f.flash, f.data)
|
|
29
|
+
assert.deepEqual(f.flash.slice(0, f.data.length), f.data)
|
|
58
30
|
})
|
|
59
31
|
|
|
60
32
|
test('install selection skips app-only releases and rejects explicit unqualified versions', async () => {
|
|
@@ -97,13 +69,14 @@ for (const change of ['wrong-chip', 'wrong-revision', 'secure-boot', 'encryption
|
|
|
97
69
|
if (change === 'unknown-flash') f.loader.flashId = async () => 0xffffff
|
|
98
70
|
if (change === 'missing-capacity') delete f.loader.flashId
|
|
99
71
|
await assert.rejects(f.device.firmware.update(f.request))
|
|
100
|
-
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0]
|
|
72
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && ['write', 'erase'].includes(call[0])))
|
|
101
73
|
assert.equal(f.calls.at(-1), 'close')
|
|
102
74
|
})
|
|
103
75
|
}
|
|
104
76
|
|
|
105
77
|
test('installation with damaged readback does not reset or report success', async () => {
|
|
106
|
-
const f = await fixture()
|
|
78
|
+
const f = await fixture(), read = f.loader.readFlash
|
|
79
|
+
f.loader.readFlash = async (offset, count) => offset >= 0x20000 ? read(offset, count) : new Uint8Array(count)
|
|
107
80
|
await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
|
|
108
81
|
assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
|
|
109
82
|
})
|
|
@@ -4,19 +4,20 @@ import test from 'node:test'
|
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
5
|
import { createDevice } from '../src/index.js'
|
|
6
6
|
import { firmwareSha256 } from '../src/firmware.js'
|
|
7
|
+
import { partitionTable } from './firmware-fixtures.mjs'
|
|
7
8
|
|
|
8
9
|
async function fixture() {
|
|
9
10
|
const full = new Uint8Array(0x11000).fill(0xff), appOffset = 0x10000, appLength = 4, calls = []
|
|
10
|
-
full.set([0xe9, 1, 2, 3], 0x2000); full.set(
|
|
11
|
-
const segment = async offset => ({ offset, bytes:
|
|
11
|
+
full.set([0xe9, 1, 2, 3], 0x2000); full.set(partitionTable(), 0x8000); full.set([0xe9, 6, 7, 8], appOffset)
|
|
12
|
+
const segment = async (offset, size = 4) => ({ offset, bytes: size, sha256: await firmwareSha256(full.subarray(offset, offset + size)) })
|
|
12
13
|
const source = { url: 'https://example.test/full.bin', bytes: full.length, sha256: await firmwareSha256(full), offset: appOffset }
|
|
13
14
|
const release = { board: 'BOARD', version: 'new', image: {
|
|
14
|
-
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(appOffset)), partitionSize:
|
|
15
|
-
partitionTable: await segment(0x8000), url: source.url, source,
|
|
15
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(appOffset)), partitionSize: 0x10000,
|
|
16
|
+
partitionTable: await segment(0x8000, 0xc00), url: source.url, source,
|
|
16
17
|
}, install: { url: source.url, offset: 0, bytes: full.length, sha256: source.sha256, flashSize: 0x1000000,
|
|
17
18
|
chipRevisionMin: 302, chipRevisionMax: 302, bootloader: await segment(0x2000) } }
|
|
18
|
-
const flash = new Uint8Array(
|
|
19
|
-
flash.set(full.subarray(0x8000,
|
|
19
|
+
const flash = new Uint8Array(0x41000).fill(0x5a)
|
|
20
|
+
flash.set(full.subarray(0x8000, 0x8c00), 0x8000)
|
|
20
21
|
const before = flash.slice()
|
|
21
22
|
const loader = {
|
|
22
23
|
chipFamily: 0x3280, chipRevision: 302,
|
|
@@ -24,6 +25,7 @@ async function fixture() {
|
|
|
24
25
|
async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
|
|
25
26
|
async runStub() { calls.push('stub'); return loader },
|
|
26
27
|
async flashId() { return 0x1840ef },
|
|
28
|
+
async eraseRegion(offset, size) { calls.push(['erase', offset, size]); flash.fill(0xff, offset, offset + size) },
|
|
27
29
|
async flashData(buffer, progress, offset) { calls.push(['write', offset, buffer.byteLength]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
|
|
28
30
|
async readFlash(offset, bytes) { return flash.slice(offset, offset + bytes) },
|
|
29
31
|
async readRegister() { return 0 },
|
|
@@ -89,7 +91,8 @@ test('downloaded full BIN enforces approved length during streaming before openi
|
|
|
89
91
|
test('single BIN explicit install writes the complete container without slicing it', async () => {
|
|
90
92
|
const f = await fixture()
|
|
91
93
|
assert.equal((await f.device.firmware.update({ ...f.request, data: f.full, mode: 'install' })).verified, true)
|
|
92
|
-
assert.deepEqual(f.flash, f.full)
|
|
94
|
+
assert.deepEqual(f.flash.slice(0, f.full.length), f.full)
|
|
95
|
+
assert.ok(f.flash.subarray(0x20000).every(value => value === 0xff))
|
|
93
96
|
assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', 0, f.full.length])
|
|
94
97
|
})
|
|
95
98
|
test('single BIN application update still refuses changed partition layouts with no install fallback', async () => {
|
|
@@ -0,0 +1,153 @@
|
|
|
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 { FirmwareFlashSession, firmwareSha256 } from '../src/firmware_flash.js'
|
|
6
|
+
import { installFixture, partitionTable } from './firmware-fixtures.mjs'
|
|
7
|
+
|
|
8
|
+
const entries = () => [
|
|
9
|
+
{ label: 'nvs', type: 1, subtype: 2, offset: 0x9000, size: 0x6000 },
|
|
10
|
+
{ label: 'factory', type: 0, subtype: 0, offset: 0x10000, size: 0x10000 },
|
|
11
|
+
{ label: 'ffat', type: 1, subtype: 0x81, offset: 0x20000, size: 0x21000 },
|
|
12
|
+
]
|
|
13
|
+
async function replaceTable(f, raw) {
|
|
14
|
+
f.data.fill(0xff, 0x8000, 0x8c00)
|
|
15
|
+
f.data.set(raw, 0x8000)
|
|
16
|
+
f.release.image.partitionTable.bytes = raw.length
|
|
17
|
+
f.release.image.partitionTable.sha256 = await firmwareSha256(raw)
|
|
18
|
+
f.release.install.sha256 = await firmwareSha256(f.data)
|
|
19
|
+
}
|
|
20
|
+
const destructive = f => f.calls.filter(call => Array.isArray(call) && ['erase', 'write'].includes(call[0]))
|
|
21
|
+
|
|
22
|
+
for (const mutation of ['no-ffat', 'legacy-vfs', 'wrong-type', 'wrong-subtype', 'encrypted', 'readonly', 'unknown-flags',
|
|
23
|
+
'unaligned-offset', 'unaligned-size', 'zero-size', 'outside-flash', 'overlap', 'duplicate-label', 'wrong-app', 'reserved-range']) {
|
|
24
|
+
test(`install refuses ${mutation} in a hash-approved table before opening ROM`, async () => {
|
|
25
|
+
const f = await installFixture(), parts = entries(), fat = parts[2]
|
|
26
|
+
if (mutation === 'no-ffat') parts.pop()
|
|
27
|
+
if (mutation === 'legacy-vfs') fat.label = 'vfs'
|
|
28
|
+
if (mutation === 'wrong-type') fat.type = 0x40
|
|
29
|
+
if (mutation === 'wrong-subtype') fat.subtype = 0x82
|
|
30
|
+
if (mutation === 'encrypted') fat.flags = 1
|
|
31
|
+
if (mutation === 'readonly') fat.flags = 2
|
|
32
|
+
if (mutation === 'unknown-flags') fat.flags = 4
|
|
33
|
+
if (mutation === 'unaligned-offset') fat.offset++
|
|
34
|
+
if (mutation === 'unaligned-size') fat.size--
|
|
35
|
+
if (mutation === 'zero-size') fat.size = 0
|
|
36
|
+
if (mutation === 'outside-flash') fat.size = 0x1000000
|
|
37
|
+
if (mutation === 'overlap') fat.offset = 0x1f000
|
|
38
|
+
if (mutation === 'duplicate-label') parts[0].label = 'ffat'
|
|
39
|
+
if (mutation === 'wrong-app') parts[1].size = 0x1000
|
|
40
|
+
if (mutation === 'reserved-range') parts[0].offset = 0x8000
|
|
41
|
+
await replaceTable(f, partitionTable(parts))
|
|
42
|
+
await assert.rejects(f.device.firmware.update(f.request), error => ['FIRMWARE_PARTITION_INVALID', 'FIRMWARE_FATFS_UNAVAILABLE'].includes(error.code))
|
|
43
|
+
assert.deepEqual(f.calls, [])
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const mutation of ['bad-magic', 'bad-md5', 'duplicate-md5', 'entry-after-md5', 'data-after-end', 'short-table', 'missing-end', 'empty']) {
|
|
48
|
+
test(`install refuses ${mutation} despite an approved outer SHA`, async () => {
|
|
49
|
+
const f = await installFixture(), raw = partitionTable(entries())
|
|
50
|
+
if (mutation === 'bad-magic') raw[0] ^= 1
|
|
51
|
+
if (mutation === 'bad-md5') raw[112] ^= 1
|
|
52
|
+
if (mutation === 'duplicate-md5') raw.copyWithin(128, 96, 128)
|
|
53
|
+
if (mutation === 'entry-after-md5') raw.copyWithin(128, 0, 32)
|
|
54
|
+
if (mutation === 'data-after-end') raw[200] = 0
|
|
55
|
+
if (mutation === 'empty') raw.fill(0xff)
|
|
56
|
+
await replaceTable(f, mutation === 'short-table' ? raw.subarray(0, 129) : mutation === 'missing-end' ? raw.subarray(0, 128) : raw)
|
|
57
|
+
await assert.rejects(f.device.firmware.update(f.request), { code: 'FIRMWARE_PARTITION_INVALID' })
|
|
58
|
+
assert.deepEqual(f.calls, [])
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('historical tables without MD5 are accepted with their SHA and FF end marker', async () => {
|
|
63
|
+
const f = await installFixture()
|
|
64
|
+
await replaceTable(f, partitionTable(entries(), { md5: false }))
|
|
65
|
+
assert.equal((await f.device.firmware.update(f.request)).verified, true)
|
|
66
|
+
assert.deepEqual(destructive(f), [['erase', 0x20000, 0x21000], ['write', 0]])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
for (const failure of ['erase', 'read', 'short-read', 'not-erased', 'missing-erase', 'missing-read', 'missing-write']) {
|
|
70
|
+
test(`${failure} cannot write firmware, reset, or report complete installation`, async () => {
|
|
71
|
+
const f = await installFixture(), progress = [], read = f.loader.readFlash
|
|
72
|
+
if (failure === 'erase') f.loader.eraseRegion = async () => { f.calls.push(['erase', 0x20000, 0x21000]); throw new Error('erase denied') }
|
|
73
|
+
if (failure === 'read') f.loader.readFlash = async () => { throw new Error('port lost') }
|
|
74
|
+
if (failure === 'short-read') f.loader.readFlash = async () => new Uint8Array(1).fill(0xff)
|
|
75
|
+
if (failure === 'not-erased') f.loader.readFlash = async (offset, count) => { const result = await read(offset, count); result[0] = 0; return result }
|
|
76
|
+
if (failure === 'missing-erase') delete f.loader.eraseRegion
|
|
77
|
+
if (failure === 'missing-read') delete f.loader.readFlash
|
|
78
|
+
if (failure === 'missing-write') delete f.loader.flashData
|
|
79
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) }))
|
|
80
|
+
assert.ok(!destructive(f).some(call => call[0] === 'write'))
|
|
81
|
+
assert.ok(!f.calls.includes('reset'))
|
|
82
|
+
assert.ok(!progress.includes(100))
|
|
83
|
+
assert.equal(f.calls.at(-1), 'close')
|
|
84
|
+
assert.ok(destructive(f).length <= 1, 'never retry an uncertain erase')
|
|
85
|
+
if (failure.startsWith('missing-')) assert.deepEqual(destructive(f), [])
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const point of ['before-erase', 'during-erase', 'during-read', 'last-erase-progress', 'before-write']) {
|
|
90
|
+
test(`cancellation ${point} stops installation before any firmware write`, async () => {
|
|
91
|
+
const f = await installFixture(), controller = new AbortController(), reason = new Error('cancel install')
|
|
92
|
+
const erase = f.loader.eraseRegion, read = f.loader.readFlash
|
|
93
|
+
if (point === 'during-erase') f.loader.eraseRegion = async (...args) => { await erase(...args); controller.abort(reason) }
|
|
94
|
+
if (point === 'during-read') f.loader.readFlash = async (...args) => { const result = await read(...args); controller.abort(reason); return result }
|
|
95
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, signal: controller.signal,
|
|
96
|
+
onPhase: text => { if ((point === 'before-erase' && text.startsWith('Erasing')) || (point === 'before-write' && text.startsWith('Installing'))) controller.abort(reason) },
|
|
97
|
+
onProgress: percent => { if (point === 'last-erase-progress' && percent === 20) controller.abort(reason) },
|
|
98
|
+
}), error => error === reason)
|
|
99
|
+
assert.ok(!destructive(f).some(call => call[0] === 'write'))
|
|
100
|
+
assert.ok(!f.calls.includes('reset'))
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('application update preserves the FAT sentinel and never reads or erases FFAT', async () => {
|
|
105
|
+
const f = await installFixture(), table = f.release.image.partitionTable
|
|
106
|
+
f.flash.set(f.data.subarray(table.offset, table.offset + table.bytes), table.offset)
|
|
107
|
+
const before = f.flash.slice()
|
|
108
|
+
await f.device.firmware.update({ ...f.request, mode: 'update', data: f.data.slice(0x10000, 0x10004) })
|
|
109
|
+
assert.deepEqual(f.flash.subarray(0x20000), before.subarray(0x20000))
|
|
110
|
+
assert.deepEqual(destructive(f), [['write', 0x10000]])
|
|
111
|
+
assert.deepEqual(f.calls.filter(call => Array.isArray(call) && call[0] === 'read'), [['read', 0x8000], ['read', 0x10000]])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
function installationImage(f) {
|
|
115
|
+
return { ...f.release.image, ...f.release.install, application: f.release.image,
|
|
116
|
+
chipRevision: undefined, chipRevisionMin: 302, chipRevisionMax: 302 }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test('metadata and Buffer mutations cannot change qualified erase or write ranges', async () => {
|
|
120
|
+
const f = await installFixture(), original = f.data.slice(), buffer = Buffer.from(f.data)
|
|
121
|
+
const image = installationImage(f), session = new FirmwareFlashSession(f.loader, image, { mode: 'install' })
|
|
122
|
+
await session.connect(buffer)
|
|
123
|
+
image.sha256 = '0'.repeat(64); image.flashSize = 0x4000000; image.partitionTable.offset = 0
|
|
124
|
+
session.mode = 'update'
|
|
125
|
+
await session.flash(buffer, { onPhase: () => buffer.fill(0) })
|
|
126
|
+
assert.deepEqual(destructive(f), [['erase', 0x20000, 0x21000], ['write', 0]])
|
|
127
|
+
assert.deepEqual(f.flash.slice(0, original.length), original)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a different image after connection rejects before erase even if public metadata changes', async () => {
|
|
131
|
+
const f = await installFixture(), session = new FirmwareFlashSession(f.loader, installationImage(f), { mode: 'install' })
|
|
132
|
+
await session.connect(f.data)
|
|
133
|
+
f.data[0] ^= 1
|
|
134
|
+
session.image.sha256 = await firmwareSha256(f.data)
|
|
135
|
+
await assert.rejects(session.flash(f.data), /SHA-256/)
|
|
136
|
+
assert.deepEqual(destructive(f), [])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('secure download mode refuses installation before stub or erase', async () => {
|
|
140
|
+
const f = await installFixture()
|
|
141
|
+
f.loader.getSecurityInfo = async () => ({ flags: 4, flashCryptCnt: 0 })
|
|
142
|
+
await assert.rejects(f.device.firmware.update(f.request), /signed flashing workflow/)
|
|
143
|
+
assert.deepEqual(f.calls, ['open', 'initialize', 'close'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
for (const security of [{ flags: -8, flashCryptCnt: 0 }, { flags: 0, flashCryptCnt: -3 }, { flags: 0x100000000, flashCryptCnt: 0 }]) {
|
|
147
|
+
test(`invalid ROM security data ${JSON.stringify(security)} rejects before erase`, async () => {
|
|
148
|
+
const f = await installFixture()
|
|
149
|
+
f.loader.getSecurityInfo = async () => security
|
|
150
|
+
await assert.rejects(f.device.firmware.update(f.request), /security mode/)
|
|
151
|
+
assert.deepEqual(f.calls, ['open', 'initialize', 'close'])
|
|
152
|
+
})
|
|
153
|
+
}
|
package/tests/test-node-rom.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'
|
|
|
6
6
|
import { PassThrough, Writable } from 'node:stream'
|
|
7
7
|
import { openNodeRom } from '../src/node_rom.js'
|
|
8
8
|
|
|
9
|
-
function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError, operationError, readResult = 'QUI=' } = {}) {
|
|
9
|
+
function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError, operationError, readResult = 'QUI=', stubResult = { stubRunning: true }, holdErase = false } = {}) {
|
|
10
10
|
const calls = [], requests = []
|
|
11
11
|
const child = new EventEmitter()
|
|
12
12
|
child.exitCode = null
|
|
@@ -15,11 +15,14 @@ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeErro
|
|
|
15
15
|
write(data, encoding, done) {
|
|
16
16
|
const request = JSON.parse(data.toString())
|
|
17
17
|
requests.push(request)
|
|
18
|
-
const detail = request.method === 'initialize' ? initializeError
|
|
18
|
+
const detail = request.method === 'initialize' ? initializeError
|
|
19
|
+
: typeof operationError === 'function' ? operationError(request) : operationError
|
|
20
|
+
if (request.method === 'eraseRegion' && holdErase) { done(); return }
|
|
19
21
|
queueMicrotask(() => child.stdout.write(JSON.stringify(detail
|
|
20
22
|
? { id: request.id, error: 'ROM_OPERATION_FAILED', method: request.method, detail }
|
|
21
23
|
: { id: request.id, result: request.method === 'initialize'
|
|
22
|
-
? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' }
|
|
24
|
+
? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' }
|
|
25
|
+
: request.method === 'read' ? readResult : request.method === 'stub' ? stubResult : null }) + '\n'))
|
|
23
26
|
done()
|
|
24
27
|
},
|
|
25
28
|
final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
|
|
@@ -229,3 +232,84 @@ test('incomplete read results are rejected without progress, recovery, or duplic
|
|
|
229
232
|
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'read'])
|
|
230
233
|
} finally { await loader.disconnect() }
|
|
231
234
|
})
|
|
235
|
+
|
|
236
|
+
test('Node region erase requires the validated stub and sends only the exact qualified range', async () => {
|
|
237
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
238
|
+
try {
|
|
239
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_NOT_INITIALIZED' })
|
|
240
|
+
await loader.initialize()
|
|
241
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_STUB_REQUIRED' })
|
|
242
|
+
const stub = await loader.runStub()
|
|
243
|
+
assert.equal(stub, loader); assert.equal(stub.IS_STUB, true)
|
|
244
|
+
await stub.eraseRegion(0x710000, 0x8f0000)
|
|
245
|
+
await stub.eraseRegion(0xfffff000, 4096)
|
|
246
|
+
assert.deepEqual(f.requests.map(r => [r.method, r.args]), [
|
|
247
|
+
['initialize', []], ['stub', []], ['eraseRegion', [0x710000, 0x8f0000]],
|
|
248
|
+
['eraseRegion', [0xfffff000, 4096]],
|
|
249
|
+
])
|
|
250
|
+
} finally { await loader.disconnect() }
|
|
251
|
+
assert.equal(loader.IS_STUB, false)
|
|
252
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('invalid Node erase ranges issue no child request', async () => {
|
|
256
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
257
|
+
try {
|
|
258
|
+
await loader.initialize(); await loader.runStub()
|
|
259
|
+
for (const [address, size] of [[-4096, 4096], [1, 4096], [0, 0], [0, 1], [0, 0x100000000],
|
|
260
|
+
[0xfffff000, 8192], [NaN, 4096], [true, 4096], [0, true], [0x100000000, 4096]]) {
|
|
261
|
+
await assert.rejects(loader.eraseRegion(address, size), RangeError)
|
|
262
|
+
}
|
|
263
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
|
|
264
|
+
} finally { await loader.disconnect() }
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('unconfirmed Node stub responses close the process before any erase', async () => {
|
|
268
|
+
for (const stubResult of [null, {}, { stubRunning: false }, { stubRunning: 1 }]) {
|
|
269
|
+
const f = fixture({ stubResult }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
270
|
+
await loader.initialize()
|
|
271
|
+
await assert.rejects(loader.runStub(), { code: 'ROM_STUB_REQUIRED' })
|
|
272
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
|
|
273
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
|
|
274
|
+
assert.equal(f.child.exitCode, 0)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('Node erase failure keeps official diagnostics and prevents later writes or recovery', async () => {
|
|
279
|
+
const f = fixture({ operationError: request => request.method === 'eraseRegion' ? 'Erase command rejected' : undefined })
|
|
280
|
+
let recoveries = 0
|
|
281
|
+
const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
|
|
282
|
+
recoverPort: async () => { recoveries++; throw new Error('Erase must not recover') },
|
|
283
|
+
})
|
|
284
|
+
await loader.initialize(); await loader.runStub()
|
|
285
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), error => error.code === 'ROM_OPERATION_FAILED'
|
|
286
|
+
&& error.operation === 'eraseRegion' && error.cause === 'Erase command rejected')
|
|
287
|
+
await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 0), { code: 'ROM_CLOSED' })
|
|
288
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
|
|
289
|
+
assert.equal(recoveries, 0)
|
|
290
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub', 'eraseRegion'])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('Node erase cancellation or port loss closes the in-flight request without success or retry', async () => {
|
|
294
|
+
for (const cancelled of [true, false]) {
|
|
295
|
+
const f = fixture({ holdErase: true }), controller = new AbortController()
|
|
296
|
+
const loader = openNodeRom({ port: '/dev/runtime', signal: controller.signal }, f.dependencies)
|
|
297
|
+
await loader.initialize(); await loader.runStub()
|
|
298
|
+
const erasing = loader.eraseRegion(0x710000, 4096)
|
|
299
|
+
const failed = assert.rejects(erasing, { code: cancelled ? 'CANCELLED' : 'ROM_CLOSED' })
|
|
300
|
+
if (cancelled) controller.abort()
|
|
301
|
+
else { f.child.exitCode = 1; f.child.emit('exit', 1) }
|
|
302
|
+
await failed
|
|
303
|
+
await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 0), { code: 'ROM_CLOSED' })
|
|
304
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub', 'eraseRegion'])
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('a previously cancelled Node erase never sends a command', async () => {
|
|
309
|
+
const f = fixture(), controller = new AbortController()
|
|
310
|
+
const loader = openNodeRom({ port: '/dev/runtime', signal: controller.signal }, f.dependencies)
|
|
311
|
+
await loader.initialize(); await loader.runStub(); controller.abort()
|
|
312
|
+
await assert.rejects(loader.eraseRegion(0x710000, 4096), { name: 'AbortError' })
|
|
313
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
|
|
314
|
+
assert.equal(f.child.exitCode, 0)
|
|
315
|
+
})
|