From 9038c97e916e8ceccbb6c9603505d35028892d3d Mon Sep 17 00:00:00 2001 From: dot Date: Thu, 1 Oct 2026 23:30:24 -0700 Subject: [PATCH 1/6] feat: prepare v2.4.2 AppImage updates and catalog validation --- .github/dependabot.yml | 41 +++- .github/workflows/ci.yml | 90 +++++++- .github/workflows/release.yml | 189 +++++++++++++--- .gitignore | 1 + CMakeLists.txt | 2 +- README.md | 6 +- RELEASE.md | 22 +- docs/BUILDING.md | 25 ++- docs/PACKAGING.md | 29 ++- docs/releases/v2.4.2.md | 20 ++ native/CMakeLists.txt | 4 +- .../net.cyberbrand.CyberSnapper.desktop | 1 + .../net.cyberbrand.CyberSnapper.metainfo.xml | 16 +- package-lock.json | 7 +- package.json | 5 +- scripts/check-appimage-update.py | 107 +++++++++ scripts/check-linux-appimage.py | 206 ++++++++++++++++++ scripts/check-release-assets.py | 57 +++++ scripts/check-release-version.mjs | 17 +- scripts/package-linux.sh | 19 +- scripts/test_check_appimage_update.py | 132 +++++++++++ scripts/test_check_linux_appimage.py | 108 +++++++++ scripts/test_check_release_assets.py | 58 +++++ scripts/test_check_release_version.py | 59 +++++ site/index.html | 2 +- 25 files changed, 1154 insertions(+), 69 deletions(-) create mode 100644 docs/releases/v2.4.2.md create mode 100644 scripts/check-appimage-update.py create mode 100644 scripts/check-linux-appimage.py create mode 100644 scripts/check-release-assets.py create mode 100644 scripts/test_check_appimage_update.py create mode 100644 scripts/test_check_linux_appimage.py create mode 100644 scripts/test_check_release_assets.py create mode 100644 scripts/test_check_release_version.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aaf9bea..f723360 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,7 @@ version: 2 +# Dependabot opens reviewable pull requests; it does not auto-merge them. +# Keep major upgrades (including major security fixes) outside these groups. updates: - package-ecosystem: "npm" directory: "/" @@ -7,11 +9,31 @@ updates: interval: "monthly" time: "05:00" timezone: "America/Los_Angeles" - open-pull-requests-limit: 3 + open-pull-requests-limit: 5 groups: - npm-dependencies: + npm-security: + applies-to: security-updates patterns: - "*" + update-types: + - "minor" + - "patch" + npm-runtime: + applies-to: version-updates + dependency-type: production + patterns: + - "*" + update-types: + - "minor" + - "patch" + npm-development: + applies-to: version-updates + dependency-type: development + patterns: + - "*" + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" @@ -19,8 +41,19 @@ updates: interval: "monthly" time: "05:30" timezone: "America/Los_Angeles" - open-pull-requests-limit: 2 + open-pull-requests-limit: 5 groups: - github-actions: + github-actions-security: + applies-to: security-updates + patterns: + - "*" + update-types: + - "minor" + - "patch" + github-actions-compatible: + applies-to: version-updates patterns: - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 253fbef..cd3bebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,23 @@ on: pull_request: workflow_dispatch: +# Pull requests, including Dependabot's, must work with a read-only token. +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: lint-commit-messages: runs-on: ubuntu-24.04 + timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Block AI-tool attribution trailers shell: bash run: | @@ -43,19 +53,78 @@ jobs: fi echo "No commit messages contain AI-tool attribution trailers." + packaging-checks: + name: Packaging and release metadata + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + package-manager-cache: false + - name: Install packaging validation tools + run: | + sudo apt-get update + sudo apt-get install -y desktop-file-utils appstream zsync + - name: Verify release version + run: node scripts/check-release-version.mjs + - name: Check packaging scripts + shell: bash + run: | + for script in scripts/*.sh; do + bash -n "$script" + done + python3 -m unittest discover -s scripts -p 'test_*.py' -v + - name: Validate Linux application metadata + run: | + desktop-file-validate native/packaging/net.cyberbrand.CyberSnapper.desktop + appstreamcli validate --no-net native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml + + minimum-node: + name: Worker on minimum Node.js + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CYBERSNAPPER_REQUIRED_BROWSERS: chromium + CYBERSNAPPER_SKIP_BROWSER_TESTS: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # Keep this exact minimum in sync with package.json and the build docs. + node-version: '20.9.0' + cache: npm + - name: Verify dependency and worker compatibility + run: | + npm ci --engine-strict + git diff --exit-code -- package.json package-lock.json + npx playwright install --with-deps chromium + npm run typecheck:worker + npm run build:worker + npm run test:worker + npm run test:site + test: strategy: fail-fast: false matrix: os: [ubuntu-24.04, windows-2022, macos-15] runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm - - uses: jurplel/install-qt-action@v4 + - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 with: version: '6.8.3' cache: true @@ -64,11 +133,16 @@ jobs: run: sudo apt-get update && sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 - name: Worker checks shell: bash + env: + CYBERSNAPPER_REQUIRED_BROWSERS: ${{ runner.os == 'Linux' && 'chromium,firefox,webkit' || 'chromium' }} + CYBERSNAPPER_SKIP_BROWSER_TESTS: '0' run: | - npm ci - npx playwright install chromium - if [ "${{ runner.os }}" = "Linux" ]; then - npx playwright install firefox webkit + npm ci --engine-strict + git diff --exit-code -- package.json package-lock.json + if [ "$RUNNER_OS" = "Linux" ]; then + npx playwright install --with-deps chromium firefox webkit + else + npx playwright install chromium fi npm run typecheck:worker npm run build:worker diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 491d240..8ccd336 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,9 +17,13 @@ jobs: browser-matrix: name: Browser matrix (Linux) runs-on: ubuntu-24.04 + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -32,7 +36,7 @@ jobs: env: CYBERSNAPPER_REQUIRED_BROWSERS: chromium,firefox,webkit run: | - npm ci + npm ci --engine-strict npx playwright install --with-deps chromium firefox webkit npm run typecheck:worker npm run build:worker @@ -66,12 +70,14 @@ jobs: appimagetool_sha256: f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 runtime_sha256: 00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 runs-on: ${{ matrix.runner }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 architecture: ${{ matrix.node_arch }} @@ -83,7 +89,7 @@ jobs: CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG" - - uses: jurplel/install-qt-action@v4 + - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 with: version: '6.8.3' host: ${{ matrix.qt_host }} @@ -93,12 +99,12 @@ jobs: - name: Install Linux build tools run: | sudo apt-get update - sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 file desktop-file-utils + sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 file desktop-file-utils appstream zsync - name: Worker checks shell: bash run: | - npm ci + npm ci --engine-strict npx playwright install chromium if [ "${{ runner.os }}" = "Linux" ]; then npx playwright install firefox webkit @@ -179,7 +185,7 @@ jobs: build/native/AppDir/usr/share/cybersnapper/browsers \ build/native/smoke-state - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CyberSnapper-linux-${{ matrix.arch }} path: release-assets/* @@ -202,12 +208,14 @@ jobs: qt_arch: win64_msvc2022_arm64 msvc_arch: amd64_arm64 runs-on: ${{ matrix.runner }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 architecture: ${{ matrix.node_arch }} @@ -223,7 +231,7 @@ jobs: with: arch: ${{ matrix.msvc_arch }} - - uses: jurplel/install-qt-action@v4 + - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 with: version: '6.8.3' arch: ${{ matrix.qt_arch }} @@ -232,7 +240,7 @@ jobs: - name: Worker checks shell: bash run: | - npm ci + npm ci --engine-strict npx playwright install chromium npm run typecheck:worker npm run build:worker @@ -303,7 +311,7 @@ jobs: stage\bin\browsers ` build\native\smoke-state - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CyberSnapper-windows-${{ matrix.arch }} path: release-assets/* @@ -324,12 +332,14 @@ jobs: node_arch: arm64 cmake_arch: arm64 runs-on: ${{ matrix.runner }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 architecture: ${{ matrix.node_arch }} @@ -341,7 +351,7 @@ jobs: CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG" - - uses: jurplel/install-qt-action@v4 + - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 with: version: '6.8.3' cache: true @@ -349,7 +359,7 @@ jobs: - name: Worker checks shell: bash run: | - npm ci + npm ci --engine-strict npx playwright install chromium if [ "${{ runner.os }}" = "Linux" ]; then npx playwright install firefox webkit @@ -443,37 +453,162 @@ jobs: stage/CyberSnapper.app/Contents/Resources/browsers \ build/native/smoke-state - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CyberSnapper-macos-${{ matrix.arch }} path: release-assets/* if-no-files-found: error + appimage-compatibility: + name: Verify AppImage runtime (${{ matrix.os }}) + needs: linux + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: CyberSnapper-linux-x64 + path: candidates + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - name: Install runtime tools without a Qt SDK + run: | + sudo apt-get update + sudo apt-get install -y xvfb xauth libfuse2 libxcb-cursor0 libxkbcommon-x11-0 libgl1 libegl1 libopengl0 zsync binutils libc-bin xdotool imagemagick + - name: Install Chromium host dependencies + run: | + npm ci --engine-strict + npx playwright install-deps chromium + - name: Validate exact AppImage and update sidecar + shell: bash + run: | + set -euo pipefail + version=$(node -p "require('./package.json').version") + image="candidates/CyberSnapper-linux-x64.AppImage" + chmod +x "$image" + channel=latest + case "$version" in *-*) channel="v$version" ;; esac + python3 scripts/check-appimage-update.py "$image" --expected-channel \ + "gh-releases-zsync|Alex9001|CyberSnapper|$channel|CyberSnapper-linux-x64.AppImage.zsync" + xvfb-run -a python3 scripts/check-linux-appimage.py "$image" \ + --evidence build/linux-evidence + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: appimage-runtime-evidence-${{ matrix.os }} + path: build/linux-evidence/ + + + appimage-catalog: + name: Verify upstream AppImage catalog compatibility + needs: linux + runs-on: ubuntu-22.04 + timeout-minutes: 20 + env: + DISPLAY: ':99' + IS_PULLREQUEST: 'true' + STRICT: 'true' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: CyberSnapper-linux-x64 + path: candidates + - name: Record candidate identity + run: sha256sum candidates/*.AppImage > candidate-sha256.txt + - name: Check out pinned upstream catalog worker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: AppImage/appimage.github.io + ref: f1249e9e1ff840dd53a6218aec3451ebd6bf476d + path: catalog + - name: Install upstream worker dependencies + run: | + sudo apt-get update + sudo apt-get install -y sudo imagemagick libasound2-dev pulseaudio-utils alsa-utils alsa-oss libjack0 desktop-file-utils xmlstarlet xsel xterm xvfb icewm x11-utils x11-apps netpbm xdotool libgl1-mesa-dri libgl1-mesa-dev mesa-utils libosmesa6 libsdl1.2-dev libsdl2-2.0-0 fonts-wqy-microhei libfile-mimeinfo-perl tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim tesseract-ocr-ara tesseract-ocr-rus tesseract-ocr-ell libfuse2 stalonetray libxcb-cursor0 libxkbcommon-x11-0 libopengl0 + cd catalog + bash code/fetch-deps.sh + cp -a deps/. . + sudo bash code/prep-dummy-soundcard.sh + sudo gem install dupervisor -v 1.0.5 + sudo npm install -g asar + - name: Run unmodified catalog worker against candidate bytes + shell: bash + run: | + set -euo pipefail + python3 -m http.server 8765 --bind 127.0.0.1 --directory candidates > server.log 2>&1 & + Xvfb :99 -screen 0 800x600x24 > xvfb.log 2>&1 & + mkdir -p "$HOME/.icewm" + echo 'ShowTaskBar = 0' > "$HOME/.icewm/preferences" + sleep 2 + cd catalog + echo "http://127.0.0.1:8765/CyberSnapper-linux-x64.AppImage" > data/CyberSnapper + timeout --kill-after=30 600 bash -e code/worker.sh "$PWD/data/CyberSnapper" 2>&1 | tee ../catalog.log + cmp BeingTested.AppImage "../candidates/CyberSnapper-linux-x64.AppImage" + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: appimage-catalog-evidence + path: | + catalog.log + candidate-sha256.txt + catalog/database/CyberSnapper/ + catalog/failure-screens/ + server.log + xvfb.log + publish: - name: Assemble and publish - needs: [linux, windows, macos] - if: github.event_name == 'release' || inputs.release_tag != '' + name: Assemble, verify, and optionally publish + needs: [linux, windows, macos, appimage-compatibility, appimage-catalog] runs-on: ubuntu-latest permissions: contents: write id-token: write attestations: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts + pattern: CyberSnapper-* merge-multiple: true - - name: Create SHA-256 checksums - shell: bash - run: (cd artifacts && sha256sum -- * > SHA256SUMS.txt) + - name: Verify complete package set and create SHA-256 checksums + run: python3 scripts/check-release-assets.py artifacts + + - name: Preserve verified release rehearsal + if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: CyberSnapper-release-rehearsal + path: artifacts/* + if-no-files-found: error - name: Attest package provenance - uses: actions/attest-build-provenance@v2 + if: github.event_name == 'release' || inputs.release_tag != '' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: artifacts/* - name: Publish packages to release + if: github.event_name == 'release' || inputs.release_tag != '' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 547eac6..8bc0e46 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .opencode/ .cybersnapper.pid .cybersnapper.url +__pycache__/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f870348..103852d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(CyberSnapper VERSION 2.4.1 LANGUAGES CXX) +project(CyberSnapper VERSION 2.4.2 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 3788f9d..6dbc18a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The finished files are normal PNG, WebP, AVIF, or PDF files in a portable folder ## Download -CyberSnapper 2.4.1 makes Capture more comfortable on shorter screens, removes scrollbar-gutter white strips, and omits identical dark output files when capturing both themes. Every package bundles the application, Qt runtime, Node runtime, capture worker, and Chromium. Firefox and WebKit can be installed on demand from Settings with visible progress and launch verification. See the [2.4.1 release notes](docs/releases/v2.4.1.md) and [latest published release](https://github.com/Alex9001/CyberSnapper/releases/latest) for downloads. +CyberSnapper 2.4.2 adds AppImage update metadata and zsync sidecars, catalog-compatible application metadata, and stronger dependency and release checks. Every package bundles the application, Qt runtime, Node runtime, capture worker, and Chromium. Firefox and WebKit can be installed on demand from Settings with visible progress and launch verification. See the [2.4.2 release notes](docs/releases/v2.4.2.md) and [latest published release](https://github.com/Alex9001/CyberSnapper/releases/latest) for downloads. | Platform | Recommended | Portable archive | | --- | --- | --- | @@ -38,6 +38,8 @@ CyberSnapper 2.4.1 makes Capture more comfortable on shorter screens, removes sc | Linux · x64 | [AppImage](https://github.com/Alex9001/CyberSnapper/releases/latest/download/CyberSnapper-linux-x64.AppImage) | [tar.gz](https://github.com/Alex9001/CyberSnapper/releases/latest/download/CyberSnapper-linux-x64.tar.gz) | | Linux · arm64 | [AppImage](https://github.com/Alex9001/CyberSnapper/releases/latest/download/CyberSnapper-linux-arm64.AppImage) | [tar.gz](https://github.com/Alex9001/CyberSnapper/releases/latest/download/CyberSnapper-linux-arm64.tar.gz) | +Linux AppImages from 2.4.2 onward support external AppImageUpdate-compatible tools. Close CyberSnapper before updating; stable builds follow the architecture-specific latest release. Older images need a one-time manual download of 2.4.2 or newer. See the [AppImage update and catalog notes](docs/PACKAGING.md#appimage-updates-and-catalog-submission). + Every release includes SHA-256 checksums and GitHub build-provenance attestations. macOS application bundles are ad-hoc signed but not notarized; Windows installers and Linux packages are currently unsigned. See the [packaging notes](docs/BUILDING.md#packages). ## Built for portfolio screenshots @@ -115,7 +117,7 @@ Read the deeper guides: ## Build from source -You need CMake 3.24+, a C++20 compiler, Qt 6.8+ (`Core`, `Gui`, `Widgets`, `Network`, `Sql`, `Svg`, and `Test`), Node.js 20+, and npm. +You need CMake 3.24+, a C++20 compiler, Qt 6.8+ (`Core`, `Gui`, `Widgets`, `Network`, `Sql`, `Svg`, and `Test`), Node.js 20.9.0+, and npm. ```bash npm install diff --git a/RELEASE.md b/RELEASE.md index d37f78d..93505e9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,7 +6,7 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup ## 1. Prepare the release -1. Update the version in the top-level `CMakeLists.txt` and `package.json`; both values must match the intended `vMAJOR.MINOR.PATCH` tag. +1. Update the version in `CMakeLists.txt`, `package.json`, `package-lock.json`, the desktop `X-AppImage-Version`, AppStream latest release, and website structured metadata. Run `node scripts/check-release-version.mjs vMAJOR.MINOR.PATCH`; every value must match. 2. Update the matching file in `docs/releases/`, the changelog below, and the README download/status copy. 3. Run every local check documented in `docs/BUILDING.md`. 4. Confirm a real capture succeeds through the native CLI, appears in History, and produces its expected original and portfolio-styled files in the GUI. @@ -15,8 +15,8 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup ## 2. Rehearse packaging 1. From GitHub Actions, run **Native Release** on the final candidate commit with `release_tag` left blank. -2. Wait for all six platform/architecture package jobs and their package smoke tests to pass. A blank `release_tag` uploads workflow artifacts but does not create or modify a GitHub release. -3. Download every rehearsal artifact. Confirm all twelve package names, install or extract each package on its target platform, run the packaged CLI with `--version`, and inspect that the application, agent, worker, Node runtime, Qt runtime, and bundled Chromium are present. +2. Wait for all six platform/architecture package jobs, packaged capture smoke tests, Ubuntu 22.04/24.04 runtime checks, the upstream AppImage catalog worker, and complete release assembly to pass. A blank `release_tag` uploads workflow artifacts but does not create or modify a GitHub release. +3. Download every rehearsal artifact. Confirm all twelve package names and both `.AppImage.zsync` sidecars, install or extract each package on its target platform, run the packaged CLI with `--version`, and inspect that the application, agent, worker, Node runtime, Qt runtime, and bundled Chromium are present. 4. Fix any problem in a new commit and repeat the full CI and rehearsal sequence. Do not tag a commit that has not passed rehearsal. ## 3. Freeze the final commit and tag @@ -37,16 +37,17 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup 1. Require the complete release workflow to pass, including all six native builds and the publish job. 2. Confirm the release contains all of the following, plus `SHA256SUMS.txt`, with provenance attestations visible in GitHub: - - `CyberSnapper-linux-x64.AppImage` and `CyberSnapper-linux-x64.tar.gz` - - `CyberSnapper-linux-arm64.AppImage` and `CyberSnapper-linux-arm64.tar.gz` + - `CyberSnapper-linux-x64.AppImage`, its `.zsync` sidecar, and `CyberSnapper-linux-x64.tar.gz` + - `CyberSnapper-linux-arm64.AppImage`, its `.zsync` sidecar, and `CyberSnapper-linux-arm64.tar.gz` - `CyberSnapper-windows-x64-setup.exe` and `CyberSnapper-windows-x64-portable.zip` - `CyberSnapper-windows-arm64-setup.exe` and `CyberSnapper-windows-arm64-portable.zip` - `CyberSnapper-macos-x64.dmg` and `CyberSnapper-macos-x64.zip` - `CyberSnapper-macos-arm64.dmg` and `CyberSnapper-macos-arm64.zip` 3. Download the published assets, verify every checksum, install or extract each package on its target platform, and repeat the packaged CLI `--version` smoke test. -4. Confirm GitHub marks the release as latest and that the README and Pages download links resolve to these assets. -5. Announce the release only after the release page, downloads, checksums, and website have all been verified. +4. Run `python3 scripts/check-appimage-update.py ` on each native architecture. Confirm the embedded channel, sidecar content, and reconstruction, not just asset presence. +5. Confirm GitHub marks the release as latest and that the README and Pages download links resolve to these assets. +6. Announce the release only after the release page, downloads, checksums, and website have all been verified. ## Recovery @@ -55,6 +56,13 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup - If the tagged source or a packaged application is defective, do not move the tag or silently replace the release. Document the issue and publish a corrected patch release from a new commit and tag. - Keep an incomplete release unannounced until recovery succeeds. If downloads may be unsafe or misleading, mark the release as a prerelease while preparing the corrective release. +## v2.4.2 + +- **External AppImage updates**: Stable architecture-specific channels and validated zsync sidecars for x64 and arm64; existing download filenames remain unchanged. +- **Catalog metadata**: Versioned desktop/AppStream metadata and a compatible installed appdata filename. +- **Release gates**: Ubuntu 22.04/24.04 x64 ELF/startup audit, exact-candidate upstream catalog worker, and complete fourteen-asset checksum validation. +- **Dependency updates**: Separate security/runtime/development groups, individual major updates, strict engine and minimum-Node coverage, and required browser/native CI checks. + ## v2.4.1 - **Compact Capture workspace**: Smaller buttons, tighter spacing, contextual comparison settings, and scrollable options with capture actions and progress always reachable. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index c2f1056..9db3d5c 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -2,16 +2,18 @@ ## Local developer build -Install Qt 6.8+, CMake 3.24+, a C++20 compiler, Node.js 20+, and npm. Then: +Install Qt 6.8+, CMake 3.24+, a C++20 compiler, Node.js 20.9.0+, and npm. Then: ```bash -npm install +npm ci --engine-strict npm run typecheck:worker npm run build:worker npm run test:worker cmake -S . -B build/native -DCMAKE_BUILD_TYPE=Debug cmake --build build/native --parallel ctest --test-dir build/native --output-on-failure +node scripts/check-release-version.mjs +python3 -m unittest discover -s scripts -p 'test_*.py' ``` `cpp-httplib` may be installed system-wide. If it is absent, CMake fetches the pinned v0.52.0 source. @@ -72,6 +74,23 @@ Platform deployment tools must run before creating these self-contained packages - Windows: run `windeployqt` for the GUI and agent. - macOS: run `macdeployqt`, then ad-hoc sign the complete app bundle. A future Developer ID release would also require Apple notarization. -The release workflow in `.github/workflows/release.yml` performs native builds for Linux x64 and arm64, Windows x64 and arm64, and macOS x64 and arm64. It publishes SHA-256 checksums and GitHub/Sigstore build-provenance attestations for all twelve packages. macOS application bundles use ad-hoc signing but are not notarized; Windows installers and Linux packages remain unsigned because the project does not require paid signing credentials. +The release workflow in `.github/workflows/release.yml` performs native builds for Linux x64 and arm64, Windows x64 and arm64, and macOS x64 and arm64. It publishes SHA-256 checksums and GitHub/Sigstore build-provenance attestations for all twelve packages and both AppImage zsync sidecars. macOS application bundles use ad-hoc signing but are not notarized; Windows installers and Linux packages remain unsigned because the project does not require paid signing credentials. GitHub documents the Sigstore-backed attestation model at . Apple ties Developer ID distribution/notarization to its paid developer program (). If CyberSnapper later adopts MSIX/Store distribution, Microsoft documents Store-managed signing as a no-certificate-cost path at . + +## Dependency update checks + +Dependabot checks the root npm lockfile and GitHub Actions monthly. Security +updates and production/development patch/minor updates have separate groups; +major updates remain individual for review. No automatic merge is configured. + +PR CI runs with read-only permissions and no repository secrets: strict npm +engine/lockfile installation, worker typechecking and tests, all three browser +engines on Linux, native Linux/Windows/macOS tests, packaging regression and +metadata checks, deterministic docs screenshots, and a worker check on the +minimum Node.js 20.9.0 runtime. Release packaging uses Node.js 22. + +Install `zsync`, `desktop-file-utils`, and `appstream` to run all local packaging +checks. Browser-dependent tests require Playwright browser downloads and host +libraries; set `CYBERSNAPPER_REQUIRED_BROWSERS=chromium,firefox,webkit` when +verifying a release so unavailable engines fail instead of skipping. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 202af20..107ff3e 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -27,12 +27,14 @@ Asset names follow `CyberSnapper--.`, for example ## Workflow shape -The workflow has three platform jobs plus a publish job: +The workflow has native package jobs and independent validation gates: - `linux` — matrix over x64 and arm64. - `windows` — matrix over x64 and arm64. - `macos` — matrix over x64 and arm64. -- `publish` — `needs` all three jobs; runs only when a release tag is involved. +- `appimage-compatibility` — audits the exact x64 AppImage and opens its GUI on Ubuntu 22.04 and 24.04 without a Qt SDK. +- `appimage-catalog` — runs the pinned upstream AppImageHub worker on the exact x64 candidate. +- `publish` — waits for every package and compatibility gate, validates the complete asset set, then preserves a rehearsal bundle or publishes an authorized release. Triggers: @@ -52,7 +54,7 @@ dispatched branch, while the built source comes from the selected ref. Every platform job starts with the same sequence: 1. **Version gate** — `scripts/check-release-version.mjs` asserts that - `package.json` and `CMakeLists.txt` declare the same version, and that any + `package.json`, `package-lock.json`, `CMakeLists.txt`, desktop metadata, AppStream, and the website declare the same version, and that any requested tag equals `v`. A tag/source mismatch fails the job before anything is built. 2. **Qt 6.8.3** — installed via `jurplel/install-qt-action`. @@ -90,7 +92,7 @@ package: 4. `linuxdeploy` with `linuxdeploy-plugin-qt` bundles non-Qt and Qt dependencies into `AppDir/`. A stale Qt 6 hook is removed after deployment. 5. `appimagetool` with a pinned type-2 runtime turns `AppDir/` into the - `.AppImage`. + `.AppImage` with architecture-specific update information and a matching `.zsync` file. Header, content hash, block table, and full offline zsync reconstruction must validate. 6. The portable archive is `tar -czf` of `AppDir/usr/`. The script also guards the result: required files must exist, every binary must @@ -171,7 +173,7 @@ launch Chromium never reaches the release. When all platform jobs succeed and a release tag is in play, the `publish` job: 1. Downloads all uploaded artifacts (merged into one directory). -2. Writes `SHA256SUMS.txt` from every asset. +2. Requires exactly twelve nonempty native packages plus two matching AppImage zsync sidecars, then writes `SHA256SUMS.txt` from all fourteen assets. 3. Records a GitHub/Sigstore build-provenance attestation over the artifacts. 4. Uploads everything to the release with `gh release upload "$tag" artifacts/* --clobber`. The job has no checkout, @@ -192,3 +194,20 @@ workflow with the same nonblank `release_tag`; the build jobs check out that tag and the publish job re-attaches with `--clobber`. Do not move a published `v*` tag — ship a new patch version if tagged source needs a code change. See the Recovery section of [RELEASE.md](../RELEASE.md) for the full policy. + +## AppImage updates and catalog submission + +Each AppImage embeds `gh-releases-zsync|Alex9001|CyberSnapper|latest|CyberSnapper-linux-.AppImage.zsync`, where `` is `x64` or `arm64`. The exact architecture-specific filename keeps external updaters from switching architectures. Prerelease packaging uses its exact version tag instead of the stable `latest` channel. The adjacent sidecar names the AppImage by basename, so both files can move together between staging and GitHub Releases. Checksums and provenance include the sidecars. + +Use an AppImageUpdate-compatible external tool with CyberSnapper closed. The application does not silently update itself. Images older than 2.4.2 lack update information and need a one-time manual upgrade. + +The catalog-facing desktop entry has `Name=CyberSnapper`, its installed icon, and `X-AppImage-Version`. The AppStream source is installed as `net.cyberbrand.CyberSnapper.appdata.xml`, a filename understood by the upstream worker; the schema remains compatible with Ubuntu 22.04. + +The release rehearsal downloads the just-built x64 artifact and serves those exact bytes to the unmodified, pinned catalog worker. It preserves startup/catalog screenshots, logs, and candidate hashes separately from release assets. This test does not submit or imply acceptance into the catalog. + +After the public release and downloaded update sidecars pass verification, submit one file to [AppImage/appimage.github.io](https://github.com/AppImage/appimage.github.io): + +- Path: `data/CyberSnapper` +- Sole line: `https://github.com/Alex9001/CyberSnapper` + +Confirm no entry or open duplicate submission exists first. The upstream PR must pass its own checks and maintainer review before calling CyberSnapper listed. diff --git a/docs/releases/v2.4.2.md b/docs/releases/v2.4.2.md new file mode 100644 index 0000000..f4e4e0b --- /dev/null +++ b/docs/releases/v2.4.2.md @@ -0,0 +1,20 @@ +# CyberSnapper 2.4.2 (2026-10-02) + +## AppImage updates and discovery + +- Linux x64 and arm64 AppImages embed separate stable GitHub release channels for AppImageUpdate-compatible external tools. +- Each release includes a matching `.AppImage.zsync` sidecar, covered by SHA-256 checksums and provenance alongside the existing twelve native packages. +- Desktop/AppStream metadata includes the release identity, screenshots, categories, and the catalog-compatible appdata filename. +- Existing package filenames and download links stay the same. Images older than 2.4.2 need a one-time manual upgrade before external update tools can use the new channel. Close CyberSnapper before updating. + +Catalog readiness is tested independently; a successful package build does not mean the upstream catalog submission has been accepted. + +## Stronger dependency and release checks + +- Dependabot groups security and production/development patch/minor updates separately; major updates stay individual for review. Automatic merging is not enabled. +- CI enforces strict dependency engines and a consistent lockfile, Node.js 20.9.0 compatibility, required browser coverage, and Linux/Windows/macOS native checks. +- Release checks validate update channels, sidecar metadata and block checksums, and actual offline zsync reconstruction. +- The exact x64 candidate is checked on Ubuntu 22.04 and 24.04 without a Qt SDK and run through the pinned upstream AppImageHub worker before release assembly. +- Assembly fails on missing, unexpected, empty, or mismatched assets and preserves a complete checksummed rehearsal bundle. + +All six platform/architecture package jobs retain end-to-end capture smoke tests. macOS remains ad-hoc signed and not notarized; Windows and Linux packages remain unsigned. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 2eb164b..100aad9 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -93,8 +93,10 @@ else() set(CYBERSNAPPER_NODE_DESTINATION lib/cybersnapper/runtime) install(FILES packaging/net.cyberbrand.CyberSnapper.desktop DESTINATION share/applications) + # The catalog worker recognizes the legacy appdata.xml filename. install(FILES packaging/net.cyberbrand.CyberSnapper.metainfo.xml - DESTINATION share/metainfo) + DESTINATION share/metainfo + RENAME net.cyberbrand.CyberSnapper.appdata.xml) install(FILES ${CMAKE_SOURCE_DIR}/assets/cybersnapper-512.png DESTINATION share/pixmaps RENAME net.cyberbrand.CyberSnapper.png) diff --git a/native/packaging/net.cyberbrand.CyberSnapper.desktop b/native/packaging/net.cyberbrand.CyberSnapper.desktop index 47de563..689abef 100644 --- a/native/packaging/net.cyberbrand.CyberSnapper.desktop +++ b/native/packaging/net.cyberbrand.CyberSnapper.desktop @@ -9,3 +9,4 @@ Terminal=false Categories=Graphics;Development; Keywords=Screenshot;Website;Portfolio;Mockup;Responsive;Browser; StartupNotify=true +X-AppImage-Version=2.4.2 diff --git a/native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml b/native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml index 7fc758e..974f7e4 100644 --- a/native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml +++ b/native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml @@ -16,12 +16,9 @@

net.cyberbrand.CyberSnapper.desktop - - CYBER BRAND - + CYBER BRAND https://alex9001.github.io/CyberSnapper/ https://github.com/Alex9001/CyberSnapper/issues - https://github.com/Alex9001/CyberSnapper https://raw.githubusercontent.com/Alex9001/CyberSnapper/master/docs/images/app-capture.png @@ -32,5 +29,16 @@ Portfolio presentation scene and frame editor + + + + + CyberSnapper + cybersnapper-cli + + + Graphics + Development + diff --git a/package-lock.json b/package-lock.json index a665cfc..89c16c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cybersnapper", - "version": "2.4.1", + "version": "2.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cybersnapper", - "version": "2.4.1", + "version": "2.4.2", "license": "ISC", "dependencies": { "@ghostery/adblocker": "^2.18.2", @@ -17,6 +17,9 @@ "@types/node": "^24.0.0", "esbuild": "^0.25.12", "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@emnapi/runtime": { diff --git a/package.json b/package.json index 72219de..5aa0536 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cybersnapper", - "version": "2.4.1", + "version": "2.4.2", "description": "Native cross-platform website screenshot tool and portfolio mockup generator, powered by Qt and Playwright", "scripts": { "build:worker": "node worker/build.mjs", @@ -46,5 +46,8 @@ "@types/node": "^24.0.0", "esbuild": "^0.25.12", "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20.9.0" } } diff --git a/scripts/check-appimage-update.py b/scripts/check-appimage-update.py new file mode 100644 index 0000000..a37762d --- /dev/null +++ b/scripts/check-appimage-update.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Verify a CyberSnapper AppImage's update channel and matching zsync sidecar. + +Stable images use the architecture-specific GitHub latest channel by default. +Pass --expected-channel explicitly when validating a different release channel. +Requires zsync, and executes only the AppImage runtime's metadata query. +""" +import argparse +import hashlib +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + + +class VerificationError(ValueError): + """The image or sidecar does not satisfy the release contract.""" + + +def require(condition, message): + if not condition: + raise VerificationError(message) + + +def digest(path, algorithm): + result = hashlib.new(algorithm) + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def stable_channel(appimage): + match = re.fullmatch(r'CyberSnapper-linux-(x64|arm64)\.AppImage', appimage.name) + require(match is not None, 'Expected CyberSnapper-linux-{x64,arm64}.AppImage; use --expected-channel for other channels') + return f'gh-releases-zsync|Alex9001|CyberSnapper|latest|CyberSnapper-linux-{match.group(1)}.AppImage.zsync' + + +def verify_sidecar(appimage): + sidecar = appimage.with_name(appimage.name + '.zsync') + content = sidecar.read_bytes() + require(b'\n\n' in content, 'Missing zsync header separator') + header, blocks = content.split(b'\n\n', 1) + fields = {} + for line in header.decode('utf-8').splitlines(): + require(': ' in line, 'Malformed zsync header line') + key, value = line.split(': ', 1) + require(key not in fields, f'Duplicate zsync field: {key}') + fields[key] = value + for key in ('zsync', 'Filename', 'URL', 'Length', 'SHA-1', 'Blocksize', 'Hash-Lengths'): + require(key in fields, f'Missing zsync field: {key}') + require(fields['Filename'] == appimage.name, 'zsync Filename does not match image') + require(fields['URL'] == appimage.name, 'zsync URL must be the image basename') + length = appimage.stat().st_size + require(int(fields['Length']) == length, 'zsync Length does not match image') + require(fields['SHA-1'] == digest(appimage, 'sha1'), 'zsync SHA-1 does not match image') + blocksize = int(fields['Blocksize']) + hashes = [int(value) for value in fields['Hash-Lengths'].split(',')] + require(blocksize > 0 and blocksize & (blocksize - 1) == 0, 'Invalid zsync Blocksize') + require(len(hashes) == 3, 'Invalid zsync Hash-Lengths') + sequence, weak, strong = hashes + require(sequence in (1, 2) and 1 <= weak <= 4 and 1 <= strong <= 16, 'Invalid zsync hash lengths') + expected_bytes = ((length + blocksize - 1) // blocksize) * (weak + strong) + require(length > 0 and len(blocks) == expected_bytes, 'Missing or truncated zsync block checksums') + return sidecar + + +def verify(appimage, expected_channel=None): + appimage = Path(appimage).resolve() + require(appimage.is_file(), f'Image does not exist: {appimage}') + expected_channel = expected_channel or stable_channel(appimage) + environment = {key: value for key, value in os.environ.items() if key != 'APPIMAGE_EXTRACT_AND_RUN'} + actual = subprocess.check_output( + [str(appimage), '--appimage-updateinformation'], text=True, + env=environment, timeout=30).strip() + require(actual == expected_channel, f'Unexpected update channel: {actual!r}; expected {expected_channel!r}') + sidecar = verify_sidecar(appimage) + with tempfile.TemporaryDirectory(prefix='cybersnapper-zsync-') as temporary: + rebuilt = Path(temporary) / appimage.name + # A complete matching seed avoids downloading anything. zsync still + # checks its block table and final checksum before producing the file. + subprocess.run(['zsync', '-i', str(appimage), '-o', str(rebuilt), str(sidecar)], + check=True, cwd=temporary, timeout=120) + require(rebuilt.is_file(), 'zsync did not produce a reconstructed image') + require(rebuilt.stat().st_size == appimage.stat().st_size and + digest(rebuilt, 'sha256') == digest(appimage, 'sha256'), + 'zsync reconstruction differs from image') + print(f'Verified update channel and zsync reconstruction: {appimage.name}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('appimage', type=Path) + parser.add_argument('--expected-channel') + args = parser.parse_args() + try: + verify(args.appimage, args.expected_channel) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'AppImage update verification failed: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check-linux-appimage.py b/scripts/check-linux-appimage.py new file mode 100644 index 0000000..795da6e --- /dev/null +++ b/scripts/check-linux-appimage.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Audit packaged ELF compatibility and capture CyberSnapper's visible X11 startup. + +Run under an isolated xvfb-run display, without a Qt SDK installed: + xvfb-run -a python3 scripts/check-linux-appimage.py IMAGE --evidence DIRECTORY +Needs binutils, libc-bin, xdotool, ImageMagick, Xvfb, and the runtime libraries. +No domain lookup is requested. Visibility is evidence of startup, not a complete +interactive GUI test or an event-loop responsiveness assertion. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import tempfile +import time + + +LIMITS = {'GLIBC': (2, 35), 'GLIBCXX': (3, 4, 30), 'CXXABI': (1, 3, 13)} + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def output(*args, **kwargs): + return subprocess.check_output(args, text=True, timeout=30, stderr=subprocess.STDOUT, **kwargs) + + +def sha256(path): + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def inspect_elf(root, report): + inspected = [] + environment = {**os.environ, 'LD_LIBRARY_PATH': str(root / 'usr/lib')} + for file in sorted(root.rglob('*')): + if not file.is_file(): + continue + with file.open('rb') as stream: + if stream.read(4) != b'\x7fELF': + continue + require(file.resolve().is_relative_to(root.resolve()), f'ELF symlink escapes AppDir: {file}') + dynamic = output('readelf', '-d', str(file)) + record = {'path': str(file.relative_to(root)), 'dynamic': '(NEEDED)' in dynamic} + # Static Go executables have no dynamic symbol table; their absence is + # valid, while every dynamically linked executable/library is audited. + if '(NEEDED)' in dynamic or 'Dynamic section at offset' in dynamic: + symbols = output('objdump', '-T', str(file)) + for line in symbols.splitlines(): + if '*UND*' not in line: + continue + for family, version in re.findall(r'\b(GLIBCXX|GLIBC|CXXABI)_([0-9.]+)', line): + require(tuple(map(int, version.split('.'))) <= LIMITS[family], + f'Ubuntu 22.04 symbol limit exceeded in {file}: {line}') + for line in dynamic.splitlines(): + if '(RPATH)' in line or '(RUNPATH)' in line: + match = re.search(r'\[(.*)\]', line) + require(match is not None, f'Unrecognized runtime path: {file}: {line}') + for entry in match.group(1).split(':'): + require(entry == '$ORIGIN' or entry.startswith('$ORIGIN/') or + entry == '${ORIGIN}' or entry.startswith('${ORIGIN}/'), + f'Non-relocatable or empty runtime path in {file}: {line}') + relative = entry.replace('${ORIGIN}', '.', 1).replace('$ORIGIN', '.', 1) + require((file.parent / relative).resolve().is_relative_to(root.resolve()), + f'Runtime path escapes AppDir in {file}: {line}') + if record['dynamic']: + libraries = output('ldd', str(file), env=environment) + require('not found' not in libraries, f'Unresolved dependency in {file}:\n{libraries}') + record['dependencies'] = libraries + for path in re.findall(r'=> (/\S+)', libraries): + resolved = Path(path).resolve() + require(resolved.is_relative_to(root.resolve()) or + str(resolved).startswith(('/usr/lib/', '/lib/')), + f'Dependency outside package/system library roots: {file}: {resolved}') + inspected.append(record) + # Retain partial evidence even if a later ELF fails. + report.write_text(json.dumps(inspected, indent=2) + '\n') + require(len(inspected) > 10, f'Unexpectedly few packaged ELF files: {len(inspected)}') + return len(inspected) + + +def visible_window(process, environment): + found = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^CyberSnapper$'], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, timeout=5) + if found.returncode != 0: + return None + for window in found.stdout.split(): + try: + pid = int(output('xdotool', 'getwindowpid', window, env=environment).strip()) + if os.getpgid(pid) != process.pid: + continue + geometry = output('xdotool', 'getwindowgeometry', '--shell', window, env=environment) + fields = dict(line.split('=', 1) for line in geometry.splitlines() if '=' in line) + if int(fields.get('WIDTH', '0')) >= 100 and int(fields.get('HEIGHT', '0')) >= 100: + return {'window_id': window, 'pid': pid, 'geometry': fields} + except (ValueError, ProcessLookupError, subprocess.CalledProcessError): + continue + return None + + +def launch(appimage, state, evidence, appdir): + require(bool(os.environ.get('DISPLAY')), 'DISPLAY is required; run with xvfb-run -a') + environment = {**os.environ, 'QT_QPA_PLATFORM': 'xcb', 'APPIMAGE_EXTRACT_AND_RUN': '1', + 'HOME': str(state), 'XDG_CONFIG_HOME': str(state / 'config'), + 'XDG_DATA_HOME': str(state / 'data'), 'XDG_CACHE_HOME': str(state / 'cache'), + 'XDG_RUNTIME_DIR': str(state / 'runtime'), + 'CYBERSNAPPER_DEFAULT_PROJECT': str(state / 'project'), + 'CYBERSNAPPER_AGENT_SERVER': str(state / 'agent.sock')} + (state / 'runtime').mkdir(mode=0o700) + for key in ('QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH', 'QML2_IMPORT_PATH', 'LD_LIBRARY_PATH', 'LD_PRELOAD'): + environment.pop(key, None) + with (evidence / 'startup.log').open('w') as log: + process = subprocess.Popen([str(appimage)], env=environment, stdout=log, stderr=log, + start_new_session=True) + try: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + require(process.poll() is None, f'AppImage exited with {process.returncode}; see startup.log') + window = visible_window(process, environment) + if window: + # Avoid accepting a transient splash or a window that + # immediately crashes: require continued visibility. + time.sleep(2) + require(process.poll() is None, 'AppImage exited after showing its window') + window = visible_window(process, environment) + require(window is not None, 'CyberSnapper main window disappeared') + subprocess.run(['import', '-window', window['window_id'], + str(evidence / 'main-window.png')], + check=True, env=environment, timeout=20) + subprocess.run(['import', '-window', 'root', str(evidence / 'desktop.png')], + check=True, env=environment, timeout=20) + (evidence / 'window.json').write_text(json.dumps(window, indent=2) + '\n') + return + time.sleep(0.1) + raise ValueError('No visible CyberSnapper main window appeared within 60 seconds') + finally: + # The agent detaches from the GUI process group. Stop only this + # isolated test agent before removing its private state directory. + try: + subprocess.run([str(appdir / 'usr/bin/cybersnapper-cli'), '--force', 'agent', 'stop'], + env=environment, stdout=log, stderr=log, timeout=30, check=False) + except (OSError, subprocess.SubprocessError) as error: + log.write(f'Agent cleanup warning: {error}\n') + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def verify(appimage, evidence): + appimage = appimage.resolve() + evidence = evidence.resolve() + require(appimage.is_file(), f'AppImage does not exist: {appimage}') + evidence.mkdir(parents=True, exist_ok=True) + report = {'appimage': appimage.name, 'sha256': sha256(appimage), 'passed': False} + try: + with tempfile.TemporaryDirectory(prefix='cybersnapper-runtime-') as temporary: + stage = Path(temporary) + with (evidence / 'extraction.log').open('w') as log: + subprocess.run([str(appimage), '--appimage-extract'], cwd=stage, + check=True, stdout=log, stderr=log, timeout=120, + env={k: v for k, v in os.environ.items() if k != 'APPIMAGE_EXTRACT_AND_RUN'}) + report['elf_count'] = inspect_elf(stage / 'squashfs-root', evidence / 'elf-audit.json') + state = stage / 'isolated-home' + state.mkdir() + launch(appimage, state, evidence, stage / 'squashfs-root') + report['passed'] = True + except Exception as error: + report['error'] = str(error) + raise + finally: + (evidence / 'result.json').write_text(json.dumps(report, indent=2) + '\n') + print(f'Verified {report["elf_count"]} packaged ELF files and visible CyberSnapper startup') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('appimage', type=Path) + parser.add_argument('--evidence', type=Path, required=True) + args = parser.parse_args() + try: + verify(args.appimage, args.evidence) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Linux AppImage verification failed: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check-release-assets.py b/scripts/check-release-assets.py new file mode 100644 index 0000000..a9a8e12 --- /dev/null +++ b/scripts/check-release-assets.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Validate the complete release asset set and write deterministic SHA-256 sums.""" +import argparse +import importlib.util +from pathlib import Path +import sys + +spec = importlib.util.spec_from_file_location('appimage_update', Path(__file__).with_name('check-appimage-update.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def expected_assets(): + names = set() + for arch in ('x64', 'arm64'): + for platform, extensions in ( + ('linux', ('.AppImage', '.AppImage.zsync', '.tar.gz')), + ('windows', ('-setup.exe', '-portable.zip')), + ('macos', ('.dmg', '.zip')), + ): + names.update(f'CyberSnapper-{platform}-{arch}{extension}' for extension in extensions) + return names + + +def verify(directory): + directory = Path(directory) + expected = expected_assets() + actual = {path.name for path in directory.iterdir() if path.name != 'SHA256SUMS.txt'} + checker.require(actual == expected, + f'Release assets differ: missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}') + for name in sorted(expected): + path = directory / name + checker.require(path.is_file() and not path.is_symlink() and path.stat().st_size > 0, + f'Missing, empty, or symlinked release asset: {name}') + for arch in ('x64', 'arm64'): + # Architecture-neutral validation: each native package job already + # executes its runtime query and a full offline zsync reconstruction. + checker.verify_sidecar(directory / f'CyberSnapper-linux-{arch}.AppImage') + sums = ''.join(f'{checker.digest(directory / name, "sha256")} {name}\n' for name in sorted(expected)) + (directory / 'SHA256SUMS.txt').write_text(sums) + print(f'Verified {len(expected)} release assets and wrote SHA256SUMS.txt') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + args = parser.parse_args() + try: + verify(args.directory) + except (OSError, ValueError) as error: + print(f'Release asset verification failed: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs index c1dca2a..5397d1a 100644 --- a/scripts/check-release-version.mjs +++ b/scripts/check-release-version.mjs @@ -13,6 +13,21 @@ function fail(message) { const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); const cmakeLists = await readFile(new URL('../CMakeLists.txt', import.meta.url), 'utf8'); const packageVersion = packageJson.version; +const lock = JSON.parse(await readFile(new URL('../package-lock.json', import.meta.url), 'utf8')); +const desktop = await readFile(new URL('../native/packaging/net.cyberbrand.CyberSnapper.desktop', import.meta.url), 'utf8'); +const metadata = await readFile(new URL('../native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml', import.meta.url), 'utf8'); +const website = await readFile(new URL('../site/index.html', import.meta.url), 'utf8'); +const versions = { + 'package-lock.json': lock.version, + 'package-lock.json root package': lock.packages?.['']?.version, + 'desktop AppImage version': desktop.match(/^X-AppImage-Version=(.+)$/m)?.[1], + 'AppStream latest release': metadata.match(/ ...).'); } else if (packageVersion !== cmakeVersion) { fail(`package.json is ${packageVersion}, but CMakeLists.txt is ${cmakeVersion}.`); -} else { +} else if (!process.exitCode) { const expectedTag = `v${packageVersion}`; if (requestedTag && requestedTag !== expectedTag) { fail(`requested tag is ${requestedTag}, but the source version requires ${expectedTag}.`); diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 7a2d7aa..072a204 100755 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -10,6 +10,9 @@ build_dir=$1 output_dir=$2 release_arch=$3 version=${4#v} +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo "invalid release version: $version" >&2; exit 2; +} : "${LINUXDEPLOY:?Set LINUXDEPLOY to the pinned linuxdeploy AppImage}" : "${APPIMAGETOOL:?Set APPIMAGETOOL to the pinned appimagetool AppImage}" @@ -21,7 +24,7 @@ case "$release_arch" in *) echo "unsupported Linux release architecture: $release_arch" >&2; exit 2 ;; esac -for executable in cmake file desktop-file-validate timeout; do +for executable in cmake file desktop-file-validate appstreamcli python3 zsync zsyncmake timeout; do command -v "$executable" >/dev/null 2>&1 || { echo "$executable is required" >&2 exit 1 @@ -48,6 +51,7 @@ DESTDIR="$app_dir" cmake --install "$build_dir" --prefix /usr --config Release desktop_file="$app_dir/usr/share/applications/net.cyberbrand.CyberSnapper.desktop" icon_file="$app_dir/usr/share/pixmaps/net.cyberbrand.CyberSnapper.png" desktop-file-validate "$desktop_file" +appstreamcli validate --no-net "$app_dir/usr/share/metainfo/net.cyberbrand.CyberSnapper.appdata.xml" [[ -s "$icon_file" ]] || { echo "Linux application icon is missing" >&2; exit 1; } qt_plugins=$("$real_qmake" -query QT_INSTALL_PLUGINS) @@ -149,9 +153,19 @@ for binary in "$app_dir/usr/bin/CyberSnapper" "$app_dir/usr/bin/cybersnapper-age done appimage="$output_dir/CyberSnapper-linux-$release_arch.AppImage" +channel=latest +[[ "$version" != *-* ]] || channel="v$version" +update_information="gh-releases-zsync|Alex9001|CyberSnapper|$channel|CyberSnapper-linux-$release_arch.AppImage.zsync" ARCH="$appimage_arch" VERSION="$version" "$APPIMAGETOOL" \ - --runtime-file "$APPIMAGE_RUNTIME" "$app_dir" "$appimage" + --runtime-file "$APPIMAGE_RUNTIME" \ + --updateinformation "$update_information" "$app_dir" "$appimage" +# appimagetool may write the sidecar into its working directory. +sidecar="$(basename "$appimage").zsync" +if [[ -f "$sidecar" && ! "$sidecar" -ef "$appimage.zsync" ]]; then + mv "$sidecar" "$appimage.zsync" +fi chmod +x "$appimage" +python3 "$script_dir/check-appimage-update.py" "$appimage" --expected-channel "$update_information" env -u APPIMAGE_EXTRACT_AND_RUN "$appimage" --appimage-offset | grep -Eq '^[0-9]+$' file "$appimage" | grep -Eq "$architecture_pattern" @@ -183,4 +197,5 @@ archive="$output_dir/CyberSnapper-linux-$release_arch.tar.gz" tar -C "$app_dir/usr" -czf "$archive" . echo "Created $appimage" +echo "Created $appimage.zsync" echo "Created $archive" diff --git a/scripts/test_check_appimage_update.py b/scripts/test_check_appimage_update.py new file mode 100644 index 0000000..10e206d --- /dev/null +++ b/scripts/test_check_appimage_update.py @@ -0,0 +1,132 @@ +"""Offline regression tests for the release AppImage/zsync validation gate.""" +import hashlib +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('appimage_update', Path(__file__).with_name('check-appimage-update.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class UpdateVerificationTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.image = Path(self.temp.name) / 'CyberSnapper-linux-x64.AppImage' + self.image.write_bytes(b'CyberSnapper fixture payload') + self.fields = { + 'zsync': '0.6.2', 'Filename': self.image.name, 'MTime': 'Thu, 01 Oct 2026 00:00:00 +0000', + 'Blocksize': '2048', 'Length': str(self.image.stat().st_size), + 'Hash-Lengths': '1,2,4', 'URL': self.image.name, + 'SHA-1': hashlib.sha1(self.image.read_bytes()).hexdigest(), + } + self.write_sidecar() + + def write_sidecar(self, blocks=b'123456', extra=''): + self.image.with_name(self.image.name + '.zsync').write_bytes( + ('\n'.join(f'{key}: {value}' for key, value in self.fields.items()) + extra + '\n\n').encode() + blocks) + + def test_stable_architecture_channels(self): + for arch in ('x64', 'arm64'): + channel = checker.stable_channel(Path(f'CyberSnapper-linux-{arch}.AppImage')) + self.assertEqual(channel, f'gh-releases-zsync|Alex9001|CyberSnapper|latest|CyberSnapper-linux-{arch}.AppImage.zsync') + + def test_nonstable_and_legacy_names_rejected(self): + for name in ('CyberSnapper-linux-x86_64.AppImage', 'CyberSnapper-linux-aarch64.AppImage', 'CyberSnapper-linux-x64.AppImage.zsync'): + with self.subTest(name=name), self.assertRaises(checker.VerificationError): + checker.stable_channel(Path(name)) + + def test_valid_header(self): + self.assertEqual(checker.verify_sidecar(self.image).name, self.image.name + '.zsync') + + def test_identity_mismatches(self): + for key, value in [('Filename', 'wrong.AppImage'), ('URL', '../wrong.AppImage'), ('Length', '999'), ('SHA-1', '0' * 40)]: + with self.subTest(key=key): + original = self.fields[key] + self.fields[key] = value + self.write_sidecar() + with self.assertRaises(checker.VerificationError): + checker.verify_sidecar(self.image) + self.fields[key] = original + + def test_bad_block_table(self): + for blocks in (b'', b'12345', b'1234567'): + self.write_sidecar(blocks) + with self.assertRaises(checker.VerificationError): + checker.verify_sidecar(self.image) + + def test_duplicate_header(self): + self.write_sidecar(extra='\nURL: ' + self.image.name) + with self.assertRaises(checker.VerificationError): + checker.verify_sidecar(self.image) + + def test_missing_and_invalid_fields(self): + for field, value in [('Hash-Lengths', '1,9,4'), ('Hash-Lengths', '1,2'), ('Blocksize', '0'), ('Blocksize', '3'), ('Length', 'oops')]: + original = self.fields[field] + self.fields[field] = value + self.write_sidecar() + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + checker.verify_sidecar(self.image) + self.fields[field] = original + del self.fields['SHA-1'] + self.write_sidecar() + with self.assertRaises(checker.VerificationError): + checker.verify_sidecar(self.image) + + def reconstruct(self, args, **kwargs): + shutil.copyfile(self.image, args[args.index('-o') + 1]) + + def test_full_gate_and_runtime_environment(self): + channel = checker.stable_channel(self.image) + with patch.dict(os.environ, {'APPIMAGE_EXTRACT_AND_RUN': '1'}), \ + patch.object(checker.subprocess, 'check_output', return_value=channel + '\n') as query, \ + patch.object(checker.subprocess, 'run', side_effect=self.reconstruct) as reconstruction: + checker.verify(self.image) + self.assertNotIn('APPIMAGE_EXTRACT_AND_RUN', query.call_args.kwargs['env']) + self.assertEqual(query.call_args.args[0][-1], '--appimage-updateinformation') + self.assertTrue(reconstruction.call_args.kwargs['check']) + + def test_explicit_channel(self): + with patch.object(checker.subprocess, 'check_output', return_value='custom-channel'), \ + patch.object(checker.subprocess, 'run', side_effect=self.reconstruct): + checker.verify(self.image, 'custom-channel') + + def test_wrong_channel_stops_before_reconstruction(self): + with patch.object(checker.subprocess, 'check_output', return_value='wrong'), \ + patch.object(checker.subprocess, 'run') as reconstruction: + with self.assertRaises(checker.VerificationError): + checker.verify(self.image) + reconstruction.assert_not_called() + + @unittest.skipUnless(shutil.which('zsync') and shutil.which('zsyncmake'), + 'zsync and zsyncmake are required for real reconstruction') + def test_real_zsync_reconstruction(self): + channel = checker.stable_channel(self.image) + self.image.write_text('#!/bin/sh\n' + "printf '%s\\n' '" + channel + "'\n" + + '# Payload padding\n' * 300) + self.image.chmod(0o755) + subprocess.run(['zsyncmake', '-u', self.image.name, '-o', self.image.name + '.zsync', + self.image.name], cwd=self.image.parent, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + checker.verify(self.image) + + def test_reconstruction_failure_and_mismatch(self): + for failure in (True, False): + def reconstruct(args, **kwargs): + if failure: + raise subprocess.CalledProcessError(1, args) + Path(args[args.index('-o') + 1]).write_bytes(b'wrong bytes') + with patch.object(checker.subprocess, 'check_output', return_value=checker.stable_channel(self.image)), \ + patch.object(checker.subprocess, 'run', side_effect=reconstruct): + with self.assertRaises((checker.VerificationError, subprocess.CalledProcessError)): + checker.verify(self.image) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_check_linux_appimage.py b/scripts/test_check_linux_appimage.py new file mode 100644 index 0000000..21d577b --- /dev/null +++ b/scripts/test_check_linux_appimage.py @@ -0,0 +1,108 @@ +"""Offline regression tests for the packaged ELF compatibility audit.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('linux_appimage', Path(__file__).with_name('check-linux-appimage.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class ElfAuditTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / 'AppDir' + self.root.mkdir() + self.executables = self.root / 'usr/bin' + self.executables.mkdir(parents=True) + self.report = Path(self.temp.name) / 'elf-audit.json' + for number in range(11): + (self.executables / str(number)).write_bytes(b'\x7fELFfixture') + self.dynamic = ('Dynamic section at offset 0x10\n' + ' (NEEDED) Shared library: [libc.so.6]\n' + ' (RUNPATH) Library runpath: [$ORIGIN/../lib]') + self.symbols = ('0000 *UND* (GLIBC_2.35) libc_entry\n' + '0000 *UND* (GLIBCXX_3.4.30) std_entry\n' + '0000 *UND* (CXXABI_1.3.13) abi_entry') + self.libraries = 'libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0)' + + def command_output(self, *args, **kwargs): + return {'readelf': self.dynamic, 'objdump': self.symbols, 'ldd': self.libraries}[args[0]] + + def audit(self): + with patch.object(checker, 'output', side_effect=self.command_output): + return checker.inspect_elf(self.root, self.report) + + def test_baseline_symbols_and_relative_paths(self): + self.assertEqual(self.audit(), 11) + self.assertIn('libc.so.6', self.report.read_text()) + + def test_too_new_symbol_families(self): + for family, version in [('GLIBC', '2.36'), ('GLIBCXX', '3.4.31'), ('CXXABI', '1.3.14')]: + self.symbols = f'0000 *UND* ({family}_{version}) entry' + with self.subTest(family=family), self.assertRaisesRegex(ValueError, 'symbol limit'): + self.audit() + + def test_defined_symbols_do_not_raise_required_baseline(self): + self.symbols = '0000 .text (GLIBC_9.99) locally_defined' + self.assertEqual(self.audit(), 11) + + def test_static_executables_need_no_dynamic_table(self): + def static_output(*args, **kwargs): + self.assertEqual(args[0], 'readelf') + return 'There is no dynamic section in this file.' + with patch.object(checker, 'output', side_effect=static_output): + self.assertEqual(checker.inspect_elf(self.root, self.report), 11) + + def test_missing_library_rejected(self): + self.libraries = 'libQt6Core.so.6 => not found' + with self.assertRaisesRegex(ValueError, 'Unresolved dependency'): + self.audit() + + def test_nonrelocatable_and_empty_rpaths_rejected(self): + for runtime_path in ('/home/builder/qt/lib', '/usr/local/lib', '', '$ORIGIN:', 'relative/lib'): + self.dynamic = f'Dynamic section at offset 0x10\n (RUNPATH) Library runpath: [{runtime_path}]' + with self.subTest(path=runtime_path), self.assertRaisesRegex(ValueError, 'runtime path'): + self.audit() + + def test_origin_traversal_outside_appdir_rejected(self): + for prefix in ('$ORIGIN', '${ORIGIN}'): + self.dynamic = f'Dynamic section at offset 0x10\n (RUNPATH) Library runpath: [{prefix}/../../../opt/Qt/lib]' + with self.subTest(prefix=prefix), self.assertRaisesRegex(ValueError, 'escapes AppDir'): + self.audit() + + def test_braced_origin_rpath_supported(self): + self.dynamic = 'Dynamic section at offset 0x10\n (RPATH) Library rpath: [${ORIGIN}/../lib]' + self.assertEqual(self.audit(), 11) + + def test_dependency_outside_package_system_roots_rejected(self): + self.libraries = 'libQt6Core.so.6 => /opt/Qt/lib/libQt6Core.so.6 (0x0)' + with self.assertRaisesRegex(ValueError, 'outside package/system'): + self.audit() + + def test_bundled_dependency_supported(self): + library = self.root / 'usr/lib/libQt6Core.so.6' + library.parent.mkdir(parents=True) + library.write_bytes(b'fixture library') + self.libraries = f'libQt6Core.so.6 => {library} (0x0)' + self.assertEqual(self.audit(), 11) + + def test_elf_symlink_escape_rejected(self): + external = Path(self.temp.name) / 'external' + external.write_bytes(b'\x7fELFexternal') + (self.root / 'escape').symlink_to(external) + with self.assertRaisesRegex(ValueError, 'symlink escapes'): + self.audit() + + def test_implausibly_small_bundle_rejected(self): + for number in range(1, 11): + (self.executables / str(number)).unlink() + with self.assertRaisesRegex(ValueError, 'Unexpectedly few'): + self.audit() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_check_release_assets.py b/scripts/test_check_release_assets.py new file mode 100644 index 0000000..9a8e753 --- /dev/null +++ b/scripts/test_check_release_assets.py @@ -0,0 +1,58 @@ +"""Offline tests for release assembly: no missing, extra, or unmatched artifacts.""" +import hashlib +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('release_assets', Path(__file__).with_name('check-release-assets.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class ReleaseAssetsTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + for name in checker.expected_assets(): + (self.root / name).write_bytes(b'release fixture') + for arch in ('x64', 'arm64'): + image = self.root / f'CyberSnapper-linux-{arch}.AppImage' + fields = {'zsync': '0.6.2', 'Filename': image.name, 'URL': image.name, + 'Length': str(image.stat().st_size), 'SHA-1': hashlib.sha1(image.read_bytes()).hexdigest(), + 'Blocksize': '2048', 'Hash-Lengths': '1,2,4'} + image.with_name(image.name + '.zsync').write_bytes( + ('\n'.join(f'{key}: {value}' for key, value in fields.items()) + '\n\n').encode() + b'123456') + + def test_complete_assets_and_deterministic_sums(self): + checker.verify(self.root) + sums = (self.root / 'SHA256SUMS.txt').read_text() + self.assertEqual(len(sums.splitlines()), 14) + self.assertIn('CyberSnapper-linux-arm64.AppImage.zsync', sums) + checker.verify(self.root) + self.assertEqual((self.root / 'SHA256SUMS.txt').read_text(), sums) + + def test_missing_sidecar_rejected(self): + (self.root / 'CyberSnapper-linux-x64.AppImage.zsync').unlink() + with self.assertRaisesRegex(ValueError, 'missing='): + checker.verify(self.root) + + def test_unexpected_evidence_rejected(self): + (self.root / 'startup.log').write_text('evidence belongs in a separate artifact') + with self.assertRaisesRegex(ValueError, 'unexpected='): + checker.verify(self.root) + + def test_empty_asset_rejected(self): + (self.root / 'CyberSnapper-macos-x64.dmg').write_bytes(b'') + with self.assertRaisesRegex(ValueError, 'empty'): + checker.verify(self.root) + + def test_mismatched_image_rejected(self): + (self.root / 'CyberSnapper-linux-x64.AppImage').write_bytes(b'changed fixture') + with self.assertRaisesRegex(ValueError, 'does not match image'): + checker.verify(self.root) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_check_release_version.py b/scripts/test_check_release_version.py new file mode 100644 index 0000000..dabd6a1 --- /dev/null +++ b/scripts/test_check_release_version.py @@ -0,0 +1,59 @@ +"""Exercise the version gate using isolated copies of the release metadata.""" +from pathlib import Path +import json +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +VERSION = json.loads((ROOT / 'package.json').read_text())['version'] + + +@unittest.skipUnless(shutil.which('node'), 'Node.js is required for the release version gate') +class VersionGateTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + for name in ('scripts/check-release-version.mjs', 'package.json', 'package-lock.json', + 'CMakeLists.txt', 'site/index.html', + 'native/packaging/net.cyberbrand.CyberSnapper.desktop', + 'native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml'): + destination = self.root / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / name, destination) + + def gate(self, tag='v' + VERSION): + return subprocess.run(['node', str(self.root / 'scripts/check-release-version.mjs'), tag], + text=True, capture_output=True, timeout=10) + + def test_current_version_matches_every_source(self): + result = self.gate() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Release version verified', result.stdout) + + def test_old_tag_is_rejected(self): + self.assertNotEqual(self.gate('v0.0.0').returncode, 0) + + def test_rehearsal_needs_no_tag(self): + result = self.gate('') + self.assertEqual(result.returncode, 0) + self.assertIn('rehearsal', result.stdout) + + def test_metadata_mismatch_fails_without_success_message(self): + for name in ('package-lock.json', 'CMakeLists.txt', 'site/index.html', + 'native/packaging/net.cyberbrand.CyberSnapper.desktop', + 'native/packaging/net.cyberbrand.CyberSnapper.metainfo.xml'): + with self.subTest(source=name): + path = self.root / name + original = path.read_text() + path.write_text(original.replace(VERSION, '0.0.0')) + result = self.gate() + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Release version verified', result.stdout) + path.write_text(original) + + +if __name__ == '__main__': + unittest.main() diff --git a/site/index.html b/site/index.html index a8c6571..b34a3b5 100644 --- a/site/index.html +++ b/site/index.html @@ -31,7 +31,7 @@ "name": "CyberSnapper", "applicationCategory": "DesignApplication", "operatingSystem": "macOS, Windows, Linux", - "softwareVersion": "2.4.1", + "softwareVersion": "2.4.2", "description": "Open-source website screenshot tool for responsive portfolio images and browser, tablet, and phone mockups.", "url": "https://alex9001.github.io/CyberSnapper/", "downloadUrl": "https://github.com/Alex9001/CyberSnapper/releases/latest", From bbb3f8c4b9aabc2379fd0603895c5ca6c57642f8 Mon Sep 17 00:00:00 2001 From: dot Date: Thu, 1 Oct 2026 23:43:38 -0700 Subject: [PATCH 2/6] fix: load Sharp compatibly on minimum Node.js --- scripts/build-site.mjs | 6 +++++- scripts/generate-doc-screenshots.mjs | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 11f8e74..d0ea0d8 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -1,7 +1,11 @@ import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import sharp from 'sharp'; +// Sharp's ESM entry uses import attributes newer than our Node.js minimum. +// Use its supported CommonJS export, as the bundled worker does. +const require = createRequire(import.meta.url); +const sharp = require('sharp'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const source = path.join(root, 'site'); diff --git a/scripts/generate-doc-screenshots.mjs b/scripts/generate-doc-screenshots.mjs index bd874e1..7d908dd 100644 --- a/scripts/generate-doc-screenshots.mjs +++ b/scripts/generate-doc-screenshots.mjs @@ -4,11 +4,12 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import sharp from 'sharp'; const exec = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const require = createRequire(import.meta.url); +// Keep documentation tooling compatible with the advertised Node.js minimum. +const sharp = require('sharp'); const runtimeRoot = path.join(root, 'build', 'test-runtime', 'docs-screenshots'); const buildRoot = path.resolve(process.env.CYBERSNAPPER_NATIVE_BUILD || path.join(root, 'build', 'native')); const requestedOutput = process.argv.indexOf('--output'); From cb26c8e8c379f81627616cd2782e90fa140d979b Mon Sep 17 00:00:00 2001 From: dot Date: Fri, 2 Oct 2026 00:10:49 -0700 Subject: [PATCH 3/6] fix: update Sharp to patched libheif release --- docs/releases/v2.4.2.md | 5 + package-lock.json | 288 +++++++++++++++++----------------------- package.json | 2 +- 3 files changed, 126 insertions(+), 169 deletions(-) diff --git a/docs/releases/v2.4.2.md b/docs/releases/v2.4.2.md index f4e4e0b..27dd430 100644 --- a/docs/releases/v2.4.2.md +++ b/docs/releases/v2.4.2.md @@ -9,6 +9,11 @@ Catalog readiness is tested independently; a successful package build does not mean the upstream catalog submission has been accepted. +## Dependency security fix + +- Update Sharp to 0.35.4, including libheif 1.23.2, to address [GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c). The vulnerable decoder could be reached when comparing AVIF baselines from a modified or shared project. +- Keep the documented Node.js 20.9.0 minimum supported by loading Sharp's CommonJS export in site and documentation tooling. + ## Stronger dependency and release checks - Dependabot groups security and production/development patch/minor updates separately; major updates stay individual for review. Automatic merging is not enabled. diff --git a/package-lock.json b/package-lock.json index 89c16c4..42a65a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@ghostery/adblocker": "^2.18.2", "playwright": "^1.61.1", - "sharp": "^0.35.3" + "sharp": "^0.35.4" }, "devDependencies": { "@types/node": "^24.0.0", @@ -523,9 +523,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -541,13 +541,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -563,20 +563,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -586,9 +586,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -602,9 +602,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -618,15 +618,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -637,15 +634,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -656,15 +650,12 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -675,15 +666,12 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -694,15 +682,12 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -713,15 +698,12 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -732,15 +714,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -751,15 +730,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -770,15 +746,12 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -791,19 +764,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -816,19 +786,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -841,19 +808,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -866,19 +830,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -891,19 +852,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -916,19 +874,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -941,19 +896,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -966,17 +918,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -986,16 +938,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1005,9 +957,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1024,9 +976,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1043,9 +995,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1222,9 +1174,9 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -1238,31 +1190,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/package.json b/package.json index 5aa0536..3fb129b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dependencies": { "@ghostery/adblocker": "^2.18.2", "playwright": "^1.61.1", - "sharp": "^0.35.3" + "sharp": "^0.35.4" }, "devDependencies": { "@types/node": "^24.0.0", From aa1c6e6d14d1c7581f43e6e2b6dd22e12c58aef6 Mon Sep 17 00:00:00 2001 From: dot Date: Fri, 2 Oct 2026 00:10:49 -0700 Subject: [PATCH 4/6] fix: audit executable and library symlink origins correctly --- scripts/check-linux-appimage.py | 31 ++++++++-- scripts/test_check_linux_appimage.py | 85 +++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/scripts/check-linux-appimage.py b/scripts/check-linux-appimage.py index 795da6e..009a643 100644 --- a/scripts/check-linux-appimage.py +++ b/scripts/check-linux-appimage.py @@ -40,6 +40,16 @@ def sha256(path): return digest.hexdigest() +def is_main_executable(file, dynamic): + # Some shared libraries (for example libc) can also carry an interpreter. + if '(SONAME)' in dynamic: + return False + headers = output('readelf', '--file-header', '--program-headers', str(file)) + return bool(re.search(r'^\s*Type:\s+EXEC\b', headers, re.MULTILINE) or + re.search(r'^\s*INTERP\s', headers, re.MULTILINE) or + re.search(r'\(FLAGS_1\).*\bPIE\b', dynamic)) + + def inspect_elf(root, report): inspected = [] environment = {**os.environ, 'LD_LIBRARY_PATH': str(root / 'usr/lib')} @@ -49,13 +59,22 @@ def inspect_elf(root, report): with file.open('rb') as stream: if stream.read(4) != b'\x7fELF': continue - require(file.resolve().is_relative_to(root.resolve()), f'ELF symlink escapes AppDir: {file}') - dynamic = output('readelf', '-d', str(file)) - record = {'path': str(file.relative_to(root)), 'dynamic': '(NEEDED)' in dynamic} + resolved_file = file.resolve() + require(resolved_file.is_relative_to(root.resolve()), f'ELF symlink escapes AppDir: {file}') + dynamic = output('readelf', '-d', str(resolved_file)) + executable = is_main_executable(resolved_file, dynamic) + # The kernel resolves executable symlinks (including AppRun.wrapped) + # before the loader expands $ORIGIN. Shared libraries instead retain + # their load pathname, so a DSO alias must be audited from its own + # directory. ldd must use that same origin to avoid false resolutions. + load_file = resolved_file if executable else file + record = {'path': str(file.relative_to(root)), + 'target': str(resolved_file.relative_to(root.resolve())), + 'main_executable': executable, 'dynamic': '(NEEDED)' in dynamic} # Static Go executables have no dynamic symbol table; their absence is # valid, while every dynamically linked executable/library is audited. if '(NEEDED)' in dynamic or 'Dynamic section at offset' in dynamic: - symbols = output('objdump', '-T', str(file)) + symbols = output('objdump', '-T', str(resolved_file)) for line in symbols.splitlines(): if '*UND*' not in line: continue @@ -71,10 +90,10 @@ def inspect_elf(root, report): entry == '${ORIGIN}' or entry.startswith('${ORIGIN}/'), f'Non-relocatable or empty runtime path in {file}: {line}') relative = entry.replace('${ORIGIN}', '.', 1).replace('$ORIGIN', '.', 1) - require((file.parent / relative).resolve().is_relative_to(root.resolve()), + require((load_file.parent / relative).resolve().is_relative_to(root.resolve()), f'Runtime path escapes AppDir in {file}: {line}') if record['dynamic']: - libraries = output('ldd', str(file), env=environment) + libraries = output('ldd', str(load_file), env=environment) require('not found' not in libraries, f'Unresolved dependency in {file}:\n{libraries}') record['dependencies'] = libraries for path in re.findall(r'=> (/\S+)', libraries): diff --git a/scripts/test_check_linux_appimage.py b/scripts/test_check_linux_appimage.py index 21d577b..423917f 100644 --- a/scripts/test_check_linux_appimage.py +++ b/scripts/test_check_linux_appimage.py @@ -2,6 +2,8 @@ import importlib.util from pathlib import Path import tempfile +import shutil +import subprocess import unittest from unittest.mock import patch @@ -27,10 +29,13 @@ def setUp(self): self.symbols = ('0000 *UND* (GLIBC_2.35) libc_entry\n' '0000 *UND* (GLIBCXX_3.4.30) std_entry\n' '0000 *UND* (CXXABI_1.3.13) abi_entry') + self.headers = ' Type: DYN (Position-Independent Executable)\n INTERP 0x00000040' self.libraries = 'libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0)' def command_output(self, *args, **kwargs): - return {'readelf': self.dynamic, 'objdump': self.symbols, 'ldd': self.libraries}[args[0]] + if args[0] == 'readelf': + return self.dynamic if '-d' in args else self.headers + return {'objdump': self.symbols, 'ldd': self.libraries}[args[0]] def audit(self): with patch.object(checker, 'output', side_effect=self.command_output): @@ -90,6 +95,44 @@ def test_bundled_dependency_supported(self): self.libraries = f'libQt6Core.so.6 => {library} (0x0)' self.assertEqual(self.audit(), 11) + def test_apprun_symlink_uses_canonical_origin_and_ldd_path(self): + target = self.executables / '0' + alias = self.root / 'AppRun.wrapped' + alias.symlink_to('usr/bin/0') + with patch.object(checker, 'output', side_effect=self.command_output) as command: + self.assertEqual(checker.inspect_elf(self.root, self.report), 12) + inspected = [call.args[-1] for call in command.call_args_list if call.args[0] == 'ldd'] + self.assertNotIn(str(alias), inspected) + self.assertEqual(inspected.count(str(target.resolve())), 2) + self.assertIn('AppRun.wrapped', self.report.read_text()) + + def test_symlink_cannot_hide_escape_from_real_elf_location(self): + target = self.root / 'root-executable' + target.write_bytes(b'\x7fELFfixture') + (self.executables / 'alias').symlink_to('../../root-executable') + # At usr/bin this path looks valid, but the real ELF is at AppDir root. + with self.assertRaisesRegex(ValueError, 'escapes AppDir'): + self.audit() + + def test_library_soname_takes_precedence_over_interpreter(self): + self.dynamic += '\n (SONAME) Library soname: [libexample.so]' + with patch.object(checker, 'output', side_effect=self.command_output): + self.assertFalse(checker.is_main_executable(self.executables / '0', self.dynamic)) + + def test_dso_alias_preserves_load_path_origin(self): + self.headers = ' Type: DYN (Shared object file)' + target = self.executables / '0' + alias = self.root / 'libalias.so' + alias.symlink_to('usr/bin/0') + # Canonical usr/bin/../lib is inside, but DSO alias/../lib is outside. + with self.assertRaisesRegex(ValueError, 'escapes AppDir'): + self.audit() + self.dynamic = 'Dynamic section at offset 0x10\n (NEEDED) [libc.so.6]\n (RUNPATH) [$ORIGIN/child]' + with patch.object(checker, 'output', side_effect=self.command_output) as command: + self.assertEqual(checker.inspect_elf(self.root, self.report), 12) + ldd_paths = [call.args[-1] for call in command.call_args_list if call.args[0] == 'ldd'] + self.assertIn(str(alias), ldd_paths) + def test_elf_symlink_escape_rejected(self): external = Path(self.temp.name) / 'external' external.write_bytes(b'\x7fELFexternal') @@ -104,5 +147,45 @@ def test_implausibly_small_bundle_rejected(self): self.audit() +@unittest.skipUnless(shutil.which('cc') and shutil.which('readelf') and shutil.which('ldd'), + 'C compiler and ELF tools are required for real loader fixtures') +class RealLoaderOriginTest(unittest.TestCase): + def test_executable_and_dso_symlinks_have_different_origin_rules(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for directory in ('usr/bin', 'usr/lib', 'real/child', 'alias/child'): + (root / directory).mkdir(parents=True) + leaf = root / 'leaf.c' + leaf.write_text('int answer(void) { return ANSWER; }\n') + outer = root / 'outer.c' + outer.write_text('extern int answer(void); int outer(void) { return answer(); }\n') + main = root / 'main.c' + main.write_text('#include \nextern int answer(void);\nint main(void) { printf("%d", answer()); }\n') + loader = root / 'loader.c' + loader.write_text('#include \n#include \nint main(int c,char**v) { ' + 'void*h=dlopen(v[1],RTLD_NOW); if(!h) return 1; ' + 'int(*f)(void)=dlsym(h,"outer"); printf("%d",f()); }\n') + def compile(*args): + subprocess.run(['cc', *map(str, args)], check=True, capture_output=True, timeout=30) + for destination, answer in (('usr/lib', '42'), ('real/child', '42'), ('alias/child', '99')): + compile('-shared', '-fPIC', '-DANSWER=' + answer, leaf, '-o', root / destination / 'libleaf.so') + executable = root / 'usr/bin/app' + compile(main, '-L' + str(root / 'usr/lib'), '-lleaf', '-Wl,-rpath,$ORIGIN/../lib', '-o', executable) + app_alias = root / 'AppRun.wrapped' + app_alias.symlink_to('usr/bin/app') + self.assertEqual(subprocess.check_output([str(app_alias)], text=True), '42') + dynamic = checker.output('readelf', '-d', str(executable)) + self.assertTrue(checker.is_main_executable(executable, dynamic)) + dso = root / 'real/libouter.so' + compile('-shared', '-fPIC', outer, '-L' + str(root / 'real/child'), '-lleaf', + '-Wl,-rpath,$ORIGIN/child', '-o', dso) + compile(loader, '-ldl', '-o', root / 'loader') + dso_alias = root / 'alias/libouter.so' + dso_alias.symlink_to('../real/libouter.so') + self.assertEqual(subprocess.check_output([str(root / 'loader'), str(dso)], text=True), '42') + self.assertEqual(subprocess.check_output([str(root / 'loader'), str(dso_alias)], text=True), '99') + self.assertFalse(checker.is_main_executable(dso, checker.output('readelf', '-d', str(dso)))) + + if __name__ == '__main__': unittest.main() From f54c14f0bd74e5722868b102ea1ea4c7f0c71ebd Mon Sep 17 00:00:00 2001 From: dot Date: Fri, 2 Oct 2026 00:37:25 -0700 Subject: [PATCH 5/6] fix: stage only compatible native Sharp binaries --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 9 ++- docs/PACKAGING.md | 2 + package-lock.json | 80 ++++++++++++++++----- scripts/package-linux.sh | 5 +- scripts/prune-worker-dependencies.mjs | 84 ++++++++++++++++++++++ scripts/test_prune_worker_dependencies.mjs | 77 ++++++++++++++++++++ 7 files changed, 238 insertions(+), 20 deletions(-) create mode 100644 scripts/prune-worker-dependencies.mjs create mode 100644 scripts/test_prune_worker_dependencies.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd3bebd..623c2eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,7 @@ jobs: bash -n "$script" done python3 -m unittest discover -s scripts -p 'test_*.py' -v + node --test scripts/test_*.mjs - name: Validate Linux application metadata run: | desktop-file-validate native/packaging/net.cyberbrand.CyberSnapper.desktop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ccd336..0199ea1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,7 +270,10 @@ jobs: - name: Install application shell: pwsh - run: cmake --install build/native --prefix stage --config Release + run: | + cmake --install build/native --prefix stage --config Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node scripts/prune-worker-dependencies.mjs stage/bin/worker/node_modules - name: Deploy Qt shell: pwsh @@ -411,7 +414,9 @@ jobs: - name: Install application shell: bash - run: cmake --install build/native --prefix stage --config Release + run: | + cmake --install build/native --prefix stage --config Release + node scripts/prune-worker-dependencies.mjs stage/CyberSnapper.app/Contents/Resources/worker/node_modules - name: Deploy Qt and sign shell: bash diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 107ff3e..64bb78b 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -70,6 +70,8 @@ Every platform job starts with the same sequence: 7. **Production dependencies** — `npm prune --omit=dev` trims the worker `node_modules` before install. +After CMake copies dependencies into the release install tree, `scripts/prune-worker-dependencies.mjs` retains only its matching native Sharp/libvips variants (Windows bundles libvips in Sharp), preserves the optional WASM fallback, and verifies that the native module itself loads. PNG and AVIF round-trips must succeed from the staged tree, without resolving modules from the source checkout. This prevents npm optional dependencies for another architecture or libc from entering a release. + The CMake install rules then lay out the application, agent, CLI, worker bundle, worker dependencies, Node runtime, and browser cache. Two rules carry `USE_SOURCE_PERMISSIONS` — the `.runtime/` Node binary and the diff --git a/package-lock.json b/package-lock.json index 42a65a4..f53566c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -631,7 +631,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linux-arm64": { "version": "1.3.3", @@ -647,7 +650,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.3.3", @@ -663,7 +669,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linux-riscv64": { "version": "1.3.3", @@ -679,7 +688,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.3.3", @@ -695,7 +707,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.3.3", @@ -711,7 +726,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { "version": "1.3.3", @@ -727,7 +745,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "musl" + ] }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.3.3", @@ -743,7 +764,10 @@ ], "funding": { "url": "https://opencollective.com/libvips" - } + }, + "libc": [ + "musl" + ] }, "node_modules/@img/sharp-linux-arm": { "version": "0.35.4", @@ -765,7 +789,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linux-arm64": { "version": "0.35.4", @@ -787,7 +814,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linux-ppc64": { "version": "0.35.4", @@ -809,7 +839,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linux-riscv64": { "version": "0.35.4", @@ -831,7 +864,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linux-s390x": { "version": "0.35.4", @@ -853,7 +889,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linux-x64": { "version": "0.35.4", @@ -875,7 +914,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" - } + }, + "libc": [ + "glibc" + ] }, "node_modules/@img/sharp-linuxmusl-arm64": { "version": "0.35.4", @@ -897,7 +939,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" - } + }, + "libc": [ + "musl" + ] }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.35.4", @@ -919,7 +964,10 @@ }, "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" - } + }, + "libc": [ + "musl" + ] }, "node_modules/@img/sharp-wasm32": { "version": "0.35.4", diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 072a204..bf0dce0 100755 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -24,7 +24,7 @@ case "$release_arch" in *) echo "unsupported Linux release architecture: $release_arch" >&2; exit 2 ;; esac -for executable in cmake file desktop-file-validate appstreamcli python3 zsync zsyncmake timeout; do +for executable in cmake file desktop-file-validate appstreamcli node python3 zsync zsyncmake timeout; do command -v "$executable" >/dev/null 2>&1 || { echo "$executable is required" >&2 exit 1 @@ -47,6 +47,8 @@ app_dir="$build_dir/AppDir" cmake -E remove_directory "$app_dir" DESTDIR="$app_dir" cmake --install "$build_dir" --prefix /usr --config Release +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +node "$script_dir/prune-worker-dependencies.mjs" "$app_dir/usr/share/cybersnapper/worker/node_modules" desktop_file="$app_dir/usr/share/applications/net.cyberbrand.CyberSnapper.desktop" icon_file="$app_dir/usr/share/pixmaps/net.cyberbrand.CyberSnapper.png" @@ -68,7 +70,6 @@ rm -f "$release_plugins"/sqldrivers/libqsqlmysql.so \ "$release_plugins"/sqldrivers/libqsqlodbc.so \ "$release_plugins"/sqldrivers/libqsqlpsql.so -script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) qmake="$script_dir/release-qmake-wrapper.sh" export CYBERSNAPPER_REAL_QMAKE="$real_qmake" export CYBERSNAPPER_RELEASE_PLUGINS="$release_plugins" diff --git a/scripts/prune-worker-dependencies.mjs b/scripts/prune-worker-dependencies.mjs new file mode 100644 index 0000000..2769793 --- /dev/null +++ b/scripts/prune-worker-dependencies.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Deployment trees need only their native Sharp build, even when npm retains +// optional packages for another CPU, operating system, or Linux libc. +import { lstat, readFile, readdir, realpath, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function requiredSharpPackages({ platform, arch, libc }) { + if (!['linux', 'darwin', 'win32'].includes(platform) || !['x64', 'arm64'].includes(arch)) { + throw new Error(`Unsupported release platform: ${platform}-${arch}`); + } + if (platform === 'linux' && libc !== 'glibc') { + throw new Error('Linux releases require a glibc runtime'); + } + const target = `${platform}-${arch}`; + return [`sharp-${target}`, ...(platform === 'win32' ? [] : [`sharp-libvips-${target}`])]; +} + +export async function pruneSharpPackages(modulesRoot, target) { + const scope = path.resolve(modulesRoot, '@img'); + const required = requiredSharpPackages(target); + if ((await lstat(scope)).isSymbolicLink()) throw new Error('Sharp package scope must not be a symlink'); + const entries = await readdir(scope, { withFileTypes: true }); + const variants = entries.filter(entry => entry.name.startsWith('sharp-')); + // Complete all validation before removing anything. Never follow a package + // symlink out of the install tree or silently substitute a WASM fallback. + for (const entry of variants) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error(`Sharp package is not a regular directory: ${entry.name}`); + } + } + for (const name of required) { + if (!variants.some(entry => entry.name === name)) throw new Error(`Required native package missing: @img/${name}`); + const manifest = JSON.parse(await readFile(path.join(scope, name, 'package.json'), 'utf8')); + if (manifest.name !== `@img/${name}`) throw new Error(`Unexpected package identity: ${name}`); + } + const allowed = [...required, 'sharp-wasm32']; + const removed = variants.map(entry => entry.name).filter(name => !allowed.includes(name)).sort(); + for (const name of removed) { + if (!/^sharp-(?:libvips-)?(?:linux(?:musl)?|darwin|win32|freebsd|webcontainers)-[a-z0-9]+$/.test(name)) { + throw new Error(`Unrecognized Sharp deployment package: ${name}`); + } + } + for (const name of removed) await rm(path.join(scope, name), { recursive: true }); + return { kept: required, removed }; +} + +export async function loadStaged(modulesRoot, specifier) { + const root = await realpath(modulesRoot); + const require = createRequire(path.join(root, '..', 'verify-sharp.cjs')); + const entry = await realpath(require.resolve(specifier)); + const relative = path.relative(root, entry); + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) { + throw new Error(`Dependency resolved outside staged node_modules: ${specifier}`); + } + return require(entry); +} + +async function main() { + if (process.argv.length !== 3) throw new Error('usage: prune-worker-dependencies.mjs '); + const modulesRoot = path.resolve(process.argv[2]); + const sourceModules = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'node_modules'); + if (modulesRoot === sourceModules) throw new Error('Refusing to prune source-tree dependencies; pass a deployment staging tree'); + const target = { platform: process.platform, arch: process.arch, + libc: process.platform === 'linux' ? (await loadStaged(modulesRoot, 'detect-libc')).familySync() : undefined }; + const result = await pruneSharpPackages(modulesRoot, target); + // A WASM fallback must not conceal a broken native dependency set. + await loadStaged(modulesRoot, `@img/sharp-${target.platform}-${target.arch}/sharp.node`); + const sharp = await loadStaged(modulesRoot, 'sharp'); + for (const format of ['png', 'avif']) { + const bytes = await sharp({ create: { width: 4, height: 4, channels: 4, background: '#24578fff' } }) + .toFormat(format).toBuffer(); + const metadata = await sharp(bytes).metadata(); + if (metadata.width !== 4 || metadata.height !== 4 || metadata.format !== (format === 'avif' ? 'heif' : 'png')) { + throw new Error(`Staged Sharp ${format} round-trip failed`); + } + } + console.log(`Verified staged native Sharp for ${target.platform}-${target.arch}: ${JSON.stringify(result)}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/test_prune_worker_dependencies.mjs b/scripts/test_prune_worker_dependencies.mjs new file mode 100644 index 0000000..54f0e92 --- /dev/null +++ b/scripts/test_prune_worker_dependencies.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readdir, rm, symlink } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { requiredSharpPackages, pruneSharpPackages, loadStaged } from './prune-worker-dependencies.mjs'; + +const targets = ['linux', 'darwin', 'win32'].flatMap(platform => ['x64', 'arm64'].map(arch => ({ platform, arch, libc: 'glibc' }))); +const variants = [...new Set(targets.flatMap(requiredSharpPackages)), 'sharp-linuxmusl-x64', + 'sharp-libvips-linuxmusl-x64', 'sharp-linuxmusl-arm64', 'sharp-libvips-linuxmusl-arm64', 'sharp-wasm32']; + +async function fixture(t, names = variants) { + const root = await mkdtemp(path.join(os.tmpdir(), 'cybersnapper-prune-')); + t.after(() => rm(root, { recursive: true, force: true })); + const modules = path.join(root, 'node_modules'); + for (const name of [...names, 'colour']) { + const directory = path.join(modules, '@img', name); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, 'package.json'), JSON.stringify({ name: `@img/${name}` })); + } + return modules; +} + +for (const target of targets) { + test(`retain only native Sharp dependencies for ${target.platform}-${target.arch}`, async t => { + const modules = await fixture(t); + const result = await pruneSharpPackages(modules, target); + assert.deepEqual((await readdir(path.join(modules, '@img'))).sort(), ['colour', 'sharp-wasm32', ...requiredSharpPackages(target)].sort()); + assert(!result.removed.includes('sharp-wasm32')); + assert(result.removed.includes('sharp-linuxmusl-x64')); + assert.deepEqual(await pruneSharpPackages(modules, target), { kept: requiredSharpPackages(target), removed: [] }); + }); +} + +test('missing native build fails before deleting other packages', async t => { + const modules = await fixture(t, variants.filter(name => name !== 'sharp-linux-x64')); + const before = await readdir(path.join(modules, '@img')); + await assert.rejects(pruneSharpPackages(modules, targets[0]), /Required native package missing/); + assert.deepEqual(await readdir(path.join(modules, '@img')), before); +}); + +test('incorrect package identity fails before pruning', async t => { + const modules = await fixture(t); + await writeFile(path.join(modules, '@img/sharp-linux-x64/package.json'), JSON.stringify({ name: 'wrong' })); + await assert.rejects(pruneSharpPackages(modules, targets[0]), /Unexpected package identity/); + assert((await readdir(path.join(modules, '@img'))).includes('sharp-linuxmusl-x64')); +}); + +test('symlinked optional package is rejected without following it', async t => { + const modules = await fixture(t); + await symlink(path.join(modules, '@img/colour'), path.join(modules, '@img/sharp-foreign')); + await assert.rejects(pruneSharpPackages(modules, targets[0]), /not a regular directory/); + assert((await readdir(path.join(modules, '@img'))).includes('colour')); +}); + +test('unrecognized Sharp helper fails without deleting anything', async t => { + const modules = await fixture(t, [...variants, 'sharp-new-helper']); + const before = await readdir(path.join(modules, '@img')); + await assert.rejects(pruneSharpPackages(modules, targets[0]), /Unrecognized Sharp deployment package/); + assert.deepEqual(await readdir(path.join(modules, '@img')), before); +}); + +test('unsupported platforms and musl hosts fail closed', () => { + for (const target of [{ platform: 'linux', arch: 'x64', libc: 'musl' }, + { platform: 'linux', arch: 'x64' }, { platform: 'linux', arch: 'ia32', libc: 'glibc' }, + { platform: 'freebsd', arch: 'x64' }]) assert.throws(() => requiredSharpPackages(target)); +}); + + +test('staged loader cannot fall back to source-tree dependencies', async t => { + const modules = await fixture(t); + await writeFile(path.join(modules, '@img/colour/index.js'), 'module.exports = 42;'); + assert.equal(await loadStaged(modules, '@img/colour'), 42); + const isolated = path.join(modules, '..', 'stage', 'node_modules'); + await mkdir(isolated, { recursive: true }); + await assert.rejects(loadStaged(isolated, '@img/colour'), /outside staged node_modules/); +}); From 2288badabe041b4a64ca73eb312a3a359b3c2722 Mon Sep 17 00:00:00 2001 From: dot Date: Fri, 2 Oct 2026 01:25:41 -0700 Subject: [PATCH 6/6] fix: await isolated AppImage readiness and owned process cleanup --- scripts/check-linux-appimage.py | 284 +++++++++++++++++++++--- scripts/test_linux_runtime_lifecycle.py | 177 +++++++++++++++ 2 files changed, 426 insertions(+), 35 deletions(-) create mode 100644 scripts/test_linux_runtime_lifecycle.py diff --git a/scripts/check-linux-appimage.py b/scripts/check-linux-appimage.py index 009a643..8ede56f 100644 --- a/scripts/check-linux-appimage.py +++ b/scripts/check-linux-appimage.py @@ -14,6 +14,9 @@ from pathlib import Path import re import signal +import select +import socket +import struct import subprocess import sys import tempfile @@ -128,6 +131,201 @@ def visible_window(process, environment): return None +class OwnedProcess: + """Pin one process identity so a recycled PID can never be signalled.""" + def __init__(self, pid): + self.pid = pid + self.fd = os.pidfd_open(pid) + try: + fields = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split() + self.start_ticks = int(fields[19]) + except Exception: + os.close(self.fd) + raise + + def wait(self, timeout): + poller = select.poll() + poller.register(self.fd, select.POLLIN) + return bool(poller.poll(max(0, int(timeout * 1000)))) + + def send(self, sig): + if not self.wait(0): + try: + signal.pidfd_send_signal(self.fd, sig) + except ProcessLookupError: + pass + + def close(self): + os.close(self.fd) + + +def descendants(process): + """Pin children while their known parent is still alive.""" + result = [] + if process.wait(0): + return result + try: + children = Path(f'/proc/{process.pid}/task/{process.pid}/children').read_text().split() + except FileNotFoundError: + return result + for pid in children: + child = None + try: + child = OwnedProcess(int(pid)) + status = Path(f'/proc/{pid}/status').read_text() + if not re.search(rf'^PPid:\s+{process.pid}$', status, re.MULTILINE): + child.close() + continue + result.append(child) + result.extend(descendants(child)) + except ProcessLookupError: + continue + except FileNotFoundError: + if child is not None: + child.close() + return result + + +class AgentRpc: + def __init__(self, server, owned): + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + self.socket.settimeout(2) + self.socket.connect(str(server)) + pid, uid, _ = struct.unpack('3i', self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + require(uid == os.getuid(), 'Isolated socket belongs to another user') + require(Path(os.readlink(f'/proc/{pid}/exe')).name == 'cybersnapper-agent', + 'Isolated socket is not served by the packaged agent') + # Pin the peer before waiting for initialization or an RPC reply. + if pid not in owned: + owned[pid] = OwnedProcess(pid) + self.process = owned[pid] + self.sequence = 0 + except Exception: + self.socket.close() + raise + + def read_exact(self, length): + data = bytearray() + while len(data) < length: + chunk = self.socket.recv(length - len(data)) + require(bool(chunk), 'Agent closed its RPC connection') + data.extend(chunk) + return data + + def call(self, method, params=None, timeout=30): + self.socket.settimeout(timeout) + self.sequence += 1 + request_id = f'runtime-check-{self.sequence}' + body = json.dumps({'v': 1, 'id': request_id, 'method': method, 'params': params or {}}).encode() + self.socket.sendall(struct.pack('>I', len(body)) + body) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + self.socket.settimeout(max(0.01, deadline - time.monotonic())) + length = struct.unpack('>I', self.read_exact(4))[0] + require(0 < length <= 16 * 1024 * 1024, 'Invalid agent RPC frame length') + response = json.loads(self.read_exact(length)) + if response.get('id') != request_id: + continue + require(response.get('v') == 1 and 'error' not in response, f'Agent RPC failed: {response}') + require(isinstance(response.get('result'), dict), 'Agent RPC result is missing') + return response['result'] + raise TimeoutError(f'Agent RPC timed out: {method}') + + def close(self): + self.socket.close() + + +def idle_status(status): + return bool(status.get('activeProjectId')) and not any(status.get(key) for key in + ('activeJobs', 'queuedJobs', 'browserOperations', 'queuedBrowserOperations')) + + +def stop_owned(process, graceful_timeout=10): + """Wait for graceful exit, then clean up strictly and report escalation.""" + if process.wait(graceful_timeout): + return False + process.send(signal.SIGTERM) + if not process.wait(5): + process.send(signal.SIGKILL) + require(process.wait(5), f'Owned process did not exit: {process.pid}') + return True + + +def teardown(process, gui, rpc, agents, evidence): + children = [] + report = {'passed': False, 'agents': [], 'escalated': [], 'errors': []} + + def failed(stage, error): + report['errors'].append(f'{stage}: {error}') + + try: + for agent in agents.values(): + report['agents'].append({'pid': agent.pid, 'start_ticks': agent.start_ticks}) + try: + children.extend(descendants(agent)) + except Exception as error: + failed(f'Inspect descendants of {agent.pid}', error) + if rpc is not None: + try: + if not rpc.process.wait(0): + rpc.call('agent.stop', {'force': True}, timeout=15) + except (OSError, ValueError) as error: + # Exit may precede response flush; the exact pidfd must still + # confirm exit below, otherwise cleanup fails the gate. + report['stop_response_error'] = str(error) + for handle in [*agents.values(), *children]: + try: + if stop_owned(handle, 15 if handle in agents.values() else 2): + report['escalated'].append(handle.pid) + except Exception as error: + # One broken process must not skip the remaining identities. + failed(f'Stop owned process {handle.pid}', error) + finally: + # Keep the extraction tree alive until agent cleanup has been tried, + # then always attempt GUI cleanup, evidence writing and every fd close. + try: + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + report['escalated'].append(process.pid) + except Exception as error: + failed('Stop AppImage process group', error) + if gui is not None: + try: + if stop_owned(gui, 5): + report['escalated'].append(gui.pid) + except Exception as error: + failed(f'Stop GUI process {gui.pid}', error) + handles = [*children, *agents.values(), *([gui] if gui is not None else [])] + for handle in handles: + try: + if not handle.wait(0): + failed(f'Owned process {handle.pid}', 'still alive after teardown') + except Exception as error: + failed(f'Check owned process {handle.pid}', error) + if rpc is not None: + try: + rpc.close() + except Exception as error: + failed('Close agent RPC', error) + for handle in handles: + try: + handle.close() + except Exception as error: + failed(f'Close process handle {handle.pid}', error) + report['passed'] = not report['escalated'] and not report['errors'] + (evidence / 'teardown.json').write_text(json.dumps(report, indent=2) + '\n') + require(report['passed'], f'Runtime teardown did not exit cleanly: {report}') + + def launch(appimage, state, evidence, appdir): require(bool(os.environ.get('DISPLAY')), 'DISPLAY is required; run with xvfb-run -a') environment = {**os.environ, 'QT_QPA_PLATFORM': 'xcb', 'APPIMAGE_EXTRACT_AND_RUN': '1', @@ -137,49 +335,64 @@ def launch(appimage, state, evidence, appdir): 'CYBERSNAPPER_DEFAULT_PROJECT': str(state / 'project'), 'CYBERSNAPPER_AGENT_SERVER': str(state / 'agent.sock')} (state / 'runtime').mkdir(mode=0o700) - for key in ('QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH', 'QML2_IMPORT_PATH', 'LD_LIBRARY_PATH', 'LD_PRELOAD'): + settings = state / 'config/CyberBrand/CyberSnapper.conf' + settings.parent.mkdir(parents=True) + settings.write_text('[onboarding]\ncompleted=true\n') + (evidence / 'fixture.json').write_text(json.dumps({ + 'onboarding_completed': True, 'scope': 'isolated test home only', + 'reason': 'Keep the main window unobscured by the separate first-run wizard', + 'cold_appimage_launch': True, 'project_created_by_packaged_agent': True}, indent=2) + '\n') + for key in ('QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH', 'QML2_IMPORT_PATH', 'LD_LIBRARY_PATH', 'LD_PRELOAD', + 'CYBERSNAPPER_AGENT', 'CYBERSNAPPER_WORKER_ENTRY', 'CYBERSNAPPER_NODE', + 'CYBERSNAPPER_BROWSER_CACHE', 'CYBERSNAPPER_UI_SCREENSHOT', + 'CYBERSNAPPER_UI_SCENE', 'CYBERSNAPPER_UI_SCREENSHOT_DELAY'): environment.pop(key, None) + agents = {} + rpc = None + gui = None with (evidence / 'startup.log').open('w') as log: process = subprocess.Popen([str(appimage)], env=environment, stdout=log, stderr=log, start_new_session=True) try: - deadline = time.monotonic() + 60 + deadline = time.monotonic() + 120 + stable = 0 + last_error = '' while time.monotonic() < deadline: require(process.poll() is None, f'AppImage exited with {process.returncode}; see startup.log') window = visible_window(process, environment) - if window: - # Avoid accepting a transient splash or a window that - # immediately crashes: require continued visibility. - time.sleep(2) - require(process.poll() is None, 'AppImage exited after showing its window') - window = visible_window(process, environment) - require(window is not None, 'CyberSnapper main window disappeared') - subprocess.run(['import', '-window', window['window_id'], - str(evidence / 'main-window.png')], - check=True, env=environment, timeout=20) - subprocess.run(['import', '-window', 'root', str(evidence / 'desktop.png')], - check=True, env=environment, timeout=20) - (evidence / 'window.json').write_text(json.dumps(window, indent=2) + '\n') - return - time.sleep(0.1) - raise ValueError('No visible CyberSnapper main window appeared within 60 seconds') + if window and gui is None: + gui = OwnedProcess(window['pid']) + try: + if rpc is None: + rpc = AgentRpc(state / 'agent.sock', agents) + # This also waits for initial bundled-browser copying and + # GUI-requested verification, without ever autostarting. + browsers = rpc.call('browser.status', timeout=30) + status = rpc.call('agent.status', timeout=30) + stable = stable + 1 if window and idle_status(status) else 0 + if stable >= 2: + time.sleep(2) # Allow completed RPC updates to paint. + require(process.poll() is None and not rpc.process.wait(0), 'Application exited after readiness') + window = visible_window(process, environment) + require(window is not None, 'CyberSnapper main window disappeared') + (evidence / 'readiness.json').write_text(json.dumps({ + 'agent_pid': rpc.process.pid, 'agent_start_ticks': rpc.process.start_ticks, + 'status': status, 'browsers': browsers, 'idle_observations': stable}, indent=2) + '\n') + for target, filename in ((window['window_id'], 'main-window.png'), ('root', 'desktop.png')): + subprocess.run(['import', '-window', target, str(evidence / filename)], + check=True, env=environment, timeout=20) + (evidence / 'window.json').write_text(json.dumps(window, indent=2) + '\n') + return + except (OSError, ValueError) as error: + last_error = str(error) + stable = 0 + if rpc is not None: + rpc.close() + rpc = None + time.sleep(1) + raise ValueError(f'Packaged GUI and isolated agent did not become ready: {last_error}') finally: - # The agent detaches from the GUI process group. Stop only this - # isolated test agent before removing its private state directory. - try: - subprocess.run([str(appdir / 'usr/bin/cybersnapper-cli'), '--force', 'agent', 'stop'], - env=environment, stdout=log, stderr=log, timeout=30, check=False) - except (OSError, subprocess.SubprocessError) as error: - log.write(f'Agent cleanup warning: {error}\n') - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - os.killpg(process.pid, signal.SIGKILL) - process.wait(timeout=5) + teardown(process, gui, rpc, agents, evidence) def verify(appimage, evidence): @@ -199,8 +412,9 @@ def verify(appimage, evidence): state = stage / 'isolated-home' state.mkdir() launch(appimage, state, evidence, stage / 'squashfs-root') - report['passed'] = True + report['passed'] = True except Exception as error: + report['passed'] = False report['error'] = str(error) raise finally: diff --git a/scripts/test_linux_runtime_lifecycle.py b/scripts/test_linux_runtime_lifecycle.py new file mode 100644 index 0000000..3cd334c --- /dev/null +++ b/scripts/test_linux_runtime_lifecycle.py @@ -0,0 +1,177 @@ +"""Readiness, precise process teardown, and failure-report regressions.""" +import importlib.util +import io +import json +import os +from pathlib import Path +import signal +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +spec = importlib.util.spec_from_file_location('runtime', Path(__file__).with_name('check-linux-appimage.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class RuntimeLifecycleTest(unittest.TestCase): + def test_ready_requires_project_and_no_background_operations(self): + ready = {'activeProjectId': 'project', 'activeJobs': 0, 'queuedJobs': 0, + 'browserOperations': False, 'queuedBrowserOperations': 0} + self.assertTrue(checker.idle_status(ready)) + for key in ready: + value = '' if key == 'activeProjectId' else 1 + self.assertFalse(checker.idle_status({**ready, key: value}), key) + + def test_peer_is_pinned_even_when_initial_rpc_times_out(self): + sock = Mock() + sock.getsockopt.return_value = struct.pack('3i', 456, os.getuid(), 0) + sock.recv.side_effect = TimeoutError('initialization still busy') + owned = {} + handle = Mock(pid=456) + with patch.object(checker.socket, 'socket', return_value=sock), \ + patch.object(checker.os, 'readlink', return_value='/app/usr/bin/cybersnapper-agent'), \ + patch.object(checker, 'OwnedProcess', return_value=handle) as pin: + rpc = checker.AgentRpc(Path('/isolated/agent.sock'), owned) + pin.assert_called_once_with(456) + self.assertIs(owned[456], handle) + with self.assertRaises(TimeoutError): + rpc.call('agent.status') + rpc.close() + handle.close.assert_not_called() # Teardown still owns this identity. + + def test_rpc_uses_v1_framing_and_ignores_events(self): + def frame(value): + body = json.dumps(value).encode() + return struct.pack('>I', len(body)) + body + stream = io.BytesIO(frame({'v': 1, 'event': 'queue.changed'}) + + frame({'v': 1, 'id': 'runtime-check-1', 'result': {'ok': True}})) + rpc = checker.AgentRpc.__new__(checker.AgentRpc) + rpc.socket = Mock() + rpc.socket.recv.side_effect = lambda length: stream.read(min(length, 3)) + rpc.sequence = 0 + self.assertEqual(rpc.call('agent.status'), {'ok': True}) + request = rpc.socket.sendall.call_args.args[0] + self.assertEqual(struct.unpack('>I', request[:4])[0], len(request) - 4) + self.assertEqual(json.loads(request[4:])['v'], 1) + + def test_rpc_rejects_invalid_frame_and_closed_connection(self): + for data in (b'', struct.pack('>I', 17 * 1024 * 1024)): + rpc = checker.AgentRpc.__new__(checker.AgentRpc) + rpc.socket = Mock() + rpc.socket.recv.side_effect = io.BytesIO(data).read + rpc.sequence = 0 + with self.assertRaises(ValueError): + rpc.call('agent.status') + + def test_graceful_exit_sends_no_signal(self): + handle = Mock() + handle.wait.return_value = True + self.assertFalse(checker.stop_owned(handle)) + handle.send.assert_not_called() + + def test_delayed_exit_is_escalation(self): + handle = Mock() + handle.wait.side_effect = [False, True] + self.assertTrue(checker.stop_owned(handle)) + handle.send.assert_called_once_with(signal.SIGTERM) + + def test_stuck_process_fails_after_bounded_kill(self): + handle = Mock(pid=123) + handle.wait.return_value = False + with self.assertRaisesRegex(ValueError, 'did not exit'): + checker.stop_owned(handle) + self.assertEqual([call.args[0] for call in handle.send.call_args_list], + [signal.SIGTERM, signal.SIGKILL]) + + def test_teardown_stops_agent_before_gui_and_rejects_escalation(self): + for escalated in (False, True): + with self.subTest(escalated=escalated), tempfile.TemporaryDirectory() as directory: + evidence = Path(directory) + process = Mock(pid=234) + process.poll.return_value = None + agent = Mock(pid=345, start_ticks=678) + agent.wait.return_value = True + rpc = Mock(process=agent) + order = [] + rpc.call.side_effect = lambda *a, **k: order.append('agent') + agent.wait.return_value = False + with patch.object(checker, 'descendants', return_value=[]), \ + patch.object(checker, 'stop_owned', return_value=escalated), \ + patch.object(checker.os, 'killpg', side_effect=lambda *a: order.append('gui')): + # Exact process is known to have exited by final verification. + agent.wait.side_effect = [False, True] + if escalated: + with self.assertRaisesRegex(ValueError, 'did not exit cleanly'): + checker.teardown(process, None, rpc, {345: agent}, evidence) + else: + checker.teardown(process, None, rpc, {345: agent}, evidence) + self.assertEqual(order, ['agent', 'gui']) + self.assertEqual(json.loads((evidence / 'teardown.json').read_text())['passed'], not escalated) + + def test_teardown_failure_still_cleans_every_identity_and_writes_evidence(self): + with tempfile.TemporaryDirectory() as directory: + process = Mock(pid=222) + process.poll.return_value = None + process.wait.side_effect = RuntimeError('group wait failed') + agent = Mock(pid=333, start_ticks=123) + child = Mock(pid=444, start_ticks=124) + gui = Mock(pid=555, start_ticks=125) + for handle in (agent, child, gui): + handle.wait.return_value = True + with patch.object(checker, 'descendants', return_value=[child]), \ + patch.object(checker.os, 'killpg'), \ + patch.object(checker, 'stop_owned', side_effect=[RuntimeError('agent stop failed'), False, + RuntimeError('GUI stop failed')]) as stop: + with self.assertRaisesRegex(ValueError, 'did not exit cleanly'): + checker.teardown(process, gui, None, {333: agent}, Path(directory)) + self.assertEqual([call.args[0].pid for call in stop.call_args_list], [333, 444, 555]) + for handle in (agent, child, gui): + handle.close.assert_called_once() + result = json.loads((Path(directory) / 'teardown.json').read_text()) + self.assertFalse(result['passed']) + self.assertEqual(len(result['errors']), 3) + + def test_temp_cleanup_failure_cannot_keep_success_marker(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + image = root / 'test.AppImage' + image.write_bytes(b'fixture') + stage = root / 'stage' + stage.mkdir() + evidence = root / 'evidence' + temporary = Mock() + temporary.__enter__ = Mock(return_value=str(stage)) + temporary.__exit__ = Mock(side_effect=OSError('cleanup failure')) + with patch.object(checker.tempfile, 'TemporaryDirectory', return_value=temporary), \ + patch.object(checker.subprocess, 'run'), \ + patch.object(checker, 'inspect_elf', return_value=108), \ + patch.object(checker, 'launch'): + with self.assertRaisesRegex(OSError, 'cleanup failure'): + checker.verify(image, evidence) + result = json.loads((evidence / 'result.json').read_text()) + self.assertFalse(result['passed']) + self.assertEqual(result['error'], 'cleanup failure') + + @unittest.skipUnless(hasattr(os, 'pidfd_open') and hasattr(signal, 'pidfd_send_signal'), 'Linux pidfds required') + def test_real_owned_process_identity_and_wait(self): + process = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']) + handle = checker.OwnedProcess(process.pid) + try: + self.assertGreater(handle.start_ticks, 0) + self.assertFalse(handle.wait(0)) + handle.send(signal.SIGTERM) + self.assertTrue(handle.wait(5)) + process.wait(timeout=5) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + handle.close() + + +if __name__ == '__main__': + unittest.main()