diff --git a/.github/workflows/arstack-studio-linux-appimage.yml b/.github/workflows/arstack-studio-linux-appimage.yml index 9b04d33a0..62b094a2d 100644 --- a/.github/workflows/arstack-studio-linux-appimage.yml +++ b/.github/workflows/arstack-studio-linux-appimage.yml @@ -6,10 +6,22 @@ on: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-linux-appimage.yml' - 'scripts/install-linux.sh' + # When invoked by Native Preview, reuse the exact PR-head firmware + # artifact already built in that run. Do not compile a third copy of IDF. + workflow_call: + inputs: + paired_preview: + type: boolean + required: false + default: false + release_tag: + type: string + required: false + default: '' workflow_dispatch: inputs: release_tag: - description: 'Existing GitHub release tag to build, test and attach Linux AppImage; blank = build artifact only' + description: 'Build and test exact-tag Linux package; publishing is owned by the separate release workflow' required: false type: string @@ -26,7 +38,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag != '' && inputs.release_tag || github.sha }} + # A PR's github.sha is usually a synthetic merge commit. The paired + # firmware job uses the real PR HEAD: compile the matching GUI. + ref: ${{ (inputs.release_tag != '' && inputs.release_tag) || github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Confirm release identity before packaging @@ -106,12 +120,84 @@ jobs: shutil.copy2(root/'firmware-manifest.json',output/'firmware-manifest.json') (output/'SHA256SUMS.txt').write_text(f"{manifest['sha256']} {manifest['image']}\n") PY - curl -fLsS https://github.com/esp-rs/espflash/releases/download/v4.5.0/espflash-x86_64-unknown-linux-gnu.zip -o espflash.zip - echo 'dcd7fd822f247df18966935bbb10c7cb4b700c87172349633e9e323ee2542717 espflash.zip' | sha256sum -c - - unzip -j espflash.zip 'espflash' -d AppDir/usr/bin/tools - chmod +x AppDir/usr/bin/tools/espflash fi + - name: Download matched dual-revision firmware from this exact workflow + if: ${{ inputs.paired_preview == true }} + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Verify and stage pre-v3 + v3.2 preview firmware + if: ${{ inputs.paired_preview == true }} + env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + test -z "${{ inputs.release_tag }}" + test -n "$EXPECTED_SOURCE_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" + (cd firmware-package && sha256sum -c SHA256SUMS.txt) + python3 scripts/validate_esp32p4_revision_catalog.py firmware-package + python3 - <<'PY' + import hashlib, json, os, pathlib, shutil + root = pathlib.Path('firmware-package') + destination = pathlib.Path('AppDir/usr/bin/firmware') + destination.mkdir(parents=True, exist_ok=True) + legacy = json.loads((root/'firmware-manifest.json').read_text(encoding='utf-8')) + catalog = json.loads((root/'firmware-catalog.json').read_text(encoding='utf-8')) + expected = os.environ['EXPECTED_SOURCE_SHA'] + if not (legacy['sourceCommit'] == catalog['sourceCommit'] == expected): + raise SystemExit('Linux preview firmware/GUI commits differ') + if (not legacy.get('phaseSyncPreview') or + not legacy.get('bootSourceAutostartEnabled') or + legacy.get('globalGmTraceabilityPhysicallyProven') is not False or + catalog.get('releaseChannel') != 'engineering-preview'): + raise SystemExit('Unqualified preview firmware or misleading timing evidence') + allowed = {entry['image'] for entry in catalog['images']} + allowed.update(('firmware-manifest.json', 'firmware-catalog.json', 'SHA256SUMS.txt')) + if any(p.name not in allowed for p in root.iterdir()): + raise SystemExit('Unexpected files in firmware preview artifact') + for name in sorted(allowed): + shutil.copy2(root/name, destination/name) + provenance = { + 'schema': 'arstack.studio.linux.preview-provenance.v1', + 'sourceCommit': expected, + 'firmwareBuildId': expected[:16], + 'revisionProfiles': ['pre-v3', 'v3.2'], + 'firmwareReleaseChannel': 'engineering-preview', + 'siliconV3PhysicallyQualified': False, + 'globalGmTraceabilityPhysicallyProven': False, + 'stableRelease': False, + } + (pathlib.Path('AppDir/usr/bin')/'PREVIEW-PROVENANCE.json').write_text( + json.dumps(provenance, indent=2, sort_keys=True)+'\n', encoding='utf-8') + print('Linux paired preview: exact source + both revision images + provenance PASS') + PY + + # Both immutable stable-tag and paired-preview paths share one verified + # flasher source. The GUI-only PR AppImage never claims firmware support. + - name: Bundle verified Linux espflash v4.5.0 + if: ${{ inputs.release_tag != '' || inputs.paired_preview == true }} + run: | + set -euo pipefail + mkdir -p AppDir/usr/bin/tools + curl -fLsS https://github.com/esp-rs/espflash/releases/download/v4.5.0/espflash-x86_64-unknown-linux-gnu.zip -o espflash.zip + echo 'dcd7fd822f247df18966935bbb10c7cb4b700c87172349633e9e323ee2542717 espflash.zip' | sha256sum -c - + unzip -j espflash.zip 'espflash' -d AppDir/usr/bin/tools + chmod +x AppDir/usr/bin/tools/espflash + AppDir/usr/bin/tools/espflash --version + + - name: Check matched Linux preview with the production Studio + if: ${{ inputs.paired_preview == true }} + env: + ARSTACK_STUDIO_FIRMWARE_DIR: ${{ github.workspace }}/AppDir/usr/bin/firmware + ARSTACK_STUDIO_ESPFLASH: ${{ github.workspace }}/AppDir/usr/bin/tools/espflash + run: | + set -euo pipefail + QT_QPA_PLATFORM=offscreen xvfb-run -a build-studio-linux/arstack_studio --check-firmware-contract + - name: Bundle Qt and create single-file AppImage uses: miurahr/install-linuxdeploy-action@v1.8.0 id: linuxdeploy @@ -140,8 +226,10 @@ jobs: xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-reference-template APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-p0-policy - if [ -n "${{ inputs.release_tag }}" ]; then - APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ + if [ -n "${{ inputs.release_tag }}" ] || [ "${{ inputs.paired_preview }}" = "true" ]; then + # Test real extracted AppImage paths, not a developer-env override. + env -u ARSTACK_STUDIO_FIRMWARE_DIR -u ARSTACK_STUDIO_ESPFLASH \ + APPIMAGE_EXTRACT_AND_RUN=1 QT_QPA_PLATFORM=offscreen \ xvfb-run -a ./ARStack-Studio-linux-x86_64.AppImage --check-firmware-contract fi sha256sum ARStack-Studio-linux-x86_64.AppImage > ARStack-Studio-linux-x86_64.sha256 @@ -149,44 +237,10 @@ jobs: - name: Upload reviewed Linux artifacts uses: actions/upload-artifact@v4 with: - name: arstack-studio-linux-x86_64 + name: ${{ inputs.paired_preview && 'arstack-studio-linux-esp32p4-dual-preview' || 'arstack-studio-linux-x86_64' }} path: | ARStack-Studio-linux-x86_64.AppImage ARStack-Studio-linux-x86_64.sha256 if-no-files-found: error retention-days: 14 - attach-verified-release: - name: Attach tested Linux package to exact release tag - needs: appimage - if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag != '' }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.release_tag }} - fetch-depth: 0 - - name: Download Linux build that passed the AppImage checks - uses: actions/download-artifact@v4 - with: - name: arstack-studio-linux-x86_64 - - name: Verify tag and release before publishing - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.release_tag }} - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" - git fetch --no-tags origin main - git merge-base --is-ancestor "$TAG^{commit}" origin/main || { - echo "Release source is not integrated into main; refusing Linux package" >&2 - exit 1 - } - gh release view "$TAG" --json isDraft,isPrerelease -q 'select(.isDraft==false and .isPrerelease==false)' > release-check.json - test -s release-check.json - sha256sum -c ARStack-Studio-linux-x86_64.sha256 - gh release upload "$TAG" \ - ARStack-Studio-linux-x86_64.AppImage \ - ARStack-Studio-linux-x86_64.sha256 diff --git a/.github/workflows/arstack-studio-linux-auto-release.yml b/.github/workflows/arstack-studio-linux-auto-release.yml index 131405d7a..25f7efc4f 100644 --- a/.github/workflows/arstack-studio-linux-auto-release.yml +++ b/.github/workflows/arstack-studio-linux-auto-release.yml @@ -74,7 +74,7 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} run: | - gh workflow run arstack-studio-linux-appimage.yml \ + gh workflow run arstack-studio-linux-release-publish.yml \ --repo "$GITHUB_REPOSITORY" --ref main \ --field release_tag="$TAG" echo "Dispatched Linux build for release tag: $TAG" diff --git a/.github/workflows/arstack-studio-linux-release-publish.yml b/.github/workflows/arstack-studio-linux-release-publish.yml new file mode 100644 index 000000000..a30544277 --- /dev/null +++ b/.github/workflows/arstack-studio-linux-release-publish.yml @@ -0,0 +1,68 @@ +name: Publish Verified Linux AppImage to Stable Release + +# The read-only reusable AppImage builder MUST NOT request contents:write on +# pull requests. This separate trusted dispatch is the sole release uploader. +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Existing published latest stable GitHub release tag' + type: string + required: true + +permissions: + contents: read + +jobs: + build-verified-linux: + uses: ./.github/workflows/arstack-studio-linux-appimage.yml + with: + release_tag: ${{ inputs.release_tag }} + paired_preview: false + + attach-verified-release: + name: Attach exact-tag Linux AppImage after independent release checks + needs: build-verified-linux + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag }} + fetch-depth: 0 + + - name: Download the same-run tested Linux build + uses: actions/download-artifact@v4 + with: + name: arstack-studio-linux-x86_64 + + - name: Verify exact source, release identity and checksum before upload + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + test -n "$TAG" + test "$TAG" != "processbus-sim-m3h-20261024" + test "$TAG" != "processbus-sim-m3h3-20261025" + test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" + git fetch --no-tags origin main + git merge-base --is-ancestor "$TAG^{commit}" origin/main || { + echo "Release source is not integrated in main" >&2 + exit 1 + } + gh release view "$TAG" --json tagName,isDraft,isPrerelease -q \ + 'select(.tagName == env.TAG and .isDraft == false and .isPrerelease == false)' > release-check.json + test -s release-check.json + gh api "repos/$GITHUB_REPOSITORY/releases/latest" > latest-release.json + python3 - <<'PY' + import json, os + latest=json.load(open('latest-release.json', encoding='utf-8')) + assert latest['tag_name'] == os.environ['TAG'], 'not current stable release' + assert not latest['draft'] and not latest['prerelease'] + PY + sha256sum -c ARStack-Studio-linux-x86_64.sha256 + gh release upload "$TAG" \ + ARStack-Studio-linux-x86_64.AppImage \ + ARStack-Studio-linux-x86_64.sha256 diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index ae6b52943..7fdbca9a1 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -29,6 +29,15 @@ jobs: # Stable release workflow keeps ordinary firmware until board validation. phase_sync_preview: true + # Windows and Linux must package the SAME firmware artifact. This job + # runs after the existing exact-HEAD firmware builder, and adds no IDF jobs. + linux-preview: + needs: firmware + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + uses: ./.github/workflows/arstack-studio-linux-appimage.yml + with: + paired_preview: true + windows-preview: needs: firmware if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} @@ -249,7 +258,7 @@ jobs: guiVersion = '0.1.1-preview' firmwareVersion = '0.1.1' firmwareSourceCommit = $env:PREVIEW_SHA - firmwareCompile = 'ESP-IDF v5.5.4 / exact PR HEAD' + firmwareCompile = 'ESP-IDF v5.5.6 / exact PR HEAD' pinnedFlasherBasePackageSha256 = $env:BASE_PORTABLE_SHA256 embeddedExperimentalM3 = $false ptpHardwareQualified = $false diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 443ef47a6..e3d451161 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -9,6 +9,11 @@ on: default: false required: false workflow_dispatch: + inputs: + dual_revision_preview: + description: 'Also build the v3.2 engineering-preview image and catalog' + type: boolean + default: false push: branches: [main] paths: @@ -69,7 +74,9 @@ jobs: CCACHE_MAXSIZE: 350M CCACHE_COMPILERCHECK: content container: - image: espressif/idf:v5.5.4 + # v5.5.6 contains later P4 v3.2 watchdog fixes; both profiles use the + # same locked toolchain but separate generated sdkconfig/build outputs. + image: espressif/idf:v5.5.6 defaults: run: shell: bash @@ -91,6 +98,10 @@ jobs: restore-keys: | esp32p4-idf554-ccache-v1-${{ runner.os }}- + - name: Validate dual-revision package verifier negative fixtures + shell: bash + run: python3 "$GITHUB_WORKSPACE/tests/test_esp32p4_revision_catalog.py" + - name: Guard autonomous publisher and explicit Ethernet STOP policy shell: bash run: python3 "$GITHUB_WORKSPACE/tests/test_board_autonomous_publisher_contract.py" @@ -289,7 +300,7 @@ jobs: "flashOffset": 0, "image": image, "sha256": sha256, - "espIdf": "5.5.4", + "espIdf": "5.5.6", "sourceCommit": source_commit, "buildId": source_commit[:16], "phaseSyncPreview": candidate, @@ -330,13 +341,80 @@ jobs: print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY + # Only purpose-built engineering previews pay for a second IDF compile. + # Historical v1 single-image release pipelines stay unchanged. + - name: Build independent ESP32-P4 revision v3.2 preview image + if: ${{ inputs.phase_sync_preview == true || inputs.dual_revision_preview == true }} + run: | + set -euo pipefail + source /opt/esp/idf/export.sh + python3 - <<'PY' + from pathlib import Path + cfg = Path('sdkconfig.defaults').read_text(encoding='utf-8') + old = 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' + assert cfg.count(old) == 1, 'legacy silicon selector changed' + cfg = cfg.replace(old, '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set\nCONFIG_ESP32P4_REV_MIN_300=y') + Path('sdkconfig.v3x.defaults').write_text(cfg, encoding='utf-8') + PY + # Never allow the first profile's sdkconfig/build cache to be reused. + test ! -e sdkconfig.v3x + idf.py -B build-v3x -DSDKCONFIG="$PWD/sdkconfig.v3x" \ + -DSDKCONFIG_DEFAULTS="$PWD/sdkconfig.v3x.defaults" set-target esp32p4 + grep -qx '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set' sdkconfig.v3x + grep -qx 'CONFIG_ESP32P4_REV_MIN_300=y' sdkconfig.v3x + idf.py -B build-v3x build + # Fail if the effective revision configuration was silently reset. + grep -qx '# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is not set' sdkconfig.v3x + grep -qx 'CONFIG_ESP32P4_REV_MIN_300=y' sdkconfig.v3x + image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}-v3x.bin" + idf.py -B build-v3x merge-bin -o "$image" -f raw + test -s "build-v3x/$image" + + - name: Generate revision-aware catalog with distinct exact-SHA images + if: ${{ inputs.phase_sync_preview == true || inputs.dual_revision_preview == true }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib, json, os, pathlib + root = pathlib.Path('build') + legacy = json.loads((root/'firmware-manifest.json').read_text(encoding='utf-8')) + image = f"arstack-esp32p4-smv-{os.environ['ARSTACK_FIRMWARE_VERSION']}-v3x.bin" + payload = (pathlib.Path('build-v3x')/image).read_bytes() + old = (root/legacy['image']).read_bytes() + assert payload != old, 'two revisions accidentally contain identical firmware' + assert legacy['sourceCommit'][:16].encode('ascii') in payload, 'v3x build-id does not match checkout' + assert legacy['espIdf'] == '5.5.6' + catalog = { + 'schema': 'arstack.studio.firmware.catalog.v2', + 'chip': 'esp32p4', + 'version': legacy['version'], + 'protocol': legacy['protocol'], + 'sourceCommit': legacy['sourceCommit'], + 'releaseChannel': 'engineering-preview', + 'images': [ + {'policy': 'pre-v3', 'minRevision': 100, 'maxRevision': 199, + 'image': legacy['image'], 'sha256': legacy['sha256']}, + {'policy': 'v3.x', 'minRevision': 302, 'maxRevision': 302, + 'image': image, 'sha256': hashlib.sha256(payload).hexdigest()} + ] + } + assert hashlib.sha256(old).hexdigest() == legacy['sha256'] + (root/'firmware-catalog.json').write_text( + json.dumps(catalog, sort_keys=True, indent=2)+'\n', encoding='utf-8') + PY + - name: Stage Studio firmware package run: | set -euo pipefail mkdir -p build/studio-firmware cp "build/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" build/studio-firmware/ cp build/firmware-manifest.json build/studio-firmware/ - sha256sum build/studio-firmware/* > build/studio-firmware/SHA256SUMS.txt + if [[ -f build/firmware-catalog.json ]]; then + cp "build-v3x/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}-v3x.bin" build/studio-firmware/ + cp build/firmware-catalog.json build/studio-firmware/ + python3 "$GITHUB_WORKSPACE/scripts/validate_esp32p4_revision_catalog.py" build/studio-firmware + fi + (cd build/studio-firmware && sha256sum *.bin *.json > SHA256SUMS.txt) - name: Upload ESP32-P4 firmware package uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 552ba4f11..bd25ac991 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -249,7 +249,9 @@ SurfacePanel { : "Reinstall current" visible: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) - enabled: visible && !panel.firmware.busy + enabled: visible && !panel.firmware.busy && + !panel.device.running && !panel.device.ptpRunning && !panel.device.ptpStarting + toolTipText: enabled ? "Install the matched firmware" : "Stop active SV/PTP in the main firmware update dialog first" tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" onClicked: { if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 6f41d798d..d96969bb1 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -488,6 +488,7 @@ SurfacePanel { Dialog { id: installDialog + onOpened: recoveryBoardConfirmed.checked = false modal: true anchors.centerIn: Overlay.overlay width: 408 @@ -507,10 +508,18 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Keep USB connected until verification finishes." + text: "Keep USB connected. Use only Waveshare ESP32-P4-ETH-M, isolated from energized process-bus equipment." color: ribbon.theme.muted font.family: ribbon.uiFont font.pixelSize: 10 + wrapMode: Text.WordWrap + } + CheckBox { + id: recoveryBoardConfirmed + text: "I confirmed the supported board and safe lab connection" + font.family: ribbon.uiFont + font.pixelSize: 11 + checked: false } RowLayout { Layout.fillWidth: true @@ -521,8 +530,9 @@ SurfacePanel { } CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Install firmware"; tone: "accent"; implicitWidth: 142 + enabled: recoveryBoardConfirmed.checked onClicked: { - if (smartSession.beginFirmwareInstall()) { + if (smartSession.beginFirmwareInstall(recoveryBoardConfirmed.checked)) { ribbon.installPromptDeferred = false installDialog.close() } else controller.showMessage("Firmware installation could not start. Open Tools > Advanced for recovery details.", true) @@ -545,9 +555,11 @@ SurfacePanel { spacing: 12 Label { Layout.fillWidth: true - text: smartSession.firmwareUpdateRequired - ? "Update is required before SV injection." - : "Current firmware works. Updating is optional." + text: (device.running || device.ptpRunning || device.ptpStarting) + ? "This will stop the active SV/PTP test before installing firmware. Continue only when the test may be interrupted." + : (smartSession.firmwareUpdateRequired + ? "Update is required before SV injection." + : "Current firmware works. Updating is optional.") color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -567,7 +579,9 @@ SurfacePanel { onClicked: { ribbon.updatePromptDeferred = true; updateDialog.close() } } CalmButton { - theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Update firmware"; tone: "accent"; implicitWidth: 138 + theme: ribbon.theme; uiFont: ribbon.uiFont + text: (device.running || device.ptpRunning || device.ptpStarting) ? "Stop test & update" : "Update firmware" + tone: "accent"; implicitWidth: 154 onClicked: { if (smartSession.beginFirmwareUpdate()) { ribbon.updatePromptDeferred = false diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 8603b3b16..d9d956417 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -27,6 +27,8 @@ class DeterministicSessionHarness final { }; const CaseResult cases[] = { + {"dual-revision catalog selects only qualified silicon", revisionCatalogFailClosed()}, + {"firmware timeout retains port until flasher exits", firmwarePortReleaseWaitsForWorker()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, {"same version missing build ID -> READY with update suggestion", sameVersionMissingBuildOffersUpdate()}, @@ -128,6 +130,29 @@ class DeterministicSessionHarness final { } private: + static bool revisionCatalogFailClosed() { + FirmwareManager firmware; + firmware.revisionCatalogAvailable_ = true; + firmware.revisionImages_ = { + {QStringLiteral("pre-v3"), QStringLiteral("legacy.bin"), QStringLiteral("a"), 100, 199}, + {QStringLiteral("v3.x"), QStringLiteral("v3.bin"), QStringLiteral("b"), 302, 302}, + }; + const bool legacy = firmware.selectFirmwareForRevision(1, 3) && + firmware.revisionPolicy_ == QStringLiteral("pre-v3") && + firmware.firmwareImagePath_ == QStringLiteral("legacy.bin"); + const bool v3 = firmware.selectFirmwareForRevision(3, 2) && + firmware.revisionPolicy_ == QStringLiteral("v3.x") && + firmware.firmwareImagePath_ == QStringLiteral("v3.bin"); + const bool rejectsUnknown = !firmware.selectFirmwareForRevision(3, 1) && + !firmware.selectFirmwareForRevision(4, 0); + firmware.revisionImages_.push_back( + {QStringLiteral("overlap"), QStringLiteral("duplicate.bin"), QStringLiteral("c"), 302, 302}); + const bool rejectsOverlap = !firmware.selectFirmwareForRevision(3, 2); + const bool deadline = SmartSessionController::firmwareTotalDeadlineMs() == 300000; + firmware.shutdown(); + return legacy && v3 && rejectsUnknown && rejectsOverlap && deadline; + } + struct Fixture { SclProfileModel profiles; FirmwareManager firmware; @@ -150,6 +175,27 @@ class DeterministicSessionHarness final { } }; + static bool firmwarePortReleaseWaitsForWorker() { + Fixture fixture; + quiesce(fixture.session, fixture.device); + auto& s = fixture.session; + auto& f = fixture.firmware; + s.updateRequested_ = true; + s.updateStage_ = SmartSessionController::UpdateStage::flashing; + s.portOwner_ = SmartSessionController::PortOwner::firmwareTool; + f.busy_ = true; + s.latchFirmwareFailure(QStringLiteral("Deadline reached")); + const bool retained = s.portOwner_ == SmartSessionController::PortOwner::firmwareTool && + s.firmwareProcessRetirementPending_ && !s.updateRequested_ && + !s.startDeviceDiscovery(); + f.busy_ = false; + emit f.stateChanged(); // Emulate QProcess cancellation acknowledgement. + const bool released = !s.firmwareProcessRetirementPending_ && + s.portOwner_ == SmartSessionController::PortOwner::none && + s.setupError_; + return retained && released; + } + static void quiesce(SmartSessionController& session, StudioDeviceController& device) { session.discoveryTimer_.stop(); session.prepareTimer_.stop(); diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 7ea1a10c0..5beeec99c 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -27,6 +27,32 @@ constexpr auto kManifestName = "firmware-manifest.json"; constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; constexpr auto kExpectedChip = "esp32p4"; constexpr auto kPreV3Policy = "pre-v3"; +constexpr auto kV3Policy = "v3.x"; +constexpr auto kCatalogName = "firmware-catalog.json"; +constexpr auto kCatalogSchema = "arstack.studio.firmware.catalog.v2"; +constexpr qint64 kMaxFirmwareSize = 16 * 1024 * 1024; + +// ESP32-P4 ROM bootloader and the default factory application locations. +// Verify *both* headers, not the 0x0 fill in idf.py's merged raw image. +bool imageHeadersAcceptRevision(const QString& imagePath, const int minRevision, + const int maxRevision, const bool isV3) { + QFile file{imagePath}; + if (!file.open(QIODevice::ReadOnly)) return false; + for (const qint64 offset : {qint64{0x2000}, qint64{0x10000}}) { + if (!file.seek(offset)) return false; + const QByteArray header = file.read(24); + if (header.size() != 24 || static_cast(header[0]) != 0xE9) return false; + const auto word = [&header](const int at) -> int { + return static_cast(header[at]) | + (static_cast(static_cast(header[at + 1])) << 8); + }; + const int declaredMin = word(15); + const int declaredMax = word(17); + if (declaredMin > minRevision || declaredMax < maxRevision || + (isV3 && declaredMin < 300) || (!isV3 && declaredMax >= 300)) return false; + } + return true; +} constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; constexpr qsizetype kMaxProgressOutputTail = 2048; @@ -132,21 +158,77 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in static const QRegularExpression expression{ QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), QRegularExpression::CaseInsensitiveOption}; + // Ignore no chip or contradictory chip labels. Only one ROM identity is + // admissible, even if a second line has a different ESP chip name. + static const QRegularExpression chipLabel{ + QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}; + auto labels = chipLabel.globalMatch(output); + if (!labels.hasNext()) { + major = -1; + minor = -1; + return false; + } + labels.next(); + if (labels.hasNext()) { + major = -1; + minor = -1; + return false; + } const auto match = expression.match(output); if (!match.hasMatch()) { major = -1; minor = -1; return false; } + // Multiple chip-identity lines must not permit cherry-picking a + // compatible revision from contradictory/flaky ROM probe output. + if (expression.match(output, match.capturedEnd()).hasMatch()) { + major = -1; + minor = -1; + return false; + } major = match.captured(1).toInt(); minor = match.captured(2).toInt(); return true; } +QString FirmwareManager::parseEsp32P4Mac(const QString& output) { + // espflash v4.5 board-info reports "MAC address: aa:bb:cc:dd:ee:ff". + // Require one unambiguous ROM eFuse MAC; unrelated lines are diagnostics, + // and a recycled COM number is never a physical identity. + static const QRegularExpression macLabel{ + QStringLiteral(R"(^[ \t]*MAC address:)"), + QRegularExpression::CaseInsensitiveOption | QRegularExpression::MultilineOption}; + auto labels = macLabel.globalMatch(output); + if (!labels.hasNext()) return {}; + labels.next(); + if (labels.hasNext()) return {}; // Conflicting valid/invalid MAC labels. + static const QRegularExpression macExpression{ + QStringLiteral(R"(^[ \t]*MAC address:[ \t]*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})[ \t\r]*$)"), + QRegularExpression::CaseInsensitiveOption | QRegularExpression::MultilineOption}; + auto matches = macExpression.globalMatch(output); + if (!matches.hasNext()) return {}; + QString mac = matches.next().captured(1); + if (matches.hasNext()) return {}; // Contradictory/duplicated evidence. + mac.remove(QLatin1Char(':')); + mac = mac.toUpper(); + if (mac == QStringLiteral("000000000000") || mac == QStringLiteral("FFFFFFFFFFFF")) + return {}; + return mac; +} + bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) noexcept { return major >= 0 && major < 3 && minor >= 0; } +bool FirmwareManager::revisionInRange(const int major, const int minor, + const int minRevision, const int maxRevision) noexcept { + if (major < 0 || minor < 0 || minor >= 100 || major > 99 || + minRevision < 0 || maxRevision < minRevision) return false; + const int revision = major * 100 + minor; + return revision >= minRevision && revision <= maxRevision; +} + int FirmwareManager::parseFlashProgress(const QString& output) { // Keep this parser pure so deterministic fixtures exercise the exact // production token rules. Stream reassembly remains bounded in @@ -205,6 +287,10 @@ void FirmwareManager::refreshBundle() { revisionPolicy_.clear(); firmwareSha256_.clear(); firmwareImagePath_.clear(); + revisionImages_.clear(); + revisionCatalogAvailable_ = false; + targetVerified_ = false; + targetDeviceMac_.clear(); flashProgress_ = -1; progressOutputTail_.clear(); @@ -220,8 +306,11 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") - .arg(firmwareVersion_, firmwareBuildId_); + bundleStatus_ = revisionCatalogAvailable_ + ? QStringLiteral("Ready · pre-v3 + v3.2 preview · firmware v%1 · build %2 · SHA-256 verified") + .arg(firmwareVersion_, firmwareBuildId_) + : QStringLiteral("Ready · firmware v%1 · build %2 · ESP32-P4 pre-v3 · PTP-P2 · SHA-256 verified") + .arg(firmwareVersion_, firmwareBuildId_); emit stateChanged(); } @@ -273,7 +362,7 @@ bool FirmwareManager::loadManifest() { if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || !QRegularExpression(QStringLiteral("^[0-9a-f]{16}$")).match(buildId).hasMatch() || - !sourceCommit.startsWith(buildId, Qt::CaseInsensitive) || + !QRegularExpression(QStringLiteral("^[0-9a-f]{40}$")).match(sourceCommit).hasMatch() || revisionPolicy != QString::fromLatin1(kPreV3Policy) || !productionCapabilities) { bundleStatus_ = QStringLiteral("Firmware manifest fields/capabilities are incomplete or unsafe."); return false; @@ -303,6 +392,109 @@ bool FirmwareManager::loadManifest() { revisionPolicy_ = revisionPolicy; firmwareSha256_ = actualHash; firmwareImagePath_ = QDir::cleanPath(imagePath); + return loadRevisionCatalog(root, imageName, actualHash, sourceCommit); +} + +bool FirmwareManager::loadRevisionCatalog( + const QDir& root, const QString& legacyImage, + const QString& legacyHash, const QString& sourceCommit) { + const QFileInfo catalogInfo{root.filePath(QString::fromLatin1(kCatalogName))}; + if (!catalogInfo.exists()) return true; // Immutable historical v1 bundles remain pre-v3 only. + QFile catalog{catalogInfo.absoluteFilePath()}; + if (catalogInfo.isSymLink() || catalogInfo.size() <= 0 || catalogInfo.size() > 65536 || + !catalog.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("Firmware catalog could not be read safely."); + return false; + } + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(catalog.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + bundleStatus_ = QStringLiteral("Firmware catalog is invalid JSON."); + return false; + } + const QJsonObject object = document.object(); + const QJsonArray images = object.value(QStringLiteral("images")).toArray(); + if (object.value(QStringLiteral("schema")).toString() != QString::fromLatin1(kCatalogSchema) || + object.value(QStringLiteral("chip")).toString() != QString::fromLatin1(kExpectedChip) || + object.value(QStringLiteral("sourceCommit")).toString() != sourceCommit || + object.value(QStringLiteral("version")).toString() != firmwareVersion_ || + object.value(QStringLiteral("protocol")).toInt(-1) != expectedProtocol_.toInt() || + object.value(QStringLiteral("releaseChannel")).toString() != QStringLiteral("engineering-preview") || + images.size() != 2) { + bundleStatus_ = QStringLiteral("Firmware catalog identity/schema or image count is incompatible."); + return false; + } + QVector validated; + const QString canonicalRoot = QFileInfo(root.absolutePath()).canonicalFilePath(); + if (canonicalRoot.isEmpty()) { + bundleStatus_ = QStringLiteral("Firmware directory could not be resolved."); + return false; + } + for (const auto& value : images) { + if (!value.isObject()) { + bundleStatus_ = QStringLiteral("Firmware catalog contains an invalid image entry."); + return false; + } + const QJsonObject entry = value.toObject(); + const QString policy = entry.value(QStringLiteral("policy")).toString(); + const QString name = entry.value(QStringLiteral("image")).toString(); + const QString hash = normalizedHash(entry.value(QStringLiteral("sha256")).toString()); + const int minRevision = entry.value(QStringLiteral("minRevision")).toInt(-1); + const int maxRevision = entry.value(QStringLiteral("maxRevision")).toInt(-1); + // Qualification is intentionally limited to proven v1.x and the v3.2 + // engineering-preview target. Do not silently permit future revisions. + const bool legacy = policy == QString::fromLatin1(kPreV3Policy) && + minRevision == 100 && maxRevision == 199; + const bool v3 = policy == QString::fromLatin1(kV3Policy) && + minRevision == 302 && maxRevision == 302; + const QFileInfo imageInfo{root.filePath(name)}; + if ((!legacy && !v3) || name.isEmpty() || QFileInfo(name).fileName() != name || + !QRegularExpression(QStringLiteral("^[0-9a-f]{64}$")).match(hash).hasMatch() || + imageInfo.isSymLink() || !imageInfo.isFile() || imageInfo.size() <= 0 || + imageInfo.size() > kMaxFirmwareSize || + imageInfo.canonicalPath() != canonicalRoot || + (legacy && (name != legacyImage || hash != legacyHash))) { + bundleStatus_ = QStringLiteral("Firmware catalog has an unsafe or incompatible image entry."); + return false; + } + for (const RevisionImage& earlier : validated) { + if (earlier.policy == policy || earlier.imagePath == imageInfo.canonicalFilePath() || + !(maxRevision < earlier.minRevision || minRevision > earlier.maxRevision)) { + bundleStatus_ = QStringLiteral("Firmware catalog contains duplicate or overlapping images."); + return false; + } + } + QFile imageFile{imageInfo.canonicalFilePath()}; + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!imageFile.open(QIODevice::ReadOnly) || !hasher.addData(&imageFile) || + QString::fromLatin1(hasher.result().toHex()) != hash || + !imageHeadersAcceptRevision(imageInfo.canonicalFilePath(), minRevision, maxRevision, v3)) { + bundleStatus_ = QStringLiteral("Firmware catalog SHA-256 verification failed."); + return false; + } + validated.push_back({policy, imageInfo.canonicalFilePath(), hash, minRevision, maxRevision}); + } + if (validated.size() != 2 || validated[0].policy == validated[1].policy) { + bundleStatus_ = QStringLiteral("Firmware catalog must contain two disjoint revision policies."); + return false; + } + revisionImages_ = std::move(validated); + revisionCatalogAvailable_ = true; + return true; +} + +bool FirmwareManager::selectFirmwareForRevision(const int major, const int minor) { + if (!revisionCatalogAvailable_) return supportsEsp32P4Revision(major, minor); + const RevisionImage* selected = nullptr; + for (const RevisionImage& image : revisionImages_) { + if (!revisionInRange(major, minor, image.minRevision, image.maxRevision)) continue; + if (selected != nullptr) return false; // Multiple matches must never be resolved by array order. + selected = ℑ + } + if (selected == nullptr) return false; + revisionPolicy_ = selected->policy; + firmwareImagePath_ = selected->imagePath; + firmwareSha256_ = selected->sha256; return true; } @@ -313,6 +505,10 @@ bool FirmwareManager::probeTarget(const QString& portName) { fail(QStringLiteral("Bundled espflash executable is unavailable.")); return false; } + if (!bundleReady_) { + fail(bundleStatus_); + return false; + } const QString port = portName.trimmed(); if (port.isEmpty()) { fail(QStringLiteral("Select the ESP32-P4 USB serial port first.")); @@ -325,6 +521,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { selectedPort_ = port; targetVerified_ = false; + targetDeviceMac_.clear(); bootloaderHelpNeeded_ = false; flashProgress_ = -1; progressOutputTail_.clear(); @@ -347,7 +544,17 @@ bool FirmwareManager::installFirmware(const QString& portName) { return false; } if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { - fail(QStringLiteral("Check and verify a supported ESP32-P4 pre-v3 board before flashing.")); + fail(QStringLiteral("Identify and verify a supported ESP32-P4 board before installing firmware.")); + return false; + } + // Re-hash immediately before the write. A file replaced after preflight + // must never be trusted because the catalog was valid earlier. + QFile image{firmwareImagePath_}; + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!image.open(QIODevice::ReadOnly) || !hasher.addData(&image) || + QString::fromLatin1(hasher.result().toHex()) != firmwareSha256_) { + targetVerified_ = false; + fail(QStringLiteral("Firmware image changed after verification. Installation is blocked.")); return false; } if (sessionGeneration_ == 0) { @@ -606,10 +813,11 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) const bool p4WithRevision = success && parseEsp32P4Revision(operationOutput_, major, minor); const bool anyChipAnswered = success && operationOutput_.contains( QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); - const bool supportedRevision = p4WithRevision && - revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && - supportsEsp32P4Revision(major, minor); + const QString romMac = p4WithRevision ? parseEsp32P4Mac(operationOutput_) : QString{}; + const bool supportedRevision = p4WithRevision && !romMac.isEmpty() && + selectFirmwareForRevision(major, minor); + targetDeviceMac_ = supportedRevision ? romMac : QString{}; targetVerified_ = supportedRevision; bootloaderHelpNeeded_ = !success; if (p4WithRevision) { @@ -621,10 +829,12 @@ void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) } if (supportedRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Ready to install ARStack firmware.") - .arg(major).arg(minor).arg(selectedPort_)); + setStatus(QStringLiteral("ESP32-P4 v%1.%2 verified. Matching %3 firmware selected.") + .arg(major).arg(minor).arg(revisionPolicy_)); + } else if (p4WithRevision && romMac.isEmpty()) { + setStatus(QStringLiteral("The board responded, but its factory identity could not be verified. Installation is blocked.")); } else if (p4WithRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this firmware package (pre-v3). Installation is blocked.") + setStatus(QStringLiteral("ESP32-P4 v%1.%2 is not supported by this firmware package. Installation is blocked.") .arg(major).arg(minor)); } else if (anyChipAnswered) { setStatus(QStringLiteral("A chip answered on %1, but it is not an ESP32-P4. Installation is blocked.") diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 1616b42cc..04607c33e 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -5,9 +5,11 @@ #include #include #include +#include class DeterministicSessionHarness; class FirmwareWorker; +class QDir; class FirmwareManager : public QObject { Q_OBJECT @@ -39,6 +41,9 @@ class FirmwareManager : public QObject { [[nodiscard]] int flashProgress() const noexcept { return flashProgress_; } [[nodiscard]] QString selectedPort() const { return selectedPort_; } [[nodiscard]] QString targetChip() const { return targetChip_; } + // Factory eFuse MAC from the authorized ROM probe. Used to pin a physical + // device across COM renumbering and exact-build postboot verification. + [[nodiscard]] QString targetDeviceMac() const { return targetDeviceMac_; } [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } [[nodiscard]] QString firmwareBuildId() const { return firmwareBuildId_; } [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; } @@ -51,7 +56,9 @@ class FirmwareManager : public QObject { [[nodiscard]] bool workerAffinityValid() const noexcept { return workerAffinityValid_; } [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + [[nodiscard]] static QString parseEsp32P4Mac(const QString& output); [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; + [[nodiscard]] static bool revisionInRange(int major, int minor, int minRevision, int maxRevision) noexcept; [[nodiscard]] static int parseFlashProgress(const QString& output); [[nodiscard]] static constexpr int launchTimeoutMs() noexcept { return 15000; } [[nodiscard]] static constexpr int probeTimeoutMs() noexcept { return 30000; } @@ -89,10 +96,20 @@ class FirmwareManager : public QObject { private: friend class DeterministicSessionHarness; enum class Operation { none, probe, flash, reset }; + struct RevisionImage { + QString policy; + QString imagePath; + QString sha256; + int minRevision{-1}; + int maxRevision{-1}; + }; [[nodiscard]] QString bundleRoot() const; [[nodiscard]] QString flasherPath() const; bool loadManifest(); + bool loadRevisionCatalog(const QDir& root, const QString& legacyImage, + const QString& legacyHash, const QString& sourceCommit); + bool selectFirmwareForRevision(int major, int minor); bool startEspflash(const QStringList& arguments, Operation operation); int operationTimeoutMs(Operation operation) const noexcept; void connectWorkerSignals(); @@ -115,6 +132,7 @@ class FirmwareManager : public QObject { // operation log or creating another process/parser owner. QString progressOutputTail_; QString selectedPort_; + QString targetDeviceMac_; QString targetChip_{QStringLiteral("Not checked")}; QString firmwareVersion_{QStringLiteral("-")}; QString firmwareBuildId_; @@ -122,6 +140,8 @@ class FirmwareManager : public QObject { QString revisionPolicy_; QString firmwareSha256_; QString firmwareImagePath_; + QVector revisionImages_; + bool revisionCatalogAvailable_{false}; QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index b4ba1e05d..950fda184 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -105,6 +105,21 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent reconcile(); }); + firmwareTotalTimer_.setInterval(firmwareTotalDeadlineMs()); + firmwareTotalTimer_.setSingleShot(true); + connect(&firmwareTotalTimer_, &QTimer::timeout, this, [this] { + if (!updateRequested_) return; + if (firmware_ != nullptr && firmware_->busy()) { + firmwareProcessRetirementPending_ = true; + firmware_->cancel(); + } + latchFirmwareFailure(QStringLiteral( + "Firmware setup timed out. Studio is stopping the firmware tool; " + "the board is not verified. Use Recovery once the operation ends.")); + emit firmwareUpdateFinished(false); + reconcile(); + }); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -142,6 +157,7 @@ void SmartSessionController::shutdown() { discoveryTimer_.stop(); prepareTimer_.stop(); reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); profileSyncTimer_.stop(); profileRetryTimer_.stop(); profileSyncTotalTimer_.stop(); @@ -252,7 +268,7 @@ QString SmartSessionController::updateStatus() const { if (firmware_ == nullptr) return {}; switch (updateStage_) { case UpdateStage::stopping: - return QStringLiteral("Stopping SMV output safely…"); + return QStringLiteral("Stopping output safely before firmware setup…"); case UpdateStage::releasingPort: return QStringLiteral("Releasing the device serial session before firmware access…"); case UpdateStage::probing: @@ -293,6 +309,7 @@ void SmartSessionController::setPortOwner(const PortOwner owner) { bool SmartSessionController::startDeviceDiscovery() { if (device_ == nullptr || portOwner_ == PortOwner::firmwareTool || + firmwareProcessRetirementPending_ || (firmware_ != nullptr && firmware_->busy()) || device_->connected() || device_->discovering()) { return false; } @@ -584,7 +601,11 @@ bool SmartSessionController::beginFirmwareReinstall() { return beginFirmwareOperation(device_->portName()); } -bool SmartSessionController::beginFirmwareInstall() { +bool SmartSessionController::beginFirmwareInstall(const bool supportedBoardAndIsolationConfirmed) { + // A blank ROM cannot attest the PCB/PHY model or physical isolation. + // A confirmation token is required by the sole C++ authorization owner, + // not merely used to disable a button in the presentation layer. + if (!supportedBoardAndIsolationConfirmed) return false; updateWasOptional_ = false; refreshRecoveryOfferFromIdentity(); if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || @@ -596,10 +617,19 @@ bool SmartSessionController::beginFirmwareInstall() { bool SmartSessionController::beginFirmwareOperation(const QString& portName) { const QString port = portName.trimmed(); - if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false; + if (firmwareProcessRetirementPending_ && firmware_ != nullptr && !firmware_->busy()) { + firmwareProcessRetirementPending_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + } + if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || + firmware_->busy() || firmwareProcessRetirementPending_) return false; updatePort_ = port; + updateExpectedDeviceId_ = device_->deviceVerified() ? device_->deviceId().trimmed() : QString{}; + updateExpectedBootId_ = device_->deviceVerified() ? device_->bootId().trimmed() : QString{}; + stoppingPtpForFirmware_ = false; updateRequested_ = true; + firmwareTotalTimer_.start(); updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; profileSyncBootId_.clear(); @@ -608,16 +638,22 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { setupErrorStatus_.clear(); lastFirmwareUpdateResult_.clear(); - if (device_->running()) { + if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { updateStage_ = UpdateStage::stopping; + const bool stopSv = device_->running(); + stoppingPtpForFirmware_ = device_->ptpRunning() || device_->ptpStarting(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Stopping SMV output before firmware installation…"), + QStringLiteral("Stopping active SV/PTP test before firmware installation…"), false, false); - if (!device_->stop()) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + // Both STOP paths are ACK/readback verified by the existing device owner. + // An asynchronous STOP acceptance is not evidence of a stopped board. + const bool svAccepted = !stopSv || device_->stop(); + const bool ptpAccepted = !stoppingPtpForFirmware_ || (svAccepted && device_->stopPtp()); + if (!svAccepted || !ptpAccepted) { + latchFirmwareFailure(QStringLiteral("Could not safely stop active SV/PTP output. Firmware access was blocked.")); + emit firmwareUpdateFinished(false); reconcile(); return false; } @@ -629,12 +665,34 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { } bool SmartSessionController::retryFirmwareUpdate() { - if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || + firmware_->busy() || (updateRequested_ && updateStage_ != UpdateStage::waitingForBootloader)) + return false; + + const QString preservedMac = updateExpectedDeviceId_; + if (!preservedMac.isEmpty() && device_->deviceVerified() && + !recoveryIdentityMatches(preservedMac, device_->deviceId())) { + // An unrelated board might have appeared on the same COM number. + return false; + } + + if (!updateRequested_) { + // A terminal failure restarts through the *same* safe SV/PTP STOP and + // port-owner preflight as an initial operator-authorized installation. + // Do not bypass active PTP/SV simply because this button says Retry. + if (!beginFirmwareOperation(updatePort_)) return false; + if (updateExpectedDeviceId_.isEmpty()) + updateExpectedDeviceId_ = preservedMac; + return true; + } + + // Explicit user retry after Download-mode guidance. No implicit replay, + // and a fresh ROM probe is still required before every write attempt. setupError_ = false; setupErrorStatus_.clear(); lastFirmwareUpdateResult_.clear(); blankBoardDetected_ = false; - updateRequested_ = true; + firmwareTotalTimer_.start(); updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; continueFirmwareUpdate(); @@ -644,8 +702,10 @@ bool SmartSessionController::retryFirmwareUpdate() { bool SmartSessionController::cancelFirmwareUpdate() { if (!firmwareUpdateCanCancel() || device_ == nullptr) return false; reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); updateRequested_ = false; updateWasOptional_ = false; + stoppingPtpForFirmware_ = false; updateStage_ = UpdateStage::idle; updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; @@ -726,7 +786,7 @@ bool SmartSessionController::startFirmwareProbe() { void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; - if (device_->running()) { + if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { updateStage_ = UpdateStage::stopping; return; } @@ -849,7 +909,16 @@ void SmartSessionController::reconnectDeviceSignals() { }); connect(device_, &DeviceController::runningChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && - device_ != nullptr && !device_->running()) { + device_ != nullptr && !device_->running() && + !device_->ptpRunning() && !device_->ptpStarting()) { + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + } + reconcile(); + }); + connect(device_, &DeviceController::ptpStateChanged, this, [this] { + if (updateRequested_ && updateStage_ == UpdateStage::stopping && + device_ != nullptr && !device_->running() && + !device_->ptpRunning() && !device_->ptpStarting()) { QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); } reconcile(); @@ -905,6 +974,10 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_ == nullptr) return; connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (firmwareProcessRetirementPending_ && !firmware_->busy()) { + firmwareProcessRetirementPending_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + } if (!updateRequested_) { reconcile(); return; @@ -917,6 +990,19 @@ void SmartSessionController::reconnectFirmwareSignals() { if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { if (firmware_->targetVerified()) { + const QString romMac = firmware_->targetDeviceMac(); + if (romMac.isEmpty() || + (!updateExpectedDeviceId_.isEmpty() && + !recoveryIdentityMatches(updateExpectedDeviceId_, romMac))) { + latchFirmwareFailure(QStringLiteral( + "ROM identity does not match the selected ARStack board. No firmware was written.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } + // A fresh board has no application identity yet. This ROM + // eFuse MAC pins its identity for the subsequent boot proof. + updateExpectedDeviceId_ = romMac; updateStage_ = UpdateStage::flashing; setPresentation( QStringLiteral("UPDATING FIRMWARE"), @@ -1151,6 +1237,8 @@ QString SmartSessionController::diagnosticsText() const { void SmartSessionController::latchFirmwareFailure(QString message) { reconnectTimer_.stop(); + firmwareTotalTimer_.stop(); + stoppingPtpForFirmware_ = false; const bool optionalOperation = updateWasOptional_; updateRequested_ = false; updateWasOptional_ = false; @@ -1158,7 +1246,15 @@ void SmartSessionController::latchFirmwareFailure(QString message) { updateReconnectAttempts_ = 0; pendingReleaseGeneration_ = 0; blankBoardDetected_ = false; - if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + if (portOwner_ == PortOwner::firmwareTool) { + // QProcess::kill is asynchronous. Busy remains true until its finish + // signal; releasing the COM lease earlier would permit another owner + // while the flasher can still be touching the board. + if (firmware_ != nullptr && firmware_->busy()) + firmwareProcessRetirementPending_ = true; + else + setPortOwner(PortOwner::none); + } message = message.trimmed(); lastFirmwareUpdateResult_ = message.isEmpty() ? QStringLiteral("Firmware verification failed; inspect the firmware-service log.") @@ -1436,6 +1532,15 @@ void SmartSessionController::reconcile() { // A completed write must verify the exact bundled build. This remains // fail-closed even though a pre-existing compatible build may be used. if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + if ((!updateExpectedDeviceId_.isEmpty() && + !recoveryIdentityMatches(updateExpectedDeviceId_, device_->deviceId())) || + (!updateExpectedBootId_.isEmpty() && + updateExpectedBootId_ == device_->bootId().trimmed())) { + latchFirmwareFailure(QStringLiteral("A different or stale device identity appeared after firmware setup. Verification was blocked.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } if (!firmwareIsCurrent()) { const QString observed = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy/unknown firmware") @@ -1453,6 +1558,8 @@ void SmartSessionController::reconcile() { reconcile(); return; } + firmwareTotalTimer_.stop(); + stoppingPtpForFirmware_ = false; updateRequested_ = false; updateWasOptional_ = false; updateStage_ = UpdateStage::idle; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 516783333..12a351f17 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -144,6 +144,7 @@ class SmartSessionController : public QObject { [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } + [[nodiscard]] static constexpr int firmwareTotalDeadlineMs() noexcept { return 300000; } [[nodiscard]] static constexpr bool generationIsCurrent( const quint64 activeGeneration, @@ -229,7 +230,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool beginFirmwareUpdate(); Q_INVOKABLE bool beginFirmwareReinstall(); - Q_INVOKABLE bool beginFirmwareInstall(); + Q_INVOKABLE bool beginFirmwareInstall(bool supportedBoardAndIsolationConfirmed); Q_INVOKABLE bool retryFirmwareUpdate(); Q_INVOKABLE bool cancelFirmwareUpdate(); Q_INVOKABLE bool retryFirmwareSetup(); @@ -265,7 +266,9 @@ class SmartSessionController : public QObject { return owner_->updateStage_ == UpdateStage::releasingPort && owner_->pendingReleaseGeneration_ != 0; } [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { - return stage == UpdateStage::stopping ? firmwareStopAckTimeoutMs() : firmwareReleaseAckTimeoutMs(); + return stage == UpdateStage::stopping + ? (owner_ != nullptr && owner_->stoppingPtpForFirmware_ ? 12000 : firmwareStopAckTimeoutMs()) + : firmwareReleaseAckTimeoutMs(); } void synchronize() { if (!waitingForAck()) { @@ -405,6 +408,7 @@ class SmartSessionController : public QObject { QTimer discoveryTimer_; QTimer prepareTimer_; QTimer reconnectTimer_; + QTimer firmwareTotalTimer_; QTimer profileSyncTimer_; QTimer profileRetryTimer_; QTimer profileSyncTotalTimer_; @@ -413,6 +417,8 @@ class SmartSessionController : public QObject { QString deviceFirmwareVersion_; QString deviceFirmwareBuildId_; QString updatePort_; + QString updateExpectedDeviceId_; + QString updateExpectedBootId_; QString blankBoardPort_; QString setupErrorStatus_; QString lastFirmwareUpdateResult_; @@ -429,6 +435,10 @@ class SmartSessionController : public QObject { bool m4Mode_{false}; // suppress V1 automatic profile sync after M4 operator intent bool updateRequested_{false}; bool updateWasOptional_{false}; + bool stoppingPtpForFirmware_{false}; + // Do not transfer serial authority until an asynchronously cancelled + // flasher has actually exited and FirmwareManager has cleared busy(). + bool firmwareProcessRetirementPending_{false}; bool blankBoardDetected_{false}; bool manualRecoveryArmed_{false}; bool setupError_{false}; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index c421dc0f3..ea10567e9 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -260,13 +260,35 @@ int checkFirmwareContract(int argc, char* argv[]) { const bool rejectsWrongChip = !FirmwareManager::parseEsp32P4Revision( QStringLiteral("Chip type: esp32s3 (revision v0.2)\n"), wrongMajor, - wrongMinor); + wrongMinor) && + !FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32p4 (revision v1.3)\nChip type: esp32p4 (revision v3.2)"), + wrongMajor, + wrongMinor); + + const bool macIdentityContract = + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "Chip type: esp32p4 (revision v3.2)\r\n" + "MAC address: c8:f0:9e:50:8a:d0\r\n")) == + QStringLiteral("C8F09E508AD0") && + FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: 00:00:00:00:00:00")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral("MAC address: bad")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "MAC address: c8:f0:9e:50:8a:d0\nMAC address: aa:bb:cc:dd:ee:ff")).isEmpty() && + FirmwareManager::parseEsp32P4Mac(QStringLiteral( + "MAC address: c8:f0:9e:50:8a:d0\nMAC address: invalid")).isEmpty(); const bool revisionPolicy = FirmwareManager::supportsEsp32P4Revision(0, 0) && FirmwareManager::supportsEsp32P4Revision(1, 3) && FirmwareManager::supportsEsp32P4Revision(2, 99) && !FirmwareManager::supportsEsp32P4Revision(3, 0) && + FirmwareManager::revisionInRange(1, 3, 100, 199) && + !FirmwareManager::revisionInRange(1, 3, 302, 302) && + FirmwareManager::revisionInRange(3, 2, 302, 302) && + !FirmwareManager::revisionInRange(3, 1, 302, 302) && + !FirmwareManager::revisionInRange(3, 99, 302, 302) && + !FirmwareManager::revisionInRange(3, 100, 300, 399) && !FirmwareManager::supportsEsp32P4Revision(-1, 0) && !FirmwareManager::supportsEsp32P4Revision(1, -1); @@ -305,7 +327,7 @@ int checkFirmwareContract(int argc, char* argv[]) { firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareBuildId().size() == 16 && firmware.firmwareSha256().size() == 64 && - realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && + realEspflashFormat && dashedFormat && rejectsWrongChip && macIdentityContract && revisionPolicy && flashProgressFormat && recoverySelection && firmwareTimeoutPolicy && firmwareWorkerBoundary; if (!valid) { qCritical().noquote() @@ -315,6 +337,7 @@ int checkFirmwareContract(int argc, char* argv[]) { << "dashed-format=" << dashedFormat << "wrong-chip-rejected=" << rejectsWrongChip << "revision-policy=" << revisionPolicy + << "rom-mac-contract=" << macIdentityContract << "progress-format=" << flashProgressFormat << "recovery-selection=" << recoverySelection << "timeout-policy=" << firmwareTimeoutPolicy diff --git a/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md b/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md new file mode 100644 index 000000000..43096621b --- /dev/null +++ b/docs/releases/P1_ESP32P4_DUAL_REVISION_HIL_ACCEPTANCE.md @@ -0,0 +1,33 @@ +# P1 ESP32-P4 dual-revision — physical acceptance (engineering preview) + +**Scope:** Waveshare ESP32-P4-ETH-M v3.2 and previously accepted ESP32-P4 v1.3. **Status:** PHYSICAL TESTING PENDING. No energized process bus, no live protection system, no externally traceable PTP claim. Existing M3.H.3 stable evidence is not v3.2 evidence. + +Use the **matched Windows Native Preview** or **paired Linux Preview AppImage** from the same PR build run, not the standalone GUI-only Linux artifact. A valid test package contains Studio, bundled espflash v4.5.0, `firmware-manifest.json`, `firmware-catalog.json`, separate `pre-v3` / `v3.x` binaries, and `SHA256SUMS.txt`. Record the manifest `sourceCommit` and both image digests. + +## Operator procedure (one isolated board at a time) + +1. Disconnect from energized equipment. Connect the board USB; keep Ethernet connected only to an isolated bench switch/capture port. Use external board power if required by vendor instructions. Record PCB model and actual silicon revision. +2. Launch Studio. Confirm that passive detection does not reset, write firmware, stop an autonomous board or start SV/PTP. For a fresh device, use **Set up board** and acknowledge the qualified PCB/isolation check. For an existing verified device, choose **Update firmware**. +3. Studio performs an authorized ROM probe. On v1.3 it must select **pre-v3**; on v3.2 it must select **v3.x**. Check ROM factory eFuse MAC against the application `device_id` in copied diagnostics. Unknown/ambiguous revision, invalid MAC or unsupported board must be rejected before write. +4. Keep USB connected until installation ends. Verify that Studio resets/reacquires the *same* physical board and reports a fresh `boot_id`, expected firmware version/protocol/capabilities and the **exact** manifest build ID. A 100% flash bar by itself is not Ready. +5. Check PHY link and standalone board behavior. With isolation maintained, explicitly START the canonical 4I+4V/4000fps test. Capture Ethernet SV packets and PTP Layer-2 frames. Verify 4000 frames/s per configured MU, correct `smpCnt` sequence (including wrap), expected VLAN/APPID/quality, no loss/duplicate frames, and truthful sync state. Do not interpret simulated GM as physical UTC/GNSS traceability. +6. Verify STOP, cold boot, USB disconnect/reconnect, COM renumbering, and bounded recovery. No silent SV/PTP restart or cached SCD/profile deployment after reconnect. +7. Exercise controlled negative scenarios on isolated hardware: unexpected USB removal, explicit Download mode, failed flash, and recovery. Record whether content may be partially written; never force-write a deliberately incompatible revision or change eFuses/security to make a test pass. + +## Evidence sheet (complete separately for **each** silicon revision) + +| Evidence | v1.3 | v3.2 | +| --- | --- | --- | +| Board model / PCB rev / silicon ROM rev | PENDING | PENDING | +| ROM factory MAC = postboot device ID | PENDING | PENDING | +| Tool version + OS + preview artifact ID / sourceCommit | PENDING | PENDING | +| Selected image name + manifest SHA + image-header bounds | PENDING | PENDING | +| Fresh boot ID + exact postboot firmware build/protocol | PENDING | PENDING | +| PHY link + captured 4kHz SV with `smpCnt`/quality continuity | PENDING | PENDING | +| PTP Layer-2 capture + defensible `smpSynch` evidence | PENDING | PENDING | +| STOP/START, reset/reconnect, no silent restart | PENDING | PENDING | +| Interrupted write and bounded recovery | PENDING | PENDING | + +Attach Wireshark PCAP/PCAPNG, serial diagnostics, ROM/flash tool output, OS/build SHA and notes about any nonconformance. Mark **PASS** only after reproducible measured evidence; CI artifacts and simulations are never physical evidence. v3.2 remains an **engineering preview** until qualified in hardware on both lanes. + +See [PRD](../PRD_REVISION_AWARE_ESP32P4_FIRMWARE.md) and [ARD](../ARD_REVISION_AWARE_ESP32P4_FIRMWARE.md) for mandatory negative tests and release gates. diff --git a/scripts/validate_esp32p4_revision_catalog.py b/scripts/validate_esp32p4_revision_catalog.py new file mode 100644 index 000000000..ea62112c2 --- /dev/null +++ b/scripts/validate_esp32p4_revision_catalog.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Fail-closed CI/package validation for ESP32-P4 silicon revision artifacts. + +The Studio runtime separately validates the catalog and SHA before a write. +This check prevents shipping a catalog with a wrong bootloader image header. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +CAP = 16 * 1024 * 1024 +SCHEMA = "arstack.studio.firmware.catalog.v2" + + +def header_revision_bounds(image: bytes, offset: int) -> tuple[int, int]: + # ESP32-P4 ROM loads bootloader from flash 0x2000; the default factory + # application lives at 0x10000. The merged raw image begins at flash 0. + # ESP-IDF esp_image_header_t: min_chip_rev_full @15, max @17. + if len(image) < offset + 24 or image[offset] != 0xE9: + raise ValueError(f"missing ESP32-P4 image header at flash 0x{offset:x}") + minimum = int.from_bytes(image[offset + 15:offset + 17], "little") + maximum = int.from_bytes(image[offset + 17:offset + 19], "little") + if minimum > maximum: + raise ValueError("inverted chip revision bounds in bootloader") + return minimum, maximum + + +def verify(root: Path) -> None: + root = root.resolve(strict=True) + legacy = json.loads((root / "firmware-manifest.json").read_text()) + catalog = json.loads((root / "firmware-catalog.json").read_text()) + if (catalog.get("schema") != SCHEMA or catalog.get("chip") != "esp32p4" + or legacy.get("chip") != "esp32p4" + or catalog.get("sourceCommit") != legacy.get("sourceCommit") + or catalog.get("version") != legacy.get("version") + or catalog.get("protocol") != legacy.get("protocol") + or catalog.get("releaseChannel") != "engineering-preview"): + raise ValueError("catalog identity mismatch") + images = catalog.get("images") + if not isinstance(images, list) or len(images) != 2: + raise ValueError("catalog must include exactly two images") + policies = set() + names = set() + for entry in images: + if not isinstance(entry, dict): + raise ValueError("invalid image entry") + policy = entry.get("policy") + name = entry.get("image") + sha = entry.get("sha256") + if (policy not in ("pre-v3", "v3.x") + or not isinstance(name, str) or Path(name).name != name + or name in names or policy in policies + or not isinstance(sha, str) or len(sha) != 64): + raise ValueError("unsafe image or duplicate revision selection") + policies.add(policy) + names.add(name) + low, high = (100, 199) if policy == "pre-v3" else (302, 302) + if (entry.get("minRevision"), entry.get("maxRevision")) != (low, high): + raise ValueError("unexpected qualification range") + path = root / name + if path.is_symlink() or not path.is_file() or path.resolve().parent != root: + raise ValueError("unsafe image path") + if not (24 <= path.stat().st_size <= CAP): + raise ValueError("invalid image size") + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != sha: + raise ValueError("image SHA256 mismatch") + if catalog["sourceCommit"][:16].encode("ascii") not in payload: + raise ValueError("image does not identify the exact source commit") + for offset, label in ((0x2000, "bootloader"), (0x10000, "application")): + minimum, maximum = header_revision_bounds(payload, offset) + if not (minimum <= low <= high <= maximum): + raise ValueError(f"{policy}: {label} incompatible with approved revision {low}..{high} (header {minimum}..{maximum})") + if policy == "pre-v3" and maximum >= 300: + raise ValueError(f"legacy {label} incorrectly enables v3 silicon") + if policy == "v3.x" and minimum < 300: + raise ValueError(f"v3 {label} incorrectly enables pre-v3 silicon") + if policy == "pre-v3" and (name != legacy["image"] or sha != legacy["sha256"]): + raise ValueError("legacy manifest and catalog disagree") + if policies != {"pre-v3", "v3.x"}: + raise ValueError("missing silicon variant") + print("Revision-aware firmware package: PASS (two disjoint images, headers, source and SHA)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("firmware_directory", type=Path) + arguments = parser.parse_args() + verify(arguments.firmware_directory) diff --git a/tests/test_esp32p4_revision_catalog.py b/tests/test_esp32p4_revision_catalog.py new file mode 100644 index 000000000..e9a784058 --- /dev/null +++ b/tests/test_esp32p4_revision_catalog.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Synthetic ESP32-P4 merged-image contract failures, no hardware or SDK required.""" +import hashlib +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from validate_esp32p4_revision_catalog import verify + +SOURCE = "0123456789abcdef0123456789abcdef01234567" + + +def image(minimum, maximum): + raw = bytearray(bytes([255]) * 0x11000) + for off in (0x2000, 0x10000): + raw[off] = 0xE9 + raw[off + 15:off + 17] = minimum.to_bytes(2, "little") + raw[off + 17:off + 19] = maximum.to_bytes(2, "little") + raw[0x8000:0x8010] = SOURCE[:16].encode("ascii") + return bytes(raw) + + +class CatalogTest(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.root = Path(self.folder.name) + self.a, self.b = image(100, 299), image(300, 399) + self.root.joinpath("old.bin").write_bytes(self.a) + self.root.joinpath("new.bin").write_bytes(self.b) + a, b = hashlib.sha256(self.a).hexdigest(), hashlib.sha256(self.b).hexdigest() + self.manifest = dict(chip="esp32p4", version="0.1.1", protocol=1, + sourceCommit=SOURCE, image="old.bin", sha256=a) + self.catalog = dict(schema="arstack.studio.firmware.catalog.v2", + chip="esp32p4", version="0.1.1", protocol=1, + sourceCommit=SOURCE, releaseChannel="engineering-preview", images=[ + dict(policy="pre-v3", image="old.bin", sha256=a, minRevision=100, maxRevision=199), + dict(policy="v3.x", image="new.bin", sha256=b, minRevision=302, maxRevision=302), + ]) + + def run_verifier(self): + self.root.joinpath("firmware-manifest.json").write_text(json.dumps(self.manifest)) + self.root.joinpath("firmware-catalog.json").write_text(json.dumps(self.catalog)) + verify(self.root) + + def reject(self): + with self.assertRaises(ValueError): + self.run_verifier() + + def test_valid(self): + self.run_verifier() + + def test_v3_header_not_legacy(self): + bad = image(100, 299) + self.root.joinpath("new.bin").write_bytes(bad) + self.catalog["images"][1]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + def test_legacy_header_never_v3(self): + bad = image(100, 399) + self.root.joinpath("old.bin").write_bytes(bad) + self.manifest["sha256"] = self.catalog["images"][0]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + def test_corrupt_image(self): + self.root.joinpath("new.bin").write_bytes(self.b + b"corrupt") + self.reject() + + def test_revision_policy_range(self): + self.catalog["images"][1]["maxRevision"] = 399 + self.reject() + + def test_duplicate_image(self): + self.catalog["images"][1]["image"] = "old.bin" + self.reject() + + def test_wrong_provenance(self): + self.catalog["sourceCommit"] = "f" * 40 + self.reject() + + def test_wrong_application_header(self): + bad = bytearray(self.b) + bad[0x10000 + 15:0x10000 + 17] = (100).to_bytes(2, "little") + self.root.joinpath("new.bin").write_bytes(bad) + self.catalog["images"][1]["sha256"] = hashlib.sha256(bad).hexdigest() + self.reject() + + +if __name__ == "__main__": + unittest.main(verbosity=2)