mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
Integrate reviewed source PRs #2201-#2223, #2225-#2231, #2235-#2240, #2242 and #2243 (firmware README hunks remain in #2247). Regenerate one package manifest after integration, validate native Windows test execution, bound MTK listener startup, support official C6 release bundles, and retain stronger release metadata gates.
226 lines
12 KiB
JavaScript
226 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { buildFlashArgs, CAPTURE_SCRIPT, flashFirmware, parseChip, resolveBundle, summarizeBootLog, validatePort } from '../src/firmware.js';
|
|
import { runTool } from '../src/tools.js';
|
|
|
|
const sha = (b) => createHash('sha256').update(b).digest('hex');
|
|
|
|
function makeBundle({ otadata = true, sums = true, tamper = false, extra = {} } = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ruview-fw-'));
|
|
const files = { 'bootloader.bin': 'BOOT', 'partition-table.bin': 'PT', 'esp32-csi-node.bin': 'APP', ...(otadata ? { 'ota_data_initial.bin': 'OTA' } : {}), ...extra };
|
|
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
|
|
if (sums) {
|
|
const lines = Object.entries(files).map(([name, body]) => `${tamper && name === 'esp32-csi-node.bin' ? '0'.repeat(64) : sha(body)} ${name}`);
|
|
writeFileSync(join(dir, 'SHA256SUMS.txt'), `${lines.join('\n')}\n`);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
/** Fake hardware: records every esptool/python invocation. */
|
|
function fakeDeps({ chip = 'ESP32-S3', flashOk = true, bootLog = 'I (123) csi_collector: CSI cb\nMGMT+DATA\n' } = {}) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
python: () => '/usr/bin/python3',
|
|
exec: async (cmd, args) => {
|
|
calls.push([cmd, ...args]);
|
|
if (args.includes('chip_id')) return { ok: Boolean(chip), code: 0, stdout: chip ? `Detecting chip type... ${chip}\nChip type: ${chip} (QFN56)` : '', stderr: chip ? '' : 'Failed to connect', error: null };
|
|
if (args.includes('write_flash')) return { ok: flashOk, code: flashOk ? 0 : 2, stdout: flashOk ? 'Hash of data verified.' : '', stderr: flashOk ? '' : 'A fatal error occurred', error: null };
|
|
if (args[0] === '-c') return { ok: true, code: 0, stdout: bootLog, stderr: '', error: null };
|
|
return { ok: false, code: 1, stdout: '', stderr: 'unexpected', error: null };
|
|
},
|
|
};
|
|
}
|
|
|
|
test('port validation rejects shell metacharacters and odd paths', () => {
|
|
for (const good of ['COM7', 'COM123', '/dev/ttyUSB0', '/dev/ttyACM1', '/dev/cu.usbserial-0001']) assert.equal(validatePort(good), good);
|
|
for (const bad of ['COM0', 'COM7;rm -rf /', '/etc/passwd', '/dev/sda', '../dev/ttyUSB0', '', 'COM1234']) {
|
|
assert.throws(() => validatePort(bad), /port must look like/, bad);
|
|
}
|
|
});
|
|
|
|
test('bundle resolution verifies every image and builds a shell-free esptool argv', () => {
|
|
const dir = makeBundle();
|
|
try {
|
|
const b = resolveBundle(dir, 's3-8mb');
|
|
assert.deepEqual(b.images.map((i) => [i.role, i.offset, i.integrity]), [
|
|
['bootloader', '0x0', 'verified'], ['partition-table', '0x8000', 'verified'], ['otadata', '0xf000', 'verified'], ['app', '0x20000', 'verified'],
|
|
]);
|
|
const args = buildFlashArgs(b, { port: 'COM7' });
|
|
assert.deepEqual(args.slice(0, 10), ['--chip', 'esp32s3', '--port', 'COM7', '--baud', '460800', 'write_flash', '--flash_mode', 'dio', '--flash_size']);
|
|
assert.equal(args[10], '8MB');
|
|
assert.throws(() => buildFlashArgs(b, { port: 'COM7', baud: 12345 }), /baud/);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// Creating symlinks on Windows needs Developer Mode or an elevated shell.
|
|
function trySymlink(target, path) {
|
|
try { symlinkSync(target, path); return true; } catch (error) {
|
|
if (process.platform === 'win32' && error.code === 'EPERM') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
test('checksum mismatch, missing checksums, and escaping symlinks are refused', (t) => {
|
|
const tampered = makeBundle({ tamper: true });
|
|
const unsigned = makeBundle({ sums: false });
|
|
const outside = mkdtempSync(join(tmpdir(), 'ruview-outside-'));
|
|
const linked = makeBundle();
|
|
try {
|
|
assert.throws(() => resolveBundle(tampered, 's3-8mb'), (e) => e.reason === 'checksum_mismatch');
|
|
assert.throws(() => resolveBundle(unsigned, 's3-8mb'), (e) => e.reason === 'checksums_missing');
|
|
assert.equal(resolveBundle(unsigned, 's3-8mb', { requireChecksums: false }).images[0].integrity, 'unverified');
|
|
writeFileSync(join(outside, 'evil.bin'), 'X');
|
|
rmSync(join(linked, 'esp32-csi-node.bin'));
|
|
if (trySymlink(join(outside, 'evil.bin'), join(linked, 'esp32-csi-node.bin'))) {
|
|
assert.throws(() => resolveBundle(linked, 's3-8mb', { requireChecksums: false }), (e) => e.reason === 'image_untrusted');
|
|
} else {
|
|
t.diagnostic('symlink case skipped: this Windows account cannot create symlinks (EPERM)');
|
|
}
|
|
assert.throws(() => resolveBundle(tampered, 'esp8266'), (e) => e.reason === 'invalid_variant');
|
|
} finally {
|
|
for (const d of [tampered, unsigned, outside, linked]) rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('4 MB variant prefers 4 MB images and 8 MB refuses a 4 MB partition table', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ruview-fw4-'));
|
|
try {
|
|
const files = { 'bootloader.bin': 'B', 'partition-table-4mb.bin': 'P4', 'esp32-csi-node-4mb.bin': 'A4' };
|
|
for (const [n, b] of Object.entries(files)) writeFileSync(join(dir, n), b);
|
|
writeFileSync(join(dir, 'SHA256SUMS'), Object.entries(files).map(([n, b]) => `${sha(b)} ${n}`).join('\n'));
|
|
const b = resolveBundle(dir, 's3-4mb');
|
|
assert.equal(b.flashSize, '4MB');
|
|
assert.equal(b.images.find((i) => i.role === 'app').relative, 'esp32-csi-node-4mb.bin');
|
|
assert.throws(() => resolveBundle(dir, 's3-8mb'), (e) => e.reason === 'image_missing');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('official C6 bundle names verify checksums and remain scoped to the C6 variant', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ruview-fw-c6-'));
|
|
try {
|
|
const files = {
|
|
'bootloader.bin': 'C6_BOOT',
|
|
'partition-table-c6.bin': 'C6_PT',
|
|
'esp32-csi-node-c6.bin': 'C6_APP',
|
|
'ota_data_initial.bin': 'C6_OTA',
|
|
};
|
|
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
|
|
writeFileSync(join(dir, 'SHA256SUMS.txt'), Object.entries(files).map(([name, body]) => `${sha(body)} ${name}`).join('\n'));
|
|
const bundle = resolveBundle(dir, 'c6');
|
|
assert.equal(bundle.chip, 'esp32c6');
|
|
assert.equal(bundle.flashSize, '4MB');
|
|
assert.deepEqual(bundle.images.map(({ relative, integrity }) => [relative, integrity]), [
|
|
['bootloader.bin', 'verified'], ['partition-table-c6.bin', 'verified'],
|
|
['ota_data_initial.bin', 'verified'], ['esp32-csi-node-c6.bin', 'verified'],
|
|
]);
|
|
for (const variant of ['s3-8mb', 's3-4mb']) {
|
|
assert.throws(() => resolveBundle(dir, variant), (error) => error.reason === 'image_missing');
|
|
}
|
|
// A local build may also be copied into the folder: prefer the release's
|
|
// explicit C6 images, never accidentally fall back to an unrelated app.
|
|
writeFileSync(join(dir, 'esp32-csi-node.bin'), 'OTHER_APP');
|
|
assert.equal(resolveBundle(dir, 'c6').images.find((image) => image.role === 'app').relative, 'esp32-csi-node-c6.bin');
|
|
writeFileSync(join(dir, 'esp32-csi-node-c6.bin'), 'TAMPERED');
|
|
assert.throws(() => resolveBundle(dir, 'c6'), (error) => error.reason === 'checksum_mismatch');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('C6 keeps generic ESP-IDF build directory support', () => {
|
|
const dir = makeBundle();
|
|
try {
|
|
const bundle = resolveBundle(dir, 'c6');
|
|
assert.equal(bundle.chip, 'esp32c6');
|
|
assert.equal(bundle.images.find((image) => image.role === 'app').relative, 'esp32-csi-node.bin');
|
|
assert.ok(bundle.images.every((image) => image.integrity === 'verified'));
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('flash without confirm is a plan and touches no hardware', async () => {
|
|
const dir = makeBundle({ otadata: false });
|
|
const deps = fakeDeps();
|
|
try {
|
|
const r = await flashFirmware({ port: '/dev/ttyUSB0', bundle: dir }, deps);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.dryRun, true);
|
|
assert.equal(deps.calls.length, 0);
|
|
assert.equal(r.plan.warnings.length, 1, 'missing otadata is called out');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('confirmed flash probes the chip, refuses a mismatch, and records boot evidence', async () => {
|
|
const dir = makeBundle();
|
|
try {
|
|
const mismatch = fakeDeps({ chip: 'ESP32-C6' });
|
|
const r1 = await flashFirmware({ port: 'COM7', bundle: dir, variant: 's3-8mb', confirm: true }, mismatch);
|
|
assert.deepEqual([r1.ok, r1.reason, r1.detected], [false, 'chip_mismatch', 'ESP32-C6']);
|
|
assert.ok(!mismatch.calls.some((c) => c.includes('write_flash')), 'no write after a chip mismatch');
|
|
|
|
const good = fakeDeps();
|
|
const r2 = await flashFirmware({ port: 'COM7', bundle: dir, confirm: true, boot_log_seconds: 5 }, good);
|
|
assert.equal(r2.ok, true);
|
|
assert.equal(r2.hardwareValidated, true);
|
|
assert.equal(r2.bootLog.csiCallbacks, 1);
|
|
assert.match(r2.evidence, /^MEASURED/);
|
|
const write = good.calls.find((c) => c.includes('write_flash'));
|
|
assert.equal(write[write.indexOf('--port') + 1], 'COM7', 'port travels as its own argv element');
|
|
|
|
const silent = fakeDeps({ bootLog: 'rst:0x1 (POWERON)\nboot:0x8\n' });
|
|
const r3 = await flashFirmware({ port: 'COM7', bundle: dir, confirm: true }, silent);
|
|
assert.equal(r3.ok, true);
|
|
assert.equal(r3.hardwareValidated, false, 'a write alone is not hardware validation');
|
|
|
|
const noBoard = await flashFirmware({ port: 'COM7', bundle: dir, confirm: true }, fakeDeps({ chip: null }));
|
|
assert.equal(noBoard.reason, 'chip_probe_failed');
|
|
const failed = await flashFirmware({ port: 'COM7', bundle: dir, confirm: true }, fakeDeps({ flashOk: false }));
|
|
assert.equal(failed.reason, 'flash_failed');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('chip and boot-log parsers handle esptool 4.x and 5.x output', () => {
|
|
assert.equal(parseChip('Chip is ESP32-S3 (QFN56) (revision v0.2)'), 'ESP32-S3');
|
|
assert.equal(parseChip('Detecting chip type... ESP32-C6'), 'ESP32-C6');
|
|
assert.equal(parseChip('Chip type: ESP32-S3 (QFN56)'), 'ESP32-S3');
|
|
assert.equal(parseChip('nothing'), null);
|
|
const panic = summarizeBootLog('CSI cb\nGuru Meditation Error: Core 0 panic\n');
|
|
assert.equal(panic.panic, true);
|
|
assert.equal(panic.hardwareValidated, false);
|
|
});
|
|
|
|
test('MCP flash requires a hardware-write grant and confirmation; plan is read-only', async () => {
|
|
const denied = await runTool('ruview_node_flash', { port: 'COM7', bundle: '/x', confirm: true }, { source: 'mcp', grants: [] });
|
|
assert.equal(denied.reason, 'authority_denied');
|
|
const unconfirmed = await runTool('ruview_node_flash', { port: 'COM7', bundle: '/x' }, { source: 'mcp', grants: ['hardware-write'] });
|
|
assert.equal(unconfirmed.reason, 'not_confirmed');
|
|
const plan = await runTool('ruview_firmware_plan', { port: 'COM7', bundle: '/definitely/missing' }, { source: 'mcp', grants: [] });
|
|
assert.equal(plan.reason, 'bundle_missing');
|
|
const injected = await runTool('ruview_firmware_plan', { port: 'COM7', bundle: '/x', confirm: true }, { source: 'mcp', grants: [] });
|
|
assert.equal(injected.reason, 'invalid_arguments', 'plan tool cannot be coerced into a write');
|
|
});
|
|
|
|
test('boot-log capture survives non-ASCII firmware logs on a cp1252 stdout', (t) => {
|
|
const py = ['python3', 'python'].find((p) => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0);
|
|
if (!py) { t.skip('python not available'); return; }
|
|
const fake = mkdtempSync(join(tmpdir(), 'ruview-fakeserial-'));
|
|
try {
|
|
// Stand-in pyserial: two lines, the second containing U+2192 (as the C6 firmware logs).
|
|
writeFileSync(join(fake, 'serial.py'), [
|
|
'class Serial:',
|
|
' def __init__(self, *a, **k): self.lines = [b"I (10) csi_collector: CSI cb #1\\n", "I (11) main: sync \\u2192 leader\\n".encode()]',
|
|
' def readline(self): return self.lines.pop(0) if self.lines else b""',
|
|
' def close(self): pass',
|
|
].join('\n'));
|
|
const r = spawnSync(py, ['-c', CAPTURE_SCRIPT, 'COM1', '0.5'], { env: { PATH: process.env.PATH, SYSTEMROOT: process.env.SYSTEMROOT || '', PYTHONPATH: fake, PYTHONIOENCODING: 'cp1252' }, encoding: 'utf8' });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
assert.match(r.stdout, /sync \u2192 leader/);
|
|
assert.equal(summarizeBootLog(r.stdout).csiCallbacks, 1);
|
|
} finally {
|
|
rmSync(fake, { recursive: true, force: true });
|
|
}
|
|
});
|