@esp-vision/device-core 0.2.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.
@@ -0,0 +1,126 @@
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_flash.js'
7
+ import { parseCli, runCli } from '../src/cli.js'
8
+
9
+ async function fixture() {
10
+ const data = new Uint8Array(0x11000).fill(0xff), calls = []
11
+ data.set([0xe9, 1, 2, 3], 0x2000)
12
+ data.set([0xaa, 0x50, 4, 5], 0x8000)
13
+ data.set([0xe9, 6, 7, 8], 0x10000)
14
+ const part = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(data.subarray(offset, offset + 4)) })
15
+ const release = { board: 'BOARD', version: 'new', image: {
16
+ evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
17
+ partitionSize: 4096, partitionTable: await part(0x8000), url: 'https://example.test/app.bin',
18
+ }, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
19
+ sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
20
+ const flash = new Uint8Array(data.length).fill(0xff)
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() { calls.push('capacity'); return 0x1840ef },
27
+ async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
28
+ async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
29
+ async readRegister() { return 0 },
30
+ async hardReset() { calls.push('reset') },
31
+ async disconnect() { calls.push('close') },
32
+ }
33
+ const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
34
+ return { data, calls, release, loader, device, request, flash }
35
+ }
36
+
37
+ test('blank device installs through ROM without an EV-MUX transport or existing partition table', async () => {
38
+ const f = await fixture(), progress = []
39
+ const result = await f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) })
40
+ assert.equal(result.verified, true); assert.equal(result.reconnected, false)
41
+ assert.deepEqual(f.flash, f.data)
42
+ assert.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
43
+ assert.equal(progress.at(-1), 100)
44
+ })
45
+
46
+ test('default application update refuses blank layout and never falls back to installing', async () => {
47
+ const f = await fixture()
48
+ await assert.rejects(f.device.firmware.update({ ...f.request, mode: undefined, data: f.data.slice(65536, 65540) }), /Partition layout/)
49
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
50
+ assert.ok(!f.calls.includes('capacity'))
51
+ })
52
+
53
+ test('complete image is downloaded from install URL with the requested cancellation signal', async () => {
54
+ const f = await fixture(), controller = new AbortController()
55
+ await f.device.firmware.update({ ...f.request, data: undefined, signal: controller.signal,
56
+ 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 } },
57
+ })
58
+ assert.deepEqual(f.flash, f.data)
59
+ })
60
+
61
+ test('install selection skips app-only releases and rejects explicit unqualified versions', async () => {
62
+ const f = await fixture(), appOnly = { ...f.release, version: 'app-only', install: undefined }
63
+ const request = { mode: 'install', identity: { board: 'BOARD', firmwareVersion: 'unknown' }, catalog: { schemaVersion: 1, releases: [appOnly, f.release] } }
64
+ const result = await f.device.firmware.check(request)
65
+ assert.equal(result.release.version, 'new'); assert.equal(result.releases.length, 2)
66
+ await assert.rejects(f.device.firmware.check({ ...request, version: 'app-only' }), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
67
+ const updates = await f.device.firmware.check({ ...request, mode: 'update' })
68
+ assert.equal(updates.release.version, 'app-only')
69
+ assert.deepEqual(f.calls, [])
70
+ })
71
+
72
+ for (const change of ['hash', 'bootloader', 'table', 'application', 'overlap', 'bounds', 'revision-range', 'missing-metadata', 'mode', 'board']) {
73
+ test(`invalid ${change} rejects installation before opening or releasing a port`, async () => {
74
+ const f = await fixture()
75
+ if (change === 'hash') f.data[1] = 0
76
+ if (change === 'bootloader') f.release.install.bootloader.sha256 = '0'.repeat(64)
77
+ if (change === 'table') f.release.image.partitionTable.sha256 = '0'.repeat(64)
78
+ if (change === 'application') f.release.image.sha256 = '0'.repeat(64)
79
+ if (change === 'overlap') f.release.install.bootloader.bytes = 0x8000
80
+ if (change === 'bounds') f.release.install.offset = 0x1000
81
+ if (change === 'revision-range') f.release.install.chipRevisionMin = 301
82
+ if (change === 'missing-metadata') delete f.release.install
83
+ if (change === 'mode') f.request.mode = 'auto'
84
+ if (change === 'board') f.request.board = 'WRONG'
85
+ await assert.rejects(f.device.firmware.update(f.request))
86
+ assert.deepEqual(f.calls, [])
87
+ })
88
+ }
89
+
90
+ for (const change of ['wrong-chip', 'wrong-revision', 'secure-boot', 'encryption', 'small-flash', 'unknown-flash', 'missing-capacity']) {
91
+ test(`physical ${change} refuses installation before any write`, async () => {
92
+ const f = await fixture()
93
+ if (change === 'wrong-chip') f.loader.chipFamily = 0x3253
94
+ if (change === 'wrong-revision') f.loader.chipRevision = 301
95
+ if (change === 'secure-boot') f.loader.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
96
+ if (change === 'encryption') f.loader.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 1 })
97
+ if (change === 'small-flash') f.loader.flashId = async () => 0x1740ef
98
+ if (change === 'unknown-flash') f.loader.flashId = async () => 0xffffff
99
+ if (change === 'missing-capacity') delete f.loader.flashId
100
+ await assert.rejects(f.device.firmware.update(f.request))
101
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
102
+ assert.equal(f.calls.at(-1), 'close')
103
+ })
104
+ }
105
+
106
+ test('installation with damaged readback does not reset or report success', async () => {
107
+ const f = await fixture(); f.loader.readFlash = async (_offset, count) => new Uint8Array(count)
108
+ await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
109
+ assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
110
+ })
111
+
112
+ test('CLI install uses ROM directly and passes mode through both check and update', async () => {
113
+ const f = await fixture(), checked = []
114
+ const options = parseCli(['firmware', 'update', '--mode', 'install', '--board', 'BOARD', '--port', '/dev/ttyACM0', '--version', 'new'])
115
+ const result = await runCli(options, {
116
+ transportFor: () => { throw new Error('No application transport should be acquired') },
117
+ async readFile() { throw new Error('No user catalog required') },
118
+ createDevice: () => ({ firmware: {
119
+ async check(request) { checked.push(request.mode); return { release: f.release } },
120
+ async update(request) { assert.equal(request.mode, 'install'); return { verified: true } },
121
+ }, async disconnect() {} }),
122
+ })
123
+ assert.equal(result.verified, true); assert.deepEqual(checked, ['install'])
124
+ assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--port', 'COM3', '--version', 'new']), /explicit --board/)
125
+ assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--tcp', 'host', '--board', 'BOARD', '--version', 'new']), /local --port/)
126
+ })
@@ -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
+ })
@@ -0,0 +1,214 @@
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 { loadOfficialFirmware, loadReleaseManifest, firmwareSha256 } from '../src/firmware.js'
7
+
8
+ const base = 'https://dl.espressif.com/AE/esp-vision/'
9
+ const commit = 'a'.repeat(40)
10
+ async function fixture(version = 'new', sourceCommit = commit) {
11
+ const bytes = new Uint8Array(0x11000).fill(0xff)
12
+ bytes.set([0xe9, 1, 2, 3], 0x2000); bytes.set([0xaa, 0x50, 4, 5], 0x8000); bytes.set([0xe9, 6, 7, 8], 0x10000)
13
+ const segment = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(bytes.subarray(offset, offset + 4)) })
14
+ const source = { url: `${base}bin/esp-vision-BOARD-${version}.bin`, bytes: bytes.byteLength, sha256: await firmwareSha256(bytes), offset: 0x10000 }
15
+ const release = { board: 'BOARD', version, image: {
16
+ evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(0x10000)),
17
+ partitionSize: 4096, partitionTable: await segment(0x8000), url: source.url, source,
18
+ }, install: { url: source.url, offset: 0, bytes: source.bytes, sha256: source.sha256, flashSize: 0x1000000,
19
+ chipRevisionMin: 302, chipRevisionMax: 302, bootloader: await segment(0x2000) } }
20
+ const manifest = { schemaVersion: 1, version, sourceCommit, pipelineId: 123, layout: 'single-bin', releases: [release], website: {
21
+ schemaVersion: '1.0.0', release: version, releases: [version], binaryNameTemplate: 'esp-vision-{board}-{tag}.bin',
22
+ boards: [{ slug: 'board', board: 'BOARD', name: 'Board', flashable: true }],
23
+ models: [{ slug: 'model', sizeBytes: 4, path: 'models/a/model.espdl', downloadUrl: `https://raw.githubusercontent.com/espressif/esp-vision/${sourceCommit}/models/a/model.espdl` }],
24
+ }, mcp: { url: `${base}bin/mcp/mcp-pack-${version}.json`, bytes: 1234, sha256: 'c'.repeat(64), version, sourceCommit } }
25
+ const reference = async () => {
26
+ const raw = new TextEncoder().encode(JSON.stringify(manifest))
27
+ return { url: `${base}releases/${version}/manifest.json`, sha256: await firmwareSha256(raw), version, sourceCommit, boards: ['BOARD'], fetch: async () => new Response(raw) }
28
+ }
29
+ return { bytes, manifest, release, reference }
30
+ }
31
+ function config(current, history = [current]) {
32
+ return `[publication]\nschemaVersion = 1\nversion = "${current.version}"\nsourceCommit = "${current.sourceCommit}"\nmanifestUrl = "${current.url}"\nmanifestSha256 = "${current.sha256}"\npipelineId = 123\npreviousConfigSha256 = "${'e'.repeat(64)}"\nreleases = [${history.map(item => `{version = "${item.version}", manifestUrl = "${item.url}", manifestSha256 = "${item.sha256}", boards = ["BOARD"]}`).join(',')}]\n`
33
+ }
34
+
35
+ test('public manifest reader verifies the raw snapshot and preserves website, MCP and source metadata', async () => {
36
+ const f = await fixture(), request = await f.reference()
37
+ const result = await loadReleaseManifest(request)
38
+ assert.deepEqual(result, f.manifest)
39
+ assert.equal(result.releases[0].image.bytes, 4)
40
+ assert.equal(result.releases[0].image.source.bytes, f.bytes.length)
41
+ })
42
+ test('raw manifest hash is checked before parsing or returning any metadata', async () => {
43
+ const f = await fixture(), request = await f.reference()
44
+ await assert.rejects(loadReleaseManifest({ ...request, fetch: async () => new Response(JSON.stringify(f.manifest) + '\n') }), /manifest SHA-256/)
45
+ await assert.rejects(loadReleaseManifest({ ...request, sha256: 'invalid', fetch: () => { throw new Error('must not fetch') } }), /qualified SHA-256/)
46
+ })
47
+ for (const mutation of ['schema', 'version', 'commit', 'pipeline', 'layout', 'layout-source', 'duplicate-board', 'entry-version', 'entry-commit', 'app-hash', 'source-size', 'firmware-host', 'website-version', 'website-board', 'model-master', 'model-traversal', 'mcp-url', 'mcp-latest', 'mcp-version', 'mcp-commit', 'mcp-size']) {
48
+ test(`invalid manifest ${mutation} never yields an approved release`, async () => {
49
+ const f = await fixture(), m = f.manifest
50
+ if (mutation === 'schema') m.schemaVersion = 2
51
+ if (mutation === 'version') m.version = 'other'
52
+ if (mutation === 'commit') m.sourceCommit = 'master'
53
+ if (mutation === 'pipeline') m.pipelineId = 0
54
+ if (mutation === 'layout') m.layout = 'unknown'
55
+ if (mutation === 'layout-source') m.layout = 'legacy'
56
+ if (mutation === 'duplicate-board') m.releases.push(structuredClone(f.release))
57
+ if (mutation === 'entry-version') f.release.version = 'other'
58
+ if (mutation === 'entry-commit') f.release.sourceCommit = 'b'.repeat(40)
59
+ if (mutation === 'app-hash') f.release.image.sha256 = 'invalid'
60
+ if (mutation === 'source-size') f.release.image.source.bytes++
61
+ if (mutation === 'firmware-host') {
62
+ const url = 'https://untrusted.test/app.bin'
63
+ f.release.image.url = url; f.release.image.source.url = url; f.release.install.url = url
64
+ }
65
+ if (mutation === 'website-version') m.website.release = 'other'
66
+ if (mutation === 'website-board') m.website.boards[0].board = 'UNQUALIFIED'
67
+ if (mutation === 'model-master') m.website.models[0].downloadUrl = m.website.models[0].downloadUrl.replace(commit, 'master')
68
+ if (mutation === 'model-traversal') {
69
+ const model = m.website.models[0]
70
+ model.path = 'models/%2e%2e/%2e%2e/master/models/evil.espdl'
71
+ model.downloadUrl = `https://raw.githubusercontent.com/espressif/esp-vision/${commit}/${model.path}`
72
+ }
73
+ if (mutation === 'mcp-url') m.mcp.url = 'https://untrusted.test/pack.json'
74
+ if (mutation === 'mcp-latest') m.mcp.url = `${base}bin/mcp/mcp-pack-latest.json`
75
+ if (mutation === 'mcp-version') m.mcp.version = 'other'
76
+ if (mutation === 'mcp-commit') m.mcp.sourceCommit = 'b'.repeat(40)
77
+ if (mutation === 'mcp-size') m.mcp.bytes = 17 * 1024 * 1024
78
+ const reference = await f.reference()
79
+ reference.version = 'new'; reference.sourceCommit = commit
80
+ await assert.rejects(loadReleaseManifest(reference))
81
+ })
82
+ }
83
+ test('multi-file model groups may share a slug while each model path remains unique', async () => {
84
+ const f = await fixture(), model = f.manifest.website.models[0]
85
+ const otherPath = 'models/a/other.espdl'
86
+ f.manifest.website.models.push({ ...model, path: otherPath, downloadUrl: model.downloadUrl.replace(model.path, otherPath) })
87
+ assert.equal((await loadReleaseManifest(await f.reference())).website.models.length, 2)
88
+ f.manifest.website.models.push(structuredClone(model))
89
+ await assert.rejects(loadReleaseManifest(await f.reference()), /Invalid manifest model/)
90
+ })
91
+ test('manifest board set is checked against its immutable publication reference', async () => {
92
+ const f = await fixture()
93
+ await assert.rejects(loadReleaseManifest({ ...await f.reference(), boards: ['OTHER'] }), /board set/)
94
+ })
95
+ test('legacy manifest layout accepts qualified application-only artifacts and explicitly missing MCP', async () => {
96
+ const f = await fixture(); f.manifest.layout = 'legacy'; f.manifest.mcp = null
97
+ delete f.release.image.source
98
+ f.release.image.url = `${base}bin/app.bin`
99
+ assert.equal((await loadReleaseManifest(await f.reference())).layout, 'legacy')
100
+ })
101
+ test('official check loads only the requested version snapshot and never traverses old board/version metadata', async () => {
102
+ const current = await fixture(), old = await fixture('old', 'b'.repeat(40)), refs = [await current.reference(), await old.reference()], calls = []
103
+ const fetch = async (url, options) => {
104
+ calls.push(url)
105
+ assert.equal(options.cache, url.endsWith('config.toml') ? 'no-store' : 'default')
106
+ if (url.endsWith('config.toml')) return new Response(config(refs[0], refs))
107
+ assert.equal(url, refs[1].url)
108
+ assert.equal(options.redirect, 'error')
109
+ return refs[1].fetch()
110
+ }
111
+ const checked = await createDevice().firmware.check({ identity: { board: 'BOARD', firmwareVersion: 'older' }, version: 'old', fetch })
112
+ assert.equal(checked.release.version, 'old')
113
+ assert.deepEqual(calls, [base + 'config.toml', refs[1].url])
114
+ assert.equal(checked.releases.length, 1)
115
+ })
116
+ test('default official reader returns one current snapshot and its version references', async () => {
117
+ const f = await fixture(), reference = await f.reference(), calls = []
118
+ const result = await loadOfficialFirmware({ fetch: async url => {
119
+ calls.push(url)
120
+ return url.endsWith('config.toml') ? new Response(config(reference)) : reference.fetch()
121
+ } })
122
+ assert.equal(result.sourceCommit, commit); assert.equal(result.publication.releases[0].version, 'new')
123
+ assert.deepEqual(result.website, f.manifest.website)
124
+ assert.equal(calls.length, 2)
125
+ })
126
+ for (const mutation of ['manifest-404', 'manifest-corrupt', 'publication-invalid', 'missing-version', 'wrong-board']) {
127
+ test(`explicit publication ${mutation} never falls back to legacy per-board metadata`, async () => {
128
+ const f = await fixture(), reference = await f.reference(), calls = []
129
+ const request = { version: mutation === 'missing-version' ? 'missing' : 'new', board: mutation === 'wrong-board' ? 'OTHER' : 'BOARD', fetch: async url => {
130
+ calls.push(url)
131
+ if (url.endsWith('config.toml')) return new Response(mutation === 'publication-invalid' ? '[publication]\nschemaVersion = 7' : config(reference))
132
+ return mutation === 'manifest-corrupt' ? new Response('{}') : new Response('', { status: 404 })
133
+ } }
134
+ await assert.rejects(loadOfficialFirmware(request))
135
+ assert.ok(!calls.some(url => url.includes('/firmware/')))
136
+ assert.equal(calls.length, ['manifest-404', 'manifest-corrupt'].includes(mutation) ? 2 : 1)
137
+ })
138
+ }
139
+ test('old official configs retain compatibility but explicit version avoids historical requests', async () => {
140
+ const f = await fixture('old'), calls = []
141
+ delete f.release.image.source
142
+ const text = `firmware_images_url = "${base}bin/"\n[esp-vision.image]\nBOARD = "latest.bin"\n[website]\nrelease = "new"\nreleases = ["new", "old"]\nbinaryNameTemplate = "esp-vision-{board}-{tag}.bin"\n`
143
+ const result = await loadOfficialFirmware({ version: 'old', fetch: async url => {
144
+ calls.push(url)
145
+ return url.endsWith('config.toml') ? new Response(text) : new Response(JSON.stringify(f.release))
146
+ } })
147
+ assert.equal(result.releases[0].version, 'old')
148
+ assert.deepEqual(calls, [base + 'config.toml', base + 'firmware/BOARD/old.json'])
149
+ })
150
+ test('manifest responses enforce a byte limit before allocation and during streaming', async () => {
151
+ const f = await fixture(), reference = await f.reference()
152
+ await assert.rejects(loadReleaseManifest({ ...reference, fetch: async () => ({ ok: true, headers: new Headers({ 'content-length': String(5 * 1024 * 1024) }), arrayBuffer() { throw new Error('must not allocate') } }) }), /byte limit/)
153
+ let cancelled = false
154
+ const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(4 * 1024 * 1024 + 1)) }, cancel() { cancelled = true } }))
155
+ await assert.rejects(loadReleaseManifest({ ...reference, fetch: async () => response }), /byte limit/)
156
+ assert.equal(cancelled, true)
157
+ })
158
+ test('a cancelled pending manifest stream closes its reader and rejects', async () => {
159
+ const f = await fixture(), controller = new AbortController()
160
+ let cancelled = false
161
+ const response = new Response(new ReadableStream({ cancel() { cancelled = true } }))
162
+ const pending = loadReleaseManifest({ ...await f.reference(), signal: controller.signal, fetch: async () => response })
163
+ await new Promise(resolve => setTimeout(resolve, 5))
164
+ controller.abort()
165
+ await assert.rejects(pending, { name: 'AbortError' })
166
+ assert.equal(cancelled, true)
167
+ })
168
+ test('official manifest URLs reject HTTP, credentials, redirects and outside-root origins', async () => {
169
+ const f = await fixture(), reference = await f.reference()
170
+ for (const url of ['http://dl.espressif.com/AE/esp-vision/release.json', 'https://user:pass@dl.espressif.com/AE/esp-vision/release.json', 'https://dl.espressif.com/other/release.json', 'https://example.test/release.json']) {
171
+ await assert.rejects(loadReleaseManifest({ ...reference, url }))
172
+ }
173
+ await assert.rejects(loadReleaseManifest({ ...reference, fetch: async () => ({ ok: true, url: 'https://untrusted.test/redirect', arrayBuffer() { throw new Error('must not read') } }) }), /official source/)
174
+ })
175
+
176
+ test('hash-pinned manifest uses browser cache and retries a fetch-stage CORS TypeError once without cache', async () => {
177
+ const f = await fixture(), reference = await f.reference(), caches = []
178
+ const result = await loadReleaseManifest({ ...reference, fetch: async (_url, options) => {
179
+ caches.push(options.cache)
180
+ if (caches.length === 1) throw new TypeError('Cached navigation response has no CORS headers')
181
+ return reference.fetch()
182
+ } })
183
+ assert.equal(result.version, 'new')
184
+ assert.deepEqual(caches, ['default', 'no-store'])
185
+ })
186
+ test('manifest cache recovery never retries HTTP, hash or metadata failures', async () => {
187
+ const f = await fixture(), reference = await f.reference()
188
+ for (const failure of ['http', 'body', 'hash', 'metadata']) {
189
+ const caches = []
190
+ let bytes
191
+ if (failure === 'metadata') {
192
+ f.manifest.pipelineId = 0
193
+ bytes = new TextEncoder().encode(JSON.stringify(f.manifest))
194
+ }
195
+ await assert.rejects(loadReleaseManifest({ ...reference, sha256: bytes ? await firmwareSha256(bytes) : reference.sha256, fetch: async (_url, options) => {
196
+ caches.push(options.cache)
197
+ if (failure === 'body') return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('body read failed')) } }))
198
+ return failure === 'http' ? new Response('', { status: 503 }) : new Response(bytes ?? '{}')
199
+ } }))
200
+ assert.deepEqual(caches, ['default'])
201
+ }
202
+ })
203
+ test('manifest fetch TypeError recovery stops after one retry and does not retry after cancellation', async () => {
204
+ const f = await fixture(), reference = await f.reference(), caches = []
205
+ await assert.rejects(loadReleaseManifest({ ...reference, fetch: async (_url, options) => {
206
+ caches.push(options.cache); throw new TypeError('CORS still unavailable')
207
+ } }), /CORS still unavailable/)
208
+ assert.deepEqual(caches, ['default', 'no-store'])
209
+ const controller = new AbortController(), cancelledCalls = []
210
+ await assert.rejects(loadReleaseManifest({ ...reference, signal: controller.signal, fetch: async (_url, options) => {
211
+ cancelledCalls.push(options.cache); controller.abort(); throw new TypeError('request cancelled')
212
+ } }))
213
+ assert.deepEqual(cancelledCalls, ['default'])
214
+ })
@@ -51,8 +51,16 @@ test('missing published integrity is not accepted', async () => {
51
51
  await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
52
52
  assert.deepEqual(f.waits, [])
53
53
  })
54
- test('persistent propagation failures stop after six reads with bounded backoff', async () => {
55
- const f = fixture(Array.from({ length: 6 }, () => new Response('', { status: 404 })))
56
- await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 6 reads/)
57
- assert.equal(f.calls.length, 6); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000])
54
+ test('persistent propagation failures stop after ten reads with bounded backoff', async () => {
55
+ const f = fixture(Array.from({ length: 10 }, () => new Response('', { status: 404 })))
56
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 10 reads/)
57
+ assert.equal(f.calls.length, 10); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000])
58
+ })
59
+
60
+ test('registry visibility delayed beyond the original 31-second window is verified without another publish', async () => {
61
+ const f = fixture([...Array.from({ length: 7 }, () => new Response('', { status: 404 })), published(integrity)])
62
+ await verifyPublishedIntegrity(url, integrity, f.options)
63
+ assert.equal(f.calls.length, 8)
64
+ assert.ok(f.waits.reduce((total, delay) => total + delay, 0) > 31000)
65
+ assert.ok(f.waits.every(delay => delay <= 30000))
58
66
  })