@esp-vision/device-core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })