@esp-vision/device-core 0.6.0 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -5
  3. package/package.json +1 -1
  4. package/schemas/device-actions-v1.json +1 -1
  5. package/scripts/generate-action-schema.mjs +2 -1
  6. package/scripts/npm-command.mjs +17 -0
  7. package/scripts/release-npm.mjs +2 -1
  8. package/src/browser.js +5 -2
  9. package/src/browser_rom.js +55 -5
  10. package/src/device_session.js +85 -9
  11. package/src/evmux.js +15 -0
  12. package/src/firmware_flash.js +84 -26
  13. package/src/firmware_partitions.js +57 -0
  14. package/src/firmware_updates.js +5 -2
  15. package/src/node_rom.js +49 -7
  16. package/src/node_rom.py +92 -5
  17. package/src/node_rom_port.js +27 -19
  18. package/src/node_serial.py +75 -12
  19. package/src/node_transport.js +63 -14
  20. package/src/rom_identity.js +9 -0
  21. package/tests/check-browser.mjs +23 -4
  22. package/tests/check-package.mjs +15 -7
  23. package/tests/device-checks.mjs +89 -7
  24. package/tests/firmware-fixtures.mjs +60 -0
  25. package/tests/fixtures/device-checks.py +357 -1
  26. package/tests/hardware-check.mjs +3 -2
  27. package/tests/test-browser-rom.mjs +169 -3
  28. package/tests/test-ci-check-selection.mjs +31 -0
  29. package/tests/test-device-functional-checks.mjs +275 -3
  30. package/tests/test-device-v4-alignment.mjs +261 -7
  31. package/tests/test-firmware-flash.mjs +22 -20
  32. package/tests/test-firmware-install.mjs +8 -35
  33. package/tests/test-firmware-source.mjs +10 -7
  34. package/tests/test-host-checks.mjs +47 -0
  35. package/tests/test-install-erase.mjs +153 -0
  36. package/tests/test-node-rom-port.mjs +81 -1
  37. package/tests/test-node-rom.mjs +159 -4
  38. package/tests/test-node-serial.mjs +231 -2
  39. package/tests/test-npm-release-policy.mjs +3 -9
@@ -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 { createDevice } from '../src/index.js'
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.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
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] === 'write'))
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(); f.loader.readFlash = async (_offset, count) => new Uint8Array(count)
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([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)) })
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: 4096,
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(full.length).fill(0x5a)
19
- flash.set(full.subarray(0x8000, 0x8004), 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,47 @@
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 { spawnSync } from 'node:child_process'
5
+ import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import process from 'node:process'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { test } from 'node:test'
11
+ import { npmCommand } from '../scripts/npm-command.mjs'
12
+
13
+ const root = fileURLToPath(new URL('../', import.meta.url))
14
+
15
+ test('npm execution preserves Unicode, spaces and shell metacharacters as literal arguments', async t => {
16
+ const directory = await mkdtemp(join(tmpdir(), 'core npm 中文 '))
17
+ t.after(() => rm(directory, { recursive: true, force: true }))
18
+ const cli = join(directory, 'npm entry.cjs')
19
+ await writeFile(cli, 'process.stdout.write(JSON.stringify(process.argv.slice(2))); process.exitCode = 7\n')
20
+ const args = ['pack', '中文 path', '& echo forbidden', '$(forbidden)', '%PATH%']
21
+ const env = { ...process.env, npm_execpath: cli }
22
+ const result = spawnSync(...npmCommand(args, env), { env, encoding: 'utf8' })
23
+ assert.equal(result.status, 7, result.stderr)
24
+ assert.deepEqual(JSON.parse(result.stdout), args)
25
+ })
26
+
27
+ test('schema check accepts checkout CRLF but rejects a real artifact change', async t => {
28
+ const directory = await mkdtemp(join(tmpdir(), 'core schema '))
29
+ t.after(() => rm(directory, { recursive: true, force: true }))
30
+ await mkdir(join(directory, 'scripts'))
31
+ await mkdir(join(directory, 'schemas'))
32
+ await cp(join(root, 'src'), join(directory, 'src'), { recursive: true })
33
+ await copyFile(join(root, 'package.json'), join(directory, 'package.json'))
34
+ const script = join(directory, 'scripts/generate-action-schema.mjs')
35
+ await copyFile(join(root, 'scripts/generate-action-schema.mjs'), script)
36
+ const schema = join(directory, 'schemas/device-actions-v1.json')
37
+ const original = (await readFile(join(root, 'schemas/device-actions-v1.json'), 'utf8')).replaceAll('\r\n', '\n')
38
+ await writeFile(schema, original.replaceAll('\n', '\r\n'))
39
+ const current = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' })
40
+ assert.equal(current.status, 0, current.stderr)
41
+ const changed = JSON.parse(original)
42
+ changed.deviceCoreVersion = '0.0.0'
43
+ await writeFile(schema, JSON.stringify(changed, null, 2).replaceAll('\n', '\r\n') + '\r\n')
44
+ const stale = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' })
45
+ assert.notEqual(stale.status, 0)
46
+ assert.match(stale.stderr, /Device Action schema artifact is stale/)
47
+ })
@@ -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
+ }
@@ -265,10 +265,12 @@ test('recovery never repeats a previous active handoff or uses an incomplete pre
265
265
  }
266
266
  })
267
267
 
268
- test('same ROM or no runtime returns null without issuing a bootloader command', async () => {
268
+ test('persistent ROM or absence exhausts only passive observation before returning null', async () => {
269
269
  for (const snapshot of [[rom('/dev/ttyACM1')], [], [native('/dev/ttyACM8', { serialNumber: 'e8f60ae75653', location: '2-3' })]]) {
270
270
  const f = fixture({ snapshots: [snapshot] })
271
271
  assert.equal(await f.recover(previousRom()), null)
272
+ assert.equal(f.now(), 200)
273
+ assert.equal(f.calls.enumerate.length, 4)
272
274
  assert.equal(f.calls.creates, 0)
273
275
  assert.equal(f.calls.control.length, 0)
274
276
  }
@@ -303,3 +305,81 @@ test('read-only recovery honors cancel and fresh-enumeration deadline', async ()
303
305
  await assert.rejects(late.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
304
306
  assert.equal(late.calls.creates, 0)
305
307
  })
308
+
309
+
310
+ test('read-only recovery observes a real 2.4-second missing-device window before one handoff', async () => {
311
+ const runtime = native('/dev/ttyACM2', { productId: 0x4023 })
312
+ const f = fixture({ snapshots: [...Array.from({ length: 48 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]] })
313
+ const result = await f.recover(previousRom(), { timeout: 5000 })
314
+ assert.equal(f.now(), 2400)
315
+ assert.equal(result.port, '/dev/ttyACM3')
316
+ assert.equal(result.transition.recovery.runtimePid, 0x4023)
317
+ assert.equal(f.calls.control.length, 1)
318
+ assert.equal(f.calls.connect.length, 1)
319
+ assert.equal(f.calls.disconnect, 1)
320
+ assert.equal(f.calls.enumerate[48].timeout, 2600)
321
+ })
322
+
323
+ test('a stale ROM followed by disappearance is observed passively until the same runtime returns', async () => {
324
+ const runtime = native('/dev/ttyACM2')
325
+ const snapshots = [...Array.from({ length: 10 }, () => [rom('/dev/ttyACM0')]),
326
+ ...Array.from({ length: 38 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]]
327
+ const f = fixture({ snapshots })
328
+ const result = await f.recover(previousRom(), { timeout: 5000 })
329
+ assert.equal(f.now(), 2400)
330
+ assert.equal(result.port, '/dev/ttyACM3')
331
+ assert.equal(f.calls.creates, 1)
332
+ assert.equal(f.calls.control.length, 1)
333
+ assert.equal(f.calls.disconnect, 1)
334
+ })
335
+
336
+ test('a conflicting MAC or location seen after waiting fails before opening any runtime', async () => {
337
+ for (const conflicting of [native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
338
+ native('/dev/ttyACM2', { location: '2-3' }), rom('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
339
+ rom('/dev/ttyACM2', { location: '2-3' })]) {
340
+ const f = fixture({ snapshots: [[], [], [conflicting]] })
341
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
342
+ assert.equal(f.now(), 100)
343
+ assert.equal(f.calls.creates, 0)
344
+ assert.equal(f.calls.control.length, 0)
345
+ }
346
+ })
347
+
348
+ test('late duplicate runtime or simultaneous ROM/runtime descriptors remain ambiguous', async () => {
349
+ for (const second of [native('/dev/ttyACM3'), rom('/dev/ttyACM3')]) {
350
+ const f = fixture({ snapshots: [[], [native('/dev/ttyACM2'), second]] })
351
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_AMBIGUOUS' })
352
+ assert.equal(f.now(), 50)
353
+ assert.equal(f.calls.creates, 0)
354
+ assert.equal(f.calls.control.length, 0)
355
+ }
356
+ })
357
+
358
+ test('cancel during passive recovery sleep never creates a session or sends bootloader', async () => {
359
+ const reason = Object.assign(new Error('cancelled during native USB observation'), { name: 'AbortError' })
360
+ const f = fixture({ snapshots: [[]], onSleep(controller, clock) { if (clock >= 150) controller.abort(reason) } })
361
+ await assert.rejects(f.recover(previousRom(), { timeout: 5000 }), value => value === reason)
362
+ assert.equal(f.now(), 150)
363
+ assert.equal(f.calls.creates, 0)
364
+ assert.equal(f.calls.control.length, 0)
365
+ })
366
+
367
+ test('late runtime enumeration does not start handoff after the passive deadline', async () => {
368
+ const runtime = native('/dev/ttyACM2')
369
+ const f = fixture({ snapshots: [[runtime]], enumerationDelay: 201 })
370
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
371
+ assert.equal(f.calls.creates, 0)
372
+ assert.equal(f.calls.control.length, 0)
373
+ })
374
+
375
+ test('a runtime handshake error after settling is propagated without another observation or handoff', async () => {
376
+ const runtime = native('/dev/ttyACM2')
377
+ const reason = failure('SESSION_BUSY')
378
+ const f = fixture({ snapshots: [[], [runtime], [runtime]], connectError: reason })
379
+ await assert.rejects(f.recover(previousRom()), value => value === reason)
380
+ assert.equal(f.now(), 50)
381
+ assert.equal(f.calls.connect.length, 1)
382
+ assert.equal(f.calls.creates, 1)
383
+ assert.equal(f.calls.control.length, 0)
384
+ assert.equal(f.calls.disconnect, 1)
385
+ })
@@ -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 } = {}) {
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,10 +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
- queueMicrotask(() => child.stdout.write(JSON.stringify(request.method === 'initialize' && initializeError
19
- ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: 'initialize', detail: initializeError }
18
+ const detail = request.method === 'initialize' ? initializeError
19
+ : typeof operationError === 'function' ? operationError(request) : operationError
20
+ if (request.method === 'eraseRegion' && holdErase) { done(); return }
21
+ queueMicrotask(() => child.stdout.write(JSON.stringify(detail
22
+ ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: request.method, detail }
20
23
  : { id: request.id, result: request.method === 'initialize'
21
- ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : null }) + '\n'))
24
+ ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' }
25
+ : request.method === 'read' ? readResult : request.method === 'stub' ? stubResult : null }) + '\n'))
22
26
  done()
23
27
  },
24
28
  final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
@@ -158,3 +162,154 @@ test('a second initialization sync failure cannot trigger a third process or han
158
162
  assert.equal(recoveries, 1)
159
163
  assert.equal(first.requests.length + second.requests.length, 2)
160
164
  })
165
+
166
+
167
+ test('post-initialize read or write failures never start recovery, a second process, or replay data', async () => {
168
+ for (const method of ['read', 'write']) {
169
+ const f = fixture({ operationError: 'Original operation failed' })
170
+ f.selected.transition = { observed: 'rom', requested: false }
171
+ let recoveries = 0, progresses = 0
172
+ const loader = openNodeRom({ port: '/dev/rom' }, { ...f.dependencies,
173
+ recoverPort: async () => { recoveries++; throw Error('Read/write must not recover') },
174
+ })
175
+ try {
176
+ await loader.initialize()
177
+ const operation = method === 'read' ? loader.readFlash(0x8000, 1024)
178
+ : loader.flashData(new Uint8Array([1, 2, 3]), () => { progresses++ }, 0x10000)
179
+ await assert.rejects(operation, error => error.code === 'ROM_OPERATION_FAILED'
180
+ && error.operation === method && error.cause === 'Original operation failed')
181
+ assert.equal(recoveries, 0)
182
+ assert.equal(progresses, 0)
183
+ assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
184
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', method])
185
+ } finally { await loader.disconnect() }
186
+ }
187
+ })
188
+
189
+
190
+ test('Node ROM reads forward default and explicit budgets and report progress only after complete data', async () => {
191
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
192
+ const progress = []
193
+ try {
194
+ await loader.initialize()
195
+ assert.deepEqual(await loader.readFlash(0x8000, 2, (...args) => progress.push(args)), new Uint8Array([65, 66]))
196
+ assert.deepEqual(await loader.readFlash(0x10000, 2, undefined,
197
+ { chunkSize: 65536, blockSize: 4096, maxInFlight: 2 }), new Uint8Array([65, 66]))
198
+ assert.deepEqual(f.requests.map(r => [r.method, r.args]), [['initialize', []],
199
+ ['read', [0x8000, 2, { blockSize: 1024, maxInFlight: 1 }]],
200
+ ['read', [0x10000, 2, { blockSize: 4096, maxInFlight: 2 }]]])
201
+ assert.equal(progress.length, 1)
202
+ assert.deepEqual(progress[0], [new Uint8Array([65, 66]), 2, 2])
203
+ } finally { await loader.disconnect() }
204
+ })
205
+
206
+ test('invalid read ranges, progress, or packet budgets never issue a child process RPC', async () => {
207
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
208
+ try {
209
+ await loader.initialize()
210
+ for (const [offset, count, progress, options] of [[-1, 2], [0, 0], [0, 0x100000000], [0xffffffff, 2], [0, 2, null],
211
+ [0, 2, undefined, null], [0, 2, undefined, []], [0, 2, undefined, { blockSize: 512 }],
212
+ [0, 2, undefined, { blockSize: true }], [0, 2, undefined, { blockSize: null }], [0, 2, undefined, { maxInFlight: 0 }],
213
+ [0, 2, undefined, { maxInFlight: 65 }], [0, 2, undefined, { maxInFlight: null }], [0, 2, undefined, { maxInFlight: 1.5 }],
214
+ [0, 2, undefined, { unknown: 1 }], [0, 2, undefined, { chunkSize: 1 }]]) {
215
+ await assert.rejects(loader.readFlash(offset, count, progress, options), error => error instanceof TypeError || error instanceof RangeError)
216
+ }
217
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize'])
218
+ } finally { await loader.disconnect() }
219
+ })
220
+
221
+ test('incomplete read results are rejected without progress, recovery, or duplicate read', async () => {
222
+ const f = fixture({ readResult: 'QQ==' })
223
+ let recovered = 0, progressed = 0
224
+ const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
225
+ recoverPort: async () => { recovered++; throw Error('Read must not recover') },
226
+ })
227
+ try {
228
+ await loader.initialize()
229
+ await assert.rejects(loader.readFlash(0, 2, () => { progressed++ }), { code: 'ROM_READ_LENGTH', operation: 'read' })
230
+ assert.equal(recovered, 0)
231
+ assert.equal(progressed, 0)
232
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'read'])
233
+ } finally { await loader.disconnect() }
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
+ })