From 9c0997befa787507eaeabb9f1ec11e3858963a47 Mon Sep 17 00:00:00 2001 From: Torlando <239676438+torlando-tech@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:06:44 +0000 Subject: [PATCH] fix: make firmware updates persistence-safe --- .github/workflows/release-firmware.yml | 92 +++- docs/flasher/index.html | 404 +++++++++++++++--- src/main.cpp | 46 +- .../test_message_persistence_contract.py | 14 + .../test_package_pyxis_release.py | 88 ++++ .../test_validate_pyxis_web_release.py | 83 ++++ .../test_web_flasher_release_safety.py | 156 +++++++ tools/audit_release_build.py | 15 + tools/package_pyxis_release.py | 115 +++++ tools/validate_pyxis_web_release.py | 79 ++++ version.py | 17 +- 11 files changed, 1032 insertions(+), 77 deletions(-) create mode 100644 tests/build_scripts/test_package_pyxis_release.py create mode 100644 tests/build_scripts/test_validate_pyxis_web_release.py create mode 100644 tests/build_scripts/test_web_flasher_release_safety.py create mode 100644 tools/package_pyxis_release.py create mode 100644 tools/validate_pyxis_web_release.py diff --git a/.github/workflows/release-firmware.yml b/.github/workflows/release-firmware.yml index 2ab52044..87918f8c 100644 --- a/.github/workflows/release-firmware.yml +++ b/.github/workflows/release-firmware.yml @@ -21,6 +21,18 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 + + - name: Verify release tag points at current main + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $TAG" >&2 + exit 1 + fi + git fetch origin main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" - name: Set up Python uses: actions/setup-python@v5 @@ -65,21 +77,73 @@ jobs: cp "$BOOT_APP0" "docs/flasher/firmware/releases/${TAG}/" fi + - name: Generate audited release metadata + if: startsWith(github.ref, 'refs/tags/v') + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + BL="docs/flasher/firmware/bootloader.bin" + PT="docs/flasher/firmware/partitions.bin" + BA="docs/flasher/firmware/boot_app0.bin" + FW="docs/flasher/firmware/firmware.bin" + BL_SHA="$(sha256sum "$BL" | cut -d' ' -f1)" + PT_SHA="$(sha256sum "$PT" | cut -d' ' -f1)" + BA_SHA="$(sha256sum "$BA" | cut -d' ' -f1)" + FW_SHA="$(sha256sum "$FW" | cut -d' ' -f1)" + BL_SIZE="$(stat -c '%s' "$BL")" + PT_SIZE="$(stat -c '%s' "$PT")" + BA_SIZE="$(stat -c '%s' "$BA")" + FW_SIZE="$(stat -c '%s' "$FW")" + cat > docs/flasher/firmware/pyxis-release.json << EOF + { + "schema": 1, + "version": "${VERSION}", + "source_commit": "${GITHUB_SHA}", + "environment": "tdeck-release", + "chip_family": "ESP32-S3", + "flash_size": 8388608, + "persistence_safe": true, + "images": { + "bootloader.bin": { "file": "bootloader.bin", "offset": 0, "size": ${BL_SIZE}, "sha256": "${BL_SHA}" }, + "partitions.bin": { "file": "partitions.bin", "offset": 32768, "size": ${PT_SIZE}, "sha256": "${PT_SHA}" }, + "boot_app0.bin": { "file": "boot_app0.bin", "offset": 57344, "size": ${BA_SIZE}, "sha256": "${BA_SHA}" }, + "firmware.bin": { "file": "firmware.bin", "offset": 65536, "size": ${FW_SIZE}, "sha256": "${FW_SHA}" } + } + } + EOF + cp docs/flasher/firmware/pyxis-release.json "docs/flasher/firmware/releases/${VERSION}/" + python tools/validate_pyxis_web_release.py \ + --directory "docs/flasher/firmware/releases/${VERSION}" \ + --version "${VERSION}" + + - name: Build Columba-compatible Pyxis package + if: startsWith(github.ref, 'refs/tags/v') + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + PACKAGE_VERSION="${VERSION#v}" + python tools/package_pyxis_release.py \ + --firmware docs/flasher/firmware/firmware.bin \ + --boot-app0 docs/flasher/firmware/boot_app0.bin \ + --version "${PACKAGE_VERSION}" \ + --output "docs/flasher/firmware/pyxis-${VERSION}.pyxis.zip" + - name: Download existing release assets for GitHub Pages + if: github.ref == 'refs/heads/main' run: | # Fetch all published releases and download their firmware assets # so versioned firmware is available same-origin on GitHub Pages. # With keep_files: true, each release only needs to be downloaded once. - for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[].tag_name'); do + for tag in $(gh api repos/${{ github.repository }}/releases --jq '.[] | select(.draft == false and .prerelease == false) | select(any(.assets[]; .name == "pyxis-release.json")) | .tag_name'); do dir="docs/flasher/firmware/releases/${tag}" # Skip if we already have this version's firmware (e.g., current tagged build) - if [ -f "${dir}/firmware.bin" ]; then + if python tools/validate_pyxis_web_release.py --directory "${dir}" --version "${tag}"; then echo "Skipping ${tag} — already present" continue fi mkdir -p "${dir}" echo "Downloading assets for ${tag}..." - for asset in bootloader.bin partitions.bin boot_app0.bin firmware.bin; do + for asset in pyxis-release.json bootloader.bin partitions.bin boot_app0.bin firmware.bin; do url="https://github.com/${{ github.repository }}/releases/download/${tag}/${asset}" if curl -sL -o "${dir}/${asset}" -w '%{http_code}' "${url}" | grep -qx 200; then echo " ${asset} OK" @@ -88,13 +152,19 @@ jobs: echo " ${asset} not found" fi done + if ! python tools/validate_pyxis_web_release.py --directory "${dir}" --version "${tag}"; then + rm -rf "${dir}" + echo "Rejected ${tag} — metadata or image verification failed" + fi done env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate manifest files + if: github.ref == 'refs/heads/main' + env: + VERSION: ${{ steps.version.outputs.VERSION }} run: | - VERSION="${{ steps.version.outputs.VERSION }}" cat > docs/flasher/manifest-install.json << EOF { "name": "Pyxis T-Deck", @@ -130,6 +200,7 @@ jobs: EOF - name: Generate root landing page from README + if: github.ref == 'refs/heads/main' run: | mkdir -p docs/root pip install markdown @@ -168,6 +239,7 @@ jobs: " - name: Deploy root landing page + if: github.ref == 'refs/heads/main' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -175,6 +247,7 @@ jobs: keep_files: true - name: Deploy flasher to GitHub Pages + if: github.ref == 'refs/heads/main' uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -182,6 +255,15 @@ jobs: destination_dir: flasher keep_files: true + - name: Deploy versioned web-flasher assets + if: startsWith(github.ref, 'refs/tags/v') + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/flasher/firmware/releases/${{ steps.version.outputs.VERSION }} + destination_dir: flasher/firmware/releases/${{ steps.version.outputs.VERSION }} + keep_files: true + - name: Create GitHub Release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 @@ -193,5 +275,7 @@ jobs: docs/flasher/firmware/partitions.bin docs/flasher/firmware/boot_app0.bin docs/flasher/firmware/firmware.bin + docs/flasher/firmware/pyxis-release.json + docs/flasher/firmware/*.pyxis.zip generate_release_notes: true draft: true diff --git a/docs/flasher/index.html b/docs/flasher/index.html index 05dd46e5..50aa1766 100644 --- a/docs/flasher/index.html +++ b/docs/flasher/index.html @@ -213,6 +213,28 @@ margin-top: 0.5rem; } + .custom-firmware-group { + margin: -0.5rem 0 1.5rem; + padding-top: 1rem; + border-top: 1px solid rgba(255,255,255,0.1); + } + .custom-firmware-group label { + display: block; + color: var(--muted); + font-size: 0.875rem; + margin-bottom: 0.5rem; + } + .custom-firmware-group input { width: 100%; color: var(--text); } + .custom-firmware-status { + min-height: 1.2rem; + margin-top: 0.65rem; + color: var(--muted); + font-size: 0.8rem; + overflow-wrap: anywhere; + } + .custom-firmware-status.success { color: var(--success); } + .custom-firmware-status.error { color: var(--error); } + footer { text-align: center; margin-top: 2rem; @@ -242,19 +264,25 @@
- +
+
+ + +
No custom firmware selected.
+
+
- +
@@ -314,13 +342,20 @@ const progressText = document.getElementById('progress-text'); const versionSelect = document.getElementById('version-select'); const versionNote = document.getElementById('version-note'); + const customFirmwareInput = document.getElementById('custom-firmware'); + const customFirmwareStatus = document.getElementById('custom-firmware-status'); const REQUIRED_FULL_ASSETS = ['bootloader.bin', 'partitions.bin', 'boot_app0.bin', 'firmware.bin']; + const RELEASE_METADATA_ASSET = 'pyxis-release.json'; + const CONNECT_TIMEOUT_MS = 15000; + const EXPECTED_CHIP = 'ESP32-S3'; - // Current firmware path prefix — empty string for relative (latest), or a release download URL - let firmwarePathPrefix = ''; + // Fail closed until the GitHub API supplies a published, non-prerelease version. + let firmwarePathPrefix = null; + let selectedReleaseMetadata = null; + let customFirmwareBytes = null; // Track whether the selected release has all assets for full install - let releaseHasFullAssets = true; + let releaseHasFullAssets = false; function makeFirmwareFiles(prefix) { return { @@ -357,57 +392,217 @@ progressText.textContent = text; } - // Version selector logic - let latestVersion = 'dev'; + function withTimeout(promise, timeoutMs, message) { + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId)); + } - async function loadVersions() { - // Load deployed manifest version first - try { - const manifest = await fetch('manifest-install.json').then(r => r.json()); - latestVersion = manifest.version; - document.getElementById('firmware-version').textContent = latestVersion; - } catch (e) {} - - // Update the "Latest" option text - versionSelect.options[0].textContent = `Latest (${latestVersion})`; - - // Fetch GitHub releases - try { - const resp = await fetch('https://api.github.com/repos/torlando-tech/pyxis/releases'); - if (!resp.ok) return; - const releases = await resp.json(); - - for (const release of releases) { - if (release.draft || release.prerelease) continue; - const assetNames = release.assets.map(a => a.name); - if (!assetNames.includes('firmware.bin')) continue; - - const option = document.createElement('option'); - const hasAll = REQUIRED_FULL_ASSETS.every(f => assetNames.includes(f)); - option.value = release.tag_name; - option.textContent = release.tag_name + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : ''); - option.dataset.downloadUrl = `firmware/releases/${release.tag_name}/`; - option.dataset.hasFullAssets = hasAll ? 'true' : 'false'; - versionSelect.appendChild(option); + function sha256Fallback(bytes) { + const K = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, + ]; + const H = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]); + const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + const bitLength = bytes.length * 8; + const view = new DataView(padded.buffer); + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false); + view.setUint32(paddedLength - 4, bitLength >>> 0, false); + const w = new Uint32Array(64); + const rotr = (value, bits) => (value >>> bits) | (value << (32 - bits)); + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; } - } catch (e) { - // API failures are non-critical — user can still flash latest + let [a,b,c,d,e,f,g,h] = H; + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (h + s1 + ch + K[i] + w[i]) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + maj) >>> 0; + h=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0; + } + H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0; + H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0; + } + return Array.from(H, word => word.toString(16).padStart(8, '0')).join(''); + } + + async function sha256Hex(bytes) { + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + } + return sha256Fallback(bytes); + } + + async function validateEspImageStructure(bytes) { + const segmentCount = bytes[1]; + if (segmentCount < 1 || segmentCount > 16) { + throw new Error('firmware.bin has an invalid ESP segment count'); + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 24; + let checksum = 0xef; + for (let segment = 0; segment < segmentCount; segment++) { + if (offset + 8 > bytes.length) { + throw new Error('firmware.bin has a truncated ESP segment header'); + } + const dataLength = view.getUint32(offset + 4, true); + offset += 8; + if (dataLength === 0 || offset + dataLength > bytes.length) { + throw new Error('firmware.bin has an invalid ESP segment length'); + } + for (let index = offset; index < offset + dataLength; index++) checksum ^= bytes[index]; + offset += dataLength; + } + + const checksumOffset = Math.ceil((offset + 1) / 16) * 16 - 1; + if (checksumOffset >= bytes.length || bytes[checksumOffset] !== checksum) { + throw new Error('firmware.bin has an invalid ESP image checksum'); + } + for (let index = offset; index < checksumOffset; index++) { + if (bytes[index] !== 0) throw new Error('firmware.bin has invalid ESP image padding'); + } + + const hashAppended = bytes[23] === 1; + const expectedLength = checksumOffset + 1 + (hashAppended ? 32 : 0); + if (bytes.length !== expectedLength) { + throw new Error('firmware.bin is truncated or contains trailing data'); + } + if (hashAppended) { + const expectedHash = await sha256Hex(bytes.slice(0, checksumOffset + 1)); + const appendedHash = Array.from( + bytes.slice(checksumOffset + 1), + byte => byte.toString(16).padStart(2, '0') + ).join(''); + if (appendedHash !== expectedHash) throw new Error('firmware.bin has an invalid appended SHA-256'); } } - versionSelect.addEventListener('change', function() { - const selected = this.options[this.selectedIndex]; - if (this.value === 'latest') { - firmwarePathPrefix = 'firmware/'; - releaseHasFullAssets = true; - document.getElementById('firmware-version').textContent = latestVersion; - } else { - firmwarePathPrefix = selected.dataset.downloadUrl; - releaseHasFullAssets = selected.dataset.hasFullAssets === 'true'; - document.getElementById('firmware-version').textContent = this.value; - } + function setCustomFirmwareStatus(text, state = '') { + customFirmwareStatus.textContent = text; + customFirmwareStatus.className = `custom-firmware-status ${state}`.trim(); + } + + async function loadReleaseMetadata(release, prefix) { + const response = await fetch(prefix + RELEASE_METADATA_ASSET, { cache: 'no-store' }); + if (!response.ok) throw new Error(`Release metadata returned HTTP ${response.status}`); + const metadata = await response.json(); + if (metadata.schema !== 1 || metadata.persistence_safe !== true) throw new Error('Release is not persistence-safe'); + if (metadata.version !== release.tag_name) throw new Error('Release metadata version mismatch'); + if (metadata.environment !== 'tdeck-release') throw new Error('Release environment mismatch'); + if (metadata.chip_family !== 'ESP32-S3' || metadata.flash_size !== 8388608) throw new Error('Release target mismatch'); + if (!/^[0-9a-f]{40}$/.test(metadata.source_commit || '')) throw new Error('Invalid release source commit'); + + const expectedOffsets = { + 'bootloader.bin': 0x0, + 'partitions.bin': 0x8000, + 'boot_app0.bin': 0xe000, + 'firmware.bin': 0x10000, + }; + if (!metadata.images || Object.keys(metadata.images).length !== Object.keys(expectedOffsets).length) { + throw new Error('Release image descriptor set mismatch'); + } + for (const [name, offset] of Object.entries(expectedOffsets)) { + const descriptor = metadata.images[name]; + if (!descriptor || descriptor.file !== name || descriptor.offset !== offset || + !Number.isInteger(descriptor.size) || descriptor.size <= 0 || + !/^[0-9a-f]{64}$/.test(descriptor.sha256 || '')) { + throw new Error(`Invalid release descriptor for ${name}`); + } + } + if (metadata.images['bootloader.bin'].size > 0x8000 || + metadata.images['partitions.bin'].size > 0x1000 || + metadata.images['boot_app0.bin'].size !== 0x2000 || + metadata.images['firmware.bin'].size > 0x300000) { + throw new Error('Release image exceeds its partition bounds'); + } + return metadata; + } + + async function verifyDownloadedImage(file, bytes) { + const descriptor = selectedReleaseMetadata?.images?.[file.name]; + if (!descriptor || descriptor.offset !== file.offset || descriptor.size !== bytes.byteLength) { + throw new Error(`Release metadata mismatch for ${file.name}`); + } + const actualHash = await sha256Hex(bytes); + if (actualHash !== descriptor.sha256) throw new Error(`SHA-256 mismatch for ${file.name}`); + } + + async function validateCustomFirmware(bytes) { + if (bytes.byteLength === 0 || bytes.byteLength > 0x300000) { + throw new Error('firmware.bin exceeds the app0 partition or is empty'); + } + const chipId = bytes.byteLength >= 14 ? bytes[12] | (bytes[13] << 8) : -1; + if (bytes.byteLength < 24 || bytes[0] !== 0xe9 || chipId !== 9) { + throw new Error('Selected file is not an ESP32-S3 application image'); + } + await validateEspImageStructure(bytes); + } + + customFirmwareInput.addEventListener('change', async function() { + const file = this.files?.[0]; + if (!file) return; + setCustomFirmwareStatus('Reading firmware.bin...'); + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + await validateCustomFirmware(bytes); + const hash = await sha256Hex(bytes); + customFirmwareBytes = bytes; + firmwarePathPrefix = null; + selectedReleaseMetadata = null; + versionSelect.selectedIndex = 0; + eraseCheckbox.checked = false; + eraseCheckbox.disabled = true; + versionNote.textContent = `Custom firmware update only — ${bytes.byteLength} bytes — SHA-256 ${hash}`; + versionNote.classList.remove('hidden'); + document.getElementById('firmware-version').textContent = file.name; + flashBtn.textContent = 'Flash Custom Firmware'; + flashBtn.disabled = false; + const serialWarning = navigator.serial ? '' : ' Web Serial is unavailable in this browser context.'; + setCustomFirmwareStatus(`Ready: ${file.name} (${bytes.byteLength} bytes).${serialWarning}`, 'success'); + } catch (error) { + customFirmwareBytes = null; + this.value = ''; + flashBtn.disabled = !firmwarePathPrefix; + versionNote.textContent = error.message; + versionNote.classList.remove('hidden'); + setCustomFirmwareStatus(error.message, 'error'); + } + }); + + function selectPublishedRelease() { + const selected = versionSelect.options[versionSelect.selectedIndex]; + if (!selected || !selected.dataset.downloadUrl || !selected.releaseMetadata) return; + + firmwarePathPrefix = selected.dataset.downloadUrl; + selectedReleaseMetadata = selected.releaseMetadata; + customFirmwareBytes = null; + customFirmwareInput.value = ''; + setCustomFirmwareStatus('No custom firmware selected.'); + flashBtn.textContent = 'Flash Firmware'; + releaseHasFullAssets = selected.dataset.hasFullAssets === 'true'; + document.getElementById('firmware-version').textContent = selected.value; - // Handle full-install availability if (!releaseHasFullAssets) { eraseCheckbox.checked = false; eraseCheckbox.disabled = true; @@ -417,12 +612,78 @@ eraseCheckbox.disabled = false; versionNote.classList.add('hidden'); } - }); + flashBtn.disabled = false; + } + + async function loadVersions() { + flashBtn.disabled = true; + versionSelect.disabled = true; + try { + const resp = await fetch('https://api.github.com/repos/torlando-tech/pyxis/releases'); + if (!resp.ok) throw new Error(`GitHub API returned HTTP ${resp.status}`); + const releases = await resp.json(); + let publishedCount = 0; + + for (const release of releases) { + if (release.draft || release.prerelease) continue; + const assetNames = release.assets.map(a => a.name); + if (!assetNames.includes(RELEASE_METADATA_ASSET)) continue; + if (!assetNames.includes('firmware.bin')) continue; + + const prefix = `firmware/releases/${release.tag_name}/`; + let metadata; + try { + metadata = await loadReleaseMetadata(release, prefix); + } catch (e) { + continue; + } + + const option = document.createElement('option'); + const hasAll = REQUIRED_FULL_ASSETS.every(f => assetNames.includes(f)); + option.value = release.tag_name; + option.textContent = release.tag_name + (release.name && release.name !== release.tag_name ? ` — ${release.name}` : ''); + option.dataset.downloadUrl = prefix; + option.dataset.hasFullAssets = hasAll ? 'true' : 'false'; + option.releaseMetadata = metadata; + versionSelect.appendChild(option); + publishedCount++; + } + + if (publishedCount === 0) { + if (!customFirmwareBytes) { + versionNote.textContent = 'No persistence-safe published firmware releases are available.'; + versionNote.classList.remove('hidden'); + document.getElementById('firmware-version').textContent = 'unavailable'; + } + return; + } + + versionSelect.selectedIndex = 1; + versionSelect.disabled = false; + if (!customFirmwareBytes) selectPublishedRelease(); + } catch (e) { + if (!customFirmwareBytes) { + versionNote.textContent = 'Unable to load published firmware releases.'; + versionNote.classList.remove('hidden'); + document.getElementById('firmware-version').textContent = 'unavailable'; + } + versionSelect.disabled = true; + } + } + + versionSelect.addEventListener('change', selectPublishedRelease); async function flash() { - const useFullInstall = eraseCheckbox.checked; - const fw = makeFirmwareFiles(firmwarePathPrefix); - const files = useFullInstall ? fw.full : fw.update; + if (!customFirmwareBytes && !firmwarePathPrefix) { + versionNote.textContent = 'Select a published firmware release before flashing.'; + versionNote.classList.remove('hidden'); + return; + } + const useFullInstall = customFirmwareBytes ? false : eraseCheckbox.checked; + const fw = customFirmwareBytes ? null : makeFirmwareFiles(firmwarePathPrefix); + const files = customFirmwareBytes + ? [{ offset: 0x10000, name: 'firmware.bin', dataBytes: customFirmwareBytes }] + : (useFullInstall ? fw.full : fw.update); let transport = null; flashBtn.disabled = true; @@ -448,8 +709,16 @@ }, }); - const chip = await esploader.main(); + const chip = await withTimeout( + esploader.main(), + CONNECT_TIMEOUT_MS, + 'Timed out entering ESP32-S3 download mode. Cancel this attempt, hold BOOT, tap RESET, release BOOT, then retry.' + ); log(`Connected to ${chip}`, 'success'); + const chipName = esploader.chip?.CHIP_NAME; + if (chipName !== EXPECTED_CHIP) { + throw new Error(`Wrong device: expected ${EXPECTED_CHIP}, detected ${chipName || chip}`); + } // Load firmware files const fileArray = []; @@ -457,17 +726,22 @@ for (const file of files) { log(`Loading ${file.name}...`); - const response = await fetch(file.path); - if (!response.ok) throw new Error(`Failed to fetch ${file.name} (HTTP ${response.status}). This version may not be available yet — try "Latest" instead.`); - const arrayBuffer = await response.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); + let bytes; + if (file.dataBytes) { + bytes = file.dataBytes; + } else { + const response = await fetch(file.path); + if (!response.ok) throw new Error(`Failed to fetch ${file.name} (HTTP ${response.status}). Select another published version.`); + bytes = new Uint8Array(await response.arrayBuffer()); + await verifyDownloadedImage(file, bytes); + } let binaryString = ''; for (let i = 0; i < bytes.length; i++) { binaryString += String.fromCharCode(bytes[i]); } fileArray.push({ data: binaryString, address: file.offset }); - totalSize += arrayBuffer.byteLength; - log(`Loaded ${file.name} (${arrayBuffer.byteLength} bytes)`); + totalSize += bytes.byteLength; + log(`Loaded ${file.name} (${bytes.byteLength} bytes)`); } // Flash @@ -512,8 +786,8 @@ flashBtn.addEventListener('click', flash); - // Initialize: set default prefix and load versions - firmwarePathPrefix = 'firmware/'; + // Initialize with no flashable target; loadVersions enables flashing only + // after selecting a published, non-prerelease GitHub release. loadVersions(); diff --git a/src/main.cpp b/src/main.cpp index 70f7d117..aa90e94a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -125,6 +125,8 @@ AutoInterface* auto_interface_impl = nullptr; Interface* auto_interface = nullptr; BLEInterface* ble_interface_impl = nullptr; Interface* ble_interface = nullptr; +static bool persistent_storage_ready = false; +static bool storage_recovery_mode = false; // Timing uint32_t last_ui_update = 0; @@ -941,7 +943,8 @@ void setup_hardware() { // Pyxis's lib/universal_filesystem/ is now dead code on this build path and // can be deleted once the graft lands. static microStore::Adapters::LittleFSFileSystem fs; - if (!fs.init(false)) { + persistent_storage_ready = fs.init(false); + if (!persistent_storage_ready) { ERROR("FileSystem mount failed; preserving persistent data"); } else { INFO("FileSystem mounted"); @@ -1021,6 +1024,37 @@ void setup_lvgl_and_ui() { #endif } +void enter_storage_recovery_mode() { + storage_recovery_mode = true; + ERROR("Persistent storage unavailable; entering non-destructive recovery mode"); + + lv_obj_clean(lv_scr_act()); + lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0x1D1A1E), 0); + + lv_obj_t* title = lv_label_create(lv_scr_act()); + lv_label_set_text(title, "Persistent storage unavailable"); + lv_obj_set_style_text_color(title, lv_color_hex(0xFF6B6B), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 48); + + lv_obj_t* detail = lv_label_create(lv_scr_act()); + lv_obj_set_width(detail, 280); + lv_label_set_long_mode(detail, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(detail, lv_color_hex(0xF0F0F0), 0); + lv_label_set_text(detail, + "LittleFS could not be mounted.\n\n" + "Persistent data was not erased.\n" + "Messaging is disabled to prevent further damage.\n\n" + "USB serial recovery remains available."); + lv_obj_align(detail, LV_ALIGN_CENTER, 0, 18); + + if (!UI::LVGL::LVGLInit::start_task(1, 1)) { + ERROR("Failed to start LVGL recovery task"); + while (1) delay(1000); + } + INFO("Storage recovery UI active"); +} + void setup_reticulum() { INFO("\n=== Reticulum Initialization ==="); @@ -1735,6 +1769,11 @@ void setup() { setup_lvgl_and_ui(); BOOT_PROFILE_END("lvgl"); + if (!persistent_storage_ready) { + enter_storage_recovery_mode(); + return; + } + // Set SPI mutex on LoRa interface (before setup_reticulum creates it) if (spi_mutex) { SX1262Interface::set_spi_mutex(spi_mutex); @@ -2650,6 +2689,11 @@ void loop() { // Monitor display health Hardware::TDeck::Display::log_health(); + if (storage_recovery_mode) { + delay(10); + return; + } + // Handle deferred WiFi reconnect (from LVGL task) LOOP_STEP(3); // WiFi reconnect check if (wifi_reconnect_pending) { diff --git a/tests/build_scripts/test_message_persistence_contract.py b/tests/build_scripts/test_message_persistence_contract.py index b46231ff..e4c50c49 100644 --- a/tests/build_scripts/test_message_persistence_contract.py +++ b/tests/build_scripts/test_message_persistence_contract.py @@ -130,3 +130,17 @@ def test_system_info_reports_littlefs_not_unmounted_spiffs(): assert "LittleFS.totalBytes()" in source assert "LittleFS.usedBytes()" in source assert "SPIFFS.totalBytes()" not in source + + +def test_failed_littlefs_mount_enters_stable_recovery_mode_before_reticulum(): + source = MAIN.read_text() + setup = function_body(source, "void setup()", "void loop()") + loop = source[source.index("void loop()"):] + + assert "persistent_storage_ready = fs.init(false);" in source + assert "Persistent storage unavailable" in source + assert "USB serial recovery remains available." in source + assert setup.index("if (!persistent_storage_ready)") < setup.index("setup_reticulum();") + assert "enter_storage_recovery_mode();" in setup + assert "if (storage_recovery_mode)" in loop + assert loop.index("if (storage_recovery_mode)") < loop.index("reticulum->loop();") diff --git a/tests/build_scripts/test_package_pyxis_release.py b/tests/build_scripts/test_package_pyxis_release.py new file mode 100644 index 00000000..d529d35e --- /dev/null +++ b/tests/build_scripts/test_package_pyxis_release.py @@ -0,0 +1,88 @@ +"""Contract tests for deterministic Columba-compatible Pyxis update packages.""" + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "tools/package_pyxis_release.py" + + +def valid_firmware() -> bytes: + image = bytearray(128) + image[0] = 0xE9 + image[12:14] = (9).to_bytes(2, "little") + return bytes(image) + + +def valid_boot_app0() -> bytes: + image = bytearray([0xFF] * 0x2000) + image[:4] = b"\x01\x00\x00\x00" + return bytes(image) + + +def run_builder(tmp_path: Path, version: str = "v0.3.0") -> Path: + tmp_path.mkdir(parents=True, exist_ok=True) + firmware = tmp_path / "firmware.bin" + boot = tmp_path / "boot_app0.bin" + output = tmp_path / f"pyxis-{version}.pyxis.zip" + firmware.write_bytes(valid_firmware()) + boot.write_bytes(valid_boot_app0()) + subprocess.run( + [sys.executable, str(SCRIPT), "--firmware", str(firmware), "--boot-app0", str(boot), "--version", version, "--output", str(output)], + check=True, + ) + return output + + +def test_builder_emits_exact_columba_archive_and_manifest(tmp_path): + package = run_builder(tmp_path) + + with zipfile.ZipFile(package) as archive: + assert archive.namelist() == ["manifest.json", "firmware.bin", "boot_app0.bin"] + manifest = json.loads(archive.read("manifest.json")) + firmware = archive.read("firmware.bin") + boot = archive.read("boot_app0.bin") + + assert manifest == { + "schemaVersion": 1, + "product": "pyxis", + "board": "t-deck-plus", + "chip": "esp32-s3", + "version": "v0.3.0", + "firmware": { + "name": "firmware.bin", + "offset": 0x10000, + "size": len(firmware), + "sha256": hashlib.sha256(firmware).hexdigest(), + }, + "bootApp0": { + "name": "boot_app0.bin", + "offset": 0xE000, + "size": len(boot), + "sha256": hashlib.sha256(boot).hexdigest(), + }, + } + assert package.stat().st_size <= 4 * 1024 * 1024 + + +def test_builder_is_byte_for_byte_deterministic(tmp_path): + first = run_builder(tmp_path / "first") + second = run_builder(tmp_path / "second") + + assert first.read_bytes() == second.read_bytes() + + +def test_release_workflow_builds_and_uploads_columba_package(): + workflow = (ROOT / ".github/workflows/release-firmware.yml").read_text() + + assert "Build Columba-compatible Pyxis package" in workflow + assert "python tools/package_pyxis_release.py" in workflow + assert 'PACKAGE_VERSION="${VERSION#v}"' in workflow + assert '--version "${PACKAGE_VERSION}"' in workflow + assert 'docs/flasher/firmware/pyxis-${VERSION}.pyxis.zip' in workflow + assert "docs/flasher/firmware/*.pyxis.zip" in workflow diff --git a/tests/build_scripts/test_validate_pyxis_web_release.py b/tests/build_scripts/test_validate_pyxis_web_release.py new file mode 100644 index 00000000..3a16be37 --- /dev/null +++ b/tests/build_scripts/test_validate_pyxis_web_release.py @@ -0,0 +1,83 @@ +import hashlib +import json +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "tools/validate_pyxis_web_release.py" +OFFSETS = { + "bootloader.bin": 0, + "partitions.bin": 0x8000, + "boot_app0.bin": 0xE000, + "firmware.bin": 0x10000, +} + + +def make_release(directory: Path) -> None: + directory.mkdir() + images = { + "bootloader.bin": b"bootloader", + "partitions.bin": b"partitions", + "boot_app0.bin": b"\x01\x00\x00\x00" + b"\xff" * (0x2000 - 4), + "firmware.bin": b"\xe9" + b"\x00" * 11 + b"\x09\x00" + b"\x00" * 114, + } + descriptors = {} + for name, data in images.items(): + (directory / name).write_bytes(data) + descriptors[name] = { + "file": name, + "offset": OFFSETS[name], + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + metadata = { + "schema": 1, + "version": "v0.3.0", + "source_commit": "a" * 40, + "environment": "tdeck-release", + "chip_family": "ESP32-S3", + "flash_size": 8 * 1024 * 1024, + "persistence_safe": True, + "images": descriptors, + } + (directory / "pyxis-release.json").write_text(json.dumps(metadata)) + + +def test_validator_accepts_complete_matching_release(tmp_path): + release = tmp_path / "release" + make_release(release) + + subprocess.run( + [sys.executable, str(VALIDATOR), "--directory", str(release), "--version", "v0.3.0"], + check=True, + ) + + +def test_validator_rejects_digest_mismatch(tmp_path): + release = tmp_path / "release" + make_release(release) + (release / "firmware.bin").write_bytes(b"corrupt") + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--directory", str(release), "--version", "v0.3.0"], + ) + assert result.returncode != 0 + + +def test_validator_rejects_image_that_overlaps_next_persistent_region(tmp_path): + release = tmp_path / "release" + make_release(release) + oversized = b"\xff" * 0x1001 + (release / "partitions.bin").write_bytes(oversized) + metadata_path = release / "pyxis-release.json" + metadata = json.loads(metadata_path.read_text()) + metadata["images"]["partitions.bin"]["size"] = len(oversized) + metadata["images"]["partitions.bin"]["sha256"] = hashlib.sha256(oversized).hexdigest() + metadata_path.write_text(json.dumps(metadata)) + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--directory", str(release), "--version", "v0.3.0"], + ) + assert result.returncode != 0 \ No newline at end of file diff --git a/tests/build_scripts/test_web_flasher_release_safety.py b/tests/build_scripts/test_web_flasher_release_safety.py new file mode 100644 index 00000000..f8059066 --- /dev/null +++ b/tests/build_scripts/test_web_flasher_release_safety.py @@ -0,0 +1,156 @@ +"""Regression contracts for published-release-only, persistence-safe web flashing.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +FLASHER = ROOT / "docs/flasher/index.html" +WORKFLOW = ROOT / ".github/workflows/release-firmware.yml" +AUDIT = ROOT / "tools/audit_release_build.py" +VERSION_SCRIPT = ROOT / "version.py" + + +def test_flasher_starts_disabled_until_a_published_release_is_selected(): + source = FLASHER.read_text() + + assert '' in source + assert "flashBtn.disabled = true;" in source + assert "let firmwarePathPrefix = null;" in source + assert "firmwarePathPrefix = 'firmware/';" not in source + assert '